From d1c3d4e22fe393af7c8232060c224966c4c5ea66 Mon Sep 17 00:00:00 2001 From: DeepnaPooja Date: Tue, 20 Oct 2026 16:45:48 +0530 Subject: [PATCH] feat: implement notification system with repository, service, and controller layers --- .../Notifications/INotificationRepository.cs | 29 ++++ .../Notifications/NotificationCleanupJob.cs | 63 +++++++ .../Notifications/NotificationController.cs | 57 ++++++ .../Notifications/NotificationDTOs.cs | 28 +++ .../Features/Notifications/NotificationHub.cs | 22 +++ .../NotificationRecipientResolver.cs | 53 ++++++ .../Notifications/NotificationRepository.cs | 156 +++++++++++++++++ .../Notifications/NotificationService.cs | 162 ++++++++++++++++++ .../Notifications/NotificationTypes.cs | 49 ++++++ .../Features/Notifications/RecipientRule.cs | 37 ++++ 10 files changed, 656 insertions(+) create mode 100644 Distribution/Features/Notifications/INotificationRepository.cs create mode 100644 Distribution/Features/Notifications/NotificationCleanupJob.cs create mode 100644 Distribution/Features/Notifications/NotificationController.cs create mode 100644 Distribution/Features/Notifications/NotificationDTOs.cs create mode 100644 Distribution/Features/Notifications/NotificationHub.cs create mode 100644 Distribution/Features/Notifications/NotificationRecipientResolver.cs create mode 100644 Distribution/Features/Notifications/NotificationRepository.cs create mode 100644 Distribution/Features/Notifications/NotificationService.cs create mode 100644 Distribution/Features/Notifications/NotificationTypes.cs create mode 100644 Distribution/Features/Notifications/RecipientRule.cs diff --git a/Distribution/Features/Notifications/INotificationRepository.cs b/Distribution/Features/Notifications/INotificationRepository.cs new file mode 100644 index 0000000..ac86a80 --- /dev/null +++ b/Distribution/Features/Notifications/INotificationRepository.cs @@ -0,0 +1,29 @@ +namespace Distribution.Features.Notifications; + +public interface INotificationRepository +{ + // ── Inbox ──────────────────────────────────────────────────────────── + Task AddAsync(Models.Notification notification); + /// Stops tracking a notification (and its recipients) whose save failed. + void Discard(Models.Notification notification); + + Task GetInboxCountAsync(Guid userId, bool unreadOnly); + Task> GetInboxAsync(Guid userId, bool unreadOnly, int page, int pageSize); + Task GetUnreadCountAsync(Guid userId); + Task GetRecipientAsync(Guid userId, Guid recipientId); + Task> GetUnreadRecipientsAsync(Guid userId); + void RemoveRecipient(Models.NotificationRecipient recipient); + + // ── Recipient resolution ───────────────────────────────────────────── + Task> FilterActiveUserIdsAsync(IEnumerable userIds); + Task> GetUserIdsByPermissionAsync(string permissionCode); + Task> GetUserIdsByScopeAsync(ScopeKind kind, Guid id); + Task GetReportsToUserIdAsync(Guid userId); + Task GetRouteOwnerUserIdAsync(ScopeKind kind, Guid id); + + // ── Retention ──────────────────────────────────────────────────────── + Task> GetExpiredReadRecipientsAsync(DateTime cutoff, int take); + Task> GetOrphanNotificationsAsync(int take); + void RemoveRecipients(IEnumerable recipients); + void RemoveNotifications(IEnumerable notifications); +} diff --git a/Distribution/Features/Notifications/NotificationCleanupJob.cs b/Distribution/Features/Notifications/NotificationCleanupJob.cs new file mode 100644 index 0000000..fd43b1a --- /dev/null +++ b/Distribution/Features/Notifications/NotificationCleanupJob.cs @@ -0,0 +1,63 @@ +using Distribution.Infra.UoW; + +namespace Distribution.Features.Notifications; + +/// +/// Daily retention purge: deletes read inbox rows older than Notifications:RetentionDays +/// (default 90), then any notification left with no recipients. +/// +public class NotificationCleanupJob( + IServiceScopeFactory scopes, + IConfiguration config, + ILogger logger) : BackgroundService +{ + private const int BatchSize = 500; + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + // Let the app finish starting before the first run. + await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken); + + while (!stoppingToken.IsCancellationRequested) + { + try { await RunOnceAsync(stoppingToken); } + catch (OperationCanceledException) { break; } + catch (Exception ex) { logger.LogError(ex, "Notification cleanup failed."); } + + await Task.Delay(TimeSpan.FromHours(24), stoppingToken); + } + } + + private async Task RunOnceAsync(CancellationToken ct) + { + var retentionDays = config.GetValue("Notifications:RetentionDays", 90); + var cutoff = DateTime.Now.AddDays(-retentionDays); + + using var scope = scopes.CreateScope(); + var repo = scope.ServiceProvider.GetRequiredService(); + var uow = scope.ServiceProvider.GetRequiredService(); + + var removed = 0; + while (true) + { + var expired = await repo.GetExpiredReadRecipientsAsync(cutoff, BatchSize); + if (expired.Count == 0) break; + + repo.RemoveRecipients(expired); + await uow.CommitAsync(ct); + removed += expired.Count; + } + + while (true) + { + var orphans = await repo.GetOrphanNotificationsAsync(BatchSize); + if (orphans.Count == 0) break; + + repo.RemoveNotifications(orphans); + await uow.CommitAsync(ct); + } + + if (removed > 0) + logger.LogInformation("Notification cleanup removed {Count} read inbox rows older than {Days} days.", removed, retentionDays); + } +} diff --git a/Distribution/Features/Notifications/NotificationController.cs b/Distribution/Features/Notifications/NotificationController.cs new file mode 100644 index 0000000..2429290 --- /dev/null +++ b/Distribution/Features/Notifications/NotificationController.cs @@ -0,0 +1,57 @@ +using Distribution.Infra.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Distribution.Features.Notifications; + +/// Every endpoint acts on the signed-in user's own notifications only. +[ApiController] +[Route("api/notifications")] +public class NotificationController(NotificationService service, ICurrentUserService currentUser) : ControllerBase +{ + [HttpGet] + public async Task GetInbox( + [FromQuery] bool unreadOnly = false, [FromQuery] int page = 1, [FromQuery] int pageSize = 20) + { + if (currentUser.UserId is not { } userId) return Unauthorized(); + return Ok(await service.GetInboxAsync(userId, unreadOnly, page, pageSize)); + } + + [HttpGet("unread-count")] + public async Task GetUnreadCount() + { + if (currentUser.UserId is not { } userId) return Unauthorized(); + return Ok(await service.GetUnreadCountAsync(userId)); + } + + [HttpPatch("{id:guid}/read")] + public async Task MarkRead(Guid id, CancellationToken ct) + { + if (currentUser.UserId is not { } userId) return Unauthorized(); + try + { + await service.MarkReadAsync(userId, id, ct); + return NoContent(); + } + catch (KeyNotFoundException ex) { return NotFound(new { error = ex.Message }); } + } + + [HttpPatch("read-all")] + public async Task MarkAllRead(CancellationToken ct) + { + if (currentUser.UserId is not { } userId) return Unauthorized(); + await service.MarkAllReadAsync(userId, ct); + return NoContent(); + } + + [HttpDelete("{id:guid}")] + public async Task Delete(Guid id, CancellationToken ct) + { + if (currentUser.UserId is not { } userId) return Unauthorized(); + try + { + await service.DeleteAsync(userId, id, ct); + return NoContent(); + } + catch (KeyNotFoundException ex) { return NotFound(new { error = ex.Message }); } + } +} diff --git a/Distribution/Features/Notifications/NotificationDTOs.cs b/Distribution/Features/Notifications/NotificationDTOs.cs new file mode 100644 index 0000000..5eb65e0 --- /dev/null +++ b/Distribution/Features/Notifications/NotificationDTOs.cs @@ -0,0 +1,28 @@ +using Distribution.Models; + +namespace Distribution.Features.Notifications; + +/// Object whose public properties fill the {Token} placeholders in the type's templates. +/// The acting user; excluded from the recipients. +public record NotificationArgs( + object? Values, + string EntityType, + Guid EntityId, + string Link, + Guid? CreatedByUserId = null); + +/// The caller's own recipient row id — use it for read / delete. +public record NotificationResponse( + Guid Id, + string Type, + NotificationSeverity Severity, + string Title, + string Message, + string EntityType, + Guid EntityId, + string Link, + DateTime CreatedAt, + bool IsRead, + DateTime? ReadAt); + +public record UnreadCountResponse(int Count); diff --git a/Distribution/Features/Notifications/NotificationHub.cs b/Distribution/Features/Notifications/NotificationHub.cs new file mode 100644 index 0000000..fc83279 --- /dev/null +++ b/Distribution/Features/Notifications/NotificationHub.cs @@ -0,0 +1,22 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.SignalR; + +namespace Distribution.Features.Notifications; + +/// +/// Server → client only. The server pushes a "notification" event to each recipient; clients never +/// invoke hub methods. Users are addressed by their id (see ). +/// +[Authorize] +public class NotificationHub : Hub +{ + public const string Path = "/hubs/notifications"; + public const string ClientEvent = "notification"; +} + +/// Maps a SignalR connection to the "UserId" claim that AuthMiddleware puts on the principal. +public class NotificationUserIdProvider : IUserIdProvider +{ + public string? GetUserId(HubConnectionContext connection) => + connection.User?.FindFirst("UserId")?.Value; +} diff --git a/Distribution/Features/Notifications/NotificationRecipientResolver.cs b/Distribution/Features/Notifications/NotificationRecipientResolver.cs new file mode 100644 index 0000000..ad7b279 --- /dev/null +++ b/Distribution/Features/Notifications/NotificationRecipientResolver.cs @@ -0,0 +1,53 @@ +namespace Distribution.Features.Notifications; + +public class NotificationRecipientResolver(INotificationRepository repo) +{ + /// + /// Expands the rules into a de-duplicated list of active user ids, never including + /// . + /// + public async Task> ResolveAsync(IEnumerable rules, Guid? actingUserId) + { + var ids = new HashSet(); + + foreach (var rule in rules) + ids.UnionWith(await ResolveRuleAsync(rule)); + + if (actingUserId is { } actor) + ids.Remove(actor); + + if (ids.Count == 0) return []; + return await repo.FilterActiveUserIdsAsync(ids); + } + + private async Task> ResolveRuleAsync(RecipientRule rule) + { + switch (rule) + { + case UsersRule r: + return r.Ids; + + case PermissionRule r: + return await repo.GetUserIdsByPermissionAsync(r.Code); + + case ScopeRule r: + return await repo.GetUserIdsByScopeAsync(r.Kind, r.Id); + + case PermissionInScopeRule r: + var withPermission = await repo.GetUserIdsByPermissionAsync(r.Code); + var inScope = await repo.GetUserIdsByScopeAsync(r.Kind, r.Id); + return withPermission.Intersect(inScope); + + case ReportsToRule r: + var supervisor = await repo.GetReportsToUserIdAsync(r.UserId); + return supervisor is { } s ? [s] : []; + + case RouteOwnerRule r: + var owner = await repo.GetRouteOwnerUserIdAsync(r.Kind, r.Id); + return owner is { } o ? [o] : []; + + default: + throw new NotSupportedException($"Unknown recipient rule {rule.GetType().Name}."); + } + } +} diff --git a/Distribution/Features/Notifications/NotificationRepository.cs b/Distribution/Features/Notifications/NotificationRepository.cs new file mode 100644 index 0000000..b766edc --- /dev/null +++ b/Distribution/Features/Notifications/NotificationRepository.cs @@ -0,0 +1,156 @@ +using Distribution.Data; +using Microsoft.EntityFrameworkCore; + +namespace Distribution.Features.Notifications; + +public class NotificationRepository(AppDBContext context) : INotificationRepository +{ + // ── Inbox ──────────────────────────────────────────────────────────── + + public async Task AddAsync(Models.Notification notification) => + await context.Notifications.AddAsync(notification); + + public void Discard(Models.Notification notification) + { + foreach (var recipient in notification.Recipients) + context.Entry(recipient).State = EntityState.Detached; + context.Entry(notification).State = EntityState.Detached; + } + + private IQueryable InboxQuery(Guid userId, bool unreadOnly) + { + var query = context.NotificationRecipients.Where(r => r.FK_UserId == userId && r.IsActive); + return unreadOnly ? query.Where(r => !r.IsRead) : query; + } + + public Task GetInboxCountAsync(Guid userId, bool unreadOnly) => + InboxQuery(userId, unreadOnly).CountAsync(); + + public Task> GetInboxAsync(Guid userId, bool unreadOnly, int page, int pageSize) => + InboxQuery(userId, unreadOnly) + .Include(r => r.Notification) + .OrderByDescending(r => r.Notification.CreatedAt) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .AsNoTracking() + .ToListAsync(); + + public Task GetUnreadCountAsync(Guid userId) => + InboxQuery(userId, unreadOnly: true).CountAsync(); + + public Task GetRecipientAsync(Guid userId, Guid recipientId) => + context.NotificationRecipients + .FirstOrDefaultAsync(r => r.Id == recipientId && r.FK_UserId == userId && r.IsActive); + + public Task> GetUnreadRecipientsAsync(Guid userId) => + InboxQuery(userId, unreadOnly: true).ToListAsync(); + + public void RemoveRecipient(Models.NotificationRecipient recipient) => + context.NotificationRecipients.Remove(recipient); + + // ── Recipient resolution ───────────────────────────────────────────── + + public Task> FilterActiveUserIdsAsync(IEnumerable userIds) + { + var ids = userIds.ToList(); + return context.Users + .Where(u => u.IsActive && ids.Contains(u.Id)) + .Select(u => u.Id) + .ToListAsync(); + } + + public Task> GetUserIdsByPermissionAsync(string permissionCode) => + context.Users + .Where(u => u.IsActive && context.RolePermissions.Any(rp => + rp.FK_RoleId == u.FK_RoleId && rp.IsActive && + rp.Permission.IsActive && rp.Permission.Code == permissionCode)) + .Select(u => u.Id) + .ToListAsync(); + + public async Task> GetUserIdsByScopeAsync(ScopeKind kind, Guid id) + { + Guid? regionId = null; + Guid? routeId = null; + Guid? storeId = null; + + switch (kind) + { + case ScopeKind.Region: + regionId = id; + break; + case ScopeKind.Route: + routeId = id; + regionId = await context.Routes.Where(r => r.Id == id) + .Select(r => (Guid?)r.FK_RegionId).FirstOrDefaultAsync(); + break; + case ScopeKind.Store: + storeId = id; + routeId = await context.Stores.Where(s => s.Id == id) + .Select(s => (Guid?)s.FK_RouteId).FirstOrDefaultAsync(); + if (routeId is { } rid) + regionId = await context.Routes.Where(r => r.Id == rid) + .Select(r => (Guid?)r.FK_RegionId).FirstOrDefaultAsync(); + break; + } + + var userIds = new HashSet(); + + if (regionId is { } region) + userIds.UnionWith(await context.UserRegionScopes + .Where(s => s.IsActive && s.FK_RegionId == region) + .Select(s => s.FK_UserId).ToListAsync()); + + if (routeId is { } route) + userIds.UnionWith(await context.UserRouteScopes + .Where(s => s.IsActive && s.FK_RouteId == route) + .Select(s => s.FK_UserId).ToListAsync()); + + if (storeId is { } store) + userIds.UnionWith(await context.UserStoreScopes + .Where(s => s.IsActive && s.FK_StoreId == store) + .Select(s => s.FK_UserId).ToListAsync()); + + return userIds.ToList(); + } + + public Task GetReportsToUserIdAsync(Guid userId) => + context.Users.Where(u => u.Id == userId) + .Select(u => u.FK_ReportsToUserId) + .FirstOrDefaultAsync(); + + public async Task GetRouteOwnerUserIdAsync(ScopeKind kind, Guid id) + { + Guid? routeId = kind switch + { + ScopeKind.Route => id, + ScopeKind.Store => await context.Stores.Where(s => s.Id == id) + .Select(s => (Guid?)s.FK_RouteId).FirstOrDefaultAsync(), + _ => null + }; + if (routeId is not { } rid) return null; + + return await context.Routes.Where(r => r.Id == rid && r.IsActive) + .Select(r => r.FK_OwnerUserId) + .FirstOrDefaultAsync(); + } + + // ── Retention ──────────────────────────────────────────────────────── + + public Task> GetExpiredReadRecipientsAsync(DateTime cutoff, int take) => + context.NotificationRecipients + .Where(r => r.IsRead && r.ReadAt != null && r.ReadAt < cutoff) + .Take(take) + .ToListAsync(); + + public Task> GetOrphanNotificationsAsync(int take) => + context.Notifications + .Where(n => !n.Recipients.Any()) + .Take(take) + .ToListAsync(); + + public void RemoveRecipients(IEnumerable recipients) => + context.NotificationRecipients.RemoveRange(recipients); + + public void RemoveNotifications(IEnumerable notifications) => + context.Notifications.RemoveRange(notifications); +} diff --git a/Distribution/Features/Notifications/NotificationService.cs b/Distribution/Features/Notifications/NotificationService.cs new file mode 100644 index 0000000..3c2cd3f --- /dev/null +++ b/Distribution/Features/Notifications/NotificationService.cs @@ -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 +{ + /// + /// 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)] + "…"; +} diff --git a/Distribution/Features/Notifications/NotificationTypes.cs b/Distribution/Features/Notifications/NotificationTypes.cs new file mode 100644 index 0000000..c196a47 --- /dev/null +++ b/Distribution/Features/Notifications/NotificationTypes.cs @@ -0,0 +1,49 @@ +using Distribution.Models; + +namespace Distribution.Features.Notifications; + +public record NotificationTypeDefinition(NotificationSeverity Severity, string TitleTemplate, string MessageTemplate); + +/// +/// Registry of every notification type. Services must pass one of these constants to +/// — never a string literal. Templates use {Token} +/// placeholders filled from . +/// +public static class NotificationTypes +{ + public const string RideCancelled = "RideCancelled"; + public const string RideDriverAssigned = "RideDriverAssigned"; + public const string DeliveryTaskFailed = "DeliveryTaskFailed"; + public const string DeliveryTaskPartiallyDelivered = "DeliveryTaskPartiallyDelivered"; + public const string SupplierClaimApproved = "SupplierClaimApproved"; + public const string SupplierClaimRejected = "SupplierClaimRejected"; + public const string ShopPOConfirmed = "ShopPOConfirmed"; + public const string ShopPOCancelled = "ShopPOCancelled"; + public const string StockLow = "StockLow"; + public const string CreditLimitBreached = "CreditLimitBreached"; + + public static readonly IReadOnlyDictionary All = + new Dictionary + { + [RideCancelled] = new(NotificationSeverity.Critical, + "Ride cancelled", "A ride was cancelled ({CancelType}). {Reason}"), + [RideDriverAssigned] = new(NotificationSeverity.Info, + "Ride assigned", "You have been assigned to a ride."), + [DeliveryTaskFailed] = new(NotificationSeverity.Warning, + "Delivery failed", "Delivery to {StoreName} failed. {Note}"), + [DeliveryTaskPartiallyDelivered] = new(NotificationSeverity.Warning, + "Partial delivery", "Delivery to {StoreName} was only partly delivered."), + [SupplierClaimApproved] = new(NotificationSeverity.Success, + "Supplier claim approved", "Your supplier claim was approved."), + [SupplierClaimRejected] = new(NotificationSeverity.Warning, + "Supplier claim rejected", "Your supplier claim was rejected."), + [ShopPOConfirmed] = new(NotificationSeverity.Info, + "Shop order confirmed", "Shop order for {StoreName} was confirmed."), + [ShopPOCancelled] = new(NotificationSeverity.Warning, + "Shop order cancelled", "Shop order for {StoreName} was cancelled: {Reason}"), + [StockLow] = new(NotificationSeverity.Warning, + "Low stock", "{ItemName} is down to {Quantity} at {Location}."), + [CreditLimitBreached] = new(NotificationSeverity.Warning, + "Credit limit exceeded", "{StoreName} has exceeded its credit limit."), + }; +} diff --git a/Distribution/Features/Notifications/RecipientRule.cs b/Distribution/Features/Notifications/RecipientRule.cs new file mode 100644 index 0000000..528cf55 --- /dev/null +++ b/Distribution/Features/Notifications/RecipientRule.cs @@ -0,0 +1,37 @@ +namespace Distribution.Features.Notifications; + +public enum ScopeKind { Region, Route, Store } + +/// +/// Who a notification goes to. There is deliberately no "everyone" rule — every trigger must say who +/// needs to know. Scope coverage follows Region → Route → Store: a region scope covers its routes +/// and their stores, a route scope covers its stores. +/// +public abstract record RecipientRule +{ + /// These specific users (creator, driver, approver, route owner …). + public static RecipientRule Users(params Guid[] ids) => new UsersRule(ids); + + /// Every active user whose role holds the permission code. + public static RecipientRule Permission(string code) => new PermissionRule(code); + + /// Every active user whose scope covers the region / route / store. + public static RecipientRule Scope(ScopeKind kind, Guid id) => new ScopeRule(kind, id); + + /// Users who hold the permission AND whose scope covers the region / route / store. + public static RecipientRule PermissionInScope(string code, ScopeKind kind, Guid id) => + new PermissionInScopeRule(code, kind, id); + + /// The user's supervisor (User.FK_ReportsToUserId). + public static RecipientRule ReportsTo(Guid userId) => new ReportsToRule(userId); + + /// The owner of the route () or of the store's route (). + public static RecipientRule RouteOwner(ScopeKind kind, Guid id) => new RouteOwnerRule(kind, id); +} + +public sealed record UsersRule(IReadOnlyCollection Ids) : RecipientRule; +public sealed record PermissionRule(string Code) : RecipientRule; +public sealed record ScopeRule(ScopeKind Kind, Guid Id) : RecipientRule; +public sealed record PermissionInScopeRule(string Code, ScopeKind Kind, Guid Id) : RecipientRule; +public sealed record ReportsToRule(Guid UserId) : RecipientRule; +public sealed record RouteOwnerRule(ScopeKind Kind, Guid Id) : RecipientRule;