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