Complete feature list, actor journeys, state flows, business rules, edge cases, and diagrams for Notices.
| Source Type | Files or Docs | What Was Extracted |
|---|
| API | Sibling API doc, notice/notice.controller.ts, publication/notice-publication.controller.ts, notice-centre/notice-centre.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 | Route surface and actors |
| Backend | Sibling backend doc, the services and schema it documents | Business behavior, side effects, lifecycle |
| Schema | packages/db/src/schema/school/notices.ts | State machine, constraints |
| Authorization | packages/db/src/authorization/permission-catalog.ts, packages/db/src/seed/seed-auth.ts | Actor permissions |
| Field | Value |
|---|
| Module | Notices |
| Submodule | NoticePublish, NoticeCategories, NoticeTemplates, NoticeDelivery, NoticeView, NoticeAttachment |
| Primary user value | An administrator writes one announcement, chooses who it reaches and through which channels, and every eligible pupil, parent, teacher, or staff member sees it in a permanent Notice Centre — with delivery proof and read statistics the administrator can check later. |
| Actors | Administrator (author/publisher), staff, teacher, pupil, guardian (Notice Centre readers), the notices worker (system) |
| Main entry points | /api/notices admin CRUD, /api/notices/:id/publish, /api/mobile/portal/notices (Notice Centre), the two cron sweeps |
| Main outputs | Persisted notices, notification_event/delivery rows via NotificationService, per-user read state, delivery statistics |
| Related docs | API, backend |
| Actor | Can Do | Cannot Do | Auth Requirement | Notes |
|---|
Administrator (Notices_*) | Create/edit/soft-delete/restore/duplicate a notice; manage categories and templates | Publish without NoticePublish_CREATE; see recipient-level delivery without NoticeDelivery_READ | Admin JWT + Notices_* permission | Editing and publishing are separate capabilities on purpose |
Publisher (NoticePublish_*) | Publish/schedule, unpublish before delivery, send an explicit update notification | Edit a notice's content — that is Notices_UPDATE | Admin JWT + NoticePublish_CREATE/_UPDATE | _CREATE also authorises changing audience/channels/priority/publishAt on a non-draft, because those changes are the delivery |
Delivery auditor (NoticeDelivery_*) | See per-recipient delivery lists and per-channel stats for any notice; retry one failed delivery | Edit or publish a notice | Admin JWT + NoticeDelivery_READ/_UPDATE | Retry is bound to the specific notice's own dispatches, never a bare delivery id |
Staff / teacher / pupil / guardian (NoticeView_READ) | Read the Notice Centre: list, detail, mark read/unread, unread count | See a notice not addressed to them (directly or via a guardians_of_*/teachers_of_* target); reach the admin authoring surface | JWT (any authenticated identity holding the code) | One code for every audience — the WHERE clause, not the permission, decides which notices come back |
| Notices worker (system) | Publish, send update notifications, sweep expiry/leases, capture delivery stats | Act outside its own queue | Internal (BullMQ) | Idempotent by lease claim, unique constraint, and notification dedupe |
| Capability | Surface | Actor | Route/Trigger | State Read | State Written | Linked API Section |
|---|
| Create draft notice | Admin | Administrator | POST /api/notices | categories, calendar days, session | notices, notice_audience_targets | Endpoint Reference §8.5 |
| List/filter notices | Admin | Administrator | GET /api/notices | notices | — | §8.1 |
| Dashboard tiles | Admin | Administrator | GET /api/notices/summary | aggregates | — | §8.2 |
| Find a person to address | Admin | Administrator | GET /api/notices/audience/people | students/guardians/staff | — | §8.3 |
| Preview audience size | Admin | Administrator | POST /api/notices/audience/preview | audience membership | — | §8.4 |
| Update a notice | Admin | Administrator | PATCH /api/notices/:id | notices (locked) | notices, targets | §8.7 |
| Soft delete / restore | Admin | Administrator | DELETE / POST .../restore | notices | deleted_at | §8.8/§8.9 |
| List removed notices | Admin | Administrator | GET /api/notices?deleted=true | notices | — | §8.1 |
| Archive | Admin | Administrator | POST /api/notices/:id/archive | notices, notice_runtime | status | §8.10 |
| Duplicate | Admin | Administrator | POST /api/notices/:id/duplicate | source notice | new notices row | §8.11 |
| Extend/change expiry | Admin | Administrator | PATCH /api/notices/:id/expiry | notices (locked) | expires_at, possibly status | §8.12 |
| Publish/schedule | Admin | Publisher | POST /api/notices/:id/publish | notices (locked) | status, dispatch_seq, outbox | §8.13 |
| Unpublish | Admin | Publisher | POST /api/notices/:id/unpublish | notices, notice_runtime | status, outbox row dead | §8.14 |
| Notify update | Admin | Publisher | POST /api/notices/:id/notify-update | notices | dispatch_seq, outbox | §8.15 |
| Upload/remove/promote attachment | Admin | Administrator | /api/notices/:id/attachments* | notice_attachments | insert/delete/flag | §8.16-8.18 |
| Stream attachment bytes | Admin/portal | Any visible reader, or Notices_READ | GET /api/notice-attachments/:id | attachment + visibility | — | §8.19 |
| Manage categories | Admin | Administrator | /api/notice-categories* | notice_categories | CRUD | §8.20-8.25 |
| Manage templates, render | Admin | Administrator | /api/notice-templates* | notice_templates | CRUD | §8.26-8.32 |
| Delivery stats / recipients / retry | Admin | Delivery auditor | /api/notices/:id/delivery, /recipients, /deliveries/:id/retry | delivery tables | retry job | §8.33-8.35 |
| Notice Centre list/detail/read state | Portal | Any NoticeView_READ holder | /api/mobile/portal/notices* | visible notices | notice_reads | §8.36-8.40 |
| Maintenance sweep | Worker | System | cron, 5 min | notices, notice_runtime | archive expired, reclaim leases | Backend §9 |
| Capture delivery stats | Worker | System | cron, daily | delivery tables | notice_dispatches.delivery_stats | Backend §9 |
An administrator composes a notice, chooses an audience and channels, and either publishes it immediately or schedules it for later. The system resolves who is addressed, sends through NotificationService, and records exactly one dispatch.
- Admin JWT holding
Notices_CREATE to draft it, and NoticePublish_CREATE to publish it.
- A live notice category exists (13 are seeded and cannot be deleted, only deactivated).
- If addressing individuals, their account ids are known (via the people-search endpoint).
| Step | Actor/System | Action | Result | Source |
|---|
| 1 | Administrator | Composes title/content/audience/channels/priority, saves | A draft notice exists | NoticeWriteService.create |
| 2 | Administrator | Reviews audience/preview if unsure of reach | Per-kind counts, capped flag, no writes | NoticeController.previewAudience |
| 3 | Administrator | Calls publish with the loaded version (immediate or scheduled) | Status → scheduled, dispatch_seq bumped, an outbox row queued | NoticePublicationService.publish |
| 4 | Outbox dispatcher | Relays within ~5s | NoticeJob.PUBLISH enqueued | Outbox |
| 5 | Notices worker | Claims lease, walks audience, sends | Status → published, notification event raised, notice_dispatches row written | NoticePublishService.publish |
| 6 | Recipients | See it in the Notice Centre and/or receive push/email/SMS | Read state begins at unread | NotificationService.send, Notice Centre |
| Branch | Condition | Behavior | Error/Result |
|---|
| Stale version at publish | Caller's version no longer matches the notice's current row version | Reload and reapply | 409 NOTICE_VERSION_CONFLICT |
| Schedule in the past | publishAt more than 60s behind server time | Reject | NOTICE_PUBLISH_AT_IN_PAST |
| Audience too large | >10 compound members, or >500 explicit users ids | Reject at publish time | NOTICE_AUDIENCE_TOO_LARGE |
| No channels chosen | channels empty | Reject | NOTICE_CHANNELS_EMPTY |
| Notice deleted before the job runs | Soft-deleted between scheduling and firing | Worker skips silently, no error surfaced to anyone | logged [notice.publish] [skip] ... gone |
| Republish after correction | Unpublish → edit → publish again | New dispatch_seq, fresh notification, recipients notified again | By design — see §12.3 |
| Retried job | Worker retried after a transient failure | No duplicate delivery | Three independent idempotency mechanisms (backend §6.3) |
After a notice is already published, the administrator wants recipients to know something changed (a room, a time) without withdrawing and republishing the whole notice.
- Notice is
status = published.
- Actor holds
NoticePublish_UPDATE.
| Step | Actor/System | Action | Result | Source |
|---|
| 1 | Administrator | POST /notices/:id/notify-update with an optional internal note | dispatch_seq bumped, NoticeJob.NOTIFY_UPDATE queued | NoticePublicationService.notifyUpdate |
| 2 | Worker | Sends notice.updated[.urgent] | New notice_dispatches row, reason = updated | NoticePublishService.notifyUpdate |
| Branch | Condition | Behavior | Error/Result |
|---|
| Notice not published | draft/scheduled/archived | Reject | NOTICE_NOT_PUBLISHED |
| Note supplied | note present | Recorded on the dispatch row, never sent to recipients | Internal-only field |
| Retry of the same request | Job retried by BullMQ | Deterministic requestId collapses to one event | No duplicate re-notification |
Any authenticated person holding NoticeView_READ opens the Notice Centre and sees exactly the notices whose audience includes them — themselves directly, a role they hold, their class/section/grade, a child they guard (and which one), a class or grade they teach, or their department — plus anything already delivered to them even if the audience has since narrowed.
- JWT with
NoticeView_READ (seeded to staff, teacher, pupil, and guardian roles).
- The notice is
published or archived, has a publishAt in the past, and is not soft-deleted.
| Step | Actor/System | Action | Result | Source |
|---|
| 1 | Reader | GET /portal/notices | List of visible notices, each flagged read/unread, with matchedFor for a guardian | NoticeCentreService.listNotices |
| 2 | Reader | Opens one | GET /portal/notices/:id returns full content; does not mark it read | NoticeCentreService.getDetail |
| 3 | Client | Fires a separate mark-read call on mount | notice_reads row written, unread count invalidated | NoticeCentreService.markRead |
| Branch | Condition | Behavior | Error/Result |
|---|
| Notice not addressed to this person | No matching membership, no prior read, no prior delivery | Not returned in the list; detail returns 404 | NOTICE_NOT_FOUND — never 403, so the id space cannot be enumerated |
| Audience narrowed after delivery | Person was addressed, then removed from a later edit | Still visible — "already delivered" is unioned into the predicate | Their deep link keeps working |
| Guardian with multiple children | More than one child matched by a guardians_of_* target | matchedFor lists every matched child | NoticeCentreEnrichmentService.resolveMatchedFor |
| Expired notice | expiresAt in the past and includeExpired not set | Excluded from the default list | includeExpired=true shows it |
| Mark read twice | Already-read notice marked read again | No-op, no error | onConflictDoNothing |
| Mark unread | DELETE /portal/notices/:id/read | Row deleted; both notice_reads and the notification bell mirror clear | Symmetric to mark-read |
POST /api/notices always creates a draft. Content is sanitised once at write time; an empty result after sanitisation is refused (NOTICE_CONTENT_EMPTY).
GET /api/notices supports status/priority/category/audience-kind/creator-name/publish-date-range filters. GET /api/notices/:id returns the full record including attachments and the raw AudienceSpec.
PATCH /api/notices/:id requires the loaded version. Content fields (title/summary/content/category/expiry/action link/calendar day) are editable on any non-archived notice. Publication parameters (audience/channels/priority/publishAt) are editable only while draft.
DELETE /api/notices/:id soft-deletes. It is refused while a publish claim is currently held (an in-flight publish must finish or be reclaimed by the sweep first). When the notice is scheduled, its pending publish outbox row is marked dead and its status is reset to draft, in the same transaction as the soft delete — a deleted notice never has a live publish job racing it.
A deleted notice disappears from the ordinary list and every other view, and reappears only in the removed-notices view: GET /api/notices?deleted=true. The admin screen builds its restore flow from that view — list with deleted=true, then POST /api/notices/:id/restore, which clears deletedAt. Because remove already reset a scheduled notice's status to draft and cancelled its pending publish, a restored notice that was scheduled when it was deleted comes back as a draft — an administrator must publish it again explicitly, rather than a cancelled schedule silently resuming.
POST /api/notices/:id/archive retires a notice, and is legal only from status = published. Any other status is refused with NOTICE_NOT_PUBLISHED: a scheduled notice must be unpublished first, a draft that was never published should be deleted instead, and an already-archived notice is a no-op refusal. It is also refused while a publish lease is live.
POST /api/notices/:id/duplicate copies content, category, priority, channels, and audience into a fresh draft; every publication field (publishAt, expiresAt, publishedAt, publishRequestedBy, dispatchSeq) is cleared.
Both categories and templates support create/list/get/update/soft-delete/restore. Thirteen categories and their seeded templates carry isSystem = true and refuse deletion (NOTICE_CATEGORY_SYSTEM_IMMUTABLE / NOTICE_TEMPLATE_SYSTEM_IMMUTABLE); deactivation (isActive = false) is always available instead.
POST /api/notices/:id/deliveries/:deliveryPublicId/retry — resolves the delivery through this notice's own dispatches first, then delegates to the shared dead-letter replay. Refused with NOTICE_DELIVERY_NOT_RETRYABLE unless a matching un-replayed job_failures row exists on a replayable notification queue.
| Entity | From | Event/Action | To | Guard Condition | Side Effects |
|---|
notices | (new) | create | draft | — | none |
notices | draft | publish | scheduled | channels non-empty, audience within caps, safe action URL, valid schedule | dispatch_seq +1, outbox row |
notices | scheduled | worker completes (conditional flip: still scheduled, same dispatch_seq, not deleted) | published | lease claimed, status/seq unchanged since claim | notification event, notice_dispatches row |
notices | scheduled | worker's conditional flip fails (status/seq changed since claim) | unchanged | — | skip changed_while_resolving, claim released |
notices | scheduled | unpublish | draft | not yet delivered | pending outbox row marked dead |
notices | scheduled, stuck past publish_at by 10+ minutes | maintenance sweep redrive | scheduled (unchanged) | unresolved, unclaimed, no pending publish outbox row | fresh outbox row, same dispatch identity |
notices | published | notify-update | published (unchanged) | — | dispatch_seq +1, new dispatch row |
notices | published | sweep detects expiry | archived | expiresAt <= now() | cache busted |
notices | published | archive | archived | no live lease | — |
notices | scheduled/draft/archived | archive attempted | refused | — | 409 NOTICE_NOT_PUBLISHED |
notices | archived | expiry extended to the future | published | updateExpiry with future expiresAt, and published_at is set | — |
notices | scheduled | remove | soft-deleted, status reset to draft | no live publish claim | pending outbox row marked dead, list caches invalidated |
notices | draft/published/archived | remove | soft-deleted (deletedAt set) | no live publish claim | list caches invalidated |
notices | soft-deleted | restore | previous status, deletedAt cleared | — | — |
Diagram:
archived is reachable only from published — by the expiry sweep, or by a manual archive — and is never terminal: extending expiry on an already-expired, already-archived notice (with published_at set) returns it to published, the specific case the "extendable" requirement exists to serve.
| Flow | DB Writes | Cache Effects | Jobs | Realtime | Analytics | Notifications |
|---|
| Create draft | notices, notice_audience_targets | invalidate notice:admin:list:* | — | — | — | — |
| Publish (request) | notices (status, seq), notice_runtime insert, outbox_events | invalidate detail + list | enqueue NoticeJob.PUBLISH | — | — | — |
| Publish (worker) | notices (status, publishedAt), notice_runtime (resolved), notice_dispatches | bust detail, admin list, unread patterns | — | — | — | one notice.published.<priority> event |
| Notify update | notices (seq), outbox_events, later notice_dispatches | bust caches | enqueue NoticeJob.NOTIFY_UPDATE | — | — | one notice.updated[.urgent] event |
| Mark read/unread | notice_reads, best-effort notification_recipient.read_at | delete notice:unread:user:<id> | — | — | — | — |
| Maintenance sweep | notices.status (archive), notice_runtime (reclaim) | bust detail + list | — | — | — | — |
| Maintenance sweep (redrive) | outbox_events (fresh row, same dispatch identity) | — | none directly — the redriven outbox row itself later enqueues NoticeJob.PUBLISH | — | — | — |
| Capture stats | notice_dispatches.delivery_stats | — | — | — | — | — |
| Attachment upload/remove/primary | notice_attachments | drop detail cache | — | — | — | — |
| Scenario | Trigger | User/System Experience | Recovery | Source |
|---|
| Concurrent edits | Two admins PATCH the same notice | Second submission gets NOTICE_VERSION_CONFLICT | Reload and reapply | NoticeWriteService.update |
| Publish job fails transiently | Redis/DB hiccup | BullMQ retries automatically | Automatic, idempotent | NoticesQueueProcessor |
| Publish job exhausts retries | Persistent failure | Notice appears stuck scheduled; a job_failures row is recorded | Operator inspects/replays via the dead-letter surface | JobFailureRecorderService |
| Worker killed mid-claim | Process crash between claim and completion | Notice looks permanently un-editable and un-publishable | The 5-minute maintenance sweep reclaims the expired lease | NoticeMaintenanceService.sweep |
| Publish job exhausted its retries entirely | A database outage longer than the job's backoff, or a worker killed mid-audience-walk | Notice sits scheduled, past its publish_at, with no live claim and no pending publish outbox row — nothing else would ever retry it, because its original outbox row is already dispatched | The sweep's redrive pass finds it (10+ minutes past publish_at) and writes a fresh outbox row reusing the same dispatch identity, so recipients are notified exactly once even if the original attempt partially succeeded | NoticeMaintenanceService.redriveStuckPublications |
| Delivery statistics needed after retention prune | Notice older than 30 days | Live query would return nothing | CAPTURE_STATS freezes the numbers at 48h, well before the 30-day prune | NoticeMaintenanceService.captureStats |
| One recipient's delivery failed | Provider error on one channel for one person | Administrator sees it in the delivery list | Bounded retry via POST .../deliveries/:id/retry | NoticeDeliveryService.retry |
Covered above: the actor/capability tables (§3-4), the module flow and sequence diagrams (§5), the lifecycle state diagram (§7), the data side-effect table (§9), and the error/recovery table (§10).
| Feature | Minor Behavior | Actor | Trigger | User/System Result | Backend Side Effect | Source |
|---|
| Create | Blank summary | Administrator | Field left empty | Summary derived from sanitised content | deriveSummary | notice-content.util.ts |
| Create | Audience normalisation | Administrator | Compound audience with duplicate members | Deduplicated and sorted before storage | normaliseAudience | notice-audience.util.ts |
| Create | all_users absorbs other members | Administrator | Compound spec including all_users plus other kinds | Collapses to {kind: all_users} | normaliseAudience | same |
| Update | Content-only edit on archived notice | Administrator | PATCH touching title/content on an archived notice | Refused | NOTICE_ARCHIVED_IMMUTABLE | notice-write.service.ts |
| Update | Publication-param edit on non-draft | Administrator | PATCH touching audience/channels/priority/publishAt post-draft | Refused, even under the right permission | NOTICE_PUBLICATION_PARAMS_IMMUTABLE | same |
| Publish | Live claim guard | Administrator | PATCH/archive/expiry-update while worker holds the publish lease | Refused | assertNoLivePublishClaim | notice-lookup.service.ts |
| Publish | Clock-skew allowance | Administrator | publishAt up to 60s in the past | Accepted (treated as "now") | assertSchedule | notice-publication.service.ts |
| Archive | Non-published state | Administrator | Archive a draft, scheduled, or already-archived notice | Refused; unpublish/delete/no-op respectively | NOTICE_NOT_PUBLISHED | notice-write.service.ts |
| Delete | Scheduled notice deleted | Administrator | DELETE a notice still scheduled | Pending publish outbox row marked dead, status reset to draft, then soft-deleted, all in one transaction | NoticeWriteService.remove | same |
| Delete | Live publish claim | Administrator | DELETE while the worker holds the publish lease | Refused | assertNoLivePublishClaim | same |
| Duplicate | Template lineage kept | Administrator | Duplicate a notice created from a template | templateId copied forward | NoticeWriteService.duplicate | same |
| Attachment | Primary flag exclusivity | Administrator | Set a new primary image | Old primary cleared and new primary set under a notice row lock, in the same transaction | NoticeAttachmentService.setPrimary | same |
| Attachment | Concurrent primary swaps | Administrator (two requests) | Two setPrimary calls on the same notice race | Serialised by the row lock; a rare unique-index collision maps to 409 NOTICE_VERSION_CONFLICT rather than a bare 500 | same | same |
| Attachment | Non-image as primary | Administrator | Attempt to promote a document | Refused | NOTICE_PRIMARY_IMAGE_NOT_AN_IMAGE | same |
| Attachment | Archived notice | Administrator | Upload, remove, or set-primary on an archived notice | Refused | NOTICE_ARCHIVED_IMMUTABLE | requireEditableNotice |
| Attachment | Cap enforced under lock | Administrator | Two uploads to a notice at 19 attachments race | The notice row lock serialises them; only one can insert the 20th | NOTICE_ATTACHMENT_LIMIT_EXCEEDED for the loser | NoticeAttachmentService.create |
| Attachment stream | Administrator bypass | Administrator holding Notices_READ | Stream an attachment on a draft/unpublished notice | Allowed without the visibility check | hasNoticesReadPermission | same |
| Notice Centre list | Guardian multi-child match | Guardian | Notice addressed via guardians_of_class matching two children | matchedFor lists both | resolveMatchedFor | notice-centre-enrichment.service.ts |
| Notice Centre detail | Never marks read | Any reader | GET .../:id | Read state unaffected | NoticeCentreService.getDetail | same |
| Notice Centre list | Empty result | Reader with no matching membership | Nobody addresses them | Empty list, no error | buildVisibleWhere | notice-centre.service.ts |
| Delivery stats | Never dispatched | Delivery auditor | Draft/unpublished notice | Zeros returned, not a 404 | getDeliveryStats | notice-delivery.service.ts |
| Delivery stats | Retention expired | Delivery auditor | Notice older than 30 days, stats not yet captured | source: "live" returns zeros/incomplete because rows are gone; usually already snapshotted by then | getDeliveryStats | same |
| Recipients list | Retention expired | Delivery auditor | notification_event pruned | retained: false, empty array — distinct from "nobody addressed" | getRecipients | same |
| Template render | Unresolvable token | Administrator | A {{token}} outside the closed set | Left literal in the output | notice-template-token.util.ts | same |
| Category slug | Retired slug reuse | Administrator | Create a category with a slug matching a soft-deleted one | Allowed (partial unique index) | notice_categories_slug_unique | schema |
| Category restore | Slug now collides with a live category | Administrator | Restore after someone else took the slug | NOTICE_CATEGORY_SLUG_ALREADY_EXISTS | mapped from 23505 | notice-category.service.ts |
| Rule | Business Reason | Actor Impact | Enforced In | API Impact | Backend Impact | Tests |
|---|
Publication params freeze outside draft | Visibility is the audience; a scheduled outbox row cannot follow a later change | Admin cannot change who a scheduled notice reaches without unpublishing | NoticeWriteService.update | NOTICE_PUBLICATION_PARAMS_IMMUTABLE | — | notice.service.integration.spec.ts |
dispatch_seq bumped on every publish/notify-update | Makes dedupe material unique per delivery act, so a republish actually reaches people | A correction-then-republish notifies again, not silently | notice-publication.service.ts | dispatchSeq in the publication result | notice_dispatches unique constraint | same |
| Publisher permission split from editor | Publishing spends metered SMS budget across the whole school | An editor cannot silently make a private notice public | NoticePublish permission module | — | — | permission-catalog.spec.ts |
Visibility matches (kind, uuid) pairs | The id spaces for class/guardians_of_class/teachers_of_class collide | A pupil never reads a notice sent to their own parents | NoticeVisibilityService | — | notice_audience_targets composite index | notice-centre-visibility.integration.spec.ts |
| Already-delivered recipients stay visible | Narrowing an audience must not break an already-sent link | A removed recipient keeps their notice | visibilityPredicate | — | — | same |
guardians_of_users membership needs no live enrolment | A notice addressed to "the parents of this named pupil" must reach them whether or not the pupil is currently enrolled | A guardian can open a notice about their child even if that child's enrolment has since ended | NoticeVisibilityService.membershipsFor | — | — | notice-centre-visibility.integration.spec.ts |
Category/template isSystem is immutable to delete | The 13/8 seeded defaults are relied on by other seed data and UI defaults | Admin can deactivate but not remove | NoticeCategoryService/NoticeTemplateService | NOTICE_CATEGORY_SYSTEM_IMMUTABLE | — | notice-category.service.integration.spec.ts |
| Audience caps (10 compound members, 500 explicit ids) | Prevents an accidental or malicious school-wide blast beyond what NotificationService's own top-level check catches | A very large audience must be split into multiple notices | assertSendablePreconditions | NOTICE_AUDIENCE_TOO_LARGE | — | notice.service.integration.spec.ts |
| Sanitiser applied once, at write | A renderer that forgets to sanitise is the whole class of stored-XSS bug | Content is safe regardless of which client renders it | notice.constants.ts NOTICE_SANITIZE_OPTIONS | — | — | notice-content.util.spec.ts |
| Attachment stream never signed | A signed URL would outlive authorization and leak via HTML/email/job payload | Every fetch re-checks visibility | NoticeAttachmentService.stream | — | — | notice-attachment.service.integration.spec.ts |
| Product Decision | User Benefit | Engineering Benefit | Alternative | Tradeoff | Risk |
|---|
| Publication as a two-step (request, then worker) process | Immediate UI response ("queued for delivery") | No long-lived transaction across a school-wide audience walk | Synchronous send in the request | A brief (~5s) delay before delivery actually starts | Mitigated by outbox at-least-once delivery |
| Audience stored as a live specification, not a snapshot | A pupil enrolled after publication still sees a still-live class notice | One source of truth, re-projected rather than duplicated | Materialised recipient list at publish time | Recipient count is a publish-time snapshot, so it never equals "everyone who can currently see it" | Documented explicitly everywhere a count is shown |
Read state duplicated in notice_reads and notification_recipient.read_at | Notice history stays permanent even after notification retention prunes | notice_reads never needs to reason about retention | Read only notification_recipient.read_at | Two writes on every read/unread action | The second write is best-effort and never blocks the first |
One permission per audience (NoticeView_READ) rather than per role | Simpler grant, one endpoint serves everyone | No per-audience controller/route duplication | Separate endpoints per audience | The WHERE clause becomes the entire safety boundary | Tested directly (notice-centre-visibility.integration.spec.ts) |
| Categories/templates seeded and system-locked | Administrators get a usable starting set immediately | No "empty state" screens on first boot | Fully admin-authored from empty | Cannot delete the defaults, only deactivate | Low — deactivation covers the real need |
| Flow | Edge Case | Trigger | Expected Behavior | User/System Feedback | Source |
|---|
| Publish | Duplicate publish click | Same request submitted twice quickly | Second call finds status already scheduled/published | NOTICE_ALREADY_PUBLISHED | notice-publication.service.ts |
| Publish worker | Concurrent claim attempt | Two worker instances race | Only one claim succeeds (conditional UPDATE) | The other skips as not_claimed | NoticePublishService.claim |
| Update | Concurrent edits | Two admins load, then both save | Second write's version no longer matches | NOTICE_VERSION_CONFLICT | NoticeWriteService.update |
| Archive | Non-published state | Archive attempt on a draft, scheduled, or already-archived notice | Refused, message names the specific reason | NOTICE_NOT_PUBLISHED | notice-write.service.ts |
| Publish worker | Notice changed mid-resolution | Notice archived/unpublished/republished/deleted between the claim and the conditional status flip | Flip is a no-op (its WHERE clause no longer matches); claim released, caches busted, no notification sent | skip changed_while_resolving | NoticePublishService.publish |
| Publish worker | Lease held by another attempt, retries remain | A duplicate outbox delivery or racing retry finds a live lease | Throws so BullMQ retries with backoff, rather than silently no-op-ing | — | NoticePublishService.claim ("held") |
| Publish worker | Lease held by another attempt, final retry | Same race, but this is the job's last BullMQ attempt | Skips instead of exhausting into a dead-letter row; a dead-letter row would be a false alarm if the holder still publishes | skip held_at_final_attempt; recovery left to the maintenance sweep | NoticePublishService.claim ("held", final attempt) |
| Maintenance sweep | Publish job exhausted retries | Notice stuck scheduled 10+ minutes past publish_at, unresolved, unclaimed, no pending outbox row | Redriven with a fresh outbox row on the same dispatch identity, capped at 100 per sweep | — | redriveStuckPublications |
| Expiry extension | Already-expired archived notice | New expiresAt in the future | Status flips back to published | Notice reappears in the live list | updateExpiry |
| Notice Centre | Guardian unlinked from a child | Safeguarding removal between cache TTL and next request | Membership resolution is never cached, so it reflects immediately | No stale access window | NoticeCentreService comment on caching |
| Notice Centre list | Category filter with no matches | categoryId narrows to zero rows | Empty list, totalCount: 0 | Normal empty state | buildVisibleWhere |
| Audience preview | Empty audience | {kind: "users", userPublicIds: []} | Total 0, no crash | NOTICE_AUDIENCE_EMPTY at actual publish/save time if truly empty | notice-audience-input.util.ts |
| Attachments | 21st attachment upload | Already at the 20-item cap | Refused | NOTICE_ATTACHMENT_LIMIT_EXCEEDED | notice-attachment.service.ts |
| Attachments | Zero-byte file | Empty upload | Refused at the CHECK constraint via error translation | NOTICE_ATTACHMENT_TYPE_FORBIDDEN | NoticeAttachmentService.translate |
| Delivery retry | Delivery belongs to a different notice | Mismatched notice/delivery id pair | Refused before even checking job_failures | NOTICE_DELIVERY_NOT_FOUND | notice-delivery.service.ts |
| Delivery retry | Delivery already succeeded | No matching un-replayed failure row | Refused | NOTICE_DELIVERY_NOT_RETRYABLE | same |
| Cache | Maintenance sweep archives while list cached | Notice expires mid-TTL | Sweep busts detail + list cache in the same run | No stale "published" reads past the sweep interval | NoticeMaintenanceService.bust |
| Queue | Job retried after transient failure | Redis/DB blip mid-processing | No duplicate SMS/email | Three independent idempotency layers | Backend §6.3 |
| Flow | Reads | Writes | Cache | Jobs/Events | Response Fields |
|---|
| Publish request | notices (locked) | notices, notice_runtime, outbox_events | — | NoticeJob.PUBLISH | publicId, publishAt, dispatchSeq |
| Publish (worker) | notices, audience tables | notices, notice_runtime, notice_dispatches | detail, admin-list, unread | one NotificationService event | (async — no direct HTTP response) |
| Notice Centre list | notices, notice_categories, notice_reads, audience membership | — | unread count only | — | list items with isRead, matchedFor |
- API doc:
/docs/developer/notices/api
- Backend doc:
/docs/developer/notices/backend