using System.Text.RegularExpressions; using Distribution.Features.Stock; using Distribution.Infra.UoW; using Microsoft.AspNetCore.SignalR; namespace Distribution.Features.Notifications; public interface INotificationService { /// /// 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 uow.CommitAsync() has succeeded. /// Task NotifyAsync(string type, IReadOnlyList recipients, NotificationArgs args, CancellationToken ct = default); } public class NotificationService( INotificationRepository repo, NotificationRecipientResolver resolver, IHubContext hub, IUnitOfWork uow, ILogger logger) : INotificationService { private static readonly Regex TokenPattern = new(@"\{(\w+)\}", RegexOptions.Compiled); // ── Trigger side ───────────────────────────────────────────────────── public async Task NotifyAsync(string type, IReadOnlyList 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> 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(page, pageSize, total, rows.Select(r => ToResponse(r.Notification, r)).ToList()); } public async Task 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 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 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)] + "…"; }