feat: implement notification system with repository, service, and controller layers

This commit is contained in:
2026-10-20 16:45:48 +05:30
parent 28c465f59b
commit d1c3d4e22f
10 changed files with 656 additions and 0 deletions
@@ -0,0 +1,162 @@
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)] + "…";
}