Files
ERP_Distribution/Distribution/docs/21_notifications_alerts.md
DeepnaPooja 6b9f27573b Introduced NotificationService for managing notification logic and recipient resolution.
- Integrated SignalR for real-time notification delivery to users.
2026-10-20 16:51:01 +05:30

19 KiB

21 — Notifications & Alerts

Status: v1 in-app built — backend, SignalR hub, frontend bell + view-all page, 10 unit tests. EF migration AddNotifications is generated but not applied to any database. Six of the seven Phase 1 triggers are wired into the services (see Triggers); StockLow is deferred. The temporary test endpoint and button were removed. Depends On: 02 — Stock Ledger, 12 — Store Management, 08 — Sales Agent Controls, 15 — Procurement, 18 — Returns & Claims, 19 — Finance & Accounting


Goal

Real-time in-app notifications (bell + toast) triggered by events other modules already produce (mainPlan.md §15). Email / SMS / push are out of scope for v1.

Real-time uses SignalR. A notification is saved first, pushed second, so offline users see what they missed on their next load.


Implemented now — who gets which notification

Every notification is in-app only (bell + toast + /notifications), pushed live over SignalR.

# When this happens Where in the app Notification the user sees Who receives it Who does not Level Click opens
1 Shop order confirmed ShopPOService.ConfirmAsync, ConfirmAndCreateInvoiceAsync Shop order confirmed — "Shop order for Store was confirmed." The user who raised the order + that user's supervisor The person who confirmed it Info /sales/orders
2 Shop order cancelled ShopPOService.CancelAsync Shop order cancelled — "Shop order for Store was cancelled: reason" The user who raised the order + that user's supervisor The person who cancelled it Warning /sales/orders
3 Supplier claim approved SupplierClaimService.ApproveAsync Supplier claim approved The user who raised the claim The approver Success /returns-claims/supplier-claims
4 Supplier claim rejected SupplierClaimService.RejectAsync Supplier claim rejected The user who raised the claim The person who rejected it Warning /returns-claims/supplier-claims
5 Driver assigned to a ride RideService.AssignDriverAsync Ride assigned — "You have been assigned to a ride." The assigned driver Everyone else Info /operations/rides
6 Ride cancelled RideService.CancelAsync Ride cancelled — "A ride was cancelled (type). reason" The owner of every route in the ride, plus users with delivery-task.manage whose scope covers those routes The person who cancelled it Critical /operations/rides
7 Delivery failed DeliveryTaskService.FailAsync Delivery failed — "Delivery to Store failed. note" The owner of the store's route, plus users with delivery-task.manage whose scope covers the store Nobody is excluded (this action does not record who did it) Warning /operations/deliveries
8 Delivery partly delivered DeliveryTaskService.ConfirmAsync Partial delivery — "Delivery to Store was only partly delivered." Same as #7 The person who confirmed it Warning /operations/deliveries
9 Store credit limit crossed StoreService.UpdateOutstandingBalanceAsync Credit limit exceeded — "Store has exceeded its credit limit." The owner of the store's route, plus users with store.manage whose scope covers the store The person who updated the balance. Fires only when the balance goes from within the limit to above it Warning /stores/{id}

Not wired yet: low stock (StockLow), cheque returned / bounced / due, expiring batches, overdue payments, and the Phase 2 list below.

Three rules that decide who sees what

  1. You are never notified of your own action.
  2. Receiving needs no permission — any logged-in user gets the bell and the page. Permissions only matter for doing the action, and for the "managers in scope" recipients (they need the permission and a scope covering the store or route).
  3. SuperAdmin usually acts, rarely receives. It holds every permission but has no scope rows in the seed, so it is only a recipient when it created the record (orders, claims) or owns the route.

With two logins — SuperAdmin and a sales rep

Who does it Action Who gets the notification
Rep creates, SuperAdmin confirms or cancels Shop order Rep (creator), plus the rep's supervisor if they have one
SuperAdmin creates, rep confirms or cancels Shop order SuperAdmin (creator)
SuperAdmin Sets a store's outstanding balance above its limit The rep, if the rep owns that store's route. SuperAdmin never (actor)
SuperAdmin Assigns a driver to a ride The rep, only if the rep is that driver
SuperAdmin Approves or rejects a supplier claim Whoever raised the claim — the rep only if the rep raised it
Anyone Fails a delivery / cancels a ride The rep, if the rep owns a route involved. SuperAdmin only if it has a scope on that route

Seed route owners: nimal.perera owns the Colombo and Negombo routes; kamal.silva owns Kandy and Matale.

Quick test (two browsers, one login each, both on a dashboard page)

  1. As the rep, create a shop order (Sales → New).
  2. As SuperAdmin, confirm it, then create a second one and cancel it with a reason.
  3. The rep sees a toast, a red badge on the bell and a new row on /notifications — "Shop order confirmed" and "Shop order cancelled: reason". Clicking one marks it read and opens /sales/orders.
  4. Reverse it: SuperAdmin creates an order and the rep confirms or cancels it — SuperAdmin is notified.
  5. Credit limit: there is no screen for it, so use Swagger (/swagger): PUT /api/stores/{id}/outstanding-balance with { "newBalance": 60000 } on a store whose limit is lower (Fort Mini Mart: limit 50,000). A second call with a higher value does not alert again; drop the balance back under the limit and set it over again to re-trigger.

Flow

Service action ──► uow.CommitAsync()                    (business transaction, unchanged)
      │
      ▼  only after a successful commit
NotificationService.NotifyAsync(type, recipientRule, args, entity)
      1. resolve recipients      (users | permission | scope | reports-to)
      2. insert Notification + NotificationRecipient rows
      3. hub.Clients.Users(ids).SendAsync("notification", dto)
      ▼
Frontend provider: connection.on("notification") → badge + toast → click opens Link + marks read

Decisions

Question Decision
First batch of triggers The seven Phase 1 triggers below. Cheque triggers wait until finance permissions exist in the seed
Channels In-app only (SignalR). Email / SMS / push deferred
Per-type config table + admin screen Not in v1. Every type is always enabled; add NotificationModuleConfig when someone needs to mute a type
Scheduled alerts (Phase 1b) Second pass. Only the retention cleanup job ships in v1
CORS / instances Existing Cors:AllowedOrigins + AllowCredentials() already suit SignalR. Single API instance, no Redis backplane
Recipients By permission code and scope, not role name. The seeded roles are SalesRep, Supervisor, AreaManager, AreaSupervisor, RegionalManager, RegionalAgency, SuperAdmin; there is no Accounts / operations role
Ids User, Permission and entity ids are Guid (see seeds)

Data Model

Notification — one row per event

Field Type Notes
Id Guid PK
Type varchar(60) NotificationTypes constant; frontend maps it to an icon
Severity enum Info, Success, Warning, Critical
Title / Message varchar(150) / varchar(500) Built from the type's templates
EntityType / EntityId varchar(50) / Guid The related record
Link varchar(200) Frontend route, e.g. /payments/received-cheques/88
CreatedByUserId FK User, null Null for system / scheduled events
CreatedAt datetime DateTime.Now
DedupeKey — Not in v1. Added with the Phase 1b scheduled jobs, e.g. ItemBatchExpiring:77:2026-10-20

NotificationRecipient — one row per user per event (read state is per user)

Field Type
Id Guid PK
NotificationId FK
UserId FK
IsRead / ReadAt bool / datetime null

Index (UserId, IsRead); the bell orders by the joined Notification.CreatedAt DESC.

No config table in v1 — every type is enabled. (Later: NotificationModuleConfig with Type PK + InAppEnabled, missing row = enabled.)

Example (failed delivery):

Notification:          Type=DeliveryTaskFailed, Severity=Warning,
                       Title="Delivery failed", Message="Delivery to Fort Mini Mart failed.",
                       EntityType=DeliveryTask, EntityId=<guid>, Link=/operations/delivery-tasks/<guid>
NotificationRecipient: (that notification, Nimal Perera - route owner, unread)
                       (that notification, a delivery-task.manage user in Western region, unread)

Recipients

Every trigger names its rule. There is no "notify everyone" option.

Rule Resolves to
Users(ids) Those users (creator, driver, approver, claim owner)
Permission(code) Active users whose role has that permission code (User.FK_RoleId → RolePermission → Permission.Code), e.g. delivery-task.manage
Scope(kind, id) Users whose UserStoreScope / UserRegionScope / UserRouteScope covers the record
PermissionInScope(code, kind, id) Intersection of the two (e.g. store.manage users for that store's region)
ReportsTo(userId) The user's supervisor via User.FK_ReportsToUserId
RouteOwner(kind, id) The owner (Route.FK_OwnerUserId) of the route, or of the store's route when kind is Store

SuperAdmin holds every permission, so it receives Permission rules automatically; use PermissionInScope where that would be too noisy.

The resolver always drops inactive users and the acting user, and de-duplicates.


Triggers

Each is called after the owning service's uow.CommitAsync().

Phase 1 — must have

Type Raised by When Recipients Severity
Type Status Raised by When Recipients
--- --- --- --- ---
SupplierClaimApproved / Rejected ✅ wired SupplierClaimService.ApproveAsync / RejectAsync Decision Users(RaisedByUserId) (not the decider)
RideCancelled ✅ wired RideService.CancelAsync Cancelled (message carries CancelRequestType + reason) For each distinct route in the ride's delivery tasks: RouteOwner(Route) + PermissionInScope(delivery-task.manage, Route). A ride has no route of its own, so routes come from its tasks' stores
RideDriverAssigned ✅ wired RideService.AssignDriverAsync Driver assigned Users(driver.FK_UserId)
DeliveryTaskFailed ✅ wired DeliveryTaskService.FailAsync Task failed RouteOwner(Store) + PermissionInScope(delivery-task.manage, Store)
DeliveryTaskPartiallyDelivered ✅ wired DeliveryTaskService.ConfirmAsync Confirmed with fullyDelivered == false (a separate type: "failed" would be the wrong wording) same as above
ShopPOConfirmed / Cancelled ✅ wired ShopPOService.ConfirmAsync, ConfirmAndCreateInvoiceAsync / CancelAsync Status change Users(RaisedById) + ReportsTo(RaisedById) (not the actor)
CreditLimitBreached ✅ wired StoreService.UpdateOutstandingBalanceAsync Balance crosses from ≤ to > CreditLimit (crossing only). This is the only place OutstandingBalance is ever written — invoices and payments do not update it RouteOwner(Store) + PermissionInScope(store.manage, Store)
StockLow ⏸ deferred Stock movement Crosses to ≤ Item.ReorderLevel Undecided — warehouses have no region / scope link, so PermissionInScope cannot target them. Needs a per-request tracker + action filter because stock moves from many call sites that commit separately

Waiting on finance permissions (not in the seed yet — add a cheque permission first)

ReceivedChequeReturned (ReceivedChequeService.UpdateStatusAsync, action Return, Critical) · ChequePageBounced (ChequeBookService.UpdatePageStatusAsync, action Bounce, Critical)

Phase 1b — scheduled, second pass (daily BackgroundService, uses DedupeKey)

ReceivedChequeDueSoon (≤ 2 days) · ItemBatchExpiring (≤ N days) · PaymentOverdue · DriverComplianceExpiring

Phase 2 — later

PoCancelled / PoClosed · GrnShortOrDamaged · LoadingSheetConfirmed · InvoiceFinalized · ReturnCreated / ClaimSettled · PurchaseReturnCreated · ApprovalPending · RouteDeviationFlagged · RoleAssigned / ScopeChanged · SyncFailed

Excluded on purpose: plain master-data CRUD (brand, category, UOM, vehicle, region).


API

Endpoint Purpose
GET /api/notifications?unreadOnly=&page=&pageSize= Caller's inbox, newest first
GET /api/notifications/unread-count Badge count
PATCH /api/notifications/{id}/read Mark one read (own row only)
PATCH /api/notifications/read-all Mark all read
DELETE /api/notifications/{id} Remove caller's recipient row
WS /hubs/notifications SignalR; server → client event notification

Backend structure

Features/Notifications/     (namespace Distribution.Features.Notifications — "Notification" alone clashes with Models.Notification)
  NotificationController.cs         thin, no UoW
  NotificationService.cs            resolve → persist → push; owns uow.CommitAsync()
  INotificationRepository.cs / NotificationRepository.cs
  NotificationRecipientResolver.cs  Users / Permission / Scope / PermissionInScope / ReportsTo
  NotificationTypes.cs              constants + definition (severity, title/message template)
  NotificationDTOs.cs
  NotificationHub.cs                SignalR hub
  NotificationCleanupJob.cs         daily retention purge
  NotificationScheduleJob.cs        Phase 1b alerts (second pass)
Models/Notification.cs, NotificationRecipient.cs

Code reference

Registry — services never pass string literals:

public static class NotificationTypes
{
    public const string DeliveryTaskFailed = "DeliveryTaskFailed";
    // ...
    public static readonly IReadOnlyDictionary<string, NotificationTypeDefinition> All = new Dictionary<string, NotificationTypeDefinition>
    {
        [DeliveryTaskFailed] = new(NotificationSeverity.Warning,
            "Delivery failed", "Delivery to {StoreName} failed."),
    };
}

Service contract — never throws; failures are logged:

public interface INotificationService
{
    // recipients = union of the rules, then resolved, filtered (inactive, actor) and de-duplicated
    Task NotifyAsync(string type, IReadOnlyList<RecipientRule> recipients, NotificationArgs args, CancellationToken ct = default);
}
// RecipientRule: Users(ids) | Permission(code) | Scope(kind, id) | PermissionInScope(code, kind, id) | ReportsTo(userId) | RouteOwner(kind, id)
// NotificationArgs: Values (template tokens), EntityType, EntityId (Guid), Link, CreatedByUserId (Guid?)

Calling it from an existing service:

await _uow.CommitAsync(ct);
await _notifications.NotifyAsync(NotificationTypes.DeliveryTaskFailed,
    [RecipientRule.Users(route.FK_OwnerUserId),
     RecipientRule.PermissionInScope("delivery-task.manage", ScopeKind.Region, route.FK_RegionId)],
    new(new { StoreName = store.StoreName }, "DeliveryTask", task.Id,
        $"/operations/delivery-tasks/{task.Id}", currentUserId), ct);

Program.cs:

builder.Services.AddSignalR();
builder.Services.AddScoped<INotificationService, NotificationService>();
builder.Services.AddHostedService<NotificationCleanupJob>();
// CORS: AllowCredentials() with explicit origins
app.MapHub<NotificationHub>("/hubs/notifications");

The hub authenticates through the existing access-token cookie (read by AuthMiddleware); the client connects with withCredentials: true. A ?access_token= query fallback is not needed unless the token ever moves out of the cookie. Users are addressed by their UserId claim (NotificationUserIdProvider). Each recipient gets its own push because the DTO Id is the recipient row id.

Frontend (contexts/notification-context.tsx):

const connection = new HubConnectionBuilder()
  .withUrl(`${API}/hubs/notifications`, { withCredentials: true })
  .withAutomaticReconnect().build();

connection.on("notification", (n: NotificationDto) => {
  setUnread((c) => c + 1);
  setItems((p) => [n, ...p]);
  toast(n.title, { description: n.message, action: { label: "Open", onClick: () => router.push(n.link) } });
});
connection.onreconnected(refetch);   // catch up on anything missed

Also: lib/api/notifications.ts, bell + popover in the dashboard header, /notifications "view all" page.


Rules

  • Notify after a successful commit, inside try/catch + log. A notification failure must never fail or roll back the business action.
  • Saving and the hub push are separate try/catch blocks (a failed push still leaves the inbox entry).
  • Unknown type: throws in dev/test, logged and skipped in production. A unit test checks that every constant has a definition.
  • No per-type config in v1: every type is always delivered.
  • Click-through is mandatory: EntityType, EntityId and Link are required; clicking marks read.
  • Retention: NotificationCleanupJob deletes read recipients older than 90 days (SysConfig), then events with no recipients left.
  • No Email/SMS switches in the UI until a sender exists.
  • AGENTS.md: uow.CommitAsync() only in services, never SaveChangesAsync(), DateTime.Now only.

Open Questions

Resolved — see Decisions. Remaining for later:

  1. Add a cheque / finance permission to the seed, then enable the two cheque triggers.
  2. Email / SMS / push: see 22_integration_api.md.
  3. If the API ever runs on more than one instance, add a SignalR Redis backplane.
  4. Per-type mute (NotificationModuleConfig + admin screen) when requested.

Build order

  1. Entities (Notification, NotificationRecipient) + EF migration.

  2. Features/Notifications/ (repository, resolver, service, controller, DTOs, NotificationTypes) + retention job.

  3. SignalR hub, Program.cs registration, user-id provider.

  4. Frontend: API client, provider, bell, toast, click-through, view-all page.

  5. Phase 1 triggers — six wired (recipient resolution is unit-tested; the per-service calls are not, because Distribution.Tests does not currently compile). StockLow deferred.

  6. Later: StockLow, cheque triggers, Phase 1b scheduled alerts, Phase 2 triggers.

Remaining before this works end to end: apply the AddNotifications migration.