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
| Area | Files Inspected | What Was Verified |
|---|---|---|
| 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 | Routes, methods, guards, permissions, status codes |
| DTOs | cycles/dto/promotion-cycle.dto.ts, decisions/dto/promotion-decision.dto.ts, settings/dto/promotion-settings.dto.ts, shared/academic-history.dto.ts | Request/response fields, validators, defaults |
| Services | cycles/core/*.service.ts, decisions/core/*.service.ts, settings/core/promotion-settings.service.ts | Behavior, side effects, error conditions |
| Error registry | apps/api/src/common/types/error-codes.ts | Every PROMOTION_* code |
| Schema | packages/db/src/schema/school/promotions.ts | Enums, constraints |
| Permissions | packages/db/src/authorization/permission-catalog.ts | Module grants |
2. Module Summary
| Field | Value |
|---|---|
| Module name | Promotions |
| Module slug | promotions |
| Primary actors | admin/superadmin (or any role holding the relevant Promotions* code), student, guardian, internal worker |
| API surfaces | admin (/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 model | JwtAuthGuard (global) + RoleGuard; admin routes carry @Permissions("Module_ACTION"); the two portal routes carry no permission and are allowlisted, asserting their audience instead |
| Persistence | PostgreSQL only (no cache, no Mongo document type of its own — audit entries land in the shared audit_logs collection) |
| Runtime source of truth | promotion_cycles, promotion_decisions, promotion_settings, promotion_grade_progressions tables |
| Sibling docs | Backend, Features and flows |
3. Concepts and Terminology
| Term | Meaning | Source File | Used By |
|---|---|---|---|
| Promotion cycle | One run of the promotion process for one academic session; draft → finalized or cancelled | promotions.ts | Every cycle route |
| Promotion decision | One version of one pupil's outcome for one cycle; append-only, exactly one isCurrent per (cycle, pupil) | promotions.ts | Decisions routes |
| Outcome | promoted | retained | conditionally_promoted | graduated | pending | excluded | promotionOutcomeEnum | Every decision-bearing response |
| Source | Who/what produced a decision version: recommendation | manual | bulk | supplementary | correction | condition | promotionDecisionSourceEnum | Decisions responses, filters |
| Recommendation reason | Why the algorithm could not resolve a definite outcome, or why it landed where it did | promotion-codes.ts (PROMOTION_RECOMMENDATION_REASONS) | recommendationReason field |
| Basis exam | An exam attached to a cycle as final or supplementary | promotion_cycle_exams | Cycle create/update, supplementary |
| Publication state | none | queued | sent | failed — whether the finalized cycle's family notifications have gone out | promotionPublicationStateEnum | Cycle summary, finalize/republish |
| Condition status | pending | satisfied | failed | waived — the state of a conditionally_promoted pupil's outstanding condition | promotionConditionStatusEnum | Decision responses, /condition |
isOverridden | True when a decision's source is manual, bulk, or correction (a human touched it) | promotion-decisions-read.service.ts | Decisions list/detail |
basisDrift | Count of current auto decisions whose recomputation now differs from what is stored | promotion-cycles.service.ts | Cycle detail, visible only after finalize |
| Audience surface | The pupil/guardian self-service reads, carrying no @Permissions() — asserted by role, not permission | portal-audience.ts | Portal routes |
4. API Surface Map
| Surface | Method | Path | Actor | Auth/Guard | Permission | Controller | Purpose |
|---|---|---|---|---|---|---|---|
| Admin | GET | /api/promotions/cycles | Admin | JwtAuthGuard, RoleGuard | Promotions_READ | PromotionCyclesController | List cycles |
| Admin | POST | /api/promotions/cycles | Admin | same | Promotions_CREATE | PromotionCyclesController | Create a draft cycle |
| Admin | GET | /api/promotions/cycles/:publicId | Admin | same | Promotions_READ | PromotionCyclesController | Cycle detail, with counts and basisDrift |
| Admin | PATCH | /api/promotions/cycles/:publicId | Admin | same | Promotions_UPDATE | PromotionCyclesController | Rename / change basis exams (draft only) |
| Admin | DELETE | /api/promotions/cycles/:publicId | Admin | same | Promotions_DELETE | PromotionCyclesController | Cancel a draft cycle |
| Admin | POST | /api/promotions/cycles/:publicId/recompute | Admin | same | Promotions_UPDATE | PromotionCyclesController | Recompute draft recommendations |
| Admin | POST | /api/promotions/cycles/:publicId/finalize | Admin | same | PromotionFinalize_UPDATE | PromotionCyclesController | Finalize and publish |
| Admin | POST | /api/promotions/cycles/:publicId/republish | Admin | same | PromotionFinalize_UPDATE | PromotionCyclesController | Retry a stuck/failed publication |
| Admin | POST | /api/promotions/cycles/:publicId/supplementary | Admin | same | PromotionCorrections_UPDATE | PromotionCyclesController | Apply a supplementary exam post-finalize |
| Admin | GET | /api/promotions/cycles/:publicId/decisions | Admin | same | Promotions_READ | PromotionDecisionsController | List a cycle's current decisions |
| Admin | GET | /api/promotions/cycles/:publicId/students/:studentPublicId/history | Admin | same | Promotions_READ | PromotionDecisionsController | A pupil's full version history |
| Admin | PUT | /api/promotions/cycles/:publicId/students/:studentPublicId/decision | Admin | same | Promotions_UPDATE | PromotionDecisionsController | Manual override (draft only) |
| Admin | POST | /api/promotions/cycles/:publicId/decisions/bulk | Admin | same | Promotions_UPDATE | PromotionDecisionsController | Bulk decide (draft only) |
| Admin | POST | /api/promotions/cycles/:publicId/decisions/reset | Admin | same | Promotions_UPDATE | PromotionDecisionsController | Reset to computed (draft only) |
| Admin | POST | /api/promotions/cycles/:publicId/students/:studentPublicId/correct | Admin | same | PromotionCorrections_UPDATE | PromotionDecisionsController | Correct a finalized decision |
| Admin | POST | /api/promotions/cycles/:publicId/students/:studentPublicId/condition | Admin | same | PromotionCorrections_UPDATE | PromotionDecisionsController | Resolve an outstanding condition |
| Admin | GET | /api/promotions/settings | Admin | same | PromotionSettings_READ | PromotionSettingsController | Read settings |
| Admin | PATCH | /api/promotions/settings | Admin | same | PromotionSettings_UPDATE | PromotionSettingsController | Update settings |
| Admin | GET | /api/promotions/settings/progressions | Admin | same | PromotionSettings_READ | PromotionSettingsController | List per-grade progression overrides |
| Admin | PUT | /api/promotions/settings/progressions/:gradePublicId | Admin | same | PromotionSettings_UPDATE | PromotionSettingsController | Set a grade's override |
| Admin | DELETE | /api/promotions/settings/progressions/:gradePublicId | Admin | same | PromotionSettings_UPDATE | PromotionSettingsController | Remove a grade's override |
| Admin | GET | /api/promotions/students/:studentPublicId/academic-history | Admin | same + PeopleAccessService.assertCanAccess | Students_READ | AcademicHistoryController | A pupil's academic history (admin view) |
| Mobile/portal | GET | /api/mobile/student/promotion-history | Student | JwtAuthGuard, RoleGuard | None — audience-asserted (assertPortalAudience(actor, "student")) | StudentPromotionHistoryPortalController | The pupil's own history |
| Mobile/portal | GET | /api/mobile/guardian/children/:id/promotion-history | Guardian | JwtAuthGuard, RoleGuard | None — audience-asserted (assertPortalAudience(actor, "guardian")) | GuardianPromotionHistoryPortalController | A guardian's child's history |
5. Auth, Identity, and Permissions
| Surface | Guard/Decorator | Identity Shape | Permission | Guest Allowed | Notes |
|---|---|---|---|---|---|
| Admin (cycles/decisions/settings/history) | JwtAuthGuard, RoleGuard, @Permissions(code) | req.user via @CurrentUser() | As listed per route above | No | PromotionFinalize, PromotionCorrections, PromotionSettings are withheld from the automatic admin role grant — a school must explicitly grant them to a role. |
| Student portal | JwtAuthGuard, RoleGuard, allowlisted (no @Permissions()) | @CurrentAdmin() — RoleGuard returns early for an allowlisted handler, before its own activeRole refusal | None | No | assertPortalAudience requires activeRole.scopeKind === "student"; a live students row must exist for the caller. |
| Guardian portal | Same | @CurrentAdmin() | None | No | assertPortalAudience 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
| Field | Type | Required | Default | Validation | Example | Source |
|---|---|---|---|---|---|---|
academicSessionPublicId | string (uuid) | Yes | — | @IsUUID() | "a1b2..." | promotion-cycle.dto.ts |
name | string | Yes | — | @IsString, 1–120 chars | "2081 Final Promotion" | same |
finalExamPublicIds | string[] (uuid) | Yes | — | 1–20 items, unique | ["..."] | same |
supplementaryExamPublicIds | string[] (uuid) | No | undefined | 0–20 items, unique | ["..."] | same |
6.2 UpdatePromotionCycleDto
| Field | Type | Required | Default | Validation | Notes |
|---|---|---|---|---|---|
name | string | No | unchanged | 1–120 chars | Draft only |
finalExamPublicIds | string[] | No | unchanged | 1–20 items if provided | Replaces the whole final-exam set |
supplementaryExamPublicIds | string[] | No | unchanged | 0–20 items if provided | Replaces the whole supplementary set |
version | int | Yes | — | >= 0 | Optimistic concurrency token |
6.3 RecomputeCycleDto / FinalizeCycleDto / CancelPromotionCycleDto / SupplementaryDto
| DTO | Field | Type | Required | Validation |
|---|---|---|---|---|
RecomputeCycleDto | version | int | Yes | >= 0 |
FinalizeCycleDto | version | int | Yes | >= 0 |
FinalizeCycleDto | acknowledgePending | boolean | Yes | Must be true if pending decisions exist, or finalize is refused |
CancelPromotionCycleDto | reason | string | Yes | 3–1000 chars |
SupplementaryDto | examPublicId | string (uuid) | Yes | @IsUUID() |
6.4 PromotionCycleSummaryDto / PromotionCycleDetailDto
| Field | Type | Nullable | Notes |
|---|---|---|---|
publicId | string | No | |
name | string | No | |
academicSessionPublicId/academicSessionName | string | No | |
status | "draft"|"finalized"|"cancelled" | No | |
publicationState | "none"|"queued"|"sent"|"failed" | No | |
version | int | No | |
finalizedAt | Date | Yes | |
createdAt/updatedAt | Date | No | |
basisExams (detail only) | {examPublicId, name, role, resultsPublished}[] | No | |
countsByOutcome (detail only) | {outcome, count}[] | No | Current decisions only |
countsBySource (detail only) | {source, count}[] | No | Current decisions only |
pendingReasons (detail only) | {reason, count}[] | No | reason: "UNKNOWN" if a pending row somehow has none |
basisDrift (detail only) | int | No | 0 unless status === "finalized" |
6.5 RecomputeResultDto / SupplementaryResultDto
| DTO | Field | Type | Meaning |
|---|---|---|---|
RecomputeResultDto | added | int | New decisions written for pupils with none before |
RecomputeResultDto | changed | int | Current auto decisions that drifted and were rewritten |
RecomputeResultDto | unchanged | int | Manually-decided or already-matching pupils |
SupplementaryResultDto | upgraded | int | Non-conditional decisions whose outcome improved |
SupplementaryResultDto | conditionsSatisfied | int | Conditional promotions whose condition was cleared |
SupplementaryResultDto | skippedManual | string[] | Student public ids skipped because a human already decided them |
SupplementaryResultDto | unchanged | int | Left-session or non-drifted pupils |
6.6 Decision write DTOs (PutPromotionDecisionDto / BulkPromotionDecisionDto / CorrectPromotionDecisionDto / ConditionResolutionDto)
All extend the common outcome/targetGradePublicId/conditionText/conditionDeadline fields.
| Field | Type | Required | Validation | Notes |
|---|---|---|---|---|
outcome | enum (6 values) | Yes | @IsEnum | |
targetGradePublicId | string (uuid) | Conditionally | Required (and must resolve to an active grade) for promoted/retained/conditionally_promoted; ignored otherwise | Enforced by resolveTargetGradeId, mirroring the DB CHECK |
conditionText | string | Conditionally | 3–500 chars, required iff outcome === "conditionally_promoted" | resolveConditionFields |
conditionDeadline | string (ISO date) | No | Must not be in the past | |
reason | string | See per-DTO below | 3–1000 chars | PutPromotionDecisionDto: optional at the class-validator layer but required by the service (PROMOTION_REASON_REQUIRED); BulkPromotionDecisionDto/CorrectPromotionDecisionDto: required |
expectedDecisionPublicId | string (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) | Yes | 1–500 items, unique | |
expectedDecisionPublicIds (bulk/reset only) | string[] (uuid) | Yes | 1–500 items, unique, same length as studentPublicIds | Current 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)
| Field | Type | Nullable |
|---|---|---|
publicId, studentPublicId, studentName, studentIdCode | string | No |
gradePublicId, gradeName, classPublicId, className | string | No |
outcome | enum | No |
targetGradePublicId, targetGradeName | string | Yes |
recommendedOutcome | enum | Yes |
recommendationReason | string | Yes |
conditionStatus, conditionText, conditionDeadline | string | Yes |
source | enum | No |
reason | string | Yes |
isOverridden | boolean | No |
decidedAt, notifiedAt | Date | Yes (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
| Field | Type | Nullable | Notes |
|---|---|---|---|
graduatingGradePublicId, graduatingGradeName | string | Yes | ValidateIf on the update DTO: explicit null clears it, an absent field leaves it untouched |
allowConditionalPromotion | boolean | No | Default true |
shareConditionInNotification | boolean | No | Default false |
notifyOnPublish | boolean | No | Default true |
publishChannels | ("email"|"sms"|"push"|"in_app")[] | No | 1–4 items, unique, must be a subset of the four channels |
version | int | No | Required on update — optimistic concurrency |
6.10 PromotionProgressionDto / PutPromotionProgressionDto
| Field | Type | Nullable | Notes |
|---|---|---|---|
gradePublicId, gradeName | string | No | |
nextGradePublicId, nextGradeName | string | Yes | Exactly one of this or graduates must be set |
graduates | boolean | No | |
updatedAt | Date | No |
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
| Enum | Value | Meaning | Runtime Effect | Source |
|---|---|---|---|---|
promotion_outcome | promoted | Advances to a target grade | targetGradeId required | promotions.ts |
retained | Stays at the source grade | targetGradeId required (set to the source grade) | ||
conditionally_promoted | Advances, pending a condition | targetGradeId, conditionText, conditionStatus all required | ||
graduated | Completed the top of the ladder | targetGradeId must be null | ||
pending | No usable outcome yet | targetGradeId null; recommendationReason explains why | ||
excluded | Deliberately outside this cycle (left mid-year, special arrangement) | targetGradeId null; reason required | ||
promotion_decision_source | recommendation | Computed by the algorithm, draft phase | Only source recompute freely rewrites | |
manual | One pupil manually decided, draft phase | |||
bulk | Many pupils decided together, draft phase | |||
supplementary | Computed via a supplementary exam re-evaluation; valid in both draft and finalized cycles | |||
correction | Post-finalize correction | Finalized cycles only | ||
condition | Post-finalize condition resolution | Finalized cycles only | ||
promotion_cycle_status | draft | finalized | cancelled | Cycle lifecycle | Gates which decision sources may be written | |
promotion_publication_state | none | queued | sent | failed | Family-notification delivery state | Only relevant once status = finalized | |
promotion_condition_status | pending | satisfied | failed | waived | State of a conditional promotion's condition | failed rewrites the outcome to retained | |
PROMOTION_RECOMMENDATION_REASONS | NO_BASIS_EXAM | No applicable final exam exists for the pupil | pending | promotion-codes.ts |
RESULT_NOT_PUBLISHED | An applicable final's results are unpublished | pending | ||
RESULT_MISSING | No result row exists for an applicable final | pending | ||
RESULT_STALE | The result is marked stale | pending | ||
RESULT_WITHHELD | Result status is withheld | pending | ||
RESULT_INCOMPLETE | Result status is incomplete | pending | ||
RESULT_ABSENT | Result status is absent | pending | ||
SUPPLEMENTARY_PENDING | A FAIL/CONDITIONAL final verdict has an applicable supplementary that itself has a precondition failure | pending | ||
NEXT_GRADE_INACTIVE | The resolved next grade is not active | pending | ||
NEXT_GRADE_AMBIGUOUS | Two+ grades tie for the next sort order | pending | ||
NO_NEXT_GRADE | No grade above the source in sort order | pending | ||
GRADUATING_GRADE_AMBIGUOUS | Two+ active grades tie at the top sort order, no explicit graduating grade set | pending | ||
FINAL_GRADE_CONDITIONAL | A CONDITIONAL verdict at the graduating grade (no next grade to be conditional toward) | pending | ||
CONDITIONAL_NOT_ALLOWED | CONDITIONAL verdict but allowConditionalPromotion is off | pending | ||
SOURCE_GRADE_INACTIVE | A FAIL verdict, but the source grade itself is inactive | pending | ||
LEFT_SESSION | Pupil has no active enrollment in the source session | excluded — 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
| Evidence | Path |
|---|---|
| Controller | cycles/admin/promotion-cycles.controller.ts |
| DTO | cycles/dto/promotion-cycle.dto.ts (CreatePromotionCycleDto) |
| Service | cycles/core/promotion-cycles.service.ts#create |
| Schema | packages/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_cyclesinsert,promotion_cycle_examsinserts. - No cache, no job, no notification.
Error Cases
| HTTP Status | Error Code | Condition |
|---|---|---|
404 | ACADEMIC_SESSION_NOT_FOUND | Session id does not resolve |
400 | PROMOTION_EXAM_INVALID | An exam is missing, cancelled, in both lists, or outside the session's rules |
409 | PROMOTION_CYCLE_EXISTS | A 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 automaticadmingrant — 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_cyclesstatus flip;outbox_eventsrow (ifnotifyOnPublish). - Jobs: enqueues
academic_year.publish_promotion_decisions. - No notification is sent synchronously — delivery happens in the worker.
Error Cases
| HTTP Status | Error Code | Condition |
|---|---|---|
409 | PROMOTION_CYCLE_NOT_DRAFT | Already finalized/cancelled |
409 | PROMOTION_CYCLE_STALE | Version mismatch, missing decisions, or recommendation drift |
409 | PROMOTION_EXAM_NOT_PUBLISHED | A final basis exam's results are not published |
409 | PROMOTION_PENDING_UNACKNOWLEDGED | Pending 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
computeCycleRecommendationscall. acknowledgePending: truewith 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 automaticadmingrant).
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 beconditionally_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 inNO_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
10. Pagination, Sorting, Filtering, and Search
| Endpoint | Pagination Type | Default Size | Max Size | Sort Fields | Filters | Result Cap |
|---|---|---|---|---|---|---|
GET /api/promotions/cycles | page/size (QueryDto) | 20 | 100 | createdAt desc (fixed) | status, academicSessionPublicId | — |
GET /api/promotions/cycles/:publicId/decisions | page/size, cannot be disabled | 20 | 100 | users.fullName asc, id asc (fixed) | outcome, recommendedOutcome, source, gradePublicId, classPublicId, reason, overridden, search | Refuses 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
| Integration | Used? | Details | Source |
|---|---|---|---|
| Redis cache | No | This module reads directly from PostgreSQL on every request | — |
| BullMQ | Yes | QueueName.ACADEMIC_YEAR / AcademicYearJob.PUBLISH_PROMOTION_DECISIONS, enqueued only via the outbox from finalize, republish, and the five-minute recovery sweep | promotion-publication.service.ts |
| External API | No | — | — |
13.1 Route-by-Route Completeness Matrix
| Route | Controller Method | DTOs | Service Method | Guards | Permissions | Jobs | DB Touches | Errors | Tests |
|---|---|---|---|---|---|---|---|---|---|
POST /promotions/cycles | create | CreatePromotionCycleDto | PromotionCyclesService.create | JWT, Role | Promotions_CREATE | — | promotion_cycles, promotion_cycle_exams | ACADEMIC_SESSION_NOT_FOUND, PROMOTION_EXAM_INVALID, PROMOTION_CYCLE_EXISTS | promotion-cycles.integration.spec.ts |
GET /promotions/cycles | findAll | ListPromotionCyclesQueryDto | PromotionCyclesService.findAll | JWT, Role | Promotions_READ | — | promotion_cycles | — | same |
GET /promotions/cycles/:publicId | findOne | — | PromotionCyclesService.findOne | JWT, Role | Promotions_READ | — | promotion_cycles, promotion_decisions | PROMOTION_CYCLE_NOT_FOUND | same |
PATCH /promotions/cycles/:publicId | update | UpdatePromotionCycleDto | PromotionCyclesService.update | JWT, Role | Promotions_UPDATE | — | promotion_cycles, promotion_cycle_exams | PROMOTION_CYCLE_NOT_FOUND, _NOT_DRAFT, _STALE | same |
DELETE /promotions/cycles/:publicId | cancel | CancelPromotionCycleDto | PromotionCyclesService.cancel | JWT, Role | Promotions_DELETE | — | promotion_cycles | PROMOTION_CYCLE_NOT_FOUND, _NOT_DRAFT, _IN_USE | same |
POST /promotions/cycles/:publicId/recompute | recomputeCycle | RecomputeCycleDto | PromotionCycleRecomputeService.recompute | JWT, Role | Promotions_UPDATE | — | promotion_decisions | _NOT_FOUND, _NOT_DRAFT, _STALE | same |
POST /promotions/cycles/:publicId/finalize | finalizeCycle | FinalizeCycleDto | PromotionCycleFinalizeService.finalize | JWT, Role | PromotionFinalize_UPDATE | Enqueues publish | promotion_cycles, outbox_events | _NOT_DRAFT, _STALE, PROMOTION_EXAM_NOT_PUBLISHED, PROMOTION_PENDING_UNACKNOWLEDGED | promotion-publication.integration.spec.ts |
POST /promotions/cycles/:publicId/republish | republishCycle | — | PromotionCycleFinalizeService.republish | JWT, Role | PromotionFinalize_UPDATE | Enqueues publish | promotion_cycles, outbox_events | _NOT_FINALIZED, PROMOTION_PUBLICATION_INVALID_STATE | same |
POST /promotions/cycles/:publicId/supplementary | applySupplementary | SupplementaryDto | PromotionCycleSupplementaryService.apply | JWT, Role | PromotionCorrections_UPDATE | — | promotion_cycle_exams, promotion_decisions | _NOT_FINALIZED, PROMOTION_EXAM_INVALID | promotion-cycle-supplementary.integration.spec.ts |
GET /promotions/cycles/:publicId/decisions | findAll | ListPromotionDecisionsQueryDto | PromotionDecisionsReadService.findDecisions | JWT, Role | Promotions_READ | — | promotion_decisions | PAGINATION_LIMIT_INVALID | promotion-decisions.integration.spec.ts |
GET /.../students/:studentPublicId/history | history | — | PromotionDecisionsReadService.findHistory | JWT, Role | Promotions_READ | — | promotion_decisions | — | same |
PUT /.../students/:studentPublicId/decision | putDecision | PutPromotionDecisionDto | PromotionDecisionManualWriteService.putDecision | JWT, Role | Promotions_UPDATE | — | promotion_decisions | _NOT_DRAFT, _STALE, _TARGET_GRADE_INVALID, _CONDITION_REQUIRED, _REASON_REQUIRED | same |
POST /.../decisions/bulk | bulkDecide | BulkPromotionDecisionDto | PromotionDecisionManualWriteService.bulkDecide | JWT, Role | Promotions_UPDATE | — | promotion_decisions | _STUDENT_NOT_IN_CYCLE, others as above | same |
POST /.../decisions/reset | reset | ResetPromotionDecisionsDto | PromotionDecisionManualWriteService.reset | JWT, Role | Promotions_UPDATE | — | promotion_decisions | _STUDENT_NOT_IN_CYCLE | same |
POST /.../students/:studentPublicId/correct | correct | CorrectPromotionDecisionDto | PromotionDecisionCorrectionsService.correct | JWT, Role | PromotionCorrections_UPDATE | Inline notification | promotion_decisions | _NOT_FINALIZED, _STALE, _STUDENT_NOT_IN_CYCLE | same |
POST /.../students/:studentPublicId/condition | resolveCondition | ConditionResolutionDto | PromotionDecisionCorrectionsService.resolveCondition | JWT, Role | PromotionCorrections_UPDATE | Inline notification | promotion_decisions | _NOT_FINALIZED, _NOT_CONDITIONAL, _STALE | same |
GET /promotions/settings | getSettings | — | PromotionSettingsService.getSettings | JWT, Role | PromotionSettings_READ | — | promotion_settings | _SETTINGS_STALE | promotion-settings.integration.spec.ts |
PATCH /promotions/settings | updateSettings | UpdatePromotionSettingsDto | PromotionSettingsService.updateSettings | JWT, Role | PromotionSettings_UPDATE | — | promotion_settings | _SETTINGS_STALE, GRADE_NOT_FOUND | same |
GET /promotions/settings/progressions | listProgressions | — | PromotionSettingsService.listProgressions | JWT, Role | PromotionSettings_READ | — | promotion_grade_progressions | — | same |
PUT /promotions/settings/progressions/:gradePublicId | putProgression | PutPromotionProgressionDto | PromotionSettingsService.putProgression | JWT, Role | PromotionSettings_UPDATE | — | promotion_grade_progressions | GRADE_NOT_FOUND, PROMOTION_PROGRESSION_INVALID | same |
DELETE /promotions/settings/progressions/:gradePublicId | deleteProgression | — | PromotionSettingsService.deleteProgression | JWT, Role | PromotionSettings_UPDATE | — | promotion_grade_progressions | GRADE_NOT_FOUND | same |
GET /promotions/students/:studentPublicId/academic-history | getAcademicHistory | — | AcademicHistoryReaderService.read | JWT, Role, assertCanAccess | Students_READ | — | Enrollments, decisions, graduations | — | — |
GET /mobile/student/promotion-history | getOwnHistory | — | AcademicHistoryReaderService.read | JWT, Role (allowlisted) | None (audience) | — | Same | STUDENT_NOT_FOUND | promotion-portal.integration.spec.ts |
GET /mobile/guardian/children/:id/promotion-history | getChildHistory | — | AcademicHistoryReaderService.read | JWT, Role (allowlisted) | None (audience) | — | Same | 404 for non-owned child | same |
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
- Backend doc: /docs/developer/promotions/backend
- Features and flows doc: /docs/developer/promotions/feature