Notices API Reference
Complete API contracts for Notices, including routes, auth, DTOs, responses, errors, examples, and integration notes.
Notices - API Reference
Audience: Frontend engineers, mobile/panel engineers, backend engineers, QA, and API consumers.
Scope: Admin CRUD, publication, delivery/recipients, attachments, categories, templates, and the audience-facing Notice Centre (/api/mobile/portal/notices).
1. Documentation Evidence
| Area | Files Inspected | What Was Verified |
|---|---|---|
| Controllers | notice/notice.controller.ts, publication/notice-publication.controller.ts, notice-delivery/notice-delivery.controller.ts, notice-attachment/notice-attachment.controller.ts, notice-category/notice-category.controller.ts, notice-template/notice-template.controller.ts, notice-centre/notice-centre.controller.ts | Routes, methods, guards, decorators, status codes |
| DTOs | notice/dto/notice.dto.ts, publication/dto/index.ts, notice-category/dto/notice-category.dto.ts, notice-template/dto/notice-template.dto.ts, notice-attachment/dto/notice-attachment.dto.ts, notice-centre/dto/index.ts, notice-delivery/dto/index.ts | Request/query/response shapes, validation, examples |
| Services | corresponding *.service.ts files | Behavior, side effects, response mapping, errors |
| Schema | packages/db/src/schema/school/notices.ts | Enums, constraints, persisted fields |
| Errors | apps/api/src/common/types/error-codes.ts | Every NOTICE_* code |
| Routes | apps/api/test/structure/structure.baseline.json | Authoritative route list, verified with jq |
2. Module Summary
| Field | Value |
|---|---|
| Module name | Notices |
| Module slug | notices |
| Primary actors | administrator, publisher, delivery auditor, staff, teacher, pupil, guardian, the notices worker |
| API surfaces | admin (/api/notices*, /api/notice-categories*, /api/notice-templates*, /api/notice-attachments/*), mobile/portal (/api/mobile/portal/notices*) |
| Base route prefixes | /api/notices, /api/notice-categories, /api/notice-templates, /api/notice-attachments, /api/mobile/portal/notices |
| Auth model | JwtAuthGuard (global) + RoleGuard; every handler carries @Permissions(...) — no public and no permission-less routes in this module |
| Persistence | PostgreSQL (Drizzle), Redis (cache only), BullMQ (QueueName.NOTICES), object storage (attachments) |
| Runtime source of truth | notices table for lifecycle; NoticeVisibilityService for what a reader may see |
| Sibling docs | Backend, features/flows |
3. Concepts and Terminology
| Term | Meaning | Source File | Used By |
|---|---|---|---|
publicId | The uuid a client addresses any notice/category/template/attachment/dispatch by | notices.ts schema | Every route |
status | Notice lifecycle: draft | scheduled | published | archived | noticeStatusEnum | Admin CRUD, publication |
priority | low | medium | high | urgent — affects prominence and the notification kind | noticePriorityEnum | Create/update, Notice Centre |
channel | in_app | push | email | sms | noticeChannelEnum | Create/update, delivery stats |
AudienceSpec | A structured description of who a notice addresses — one kind, plus id lists, or {kind: "compound", any: [...]} | notification.types.ts (shared with the notification module) | Create/update, audience preview |
dispatchSeq | The delivery identity — increments on every publish and every notify-update; what makes a republish actually notify people | notices.dispatch_seq | Publication result |
version | Optimistic concurrency token, bumped by a database trigger on any update | notices.version | Update, expiry PATCH |
| Notice Centre | The /api/mobile/portal/notices surface every audience reads through | notice-centre/* | All portal routes |
matchedFor | Which of the caller's children a notice concerns, for a guardian | MatchedForDto | Notice Centre list/detail |
retained | Whether the underlying notification_event/notification_recipient rows for a dispatch still exist (30-day retention) | NoticeRecipientsPageDto | Recipients endpoint |
4. API Surface Map
| Surface | Method | Path | Actor | Auth/Guard | Permission | Controller | Purpose |
|---|---|---|---|---|---|---|---|
| Admin | GET | /api/notices | Administrator | JWT + Role | Notices_READ | NoticeController | List notices |
| Admin | GET | /api/notices/summary | Administrator | JWT + Role | Notices_READ | NoticeController | Dashboard tiles |
| Admin | GET | /api/notices/audience/people | Administrator | JWT + Role | Notices_READ | NoticeController | Find a person to address |
| Admin | POST | /api/notices/audience/preview | Administrator | JWT + Role | Notices_READ | NoticeController | Preview audience counts |
| Admin | POST | /api/notices | Administrator | JWT + Role | Notices_CREATE | NoticeController | Create a draft |
| Admin | GET | /api/notices/:publicId | Administrator | JWT + Role | Notices_READ | NoticeController | Get one notice |
| Admin | PATCH | /api/notices/:publicId | Administrator | JWT + Role | Notices_UPDATE | NoticeController | Update a notice |
| Admin | DELETE | /api/notices/:publicId | Administrator | JWT + Role | Notices_DELETE | NoticeController | Soft delete |
| Admin | POST | /api/notices/:publicId/restore | Administrator | JWT + Role | Notices_RESTORE | NoticeController | Restore |
| Admin | POST | /api/notices/:publicId/archive | Administrator | JWT + Role | Notices_UPDATE | NoticeController | Archive |
| Admin | POST | /api/notices/:publicId/duplicate | Administrator | JWT + Role | Notices_CREATE | NoticeController | Duplicate |
| Admin | PATCH | /api/notices/:publicId/expiry | Administrator | JWT + Role | Notices_UPDATE | NoticeController | Extend/change expiry |
| Admin | POST | /api/notices/:publicId/publish | Publisher | JWT + Role | NoticePublish_CREATE | NoticePublicationController | Publish or schedule |
| Admin | POST | /api/notices/:publicId/unpublish | Publisher | JWT + Role | NoticePublish_CREATE | NoticePublicationController | Withdraw before delivery |
| Admin | POST | /api/notices/:publicId/notify-update | Publisher | JWT + Role | NoticePublish_UPDATE | NoticePublicationController | Notify of a change |
| Admin | GET | /api/notices/:publicId/recipients | Delivery auditor | JWT + Role | NoticeDelivery_READ | NoticeDeliveryController | Per-recipient list |
| Admin | GET | /api/notices/:publicId/delivery | Delivery auditor | JWT + Role | NoticeDelivery_READ | NoticeDeliveryController | Per-channel totals |
| Admin | POST | /api/notices/:publicId/deliveries/:deliveryPublicId/retry | Delivery auditor | JWT + Role | NoticeDelivery_UPDATE | NoticeDeliveryController | Retry one delivery |
| Admin | POST | /api/notices/:publicId/attachments | Administrator | JWT + Role | Notices_UPDATE | NoticeAttachmentController | Upload attachment |
| Admin | DELETE | /api/notices/:publicId/attachments/:attachmentPublicId | Administrator | JWT + Role | Notices_UPDATE | NoticeAttachmentController | Remove attachment |
| Admin | PATCH | /api/notices/:publicId/attachments/:attachmentPublicId/primary | Administrator | JWT + Role | Notices_UPDATE | NoticeAttachmentController | Set primary image |
| Admin/Portal | GET | /api/notice-attachments/:attachmentPublicId | Any visible reader, or Notices_READ | JWT + Role | NoticeView_READ | NoticeAttachmentController | Stream attachment bytes |
| Admin | GET | /api/notice-categories | Administrator | JWT + Role | NoticeCategories_READ | NoticeCategoryController | List categories |
| Admin | POST | /api/notice-categories | Administrator | JWT + Role | NoticeCategories_CREATE | NoticeCategoryController | Create category |
| Admin | GET | /api/notice-categories/:publicId | Administrator | JWT + Role | NoticeCategories_READ | NoticeCategoryController | Get category |
| Admin | PATCH | /api/notice-categories/:publicId | Administrator | JWT + Role | NoticeCategories_UPDATE | NoticeCategoryController | Update category |
| Admin | DELETE | /api/notice-categories/:publicId | Administrator | JWT + Role | NoticeCategories_DELETE | NoticeCategoryController | Retire category |
| Admin | POST | /api/notice-categories/:publicId/restore | Administrator | JWT + Role | NoticeCategories_RESTORE | NoticeCategoryController | Restore category |
| Admin | GET | /api/notice-templates | Administrator | JWT + Role | NoticeTemplates_READ | NoticeTemplateController | List templates |
| Admin | POST | /api/notice-templates | Administrator | JWT + Role | NoticeTemplates_CREATE | NoticeTemplateController | Create template |
| Admin | GET | /api/notice-templates/:publicId | Administrator | JWT + Role | NoticeTemplates_READ | NoticeTemplateController | Get template |
| Admin | GET | /api/notice-templates/:publicId/render | Administrator | JWT + Role | NoticeTemplates_READ | NoticeTemplateController | Render for a new notice |
| Admin | PATCH | /api/notice-templates/:publicId | Administrator | JWT + Role | NoticeTemplates_UPDATE | NoticeTemplateController | Update template |
| Admin | DELETE | /api/notice-templates/:publicId | Administrator | JWT + Role | NoticeTemplates_DELETE | NoticeTemplateController | Retire template |
| Admin | POST | /api/notice-templates/:publicId/restore | Administrator | JWT + Role | NoticeTemplates_RESTORE | NoticeTemplateController | Restore template |
| Mobile/Portal | GET | /api/mobile/portal/notices | Staff/teacher/pupil/guardian/superadmin | JWT + Role | NoticeView_READ | NoticeCentreController | List visible notices |
| Mobile/Portal | GET | /api/mobile/portal/notices/unread-count | same | JWT + Role | NoticeView_READ | NoticeCentreController | Unread badge count |
| Mobile/Portal | GET | /api/mobile/portal/notices/:publicId | same | JWT + Role | NoticeView_READ | NoticeCentreController | Read one notice |
| Mobile/Portal | POST | /api/mobile/portal/notices/:publicId/read | same | JWT + Role | NoticeView_READ | NoticeCentreController | Mark read |
| Mobile/Portal | DELETE | /api/mobile/portal/notices/:publicId/read | same | JWT + Role | NoticeView_READ | NoticeCentreController | Mark unread |
This list is exhaustive against apps/api/test/structure/structure.baseline.json — 34 concrete routes, no aliases omitted. notices and notice-delivery share the notices root path across two separate controllers (NoticeController, NoticePublicationController, NoticeDeliveryController); none of their sub-paths collide.
5. Auth, Identity, and Permissions
| Surface | Guard/Decorator | Identity Shape | Permission | Guest Allowed | Notes |
|---|---|---|---|---|---|
| All admin routes | JwtAuthGuard, RoleGuard, @Permissions(...) | req.user.id, activeRole | one Module_ACTION code per route (table above) | No | Standard admin permission gate |
Notice Centre (/mobile/portal/notices*) | JwtAuthGuard, RoleGuard, @Permissions("NoticeView_READ") | req.user.id, activeRole | NoticeView_READ (held by staff, teacher, pupil, and guardian roles, plus superadmin) | No | The permission proves nothing about which notices come back — NoticeVisibilityService builds the WHERE clause from the caller's own profile rows |
| Attachment stream | JwtAuthGuard, RoleGuard, @Permissions("NoticeView_READ") | req.user.id | NoticeView_READ, or the caller separately holds Notices_READ (checked in-service) | No | The only route in this module with a permission-checked in-service bypass in addition to the route-level @Permissions() |
There is no guest or public access anywhere in this module — every route sits behind the global JwtAuthGuard with an explicit permission. NoticeCentreController needs no entry in NO_PERMISSION_ADMIN_HANDLERS because every one of its handlers carries @Permissions().
Every :publicId, :attachmentPublicId, and :deliveryPublicId route parameter across every controller in this module is decorated with ParseUUIDPipe, so a malformed id fails validation as a 400 before the handler runs, rather than reaching a query that could never match.
6. DTO and Model Reference
6.1 NoticeListItemDto / NoticeDto (response)
| Field | Type | Required | Default | Validation | Source |
|---|---|---|---|---|---|
publicId | string (uuid) | Yes | — | — | notice.dto.ts |
title | string | Yes | — | — | |
summary | string | Yes | — | — | |
categoryId | string | Yes | — | The category's publicId | |
categoryName | string | Yes | — | — | |
categoryColorToken | string | null | No | — | — | |
priority | enum | Yes | — | low|medium|high|urgent | |
status | enum | Yes | — | draft|scheduled|published|archived | |
audienceLabel | string | Yes | — | Rendered server-side | |
channels | enum[] | Yes | — | in_app|push|email|sms | |
publishAt / expiresAt / publishedAt | Date | null | No | — | — | |
createdByName | string | Yes | — | Name snapshot, survives author removal | |
version | string | Yes | — | Send back verbatim on PATCH | |
createdAt / updatedAt | Date | Yes | — | — | |
deletedAt | Date | null | No | null | Set only in the ?deleted=true list view, where every row is a removed notice POST /notices/:publicId/restore can bring back | |
(NoticeDto extends the above, adding:) | |||||
contentHtml / contentText | string | Yes | — | Sanitised HTML / derived plain text | |
audience | object | Yes | — | The stored AudienceSpec verbatim | |
actionUrl / actionLabel | string | null | No | — | — | |
calendarDayId | string | null | No | — | — | |
academicSessionId | string | Yes | — | — | |
duplicatedFromId | string | null | No | — | Source notice's publicId | |
publishRequestedByName | string | null | No | — | Null until scheduled/published | |
attachments | NoticeAttachmentSummaryDto[] | Yes | — | Never carries storageKey |
6.2 CreateNoticeDto (request body)
| Field | Type | Required | Default | Validation | Example |
|---|---|---|---|---|---|
title | string | Yes | — | non-empty, ≤200 | "Exam Schedule" |
summary | string | No | derived from content | ≤300 | — |
contentHtml | string | Yes | — | non-empty; sanitised server-side | "<p>...</p>" |
categoryId | string (uuid) | Yes | — | must resolve to a live category | — |
priority | enum | Yes | — | low|medium|high|urgent | "high" |
audience | object | Yes | — | an AudienceSpec; validated structurally in-service | {"kind":"all_users"} |
channels | enum[] | Yes | — | 1-4 items, each a valid channel | ["in_app","push"] |
publishAt | string (ISO 8601) | No | — | — | — |
expiresAt | string (ISO 8601) | No | — | must be after publishAt if both set | — |
actionUrl | string | No | — | ≤2048; https:// or app-relative single-slash path | — |
actionLabel | string | No | — | ≤60; required together with actionUrl | — |
calendarDayId | string (uuid) | No | — | — | — |
6.3 UpdateNoticeDto (request body)
Same content fields as create, all optional, plus version (required — the concurrency token). audience/channels/priority/publishAt are accepted structurally but rejected by the service with NOTICE_PUBLICATION_PARAMS_IMMUTABLE unless the notice is still draft.
6.4 UpdateNoticeExpiryDto
| Field | Type | Required | Notes |
|---|---|---|---|
version | string | Yes | Concurrency token |
expiresAt | string (ISO 8601) | Yes | An archived notice returns to published if this is in the future |
6.5 AudiencePreviewRequestDto / NoticeAudiencePreviewDto
Request: { audience: object } (same AudienceSpec shape). Response: { total, capped, byKind: [{ kind, count, capped }] } — writes nothing.
6.6 NoticePeopleQueryDto / NoticePersonDto
Query: search (string, 2-100 chars, required), kind (optional, student|guardian|staff). Response array item: { userPublicId, name, kind, detail } — at most 20 rows.
6.7 PublishNoticeDto / NoticePublicationResultDto
Request: { version: string, publishAt?: Date, expiresAt?: Date }. version is required — the row version the caller loaded the notice at (digits only, ^\d{1,19}$); a stale value refuses with 409 NOTICE_VERSION_CONFLICT. Absent publishAt means "now" (within ~5s via the outbox). Response: { publicId, publishAt, dispatchSeq }.
6.8 NotifyNoticeUpdateDto
{ note?: string } (≤200 chars) — internal only, never sent to recipients. Response: { dispatchSeq } wrapped in the same NoticePublicationResultDto shape by the controller (with publishAt reflecting the notice's existing schedule).
6.9 NoticeCategoryDto / Create/UpdateNoticeCategoryDto
| Field | Type | Required (create) | Validation |
|---|---|---|---|
name | string | Yes | 1-80 chars |
slug | string | Yes | ^[a-z][a-z0-9-]*$, ≤80 |
description | string | No | ≤1000 |
colorToken | string | No | ≤32, a design-token name, never a hex value |
sortOrder | number | No, default 0 | 0-1000 |
isActive | boolean | No, default true | — |
Response additionally carries publicId, isSystem (never accepted from a caller), createdAt/updatedAt/deletedAt.
6.10 NoticeTemplateDto / Create/UpdateNoticeTemplateDto
| Field | Type | Required (create) | Validation |
|---|---|---|---|
name | string | Yes | 1-120 |
slug | string | Yes | ^[a-z][a-z0-9-]*$, ≤120 |
categoryPublicId | string | No | — |
priority | enum | No | — |
titleTemplate | string | Yes | 1-200; {{token}} placeholders |
contentHtml | string | Yes | non-empty |
defaultChannels | enum[] | Yes | ≤4, unique, each a valid channel (no minimum — an empty array reaches the named NOTICE_CHANNELS_EMPTY error before the bare CHECK constraint would) |
isActive | boolean | No, default true | — |
sortOrder | number | No, default 0 | 0-1000 |
NoticeTemplateRenderDto (response of the render endpoint): { title, contentHtml, summary, priority, categoryId, defaultChannels } — tokens substituted, sanitised.
6.11 NoticeAttachmentDto / NoticeAttachmentUploadDto
Upload is multipart/form-data, field file. Response: { publicId, kind, originalName, mimeType, sizeBytes, isPrimary, sortOrder, createdAt } — never storageKey.
6.12 Notice Centre DTOs
ListNoticeCentreQueryDto: unreadOnly?, categoryId?, priority?, includeExpired? (default excludes expired/archived). NoticeCentreListItemDto: { publicId, title, summary, priority, category: {publicId,name,colorToken}, publishAt, expiresAt, isRead, matchedFor: MatchedForDto[], primaryAttachmentPublicId }. NoticeCentreDetailDto extends it with contentHtml, contentText, actionUrl, actionLabel, channels, publishedAt, readAt, attachments: NoticeCentreAttachmentDto[]. UnreadCountDto: { count }.
6.13 Delivery DTOs
ListNoticeRecipientsQueryDto: pagination only. NoticeRecipientsPageDto: { retained: boolean, recipients: NoticeRecipientDto[] }. NoticeRecipientDto: { recipientPublicId, userPublicIdSnapshot, name (nullable), readAt (nullable), deliveries: NoticeRecipientDeliveryDto[] }. NoticeRecipientDeliveryDto: { publicId, channel, status }. NoticeDeliveryStatsDto: { noticePublicId, dispatchPublicId (nullable), source: "live"|"snapshot", recipientsAtPublish, readCount, unread, channels: ChannelDeliveryStatsDto[] }. ChannelDeliveryStatsDto: { channel, sent, delivered, failed, skipped }.
7. Enum Reference
| Enum | Value | Meaning | Runtime Effect | Source |
|---|---|---|---|---|
notice_priority | low | Lowest prominence | Maps to notice.published.low / notice.updated | notices.ts |
notice_priority | medium | Default prominence | Maps to notice.published.normal / notice.updated | |
notice_priority | high | Elevated prominence | Maps to notice.published.high / notice.updated | |
notice_priority | urgent | Highest prominence | Maps to notice.published.urgent / notice.updated.urgent — both resolve to notification priority high, never critical | |
notice_status | draft | Not yet scheduled or sent | Editable in full | |
notice_status | scheduled | Queued for delivery, not yet delivered | Publication params frozen; can be unpublished | |
notice_status | published | Delivered | Publication params frozen; content still editable; can be archived or notify-updated | |
notice_status | archived | Retired | Content frozen; reachable from any state; not terminal — extending expiry can return it to published | |
notice_channel | in_app | push | email | sms | Delivery channel | Passed to NotificationService.send() | |
notice_attachment_kind | image | document | Attachment type | Only image may be primary | |
notice_dispatch_reason | published | updated | Why a notice_dispatches row exists | updated is only ever the explicit notify-update action, never an ordinary edit | |
notice_target_kind | users, role, class, section, grade, guardians_of_class, guardians_of_section, guardians_of_grade, guardians_of_users, teachers_of_class, teachers_of_grade, staff_department, all_users | Every leaf AudienceSpec kind | compound is never stored as a row — flattened at write time into one row per member |
8. Endpoint Reference
Representative endpoints are detailed below; every route in §4 follows the same guard/DTO/error pattern documented in the backend doc.
8.1 GET /api/notices
Purpose
Lists notices for the admin authoring surface, with status/priority/category/audience-kind/creator-name/publish-date filters. Used by the notices list screen.
Source Evidence
| Evidence | Path |
|---|---|
| Controller | notice/notice.controller.ts |
| DTO | notice/dto/notice.dto.ts (ListNoticesQueryDto) |
| Service | notice/notice.service.ts → NoticeReadService |
Auth and Permissions
- Auth: JWT — Guard chain:
JwtAuthGuard,RoleGuard— Permission:Notices_READ— Guest support: No — Idempotency: N/A (read)
Request
Query: status?, priority?, categoryId? (category's publicId), audienceKind?, createdByName?, publishedFrom?/publishedTo? (ISO 8601; a date-only YYYY-MM-DD is the whole school day in Asia/Kathmandu — from its first instant, to its last — while a full timestamp is used as given), deleted? (boolean), plus inherited pagination/search/sort.
deleted=true lists only soft-deleted notices — the view a restore is made from. Omitting it, or passing false, lists live notices only; the two views never overlap. This is the list an admin screen drives a "Deleted notices" tab from: filter with ?deleted=true, then call POST /notices/:publicId/restore on the row to bring it back. The admin-list cache key includes the flag, so the two views never share a cached page.
Response
{
"success": true,
"message": "Notices fetched.",
"data": [{ "publicId": "...", "title": "...", "status": "published" }],
"metadata": { "count": 42, "page": 1, "size": 20 }
}Side Effects
None — a read.
Edge Cases
Empty result set returns data: [] with count: 0. pagination=false (not the module's default and not forbidden here — see backend cache/pagination notes) is bounded by PaginationUtil.UNPAGINATED_HARD_CAP.
8.2 GET /api/notices/summary
Dashboard tiles: { total, draft, scheduled, published, archived, recipientsToday, readCount, recipientsAtPublish }. Every figure is a fresh aggregate, never a stored counter. Declared before :publicId in the controller so Nest does not resolve summary as a uuid param.
8.3 GET /api/notices/audience/people
Name search (≥2 characters) over pupils, parents, and staff, returning each match's userPublicId for use in a users audience. At most 20 rows. kind optionally narrows to student|guardian|staff.
8.4 POST /api/notices/audience/preview
Writes nothing. Resolves an AudienceSpec to per-kind counts and a total, each with its own capped flag when the walk hit its bound before exhausting a kind.
8.5 POST /api/notices
Purpose
Creates a notice. Always a draft, regardless of whether publishAt is supplied.
Auth and Permissions
Notices_CREATE.
Request
{
"title": "Exam Schedule Released",
"contentHtml": "<p>The mid-term schedule is now available.</p>",
"categoryId": "6f1a...-uuid",
"priority": "high",
"audience": { "kind": "all_users" },
"channels": ["in_app", "push"]
}Response
201, body is a full NoticeDto.
Side Effects
Writes notices and notice_audience_targets; invalidates the admin list cache.
Error Cases
| HTTP Status | Error Code | Condition |
|---|---|---|
400 | NOTICE_CONTENT_EMPTY | Sanitised HTML has no remaining text |
404 | NOTICE_CATEGORY_NOT_FOUND | categoryId does not resolve |
400 | NOTICE_AUDIENCE_KIND_UNKNOWN | Unrecognised top-level kind |
400 | NOTICE_AUDIENCE_EMPTY | The normalised audience names nobody — checked by assertCaps on both create and update, not only at publish time |
400 | NOTICE_AUDIENCE_TOO_LARGE | More than 500 explicit users ids, or more than 10 compound members — the same caps assertSendablePreconditions re-checks at publish time |
400 | NOTICE_ACTION_URL_FORBIDDEN | Unsafe actionUrl |
Example Requests
curl -X POST "$API_URL/api/notices" \
-H "Authorization: Bearer TOKEN" \
-H "Content-Type: application/json" \
-d '{"title":"Exam Schedule","contentHtml":"<p>...</p>","categoryId":"...","priority":"high","audience":{"kind":"all_users"},"channels":["in_app"]}'8.6 GET /api/notices/:publicId
Returns the full NoticeDto, including audience, attachments, and every content field. Notices_READ. 404 NOTICE_NOT_FOUND if missing or soft-deleted.
8.7 PATCH /api/notices/:publicId
Notices_UPDATE. Requires version. Content fields editable on any non-archived notice; audience/channels/priority/publishAt editable only while draft.
| HTTP Status | Error Code | Condition |
|---|---|---|
409 | NOTICE_VERSION_CONFLICT | version mismatch |
409 | NOTICE_PUBLICATION_PARAMS_IMMUTABLE | Publication field touched on non-draft |
409 | NOTICE_ARCHIVED_IMMUTABLE | Content field touched on an archived notice |
8.8 DELETE /api/notices/:publicId
Notices_DELETE. Soft delete. 204-equivalent envelope with data: null. Refused with 409 (assertNoLivePublishClaim) while a publish lease is currently held. When the notice is scheduled, its pending publish outbox row is marked dead and its status is reset to draft in the same transaction as the soft delete, so a deleted notice never has a live publish job racing it.
8.9 POST /api/notices/:publicId/restore
Notices_RESTORE. Clears deletedAt. Returns the restored NoticeDto. The panel drives a restore from the ?deleted=true list view. A notice that was scheduled when it was deleted comes back as a draft — remove reset its status and cancelled its pending publish outbox row before soft-deleting it, so a restore never resurrects a publish that was already withdrawn.
8.10 POST /api/notices/:publicId/archive
Notices_UPDATE. Legal only from status = published. 409 NOTICE_NOT_PUBLISHED for any other status — a scheduled notice, a draft that was never published, or an already-archived notice — with a message naming the specific reason. Also refused while a publish lease is currently held.
8.11 POST /api/notices/:publicId/duplicate
Notices_CREATE. 201. New draft; every publication field cleared; duplicatedFromId set.
8.12 PATCH /api/notices/:publicId/expiry
Notices_UPDATE. Requires version. An archived notice returns to published only when all three hold: it is currently archived, its publishedAt is set (proof it was actually delivered), and the new expiresAt is in the future.
8.13 POST /api/notices/:publicId/publish
Purpose
Publishes immediately or schedules for later. Frontend calls this after a draft is complete and reviewed.
Auth and Permissions
NoticePublish_CREATE.
Request
{ "version": "3", "publishAt": "2026-10-01T03:15:00.000Z", "expiresAt": "2026-10-15T03:15:00.000Z" }version is required — the row version the caller loaded the notice at. publishAt/expiresAt are optional; absent publishAt publishes as soon as the outbox dispatches (within ~5s).
Response
{
"success": true,
"message": "Notice queued for delivery.",
"data": { "publicId": "...", "publishAt": "2026-10-01T03:15:00.000Z", "dispatchSeq": 1 }
}Side Effects
notices.status → scheduled, dispatch_seq incremented, outbox_events row written, notice_runtime row created (unclaimed). The worker later flips status to published, resolves the audience, calls NotificationService.send(), and writes one notice_dispatches row.
Error Cases
| HTTP Status | Error Code | Condition |
|---|---|---|
409 | NOTICE_VERSION_CONFLICT | version does not match the notice's current row version — "This notice changed after you opened it. Reload it and publish again." Reload the notice and publish again. |
409 | NOTICE_ALREADY_PUBLISHED | Already published/archived |
400 | NOTICE_PUBLISH_AT_IN_PAST | Schedule more than 60s in the past |
400 | NOTICE_EXPIRY_BEFORE_PUBLISH | expiresAt <= publishAt |
400 | NOTICE_CHANNELS_EMPTY | No channels on the notice |
400 | NOTICE_ACTION_URL_FORBIDDEN | Unsafe action URL |
400 | NOTICE_AUDIENCE_TOO_LARGE | >10 compound members or >500 explicit users ids |
The version check runs first, inside the row lock, before any other publish rule — the version mismatch and the caller's stale copy both need surfacing before spending a check on rules that only matter if the caller's copy is current.
8.14 POST /api/notices/:publicId/unpublish
NoticePublish_CREATE. Legal only before delivery. 409 NOTICE_ALREADY_DELIVERED once resolved; 409 NOTICE_NOT_PUBLISHED for a draft. On success, data: null.
8.15 POST /api/notices/:publicId/notify-update
NoticePublish_UPDATE. Legal only for status = published. Optional { note? } (internal only). Response mirrors the publish result shape with the incremented dispatchSeq. 409 NOTICE_NOT_PUBLISHED otherwise.
All three attachment-write endpoints below resolve the notice through requireEditableNotice, which refuses 409 NOTICE_ARCHIVED_IMMUTABLE once the notice is archived — an archived notice's content is frozen, and its attachments are part of that content.
8.16 POST /api/notices/:publicId/attachments
Notices_UPDATE. multipart/form-data, field file. 201, body is NoticeAttachmentDto. 409 NOTICE_ATTACHMENT_LIMIT_EXCEEDED past 20 attachments — checked once as a fast refusal before the upload, and again inside the insert transaction under a row lock on the notice, so two concurrent uploads to the same notice cannot both insert the 20th. 400 NOTICE_ATTACHMENT_TYPE_FORBIDDEN on a rejected or empty file. 409 NOTICE_ARCHIVED_IMMUTABLE on an archived notice.
8.17 DELETE /api/notices/:publicId/attachments/:attachmentPublicId
Notices_UPDATE. 404 NOTICE_ATTACHMENT_NOT_FOUND unless the pair (notice, attachment) both resolve. 409 NOTICE_ARCHIVED_IMMUTABLE on an archived notice.
8.18 PATCH /api/notices/:publicId/attachments/:attachmentPublicId/primary
Notices_UPDATE. 409 NOTICE_PRIMARY_IMAGE_NOT_AN_IMAGE for a document. 409 NOTICE_ARCHIVED_IMMUTABLE on an archived notice. Clears any prior primary and sets the new one under a row lock on the notice in the same transaction — two concurrent primary-swap requests are serialised by the lock; the rare case where both still collide on notice_attachments_primary_unique is mapped to 409 NOTICE_VERSION_CONFLICT rather than a bare, unnamed constraint error.
8.19 GET /api/notice-attachments/:attachmentPublicId
Purpose
Streams an attachment's raw bytes. Used for both the admin editor preview and every Notice Centre inline image/document link.
Auth and Permissions
- Auth: JWT — Permission:
NoticeView_READ— Guest support: No. - Never signed. Authorized per-request by
NoticeVisibilityService.requireVisibleNotice, or bypassed for a caller who separately holdsNotices_READ(administrator preview).
Response
Raw bytes, Content-Type from the stored mime type, Cache-Control: private, max-age=3600, Content-Disposition: inline.
Error Cases
| HTTP Status | Error Code | Condition |
|---|---|---|
404 | NOTICE_ATTACHMENT_NOT_FOUND | No such attachment |
404 | NOTICE_NOT_FOUND | Caller cannot see the parent notice (and does not hold Notices_READ) |
8.20-8.25 Notice Categories
Standard CRUD at /api/notice-categories (NoticeCategories_READ/_CREATE/_UPDATE/_DELETE/_RESTORE), plus GET /:publicId. remove refuses a system default (NOTICE_CATEGORY_SYSTEM_IMMUTABLE) or one still referenced by a notice (NOTICE_CATEGORY_IN_USE, since the FK is ON DELETE RESTRICT). Slugs are unique only among live rows, so a retired slug is reusable — but restore can then collide, surfaced as 409 NOTICE_CATEGORY_SLUG_ALREADY_EXISTS rather than a bare 500. GET /api/notice-categories orders by sortOrder ascending (then id) by default; pass sort=name or sort=sortOrder explicitly to override. The Redis reference cache is used only for the default, unsorted listing — an explicit sort bypasses it.
8.26-8.32 Notice Templates
Standard CRUD at /api/notice-templates (NoticeTemplates_READ/_CREATE/_UPDATE/_DELETE/_RESTORE), plus GET /:publicId/render.
GET /api/notice-templates/:publicId/render substitutes {{schoolName}}, {{sessionName}} and {{date}} (in the school's own timezone) in both the title and content, sanitises the result with the identical configuration notice content uses, and derives a summary the same way a new notice would. An unresolvable token (outside that closed set) is left literal rather than stripped or erroring. remove refuses a system default with NOTICE_TEMPLATE_SYSTEM_IMMUTABLE. GET /api/notice-templates orders by sortOrder ascending (then id) by default, overridable with sort=name/sort=sortOrder, on the same caching rule as categories.
Both GET /api/notices/:publicId/recipients and GET /api/notices/:publicId/delivery below report on the latest dispatch whose reason = 'published' — never a notify-update dispatch, which carries no recipient count or delivery rows of its own.
8.33 GET /api/notices/:publicId/recipients
NoticeDelivery_READ. Returns { retained, recipients } — retained: false with an empty array once the dispatch's notification_event has been pruned at the 30-day retention horizon, distinct from "nobody was addressed".
8.34 GET /api/notices/:publicId/delivery
NoticeDelivery_READ. Returns NoticeDeliveryStatsDto. source: "live" reads notification_delivery directly; source: "snapshot" reads the frozen delivery_stats captured before the retention prune. Zeros (not a 404) for a notice never dispatched.
8.35 POST /api/notices/:publicId/deliveries/:deliveryPublicId/retry
NoticeDelivery_UPDATE. Resolves the delivery through this notice's own dispatches first — 404 NOTICE_DELIVERY_NOT_FOUND if the delivery does not belong to this notice — then delegates to the shared dead-letter replay. 409 NOTICE_DELIVERY_NOT_RETRYABLE when no matching un-replayed job_failures row exists on QueueName.NOTIFICATION_EMAIL/_SMS/_PUSH.
8.36 GET /api/mobile/portal/notices
Purpose
The Notice Centre list — every audience (staff, teacher, pupil, guardian) reads their own notices through this one endpoint.
Auth and Permissions
NoticeView_READ. The WHERE clause, built from NoticeVisibilityService.membershipsFor, is the actual control — not the permission.
Request
Query: unreadOnly?, categoryId?, priority?, includeExpired? (default excludes archived/expired), plus pagination.
Response
{
"success": true,
"message": "Notices fetched.",
"data": [{
"publicId": "...",
"title": "Exam Schedule Released",
"isRead": false,
"matchedFor": [],
"category": { "publicId": "...", "name": "Academic", "colorToken": "primary" }
}],
"metadata": { "count": 3, "page": 1, "size": 20 }
}Edge Cases
Deliberately not cached — read-your-writes matters more here than a cache hit, since a reader typically opens a notice (which marks it read) and returns to the same list immediately.
8.37 GET /api/mobile/portal/notices/unread-count
NoticeView_READ. { count }. Cached (CACHE_TTL.VOLATILE), invalidated on every mark-read/mark-unread for that user.
8.38 GET /api/mobile/portal/notices/:publicId
NoticeView_READ. Full NoticeCentreDetailDto. Never marks the notice read — the client fires the separate read call on mount. 404 NOTICE_NOT_FOUND if the caller cannot see it (never 403 — the id space must not become an enumeration oracle).
8.39 POST /api/mobile/portal/notices/:publicId/read
NoticeView_READ. Writes notice_reads (durable) and best-effort mirrors notification_recipient.read_at so the notification bell agrees, in one transaction that re-runs the visibility check.
8.40 DELETE /api/mobile/portal/notices/:publicId/read
NoticeView_READ. Symmetric mark-unread; deletes the notice_reads row and clears the mirrored read_at.
9. Flow Diagrams
9.1 Route Ownership
9.2 Request Sequence (publish)
9.3 Error Branch
10. Pagination, Sorting, Filtering, and Search
| Endpoint | Pagination Type | Default Size | Max Size | Sort Fields | Filters | Result Cap |
|---|---|---|---|---|---|---|
GET /api/notices | page/size | 20 | 100 | inherited QueryDto fields | status, priority, categoryId, audienceKind, createdByName, publishedFrom/To, deleted (live vs. removed view) | PaginationUtil.UNPAGINATED_HARD_CAP if pagination=false |
GET /api/notice-categories | page/size | 20 | 100 | inherited | isActive, includeDeleted | same |
GET /api/notice-templates | page/size | 20 | 100 | inherited | isActive, includeDeleted | same |
GET /api/notices/:id/recipients | page/size | 20 | 100 | insertion order | — | — |
GET /api/mobile/portal/notices | page/size | 20 | 100 | publishAt desc, id desc (fixed) | unreadOnly, categoryId, priority, includeExpired | — |
GET /api/notices/audience/people and POST /api/notices/audience/preview are not paginated list endpoints — the former is hard-capped at 20 rows, the latter returns aggregates only.
11. Caching, Jobs, and External Integrations
| Integration | Used? | Details | Source |
|---|---|---|---|
| Redis cache | Yes | notice:admin:list:*, notice:detail:id:<publicId>, notice:unread:user:<userId> — see backend §8 for the full key table | shared/notice.constants.ts |
| BullMQ | Yes | QueueName.NOTICES — PUBLISH, NOTIFY_UPDATE, MAINTENANCE_SWEEP (also redrives up to 100 stuck-scheduled publications per run, reusing their original dispatch identity), CAPTURE_STATS, CACHE_INVALIDATE | packages/jobs/src/index.ts, backend §9 |
| External API | No | Provider dispatch (SMTP/SMS/push) is entirely inside NotificationService, not called directly from this module | — |
| Object storage | Yes | StorageManager for attachment upload/read; no signed URLs | notice-attachment.service.ts |
13. Mandatory Deep API Documentation Pack
13.1 Route-by-Route Completeness Matrix
See §4 (API Surface Map) for the full route list with controller, permission, and purpose; §8 for the per-endpoint detail including DTOs, cache/job/error behaviour. Every @Get/@Post/@Patch/@Delete in every controller listed in §1's Documentation Evidence table is represented — 34 routes total, matching jq -r '.routes.all[]' apps/api/test/structure/structure.baseline.json | grep -i notice.
13.2 Request/Response Exhaustiveness
Minimal and full request bodies are shown for the create, publish, and update-category endpoints in §8 as representative examples; every other endpoint's body shape is fully enumerated in §6. Empty-list responses follow the standard pagination envelope with data: []. Validation errors follow the framework's standard 400 shape with errorCode from ErrorCodes. Domain errors are enumerated per-endpoint in §8 and exhaustively in the backend doc's §13.
13.3 API Diagram Pack
Covered in §9 (route ownership, request sequence, error branch). A dedicated cache-flow and async-flow diagram for the publish path is in the backend doc §8-9.
13.4 Consumer Integration Notes
| Consumer | Required Knowledge | Failure Handling | Contract Stability |
|---|---|---|---|
Admin panel (../skoolsewa-frontend) | Publication-params-immutable-outside-draft; version required on every PATCH; publish result is asynchronous (queued, not delivered) | Show the named NOTICE_* error code's message; reload on NOTICE_VERSION_CONFLICT | Stable |
| Notice Centre (same panel, portal routes) | List is not cached — safe to re-fetch after mark-read; detail never marks read | 404 on an inaccessible notice — treat as "not visible", not as an error state | Stable |
| QA | The 14 built-in categories and 7 built-in templates are system-locked (deactivate, not delete) | Reproduce version conflicts with two concurrent PATCH calls | Stable |
| Internal service | None — this module has no service-to-service callers today | — | — |
13.5 API Tradeoffs and Rationale
See features/flows doc §12.4 for the full tradeoff table (async publication, live-spec audience vs. snapshot, dual read-state writes, single Notice Centre permission).
13.6 API Change Impact
| Change | Affected Consumers | Backend Impact | Data Impact | Migration Needed? |
|---|---|---|---|---|
Adding a new notice_target_kind | NoticeVisibilityService, the audience util, the admin audience picker | New membership branch required in membershipsFor | New enum value via ALTER TYPE ... ADD VALUE (checks are implications, so this is safe) | Yes — code and enum together |
| Adding a new channel | Create/update DTOs, NotificationService channel table | New notice_channel enum value | Migration | Yes |
| Changing the audience caps | notice.constants.ts only | Behavioural only | None | No |
14. Zero-Omission API Checklist
- Every controller route is documented (§4, §8).
- Every parent route prefix and runtime URL is documented.
- Every DTO field, enum, default, and validator is documented (§6-7).
- Every response field and omitted raw field (
storageKey) is documented. - Every auth, guard, and permission is documented (§5).
- Every success, validation, auth, not-found, and conflict branch is documented (§8, backend §13).
- Every DB write, cache effect, queue job, and notification is documented (§9, §11).
- Representative examples are given for create, publish, and category flows.
- Route, sequence, and error diagrams are included (§9).
- Tradeoffs are documented (§13.5, cross-referenced).
- Links to backend and features/flows docs are present.
15. Integration Checklist
- Every route from controllers is documented.
- Every DTO field is documented.
- Every enum value is documented.
- Every response envelope is documented.
- Every error code is documented (backend §13, cross-referenced).
- Every auth guard and permission is documented.
- Every cache key, queue job, and external call is documented.
- Diagrams match the current code.
- Links to backend and features/flows docs are present.
See Also
- Backend doc:
/docs/developer/notices/backend - Features and flows doc:
/docs/developer/notices/feature