Skoolsewa - School Management System Docs
Developer ResourcesNotices

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

AreaFiles InspectedWhat Was Verified
Controllersnotice/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.tsRoutes, methods, guards, decorators, status codes
DTOsnotice/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.tsRequest/query/response shapes, validation, examples
Servicescorresponding *.service.ts filesBehavior, side effects, response mapping, errors
Schemapackages/db/src/schema/school/notices.tsEnums, constraints, persisted fields
Errorsapps/api/src/common/types/error-codes.tsEvery NOTICE_* code
Routesapps/api/test/structure/structure.baseline.jsonAuthoritative route list, verified with jq

2. Module Summary

FieldValue
Module nameNotices
Module slugnotices
Primary actorsadministrator, publisher, delivery auditor, staff, teacher, pupil, guardian, the notices worker
API surfacesadmin (/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 modelJwtAuthGuard (global) + RoleGuard; every handler carries @Permissions(...) — no public and no permission-less routes in this module
PersistencePostgreSQL (Drizzle), Redis (cache only), BullMQ (QueueName.NOTICES), object storage (attachments)
Runtime source of truthnotices table for lifecycle; NoticeVisibilityService for what a reader may see
Sibling docsBackend, features/flows

3. Concepts and Terminology

TermMeaningSource FileUsed By
publicIdThe uuid a client addresses any notice/category/template/attachment/dispatch bynotices.ts schemaEvery route
statusNotice lifecycle: draft | scheduled | published | archivednoticeStatusEnumAdmin CRUD, publication
prioritylow | medium | high | urgent — affects prominence and the notification kindnoticePriorityEnumCreate/update, Notice Centre
channelin_app | push | email | smsnoticeChannelEnumCreate/update, delivery stats
AudienceSpecA 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
dispatchSeqThe delivery identity — increments on every publish and every notify-update; what makes a republish actually notify peoplenotices.dispatch_seqPublication result
versionOptimistic concurrency token, bumped by a database trigger on any updatenotices.versionUpdate, expiry PATCH
Notice CentreThe /api/mobile/portal/notices surface every audience reads throughnotice-centre/*All portal routes
matchedForWhich of the caller's children a notice concerns, for a guardianMatchedForDtoNotice Centre list/detail
retainedWhether the underlying notification_event/notification_recipient rows for a dispatch still exist (30-day retention)NoticeRecipientsPageDtoRecipients endpoint

4. API Surface Map

SurfaceMethodPathActorAuth/GuardPermissionControllerPurpose
AdminGET/api/noticesAdministratorJWT + RoleNotices_READNoticeControllerList notices
AdminGET/api/notices/summaryAdministratorJWT + RoleNotices_READNoticeControllerDashboard tiles
AdminGET/api/notices/audience/peopleAdministratorJWT + RoleNotices_READNoticeControllerFind a person to address
AdminPOST/api/notices/audience/previewAdministratorJWT + RoleNotices_READNoticeControllerPreview audience counts
AdminPOST/api/noticesAdministratorJWT + RoleNotices_CREATENoticeControllerCreate a draft
AdminGET/api/notices/:publicIdAdministratorJWT + RoleNotices_READNoticeControllerGet one notice
AdminPATCH/api/notices/:publicIdAdministratorJWT + RoleNotices_UPDATENoticeControllerUpdate a notice
AdminDELETE/api/notices/:publicIdAdministratorJWT + RoleNotices_DELETENoticeControllerSoft delete
AdminPOST/api/notices/:publicId/restoreAdministratorJWT + RoleNotices_RESTORENoticeControllerRestore
AdminPOST/api/notices/:publicId/archiveAdministratorJWT + RoleNotices_UPDATENoticeControllerArchive
AdminPOST/api/notices/:publicId/duplicateAdministratorJWT + RoleNotices_CREATENoticeControllerDuplicate
AdminPATCH/api/notices/:publicId/expiryAdministratorJWT + RoleNotices_UPDATENoticeControllerExtend/change expiry
AdminPOST/api/notices/:publicId/publishPublisherJWT + RoleNoticePublish_CREATENoticePublicationControllerPublish or schedule
AdminPOST/api/notices/:publicId/unpublishPublisherJWT + RoleNoticePublish_CREATENoticePublicationControllerWithdraw before delivery
AdminPOST/api/notices/:publicId/notify-updatePublisherJWT + RoleNoticePublish_UPDATENoticePublicationControllerNotify of a change
AdminGET/api/notices/:publicId/recipientsDelivery auditorJWT + RoleNoticeDelivery_READNoticeDeliveryControllerPer-recipient list
AdminGET/api/notices/:publicId/deliveryDelivery auditorJWT + RoleNoticeDelivery_READNoticeDeliveryControllerPer-channel totals
AdminPOST/api/notices/:publicId/deliveries/:deliveryPublicId/retryDelivery auditorJWT + RoleNoticeDelivery_UPDATENoticeDeliveryControllerRetry one delivery
AdminPOST/api/notices/:publicId/attachmentsAdministratorJWT + RoleNotices_UPDATENoticeAttachmentControllerUpload attachment
AdminDELETE/api/notices/:publicId/attachments/:attachmentPublicIdAdministratorJWT + RoleNotices_UPDATENoticeAttachmentControllerRemove attachment
AdminPATCH/api/notices/:publicId/attachments/:attachmentPublicId/primaryAdministratorJWT + RoleNotices_UPDATENoticeAttachmentControllerSet primary image
Admin/PortalGET/api/notice-attachments/:attachmentPublicIdAny visible reader, or Notices_READJWT + RoleNoticeView_READNoticeAttachmentControllerStream attachment bytes
AdminGET/api/notice-categoriesAdministratorJWT + RoleNoticeCategories_READNoticeCategoryControllerList categories
AdminPOST/api/notice-categoriesAdministratorJWT + RoleNoticeCategories_CREATENoticeCategoryControllerCreate category
AdminGET/api/notice-categories/:publicIdAdministratorJWT + RoleNoticeCategories_READNoticeCategoryControllerGet category
AdminPATCH/api/notice-categories/:publicIdAdministratorJWT + RoleNoticeCategories_UPDATENoticeCategoryControllerUpdate category
AdminDELETE/api/notice-categories/:publicIdAdministratorJWT + RoleNoticeCategories_DELETENoticeCategoryControllerRetire category
AdminPOST/api/notice-categories/:publicId/restoreAdministratorJWT + RoleNoticeCategories_RESTORENoticeCategoryControllerRestore category
AdminGET/api/notice-templatesAdministratorJWT + RoleNoticeTemplates_READNoticeTemplateControllerList templates
AdminPOST/api/notice-templatesAdministratorJWT + RoleNoticeTemplates_CREATENoticeTemplateControllerCreate template
AdminGET/api/notice-templates/:publicIdAdministratorJWT + RoleNoticeTemplates_READNoticeTemplateControllerGet template
AdminGET/api/notice-templates/:publicId/renderAdministratorJWT + RoleNoticeTemplates_READNoticeTemplateControllerRender for a new notice
AdminPATCH/api/notice-templates/:publicIdAdministratorJWT + RoleNoticeTemplates_UPDATENoticeTemplateControllerUpdate template
AdminDELETE/api/notice-templates/:publicIdAdministratorJWT + RoleNoticeTemplates_DELETENoticeTemplateControllerRetire template
AdminPOST/api/notice-templates/:publicId/restoreAdministratorJWT + RoleNoticeTemplates_RESTORENoticeTemplateControllerRestore template
Mobile/PortalGET/api/mobile/portal/noticesStaff/teacher/pupil/guardian/superadminJWT + RoleNoticeView_READNoticeCentreControllerList visible notices
Mobile/PortalGET/api/mobile/portal/notices/unread-countsameJWT + RoleNoticeView_READNoticeCentreControllerUnread badge count
Mobile/PortalGET/api/mobile/portal/notices/:publicIdsameJWT + RoleNoticeView_READNoticeCentreControllerRead one notice
Mobile/PortalPOST/api/mobile/portal/notices/:publicId/readsameJWT + RoleNoticeView_READNoticeCentreControllerMark read
Mobile/PortalDELETE/api/mobile/portal/notices/:publicId/readsameJWT + RoleNoticeView_READNoticeCentreControllerMark 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

SurfaceGuard/DecoratorIdentity ShapePermissionGuest AllowedNotes
All admin routesJwtAuthGuard, RoleGuard, @Permissions(...)req.user.id, activeRoleone Module_ACTION code per route (table above)NoStandard admin permission gate
Notice Centre (/mobile/portal/notices*)JwtAuthGuard, RoleGuard, @Permissions("NoticeView_READ")req.user.id, activeRoleNoticeView_READ (held by staff, teacher, pupil, and guardian roles, plus superadmin)NoThe permission proves nothing about which notices come back — NoticeVisibilityService builds the WHERE clause from the caller's own profile rows
Attachment streamJwtAuthGuard, RoleGuard, @Permissions("NoticeView_READ")req.user.idNoticeView_READ, or the caller separately holds Notices_READ (checked in-service)NoThe 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)

FieldTypeRequiredDefaultValidationSource
publicIdstring (uuid)Yes——notice.dto.ts
titlestringYes——
summarystringYes——
categoryIdstringYes—The category's publicId
categoryNamestringYes——
categoryColorTokenstring | nullNo——
priorityenumYes—low|medium|high|urgent
statusenumYes—draft|scheduled|published|archived
audienceLabelstringYes—Rendered server-side
channelsenum[]Yes—in_app|push|email|sms
publishAt / expiresAt / publishedAtDate | nullNo——
createdByNamestringYes—Name snapshot, survives author removal
versionstringYes—Send back verbatim on PATCH
createdAt / updatedAtDateYes——
deletedAtDate | nullNonullSet 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 / contentTextstringYes—Sanitised HTML / derived plain text
audienceobjectYes—The stored AudienceSpec verbatim
actionUrl / actionLabelstring | nullNo——
calendarDayIdstring | nullNo——
academicSessionIdstringYes——
duplicatedFromIdstring | nullNo—Source notice's publicId
publishRequestedByNamestring | nullNo—Null until scheduled/published
attachmentsNoticeAttachmentSummaryDto[]Yes—Never carries storageKey

6.2 CreateNoticeDto (request body)

FieldTypeRequiredDefaultValidationExample
titlestringYes—non-empty, ≤200"Exam Schedule"
summarystringNoderived from content≤300—
contentHtmlstringYes—non-empty; sanitised server-side"<p>...</p>"
categoryIdstring (uuid)Yes—must resolve to a live category—
priorityenumYes—low|medium|high|urgent"high"
audienceobjectYes—an AudienceSpec; validated structurally in-service{"kind":"all_users"}
channelsenum[]Yes—1-4 items, each a valid channel["in_app","push"]
publishAtstring (ISO 8601)No———
expiresAtstring (ISO 8601)No—must be after publishAt if both set—
actionUrlstringNo—≤2048; https:// or app-relative single-slash path—
actionLabelstringNo—≤60; required together with actionUrl—
calendarDayIdstring (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

FieldTypeRequiredNotes
versionstringYesConcurrency token
expiresAtstring (ISO 8601)YesAn 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

FieldTypeRequired (create)Validation
namestringYes1-80 chars
slugstringYes^[a-z][a-z0-9-]*$, ≤80
descriptionstringNo≤1000
colorTokenstringNo≤32, a design-token name, never a hex value
sortOrdernumberNo, default 00-1000
isActivebooleanNo, default true—

Response additionally carries publicId, isSystem (never accepted from a caller), createdAt/updatedAt/deletedAt.

6.10 NoticeTemplateDto / Create/UpdateNoticeTemplateDto

FieldTypeRequired (create)Validation
namestringYes1-120
slugstringYes^[a-z][a-z0-9-]*$, ≤120
categoryPublicIdstringNo—
priorityenumNo—
titleTemplatestringYes1-200; {{token}} placeholders
contentHtmlstringYesnon-empty
defaultChannelsenum[]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)
isActivebooleanNo, default true—
sortOrdernumberNo, default 00-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

EnumValueMeaningRuntime EffectSource
notice_prioritylowLowest prominenceMaps to notice.published.low / notice.updatednotices.ts
notice_prioritymediumDefault prominenceMaps to notice.published.normal / notice.updated
notice_priorityhighElevated prominenceMaps to notice.published.high / notice.updated
notice_priorityurgentHighest prominenceMaps to notice.published.urgent / notice.updated.urgent — both resolve to notification priority high, never critical
notice_statusdraftNot yet scheduled or sentEditable in full
notice_statusscheduledQueued for delivery, not yet deliveredPublication params frozen; can be unpublished
notice_statuspublishedDeliveredPublication params frozen; content still editable; can be archived or notify-updated
notice_statusarchivedRetiredContent frozen; reachable from any state; not terminal — extending expiry can return it to published
notice_channelin_app | push | email | smsDelivery channelPassed to NotificationService.send()
notice_attachment_kindimage | documentAttachment typeOnly image may be primary
notice_dispatch_reasonpublished | updatedWhy a notice_dispatches row existsupdated is only ever the explicit notify-update action, never an ordinary edit
notice_target_kindusers, 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_usersEvery leaf AudienceSpec kindcompound 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

EvidencePath
Controllernotice/notice.controller.ts
DTOnotice/dto/notice.dto.ts (ListNoticesQueryDto)
Servicenotice/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 StatusError CodeCondition
400NOTICE_CONTENT_EMPTYSanitised HTML has no remaining text
404NOTICE_CATEGORY_NOT_FOUNDcategoryId does not resolve
400NOTICE_AUDIENCE_KIND_UNKNOWNUnrecognised top-level kind
400NOTICE_AUDIENCE_EMPTYThe normalised audience names nobody — checked by assertCaps on both create and update, not only at publish time
400NOTICE_AUDIENCE_TOO_LARGEMore than 500 explicit users ids, or more than 10 compound members — the same caps assertSendablePreconditions re-checks at publish time
400NOTICE_ACTION_URL_FORBIDDENUnsafe 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 StatusError CodeCondition
409NOTICE_VERSION_CONFLICTversion mismatch
409NOTICE_PUBLICATION_PARAMS_IMMUTABLEPublication field touched on non-draft
409NOTICE_ARCHIVED_IMMUTABLEContent 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 StatusError CodeCondition
409NOTICE_VERSION_CONFLICTversion 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.
409NOTICE_ALREADY_PUBLISHEDAlready published/archived
400NOTICE_PUBLISH_AT_IN_PASTSchedule more than 60s in the past
400NOTICE_EXPIRY_BEFORE_PUBLISHexpiresAt <= publishAt
400NOTICE_CHANNELS_EMPTYNo channels on the notice
400NOTICE_ACTION_URL_FORBIDDENUnsafe action URL
400NOTICE_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 holds Notices_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 StatusError CodeCondition
404NOTICE_ATTACHMENT_NOT_FOUNDNo such attachment
404NOTICE_NOT_FOUNDCaller 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

EndpointPagination TypeDefault SizeMax SizeSort FieldsFiltersResult Cap
GET /api/noticespage/size20100inherited QueryDto fieldsstatus, priority, categoryId, audienceKind, createdByName, publishedFrom/To, deleted (live vs. removed view)PaginationUtil.UNPAGINATED_HARD_CAP if pagination=false
GET /api/notice-categoriespage/size20100inheritedisActive, includeDeletedsame
GET /api/notice-templatespage/size20100inheritedisActive, includeDeletedsame
GET /api/notices/:id/recipientspage/size20100insertion order——
GET /api/mobile/portal/noticespage/size20100publishAt 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

IntegrationUsed?DetailsSource
Redis cacheYesnotice:admin:list:*, notice:detail:id:<publicId>, notice:unread:user:<userId> — see backend §8 for the full key tableshared/notice.constants.ts
BullMQYesQueueName.NOTICES — PUBLISH, NOTIFY_UPDATE, MAINTENANCE_SWEEP (also redrives up to 100 stuck-scheduled publications per run, reusing their original dispatch identity), CAPTURE_STATS, CACHE_INVALIDATEpackages/jobs/src/index.ts, backend §9
External APINoProvider dispatch (SMTP/SMS/push) is entirely inside NotificationService, not called directly from this module—
Object storageYesStorageManager for attachment upload/read; no signed URLsnotice-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

ConsumerRequired KnowledgeFailure HandlingContract 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_CONFLICTStable
Notice Centre (same panel, portal routes)List is not cached — safe to re-fetch after mark-read; detail never marks read404 on an inaccessible notice — treat as "not visible", not as an error stateStable
QAThe 14 built-in categories and 7 built-in templates are system-locked (deactivate, not delete)Reproduce version conflicts with two concurrent PATCH callsStable
Internal serviceNone — 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

ChangeAffected ConsumersBackend ImpactData ImpactMigration Needed?
Adding a new notice_target_kindNoticeVisibilityService, the audience util, the admin audience pickerNew membership branch required in membershipsForNew enum value via ALTER TYPE ... ADD VALUE (checks are implications, so this is safe)Yes — code and enum together
Adding a new channelCreate/update DTOs, NotificationService channel tableNew notice_channel enum valueMigrationYes
Changing the audience capsnotice.constants.ts onlyBehavioural onlyNoneNo

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

On this page

Notices - API Reference1. Documentation Evidence2. Module Summary3. Concepts and Terminology4. API Surface Map5. Auth, Identity, and Permissions6. DTO and Model Reference6.1 NoticeListItemDto / NoticeDto (response)6.2 CreateNoticeDto (request body)6.3 UpdateNoticeDto (request body)6.4 UpdateNoticeExpiryDto6.5 AudiencePreviewRequestDto / NoticeAudiencePreviewDto6.6 NoticePeopleQueryDto / NoticePersonDto6.7 PublishNoticeDto / NoticePublicationResultDto6.8 NotifyNoticeUpdateDto6.9 NoticeCategoryDto / Create/UpdateNoticeCategoryDto6.10 NoticeTemplateDto / Create/UpdateNoticeTemplateDto6.11 NoticeAttachmentDto / NoticeAttachmentUploadDto6.12 Notice Centre DTOs6.13 Delivery DTOs7. Enum Reference8. Endpoint Reference8.1 GET /api/noticesPurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsEdge Cases8.2 GET /api/notices/summary8.3 GET /api/notices/audience/people8.4 POST /api/notices/audience/preview8.5 POST /api/noticesPurposeAuth and PermissionsRequestResponseSide EffectsError CasesExample Requests8.6 GET /api/notices/:publicId8.7 PATCH /api/notices/:publicId8.8 DELETE /api/notices/:publicId8.9 POST /api/notices/:publicId/restore8.10 POST /api/notices/:publicId/archive8.11 POST /api/notices/:publicId/duplicate8.12 PATCH /api/notices/:publicId/expiry8.13 POST /api/notices/:publicId/publishPurposeAuth and PermissionsRequestResponseSide EffectsError Cases8.14 POST /api/notices/:publicId/unpublish8.15 POST /api/notices/:publicId/notify-update8.16 POST /api/notices/:publicId/attachments8.17 DELETE /api/notices/:publicId/attachments/:attachmentPublicId8.18 PATCH /api/notices/:publicId/attachments/:attachmentPublicId/primary8.19 GET /api/notice-attachments/:attachmentPublicIdPurposeAuth and PermissionsResponseError Cases8.20-8.25 Notice Categories8.26-8.32 Notice Templates8.33 GET /api/notices/:publicId/recipients8.34 GET /api/notices/:publicId/delivery8.35 POST /api/notices/:publicId/deliveries/:deliveryPublicId/retry8.36 GET /api/mobile/portal/noticesPurposeAuth and PermissionsRequestResponseEdge Cases8.37 GET /api/mobile/portal/notices/unread-count8.38 GET /api/mobile/portal/notices/:publicId8.39 POST /api/mobile/portal/notices/:publicId/read8.40 DELETE /api/mobile/portal/notices/:publicId/read9. Flow Diagrams9.1 Route Ownership9.2 Request Sequence (publish)9.3 Error Branch10. Pagination, Sorting, Filtering, and Search11. Caching, Jobs, and External Integrations13. Mandatory 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