Skoolsewa - School Management System Docs
Developer ResourcesPromotions

Promotions Backend Documentation

Backend architecture, data model, services, jobs, runtime rules, and operational behavior for Promotions.

Promotions Backend Documentation

1. Documentation Evidence

AreaFiles InspectedVerified Details
Module wiringapps/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.tsAggregate composition, leaf declarations
Controllerscycles/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.tsRoute ownership, permissions, audience assertions
Servicescycles/core/*.service.ts, decisions/core/*.service.ts, settings/core/promotion-settings.service.ts, publication/promotion-publication.service.ts, shared/*.service.tsBusiness logic, transactions, locking, notifications
Pure algorithmapps/api/src/utils/promotion/promotion-recommendation.util.tsThe recommendation and progression-resolution functions
Schemapackages/db/src/schema/school/promotions.ts, packages/db/src/migrations/0035_promotions_rollover.sql, 0036_promotions_rollover_guards.sqlTables, enums, constraints, triggers
Jobspackages/jobs/src/index.ts (AcademicYearJob.PUBLISH_PROMOTION_DECISIONS, PromotionPublishPayload)Queue contract
Workerapps/api/src/modules/rollover/workers/academic-year-queue.processor.ts, academic-year-sweep.service.tsConsumption, failure handling, recovery
Notificationsapps/api/src/modules/notification/templates/promotion-templates.tsFamily/admin notification kinds
Permissionspackages/db/src/authorization/permission-catalog.tsModule 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 is PromotionPublicationService).

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 in packages/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 supplies kind and variables.
  • The outbox transport itself — modules/outbox; Promotions writes rows through OutboxService.enqueue.
  • Portal audience assertion primitives (assertPortalAudience, resolveOwnChildId) — modules/mobile/shared and modules/mobile/guardian.

Source of Truth

ConcernSource of TruthNotes
A pupil's current promotion outcomepromotion_decisions row with isCurrent = true for the given cycleNever derive it from a live recompute for display — only recompute drives new writes.
Whether a cycle can accept a given writepromotion_cycles.status, cross-checked by the promotion_decisions_status_gate triggerThe service checks status for a named 409; the trigger is the backstop against a bug that skips the check.
The recommendation itselfrecommendPromotion() in promotion-recommendation.util.ts, fed by PromotionRecommendationService.computeCycleRecommendationsThe one function recompute, finalize's staleness check, and basisDrift all call — they cannot disagree with each other.
Whether a pupil is still in-sessionstudent_class_enrollments.status = 'active' for the cycle's academic session, evaluated fresh on every recomputeNever cached; a pupil who leaves mid-cycle is picked up on the next recompute.
Publication delivery statepromotion_cycles.publication_state + publication_attemptThe worker's publish() only completes when both still match what it was enqueued for.

3. Module Composition

ModuleTypePathControllersProvidersExportsResponsibility
PromotionsModuleAggregateapps/api/src/modules/promotions/promotions.module.tsNoneNoneNoneComposes the four admin leaves. Declares no controllers of its own — each leaf is named individually in ADMIN_MODULES.
PromotionCyclesAdminModuleLeafcycles/promotion-cycles-admin.module.tsPromotionCyclesControllerPromotionCyclesService, PromotionCycleRecomputeService, PromotionCycleFinalizeService, PromotionCycleSupplementaryService, PromotionCycleExamResolverServiceThese servicesCycle CRUD, recompute, finalize, republish, supplementary.
PromotionDecisionsAdminModuleLeafdecisions/promotion-decisions-admin.module.tsPromotionDecisionsControllerPromotionDecisionsReadService, PromotionDecisionManualWriteService, PromotionDecisionCorrectionsServiceThese servicesDecision listing/history, manual/bulk/reset, correction/condition.
PromotionSettingsAdminModuleLeafsettings/promotion-settings-admin.module.tsPromotionSettingsControllerPromotionSettingsServiceThis serviceSettings and per-grade progression overrides.
AcademicHistoryAdminModuleLeafhistory/academic-history-admin.module.tsAcademicHistoryControllerAcademicHistoryReaderService (imported from shared)—Admin read of a pupil's academic history.
StudentPromotionHistoryPortalModuleLeaf (mounted via MOBILE_CHILDREN, not PromotionsModule)portal/student/student-promotion-history-portal.module.tsStudentPromotionHistoryPortalControllerAcademicHistoryReaderService—A pupil's own promotion/academic history.
GuardianPromotionHistoryPortalModuleLeaf (mounted via MOBILE_CHILDREN)portal/guardian/guardian-promotion-history-portal.module.tsGuardianPromotionHistoryPortalControllerAcademicHistoryReaderService—A guardian's child's promotion/academic history.
PromotionsSharedModuleShared providersshared/promotions-shared.module.tsNonePromotionRecommendationService, PromotionDecisionWriterService, AcademicHistoryReaderServiceThese servicesThe 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
FilePurposeKey ExportsNotes
utils/promotion/promotion-recommendation.util.tsPure algorithm: no NestJS, no DB, no clockrecommendPromotion, resolveProgressionFed entirely by caller-supplied booleans (applicable, leftSession) — never queries anything itself.
shared/promotion-recommendation.service.tsLoads the roster and every input row the pure function needs, in a fixed number of set-based queriesPromotionRecommendationServiceThe ONE recompute path — recompute, finalize's staleness check, and basisDrift all call it.
shared/promotion-decision-writer.service.tsTHE ONLY writer of promotion_decisionsPromotionDecisionWriterServiceEnforces the lock order and the mark-then-insert-then-link supersession protocol.
shared/with-lock-retry.tsRetries a transaction exactly once on 40001/40P01withLockRetryThe one retry a promotions write needs; the fixed cycle→student lock order prevents deadlock by construction.
shared/promotion-codes.tsClosed vocabulary for recommendation-reason codesPROMOTION_RECOMMENDATION_REASONS, PromotionRecommendationReasonFramework-free so the pure util can import it.
shared/academic-history-reader.service.tsBuilds the one academic-history shape for admin + both portalsAcademicHistoryReaderServiceCallers own the "may this caller see this pupil" check; this class only assembles.
shared/academic-history.dto.tsThe family-safe response shapeAcademicHistoryEntryDto and nested DTOsDeliberately excludes reason, basis, decidedByUserId, superseded versions.
shared/promotion-notification-copy.tsMaps the outcome enum to a display labelpromotionOutcomeLabelThe one place an enum value becomes words a notification shows.
cycles/core/promotion-cycles.service.tsCycle CRUD, listing, detail (with basisDrift)PromotionCyclesServicecomputeBasisDrift only runs for a finalized cycle.
cycles/core/promotion-cycle-exam-resolver.service.tsValidates basis-exam listsPromotionCycleExamResolverServiceSplit out to keep PromotionCyclesService under the module-shape line cap.
cycles/core/promotion-cycle-recompute.service.tsDraft recomputePromotionCycleRecomputeServiceExcluded students get a fixed reason string, not the algorithm's own text.
cycles/core/promotion-cycle-finalize.service.tsFinalize and republishPromotionCycleFinalizeServiceEnforces the four finalize preconditions before flipping status.
cycles/core/promotion-cycle-supplementary.service.tsPost-finalize supplementary applicationPromotionCycleSupplementaryServiceEligibility rule documented inline; only three source kinds are touched.
decisions/core/promotion-decision-manual-write.service.tsPUT, bulk, resetPromotionDecisionManualWriteServiceAll three are draft-only; the service checks status for a named 409.
decisions/core/promotion-decision-corrections.service.tsCorrect and resolve-conditionPromotionDecisionCorrectionsServiceBoth finalized-only; both notify inline.
decisions/core/promotion-decision-validation.util.tsShared validation for every write endpointrequireReason, resolveTargetGradeId, resolveConditionFieldsMirrors the DB CHECK constraints so a bad request never reaches a raw constraint violation.
decisions/core/promotion-decisions-read.service.tsList, history, single-row-to-DTO mappingPromotionDecisionsReadServiceisOverridden is source-based, a deliberate simplification (see docblock).
settings/core/promotion-settings.service.tsSettings read/update, progression CRUDPromotionSettingsServiceWrites a manual ActivityRecordService.recordActivity call for per-field change detail, alongside the global interceptor's own entry.
publication/promotion-publication.service.tsEnqueue + deliver publication notificationsPromotionPublicationServiceShared 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.sql

5.2 Tables and Collections

promotion_cycles

ColumnTypeNullableDefaultIndex/ConstraintNotes
iduuidNouuidv7()PKInternal id, used for FKs.
public_iduuidNouuidv7()uniqueThe id every route uses.
academic_session_idintNo—FK academic_sessions.id ON DELETE restrict
nametextNo—CHECK: no leading/trailing whitespace, ≤120 chars
statuspromotion_cycle_status (draft/finalized/cancelled)Nodraft—
publication_statepromotion_publication_state (none/queued/sent/failed)NononeCHECK: only non-none while status='finalized'; queued requires publication_queued_at
publication_attemptintNo0CHECK: 0–100Bumped on every publication enqueue; the job's dedupe key includes it.
publication_queued_attimestamptzYes——
finalized_at / finalized_by_user_idtimestamptz / uuidYes—FK users.id SET NULL; CHECK: pair with status='finalized'
cancelled_at / cancelled_by_user_id / cancel_reasonmixedYes—FK SET NULL; CHECK: pair with status='cancelled'; reason length 3–1000
created_by_user_iduuidYes—FK users.id SET NULL
versionbigintNo0Bumped by bump_row_version triggerOptimistic-concurrency token.
created_at/updated_attimestamptzNonow()—

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

ColumnTypeNullableDefaultNotes
cycle_iduuidNo—FK promotion_cycles.id CASCADE, part of composite PK
exam_idintNo—FK exams.id RESTRICT, part of composite PK
rolepromotion_basis_role (final/supplementary)No—
added_attimestamptzNonow()
added_by_user_iduuidYes—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

ColumnTypeNullableDefaultNotes
grade_idintNo—PK, FK grades.id CASCADE
next_grade_idintYes—FK grades.id RESTRICT
graduatesbooleanNofalse
updated_at/updated_by_user_idmixed—now() / nullFK 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

ColumnTypeNullableDefaultNotes
iduuidNouuidv7()PK
public_iduuidNouuidv7()unique
cycle_iduuidNo—FK promotion_cycles.id CASCADE
student_iduuidNo—FK students.id CASCADE
source_enrollment_iduuidYes—FK student_class_enrollments.id SET NULL — history pointer only
source_class_idintNo—FK classes.id RESTRICT
source_grade_idintNo—FK grades.id RESTRICT
outcomepromotion_outcomeNo—promoted/retained/conditionally_promoted/graduated/pending/excluded
target_grade_idintYes—FK grades.id RESTRICT; CHECK: present iff outcome is promoted/retained/conditionally_promoted
recommended_outcomepromotion_outcomeYes—Snapshot of what the algorithm said, even on a manual override
recommendation_reasontextYes—A PROMOTION_RECOMMENDATION_REASONS code
basisjsonbNo'[]'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_statustext/date/enumYes—CHECK: text+status present iff outcome is conditionally_promoted; deadline requires text; text length 3–500
sourcepromotion_decision_sourceNo—recommendation/manual/bulk/supplementary/correction/condition
reasontextYes—CHECK: required (3–1000 chars) unless source is recommendation/supplementary; also required whenever outcome='excluded'
is_currentbooleanNotrueCHECK: is_current = (superseded_at IS NULL)
superseded_at/superseded_by_idtimestamptz/uuidYes—Self-referencing FK SET NULL
decided_by_user_iduuidYes—FK users.id SET NULL
decided_attimestamptzNoclock_timestamp()
notified_attimestamptzYes—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.

ColumnTypeDefaultNotes
graduating_grade_idint, FK grades.id SET NULL—Optional explicit graduating grade; if null, the algorithm treats the top-sort-order active grade as graduating.
allow_conditional_promotionbooleantrueGates whether a CONDITIONAL verdict may become conditionally_promoted.
share_condition_in_notificationbooleanfalseOff by default — a condition is free text written by staff.
notify_on_publishbooleantrueGates whether finalize enqueues a publication job at all.
publish_channelstext[]{in_app,push}CHECK: subset of {email,sms,push,in_app}, at least one.
versionbigint0Bumped by bump_row_version.

5.3 Relationship Diagram

6. Services and Responsibilities

6.1 PromotionRecommendationService

MethodCalled ByReadsWritesSide EffectsErrors
computeCycleRecommendations(tx, cycleId)Recompute, finalize, supplementary, corrections, bulk-decide, manual-write, basisDriftRoster (active + left-session), cycle exams, exam-class scope, exam results, grades, progressions, settingsNoneNoneNone (returns an empty map for a missing cycle)
unpublishedFinalExamCount(tx, cycleId)Finalizepromotion_cycle_exams, examsNoneNoneNone

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:

  1. Lock the cycle FOR SHARE (participates in the global cycle→student lock order; cycle-state changes like finalize/cancel take FOR UPDATE on the same row first).
  2. Lock the affected students FOR UPDATE, in ascending id order (same construction enroll() uses elsewhere) — this is what makes two concurrent bulk writes over an overlapping student set incapable of deadlocking each other.
  3. 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.
  4. INSERT the new version.
  5. If step 3 ran, UPDATE the old row's superseded_by_id to the new row's id.
  6. If the write carries a notify, send the notification and stamp notified_at on 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

StepCode PathBehaviorFailure Case
1PromotionCycleRecomputeService.recomputeLocks cycle, validates draft + version404/409 NOT_DRAFT/409 STALE
2PromotionRecommendationService.computeCycleRecommendationsSet-based load + pure-function apply per roster memberEmpty map if cycle vanished
3Diff against current decisionsSkip manually-decided, skip unchanged auto decisions—
4PromotionDecisionWriterService.writeDecisionsSupersede-then-insert per changed/added student409 STALE if a concurrent write already superseded the row

7.2 Finalize

StepCode PathBehaviorFailure Case
1Lock + validatedraft + matching version409 NOT_DRAFT/STALE
2Exam-publication checkEvery final exam must have published results409 PROMOTION_EXAM_NOT_PUBLISHED
3Missing-decision checkEvery active roster member must have a decision409 PROMOTION_CYCLE_STALE
4Drift checkEvery current auto decision must match a fresh compute409 PROMOTION_CYCLE_STALE
5Pending checkAny pending outcome requires acknowledgePending: true409 PROMOTION_PENDING_UNACKNOWLEDGED
6Status flip + publish enqueueConditional 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

QueueJobProducerProcessorPayloadRetry/BackoffIdempotency
QueueName.ACADEMIC_YEARAcademicYearJob.PUBLISH_PROMOTION_DECISIONS ("academic_year.publish_promotion_decisions")PromotionPublicationService.enqueuePublication, via the outbox, from finalize, republish, and the recovery sweepAcademicYearQueueProcessor (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 attemptsOutbox 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, and PromotionSettings are listed in SUPERADMIN_ONLY_MODULES — this does not mean only superadmin can hold them; it means the automatic "grant everything not on this list" pass that seeds the admin role skips them. A school must deliberately create or extend a role with these codes. Promotions and AcademicHistory themselves are not on that list, so they are part of the automatic admin grant.
  • 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 in RoleGuard's NO_PERMISSION_ADMIN_HANDLERS and mirrored in test/structure/route-permissions.spec.ts; missing either entry means the route 403s for every caller including superadmin, or the structure test fails. RoleGuard returns early for an allowlisted handler, before its own activeRole check — 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) students row from actor.id — an identity-layer check (404, never 403, if none).
  • The guardian portal asserts assertPortalAudience(actor, "guardian") and delegates ownership entirely to GuardianPortalService.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 _READ code, which would leak the whole roster to a teacher holding Students_READ if used here.
  • promotion_decisions.basis (the raw exam-by-exam audit trail) and reason (a human's free-text justification) are never returned on AcademicHistoryEntryDto or any portal-facing shape — only on the admin PromotionDecisionDto/PromotionDecisionHistoryEntryDto.
  • Family notification copy (push, SMS, in-app, email) never interpolates a decision's reason, a correction reason, or an exclusion reason — see promotion-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 in requireReason() before the DB's own promotion_decisions_reason_required CHECK would otherwise raise a raw constraint violation.

13. Error Handling

Error CodeHTTP StatusThrown ByConditionClient Action
PROMOTION_CYCLE_NOT_FOUND404Every cycle-scoped serviceNo cycle with that publicIdVerify the id
PROMOTION_CYCLE_EXISTS409PromotionCyclesService.createAnother live (non-cancelled) cycle already exists for the sessionUse/cancel the existing cycle
PROMOTION_CYCLE_NOT_DRAFT409Update, cancel, recompute, manual write, bulk, resetCycle is not draftOnly draft-phase actions apply
PROMOTION_CYCLE_NOT_FINALIZED409Correct, condition, supplementaryCycle is not finalizedOnly post-finalize actions apply
PROMOTION_CYCLE_STALE409Update/recompute/finalize (version mismatch or drift/missing-decision)Concurrent edit, or recommendations changed since last computedReload / recompute and retry
PROMOTION_CYCLE_IN_USE409PromotionCyclesService.cancelA non-cancelled rollover cycle already references this cycleCancel the rollover first
ACADEMIC_SESSION_IN_USE409DELETE /academic-sessions/:publicIdThe session is still referenced by a promotion (or rollover) row through a restrict foreign keyRetire the session instead of deleting it
PROMOTION_EXAM_INVALID400Exam resolver, supplementary attachExam missing, cancelled, duplicated across final/supp, or wrong session orderingFix the exam selection
PROMOTION_EXAM_NOT_PUBLISHED409Finalize, supplementary attachA final basis exam has no published results, or the exam being attached as the supplementary basis has nonePublish results before finalizing or attaching it
PROMOTION_STUDENT_NOT_IN_CYCLE400/404Bulk, reset, correction (no active enrollment)Pupil has no decision in this cycle / no active enrollmentVerify the pupil is in this cycle's roster
PROMOTION_DECISION_STALE409Every decision writeexpectedCurrentId/expectedDecisionPublicId/expectedDecisionPublicIds mismatchReload and retry
PROMOTION_DECISION_WRITE_REFUSED409Any promotion decision writeA database guard trigger (PROMOTION_CYCLE_STATUS_MISMATCH) fired because the cycle's status changed between when the write was validated and when it committedReload the cycle and retry
PROMOTION_TARGET_GRADE_INVALID400resolveTargetGradeIdMissing or inactive target grade for an outcome that needs oneChoose an active grade
PROMOTION_CONDITION_REQUIRED400resolveConditionFieldsMissing/short condition text, or a past deadlineProvide valid condition fields
PROMOTION_REASON_REQUIRED400requireReasonMissing/short reason on a manual/bulk/correction/condition writeProvide a reason (3–1000 chars)
PROMOTION_PENDING_UNACKNOWLEDGED409FinalizePending decisions exist and acknowledgePending was falseResend with acknowledgePending: true, or resolve the pending pupils first
PROMOTION_DECISION_NOT_CONDITIONAL409resolveConditionCurrent decision is not conditionally_promotedOnly applicable to a conditional promotion
PROMOTION_SETTINGS_STALE404/409PromotionSettingsServiceSettings row missing, or version mismatchReload settings and retry
PROMOTION_PROGRESSION_INVALID400putProgressionBoth/neither of graduates and next grade set, or next grade equals the grade itselfFix the progression body
PROMOTION_PUBLICATION_INVALID_STATE409republishpublication_state is not none/failedWait for the current attempt to resolve

14. Observability

SignalLocationPurpose
LogLogger in PromotionPublicationService, AcademicYearQueueProcessor, AcademicYearSweepServiceJob start/success/failure, stuck-publication recovery/exhaustion
AuditActivityAuditInterceptor (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 listThe interceptor alone cannot see field-level detail
Job failureJobFailureRecorderService.recordIfTerminal, called from AcademicYearQueueProcessor.onFailedRecords a terminally-failed publication job in job_failures, with only id references (never the raw payload)

15. Testing and Validation

Test TypeFilesCoverage
Integrationapps/api/src/modules/promotions/cycles/__tests__/promotion-cycles.integration.spec.tsCycle CRUD, lifecycle
Integrationapps/api/src/modules/promotions/cycles/__tests__/promotion-cycle-supplementary.integration.spec.tsSupplementary application
Integrationapps/api/src/modules/promotions/decisions/__tests__/promotion-decisions.integration.spec.tsDecision writes, history, corrections
Integrationapps/api/src/modules/promotions/settings/__tests__/promotion-settings.integration.spec.tsSettings and progressions
Integrationapps/api/src/modules/promotions/publication/__tests__/promotion-publication.integration.spec.tsPublication enqueue/deliver/recover
Integrationapps/api/src/modules/promotions/portal/__tests__/promotion-portal.integration.spec.tsStudent/guardian portal reads, audience refusal, ownership refusal
Structureapps/api/test/structure/route-permissions.spec.tsThe two portal handlers' no-permission allowlist entries
Structureapps/api/test/structure/swagger-coverage.spec.tsEvery route documented, tags correct, no credential-shaped response field
Fixturesapps/api/src/modules/promotions/__tests__/fixtures.tsShared setup for promotions integration tests

Commands:

pnpm --filter @skoolsewa/api test
pnpm --filter @skoolsewa/api test:structure

Tests 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