up
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
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
This commit is contained in:
@@ -0,0 +1,483 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user