Notification API Reference
Complete API contracts for notification, including routes, auth, DTOs, responses, errors, examples, and integration notes.
Notification - API Reference
Audience: Frontend engineers, mobile engineers, backend engineers, QA, and API consumers. Scope: Admin, mobile, and internal-facing APIs owned by the notification module, plus the admin operational feed that shares its noun.
Twenty-one routes across two delivery models. Ten admin routes manage templates, delivery history, dead letters and event cancellation. Eight mobile routes serve the consumer notification centre, push device registration and the preference matrix. Three admin routes serve the operational feed, which is a different feature — it fans out on read by permission, not on write to named people.
There is no public route in this module. Everything requires a session.
1. Documentation Evidence
| Area | Files Inspected | What Was Verified |
|---|---|---|
| Controllers | apps/api/src/modules/notification/admin/{template,history,dead-letter,event}/*.controller.ts, customer/{notification-centre,notification-devices,notification-preferences}/*.controller.ts, apps/api/src/modules/notification-feed/notification-feed.controller.ts | Routes, methods, guards, decorators, permissions, status codes. |
| Route prefixes | apps/api/src/main.ts, apps/api/src/modules/mobile/mobile.module.ts | setGlobalPrefix("api"); the three consumer leaves mounted under mobile. |
| Route ground truth | apps/api/test/structure/structure.baseline.json | Confirmed the exact 21 paths documented here. |
| DTOs | admin/template/notification-template.dto.ts, admin/history/notification-history.dto.ts, admin/dead-letter/notification-failure.dto.ts, admin/event/notification-event.dto.ts, customer/*/dto/*.ts, notification-feed/dto/notification.dto.ts, apps/api/src/common/dto/query.dto.ts, response-dto.ts | Request, query, response, validation decorators, defaults, nullability. |
| Services | Every *.service.ts behind those controllers | Behaviour, side effects, response mapping, exact error conditions. |
| Vocabulary | packages/db/src/notification/notification-contract.ts | Every enum value a response or request can carry. |
| Schema | packages/db/src/schema/notification/*.ts, packages/db/src/schema/notifications.ts, packages/db/src/schema/jobs/job-failures.ts | Persisted field types, generated ids, constraints. |
| Errors | apps/api/src/common/types/error-codes.ts | Every NOTIFICATION_* and JOB_FAILURE_* code. |
| Auth | apps/api/src/common/authorization/role.guard.ts, permissions.decorator.ts, packages/db/src/authorization/permission-catalog.ts | Guard chain, the no-permission allowlist, the three catalogue modules. |
| Validation | apps/api/src/main.ts | ValidationPipe with whitelist, forbidNonWhitelisted, transform. |
| Pagination | apps/api/src/common/utils/pagination.util.ts | Defaults, caps, metadata shape. |
| Jobs and Redis | packages/jobs/src/index.ts, apps/api/src/services/bullmq/bull.module.ts | Queue names, job names, payloads, per-queue attempts. |
| Swagger | apps/api/src/config/swagger/swagger-documents.ts | Which document each controller appears in. |
2. Module Summary
| Field | Value |
|---|---|
| Module name | notification (plus notification-feed, documented here because it shares the noun) |
| Module slug | notification |
| Primary actors | admin, superadmin, teacher, guardian, student — every signed-in person. No guest actor. |
| API surfaces | admin, mobile |
| Base route prefixes | /api/notification-templates, /api/notification-history, /api/notification-failures, /api/notification-events, /api/notifications, /api/mobile/notifications, /api/mobile/notification-devices, /api/mobile/notification-preferences |
| Auth model | JwtAuthGuard then RoleGuard on every route. Admin routes additionally carry @Permissions(...); the eleven consumer and feed handlers carry none and are on RoleGuard's NO_PERMISSION_ADMIN_HANDLERS allowlist, where the service predicate is the only control. |
| Persistence | PostgreSQL (notification_event, notification_recipient, notification_delivery, notification_template, notification_push_token, three preference tables, notification, notification_read, job_failures); Redis (secret vault, per-user pub/sub); BullMQ (notification_fanout, notification_email, notification_sms, notification_push, notification_operational) |
| Runtime source of truth | notification_delivery.status for what happened to a message; notification_recipient.read_at for consumer unread; absence of a notification_read row for admin-feed unread |
| Response envelope | ResponseDto — message, data, optional count / currentPage / totalPage, errorCode (null on success). There is no success boolean. |
| Sibling docs | Backend, Features and flows |
Global request handling that applies to every route below:
ValidationPiperuns withwhitelist: true,forbidNonWhitelisted: trueandtransform: true. An unknown body or query property is rejected with400, not silently stripped.- The global prefix is
api. URI versioning is enabled platform-wide, but no controller in this module declares a version, so every path is unversioned. ClassSerializerInterceptoris applied globally.
3. Concepts and Terminology
| Term | Meaning | Source File | Used By |
|---|---|---|---|
| Event | One business fact somebody should be told about. Written by NotificationService.send() in the caller's transaction. | packages/db/src/schema/notification/notification-event.ts | /api/notification-history, /api/notification-events |
| Recipient | One (event, person) pair. What a consumer lists and marks read. | notification-recipient.ts | /api/mobile/notifications |
| Delivery | One attempt for one (recipient, channel, provider target). Carries the state machine. | notification-delivery.ts | /api/notification-history/{publicId}, /api/notification-failures |
| Kind | A registry key such as auth.password_reset or school.announcement. Not an FK — the registry is code. | templates/template-registry.ts | Template CRUD, history filters |
| Category | What the notification is about. Nine values. The unit a person expresses a preference for. | notification-contract.ts | Preferences, history filters |
| Priority | low / normal / high / critical. Orders work within a channel queue. | notification-contract.ts | Centre and history responses |
| Channel | email, sms, push or in_app. SSE is a transport, not a channel. | notification-contract.ts | Everywhere |
| Audience specification | Who an event is for, stored as a spec and resolved per batch — never a materialised list. | shared/notification.types.ts | History detail response |
| Template override | An operator-authored row overriding the code registry for one (kind, channel, locale). Never a replacement. | notification-template.ts | /api/notification-templates |
| Fan-out | Resolving an audience into recipient and delivery rows. Claimed before the work, completed after. | workers/notification-fanout.processor.ts | History response fields |
| Dead letter | A job_failures row written on a channel job's final attempt. Carries a payload_ref, never the payload. | packages/db/src/schema/jobs/job-failures.ts | /api/notification-failures |
| Replay | Inserting a fresh delivery pointing at the original and scheduling it. The original is left untouched. | admin/dead-letter/notification-failure.service.ts | POST .../replay |
| Skip | A terminal, non-failure outcome: skipped_unconfigured, skipped_preference, skipped_no_template, skipped_no_destination. Excluded from every failure count. | notification-contract.ts | History failedDeliveryCount |
| Lease | claimed_at plus lease_expires_at on a delivery, reclaimed by the reaper. | shared/delivery-recorder.service.ts | History delivery fields |
| Secret reference | { secretRef: "<prefix>:<uuid>" } in an event's variables, resolved from Redis at send time. Never returned by any route. | shared/secret-reference.service.ts | — |
| Destination hint | A masked address — 9779●●●●●123, j●●●@example.com. The only address form any response carries. | channels/email.provider.ts, packages/sms | History delivery response |
| Active role | The single role a session is currently acting as. Both feeds filter on it, never on the union of roles held. | apps/api/src/modules/auth/auth.service.ts | Centre and feed |
| Operational feed | notification + notification_read: one row per event carrying the permission a viewer must hold, filtered at read time. | packages/db/src/schema/notifications.ts | /api/notifications |
| Preference set version | A compare-and-set token over a person's whole preference matrix. 0 means "no row yet". | notification-preference.ts | /api/mobile/notification-preferences |
4. API Surface Map
| Surface | Method | Path | Actor | Auth/Guard | Permission | Controller | Purpose |
|---|---|---|---|---|---|---|---|
| Admin | GET | /api/notification-templates | Admin | JwtAuthGuard, RoleGuard | NotificationTemplate_READ | NotificationTemplateController.findAll | List operator template overrides, newest first, optionally filtered by kind or channel. |
| Admin | GET | /api/notification-templates/{publicId} | Admin | Same | NotificationTemplate_READ | .findOne | Read one override, including its version. |
| Admin | POST | /api/notification-templates | Admin | Same | NotificationTemplate_CREATE | .create | Create an override for one (kind, channel, locale). |
| Admin | PATCH | /api/notification-templates/{publicId} | Admin | Same | NotificationTemplate_UPDATE | .update | Edit an override under optimistic concurrency. |
| Admin | DELETE | /api/notification-templates/{publicId} | Admin | Same | NotificationTemplate_DELETE | .remove | Delete an override under optimistic concurrency. Returns 200, and takes a body. |
| Admin | GET | /api/notification-history | Admin | Same | NotificationHistory_READ | NotificationHistoryController.findAll | List events fanned out to real people, newest first by occurredAt. |
| Admin | GET | /api/notification-history/{publicId} | Admin | Same | NotificationHistory_READ | .findOne | One event with every recipient and every delivery. |
| Admin | DELETE | /api/notification-events/{publicId} | Admin | Same | NotificationHistory_UPDATE | NotificationEventController.cancel | Cancel a scheduled event before it fans out. Returns 200. |
| Admin | GET | /api/notification-failures | Admin | Same | NotificationFailure_READ | NotificationFailureController.findAll | List failed notification channel jobs. Hard-scoped to three queues. |
| Admin | POST | /api/notification-failures/{publicId}/replay | Admin | Same | NotificationFailure_UPDATE | .replay | Insert a fresh delivery pointing at the original and schedule it. Returns 201. |
| Admin feed | GET | /api/notifications | Any signed-in admin actor | JwtAuthGuard, RoleGuard | None — allowlisted | NotificationFeedController.list | The operational feed for the active role, newest first, with an unread count. |
| Admin feed | POST | /api/notifications/{publicId}/read | Same | Same | None — allowlisted | .markRead | Mark one feed row read for this caller. Returns 201. |
| Admin feed | POST | /api/notifications/read-all | Same | Same | None — allowlisted | .markAllRead | Mark everything currently visible read. Returns 201. |
| Mobile | GET | /api/mobile/notifications | Any signed-in person | Same | None — allowlisted | NotificationCentreController.list | The consumer notification centre, paginated. |
| Mobile | GET | /api/mobile/notifications/unread-count | Same | Same | None — allowlisted | .unreadCount | The badge number. |
| Mobile | POST | /api/mobile/notifications/read-all | Same | Same | None — allowlisted | .readAll | Mark every visible notification read. Returns 201. |
| Mobile | POST | /api/mobile/notifications/{publicId}/read | Same | Same | None — allowlisted | .markRead | Mark one read and return it. Returns 201. |
| Mobile | POST | /api/mobile/notification-devices | Same | Same | None — allowlisted | NotificationDevicesController.register | Register a push token for this session. Returns 201. |
| Mobile | DELETE | /api/mobile/notification-devices/{publicId} | Same | Same | None — allowlisted | .remove | Invalidate one of the caller's own devices. Returns 200. |
| Mobile | GET | /api/mobile/notification-preferences | Same | Same | None — allowlisted | NotificationPreferencesController.get | The resolved preference matrix plus its version. |
| Mobile | PUT | /api/mobile/notification-preferences | Same | Same | None — allowlisted | .update | Save per-category overrides under a compare-and-set. Returns 200. |
Runtime path versus controller path. Every admin controller declares its own path with no prefix, so the runtime URL is the global api prefix plus the controller path. The three consumer leaves declare notifications, notification-devices and notification-preferences, and MOBILE_CHILDREN mounts each of them under mobile — so @Controller("notifications") in NotificationCentreController becomes /api/mobile/notifications, while @Controller("notifications") in NotificationFeedController becomes /api/notifications. Two different controllers declare the same local path and resolve to different runtime routes, and confusing them is the single most likely integration mistake in this module: one is a per-person centre, the other a permission-filtered operational feed.
Registration is by concrete leaf, never by aggregate. RouterModule.register() does not recurse, so listing an aggregate would mount its controllers at /api/<thing>/... instead — silently, with the routes existing at the wrong prefix and nothing reporting it.
Swagger membership. NotificationTemplateModule, NotificationHistoryModule, NotificationFailureModule, NotificationEventModule and NotificationFeedModule are named individually in ADMIN_MODULES, never through the admin aggregate, because Swagger's include does not recurse. The three consumer leaves are reflected from MOBILE_CHILDREN. Swagger tags in use: Notification Templates, Notification History, Notification Failures, Notification Events, Notifications, Shared — Notifications, Shared — Devices, Shared — Notification Preferences.
Status codes not stated by Swagger. POST handlers without an explicit @HttpCode return 201 at runtime, which is Nest's default. That applies to POST /api/notification-templates, both feed POSTs, both centre POSTs and POST /api/notification-failures/{publicId}/replay. Both DELETE handlers in this module carry an explicit @HttpCode(HttpStatus.OK) and return 200 with a body. PUT /api/mobile/notification-preferences returns 200.
5. Auth, Identity, and Permissions
| Surface | Guard/Decorator | Identity Shape | Permission | Guest Allowed | Notes |
|---|---|---|---|---|---|
| Admin — templates | JwtAuthGuard, RoleGuard, @Permissions("NotificationTemplate_*") | req.user via @CurrentUser(); actor.id is written to updated_by | NotificationTemplate_READ / _CREATE / _UPDATE / _DELETE | No | The activity interceptor reads the same @Permissions() value to derive the audited module. |
| Admin — history | Same, @Permissions("NotificationHistory_READ") | Not read by the service | NotificationHistory_READ | No | Read-only. The response deliberately carries no recipient identity. |
| Admin — event cancel | Same, @Permissions("NotificationHistory_UPDATE") | Not read by the service | NotificationHistory_UPDATE | No | Gated on the history module rather than one of its own: it is the single write this surface makes, on the same rows the history screen reads. |
| Admin — failures | Same, @Permissions("NotificationFailure_*") | @CurrentUser(); actor.id is written to replayed_by | NotificationFailure_READ / _UPDATE | No | Its own module because replay is a write capability wearing a diagnostic name. |
| Admin feed | JwtAuthGuard, RoleGuard, no @Permissions() | @CurrentUser(); actor.id and actor.activeRole | Per row, from notification.permission | No | On NO_PERMISSION_ADMIN_HANDLERS. |
| Mobile — centre, devices, preferences | JwtAuthGuard, RoleGuard, no @Permissions() | @CurrentAdmin(); actor.id and actor.activeRole | None | No | On NO_PERMISSION_ADMIN_HANDLERS. |
Why several handlers declare no permission. RoleGuard normally refuses an admin route that declares none, precisely so a route cannot ship open by accident. Eleven handlers here are on its explicit allowlist, for two different reasons:
- The consumer surfaces are used by guardians and students, who hold no admin-catalogue permission at all. A permissioned handler would refuse them before any scoping ran.
- The operational feed has no single permission that means "notifications". Each row carries the permission of the screen its event belongs to, and the service returns only rows the caller's active role could already have read by opening that screen. A blanket permission would gate the whole feed behind the widest of them, or show every operator everything.
For all eleven, RoleGuard returns true before it ever reads request.user, so the service predicate is the only access control. Those predicates are documented per endpoint in section 8 and in full in the backend doc.
Active role, not the union of roles held. Both feeds resolve visibility from actor.activeRole. A teacher who is also a parent sees staff-audience notifications only while acting as staff. A session with no active role sees only rows that are not role-scoped (consumer centre) or nothing at all (operational feed), because an empty permission list becomes the SQL literal false.
No idempotency keys. No route in this module accepts one. Idempotency where it matters is structural: marking read twice is a no-op, registering the same token twice is a touch, and replay is guarded by a compare-and-set on job_failures.replayed_at.
No route-level rate limiting is declared in this module. Abuse is bounded by the caps listed in section 11.
6. DTO and Model Reference
6.1 ResponseDto — the envelope
Every route in this module returns it.
| Field | Type | Present | Notes |
|---|---|---|---|
message | string | Always | Human-readable, e.g. "Notifications fetched." |
data | T | On every route except where noted | null on POST /api/notifications/{publicId}/read and DELETE template. |
count | number | Paginated lists only | Total matching rows, not the page length. |
currentPage | number | Paginated lists only | Echoes the requested page. |
totalPage | number | Paginated lists only | Math.ceil(count / size). |
nextCursor | string | null | Never in this module | No route here is cursor-paginated. |
errorCode | string | null | Always | null on success. On an error the exception body carries message and errorCode. |
There is no success field. Consumers must branch on the HTTP status and on errorCode.
6.2 QueryDto — the shared list query
Inherited by ListNotificationTemplatesQueryDto, ListNotificationHistoryQueryDto, ListNotificationFailuresQueryDto and the centre's ListNotificationsQueryDto.
| Field | Type | Required | Default | Validation | Example | Source |
|---|---|---|---|---|---|---|
pagination | boolean | No | true | Coerced from a query string | true | common/dto/query.dto.ts |
page | number | No | 1 | Positive integer; a non-finite or non-positive value falls back to the default | 2 | Same |
size | number | No | 20 | Positive integer, clamped to MAX_PAGE_SIZE (100) | 50 | Same |
sort | string | No | "updatedAt" | — | — | Same |
order | "asc" | "desc" | No | "desc" | — | — | Same |
search | string | No | — | — | — | Same |
sort, order and search are inherited but unused by every list in this module. Each list has a fixed order chosen so offset pagination is stable — an unstable sort silently drops and duplicates rows across pages. They are accepted rather than rejected because forbidNonWhitelisted would otherwise 400 a client that sends the platform's standard query shape.
Every list in this module refuses pagination=false with 400 PAGINATION_LIMIT_INVALID. These tables only grow, and there is no ceiling small enough to make an unbounded read safe to buffer at once.
6.3 NotificationTemplateResponseDto
| Field | Type | Nullable | Notes |
|---|---|---|---|
publicId | string (uuid) | No | — |
kind | string | No | The registry key this overrides. |
channel | string | No | One of the four channels. |
locale | string | No | Defaults to en at create. |
subject | string | Yes | Email only. Always null for sms, push and in_app. |
body | string | No | — |
isActive | boolean | No | An inactive override is not loaded by the renderer. |
version | number | No | Send it back unchanged on PATCH and DELETE. |
updatedBy | string (uuid) | Yes | null once that user is deleted. |
createdAt | Date | No | ISO 8601 on the wire. |
updatedAt | Date | No | Same. |
6.4 ListNotificationTemplatesQueryDto
Extends QueryDto.
| Field | Type | Required | Default | Validation | Example |
|---|---|---|---|---|---|
kind | string | No | — | @IsOptional, @IsString | "school.announcement" |
channel | string | No | — | @IsIn(NOTIFICATION_CHANNEL) | "email" |
6.5 CreateNotificationTemplateDto
| Field | Type | Required | Default | Validation | Example |
|---|---|---|---|---|---|
kind | string | Yes | — | @IsString, @MinLength(1); additionally checked against the code registry in the service | "school.announcement" |
channel | string | Yes | — | @IsIn(NOTIFICATION_CHANNEL) | "email" |
locale | string | No | "en" | @IsString | "ne" |
subject | string | No | null | @IsString | "A message from school" |
body | string | Yes | — | @IsString, @MinLength(1); the database additionally requires length(btrim(body)) > 0 | "Hello {{name}}, {{message}}" |
isActive | boolean | No | true | @IsBoolean | true |
subject is accepted for every channel by the DTO but is only meaningful for email; the renderer reads it as the title for email and ignores it elsewhere. updatedBy is server-set from the session and is not accepted in the body.
6.6 UpdateNotificationTemplateDto
| Field | Type | Required | Default | Validation | Example |
|---|---|---|---|---|---|
version | number | Yes | — | @Type(() => Number), @IsInt, @Min(1) | 3 |
kind | string | No | Unchanged | @IsString, @MinLength(1), registry-checked | "school.announcement" |
channel | string | No | Unchanged | @IsIn(NOTIFICATION_CHANNEL) | "sms" |
locale | string | No | Unchanged | @IsString | "ne" |
subject | string | null | No | Unchanged | @IsString | null |
body | string | No | Unchanged | @IsString, @MinLength(1) | "Updated copy" |
isActive | boolean | No | Unchanged | @IsBoolean | false |
An omitted field keeps its current value; subject distinguishes omitted from explicitly null, and only an explicit null clears it. version is always incremented server-side.
6.7 DeleteNotificationTemplateDto
| Field | Type | Required | Default | Validation | Example |
|---|---|---|---|---|---|
version | number | Yes | — | @Type(() => Number), @IsInt, @Min(1) | 3 |
A DELETE with a body is unusual and deliberate: without the version a delete could silently win over a concurrent edit.
6.8 ListNotificationHistoryQueryDto
Extends QueryDto.
| Field | Type | Required | Default | Validation | Example |
|---|---|---|---|---|---|
kind | string | No | — | @IsString | "auth.password_reset" |
category | string | No | — | @IsIn(NOTIFICATION_CATEGORY) | "announcement" |
from | string | No | — | @IsDateString; filters occurredAt >= | "2026-09-01T00:00:00.000Z" |
to | string | No | — | @IsDateString; filters occurredAt <= | "2026-09-30T23:59:59.999Z" |
6.9 NotificationHistoryListItemDto
| Field | Type | Nullable | Notes |
|---|---|---|---|
publicId | string (uuid) | No | — |
sourceModule | string | No | Which module raised it. |
kind | string | No | — |
category | string | No | One of the nine. |
priority | string | No | One of the four. |
scheduledFor | Date | Yes | null means immediate. |
occurredAt | Date | No | When the thing happened, not when the row was written. |
cancelledAt | Date | Yes | Set by the cancel route. |
fannedOutAt | Date | Yes | null while pending or in progress. |
recipientCount | number | Yes | null until fan-out completes. Moves with fannedOutAt in one statement. |
unresolvedCount | number | Yes | Audience ids that matched nobody. |
failedDeliveryCount | number | No | Deliveries currently failed or dead. Excludes every skipped_* status — an unconfigured channel is not a failure. |
createdAt | Date | No | — |
Deliberately absent, permanently: variables, any rendered content, and any recipient name, email or phone. NotificationHistory_READ is not superadmin-only, and returning any of those would let one grant re-aggregate what StaffSalary and StudentMedical were split apart to keep separate. The service does not read those columns into memory at all, so there is nothing to accidentally spread.
6.10 NotificationHistoryDetailDto
Extends NotificationHistoryListItemDto and adds:
| Field | Type | Nullable | Notes |
|---|---|---|---|
actionUrl | string | Yes | https: or a single-slash relative path. Re-validated at render. |
actionLabel | string | Yes | Paired with actionUrl by a CHECK. |
requestedChannels | string[] | No | What the caller asked for, before preferences. |
audience | object | No | The audience specification that was resolved — who to notify, not names. |
recipients | NotificationHistoryRecipientDto[] | No | Ordered by internal id. |
6.11 NotificationHistoryRecipientDto
| Field | Type | Nullable | Notes |
|---|---|---|---|
publicId | string (uuid) | No | The recipient row's own public id. Never the person's user id, name, email or phone. |
audienceRoleId | number | Yes | The role this person was resolved through. null means not role-scoped. |
readAt | Date | Yes | null means unread. |
createdAt | Date | No | — |
deliveries | NotificationHistoryDeliveryDto[] | No | Ordered by internal id. |
6.12 NotificationHistoryDeliveryDto
| Field | Type | Nullable | Notes |
|---|---|---|---|
publicId | string (uuid) | No | — |
channel | string | No | — |
providerTargetId | string | Yes | The push token's public id. null for every single-target channel. |
status | string | No | One of the eleven delivery statuses. |
provider | string | Yes | null until a provider has been chosen. |
providerMessageId | string | Yes | — |
destinationHint | string | Yes | Masked at write time. Never the full address. |
attempts | number | No | Incremented by a failure and by a lease reclaim. |
failureCount | number | No | Provider failures only. |
leaseExpiryCount | number | No | Lease reclaims only. Non-zero means workers are dying, not that the provider is refusing. |
lastError | string | Yes | A code from a fixed table, truncated to 500. Never a raw provider response. |
replayOfDeliveryId | number | Yes | The internal id of the row this replays. |
queuedAt | Date | No | — |
sentAt | Date | Yes | Legitimately null on a delivered in-app row. |
deliveredAt | Date | Yes | — |
failedAt | Date | Yes | Cleared when a backoff elapses. |
skippedAt | Date | Yes | Set for exactly the four skipped_* statuses. |
nextAttemptAt | Date | Yes | When a failed row becomes eligible again. |
createdAt | Date | No | — |
renderedTitle and renderedBody exist on the table and are never exposed here.
6.13 CancelledNotificationEventDto
| Field | Type | Nullable | Notes |
|---|---|---|---|
publicId | string (uuid) | No | — |
cancelledAt | Date | No | Always present on a successful response. |
6.14 ListNotificationFailuresQueryDto
Extends QueryDto.
| Field | Type | Required | Default | Validation | Example |
|---|---|---|---|---|---|
channel | "email" | "sms" | "push" | No | — | @IsIn(NOTIFICATION_FAILURE_CHANNELS) | "sms" |
unreplayedOnly | boolean | No | — | @QueryBoolean(), @IsBoolean | true |
in_app is not a permitted value: it has no queue and therefore no dead letters. channel narrows within the three notification channel queues and can never widen past them, because the queue allowlist is ANDed unconditionally.
6.15 NotificationFailureDto
| Field | Type | Nullable | Notes |
|---|---|---|---|
publicId | string (uuid) | No | What POST .../replay addresses. |
queueName | string | No | Always one of the three notification channel queues on this surface. |
jobName | string | No | notification_channel.send_email / _sms / _push. |
jobId | string | Yes | BullMQ's own id. |
payloadRef | object | Yes | How to find the delivery this job was sending — never the rendered message, which may carry a live single-use token. Shape: { notificationDeliveryPublicId, channel }. |
actorId | string (uuid) | Yes | Always null for these rows; the recorder writes null. |
attempts | number | No | At least 1. |
lastError | string | Yes | The thrown message, truncated to 4000. |
replayedAt | Date | Yes | — |
replayedBy | string (uuid) | Yes | — |
replayJobId | string | Yes | For a notification replay this holds the new delivery's public id, not a BullMQ job id. |
failedAt | Date | No | — |
6.16 ReplayNotificationFailureResponseDto
Extends NotificationFailureDto and adds:
| Field | Type | Nullable | Notes |
|---|---|---|---|
newDeliveryPublicId | string (uuid) | No | The fresh notification_delivery row the replay created. The original row is left untouched — its failedAt, lastError and providerMessageId are the evidence the operator opened the screen to read. |
6.17 NotificationCentreItemDto — the consumer centre row
| Field | Type | Nullable | Notes |
|---|---|---|---|
publicId | string (uuid) | No | The recipient row's public id, which POST .../{publicId}/read addresses. |
kind | string | No | — |
category | NotificationCategory | No | One of the nine. |
priority | NotificationPriority | No | One of the four. |
title | string | Yes | From the in-app delivery's rendered_title, frozen at fan-out. |
body | string | Yes | From rendered_body. |
actionUrl | string | Yes | — |
actionLabel | string | Yes | — |
occurredAt | Date | No | — |
readAt | Date | Yes | null means unread. |
title and body are typed nullable because the columns are, but a row this endpoint returns always carries both: the template registry refuses at boot any kind declaring an in_app channel without persistRendered, and a kind whose template fails to render is recorded skipped_no_template and is excluded by the query. Consumers should still render defensively rather than assert.
Every field is listed explicitly in the DTO — no spread — because a spread would leak whatever the underlying SELECT happens to carry. The integration spec asserts Object.keys(response) against exactly this set.
6.18 UnreadNotificationCountDto and MarkAllNotificationsReadResultDto
| DTO | Field | Type | Notes |
|---|---|---|---|
UnreadNotificationCountDto | count | number | Unread across everything visible, not just a page. |
MarkAllNotificationsReadResultDto | marked | number | Rows whose read_at moved from null. Already-read rows are not counted. |
6.19 RegisterNotificationDeviceDto and NotificationDeviceDto
| Field | Type | Required | Default | Validation | Example |
|---|---|---|---|---|---|
token | string | Yes | — | @IsString, @MinLength(8), @MaxLength(4096) | "fcm-token-..." |
platform | "android" | "ios" | "web" | Yes | — | @IsIn(PUSH_PLATFORM) | "web" |
The request DTO deliberately carries no userId and no deviceId. The row is bound to actor.id from the verified session; a body field here would let anyone bind a token to somebody else's account, and RoleGuard runs no permission check on this handler.
| Response field | Type | Notes |
|---|---|---|
publicId | string (uuid) | The only durable identifier the client needs, used to DELETE the device later. |
platform | "android" | "ios" | "web" | Reflects the value just registered. |
createdAt | Date | On a same-user re-registration this is the original row's createdAt, because that path is a touch rather than a new row. |
The token is never echoed back.
6.20 NotificationPreferencesDto and its children
| Field | Type | Notes |
|---|---|---|
version | number | 0 when the person has never saved. Send it back on PUT. |
categories | NotificationPreferenceCategoryDto[] | Always all nine, in NOTIFICATION_CATEGORY order. |
NotificationPreferenceCategoryDto:
| Field | Type | Notes |
|---|---|---|
category | NotificationCategory | — |
locked | boolean | true when every registered kind in this category is unsuppressible. The UI must not offer a switch that can never do anything. |
channels | Record<NotificationChannel, boolean> | All four keys, always present. For a locked category every value is true. |
locked is computed from the template registry, not from UNSUPPRESSIBLE_CATEGORY: that constant names categories a kind is allowed to lock, not categories that are fully locked today. system permits an unsuppressible kind but currently holds none, so treating it as locked would show a switch that works for a category with nothing to switch.
6.21 UpdateNotificationPreferencesDto
| Field | Type | Required | Validation | Notes |
|---|---|---|---|---|
version | number | Yes | @IsInt, @Min(0) | The value last read from GET. 0 means "I read no row". |
overrides | NotificationPreferenceOverrideDto[] | Yes | @ValidateNested({ each: true }), @ArrayMaxSize(36) | Categories times channels is the hard bound. |
NotificationPreferenceOverrideDto:
| Field | Type | Required | Validation |
|---|---|---|---|
category | NotificationCategory | Yes | @IsIn(NOTIFICATION_CATEGORY) |
channel | NotificationChannel | Yes | @IsIn(NOTIFICATION_CHANNEL) |
enabled | boolean | Yes | @IsBoolean |
Accepted but not persisted: an override naming a locked category. isEnabled always returns true for an unsuppressible kind regardless of any stored row, so persisting one would be a switch with no effect on delivery. Duplicates on (category, channel) are de-duplicated last-write-wins.
There is no field for the global per-channel switch. notification_channel_preference exists and is honoured by the resolver as level 3, but no route in this module writes it — the PUT body carries per-category overrides only. The GET response does reflect any stored global value as the fallback beneath a per-category override.
6.22 NotificationDto and NotificationListDto — the operational feed
| Field | Type | Notes |
|---|---|---|
publicId | string (uuid) | The notification row. |
kind | string | e.g. feedback.submitted. |
summary | string | Already-safe summary text. Never a raw payload. |
soundClass | string | How the panel should announce it, e.g. soft. |
aggregateId | string | Public id of the thing that changed. |
occurredAt | string | ISO 8601. When it happened, not when it was recorded. |
read | boolean | Read by this caller, not by anyone. |
NotificationListDto wraps data: NotificationDto[] plus unreadCount: number, counted across everything visible to the caller rather than just the page — a badge that counted the page would cap at its size.
6.23 ListNotificationsQueryDto — the feed's own query
This is a different class from the centre's, despite the shared name.
| Field | Type | Required | Default | Validation |
|---|---|---|---|---|
limit | number | No | 50 | @Type(() => Number), @IsInt, @Min(1), @Max(200) |
The feed does not use QueryDto: it is a bounded newest-first window, not an offset-paginated list, so it returns no count, currentPage or totalPage. The service additionally clamps limit between 1 and 200.
7. Enum Reference
| Enum | Value | Meaning | Runtime Effect | Source |
|---|---|---|---|---|
NotificationChannel | email | Sent by Resend via EmailChannelProvider. | A queued delivery on notification_email. | notification-contract.ts |
sms | Sent by the configured Nepali gateway. | A queued delivery on notification_sms. Metered. | Same | |
push | Sent by FCM, one delivery per active token. | N queued deliveries on notification_push. | Same | |
in_app | Written inline by the fan-out worker. No send step, no queue, no provider. | A delivered delivery with sent_at null and rendered content. | Same | |
NotificationCategory | security | Account safety. | May hold unsuppressible kinds. Defaults on for all four channels. | Same |
system | Operational notices. | May hold unsuppressible kinds. SMS off by default. | Same | |
assignment | Coursework. | Push and in-app on by default. | Same | |
announcement | School-wide notices. | Email, push and in-app on by default. | Same | |
message | Person-to-person. | Push and in-app on by default. | Same | |
payment | Fees and billing. | Email, push and in-app on by default. | Same | |
attendance | Daily attendance. | Push and in-app on by default. | Same | |
event | Calendar items. | Push and in-app on by default. | Same | |
marketing | Promotional. | Off on every channel by default. Opt-in, not opt-out. | Same | |
NotificationPriority | low | BullMQ priority 9. | Least urgent within its queue. | Same |
normal | BullMQ priority 5. | The default on the event row. | Same | |
high | BullMQ priority 3. | — | Same | |
critical | BullMQ priority 1. | Most urgent within its queue. Does not preempt a running job. | Same | |
DeliveryStatus | queued | Written by the fan-out worker. The only entry state. | Claimable by a channel worker. | Same |
processing | Claimed under a lease. | Reclaimed by the reaper past lease_expires_at. | Same | |
sent | The provider accepted it. Not proof it arrived. | Terminal today; would advance to delivered on a receipt. | Same | |
delivered | Confirmed arrival. | Reachable synchronously only for in_app. | Same | |
failed | Retryable failure. | Returned to queued when its backoff elapses. | Same | |
dead | Attempts exhausted, or a failure the provider says will never succeed. | Terminal. Counted by failedDeliveryCount. | Same | |
skipped_unconfigured | No provider credentials. | Consumes no attempt, raises no dead letter, excluded from every failure metric. Re-queued by the backfill worker once credentials arrive. | Same | |
skipped_preference | The recipient's preferences suppressed this channel. | Terminal. Excluded from the notification centre. | Same | |
skipped_no_template | No builder for this (kind, channel), or an unresolved variable. | Terminal. | Same | |
skipped_no_destination | No address, no active token, an expired secret, or the recipient is gone. | Terminal. | Same | |
cancelled | The event was cancelled before this delivery was claimed. | Terminal. Excluded from the notification centre. | Same | |
AudienceKind | users | An explicit list of user public ids, capped at 500. | Resolved directly. | Same |
role | Everyone holding the named roles. | Yields that role as audienceRoleId. | Same | |
class | Pupils with an active enrolment in the named classes. | Yields their student-scoped role. | Same | |
section | The same, through sections. | Same. | Same | |
grade | The same, through grades. | Same. | Same | |
guardians_of_class | The guardians of those pupils, via student_guardian. | Yields their guardian-scoped role. | Same | |
guardians_of_users | The guardians of the named students. | Same. | Same | |
staff_department | Staff in the named departments. | audienceRoleId is null. | Same | |
all_users | Every live, loginable user. | audienceRoleId is null. | Same | |
compound | A union of 1–10 of the above. Depth-1 by type. | Unioned in SQL and paged as one set. | Same | |
PushPlatform | android | A mobile installation. | FCM gets the plain notification block; the client reads data.actionUrl. | Same |
ios | A mobile installation. | Same. | Same | |
web | A browser subscription. No user_device row. | FCM gets a webpush block with a TTL header and, when the action URL is https, fcmOptions.link — which is the only thing that decides what a click opens. | packages/firebase/src/fcm.provider.ts | |
PushTokenInvalidationReason | unregistered | FCM says the token is dead. | Non-retryable; the token row is deactivated. | Same |
invalid_argument | FCM refused the message shape or a non-https web link. | Non-retryable; the token row is deactivated. | Same | |
user_logout | The caller deleted their own device. | Set by DELETE /api/mobile/notification-devices/{publicId}. | notification-devices.service.ts | |
replaced | The token arrived for a different user. | The old row is invalidated and a fresh one inserted — never reassigned. | Same | |
cap_exceeded | Reserved for eviction at the per-user cap. | Not currently written; registration refuses instead. | notification-contract.ts |
8. Endpoint Reference
8.1 GET /api/notification-templates
Purpose
Lists operator overrides of the notification template registry, newest first. The admin panel calls this to render the template management screen. Only rows somebody deliberately created appear — the table is never seeded, so an empty list is the correct and expected state on a fresh deployment, and it does not mean notifications have no copy. The shipped wording lives in code and is used whenever no active override matches.
Source Evidence
| Evidence | Path |
|---|---|
| Controller | apps/api/src/modules/notification/admin/template/notification-template.controller.ts |
| DTO | apps/api/src/modules/notification/admin/template/notification-template.dto.ts |
| Service | apps/api/src/modules/notification/admin/template/notification-template.service.ts |
| Schema | packages/db/src/schema/notification/notification-template.ts |
| Tests | apps/api/src/modules/notification/admin/template/notification-template.service.int.spec.ts |
Auth and Permissions
- Auth: admin JWT.
- Guard chain:
JwtAuthGuardthenRoleGuard. - Permission:
NotificationTemplate_READ. - Guest support: none.
- Rate limit: none declared.
- Idempotency: read-only.
Request
| Part | Required | Details |
|---|---|---|
| Headers | Yes | Authorization: Bearer <token> |
| Params | No | — |
| Query | No | ListNotificationTemplatesQueryDto — kind, channel, plus QueryDto's pagination, page (1), size (20, max 100), and the unused sort, order, search. |
| Body | No | — |
Response
200, ordered createdAt DESC, id DESC.
{
"message": "Notification templates fetched.",
"data": [
{
"publicId": "0192f3a1-9c4e-7a10-b3d2-6f1e0c5a7b44",
"kind": "school.announcement",
"channel": "email",
"locale": "en",
"subject": "A message from school",
"body": "Hello {{name}}, {{message}}",
"isActive": true,
"version": 2,
"updatedBy": "0192f0aa-1111-7000-8000-0123456789ab",
"createdAt": "2026-09-01T04:15:22.311Z",
"updatedAt": "2026-09-08T09:02:44.107Z"
}
],
"count": 1,
"currentPage": 1,
"totalPage": 1,
"errorCode": null
}Empty-list response:
{ "message": "Notification templates fetched.", "data": [], "count": 0, "currentPage": 1, "totalPage": 0, "errorCode": null }Side Effects
- Database reads:
notification_template, plus aCOUNT(*)when pagination is enabled. - Cache, jobs, realtime, analytics, notifications, external calls: none.
- Audit: the global activity interceptor records reads only where configured to; no explicit record is written here.
Error Cases
| HTTP Status | Error Code | Condition | User-Facing Meaning | Source |
|---|---|---|---|---|
400 | PAGINATION_LIMIT_INVALID | pagination=false | This list must be paginated. | notification-template.service.ts |
400 | — | An unknown query property, or channel outside the vocabulary | Fix the request. forbidNonWhitelisted rejects unknown properties. | main.ts, the DTO |
401 | — | No or invalid token | Sign in. | JwtAuthGuard |
403 | — | The active role lacks NotificationTemplate_READ | Ask for the permission. | RoleGuard |
Edge Cases
- Empty input — no filters returns every override.
- Blank search —
searchis accepted and ignored. - Invalid enum — a
channeloutside the four is a400from@IsIn. sizeabove 100 — clamped to 100 rather than rejected.pagebeyond the end — an emptydataarray with a truthfulcount.- Unstable sort — impossible:
id DESCis the tie-break, because two overrides saved in the same millisecond share acreatedAt. - Unsupported sort option —
sortandorderdo not change the ordering.
Example Requests
GET /api/notification-templates?kind=school.announcement&channel=email&page=1&size=20 HTTP/1.1
Authorization: Bearer TOKENcurl -s "$API_URL/api/notification-templates?channel=sms" \
-H "Authorization: Bearer TOKEN"8.2 GET /api/notification-templates/{publicId}
Purpose
Reads one override, including the version that a subsequent PATCH or DELETE must echo back. The panel calls this when opening the edit form, and must not reuse a version cached from the list if any time has passed — reading immediately before editing is what keeps the optimistic-concurrency window short.
Source Evidence
Same files as 8.1.
Auth and Permissions
- Auth: admin JWT. Guard chain
JwtAuthGuard,RoleGuard. PermissionNotificationTemplate_READ. No guest support, no rate limit, read-only.
Request
| Part | Required | Details |
|---|---|---|
| Headers | Yes | Authorization |
| Params | Yes | publicId — the template's uuid. No ParseUUIDPipe, so a malformed value reaches the query and answers 404 rather than 400. |
| Query | No | — |
| Body | No | — |
Response
200 with a single NotificationTemplateResponseDto in data, and no pagination fields.
{
"message": "Notification template fetched.",
"data": {
"publicId": "0192f3a1-9c4e-7a10-b3d2-6f1e0c5a7b44",
"kind": "school.announcement",
"channel": "sms",
"locale": "ne",
"subject": null,
"body": "{{title}}: {{message}}",
"isActive": true,
"version": 1,
"updatedBy": null,
"createdAt": "2026-09-01T04:15:22.311Z",
"updatedAt": "2026-09-01T04:15:22.311Z"
},
"errorCode": null
}Side Effects
One SELECT. Nothing else.
Error Cases
| HTTP Status | Error Code | Condition | User-Facing Meaning | Source |
|---|---|---|---|---|
404 | NOTIFICATION_TEMPLATE_NOT_FOUND | No row with that public id, or the id is malformed | Reload the list. | notification-template.service.ts |
401 / 403 | — | As above | — | Guards |
Edge Cases
subjectisnullfor every non-email channel and is not an error.updatedByisnullfor a row whose author has since been deleted.
Example Requests
curl -s "$API_URL/api/notification-templates/0192f3a1-9c4e-7a10-b3d2-6f1e0c5a7b44" \
-H "Authorization: Bearer TOKEN"8.3 POST /api/notification-templates
Purpose
Creates an override for one (kind, channel, locale). Use this when the shipped wording needs changing for a school — a different greeting, a translated body, a shorter SMS. The override layers on top of the code registry and never replaces it, so if the row is later deactivated, deleted, or fails to render, the notification still goes out with the built-in copy.
Source Evidence
Same files as 8.1.
Auth and Permissions
- Auth: admin JWT. Guards
JwtAuthGuard,RoleGuard. PermissionNotificationTemplate_CREATE. No guest support. No idempotency key — a second identical request violates the(kind, channel, locale)unique constraint.
Request
| Part | Required | Details |
|---|---|---|
| Headers | Yes | Authorization, Content-Type: application/json |
| Params | No | — |
| Query | No | — |
| Body | Yes | CreateNotificationTemplateDto |
Minimal valid request:
{ "kind": "school.announcement", "channel": "sms", "body": "{{title}}: {{message}}" }Full valid request:
{
"kind": "school.announcement",
"channel": "email",
"locale": "ne",
"subject": "विद्यालयबाट सूचना",
"body": "नमस्ते {{name}}, {{message}}",
"isActive": true
}Response
201 with the created row.
{
"message": "Notification template created.",
"data": {
"publicId": "0192f4b2-0000-7a10-b3d2-6f1e0c5a7b45",
"kind": "school.announcement",
"channel": "email",
"locale": "ne",
"subject": "विद्यालयबाट सूचना",
"body": "नमस्ते {{name}}, {{message}}",
"isActive": true,
"version": 1,
"updatedBy": "0192f0aa-1111-7000-8000-0123456789ab",
"createdAt": "2026-09-10T06:11:03.982Z",
"updatedAt": "2026-09-10T06:11:03.982Z"
},
"errorCode": null
}Side Effects
- Database writes: one
notification_templaterow.versionstarts at 1;updatedByis set from the session. - Audit: the global
ActivityAuditInterceptorrecords the mutation from the handler's@Permissions(). - Rendering effect: takes effect on the next fan-out batch, because overrides are loaded once per batch. Messages already queued are unaffected.
- No jobs, no realtime, no cache invalidation, no external calls.
Error Cases
| HTTP Status | Error Code | Condition | User-Facing Meaning | Source |
|---|---|---|---|---|
400 | NOTIFICATION_TEMPLATE_INVALID | kind is not a key the code registry knows | Choose a known kind. | assertKnownKind |
400 | — | Missing kind, channel or body; channel outside the four; an unknown property | Fix the body. | ValidationPipe |
409 | — | A row already exists for that (kind, channel, locale) — a raw unique violation | Edit the existing override instead. | notification_template_kind_channel_locale_key |
500 | SYS_INTERNAL_ERROR | RETURNING produced no row. Unreachable in PostgreSQL. | Report it. | notification-template.service.ts |
401 / 403 | — | As above | — | Guards |
Edge Cases
- Whitespace-only body — a body of spaces is rejected by
length(btrim(body)) > 0; a body of a single tab or newline passes, deliberately: the constraint exists to catch the empty string a form submits, not to be a whitespace validator. subjecton a non-email channel — accepted and stored, ignored at render.- Unknown placeholder — a
{{whatever}}the kind never supplies makes the override unrenderable at send time, and the renderer silently falls back to the shipped copy with one warning per key. Nothing fails here. - Duplicate action — the second create is a unique violation, not a no-op.
Example Requests
curl -s -X POST "$API_URL/api/notification-templates" \
-H "Authorization: Bearer TOKEN" \
-H "Content-Type: application/json" \
-d '{"kind":"school.announcement","channel":"sms","body":"{{title}}: {{message}}"}'8.4 PATCH /api/notification-templates/{publicId}
Purpose
Edits an override under optimistic concurrency. Every field is optional except version, which must match the row's current value. Two operators editing the same template is a real scenario in a school office, and the alternative — last write wins — silently discards one of them. The response carries the incremented version, which the client must use for any further edit.
Source Evidence
Same files as 8.1.
Auth and Permissions
- Auth: admin JWT. Guards
JwtAuthGuard,RoleGuard. PermissionNotificationTemplate_UPDATE. The request object is read for the activity context.
Request
| Part | Required | Details |
|---|---|---|
| Headers | Yes | Authorization, Content-Type |
| Params | Yes | publicId. No ParseUUIDPipe; a malformed value answers 404. |
| Query | No | — |
| Body | Yes | UpdateNotificationTemplateDto. version is mandatory. |
Minimal valid request:
{ "version": 2, "body": "Updated copy" }Full valid request:
{
"version": 2,
"kind": "school.announcement",
"channel": "email",
"locale": "en",
"subject": "A message from school",
"body": "Hello {{name}}, {{message}}",
"isActive": false
}Response
200 with the updated row and version incremented by one.
Side Effects
- Database writes: the
notification_templaterow, withversion = version + 1andupdatedByset from the session. - Audit: two records. The global interceptor covers the mutation, and the service additionally calls
ActivityRecordService.recordActivitywith per-fieldchangesforkind,channel,locale,subject,bodyandisActive. This is the one caller that computeschangesby hand, because the interceptor has no before-value. - Rendering effect: takes effect on the next fan-out batch.
Error Cases
| HTTP Status | Error Code | Condition | User-Facing Meaning | Source |
|---|---|---|---|---|
404 | NOTIFICATION_TEMPLATE_NOT_FOUND | No such row | Reload. | findRawByPublicId |
400 | NOTIFICATION_TEMPLATE_INVALID | A supplied kind is not a registry key | Choose a known kind. | assertKnownKind |
409 | NOTIFICATION_TEMPLATE_VERSION_CONFLICT | version no longer matches — somebody committed a change, or a delete, since the caller read it | Reload and try again. | The compare-and-set |
400 | — | version missing, below 1, or not an integer; an unknown property | Fix the body. | ValidationPipe |
409 | — | The edit collides with another row's (kind, channel, locale) | Choose a different slot. | The unique constraint |
401 / 403 | — | As above | — | Guards |
Edge Cases
- Omitted versus null
subject— omitting it keeps the current value; sendingnullclears it. Every other optional field only supports "omitted keeps". - Race condition — two
PATCHes with the sameversion: the first wins, the second is a409. The response body of the winner carries the newversion. - Concurrent delete — a
PATCHwhoseversionwas invalidated by aDELETEreports409, not404, because the compare-and-set matched zero rows before the row lookup could notice it had gone. - No-op edit — sending only
versionsucceeds, bumpsversion, and records an activity entry with an emptychangesset.
Example Requests
curl -s -X PATCH "$API_URL/api/notification-templates/0192f3a1-9c4e-7a10-b3d2-6f1e0c5a7b44" \
-H "Authorization: Bearer TOKEN" \
-H "Content-Type: application/json" \
-d '{"version":2,"isActive":false}'Conflict response:
{
"message": "This template was changed by someone else. Reload it and try again.",
"errorCode": "NOTIFICATION_TEMPLATE_VERSION_CONFLICT"
}8.5 DELETE /api/notification-templates/{publicId}
Purpose
Removes an override so the affected (kind, channel, locale) reverts to the shipped wording. This is the correct way to undo a bad edit: the code registry is always present, so there is nothing to restore afterwards. The request carries a body — the version — so a delete cannot silently win over a concurrent edit.
Source Evidence
Same files as 8.1.
Auth and Permissions
- Auth: admin JWT. Guards
JwtAuthGuard,RoleGuard. PermissionNotificationTemplate_DELETE.@HttpCode(HttpStatus.OK)overrides Nest'sDELETEdefault.
Request
| Part | Required | Details |
|---|---|---|
| Headers | Yes | Authorization, Content-Type |
| Params | Yes | publicId |
| Query | No | — |
| Body | Yes | DeleteNotificationTemplateDto — { "version": 3 } |
Response
200 with data: null.
{ "message": "Notification template deleted.", "data": null, "errorCode": null }Side Effects
- Database writes: one row deleted. Hard delete — there is no soft-delete column on this table, because the registry is the durable copy.
- Audit: the global interceptor records the mutation.
- Rendering effect: the next fan-out batch uses the shipped copy.
Error Cases
| HTTP Status | Error Code | Condition | User-Facing Meaning | Source |
|---|---|---|---|---|
404 | NOTIFICATION_TEMPLATE_NOT_FOUND | No such row | Reload. | findRawByPublicId |
409 | NOTIFICATION_TEMPLATE_VERSION_CONFLICT | version no longer matches | Reload and try again. | The compare-and-set |
400 | — | Missing or invalid version | Send the version read from GET. | ValidationPipe |
401 / 403 | — | As above | — | Guards |
Edge Cases
- Deleting the last override for a kind — entirely safe. The shipped copy takes over.
- Deleting an inactive override — permitted;
isActiveis irrelevant to the delete. - Duplicate action — the second
DELETEanswers404. - Concurrent edit — a
PATCHthat committed first makes this409.
8.6 GET /api/notification-history
Purpose
Lists every notification event that was fanned out to real people, newest first by when the thing happened. This is the screen an operator opens to answer "did that announcement actually go out". It is deliberately event-level: the response carries counts and fan-out state, not recipients, so a large announcement is one row rather than three thousand.
Source Evidence
| Evidence | Path |
|---|---|
| Controller | apps/api/src/modules/notification/admin/history/notification-history.controller.ts |
| DTO | apps/api/src/modules/notification/admin/history/notification-history.dto.ts |
| Service | apps/api/src/modules/notification/admin/history/notification-history.service.ts |
| Schema | packages/db/src/schema/notification/notification-event.ts, notification-delivery.ts |
| Tests | apps/api/src/modules/notification/admin/history/notification-history.service.int.spec.ts |
Auth and Permissions
- Auth: admin JWT. Guards
JwtAuthGuard,RoleGuard. PermissionNotificationHistory_READ. Read-only, no guest support, no rate limit.
Request
| Part | Required | Details |
|---|---|---|
| Headers | Yes | Authorization |
| Params | No | — |
| Query | No | ListNotificationHistoryQueryDto — kind, category, from, to, plus QueryDto's pagination fields. |
| Body | No | — |
Response
200, ordered occurredAt DESC, id DESC.
{
"message": "Notification history fetched.",
"data": [
{
"publicId": "0192f5c3-1111-7a10-b3d2-6f1e0c5a7b46",
"sourceModule": "auth",
"kind": "auth.password_reset",
"category": "security",
"priority": "critical",
"scheduledFor": null,
"occurredAt": "2026-09-10T05:58:11.004Z",
"cancelledAt": null,
"fannedOutAt": "2026-09-10T05:58:12.220Z",
"recipientCount": 1,
"unresolvedCount": 0,
"failedDeliveryCount": 0,
"createdAt": "2026-09-10T05:58:11.010Z"
}
],
"count": 1,
"currentPage": 1,
"totalPage": 1,
"errorCode": null
}Side Effects
Reads notification_event, a COUNT(*) when paginated, and one grouped join across notification_delivery and notification_recipient to compute failedDeliveryCount for the page. Nothing is written.
Error Cases
| HTTP Status | Error Code | Condition | User-Facing Meaning | Source |
|---|---|---|---|---|
400 | PAGINATION_LIMIT_INVALID | pagination=false | This list must be paginated. | notification-history.service.ts |
400 | — | category outside the nine; from or to not an ISO date string; an unknown property | Fix the request. | ValidationPipe |
401 / 403 | — | As above | — | Guards |
Edge Cases
- A pending event —
fannedOutAt,recipientCountandunresolvedCountare allnulltogether, by constraint. - A cancelled event —
cancelledAtset,fannedOutAtnull, and it will never fan out. - A zero-recipient fan-out —
fannedOutAtset withrecipientCount: 0. Legal: the audience matched nobody. failedDeliveryCountversus visible failures — it counts onlyfailedanddead. An event with every deliveryskipped_unconfiguredreports0, which is correct: an unconfigured channel is not an outage.fromafterto— accepted; returns nothing.- Empty result —
data: []with a truthfulcount.
Example Requests
curl -s "$API_URL/api/notification-history?category=announcement&from=2026-09-01T00:00:00.000Z&size=50" \
-H "Authorization: Bearer TOKEN"8.7 GET /api/notification-history/{publicId}
Purpose
Returns one event with every recipient and every delivery beneath it — the screen an operator opens to answer "what happened to this particular message, for this particular family". It is the only place the delivery state machine is visible in full: status, attempts, lease-expiry count, last error code, masked destination and every timestamp.
Source Evidence
Same files as 8.6.
Auth and Permissions
Admin JWT, JwtAuthGuard, RoleGuard, NotificationHistory_READ.
Request
| Part | Required | Details |
|---|---|---|
| Headers | Yes | Authorization |
| Params | Yes | publicId — the event's uuid. No ParseUUIDPipe; a malformed value answers 404. |
| Query | No | — |
| Body | No | — |
Response
200. Recipients are ordered by internal id; deliveries within each recipient likewise.
{
"message": "Notification event fetched.",
"data": {
"publicId": "0192f5c3-1111-7a10-b3d2-6f1e0c5a7b46",
"sourceModule": "school",
"kind": "school.announcement",
"category": "announcement",
"priority": "normal",
"scheduledFor": null,
"occurredAt": "2026-09-10T05:58:11.004Z",
"cancelledAt": null,
"fannedOutAt": "2026-09-10T05:58:12.220Z",
"recipientCount": 2,
"unresolvedCount": 0,
"failedDeliveryCount": 1,
"createdAt": "2026-09-10T05:58:11.010Z",
"actionUrl": "/announcements/0192f5c3",
"actionLabel": "Read it",
"requestedChannels": ["email", "sms", "in_app"],
"audience": { "kind": "guardians_of_class", "classPublicIds": ["0192aaaa-2222-7000-8000-000000000001"] },
"recipients": [
{
"publicId": "0192f5c4-2222-7a10-b3d2-6f1e0c5a7b47",
"audienceRoleId": 4,
"readAt": null,
"createdAt": "2026-09-10T05:58:12.100Z",
"deliveries": [
{
"publicId": "0192f5c5-3333-7a10-b3d2-6f1e0c5a7b48",
"channel": "in_app",
"providerTargetId": null,
"status": "delivered",
"provider": "in_app",
"providerMessageId": null,
"destinationHint": null,
"attempts": 0,
"failureCount": 0,
"leaseExpiryCount": 0,
"lastError": null,
"replayOfDeliveryId": null,
"queuedAt": "2026-09-10T05:58:12.100Z",
"sentAt": null,
"deliveredAt": "2026-09-10T05:58:12.100Z",
"failedAt": null,
"skippedAt": null,
"nextAttemptAt": null,
"createdAt": "2026-09-10T05:58:12.100Z"
},
{
"publicId": "0192f5c6-4444-7a10-b3d2-6f1e0c5a7b49",
"channel": "sms",
"providerTargetId": null,
"status": "dead",
"provider": "aakash",
"providerMessageId": null,
"destinationHint": "9779●●●●●123",
"attempts": 1,
"failureCount": 1,
"leaseExpiryCount": 0,
"lastError": "SMS_INSUFFICIENT_CREDIT",
"replayOfDeliveryId": null,
"queuedAt": "2026-09-10T05:58:12.100Z",
"sentAt": null,
"deliveredAt": null,
"failedAt": "2026-09-10T05:58:15.441Z",
"skippedAt": null,
"nextAttemptAt": null,
"createdAt": "2026-09-10T05:58:12.100Z"
},
{
"publicId": "0192f5c7-5555-7a10-b3d2-6f1e0c5a7b4a",
"channel": "email",
"providerTargetId": null,
"status": "skipped_preference",
"provider": null,
"providerMessageId": null,
"destinationHint": null,
"attempts": 0,
"failureCount": 0,
"leaseExpiryCount": 0,
"lastError": null,
"replayOfDeliveryId": null,
"queuedAt": "2026-09-10T05:58:12.100Z",
"sentAt": null,
"deliveredAt": null,
"failedAt": null,
"skippedAt": "2026-09-10T05:58:12.100Z",
"nextAttemptAt": null,
"createdAt": "2026-09-10T05:58:12.100Z"
}
]
}
]
},
"errorCode": null
}That single response demonstrates four things worth reading carefully: an in_app delivery legitimately delivered with sentAt null; a dead SMS carrying a redacted error code rather than a provider response; a skipped_preference row that exists precisely so the question "why did this parent not get the SMS" is answerable from the record; and a masked destination hint.
Side Effects
Three reads: the event, its recipients, and their deliveries. Nothing is written.
Error Cases
| HTTP Status | Error Code | Condition | User-Facing Meaning | Source |
|---|---|---|---|---|
404 | NOTIFICATION_EVENT_NOT_FOUND | No such event, or a malformed id | Reload the list. | findRawEventByPublicId |
401 / 403 | — | As above | — | Guards |
Edge Cases
- A very large event — recipients and deliveries are not paginated on this endpoint. An
all_usersannouncement returns everything. Callers should expect a large payload and should reach for the list endpoint's counts where a summary suffices. - A recipient whose user was hard-erased — the row survives with only its own
publicId; the response never carried a user id in any case. - A replayed delivery — appears as an additional row with
replayOfDeliveryIdset to the internal id of the original, which is not addressable through this API. - Push — one delivery row per token, each with a
providerTargetId. unresolvedCount— how many ids in the audience specification name nothing that exists. It is what separates the two causes ofrecipientCount: 0: a real class that happens to be empty, which is not a fault, and a stale or mistyped id, which means the announcement was never going to reach anyone. Written once when the fan-out completes, by one existence probe per audience kind. It asks only whether an id names a row — never whether the group has members, because an empty group is legitimate and marking it as a fault would be wrong.
Example Requests
curl -s "$API_URL/api/notification-history/0192f5c3-1111-7a10-b3d2-6f1e0c5a7b46" \
-H "Authorization: Bearer TOKEN"8.8 DELETE /api/notification-events/{publicId}
Purpose
Cancels a scheduled notification event before it fans out. A design that creates a scheduled, irreversible send to three thousand families has to provide the inverse, and this is it. It works only while the event has not been fanned out; once recipients exist the messages are already queued or gone, and cancelling would be a lie.
Source Evidence
| Evidence | Path |
|---|---|
| Controller | apps/api/src/modules/notification/admin/event/notification-event.controller.ts |
| DTO | apps/api/src/modules/notification/admin/event/notification-event.dto.ts |
| Service | apps/api/src/modules/notification/admin/event/notification-event.service.ts |
| Schema | packages/db/src/schema/notification/notification-event.ts |
| Tests | apps/api/src/modules/notification/admin/event/notification-event.service.int.spec.ts |
Auth and Permissions
- Auth: admin JWT. Guards
JwtAuthGuard,RoleGuard. PermissionNotificationHistory_UPDATE, not a module of its own — this is the single write the history surface makes, on the same rows it reads. @HttpCode(HttpStatus.OK).
Request
| Part | Required | Details |
|---|---|---|
| Headers | Yes | Authorization |
| Params | Yes | publicId, validated by ParseUUIDPipe — a malformed value is 400, not 404. |
| Query | No | — |
| Body | No | — |
Response
200.
{
"message": "Notification event cancelled.",
"data": {
"publicId": "0192f5c3-1111-7a10-b3d2-6f1e0c5a7b46",
"cancelledAt": "2026-09-10T06:20:03.115Z"
},
"errorCode": null
}Side Effects
- Database writes:
notification_event.cancelled_at, via a compare-and-set onfanned_out_at IS NULL AND cancelled_at IS NULL. - Downstream effect: the fan-out worker's own claim carries
cancelled_at IS NULLin its predicate, so a cancel landing between the worker's read and its claim wins. The orphan sweep also excludes cancelled events, so nothing re-dispatches it. - Audit: the global interceptor records the mutation.
- No jobs, no realtime, no external calls.
Error Cases
| HTTP Status | Error Code | Condition | User-Facing Meaning | Source |
|---|---|---|---|---|
400 | — | publicId is not a uuid | Fix the id. | ParseUUIDPipe |
404 | NOTIFICATION_EVENT_NOT_FOUND | No such event | Reload. | notification-event.service.ts |
409 | NOTIFICATION_EVENT_CANCELLED | Already cancelled | Reload. | The re-read after a zero-row claim |
409 | NOTIFICATION_EVENT_ALREADY_FANNED_OUT | The fan-out won the race | Nothing to do; the messages have gone. | The same |
401 / 403 | — | As above | — | Guards |
The two 409s are distinguished by a deliberate re-read after the claim matches zero rows, so the operator learns which of the two happened rather than being told "conflict".
Edge Cases
- Race with the fan-out worker — a compare-and-set, not a read-then-write, so a cancel cannot appear to succeed a heartbeat after the worker committed.
- Cancelling an already-cancelled event —
409, never a silent success. - A partially fanned-out event —
fanned_out_atis written only on completion, so an event mid-fan-out is still cancellable by this predicate; the batches already committed have already queued their deliveries, and those are not withdrawn. Deliveries stillqueuedat claim time will find the event cancelled only through the delivery status they are set to by other paths — the operator should treat a mid-fan-out cancel as partial. - Cancelling an immediate event — technically possible in the seconds before the outbox relays it, and almost never useful.
Example Requests
curl -s -X DELETE "$API_URL/api/notification-events/0192f5c3-1111-7a10-b3d2-6f1e0c5a7b46" \
-H "Authorization: Bearer TOKEN"8.9 GET /api/notification-failures
Purpose
Lists notification channel jobs that failed while running — the processing dead-letter queue. This is not the same as a dead delivery: a delivery reaches dead through the row's own retry budget, while a row appears here when the BullMQ job threw on its final attempt. The two usually coincide, and the delivery row is the business record while this is the diagnostic one.
Source Evidence
| Evidence | Path |
|---|---|
| Controller | apps/api/src/modules/notification/admin/dead-letter/notification-failure.controller.ts |
| DTO | apps/api/src/modules/notification/admin/dead-letter/notification-failure.dto.ts |
| Service | apps/api/src/modules/notification/admin/dead-letter/notification-failure.service.ts |
| Schema | packages/db/src/schema/jobs/job-failures.ts |
| Writer | apps/api/src/common/jobs/job-failure-recorder.service.ts |
| Tests | apps/api/src/modules/notification/admin/dead-letter/notification-failure.service.int.spec.ts |
Auth and Permissions
Admin JWT, JwtAuthGuard, RoleGuard, NotificationFailure_READ.
Request
| Part | Required | Details |
|---|---|---|
| Headers | Yes | Authorization |
| Params | No | — |
| Query | No | ListNotificationFailuresQueryDto — channel, unreplayedOnly, plus QueryDto's pagination fields. |
| Body | No | — |
Response
200, ordered failedAt DESC, id DESC.
{
"message": "Notification failures fetched.",
"data": [
{
"publicId": "0192f6d0-6666-7a10-b3d2-6f1e0c5a7b4b",
"queueName": "notification_sms",
"jobName": "notification_channel.send_sms",
"jobId": "1042",
"payloadRef": {
"notificationDeliveryPublicId": "0192f5c6-4444-7a10-b3d2-6f1e0c5a7b49",
"channel": "sms"
},
"actorId": null,
"attempts": 1,
"lastError": "notification delivery 0192f5c6-4444-7a10-b3d2-6f1e0c5a7b49 dead on sms: SMS_INSUFFICIENT_CREDIT",
"replayedAt": null,
"replayedBy": null,
"replayJobId": null,
"failedAt": "2026-09-10T05:58:15.500Z"
}
],
"count": 1,
"currentPage": 1,
"totalPage": 1,
"errorCode": null
}Side Effects
Reads job_failures filtered to the three notification channel queues, plus a COUNT(*) when paginated.
Error Cases
| HTTP Status | Error Code | Condition | User-Facing Meaning | Source |
|---|---|---|---|---|
400 | PAGINATION_LIMIT_INVALID | pagination=false | This list must be paginated. | notification-failure.service.ts |
400 | — | channel outside email/sms/push; an unknown property | Fix the request. | ValidationPipe |
401 / 403 | — | As above | — | Guards |
Edge Cases
in_appis not a validchannel— it has no queue, so it can never appear here.- Widening is impossible — the three-queue allowlist is ANDed unconditionally. A row from
backup_restoreor any other queue is unreachable from this surface regardless of what the caller sends, which matters because a database restore's failure must not be visible to, or re-runnable by, a clerk holdingNotificationFailure_READ. actorIdis alwaysnull— the recorder writesnull; the failing job had no human actor.payloadRefmay benull— for a job whose payload carried no delivery id. Such a row cannot be replayed.lastErroris the thrown message, truncated to 4000 characters. It carries the delivery id, the terminal state, the channel and the redacted provider code — never a provider response body.- Operational-email failures do not appear here — they are on
notification_operational, which is outside the allowlist, and theirpayloadRefhas a different shape.
Example Requests
curl -s "$API_URL/api/notification-failures?channel=sms&unreplayedOnly=true" \
-H "Authorization: Bearer TOKEN"8.10 POST /api/notification-failures/{publicId}/replay
Purpose
Re-sends a failed notification. It does not re-enqueue the original job. Instead it inserts a fresh notification_delivery row pointing at the original and schedules that through the outbox, so the original's failedAt, lastError and providerMessageId — the evidence the operator opened the screen to read — survive intact. Use it after fixing the cause: topping up SMS credit, supplying a provider key, correcting an address.
Source Evidence
Same files as 8.9, plus apps/api/src/modules/outbox/shared/outbox.service.ts and packages/db/src/schema/notification/notification-delivery.ts.
Auth and Permissions
- Auth: admin JWT. Guards
JwtAuthGuard,RoleGuard. PermissionNotificationFailure_UPDATE. actor.idis recorded asreplayedBy.- Idempotency: structural. The
job_failures.replayed_atcompare-and-set happens inside the same transaction, so two concurrent replays cannot both succeed.
Request
| Part | Required | Details |
|---|---|---|
| Headers | Yes | Authorization |
| Params | Yes | publicId of the failure row, validated by ParseUUIDPipe. |
| Query | No | — |
| Body | No | — |
Response
201 with the claimed failure row plus the new delivery's public id.
{
"message": "Notification failure replayed.",
"data": {
"publicId": "0192f6d0-6666-7a10-b3d2-6f1e0c5a7b4b",
"queueName": "notification_sms",
"jobName": "notification_channel.send_sms",
"jobId": "1042",
"payloadRef": {
"notificationDeliveryPublicId": "0192f5c6-4444-7a10-b3d2-6f1e0c5a7b49",
"channel": "sms"
},
"actorId": null,
"attempts": 1,
"lastError": "notification delivery 0192f5c6-4444-7a10-b3d2-6f1e0c5a7b49 dead on sms: SMS_INSUFFICIENT_CREDIT",
"replayedAt": "2026-09-10T06:30:01.777Z",
"replayedBy": "0192f0aa-1111-7000-8000-0123456789ab",
"replayJobId": "0192f700-7777-7a10-b3d2-6f1e0c5a7b4c",
"failedAt": "2026-09-10T05:58:15.500Z",
"newDeliveryPublicId": "0192f700-7777-7a10-b3d2-6f1e0c5a7b4c"
},
"errorCode": null
}replayJobId carries the new delivery's public id, not a BullMQ job id — the column is generic across the platform and this surface uses it as its own reference.
Side Effects
All inside one transaction:
- Insert a
notification_deliveryrow: samerecipientId,channel,providerTargetId,destinationHint,renderedTitle,renderedBody;status: "queued";replayOfDeliveryIdpointing at the original. Both delivery uniqueness indexes carryAND replay_of_delivery_id IS NULL, which is exactly what permits this insert even though the original still occupies the same(recipient, channel)slot. - Insert an
outbox_eventsrow withdedupeKey: "<newDeliveryPublicId>:replay", distinct from the original enqueue's key and from a backfill's — three different dedupe keys can legitimately exist for one delivery public id, one per code path that schedules it. - Update
job_failures:replayedAt,replayedBy,replayJobId, guarded byreplayed_at IS NULL. - The original delivery row is not touched.
- Downstream: the outbox dispatcher relays the job to the channel queue, and the normal send path runs.
Error Cases
| HTTP Status | Error Code | Condition | User-Facing Meaning | Source |
|---|---|---|---|---|
400 | — | publicId is not a uuid | Fix the id. | ParseUUIDPipe |
404 | JOB_FAILURE_NOT_FOUND | No failure row with that id | Reload. | notification-failure.service.ts |
409 | JOB_FAILURE_QUEUE_NOT_PERMITTED | The row belongs to a queue outside the three | Use the screen that owns that queue. Reported as a refusal rather than a 404 because the row exists and another screen may legitimately show it. | Same |
409 | JOB_FAILURE_ALREADY_REPLAYED | replayedAt is set, or a concurrent request claimed it first | Reload. | Same |
404 | NOTIFICATION_DELIVERY_NOT_FOUND | payloadRef carries no notificationDeliveryPublicId, or the delivery it names is gone | Nothing to replay — retention may have removed it. | Same |
500 | SYS_INTERNAL_ERROR | The replay insert produced no row. Unreachable in PostgreSQL. | Report it. | Same |
401 / 403 | — | As above | — | Guards |
Edge Cases
- Duplicate action — the second replay is
409, and because the claim is the last statement in the transaction, the loser's delivery and outbox inserts are rolled back with it. Exactly one new delivery exists per failure row. - Replaying a
skipped_*delivery — cannot happen: a skip raises no dead letter, so no failure row exists to replay. - Replaying after retention deleted the delivery —
404 NOTIFICATION_DELIVERY_NOT_FOUND. Retention refuses to delete an event with an unreplayed dead letter, so this only arises for an already-replayed chain or a manual deletion. - Replaying a push delivery whose token has since been invalidated — the new delivery is created and then records
skipped_no_destination, because the send path re-reads the token and requiresis_active. - The cause is not fixed — the replay fails the same way and writes a second failure row.
- Race condition — the compare-and-set is inside the transaction and last, which is what makes the rollback correct.
Example Requests
curl -s -X POST "$API_URL/api/notification-failures/0192f6d0-6666-7a10-b3d2-6f1e0c5a7b4b/replay" \
-H "Authorization: Bearer TOKEN"8.11 GET /api/notifications
Purpose
The admin operational feed — what the bell in the panel header reads. One row per operational event, filtered to what the caller's active role may see, newest first, with an unread count across everything visible. This is not the consumer notification centre: nothing here is addressed to a person, and the same row is seen by every operator holding its permission.
Source Evidence
| Evidence | Path |
|---|---|
| Controller | apps/api/src/modules/notification-feed/notification-feed.controller.ts |
| DTO | apps/api/src/modules/notification-feed/dto/notification.dto.ts |
| Service | apps/api/src/modules/notification-feed/notification-feed.service.ts |
| Schema | packages/db/src/schema/notifications.ts |
| Tests | apps/api/src/modules/notification-feed/notification-feed.service.int.spec.ts |
Auth and Permissions
- Auth: admin JWT. Guards
JwtAuthGuard,RoleGuard. - Permission: none declared. The handler is on
NO_PERMISSION_ADMIN_HANDLERS, and the service's per-row permission filter is the access control. - A superadmin active role means no filter at all; a session with no active role sees nothing.
Request
| Part | Required | Details |
|---|---|---|
| Headers | Yes | Authorization |
| Params | No | — |
| Query | No | limit — integer 1–200, default 50. Not QueryDto: this is a bounded window, not an offset-paginated list. |
| Body | No | — |
Response
200, ordered occurredAt DESC, id DESC. No count, currentPage or totalPage.
{
"message": "Notifications fetched.",
"data": {
"data": [
{
"publicId": "0192f810-8888-7a10-b3d2-6f1e0c5a7b4d",
"kind": "feedback.submitted",
"summary": "A bug report was submitted.",
"soundClass": "soft",
"aggregateId": "0192f7ff-9999-7a10-b3d2-6f1e0c5a7b4e",
"occurredAt": "2026-09-10T06:41:10.220Z",
"read": false
}
],
"unreadCount": 1
},
"errorCode": null
}The payload is nested — data.data and data.unreadCount — because NotificationListDto is itself the envelope's data.
Side Effects
- Reads the caller's active-role permission set through
RoleService, thennotificationleft-joined to this caller'snotification_readreceipts. - A second query for
unreadCount. - Nothing is written.
Error Cases
| HTTP Status | Error Code | Condition | User-Facing Meaning | Source |
|---|---|---|---|---|
400 | — | limit outside 1–200, or an unknown query property | Fix the request. | ValidationPipe |
401 / 403 | — | No token, or RoleGuard refuses the session | Sign in. | Guards |
Edge Cases
- A session with no active role — the permission list is empty, which becomes the SQL literal
falserather than "no filter". The feed is empty, matching whatGET /auth/permissionsreports, and the panel should send such a user to the role chooser. - A superadmin — no permission filter at all, rather than materialising the whole catalogue into an
IN (...). readis per caller — the left join carriesuserId = actor.id. Joining without it would mark a row read because somebody else read it.unreadCountversus the page — counted across everything visible, so it can exceedlimit.- A newly granted permission — rows that already existed become visible immediately, and unread, because the feed fans out on read. That is the property the design exists for.
- A revoked permission — those rows disappear from both the list and the count.
Example Requests
curl -s "$API_URL/api/notifications?limit=20" -H "Authorization: Bearer TOKEN"8.12 POST /api/notifications/{publicId}/read
Purpose
Marks one operational-feed row read for the signed-in user. Read state is per person, held in a join table rather than a column, because one row is seen by everyone holding its permission — a column would let the first admin to open the bell clear it for the whole office.
Source Evidence
Same files as 8.11.
Auth and Permissions
Admin JWT, JwtAuthGuard, RoleGuard, no permission declared, allowlisted. Visibility is re-checked before the write.
Request
| Part | Required | Details |
|---|---|---|
| Headers | Yes | Authorization |
| Params | Yes | publicId, validated by ParseUUIDPipe({ version: "7" }) — a v4 uuid is 400. |
| Query | No | — |
| Body | No | — |
Response
201 with data: null.
{ "message": "Notification marked read.", "data": null, "errorCode": null }Side Effects
Inserts a notification_read row with onConflictDoNothing on (notification_id, user_id).
Error Cases
| HTTP Status | Error Code | Condition | User-Facing Meaning | Source |
|---|---|---|---|---|
400 | — | Not a v7 uuid | Fix the id. | ParseUUIDPipe |
404 | NOTIFICATION_NOT_FOUND | Absent, or not visible to this caller | Reload. | notification-feed.service.ts |
401 / 403 | — | As above | — | Guards |
Edge Cases
- A visible-to-someone-else row answers
404, exactly as an absent one does. Without that check anyone could mark any notification read by public id and — because the id is a uuid — confirm that a notification they may not see exists. - Reading twice is not an error, and a race between two browser tabs is not one either.
- Marking read then losing the permission — the receipt survives; the row simply stops being visible.
Example Requests
curl -s -X POST "$API_URL/api/notifications/0192f810-8888-7a10-b3d2-6f1e0c5a7b4d/read" \
-H "Authorization: Bearer TOKEN"8.13 POST /api/notifications/read-all
Purpose
Clears the bell. Marks every row currently visible to this caller read, scoped by the same permission filter the list uses, so clearing never creates a receipt for a notification the caller was not shown.
Source Evidence
Same files as 8.11.
Auth and Permissions
Admin JWT, JwtAuthGuard, RoleGuard, no permission declared, allowlisted.
Request
Headers only. No params, query or body.
Response
201.
{ "message": "Notifications marked read.", "data": { "marked": 14 }, "errorCode": null }Side Effects
Selects every visible notification.id, then a multi-row insert into notification_read with onConflictDoNothing. marked counts rows actually inserted, so already-read rows are excluded.
Error Cases
| HTTP Status | Error Code | Condition | User-Facing Meaning | Source |
|---|---|---|---|---|
401 / 403 | — | As above | — | Guards |
Edge Cases
- Nothing visible — returns
{ "marked": 0 }without issuing an insert. - Everything already read — returns
{ "marked": 0 }. - Scoping matters — a row the caller cannot see gets no receipt, which is what stops it being silently hidden if they gain the permission later.
- Unbounded write — this is the one write in the feed that touches every visible row; the table is retention-swept, which is what keeps it bounded.
Example Requests
curl -s -X POST "$API_URL/api/notifications/read-all" -H "Authorization: Bearer TOKEN"8.14 GET /api/mobile/notifications
Purpose
The consumer notification centre — every audience, one screen. Guardians, students, teachers and staff all read the same endpoint, and the service scopes it by ownership, active role and staff liveness. Rows are the person's own notification_recipient records, rendered with the content frozen at fan-out, and only rows that actually had an in-app delivery appear.
Source Evidence
| Evidence | Path |
|---|---|
| Controller | apps/api/src/modules/notification/customer/notification-centre/notification-centre.controller.ts |
| DTO | .../notification-centre/dto/notification-centre.dto.ts |
| Service | .../notification-centre/notification-centre.service.ts |
| Schema | packages/db/src/schema/notification/notification-recipient.ts, notification-delivery.ts |
| Tests | .../notification-centre/notification-centre.service.int.spec.ts |
Auth and Permissions
- Auth: JWT. Guards
JwtAuthGuard,RoleGuard. No permission declared, allowlisted. - The service predicate is the only control, and it has four parts: ownership, active-role scoping, a staff-liveness re-check, and an in-app delivery
EXISTS.
Request
| Part | Required | Details |
|---|---|---|
| Headers | Yes | Authorization |
| Params | No | — |
| Query | No | ListNotificationsQueryDto, which is QueryDto unchanged — pagination, page, size, plus the inherited-but-unused sort, order, search. |
| Body | No | — |
Response
200, ordered occurredAt DESC, recipientId DESC.
{
"message": "Notifications fetched.",
"data": [
{
"publicId": "0192f5c4-2222-7a10-b3d2-6f1e0c5a7b47",
"kind": "school.announcement",
"category": "announcement",
"priority": "normal",
"title": "Sports day moved",
"body": "It is now on Friday.",
"actionUrl": "/announcements/0192f5c3",
"actionLabel": "Read it",
"occurredAt": "2026-09-10T05:58:11.004Z",
"readAt": null
}
],
"count": 1,
"currentPage": 1,
"totalPage": 1,
"errorCode": null
}Side Effects
Two reads: the page, and a COUNT(*) over the same predicate. Nothing is written.
Error Cases
| HTTP Status | Error Code | Condition | User-Facing Meaning | Source |
|---|---|---|---|---|
400 | PAGINATION_LIMIT_INVALID | pagination=false | The centre cannot be read unpaginated. | notification-centre.service.ts |
400 | — | An unknown query property | Fix the request. | ValidationPipe |
401 / 403 | — | As above | — | Guards |
Edge Cases
- Fixed order —
sortandorderare accepted and ignored. The order is fixed so two events fanned out in one transaction cannot be dropped or duplicated under offset paging. sizeabove 100 — clamped to 100.- A teacher who is also a parent — viewing as Guardian, staff-audience rows are excluded, because
audience_role_idmust benullor equal the active role. - A session with no active role —
x = NULLis never true in SQL, so the predicate correctly narrows to rows that are not role-scoped, with no extra branch. - A dismissed teacher who is still a parent — keeps a live
usersrow, so ownership and active-role scoping alone would keep serving them disciplinary and roster notifications. The staff-livenessEXISTSis what stops that. - Email-only and SMS-only notifications never appear, and neither do channels the person suppressed — the in-app
EXISTSexcludesskipped_preferenceandcancelled. titleandbodyare typed nullable but are always present on a returned row; render defensively rather than asserting.- A template edited after delivery — the row keeps the wording it was delivered with. Content is frozen at fan-out and never re-rendered here.
- Empty state —
data: [],count: 0.
Example Requests
curl -s "$API_URL/api/mobile/notifications?page=1&size=20" -H "Authorization: Bearer TOKEN"8.15 GET /api/mobile/notifications/unread-count
Purpose
The badge number. Counted across everything visible to the caller under the same four-part predicate the list uses, with read_at IS NULL added — so the badge and the list can never disagree about what counts.
Source Evidence
Same files as 8.14.
Auth and Permissions
JWT, JwtAuthGuard, RoleGuard, no permission declared, allowlisted.
Request
Headers only.
Response
200.
{ "message": "Unread count fetched.", "data": { "count": 3 }, "errorCode": null }Side Effects
One COUNT(*). It is served by the partial index on notification_recipient (user_id) WHERE read_at IS NULL.
Error Cases
401 / 403 only.
Edge Cases
- Switching active role changes the count, because role scoping is part of the predicate.
- Zero is the normal steady state and is not an error.
- It is not capped by any page size.
- Redis being unreachable does not affect it — Postgres is the authoritative unread store; the realtime stream is only an enhancement that saves polling.
Example Requests
curl -s "$API_URL/api/mobile/notifications/unread-count" -H "Authorization: Bearer TOKEN"8.16 POST /api/mobile/notifications/read-all
Purpose
Marks every notification currently visible to the caller read, under the same predicate as the list. Returns how many rows actually changed.
Source Evidence
Same files as 8.14.
Auth and Permissions
JWT, allowlisted, service-scoped.
Request
Headers only.
Response
201.
{ "message": "Notifications marked read.", "data": { "marked": 3 }, "errorCode": null }Side Effects
One bounded UPDATE ... SET read_at = now() over the visible, currently-unread rows, returning their ids.
Error Cases
401 / 403 only.
Edge Cases
- Already-read rows are not touched and are not counted — the
UPDATEcarriesread_at IS NULL. - Nothing visible returns
{ "marked": 0 }. - Role-scoped rows for a role the caller is not currently acting as are not marked, so switching role afterwards still shows them unread. That is intentional: clearing a badge in one role must not clear another's.
Example Requests
curl -s -X POST "$API_URL/api/mobile/notifications/read-all" -H "Authorization: Bearer TOKEN"8.17 POST /api/mobile/notifications/{publicId}/read
Purpose
Marks one notification read and returns the row as the centre renders it, so a client can update a list item in place without refetching the page.
Source Evidence
Same files as 8.14.
Auth and Permissions
JWT, allowlisted. The write is scoped by the full visibility predicate, not merely user_id = actor.id.
Request
| Part | Required | Details |
|---|---|---|
| Headers | Yes | Authorization |
| Params | Yes | publicId of the recipient row. No ParseUUIDPipe; a malformed value answers 404. |
| Query | No | — |
| Body | No | — |
Response
201 with a NotificationCentreItemDto.
{
"message": "Notification marked read.",
"data": {
"publicId": "0192f5c4-2222-7a10-b3d2-6f1e0c5a7b47",
"kind": "school.announcement",
"category": "announcement",
"priority": "normal",
"title": "Sports day moved",
"body": "It is now on Friday.",
"actionUrl": "/announcements/0192f5c3",
"actionLabel": "Read it",
"occurredAt": "2026-09-10T05:58:11.004Z",
"readAt": "2026-09-10T07:02:19.660Z"
},
"errorCode": null
}Side Effects
UPDATE notification_recipient SET read_at = coalesce(read_at, now()) scoped by the predicate, then a re-read to build the response.
Error Cases
| HTTP Status | Error Code | Condition | User-Facing Meaning | Source |
|---|---|---|---|---|
404 | NOTIFICATION_NOT_FOUND | Absent, owned by somebody else, scoped to a role the caller is not acting as, or with no qualifying in-app delivery | Reload. | notification-centre.service.ts |
401 / 403 | — | As above | — | Guards |
Zero rows is a 404, never a 403: a 403 would confirm the row exists for someone else, or under a role the caller is not currently acting as.
Edge Cases
- Marking read twice —
coalescekeeps the original timestamp, soreadAtdoes not move. - A row whose only channels were email or SMS — invisible to the centre, so
404. - A row the person suppressed —
skipped_preferenceis excluded by the predicate, so404. - Race between two devices — both succeed; the first timestamp wins.
Example Requests
curl -s -X POST "$API_URL/api/mobile/notifications/0192f5c4-2222-7a10-b3d2-6f1e0c5a7b47/read" \
-H "Authorization: Bearer TOKEN"8.18 POST /api/mobile/notification-devices
Purpose
Registers a push credential for the signed-in session. Mobile apps call this on launch and after every token rotation; a browser calls it with platform: "web" after the user grants notification permission. The token is bound to the session's user, never to anything in the body, and registering a token that already belongs to somebody else invalidates their row rather than moving it.
Source Evidence
| Evidence | Path |
|---|---|
| Controller | apps/api/src/modules/notification/customer/notification-devices/notification-devices.controller.ts |
| DTO | .../notification-devices/dto/notification-device.dto.ts |
| Service | .../notification-devices/notification-devices.service.ts |
| Schema | packages/db/src/schema/notification/notification-push-token.ts |
| Tests | .../notification-devices/notification-devices.service.int.spec.ts |
Auth and Permissions
- Auth: JWT. Guards
JwtAuthGuard,RoleGuard. No permission declared, allowlisted. user_idis never taken from the body.RoleGuardruns no permission check here, so keying every write onactor.idis the only remaining control.
Request
| Part | Required | Details |
|---|---|---|
| Headers | Yes | Authorization, Content-Type |
| Params | No | — |
| Query | No | — |
| Body | Yes | RegisterNotificationDeviceDto |
{ "token": "fcm-registration-token-value", "platform": "android" }Web push:
{ "token": "BNc9...browser-subscription-token", "platform": "web" }Response
201.
{
"message": "Device registered.",
"data": {
"publicId": "0192f900-aaaa-7a10-b3d2-6f1e0c5a7b50",
"platform": "android",
"createdAt": "2026-09-10T07:10:44.019Z"
},
"errorCode": null
}The token is never echoed back.
Side Effects
One transaction, with three cases:
| Case | Effect |
|---|---|
| The active token already belongs to this user | A touch: last_used_at and platform updated on the existing row. No new row, and the cap is not consulted — invalidating and reinserting on every app launch would defeat the per-user cap for no reason, since nothing adversarial happened. |
| The active token belongs to another user | That row is invalidated with is_active = false, invalidated_at = now(), invalidated_reason = "replaced", and a fresh row is inserted for this user. |
| The token is new | A row is inserted, after the active-token count is checked against NOTIFICATION_MAX_TOKENS_PER_USER (10). |
No jobs, no realtime, no external calls. Future push sends read the active tokens for a user, so the effect on delivery is immediate for the next fan-out.
Error Cases
| HTTP Status | Error Code | Condition | User-Facing Meaning | Source |
|---|---|---|---|---|
400 | NOTIFICATION_DEVICE_LIMIT_REACHED | Already at 10 active tokens | Remove a device before adding another. | notification-devices.service.ts |
400 | — | token shorter than 8 or longer than 4096; platform outside the three; an unknown property such as userId | Fix the body. | ValidationPipe |
401 / 403 | — | As above | — | Guards |
Edge Cases
- Re-registering the same token — a touch, and a
201with the originalcreatedAt. Not a new device. - The cap is a refusal, not an eviction — the caller learns the limit rather than losing a device it never asked to remove.
- A token arriving for a second user is invalidated with reason
replaced, never reassigned in place. Reassignment would destroy the record that the first user ever held it and would, on its own, be a denial of service: anyone who learns a victim's token — a shared school tablet, a resold device, a sibling — couldPOSTit and silently stop the victim's push, including the security notifications that cannot otherwise be switched off. - Web push has no
user_devicerow, souser_device_idisnull. A browser has no installation. - Platform matters at send time:
webgets FCM'swebpushblock with a TTL header and, when the notification's action URL ishttps,fcmOptions.link— the only thing that decides what a click opens when every tab is closed.androidandiosget the plain notification block and readdata.actionUrlin their own handler. - A token FCM later declares dead is deactivated automatically by the send path with reason
unregisteredorinvalid_argument; the app should simply re-register on next launch. - Long-invalidated rows are pruned after
NOTIFICATION_PUSH_TOKEN_RETENTION_DAYS(180).
Example Requests
curl -s -X POST "$API_URL/api/mobile/notification-devices" \
-H "Authorization: Bearer TOKEN" \
-H "Content-Type: application/json" \
-d '{"token":"fcm-registration-token-value","platform":"web"}'8.19 DELETE /api/mobile/notification-devices/{publicId}
Purpose
Removes one of the caller's own registered devices, typically on sign-out. It is a soft invalidation rather than a hard delete: the table exists to keep the history that answers "why did this parent stop receiving notifications", and a hard delete for a caller-initiated removal would erase exactly that.
Source Evidence
Same files as 8.18.
Auth and Permissions
JWT, allowlisted, scoped by user_id = actor.id. @HttpCode is not applied, but DELETE returns 200 by Nest default.
Request
| Part | Required | Details |
|---|---|---|
| Headers | Yes | Authorization |
| Params | Yes | publicId of the device row. No ParseUUIDPipe; a malformed value answers 404. |
| Query | No | — |
| Body | No | — |
Response
200 with data: null.
{ "message": "Device removed.", "data": null, "errorCode": null }Side Effects
UPDATE notification_push_token SET is_active = false, invalidated_at = now(), invalidated_reason = 'user_logout' scoped by public_id, user_id = actor.id and is_active = true.
Error Cases
| HTTP Status | Error Code | Condition | User-Facing Meaning | Source |
|---|---|---|---|---|
404 | NOTIFICATION_DEVICE_NOT_FOUND | No active token with that id for this user | Reload the device list. | notification-devices.service.ts |
401 / 403 | — | As above | — | Guards |
A publicId belonging to another user answers 404, never 403 — a 403 would confirm the row exists for someone else.
Edge Cases
- Removing twice — the second call is
404, because the row is no longer active. - Removing a device already invalidated by FCM — also
404, and harmless. - Removing the last device — permitted. Push deliveries for that person then record
skipped_no_destinationat fan-out. - The row remains queryable with its
invalidated_reason, until retention prunes it.
Example Requests
curl -s -X DELETE "$API_URL/api/mobile/notification-devices/0192f900-aaaa-7a10-b3d2-6f1e0c5a7b50" \
-H "Authorization: Bearer TOKEN"8.20 GET /api/mobile/notification-preferences
Purpose
Returns the caller's resolved preference matrix — all nine categories, all four channels, with the value the resolver would actually use — plus the version token a save must echo back. It is resolved rather than raw so the client can render switches directly without reimplementing the four-step precedence order.
Source Evidence
| Evidence | Path |
|---|---|
| Controller | apps/api/src/modules/notification/customer/notification-preferences/notification-preferences.controller.ts |
| DTO | .../notification-preferences/dto/notification-preference.dto.ts |
| Service | .../notification-preferences/notification-preferences.service.ts |
| Resolver | apps/api/src/modules/notification/shared/preference-resolver.service.ts |
| Schema | packages/db/src/schema/notification/notification-preference.ts |
| Tests | .../notification-preferences/notification-preferences.service.int.spec.ts |
Auth and Permissions
JWT, JwtAuthGuard, RoleGuard, no permission declared, allowlisted. Keyed on actor.id.
Request
Headers only.
Response
200.
{
"message": "Preferences fetched.",
"data": {
"version": 2,
"categories": [
{ "category": "security", "locked": true, "channels": { "email": true, "sms": true, "push": true, "in_app": true } },
{ "category": "system", "locked": false, "channels": { "email": true, "sms": false, "push": true, "in_app": true } },
{ "category": "assignment", "locked": false, "channels": { "email": false, "sms": false, "push": true, "in_app": true } },
{ "category": "announcement", "locked": false, "channels": { "email": true, "sms": false, "push": true, "in_app": true } },
{ "category": "message", "locked": false, "channels": { "email": false, "sms": false, "push": true, "in_app": true } },
{ "category": "payment", "locked": false, "channels": { "email": true, "sms": false, "push": true, "in_app": true } },
{ "category": "attendance", "locked": false, "channels": { "email": false, "sms": false, "push": true, "in_app": true } },
{ "category": "event", "locked": false, "channels": { "email": false, "sms": false, "push": true, "in_app": true } },
{ "category": "marketing", "locked": false, "channels": { "email": false, "sms": false, "push": false, "in_app": false } }
]
},
"errorCode": null
}For a person who has never saved, the response is identical except version: 0 — the shipped defaults, resolved.
Side Effects
Three reads: the version row, the global channel rows, the per-category rows. Nothing is written — a person with no preference set does not get one created here, because a read that writes is a read that lies about being one.
Error Cases
401 / 403 only.
Edge Cases
version: 0means no row exists yet. It is a real state, not an error, and the firstPUTmust send0.locked: truemeans every registered kind in that category is unsuppressible, so the switch would have no effect on delivery. The UI must not offer it. Today onlysecurityis locked;systempermits unsuppressible kinds but currently holds none, and is therefore reported unlocked.- A locked category always reports every channel
true, regardless of stored rows. - Resolution order is applied here too: per-category override, then the global channel switch, then the code default. The global switch is honoured but is not writable by any route in this module.
- Adding a new category — it appears immediately with its code default, for everyone, including people who saved preferences years earlier. That is the point of storing absence as "the default".
Example Requests
curl -s "$API_URL/api/mobile/notification-preferences" -H "Authorization: Bearer TOKEN"8.21 PUT /api/mobile/notification-preferences
Purpose
Saves per-category overrides in one atomic write, guarded by the version read from GET. A "turn off email everywhere" action touches one row per category, so two devices saving at once would otherwise interleave into a mixed state with no error — the version on the set is the smallest thing that makes the write atomic from the client's point of view. The response is the freshly resolved matrix, so a client never has to guess what it just saved.
Source Evidence
Same files as 8.20.
Auth and Permissions
JWT, allowlisted, keyed on actor.id. Returns 200 — PUT has no 201 default in Nest.
Request
| Part | Required | Details |
|---|---|---|
| Headers | Yes | Authorization, Content-Type |
| Params | No | — |
| Query | No | — |
| Body | Yes | UpdateNotificationPreferencesDto |
First save, from a person who has never saved:
{
"version": 0,
"overrides": [
{ "category": "announcement", "channel": "sms", "enabled": true }
]
}Full save:
{
"version": 2,
"overrides": [
{ "category": "announcement", "channel": "email", "enabled": false },
{ "category": "announcement", "channel": "sms", "enabled": true },
{ "category": "assignment", "channel": "push", "enabled": false },
{ "category": "marketing", "channel": "in_app", "enabled": true }
]
}Response
200 with the same shape as GET, and version incremented.
Side Effects
One transaction:
- The version claim.
version === 0is anINSERT ... onConflictDoNothingonnotification_preference_setat version 1, which must produce a row. Any other value is anUPDATE ... SET version = version + 1 WHERE version = $expected, which must match. Either producing zero rows is a409. - The overrides. Each surviving entry is an upsert into
notification_preferenceon(user_id, category, channel). - Then the service re-reads and returns the resolved matrix.
Delivery effect: the next fan-out batch reads the new values. Messages already queued are unaffected — preferences are resolved once, at fan-out.
Error Cases
| HTTP Status | Error Code | Condition | User-Facing Meaning | Source |
|---|---|---|---|---|
409 | NOTIFICATION_PREFERENCE_VERSION_CONFLICT | A stale version, or version: 0 when a row already exists | Re-GET and try again. | notification-preferences.service.ts |
400 | — | version missing or negative; more than 36 overrides; an unknown category or channel; a non-boolean enabled; an unknown property | Fix the body. | ValidationPipe |
401 / 403 | — | As above | — | Guards |
Edge Cases
- An override for a locked category is accepted and silently not persisted.
isEnabledalways returnstruefor an unsuppressible kind regardless of any stored row, so persisting one would be a switch with no effect on delivery. The response shows the category still locked and still fully enabled. - Duplicate
(category, channel)entries — de-duplicated last-write-wins, not rejected. - An empty
overridesarray — legal. It bumps the version and changes nothing, which is a valid way to take the lock. - Nothing is ever deleted — there is no way to remove an override and revert a single cell to the code default through this route; setting it explicitly is the supported action.
- The global per-channel switch is not writable here. It is read and honoured, but no route in this module sets it.
- Unsuppressible kinds ignore everything — a person cannot switch off their own password reset, and a caller cannot declare its marketing blast exempt, because suppressibility is a property of the kind in code rather than of any column or any field a caller supplies.
- Race between two devices — the loser gets
409with nothing written, rather than a half-applied matrix.
Example Requests
curl -s -X PUT "$API_URL/api/mobile/notification-preferences" \
-H "Authorization: Bearer TOKEN" \
-H "Content-Type: application/json" \
-d '{"version":2,"overrides":[{"category":"announcement","channel":"sms","enabled":true}]}'Conflict response:
{
"message": "Preferences were changed by another session.",
"errorCode": "NOTIFICATION_PREFERENCE_VERSION_CONFLICT"
}9. Flow Diagrams
9.1 Route Ownership
9.2 Request Sequence — a typical read
9.3 Error Decision Tree
9.4 Auth and Permission Flow
9.5 Data Contract Map
9.6 Activity Diagram — replay
9.7 Async Flow — what happens after a replay
9.8 Cache Flow
There is no read-through cache on any route in this module. Every read goes to PostgreSQL. See section 11 for the two ways Redis is used.
10. Pagination, Sorting, Filtering, and Search
| Endpoint | Pagination Type | Default Size | Max Size | Sort Fields | Filters | Result Cap |
|---|---|---|---|---|---|---|
GET /api/notification-templates | page/size | 20 | 100 | Fixed createdAt DESC, id DESC | kind, channel | None beyond the page |
GET /api/notification-history | page/size | 20 | 100 | Fixed occurredAt DESC, id DESC | kind, category, from, to | None beyond the page |
GET /api/notification-history/{publicId} | None | — | — | Recipients and deliveries ordered by internal id | — | Unbounded — every recipient and every delivery |
GET /api/notification-failures | page/size | 20 | 100 | Fixed failedAt DESC, id DESC | channel, unreplayedOnly, plus a hard three-queue allowlist | None beyond the page |
GET /api/notifications | Limit only | 50 | 200 | Fixed occurredAt DESC, id DESC | Implicit: the caller's active-role permissions | 200 |
GET /api/mobile/notifications | page/size | 20 | 100 | Fixed occurredAt DESC, recipientId DESC | Implicit: ownership, active role, staff liveness, in-app delivery | None beyond the page |
The shared utility is PaginationUtil: normalize clamps page to a positive integer and size to at most MAX_PAGE_SIZE (100), getDrizzleParams turns that into limit/offset, and buildMetadata produces the count / currentPage / totalPage fields on the envelope. UNPAGINATED_HARD_CAP (1000) is the fallback limit where pagination is legitimately disabled — but no list in this module permits that, so the cap is never reached here.
Every list has a fixed order with an id tie-break, and that is not incidental. Two rows written in the same millisecond share a timestamp, and an unstable sort under offset pagination silently drops and duplicates rows across pages. sort and order are accepted from QueryDto and ignored.
There is no search on any endpoint here. QueryDto.search is inherited and unused; it is accepted rather than rejected because forbidNonWhitelisted would otherwise 400 a client sending the platform's standard query shape.
Empty results return data: [] with a truthful count, never a 404.
pagination=false is refused on every list, with 400 PAGINATION_LIMIT_INVALID. These tables only grow, and there is no ceiling small enough to make an unbounded read safe to buffer.
The one unbounded response is GET /api/notification-history/{publicId}. An all_users announcement returns every recipient and every delivery. Prefer the list endpoint's recipientCount and failedDeliveryCount when a summary suffices.
11. Caching, Jobs, and External Integrations
| Integration | Used? | Details | Source |
|---|---|---|---|
| Redis read cache | No | Every read hits PostgreSQL. Each read surface is either per-caller — where a cache has one entry per person — or an operator screen whose value is being current. | — |
| Redis secret vault | Yes, indirectly | notification:secret:<prefix>:<uuid>, SET ... EX 900, read with GETDEL. No API route reads or writes it; it is written by auth and consumed by the channel worker. No route ever returns a resolved secret. | shared/secret-reference.service.ts |
| Redis pub/sub | Yes, indirectly | realtime:user:<userId>. Published after each fan-out batch commits, carrying recipientPublicId, kind, category, priority, occurredAt — no title and no body, so it never becomes a second, unversioned read API. GET /api/mobile/notifications is the real one. | customer/realtime/notification-realtime-publisher.service.ts |
| BullMQ | Yes | notification_fanout (notification.fan_out, .reap, .sweep_orphans, .backfill_unconfigured, .prune, .check_sms_credit); notification_email / _sms / _push (notification_channel.send_*, attempts: 1 each, because the delivery row owns retry); notification_operational (notification_operational.send_email, ordinary default attempts, because it has no delivery row). | packages/jobs/src/index.ts, apps/api/src/services/bullmq/bull.module.ts |
| Transactional outbox | Yes | Every enqueue this module makes is an outbox_events row written in the same transaction as its business write. No API route calls queue.add() directly. | apps/api/src/modules/outbox/shared/outbox.service.ts |
| Resend (email) | Yes, asynchronously | Through EmailChannelProvider, which wraps the shared EmailClient. Unconfigured is a skip, not a failure; a mocked send is reported skipped, never sent. | channels/email.provider.ts |
| Aakash / Sparrow (SMS) | Yes, asynchronously | Selected by SMS_PROVIDER. Encoding-aware truncation at 3 segments. An exhausted balance is a non-retryable failure, which is why a daily credit check exists. | packages/sms |
| Firebase FCM (push) | Yes, asynchronously | One send per active token. Per-platform blocks; web additionally gets webpush.fcmOptions.link when the action URL is https. FCM's unregistered and invalid-argument are non-retryable and deactivate the token. | packages/firebase |
| MongoDB | No | Not used by this module. | — |
Nothing in this module sends synchronously. Every API route either reads, or writes rows plus an outbox row. A 201 from POST .../replay means "scheduled", not "sent" — the delivery row is where the outcome lands, and GET /api/notification-history/{publicId} is where a consumer reads it.
Caps a consumer should know about:
| Cap | Value | Applies to |
|---|---|---|
| Explicit recipients per event | 500 | Internal callers of NotificationService.send |
| Compound audience members | 1–10 | The same |
| Active push tokens per user | 10 | POST /api/mobile/notification-devices |
| Preference overrides per request | 36 | PUT /api/mobile/notification-preferences |
| Page size | 100 | Every QueryDto list |
| Feed limit | 200 | GET /api/notifications |
| Delivery attempts | 5 | The delivery row's own budget |
| History retention | 30 days | Everything reachable through GET /api/notification-history |
| Push token retention | 180 days after invalidation | Device history |
Retention has a consumer-visible consequence: a notification older than NOTIFICATION_RETENTION_DAYS disappears from the history screen, from the notification centre and from every unread count, because the rows are deleted. Clients must not treat a previously-seen publicId as permanently resolvable.
13. Deep API Documentation Pack
13.1 Route-by-Route Completeness Matrix
One row per concrete runtime route. Every route in this module is covered by the global JwtAuthGuard, RoleGuard chain and the global ValidationPipe; the guard column names only what is additional.
| Route | Controller Method | DTOs | Service Method | Permissions | Cache | Jobs | DB Touches | Errors | Tests | Documented? |
|---|---|---|---|---|---|---|---|---|---|---|
GET /api/notification-templates | NotificationTemplateController.findAll | ListNotificationTemplatesQueryDto, NotificationTemplateResponseDto | .findAll | NotificationTemplate_READ | N/A | N/A | notification_template | PAGINATION_LIMIT_INVALID | notification-template.service.int.spec.ts | Yes |
GET /api/notification-templates/{publicId} | .findOne | NotificationTemplateResponseDto | .findById | NotificationTemplate_READ | N/A | N/A | notification_template | NOTIFICATION_TEMPLATE_NOT_FOUND | Same | Yes |
POST /api/notification-templates | .create | CreateNotificationTemplateDto, NotificationTemplateResponseDto | .create | NotificationTemplate_CREATE | N/A | N/A | notification_template insert; activity | NOTIFICATION_TEMPLATE_INVALID, SYS_INTERNAL_ERROR, unique violation | Same | Yes |
PATCH /api/notification-templates/{publicId} | .update | UpdateNotificationTemplateDto, NotificationTemplateResponseDto | .update | NotificationTemplate_UPDATE | N/A | N/A | notification_template update; explicit activity record with changes | NOTIFICATION_TEMPLATE_NOT_FOUND, _INVALID, _VERSION_CONFLICT | Same | Yes |
DELETE /api/notification-templates/{publicId} | .remove | DeleteNotificationTemplateDto | .remove | NotificationTemplate_DELETE | N/A | N/A | notification_template delete; activity | NOTIFICATION_TEMPLATE_NOT_FOUND, _VERSION_CONFLICT | Same | Yes |
GET /api/notification-history | NotificationHistoryController.findAll | ListNotificationHistoryQueryDto, NotificationHistoryListItemDto | .findAll | NotificationHistory_READ | N/A | N/A | notification_event, notification_recipient, notification_delivery | PAGINATION_LIMIT_INVALID | notification-history.service.int.spec.ts | Yes |
GET /api/notification-history/{publicId} | .findOne | NotificationHistoryDetailDto, ...RecipientDto, ...DeliveryDto | .findById | NotificationHistory_READ | N/A | N/A | The same three tables | NOTIFICATION_EVENT_NOT_FOUND | Same | Yes |
DELETE /api/notification-events/{publicId} | NotificationEventController.cancel | CancelledNotificationEventDto | .cancel | NotificationHistory_UPDATE | N/A | N/A | notification_event.cancelled_at; activity | NOTIFICATION_EVENT_NOT_FOUND, _CANCELLED, _ALREADY_FANNED_OUT, 400 from ParseUUIDPipe | notification-event.service.int.spec.ts | Yes |
GET /api/notification-failures | NotificationFailureController.findAll | ListNotificationFailuresQueryDto, NotificationFailureDto | .findAll | NotificationFailure_READ | N/A | N/A | job_failures | PAGINATION_LIMIT_INVALID | notification-failure.service.int.spec.ts | Yes |
POST /api/notification-failures/{publicId}/replay | .replay | ReplayNotificationFailureResponseDto | .replay | NotificationFailure_UPDATE | N/A | notification_channel.send_* via outbox_events | job_failures, notification_delivery, outbox_events | JOB_FAILURE_NOT_FOUND, _QUEUE_NOT_PERMITTED, _ALREADY_REPLAYED, NOTIFICATION_DELIVERY_NOT_FOUND, SYS_INTERNAL_ERROR, 400 from ParseUUIDPipe | Same | Yes |
GET /api/notifications | NotificationFeedController.list | ListNotificationsQueryDto (feed), NotificationListDto, NotificationDto | .list, .unreadCount | None — allowlisted; per-row notification.permission | N/A | N/A | notification, notification_read, role permissions | Validation only | notification-feed.service.int.spec.ts | Yes |
POST /api/notifications/{publicId}/read | .markRead | — | .markRead | None — allowlisted | N/A | N/A | notification, notification_read insert | NOTIFICATION_NOT_FOUND, 400 from ParseUUIDPipe({ version: "7" }) | Same | Yes |
POST /api/notifications/read-all | .markAllRead | — | .markAllRead | None — allowlisted | N/A | N/A | notification, notification_read bulk insert | — | Same | Yes |
GET /api/mobile/notifications | NotificationCentreController.list | ListNotificationsQueryDto (centre), NotificationCentreItemDto | .list | None — allowlisted; service predicate | N/A | N/A | notification_recipient, notification_event, role, staff, notification_delivery | PAGINATION_LIMIT_INVALID | notification-centre.service.int.spec.ts | Yes |
GET /api/mobile/notifications/unread-count | .unreadCount | UnreadNotificationCountDto | .unreadCount | None — allowlisted | N/A | N/A | The same tables | — | Same | Yes |
POST /api/mobile/notifications/read-all | .readAll | MarkAllNotificationsReadResultDto | .markAllRead | None — allowlisted | N/A | N/A | notification_recipient.read_at | — | Same | Yes |
POST /api/mobile/notifications/{publicId}/read | .markRead | NotificationCentreItemDto | .markRead | None — allowlisted | N/A | N/A | notification_recipient.read_at | NOTIFICATION_NOT_FOUND | Same | Yes |
POST /api/mobile/notification-devices | NotificationDevicesController.register | RegisterNotificationDeviceDto, NotificationDeviceDto | .register | None — allowlisted; keyed on actor.id | N/A | N/A | notification_push_token | NOTIFICATION_DEVICE_LIMIT_REACHED | notification-devices.service.int.spec.ts | Yes |
DELETE /api/mobile/notification-devices/{publicId} | .remove | — | .remove | None — allowlisted; scoped to actor.id | N/A | N/A | notification_push_token | NOTIFICATION_DEVICE_NOT_FOUND | Same | Yes |
GET /api/mobile/notification-preferences | NotificationPreferencesController.get | NotificationPreferencesDto, NotificationPreferenceCategoryDto | .get | None — allowlisted | N/A | N/A | Three preference tables | — | notification-preferences.service.int.spec.ts | Yes |
PUT /api/mobile/notification-preferences | .update | UpdateNotificationPreferencesDto, NotificationPreferenceOverrideDto | .update | None — allowlisted | N/A | N/A | notification_preference_set, notification_preference | NOTIFICATION_PREFERENCE_VERSION_CONFLICT | Same | Yes |
Decorators and pipes that change behaviour:
| Element | Where | Effect |
|---|---|---|
ValidationPipe({ whitelist, forbidNonWhitelisted, transform }) | Global | An unknown body or query property is a 400, not silently stripped. Query strings are coerced to the DTO's declared types. |
ClassSerializerInterceptor | Global | Applies serialization decorators to responses. |
setGlobalPrefix("api") | Global | Every path is prefixed. health/live and health/ready are excluded. |
RouterModule.register({ path: "mobile", children }) | MobileModule | The three consumer leaves are mounted under /api/mobile. Does not recurse through an aggregate. |
@HttpCode(HttpStatus.OK) | Template DELETE, event DELETE | Returns 200 with a body instead of Nest's DELETE default. |
ParseUUIDPipe | Event cancel, failure replay | A malformed id is 400. |
ParseUUIDPipe({ version: "7" }) | Feed markRead | A non-v7 uuid is 400. |
No pipe on publicId | Template routes, centre markRead, device remove | A malformed id reaches the query and answers 404. |
@CurrentUser() / @CurrentAdmin() | Every handler that needs identity | Supplies actor.id and actor.activeRole. |
@Permissions(...) | Admin handlers only | Read by RoleGuard and by the activity interceptor, which derives the audited module from it. |
NO_PERMISSION_ADMIN_HANDLERS | role.guard.ts | Eleven handlers here. RoleGuard returns true before reading request.user. |
ApiPaginatedResponseDto / ApiResponseDto | Swagger only | Documents the response schema; does not change runtime behaviour, and its default documented status is 200 even where the runtime status is 201. |
13.2 Request/Response Exhaustiveness
| Example Type | Covered where applicable | Notes |
|---|---|---|
| Minimal valid request | 8.3, 8.4, 8.18, 8.21 | Smallest body the DTO accepts. |
| Full valid request | The same sections | Every optional field with a realistic value. |
| Public/guest request | Not applicable | No route in this module is public. An unauthenticated request is 401 everywhere. |
| Authenticated request | Every section | Authorization: Bearer TOKEN. Identity affects visibility on every consumer and feed route. |
| Admin request | 8.1–8.10 | Each names its exact permission. |
| Success response | Every section | Full envelope, every nullable field shown. |
| Empty-list response | 8.1 | With pagination metadata. |
| Validation error | Every error table | Representative 400s. |
| Domain error | Every error table | Exact code and condition. |
| Auth / permission error | Every error table | 401 and 403. |
| Conflict | 8.4, 8.8, 8.10, 8.21 | With the exact response body. |
| Rate-limit error | Not applicable | No route-level rate limit is declared. |
Error responses share one shape across the module:
{ "message": "Human-readable explanation.", "errorCode": "NOTIFICATION_TEMPLATE_VERSION_CONFLICT" }ValidationPipe failures carry Nest's default array-of-strings message and no errorCode, which is the one shape a consumer must handle separately.
13.3 API Diagram Pack
| Diagram | Section | Purpose |
|---|---|---|
| Route ownership graph | 9.1 | Actors, controllers, services, infrastructure. |
| Sequence per endpoint family | 9.2, 9.7 | Request through to response, and what happens after a 201. |
| Activity diagram for a write | 9.6 | Validation branches and side effects for the most complex mutation. |
| Error decision tree | 9.3 | Validation, auth, not-found, conflict. |
| Auth and permission flow | 9.4 | Guard ordering and the allowlist branch. |
| Data contract map | 9.5 | Request DTO to service to response DTO. |
| Cache flow | 9.8 | Stated as absent, with what Redis is used for instead. |
| Async/job flow | 9.7 | Producer, outbox, queue, worker, provider. |
| Realtime/event flow | 11 | The per-user channel and its deliberately thin payload. |
13.4 Consumer Integration Notes
| Consumer | Required Knowledge | Failure Handling | Contract Stability |
|---|---|---|---|
| Admin panel | The three permission modules and which routes need each; that version must round-trip on template PATCH and DELETE; that the history detail response is unpaginated; that a 201 from replay means scheduled. | Show 409 as "reload and try again"; show PAGINATION_LIMIT_INVALID as a bug rather than to the user; distinguish the two cancel 409s by errorCode. | Stable |
| Admin panel — the bell | That GET /api/notifications returns a nested data.data plus data.unreadCount; that read is per caller; that a session with no active role legitimately sees nothing. | Send such a user to the role chooser rather than showing an empty bell. | Stable |
| Mobile app — centre | That publicId is the recipient row; that title/body are frozen at fan-out; that switching active role changes both the list and the badge; that only in-app-delivered notifications appear. | 404 on markRead means the row is not visible under the current role — refresh rather than retry. | Stable |
| Mobile app — devices | That the token is bound to the session; that re-registering is a touch; that the cap is a refusal; that platform decides the FCM block. | On NOTIFICATION_DEVICE_LIMIT_REACHED, show the device list and let the user remove one. | Stable |
| Mobile app — preferences | That version: 0 means "never saved"; that locked categories must not render a switch; that the global per-channel switch is read-only through this API; that unsuppressible kinds ignore everything. | On 409, re-GET and re-apply the user's intent — never retry blindly with the same version. | Stable |
| Browser (web push) | That a web subscription is registered exactly like a mobile token with platform: "web"; that the click destination comes from webpush.fcmOptions.link, which FCM only accepts over https; that a service worker reading data.actionUrl is not enough for a browser with no tab open. | A token FCM rejects is deactivated server-side; re-register on next visit. | Stable |
| QA | That every id-addressed route answers 404 rather than 403 for an invisible row; that a skipped_* delivery is not a failure and is excluded from failedDeliveryCount; that an unconfigured provider produces skipped_unconfigured, not an error. | Reproduce conflicts by issuing two writes with the same version. | Stable |
Internal service (a module calling send) | That send takes a transaction, not the root handle; that category, priority, suppressibility and dedupe policy come from the registry; that a repeatable kind requires dedupe.requestId and a collapse kind forbids it; that a secret must go through the vault as a secretRef. | Swallow-and-log is the established pattern — a notification that cannot be scheduled must not roll back the business write it describes. | Stable, and enforced at compile time by SendNotificationInput |
The single most likely integration mistake is confusing /api/notifications with /api/mobile/notifications. Both are declared @Controller("notifications"); one is a permission-filtered operational feed with a limit query and a nested payload, the other a per-person centre with QueryDto pagination and a flat array.
13.5 API Tradeoffs and Rationale
| Decision | Chosen Behavior | Alternatives Considered | Why This Tradeoff | Risk | Mitigation |
|---|---|---|---|---|---|
| Pagination style | Offset page/size with a fixed, id-tied order | Cursor pagination | The platform's shared QueryDto and metadata shape; these lists are browsed, not streamed | Deep pages are expensive | size capped at 100; the tie-break makes paging stable |
pagination=false refused | Always 400 | Allow it under UNPAGINATED_HARD_CAP | These tables only grow; no ceiling makes an unbounded read safe | A client wanting everything must page | Documented per endpoint |
| History detail unpaginated | Returns every recipient and delivery | Paginate the children | The screen exists to show the whole picture for one event | A large announcement is a large payload | The list endpoint carries counts for summary use |
| Optimistic concurrency on templates and preferences | A version that must round-trip | Last write wins, or pessimistic locking | Two operators in one office, and two devices for one parent, are both real | Clients must handle 409 | The conflict message names the remedy |
DELETE with a body | Template delete carries version | A query parameter, or no guard at all | A delete must not silently win over a concurrent edit | Unusual for DELETE; some clients strip bodies | Documented explicitly |
404 rather than 403 for an invisible row | Everywhere an id is addressed | 403 | Public ids are uuids, and a 403 confirms existence | An operator cannot distinguish absent from forbidden | Stated in this doc and in the code |
| No permission on eleven handlers | The service predicate is the control | A blanket Notification_READ | Guardians and students hold no admin permission; the feed's permission is per row | A future handler added to the allowlist without a predicate is open | The allowlist is explicit and reviewed |
| Feed limit, not pagination | limit 1–200 | QueryDto | It is a bounded newest-first window for a bell, not a browsable list | No way to page back beyond 200 | Retention bounds the table anyway |
| Async everything | A 201 means scheduled | Send synchronously and return the outcome | A three-thousand-parent fan-out cannot run inside a request | A client cannot confirm delivery from the response | The history detail endpoint is the outcome surface |
Skips excluded from failedDeliveryCount | Only failed and dead count | Count every non-success | An unconfigured machine must not look like an outage | An operator may miss a silently-skipped channel | Every skip is visible per delivery in the detail response |
| No rendered content on the history surface | Never returned | Return it for support purposes | NotificationHistory_READ is not superadmin-only, and a rendered security email embeds a live token | Support cannot see what was sent | The template screen shows what would be sent |
| Masked destinations only | 9779●●●●●123 | Full addresses | The full set is a contact-details export of every family | Ambiguous when two numbers share a mask | The recipient row identifies the person internally |
Response envelope without success | message, data, errorCode | Add a boolean | The platform-wide ResponseDto predates this module and is used everywhere | A consumer expecting success breaks | Documented in 6.1 |
| Replay inserts rather than resets | A new delivery row | Reset the original and re-enqueue | The original's failedAt, lastError and providerMessageId are the evidence the operator opened the screen to read | Two rows for one logical send | replayOfDeliveryId links them |
13.6 API Change Impact
| Change | Affected Consumers | Backend Impact | Data Impact | Migration Needed? | Compatibility Plan |
|---|---|---|---|---|---|
Adding a NotificationCategory | Mobile preferences screen | CATEGORY_DEFAULTS must gain an entry, or the resolver returns false and the category silently never delivers | The CHECK on both notification_event.category and notification_preference.category is regenerated from the constant | Yes — a constraint change | The category appears in GET immediately with its code default, for everyone. Clients must render an unknown category rather than assuming nine. |
Adding a DeliveryStatus | Admin history screen | Must also be classified into TERMINAL_, IN_FLIGHT_ or SKIPPED_, or retention and failure counting are both wrong | The status CHECK is regenerated | Yes | Clients must treat status as an open string, not a closed union. |
| Adding a channel | Everything | New provider, new queue, new attempts pin, new default in CATEGORY_DEFAULTS | Four channel CHECKs regenerated | Yes | The preference matrix gains a key; clients must iterate channels rather than destructuring four names. |
Adding an AudienceKind | None externally | A resolver must be added or the code stops compiling | The audience CHECK is regenerated | Yes | Internal only; audience is opaque in the history response. |
| Adding a notification kind | Template screen | A registry entry; if it declares in_app it must set persistRendered or boot fails | None | No | kind is already an open string in every DTO. |
| Renaming a route | Admin panel, mobile app | Controller path change | None | No | Breaking. Requires a coordinated release; there is no versioning in use on these routes. |
| Removing a field from the history response | Admin panel | DTO and mapper | None | No | Breaking for anything reading it. |
| Adding a field to any response | None | DTO and mapper | None | No | Additive and safe; clients must ignore unknown fields. |
Changing NOTIFICATION_RETENTION_DAYS | Admin panel, mobile app | Retention worker only | Rows disappear sooner or later | No | Consumers must already treat a publicId as impermanent. |
| Adding a query filter | Admin panel | DTO | None | No | Additive. Note that forbidNonWhitelisted means a client sending an unknown filter gets 400 today. |
| Making the global channel switch writable | Mobile preferences | A new field on UpdateNotificationPreferencesDto | notification_channel_preference gains a writer | No | Additive; the resolver already honours the table. |
| Implementing delivery receipts | Admin history | A webhook route, and sent -> delivered transitions | notification_delivery_provider_message_idx already exists for it | No | Additive; delivered is already a documented status. |
14. Zero-Omission API Checklist
- Every controller route is documented — all 21, matched against
structure.baseline.json. - Every parent route prefix and runtime URL is documented, including the two controllers that share a local path.
- Every DTO field, nested field, enum, default, transform and validator is documented.
- Every response field, nullable field, generated field and deliberately omitted field is documented.
- Every guard, permission, public-decorator absence and allowlisted handler is documented.
- Every success, validation, auth, permission, not-found, conflict and server-error branch is documented.
- Every database read and write, Redis usage, queue job, realtime event and external call is documented.
- Every route has examples for a minimal request, a full request where a body exists, a success response and representative failures.
- Every endpoint family has route, sequence, activity and error diagrams.
- Every tradeoff and compatibility risk is documented.
- The API doc links to the backend and features/flows docs.
15. Integration Checklist
- Every route from every controller is documented.
- Every DTO field is documented.
- Every enum value is documented, including the four
skipped_*statuses and all five token invalidation reasons. - The response envelope is documented, including the absence of a
successfield. - Every error code is documented with its HTTP status and the exact condition that raises it.
- Every auth guard, permission and allowlist entry is documented.
- Every queue job, outbox event, Redis key and external call is documented.
- Every diagram matches the current code.
See Also
- Backend doc: /docs/developer/notification/backend
- Features and flows doc: /docs/developer/notification/feature