- Integrated SignalR for real-time notification delivery to users.
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
- You are never notified of your own action.
- 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).
- 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)
- As the rep, create a shop order (Sales → New).
- As SuperAdmin, confirm it, then create a second one and cancel it with a reason.
- 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. - Reverse it: SuperAdmin creates an order and the rep confirms or cancels it — SuperAdmin is notified.
- Credit limit: there is no screen for it, so use Swagger (
/swagger):PUT /api/stores/{id}/outstanding-balancewith{ "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,EntityIdandLinkare required; clicking marks read. - Retention:
NotificationCleanupJobdeletes 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, neverSaveChangesAsync(),DateTime.Nowonly.
Open Questions
Resolved — see Decisions. Remaining for later:
- Add a cheque / finance permission to the seed, then enable the two cheque triggers.
- Email / SMS / push: see 22_integration_api.md.
- If the API ever runs on more than one instance, add a SignalR Redis backplane.
- Per-type mute (
NotificationModuleConfig+ admin screen) when requested.
Build order
-
Entities (
Notification,NotificationRecipient) + EF migration. -
Features/Notifications/(repository, resolver, service, controller, DTOs,NotificationTypes) + retention job. -
SignalR hub,
Program.csregistration, user-id provider. -
Frontend: API client, provider, bell, toast, click-through, view-all page.
-
Phase 1 triggers — six wired (recipient resolution is unit-tested; the per-service calls are not, because
Distribution.Testsdoes not currently compile).StockLowdeferred. -
Later:
StockLow, cheque triggers, Phase 1b scheduled alerts, Phase 2 triggers.
Remaining before this works end to end: apply the AddNotifications migration.