163 lines
6.6 KiB
C#
163 lines
6.6 KiB
C#
using System.Text.RegularExpressions;
|
|
using Distribution.Features.Stock;
|
|
using Distribution.Infra.UoW;
|
|
using Microsoft.AspNetCore.SignalR;
|
|
|
|
namespace Distribution.Features.Notifications;
|
|
|
|
public interface INotificationService
|
|
{
|
|
/// <summary>
|
|
/// Creates a notification for every resolved recipient and pushes it live. Best-effort: this method
|
|
/// never throws, so a notification problem can never fail the business action that triggered it.
|
|
/// Call it only AFTER the owning service's <c>uow.CommitAsync()</c> has succeeded.
|
|
/// </summary>
|
|
Task NotifyAsync(string type, IReadOnlyList<RecipientRule> recipients, NotificationArgs args, CancellationToken ct = default);
|
|
}
|
|
|
|
public class NotificationService(
|
|
INotificationRepository repo,
|
|
NotificationRecipientResolver resolver,
|
|
IHubContext<NotificationHub> hub,
|
|
IUnitOfWork uow,
|
|
ILogger<NotificationService> logger) : INotificationService
|
|
{
|
|
private static readonly Regex TokenPattern = new(@"\{(\w+)\}", RegexOptions.Compiled);
|
|
|
|
// ── Trigger side ─────────────────────────────────────────────────────
|
|
|
|
public async Task NotifyAsync(string type, IReadOnlyList<RecipientRule> recipients, NotificationArgs args, CancellationToken ct = default)
|
|
{
|
|
try
|
|
{
|
|
if (!NotificationTypes.All.TryGetValue(type, out var definition))
|
|
{
|
|
logger.LogError("Notification type {Type} is not registered in NotificationTypes; skipped.", type);
|
|
return;
|
|
}
|
|
|
|
var userIds = await resolver.ResolveAsync(recipients, args.CreatedByUserId);
|
|
if (userIds.Count == 0) return;
|
|
|
|
var values = ToDictionary(args.Values);
|
|
var notification = new Models.Notification
|
|
{
|
|
Type = type,
|
|
Severity = definition.Severity,
|
|
Title = Truncate(Render(definition.TitleTemplate, values), 150),
|
|
Message = Truncate(Render(definition.MessageTemplate, values), 500),
|
|
EntityType = args.EntityType,
|
|
EntityId = args.EntityId,
|
|
Link = args.Link,
|
|
CreatedByUserId = args.CreatedByUserId,
|
|
CreatedAt = DateTime.Now,
|
|
Recipients = userIds.Select(id => new Models.NotificationRecipient { FK_UserId = id }).ToList()
|
|
};
|
|
|
|
await repo.AddAsync(notification);
|
|
try
|
|
{
|
|
await uow.CommitAsync(ct);
|
|
}
|
|
catch
|
|
{
|
|
repo.Discard(notification);
|
|
throw;
|
|
}
|
|
|
|
await PushAsync(notification);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger.LogError(ex, "Failed to create {Type} notification for {EntityType} {EntityId}.",
|
|
type, args.EntityType, args.EntityId);
|
|
}
|
|
}
|
|
|
|
// Separate from persistence: a failed push still leaves the notification in the inbox.
|
|
private async Task PushAsync(Models.Notification notification)
|
|
{
|
|
foreach (var recipient in notification.Recipients)
|
|
{
|
|
try
|
|
{
|
|
await hub.Clients.User(recipient.FK_UserId.ToString())
|
|
.SendAsync(NotificationHub.ClientEvent, ToResponse(notification, recipient));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger.LogWarning(ex, "Failed to push notification {NotificationId} to user {UserId}.",
|
|
notification.Id, recipient.FK_UserId);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Inbox side ───────────────────────────────────────────────────────
|
|
|
|
public async Task<PagedResponse<NotificationResponse>> GetInboxAsync(Guid userId, bool unreadOnly, int page, int pageSize)
|
|
{
|
|
page = Math.Max(1, page);
|
|
pageSize = Math.Clamp(pageSize, 1, 100);
|
|
|
|
var total = await repo.GetInboxCountAsync(userId, unreadOnly);
|
|
var rows = await repo.GetInboxAsync(userId, unreadOnly, page, pageSize);
|
|
return new PagedResponse<NotificationResponse>(page, pageSize, total,
|
|
rows.Select(r => ToResponse(r.Notification, r)).ToList());
|
|
}
|
|
|
|
public async Task<UnreadCountResponse> GetUnreadCountAsync(Guid userId) =>
|
|
new(await repo.GetUnreadCountAsync(userId));
|
|
|
|
public async Task MarkReadAsync(Guid userId, Guid recipientId, CancellationToken ct = default)
|
|
{
|
|
var recipient = await repo.GetRecipientAsync(userId, recipientId)
|
|
?? throw new KeyNotFoundException("Notification not found.");
|
|
if (recipient.IsRead) return;
|
|
|
|
recipient.IsRead = true;
|
|
recipient.ReadAt = DateTime.Now;
|
|
await uow.CommitAsync(ct);
|
|
}
|
|
|
|
public async Task MarkAllReadAsync(Guid userId, CancellationToken ct = default)
|
|
{
|
|
var unread = await repo.GetUnreadRecipientsAsync(userId);
|
|
if (unread.Count == 0) return;
|
|
|
|
var now = DateTime.Now;
|
|
foreach (var recipient in unread)
|
|
{
|
|
recipient.IsRead = true;
|
|
recipient.ReadAt = now;
|
|
}
|
|
await uow.CommitAsync(ct);
|
|
}
|
|
|
|
public async Task DeleteAsync(Guid userId, Guid recipientId, CancellationToken ct = default)
|
|
{
|
|
var recipient = await repo.GetRecipientAsync(userId, recipientId)
|
|
?? throw new KeyNotFoundException("Notification not found.");
|
|
|
|
repo.RemoveRecipient(recipient);
|
|
await uow.CommitAsync(ct);
|
|
}
|
|
|
|
// ── Helpers ──────────────────────────────────────────────────────────
|
|
|
|
private static NotificationResponse ToResponse(Models.Notification n, Models.NotificationRecipient r) =>
|
|
new(r.Id, n.Type, n.Severity, n.Title, n.Message, n.EntityType, n.EntityId, n.Link,
|
|
n.CreatedAt, r.IsRead, r.ReadAt);
|
|
|
|
private static Dictionary<string, string> ToDictionary(object? values) =>
|
|
values is null
|
|
? []
|
|
: values.GetType().GetProperties()
|
|
.ToDictionary(p => p.Name, p => p.GetValue(values)?.ToString() ?? string.Empty);
|
|
|
|
private static string Render(string template, Dictionary<string, string> values) =>
|
|
TokenPattern.Replace(template, m => values.TryGetValue(m.Groups[1].Value, out var v) ? v : string.Empty).Trim();
|
|
|
|
private static string Truncate(string value, int max) =>
|
|
value.Length <= max ? value : value[..(max - 1)] + "…";
|
|
}
|