Introduced NotificationService for managing notification logic and recipient resolution.

- Integrated SignalR for real-time notification delivery to users.
This commit is contained in:
2026-10-20 16:51:01 +05:30
parent a0df2a1bbf
commit 6b9f27573b
8 changed files with 6649 additions and 84 deletions
+297 -34
View File
@@ -1,67 +1,330 @@
# 21 — Notifications & Alerts
**Status**: Not Started
**Depends On**: 02 — Stock Ledger, 12 — Store Management, 08 — Sales Agent Controls
**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
Covers [mainPlan.md §15](./mainPlan.md#15-notifications--alerts) in full
— a cross-cutting delivery mechanism (email/SMS/push) triggered by events other steps already
produce.
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.
---
## Background / Context
## Implemented now — who gets which notification
No notification channel exists in this codebase today. This step is purely the delivery mechanism
and trigger wiring; the underlying conditions it watches for are already defined elsewhere
(reorder point in [02_stock_ledger.md](./02_stock_ledger.md), credit limit in
[12_store_management.md](./12_store_management.md), route deviation logic in
[09_fraud_controls.md](./09_fraud_controls.md)).
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.
---
## Data Model Changes
## Flow
### Notification
`RecipientUserId`, `Type`, `Payload` (JSON), `Channel` (`Email`, `SMS`, `Push`, `InApp`), `SentAt`,
`ReadAt` (nullable).
```
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
```
---
## API Endpoints
## Decisions
| Function reference | Endpoint intent |
| Question | Decision |
|---|---|
| `sendLowStockAlert(warehouseId, sku)` | Internal — triggered when a stock movement crosses the reorder point set in [02_stock_ledger.md](./02_stock_ledger.md) |
| `sendCreditLimitBreachAlert(storeId)` | Internal — triggered on order/invoice creation when `Store.OutstandingBalance` exceeds `Store.CreditLimit` |
| `sendApprovalPendingNotification(approverId, requestId)` | Internal — triggered by `submitOrderForApproval` ([08_sales_agent_controls.md](./08_sales_agent_controls.md)) and return approval ([18_returns_claims.md](./18_returns_claims.md)) |
| `sendRouteDeviationAlert(routeId, salesRepId)` | Internal — triggered from [09_fraud_controls.md](./09_fraud_controls.md)'s anomaly flag. The source data now exists (`RouteVisitLog.IsDeviationFlagged`, see [11_route_sales_management.md](./11_route_sales_management.md)), but no anomaly flag or notification reads it yet. |
| — | `GET /api/notifications` — recipient's own notification inbox |
| — | `PUT /api/notifications/{id}/read` |
| 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) |
---
## Business Rules
## Data Model
- Every `send*` function in §15 is a trigger, not a user-facing endpoint — the only client-facing
endpoints are inbox read/list.
- Notification delivery failures (e.g. SMS provider down) must not roll back the business
transaction that triggered them — fire-and-forget, logged separately.
**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 | 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<string, NotificationTypeDefinition> All = new Dictionary<string, NotificationTypeDefinition>
{
[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<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:**
```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<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`):
```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
- Which channels are actually needed at launch — in-app only, or SMS/email/push from day one? This
significantly changes the infrastructure needed (third-party provider integration, see
[22_integration_api.md](./22_integration_api.md)).
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.
---
## Implementation Notes
## Build order
> Fill this section in during planning discussion before writing any code.
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.