Skoolsewa - Ecommerce Docs
Developer ResourcesNotification

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

AreaFiles InspectedWhat Was Verified
Controllersapps/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.tsRoutes, methods, guards, decorators, permissions, status codes.
Route prefixesapps/api/src/main.ts, apps/api/src/modules/mobile/mobile.module.tssetGlobalPrefix("api"); the three consumer leaves mounted under mobile.
Route ground truthapps/api/test/structure/structure.baseline.jsonConfirmed the exact 21 paths documented here.
DTOsadmin/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.tsRequest, query, response, validation decorators, defaults, nullability.
ServicesEvery *.service.ts behind those controllersBehaviour, side effects, response mapping, exact error conditions.
Vocabularypackages/db/src/notification/notification-contract.tsEvery enum value a response or request can carry.
Schemapackages/db/src/schema/notification/*.ts, packages/db/src/schema/notifications.ts, packages/db/src/schema/jobs/job-failures.tsPersisted field types, generated ids, constraints.
Errorsapps/api/src/common/types/error-codes.tsEvery NOTIFICATION_* and JOB_FAILURE_* code.
Authapps/api/src/common/authorization/role.guard.ts, permissions.decorator.ts, packages/db/src/authorization/permission-catalog.tsGuard chain, the no-permission allowlist, the three catalogue modules.
Validationapps/api/src/main.tsValidationPipe with whitelist, forbidNonWhitelisted, transform.
Paginationapps/api/src/common/utils/pagination.util.tsDefaults, caps, metadata shape.
Jobs and Redispackages/jobs/src/index.ts, apps/api/src/services/bullmq/bull.module.tsQueue names, job names, payloads, per-queue attempts.
Swaggerapps/api/src/config/swagger/swagger-documents.tsWhich document each controller appears in.

2. Module Summary

FieldValue
Module namenotification (plus notification-feed, documented here because it shares the noun)
Module slugnotification
Primary actorsadmin, superadmin, teacher, guardian, student — every signed-in person. No guest actor.
API surfacesadmin, 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 modelJwtAuthGuard 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.
PersistencePostgreSQL (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 truthnotification_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 envelopeResponseDtomessage, data, optional count / currentPage / totalPage, errorCode (null on success). There is no success boolean.
Sibling docsBackend, Features and flows

Global request handling that applies to every route below:

  • ValidationPipe runs with whitelist: true, forbidNonWhitelisted: true and transform: true. An unknown body or query property is rejected with 400, 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.
  • ClassSerializerInterceptor is applied globally.

3. Concepts and Terminology

TermMeaningSource FileUsed By
EventOne 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
RecipientOne (event, person) pair. What a consumer lists and marks read.notification-recipient.ts/api/mobile/notifications
DeliveryOne attempt for one (recipient, channel, provider target). Carries the state machine.notification-delivery.ts/api/notification-history/{publicId}, /api/notification-failures
KindA registry key such as auth.password_reset or school.announcement. Not an FK — the registry is code.templates/template-registry.tsTemplate CRUD, history filters
CategoryWhat the notification is about. Nine values. The unit a person expresses a preference for.notification-contract.tsPreferences, history filters
Prioritylow / normal / high / critical. Orders work within a channel queue.notification-contract.tsCentre and history responses
Channelemail, sms, push or in_app. SSE is a transport, not a channel.notification-contract.tsEverywhere
Audience specificationWho an event is for, stored as a spec and resolved per batch — never a materialised list.shared/notification.types.tsHistory detail response
Template overrideAn operator-authored row overriding the code registry for one (kind, channel, locale). Never a replacement.notification-template.ts/api/notification-templates
Fan-outResolving an audience into recipient and delivery rows. Claimed before the work, completed after.workers/notification-fanout.processor.tsHistory response fields
Dead letterA 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
ReplayInserting a fresh delivery pointing at the original and scheduling it. The original is left untouched.admin/dead-letter/notification-failure.service.tsPOST .../replay
SkipA terminal, non-failure outcome: skipped_unconfigured, skipped_preference, skipped_no_template, skipped_no_destination. Excluded from every failure count.notification-contract.tsHistory failedDeliveryCount
Leaseclaimed_at plus lease_expires_at on a delivery, reclaimed by the reaper.shared/delivery-recorder.service.tsHistory 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 hintA masked address — 9779●●●●●123, j●●●@example.com. The only address form any response carries.channels/email.provider.ts, packages/smsHistory delivery response
Active roleThe 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.tsCentre and feed
Operational feednotification + 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 versionA 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

SurfaceMethodPathActorAuth/GuardPermissionControllerPurpose
AdminGET/api/notification-templatesAdminJwtAuthGuard, RoleGuardNotificationTemplate_READNotificationTemplateController.findAllList operator template overrides, newest first, optionally filtered by kind or channel.
AdminGET/api/notification-templates/{publicId}AdminSameNotificationTemplate_READ.findOneRead one override, including its version.
AdminPOST/api/notification-templatesAdminSameNotificationTemplate_CREATE.createCreate an override for one (kind, channel, locale).
AdminPATCH/api/notification-templates/{publicId}AdminSameNotificationTemplate_UPDATE.updateEdit an override under optimistic concurrency.
AdminDELETE/api/notification-templates/{publicId}AdminSameNotificationTemplate_DELETE.removeDelete an override under optimistic concurrency. Returns 200, and takes a body.
AdminGET/api/notification-historyAdminSameNotificationHistory_READNotificationHistoryController.findAllList events fanned out to real people, newest first by occurredAt.
AdminGET/api/notification-history/{publicId}AdminSameNotificationHistory_READ.findOneOne event with every recipient and every delivery.
AdminDELETE/api/notification-events/{publicId}AdminSameNotificationHistory_UPDATENotificationEventController.cancelCancel a scheduled event before it fans out. Returns 200.
AdminGET/api/notification-failuresAdminSameNotificationFailure_READNotificationFailureController.findAllList failed notification channel jobs. Hard-scoped to three queues.
AdminPOST/api/notification-failures/{publicId}/replayAdminSameNotificationFailure_UPDATE.replayInsert a fresh delivery pointing at the original and schedule it. Returns 201.
Admin feedGET/api/notificationsAny signed-in admin actorJwtAuthGuard, RoleGuardNone — allowlistedNotificationFeedController.listThe operational feed for the active role, newest first, with an unread count.
Admin feedPOST/api/notifications/{publicId}/readSameSameNone — allowlisted.markReadMark one feed row read for this caller. Returns 201.
Admin feedPOST/api/notifications/read-allSameSameNone — allowlisted.markAllReadMark everything currently visible read. Returns 201.
MobileGET/api/mobile/notificationsAny signed-in personSameNone — allowlistedNotificationCentreController.listThe consumer notification centre, paginated.
MobileGET/api/mobile/notifications/unread-countSameSameNone — allowlisted.unreadCountThe badge number.
MobilePOST/api/mobile/notifications/read-allSameSameNone — allowlisted.readAllMark every visible notification read. Returns 201.
MobilePOST/api/mobile/notifications/{publicId}/readSameSameNone — allowlisted.markReadMark one read and return it. Returns 201.
MobilePOST/api/mobile/notification-devicesSameSameNone — allowlistedNotificationDevicesController.registerRegister a push token for this session. Returns 201.
MobileDELETE/api/mobile/notification-devices/{publicId}SameSameNone — allowlisted.removeInvalidate one of the caller's own devices. Returns 200.
MobileGET/api/mobile/notification-preferencesSameSameNone — allowlistedNotificationPreferencesController.getThe resolved preference matrix plus its version.
MobilePUT/api/mobile/notification-preferencesSameSameNone — allowlisted.updateSave 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

SurfaceGuard/DecoratorIdentity ShapePermissionGuest AllowedNotes
Admin — templatesJwtAuthGuard, RoleGuard, @Permissions("NotificationTemplate_*")req.user via @CurrentUser(); actor.id is written to updated_byNotificationTemplate_READ / _CREATE / _UPDATE / _DELETENoThe activity interceptor reads the same @Permissions() value to derive the audited module.
Admin — historySame, @Permissions("NotificationHistory_READ")Not read by the serviceNotificationHistory_READNoRead-only. The response deliberately carries no recipient identity.
Admin — event cancelSame, @Permissions("NotificationHistory_UPDATE")Not read by the serviceNotificationHistory_UPDATENoGated 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 — failuresSame, @Permissions("NotificationFailure_*")@CurrentUser(); actor.id is written to replayed_byNotificationFailure_READ / _UPDATENoIts own module because replay is a write capability wearing a diagnostic name.
Admin feedJwtAuthGuard, RoleGuard, no @Permissions()@CurrentUser(); actor.id and actor.activeRolePer row, from notification.permissionNoOn NO_PERMISSION_ADMIN_HANDLERS.
Mobile — centre, devices, preferencesJwtAuthGuard, RoleGuard, no @Permissions()@CurrentAdmin(); actor.id and actor.activeRoleNoneNoOn 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.

FieldTypePresentNotes
messagestringAlwaysHuman-readable, e.g. "Notifications fetched."
dataTOn every route except where notednull on POST /api/notifications/{publicId}/read and DELETE template.
countnumberPaginated lists onlyTotal matching rows, not the page length.
currentPagenumberPaginated lists onlyEchoes the requested page.
totalPagenumberPaginated lists onlyMath.ceil(count / size).
nextCursorstring | nullNever in this moduleNo route here is cursor-paginated.
errorCodestring | nullAlwaysnull 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.

FieldTypeRequiredDefaultValidationExampleSource
paginationbooleanNotrueCoerced from a query stringtruecommon/dto/query.dto.ts
pagenumberNo1Positive integer; a non-finite or non-positive value falls back to the default2Same
sizenumberNo20Positive integer, clamped to MAX_PAGE_SIZE (100)50Same
sortstringNo"updatedAt"Same
order"asc" | "desc"No"desc"Same
searchstringNoSame

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

FieldTypeNullableNotes
publicIdstring (uuid)No
kindstringNoThe registry key this overrides.
channelstringNoOne of the four channels.
localestringNoDefaults to en at create.
subjectstringYesEmail only. Always null for sms, push and in_app.
bodystringNo
isActivebooleanNoAn inactive override is not loaded by the renderer.
versionnumberNoSend it back unchanged on PATCH and DELETE.
updatedBystring (uuid)Yesnull once that user is deleted.
createdAtDateNoISO 8601 on the wire.
updatedAtDateNoSame.

6.4 ListNotificationTemplatesQueryDto

Extends QueryDto.

FieldTypeRequiredDefaultValidationExample
kindstringNo@IsOptional, @IsString"school.announcement"
channelstringNo@IsIn(NOTIFICATION_CHANNEL)"email"

6.5 CreateNotificationTemplateDto

FieldTypeRequiredDefaultValidationExample
kindstringYes@IsString, @MinLength(1); additionally checked against the code registry in the service"school.announcement"
channelstringYes@IsIn(NOTIFICATION_CHANNEL)"email"
localestringNo"en"@IsString"ne"
subjectstringNonull@IsString"A message from school"
bodystringYes@IsString, @MinLength(1); the database additionally requires length(btrim(body)) > 0"Hello {{name}}, {{message}}"
isActivebooleanNotrue@IsBooleantrue

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

FieldTypeRequiredDefaultValidationExample
versionnumberYes@Type(() => Number), @IsInt, @Min(1)3
kindstringNoUnchanged@IsString, @MinLength(1), registry-checked"school.announcement"
channelstringNoUnchanged@IsIn(NOTIFICATION_CHANNEL)"sms"
localestringNoUnchanged@IsString"ne"
subjectstring | nullNoUnchanged@IsStringnull
bodystringNoUnchanged@IsString, @MinLength(1)"Updated copy"
isActivebooleanNoUnchanged@IsBooleanfalse

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

FieldTypeRequiredDefaultValidationExample
versionnumberYes@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.

FieldTypeRequiredDefaultValidationExample
kindstringNo@IsString"auth.password_reset"
categorystringNo@IsIn(NOTIFICATION_CATEGORY)"announcement"
fromstringNo@IsDateString; filters occurredAt >="2026-09-01T00:00:00.000Z"
tostringNo@IsDateString; filters occurredAt <="2026-09-30T23:59:59.999Z"

6.9 NotificationHistoryListItemDto

FieldTypeNullableNotes
publicIdstring (uuid)No
sourceModulestringNoWhich module raised it.
kindstringNo
categorystringNoOne of the nine.
prioritystringNoOne of the four.
scheduledForDateYesnull means immediate.
occurredAtDateNoWhen the thing happened, not when the row was written.
cancelledAtDateYesSet by the cancel route.
fannedOutAtDateYesnull while pending or in progress.
recipientCountnumberYesnull until fan-out completes. Moves with fannedOutAt in one statement.
unresolvedCountnumberYesAudience ids that matched nobody.
failedDeliveryCountnumberNoDeliveries currently failed or dead. Excludes every skipped_* status — an unconfigured channel is not a failure.
createdAtDateNo

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:

FieldTypeNullableNotes
actionUrlstringYeshttps: or a single-slash relative path. Re-validated at render.
actionLabelstringYesPaired with actionUrl by a CHECK.
requestedChannelsstring[]NoWhat the caller asked for, before preferences.
audienceobjectNoThe audience specification that was resolved — who to notify, not names.
recipientsNotificationHistoryRecipientDto[]NoOrdered by internal id.

6.11 NotificationHistoryRecipientDto

FieldTypeNullableNotes
publicIdstring (uuid)NoThe recipient row's own public id. Never the person's user id, name, email or phone.
audienceRoleIdnumberYesThe role this person was resolved through. null means not role-scoped.
readAtDateYesnull means unread.
createdAtDateNo
deliveriesNotificationHistoryDeliveryDto[]NoOrdered by internal id.

6.12 NotificationHistoryDeliveryDto

FieldTypeNullableNotes
publicIdstring (uuid)No
channelstringNo
providerTargetIdstringYesThe push token's public id. null for every single-target channel.
statusstringNoOne of the eleven delivery statuses.
providerstringYesnull until a provider has been chosen.
providerMessageIdstringYes
destinationHintstringYesMasked at write time. Never the full address.
attemptsnumberNoIncremented by a failure and by a lease reclaim.
failureCountnumberNoProvider failures only.
leaseExpiryCountnumberNoLease reclaims only. Non-zero means workers are dying, not that the provider is refusing.
lastErrorstringYesA code from a fixed table, truncated to 500. Never a raw provider response.
replayOfDeliveryIdnumberYesThe internal id of the row this replays.
queuedAtDateNo
sentAtDateYesLegitimately null on a delivered in-app row.
deliveredAtDateYes
failedAtDateYesCleared when a backoff elapses.
skippedAtDateYesSet for exactly the four skipped_* statuses.
nextAttemptAtDateYesWhen a failed row becomes eligible again.
createdAtDateNo

renderedTitle and renderedBody exist on the table and are never exposed here.

6.13 CancelledNotificationEventDto

FieldTypeNullableNotes
publicIdstring (uuid)No
cancelledAtDateNoAlways present on a successful response.

6.14 ListNotificationFailuresQueryDto

Extends QueryDto.

FieldTypeRequiredDefaultValidationExample
channel"email" | "sms" | "push"No@IsIn(NOTIFICATION_FAILURE_CHANNELS)"sms"
unreplayedOnlybooleanNo@QueryBoolean(), @IsBooleantrue

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

FieldTypeNullableNotes
publicIdstring (uuid)NoWhat POST .../replay addresses.
queueNamestringNoAlways one of the three notification channel queues on this surface.
jobNamestringNonotification_channel.send_email / _sms / _push.
jobIdstringYesBullMQ's own id.
payloadRefobjectYesHow to find the delivery this job was sending — never the rendered message, which may carry a live single-use token. Shape: { notificationDeliveryPublicId, channel }.
actorIdstring (uuid)YesAlways null for these rows; the recorder writes null.
attemptsnumberNoAt least 1.
lastErrorstringYesThe thrown message, truncated to 4000.
replayedAtDateYes
replayedBystring (uuid)Yes
replayJobIdstringYesFor a notification replay this holds the new delivery's public id, not a BullMQ job id.
failedAtDateNo

6.16 ReplayNotificationFailureResponseDto

Extends NotificationFailureDto and adds:

FieldTypeNullableNotes
newDeliveryPublicIdstring (uuid)NoThe 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

FieldTypeNullableNotes
publicIdstring (uuid)NoThe recipient row's public id, which POST .../{publicId}/read addresses.
kindstringNo
categoryNotificationCategoryNoOne of the nine.
priorityNotificationPriorityNoOne of the four.
titlestringYesFrom the in-app delivery's rendered_title, frozen at fan-out.
bodystringYesFrom rendered_body.
actionUrlstringYes
actionLabelstringYes
occurredAtDateNo
readAtDateYesnull 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

DTOFieldTypeNotes
UnreadNotificationCountDtocountnumberUnread across everything visible, not just a page.
MarkAllNotificationsReadResultDtomarkednumberRows whose read_at moved from null. Already-read rows are not counted.

6.19 RegisterNotificationDeviceDto and NotificationDeviceDto

FieldTypeRequiredDefaultValidationExample
tokenstringYes@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 fieldTypeNotes
publicIdstring (uuid)The only durable identifier the client needs, used to DELETE the device later.
platform"android" | "ios" | "web"Reflects the value just registered.
createdAtDateOn 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

FieldTypeNotes
versionnumber0 when the person has never saved. Send it back on PUT.
categoriesNotificationPreferenceCategoryDto[]Always all nine, in NOTIFICATION_CATEGORY order.

NotificationPreferenceCategoryDto:

FieldTypeNotes
categoryNotificationCategory
lockedbooleantrue when every registered kind in this category is unsuppressible. The UI must not offer a switch that can never do anything.
channelsRecord<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

FieldTypeRequiredValidationNotes
versionnumberYes@IsInt, @Min(0)The value last read from GET. 0 means "I read no row".
overridesNotificationPreferenceOverrideDto[]Yes@ValidateNested({ each: true }), @ArrayMaxSize(36)Categories times channels is the hard bound.

NotificationPreferenceOverrideDto:

FieldTypeRequiredValidation
categoryNotificationCategoryYes@IsIn(NOTIFICATION_CATEGORY)
channelNotificationChannelYes@IsIn(NOTIFICATION_CHANNEL)
enabledbooleanYes@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

FieldTypeNotes
publicIdstring (uuid)The notification row.
kindstringe.g. feedback.submitted.
summarystringAlready-safe summary text. Never a raw payload.
soundClassstringHow the panel should announce it, e.g. soft.
aggregateIdstringPublic id of the thing that changed.
occurredAtstringISO 8601. When it happened, not when it was recorded.
readbooleanRead 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.

FieldTypeRequiredDefaultValidation
limitnumberNo50@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

EnumValueMeaningRuntime EffectSource
NotificationChannelemailSent by Resend via EmailChannelProvider.A queued delivery on notification_email.notification-contract.ts
smsSent by the configured Nepali gateway.A queued delivery on notification_sms. Metered.Same
pushSent by FCM, one delivery per active token.N queued deliveries on notification_push.Same
in_appWritten inline by the fan-out worker. No send step, no queue, no provider.A delivered delivery with sent_at null and rendered content.Same
NotificationCategorysecurityAccount safety.May hold unsuppressible kinds. Defaults on for all four channels.Same
systemOperational notices.May hold unsuppressible kinds. SMS off by default.Same
assignmentCoursework.Push and in-app on by default.Same
announcementSchool-wide notices.Email, push and in-app on by default.Same
messagePerson-to-person.Push and in-app on by default.Same
paymentFees and billing.Email, push and in-app on by default.Same
attendanceDaily attendance.Push and in-app on by default.Same
eventCalendar items.Push and in-app on by default.Same
marketingPromotional.Off on every channel by default. Opt-in, not opt-out.Same
NotificationPrioritylowBullMQ priority 9.Least urgent within its queue.Same
normalBullMQ priority 5.The default on the event row.Same
highBullMQ priority 3.Same
criticalBullMQ priority 1.Most urgent within its queue. Does not preempt a running job.Same
DeliveryStatusqueuedWritten by the fan-out worker. The only entry state.Claimable by a channel worker.Same
processingClaimed under a lease.Reclaimed by the reaper past lease_expires_at.Same
sentThe provider accepted it. Not proof it arrived.Terminal today; would advance to delivered on a receipt.Same
deliveredConfirmed arrival.Reachable synchronously only for in_app.Same
failedRetryable failure.Returned to queued when its backoff elapses.Same
deadAttempts exhausted, or a failure the provider says will never succeed.Terminal. Counted by failedDeliveryCount.Same
skipped_unconfiguredNo 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_preferenceThe recipient's preferences suppressed this channel.Terminal. Excluded from the notification centre.Same
skipped_no_templateNo builder for this (kind, channel), or an unresolved variable.Terminal.Same
skipped_no_destinationNo address, no active token, an expired secret, or the recipient is gone.Terminal.Same
cancelledThe event was cancelled before this delivery was claimed.Terminal. Excluded from the notification centre.Same
AudienceKindusersAn explicit list of user public ids, capped at 500.Resolved directly.Same
roleEveryone holding the named roles.Yields that role as audienceRoleId.Same
classPupils with an active enrolment in the named classes.Yields their student-scoped role.Same
sectionThe same, through sections.Same.Same
gradeThe same, through grades.Same.Same
guardians_of_classThe guardians of those pupils, via student_guardian.Yields their guardian-scoped role.Same
guardians_of_usersThe guardians of the named students.Same.Same
staff_departmentStaff in the named departments.audienceRoleId is null.Same
all_usersEvery live, loginable user.audienceRoleId is null.Same
compoundA union of 1–10 of the above. Depth-1 by type.Unioned in SQL and paged as one set.Same
PushPlatformandroidA mobile installation.FCM gets the plain notification block; the client reads data.actionUrl.Same
iosA mobile installation.Same.Same
webA 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
PushTokenInvalidationReasonunregisteredFCM says the token is dead.Non-retryable; the token row is deactivated.Same
invalid_argumentFCM refused the message shape or a non-https web link.Non-retryable; the token row is deactivated.Same
user_logoutThe caller deleted their own device.Set by DELETE /api/mobile/notification-devices/{publicId}.notification-devices.service.ts
replacedThe token arrived for a different user.The old row is invalidated and a fresh one inserted — never reassigned.Same
cap_exceededReserved 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

EvidencePath
Controllerapps/api/src/modules/notification/admin/template/notification-template.controller.ts
DTOapps/api/src/modules/notification/admin/template/notification-template.dto.ts
Serviceapps/api/src/modules/notification/admin/template/notification-template.service.ts
Schemapackages/db/src/schema/notification/notification-template.ts
Testsapps/api/src/modules/notification/admin/template/notification-template.service.int.spec.ts

Auth and Permissions

  • Auth: admin JWT.
  • Guard chain: JwtAuthGuard then RoleGuard.
  • Permission: NotificationTemplate_READ.
  • Guest support: none.
  • Rate limit: none declared.
  • Idempotency: read-only.

Request

PartRequiredDetails
HeadersYesAuthorization: Bearer <token>
ParamsNo
QueryNoListNotificationTemplatesQueryDtokind, channel, plus QueryDto's pagination, page (1), size (20, max 100), and the unused sort, order, search.
BodyNo

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 a COUNT(*) 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 StatusError CodeConditionUser-Facing MeaningSource
400PAGINATION_LIMIT_INVALIDpagination=falseThis list must be paginated.notification-template.service.ts
400An unknown query property, or channel outside the vocabularyFix the request. forbidNonWhitelisted rejects unknown properties.main.ts, the DTO
401No or invalid tokenSign in.JwtAuthGuard
403The active role lacks NotificationTemplate_READAsk for the permission.RoleGuard

Edge Cases

  • Empty input — no filters returns every override.
  • Blank searchsearch is accepted and ignored.
  • Invalid enum — a channel outside the four is a 400 from @IsIn.
  • size above 100 — clamped to 100 rather than rejected.
  • page beyond the end — an empty data array with a truthful count.
  • Unstable sort — impossible: id DESC is the tie-break, because two overrides saved in the same millisecond share a createdAt.
  • Unsupported sort optionsort and order do not change the ordering.

Example Requests

GET /api/notification-templates?kind=school.announcement&channel=email&page=1&size=20 HTTP/1.1
Authorization: Bearer TOKEN
curl -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. Permission NotificationTemplate_READ. No guest support, no rate limit, read-only.

Request

PartRequiredDetails
HeadersYesAuthorization
ParamsYespublicId — the template's uuid. No ParseUUIDPipe, so a malformed value reaches the query and answers 404 rather than 400.
QueryNo
BodyNo

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 StatusError CodeConditionUser-Facing MeaningSource
404NOTIFICATION_TEMPLATE_NOT_FOUNDNo row with that public id, or the id is malformedReload the list.notification-template.service.ts
401 / 403As aboveGuards

Edge Cases

  • subject is null for every non-email channel and is not an error.
  • updatedBy is null for 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. Permission NotificationTemplate_CREATE. No guest support. No idempotency key — a second identical request violates the (kind, channel, locale) unique constraint.

Request

PartRequiredDetails
HeadersYesAuthorization, Content-Type: application/json
ParamsNo
QueryNo
BodyYesCreateNotificationTemplateDto

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_template row. version starts at 1; updatedBy is set from the session.
  • Audit: the global ActivityAuditInterceptor records 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 StatusError CodeConditionUser-Facing MeaningSource
400NOTIFICATION_TEMPLATE_INVALIDkind is not a key the code registry knowsChoose a known kind.assertKnownKind
400Missing kind, channel or body; channel outside the four; an unknown propertyFix the body.ValidationPipe
409A row already exists for that (kind, channel, locale) — a raw unique violationEdit the existing override instead.notification_template_kind_channel_locale_key
500SYS_INTERNAL_ERRORRETURNING produced no row. Unreachable in PostgreSQL.Report it.notification-template.service.ts
401 / 403As aboveGuards

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.
  • subject on 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. Permission NotificationTemplate_UPDATE. The request object is read for the activity context.

Request

PartRequiredDetails
HeadersYesAuthorization, Content-Type
ParamsYespublicId. No ParseUUIDPipe; a malformed value answers 404.
QueryNo
BodyYesUpdateNotificationTemplateDto. 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_template row, with version = version + 1 and updatedBy set from the session.
  • Audit: two records. The global interceptor covers the mutation, and the service additionally calls ActivityRecordService.recordActivity with per-field changes for kind, channel, locale, subject, body and isActive. This is the one caller that computes changes by hand, because the interceptor has no before-value.
  • Rendering effect: takes effect on the next fan-out batch.

Error Cases

HTTP StatusError CodeConditionUser-Facing MeaningSource
404NOTIFICATION_TEMPLATE_NOT_FOUNDNo such rowReload.findRawByPublicId
400NOTIFICATION_TEMPLATE_INVALIDA supplied kind is not a registry keyChoose a known kind.assertKnownKind
409NOTIFICATION_TEMPLATE_VERSION_CONFLICTversion no longer matches — somebody committed a change, or a delete, since the caller read itReload and try again.The compare-and-set
400version missing, below 1, or not an integer; an unknown propertyFix the body.ValidationPipe
409The edit collides with another row's (kind, channel, locale)Choose a different slot.The unique constraint
401 / 403As aboveGuards

Edge Cases

  • Omitted versus null subject — omitting it keeps the current value; sending null clears it. Every other optional field only supports "omitted keeps".
  • Race condition — two PATCHes with the same version: the first wins, the second is a 409. The response body of the winner carries the new version.
  • Concurrent delete — a PATCH whose version was invalidated by a DELETE reports 409, not 404, because the compare-and-set matched zero rows before the row lookup could notice it had gone.
  • No-op edit — sending only version succeeds, bumps version, and records an activity entry with an empty changes set.

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. Permission NotificationTemplate_DELETE. @HttpCode(HttpStatus.OK) overrides Nest's DELETE default.

Request

PartRequiredDetails
HeadersYesAuthorization, Content-Type
ParamsYespublicId
QueryNo
BodyYesDeleteNotificationTemplateDto{ "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 StatusError CodeConditionUser-Facing MeaningSource
404NOTIFICATION_TEMPLATE_NOT_FOUNDNo such rowReload.findRawByPublicId
409NOTIFICATION_TEMPLATE_VERSION_CONFLICTversion no longer matchesReload and try again.The compare-and-set
400Missing or invalid versionSend the version read from GET.ValidationPipe
401 / 403As aboveGuards

Edge Cases

  • Deleting the last override for a kind — entirely safe. The shipped copy takes over.
  • Deleting an inactive override — permitted; isActive is irrelevant to the delete.
  • Duplicate action — the second DELETE answers 404.
  • Concurrent edit — a PATCH that committed first makes this 409.

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

EvidencePath
Controllerapps/api/src/modules/notification/admin/history/notification-history.controller.ts
DTOapps/api/src/modules/notification/admin/history/notification-history.dto.ts
Serviceapps/api/src/modules/notification/admin/history/notification-history.service.ts
Schemapackages/db/src/schema/notification/notification-event.ts, notification-delivery.ts
Testsapps/api/src/modules/notification/admin/history/notification-history.service.int.spec.ts

Auth and Permissions

  • Auth: admin JWT. Guards JwtAuthGuard, RoleGuard. Permission NotificationHistory_READ. Read-only, no guest support, no rate limit.

Request

PartRequiredDetails
HeadersYesAuthorization
ParamsNo
QueryNoListNotificationHistoryQueryDtokind, category, from, to, plus QueryDto's pagination fields.
BodyNo

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 StatusError CodeConditionUser-Facing MeaningSource
400PAGINATION_LIMIT_INVALIDpagination=falseThis list must be paginated.notification-history.service.ts
400category outside the nine; from or to not an ISO date string; an unknown propertyFix the request.ValidationPipe
401 / 403As aboveGuards

Edge Cases

  • A pending eventfannedOutAt, recipientCount and unresolvedCount are all null together, by constraint.
  • A cancelled eventcancelledAt set, fannedOutAt null, and it will never fan out.
  • A zero-recipient fan-outfannedOutAt set with recipientCount: 0. Legal: the audience matched nobody.
  • failedDeliveryCount versus visible failures — it counts only failed and dead. An event with every delivery skipped_unconfigured reports 0, which is correct: an unconfigured channel is not an outage.
  • from after to — accepted; returns nothing.
  • Empty resultdata: [] with a truthful count.

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

PartRequiredDetails
HeadersYesAuthorization
ParamsYespublicId — the event's uuid. No ParseUUIDPipe; a malformed value answers 404.
QueryNo
BodyNo

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 StatusError CodeConditionUser-Facing MeaningSource
404NOTIFICATION_EVENT_NOT_FOUNDNo such event, or a malformed idReload the list.findRawEventByPublicId
401 / 403As aboveGuards

Edge Cases

  • A very large event — recipients and deliveries are not paginated on this endpoint. An all_users announcement 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 replayOfDeliveryId set 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 of recipientCount: 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

EvidencePath
Controllerapps/api/src/modules/notification/admin/event/notification-event.controller.ts
DTOapps/api/src/modules/notification/admin/event/notification-event.dto.ts
Serviceapps/api/src/modules/notification/admin/event/notification-event.service.ts
Schemapackages/db/src/schema/notification/notification-event.ts
Testsapps/api/src/modules/notification/admin/event/notification-event.service.int.spec.ts

Auth and Permissions

  • Auth: admin JWT. Guards JwtAuthGuard, RoleGuard. Permission NotificationHistory_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

PartRequiredDetails
HeadersYesAuthorization
ParamsYespublicId, validated by ParseUUIDPipe — a malformed value is 400, not 404.
QueryNo
BodyNo

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 on fanned_out_at IS NULL AND cancelled_at IS NULL.
  • Downstream effect: the fan-out worker's own claim carries cancelled_at IS NULL in 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 StatusError CodeConditionUser-Facing MeaningSource
400publicId is not a uuidFix the id.ParseUUIDPipe
404NOTIFICATION_EVENT_NOT_FOUNDNo such eventReload.notification-event.service.ts
409NOTIFICATION_EVENT_CANCELLEDAlready cancelledReload.The re-read after a zero-row claim
409NOTIFICATION_EVENT_ALREADY_FANNED_OUTThe fan-out won the raceNothing to do; the messages have gone.The same
401 / 403As aboveGuards

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 event409, never a silent success.
  • A partially fanned-out eventfanned_out_at is 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 still queued at 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

EvidencePath
Controllerapps/api/src/modules/notification/admin/dead-letter/notification-failure.controller.ts
DTOapps/api/src/modules/notification/admin/dead-letter/notification-failure.dto.ts
Serviceapps/api/src/modules/notification/admin/dead-letter/notification-failure.service.ts
Schemapackages/db/src/schema/jobs/job-failures.ts
Writerapps/api/src/common/jobs/job-failure-recorder.service.ts
Testsapps/api/src/modules/notification/admin/dead-letter/notification-failure.service.int.spec.ts

Auth and Permissions

Admin JWT, JwtAuthGuard, RoleGuard, NotificationFailure_READ.

Request

PartRequiredDetails
HeadersYesAuthorization
ParamsNo
QueryNoListNotificationFailuresQueryDtochannel, unreplayedOnly, plus QueryDto's pagination fields.
BodyNo

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 StatusError CodeConditionUser-Facing MeaningSource
400PAGINATION_LIMIT_INVALIDpagination=falseThis list must be paginated.notification-failure.service.ts
400channel outside email/sms/push; an unknown propertyFix the request.ValidationPipe
401 / 403As aboveGuards

Edge Cases

  • in_app is not a valid channel — it has no queue, so it can never appear here.
  • Widening is impossible — the three-queue allowlist is ANDed unconditionally. A row from backup_restore or 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 holding NotificationFailure_READ.
  • actorId is always null — the recorder writes null; the failing job had no human actor.
  • payloadRef may be null — for a job whose payload carried no delivery id. Such a row cannot be replayed.
  • lastError is 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 their payloadRef has 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. Permission NotificationFailure_UPDATE.
  • actor.id is recorded as replayedBy.
  • Idempotency: structural. The job_failures.replayed_at compare-and-set happens inside the same transaction, so two concurrent replays cannot both succeed.

Request

PartRequiredDetails
HeadersYesAuthorization
ParamsYespublicId of the failure row, validated by ParseUUIDPipe.
QueryNo
BodyNo

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_delivery row: same recipientId, channel, providerTargetId, destinationHint, renderedTitle, renderedBody; status: "queued"; replayOfDeliveryId pointing at the original. Both delivery uniqueness indexes carry AND 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_events row with dedupeKey: "<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 by replayed_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 StatusError CodeConditionUser-Facing MeaningSource
400publicId is not a uuidFix the id.ParseUUIDPipe
404JOB_FAILURE_NOT_FOUNDNo failure row with that idReload.notification-failure.service.ts
409JOB_FAILURE_QUEUE_NOT_PERMITTEDThe row belongs to a queue outside the threeUse 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
409JOB_FAILURE_ALREADY_REPLAYEDreplayedAt is set, or a concurrent request claimed it firstReload.Same
404NOTIFICATION_DELIVERY_NOT_FOUNDpayloadRef carries no notificationDeliveryPublicId, or the delivery it names is goneNothing to replay — retention may have removed it.Same
500SYS_INTERNAL_ERRORThe replay insert produced no row. Unreachable in PostgreSQL.Report it.Same
401 / 403As aboveGuards

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 delivery404 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 requires is_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

EvidencePath
Controllerapps/api/src/modules/notification-feed/notification-feed.controller.ts
DTOapps/api/src/modules/notification-feed/dto/notification.dto.ts
Serviceapps/api/src/modules/notification-feed/notification-feed.service.ts
Schemapackages/db/src/schema/notifications.ts
Testsapps/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

PartRequiredDetails
HeadersYesAuthorization
ParamsNo
QueryNolimit — integer 1–200, default 50. Not QueryDto: this is a bounded window, not an offset-paginated list.
BodyNo

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, then notification left-joined to this caller's notification_read receipts.
  • A second query for unreadCount.
  • Nothing is written.

Error Cases

HTTP StatusError CodeConditionUser-Facing MeaningSource
400limit outside 1–200, or an unknown query propertyFix the request.ValidationPipe
401 / 403No token, or RoleGuard refuses the sessionSign in.Guards

Edge Cases

  • A session with no active role — the permission list is empty, which becomes the SQL literal false rather than "no filter". The feed is empty, matching what GET /auth/permissions reports, 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 (...).
  • read is per caller — the left join carries userId = actor.id. Joining without it would mark a row read because somebody else read it.
  • unreadCount versus the page — counted across everything visible, so it can exceed limit.
  • 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

PartRequiredDetails
HeadersYesAuthorization
ParamsYespublicId, validated by ParseUUIDPipe({ version: "7" }) — a v4 uuid is 400.
QueryNo
BodyNo

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 StatusError CodeConditionUser-Facing MeaningSource
400Not a v7 uuidFix the id.ParseUUIDPipe
404NOTIFICATION_NOT_FOUNDAbsent, or not visible to this callerReload.notification-feed.service.ts
401 / 403As aboveGuards

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 StatusError CodeConditionUser-Facing MeaningSource
401 / 403As aboveGuards

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

EvidencePath
Controllerapps/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
Schemapackages/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

PartRequiredDetails
HeadersYesAuthorization
ParamsNo
QueryNoListNotificationsQueryDto, which is QueryDto unchanged — pagination, page, size, plus the inherited-but-unused sort, order, search.
BodyNo

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 StatusError CodeConditionUser-Facing MeaningSource
400PAGINATION_LIMIT_INVALIDpagination=falseThe centre cannot be read unpaginated.notification-centre.service.ts
400An unknown query propertyFix the request.ValidationPipe
401 / 403As aboveGuards

Edge Cases

  • Fixed ordersort and order are accepted and ignored. The order is fixed so two events fanned out in one transaction cannot be dropped or duplicated under offset paging.
  • size above 100 — clamped to 100.
  • A teacher who is also a parent — viewing as Guardian, staff-audience rows are excluded, because audience_role_id must be null or equal the active role.
  • A session with no active rolex = NULL is 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 users row, so ownership and active-role scoping alone would keep serving them disciplinary and roster notifications. The staff-liveness EXISTS is what stops that.
  • Email-only and SMS-only notifications never appear, and neither do channels the person suppressed — the in-app EXISTS excludes skipped_preference and cancelled.
  • title and body are 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 statedata: [], 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 UPDATE carries read_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

PartRequiredDetails
HeadersYesAuthorization
ParamsYespublicId of the recipient row. No ParseUUIDPipe; a malformed value answers 404.
QueryNo
BodyNo

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 StatusError CodeConditionUser-Facing MeaningSource
404NOTIFICATION_NOT_FOUNDAbsent, owned by somebody else, scoped to a role the caller is not acting as, or with no qualifying in-app deliveryReload.notification-centre.service.ts
401 / 403As aboveGuards

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 twicecoalesce keeps the original timestamp, so readAt does not move.
  • A row whose only channels were email or SMS — invisible to the centre, so 404.
  • A row the person suppressedskipped_preference is excluded by the predicate, so 404.
  • 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

EvidencePath
Controllerapps/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
Schemapackages/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_id is never taken from the body. RoleGuard runs no permission check here, so keying every write on actor.id is the only remaining control.

Request

PartRequiredDetails
HeadersYesAuthorization, Content-Type
ParamsNo
QueryNo
BodyYesRegisterNotificationDeviceDto
{ "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:

CaseEffect
The active token already belongs to this userA 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 userThat 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 newA 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 StatusError CodeConditionUser-Facing MeaningSource
400NOTIFICATION_DEVICE_LIMIT_REACHEDAlready at 10 active tokensRemove a device before adding another.notification-devices.service.ts
400token shorter than 8 or longer than 4096; platform outside the three; an unknown property such as userIdFix the body.ValidationPipe
401 / 403As aboveGuards

Edge Cases

  • Re-registering the same token — a touch, and a 201 with the original createdAt. 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 — could POST it and silently stop the victim's push, including the security notifications that cannot otherwise be switched off.
  • Web push has no user_device row, so user_device_id is null. A browser has no installation.
  • Platform matters at send time: web gets FCM's webpush block with a TTL header and, when the notification's action URL is https, fcmOptions.link — the only thing that decides what a click opens when every tab is closed. android and ios get the plain notification block and read data.actionUrl in their own handler.
  • A token FCM later declares dead is deactivated automatically by the send path with reason unregistered or invalid_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

PartRequiredDetails
HeadersYesAuthorization
ParamsYespublicId of the device row. No ParseUUIDPipe; a malformed value answers 404.
QueryNo
BodyNo

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 StatusError CodeConditionUser-Facing MeaningSource
404NOTIFICATION_DEVICE_NOT_FOUNDNo active token with that id for this userReload the device list.notification-devices.service.ts
401 / 403As aboveGuards

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_destination at 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

EvidencePath
Controllerapps/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
Resolverapps/api/src/modules/notification/shared/preference-resolver.service.ts
Schemapackages/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: 0 means no row exists yet. It is a real state, not an error, and the first PUT must send 0.
  • locked: true means every registered kind in that category is unsuppressible, so the switch would have no effect on delivery. The UI must not offer it. Today only security is locked; system permits 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 200PUT has no 201 default in Nest.

Request

PartRequiredDetails
HeadersYesAuthorization, Content-Type
ParamsNo
QueryNo
BodyYesUpdateNotificationPreferencesDto

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:

  1. The version claim. version === 0 is an INSERT ... onConflictDoNothing on notification_preference_set at version 1, which must produce a row. Any other value is an UPDATE ... SET version = version + 1 WHERE version = $expected, which must match. Either producing zero rows is a 409.
  2. The overrides. Each surviving entry is an upsert into notification_preference on (user_id, category, channel).
  3. 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 StatusError CodeConditionUser-Facing MeaningSource
409NOTIFICATION_PREFERENCE_VERSION_CONFLICTA stale version, or version: 0 when a row already existsRe-GET and try again.notification-preferences.service.ts
400version missing or negative; more than 36 overrides; an unknown category or channel; a non-boolean enabled; an unknown propertyFix the body.ValidationPipe
401 / 403As aboveGuards

Edge Cases

  • An override for a locked category is accepted and silently not persisted. 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. The response shows the category still locked and still fully enabled.
  • Duplicate (category, channel) entries — de-duplicated last-write-wins, not rejected.
  • An empty overrides array — 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 409 with 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.

EndpointPagination TypeDefault SizeMax SizeSort FieldsFiltersResult Cap
GET /api/notification-templatespage/size20100Fixed createdAt DESC, id DESCkind, channelNone beyond the page
GET /api/notification-historypage/size20100Fixed occurredAt DESC, id DESCkind, category, from, toNone beyond the page
GET /api/notification-history/{publicId}NoneRecipients and deliveries ordered by internal idUnbounded — every recipient and every delivery
GET /api/notification-failurespage/size20100Fixed failedAt DESC, id DESCchannel, unreplayedOnly, plus a hard three-queue allowlistNone beyond the page
GET /api/notificationsLimit only50200Fixed occurredAt DESC, id DESCImplicit: the caller's active-role permissions200
GET /api/mobile/notificationspage/size20100Fixed occurredAt DESC, recipientId DESCImplicit: ownership, active role, staff liveness, in-app deliveryNone 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

IntegrationUsed?DetailsSource
Redis read cacheNoEvery 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 vaultYes, indirectlynotification: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/subYes, indirectlyrealtime:user:<userId>. Published after each fan-out batch commits, carrying recipientPublicId, kind, category, priority, occurredAtno 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
BullMQYesnotification_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 outboxYesEvery 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, asynchronouslyThrough 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, asynchronouslySelected 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, asynchronouslyOne 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
MongoDBNoNot 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:

CapValueApplies to
Explicit recipients per event500Internal callers of NotificationService.send
Compound audience members1–10The same
Active push tokens per user10POST /api/mobile/notification-devices
Preference overrides per request36PUT /api/mobile/notification-preferences
Page size100Every QueryDto list
Feed limit200GET /api/notifications
Delivery attempts5The delivery row's own budget
History retention30 daysEverything reachable through GET /api/notification-history
Push token retention180 days after invalidationDevice 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.

RouteController MethodDTOsService MethodPermissionsCacheJobsDB TouchesErrorsTestsDocumented?
GET /api/notification-templatesNotificationTemplateController.findAllListNotificationTemplatesQueryDto, NotificationTemplateResponseDto.findAllNotificationTemplate_READN/AN/Anotification_templatePAGINATION_LIMIT_INVALIDnotification-template.service.int.spec.tsYes
GET /api/notification-templates/{publicId}.findOneNotificationTemplateResponseDto.findByIdNotificationTemplate_READN/AN/Anotification_templateNOTIFICATION_TEMPLATE_NOT_FOUNDSameYes
POST /api/notification-templates.createCreateNotificationTemplateDto, NotificationTemplateResponseDto.createNotificationTemplate_CREATEN/AN/Anotification_template insert; activityNOTIFICATION_TEMPLATE_INVALID, SYS_INTERNAL_ERROR, unique violationSameYes
PATCH /api/notification-templates/{publicId}.updateUpdateNotificationTemplateDto, NotificationTemplateResponseDto.updateNotificationTemplate_UPDATEN/AN/Anotification_template update; explicit activity record with changesNOTIFICATION_TEMPLATE_NOT_FOUND, _INVALID, _VERSION_CONFLICTSameYes
DELETE /api/notification-templates/{publicId}.removeDeleteNotificationTemplateDto.removeNotificationTemplate_DELETEN/AN/Anotification_template delete; activityNOTIFICATION_TEMPLATE_NOT_FOUND, _VERSION_CONFLICTSameYes
GET /api/notification-historyNotificationHistoryController.findAllListNotificationHistoryQueryDto, NotificationHistoryListItemDto.findAllNotificationHistory_READN/AN/Anotification_event, notification_recipient, notification_deliveryPAGINATION_LIMIT_INVALIDnotification-history.service.int.spec.tsYes
GET /api/notification-history/{publicId}.findOneNotificationHistoryDetailDto, ...RecipientDto, ...DeliveryDto.findByIdNotificationHistory_READN/AN/AThe same three tablesNOTIFICATION_EVENT_NOT_FOUNDSameYes
DELETE /api/notification-events/{publicId}NotificationEventController.cancelCancelledNotificationEventDto.cancelNotificationHistory_UPDATEN/AN/Anotification_event.cancelled_at; activityNOTIFICATION_EVENT_NOT_FOUND, _CANCELLED, _ALREADY_FANNED_OUT, 400 from ParseUUIDPipenotification-event.service.int.spec.tsYes
GET /api/notification-failuresNotificationFailureController.findAllListNotificationFailuresQueryDto, NotificationFailureDto.findAllNotificationFailure_READN/AN/Ajob_failuresPAGINATION_LIMIT_INVALIDnotification-failure.service.int.spec.tsYes
POST /api/notification-failures/{publicId}/replay.replayReplayNotificationFailureResponseDto.replayNotificationFailure_UPDATEN/Anotification_channel.send_* via outbox_eventsjob_failures, notification_delivery, outbox_eventsJOB_FAILURE_NOT_FOUND, _QUEUE_NOT_PERMITTED, _ALREADY_REPLAYED, NOTIFICATION_DELIVERY_NOT_FOUND, SYS_INTERNAL_ERROR, 400 from ParseUUIDPipeSameYes
GET /api/notificationsNotificationFeedController.listListNotificationsQueryDto (feed), NotificationListDto, NotificationDto.list, .unreadCountNone — allowlisted; per-row notification.permissionN/AN/Anotification, notification_read, role permissionsValidation onlynotification-feed.service.int.spec.tsYes
POST /api/notifications/{publicId}/read.markRead.markReadNone — allowlistedN/AN/Anotification, notification_read insertNOTIFICATION_NOT_FOUND, 400 from ParseUUIDPipe({ version: "7" })SameYes
POST /api/notifications/read-all.markAllRead.markAllReadNone — allowlistedN/AN/Anotification, notification_read bulk insertSameYes
GET /api/mobile/notificationsNotificationCentreController.listListNotificationsQueryDto (centre), NotificationCentreItemDto.listNone — allowlisted; service predicateN/AN/Anotification_recipient, notification_event, role, staff, notification_deliveryPAGINATION_LIMIT_INVALIDnotification-centre.service.int.spec.tsYes
GET /api/mobile/notifications/unread-count.unreadCountUnreadNotificationCountDto.unreadCountNone — allowlistedN/AN/AThe same tablesSameYes
POST /api/mobile/notifications/read-all.readAllMarkAllNotificationsReadResultDto.markAllReadNone — allowlistedN/AN/Anotification_recipient.read_atSameYes
POST /api/mobile/notifications/{publicId}/read.markReadNotificationCentreItemDto.markReadNone — allowlistedN/AN/Anotification_recipient.read_atNOTIFICATION_NOT_FOUNDSameYes
POST /api/mobile/notification-devicesNotificationDevicesController.registerRegisterNotificationDeviceDto, NotificationDeviceDto.registerNone — allowlisted; keyed on actor.idN/AN/Anotification_push_tokenNOTIFICATION_DEVICE_LIMIT_REACHEDnotification-devices.service.int.spec.tsYes
DELETE /api/mobile/notification-devices/{publicId}.remove.removeNone — allowlisted; scoped to actor.idN/AN/Anotification_push_tokenNOTIFICATION_DEVICE_NOT_FOUNDSameYes
GET /api/mobile/notification-preferencesNotificationPreferencesController.getNotificationPreferencesDto, NotificationPreferenceCategoryDto.getNone — allowlistedN/AN/AThree preference tablesnotification-preferences.service.int.spec.tsYes
PUT /api/mobile/notification-preferences.updateUpdateNotificationPreferencesDto, NotificationPreferenceOverrideDto.updateNone — allowlistedN/AN/Anotification_preference_set, notification_preferenceNOTIFICATION_PREFERENCE_VERSION_CONFLICTSameYes

Decorators and pipes that change behaviour:

ElementWhereEffect
ValidationPipe({ whitelist, forbidNonWhitelisted, transform })GlobalAn unknown body or query property is a 400, not silently stripped. Query strings are coerced to the DTO's declared types.
ClassSerializerInterceptorGlobalApplies serialization decorators to responses.
setGlobalPrefix("api")GlobalEvery path is prefixed. health/live and health/ready are excluded.
RouterModule.register({ path: "mobile", children })MobileModuleThe three consumer leaves are mounted under /api/mobile. Does not recurse through an aggregate.
@HttpCode(HttpStatus.OK)Template DELETE, event DELETEReturns 200 with a body instead of Nest's DELETE default.
ParseUUIDPipeEvent cancel, failure replayA malformed id is 400.
ParseUUIDPipe({ version: "7" })Feed markReadA non-v7 uuid is 400.
No pipe on publicIdTemplate routes, centre markRead, device removeA malformed id reaches the query and answers 404.
@CurrentUser() / @CurrentAdmin()Every handler that needs identitySupplies actor.id and actor.activeRole.
@Permissions(...)Admin handlers onlyRead by RoleGuard and by the activity interceptor, which derives the audited module from it.
NO_PERMISSION_ADMIN_HANDLERSrole.guard.tsEleven handlers here. RoleGuard returns true before reading request.user.
ApiPaginatedResponseDto / ApiResponseDtoSwagger onlyDocuments 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 TypeCovered where applicableNotes
Minimal valid request8.3, 8.4, 8.18, 8.21Smallest body the DTO accepts.
Full valid requestThe same sectionsEvery optional field with a realistic value.
Public/guest requestNot applicableNo route in this module is public. An unauthenticated request is 401 everywhere.
Authenticated requestEvery sectionAuthorization: Bearer TOKEN. Identity affects visibility on every consumer and feed route.
Admin request8.18.10Each names its exact permission.
Success responseEvery sectionFull envelope, every nullable field shown.
Empty-list response8.1With pagination metadata.
Validation errorEvery error tableRepresentative 400s.
Domain errorEvery error tableExact code and condition.
Auth / permission errorEvery error table401 and 403.
Conflict8.4, 8.8, 8.10, 8.21With the exact response body.
Rate-limit errorNot applicableNo 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

DiagramSectionPurpose
Route ownership graph9.1Actors, controllers, services, infrastructure.
Sequence per endpoint family9.2, 9.7Request through to response, and what happens after a 201.
Activity diagram for a write9.6Validation branches and side effects for the most complex mutation.
Error decision tree9.3Validation, auth, not-found, conflict.
Auth and permission flow9.4Guard ordering and the allowlist branch.
Data contract map9.5Request DTO to service to response DTO.
Cache flow9.8Stated as absent, with what Redis is used for instead.
Async/job flow9.7Producer, outbox, queue, worker, provider.
Realtime/event flow11The per-user channel and its deliberately thin payload.

13.4 Consumer Integration Notes

ConsumerRequired KnowledgeFailure HandlingContract Stability
Admin panelThe 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 bellThat 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 — centreThat 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 — devicesThat 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 — preferencesThat 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
QAThat 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

DecisionChosen BehaviorAlternatives ConsideredWhy This TradeoffRiskMitigation
Pagination styleOffset page/size with a fixed, id-tied orderCursor paginationThe platform's shared QueryDto and metadata shape; these lists are browsed, not streamedDeep pages are expensivesize capped at 100; the tie-break makes paging stable
pagination=false refusedAlways 400Allow it under UNPAGINATED_HARD_CAPThese tables only grow; no ceiling makes an unbounded read safeA client wanting everything must pageDocumented per endpoint
History detail unpaginatedReturns every recipient and deliveryPaginate the childrenThe screen exists to show the whole picture for one eventA large announcement is a large payloadThe list endpoint carries counts for summary use
Optimistic concurrency on templates and preferencesA version that must round-tripLast write wins, or pessimistic lockingTwo operators in one office, and two devices for one parent, are both realClients must handle 409The conflict message names the remedy
DELETE with a bodyTemplate delete carries versionA query parameter, or no guard at allA delete must not silently win over a concurrent editUnusual for DELETE; some clients strip bodiesDocumented explicitly
404 rather than 403 for an invisible rowEverywhere an id is addressed403Public ids are uuids, and a 403 confirms existenceAn operator cannot distinguish absent from forbiddenStated in this doc and in the code
No permission on eleven handlersThe service predicate is the controlA blanket Notification_READGuardians and students hold no admin permission; the feed's permission is per rowA future handler added to the allowlist without a predicate is openThe allowlist is explicit and reviewed
Feed limit, not paginationlimit 1–200QueryDtoIt is a bounded newest-first window for a bell, not a browsable listNo way to page back beyond 200Retention bounds the table anyway
Async everythingA 201 means scheduledSend synchronously and return the outcomeA three-thousand-parent fan-out cannot run inside a requestA client cannot confirm delivery from the responseThe history detail endpoint is the outcome surface
Skips excluded from failedDeliveryCountOnly failed and dead countCount every non-successAn unconfigured machine must not look like an outageAn operator may miss a silently-skipped channelEvery skip is visible per delivery in the detail response
No rendered content on the history surfaceNever returnedReturn it for support purposesNotificationHistory_READ is not superadmin-only, and a rendered security email embeds a live tokenSupport cannot see what was sentThe template screen shows what would be sent
Masked destinations only9779●●●●●123Full addressesThe full set is a contact-details export of every familyAmbiguous when two numbers share a maskThe recipient row identifies the person internally
Response envelope without successmessage, data, errorCodeAdd a booleanThe platform-wide ResponseDto predates this module and is used everywhereA consumer expecting success breaksDocumented in 6.1
Replay inserts rather than resetsA new delivery rowReset the original and re-enqueueThe original's failedAt, lastError and providerMessageId are the evidence the operator opened the screen to readTwo rows for one logical sendreplayOfDeliveryId links them

13.6 API Change Impact

ChangeAffected ConsumersBackend ImpactData ImpactMigration Needed?Compatibility Plan
Adding a NotificationCategoryMobile preferences screenCATEGORY_DEFAULTS must gain an entry, or the resolver returns false and the category silently never deliversThe CHECK on both notification_event.category and notification_preference.category is regenerated from the constantYes — a constraint changeThe category appears in GET immediately with its code default, for everyone. Clients must render an unknown category rather than assuming nine.
Adding a DeliveryStatusAdmin history screenMust also be classified into TERMINAL_, IN_FLIGHT_ or SKIPPED_, or retention and failure counting are both wrongThe status CHECK is regeneratedYesClients must treat status as an open string, not a closed union.
Adding a channelEverythingNew provider, new queue, new attempts pin, new default in CATEGORY_DEFAULTSFour channel CHECKs regeneratedYesThe preference matrix gains a key; clients must iterate channels rather than destructuring four names.
Adding an AudienceKindNone externallyA resolver must be added or the code stops compilingThe audience CHECK is regeneratedYesInternal only; audience is opaque in the history response.
Adding a notification kindTemplate screenA registry entry; if it declares in_app it must set persistRendered or boot failsNoneNokind is already an open string in every DTO.
Renaming a routeAdmin panel, mobile appController path changeNoneNoBreaking. Requires a coordinated release; there is no versioning in use on these routes.
Removing a field from the history responseAdmin panelDTO and mapperNoneNoBreaking for anything reading it.
Adding a field to any responseNoneDTO and mapperNoneNoAdditive and safe; clients must ignore unknown fields.
Changing NOTIFICATION_RETENTION_DAYSAdmin panel, mobile appRetention worker onlyRows disappear sooner or laterNoConsumers must already treat a publicId as impermanent.
Adding a query filterAdmin panelDTONoneNoAdditive. Note that forbidNonWhitelisted means a client sending an unknown filter gets 400 today.
Making the global channel switch writableMobile preferencesA new field on UpdateNotificationPreferencesDtonotification_channel_preference gains a writerNoAdditive; the resolver already honours the table.
Implementing delivery receiptsAdmin historyA webhook route, and sent -> delivered transitionsnotification_delivery_provider_message_idx already exists for itNoAdditive; 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 success field.
  • 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

On this page

Notification - API Reference1. Documentation Evidence2. Module Summary3. Concepts and Terminology4. API Surface Map5. Auth, Identity, and Permissions6. DTO and Model Reference6.1 ResponseDto — the envelope6.2 QueryDto — the shared list query6.3 NotificationTemplateResponseDto6.4 ListNotificationTemplatesQueryDto6.5 CreateNotificationTemplateDto6.6 UpdateNotificationTemplateDto6.7 DeleteNotificationTemplateDto6.8 ListNotificationHistoryQueryDto6.9 NotificationHistoryListItemDto6.10 NotificationHistoryDetailDto6.11 NotificationHistoryRecipientDto6.12 NotificationHistoryDeliveryDto6.13 CancelledNotificationEventDto6.14 ListNotificationFailuresQueryDto6.15 NotificationFailureDto6.16 ReplayNotificationFailureResponseDto6.17 NotificationCentreItemDto — the consumer centre row6.18 UnreadNotificationCountDto and MarkAllNotificationsReadResultDto6.19 RegisterNotificationDeviceDto and NotificationDeviceDto6.20 NotificationPreferencesDto and its children6.21 UpdateNotificationPreferencesDto6.22 NotificationDto and NotificationListDto — the operational feed6.23 ListNotificationsQueryDto — the feed's own query7. Enum Reference8. Endpoint Reference8.1 GET /api/notification-templatesPurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesEdge CasesExample Requests8.2 GET /api/notification-templates/{publicId}PurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesEdge CasesExample Requests8.3 POST /api/notification-templatesPurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesEdge CasesExample Requests8.4 PATCH /api/notification-templates/{publicId}PurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesEdge CasesExample Requests8.5 DELETE /api/notification-templates/{publicId}PurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesEdge Cases8.6 GET /api/notification-historyPurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesEdge CasesExample Requests8.7 GET /api/notification-history/{publicId}PurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesEdge CasesExample Requests8.8 DELETE /api/notification-events/{publicId}PurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesEdge CasesExample Requests8.9 GET /api/notification-failuresPurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesEdge CasesExample Requests8.10 POST /api/notification-failures/{publicId}/replayPurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesEdge CasesExample Requests8.11 GET /api/notificationsPurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesEdge CasesExample Requests8.12 POST /api/notifications/{publicId}/readPurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesEdge CasesExample Requests8.13 POST /api/notifications/read-allPurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesEdge CasesExample Requests8.14 GET /api/mobile/notificationsPurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesEdge CasesExample Requests8.15 GET /api/mobile/notifications/unread-countPurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesEdge CasesExample Requests8.16 POST /api/mobile/notifications/read-allPurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesEdge CasesExample Requests8.17 POST /api/mobile/notifications/{publicId}/readPurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesEdge CasesExample Requests8.18 POST /api/mobile/notification-devicesPurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesEdge CasesExample Requests8.19 DELETE /api/mobile/notification-devices/{publicId}PurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesEdge CasesExample Requests8.20 GET /api/mobile/notification-preferencesPurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesEdge CasesExample Requests8.21 PUT /api/mobile/notification-preferencesPurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesEdge CasesExample Requests9. Flow Diagrams9.1 Route Ownership9.2 Request Sequence — a typical read9.3 Error Decision Tree9.4 Auth and Permission Flow9.5 Data Contract Map9.6 Activity Diagram — replay9.7 Async Flow — what happens after a replay9.8 Cache Flow10. Pagination, Sorting, Filtering, and Search11. Caching, Jobs, and External Integrations13. Deep API Documentation Pack13.1 Route-by-Route Completeness Matrix13.2 Request/Response Exhaustiveness13.3 API Diagram Pack13.4 Consumer Integration Notes13.5 API Tradeoffs and Rationale13.6 API Change Impact14. Zero-Omission API Checklist15. Integration ChecklistSee Also