Skoolsewa - School Management System Docs
Developer ResourcesPromotions

Promotions API Reference

Complete API contracts for Promotions, including routes, auth, DTOs, responses, errors, examples, and integration notes.

Promotions - API Reference

Audience: Frontend engineers, mobile/panel engineers, backend engineers, QA. Scope: Admin routes at /api/promotions/* and two consumer-facing (portal) routes at /api/mobile/student/promotion-history and /api/mobile/guardian/children/:id/promotion-history.

1. Documentation Evidence

AreaFiles InspectedWhat Was Verified
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.tsRoutes, methods, guards, permissions, status codes
DTOscycles/dto/promotion-cycle.dto.ts, decisions/dto/promotion-decision.dto.ts, settings/dto/promotion-settings.dto.ts, shared/academic-history.dto.tsRequest/response fields, validators, defaults
Servicescycles/core/*.service.ts, decisions/core/*.service.ts, settings/core/promotion-settings.service.tsBehavior, side effects, error conditions
Error registryapps/api/src/common/types/error-codes.tsEvery PROMOTION_* code
Schemapackages/db/src/schema/school/promotions.tsEnums, constraints
Permissionspackages/db/src/authorization/permission-catalog.tsModule grants

2. Module Summary

FieldValue
Module namePromotions
Module slugpromotions
Primary actorsadmin/superadmin (or any role holding the relevant Promotions* code), student, guardian, internal worker
API surfacesadmin (/api/promotions/*), mobile/consumer (/api/mobile/student/promotion-history, /api/mobile/guardian/children/:id/promotion-history)
Base route prefixes/api/promotions/cycles, /api/promotions/settings, /api/promotions/students, /api/mobile/student, /api/mobile/guardian
Auth modelJwtAuthGuard (global) + RoleGuard; admin routes carry @Permissions("Module_ACTION"); the two portal routes carry no permission and are allowlisted, asserting their audience instead
PersistencePostgreSQL only (no cache, no Mongo document type of its own — audit entries land in the shared audit_logs collection)
Runtime source of truthpromotion_cycles, promotion_decisions, promotion_settings, promotion_grade_progressions tables
Sibling docsBackend, Features and flows

3. Concepts and Terminology

TermMeaningSource FileUsed By
Promotion cycleOne run of the promotion process for one academic session; draft → finalized or cancelledpromotions.tsEvery cycle route
Promotion decisionOne version of one pupil's outcome for one cycle; append-only, exactly one isCurrent per (cycle, pupil)promotions.tsDecisions routes
Outcomepromoted | retained | conditionally_promoted | graduated | pending | excludedpromotionOutcomeEnumEvery decision-bearing response
SourceWho/what produced a decision version: recommendation | manual | bulk | supplementary | correction | conditionpromotionDecisionSourceEnumDecisions responses, filters
Recommendation reasonWhy the algorithm could not resolve a definite outcome, or why it landed where it didpromotion-codes.ts (PROMOTION_RECOMMENDATION_REASONS)recommendationReason field
Basis examAn exam attached to a cycle as final or supplementarypromotion_cycle_examsCycle create/update, supplementary
Publication statenone | queued | sent | failed — whether the finalized cycle's family notifications have gone outpromotionPublicationStateEnumCycle summary, finalize/republish
Condition statuspending | satisfied | failed | waived — the state of a conditionally_promoted pupil's outstanding conditionpromotionConditionStatusEnumDecision responses, /condition
isOverriddenTrue when a decision's source is manual, bulk, or correction (a human touched it)promotion-decisions-read.service.tsDecisions list/detail
basisDriftCount of current auto decisions whose recomputation now differs from what is storedpromotion-cycles.service.tsCycle detail, visible only after finalize
Audience surfaceThe pupil/guardian self-service reads, carrying no @Permissions() — asserted by role, not permissionportal-audience.tsPortal routes

4. API Surface Map

SurfaceMethodPathActorAuth/GuardPermissionControllerPurpose
AdminGET/api/promotions/cyclesAdminJwtAuthGuard, RoleGuardPromotions_READPromotionCyclesControllerList cycles
AdminPOST/api/promotions/cyclesAdminsamePromotions_CREATEPromotionCyclesControllerCreate a draft cycle
AdminGET/api/promotions/cycles/:publicIdAdminsamePromotions_READPromotionCyclesControllerCycle detail, with counts and basisDrift
AdminPATCH/api/promotions/cycles/:publicIdAdminsamePromotions_UPDATEPromotionCyclesControllerRename / change basis exams (draft only)
AdminDELETE/api/promotions/cycles/:publicIdAdminsamePromotions_DELETEPromotionCyclesControllerCancel a draft cycle
AdminPOST/api/promotions/cycles/:publicId/recomputeAdminsamePromotions_UPDATEPromotionCyclesControllerRecompute draft recommendations
AdminPOST/api/promotions/cycles/:publicId/finalizeAdminsamePromotionFinalize_UPDATEPromotionCyclesControllerFinalize and publish
AdminPOST/api/promotions/cycles/:publicId/republishAdminsamePromotionFinalize_UPDATEPromotionCyclesControllerRetry a stuck/failed publication
AdminPOST/api/promotions/cycles/:publicId/supplementaryAdminsamePromotionCorrections_UPDATEPromotionCyclesControllerApply a supplementary exam post-finalize
AdminGET/api/promotions/cycles/:publicId/decisionsAdminsamePromotions_READPromotionDecisionsControllerList a cycle's current decisions
AdminGET/api/promotions/cycles/:publicId/students/:studentPublicId/historyAdminsamePromotions_READPromotionDecisionsControllerA pupil's full version history
AdminPUT/api/promotions/cycles/:publicId/students/:studentPublicId/decisionAdminsamePromotions_UPDATEPromotionDecisionsControllerManual override (draft only)
AdminPOST/api/promotions/cycles/:publicId/decisions/bulkAdminsamePromotions_UPDATEPromotionDecisionsControllerBulk decide (draft only)
AdminPOST/api/promotions/cycles/:publicId/decisions/resetAdminsamePromotions_UPDATEPromotionDecisionsControllerReset to computed (draft only)
AdminPOST/api/promotions/cycles/:publicId/students/:studentPublicId/correctAdminsamePromotionCorrections_UPDATEPromotionDecisionsControllerCorrect a finalized decision
AdminPOST/api/promotions/cycles/:publicId/students/:studentPublicId/conditionAdminsamePromotionCorrections_UPDATEPromotionDecisionsControllerResolve an outstanding condition
AdminGET/api/promotions/settingsAdminsamePromotionSettings_READPromotionSettingsControllerRead settings
AdminPATCH/api/promotions/settingsAdminsamePromotionSettings_UPDATEPromotionSettingsControllerUpdate settings
AdminGET/api/promotions/settings/progressionsAdminsamePromotionSettings_READPromotionSettingsControllerList per-grade progression overrides
AdminPUT/api/promotions/settings/progressions/:gradePublicIdAdminsamePromotionSettings_UPDATEPromotionSettingsControllerSet a grade's override
AdminDELETE/api/promotions/settings/progressions/:gradePublicIdAdminsamePromotionSettings_UPDATEPromotionSettingsControllerRemove a grade's override
AdminGET/api/promotions/students/:studentPublicId/academic-historyAdminsame + PeopleAccessService.assertCanAccessStudents_READAcademicHistoryControllerA pupil's academic history (admin view)
Mobile/portalGET/api/mobile/student/promotion-historyStudentJwtAuthGuard, RoleGuardNone — audience-asserted (assertPortalAudience(actor, "student"))StudentPromotionHistoryPortalControllerThe pupil's own history
Mobile/portalGET/api/mobile/guardian/children/:id/promotion-historyGuardianJwtAuthGuard, RoleGuardNone — audience-asserted (assertPortalAudience(actor, "guardian"))GuardianPromotionHistoryPortalControllerA guardian's child's history

5. Auth, Identity, and Permissions

SurfaceGuard/DecoratorIdentity ShapePermissionGuest AllowedNotes
Admin (cycles/decisions/settings/history)JwtAuthGuard, RoleGuard, @Permissions(code)req.user via @CurrentUser()As listed per route aboveNoPromotionFinalize, PromotionCorrections, PromotionSettings are withheld from the automatic admin role grant — a school must explicitly grant them to a role.
Student portalJwtAuthGuard, RoleGuard, allowlisted (no @Permissions())@CurrentAdmin() — RoleGuard returns early for an allowlisted handler, before its own activeRole refusalNoneNoassertPortalAudience requires activeRole.scopeKind === "student"; a live students row must exist for the caller.
Guardian portalSame@CurrentAdmin()NoneNoassertPortalAudience requires activeRole.scopeKind === "guardian"; ownership is proven by GuardianPortalService.resolveOwnChildId, never by a permission or a row-scope filter.

Every route requires a valid JWT (the guard is global; there is no public/guest route in this module). Nothing in this module trusts a client-supplied identity field — the acting userId always comes from the verified token.

6. DTO and Model Reference

6.1 CreatePromotionCycleDto

FieldTypeRequiredDefaultValidationExampleSource
academicSessionPublicIdstring (uuid)Yes—@IsUUID()"a1b2..."promotion-cycle.dto.ts
namestringYes—@IsString, 1–120 chars"2081 Final Promotion"same
finalExamPublicIdsstring[] (uuid)Yes—1–20 items, unique["..."]same
supplementaryExamPublicIdsstring[] (uuid)Noundefined0–20 items, unique["..."]same

6.2 UpdatePromotionCycleDto

FieldTypeRequiredDefaultValidationNotes
namestringNounchanged1–120 charsDraft only
finalExamPublicIdsstring[]Nounchanged1–20 items if providedReplaces the whole final-exam set
supplementaryExamPublicIdsstring[]Nounchanged0–20 items if providedReplaces the whole supplementary set
versionintYes—>= 0Optimistic concurrency token

6.3 RecomputeCycleDto / FinalizeCycleDto / CancelPromotionCycleDto / SupplementaryDto

DTOFieldTypeRequiredValidation
RecomputeCycleDtoversionintYes>= 0
FinalizeCycleDtoversionintYes>= 0
FinalizeCycleDtoacknowledgePendingbooleanYesMust be true if pending decisions exist, or finalize is refused
CancelPromotionCycleDtoreasonstringYes3–1000 chars
SupplementaryDtoexamPublicIdstring (uuid)Yes@IsUUID()

6.4 PromotionCycleSummaryDto / PromotionCycleDetailDto

FieldTypeNullableNotes
publicIdstringNo
namestringNo
academicSessionPublicId/academicSessionNamestringNo
status"draft"|"finalized"|"cancelled"No
publicationState"none"|"queued"|"sent"|"failed"No
versionintNo
finalizedAtDateYes
createdAt/updatedAtDateNo
basisExams (detail only){examPublicId, name, role, resultsPublished}[]No
countsByOutcome (detail only){outcome, count}[]NoCurrent decisions only
countsBySource (detail only){source, count}[]NoCurrent decisions only
pendingReasons (detail only){reason, count}[]Noreason: "UNKNOWN" if a pending row somehow has none
basisDrift (detail only)intNo0 unless status === "finalized"

6.5 RecomputeResultDto / SupplementaryResultDto

DTOFieldTypeMeaning
RecomputeResultDtoaddedintNew decisions written for pupils with none before
RecomputeResultDtochangedintCurrent auto decisions that drifted and were rewritten
RecomputeResultDtounchangedintManually-decided or already-matching pupils
SupplementaryResultDtoupgradedintNon-conditional decisions whose outcome improved
SupplementaryResultDtoconditionsSatisfiedintConditional promotions whose condition was cleared
SupplementaryResultDtoskippedManualstring[]Student public ids skipped because a human already decided them
SupplementaryResultDtounchangedintLeft-session or non-drifted pupils

6.6 Decision write DTOs (PutPromotionDecisionDto / BulkPromotionDecisionDto / CorrectPromotionDecisionDto / ConditionResolutionDto)

All extend the common outcome/targetGradePublicId/conditionText/conditionDeadline fields.

FieldTypeRequiredValidationNotes
outcomeenum (6 values)Yes@IsEnum
targetGradePublicIdstring (uuid)ConditionallyRequired (and must resolve to an active grade) for promoted/retained/conditionally_promoted; ignored otherwiseEnforced by resolveTargetGradeId, mirroring the DB CHECK
conditionTextstringConditionally3–500 chars, required iff outcome === "conditionally_promoted"resolveConditionFields
conditionDeadlinestring (ISO date)NoMust not be in the past
reasonstringSee per-DTO below3–1000 charsPutPromotionDecisionDto: optional at the class-validator layer but required by the service (PROMOTION_REASON_REQUIRED); BulkPromotionDecisionDto/CorrectPromotionDecisionDto: required
expectedDecisionPublicIdstring (uuid)PutPromotionDecisionDto: required. CorrectPromotionDecisionDto: optional, omit only when the pupil has no decision yet. ConditionResolutionDto: required@IsUUID()Optimistic concurrency
studentPublicIds (bulk/reset only)string[] (uuid)Yes1–500 items, unique
expectedDecisionPublicIds (bulk/reset only)string[] (uuid)Yes1–500 items, unique, same length as studentPublicIdsCurrent decision publicId the operator saw, one per pupil in studentPublicIds — optimistic concurrency for the bulk routes
conditionStatus (ConditionResolutionDto only)"satisfied"|"waived"|"failed"Yes@IsEnum

6.7 PromotionDecisionDto (response)

FieldTypeNullable
publicId, studentPublicId, studentName, studentIdCodestringNo
gradePublicId, gradeName, classPublicId, classNamestringNo
outcomeenumNo
targetGradePublicId, targetGradeNamestringYes
recommendedOutcomeenumYes
recommendationReasonstringYes
conditionStatus, conditionText, conditionDeadlinestringYes
sourceenumNo
reasonstringYes
isOverriddenbooleanNo
decidedAt, notifiedAtDateYes (notifiedAt) / No (decidedAt)

6.8 PromotionDecisionHistoryEntryDto (response)

Same core fields as PromotionDecisionDto minus studentName/studentIdCode/grade-class snapshot, plus isCurrent: boolean, decidedByName: string | null (null when the acting user's account is gone), and supersededAt: Date | null.

6.9 PromotionSettingsDto / UpdatePromotionSettingsDto

FieldTypeNullableNotes
graduatingGradePublicId, graduatingGradeNamestringYesValidateIf on the update DTO: explicit null clears it, an absent field leaves it untouched
allowConditionalPromotionbooleanNoDefault true
shareConditionInNotificationbooleanNoDefault false
notifyOnPublishbooleanNoDefault true
publishChannels("email"|"sms"|"push"|"in_app")[]No1–4 items, unique, must be a subset of the four channels
versionintNoRequired on update — optimistic concurrency

6.10 PromotionProgressionDto / PutPromotionProgressionDto

FieldTypeNullableNotes
gradePublicId, gradeNamestringNo
nextGradePublicId, nextGradeNamestringYesExactly one of this or graduates must be set
graduatesbooleanNo
updatedAtDateNo

6.11 AcademicHistoryEntryDto (shared by admin + both portals)

{
  "academicSession": { "publicId": "...", "name": "2081", "startDate": "2024-04-01", "endDate": "2025-03-31", "isCurrent": false },
  "placements": [
    { "classPublicId": "...", "gradeName": "Grade 8", "sectionName": "B", "shift": "morning", "rollNumber": 12, "status": "active", "enrolledOn": "2024-04-05", "endedOn": null }
  ],
  "outcome": {
    "outcome": "promoted", "targetGradeName": "Grade 9", "conditionStatus": null,
    "conditionText": null, "conditionDeadline": null, "decidedAt": "2025-03-20T10:00:00Z", "corrected": false
  },
  "graduation": null
}

outcome is null unless the session's cycle is finalized. graduation is null unless a live (non-revoked) student_graduations row exists for that session. Never includes reason, recommendationReason, basis, decidedByUserId, superseded versions, or an exclusion reason — this shape is read by the pupil and their guardian.

7. Enum Reference

EnumValueMeaningRuntime EffectSource
promotion_outcomepromotedAdvances to a target gradetargetGradeId requiredpromotions.ts
retainedStays at the source gradetargetGradeId required (set to the source grade)
conditionally_promotedAdvances, pending a conditiontargetGradeId, conditionText, conditionStatus all required
graduatedCompleted the top of the laddertargetGradeId must be null
pendingNo usable outcome yettargetGradeId null; recommendationReason explains why
excludedDeliberately outside this cycle (left mid-year, special arrangement)targetGradeId null; reason required
promotion_decision_sourcerecommendationComputed by the algorithm, draft phaseOnly source recompute freely rewrites
manualOne pupil manually decided, draft phase
bulkMany pupils decided together, draft phase
supplementaryComputed via a supplementary exam re-evaluation; valid in both draft and finalized cycles
correctionPost-finalize correctionFinalized cycles only
conditionPost-finalize condition resolutionFinalized cycles only
promotion_cycle_statusdraft | finalized | cancelledCycle lifecycleGates which decision sources may be written
promotion_publication_statenone | queued | sent | failedFamily-notification delivery stateOnly relevant once status = finalized
promotion_condition_statuspending | satisfied | failed | waivedState of a conditional promotion's conditionfailed rewrites the outcome to retained
PROMOTION_RECOMMENDATION_REASONSNO_BASIS_EXAMNo applicable final exam exists for the pupilpendingpromotion-codes.ts
RESULT_NOT_PUBLISHEDAn applicable final's results are unpublishedpending
RESULT_MISSINGNo result row exists for an applicable finalpending
RESULT_STALEThe result is marked stalepending
RESULT_WITHHELDResult status is withheldpending
RESULT_INCOMPLETEResult status is incompletepending
RESULT_ABSENTResult status is absentpending
SUPPLEMENTARY_PENDINGA FAIL/CONDITIONAL final verdict has an applicable supplementary that itself has a precondition failurepending
NEXT_GRADE_INACTIVEThe resolved next grade is not activepending
NEXT_GRADE_AMBIGUOUSTwo+ grades tie for the next sort orderpending
NO_NEXT_GRADENo grade above the source in sort orderpending
GRADUATING_GRADE_AMBIGUOUSTwo+ active grades tie at the top sort order, no explicit graduating grade setpending
FINAL_GRADE_CONDITIONALA CONDITIONAL verdict at the graduating grade (no next grade to be conditional toward)pending
CONDITIONAL_NOT_ALLOWEDCONDITIONAL verdict but allowConditionalPromotion is offpending
SOURCE_GRADE_INACTIVEA FAIL verdict, but the source grade itself is inactivepending
LEFT_SESSIONPupil has no active enrollment in the source sessionexcluded — the one short-circuit reason not paired with pending

8. Endpoint Reference

8.1 POST /api/promotions/cycles

Purpose

Creates a new draft promotion cycle for an academic session, linking its final (and optional supplementary) basis exams. Called once per session, typically after the term's final exams have been scheduled.

Source Evidence

EvidencePath
Controllercycles/admin/promotion-cycles.controller.ts
DTOcycles/dto/promotion-cycle.dto.ts (CreatePromotionCycleDto)
Servicecycles/core/promotion-cycles.service.ts#create
Schemapackages/db/src/schema/school/promotions.ts

Auth and Permissions

  • Auth: JWT
  • Guard chain: JwtAuthGuard, RoleGuard
  • Permission: Promotions_CREATE
  • Guest support: No
  • Idempotency: None — a second identical call creates a second cycle, unless refused by promotion_cycles_one_live_per_session.

Request

{
  "academicSessionPublicId": "b6c1...",
  "name": "2081 Final Promotion",
  "finalExamPublicIds": ["e1..."],
  "supplementaryExamPublicIds": []
}

Response

201:

{
  "success": true,
  "message": "Promotion cycle created.",
  "data": {
    "publicId": "...", "name": "2081 Final Promotion",
    "academicSessionPublicId": "...", "academicSessionName": "2081",
    "status": "draft", "publicationState": "none", "version": 0,
    "finalizedAt": null, "createdAt": "...", "updatedAt": "..."
  }
}

Side Effects

  • DB writes: promotion_cycles insert, promotion_cycle_exams inserts.
  • No cache, no job, no notification.

Error Cases

HTTP StatusError CodeCondition
404ACADEMIC_SESSION_NOT_FOUNDSession id does not resolve
400PROMOTION_EXAM_INVALIDAn exam is missing, cancelled, in both lists, or outside the session's rules
409PROMOTION_CYCLE_EXISTSA live cycle already exists for the session

Edge Cases

  • Empty finalExamPublicIds — refused by @ArrayMinSize(1) before the service runs.
  • Duplicate ids in either list — refused by @ArrayUnique().
  • Same exam in both lists — 400 PROMOTION_EXAM_INVALID.

8.2 GET /api/promotions/cycles

List cycles, filterable by status and academicSessionPublicId, standard QueryDto pagination. Ordered newest-first (createdAt desc, id asc as a tiebreaker).

8.3 GET /api/promotions/cycles/:publicId

Cycle detail: adds basisExams, countsByOutcome, countsBySource, pendingReasons (all over current decisions), and basisDrift (0 unless status === "finalized", in which case it is a fresh, on-the-fly recompute-and-diff count).

8.4 PATCH /api/promotions/cycles/:publicId

Draft only. Optional name and/or exam-id list replacement, version required. 409 PROMOTION_CYCLE_NOT_DRAFT / PROMOTION_CYCLE_STALE on failure.

8.5 DELETE /api/promotions/cycles/:publicId

Draft only. Body: { "reason": "..." } (3–1000 chars). Cancels the cycle. 409 PROMOTION_CYCLE_IN_USE if a non-cancelled rollover cycle references it.

8.6 POST /api/promotions/cycles/:publicId/recompute

Purpose

Recomputes every roster member's recommendation against current exam results and writes only what changed. Call this whenever exam results are published or corrected, before finalizing.

Auth and Permissions

  • Permission: Promotions_UPDATE. Draft only.

Request

{ "version": 3 }

Response

{ "success": true, "message": "Promotion cycle recomputed.", "data": { "added": 2, "changed": 5, "unchanged": 118 } }

Error Cases

404 PROMOTION_CYCLE_NOT_FOUND, 409 PROMOTION_CYCLE_NOT_DRAFT, 409 PROMOTION_CYCLE_STALE (version mismatch).

8.7 POST /api/promotions/cycles/:publicId/finalize

Purpose

Locks in every current decision, moves the cycle to finalized, and (unless settings disable it) enqueues family notifications. This is the point past which draft-phase writes (manual, bulk, reset) are no longer possible for this cycle.

Auth and Permissions

  • Permission: PromotionFinalize_UPDATE (withheld from the automatic admin grant — must be explicitly assigned).

Request

{ "version": 4, "acknowledgePending": false }

Response

200 with the updated PromotionCycleSummaryDto (status: "finalized", publicationState: "queued" if notifications are on).

Side Effects

  • DB: promotion_cycles status flip; outbox_events row (if notifyOnPublish).
  • Jobs: enqueues academic_year.publish_promotion_decisions.
  • No notification is sent synchronously — delivery happens in the worker.

Error Cases

HTTP StatusError CodeCondition
409PROMOTION_CYCLE_NOT_DRAFTAlready finalized/cancelled
409PROMOTION_CYCLE_STALEVersion mismatch, missing decisions, or recommendation drift
409PROMOTION_EXAM_NOT_PUBLISHEDA final basis exam's results are not published
409PROMOTION_PENDING_UNACKNOWLEDGEDPending decisions exist and acknowledgePending is false

Edge Cases

  • A pupil left the session between recompute and finalize — still counted correctly because finalize reuses the same computeCycleRecommendations call.
  • acknowledgePending: true with zero pending decisions is a no-op flag — finalize proceeds regardless.

8.8 POST /api/promotions/cycles/:publicId/republish

Only from publication_state of none or failed. Re-enqueues with a fresh publication_attempt. 409 PROMOTION_CYCLE_NOT_FINALIZED / PROMOTION_PUBLICATION_INVALID_STATE otherwise. Permission: PromotionFinalize_UPDATE.

8.9 POST /api/promotions/cycles/:publicId/supplementary

Purpose

Attaches a supplementary exam to a finalized cycle and re-evaluates every eligible current decision against it — used after a pupil sits a make-up exam.

Auth and Permissions

  • Permission: PromotionCorrections_UPDATE (withheld from automatic admin grant).

Request

{ "examPublicId": "f9..." }

Response

{
  "success": true, "message": "Supplementary results applied.",
  "data": { "upgraded": 3, "conditionsSatisfied": 2, "skippedManual": ["stu_..."], "unchanged": 40 }
}

Error Cases

404 PROMOTION_CYCLE_NOT_FOUND, 409 PROMOTION_CYCLE_NOT_FINALIZED, 400 PROMOTION_EXAM_INVALID (exam missing, cancelled, or session ordering invalid — must be in the cycle's own session or a session that starts after it).

Side Effects

Writes promotion_cycle_exams (if not already linked) and new promotion_decisions versions; sends promotion.decision_updated per changed pupil, inline, subject to notifyOnPublish.

8.10 GET /api/promotions/cycles/:publicId/decisions

List current decisions. Filters: outcome, recommendedOutcome, source, gradePublicId, classPublicId, reason (a recommendation-reason code), overridden (boolean), search (name/student-id). pagination=false is refused with 400 PAGINATION_LIMIT_INVALID — a decisions roster is never returned unpaginated.

8.11 GET /api/promotions/cycles/:publicId/students/:studentPublicId/history

Every version (current and superseded) for one pupil in one cycle, newest first.

8.12 PUT /api/promotions/cycles/:publicId/students/:studentPublicId/decision

Purpose

Manually overrides one pupil's draft-phase decision — used for exam-board edge cases the algorithm cannot resolve, or a deliberate policy override.

Auth and Permissions

  • Permission: Promotions_UPDATE. Draft only.

Request

{
  "outcome": "promoted",
  "targetGradePublicId": "g9...",
  "reason": "Board override — exceptional circumstances",
  "expectedDecisionPublicId": "pd_..."
}

Response

200 with the full PromotionDecisionDto.

Error Cases

404 PROMOTION_CYCLE_NOT_FOUND, 409 PROMOTION_CYCLE_NOT_DRAFT, 409 PROMOTION_DECISION_STALE, 400 PROMOTION_TARGET_GRADE_INVALID, 400 PROMOTION_CONDITION_REQUIRED, 400 PROMOTION_REASON_REQUIRED.

Side Effects

Never notifies — draft-phase writes are silent.

8.13 POST /api/promotions/cycles/:publicId/decisions/bulk

Same field set as 8.12 plus studentPublicIds (1–500, unique) and a required reason. All pupils get the same outcome/target/condition. The body also carries expectedDecisionPublicIds — the current decision publicId the operator saw for each pupil in studentPublicIds, 1–500 unique uuids, the same length as studentPublicIds. If any selected pupil's current decision publicId is not present in that array — including a length mismatch — the whole request is refused with 409 PROMOTION_DECISION_STALE and nothing is written. Refuses (400 PROMOTION_STUDENT_NOT_IN_CYCLE) if any listed pupil has no current decision — all-or-nothing.

8.14 POST /api/promotions/cycles/:publicId/decisions/reset

Body: { "studentPublicIds": [...], "expectedDecisionPublicIds": [...] }, both 1–500 unique uuids of the same length, expectedDecisionPublicIds carrying the current decision publicId the operator saw for each pupil. A mismatch against any pupil's current decision refuses the whole request with 409 PROMOTION_DECISION_STALE and nothing is written. Otherwise recomputes and overwrites exactly those pupils back to recommendation/supplementary, discarding any manual/bulk override; a pupil who has since left the academic session is reset with the shared LEFT_SESSION_REASON, the same reason ordinary recompute uses. 400 PROMOTION_STUDENT_NOT_IN_CYCLE for a pupil with no current decision.

8.15 POST /api/promotions/cycles/:publicId/students/:studentPublicId/correct

Purpose

Corrects a pupil's decision after the cycle has already been finalized and (possibly) published — used when an error is discovered post-publication.

Auth and Permissions

  • Permission: PromotionCorrections_UPDATE. Finalized only.

Request

{
  "outcome": "retained",
  "reason": "Result recheck changed the outcome",
  "expectedDecisionPublicId": "pd_..."
}

expectedDecisionPublicId may be omitted only when the pupil truly has no decision yet — in that case the service requires an active enrollment in the cycle's session to snapshot source class/grade from.

Response

200 with the updated PromotionDecisionDto.

Error Cases

409 PROMOTION_CYCLE_NOT_FINALIZED, 409 PROMOTION_DECISION_STALE, 400 PROMOTION_STUDENT_NOT_IN_CYCLE (no active enrollment for a first-ever write), 400 PROMOTION_TARGET_GRADE_INVALID, 400 PROMOTION_CONDITION_REQUIRED, 400 PROMOTION_REASON_REQUIRED.

Side Effects

Sends promotion.decision_updated inline (subject to notifyOnPublish).

8.16 POST /api/promotions/cycles/:publicId/students/:studentPublicId/condition

Purpose

Resolves a pupil's outstanding condition on a conditionally_promoted decision.

Auth and Permissions

  • Permission: PromotionCorrections_UPDATE. Finalized only, current decision must already be conditionally_promoted.

Request

{ "conditionStatus": "satisfied", "reason": "Cleared the outstanding subject on recheck", "expectedDecisionPublicId": "pd_..." }

Response

200 with the updated PromotionDecisionDto. conditionStatus: "failed" rewrites outcome to retained at the pupil's own source grade; "satisfied"/"waived" leaves outcome and target grade untouched.

Error Cases

409 PROMOTION_CYCLE_NOT_FINALIZED, 409 PROMOTION_DECISION_NOT_CONDITIONAL, 409 PROMOTION_DECISION_STALE, 400 PROMOTION_REASON_REQUIRED.

8.17–8.18 GET / PATCH /api/promotions/settings

Singleton settings row. PATCH requires version; an explicit graduatingGradePublicId: null clears the field, an absent field leaves it untouched. 404 PROMOTION_SETTINGS_STALE if the row is somehow missing (should not happen — seeded by migration 0036); 409 PROMOTION_SETTINGS_STALE on a version mismatch.

8.19–8.21 Progression overrides

GET /api/promotions/settings/progressions lists every override. PUT /api/promotions/settings/progressions/:gradePublicId sets exactly one of graduates: true or a nextGradePublicId (never both, never neither — 400 PROMOTION_PROGRESSION_INVALID; also refused if nextGradePublicId equals the grade itself). DELETE removes the override, reverting to the default "next grade by sort order" rule.

8.22 GET /api/promotions/students/:studentPublicId/academic-history

Admin read of the shared academic-history shape (§6.11). Requires Students_READ and passes PeopleAccessService.assertCanAccess — the same object-level check every other student read uses. studentPublicId is validated by ParseUUIDPipe (400 on a malformed id), and because assertCanAccess returns early for a caller with scope: "all" without resolving the row, the controller separately re-reads the pupil and returns 404 for an unknown or soft-deleted studentPublicId — otherwise a superadmin caller would get a 200 with an empty array.

8.23 GET /api/mobile/student/promotion-history

Purpose

Lets a logged-in pupil view their own promotion and academic history.

Auth and Permissions

  • Auth: JWT. No permission code.
  • Guard chain: JwtAuthGuard, RoleGuard (allowlisted in NO_PERMISSION_ADMIN_HANDLERS).
  • Audience: assertPortalAudience(actor, "student").

Response

200 with AcademicHistoryEntryDto[] (§6.11 shape).

Error Cases

403 (wrong audience — before any row is read), 404 STUDENT_NOT_FOUND (no live students row for the caller).

Edge Cases

Empty array for a brand-new pupil with no enrollment, decision, or graduation yet.

8.24 GET /api/mobile/guardian/children/:id/promotion-history

Purpose

Lets a logged-in guardian view one specific child's promotion and academic history.

Auth and Permissions

  • Auth: JWT. No permission code.
  • Audience: assertPortalAudience(actor, "guardian").
  • Ownership: GuardianPortalService.resolveOwnChildId(actor, childId) — the one implementation of "is this pupil this guardian's ward" in the codebase.

Response

200 with AcademicHistoryEntryDto[].

Error Cases

403 (wrong audience), 404 (child id does not resolve to this guardian's own ward — never 403 for someone else's child, to avoid an id-space enumeration oracle).

9. Flow Diagrams

9.1 Route Ownership

9.2 Request Sequence (finalize)

9.3 Error Branch

EndpointPagination TypeDefault SizeMax SizeSort FieldsFiltersResult Cap
GET /api/promotions/cyclespage/size (QueryDto)20100createdAt desc (fixed)status, academicSessionPublicId—
GET /api/promotions/cycles/:publicId/decisionspage/size, cannot be disabled20100users.fullName asc, id asc (fixed)outcome, recommendedOutcome, source, gradePublicId, classPublicId, reason, overridden, searchRefuses pagination=false with PAGINATION_LIMIT_INVALID

GET .../history, GET .../academic-history, and both portal reads return unpaginated arrays — they are bounded by a pupil's own version/session count, not by roster size.

11. Caching, Jobs, and External Integrations

IntegrationUsed?DetailsSource
Redis cacheNoThis module reads directly from PostgreSQL on every request—
BullMQYesQueueName.ACADEMIC_YEAR / AcademicYearJob.PUBLISH_PROMOTION_DECISIONS, enqueued only via the outbox from finalize, republish, and the five-minute recovery sweeppromotion-publication.service.ts
External APINo——

13.1 Route-by-Route Completeness Matrix

RouteController MethodDTOsService MethodGuardsPermissionsJobsDB TouchesErrorsTests
POST /promotions/cyclescreateCreatePromotionCycleDtoPromotionCyclesService.createJWT, RolePromotions_CREATE—promotion_cycles, promotion_cycle_examsACADEMIC_SESSION_NOT_FOUND, PROMOTION_EXAM_INVALID, PROMOTION_CYCLE_EXISTSpromotion-cycles.integration.spec.ts
GET /promotions/cyclesfindAllListPromotionCyclesQueryDtoPromotionCyclesService.findAllJWT, RolePromotions_READ—promotion_cycles—same
GET /promotions/cycles/:publicIdfindOne—PromotionCyclesService.findOneJWT, RolePromotions_READ—promotion_cycles, promotion_decisionsPROMOTION_CYCLE_NOT_FOUNDsame
PATCH /promotions/cycles/:publicIdupdateUpdatePromotionCycleDtoPromotionCyclesService.updateJWT, RolePromotions_UPDATE—promotion_cycles, promotion_cycle_examsPROMOTION_CYCLE_NOT_FOUND, _NOT_DRAFT, _STALEsame
DELETE /promotions/cycles/:publicIdcancelCancelPromotionCycleDtoPromotionCyclesService.cancelJWT, RolePromotions_DELETE—promotion_cyclesPROMOTION_CYCLE_NOT_FOUND, _NOT_DRAFT, _IN_USEsame
POST /promotions/cycles/:publicId/recomputerecomputeCycleRecomputeCycleDtoPromotionCycleRecomputeService.recomputeJWT, RolePromotions_UPDATE—promotion_decisions_NOT_FOUND, _NOT_DRAFT, _STALEsame
POST /promotions/cycles/:publicId/finalizefinalizeCycleFinalizeCycleDtoPromotionCycleFinalizeService.finalizeJWT, RolePromotionFinalize_UPDATEEnqueues publishpromotion_cycles, outbox_events_NOT_DRAFT, _STALE, PROMOTION_EXAM_NOT_PUBLISHED, PROMOTION_PENDING_UNACKNOWLEDGEDpromotion-publication.integration.spec.ts
POST /promotions/cycles/:publicId/republishrepublishCycle—PromotionCycleFinalizeService.republishJWT, RolePromotionFinalize_UPDATEEnqueues publishpromotion_cycles, outbox_events_NOT_FINALIZED, PROMOTION_PUBLICATION_INVALID_STATEsame
POST /promotions/cycles/:publicId/supplementaryapplySupplementarySupplementaryDtoPromotionCycleSupplementaryService.applyJWT, RolePromotionCorrections_UPDATE—promotion_cycle_exams, promotion_decisions_NOT_FINALIZED, PROMOTION_EXAM_INVALIDpromotion-cycle-supplementary.integration.spec.ts
GET /promotions/cycles/:publicId/decisionsfindAllListPromotionDecisionsQueryDtoPromotionDecisionsReadService.findDecisionsJWT, RolePromotions_READ—promotion_decisionsPAGINATION_LIMIT_INVALIDpromotion-decisions.integration.spec.ts
GET /.../students/:studentPublicId/historyhistory—PromotionDecisionsReadService.findHistoryJWT, RolePromotions_READ—promotion_decisions—same
PUT /.../students/:studentPublicId/decisionputDecisionPutPromotionDecisionDtoPromotionDecisionManualWriteService.putDecisionJWT, RolePromotions_UPDATE—promotion_decisions_NOT_DRAFT, _STALE, _TARGET_GRADE_INVALID, _CONDITION_REQUIRED, _REASON_REQUIREDsame
POST /.../decisions/bulkbulkDecideBulkPromotionDecisionDtoPromotionDecisionManualWriteService.bulkDecideJWT, RolePromotions_UPDATE—promotion_decisions_STUDENT_NOT_IN_CYCLE, others as abovesame
POST /.../decisions/resetresetResetPromotionDecisionsDtoPromotionDecisionManualWriteService.resetJWT, RolePromotions_UPDATE—promotion_decisions_STUDENT_NOT_IN_CYCLEsame
POST /.../students/:studentPublicId/correctcorrectCorrectPromotionDecisionDtoPromotionDecisionCorrectionsService.correctJWT, RolePromotionCorrections_UPDATEInline notificationpromotion_decisions_NOT_FINALIZED, _STALE, _STUDENT_NOT_IN_CYCLEsame
POST /.../students/:studentPublicId/conditionresolveConditionConditionResolutionDtoPromotionDecisionCorrectionsService.resolveConditionJWT, RolePromotionCorrections_UPDATEInline notificationpromotion_decisions_NOT_FINALIZED, _NOT_CONDITIONAL, _STALEsame
GET /promotions/settingsgetSettings—PromotionSettingsService.getSettingsJWT, RolePromotionSettings_READ—promotion_settings_SETTINGS_STALEpromotion-settings.integration.spec.ts
PATCH /promotions/settingsupdateSettingsUpdatePromotionSettingsDtoPromotionSettingsService.updateSettingsJWT, RolePromotionSettings_UPDATE—promotion_settings_SETTINGS_STALE, GRADE_NOT_FOUNDsame
GET /promotions/settings/progressionslistProgressions—PromotionSettingsService.listProgressionsJWT, RolePromotionSettings_READ—promotion_grade_progressions—same
PUT /promotions/settings/progressions/:gradePublicIdputProgressionPutPromotionProgressionDtoPromotionSettingsService.putProgressionJWT, RolePromotionSettings_UPDATE—promotion_grade_progressionsGRADE_NOT_FOUND, PROMOTION_PROGRESSION_INVALIDsame
DELETE /promotions/settings/progressions/:gradePublicIddeleteProgression—PromotionSettingsService.deleteProgressionJWT, RolePromotionSettings_UPDATE—promotion_grade_progressionsGRADE_NOT_FOUNDsame
GET /promotions/students/:studentPublicId/academic-historygetAcademicHistory—AcademicHistoryReaderService.readJWT, Role, assertCanAccessStudents_READ—Enrollments, decisions, graduations——
GET /mobile/student/promotion-historygetOwnHistory—AcademicHistoryReaderService.readJWT, Role (allowlisted)None (audience)—SameSTUDENT_NOT_FOUNDpromotion-portal.integration.spec.ts
GET /mobile/guardian/children/:id/promotion-historygetChildHistory—AcademicHistoryReaderService.readJWT, Role (allowlisted)None (audience)—Same404 for non-owned childsame

14. Zero-Omission API Checklist

  • Every controller route is documented.
  • Every parent route prefix and runtime URL is documented.
  • Every DTO field, enum, default, and validator is documented.
  • Every response field and nullable field is documented.
  • Every auth, guard, permission, and audience branch is documented.
  • Every success, validation, permission, not-found, and conflict branch is documented.
  • Every database write, queue job, and notification is documented.
  • Route/sequence/error diagrams included.
  • Tradeoffs (versioned decisions, audience-not-permission portal reads) are documented in the sibling docs.
  • Links to backend and features/flows docs included.

15. Integration Checklist

  • Every route from controllers is documented.
  • Every DTO field is documented.
  • Every enum value is documented.
  • Every response envelope is documented.
  • Every error code is documented.
  • Every auth guard and permission is documented.
  • Every queue job is documented.
  • Diagrams match the current code.
  • Links to backend and features/flows docs included.

See Also

On this page

Promotions - API Reference1. Documentation Evidence2. Module Summary3. Concepts and Terminology4. API Surface Map5. Auth, Identity, and Permissions6. DTO and Model Reference6.1 CreatePromotionCycleDto6.2 UpdatePromotionCycleDto6.3 RecomputeCycleDto / FinalizeCycleDto / CancelPromotionCycleDto / SupplementaryDto6.4 PromotionCycleSummaryDto / PromotionCycleDetailDto6.5 RecomputeResultDto / SupplementaryResultDto6.6 Decision write DTOs (PutPromotionDecisionDto / BulkPromotionDecisionDto / CorrectPromotionDecisionDto / ConditionResolutionDto)6.7 PromotionDecisionDto (response)6.8 PromotionDecisionHistoryEntryDto (response)6.9 PromotionSettingsDto / UpdatePromotionSettingsDto6.10 PromotionProgressionDto / PutPromotionProgressionDto6.11 AcademicHistoryEntryDto (shared by admin + both portals)7. Enum Reference8. Endpoint Reference8.1 POST /api/promotions/cyclesPurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesEdge Cases8.2 GET /api/promotions/cycles8.3 GET /api/promotions/cycles/:publicId8.4 PATCH /api/promotions/cycles/:publicId8.5 DELETE /api/promotions/cycles/:publicId8.6 POST /api/promotions/cycles/:publicId/recomputePurposeAuth and PermissionsRequestResponseError Cases8.7 POST /api/promotions/cycles/:publicId/finalizePurposeAuth and PermissionsRequestResponseSide EffectsError CasesEdge Cases8.8 POST /api/promotions/cycles/:publicId/republish8.9 POST /api/promotions/cycles/:publicId/supplementaryPurposeAuth and PermissionsRequestResponseError CasesSide Effects8.10 GET /api/promotions/cycles/:publicId/decisions8.11 GET /api/promotions/cycles/:publicId/students/:studentPublicId/history8.12 PUT /api/promotions/cycles/:publicId/students/:studentPublicId/decisionPurposeAuth and PermissionsRequestResponseError CasesSide Effects8.13 POST /api/promotions/cycles/:publicId/decisions/bulk8.14 POST /api/promotions/cycles/:publicId/decisions/reset8.15 POST /api/promotions/cycles/:publicId/students/:studentPublicId/correctPurposeAuth and PermissionsRequestResponseError CasesSide Effects8.16 POST /api/promotions/cycles/:publicId/students/:studentPublicId/conditionPurposeAuth and PermissionsRequestResponseError Cases8.17–8.18 GET / PATCH /api/promotions/settings8.19–8.21 Progression overrides8.22 GET /api/promotions/students/:studentPublicId/academic-history8.23 GET /api/mobile/student/promotion-historyPurposeAuth and PermissionsResponseError CasesEdge Cases8.24 GET /api/mobile/guardian/children/:id/promotion-historyPurposeAuth and PermissionsResponseError Cases9. Flow Diagrams9.1 Route Ownership9.2 Request Sequence (finalize)9.3 Error Branch10. Pagination, Sorting, Filtering, and Search11. Caching, Jobs, and External Integrations13.1 Route-by-Route Completeness Matrix14. Zero-Omission API Checklist15. Integration ChecklistSee Also