Files
git.stella-ops.org/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Worker/Channels/InAppChannelAdapter.cs
master e950474a77
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
up
2025-11-27 15:16:31 +02:00

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
}