Notices Backend Documentation
Backend architecture, data model, services, cache, queues, runtime rules, and operational behavior for Notices.
Notices Backend Documentation
1. Documentation Evidence
| Area | Files Inspected | Verified Details |
|---|---|---|
| Module wiring | apps/api/src/modules/notices/*.module.ts, leaf module files | Imports, providers, exports, controller composition |
| Controllers | notice/notice.controller.ts, publication/notice-publication.controller.ts, notice-delivery/notice-delivery.controller.ts, notice-attachment/notice-attachment.controller.ts, notice-category/notice-category.controller.ts, notice-template/notice-template.controller.ts, notice-centre/notice-centre.controller.ts | Route ownership, guard chains, permissions |
| Services | notice/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.ts | Business logic, validation, writes, mappings, side effects |
| DTOs | notice/dto/notice.dto.ts, publication/dto/index.ts, notice-category/dto/notice-category.dto.ts, notice-template/dto/notice-template.dto.ts, notice-attachment/dto/notice-attachment.dto.ts, notice-centre/dto/index.ts, notice-delivery/dto/index.ts | API contracts and validation |
| Schema | packages/db/src/schema/school/notices.ts | Tables, enums, relations, indexes, CHECKs |
| Migrations | packages/db/src/migrations/0024_notice_module.sql, 0025_notice_version_trigger_and_defaults.sql | DDL, seeded defaults, version trigger |
| Jobs | packages/jobs/src/index.ts (QueueName.NOTICES, NoticeJob) | Queue contract and payloads |
| Cache | shared/notice.constants.ts (NOTICE_CACHE_PREFIX, key builders) | Key construction, TTLs, invalidation |
| Constants | shared/notice.constants.ts | Caps, sanitiser configuration, notification kind mapping |
| Audience | shared/notice-audience.util.ts, notice/notice-audience-input.util.ts, notice/notice-audience-resolution.service.ts | Normalisation, projection, caps |
| Authorization | packages/db/src/authorization/permission-catalog.ts, packages/db/src/seed/seed-auth.ts | Permission modules and role grants |
| Errors | apps/api/src/common/types/error-codes.ts | Every NOTICE_* code |
| Notifications | apps/api/src/modules/notification/templates/template-registry.ts | notice.* kinds |
| Route surface | apps/api/test/structure/structure.baseline.json | Authoritative 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_targetsrows 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, writesnotification_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.retrydelegates toNotificationFailureService.replay, the same dead-letter replay every other module uses. - Notification preferences and channel provider configuration. These belong to the notification module.
- Storage.
StorageManagerperforms 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
| Concern | Source of Truth | Notes |
|---|---|---|
| Notice lifecycle status | notices.status | Never derived; the maintenance sweep persists expiry rather than computing it on read, so an admin list filter agrees with the detail view. |
| Audience membership | notice_audience_targets, re-derived from notices.audience on every write | A 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 identity | notices.dispatch_seq | Bumped 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 state | notice_reads | Permanent. 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. |
| Visibility | NoticeVisibilityService | The only implementation of the predicate; every reader (list, detail, unread count, attachment stream) calls it. |
| Row concurrency | notices.version, bumped by a database trigger | A mismatch on write is NOTICE_VERSION_CONFLICT, never a silent overwrite. |
3. Module Composition
| Module | Type | Path | Controllers | Providers | Exports | Responsibility |
|---|---|---|---|---|---|---|
NoticeModule | Leaf | apps/api/src/modules/notices/notice/ | NoticeController | NoticeService, NoticeWriteService, NoticeReadService, NoticeLookupService, NoticeAudienceResolutionService, NoticePeopleService | Services used by publication/workers | Notice CRUD, lookups, audience list/preview |
NoticePublicationModule | Leaf | apps/api/src/modules/notices/publication/ | NoticePublicationController | NoticePublicationService | NoticePublicationService | Publish/unpublish/notify-update request half |
NoticeDeliveryModule | Leaf | apps/api/src/modules/notices/notice-delivery/ | NoticeDeliveryController | NoticeDeliveryService | — | Per-notice delivery stats, recipients, retry |
NoticeAttachmentModule | Leaf | apps/api/src/modules/notices/notice-attachment/ | NoticeAttachmentController | NoticeAttachmentService | — | Attachment upload/remove/primary/stream |
NoticeCategoryModule | Leaf | apps/api/src/modules/notices/notice-category/ | NoticeCategoryController | NoticeCategoryService | NoticeCategoryService (to templates/notice lookup) | Category CRUD, seeded defaults |
NoticeTemplateModule | Leaf | apps/api/src/modules/notices/notice-template/ | NoticeTemplateController | NoticeTemplateService | — | Template CRUD, token rendering, seeded defaults |
NoticeCentreModule | Leaf, mounted under mobile | apps/api/src/modules/notices/notice-centre/ | NoticeCentreController | NoticeCentreService, NoticeCentreEnrichmentService | — | The audience-facing read surface |
| (shared, no module of its own) | Shared | apps/api/src/modules/notices/shared/ | — | NoticeVisibilityService, audience util functions, constants | Imported directly by every leaf above | Visibility predicate, cache keys, sanitiser config, caps |
NoticesWorkerModule | Leaf, worker-only | apps/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| File | Purpose | Key Exports | Notes |
|---|---|---|---|
shared/notice.constants.ts | Every tunable in one place | NOTICE_SANITIZE_OPTIONS, NOTICE_CACHE_PREFIX, NOTICE_PUBLISH_KIND, NOTICE_UPDATE_KIND, cache key builders | sanitize-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.ts | The only visibility predicate | NoticeVisibilityService | Matches on the (kind, target_uuid) pair, never the bare uuid — the id spaces collide across kinds by construction. |
shared/notice-audience.util.ts | Pure spec transforms | normaliseAudience, projectAudienceTargets, countExplicitRecipients, countAudienceMembers, buildAudienceLabel | No NestJS, no I/O — lives beside the module because exactly one module imports it. |
notice/notice-write.service.ts | Every mutating notice endpoint | NoticeWriteService | Optimistic concurrency, publication-params-immutable-outside-draft, archived-content-immutable. |
workers/notices-queue.processor.ts | The single BullMQ Worker on QueueName.NOTICES | NoticesQueueProcessor | handlers 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.tsOne 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
| Column | Type | Nullable | Default | Index/Constraint | Notes |
|---|---|---|---|---|---|
id | serial | No | generated | PK | Internal FK target |
public_id | uuid | No | $defaultFn (uuid v7) | unique | Address by this |
name | varchar(80) | No | — | CHECK non-blank, ≤80 | |
slug | varchar(80) | No | — | partial unique on deleted_at IS NULL, CHECK ^[a-z][a-z0-9-]*$ | Reusable after retirement |
description | text | Yes | — | — | |
color_token | varchar(32) | Yes | — | — | A design-token name, never a hex value |
sort_order | smallint | No | 0 | CHECK 0..1000 | |
is_active | boolean | No | true | — | |
is_system | boolean | No | false | — | One of the 13 seeded defaults; deletion refused, deactivation always available |
created_at / updated_at | timestamptz | No | now() | — | |
deleted_at | timestamptz | Yes | — | index notice_categories_active_idx (partial, deleted_at IS NULL AND is_active) | Soft delete |
notice_templates
| Column | Type | Nullable | Default | Constraint | Notes |
|---|---|---|---|---|---|
id / public_id | serial / uuid | No | — | unique public_id | |
name | varchar(120) | No | — | non-blank | |
slug | varchar(120) | No | — | partial unique on deleted_at IS NULL | |
category_id | integer | Yes | — | FK notice_categories.id, ON DELETE SET NULL | |
priority | notice_priority | Yes | — | — | |
title_template | varchar(200) | No | — | non-blank | {{token}} placeholders |
content_html | text | No | — | non-blank | Sanitised at write, same config as notices.content_html |
default_channels | notice_channel[] | No | — | CHECK cardinality BETWEEN 1 AND 4 | |
is_active | boolean | No | true | — | |
is_system | boolean | No | false | — | One of 8 seeded defaults; deletion refused |
sort_order | smallint | No | 0 | CHECK 0..1000 | |
created_by | uuid | Yes | — | FK users.id, SET NULL | |
created_at / updated_at / deleted_at | timestamptz | mixed | — | — |
notices
| Column | Type | Nullable | Default | Constraint | Notes |
|---|---|---|---|---|---|
id / public_id | serial / uuid | No | — | unique public_id | |
title | varchar(200) | No | — | non-blank | |
summary | varchar(300) | No | — | non-blank | Push/SMS body and list preview; always derived if the author leaves it blank |
content_html | text | No | — | non-blank | Sanitised once, at the write path |
content_text | text | No | — | non-blank | Entity-decoded plain text, derived from sanitised HTML; drives search |
category_id | integer | No | — | FK notice_categories.id, ON DELETE RESTRICT | |
priority | notice_priority | No | — | — | low | medium | high | urgent |
status | notice_status | No | — | — | draft | scheduled | published | archived |
audience | jsonb | No | — | CHECK object, CHECK top-level kind known | The AudienceSpec, normalised |
audience_label | varchar(240) | No | — | — | Rendered once at write time, elided if long |
channels | notice_channel[] | No | — | CHECK 1..4 | |
publish_at / expires_at / published_at | timestamptz | Yes | — | see checks below | |
publish_requested_by | uuid | Yes | — | FK users.id, SET NULL | Who requested publication (publication is performed by a job) |
dispatch_seq | integer | No | 0 | CHECK >= 0 | The delivery identity — bumped on every publish and notify-update |
action_url / action_label | text / varchar(60) | Yes | — | CHECK both-or-neither, CHECK scheme (https:// or a single-leading-slash relative path, never // or \) | |
calendar_day_id | uuid | Yes | — | FK academic_calendar_days.id, SET NULL | |
academic_session_id | integer | No | — | FK academic_sessions.id, RESTRICT | |
template_id | integer | Yes | — | FK notice_templates.id, SET NULL | |
duplicated_from_id | integer | Yes | — | self-FK, SET NULL | |
created_by | uuid | Yes | — | FK users.id, SET NULL | |
created_by_name | varchar(160) | No | — | — | Snapshot beside the nullable FK, so a removed author's notices stay filterable and named |
updated_by | uuid | Yes | — | FK users.id, SET NULL | |
version | bigint (mode number) | No | 0 | CHECK >= 0 | Bumped by the bump_row_version trigger on any UPDATE |
created_at / updated_at / deleted_at | timestamptz | mixed | — | — | 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 requirespublish_at IS NOT NULL.notices_expiry_after_publish—expires_at > publish_atwhen both are set (strict>, matching the service's<=rejection).notices_published_has_timestamp—publishedrequirespublished_at IS NOT NULL.notices_action_url_scheme— mirrorsisSafeActionUrlexactly.notices_audience_kind_known— top-levelkindonly; 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.
| Column | Type | Nullable | Notes |
|---|---|---|---|
notice_id | integer | No (PK) | FK notices.id, CASCADE |
claimed_at / claim_expires_at | timestamptz | Yes | The publish lease |
claimed_by | varchar(64) | Yes | pid:<process.pid> |
resolve_cursor | varchar(64) | Yes | Keyset position of the audience walk, for resumable retries |
resolved_at | timestamptz | Yes | Written together with recipient_count in one statement |
recipient_count | integer | Yes | CHECK >= 0 when set |
last_error | text | Yes | |
updated_at | timestamptz | No |
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
| Column | Type | Nullable | Notes |
|---|---|---|---|
id / public_id | serial / uuid | No | |
notice_id | integer | No | FK notices.id, CASCADE |
kind | notice_attachment_kind | No | image | document |
storage_key | varchar(500) | No | Server-written only; never accepted from a caller |
original_name | varchar(255) | No | |
mime_type | varchar(120) | No | |
size_bytes | integer | No | CHECK > 0 — a zero-byte attachment offers a download that delivers nothing |
is_primary | boolean | No, default false | Partial unique index per notice; CHECK primary implies kind = 'image' |
sort_order | smallint | No, default 0 | CHECK 0..1000 |
created_at | timestamptz | No |
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.
| Column | Type | Nullable | Notes |
|---|---|---|---|
id | serial | No | |
notice_id | integer | No | FK notices.id, CASCADE |
kind | notice_target_kind | No | Every leaf AudienceSpec kind — compound is never a row; it is flattened at projection time |
target_uuid | uuid | Yes | NULL 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:
| Kind | References |
|---|---|
users | users.id |
role | role.public_id |
class | classes.public_id |
section | sections.public_id |
grade | grades.public_id |
guardians_of_class | classes.public_id |
guardians_of_section | sections.public_id |
guardians_of_grade | grades.public_id |
guardians_of_users | students.id (a uuid PK, not a public_id) |
teachers_of_class | classes.public_id |
teachers_of_grade | grades.public_id |
staff_department | departments.public_id |
all_users | NULL |
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).
| Column | Type | Nullable | Notes |
|---|---|---|---|
id / public_id | serial / uuid | No | |
notice_id | integer | No | FK notices.id, CASCADE |
reason | notice_dispatch_reason | No | published | updated |
dispatch_seq | integer | No | CHECK >= 1 |
notification_event_public_id | uuid | No | Not a foreign key — the event it names is pruned at retention while this row is permanent |
channels | notice_channel[] | No | CHECK 1..4 |
recipient_count | integer | Yes | The publish-time snapshot |
delivery_stats | jsonb | Yes | Frozen per-channel counters, written by CAPTURE_STATS |
stats_captured_at | timestamptz | Yes | CHECK: NULL together with delivery_stats |
requested_at | timestamptz | No | |
requested_by | uuid | Yes | FK users.id, SET NULL |
note | varchar(200) | Yes | Internal, 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
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
create | NoticeController.create | categories, calendar days, academic sessions | notices, notice_audience_targets | Invalidates admin-list cache | NOTICE_CONTENT_EMPTY, NOTICE_CATEGORY_NOT_FOUND, NOTICE_AUDIENCE_EMPTY, action-url errors |
update | NoticeController.update | notices (row lock) | notices, notice_audience_targets | Invalidates detail + list cache | NOTICE_VERSION_CONFLICT, NOTICE_PUBLICATION_PARAMS_IMMUTABLE, NOTICE_ARCHIVED_IMMUTABLE |
remove | NoticeController.remove | notices (row lock) | soft delete; a scheduled notice is returned to draft in the same transaction | Marks the notice's pending outbox row dead; invalidates caches | NOTICE_NOT_FOUND, refused while a publish claim is live (assertNoLivePublishClaim) |
restore | NoticeController.restore | notices | clears deleted_at | Invalidates list cache | NOTICE_NOT_FOUND |
archive | NoticeController.archive | notices (row lock), notice_runtime | status = 'archived' | Invalidates caches | NOTICE_NOT_PUBLISHED — legal only from status = published; refused while a publish claim is live |
duplicate | NoticeController.duplicate | notices | new notices row, notice_audience_targets | Invalidates list cache | NOTICE_NOT_FOUND |
updateExpiry | NoticeController.updateExpiry | notices (row lock) | expires_at, possibly status | Invalidates caches | NOTICE_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_atis 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 intojob_failuresfor 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 returnsskippedwith reasonheld_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
publishednotice whoseexpires_at <= now()(the comparison is inside the UPDATE, so a concurrent extension excludes its own row). - Reclaims any publish lease whose
claim_expires_athas passed with no completion. - Redrives stuck publications (
redriveStuckPublications): finds every notice that is stillscheduled, not soft-deleted, whosepublish_atis more than 10 minutes in the past, whosenotice_runtime.resolved_atis still null, whose claim is absent or expired, and which has nopendingnotice.publish_requestedoutbox 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 — samenoticePublicId, samedispatchSeq, samerequestIdas 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 thedispatchedoutbox 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
| Step | Code Path | Behavior | Failure Case |
|---|---|---|---|
| 1 | NoticeWriteService.create | Sanitise HTML, derive text/summary | NOTICE_CONTENT_EMPTY if content is empty after sanitisation |
| 2 | NoticeLookupService.resolveCategory | Category must exist and be live | NOTICE_CATEGORY_NOT_FOUND |
| 3 | NoticeAudienceResolutionService.prepareAudience | Structural validation, normalisation, label rendering | NOTICE_AUDIENCE_KIND_UNKNOWN, NOTICE_AUDIENCE_EMPTY |
| 4 | Insert notices + notice_audience_targets | Always 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 Pattern | Builder | Value | TTL | Invalidation | Caller |
|---|---|---|---|---|---|
notice:admin:list:* | CacheKeyUtil.build(NOTICE_CACHE_PREFIX.ADMIN_LIST, [...filters, ["deleted", query.deleted === true]]) | list query results | STANDARD (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 path | NoticeWriteService, NoticePublicationService, NoticePublishService, NoticeMaintenanceService |
notice:detail:id:<publicId> | noticeDetailCacheKey(publicId) | one notice's detail | ~1 hour | Explicit 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 archival | NoticeWriteService, NoticeAttachmentService, NoticePublicationService, NoticePublishService, NoticeMaintenanceService |
notice:stats:* | pattern | delivery/summary aggregates | short | Not directly written by name in this module's read paths; reserved prefix | — |
notice:categories:* | pattern | category listings | — | Reserved prefix | NoticeCategoryService |
notice:templates:* | pattern | template listings | — | Reserved prefix | NoticeTemplateService |
notice:centre:* | pattern | Notice Centre pages | — | Deliberately not used for the list itself (see §6.6) | — |
notice:unread:user:<userId> | noticeUnreadCacheKey(userId) | one person's unread count | CACHE_TTL.VOLATILE | Deleted on markRead/markUnread and by the publish worker's cache bust | NoticeCentreService, 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
| Queue | Job | Producer | Processor | Payload | Retry/Backoff | Idempotency |
|---|---|---|---|---|---|---|
QueueName.NOTICES | NoticeJob.PUBLISH (notice.publish) | NoticePublicationService.publish via outbox | NoticesQueueProcessor → 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.NOTICES | NoticeJob.NOTIFY_UPDATE (notice.notify_update) | NoticePublicationService.notifyUpdate via outbox | NoticesQueueProcessor → NoticePublishService.notifyUpdate | { noticePublicId, dispatchSeq, requestId, note? } | same | Deterministic requestId, same three mechanisms |
QueueName.NOTICES | NoticeJob.MAINTENANCE_SWEEP (notice.maintenance_sweep) | NoticeScheduler cron, every 5 minutes | NoticeMaintenanceService.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.NOTICES | NoticeJob.CAPTURE_STATS (notice.capture_stats) | NoticeScheduler cron, daily at 2am | NoticeMaintenanceService.captureStats | { requestId } | same | Bucketed jobId derived from NOTICE_CAPTURE_STATS_INTERVAL_MS |
QueueName.NOTICES | NoticeJob.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) plusRoleGuard, and every handler declares@Permissions(...). There is no@Public()route and noNO_PERMISSION_ADMIN_HANDLERSentry 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(_CREATEpublishes/schedules and authorises any publication-parameter change on a non-draft notice;_UPDATEsends an explicit "notify update"),NoticeCategories,NoticeTemplates,NoticeDelivery(_READ/_UPDATE),NoticeView(_READonly — the audience-facing code). - Split rationale.
NoticePublishis split fromNotices_UPDATEbecause publishing sends metered SMS to every parent in the school — a capability, not an edit. WithoutNoticePublish_CREATEalso covering publication-parameter changes, aNotices_UPDATE-only holder could re-point a published notice's audience towardall_users, and the whole school would read it, because visibility is the audience. - Visibility, not row scope.
NoticeVisibilityServiceis a purpose-built predicate, notPeopleAccessService.scopeFor— that service returns{kind: "all"}for any caller holdingStudents_READ, which both the seededstaffandteacherroles hold, and would hand the entire pupil roll to every teacher session if it were ever used here. - Content sanitisation.
notices.content_htmlandnotice_templates.content_htmlare both sanitised through the identicalNOTICE_SANITIZE_OPTIONSconfiguration, once, at the service write path.svgandmathtags are excluded (mutation-XSS carriers); onlyhttps:URLs and app-relative paths beginning with a single/are allowed inhref/src;styleattributes and CSS parsing are both disabled. - Attachment provenance.
NoticeAttachmentService.createnever 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 — aNotices_UPDATEholder could otherwise register anyuploads/**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_IMMUTABLEonce a notice isarchived— 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:deliveryPublicIdroute parameter in every controller in this module is decorated withParseUUIDPipe, so a malformed id is a400from 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/:attachmentPublicIdre-checksNoticeVisibilityServiceon every request (or the caller's ownNotices_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, andteachers_of_classall key onclasses.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 reasonnotice_audience_targetsand every reader join on the pair. - Delivery retry is bound to the notice first.
NoticeDeliveryService.retryresolvesnotification_delivery → ... → notice_dispatches WHERE notice_id = <this notice>before it ever looks atjob_failures, soNoticeDelivery_UPDATEcannot be used to replay an unrelatedsecurity-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 Code | HTTP Status | Thrown By | Condition | Client Action |
|---|---|---|---|---|
NOTICE_NOT_FOUND | 404 | Most services | No 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_PUBLISHED | 409 | NoticePublicationService.publish | Status already published/archived | Refresh; publishing is one-shot |
NOTICE_ALREADY_DELIVERED | 409 | NoticePublicationService.unpublish | notice_runtime.resolved_at set | Use archive instead |
NOTICE_NOT_PUBLISHED | 409 | unpublish, notifyUpdate, archive | Wrong status for the action — archive specifically requires status = published; any other status (scheduled, draft, already archived) is refused | Check current status first |
NOTICE_ARCHIVED_IMMUTABLE | 409 | NoticeWriteService.update, NoticeAttachmentService.create/remove/setPrimary | Content edit, or an attachment upload/remove/primary-set, attempted on an archived notice | Restore via expiry extension, or duplicate instead |
NOTICE_PUBLICATION_PARAMS_IMMUTABLE | 409 | NoticeWriteService.update | audience/channels/priority/publishAt on a non-draft | Unpublish first, or accept it cannot change |
NOTICE_PUBLISH_AT_IN_PAST | 400 | NoticePublicationService.publish | Requested schedule more than 60s in the past | Choose a future time |
NOTICE_EXPIRY_BEFORE_PUBLISH | 400 | publish, create/update, expiry PATCH | expiresAt <= publishAt | Choose an expiry strictly after publish |
NOTICE_VERSION_CONFLICT | 409 | update, updateExpiry, NoticeAttachmentService.setPrimary | Submitted version does not match the current row; or, on setPrimary, two concurrent primary-swap requests collided on notice_attachments_primary_unique | Reload and reapply the edit |
NOTICE_CONTENT_EMPTY | 400 | create | Sanitised HTML has no text content | Add real content |
NOTICE_AUDIENCE_EMPTY | 400 | NoticeAudienceResolutionService.assertCaps, called from create/update | The normalised audience names nobody — every leaf's id list is empty and the kind is not all_users | Add at least one target |
NOTICE_AUDIENCE_KIND_UNKNOWN | 400 | audience validation | audience.kind not in the known set | Fix the kind |
NOTICE_AUDIENCE_TOO_LARGE | 400 | assertSendablePreconditions | >10 compound members, or >500 explicit users recipients | Split into multiple notices, or use a broader kind |
NOTICE_CHANNELS_EMPTY | 400 | create/update/publish preconditions | No delivery channel chosen | Choose at least one |
NOTICE_CHANNEL_UNKNOWN | 400 | audience/channel validation | Unknown channel value | Fix the channel list |
NOTICE_ACTION_URL_FORBIDDEN | 400 | create/update, publish preconditions | Unsafe action URL scheme | Use https:// or a single-leading-slash relative path |
NOTICE_CATEGORY_NOT_FOUND | 404 | category lookups | Missing or soft-deleted category | Pick an existing category |
NOTICE_CATEGORY_SLUG_ALREADY_EXISTS | 409 | NoticeCategoryService.create/update/restore | Slug collision (live rows only — slugs are reusable after retirement) | Choose a different slug |
NOTICE_CATEGORY_IN_USE | 409 | NoticeCategoryService.remove | A notice still references this category | Reassign notices first |
NOTICE_CATEGORY_SYSTEM_IMMUTABLE | 409 | NoticeCategoryService.remove | is_system = true | Deactivate instead of deleting |
NOTICE_TEMPLATE_NOT_FOUND | 404 | template lookups | Missing or soft-deleted template | — |
NOTICE_TEMPLATE_SLUG_ALREADY_EXISTS | 409 | template create/update/restore | Slug collision | Choose a different slug |
NOTICE_TEMPLATE_SYSTEM_IMMUTABLE | 409 | NoticeTemplateService.remove | is_system = true | Deactivate instead |
NOTICE_ATTACHMENT_NOT_FOUND | 404 | attachment resolution | No attachment with that id (or not on this notice) | — |
NOTICE_ATTACHMENT_TYPE_FORBIDDEN | 400 | NoticeAttachmentService.create | Storage validation rejected the file (type, or an empty file) | Fix the file |
NOTICE_ATTACHMENT_LIMIT_EXCEEDED | 409 | create | Already at 20 attachments | Remove one first |
NOTICE_PRIMARY_IMAGE_NOT_AN_IMAGE | 409 | setPrimary | Target attachment's kind !== 'image' | Choose an image attachment |
NOTICE_DELIVERY_NOT_FOUND | 404 | NoticeDeliveryService.retry | Delivery id does not belong to this notice | Check the id |
NOTICE_DELIVERY_NOT_RETRYABLE | 409 | retry | No matching un-replayed job_failures row on a replayable queue | The delivery already succeeded, or isn't in a failed state |
14. Observability
| Signal | Location | Purpose |
|---|---|---|
| Log | NoticeScheduler, 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 record | job_failures (via JobFailureRecorderService) | Terminal job failures on QueueName.NOTICES, replayable through the dead-letter surface |
| Audit | Mongo audit_logs, via the global ActivityAuditInterceptor | Every mutating notice/category/template/attachment/publication request; module/action derived from the handler's @Permissions() |
15. Testing and Validation
| Test Type | Files | Coverage |
|---|---|---|
| Unit | notice/notice-content.util.spec.ts, notice-template/notice-template-token.util.spec.ts, shared/notice-defaults-migration.spec.ts | Pure sanitisation/derivation functions, token substitution, seeded-default shape |
| Integration | notice/__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.ts | Real-database behaviour: lifecycle transitions, visibility predicate, delivery stats, retry binding |
| Structure | apps/api/test/structure/structure.baseline.json, swagger-coverage.spec.ts | Route/guard fingerprint, Swagger document membership |
Validation commands:
pnpm --filter @skoolsewa/api test notices
pnpm --filter @skoolsewa/api test:structure
pnpm turbo run build:docsSee Also
- API doc:
/docs/developer/notices/api - Features and flows doc:
/docs/developer/notices/feature