Files
git.stella-ops.org/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Worker/Channels/InAppInboxChannelAdapter.cs
StellaOps Bot ea970ead2a
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
sdk-generator-smoke / sdk-smoke (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled
up
2025-11-27 07:46:56 +02:00

157 lines
5.9 KiB
C#

using System.Text.Json;
using Microsoft.Extensions.Logging;
using StellaOps.Notify.Models;
namespace StellaOps.Notifier.Worker.Channels;
/// <summary>
/// Channel adapter for in-app inbox notifications.
/// Stores notifications in the database for users to retrieve via API or WebSocket.
/// </summary>
public sealed class InAppInboxChannelAdapter : INotifyChannelAdapter
{
private readonly IInAppInboxStore _inboxStore;
private readonly ILogger<InAppInboxChannelAdapter> _logger;
public InAppInboxChannelAdapter(IInAppInboxStore inboxStore, ILogger<InAppInboxChannelAdapter> logger)
{
_inboxStore = inboxStore ?? throw new ArgumentNullException(nameof(inboxStore));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public NotifyChannelType ChannelType => NotifyChannelType.InAppInbox;
public async Task<ChannelDispatchResult> 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);
}
}
/// <summary>
/// Storage interface for in-app inbox messages.
/// </summary>
public interface IInAppInboxStore
{
Task StoreAsync(InAppInboxMessage message, CancellationToken cancellationToken = default);
Task<IReadOnlyList<InAppInboxMessage>> GetForUserAsync(string tenantId, string userId, int limit = 50, CancellationToken cancellationToken = default);
Task<InAppInboxMessage?> 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<int> GetUnreadCountAsync(string tenantId, string userId, CancellationToken cancellationToken = default);
}
/// <summary>
/// In-app inbox message model.
/// </summary>
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<string, string>? 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; }
}
/// <summary>
/// Priority levels for in-app inbox messages.
/// </summary>
public enum InAppInboxPriority
{
Low,
Normal,
High,
Critical
}