Skoolsewa - School Management System Docs
Developer ResourcesNotices

Notices Features and Flows

Complete feature list, actor journeys, state flows, business rules, edge cases, and diagrams for Notices.

Notices Features and Flows

1. Documentation Evidence

Source TypeFiles or DocsWhat Was Extracted
APISibling 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.tsRoute surface and actors
BackendSibling backend doc, the services and schema it documentsBusiness behavior, side effects, lifecycle
Schemapackages/db/src/schema/school/notices.tsState machine, constraints
Authorizationpackages/db/src/authorization/permission-catalog.ts, packages/db/src/seed/seed-auth.tsActor permissions

2. Feature Summary

FieldValue
ModuleNotices
SubmoduleNoticePublish, NoticeCategories, NoticeTemplates, NoticeDelivery, NoticeView, NoticeAttachment
Primary user valueAn 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.
ActorsAdministrator (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 outputsPersisted notices, notification_event/delivery rows via NotificationService, per-user read state, delivery statistics
Related docsAPI, backend

3. Actor Matrix

ActorCan DoCannot DoAuth RequirementNotes
Administrator (Notices_*)Create/edit/soft-delete/restore/duplicate a notice; manage categories and templatesPublish without NoticePublish_CREATE; see recipient-level delivery without NoticeDelivery_READAdmin JWT + Notices_* permissionEditing and publishing are separate capabilities on purpose
Publisher (NoticePublish_*)Publish/schedule, unpublish before delivery, send an explicit update notificationEdit a notice's content — that is Notices_UPDATEAdmin 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 deliveryEdit or publish a noticeAdmin JWT + NoticeDelivery_READ/_UPDATERetry 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 countSee a notice not addressed to them (directly or via a guardians_of_*/teachers_of_* target); reach the admin authoring surfaceJWT (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 statsAct outside its own queueInternal (BullMQ)Idempotent by lease claim, unique constraint, and notification dedupe

4. Capability Matrix

CapabilitySurfaceActorRoute/TriggerState ReadState WrittenLinked API Section
Create draft noticeAdminAdministratorPOST /api/noticescategories, calendar days, sessionnotices, notice_audience_targetsEndpoint Reference §8.5
List/filter noticesAdminAdministratorGET /api/noticesnotices—§8.1
Dashboard tilesAdminAdministratorGET /api/notices/summaryaggregates—§8.2
Find a person to addressAdminAdministratorGET /api/notices/audience/peoplestudents/guardians/staff—§8.3
Preview audience sizeAdminAdministratorPOST /api/notices/audience/previewaudience membership—§8.4
Update a noticeAdminAdministratorPATCH /api/notices/:idnotices (locked)notices, targets§8.7
Soft delete / restoreAdminAdministratorDELETE / POST .../restorenoticesdeleted_at§8.8/§8.9
List removed noticesAdminAdministratorGET /api/notices?deleted=truenotices—§8.1
ArchiveAdminAdministratorPOST /api/notices/:id/archivenotices, notice_runtimestatus§8.10
DuplicateAdminAdministratorPOST /api/notices/:id/duplicatesource noticenew notices row§8.11
Extend/change expiryAdminAdministratorPATCH /api/notices/:id/expirynotices (locked)expires_at, possibly status§8.12
Publish/scheduleAdminPublisherPOST /api/notices/:id/publishnotices (locked)status, dispatch_seq, outbox§8.13
UnpublishAdminPublisherPOST /api/notices/:id/unpublishnotices, notice_runtimestatus, outbox row dead§8.14
Notify updateAdminPublisherPOST /api/notices/:id/notify-updatenoticesdispatch_seq, outbox§8.15
Upload/remove/promote attachmentAdminAdministrator/api/notices/:id/attachments*notice_attachmentsinsert/delete/flag§8.16-8.18
Stream attachment bytesAdmin/portalAny visible reader, or Notices_READGET /api/notice-attachments/:idattachment + visibility—§8.19
Manage categoriesAdminAdministrator/api/notice-categories*notice_categoriesCRUD§8.20-8.25
Manage templates, renderAdminAdministrator/api/notice-templates*notice_templatesCRUD§8.26-8.32
Delivery stats / recipients / retryAdminDelivery auditor/api/notices/:id/delivery, /recipients, /deliveries/:id/retrydelivery tablesretry job§8.33-8.35
Notice Centre list/detail/read statePortalAny NoticeView_READ holder/api/mobile/portal/notices*visible noticesnotice_reads§8.36-8.40
Maintenance sweepWorkerSystemcron, 5 minnotices, notice_runtimearchive expired, reclaim leasesBackend §9
Capture delivery statsWorkerSystemcron, dailydelivery tablesnotice_dispatches.delivery_statsBackend §9

5. User-Facing Flows

5.1 An administrator writes and publishes a school-wide notice

Summary

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.

Preconditions

  • 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).

Main Flow

StepActor/SystemActionResultSource
1AdministratorComposes title/content/audience/channels/priority, savesA draft notice existsNoticeWriteService.create
2AdministratorReviews audience/preview if unsure of reachPer-kind counts, capped flag, no writesNoticeController.previewAudience
3AdministratorCalls publish with the loaded version (immediate or scheduled)Status → scheduled, dispatch_seq bumped, an outbox row queuedNoticePublicationService.publish
4Outbox dispatcherRelays within ~5sNoticeJob.PUBLISH enqueuedOutbox
5Notices workerClaims lease, walks audience, sendsStatus → published, notification event raised, notice_dispatches row writtenNoticePublishService.publish
6RecipientsSee it in the Notice Centre and/or receive push/email/SMSRead state begins at unreadNotificationService.send, Notice Centre

Sequence Diagram

Branches and Edge Cases

BranchConditionBehaviorError/Result
Stale version at publishCaller's version no longer matches the notice's current row versionReload and reapply409 NOTICE_VERSION_CONFLICT
Schedule in the pastpublishAt more than 60s behind server timeRejectNOTICE_PUBLISH_AT_IN_PAST
Audience too large>10 compound members, or >500 explicit users idsReject at publish timeNOTICE_AUDIENCE_TOO_LARGE
No channels chosenchannels emptyRejectNOTICE_CHANNELS_EMPTY
Notice deleted before the job runsSoft-deleted between scheduling and firingWorker skips silently, no error surfaced to anyonelogged [notice.publish] [skip] ... gone
Republish after correctionUnpublish → edit → publish againNew dispatch_seq, fresh notification, recipients notified againBy design — see §12.3
Retried jobWorker retried after a transient failureNo duplicate deliveryThree independent idempotency mechanisms (backend §6.3)

5.2 An administrator re-notifies recipients of a correction

Summary

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.

Preconditions

  • Notice is status = published.
  • Actor holds NoticePublish_UPDATE.

Main Flow

StepActor/SystemActionResultSource
1AdministratorPOST /notices/:id/notify-update with an optional internal notedispatch_seq bumped, NoticeJob.NOTIFY_UPDATE queuedNoticePublicationService.notifyUpdate
2WorkerSends notice.updated[.urgent]New notice_dispatches row, reason = updatedNoticePublishService.notifyUpdate

Branches and Edge Cases

BranchConditionBehaviorError/Result
Notice not publisheddraft/scheduled/archivedRejectNOTICE_NOT_PUBLISHED
Note suppliednote presentRecorded on the dispatch row, never sent to recipientsInternal-only field
Retry of the same requestJob retried by BullMQDeterministic requestId collapses to one eventNo duplicate re-notification

5.3 A pupil, parent, teacher, or staff member reads their notices

Summary

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.

Preconditions

  • 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.

Main Flow

StepActor/SystemActionResultSource
1ReaderGET /portal/noticesList of visible notices, each flagged read/unread, with matchedFor for a guardianNoticeCentreService.listNotices
2ReaderOpens oneGET /portal/notices/:id returns full content; does not mark it readNoticeCentreService.getDetail
3ClientFires a separate mark-read call on mountnotice_reads row written, unread count invalidatedNoticeCentreService.markRead

Sequence Diagram

Branches and Edge Cases

BranchConditionBehaviorError/Result
Notice not addressed to this personNo matching membership, no prior read, no prior deliveryNot returned in the list; detail returns 404NOTICE_NOT_FOUND — never 403, so the id space cannot be enumerated
Audience narrowed after deliveryPerson was addressed, then removed from a later editStill visible — "already delivered" is unioned into the predicateTheir deep link keeps working
Guardian with multiple childrenMore than one child matched by a guardians_of_* targetmatchedFor lists every matched childNoticeCentreEnrichmentService.resolveMatchedFor
Expired noticeexpiresAt in the past and includeExpired not setExcluded from the default listincludeExpired=true shows it
Mark read twiceAlready-read notice marked read againNo-op, no erroronConflictDoNothing
Mark unreadDELETE /portal/notices/:id/readRow deleted; both notice_reads and the notification bell mirror clearSymmetric to mark-read

6. Admin Flows

6.1 Create

POST /api/notices always creates a draft. Content is sanitised once at write time; an empty result after sanitisation is refused (NOTICE_CONTENT_EMPTY).

6.2 List / Read detail

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.

6.3 Update (correct)

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.

6.4 Delete / Restore

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.

6.5 Archive

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.

6.6 Duplicate

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.

6.7 Category and template management

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.

6.8 Manual retry

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.

7. Lifecycle and State Transitions

EntityFromEvent/ActionToGuard ConditionSide Effects
notices(new)createdraft—none
noticesdraftpublishscheduledchannels non-empty, audience within caps, safe action URL, valid scheduledispatch_seq +1, outbox row
noticesscheduledworker completes (conditional flip: still scheduled, same dispatch_seq, not deleted)publishedlease claimed, status/seq unchanged since claimnotification event, notice_dispatches row
noticesscheduledworker's conditional flip fails (status/seq changed since claim)unchanged—skip changed_while_resolving, claim released
noticesscheduledunpublishdraftnot yet deliveredpending outbox row marked dead
noticesscheduled, stuck past publish_at by 10+ minutesmaintenance sweep redrivescheduled (unchanged)unresolved, unclaimed, no pending publish outbox rowfresh outbox row, same dispatch identity
noticespublishednotify-updatepublished (unchanged)—dispatch_seq +1, new dispatch row
noticespublishedsweep detects expiryarchivedexpiresAt <= now()cache busted
noticespublishedarchivearchivedno live lease—
noticesscheduled/draft/archivedarchive attemptedrefused—409 NOTICE_NOT_PUBLISHED
noticesarchivedexpiry extended to the futurepublishedupdateExpiry with future expiresAt, and published_at is set—
noticesscheduledremovesoft-deleted, status reset to draftno live publish claimpending outbox row marked dead, list caches invalidated
noticesdraft/published/archivedremovesoft-deleted (deletedAt set)no live publish claimlist caches invalidated
noticessoft-deletedrestoreprevious 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.

9. Data and Side Effects by Flow

FlowDB WritesCache EffectsJobsRealtimeAnalyticsNotifications
Create draftnotices, notice_audience_targetsinvalidate notice:admin:list:*————
Publish (request)notices (status, seq), notice_runtime insert, outbox_eventsinvalidate detail + listenqueue NoticeJob.PUBLISH———
Publish (worker)notices (status, publishedAt), notice_runtime (resolved), notice_dispatchesbust detail, admin list, unread patterns———one notice.published.<priority> event
Notify updatenotices (seq), outbox_events, later notice_dispatchesbust cachesenqueue NoticeJob.NOTIFY_UPDATE——one notice.updated[.urgent] event
Mark read/unreadnotice_reads, best-effort notification_recipient.read_atdelete notice:unread:user:<id>————
Maintenance sweepnotices.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 statsnotice_dispatches.delivery_stats—————
Attachment upload/remove/primarynotice_attachmentsdrop detail cache————

10. Error and Recovery Flows

ScenarioTriggerUser/System ExperienceRecoverySource
Concurrent editsTwo admins PATCH the same noticeSecond submission gets NOTICE_VERSION_CONFLICTReload and reapplyNoticeWriteService.update
Publish job fails transientlyRedis/DB hiccupBullMQ retries automaticallyAutomatic, idempotentNoticesQueueProcessor
Publish job exhausts retriesPersistent failureNotice appears stuck scheduled; a job_failures row is recordedOperator inspects/replays via the dead-letter surfaceJobFailureRecorderService
Worker killed mid-claimProcess crash between claim and completionNotice looks permanently un-editable and un-publishableThe 5-minute maintenance sweep reclaims the expired leaseNoticeMaintenanceService.sweep
Publish job exhausted its retries entirelyA database outage longer than the job's backoff, or a worker killed mid-audience-walkNotice 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 dispatchedThe 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 succeededNoticeMaintenanceService.redriveStuckPublications
Delivery statistics needed after retention pruneNotice older than 30 daysLive query would return nothingCAPTURE_STATS freezes the numbers at 48h, well before the 30-day pruneNoticeMaintenanceService.captureStats
One recipient's delivery failedProvider error on one channel for one personAdministrator sees it in the delivery listBounded retry via POST .../deliveries/:id/retryNoticeDeliveryService.retry

11. Diagrams Required Per Module

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).

12. Mandatory Feature and Flow Deep-Dive Pack

12.1 Feature Inventory With Minor Behaviors

FeatureMinor BehaviorActorTriggerUser/System ResultBackend Side EffectSource
CreateBlank summaryAdministratorField left emptySummary derived from sanitised contentderiveSummarynotice-content.util.ts
CreateAudience normalisationAdministratorCompound audience with duplicate membersDeduplicated and sorted before storagenormaliseAudiencenotice-audience.util.ts
Createall_users absorbs other membersAdministratorCompound spec including all_users plus other kindsCollapses to {kind: all_users}normaliseAudiencesame
UpdateContent-only edit on archived noticeAdministratorPATCH touching title/content on an archived noticeRefusedNOTICE_ARCHIVED_IMMUTABLEnotice-write.service.ts
UpdatePublication-param edit on non-draftAdministratorPATCH touching audience/channels/priority/publishAt post-draftRefused, even under the right permissionNOTICE_PUBLICATION_PARAMS_IMMUTABLEsame
PublishLive claim guardAdministratorPATCH/archive/expiry-update while worker holds the publish leaseRefusedassertNoLivePublishClaimnotice-lookup.service.ts
PublishClock-skew allowanceAdministratorpublishAt up to 60s in the pastAccepted (treated as "now")assertSchedulenotice-publication.service.ts
ArchiveNon-published stateAdministratorArchive a draft, scheduled, or already-archived noticeRefused; unpublish/delete/no-op respectivelyNOTICE_NOT_PUBLISHEDnotice-write.service.ts
DeleteScheduled notice deletedAdministratorDELETE a notice still scheduledPending publish outbox row marked dead, status reset to draft, then soft-deleted, all in one transactionNoticeWriteService.removesame
DeleteLive publish claimAdministratorDELETE while the worker holds the publish leaseRefusedassertNoLivePublishClaimsame
DuplicateTemplate lineage keptAdministratorDuplicate a notice created from a templatetemplateId copied forwardNoticeWriteService.duplicatesame
AttachmentPrimary flag exclusivityAdministratorSet a new primary imageOld primary cleared and new primary set under a notice row lock, in the same transactionNoticeAttachmentService.setPrimarysame
AttachmentConcurrent primary swapsAdministrator (two requests)Two setPrimary calls on the same notice raceSerialised by the row lock; a rare unique-index collision maps to 409 NOTICE_VERSION_CONFLICT rather than a bare 500samesame
AttachmentNon-image as primaryAdministratorAttempt to promote a documentRefusedNOTICE_PRIMARY_IMAGE_NOT_AN_IMAGEsame
AttachmentArchived noticeAdministratorUpload, remove, or set-primary on an archived noticeRefusedNOTICE_ARCHIVED_IMMUTABLErequireEditableNotice
AttachmentCap enforced under lockAdministratorTwo uploads to a notice at 19 attachments raceThe notice row lock serialises them; only one can insert the 20thNOTICE_ATTACHMENT_LIMIT_EXCEEDED for the loserNoticeAttachmentService.create
Attachment streamAdministrator bypassAdministrator holding Notices_READStream an attachment on a draft/unpublished noticeAllowed without the visibility checkhasNoticesReadPermissionsame
Notice Centre listGuardian multi-child matchGuardianNotice addressed via guardians_of_class matching two childrenmatchedFor lists bothresolveMatchedFornotice-centre-enrichment.service.ts
Notice Centre detailNever marks readAny readerGET .../:idRead state unaffectedNoticeCentreService.getDetailsame
Notice Centre listEmpty resultReader with no matching membershipNobody addresses themEmpty list, no errorbuildVisibleWherenotice-centre.service.ts
Delivery statsNever dispatchedDelivery auditorDraft/unpublished noticeZeros returned, not a 404getDeliveryStatsnotice-delivery.service.ts
Delivery statsRetention expiredDelivery auditorNotice older than 30 days, stats not yet capturedsource: "live" returns zeros/incomplete because rows are gone; usually already snapshotted by thengetDeliveryStatssame
Recipients listRetention expiredDelivery auditornotification_event prunedretained: false, empty array — distinct from "nobody addressed"getRecipientssame
Template renderUnresolvable tokenAdministratorA {{token}} outside the closed setLeft literal in the outputnotice-template-token.util.tssame
Category slugRetired slug reuseAdministratorCreate a category with a slug matching a soft-deleted oneAllowed (partial unique index)notice_categories_slug_uniqueschema
Category restoreSlug now collides with a live categoryAdministratorRestore after someone else took the slugNOTICE_CATEGORY_SLUG_ALREADY_EXISTSmapped from 23505notice-category.service.ts

12.2 Business Process Diagram Pack

12.3 Business Rules and Policy Traceability

RuleBusiness ReasonActor ImpactEnforced InAPI ImpactBackend ImpactTests
Publication params freeze outside draftVisibility is the audience; a scheduled outbox row cannot follow a later changeAdmin cannot change who a scheduled notice reaches without unpublishingNoticeWriteService.updateNOTICE_PUBLICATION_PARAMS_IMMUTABLE—notice.service.integration.spec.ts
dispatch_seq bumped on every publish/notify-updateMakes dedupe material unique per delivery act, so a republish actually reaches peopleA correction-then-republish notifies again, not silentlynotice-publication.service.tsdispatchSeq in the publication resultnotice_dispatches unique constraintsame
Publisher permission split from editorPublishing spends metered SMS budget across the whole schoolAn editor cannot silently make a private notice publicNoticePublish permission module——permission-catalog.spec.ts
Visibility matches (kind, uuid) pairsThe id spaces for class/guardians_of_class/teachers_of_class collideA pupil never reads a notice sent to their own parentsNoticeVisibilityService—notice_audience_targets composite indexnotice-centre-visibility.integration.spec.ts
Already-delivered recipients stay visibleNarrowing an audience must not break an already-sent linkA removed recipient keeps their noticevisibilityPredicate——same
guardians_of_users membership needs no live enrolmentA notice addressed to "the parents of this named pupil" must reach them whether or not the pupil is currently enrolledA guardian can open a notice about their child even if that child's enrolment has since endedNoticeVisibilityService.membershipsFor——notice-centre-visibility.integration.spec.ts
Category/template isSystem is immutable to deleteThe 13/8 seeded defaults are relied on by other seed data and UI defaultsAdmin can deactivate but not removeNoticeCategoryService/NoticeTemplateServiceNOTICE_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 catchesA very large audience must be split into multiple noticesassertSendablePreconditionsNOTICE_AUDIENCE_TOO_LARGE—notice.service.integration.spec.ts
Sanitiser applied once, at writeA renderer that forgets to sanitise is the whole class of stored-XSS bugContent is safe regardless of which client renders itnotice.constants.ts NOTICE_SANITIZE_OPTIONS——notice-content.util.spec.ts
Attachment stream never signedA signed URL would outlive authorization and leak via HTML/email/job payloadEvery fetch re-checks visibilityNoticeAttachmentService.stream——notice-attachment.service.integration.spec.ts

12.4 Tradeoffs and Product Rationale

Product DecisionUser BenefitEngineering BenefitAlternativeTradeoffRisk
Publication as a two-step (request, then worker) processImmediate UI response ("queued for delivery")No long-lived transaction across a school-wide audience walkSynchronous send in the requestA brief (~5s) delay before delivery actually startsMitigated by outbox at-least-once delivery
Audience stored as a live specification, not a snapshotA pupil enrolled after publication still sees a still-live class noticeOne source of truth, re-projected rather than duplicatedMaterialised recipient list at publish timeRecipient 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_atNotice history stays permanent even after notification retention prunesnotice_reads never needs to reason about retentionRead only notification_recipient.read_atTwo writes on every read/unread actionThe second write is best-effort and never blocks the first
One permission per audience (NoticeView_READ) rather than per roleSimpler grant, one endpoint serves everyoneNo per-audience controller/route duplicationSeparate endpoints per audienceThe WHERE clause becomes the entire safety boundaryTested directly (notice-centre-visibility.integration.spec.ts)
Categories/templates seeded and system-lockedAdministrators get a usable starting set immediatelyNo "empty state" screens on first bootFully admin-authored from emptyCannot delete the defaults, only deactivateLow — deactivation covers the real need

12.5 Flow Edge-Case Matrix

FlowEdge CaseTriggerExpected BehaviorUser/System FeedbackSource
PublishDuplicate publish clickSame request submitted twice quicklySecond call finds status already scheduled/publishedNOTICE_ALREADY_PUBLISHEDnotice-publication.service.ts
Publish workerConcurrent claim attemptTwo worker instances raceOnly one claim succeeds (conditional UPDATE)The other skips as not_claimedNoticePublishService.claim
UpdateConcurrent editsTwo admins load, then both saveSecond write's version no longer matchesNOTICE_VERSION_CONFLICTNoticeWriteService.update
ArchiveNon-published stateArchive attempt on a draft, scheduled, or already-archived noticeRefused, message names the specific reasonNOTICE_NOT_PUBLISHEDnotice-write.service.ts
Publish workerNotice changed mid-resolutionNotice archived/unpublished/republished/deleted between the claim and the conditional status flipFlip is a no-op (its WHERE clause no longer matches); claim released, caches busted, no notification sentskip changed_while_resolvingNoticePublishService.publish
Publish workerLease held by another attempt, retries remainA duplicate outbox delivery or racing retry finds a live leaseThrows so BullMQ retries with backoff, rather than silently no-op-ing—NoticePublishService.claim ("held")
Publish workerLease held by another attempt, final retrySame race, but this is the job's last BullMQ attemptSkips instead of exhausting into a dead-letter row; a dead-letter row would be a false alarm if the holder still publishesskip held_at_final_attempt; recovery left to the maintenance sweepNoticePublishService.claim ("held", final attempt)
Maintenance sweepPublish job exhausted retriesNotice stuck scheduled 10+ minutes past publish_at, unresolved, unclaimed, no pending outbox rowRedriven with a fresh outbox row on the same dispatch identity, capped at 100 per sweep—redriveStuckPublications
Expiry extensionAlready-expired archived noticeNew expiresAt in the futureStatus flips back to publishedNotice reappears in the live listupdateExpiry
Notice CentreGuardian unlinked from a childSafeguarding removal between cache TTL and next requestMembership resolution is never cached, so it reflects immediatelyNo stale access windowNoticeCentreService comment on caching
Notice Centre listCategory filter with no matchescategoryId narrows to zero rowsEmpty list, totalCount: 0Normal empty statebuildVisibleWhere
Audience previewEmpty audience{kind: "users", userPublicIds: []}Total 0, no crashNOTICE_AUDIENCE_EMPTY at actual publish/save time if truly emptynotice-audience-input.util.ts
Attachments21st attachment uploadAlready at the 20-item capRefusedNOTICE_ATTACHMENT_LIMIT_EXCEEDEDnotice-attachment.service.ts
AttachmentsZero-byte fileEmpty uploadRefused at the CHECK constraint via error translationNOTICE_ATTACHMENT_TYPE_FORBIDDENNoticeAttachmentService.translate
Delivery retryDelivery belongs to a different noticeMismatched notice/delivery id pairRefused before even checking job_failuresNOTICE_DELIVERY_NOT_FOUNDnotice-delivery.service.ts
Delivery retryDelivery already succeededNo matching un-replayed failure rowRefusedNOTICE_DELIVERY_NOT_RETRYABLEsame
CacheMaintenance sweep archives while list cachedNotice expires mid-TTLSweep busts detail + list cache in the same runNo stale "published" reads past the sweep intervalNoticeMaintenanceService.bust
QueueJob retried after transient failureRedis/DB blip mid-processingNo duplicate SMS/emailThree independent idempotency layersBackend §6.3

12.6 Flow-to-Data Trace

FlowReadsWritesCacheJobs/EventsResponse Fields
Publish requestnotices (locked)notices, notice_runtime, outbox_events—NoticeJob.PUBLISHpublicId, publishAt, dispatchSeq
Publish (worker)notices, audience tablesnotices, notice_runtime, notice_dispatchesdetail, admin-list, unreadone NotificationService event(async — no direct HTTP response)
Notice Centre listnotices, notice_categories, notice_reads, audience membership—unread count only—list items with isRead, matchedFor

12.7 Experience Quality Checklist

  • The doc explains what the actor is trying to accomplish.
  • The doc explains what the backend does that the actor does not see (audience resolution, dedupe, lease claims).
  • The doc covers every minor flow and branch (§12.1, §12.5).
  • The doc includes admin, worker, and every reader audience.
  • The doc explains business logic, tradeoffs, and rationale (§12.3-12.4).
  • The doc maps every flow to API routes and backend side effects.
  • The doc includes diagrams appropriate to each flow type.
  • The doc covers edge cases and failure recovery (§10, §12.5).

13. Completion Checklist

  • Every feature, minor action, and submodule capability is listed.
  • Every actor has allowed and forbidden behavior.
  • Every major and minor flow includes steps, branches, and diagrams.
  • Every lifecycle has a transition table and state diagram.
  • Every flow links to the API and backend docs.

See Also

  • API doc: /docs/developer/notices/api
  • Backend doc: /docs/developer/notices/backend