# 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](./mainPlan.md#15-notifications--alerts)). 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=, Link=/operations/delivery-tasks/ 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 | Severity | |---|---|---|---|---|---| | `SupplierClaimApproved` / `Rejected` | ✅ wired | `SupplierClaimService.ApproveAsync` / `RejectAsync` | Decision | `Users(RaisedByUserId)` (not the decider) | Success / Warning | | `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 | Critical | | `RideDriverAssigned` | ✅ wired | `RideService.AssignDriverAsync` | Driver assigned | `Users(driver.FK_UserId)` | Info | | `DeliveryTaskFailed` | ✅ wired | `DeliveryTaskService.FailAsync` | Task failed | `RouteOwner(Store)` + `PermissionInScope(delivery-task.manage, Store)` | Warning | | `DeliveryTaskPartiallyDelivered` | ✅ wired | `DeliveryTaskService.ConfirmAsync` | Confirmed with `fullyDelivered == false` (a separate type: "failed" would be the wrong wording) | same as above | Warning | | `ShopPOConfirmed` / `Cancelled` | ✅ wired | `ShopPOService.ConfirmAsync`, `ConfirmAndCreateInvoiceAsync` / `CancelAsync` | Status change | `Users(RaisedById)` + `ReportsTo(RaisedById)` (not the actor) | Info / Warning | | `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)` | Warning | | `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 | Warning | **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: ```csharp public static class NotificationTypes { public const string DeliveryTaskFailed = "DeliveryTaskFailed"; // ... public static readonly IReadOnlyDictionary All = new Dictionary { [DeliveryTaskFailed] = new(NotificationSeverity.Warning, "Delivery failed", "Delivery to {StoreName} failed."), }; } ``` **Service contract** — never throws; failures are logged: ```csharp public interface INotificationService { // recipients = union of the rules, then resolved, filtered (inactive, actor) and de-duplicated Task NotifyAsync(string type, IReadOnlyList 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:** ```csharp 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:** ```csharp builder.Services.AddSignalR(); builder.Services.AddScoped(); builder.Services.AddHostedService(); // CORS: AllowCredentials() with explicit origins app.MapHub("/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`): ```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](./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.