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; /// /// Channel adapter for in-app notifications (inbox/CLI). /// Stores notifications in-memory for retrieval by users/services. /// public sealed class InAppChannelAdapter : IChannelAdapter { private readonly ConcurrentDictionary> _inboxes = new(); private readonly INotifyAuditRepository _auditRepository; private readonly InAppChannelOptions _options; private readonly TimeProvider _timeProvider; private readonly ILogger _logger; public InAppChannelAdapter( INotifyAuditRepository auditRepository, IOptions options, TimeProvider timeProvider, ILogger 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 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(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()); inbox.Enqueue(notification); // Enforce max notifications per inbox while (inbox.Count > _options.MaxNotificationsPerInbox && inbox.TryDequeue(out _)) { // Remove oldest } stopwatch.Stop(); var metadata = new Dictionary { ["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 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.")); } /// /// Gets unread notifications for a user. /// public IReadOnlyList 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(); } /// /// Gets all notifications for a user. /// public IReadOnlyList 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(); } /// /// Marks a notification as read. /// 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; } /// /// Marks all notifications as read for a user. /// 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; } /// /// Deletes a notification. /// 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; } /// /// Gets unread count for a user. /// 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? metadata, CancellationToken cancellationToken) { try { var auditMetadata = new Dictionary { ["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); } } } /// /// Options for in-app channel adapter. /// public sealed class InAppChannelOptions { /// /// Configuration section name. /// public const string SectionName = "InAppChannel"; /// /// Maximum notifications to keep per user inbox. /// public int MaxNotificationsPerInbox { get; set; } = 500; /// /// Time-to-live for notifications. /// public TimeSpan NotificationTtl { get; set; } = TimeSpan.FromDays(30); } /// /// An in-app notification stored in user's inbox. /// public sealed class InAppNotification { /// /// Unique notification ID. /// public required string NotificationId { get; init; } /// /// Original delivery ID. /// public required string DeliveryId { get; init; } /// /// Tenant ID. /// public required string TenantId { get; init; } /// /// Target user ID. /// public required string UserId { get; init; } /// /// Notification title. /// public required string Title { get; init; } /// /// Notification body/content. /// public string? Body { get; init; } /// /// Priority level. /// public InAppNotificationPriority Priority { get; init; } = InAppNotificationPriority.Normal; /// /// Notification category. /// public required string Category { get; init; } /// /// Related incident ID. /// public string? IncidentId { get; init; } /// /// URL for main action. /// public string? ActionUrl { get; init; } /// /// URL for acknowledgment action. /// public string? AckUrl { get; init; } /// /// Additional metadata. /// public Dictionary Metadata { get; init; } = []; /// /// When created. /// public DateTimeOffset CreatedAt { get; init; } /// /// When expires. /// public DateTimeOffset? ExpiresAt { get; init; } /// /// Current status. /// public InAppNotificationStatus Status { get; set; } = InAppNotificationStatus.Unread; /// /// When read. /// public DateTimeOffset? ReadAt { get; set; } } /// /// In-app notification status. /// public enum InAppNotificationStatus { Unread, Read, Actioned, Deleted } /// /// In-app notification priority. /// public enum InAppNotificationPriority { Low, Normal, High, Urgent }