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;