Skoolsewa - School Management System Docs
Developer ResourcesNotices

Notices Backend Documentation

Backend architecture, data model, services, cache, queues, runtime rules, and operational behavior for Notices.

Notices Backend Documentation

1. Documentation Evidence

AreaFiles InspectedVerified Details
Module wiringapps/api/src/modules/notices/*.module.ts, leaf module filesImports, providers, exports, controller composition
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.tsRoute ownership, guard chains, permissions
Servicesnotice/notice-write.service.ts, notice-read.service.ts, notice-lookup.service.ts, notice-audience-resolution.service.ts, notice-people.service.ts, publication/notice-publication.service.ts, workers/notice-publish.service.ts, workers/notice-maintenance.service.ts, notice-centre/notice-centre.service.ts, notice-centre/notice-centre-enrichment.service.ts, notice-delivery/notice-delivery.service.ts, notice-attachment/notice-attachment.service.ts, notice-category/notice-category.service.ts, notice-template/notice-template.service.ts, shared/notice-visibility.service.tsBusiness logic, validation, writes, mappings, side effects
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.tsAPI contracts and validation
Schemapackages/db/src/schema/school/notices.tsTables, enums, relations, indexes, CHECKs
Migrationspackages/db/src/migrations/0024_notice_module.sql, 0025_notice_version_trigger_and_defaults.sqlDDL, seeded defaults, version trigger
Jobspackages/jobs/src/index.ts (QueueName.NOTICES, NoticeJob)Queue contract and payloads
Cacheshared/notice.constants.ts (NOTICE_CACHE_PREFIX, key builders)Key construction, TTLs, invalidation
Constantsshared/notice.constants.tsCaps, sanitiser configuration, notification kind mapping
Audienceshared/notice-audience.util.ts, notice/notice-audience-input.util.ts, notice/notice-audience-resolution.service.tsNormalisation, projection, caps
Authorizationpackages/db/src/authorization/permission-catalog.ts, packages/db/src/seed/seed-auth.tsPermission modules and role grants
Errorsapps/api/src/common/types/error-codes.tsEvery NOTICE_* code
Notificationsapps/api/src/modules/notification/templates/template-registry.tsnotice.* kinds
Route surfaceapps/api/test/structure/structure.baseline.jsonAuthoritative routes

2. Backend Scope and Boundaries

Owns

  • The notice entity itself: title, rich content, an audience specification, a publication lifecycle (draft → scheduled → published → archived), soft delete/restore.
  • Publication as a capability distinct from editing: scheduling, immediate publishing, unpublishing (before delivery), and explicit "notify recipients of an update".
  • Notice categories and notice templates — both administrator-managed catalogues, with fourteen and seven seeded system defaults respectively (see §5).
  • Attachments (images and documents) and their bytes, streamed by this module rather than by the shared upload registry.
  • Read state (notice_reads) — durable, permanent, independent of notification retention.
  • The Notice Centre: the audience-facing read surface at /api/mobile/portal/notices, and the single visibility predicate that decides who may see which notice.
  • The audience-membership resolver that both writes the derived notice_audience_targets rows and reads them back for visibility.
  • Per-recipient delivery listing, per-channel delivery statistics, and a bounded retry of one failed delivery belonging to one notice.
  • Two BullMQ workers on its own queue (QueueName.NOTICES): publish/notify-update, and two maintenance sweeps (expiry archival plus lease reclaim, and delivery-statistics capture).

Does Not Own

  • Delivery. NotificationService.send() resolves the audience to per-person rows, writes notification_event / notification_recipient / notification_delivery, and drives per-channel providers and retries. Notices raises exactly one event per dispatch and stops. There is no second fan-out, no second delivery table, and no second dead-letter path inside the notices module.
  • Provider retries beyond one bounded manual retry. NoticeDeliveryService.retry delegates to NotificationFailureService.replay, the same dead-letter replay every other module uses.
  • Notification preferences and channel provider configuration. These belong to the notification module.
  • Storage. StorageManager performs the upload and read; the notices module only decides when to call it and never exposes a raw storage key.
  • Identity, roles, and permission grants. RoleGuard, PeopleAccessService, and the seeded role/permission tables are read, never modified, by this module.

Source of Truth

ConcernSource of TruthNotes
Notice lifecycle statusnotices.statusNever derived; the maintenance sweep persists expiry rather than computing it on read, so an admin list filter agrees with the detail view.
Audience membershipnotice_audience_targets, re-derived from notices.audience on every writeA denormalised index over the stored AudienceSpec, not a materialised recipient snapshot — a pupil enrolled after publication is still visible to a live class audience.
Delivery identitynotices.dispatch_seqBumped once per publish and once per notify-update, in the request transaction; this is what makes a republish reach people instead of silently collapsing into the first send.
Read statenotice_readsPermanent. notification_recipient.read_at is a best-effort mirror kept only so the notification bell agrees; it is pruned at 30 days and is never the read of record.
VisibilityNoticeVisibilityServiceThe only implementation of the predicate; every reader (list, detail, unread count, attachment stream) calls it.
Row concurrencynotices.version, bumped by a database triggerA mismatch on write is NOTICE_VERSION_CONFLICT, never a silent overwrite.

3. Module Composition

ModuleTypePathControllersProvidersExportsResponsibility
NoticeModuleLeafapps/api/src/modules/notices/notice/NoticeControllerNoticeService, NoticeWriteService, NoticeReadService, NoticeLookupService, NoticeAudienceResolutionService, NoticePeopleServiceServices used by publication/workersNotice CRUD, lookups, audience list/preview
NoticePublicationModuleLeafapps/api/src/modules/notices/publication/NoticePublicationControllerNoticePublicationServiceNoticePublicationServicePublish/unpublish/notify-update request half
NoticeDeliveryModuleLeafapps/api/src/modules/notices/notice-delivery/NoticeDeliveryControllerNoticeDeliveryService—Per-notice delivery stats, recipients, retry
NoticeAttachmentModuleLeafapps/api/src/modules/notices/notice-attachment/NoticeAttachmentControllerNoticeAttachmentService—Attachment upload/remove/primary/stream
NoticeCategoryModuleLeafapps/api/src/modules/notices/notice-category/NoticeCategoryControllerNoticeCategoryServiceNoticeCategoryService (to templates/notice lookup)Category CRUD, seeded defaults
NoticeTemplateModuleLeafapps/api/src/modules/notices/notice-template/NoticeTemplateControllerNoticeTemplateService—Template CRUD, token rendering, seeded defaults
NoticeCentreModuleLeaf, mounted under mobileapps/api/src/modules/notices/notice-centre/NoticeCentreControllerNoticeCentreService, NoticeCentreEnrichmentService—The audience-facing read surface
(shared, no module of its own)Sharedapps/api/src/modules/notices/shared/—NoticeVisibilityService, audience util functions, constantsImported directly by every leaf aboveVisibility predicate, cache keys, sanitiser config, caps
NoticesWorkerModuleLeaf, worker-onlyapps/api/src/modules/notices/workers/— (no HTTP surface)NoticesQueueProcessor, NoticePublishService, NoticeMaintenanceService, NoticeScheduler—The one BullMQ worker on QueueName.NOTICES, and the two cron ticks

Every leaf is registered individually — Notices (via NoticeModule), NoticeCategories, NoticeTemplates, NoticeDelivery and NoticeAttachment in ADMIN_MODULES; the Notice Centre leaf in MOBILE_CHILDREN under mobile. There is no aggregate NoticesModule that composes the others for Swagger purposes — each leaf declares its own controllers, matching the repo-wide rule that Swagger's include does not recurse.

4. File and Directory Map

apps/api/src/modules/notices/
  shared/
    notice.constants.ts            caps, sanitiser config, cache keys, notification kind maps
    notice-visibility.service.ts   the one visibility predicate
    notice-audience.util.ts        normalise/project/count/label — pure functions
  notice/
    notice.controller.ts
    notice.service.ts              thin facade over write/read/lookup/audience/people
    notice-write.service.ts        create/update/remove/restore/archive/duplicate/updateExpiry
    notice-read.service.ts         findAll/findOne/summary — loadDetail; findAll's `deleted` flag switches between the live and soft-deleted views and is part of the admin-list cache key
    notice-lookup.service.ts       category/calendar-day/academic-session resolution, live-claim guard
    notice-audience-resolution.service.ts   prepareAudience/writeTargets/replaceTargets
    notice-audience-input.util.ts  structural validation of the raw AudienceSpec DTO
    notice-content.util.ts         sanitiseNoticeHtml/deriveContentText/deriveSummary
    notice-error.util.ts           DB error -> ErrorCodes translation, noticeNotFound()
    notice-mapper.util.ts          row -> NoticeDto mapping
    notice-update-fields.util.ts   buildUpdateValues for PATCH
    notice-validation.util.ts      assertExpiryAfterPublish, validateActionPair
    notice-people.service.ts       name search for a `users` audience
    dto/
  publication/
    notice-publication.controller.ts
    notice-publication.service.ts  publish/unpublish/notifyUpdate — request half
    dto/
  notice-delivery/
    notice-delivery.controller.ts
    notice-delivery.service.ts     stats, recipients, retry
    dto/
  notice-attachment/
    notice-attachment.controller.ts
    notice-attachment.service.ts
    notice-attachment.multer.ts
    dto/
  notice-category/
    notice-category.controller.ts
    notice-category.service.ts
    notice-category.defaults.ts    13 seeded categories
    dto/
  notice-template/
    notice-template.controller.ts
    notice-template.service.ts
    notice-template-token.util.ts  {{schoolName}}/{{sessionName}}/{{date}} substitution
    notice-template.defaults.ts
    dto/
  notice-centre/
    notice-centre.controller.ts
    notice-centre.service.ts
    notice-centre-enrichment.service.ts   matchedFor, primary attachment batch lookups
    dto/
  workers/
    notice.scheduler.ts            the two cron ticks
    notices-queue.processor.ts     the one BullMQ Worker
    notice-publish.service.ts      PUBLISH / NOTIFY_UPDATE handlers
    notice-maintenance.service.ts  MAINTENANCE_SWEEP / CAPTURE_STATS / CACHE_INVALIDATE handlers
    notices-worker.module.ts
FilePurposeKey ExportsNotes
shared/notice.constants.tsEvery tunable in one placeNOTICE_SANITIZE_OPTIONS, NOTICE_CACHE_PREFIX, NOTICE_PUBLISH_KIND, NOTICE_UPDATE_KIND, cache key builderssanitize-html is pinned to an exact version — from 2.17.6 it depends on an ESM-only htmlparser2, which Jest loads only through the transformIgnorePatterns allow-list in every API Jest config.
shared/notice-visibility.service.tsThe only visibility predicateNoticeVisibilityServiceMatches on the (kind, target_uuid) pair, never the bare uuid — the id spaces collide across kinds by construction.
shared/notice-audience.util.tsPure spec transformsnormaliseAudience, projectAudienceTargets, countExplicitRecipients, countAudienceMembers, buildAudienceLabelNo NestJS, no I/O — lives beside the module because exactly one module imports it.
notice/notice-write.service.tsEvery mutating notice endpointNoticeWriteServiceOptimistic concurrency, publication-params-immutable-outside-draft, archived-content-immutable.
workers/notices-queue.processor.tsThe single BullMQ Worker on QueueName.NOTICESNoticesQueueProcessorhandlers is a Record<NoticeJob, …> — a job name with no handler is a compile error.

5. Data Model

5.1 Schema Source

packages/db/src/schema/school/notices.ts

One file defines every enum, table, and relation for the module. Migrations: 0024_notice_module.sql (the module's tables, enums, indexes and constraints) and 0025_notice_version_trigger_and_defaults.sql (the notices_bump_row_version trigger on notices, and the fourteen built-in categories and seven built-in templates, inserted idempotently so a deploy that never seeds still has them).

5.2 Tables and Collections

notice_categories

ColumnTypeNullableDefaultIndex/ConstraintNotes
idserialNogeneratedPKInternal FK target
public_iduuidNo$defaultFn (uuid v7)uniqueAddress by this
namevarchar(80)No—CHECK non-blank, ≤80
slugvarchar(80)No—partial unique on deleted_at IS NULL, CHECK ^[a-z][a-z0-9-]*$Reusable after retirement
descriptiontextYes——
color_tokenvarchar(32)Yes——A design-token name, never a hex value
sort_ordersmallintNo0CHECK 0..1000
is_activebooleanNotrue—
is_systembooleanNofalse—One of the 13 seeded defaults; deletion refused, deactivation always available
created_at / updated_attimestamptzNonow()—
deleted_attimestamptzYes—index notice_categories_active_idx (partial, deleted_at IS NULL AND is_active)Soft delete

notice_templates

ColumnTypeNullableDefaultConstraintNotes
id / public_idserial / uuidNo—unique public_id
namevarchar(120)No—non-blank
slugvarchar(120)No—partial unique on deleted_at IS NULL
category_idintegerYes—FK notice_categories.id, ON DELETE SET NULL
prioritynotice_priorityYes——
title_templatevarchar(200)No—non-blank{{token}} placeholders
content_htmltextNo—non-blankSanitised at write, same config as notices.content_html
default_channelsnotice_channel[]No—CHECK cardinality BETWEEN 1 AND 4
is_activebooleanNotrue—
is_systembooleanNofalse—One of 8 seeded defaults; deletion refused
sort_ordersmallintNo0CHECK 0..1000
created_byuuidYes—FK users.id, SET NULL
created_at / updated_at / deleted_attimestamptzmixed——

notices

ColumnTypeNullableDefaultConstraintNotes
id / public_idserial / uuidNo—unique public_id
titlevarchar(200)No—non-blank
summaryvarchar(300)No—non-blankPush/SMS body and list preview; always derived if the author leaves it blank
content_htmltextNo—non-blankSanitised once, at the write path
content_texttextNo—non-blankEntity-decoded plain text, derived from sanitised HTML; drives search
category_idintegerNo—FK notice_categories.id, ON DELETE RESTRICT
prioritynotice_priorityNo——low | medium | high | urgent
statusnotice_statusNo——draft | scheduled | published | archived
audiencejsonbNo—CHECK object, CHECK top-level kind knownThe AudienceSpec, normalised
audience_labelvarchar(240)No——Rendered once at write time, elided if long
channelsnotice_channel[]No—CHECK 1..4
publish_at / expires_at / published_attimestamptzYes—see checks below
publish_requested_byuuidYes—FK users.id, SET NULLWho requested publication (publication is performed by a job)
dispatch_seqintegerNo0CHECK >= 0The delivery identity — bumped on every publish and notify-update
action_url / action_labeltext / varchar(60)Yes—CHECK both-or-neither, CHECK scheme (https:// or a single-leading-slash relative path, never // or \)
calendar_day_iduuidYes—FK academic_calendar_days.id, SET NULL
academic_session_idintegerNo—FK academic_sessions.id, RESTRICT
template_idintegerYes—FK notice_templates.id, SET NULL
duplicated_from_idintegerYes—self-FK, SET NULL
created_byuuidYes—FK users.id, SET NULL
created_by_namevarchar(160)No——Snapshot beside the nullable FK, so a removed author's notices stay filterable and named
updated_byuuidYes—FK users.id, SET NULL
versionbigint (mode number)No0CHECK >= 0Bumped by the bump_row_version trigger on any UPDATE
created_at / updated_at / deleted_attimestamptzmixed——deleted_at is exposed on NoticeDto as deletedAt, set only when the row is soft-deleted; GET /notices?deleted=true is the only list view where it is ever non-null

Key CHECK constraints, all written as implications (status <> 'x' OR <rule>) so a future ALTER TYPE ... ADD VALUE stays writable:

  • notices_non_draft_needs_publish_at — non-draft requires publish_at IS NOT NULL.
  • notices_expiry_after_publish — expires_at > publish_at when both are set (strict >, matching the service's <= rejection).
  • notices_published_has_timestamp — published requires published_at IS NOT NULL.
  • notices_action_url_scheme — mirrors isSafeActionUrl exactly.
  • notices_audience_kind_known — top-level kind only; compound members are validated in code.

Indexes: notices_status_publish_idx, notices_category_idx, notices_session_publish_idx, notices_created_by_idx, notices_priority_publish_idx, notices_template_idx, notices_calendar_day_idx, notices_duplicated_from_idx, notices_publish_requested_by_idx, notices_updated_by_idx, plus two partial indexes that serve the sweep: notices_due_publish_idx (status = 'scheduled') and notices_due_expiry_idx (status = 'published' AND expires_at IS NOT NULL).

notice_runtime

One row per notice, created unclaimed when publication is first requested. Deliberately carries no version column and no bump_row_version trigger — that trigger fires on any update, and stamping the worker's resolution progress onto the versioned notices row would 409 an administrator holding the edit form at the exact moment the work they asked for completed.

ColumnTypeNullableNotes
notice_idintegerNo (PK)FK notices.id, CASCADE
claimed_at / claim_expires_attimestamptzYesThe publish lease
claimed_byvarchar(64)Yespid:<process.pid>
resolve_cursorvarchar(64)YesKeyset position of the audience walk, for resumable retries
resolved_attimestamptzYesWritten together with recipient_count in one statement
recipient_countintegerYesCHECK >= 0 when set
last_errortextYes
updated_attimestamptzNo

CHECKs: (resolved_at IS NULL) = (recipient_count IS NULL); (claimed_at IS NULL) = (claim_expires_at IS NULL). Partial index notice_runtime_claim_expiry_idx on claim_expires_at WHERE resolved_at IS NULL serves the reclaim sweep.

notice_attachments

ColumnTypeNullableNotes
id / public_idserial / uuidNo
notice_idintegerNoFK notices.id, CASCADE
kindnotice_attachment_kindNoimage | document
storage_keyvarchar(500)NoServer-written only; never accepted from a caller
original_namevarchar(255)No
mime_typevarchar(120)No
size_bytesintegerNoCHECK > 0 — a zero-byte attachment offers a download that delivers nothing
is_primarybooleanNo, default falsePartial unique index per notice; CHECK primary implies kind = 'image'
sort_ordersmallintNo, default 0CHECK 0..1000
created_attimestamptzNo

The primary-image flag lives on the attachment row, not as a pointer from notices — the row already belongs to exactly one notice, so no composite foreign key is needed and no circular dependency between the two tables is created.

notice_audience_targets

The denormalised, re-projected index of notices.audience.

ColumnTypeNullableNotes
idserialNo
notice_idintegerNoFK notices.id, CASCADE
kindnotice_target_kindNoEvery leaf AudienceSpec kind — compound is never a row; it is flattened at projection time
target_uuiduuidYesNULL only for all_users

CHECK: (kind = 'all_users') = (target_uuid IS NULL). Unique index on (notice_id, kind, target_uuid) plus a second partial unique index on notice_id WHERE kind = 'all_users' (Postgres treats NULLs as distinct, so the first index alone would allow unlimited all_users rows). Lookup index on (kind, target_uuid) serves the reader-side join.

What target_uuid references, per kind — there is no foreign key because the referent differs:

KindReferences
usersusers.id
rolerole.public_id
classclasses.public_id
sectionsections.public_id
gradegrades.public_id
guardians_of_classclasses.public_id
guardians_of_sectionsections.public_id
guardians_of_gradegrades.public_id
guardians_of_usersstudents.id (a uuid PK, not a public_id)
teachers_of_classclasses.public_id
teachers_of_gradegrades.public_id
staff_departmentdepartments.public_id
all_usersNULL

class, guardians_of_class, and teachers_of_class all key on the same classes.public_id — the id spaces collide by construction, which is why every reader matches on the (kind, target_uuid) pair and never on the bare uuid.

notice_reads

Composite primary key (notice_id, user_id) — a join table where absence of a row is "unread"; mark-unread deletes the row rather than nulling a timestamp. Separate from notification_recipient.read_at because that table is pruned at 30 days with no read/unread exemption, while notice history is permanent. notice_reads_user_idx on (user_id, read_at); notice_reads_notice_idx on notice_id.

notice_dispatches

One row per delivery attempt (publication, and each explicit re-notify).

ColumnTypeNullableNotes
id / public_idserial / uuidNo
notice_idintegerNoFK notices.id, CASCADE
reasonnotice_dispatch_reasonNopublished | updated
dispatch_seqintegerNoCHECK >= 1
notification_event_public_iduuidNoNot a foreign key — the event it names is pruned at retention while this row is permanent
channelsnotice_channel[]NoCHECK 1..4
recipient_countintegerYesThe publish-time snapshot
delivery_statsjsonbYesFrozen per-channel counters, written by CAPTURE_STATS
stats_captured_attimestamptzYesCHECK: NULL together with delivery_stats
requested_attimestamptzNo
requested_byuuidYesFK users.id, SET NULL
notevarchar(200)YesInternal, never sent to recipients

Unique constraints: (notice_id, dispatch_seq) (what makes a retried PUBLISH job idempotent via onConflictDoNothing) and (notice_id, notification_event_public_id). Partial index notice_dispatches_uncaptured_idx on requested_at WHERE stats_captured_at IS NULL serves the capture sweep.

5.3 Relationship Diagram

6. Services and Responsibilities

6.1 NoticeWriteService

MethodCalled ByReadsWritesSide EffectsErrors
createNoticeController.createcategories, calendar days, academic sessionsnotices, notice_audience_targetsInvalidates admin-list cacheNOTICE_CONTENT_EMPTY, NOTICE_CATEGORY_NOT_FOUND, NOTICE_AUDIENCE_EMPTY, action-url errors
updateNoticeController.updatenotices (row lock)notices, notice_audience_targetsInvalidates detail + list cacheNOTICE_VERSION_CONFLICT, NOTICE_PUBLICATION_PARAMS_IMMUTABLE, NOTICE_ARCHIVED_IMMUTABLE
removeNoticeController.removenotices (row lock)soft delete; a scheduled notice is returned to draft in the same transactionMarks the notice's pending outbox row dead; invalidates cachesNOTICE_NOT_FOUND, refused while a publish claim is live (assertNoLivePublishClaim)
restoreNoticeController.restorenoticesclears deleted_atInvalidates list cacheNOTICE_NOT_FOUND
archiveNoticeController.archivenotices (row lock), notice_runtimestatus = 'archived'Invalidates cachesNOTICE_NOT_PUBLISHED — legal only from status = published; refused while a publish claim is live
duplicateNoticeController.duplicatenoticesnew notices row, notice_audience_targetsInvalidates list cacheNOTICE_NOT_FOUND
updateExpiryNoticeController.updateExpirynotices (row lock)expires_at, possibly statusInvalidates cachesNOTICE_VERSION_CONFLICT

Every mutating method routes its final read through NoticeReadService.loadDetail rather than re-deriving a response from whichever columns the write happened to touch.

remove locks the row, refuses if a publish claim is currently held (NoticeLookupService.assertNoLivePublishClaim), and — when the notice is scheduled — cancels its pending publish outbox row (marks it dead with a failed_at) and resets status to draft before soft-deleting, all inside the one transaction. A deleted notice never has a live outbox row racing the soft delete, and its status is already back at draft by the time it is restored — restore never resurrects a publish that was already withdrawn. A soft-deleted notice is visible only through findAll({ deleted: true }); it is invisible to every other read path in this module, including the Notice Centre.

archive is legal from status = published only. A scheduled, draft, or already-archived notice is refused with NOTICE_NOT_PUBLISHED, with a message naming the specific reason (unpublish a scheduled notice instead; delete a draft that was never published instead; archiving an already-archived notice is a no-op refusal).

6.2 NoticePublicationService

Publish/unpublish/notify-update — the request half. Never resolves an audience or contacts NotificationService itself; it flips status, bumps dispatch_seq, and writes one outbox_events row, all inside the caller's transaction, behind a SELECT ... FOR UPDATE row lock so two concurrent publishes cannot both compute dispatch_seq + 1. unpublish is legal only before delivery (checked via notice_runtime.resolved_at); after delivery the only honest action is archive.

6.3 NoticePublishService (worker)

NoticeJob.PUBLISH / NoticeJob.NOTIFY_UPDATE handlers.

publish() first loads the notice and skips (never throws) on any of: the notice was soft-deleted since scheduling, its dispatch_seq no longer matches the job's payload (superseded by a later publish/republish), or its status is no longer scheduled (already published, archived, or unpublished back to draft) — this last check runs before the lease is even claimed.

It then claims the publish lease on notice_runtime with a conditional UPDATE (never read-then-write). The claim resolves to one of three outcomes:

  • claimed — proceeds.
  • resolved — notice_runtime.resolved_at is already set (or the runtime row does not exist, which cannot follow a real publish request); the handler skips.
  • held — another attempt currently holds a live lease. On any attempt but the last, the handler throws, so BullMQ retries with backoff rather than silently no-op-ing, which would be wrong if that other attempt then fails. On the final attempt, throwing would exhaust the job into job_failures for a notice whose lease holder may still go on to publish it — a dead-letter row an operator would replay as a false alarm — so instead the handler logs a warning and returns skipped with reason held_at_final_attempt, writing no dead-letter row. If the lease holder never finishes, recovery is left to the maintenance sweep, which reclaims the expired lease and re-drives the publish.

On a successful claim it walks the audience outside any transaction (a keyset scan that must not hold a transaction open for a school-wide notice), then in one short transaction flips notices.status to published conditionally — the UPDATE carries status = 'scheduled' AND dispatch_seq = <this job's seq> AND deleted_at IS NULL in its WHERE clause, so a notice archived, unpublished, soft-deleted, or republished under a new sequence between the claim and this statement loses the race and the flip returns no row. When the flip is a no-op, the handler releases the claim, records changed_while_resolving, busts caches, and returns skipped — it never sends a notification for a notice that moved out from under it. When the flip succeeds, notice_runtime.resolved_at/recipient_count are written together, NotificationService.send() is called, and the notice_dispatches row is recorded, all in that one transaction.

actionFor() builds the link every notification for the notice carries: the notice's own actionUrl/actionLabel when both are set, otherwise the default action /dashboard/my-notices/<publicId> labelled "Open notice" — so tapping the notification always lands on what it announced, in the Notice Centre if the notice itself named nowhere more specific.

Any failure during the walk or the transaction — including a thrown "held" claim — releases the lease first (recording the failure in notice_runtime.last_error, so the next attempt can claim immediately rather than waiting out the full lease) and then rethrows, so BullMQ's retry sees a failed job. Idempotent three separate ways even so: the lease claim, UNIQUE (notice_id, dispatch_seq) with onConflictDoNothing, and send()'s own dedupe on sha256(kind ␀ aggregateId).

6.4 NoticeMaintenanceService (worker)

sweep() runs three passes and returns { archived, reclaimed, redriven }:

  • Archives every published notice whose expires_at <= now() (the comparison is inside the UPDATE, so a concurrent extension excludes its own row).
  • Reclaims any publish lease whose claim_expires_at has passed with no completion.
  • Redrives stuck publications (redriveStuckPublications): finds every notice that is still scheduled, not soft-deleted, whose publish_at is more than 10 minutes in the past, whose notice_runtime.resolved_at is still null, whose claim is absent or expired, and which has no pending notice.publish_requested outbox row already in flight — the state a publish job leaves behind when it exhausts its retries (a database outage longer than its backoff, a worker killed mid-walk). For up to 100 such notices per sweep, it writes a fresh outbox row for the same dispatch — same noticePublicId, same dispatchSeq, same requestId as the original attempt, so if that attempt did get as far as raising the notification event, send()'s dedupe collapses onto it and nobody is notified twice. The dedupe key on the outbox row is the original dispatch key suffixed #redrive-<hour bucket>, because the original key is already occupied by the dispatched outbox row from the first attempt.

captureStats() freezes per-channel delivery counters onto notice_dispatches.delivery_stats for dispatches older than NOTICE_STATS_CAPTURE_AFTER_HOURS (48h default) with no snapshot yet, batched 500 at a time — before notification_delivery/notification_recipient/notification_event are pruned at the 30-day retention horizon. invalidate() runs a SCAN-based pattern delete, off the request path.

6.5 NoticeVisibilityService

The single implementation of "which notices can this person see". membershipsFor(userId) resolves every (kind, targetId) pair a person can match from their own live profile rows — themselves, every role they hold, their own class/section/grade (if a pupil, via a live enrolment), the classes/sections/grades of every child they guard (via a live enrolment), the classes/grades they teach, and their department. guardians_of_users is its own branch with no enrolment requirement: a notice addressed to "the parents of this named pupil" reaches the guardian whether or not that pupil is currently enrolled, matching the delivery resolver's own guardians_of_users branch — a parent who received the SMS must also be able to open the notice it links to. visibilityPredicate turns that list into a SQL EXISTS against notice_audience_targets, ORed with "already read it" and "already delivered to them" (so narrowing an audience never strands an already-notified recipient with a dead link). requireVisibleNotice is the one resolution path every portal endpoint (detail, mark-read, mark-unread, the attachment stream) uses; anything that resolved a notice a different way would be an endpoint that forgot the predicate.

6.6 NoticeCentreService / NoticeCentreEnrichmentService

Builds the Notice Centre list, unread count, and detail from the visibility predicate above. The list is deliberately not cached (read-your-writes: opening a notice marks it read and the same person comes straight back to the list); the unread count is cached (CACHE_TTL.VOLATILE), because its key is nameable per user and every write that changes it deletes it. NoticeCentreEnrichmentService.resolveMatchedFor tells a guardian which of their children a notice concerns; resolvePrimaryAttachments batches the thumbnail lookup for a page of results.

6.7 NoticeDeliveryService

Administrator surface, NoticeDelivery_READ/_UPDATE — no visibility predicate, because the permission itself is the authorization to see delivery numbers for any notice. Both getDeliveryStats and getRecipients resolve the dispatch to report on via resolveLatestDispatch, which reads the most recent notice_dispatches row whose reason = 'published' — never an updated (notify-update) row. An explicit re-notify has no recipient count and no delivery rows of its own to report separately; the delivery and recipient surfaces always describe the original publication. getDeliveryStats reads live notification_delivery rows if the dispatch has not yet had its stats captured, or the frozen snapshot otherwise. getRecipients returns retained: false once the underlying notification_event has been pruned, rather than an empty list that would read as "nobody was addressed". retry resolves the delivery through notice_dispatches first (never by the delivery's own public id alone) so NoticeDelivery_UPDATE can never reach an unrelated security-category delivery such as a password reset.

6.8 NoticeAttachmentService

create, remove, and setPrimary all resolve the notice via requireEditableNotice, which refuses NOTICE_ARCHIVED_IMMUTABLE for a notice whose status = 'archived' — an archived notice's content is frozen, and its attachments are part of that content — in addition to the plain NOTICE_NOT_FOUND for a missing or soft-deleted notice.

create never accepts a caller-supplied storage key — it calls StorageManager.handleUpload itself with UploadType.NOTICE. The 20-attachment cap (NOTICE_ATTACHMENT_LIMIT_EXCEEDED) is checked twice: once before the upload as a fast refusal, and again inside the insert transaction, under a SELECT ... FOR UPDATE lock on the notice row — the row lock serialises two concurrent uploads to the same notice, so two requests that both passed the early check cannot both insert the 20th attachment.

A refusal before storage — the fast cap check, requireEditableNotice — removes the multer temp file, since nothing else has taken ownership of it yet. When the file has already been stored but the insert transaction throws, the stored object is deleted only for a ConflictException (the cap trip under the lock) or a Postgres SQLSTATE of class 23 — both known to happen before commit. Any other error leaves the commit outcome unproven, so the object is kept and a warning naming the storage key is logged; deleting a file a committed row still points at would turn into a 404 the next time the notice is opened. A route-scoped interceptor on the create route additionally removes the temp file for any error that happens after multer but before the service runs — including a malformed :publicId refused by ParseUUIDPipe, since Nest resolves that pipe after the file is already on disk.

setPrimary locks the notice row for the same reason — two concurrent primary-swap requests would otherwise both clear the existing primary, both set their own, and the second write would trip notice_attachments_primary_unique. That collision is mapped to NOTICE_VERSION_CONFLICT rather than surfacing as a bare, unnamed constraint violation. setPrimary also refuses a non-image target with NOTICE_PRIMARY_IMAGE_NOT_AN_IMAGE.

remove deletes the row first, then deletes the stored file best-effort on either storage driver — a storage failure there is only logged, never surfaced, since the row is the source of truth for what the notice carries and an orphaned object is a wasted-space problem rather than a correctness one.

stream never signs a URL; it re-runs NoticeVisibilityService.requireVisibleNotice on every request unless the caller separately holds Notices_READ (the administrator preview bypass, checked via RoleService.getPermissionsForRoleId, mirroring RoleGuard's own mechanism).

6.9 NoticeCategoryService / NoticeTemplateService

Both enforce is_system immutability: remove() refuses a system row with NOTICE_CATEGORY_SYSTEM_IMMUTABLE / NOTICE_TEMPLATE_SYSTEM_IMMUTABLE; isSystem is never accepted from a caller DTO, only set by the seeding function. NoticeCategoryService.remove additionally refuses with NOTICE_CATEGORY_IN_USE when a notice still references the category (the FK is ON DELETE RESTRICT). NoticeTemplateService.render substitutes {{schoolName}}, {{sessionName}}, {{date}} and sanitises the result, deriving a summary the same way notice creation does.

Both list queries order by sort_order ascending (then id) by default. A caller can override with an explicit sort=name or sort=sortOrder query parameter, in which case the list is sorted accordingly instead. The Redis reference cache (CACHE_TTL.REFERENCE) is used only for the default, unsorted-by-request listing — an explicit sort bypasses the cache and queries live, so the cache's fixed key shape never has to represent an arbitrary caller-chosen order.

6.10 Audience cap enforcement

NoticeAudienceResolutionService.assertCaps runs inside prepareAudience, which both create and update call before ever writing a row. It refuses NOTICE_AUDIENCE_EMPTY when the normalised spec names nobody (every leaf's id list is empty and the kind is not all_users), and NOTICE_AUDIENCE_TOO_LARGE for the same two caps NoticePublicationService.assertSendablePreconditions re-checks at publish time — more than NOTICE_MAX_EXPLICIT_RECIPIENTS (500) explicit users ids, or more than NOTICE_MAX_COMPOUND_MEMBERS (10) compound members. The caps are therefore enforced at both save time (so an administrator sees the error while still drafting) and again at publish time (so a notice edited into a bad state some other way still cannot go out).

7. Runtime Flows

7.1 Create a draft notice

StepCode PathBehaviorFailure Case
1NoticeWriteService.createSanitise HTML, derive text/summaryNOTICE_CONTENT_EMPTY if content is empty after sanitisation
2NoticeLookupService.resolveCategoryCategory must exist and be liveNOTICE_CATEGORY_NOT_FOUND
3NoticeAudienceResolutionService.prepareAudienceStructural validation, normalisation, label renderingNOTICE_AUDIENCE_KIND_UNKNOWN, NOTICE_AUDIENCE_EMPTY
4Insert notices + notice_audience_targetsAlways status = draft

7.2 Publish (schedule or immediate)

Branches: already published/archived → NOTICE_ALREADY_PUBLISHED; publish time in the past (beyond a 60s clock-skew allowance) → NOTICE_PUBLISH_AT_IN_PAST; expiry not strictly after publish → NOTICE_EXPIRY_BEFORE_PUBLISH; no channels chosen → NOTICE_CHANNELS_EMPTY; audience too large (>10 compound members or >500 explicit recipients) → NOTICE_AUDIENCE_TOO_LARGE; unsafe action URL → NOTICE_ACTION_URL_FORBIDDEN. The worker itself skips (never throws, never retries) when the notice was deleted between scheduling and firing, or when its dispatch_seq no longer matches (the job belongs to a superseded dispatch).

7.3 Unpublish

Legal only before delivery. Marks the notice's own pending outbox_events row dead, flips notices.status back to draft. Refused with NOTICE_ALREADY_DELIVERED once notice_runtime.resolved_at is set, and with NOTICE_NOT_PUBLISHED for a draft.

7.4 Notify update

Legal only for a status = published notice. Bumps dispatch_seq again and enqueues NoticeJob.NOTIFY_UPDATE with a deterministic requestId (<publicId>#<dispatchSeq>) — required because the underlying notice.updated* kinds are repeatable, and a random id would re-notify the whole school on every job retry.

7.5 Extend expiry (including archived → published)

Reviving an archived notice back to published requires all three: the notice is currently archived, it carries a non-null published_at (proof it was actually delivered at some point, not merely archived from a state that never sent), and the new expiresAt is in the future. A notice archived without ever having been published (archive() accepts only a published notice — see §6.1) can never be revived this way.

7.6 Notice Centre read

GET /portal/notices/:publicId resolves the notice through requireVisibleNotice and never writes notice_reads — marking read is a separate, explicit POST .../read fired from a client effect on mount. markRead/markUnread re-run requireVisibleNotice inside the same transaction as the write, because a write reached through the wrong predicate is the identical enumeration/tamper risk as a read.

8. Caching

Cache Key PatternBuilderValueTTLInvalidationCaller
notice:admin:list:*CacheKeyUtil.build(NOTICE_CACHE_PREFIX.ADMIN_LIST, [...filters, ["deleted", query.deleted === true]])list query resultsSTANDARD (soft)invalidateListCaches() after every list-affecting write; also busted by NoticePublicationService.bustCaches after publish/unpublish/notify-update commits, and by NoticePublishService/NoticeMaintenanceService off the worker pathNoticeWriteService, NoticePublicationService, NoticePublishService, NoticeMaintenanceService
notice:detail:id:<publicId>noticeDetailCacheKey(publicId)one notice's detail~1 hourExplicit delSoft on every write to that notice — including after every publish/unpublish/notify-update commits (NoticePublicationService.bustCaches, run after the transaction, never inside it) — and by the maintenance sweep on archivalNoticeWriteService, NoticeAttachmentService, NoticePublicationService, NoticePublishService, NoticeMaintenanceService
notice:stats:*patterndelivery/summary aggregatesshortNot directly written by name in this module's read paths; reserved prefix—
notice:categories:*patterncategory listings—Reserved prefixNoticeCategoryService
notice:templates:*patterntemplate listings—Reserved prefixNoticeTemplateService
notice:centre:*patternNotice Centre pages—Deliberately not used for the list itself (see §6.6)—
notice:unread:user:<userId>noticeUnreadCacheKey(userId)one person's unread countCACHE_TTL.VOLATILEDeleted on markRead/markUnread and by the publish worker's cache bustNoticeCentreService, NoticePublishService

Every writer and reader of the detail and unread keys calls the same builder function — an earlier version of this cache spelled the detail key two different ways in two places, and a notice that published or expired kept showing its stale status in the admin detail view for up to an hour because deleting a key that never existed succeeds silently. All cache operations off the request path (the sweep, the worker) use the "soft" delete/get variants, which degrade to a no-op on Redis failure rather than failing a write that already committed. NoticePublicationService.bustCaches runs strictly after the transaction commits, never inside it — every write it makes bumps notices.version through the bump_row_version trigger, so a detail left cached for its full hour would show both a stale status and a stale version token, refusing the administrator's next save as a conflict until the cache entry expired on its own.

9. BullMQ, Schedulers, and Async Work

QueueJobProducerProcessorPayloadRetry/BackoffIdempotency
QueueName.NOTICESNoticeJob.PUBLISH (notice.publish)NoticePublicationService.publish via outboxNoticesQueueProcessor → NoticePublishService.publish{ noticePublicId, dispatchSeq, requestId }BullMQ default retry (outbox is at-least-once)Lease claim + UNIQUE(notice_id, dispatch_seq) + send()'s own dedupe
QueueName.NOTICESNoticeJob.NOTIFY_UPDATE (notice.notify_update)NoticePublicationService.notifyUpdate via outboxNoticesQueueProcessor → NoticePublishService.notifyUpdate{ noticePublicId, dispatchSeq, requestId, note? }sameDeterministic requestId, same three mechanisms
QueueName.NOTICESNoticeJob.MAINTENANCE_SWEEP (notice.maintenance_sweep)NoticeScheduler cron, every 5 minutesNoticeMaintenanceService.sweep — archives expired notices, reclaims expired leases, and re-enqueues (via the outbox) up to 100 publish jobs stuck scheduled more than 10 minutes past publish_at{ requestId }cron re-fires; no DB write to protect (exempt from the outbox rule)Bucketed jobId derived from NOTICE_MAINTENANCE_INTERVAL_MS; each redriven publish reuses its original dispatch identity, so the redrive itself cannot double-notify
QueueName.NOTICESNoticeJob.CAPTURE_STATS (notice.capture_stats)NoticeScheduler cron, daily at 2amNoticeMaintenanceService.captureStats{ requestId }sameBucketed jobId derived from NOTICE_CAPTURE_STATS_INTERVAL_MS
QueueName.NOTICESNoticeJob.CACHE_INVALIDATE (notice.cache_invalidate)(reserved for future callers)NoticeMaintenanceService.invalidate{ patterns: string[] }—SCAN-based, off request path

There is exactly one @Processor(QueueName.NOTICES) class (NoticesQueueProcessor, concurrency 2 — deliberately low, because PUBLISH performs an unbounded keyset walk). Its handlers map is a TypeScript Record<NoticeJob, …>, so an unrouted job name is a compile error rather than a job that completes silently having done nothing. On terminal failure (exhausted retries, not every individual attempt) NoticesQueueProcessor.onFailed records a row via JobFailureRecorderService, landing in job_failures where the dead-letter surface can list and replay it — QueueName.NOTICES is on REPLAYABLE_QUEUES.

Both cron job ids are built with buildJobId, whose asserted character set stops a colon (which BullMQ rejects silently) from ever reaching jobId. The bucket width is derived from the same constant as the cron interval, deliberately — BullMQ's add() returns the existing job for a duplicate jobId rather than throwing, so a bucket wider than the schedule would silently discard every extra tick.

10. Realtime and Events

The notices module raises no realtime/Socket.IO events of its own. It raises exactly one NotificationService event per dispatch (notice.published.<priority> or notice.updated[.urgent]); the notification module is what fans that out across channels, and its own realtime contract (unrelated to notices specifically) is documented in the notification module's own pages.

11. Security, Auth, and Abuse Controls

  • Guards. Every controller carries JwtAuthGuard (global) plus RoleGuard, and every handler declares @Permissions(...). There is no @Public() route and no NO_PERMISSION_ADMIN_HANDLERS entry anywhere in this module — even the Notice Centre controller is fully permissioned (NoticeView_READ), because the audience-safety control there is the WHERE clause, not the guard.
  • Permission modules. Notices (_READ/_CREATE/_UPDATE/_DELETE/_RESTORE), NoticePublish (_CREATE publishes/schedules and authorises any publication-parameter change on a non-draft notice; _UPDATE sends an explicit "notify update"), NoticeCategories, NoticeTemplates, NoticeDelivery (_READ/_UPDATE), NoticeView (_READ only — the audience-facing code).
  • Split rationale. NoticePublish is split from Notices_UPDATE because publishing sends metered SMS to every parent in the school — a capability, not an edit. Without NoticePublish_CREATE also covering publication-parameter changes, a Notices_UPDATE-only holder could re-point a published notice's audience toward all_users, and the whole school would read it, because visibility is the audience.
  • Visibility, not row scope. NoticeVisibilityService is a purpose-built predicate, not PeopleAccessService.scopeFor — that service returns {kind: "all"} for any caller holding Students_READ, which both the seeded staff and teacher roles hold, and would hand the entire pupil roll to every teacher session if it were ever used here.
  • Content sanitisation. notices.content_html and notice_templates.content_html are both sanitised through the identical NOTICE_SANITIZE_OPTIONS configuration, once, at the service write path. svg and math tags are excluded (mutation-XSS carriers); only https: URLs and app-relative paths beginning with a single / are allowed in href/src; style attributes and CSS parsing are both disabled.
  • Attachment provenance. NoticeAttachmentService.create never accepts a caller-supplied storage key. There is no uploads registry table, so an endpoint that registered a pre-existing key could not verify its provenance — a Notices_UPDATE holder could otherwise register any uploads/** key (including bulk import/export output) against their own draft and read it back through the attachment stream.
  • Attachment writes on an archived notice. Upload, remove, and set-primary all refuse NOTICE_ARCHIVED_IMMUTABLE once a notice is archived — an archived notice's content is frozen, and its attachments are part of that content, so this closes off the one path that would otherwise let a caller mutate a "frozen" notice's visible material.
  • Malformed identifiers refuse before any query runs. Every :publicId, :attachmentPublicId, and :deliveryPublicId route parameter in every controller in this module is decorated with ParseUUIDPipe, so a malformed id is a 400 from the framework's own validation layer, never a query that reaches the database with a value that cannot match.
  • Attachment streaming is never signed. GET /notice-attachments/:attachmentPublicId re-checks NoticeVisibilityService on every request (or the caller's own Notices_READ), rather than issuing a signed URL that would outlive the caller's authorization and leak into a stored HTML body, an email, or a job payload.
  • Audience id-space collisions. class, guardians_of_class, and teachers_of_class all key on classes.public_id. Matching on the bare uuid (rather than the (kind, uuid) pair) would let a pupil read the notice addressed to their own parents. This is the reason notice_audience_targets and every reader join on the pair.
  • Delivery retry is bound to the notice first. NoticeDeliveryService.retry resolves notification_delivery → ... → notice_dispatches WHERE notice_id = <this notice> before it ever looks at job_failures, so NoticeDelivery_UPDATE cannot be used to replay an unrelated security-category delivery (a password reset, an OTP).
  • Rate/abuse controls. No notice-specific rate limiter exists; the standard authenticated-route throttling applies. The audience caps (§16.5) are the module's own abuse control against an accidental or malicious school-wide blast.

13. Error Handling

Error CodeHTTP StatusThrown ByConditionClient Action
NOTICE_NOT_FOUND404Most servicesNo live notice with that public id (or, for portal routes, no visible notice)Stop; the id is wrong or the caller cannot see it
NOTICE_ALREADY_PUBLISHED409NoticePublicationService.publishStatus already published/archivedRefresh; publishing is one-shot
NOTICE_ALREADY_DELIVERED409NoticePublicationService.unpublishnotice_runtime.resolved_at setUse archive instead
NOTICE_NOT_PUBLISHED409unpublish, notifyUpdate, archiveWrong status for the action — archive specifically requires status = published; any other status (scheduled, draft, already archived) is refusedCheck current status first
NOTICE_ARCHIVED_IMMUTABLE409NoticeWriteService.update, NoticeAttachmentService.create/remove/setPrimaryContent edit, or an attachment upload/remove/primary-set, attempted on an archived noticeRestore via expiry extension, or duplicate instead
NOTICE_PUBLICATION_PARAMS_IMMUTABLE409NoticeWriteService.updateaudience/channels/priority/publishAt on a non-draftUnpublish first, or accept it cannot change
NOTICE_PUBLISH_AT_IN_PAST400NoticePublicationService.publishRequested schedule more than 60s in the pastChoose a future time
NOTICE_EXPIRY_BEFORE_PUBLISH400publish, create/update, expiry PATCHexpiresAt <= publishAtChoose an expiry strictly after publish
NOTICE_VERSION_CONFLICT409update, updateExpiry, NoticeAttachmentService.setPrimarySubmitted version does not match the current row; or, on setPrimary, two concurrent primary-swap requests collided on notice_attachments_primary_uniqueReload and reapply the edit
NOTICE_CONTENT_EMPTY400createSanitised HTML has no text contentAdd real content
NOTICE_AUDIENCE_EMPTY400NoticeAudienceResolutionService.assertCaps, called from create/updateThe normalised audience names nobody — every leaf's id list is empty and the kind is not all_usersAdd at least one target
NOTICE_AUDIENCE_KIND_UNKNOWN400audience validationaudience.kind not in the known setFix the kind
NOTICE_AUDIENCE_TOO_LARGE400assertSendablePreconditions>10 compound members, or >500 explicit users recipientsSplit into multiple notices, or use a broader kind
NOTICE_CHANNELS_EMPTY400create/update/publish preconditionsNo delivery channel chosenChoose at least one
NOTICE_CHANNEL_UNKNOWN400audience/channel validationUnknown channel valueFix the channel list
NOTICE_ACTION_URL_FORBIDDEN400create/update, publish preconditionsUnsafe action URL schemeUse https:// or a single-leading-slash relative path
NOTICE_CATEGORY_NOT_FOUND404category lookupsMissing or soft-deleted categoryPick an existing category
NOTICE_CATEGORY_SLUG_ALREADY_EXISTS409NoticeCategoryService.create/update/restoreSlug collision (live rows only — slugs are reusable after retirement)Choose a different slug
NOTICE_CATEGORY_IN_USE409NoticeCategoryService.removeA notice still references this categoryReassign notices first
NOTICE_CATEGORY_SYSTEM_IMMUTABLE409NoticeCategoryService.removeis_system = trueDeactivate instead of deleting
NOTICE_TEMPLATE_NOT_FOUND404template lookupsMissing or soft-deleted template—
NOTICE_TEMPLATE_SLUG_ALREADY_EXISTS409template create/update/restoreSlug collisionChoose a different slug
NOTICE_TEMPLATE_SYSTEM_IMMUTABLE409NoticeTemplateService.removeis_system = trueDeactivate instead
NOTICE_ATTACHMENT_NOT_FOUND404attachment resolutionNo attachment with that id (or not on this notice)—
NOTICE_ATTACHMENT_TYPE_FORBIDDEN400NoticeAttachmentService.createStorage validation rejected the file (type, or an empty file)Fix the file
NOTICE_ATTACHMENT_LIMIT_EXCEEDED409createAlready at 20 attachmentsRemove one first
NOTICE_PRIMARY_IMAGE_NOT_AN_IMAGE409setPrimaryTarget attachment's kind !== 'image'Choose an image attachment
NOTICE_DELIVERY_NOT_FOUND404NoticeDeliveryService.retryDelivery id does not belong to this noticeCheck the id
NOTICE_DELIVERY_NOT_RETRYABLE409retryNo matching un-replayed job_failures row on a replayable queueThe delivery already succeeded, or isn't in a failed state

14. Observability

SignalLocationPurpose
LogNoticeScheduler, NoticePublishService, NoticeMaintenanceService, NoticesQueueProcessor (Logger)Every job start/skip/success/failure is logged with the notice's public id and dispatch seq; a cache-bust or invalidation failure logs a warning rather than throwing
Failure recordjob_failures (via JobFailureRecorderService)Terminal job failures on QueueName.NOTICES, replayable through the dead-letter surface
AuditMongo audit_logs, via the global ActivityAuditInterceptorEvery mutating notice/category/template/attachment/publication request; module/action derived from the handler's @Permissions()

15. Testing and Validation

Test TypeFilesCoverage
Unitnotice/notice-content.util.spec.ts, notice-template/notice-template-token.util.spec.ts, shared/notice-defaults-migration.spec.tsPure sanitisation/derivation functions, token substitution, seeded-default shape
Integrationnotice/__tests__/notice.service.integration.spec.ts, notice/__tests__/notice-people.service.integration.spec.ts, notice-attachment/__tests__/notice-attachment.service.integration.spec.ts, notice-category/__tests__/notice-category.service.integration.spec.ts, notice-template/__tests__/notice-template.service.integration.spec.ts, notice-centre/__tests__/notice-centre-visibility.integration.spec.ts, notice-delivery/__tests__/notice-delivery.integration.spec.tsReal-database behaviour: lifecycle transitions, visibility predicate, delivery stats, retry binding
Structureapps/api/test/structure/structure.baseline.json, swagger-coverage.spec.tsRoute/guard fingerprint, Swagger document membership

Validation commands:

pnpm --filter @skoolsewa/api test notices
pnpm --filter @skoolsewa/api test:structure
pnpm turbo run build:docs

See Also

  • API doc: /docs/developer/notices/api
  • Features and flows doc: /docs/developer/notices/feature