Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Policy Simulation / policy-simulate (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
484 lines
15 KiB
C#
484 lines
15 KiB
C#
using System.Collections.Concurrent;
|
|
using System.Diagnostics;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Options;
|
|
using StellaOps.Notify.Models;
|
|
using StellaOps.Notify.Storage.Mongo.Repositories;
|
|
|
|
namespace StellaOps.Notifier.Worker.Channels;
|
|
|
|
/// <summary>
|
|
/// Channel adapter for in-app notifications (inbox/CLI).
|
|
/// Stores notifications in-memory for retrieval by users/services.
|
|
/// </summary>
|
|
public sealed class InAppChannelAdapter : IChannelAdapter
|
|
{
|
|
private readonly ConcurrentDictionary<string, ConcurrentQueue<InAppNotification>> _inboxes = new();
|
|
private readonly INotifyAuditRepository _auditRepository;
|
|
private readonly InAppChannelOptions _options;
|
|
private readonly TimeProvider _timeProvider;
|
|
private readonly ILogger<InAppChannelAdapter> _logger;
|
|
|
|
public InAppChannelAdapter(
|
|
INotifyAuditRepository auditRepository,
|
|
IOptions<InAppChannelOptions> options,
|
|
TimeProvider timeProvider,
|
|
ILogger<InAppChannelAdapter> logger)
|
|
{
|
|
_auditRepository = auditRepository ?? throw new ArgumentNullException(nameof(auditRepository));
|
|
_options = options?.Value ?? new InAppChannelOptions();
|
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
|
}
|
|
|
|
public NotifyChannelType ChannelType => NotifyChannelType.InApp;
|
|
|
|
public async Task<ChannelDispatchResult> DispatchAsync(
|
|
ChannelDispatchContext context,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(context);
|
|
|
|
var stopwatch = Stopwatch.StartNew();
|
|
|
|
try
|
|
{
|
|
var userId = GetTargetUserId(context);
|
|
if (string.IsNullOrWhiteSpace(userId))
|
|
{
|
|
await AuditDispatchAsync(context, false, "No target user ID specified.", null, cancellationToken);
|
|
return ChannelDispatchResult.Failed(
|
|
"Target user ID is required for in-app notifications.",
|
|
ChannelDispatchStatus.InvalidConfiguration);
|
|
}
|
|
|
|
var notification = new InAppNotification
|
|
{
|
|
NotificationId = $"notif-{Guid.NewGuid():N}"[..20],
|
|
DeliveryId = context.DeliveryId,
|
|
TenantId = context.TenantId,
|
|
UserId = userId,
|
|
Title = context.Subject ?? "Notification",
|
|
Body = context.RenderedBody,
|
|
Priority = GetPriority(context),
|
|
Category = GetCategory(context),
|
|
IncidentId = context.Metadata.GetValueOrDefault("incidentId"),
|
|
ActionUrl = context.Metadata.GetValueOrDefault("actionUrl"),
|
|
AckUrl = context.Metadata.GetValueOrDefault("ackUrl"),
|
|
Metadata = new Dictionary<string, string>(context.Metadata),
|
|
CreatedAt = _timeProvider.GetUtcNow(),
|
|
ExpiresAt = _timeProvider.GetUtcNow() + _options.NotificationTtl,
|
|
Status = InAppNotificationStatus.Unread
|
|
};
|
|
|
|
// Store in inbox
|
|
var inboxKey = BuildInboxKey(context.TenantId, userId);
|
|
var inbox = _inboxes.GetOrAdd(inboxKey, _ => new ConcurrentQueue<InAppNotification>());
|
|
inbox.Enqueue(notification);
|
|
|
|
// Enforce max notifications per inbox
|
|
while (inbox.Count > _options.MaxNotificationsPerInbox && inbox.TryDequeue(out _))
|
|
{
|
|
// Remove oldest
|
|
}
|
|
|
|
stopwatch.Stop();
|
|
|
|
var metadata = new Dictionary<string, string>
|
|
{
|
|
["notificationId"] = notification.NotificationId,
|
|
["userId"] = userId,
|
|
["inboxSize"] = inbox.Count.ToString()
|
|
};
|
|
|
|
await AuditDispatchAsync(context, true, null, metadata, cancellationToken);
|
|
|
|
_logger.LogInformation(
|
|
"In-app notification {NotificationId} delivered to user {UserId} inbox for tenant {TenantId}.",
|
|
notification.NotificationId, userId, context.TenantId);
|
|
|
|
return ChannelDispatchResult.Succeeded(
|
|
externalId: notification.NotificationId,
|
|
message: $"Delivered to inbox for user {userId}",
|
|
duration: stopwatch.Elapsed,
|
|
metadata: metadata);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
stopwatch.Stop();
|
|
await AuditDispatchAsync(context, false, ex.Message, null, cancellationToken);
|
|
|
|
_logger.LogError(ex, "In-app notification dispatch failed for delivery {DeliveryId}.", context.DeliveryId);
|
|
|
|
return ChannelDispatchResult.Failed(
|
|
ex.Message,
|
|
ChannelDispatchStatus.Failed,
|
|
exception: ex,
|
|
duration: stopwatch.Elapsed);
|
|
}
|
|
}
|
|
|
|
public Task<ChannelHealthCheckResult> CheckHealthAsync(
|
|
NotifyChannel channel,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(channel);
|
|
|
|
if (!channel.Enabled)
|
|
{
|
|
return Task.FromResult(ChannelHealthCheckResult.Degraded("Channel is disabled."));
|
|
}
|
|
|
|
return Task.FromResult(ChannelHealthCheckResult.Ok("In-app channel operational."));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets unread notifications for a user.
|
|
/// </summary>
|
|
public IReadOnlyList<InAppNotification> GetUnreadNotifications(string tenantId, string userId, int limit = 50)
|
|
{
|
|
var inboxKey = BuildInboxKey(tenantId, userId);
|
|
if (!_inboxes.TryGetValue(inboxKey, out var inbox))
|
|
{
|
|
return [];
|
|
}
|
|
|
|
var now = _timeProvider.GetUtcNow();
|
|
return inbox
|
|
.Where(n => n.Status == InAppNotificationStatus.Unread && (!n.ExpiresAt.HasValue || n.ExpiresAt > now))
|
|
.OrderByDescending(n => n.CreatedAt)
|
|
.Take(limit)
|
|
.ToList();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets all notifications for a user.
|
|
/// </summary>
|
|
public IReadOnlyList<InAppNotification> GetNotifications(
|
|
string tenantId,
|
|
string userId,
|
|
int limit = 100,
|
|
bool includeRead = true,
|
|
bool includeExpired = false)
|
|
{
|
|
var inboxKey = BuildInboxKey(tenantId, userId);
|
|
if (!_inboxes.TryGetValue(inboxKey, out var inbox))
|
|
{
|
|
return [];
|
|
}
|
|
|
|
var now = _timeProvider.GetUtcNow();
|
|
return inbox
|
|
.Where(n => (includeRead || n.Status == InAppNotificationStatus.Unread) &&
|
|
(includeExpired || !n.ExpiresAt.HasValue || n.ExpiresAt > now))
|
|
.OrderByDescending(n => n.CreatedAt)
|
|
.Take(limit)
|
|
.ToList();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Marks a notification as read.
|
|
/// </summary>
|
|
public bool MarkAsRead(string tenantId, string userId, string notificationId)
|
|
{
|
|
var inboxKey = BuildInboxKey(tenantId, userId);
|
|
if (!_inboxes.TryGetValue(inboxKey, out var inbox))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var notification = inbox.FirstOrDefault(n => n.NotificationId == notificationId);
|
|
if (notification is null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
notification.Status = InAppNotificationStatus.Read;
|
|
notification.ReadAt = _timeProvider.GetUtcNow();
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Marks all notifications as read for a user.
|
|
/// </summary>
|
|
public int MarkAllAsRead(string tenantId, string userId)
|
|
{
|
|
var inboxKey = BuildInboxKey(tenantId, userId);
|
|
if (!_inboxes.TryGetValue(inboxKey, out var inbox))
|
|
{
|
|
return 0;
|
|
}
|
|
|
|
var count = 0;
|
|
var now = _timeProvider.GetUtcNow();
|
|
foreach (var notification in inbox.Where(n => n.Status == InAppNotificationStatus.Unread))
|
|
{
|
|
notification.Status = InAppNotificationStatus.Read;
|
|
notification.ReadAt = now;
|
|
count++;
|
|
}
|
|
return count;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Deletes a notification.
|
|
/// </summary>
|
|
public bool DeleteNotification(string tenantId, string userId, string notificationId)
|
|
{
|
|
var inboxKey = BuildInboxKey(tenantId, userId);
|
|
if (!_inboxes.TryGetValue(inboxKey, out var inbox))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
// ConcurrentQueue doesn't support removal, so mark as deleted
|
|
var notification = inbox.FirstOrDefault(n => n.NotificationId == notificationId);
|
|
if (notification is null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
notification.Status = InAppNotificationStatus.Deleted;
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets unread count for a user.
|
|
/// </summary>
|
|
public int GetUnreadCount(string tenantId, string userId)
|
|
{
|
|
var inboxKey = BuildInboxKey(tenantId, userId);
|
|
if (!_inboxes.TryGetValue(inboxKey, out var inbox))
|
|
{
|
|
return 0;
|
|
}
|
|
|
|
var now = _timeProvider.GetUtcNow();
|
|
return inbox.Count(n => n.Status == InAppNotificationStatus.Unread &&
|
|
(!n.ExpiresAt.HasValue || n.ExpiresAt > now));
|
|
}
|
|
|
|
private static string GetTargetUserId(ChannelDispatchContext context)
|
|
{
|
|
if (context.Metadata.TryGetValue("targetUserId", out var userId) && !string.IsNullOrWhiteSpace(userId))
|
|
{
|
|
return userId;
|
|
}
|
|
|
|
if (context.Metadata.TryGetValue("userId", out userId) && !string.IsNullOrWhiteSpace(userId))
|
|
{
|
|
return userId;
|
|
}
|
|
|
|
return string.Empty;
|
|
}
|
|
|
|
private static InAppNotificationPriority GetPriority(ChannelDispatchContext context)
|
|
{
|
|
if (context.Metadata.TryGetValue("priority", out var priority) ||
|
|
context.Metadata.TryGetValue("severity", out priority))
|
|
{
|
|
return priority.ToLowerInvariant() switch
|
|
{
|
|
"critical" or "urgent" => InAppNotificationPriority.Urgent,
|
|
"high" => InAppNotificationPriority.High,
|
|
"medium" => InAppNotificationPriority.Normal,
|
|
"low" => InAppNotificationPriority.Low,
|
|
_ => InAppNotificationPriority.Normal
|
|
};
|
|
}
|
|
return InAppNotificationPriority.Normal;
|
|
}
|
|
|
|
private static string GetCategory(ChannelDispatchContext context)
|
|
{
|
|
if (context.Metadata.TryGetValue("category", out var category) && !string.IsNullOrWhiteSpace(category))
|
|
{
|
|
return category;
|
|
}
|
|
|
|
if (context.Metadata.TryGetValue("eventKind", out var eventKind) && !string.IsNullOrWhiteSpace(eventKind))
|
|
{
|
|
return eventKind;
|
|
}
|
|
|
|
return "general";
|
|
}
|
|
|
|
private static string BuildInboxKey(string tenantId, string userId) =>
|
|
$"{tenantId}:{userId}";
|
|
|
|
private async Task AuditDispatchAsync(
|
|
ChannelDispatchContext context,
|
|
bool success,
|
|
string? errorMessage,
|
|
IReadOnlyDictionary<string, string>? metadata,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
try
|
|
{
|
|
var auditMetadata = new Dictionary<string, string>
|
|
{
|
|
["deliveryId"] = context.DeliveryId,
|
|
["channelId"] = context.Channel.ChannelId,
|
|
["channelType"] = "InApp",
|
|
["success"] = success.ToString().ToLowerInvariant(),
|
|
["traceId"] = context.TraceId
|
|
};
|
|
|
|
if (!string.IsNullOrWhiteSpace(errorMessage))
|
|
{
|
|
auditMetadata["error"] = errorMessage;
|
|
}
|
|
|
|
if (metadata is not null)
|
|
{
|
|
foreach (var (key, value) in metadata)
|
|
{
|
|
auditMetadata[$"dispatch.{key}"] = value;
|
|
}
|
|
}
|
|
|
|
await _auditRepository.AppendAsync(
|
|
context.TenantId,
|
|
success ? "channel.dispatch.success" : "channel.dispatch.failure",
|
|
"notifier-worker",
|
|
auditMetadata,
|
|
cancellationToken).ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "Failed to write dispatch audit for delivery {DeliveryId}.", context.DeliveryId);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Options for in-app channel adapter.
|
|
/// </summary>
|
|
public sealed class InAppChannelOptions
|
|
{
|
|
/// <summary>
|
|
/// Configuration section name.
|
|
/// </summary>
|
|
public const string SectionName = "InAppChannel";
|
|
|
|
/// <summary>
|
|
/// Maximum notifications to keep per user inbox.
|
|
/// </summary>
|
|
public int MaxNotificationsPerInbox { get; set; } = 500;
|
|
|
|
/// <summary>
|
|
/// Time-to-live for notifications.
|
|
/// </summary>
|
|
public TimeSpan NotificationTtl { get; set; } = TimeSpan.FromDays(30);
|
|
}
|
|
|
|
/// <summary>
|
|
/// An in-app notification stored in user's inbox.
|
|
/// </summary>
|
|
public sealed class InAppNotification
|
|
{
|
|
/// <summary>
|
|
/// Unique notification ID.
|
|
/// </summary>
|
|
public required string NotificationId { get; init; }
|
|
|
|
/// <summary>
|
|
/// Original delivery ID.
|
|
/// </summary>
|
|
public required string DeliveryId { get; init; }
|
|
|
|
/// <summary>
|
|
/// Tenant ID.
|
|
/// </summary>
|
|
public required string TenantId { get; init; }
|
|
|
|
/// <summary>
|
|
/// Target user ID.
|
|
/// </summary>
|
|
public required string UserId { get; init; }
|
|
|
|
/// <summary>
|
|
/// Notification title.
|
|
/// </summary>
|
|
public required string Title { get; init; }
|
|
|
|
/// <summary>
|
|
/// Notification body/content.
|
|
/// </summary>
|
|
public string? Body { get; init; }
|
|
|
|
/// <summary>
|
|
/// Priority level.
|
|
/// </summary>
|
|
public InAppNotificationPriority Priority { get; init; } = InAppNotificationPriority.Normal;
|
|
|
|
/// <summary>
|
|
/// Notification category.
|
|
/// </summary>
|
|
public required string Category { get; init; }
|
|
|
|
/// <summary>
|
|
/// Related incident ID.
|
|
/// </summary>
|
|
public string? IncidentId { get; init; }
|
|
|
|
/// <summary>
|
|
/// URL for main action.
|
|
/// </summary>
|
|
public string? ActionUrl { get; init; }
|
|
|
|
/// <summary>
|
|
/// URL for acknowledgment action.
|
|
/// </summary>
|
|
public string? AckUrl { get; init; }
|
|
|
|
/// <summary>
|
|
/// Additional metadata.
|
|
/// </summary>
|
|
public Dictionary<string, string> Metadata { get; init; } = [];
|
|
|
|
/// <summary>
|
|
/// When created.
|
|
/// </summary>
|
|
public DateTimeOffset CreatedAt { get; init; }
|
|
|
|
/// <summary>
|
|
/// When expires.
|
|
/// </summary>
|
|
public DateTimeOffset? ExpiresAt { get; init; }
|
|
|
|
/// <summary>
|
|
/// Current status.
|
|
/// </summary>
|
|
public InAppNotificationStatus Status { get; set; } = InAppNotificationStatus.Unread;
|
|
|
|
/// <summary>
|
|
/// When read.
|
|
/// </summary>
|
|
public DateTimeOffset? ReadAt { get; set; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// In-app notification status.
|
|
/// </summary>
|
|
public enum InAppNotificationStatus
|
|
{
|
|
Unread,
|
|
Read,
|
|
Actioned,
|
|
Deleted
|
|
}
|
|
|
|
/// <summary>
|
|
/// In-app notification priority.
|
|
/// </summary>
|
|
public enum InAppNotificationPriority
|
|
{
|
|
Low,
|
|
Normal,
|
|
High,
|
|
Urgent
|
|
}
|