Promotions Backend Documentation
Backend architecture, data model, services, jobs, runtime rules, and operational behavior for Promotions.
Promotions Backend Documentation
1. Documentation Evidence
| Area | Files Inspected | Verified Details |
|---|---|---|
| Module wiring | apps/api/src/modules/promotions/promotions.module.ts, cycles/promotion-cycles-admin.module.ts, decisions/promotion-decisions-admin.module.ts, history/academic-history-admin.module.ts, settings/promotion-settings-admin.module.ts | Aggregate composition, leaf declarations |
| Controllers | cycles/admin/promotion-cycles.controller.ts, decisions/admin/promotion-decisions.controller.ts, settings/admin/promotion-settings.controller.ts, history/admin/academic-history.controller.ts, portal/student/*.controller.ts, portal/guardian/*.controller.ts | Route ownership, permissions, audience assertions |
| Services | cycles/core/*.service.ts, decisions/core/*.service.ts, settings/core/promotion-settings.service.ts, publication/promotion-publication.service.ts, shared/*.service.ts | Business logic, transactions, locking, notifications |
| Pure algorithm | apps/api/src/utils/promotion/promotion-recommendation.util.ts | The recommendation and progression-resolution functions |
| Schema | packages/db/src/schema/school/promotions.ts, packages/db/src/migrations/0035_promotions_rollover.sql, 0036_promotions_rollover_guards.sql | Tables, enums, constraints, triggers |
| Jobs | packages/jobs/src/index.ts (AcademicYearJob.PUBLISH_PROMOTION_DECISIONS, PromotionPublishPayload) | Queue contract |
| Worker | apps/api/src/modules/rollover/workers/academic-year-queue.processor.ts, academic-year-sweep.service.ts | Consumption, failure handling, recovery |
| Notifications | apps/api/src/modules/notification/templates/promotion-templates.ts | Family/admin notification kinds |
| Permissions | packages/db/src/authorization/permission-catalog.ts | Module grants |
2. Backend Scope and Boundaries
Owns
- The promotion cycle lifecycle: draft, finalize, cancel, republish.
- The pure recommendation algorithm and its per-pupil application against exam results.
- Every version of every pupil's promotion decision (
promotion_decisions) and its append-only history. - Manual overrides, bulk decisions, resets (draft phase); corrections and condition resolution (post-finalize).
- Supplementary-exam re-evaluation after finalization.
- Promotion settings (graduating grade, conditional-promotion policy, notification policy) and per-grade progression overrides.
- The one shared academic-history read shape, used by the admin route and both portal routes.
- Publication of promotion outcomes to pupils and guardians, including recovery of stuck publication jobs (recovery sweep itself lives in
modules/rollover, but the domain logic it calls isPromotionPublicationService).
Does Not Own
- Academic-year rollover (which class of next year's structure a pupil lands in) —
modules/rollover. Promotions answers "what is this pupil's outcome"; rollover answers "which class does that outcome place them in". - Exams, exam results, exam-class scoping —
modules/exams(schema inpackages/db/src/schema/school/exams.ts). - Grades, classes, sections, student class enrollments —
packages/db/src/schema/school/classes.ts. - Student identity, soft deletion, guardianship —
modules/people. - Notification delivery mechanics (channels, dedupe, rendering) —
modules/notification; Promotions only supplieskindandvariables. - The outbox transport itself —
modules/outbox; Promotions writes rows throughOutboxService.enqueue. - Portal audience assertion primitives (
assertPortalAudience,resolveOwnChildId) —modules/mobile/sharedandmodules/mobile/guardian.
Source of Truth
| Concern | Source of Truth | Notes |
|---|---|---|
| A pupil's current promotion outcome | promotion_decisions row with isCurrent = true for the given cycle | Never derive it from a live recompute for display — only recompute drives new writes. |
| Whether a cycle can accept a given write | promotion_cycles.status, cross-checked by the promotion_decisions_status_gate trigger | The service checks status for a named 409; the trigger is the backstop against a bug that skips the check. |
| The recommendation itself | recommendPromotion() in promotion-recommendation.util.ts, fed by PromotionRecommendationService.computeCycleRecommendations | The one function recompute, finalize's staleness check, and basisDrift all call — they cannot disagree with each other. |
| Whether a pupil is still in-session | student_class_enrollments.status = 'active' for the cycle's academic session, evaluated fresh on every recompute | Never cached; a pupil who leaves mid-cycle is picked up on the next recompute. |
| Publication delivery state | promotion_cycles.publication_state + publication_attempt | The worker's publish() only completes when both still match what it was enqueued for. |
3. Module Composition
| Module | Type | Path | Controllers | Providers | Exports | Responsibility |
|---|---|---|---|---|---|---|
PromotionsModule | Aggregate | apps/api/src/modules/promotions/promotions.module.ts | None | None | None | Composes the four admin leaves. Declares no controllers of its own — each leaf is named individually in ADMIN_MODULES. |
PromotionCyclesAdminModule | Leaf | cycles/promotion-cycles-admin.module.ts | PromotionCyclesController | PromotionCyclesService, PromotionCycleRecomputeService, PromotionCycleFinalizeService, PromotionCycleSupplementaryService, PromotionCycleExamResolverService | These services | Cycle CRUD, recompute, finalize, republish, supplementary. |
PromotionDecisionsAdminModule | Leaf | decisions/promotion-decisions-admin.module.ts | PromotionDecisionsController | PromotionDecisionsReadService, PromotionDecisionManualWriteService, PromotionDecisionCorrectionsService | These services | Decision listing/history, manual/bulk/reset, correction/condition. |
PromotionSettingsAdminModule | Leaf | settings/promotion-settings-admin.module.ts | PromotionSettingsController | PromotionSettingsService | This service | Settings and per-grade progression overrides. |
AcademicHistoryAdminModule | Leaf | history/academic-history-admin.module.ts | AcademicHistoryController | AcademicHistoryReaderService (imported from shared) | — | Admin read of a pupil's academic history. |
StudentPromotionHistoryPortalModule | Leaf (mounted via MOBILE_CHILDREN, not PromotionsModule) | portal/student/student-promotion-history-portal.module.ts | StudentPromotionHistoryPortalController | AcademicHistoryReaderService | — | A pupil's own promotion/academic history. |
GuardianPromotionHistoryPortalModule | Leaf (mounted via MOBILE_CHILDREN) | portal/guardian/guardian-promotion-history-portal.module.ts | GuardianPromotionHistoryPortalController | AcademicHistoryReaderService | — | A guardian's child's promotion/academic history. |
PromotionsSharedModule | Shared providers | shared/promotions-shared.module.ts | None | PromotionRecommendationService, PromotionDecisionWriterService, AcademicHistoryReaderService | These services | The one recompute function and the one decision writer, shared across every consuming leaf. |
4. File and Directory Map
apps/api/src/modules/promotions/
promotions.module.ts
shared/
with-lock-retry.ts
promotion-recommendation.service.ts
promotion-codes.ts
promotion-decision-writer.service.ts
promotions-shared.module.ts
academic-history.dto.ts
promotion-notification-copy.ts
academic-history-reader.service.ts
cycles/
core/
promotion-cycle-exam-resolver.service.ts
promotion-cycle-recompute.service.ts
promotion-cycle-finalize.service.ts
promotion-cycle-supplementary.service.ts
promotion-cycles.service.ts
dto/promotion-cycle.dto.ts
admin/promotion-cycles.controller.ts
promotion-cycles-admin.module.ts
decisions/
core/
promotion-decision-manual-write.service.ts
promotion-decision-corrections.service.ts
promotion-decision-validation.util.ts
promotion-decisions-read.service.ts
dto/promotion-decision.dto.ts
admin/promotion-decisions.controller.ts
promotion-decisions-admin.module.ts
settings/
core/promotion-settings.service.ts
dto/promotion-settings.dto.ts
admin/promotion-settings.controller.ts
promotion-settings-admin.module.ts
history/
admin/academic-history.controller.ts
academic-history-admin.module.ts
publication/promotion-publication.service.ts
portal/
student/student-promotion-history-portal.controller.ts
student/student-promotion-history-portal.module.ts
guardian/guardian-promotion-history-portal.controller.ts
guardian/guardian-promotion-history-portal.module.ts
apps/api/src/utils/promotion/promotion-recommendation.util.ts
packages/db/src/schema/school/promotions.ts
packages/db/src/migrations/0035_promotions_rollover.sql
packages/db/src/migrations/0036_promotions_rollover_guards.sql
apps/api/src/modules/notification/templates/promotion-templates.ts| File | Purpose | Key Exports | Notes |
|---|---|---|---|
utils/promotion/promotion-recommendation.util.ts | Pure algorithm: no NestJS, no DB, no clock | recommendPromotion, resolveProgression | Fed entirely by caller-supplied booleans (applicable, leftSession) — never queries anything itself. |
shared/promotion-recommendation.service.ts | Loads the roster and every input row the pure function needs, in a fixed number of set-based queries | PromotionRecommendationService | The ONE recompute path — recompute, finalize's staleness check, and basisDrift all call it. |
shared/promotion-decision-writer.service.ts | THE ONLY writer of promotion_decisions | PromotionDecisionWriterService | Enforces the lock order and the mark-then-insert-then-link supersession protocol. |
shared/with-lock-retry.ts | Retries a transaction exactly once on 40001/40P01 | withLockRetry | The one retry a promotions write needs; the fixed cycle→student lock order prevents deadlock by construction. |
shared/promotion-codes.ts | Closed vocabulary for recommendation-reason codes | PROMOTION_RECOMMENDATION_REASONS, PromotionRecommendationReason | Framework-free so the pure util can import it. |
shared/academic-history-reader.service.ts | Builds the one academic-history shape for admin + both portals | AcademicHistoryReaderService | Callers own the "may this caller see this pupil" check; this class only assembles. |
shared/academic-history.dto.ts | The family-safe response shape | AcademicHistoryEntryDto and nested DTOs | Deliberately excludes reason, basis, decidedByUserId, superseded versions. |
shared/promotion-notification-copy.ts | Maps the outcome enum to a display label | promotionOutcomeLabel | The one place an enum value becomes words a notification shows. |
cycles/core/promotion-cycles.service.ts | Cycle CRUD, listing, detail (with basisDrift) | PromotionCyclesService | computeBasisDrift only runs for a finalized cycle. |
cycles/core/promotion-cycle-exam-resolver.service.ts | Validates basis-exam lists | PromotionCycleExamResolverService | Split out to keep PromotionCyclesService under the module-shape line cap. |
cycles/core/promotion-cycle-recompute.service.ts | Draft recompute | PromotionCycleRecomputeService | Excluded students get a fixed reason string, not the algorithm's own text. |
cycles/core/promotion-cycle-finalize.service.ts | Finalize and republish | PromotionCycleFinalizeService | Enforces the four finalize preconditions before flipping status. |
cycles/core/promotion-cycle-supplementary.service.ts | Post-finalize supplementary application | PromotionCycleSupplementaryService | Eligibility rule documented inline; only three source kinds are touched. |
decisions/core/promotion-decision-manual-write.service.ts | PUT, bulk, reset | PromotionDecisionManualWriteService | All three are draft-only; the service checks status for a named 409. |
decisions/core/promotion-decision-corrections.service.ts | Correct and resolve-condition | PromotionDecisionCorrectionsService | Both finalized-only; both notify inline. |
decisions/core/promotion-decision-validation.util.ts | Shared validation for every write endpoint | requireReason, resolveTargetGradeId, resolveConditionFields | Mirrors the DB CHECK constraints so a bad request never reaches a raw constraint violation. |
decisions/core/promotion-decisions-read.service.ts | List, history, single-row-to-DTO mapping | PromotionDecisionsReadService | isOverridden is source-based, a deliberate simplification (see docblock). |
settings/core/promotion-settings.service.ts | Settings read/update, progression CRUD | PromotionSettingsService | Writes a manual ActivityRecordService.recordActivity call for per-field change detail, alongside the global interceptor's own entry. |
publication/promotion-publication.service.ts | Enqueue + deliver publication notifications | PromotionPublicationService | Shared by finalize, republish, and the academic-year recovery sweep — one implementation of the terminal-state transition. |
5. Data Model
5.1 Schema Source
packages/db/src/schema/school/promotions.ts
packages/db/src/schema/school/rollover.ts (studentGraduations — read by academic history, owned by rollover)
packages/db/src/migrations/0035_promotions_rollover.sql
packages/db/src/migrations/0036_promotions_rollover_guards.sql5.2 Tables and Collections
promotion_cycles
| Column | Type | Nullable | Default | Index/Constraint | Notes |
|---|---|---|---|---|---|
id | uuid | No | uuidv7() | PK | Internal id, used for FKs. |
public_id | uuid | No | uuidv7() | unique | The id every route uses. |
academic_session_id | int | No | — | FK academic_sessions.id ON DELETE restrict | |
name | text | No | — | CHECK: no leading/trailing whitespace, ≤120 chars | |
status | promotion_cycle_status (draft/finalized/cancelled) | No | draft | — | |
publication_state | promotion_publication_state (none/queued/sent/failed) | No | none | CHECK: only non-none while status='finalized'; queued requires publication_queued_at | |
publication_attempt | int | No | 0 | CHECK: 0–100 | Bumped on every publication enqueue; the job's dedupe key includes it. |
publication_queued_at | timestamptz | Yes | — | — | |
finalized_at / finalized_by_user_id | timestamptz / uuid | Yes | — | FK users.id SET NULL; CHECK: pair with status='finalized' | |
cancelled_at / cancelled_by_user_id / cancel_reason | mixed | Yes | — | FK SET NULL; CHECK: pair with status='cancelled'; reason length 3–1000 | |
created_by_user_id | uuid | Yes | — | FK users.id SET NULL | |
version | bigint | No | 0 | Bumped by bump_row_version trigger | Optimistic-concurrency token. |
created_at/updated_at | timestamptz | No | now() | — |
Indexes/constraints: promotion_cycles_public_id_unique; promotion_cycles_id_session_unique (composite — target of rollover_cycles' FK, so "a rollover consumes the promotion cycle of its own source year" is a DB fact); promotion_cycles_one_live_per_session (partial unique on academic_session_id where status <> 'cancelled' — at most one live cycle per session); promotion_cycles_session_idx.
promotion_cycle_exams
| Column | Type | Nullable | Default | Notes |
|---|---|---|---|---|
cycle_id | uuid | No | — | FK promotion_cycles.id CASCADE, part of composite PK |
exam_id | int | No | — | FK exams.id RESTRICT, part of composite PK |
role | promotion_basis_role (final/supplementary) | No | — | |
added_at | timestamptz | No | now() | |
added_by_user_id | uuid | Yes | — | FK users.id SET NULL |
A supplementary exam may belong to a later session than the cycle's own — the table does not tie the exam's session to the cycle's; the service (PromotionCycleExamResolverService) enforces the rule.
promotion_grade_progressions
| Column | Type | Nullable | Default | Notes |
|---|---|---|---|---|
grade_id | int | No | — | PK, FK grades.id CASCADE |
next_grade_id | int | Yes | — | FK grades.id RESTRICT |
graduates | boolean | No | false | |
updated_at/updated_by_user_id | mixed | — | now() / null | FK users.id SET NULL |
CHECKs: promotion_grade_progressions_one_rule (NOT (graduates AND next_grade_id IS NOT NULL)), _has_rule (graduates OR next_grade_id IS NOT NULL), _not_self (next_grade_id IS DISTINCT FROM grade_id).
promotion_decisions
| Column | Type | Nullable | Default | Notes |
|---|---|---|---|---|
id | uuid | No | uuidv7() | PK |
public_id | uuid | No | uuidv7() | unique |
cycle_id | uuid | No | — | FK promotion_cycles.id CASCADE |
student_id | uuid | No | — | FK students.id CASCADE |
source_enrollment_id | uuid | Yes | — | FK student_class_enrollments.id SET NULL — history pointer only |
source_class_id | int | No | — | FK classes.id RESTRICT |
source_grade_id | int | No | — | FK grades.id RESTRICT |
outcome | promotion_outcome | No | — | promoted/retained/conditionally_promoted/graduated/pending/excluded |
target_grade_id | int | Yes | — | FK grades.id RESTRICT; CHECK: present iff outcome is promoted/retained/conditionally_promoted |
recommended_outcome | promotion_outcome | Yes | — | Snapshot of what the algorithm said, even on a manual override |
recommendation_reason | text | Yes | — | A PROMOTION_RECOMMENDATION_REASONS code |
basis | jsonb | No | '[]' | Every final/supplementary exam considered, whether or not applicable; CHECK: must be a JSON array; internal only — never on a pupil/guardian surface |
condition_text/condition_deadline/condition_status | text/date/enum | Yes | — | CHECK: text+status present iff outcome is conditionally_promoted; deadline requires text; text length 3–500 |
source | promotion_decision_source | No | — | recommendation/manual/bulk/supplementary/correction/condition |
reason | text | Yes | — | CHECK: required (3–1000 chars) unless source is recommendation/supplementary; also required whenever outcome='excluded' |
is_current | boolean | No | true | CHECK: is_current = (superseded_at IS NULL) |
superseded_at/superseded_by_id | timestamptz/uuid | Yes | — | Self-referencing FK SET NULL |
decided_by_user_id | uuid | Yes | — | FK users.id SET NULL |
decided_at | timestamptz | No | clock_timestamp() | |
notified_at | timestamptz | Yes | — | Stamped once the publication/inline notification for this version was sent |
Indexes: promotion_decisions_public_id_unique; promotion_decisions_one_current (partial unique on (cycle_id, student_id) where is_current); promotion_decisions_cycle_outcome_idx (cycle_id, outcome, partial where current); promotion_decisions_student_idx; promotion_decisions_unnotified_idx (partial where current and notified_at IS NULL — the exact predicate the publication worker's sendChunk query uses).
promotion_settings
Singleton, id = 1 (CHECK promotion_settings_singleton), seeded by migration 0036.
| Column | Type | Default | Notes |
|---|---|---|---|
graduating_grade_id | int, FK grades.id SET NULL | — | Optional explicit graduating grade; if null, the algorithm treats the top-sort-order active grade as graduating. |
allow_conditional_promotion | boolean | true | Gates whether a CONDITIONAL verdict may become conditionally_promoted. |
share_condition_in_notification | boolean | false | Off by default — a condition is free text written by staff. |
notify_on_publish | boolean | true | Gates whether finalize enqueues a publication job at all. |
publish_channels | text[] | {in_app,push} | CHECK: subset of {email,sms,push,in_app}, at least one. |
version | bigint | 0 | Bumped by bump_row_version. |
5.3 Relationship Diagram
6. Services and Responsibilities
6.1 PromotionRecommendationService
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
computeCycleRecommendations(tx, cycleId) | Recompute, finalize, supplementary, corrections, bulk-decide, manual-write, basisDrift | Roster (active + left-session), cycle exams, exam-class scope, exam results, grades, progressions, settings | None | None | None (returns an empty map for a missing cycle) |
unpublishedFinalExamCount(tx, cycleId) | Finalize | promotion_cycle_exams, exams | None | None | None |
Input normalization: builds one RecommendPromotionInput per roster member from a fixed set of pre-loaded maps — never a per-student query. Transaction boundary: always runs inside the caller's transaction (tx), never opens its own. Response mapping: returns Map<studentId, ComputedRecommendation>, letting each caller decide what to do with a computed result.
6.2 PromotionDecisionWriterService
The only writer of promotion_decisions. Protocol per write batch:
- Lock the cycle
FOR SHARE(participates in the global cycle→student lock order; cycle-state changes like finalize/cancel takeFOR UPDATEon the same row first). - Lock the affected students
FOR UPDATE, in ascendingidorder (same constructionenroll()uses elsewhere) — this is what makes two concurrent bulk writes over an overlapping student set incapable of deadlocking each other. - For each write, if it supersedes an existing row:
UPDATE ... SET is_current=false, superseded_at=clock_timestamp() WHERE id=expectedCurrentId AND is_current=true— if this affects zero rows, someone else already superseded it →409 PROMOTION_DECISION_STALE. INSERTthe new version.- If step 3 ran,
UPDATEthe old row'ssuperseded_by_idto the new row's id. - If the write carries a
notify, send the notification and stampnotified_aton the new row.
All writes in one call happen inside one transaction — all-or-nothing.
6.3 PromotionCyclesService
CRUD, list/detail, cancel. create/update delegate exam validation to PromotionCycleExamResolverService. findOne computes basisDrift only when status === 'finalized', by calling the shared recompute and diffing every current auto (recommendation/supplementary) decision against it. update and cancel both lock the cycle row FOR UPDATE and check status === 'draft' before proceeding.
6.4 PromotionCycleRecomputeService
Locks the cycle FOR UPDATE, requires draft and a matching version, computes fresh recommendations, and writes only what changed: a new row for a student with no current decision, a new row for a student whose current auto decision (recommendation/supplementary) drifted from the fresh compute, and nothing for a manually-decided or unchanged student. Wrapped in withLockRetry.
6.5 PromotionCycleFinalizeService
finalize: locks the cycle FOR UPDATE, requires draft and a matching version, then four preconditions in order — no unpublished final basis exams, no roster member missing a decision, no drift between stored auto decisions and a fresh compute, and (unless acknowledgePending) no pending decisions. On success, flips status to finalized, stamps finalizedAt/finalizedByUserId, and enqueues publication unless notifyOnPublish is off.
republish: locks the cycle FOR UPDATE, requires finalized and publication_state in {none, failed}, then calls enqueuePublication again.
6.6 PromotionCycleSupplementaryService
Locks the cycle FOR SHARE, requires finalized, attaches the exam (validating session ordering and cancellation), recomputes the whole cycle, then for each current decision: skip if the pupil left the session; skip (record as skippedManual) if the decision is a manual/bulk/correction source that is not a conditional promotion; for a conditionally_promoted decision, only move conditionStatus to satisfied when the fresh compute resolves via a supplementary pass to promoted/graduated — never replace the outcome or target grade; otherwise, write the fresh computed outcome when it differs from the current one, and notify per write (subject to settings).
6.7 PromotionDecisionManualWriteService
putDecision: locks the cycle FOR SHARE, requires draft, requires the caller's expectedDecisionPublicId to match the current row, resolves target grade/condition fields against the same rules the DB CHECKs enforce, snapshots a fresh recommendedOutcome, writes with source: manual. Never notifies (draft-phase writes are silent).
bulkDecide: same draft/status checks, requires every listed pupil to already have a current decision, applies one outcome/target/condition to all of them, source bulk.
reset: recomputes and overwrites the named pupils back to recommendation/supplementary, discarding any manual/bulk override for exactly those pupils.
6.8 PromotionDecisionCorrectionsService
correct: requires finalized, requires expectedDecisionPublicId to match (or be omitted only when the pupil truly has no decision, in which case an active enrollment is required to snapshot source class/grade), resolves target/condition fields, snapshots a fresh recommended outcome for context, writes with source: correction, notifies inline.
resolveCondition: requires finalized and a current conditionally_promoted decision matching expectedDecisionPublicId. conditionStatus: "failed" rewrites the outcome to retained at the pupil's source grade; any other resolution keeps the outcome/target grade and only updates conditionStatus. Source condition, notifies inline.
6.9 PromotionDecisionsReadService
findDecisions: paginated, filterable (outcome, recommendedOutcome, source, gradePublicId, classPublicId, reason, overridden, free-text search over student name/id). Refuses pagination=false (PAGINATION_LIMIT_INVALID) — a decisions roster is never dumped unpaginated. isOverridden is computed from source membership in {manual, bulk, correction}, not a live recompute, as a deliberate cost/complexity tradeoff documented in the file.
findHistory: every version for a pupil in a cycle, newest first, with decidedByName resolved via a separate lookup (never a join) because decided_by_user_id is ON DELETE SET NULL and may legitimately point at nobody.
toDto: maps a single already-written row to the wire DTO, reused by every write endpoint so they don't duplicate findDecisions' join logic.
6.10 PromotionSettingsService
getSettings/updateSettings: singleton row, optimistic concurrency via version. updateSettings locks FOR UPDATE, computes a field-level change list, and — after the transaction commits — calls ActivityRecordService.recordActivity directly with those changes, because the global ActivityAuditInterceptor cannot see per-field detail on its own (it still records the mutation itself).
listProgressions/putProgression/deleteProgression: per-grade override CRUD, validated against the same "graduates XOR next grade, never both, never neither, never self" rule the DB CHECKs enforce. putProgression upserts via onConflictDoUpdate on the grade_id primary key.
6.11 AcademicHistoryReaderService
read(studentId): unions three session-id sources (any session the pupil was ever enrolled in, any session with a finalized cycle carrying a current decision for the pupil, any session with a live graduation record), then for each session assembles placements (all enrollments that session, ordered by enrolledOn), an outcome (only from a finalized cycle's current decision — corrected is true exactly when that current decision's source is correction), and a graduation record (only a live, non-revoked one). Callers own the "may this caller see this pupil" check entirely; this class never asserts access.
6.12 PromotionPublicationService
enqueuePublication(tx, cycle): bumps publication_attempt, sets publication_state='queued', stamps publication_queued_at, and writes an outbox row (aggregateType: "promotion_cycle", eventType: "promotion.publish_requested", targetQueue: QueueName.ACADEMIC_YEAR, jobName: AcademicYearJob.PUBLISH_PROMOTION_DECISIONS) inside the caller's own transaction. The dedupe key is promotion-publish-<cyclePublicId>-<attempt>, so a retried enqueue at the same attempt number is deduplicated by the outbox's own unique index.
publish(payload): re-reads the cycle; returns "missing" if it no longer exists (an erasure cascade under the job's feet), "superseded" if the cycle's live publication_attempt/publication_state no longer match the payload. Otherwise loops sendChunk (200 rows per transaction, FOR UPDATE SKIP LOCKED) until no un-notified eligible decision remains, then conditionally flips publication_state to sent only if it is still queued.
7. Runtime Flows
7.1 Recompute
| Step | Code Path | Behavior | Failure Case |
|---|---|---|---|
| 1 | PromotionCycleRecomputeService.recompute | Locks cycle, validates draft + version | 404/409 NOT_DRAFT/409 STALE |
| 2 | PromotionRecommendationService.computeCycleRecommendations | Set-based load + pure-function apply per roster member | Empty map if cycle vanished |
| 3 | Diff against current decisions | Skip manually-decided, skip unchanged auto decisions | — |
| 4 | PromotionDecisionWriterService.writeDecisions | Supersede-then-insert per changed/added student | 409 STALE if a concurrent write already superseded the row |
7.2 Finalize
| Step | Code Path | Behavior | Failure Case |
|---|---|---|---|
| 1 | Lock + validate | draft + matching version | 409 NOT_DRAFT/STALE |
| 2 | Exam-publication check | Every final exam must have published results | 409 PROMOTION_EXAM_NOT_PUBLISHED |
| 3 | Missing-decision check | Every active roster member must have a decision | 409 PROMOTION_CYCLE_STALE |
| 4 | Drift check | Every current auto decision must match a fresh compute | 409 PROMOTION_CYCLE_STALE |
| 5 | Pending check | Any pending outcome requires acknowledgePending: true | 409 PROMOTION_PENDING_UNACKNOWLEDGED |
| 6 | Status flip + publish enqueue | Conditional on notifyOnPublish | — |
7.3 Publication delivery (worker)
Failure case: if the job's final delivery attempt throws, AcademicYearQueueProcessor.onFailed conditionally sets publication_state='failed' (only if the cycle's public_id+publication_attempt+publication_state='queued' still match — a superseded delivery never overwrites a newer attempt's state).
7.4 Recovery sweep (every five minutes)
AcademicYearSweepService.sweepPublications finds cycles publication_state='queued' with publication_queued_at older than 10 minutes. For each, under FOR UPDATE SKIP LOCKED: if publication_attempt >= 5 (MAX_NOTIFICATION_ATTEMPTS), marks publication_state='failed'; otherwise re-enqueues via enqueuePublication (bumping the attempt again, so the new job's dedupe key is fresh).
8. Caching
Promotions writes and reads no Redis cache key of its own. PROMOTION_CACHE_PREFIX exists in apps/api/src/modules/cache-invalidation/cache-invalidation.tags.ts as part of the shared cache-invalidation domain registry, but no promotions service reads or invalidates through it — the module reads directly from PostgreSQL on every request. STUDENTS_CACHE_PREFIX is invalidated by the rollover worker's failure path (markRunFailed), not by anything in this module.
9. BullMQ, Schedulers, and Async Work
| Queue | Job | Producer | Processor | Payload | Retry/Backoff | Idempotency |
|---|---|---|---|---|---|---|
QueueName.ACADEMIC_YEAR | AcademicYearJob.PUBLISH_PROMOTION_DECISIONS ("academic_year.publish_promotion_decisions") | PromotionPublicationService.enqueuePublication, via the outbox, from finalize, republish, and the recovery sweep | AcademicYearQueueProcessor (concurrency 1) | PromotionPublishPayload{v:1, requestId, cyclePublicId, attempt} | BullMQ's own configured retry/backoff for the queue; a lost/stalled job is separately recovered by the five-minute sweep, capped at 5 attempts | Outbox dedupe key promotion-publish-<cyclePublicId>-<attempt>; the worker itself re-checks publication_attempt/publication_state before acting, so a duplicate or late delivery is a safe no-op ("superseded") |
Every enqueue goes through the outbox — there is no direct queue.add() call in this module's request-handling code, matching the repo-wide invariant that a direct enqueue in a handler is a defect.
10. Realtime and Events
Promotions raises no realtime/Socket.IO event. It participates in the notification system (in-app, push, email, SMS via NotificationService), not the separate realtime channel documented in modules/realtime.
11. Security, Auth, and Abuse Controls
- Every admin route requires
JwtAuthGuard(global) +RoleGuard+ an explicit@Permissions()code (Promotions_*,PromotionFinalize_UPDATE,PromotionCorrections_UPDATE,PromotionSettings_*). PromotionFinalize,PromotionCorrections, andPromotionSettingsare listed inSUPERADMIN_ONLY_MODULES— this does not mean only superadmin can hold them; it means the automatic "grant everything not on this list" pass that seeds theadminrole skips them. A school must deliberately create or extend a role with these codes.PromotionsandAcademicHistorythemselves are not on that list, so they are part of the automaticadmingrant.- The admin academic-history read additionally calls
PeoplePermissionsService.heldPermissions+PeopleAccessService.assertCanAccess(actor, "students", studentPublicId, held)— the same object-level access control every other student read in the codebase uses. - The two portal routes (
StudentPromotionHistoryPortalController,GuardianPromotionHistoryPortalController) carry no@Permissions(). Each is allowlisted inRoleGuard'sNO_PERMISSION_ADMIN_HANDLERSand mirrored intest/structure/route-permissions.spec.ts; missing either entry means the route 403s for every caller including superadmin, or the structure test fails.RoleGuardreturns early for an allowlisted handler, before its ownactiveRolecheck — so@CurrentAdmin()(not@CurrentUser()) is what actually resolves the acting identity in these two controllers. - The student portal asserts
assertPortalAudience(actor, "student")and then resolves its own live (deletedAt IS NULL)studentsrow fromactor.id— an identity-layer check (404, never 403, if none). - The guardian portal asserts
assertPortalAudience(actor, "guardian")and delegates ownership entirely toGuardianPortalService.resolveOwnChildId, which throws 404 (never 403) for a child that is not the caller's — preventing an id-space enumeration oracle. - Neither portal route ever calls
PeopleAccessService.scopeFor— that helper returns{kind:"all"}for any caller holding the entity's own_READcode, which would leak the whole roster to a teacher holdingStudents_READif used here. promotion_decisions.basis(the raw exam-by-exam audit trail) andreason(a human's free-text justification) are never returned onAcademicHistoryEntryDtoor any portal-facing shape — only on the adminPromotionDecisionDto/PromotionDecisionHistoryEntryDto.- Family notification copy (push, SMS, in-app, email) never interpolates a decision's
reason, a correction reason, or an exclusion reason — seepromotion-templates.ts's docblock. Push/SMS for family kinds is a generic "an academic update is available" line; only in-app/email carry the outcome and (optionally) the condition text. - Every manual/bulk/correction/condition write requires a non-empty
reason(PROMOTION_REASON_REQUIRED), enforced inrequireReason()before the DB's ownpromotion_decisions_reason_requiredCHECK would otherwise raise a raw constraint violation.
13. Error Handling
| Error Code | HTTP Status | Thrown By | Condition | Client Action |
|---|---|---|---|---|
PROMOTION_CYCLE_NOT_FOUND | 404 | Every cycle-scoped service | No cycle with that publicId | Verify the id |
PROMOTION_CYCLE_EXISTS | 409 | PromotionCyclesService.create | Another live (non-cancelled) cycle already exists for the session | Use/cancel the existing cycle |
PROMOTION_CYCLE_NOT_DRAFT | 409 | Update, cancel, recompute, manual write, bulk, reset | Cycle is not draft | Only draft-phase actions apply |
PROMOTION_CYCLE_NOT_FINALIZED | 409 | Correct, condition, supplementary | Cycle is not finalized | Only post-finalize actions apply |
PROMOTION_CYCLE_STALE | 409 | Update/recompute/finalize (version mismatch or drift/missing-decision) | Concurrent edit, or recommendations changed since last computed | Reload / recompute and retry |
PROMOTION_CYCLE_IN_USE | 409 | PromotionCyclesService.cancel | A non-cancelled rollover cycle already references this cycle | Cancel the rollover first |
ACADEMIC_SESSION_IN_USE | 409 | DELETE /academic-sessions/:publicId | The session is still referenced by a promotion (or rollover) row through a restrict foreign key | Retire the session instead of deleting it |
PROMOTION_EXAM_INVALID | 400 | Exam resolver, supplementary attach | Exam missing, cancelled, duplicated across final/supp, or wrong session ordering | Fix the exam selection |
PROMOTION_EXAM_NOT_PUBLISHED | 409 | Finalize, supplementary attach | A final basis exam has no published results, or the exam being attached as the supplementary basis has none | Publish results before finalizing or attaching it |
PROMOTION_STUDENT_NOT_IN_CYCLE | 400/404 | Bulk, reset, correction (no active enrollment) | Pupil has no decision in this cycle / no active enrollment | Verify the pupil is in this cycle's roster |
PROMOTION_DECISION_STALE | 409 | Every decision write | expectedCurrentId/expectedDecisionPublicId/expectedDecisionPublicIds mismatch | Reload and retry |
PROMOTION_DECISION_WRITE_REFUSED | 409 | Any promotion decision write | A database guard trigger (PROMOTION_CYCLE_STATUS_MISMATCH) fired because the cycle's status changed between when the write was validated and when it committed | Reload the cycle and retry |
PROMOTION_TARGET_GRADE_INVALID | 400 | resolveTargetGradeId | Missing or inactive target grade for an outcome that needs one | Choose an active grade |
PROMOTION_CONDITION_REQUIRED | 400 | resolveConditionFields | Missing/short condition text, or a past deadline | Provide valid condition fields |
PROMOTION_REASON_REQUIRED | 400 | requireReason | Missing/short reason on a manual/bulk/correction/condition write | Provide a reason (3–1000 chars) |
PROMOTION_PENDING_UNACKNOWLEDGED | 409 | Finalize | Pending decisions exist and acknowledgePending was false | Resend with acknowledgePending: true, or resolve the pending pupils first |
PROMOTION_DECISION_NOT_CONDITIONAL | 409 | resolveCondition | Current decision is not conditionally_promoted | Only applicable to a conditional promotion |
PROMOTION_SETTINGS_STALE | 404/409 | PromotionSettingsService | Settings row missing, or version mismatch | Reload settings and retry |
PROMOTION_PROGRESSION_INVALID | 400 | putProgression | Both/neither of graduates and next grade set, or next grade equals the grade itself | Fix the progression body |
PROMOTION_PUBLICATION_INVALID_STATE | 409 | republish | publication_state is not none/failed | Wait for the current attempt to resolve |
14. Observability
| Signal | Location | Purpose |
|---|---|---|
| Log | Logger in PromotionPublicationService, AcademicYearQueueProcessor, AcademicYearSweepService | Job start/success/failure, stuck-publication recovery/exhaustion |
| Audit | ActivityAuditInterceptor (global) records every mutating request automatically, tagged by the handler's @Permissions() module (Promotions, PromotionFinalize, PromotionCorrections, PromotionSettings) | Who changed what and when |
| Audit (detail) | PromotionSettingsService.updateSettings calls ActivityRecordService.recordActivity directly with a per-field change list | The interceptor alone cannot see field-level detail |
| Job failure | JobFailureRecorderService.recordIfTerminal, called from AcademicYearQueueProcessor.onFailed | Records a terminally-failed publication job in job_failures, with only id references (never the raw payload) |
15. Testing and Validation
| Test Type | Files | Coverage |
|---|---|---|
| Integration | apps/api/src/modules/promotions/cycles/__tests__/promotion-cycles.integration.spec.ts | Cycle CRUD, lifecycle |
| Integration | apps/api/src/modules/promotions/cycles/__tests__/promotion-cycle-supplementary.integration.spec.ts | Supplementary application |
| Integration | apps/api/src/modules/promotions/decisions/__tests__/promotion-decisions.integration.spec.ts | Decision writes, history, corrections |
| Integration | apps/api/src/modules/promotions/settings/__tests__/promotion-settings.integration.spec.ts | Settings and progressions |
| Integration | apps/api/src/modules/promotions/publication/__tests__/promotion-publication.integration.spec.ts | Publication enqueue/deliver/recover |
| Integration | apps/api/src/modules/promotions/portal/__tests__/promotion-portal.integration.spec.ts | Student/guardian portal reads, audience refusal, ownership refusal |
| Structure | apps/api/test/structure/route-permissions.spec.ts | The two portal handlers' no-permission allowlist entries |
| Structure | apps/api/test/structure/swagger-coverage.spec.ts | Every route documented, tags correct, no credential-shaped response field |
| Fixtures | apps/api/src/modules/promotions/__tests__/fixtures.ts | Shared setup for promotions integration tests |
Commands:
pnpm --filter @skoolsewa/api test
pnpm --filter @skoolsewa/api test:structureTests run against the real database (TEST_DATABASE_URL) and real migrations — mocking Postgres, Redis, or a queue in this module is a defect per repo policy.
See Also
- API doc: /docs/developer/promotions/api
- Features and flows doc: /docs/developer/promotions/feature