using System.Text.Json; using Microsoft.Extensions.Logging; using StellaOps.Notify.Models; namespace StellaOps.Notifier.Worker.Channels; /// /// Channel adapter for in-app inbox notifications. /// Stores notifications in the database for users to retrieve via API or WebSocket. /// public sealed class InAppInboxChannelAdapter : INotifyChannelAdapter { private readonly IInAppInboxStore _inboxStore; private readonly ILogger _logger; public InAppInboxChannelAdapter(IInAppInboxStore inboxStore, ILogger logger) { _inboxStore = inboxStore ?? throw new ArgumentNullException(nameof(inboxStore)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } public NotifyChannelType ChannelType => NotifyChannelType.InAppInbox; public async Task SendAsync( NotifyChannel channel, NotifyDeliveryRendered rendered, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(channel); ArgumentNullException.ThrowIfNull(rendered); var userId = rendered.Target; if (string.IsNullOrWhiteSpace(userId)) { // Try to get from channel config userId = channel.Config?.Target; } if (string.IsNullOrWhiteSpace(userId)) { return ChannelDispatchResult.Fail("Target user ID not specified", shouldRetry: false); } var tenantId = channel.Config?.Properties.GetValueOrDefault("tenantId") ?? channel.TenantId; var messageId = Guid.NewGuid().ToString("N"); var inboxMessage = new InAppInboxMessage { MessageId = messageId, TenantId = tenantId, UserId = userId, Title = rendered.Title ?? "Notification", Body = rendered.Body ?? string.Empty, Summary = rendered.Summary, Category = channel.Config?.Properties.GetValueOrDefault("category") ?? "general", Priority = DeterminePriority(rendered), Metadata = null, CreatedAt = DateTimeOffset.UtcNow, ExpiresAt = DetermineExpiry(channel), SourceChannel = channel.ChannelId, DeliveryId = messageId }; try { await _inboxStore.StoreAsync(inboxMessage, cancellationToken).ConfigureAwait(false); _logger.LogInformation( "In-app inbox message stored for user {UserId}. MessageId: {MessageId}", userId, inboxMessage.MessageId); return ChannelDispatchResult.Ok(); } catch (Exception ex) { _logger.LogError(ex, "Failed to store in-app inbox message for user {UserId}", userId); return ChannelDispatchResult.Fail(ex.Message, shouldRetry: true); } } private static InAppInboxPriority DeterminePriority(NotifyDeliveryRendered rendered) { if (rendered.Title?.Contains("critical", StringComparison.OrdinalIgnoreCase) == true || rendered.Title?.Contains("urgent", StringComparison.OrdinalIgnoreCase) == true) return InAppInboxPriority.Critical; if (rendered.Title?.Contains("error", StringComparison.OrdinalIgnoreCase) == true || rendered.Title?.Contains("important", StringComparison.OrdinalIgnoreCase) == true) return InAppInboxPriority.High; if (rendered.Title?.Contains("warning", StringComparison.OrdinalIgnoreCase) == true) return InAppInboxPriority.Normal; return InAppInboxPriority.Low; } private static DateTimeOffset? DetermineExpiry(NotifyChannel channel) { var ttlStr = channel.Config?.Properties.GetValueOrDefault("ttl"); if (!string.IsNullOrEmpty(ttlStr) && int.TryParse(ttlStr, out var ttlHours)) { return DateTimeOffset.UtcNow.AddHours(ttlHours); } // Default 30 day expiry return DateTimeOffset.UtcNow.AddDays(30); } } /// /// Storage interface for in-app inbox messages. /// public interface IInAppInboxStore { Task StoreAsync(InAppInboxMessage message, CancellationToken cancellationToken = default); Task> GetForUserAsync(string tenantId, string userId, int limit = 50, CancellationToken cancellationToken = default); Task GetAsync(string tenantId, string messageId, CancellationToken cancellationToken = default); Task MarkReadAsync(string tenantId, string messageId, CancellationToken cancellationToken = default); Task MarkAllReadAsync(string tenantId, string userId, CancellationToken cancellationToken = default); Task DeleteAsync(string tenantId, string messageId, CancellationToken cancellationToken = default); Task GetUnreadCountAsync(string tenantId, string userId, CancellationToken cancellationToken = default); } /// /// In-app inbox message model. /// public sealed record InAppInboxMessage { public required string MessageId { get; init; } public required string TenantId { get; init; } public required string UserId { get; init; } public required string Title { get; init; } public required string Body { get; init; } public string? Summary { get; init; } public required string Category { get; init; } public InAppInboxPriority Priority { get; init; } public IReadOnlyDictionary? Metadata { get; init; } public DateTimeOffset CreatedAt { get; init; } public DateTimeOffset? ExpiresAt { get; init; } public DateTimeOffset? ReadAt { get; set; } public bool IsRead => ReadAt.HasValue; public string? SourceChannel { get; init; } public string? DeliveryId { get; init; } } /// /// Priority levels for in-app inbox messages. /// public enum InAppInboxPriority { Low, Normal, High, Critical }