Introduced NotificationService for managing notification logic and recipient resolution.
- Integrated SignalR for real-time notification delivery to users.
This commit is contained in:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user