Exams API Reference
Complete API contracts for Examinations and Results, including routes, auth, DTOs, responses, errors, examples, and integration notes.
Exams - API Reference
Audience: Frontend engineers (the admin panel), teacher/student/guardian-portal engineers, backend engineers, QA. Scope: Admin, teacher-portal, student-portal, and guardian-portal surfaces owned by the Exams module, plus the one teacher-schedule aggregator route that surfaces exam invigilation duties. 80 routes total (58 admin, 22 audience).
1. Documentation Evidence
| Area | Files Inspected | What Was Verified |
|---|---|---|
| Controllers | apps/api/src/modules/exams/**/*.controller.ts (19 files), apps/api/src/modules/mobile/teacher/teacher-today-portal.controller.ts | Routes, methods, guards, decorators, status codes |
| DTOs | apps/api/src/modules/exams/**/dto/*.ts (25 files) | Request, query, response, validation |
| Services | apps/api/src/modules/exams/**/*.service.ts (33 files) | Behavior, side effects, response mapping, errors |
| Schema | packages/db/src/schema/school/exams.ts (1722 lines) | IDs, enums, persisted fields, constraints, version column types |
| Error registry | apps/api/src/common/types/error-codes.ts (grep EXAM_, GRADING_) | 50 EXAM_* + 5 GRADING_SCALE_* = 55 codes (§7, §8) |
| Jobs | packages/jobs/src/index.ts (ExamJob enum) | QueueName.EXAMS, 9 job members, payload shapes |
| Route baseline | apps/api/test/structure/structure.baseline.json | The authoritative route list this module contributes 80 routes to |
2. Module Summary
| Field | Value |
|---|---|
| Module name | Exams |
| Module slug | exams |
| Primary actors | admin, teacher (audience), student (audience), guardian (audience) |
| API surfaces | Admin (/api/exams*, /api/exam-*, /api/grading-scales*), audience (/api/mobile/teacher/*, /api/mobile/student/*, /api/mobile/guardian/*) |
| Base route prefixes | /api/exams, /api/exam-types, /api/exam-dashboard, /api/exam-day, /api/exam-routine-entries, /api/exam-marks, /api/grading-scales, /api/exam-notification-settings, /api/exam-reminder-rules, /api/mobile/teacher/exams*, /api/mobile/teacher/invigilations, /api/mobile/teacher/schedule/today, /api/mobile/student/exams*, /api/mobile/student/academic-history, /api/mobile/guardian/children/:id/exams*, /api/mobile/guardian/children/:id/academic-history |
| Auth model | JwtAuthGuard (global) + RoleGuard; admin routes carry @Permissions("Module_ACTION"); audience routes carry none and are policed by RoleGuard's NO_PERMISSION_ADMIN_HANDLERS allowlist plus the module's own assertPortalAudience/assertTeacherStaffRow/assertStaffRow calls |
| Persistence | PostgreSQL (Drizzle), attachment bytes via @skoolsewa/storage, async work via BullMQ (@skoolsewa/jobs) |
| Runtime source of truth | exams.status (stored) and exam_marks_submissions.state (stored); displayStatus, studentDisplayStatus-equivalents, and the "gate is open" flag are all derived per request, never stored |
| Sibling docs | Backend, Features and flows |
3. Concepts and Terminology
| Term | Meaning | Source File | Used By |
|---|---|---|---|
publicId | External UUID identifier for an exam, exam type, grading scale, routine entry, attachment, or mark — never the internal integer/serial PK | exams.publicId etc. | Every route param named publicId/examPublicId/entryPublicId/fileId, except exam-notification-settings/:event (keyed by the event string) and exam-marks/:publicId/correct (publicId there is the MARK's own publicId, not an exam's) |
status | The stored exam lifecycle: draft/published/completed/cancelled/archived | exams.status | List/detail responses, every lifecycle route's WHERE clause |
displayStatus | The DERIVED status shown to a viewer: draft/scheduled/in_progress/completed/cancelled/archived — computed from status + startDate against the school's own "today", never stored | EXAM_DISPLAY_STATUSES, exam-display-status.util.ts | List/detail responses; ?status= on GET /exams translates scheduled/in_progress into a SQL predicate against the stored published status, since neither is a stored value |
examStatus | A denormalized COPY of the parent exam's status, carried on exam_routine_entries and exam_invigilators at write time | schema | Row-scope predicates that need the exam's status without a join (audience reads, availability, publish-preview) |
version | An optimistic-concurrency token — not one shape across this module. See the callout immediately below. | mixed | Every write route that targets an existing row |
state | The paper's marks submission state machine: draft/submitted/returned/approved/finalized | MARKS_SUBMISSION_STATES | Marks sheet, workflow routes |
attendance | A pupil's presence for one mark cell: present/absent/withheld/not_applicable | EXAM_MARK_ATTENDANCE_VALUES | Marks sheet, corrections |
classSubjectId | The id of one class teaching one subject (class_subjects.id) — what a routine entry actually targets, and what a teacher's own scope is resolved against | class_subjects schema | Create routine entry, teacher marks-scope predicate |
examClassId | exam_classes.id — one row binding an exam to one class in scope. Internal; never appears on the wire, but is what the "class already has recorded marks" refusal keys on when a class is removed | schema | ExamClassesService.replace |
roll | The LIVE class roster read at the moment marks or results are computed (student_class_enrollments filtered active+active+not deleted), never a roster snapshotted at publish time | exam-marks-sheet.service.ts, exam-results-compute.service.ts | Marks sheet, submit-completeness check, result computation |
gate (opensAt/isOpen/isEditable) | "Marks open on time, not on a click" — marks entry opens when the paper's own end instant has passed in SCHOOL time, or an administrator marked the entry complete early via is_completed. is_completed alone is never the gate. | exam-marks-gate.util.ts | Marks sheet read/write, submit |
canPublish / warnings | The publish-preview's own simulation of the same checks publish enforces — warnings for cosmetic gaps (no room, no invigilator), hard blockers (no classes, a paper with no components, no floor grading band, any unresolved conflict) that also set canPublish: false | exam-conflicts-report.dto.ts | GET /exams/:publicId/publish-preview |
two-pass conflict detection | Every room/class/invigilator collision check runs first against OTHER exams' published/completed entries, then — if that finds nothing — against the candidate set itself in memory (a collision entirely inside the batch being published) | exam-conflict.util.ts | Publish, routine create/patch/remove, swap, invigilator/component replace |
scopeKind | The active role's audience: self|guardian|student|all — there is no staff/teacher member; teacher-ness is the active role named teacher plus a live staff row, asserted by assertTeacherStaffRow (the exam list, exam detail, attachment download and invigilation list assert only the live staff row, via assertStaffRow) | roles.scope_kind | Every audience guard |
On version — read this before writing an integration against any write route. Unlike a module where every entity's version is a uniform opaque string, this module has two genuinely different mechanisms, and mixing them up produces a type error the compiler will not catch on either side of the wire:
- String tokens, bumped by a database trigger, never re-parsed as a number.
exams,exam_types,grading_scales,exam_marks_submissions,exam_marks,exam_results,exam_notification_rules,exam_reminder_rules— every one of these storesversionasbigint, but the service layer stringifies it on read (String(row.version), or the sharedversionOf()/matchesVersion()helpers inapps/api/src/modules/people/shared/row-version.ts) and every DTO types the fieldstring. A stale token is refused withEXAM_VERSION_CONFLICT(or, for the marks submission state machine specifically,EXAM_MARKS_SUBMISSION_VERSION_CONFLICT). - Plain integers, incremented by the service itself, never touched by a trigger.
exam_routine_entriesandexam_assessment_componentstypeversionas a JSONnumberon the wire (@IsInt() @Min(0)on every request DTO that carries one —PatchExamRoutineEntryDto,SwapExamRoutineEntriesDto,PutExamComponentDto), and the service computes the next value itself (set.version = dto.version + 1) inside the sameUPDATE'sWHEREclause. A client must send back the exact integer last read, and the server — not a trigger — decides the next one.ExamRoutineWriteService.patchnames the refusalEXAM_ROUTINE_ENTRY_STATE_CHANGED, notEXAM_VERSION_CONFLICT, because the sameWHEREclause also re-checksexam_status(a publish landing between a caller's read and their edit is exactly as much a conflict as a stale counter).
A frontend or mobile client that formats every version field as a string before sending it back will send "3" where the routine-entry and component routes expect the JSON number 3 — and the global ValidationPipe's @IsInt() on those DTOs rejects a string with a 400, not a silent coercion.
4. API Surface Map
| Surface | Method | Path | Actor | Auth/Guard | Permission | Controller | Purpose |
|---|---|---|---|---|---|---|---|
| Admin | GET | /api/exam-types | Admin | JWT+Role | ExamTypes_READ | ExamTypesController | List exam types |
| Admin | POST | /api/exam-types | Admin | JWT+Role | ExamTypes_CREATE | ExamTypesController | Create exam type |
| Admin | PATCH | /api/exam-types/:publicId | Admin | JWT+Role | ExamTypes_UPDATE | ExamTypesController | Update exam type |
| Admin | DELETE | /api/exam-types/:publicId | Admin | JWT+Role | ExamTypes_DELETE | ExamTypesController | Delete unused exam type |
| Admin | GET | /api/exams | Admin | JWT+Role | Exams_READ | ExamsController | List exams |
| Admin | POST | /api/exams | Admin | JWT+Role | Exams_CREATE | ExamsController | Create a draft |
| Admin | GET | /api/exams/:publicId | Admin | JWT+Role | Exams_READ | ExamsController | One exam |
| Admin | PATCH | /api/exams/:publicId | Admin | JWT+Role | Exams_UPDATE | ExamsController | Update |
| Admin | DELETE | /api/exams/:publicId | Admin | JWT+Role | Exams_DELETE | ExamsController | Delete draft with no class scope |
| Admin | POST | /api/exams/:publicId/publish | Admin | JWT+Role | ExamPublish_UPDATE | ExamLifecycleController | Publish |
| Admin | POST | /api/exams/:publicId/unpublish | Admin | JWT+Role | ExamPublish_UPDATE | ExamLifecycleController | Return to draft |
| Admin | POST | /api/exams/:publicId/cancel | Admin | JWT+Role | ExamCancel_UPDATE | ExamLifecycleController | Cancel |
| Admin | POST | /api/exams/:publicId/reinstate | Admin | JWT+Role | ExamCancel_UPDATE | ExamLifecycleController | Return a cancelled exam to draft |
| Admin | POST | /api/exams/:publicId/complete | Admin | JWT+Role | Exams_UPDATE | ExamLifecycleController | Mark a published exam completed |
| Admin | POST | /api/exams/:publicId/archive | Admin | JWT+Role | Exams_UPDATE | ExamLifecycleController | Archive |
| Admin | POST | /api/exams/:publicId/unarchive | Admin | JWT+Role | Exams_UPDATE | ExamLifecycleController | Unarchive |
| Admin | GET | /api/exams/:publicId/classes | Admin | JWT+Role | Exams_READ | ExamClassesController | The exam's class scope |
| Admin | PUT | /api/exams/:publicId/classes | Admin | JWT+Role | Exams_UPDATE | ExamClassesController | Replace class scope |
| Admin | GET | /api/exams/:publicId/attachments | Admin | JWT+Role | Exams_READ | ExamAttachmentsController | List attachments |
| Admin | POST | /api/exams/:publicId/attachments | Admin | JWT+Role | Exams_UPDATE | ExamAttachmentsController | Upload attachment |
| Admin | DELETE | /api/exams/:publicId/attachments/:attachmentPublicId | Admin | JWT+Role | Exams_UPDATE | ExamAttachmentsController | Remove attachment |
| Admin | GET | /api/exams/:publicId/routine | Admin | JWT+Role | ExamRoutines_READ | ExamRoutineController | List routine entries |
| Admin | POST | /api/exams/:publicId/routine | Admin | JWT+Role | ExamRoutines_CREATE | ExamRoutineController | Add one paper |
| Admin | GET | /api/exams/:publicId/conflicts | Admin | JWT+Role | ExamRoutines_READ | ExamRoutineController | Every room/class/invigilator collision |
| Admin | GET | /api/exams/:publicId/publish-preview | Admin | JWT+Role | Exams_READ | ExamRoutineController | What publishing would do |
| Admin | POST | /api/exam-routine-entries/swap | Admin | JWT+Role | ExamRoutines_UPDATE | ExamRoutineEntryController | Swap two papers' schedules |
| Admin | PATCH | /api/exam-routine-entries/:publicId | Admin | JWT+Role | ExamRoutines_UPDATE | ExamRoutineEntryController | Reschedule/edit one entry |
| Admin | DELETE | /api/exam-routine-entries/:publicId | Admin | JWT+Role | ExamRoutines_DELETE | ExamRoutineEntryController | Remove one entry |
| Admin | POST | /api/exam-routine-entries/:publicId/complete | Admin | JWT+Role | ExamRoutines_UPDATE | ExamRoutineEntryController | Mark exam-day flag complete |
| Admin | GET | /api/exam-routine-entries/:publicId/availability | Admin | JWT+Role | ExamRoutines_READ | ExamRoutineEntryController | Free staff/rooms for this slot |
| Admin | PUT | /api/exam-routine-entries/:publicId/components | Admin | JWT+Role | ExamRoutines_UPDATE | ExamRoutineEntryController | Replace assessment components |
| Admin | PUT | /api/exam-routine-entries/:publicId/invigilators | Admin | JWT+Role | ExamRoutines_UPDATE | ExamRoutineEntryController | Replace invigilator assignment |
| Admin | GET | /api/exam-day | Admin | JWT+Role | ExamRoutines_READ | ExamDayController | Every paper sitting today |
| Admin | GET | /api/exam-dashboard | Admin | JWT+Role | Exams_READ | ExamDashboardController | Dashboard totals |
| Admin | GET | /api/grading-scales | Admin | JWT+Role | ExamGrading_READ | GradingScalesController | List grading scales |
| Admin | POST | /api/grading-scales | Admin | JWT+Role | ExamGrading_CREATE | GradingScalesController | Create |
| Admin | PATCH | /api/grading-scales/:publicId | Admin | JWT+Role | ExamGrading_UPDATE | GradingScalesController | Update |
| Admin | DELETE | /api/grading-scales/:publicId | Admin | JWT+Role | ExamGrading_DELETE | GradingScalesController | Delete unused scale |
| Admin | PUT | /api/grading-scales/:publicId/bands | Admin | JWT+Role | ExamGrading_UPDATE | GradingScalesController | Replace bands |
| Admin | GET | /api/exam-routine-entries/:publicId/marks | Admin | JWT+Role | ExamMarks_READ | ExamMarksController | Read marks sheet |
| Admin | PUT | /api/exam-routine-entries/:publicId/marks | Admin | JWT+Role | ExamMarks_UPDATE | ExamMarksController | Save marks |
| Admin | POST | /api/exam-routine-entries/:publicId/marks/submit | Admin | JWT+Role | ExamMarks_UPDATE | ExamMarksWorkflowController | Submit for review |
| Admin | POST | /api/exam-routine-entries/:publicId/marks/return | Admin | JWT+Role | ExamMarksVerify_UPDATE | ExamMarksWorkflowController | Return to teacher |
| Admin | POST | /api/exam-routine-entries/:publicId/marks/approve | Admin | JWT+Role | ExamMarksVerify_UPDATE | ExamMarksWorkflowController | Approve |
| Admin | POST | /api/exam-routine-entries/:publicId/marks/finalize | Admin | JWT+Role | ExamMarksVerify_UPDATE | ExamMarksWorkflowController | Finalize |
| Admin | POST | /api/exam-marks/:publicId/correct | Admin | JWT+Role | ExamMarksCorrect_UPDATE | ExamMarkCorrectionsController | Correct a finalized mark |
| Admin | GET | /api/exam-marks/:publicId/corrections | Admin | JWT+Role | ExamMarksCorrect_READ | ExamMarkCorrectionsController | Correction history |
| Admin | GET | /api/exams/:publicId/results | Admin | JWT+Role | ExamResults_READ | ExamResultsController | List results |
| Admin | POST | /api/exams/:publicId/results/compute | Admin | JWT+Role | ExamResults_UPDATE | ExamResultsController | Compute/recompute every result |
| Admin | POST | /api/exams/:publicId/results/publish | Admin | JWT+Role | ExamResultPublish_UPDATE | ExamResultsController | Publish results |
| Admin | POST | /api/exams/:publicId/results/unpublish | Admin | JWT+Role | ExamResultPublish_UPDATE | ExamResultsController | Unpublish results |
| Admin | GET | /api/exams/:publicId/results/:studentPublicId | Admin | JWT+Role | ExamResults_READ | ExamResultsController | One pupil's full result |
| Admin | POST | /api/exams/:publicId/results/:studentPublicId/withhold | Admin | JWT+Role | ExamResultPublish_UPDATE | ExamResultsController | Withhold one result |
| Admin | POST | /api/exams/:publicId/results/:studentPublicId/release | Admin | JWT+Role | ExamResultPublish_UPDATE | ExamResultsController | Release a withheld result |
| Admin | GET | /api/exam-notification-settings | Admin | JWT+Role | ExamNotificationSettings_READ | ExamNotificationSettingsController | List notification rules |
| Admin | PUT | /api/exam-notification-settings/:event | Admin | JWT+Role | ExamNotificationSettings_UPDATE | ExamNotificationSettingsController | Update one rule |
| Admin | GET | /api/exam-reminder-rules | Admin | JWT+Role | ExamNotificationSettings_READ | ExamReminderRulesController | List reminder rules |
| Admin | PUT | /api/exam-reminder-rules | Admin | JWT+Role | ExamNotificationSettings_UPDATE | ExamReminderRulesController | Replace the full rule set |
| Student | GET | /api/mobile/student/exams | Pupil | JWT+Role, audience | none | StudentExamsPortalController | My own exams |
| Student | GET | /api/mobile/student/exams/dashboard | Pupil | JWT+Role, audience | none | StudentExamsPortalController | My exams dashboard |
| Student | GET | /api/mobile/student/exams/:examPublicId | Pupil | JWT+Role, audience | none | StudentExamsPortalController | One of my exams |
| Student | GET | /api/mobile/student/exams/:examPublicId/result | Pupil | JWT+Role, audience | none | StudentExamsPortalController | My published result |
| Student | GET | /api/mobile/student/exams/:examPublicId/attachments/:attachmentPublicId/download | Pupil | JWT+Role, audience | none | StudentExamsPortalController | Download an attachment |
| Student | GET | /api/mobile/student/academic-history | Pupil | JWT+Role, audience | none | StudentExamsPortalController | My published exam-result history |
| Guardian | GET | /api/mobile/guardian/children/:id/exams | Guardian | JWT+Role, audience | none | GuardianExamsPortalController | A child's exams |
| Guardian | GET | /api/mobile/guardian/children/:id/exams/dashboard | Guardian | JWT+Role, audience | none | GuardianExamsPortalController | A child's exams dashboard |
| Guardian | GET | /api/mobile/guardian/children/:id/exams/:examPublicId | Guardian | JWT+Role, audience | none | GuardianExamsPortalController | One of a child's exams |
| Guardian | GET | /api/mobile/guardian/children/:id/exams/:examPublicId/result | Guardian | JWT+Role, audience | none | GuardianExamsPortalController | A child's published result |
| Guardian | GET | /api/mobile/guardian/children/:id/exams/:examPublicId/attachments/:attachmentPublicId/download | Guardian | JWT+Role, audience | none | GuardianExamsPortalController | Download an attachment |
| Guardian | GET | /api/mobile/guardian/children/:id/academic-history | Guardian | JWT+Role, audience | none | GuardianExamsPortalController | A child's published result history |
| Teacher | GET | /api/mobile/teacher/exams/dashboard | Teacher | JWT+Role, audience | none | TeacherExamsPortalController | My exams dashboard |
| Teacher | GET | /api/mobile/teacher/exams/marks-queue | Teacher | JWT+Role, audience | none | TeacherExamsPortalController | Papers still needing my marks |
| Teacher | GET | /api/mobile/teacher/exams | Any staff member (no teacher role needed) | JWT+Role, audience | none | TeacherExamsPortalController | Exams I teach or invigilate |
| Teacher | GET | /api/mobile/teacher/exams/:examPublicId | Any staff member (no teacher role needed) | JWT+Role, audience | none | TeacherExamsPortalController | One exam I teach or invigilate |
| Teacher | GET | /api/mobile/teacher/exams/:examPublicId/attachments/:attachmentPublicId/download | Any staff member (no teacher role needed) | JWT+Role, audience | none | TeacherExamsPortalController | Download an attachment |
| Teacher | GET | /api/mobile/teacher/invigilations | Any staff member (no teacher role needed) | JWT+Role, audience | none | TeacherExamsPortalController | My own invigilation duties |
| Teacher | GET | /api/mobile/teacher/exams/:examPublicId/entries/:entryPublicId/marks | Teacher | JWT+Role, audience | none | TeacherExamMarksPortalController | Read marks sheet for my paper |
| Teacher | PUT | /api/mobile/teacher/exams/:examPublicId/entries/:entryPublicId/marks | Teacher | JWT+Role, audience | none | TeacherExamMarksPortalController | Save marks for my paper |
| Teacher | POST | /api/mobile/teacher/exams/:examPublicId/entries/:entryPublicId/marks/submit | Teacher | JWT+Role, audience | none | TeacherExamMarksPortalController | Submit my paper's marks |
| Teacher | GET | /api/mobile/teacher/schedule/today | Teacher | JWT+Role, audience | none | TeacherTodayPortalController (modules/mobile/teacher/, not modules/exams/) | My schedule today — classes and exam duties merged |
Note on Nest route resolution: ExamsController, ExamLifecycleController, ExamClassesController and ExamAttachmentsController all share the base path exams. Static segments (publish, classes, attachments) are declared as literal children of :publicId, and ExamsController's own GET :publicId is registered after nothing collides with it, so no controller's route is shadowed. ExamRoutineController is declared with the controller-level path exams/:publicId (not exams), giving it routine, conflicts and publish-preview as literal children one level under the id — Express distinguishes these from ExamsController's bare :publicId by segment count, never by declaration order between the two classes. ExamResultsController similarly declares exams/:publicId/results as its controller-level path, with compute, publish and unpublish registered before the trailing :studentPublicId parameter route inside that class. ExamRoutineEntryController declares swap before :publicId, so a literal path segment can never be swallowed as an id.
5. Auth, Identity, and Permissions
| Surface | Guard/Decorator | Identity Shape | Permission | Guest Allowed | Notes |
|---|---|---|---|---|---|
| Admin | JwtAuthGuard, RoleGuard, @Permissions("Module_ACTION") | req.user.id, active role's permission grants | Per-route (see §4) | No | Standard admin permission model. |
| Student portal | JwtAuthGuard, RoleGuard (no @Permissions()) | req.user resolved to a live students row via assertPortalAudience(actor, "student") + students.userId = actor.id | N/A | No | The lookup is structural — the route takes no id parameter, so nothing a caller supplies can point at another pupil. |
| Guardian portal | same | req.user resolved + :id (child) bound to a linked child via GuardianPortalService.resolveOwnChildId | N/A | No | The child-binding check runs FIRST, before any exam row is read. An unlinked :id answers 404, never 403. |
| Teacher portal | same | req.user acting as the role named teacher, resolved to a live staff row via assertTeacherStaffRow; a teacher acting as staff gets 403 PERMISSION_INSUFFICIENT on those routes and switches role first. GET teacher/exams, GET teacher/exams/:examPublicId, its attachment download and GET teacher/invigilations use assertStaffRow instead — a live staff row under any non-portal active role — because invigilator assignment does not require a teaching designation, and their row scope (current subject teacher OR invigilator, both keyed on the caller's own staff id) already limits a caller to exams they hold a duty on | N/A | No | RoleGuard allows the handler through NO_PERMISSION_ADMIN_HANDLERS; the actual control is the audience assertion plus each service's own row-scope predicate (current subject-teacher OR invigilator OR — for marks specifically — the staff id that actually submitted them). |
schedule/today | same | Teacher, via assertTeacherStaffRow | N/A | No | Declared in modules/mobile/teacher/, not this module — merges class-timetable periods with this module's exam and invigilation duties into one response. |
Every endpoint requires a valid session (JwtAuthGuard is global with no @Public() opt-out anywhere in this module). No guest/anonymous access exists on any exams route. No route in this module carries an IP-throttle guard — not the attachment upload, not any of the six attachment-download routes across the three audiences — which is a deliberate difference from modules (such as Assignments) that throttle their upload/download surface; verified by grepping every controller file in modules/exams/ and modules/mobile/teacher/ for IpThrottlerGuard/Throttle, with zero matches.
The admin surface is permission-gated; the audience surface is not. A portal handler declares no @Permissions() at all. RoleGuard returns true for it purely because its handler key sits on the NO_PERMISSION_ADMIN_HANDLERS allowlist — before the guard ever reads request.user or checks activeRole. From that point on, the ONLY control standing between the caller and the data is the route's own call into assertPortalAudience/assertTeacherStaffRow/assertStaffRow, followed by the query the service builds. Teacher and staff roles both carry scopeKind: "all" and hold Students_READ by default (TEACHER_PERMISSIONS = STAFF_PERMISSIONS), so scoping a portal route through the general-purpose PeopleAccessService.scopeFor would silently hand every pupil's exam data to any staff session — which is exactly why every read here builds its own predicate instead.
A 404 on the audience surface is deliberately ambiguous. ExamPortalReadsService.resolveExamInScope throws one EXAM_NOT_FOUND for three different truths: the exam does not exist at all; it exists but the pupil's class was never in its scope; and (on the result route specifically) the exam exists and is in scope but results are not yet published, or the pupil's own result row is withheld. A 403 on any of these would itself leak information — "this exam exists and your child sat it" — to a caller probing an id space over a school's examinations. resolveAudienceAttachment follows the identical pattern for attachment downloads: "exam not in your scope", "attachment does not exist", and "attachment exists but its visibility is administrator_only, not audience" answer three different codes (EXAM_NOT_FOUND, EXAM_ATTACHMENT_NOT_FOUND, EXAM_ATTACHMENT_NOT_AUDIENCE_VISIBLE) but every one of them is a 404. No consumer should try to distinguish these from the status code or message alone.
Each audience's actual scope, precisely:
- Student: every exam whose
exam_classesincludes a class the pupil isactive-enrolled in for THAT exam's own academic session — never "the current session" — so a promoted pupil keeps reading a past, still-published exam.draftexams are never visible; every other status was, at some point, a live decision for that pupil. - Guardian: identical scope, applied per linked child, after the child-binding check.
- Teacher: every exam where the caller is the CURRENT
class_subjects.teacher_id(isActive: true) for at least one routine entry, OR is listed inexam_invigilatorsfor at least one entry. The marks read/write/submit routes narrow further — see §6.13's callout — toclass_subjects.teacher_id = meORexam_marks_submissions.submitted_by_staff_id = me, so a reassigned teacher keeps access to marks they already submitted without regaining access to a subject's future papers.
6. DTO and Model Reference
6.1 CreateExamDto
| Field | Type | Required | Default | Validation | Example | Source |
|---|---|---|---|---|---|---|
academicSessionId | uuid | Yes | — | @IsUUID(); immutable after create | — | create-exam.dto.ts:14 |
examTypeId | uuid | Yes | — | @IsUUID() | — | |
gradingScaleId | uuid | No | — | @IsUUID(); omit for a marks-only exam with no grade/GPA | — | |
name | string | Yes | — | 1-160 chars | "First Terminal Examination 2083" | |
description | string | No | — | ≤2000 chars; audience-visible | — | |
internalNote | string | No | — | ≤2000 chars; administrator-internal, never shown to any audience | — | |
startDate | ISO date (YYYY-MM-DD) | Yes | — | @IsDateString({strict:true}) | "2026-09-20" | |
endDate | ISO date | Yes | — | @IsDateString({strict:true}); refused with 400 VALIDATION_FAILED if before startDate | "2026-09-30" |
There is no clientRequestId/idempotency key on exam creation — unlike Assignments' POST, a replayed POST /exams creates a second draft exam rather than returning the first.
6.1a UpdateExamDto
Every field optional except version, which the class marks ! (required) despite @ApiPropertyOptional on the decorator — a documentation artifact in the source, not a real optionality; the DTO always requires it.
| Field | Type | Required | Notes |
|---|---|---|---|
version | string | Yes | The exam's own version token. |
examTypeId | uuid | No | Re-resolved; 404 EXAM_TYPE_NOT_FOUND if unknown. |
gradingScaleId | uuid | null | No | null clears it (marks-only). 404 GRADING_SCALE_NOT_FOUND if the given id does not exist. |
name | string | No | 1-160 chars. |
description, internalNote | string | null | No | null clears; same length caps as create. |
startDate, endDate | ISO date | No | Narrowing (moving the boundary inward) that would exclude an existing routine entry's examDate is refused: 409 EXAM_PERIOD_HAS_ENTRIES_OUTSIDE. Widening is always accepted. |
academicSessionId is absent from this DTO and cannot be changed after create — exam_classes' composite foreign key proves every attached class belongs to the exam's own session, so moving an exam between sessions would need to re-validate or silently orphan every attached class. No route in this module moves an exam between sessions.
6.2 ExamListItemDto / ExamDetailDto (response)
| Field | Type | Notes |
|---|---|---|
publicId | uuid | |
academicSession | {publicId, name} | |
academicSessionLocked | boolean | Detail only. true once the exam's academic year has locked_at set — every write to the exam, its routine, marks, submissions, results and attachments then 409s with ACADEMIC_SESSION_LOCKED. Lets the admin screen render read-only without a separate lock lookup. |
examType | {publicId, name, code} | |
gradingScale | {publicId, name} | null | Detail only. |
name | string | |
description, internalNote | string | null | Detail only. internalNote is administrator-internal — see §6 note under Portal DTOs for why it never leaves this admin shape. |
status | enum | Stored. |
displayStatus | enum | Derived — see §3, §7. |
startDate, endDate | ISO date | |
publishedAt, cancelledAt, completedAt, archivedAt, resultsPublishedAt | Date | null | Detail only, except resultsPublishedAt which also appears on the list row. |
cancellationReason | string | null | Detail only; administrator-internal — never sent to a family. |
publicCancellationNote | string | null | Detail only; audience-visible — this and only this is what a portal cancellation view shows. |
archivedFromStatus | string | null | Detail only — which status (completed or cancelled) unarchive will restore. |
classes | ExamClassRefDto[] | Detail only. |
classCount | number | List only. |
version | string | Opaque token — see §3's version callout. |
createdAt, updatedAt | Date | Detail only. |
6.3 ExamClassRefDto / ReplaceExamClassesDto
| Field | Type | Notes |
|---|---|---|
classId | int | Internal PK — this endpoint family is the one place a bare integer class id appears in an exam response; every write still addresses classes by publicId. |
classPublicId | uuid | |
gradeName, sectionName, shift | string |
ReplaceExamClassesDto.classIds: uuid[], @IsUUID(each:true), @ArrayUnique() — a full replacement of the exam's class scope, validated against classes in the exam's OWN academic session. Removing a class that already has recorded marks anywhere under it is refused whole: 409 EXAM_CLASS_HAS_RECORDED_MARKS, naming every offending class.
6.4 ExamAttachmentUploadDto / ExamAttachmentDto
| Field | Type | Required | Notes |
|---|---|---|---|
file (upload) | multipart/form-data binary | Yes | One file per request; max size UPLOAD_MAX_FILE_SIZE_MB env var (default 50 MB); extension/MIME checked against the same allowlist Assignments uses (.pdf .doc .docx .txt .rtf .xls .xlsx .csv .ppt .pptx .jpg .jpeg .png .webp + more), rejected with 400 UPLOAD_VALIDATION_FAILED otherwise. |
visibility (upload) | "administrator_only" | "audience" | No | Defaults administrator_only. Set audience for a file the sitting classes' families may download. |
publicId (response) | uuid | ||
originalName, mimeType, sizeBytes | mixed | ||
visibility | enum | ||
sortOrder | int | ||
createdAt | Date |
Files land under uploads/exam-attachment/<uploaderUserId>/… (never public/) — private storage, matching the notice-attachment pattern; nothing about this route is ever a signed URL. The audience download routes read the bytes only after their own visibility check (§5).
6.5 VersionDto / CancelExamDto (lifecycle request bodies)
Every lifecycle POST except cancel takes only { version: string }. cancel extends it:
| Field | Type | Required | Notes |
|---|---|---|---|
version | string | Yes | |
reason | string | No | ≤500 chars; administrator-internal, never sent to a family. |
publicNote | string | No | ≤500 chars; audience-visible — surfaces as ExamDetailDto.publicCancellationNote and ExamPortal*Dto.publicCancellationNote. |
6.6 ExamTypeDto / CreateExamTypeDto / UpdateExamTypeDto
| Field | Type | Required (create) | Notes |
|---|---|---|---|
name | string | Yes | 1-120 chars. Active-scoped unique — 409 EXAM_TYPE_NAME_TAKEN. |
code | string | Yes | ^[A-Z0-9_]{1,16}$. Immutable after create — UpdateExamTypeDto does not accept it at all. Active-scoped unique — 409 EXAM_TYPE_CODE_TAKEN. |
sortOrder | int | No (default 0) | ≥0. |
isActive | boolean | No (default true) | Retiring a type (isActive: false) frees its name/code for reuse without deleting history — there is no deleted_at column on this table. |
Response (ExamTypeDto) adds id (internal int PK — the one deliberate exception on this table, matching PermissionDto.id), publicId, version (string), createdAt, updatedAt. DELETE is a genuine hard delete (ON DELETE restrict from exams.exam_type_id), refused with 409 EXAM_TYPE_IN_USE while any exam still references it, and — unlike every other delete route in this module — takes no version at all: DELETE /api/exam-types/:publicId carries no body and no ?version= query param.
6.7 GradingScaleDto and bands
| Field | Type | Required (create) | Validation | Notes |
|---|---|---|---|---|
name | string | Yes | ≤120 chars, no leading/trailing whitespace, no control chars; active-scoped unique — 409 GRADING_SCALE_NAME_TAKEN | |
usesGpa | boolean | Yes | ||
conditionalPassMaxFailedSubjects | int | No (default 0) | 0..10 | How many failed subjects a pupil may carry and still be passed_with_conditions rather than failed. |
isDefault | boolean | No (default false) | — | Setting true atomically clears the previous default inside the same transaction — never two rows with isDefault: true. |
isActive | boolean | update only |
Response GradingScaleDto: id (this is the scale's publicId, aliased — there is no separate internal-id field on the wire here), name, usesGpa, conditionalPassMaxFailedSubjects, isActive, isDefault, bands[], version (string).
GradingScaleBandDto (response, one per band): gradeLabel, minPercent, maxPercent (derived — the next-higher band's minPercent minus 0.01, or null for the top band; never stored, because "a band has a floor and no ceiling" is the schema's own construction), gradePoint (number | null), isPassing, sortOrder.
GradingScaleBandInputDto (request, for PUT :id/bands): gradeLabel (≤8 chars), minPercent (0..100, 2 decimals), gradePoint (optional, 0..5), isPassing (boolean), sortOrder (optional int). maxPercent is never accepted as input — sending it is silently dropped by whitelist... actually, the global forbidNonWhitelisted pipe means sending it 400s the whole request, since it is not a declared field.
ReplaceGradingScaleBandsDto: { version: string; bands: GradingScaleBandInputDto[] } (1-30 entries) — a full replacement inside one transaction (delete every existing band, insert the new set), versioned against the SCALE's own token since bands carry none of their own. PATCH/DELETE/PUT .../bands all use EXAM_VERSION_CONFLICT for a stale token (this module has no dedicated GRADING_SCALE_VERSION_CONFLICT code).
6.8 Routine entry DTOs
CreateExamRoutineEntryDto (POST /exams/:publicId/routine):
| Field | Type | Required | Notes |
|---|---|---|---|
classId | uuid | Yes | The class's publicId; must already be in the exam's exam_classes scope, else 404 CLASS_NOT_FOUND. |
classSubjectId | uuid | Yes | class_subjects.id (the uuid PK — no separate publicId on that table); must belong to classId, else 422 EXAM_ROUTINE_SUBJECT_NOT_TAUGHT. |
examDate | ISO date | Yes | Must fall within [exam.startDate, exam.endDate], else 422 EXAM_ROUTINE_DATE_OUTSIDE_PERIOD. |
startMinute, endMinute | int 0..1440 | Yes | endMinute must exceed startMinute. |
roomId | int | null | No | Integer rooms.id, never a publicId. |
examCenter, notes (audience-visible), internalNote (administrator-only) | string | null | No | ≤160 / ≤500 / ≤500 chars respectively; no control characters. |
Creating the entry ALSO inserts one default assessment component, copying class_subjects.max_marks/pass_marks — a simple subject is publishable with no further setup.
PatchExamRoutineEntryDto: every schedule/room/text field optional (an absent field keeps its stored value), plus a required version (int). classId/classSubjectId are never patchable — changing the subject a paper is for is a delete-and-recreate. Changing examDate/startMinute/endMinute/roomId on an entry whose exam is published/completed additionally requires the ExamPublish_UPDATE permission (403 PERMISSION_INSUFFICIENT otherwise) and emits an exam.schedule_changed notification; a text-only edit does neither.
SwapExamRoutineEntriesDto: { entryPublicId, version, otherEntryPublicId, otherVersion }, all versions int. Swaps two papers' examDate/startMinute/endMinute/roomId atomically using SET CONSTRAINTS … DEFERRED so neither UPDATE transiently collides with the other's old slot — see §11.
ExamRoutineEntryDto (response): publicId, classId (the class's publicId, confusingly reusing the same field name as the request DTO's internal-class-id meaning — the two are NOT the same shape, see the field-by-field distinction in the source), className, classSubjectId, subjectName, examDate, startMinute, endMinute, roomId (int | null), roomName (string | null), examCenter, notes, internalNote (this route is admin-only, so it IS included here — every portal DTO omits it), isCompleted, completedAt (ISO string | null), version (number, not a string — see §3), components[], invigilators[].
6.9 PutExamComponentsDto / ExamComponentDto
PutExamComponentDto (request row): kind (enum, §7), label (≤64 chars), maxMarks (0.01..1000, 2 decimals), passMarks (0..maxMarks), sortOrder (optional int), id (optional int — present to update an existing row, absent to create one), version (optional int — enforced ONLY when supplied, so a brand-new row added alongside existing ones in the same request carries no token to check). A row with an id not found on this entry is 404 EXAM_COMPONENT_NOT_FOUND; a stale version on one that IS found is 409 EXAM_VERSION_CONFLICT, naming which numbered component in the request failed. An existing row omitted from the request is deleted; if marks already exist against it the delete is refused whole: 409 EXAM_COMPONENT_HAS_RECORDED_MARKS. Lowering a kept component's maxMarks below a mark already recorded against it is refused: 422 EXAM_COMPONENT_CEILING_BELOW_RECORDED_MARK.
ExamComponentDto (response): id, kind, label, maxMarks (number), passMarks (number), sortOrder, version (number).
6.10 PutExamInvigilatorsDto / ExamInvigilatorDto
Request: { invigilators: [{ staffId: uuid, isChief?: boolean }] }, @ArrayUnique on staffId. Every staffId must resolve to a live (deletedAt IS NULL) staff row, else 404 EXAM_INVIGILATOR_NOT_FOUND, naming none individually but refusing the whole request. Full replacement inside a transaction; onConflictDoUpdate on (routineEntryId, staffId) so re-listing the same staff member only flips isChief.
Response ExamInvigilatorDto: staffId, staffName, employmentStatus (reported as-is even for a soft-deleted row, so a screen shows the person has left rather than presenting them as current staff), isChief.
6.11 ExamEntryAvailabilityDto
{ routineEntryPublicId, freeStaff: [{staffId, name}], freeRooms: [{id, name}] } — every active room and every active, non-deleted staff member NOT already booked (published/completed entries only) in the same date/time window as this entry, excluding this entry itself.
6.12 ExamConflictsReportDto / ExamPublishPreviewDto
ExamConflictReportRowDto: { kind: "room"|"class"|"invigilator", routineEntryPublicId, conflictsWith: ExamConflictsWithDto }. ExamConflictsWithDto: { routineEntryPublicId, examPublicId, examName, className, subjectName, examDate, startMinute, endMinute } — enough to find and fix the other paper, never a full entry dump.
ExamPublishPreviewDto: { examPublicId, classCount, pupilCount, subjectNames: string[], examDates: string[], canPublish: boolean, warnings: ExamPublishPreviewWarningDto[], conflicts: ExamConflictReportRowDto[] }. Warning kind is one of class_no_entries | entry_no_room | entry_no_invigilator | entry_no_components | not_school_day | grading_scale_no_floor_band — only entry_no_components, grading_scale_no_floor_band, and any non-empty conflicts set canPublish: false; the rest are cosmetic. publish itself is still the actual gate — this route mutates nothing.
6.13 Marks sheet DTOs
A teacher reads and writes their own marks through the audience surface, never through ExamMarks_*. The admin ExamMarksController/ExamMarksWorkflowController (GET/PUT /exam-routine-entries/:publicId/marks, POST .../marks/submit) is the unscoped, whole-school code — any holder of ExamMarks_UPDATE can write ANY paper's marks. TeacherExamMarksPortalController on /api/mobile/teacher/exams/:examPublicId/entries/:entryPublicId/marks* calls the exact same underlying ExamMarksSheetService/ExamMarksWorkflowService methods, but only after its OWN scoping predicate passes (class_subjects.teacher_id = me OR exam_marks_submissions.submitted_by_staff_id = me). Do not "fix" a teacher's 403 by granting them ExamMarks_UPDATE — that permanently hands every teacher every child's marks across the whole school, because grants are never revoked; the fix for a legitimately blocked teacher is in the scoping predicate, not the admin permission catalogue.
SaveExamMarksDto (PUT .../marks): { students: [{ studentId: uuid, components: [{ componentId: int, attendance: enum, obtainedMarks?: number|null, remarks?: string|null }] }] }. Each cell is a FULL replacement of that (student, component) pair — an omitted obtainedMarks/remarks on a resend CLEARS it, this is not a partial patch. obtainedMarks must be null when attendance !== "present" (422 EXAM_MARK_ABSENT_CANNOT_SCORE) and must not exceed the component's maxMarks (422 EXAM_MARK_EXCEEDS_CEILING). studentId must be on the paper's LIVE roll (§3), else 404 STUDENT_NOT_FOUND; componentId must belong to this entry, else 404 EXAM_COMPONENT_NOT_FOUND. The whole save is refused with 409 EXAM_MARKS_NOT_YET_OPEN while the gate is closed, and 409 EXAM_MARKS_NOT_EDITABLE once the submission has moved past draft/returned.
ExamMarksSheetDto (response, GET/PUT): routineEntryPublicId, components: ExamMarkComponentHeaderDto[] ({componentId, label, maxMarks, passMarks, sortOrder}), students: ExamMarkStudentRowDto[] ({studentId, studentName, admissionNumber, components: ExamMarkCellDto[]}), submission: ExamMarksSubmissionDto, opensAt (Date), isOpen (boolean), isEditable (boolean — isOpen AND submission is draft/returned; the one field that actually gates the client's PUT button).
ExamMarkCellDto: {componentId, markPublicId: uuid|null, attendance, obtainedMarks: number|null, remarks: string|null}. markPublicId is null and obtainedMarks is null until the first save creates the row — rows are created lazily, one per (component, student) actually saved, never pre-created for the whole roll. A pupil with no row at all reads as attendance: "present", obtainedMarks: null — the same shape a present-but-unscored pupil has, which is exactly why submit's completeness check treats "no row" and "a present row with a null mark" identically as incomplete.
null in obtainedMarks means NOT SCORED, never zero. A pupil marked absent never carries a mark at all (the schema forbids it — exam_marks_absent_has_no_marks, 422 EXAM_MARK_ABSENT_CANNOT_SCORE). This is true everywhere a mark or result field appears on the wire in this module: ExamMarkDetailDto.obtainedMarks, every ExamResult*Dto.totalObtained/percentage/gpa, ExamSubjectResultDto.obtained/percentage. A 0 is a genuine, scored zero; null is "there is nothing to report here."
ExamMarksSubmissionDto: {publicId: string|null, state, submittedAt, submittedByStaffId, reviewedAt, returnReason, finalizedAt, version: string}. A submission that has never been saved is represented VIRTUALLY — publicId: null, state: "draft", version: "0" — rather than 404ing, because "nobody has typed anything yet" is not an error condition.
6.14 Marks workflow request DTOs
ExamMarksVersionDto: { version: string } — used by submit/approve/finalize. ReturnExamMarksDto extends it with a required reason (1-500 chars) — unlike the column's own nullability, a return with no explanation leaves the teacher unable to act on it.
6.15 Mark correction DTOs
CorrectExamMarkDto (POST /exam-marks/:publicId/correct — :publicId here is the MARK's own publicId): { attendance: enum, obtainedMarks?: number|null, reason: string (1-500, required) }. Legal only once the paper's submission has reached finalized (409 EXAM_MARKS_TRANSITION_INVALID otherwise) — a correction to an unfinalized paper does not exist as a concept; the sheet is still directly editable. reason is required even though the column is nullable, "because an unattributed rewrite of a finalised academic record is exactly what this route exists to prevent" (source comment). Every correction is append-only: a new exam_mark_corrections row carrying both the previous and new values, never an update to a prior correction. Correcting a finalized mark marks every exam_results row for that EXAM isStale: true — the recompute is not automatic; see §6.16.
ExamMarkCorrectionDto (one row of GET .../corrections): {id, previousAttendance, previousObtainedMarks, newAttendance, newObtainedMarks, reason, correctedByUserPublicId, correctedAt}.
ExamMarkDetailDto (the mark, re-read after a correction — never the request echoed): {publicId, attendance, obtainedMarks: number|null, maxMarks, version: string}.
6.16 Result DTOs
ListExamResultsQueryDto extends QueryDto: adds only status (enum, §7). The source docblock comment on this class says ?status=&classId=, but classId is NOT a declared field — sending it 400s the whole request under the global forbidNonWhitelisted pipe. Treat the comment as stale relative to the code; status is the only filter this endpoint accepts beyond the shared pagination/sort/search fields.
ExamResultSummaryDto (GET .../results list row): id (the result's publicId), studentId, studentName, admissionNumber, className, totalObtained: number|null, totalMax, percentage: number|null, gpa: number|null, overallGradeLabel: string|null, passedSubjects, failedSubjects, status (enum, §7), isStale (boolean — true the moment any underlying mark is corrected, until the next compute), computedAt, version (string).
ExamResultDetailDto extends it with remarks: string|null and subjects: ExamSubjectResultDto[] — each carrying routineEntryId (despite the name, this is the routine entry's publicId), subjectName, obtained, max, percentage, gradeLabel, gradePoint, isPassing, attendance, countsTowardTotal, remarks, and componentBreakdown: ExamResultComponentBreakdownDto[] — a per-component snapshot (componentId, label, kind, max, obtained, attendance) frozen at compute time, independent of any later edit to the live component row.
WithholdOrReleaseResultDto: { version: string }. withhold simply flips status to withheld; release RE-DERIVES the overall status/percentage/GPA/pass-fail counts from the pupil's own unchanged exam_subject_results rows through the identical computeOverall function compute uses — there is no stored "status before withhold" column, so recomputation from the unchanged per-subject snapshot is the only source of truth that does not invent one.
ComputeResultsResponseDto (POST .../results/compute): { mode: "computed"|"queued", studentCount, resultCount: number|null }. compute is synchronous for a roll of 500 pupils or fewer (EXAM_SYNC_COMPUTE_MAX_STUDENTS); for a larger roll it enqueues ExamJob.RESULT_COMPUTE through the outbox and returns immediately with mode: "queued", resultCount: null — the caller must poll GET .../results afterward rather than trust the response body to reflect the finished computation. Every compute run — synchronous or queued — serializes through a per-exam Postgres advisory lock, so two overlapping compute requests for the same exam never race onto the same result rows.
publish refuses with 409 EXAM_RESULTS_NOT_ALL_FINALIZED while any routine entry under the exam lacks a finalized marks submission, and with 409 EXAM_RESULTS_STALE while any computed result is stale (a correction landed after the last compute) — both must be clear before results can go out. unpublish refuses 409 EXAM_RESULTS_NOT_PUBLISHED if nothing is published. Publishing/unpublishing does not delete or recompute anything; it only flips exams.results_published_at.
6.17 ExamNotificationRuleDto / UpdateExamNotificationRuleDto
Response: {event: enum(7 values, §7), enabled, channels: NotificationChannel[], notifyStudents, notifyGuardians, notifyTeachers, notifyInvigilators, invigilatorApplicable: boolean, version: string}. invigilatorApplicable is computed, never stored — true only for schedule_changed, cancelled, invigilation_assigned (INVIGILATOR_APPLICABLE_EVENTS); an invigilator has no interest in a result publication or an exam simply being published.
Request (PUT :event): { version: string, enabled, channels: NotificationChannel[] (≤4, unique, each ∈ the channel registry), notifyStudents, notifyGuardians, notifyTeachers, notifyInvigilators } — event comes from the route, never the body. An unknown channel is 400 EXAM_NOTIFICATION_RULE_INVALID; setting notifyInvigilators: true on an event not in the applicable set is also 400 EXAM_NOTIFICATION_RULE_INVALID. There is no create/delete on this resource — one row per event is seeded by migration, and :event not matching one of the 7 known values is 404 EXAM_NOTIFICATION_RULE_NOT_FOUND.
6.18 ExamReminderRuleDto / reminder-rule replacement DTOs
Response: {id, offsetMinutes, label: string|null, enabled, channels, version: string}. UpsertExamReminderRuleDto (one row of the PUT full-list replace): id? (omit to create), version? (required when id is given), offsetMinutes (15..43200, i.e. 15 minutes to 30 days before the paper), label? (≤64 chars), enabled, channels. ReplaceExamReminderRulesDto: { rules: UpsertExamReminderRuleDto[] }.
Two rules cannot share an offset (400 EXAM_REMINDER_RULE_INVALID), and two ENABLED rules must be at least 15 minutes apart — one sweep tick (409 EXAM_REMINDER_RULES_TOO_CLOSE) — because an offset closer than the sweep interval can fall entirely between two ticks and never dispatch, silently. Updating an existing rule with no id-match is 400 EXAM_REMINDER_RULE_INVALID; a missing or stale version on an existing rule is 409 EXAM_VERSION_CONFLICT.
6.19 ExamDashboardDto / ExamDashboardQueryDto
Query: { academicSessionId?: uuid } — defaults to academic_sessions.is_current; 404 ACADEMIC_SESSION_NOT_FOUND if given and unknown, or if omitted and no session is marked current. Response: {upcoming, ongoing, completed, cancelled, publishedResults, pendingMarks, pendingVerification} — all number. upcoming/ongoing split the published status by whether startDate is in the future; pendingMarks counts routine entries under a published/completed exam with no finalized marks submission; pendingVerification narrows that to submitted (awaiting an ExamMarksVerify holder).
6.20 ExamDayDto
{ date: string (school's own YYYY-MM-DD, never UTC-derived), entries: ExamDayEntryDto[] }. Each entry: {routineEntryPublicId, examPublicId, examName, className, subjectName, startMinute, endMinute, roomName: string|null, invigilatorNames: string[], isCompleted} — every paper sitting TODAY across every published/completed exam, gated on ExamRoutines_READ (the same code the routine builder uses), so invigilator identity is fine here even though it would not be on an audience-facing shape.
6.21 Portal shared DTOs (student/guardian)
ExamPortalListDto: {items: ExamPortalListItemDto[], truncated: boolean} — each item {publicId, name, examTypeName, status, startDate, endDate, resultsPublished: boolean}. truncated is true when the pupil's own exam count exceeds PaginationUtil.UNPAGINATED_HARD_CAP (1000) — detected with one extra row over the cap, never a second count(*).
ExamPortalDetailDto: {publicId, name, description, examTypeName, status, startDate, endDate, publicCancellationNote, resultsPublishedAt, entries: ExamPortalSubjectEntryDto[], attachments: ExamPortalAttachmentDto[]}. entries is scoped to the pupil's OWN class — never every class under the exam. ExamPortalSubjectEntryDto: {routineEntryPublicId, subjectName, examDate, startMinute, endMinute, examCenter, notes, room: ExamPortalRoomDto|null, isCompleted} — audience-visible fields only: notes, never internalNote.
ExamPortalResultDto: {examPublicId, examName, totalObtained, totalMax, percentage, gpa, overallGradeLabel, passedSubjects, failedSubjects, status, remarks, computedAt, subjects: ExamPortalSubjectResultDto[]} — served only once resultsPublishedAt IS NOT NULL AND the pupil's own result status <> 'withheld'; both conditions failing answer the identical 404 EXAM_NOT_FOUND (§5).
ExamPortalHistoryDto: {items: ExamPortalHistoryItemDto[], truncated} — every published, non-withheld result the pupil has EVER had, across every academic session; not a promotion/attendance transcript.
ExamPortalDashboardDto: {upcoming, nextExam: {publicId,name,startDate}|null, remindersCount?, pendingResultsCount?, publishedResultsCount}. remindersCount is read as "upcoming exams within the next 7 days" — a display aggregate, independent of exam_reminder_rules' own configured windows.
ExamPortalAttachmentDto: {publicId, originalName, mimeType, sizeBytes} — never storageKey. ExamPortalRoomDto: {building, floor, roomNumber}.
Every mapper in this file enumerates fields explicitly and never spreads a raw database row — internalNote, cancellationReason, and returnReason never reach any portal DTO, by construction rather than by a field being nulled at the edge.
6.22 Teacher portal DTOs
TeacherExamListDto/TeacherExamListItemDto: {publicId, name, examTypeName, status, startDate, endDate}. TeacherExamDetailDto adds entries: TeacherExamEntryDto[] and attachments; each entry carries role: "subject_teacher"|"invigilator" so the client can distinguish why this paper appears. TeacherMarksQueueDto/TeacherMarksQueueItemDto: {examPublicId, examName, routineEntryPublicId, className, subjectName, examDate, submissionState: "draft"|"returned"} — scoped to the subject-teacher half of the authorization predicate only (a display convenience; the submitted_by_staff_id half is what the write routes additionally honour). TeacherInvigilationListDto/TeacherInvigilationItemDto: never embeds a co-invigilator's identity or employee code — only the caller's own isChief flag. TeacherExamDashboardDto: {myExams, upcoming, marksPending, marksSubmitted, resultsPending}.
6.23 schedule/today DTOs (modules/mobile/teacher/)
TeacherTodayScheduleDto: {serverTime, timezone, date, bsDate: {year,month,day}, dayOfWeek, calendar: {classification, eventName, isOperatingDay}, periods: TeacherTodayPeriodDto[], currentPeriodId, nextPeriodId, nextTransitionAt, summary: {total, finished, remaining}}. Each TeacherTodayPeriodDto: {id, kind: "class"|"exam_invigilation"|"exam", state: "finished"|"in_progress"|"upcoming", startMinute, endMinute, startsAt, endsAt, className, subjectName, room, classPublicId, subjectId, classSubjectId}; the last three are the public uuids of the period's class, subject and class-subject, present for every kind. Three independent sources are merged and NEVER suppress one another — a class period and an exam duty legitimately overlap because they come from different tables; kind disambiguates. currentPeriodId ties break in favour of exam_invigilation over exam over class when two periods are in progress simultaneously. Finished periods are never dropped from the response — a teacher arriving mid-morning must still see how much of the day is done. An exam_invigilation period names the class being invigilated, which the caller may neither teach nor be class teacher of; opening that class's roster through GET /api/mobile/teacher/classes/:publicId/roster then answers 404, so a client must not assume every period's classPublicId is openable.
7. Enum Reference
| Enum | Value | Meaning | Runtime Effect | Source |
|---|---|---|---|---|
ExamStatus | draft|published|completed|cancelled|archived | Stored lifecycle | Gates every lifecycle transition's WHERE clause | exams.ts schema, exam-common.dto.ts |
ExamDisplayStatus | draft|scheduled|in_progress|completed|cancelled|archived | Derived, time-sensitive status shown to viewers | Computed per request from status+startDate+today; never persisted | exam-common.dto.ts (EXAM_DISPLAY_STATUSES) |
ExamAttachmentVisibility | administrator_only|audience | Who may download the file | Drives every audience download's 404-vs-200 decision | exam-attachment.dto.ts |
ExamComponentKind | theory|practical|internal|viva|project|other | The kind of assessment a component represents | Display grouping only; scoring treats every kind identically | exam-component.dto.ts |
MarksSubmissionState | draft|submitted|returned|approved|finalized | The paper's marks workflow state | Gates which transitions are legal and whether the sheet is editable | exam-marks.dto.ts (MARKS_SUBMISSION_STATES) |
ExamMarkAttendance | present|absent|withheld|not_applicable | Per-cell presence | present is the only value that may carry a non-null mark | exam-marks.dto.ts |
ExamResultStatus | passed|failed|passed_with_conditions|incomplete|absent|withheld | Computed (or manually withheld) overall outcome | Filter value on GET .../results; withheld is the only one a route sets directly rather than compute deriving | exam-result.dto.ts |
ExamNotificationEvent | published|schedule_changed|cancelled|invigilation_assigned|exam_tomorrow|exam_today|result_published | The 7 configurable notification-rule keys | One exam_notification_rules row each, seeded by migration | exam-notification-rule.dto.ts (EXAM_NOTIFICATION_EVENT) |
NotificationChannel | provider-defined set (imported from @skoolsewa/db) | Delivery channel(s) a rule may target | ≤4, unique, validated against the shared registry — a channel name this module does not itself define | notification-contract.ts |
Two internal notify occasions are not their own configurable row. The worker-side ExamNotifyEvent type additionally carries unpublished and marks_submitted, neither of which is in the 7-value settings enum above. unpublished deliberately reuses the schedule_changed rule row (confirmed at exam-notifier.service.ts's RULE_EVENT_BY_NOTIFY_EVENT map) rather than adding an eighth configurable event. marks_submitted is dispatched through its own dedicated code path (notifyMarksSubmitted) rather than through that same class-scoped rule map — this document does not assert which settings row, if any, gates it, because no direct read of that method's rule-resolution logic was made this session; treat that one detail as [UNVERIFIED] and confirm against exam-notifier.service.ts before building an integration that depends on it.
8. Endpoint Reference
Every route from §4 is covered here in full detail for the capability groups a reader is most likely to integrate against, and for every route (all 80) in the route-by-route matrix at §13.1. Where an admin route and its audience-portal mirror share the same underlying service (marks read/write/submit), the audience differences are called out inline rather than repeating the whole shape.
8.1 POST /api/exams
Purpose
Creates a new exam, always as a draft, for the admin panel's exam-creation form. Used before any routine, class scope, or grading is attached — every other write in this module operates on an exam this route already created.
Source Evidence
| Evidence | Path |
|---|---|
| Controller | apps/api/src/modules/exams/authoring/admin/exams.controller.ts |
| DTO | apps/api/src/modules/exams/authoring/core/dto/create-exam.dto.ts |
| Service | apps/api/src/modules/exams/authoring/core/exam-write.service.ts |
| Schema | packages/db/src/schema/school/exams.ts (exams) |
| Tests | apps/api/src/modules/exams/__tests__/ (integration specs) |
Auth and Permissions
- Auth: JWT required.
- Guard chain:
JwtAuthGuard,RoleGuard. - Permission:
Exams_CREATE. - Guest support: none.
- Rate limit: none.
- Idempotency: none — there is no
clientRequestIdon this DTO; a replayed request creates a second draft exam.
Request
| Part | Required | Details |
|---|---|---|
| Headers | Yes | Authorization: Bearer <token> |
| Body | Yes | CreateExamDto (§6.1) |
{
"academicSessionId": "01973a...",
"examTypeId": "01973b...",
"name": "First Terminal Examination 2083",
"startDate": "2026-09-20",
"endDate": "2026-09-30"
}Full request:
{
"academicSessionId": "01973a...",
"examTypeId": "01973b...",
"gradingScaleId": "01973c...",
"name": "First Terminal Examination 2083",
"description": "Covers units 1 through 6.",
"internalNote": "Room allocation pending facilities sign-off.",
"startDate": "2026-09-20",
"endDate": "2026-09-30"
}Response
201 with "Exam created.".
{
"success": true,
"message": "Exam created.",
"data": {
"publicId": "01973h...",
"status": "draft",
"displayStatus": "draft",
"classes": [],
"version": "0"
}
}(Full shape: ExamDetailDto, §6.2.)
Side Effects
- Database writes:
exams(insert). - Cache: none.
- BullMQ jobs: none.
- Realtime events: none.
- Notifications: none (a draft is invisible to every audience).
- Audit logs:
ActivityAuditInterceptorrecords the mutating request under moduleExams, actionCREATE. - External API calls: none.
Error Cases
| HTTP Status | Error Code | Condition | User-Facing Meaning | Source |
|---|---|---|---|---|
400 | class-validator errors | Missing/invalid field | Fix the request body | global ValidationPipe |
400 | VALIDATION_FAILED | endDate before startDate | Fix the dates | exam-write.service.ts:44 |
404 | ACADEMIC_SESSION_NOT_FOUND | academicSessionId does not resolve | Choose a valid session | exam-write.service.ts:57 |
409 | EXAM_SESSION_NOT_ACTIVE | The academic session is retired | Choose an active session | exam-write.service.ts:64 |
404 | EXAM_TYPE_NOT_FOUND | examTypeId does not resolve | Choose a valid exam type | exam-write.service.ts:76 |
404 | GRADING_SCALE_NOT_FOUND | gradingScaleId given but unknown | Choose a valid scale or omit it | exam-write.service.ts:90 |
403 | PERMISSION_INSUFFICIENT | Caller lacks Exams_CREATE | Not authorized | RoleGuard |
Edge Cases
- Empty
description/internalNotestring is stored asnull(trimmed-then-falsy collapse), not an empty string. startDate === endDateis legal — a one-day exam.- Omitting
gradingScaleIdproduces a marks-only exam: results still compute (obtained/max/percentage) but never carry agradeLabel/gradePoint. - Two concurrent creates with identical fields both succeed as two separate drafts — there is no de-duplication.
Example Requests
POST /api/exams HTTP/1.1
Authorization: Bearer TOKEN
Content-Type: application/jsoncurl -X POST "$API_URL/api/exams" \
-H "Authorization: Bearer TOKEN" \
-H "Content-Type: application/json" \
-d '{"academicSessionId":"...","examTypeId":"...","name":"First Terminal","startDate":"2026-09-20","endDate":"2026-09-30"}'8.2 POST /api/exams/:publicId/publish
Purpose
Publishes a draft exam, making it visible to every scoped pupil, guardian and teacher, and arming its routine entries' room/class/invigilator exclusion constraints all at once. The primary irreversible-feeling step in the exam lifecycle — call it only once routine building, components, invigilators and (if graded) the grading scale are all in place; GET .../publish-preview is the read-only rehearsal for this exact call.
Source Evidence
| Evidence | Path |
|---|---|
| Controller | apps/api/src/modules/exams/authoring/admin/exam-lifecycle.controller.ts |
| DTO | apps/api/src/modules/exams/authoring/core/dto/exam-lifecycle.dto.ts (VersionDto) |
| Service | apps/api/src/modules/exams/authoring/core/exam-lifecycle.service.ts |
| Schema | packages/db/src/schema/school/exams.ts (exams, exam_routine_entries exclusion constraints) |
| Tests | apps/api/src/modules/exams/__tests__/ |
Auth and Permissions
- Auth: JWT required.
- Guard chain:
JwtAuthGuard,RoleGuard. - Permission:
ExamPublish_UPDATE— deliberately separate fromExams_UPDATE, so an ordinary editor cannot make an exam live. - Guest support: none.
- Rate limit: none.
- Idempotency: none by request shape; a repeat call with the same (now stale)
versionis a version conflict, not a silent no-op.
Request
| Part | Required | Details |
|---|---|---|
| Body | Yes | VersionDto — { version: string } |
{ "version": "0" }Response
200 with "Exam published." and the full ExamDetailDto.
Side Effects
- Database writes:
exams(status→published,publishedAt,publishedByUserId), cascadesexam_statusonto everyexam_routine_entries/exam_invigilatorsrow for this exam in the same statement. - Cache: none.
- BullMQ jobs:
ExamJob.PUBLISH_NOTIFYenqueued via the outbox in the same transaction, deduped onexam-published-<publicId>-v<version>. - Realtime events: none.
- Notifications:
exam.published— see §11. - Audit logs:
ActivityAuditInterceptor, moduleExamPublish, actionUPDATE. - External API calls: none.
Error Cases
| HTTP Status | Error Code | Condition | User-Facing Meaning | Source |
|---|---|---|---|---|
404 | EXAM_NOT_FOUND | No exam with that id | Not found | exam-lifecycle.service.ts:49 |
409 | EXAM_HAS_NO_CLASSES | No class in scope | Attach at least one class first | :98 |
409 | EXAM_ROUTINE_ENTRY_HAS_NO_COMPONENTS | At least one paper has zero assessment components | Add a component to every paper | :125 |
409 | GRADING_SCALE_HAS_NO_FLOOR_BAND | The attached scale has no band starting at 0% | Fix the grading scale | :142 |
409 | EXAM_ALREADY_PUBLISHED | Already published | No action needed | :186 |
409 | EXAM_STATE_TRANSITION_INVALID | Any other non-draft status | Publish is only available from draft | :189 (invalidTransition()) |
409 | EXAM_ROOM_CONFLICT | Publishing would double-book a room | Reassign the room | :201 |
409 | EXAM_CLASS_CONFLICT | Publishing would clash a class's own schedule | Reschedule one paper | :211 |
409 | EXAM_INVIGILATOR_CONFLICT | Publishing would double-book an invigilator | Reassign the invigilator | :222 |
409 | EXAM_VERSION_CONFLICT | Stale version | Reload and retry | :55 (versionConflict()) |
Edge Cases
- A conflict entirely INSIDE the batch being published (two of this exam's own papers colliding with each other, neither yet published) is caught by the second pass of the two-pass detector — see §3, §11.
- Publishing an exam with
gradingScaleId: nullnever checksGRADING_SCALE_HAS_NO_FLOOR_BAND— that check only runs when a scale is attached. - A pupil enrolled into a scoped class AFTER publish still appears on every subsequent marks/results read — the roll is read live, never snapshotted at publish.
Example Requests
curl -X POST "$API_URL/api/exams/$EXAM_ID/publish" \
-H "Authorization: Bearer TOKEN" -H "Content-Type: application/json" \
-d '{"version":"0"}'8.3 PUT /api/exam-routine-entries/:publicId/marks (admin) and the teacher-portal mirror
Purpose
Saves a paper's marks sheet — one row per pupil on the live roll, one cell per assessment component. The admin route (ExamMarks_UPDATE) is unscoped across the whole school; PUT /api/mobile/teacher/exams/:examPublicId/entries/:entryPublicId/marks calls the identical underlying service after its own scoping predicate confirms the caller is the paper's current subject teacher or already submitted marks for it (§6.13).
Source Evidence
| Evidence | Path |
|---|---|
| Controllers | apps/api/src/modules/exams/marks/admin/exam-marks.controller.ts, apps/api/src/modules/exams/portal/teacher/teacher-exam-marks-portal.controller.ts |
| DTO | apps/api/src/modules/exams/marks/core/dto/exam-marks.dto.ts |
| Service | apps/api/src/modules/exams/marks/core/exam-marks-sheet.service.ts, apps/api/src/modules/exams/portal/teacher/teacher-exam-marks-portal.service.ts |
| Schema | packages/db/src/schema/school/exams.ts (exam_marks, exam_marks_submissions) |
| Tests | apps/api/src/modules/exams/__tests__/ |
Auth and Permissions
- Auth: JWT required.
- Guard chain:
JwtAuthGuard,RoleGuard. - Permission: admin route
ExamMarks_UPDATE; teacher route none (scoping predicate is the control). - Guest support: none.
- Rate limit: none.
- Idempotency: a resend is a full replacement per cell — resending the same payload is a safe no-op; resending WITHOUT a previously-saved cell clears it.
Request
{
"students": [
{
"studentId": "01973s...",
"components": [
{ "componentId": 12, "attendance": "present", "obtainedMarks": 62 }
]
}
]
}Response
200 with "Marks saved." and the full ExamMarksSheetDto (§6.13), re-read after the write.
Side Effects
- Database writes:
exam_marksupsert on(componentId, studentId). - Cache: none.
- BullMQ jobs: none directly (submission, not save, is what notifies).
- Notifications: none.
- Audit logs:
ActivityAuditInterceptor, moduleExamMarks, actionUPDATE(admin route) or the teacher-portal's own handler key.
Error Cases
| HTTP Status | Error Code | Condition | User-Facing Meaning | Source |
|---|---|---|---|---|
404 | EXAM_ROUTINE_ENTRY_NOT_FOUND | Entry does not exist (admin), or — on the teacher route — exists but the caller is not scoped to it (identical 404 either way) | Not found | exam-shared-resolvers.service.ts:120, teacher-exam-marks-portal.service.ts:26 |
409 | EXAM_MARKS_NOT_YET_OPEN | The gate (§3) is closed | Wait until the paper's end time, or ask an admin to mark it complete early | exam-marks-sheet.service.ts:112 |
409 | EXAM_MARKS_NOT_EDITABLE | Submission state is past draft/returned | The sheet is locked — ask a verifier to return it | :122 |
404 | STUDENT_NOT_FOUND | studentId not on the live roll | Refresh the roster | :136 |
404 | EXAM_COMPONENT_NOT_FOUND | componentId not on this entry | Refresh the component list | :145 |
422 | EXAM_MARK_ABSENT_CANNOT_SCORE | Non-present attendance with a non-null mark | Clear the mark or set attendance to present | :152 |
422 | EXAM_MARK_EXCEEDS_CEILING | obtainedMarks exceeds the component's maxMarks | Lower the mark | :161 |
Edge Cases
- A cell for a pupil who has since left the class (no longer on the live roll) 404s the WHOLE save, not just that cell — the request is all-or-nothing.
- Sending
obtainedMarks: nullon apresentrow clears a previously-saved score back to "not scored", which is distinct from0. - The teacher route's
entryPublicIdnot belonging to theexamPublicIdin the URL is refused identically to "not my paper" —404 EXAM_ROUTINE_ENTRY_NOT_FOUND, never a more specific mismatch error.
Example Requests
curl -X PUT "$API_URL/api/mobile/teacher/exams/$EXAM_ID/entries/$ENTRY_ID/marks" \
-H "Authorization: Bearer TOKEN" -H "Content-Type: application/json" \
-d '{"students":[{"studentId":"...","components":[{"componentId":12,"attendance":"present","obtainedMarks":62}]}]}'8.4 POST /api/exam-routine-entries/:publicId/marks/submit (and the teacher-portal mirror)
Purpose
Moves a paper's marks from draft/returned into submitted, the point at which an ExamMarksVerify holder can act on them. Refuses if any present pupil is still missing a mark, so a verifier never receives a visibly incomplete sheet.
Source Evidence
| Evidence | Path |
|---|---|
| Controllers | apps/api/src/modules/exams/marks/admin/exam-marks-workflow.controller.ts, apps/api/src/modules/exams/portal/teacher/teacher-exam-marks-portal.controller.ts |
| DTO | apps/api/src/modules/exams/marks/core/dto/exam-marks-workflow.dto.ts |
| Service | apps/api/src/modules/exams/marks/core/exam-marks-workflow.service.ts |
| Tests | apps/api/src/modules/exams/__tests__/ |
Auth and Permissions
- Permission: admin
ExamMarks_UPDATE; teacher route none (scoping predicate). - Idempotency: none — a repeat submit against an already-
submittedstate is409 EXAM_MARKS_TRANSITION_INVALID.
Request
{ "version": "0" }The teacher route's controller supplies the caller's own staff.id as submittedByStaffId automatically; the admin route never supplies one (it stays null — "the unscoped administrator route" per source comment).
Response
200 with "Marks submitted." and ExamMarksSubmissionDto.
Side Effects
- Database writes:
exam_marks_submissions(created if absent, viaonConflictDoNothingracing safely against a concurrent first save; then transitioned tosubmitted). - BullMQ jobs:
ExamJob.MARKS_SUBMITTED_NOTIFYvia the outbox, deduped onexam-marks-submitted-<publicId>-v<version>. - Notifications:
exam.marks_submitted. - Audit logs: yes.
Error Cases
| HTTP Status | Error Code | Condition | Source |
|---|---|---|---|
409 | EXAM_MARKS_NOT_YET_OPEN | Gate closed | :104 |
409 | EXAM_MARKS_INCOMPLETE | ≥1 present pupil has no mark on ≥1 component | :111, names every missing pupil in details.students |
409 | EXAM_MARKS_TRANSITION_INVALID | Current state is not draft/returned | :119 |
409 | EXAM_MARKS_SUBMISSION_VERSION_CONFLICT | Stale version | :286 (versionConflict()) |
409 | EXAM_MARKS_SUBMISSION_NOT_FOUND | Internal-only: never externally reachable from submit, since it always gets-or-creates first; reachable from return/approve/finalize against an entry with no submission row at all | :284 |
Edge Cases
getOrCreateSubmissionloses a race to a concurrent first save gracefully — it re-reads the winner's row rather than erroring.- The completeness check treats a pupil with NO
exam_marksrow identically to one with apresent/nullrow — both count as incomplete.
8.5 GET /api/exams/:publicId/results and POST /api/exams/:publicId/results/compute
Purpose
GET lists every computed result for the exam, filterable by status; compute (re-)runs the scoring for every pupil on the exam's live roll. An administrator calls compute after every paper's marks are finalized, then GET to review before publish.
Source Evidence
| Evidence | Path |
|---|---|
| Controller | apps/api/src/modules/exams/results/admin/exam-results.controller.ts |
| DTO | apps/api/src/modules/exams/results/core/dto/exam-result.dto.ts |
| Services | apps/api/src/modules/exams/results/core/exam-results.service.ts, exam-results-compute.service.ts |
| Schema | packages/db/src/schema/school/exams.ts (exam_results, exam_subject_results) |
Auth and Permissions
- Permission:
ExamResults_READ(list/detail),ExamResults_UPDATE(compute).
Request (list)
| Part | Required | Details |
|---|---|---|
| Params | Yes | :publicId — the exam. |
| Query | No | status? (enum, §7) plus shared page/size/sort/order/search/pagination. |
Response (list)
{
"success": true,
"message": "Exam results fetched.",
"data": [
{
"id": "01973r...",
"studentId": "01973s...",
"studentName": "Aarav Sharma",
"admissionNumber": "SID-2026-0005",
"className": "Grade 5 - A",
"totalObtained": 412,
"totalMax": 500,
"percentage": 82.4,
"gpa": 3.6,
"overallGradeLabel": "A",
"passedSubjects": 4,
"failedSubjects": 1,
"status": "passed",
"isStale": false,
"computedAt": "2026-10-01T09:00:00.000Z",
"version": "1"
}
],
"meta": { "count": 42, "page": 1, "size": 20 }
}Response (compute)
{ "success": true, "message": "Result computation finished.", "data": { "mode": "computed", "studentCount": 42, "resultCount": 42 } }or, over the synchronous roll cap:
{ "success": true, "message": "Result computation finished.", "data": { "mode": "queued", "studentCount": 812, "resultCount": null } }Side Effects
- Database writes (compute):
exam_resultsupsert on(examId, studentId),exam_subject_resultsdelete-then-reinsert per pupil, inside a per-exam Postgres advisory-lock transaction. - BullMQ jobs:
ExamJob.RESULT_COMPUTEwhen the roll exceeds 500 pupils, deduped per exam so a repeated request while the first has not dispatched is a no-op. - Notifications: none directly from compute (publish is what notifies).
Error Cases
| HTTP Status | Error Code | Condition | Source |
|---|---|---|---|
404 | EXAM_NOT_FOUND | Exam does not exist | exam-results.service.ts:112, exam-results-compute.service.ts:143 |
Neither list nor compute has any other named error path in the code read this session — an empty roll simply computes zero rows (resultCount: 0), not an error. compute on an unlocked academic year runs normally. When the queued (>500-pupil) path picks up a job for an exam whose year has since been locked, ExamResultComputeAdapter does not throw: it leaves the results as they are and logs a warning, so a caller polling GET .../results after a mode:"queued" response for a year that was locked mid-flight sees no result rows rather than an error.
Every write endpoint under this module — create, publish, routine, marks, results, attachments — refuses with 409 ACADEMIC_SESSION_LOCKED once the exam's academic year is locked; see academic-sessions/backend.mdx for the trigger mechanism and exams/backend.mdx §11 for the exam-specific table list.
Edge Cases
- Recomputing an exam that already has results overwrites every value via
onConflictDoUpdate, but deliberately never overwritesclassId— that column records which class the pupil SAT the exam in, at first compute, permanently. - A
withheldresult IS still returned byGET .../resultsto an admin caller (only the audience surface hides it). isStale: truepersists until the nextcomputecall — nothing clears it automatically, andpublishrefuses outright while any result is stale.
8.6 GET /api/mobile/student/exams/:examPublicId/result (and the guardian mirror)
Purpose
A pupil (or their guardian) reading their own published result for one exam — the single most consumed audience read in this module, since it is the entire reason a school publishes results at all.
Source Evidence
| Evidence | Path |
|---|---|
| Controllers | apps/api/src/modules/exams/portal/student/student-exams-portal.controller.ts, apps/api/src/modules/exams/portal/guardian/guardian-exams-portal.controller.ts |
| Service | apps/api/src/modules/exams/portal/core/exam-portal-reads.service.ts (getExamResult), student-exams-portal.service.ts, guardian-exams-portal.service.ts |
| DTO | apps/api/src/modules/exams/portal/core/dto/exam-portal.dto.ts |
Auth and Permissions
- Auth: JWT required; no
@Permissions(). - Guard chain:
JwtAuthGuard,RoleGuard; audience asserted viaassertPortalAudience/GuardianPortalService.resolveOwnChildId. - Rate limit: none.
Request
| Part | Required | Details |
|---|---|---|
| Params | Yes (guardian only) | :id — the child's students.id. |
| Params | Yes | :examPublicId. |
Response
200, ExamPortalResultDto (§6.21) — remarks here is the AUDIENCE-visible one, distinct from any internal note; every field that could carry a per-subject breakdown returns the pupil's own subjects array only.
Error Cases
| HTTP Status | Error Code | Condition | Source |
|---|---|---|---|
404 | EXAM_NOT_FOUND | Exam does not exist, OR the pupil's class was never in its scope, OR results are not yet published for this exam, OR the pupil's own result is withheld — deliberately the SAME code for all four | exam-portal-reads.service.ts:58,183,197 |
No other error path exists on this route — a guardian's child-binding failure short-circuits before this service is ever called (GuardianPortalService.resolveOwnChildId's own 404).
Edge Cases
- A pupil whose result was computed but never published (results-publish not yet run) gets the identical 404 as one whose exam does not exist.
- A pupil's own result being
withheldis invisible to them but still visible to an admin via §8.5 — an intentional divergence, not a bug. - A guardian viewing a linked child who has since been unlinked gets
404from the child-resolution step, before this route's own logic ever runs.
9. Flow Diagrams
9.1 Route Ownership
9.2 Request Sequence — publish
9.3 Request Sequence — marks submission state machine
9.4 Error Branch — audience exam read
9.5 Activity Diagram — result publish
9.6 Data Contract Map — routine entry create
9.7 Cache Flow
No route in this module reads or writes a Redis cache key. Every read hits PostgreSQL directly.
9.8 Async/Job Flow — result compute over the sync cap
10. Pagination, Sorting, Filtering, and Search
| Endpoint | Pagination Type | Default Size | Max Size | Sort Fields | Filters | Result Cap |
|---|---|---|---|---|---|---|
GET /exams | page/size | 20 | 100 | updatedAt (default), name, startDate | search, academicSessionId, examTypeId, status (7-value display-status vocabulary, translated to SQL) | none beyond page size |
GET /exam-types | page/size | 20 | 100 | sortOrder (default), name | search, isActive | pagination=false allowed, capped 1000 |
GET /grading-scales | page/size | 20 | 100 | n/a (always name asc) | includeInactive (boolean) | pagination=false allowed, capped 1000 |
GET /exams/:id/results | page/size | 20 | 100 | n/a (always pupil name asc) | status only — not classId despite a stale source comment (§6.16) | none beyond page size |
GET .../student/exams, .../guardian/children/:id/exams | none (unpaginated) | — | — | fixed: startDate desc | none | 1000, with truncated: true on overflow |
GET .../teacher/exams, .../teacher/invigilations | none (unpaginated) | — | — | fixed | none | 1000, with truncated |
GET .../teacher/exams/marks-queue | none | — | — | fixed: exam date/start-minute asc | none | no cap — bounded by the caller's own live teaching load |
Every list/query DTO in this module runs through the global ValidationPipe's whitelist+forbidNonWhitelisted: a query parameter the DTO does not declare 400s the WHOLE request rather than being silently ignored. GET /exams accepts only search, academicSessionId, examTypeId, status beyond the shared pagination fields — nothing else. There is no broad-search relevance scoring anywhere in this module; search is a plain case-insensitive ILIKE on the name column, and an empty search string is trimmed to undefined by the shared QueryDto transform (no filter applied).
11. Caching, Jobs, and External Integrations
| Integration | Used? | Details | Source |
|---|---|---|---|
| Redis cache | No | No route in this module reads or writes a cache key. | — |
| BullMQ | Yes | QueueName.EXAMS, ExamJob enum (9 members): REMINDER_SWEEP (cron, every 15 min — dispatches enabled exam_reminder_rules offsets), LIFECYCLE_SWEEP (cron, hourly — auto-completes an exam past its last paper's end and fires date-triggered tomorrow/today notifications), PUBLISH_NOTIFY, SCHEDULE_CHANGE_NOTIFY, CANCEL_NOTIFY, INVIGILATION_NOTIFY, MARKS_SUBMITTED_NOTIFY, RESULT_PUBLISH_NOTIFY (all outbox-enqueued from the write route that causes them), RESULT_COMPUTE (the asynchronous path for a roll over 500 pupils). Every enqueue in this module goes through the transactional outbox — never a direct queue.add() in a request handler. | packages/jobs/src/index.ts:1303-1332 |
| External API | No | Attachment bytes go through @skoolsewa/storage's local/disk-backed manager, not a third-party object store integration distinct from what every other upload-bearing module in this repo uses. | exam-attachments.multer.ts |
Two Postgres advisory locks are load-bearing and worth knowing about even though they are not "jobs": every results/compute run (sync or async) takes a per-exam pg_advisory_xact_lock, and the swap route (POST /exam-routine-entries/swap) uses SET CONSTRAINTS ... DEFERRED inside its own transaction rather than a lock, to let two UPDATEs land with a transient overlap before the exclusion constraints are checked for real.
13. Mandatory Deep API Documentation Pack
13.1 Route-by-Route Completeness Matrix
| Route | Controller Method | DTOs | Service Method | Guards | Permissions | Cache | Jobs | Errors | Documented? |
|---|---|---|---|---|---|---|---|---|---|
GET /api/exam-types | ExamTypesController.findAll | ListExamTypesQueryDto→ExamTypeDto[] | ExamTypesService.findAll | JWT+Role | ExamTypes_READ | N/A | N/A | — | Yes |
POST /api/exam-types | ExamTypesController.create | CreateExamTypeDto→ExamTypeDto | ExamTypesService.create | JWT+Role | ExamTypes_CREATE | N/A | N/A | EXAM_TYPE_NAME_TAKEN, EXAM_TYPE_CODE_TAKEN | §6.6 |
PATCH /api/exam-types/:publicId | .update | UpdateExamTypeDto→ExamTypeDto | .update | JWT+Role | ExamTypes_UPDATE | N/A | N/A | EXAM_TYPE_NOT_FOUND, name/code taken | §6.6 |
DELETE /api/exam-types/:publicId | .remove | none→message | .remove | JWT+Role | ExamTypes_DELETE | N/A | N/A | EXAM_TYPE_NOT_FOUND, EXAM_TYPE_IN_USE | §6.6 |
GET /api/exams | ExamsController.findAll | ListExamsQueryDto→ExamListItemDto[] | ExamDetailService.list | JWT+Role | Exams_READ | N/A | N/A | — | §10 |
POST /api/exams | .create | CreateExamDto→ExamDetailDto | ExamWriteService.create | JWT+Role | Exams_CREATE | N/A | N/A | see §8.1 | §8.1 |
GET /api/exams/:publicId | .findOne | →ExamDetailDto | ExamDetailService.detail | JWT+Role | Exams_READ | N/A | N/A | EXAM_NOT_FOUND | §6.2 |
PATCH /api/exams/:publicId | .update | UpdateExamDto→ExamDetailDto | ExamWriteService.update | JWT+Role | Exams_UPDATE | N/A | N/A | EXAM_TYPE_NOT_FOUND, GRADING_SCALE_NOT_FOUND, EXAM_PERIOD_HAS_ENTRIES_OUTSIDE, EXAM_VERSION_CONFLICT, VALIDATION_FAILED | §6.1a |
DELETE /api/exams/:publicId | .remove | ?version=→message | .remove | JWT+Role | Exams_DELETE | N/A | N/A | EXAM_VERSION_CONFLICT, EXAM_STATE_TRANSITION_INVALID | §6.2 |
POST /api/exams/:publicId/publish | ExamLifecycleController.publish | VersionDto→ExamDetailDto | ExamLifecycleService.publish | JWT+Role | ExamPublish_UPDATE | N/A | PUBLISH_NOTIFY | see §8.2 | §8.2 |
POST /api/exams/:publicId/unpublish | .unpublish | VersionDto→ExamDetailDto | .unpublish | JWT+Role | ExamPublish_UPDATE | N/A | N/A | EXAM_CANNOT_UNPUBLISH_WITH_FINALIZED_MARKS, EXAM_NOT_PUBLISHED, EXAM_STATE_TRANSITION_INVALID, EXAM_VERSION_CONFLICT | §6.5 |
POST /api/exams/:publicId/cancel | .cancel | CancelExamDto→ExamDetailDto | .cancel | JWT+Role | ExamCancel_UPDATE | N/A | CANCEL_NOTIFY | EXAM_CANNOT_CANCEL_AFTER_RESULTS_PUBLISHED, EXAM_STATE_TRANSITION_INVALID, EXAM_VERSION_CONFLICT | §6.5 |
POST /api/exams/:publicId/reinstate | .reinstate | VersionDto→ExamDetailDto | .reinstate | JWT+Role | ExamCancel_UPDATE | N/A | N/A | EXAM_STATE_TRANSITION_INVALID, EXAM_VERSION_CONFLICT | §6.5 |
POST /api/exams/:publicId/complete | .complete | VersionDto→ExamDetailDto | .complete | JWT+Role | Exams_UPDATE | N/A | N/A | EXAM_CANNOT_COMPLETE_BEFORE_LAST_PAPER, EXAM_STATE_TRANSITION_INVALID, EXAM_VERSION_CONFLICT | §6.5 |
POST /api/exams/:publicId/archive | .archive | VersionDto→ExamDetailDto | .archive | JWT+Role | Exams_UPDATE | N/A | N/A | EXAM_STATE_TRANSITION_INVALID, EXAM_VERSION_CONFLICT | §6.5 |
POST /api/exams/:publicId/unarchive | .unarchive | VersionDto→ExamDetailDto | .unarchive | JWT+Role | Exams_UPDATE | N/A | N/A | EXAM_STATE_TRANSITION_INVALID, EXAM_VERSION_CONFLICT | §6.5 |
GET /api/exams/:publicId/classes | ExamClassesController.list | →ExamClassRefDto[] | ExamClassesService.list | JWT+Role | Exams_READ | N/A | N/A | EXAM_NOT_FOUND | §6.3 |
PUT /api/exams/:publicId/classes | .replace | ReplaceExamClassesDto→ExamClassRefDto[] | .replace | JWT+Role | Exams_UPDATE | N/A | N/A | VALIDATION_FAILED, EXAM_CLASS_HAS_RECORDED_MARKS | §6.3 |
GET /api/exams/:publicId/attachments | ExamAttachmentsController.list | →ExamAttachmentDto[] | ExamAttachmentsService.list | JWT+Role | Exams_READ | N/A | N/A | EXAM_NOT_FOUND | §6.4 |
POST /api/exams/:publicId/attachments | .upload | multipart→ExamAttachmentDto | .upload | JWT+Role | Exams_UPDATE | N/A | N/A | UPLOAD_FILE_REQUIRED, UPLOAD_VALIDATION_FAILED, UPLOAD_STORAGE_FAILED | §6.4 |
DELETE /api/exams/:publicId/attachments/:attachmentPublicId | .remove | →message | .remove | JWT+Role | Exams_UPDATE | N/A | N/A | EXAM_ATTACHMENT_NOT_FOUND | §6.4 |
GET /api/exams/:publicId/routine | ExamRoutineController.list | →ExamRoutineEntryDto[] | ExamRoutineReadService.listForExam | JWT+Role | ExamRoutines_READ | N/A | N/A | EXAM_NOT_FOUND | §6.8 |
POST /api/exams/:publicId/routine | .create | CreateExamRoutineEntryDto→ExamRoutineEntryDto | ExamRoutineWriteService.create | JWT+Role | ExamRoutines_CREATE | N/A | N/A | EXAM_ROUTINE_DATE_OUTSIDE_PERIOD, CLASS_NOT_FOUND, EXAM_ROUTINE_SUBJECT_NOT_TAUGHT, EXAM_ROOM_CONFLICT/EXAM_CLASS_CONFLICT/EXAM_INVIGILATOR_CONFLICT (published exam only), PERMISSION_INSUFFICIENT | §6.8 |
GET /api/exams/:publicId/conflicts | .conflicts | →ExamConflictsReportDto | ExamPublishPreviewReadService.conflictsReport | JWT+Role | ExamRoutines_READ | N/A | N/A | EXAM_NOT_FOUND | §6.12 |
GET /api/exams/:publicId/publish-preview | .publishPreview | →ExamPublishPreviewDto | .publishPreview | JWT+Role | Exams_READ | N/A | N/A | EXAM_NOT_FOUND | §6.12 |
POST /api/exam-routine-entries/swap | ExamRoutineEntryController.swap | SwapExamRoutineEntriesDto→{entry, otherEntry} | ExamRoutineSwapService.swap | JWT+Role | ExamRoutines_UPDATE | N/A | SCHEDULE_CHANGE_NOTIFY ×2 (published only) | EXAM_ROUTINE_ENTRY_STATE_CHANGED, EXAM_ROOM_CONFLICT/EXAM_CLASS_CONFLICT/EXAM_INVIGILATOR_CONFLICT, PERMISSION_INSUFFICIENT | §6.8 |
PATCH /api/exam-routine-entries/:publicId | .patch | PatchExamRoutineEntryDto→ExamRoutineEntryDto | ExamRoutineWriteService.patch | JWT+Role | ExamRoutines_UPDATE | N/A | SCHEDULE_CHANGE_NOTIFY (published, schedule fields only) | EXAM_ROUTINE_ENTRY_STATE_CHANGED, conflict codes, PERMISSION_INSUFFICIENT | §6.8 |
DELETE /api/exam-routine-entries/:publicId | .remove | →message | .remove | JWT+Role | ExamRoutines_DELETE | N/A | SCHEDULE_CHANGE_NOTIFY (published only) | EXAM_ROUTINE_ENTRY_STATE_CHANGED, EXAM_ROUTINE_SUBJECT_NOT_TAUGHT/CLASS_NOT_FOUND (defensive), EXAM_COMPONENT_HAS_RECORDED_MARKS, PERMISSION_INSUFFICIENT | §6.8 |
POST /api/exam-routine-entries/:publicId/complete | .complete | →ExamRoutineEntryDto | .complete | JWT+Role | ExamRoutines_UPDATE | N/A | N/A | EXAM_ROUTINE_ENTRY_NOT_FOUND (idempotent success if already complete) | §4 |
GET /api/exam-routine-entries/:publicId/availability | .availability | →ExamEntryAvailabilityDto | ExamRoutineReadService.availability | JWT+Role | ExamRoutines_READ | N/A | N/A | EXAM_ROUTINE_ENTRY_NOT_FOUND | §6.11 |
PUT /api/exam-routine-entries/:publicId/components | .replaceComponents | PutExamComponentsDto→ExamComponentDto[] | ExamComponentsWriteService.replace | JWT+Role | ExamRoutines_UPDATE | N/A | N/A | EXAM_COMPONENT_NOT_FOUND, EXAM_VERSION_CONFLICT, EXAM_COMPONENT_HAS_RECORDED_MARKS, EXAM_COMPONENT_CEILING_BELOW_RECORDED_MARK, PERMISSION_INSUFFICIENT | §6.9 |
PUT /api/exam-routine-entries/:publicId/invigilators | .replaceInvigilators | PutExamInvigilatorsDto→ExamInvigilatorDto[] | ExamInvigilatorsWriteService.replace | JWT+Role | ExamRoutines_UPDATE | N/A | INVIGILATION_NOTIFY (published, on change) | EXAM_INVIGILATOR_NOT_FOUND, conflict codes, PERMISSION_INSUFFICIENT | §6.10 |
GET /api/exam-day | ExamDayController.today | →ExamDayDto | ExamDayReadService.today | JWT+Role | ExamRoutines_READ | N/A | N/A | — | §6.20 |
GET /api/exam-dashboard | ExamDashboardController.totals | ExamDashboardQueryDto→ExamDashboardDto | ExamDashboardService.totals | JWT+Role | Exams_READ | N/A | N/A | ACADEMIC_SESSION_NOT_FOUND | §6.19 |
GET /api/grading-scales | GradingScalesController.list | ListGradingScalesQueryDto→GradingScaleDto[] | GradingScalesService.list | JWT+Role | ExamGrading_READ | N/A | N/A | — | §6.7 |
POST /api/grading-scales | .create | CreateGradingScaleDto→GradingScaleDto | .create | JWT+Role | ExamGrading_CREATE | N/A | N/A | GRADING_SCALE_NAME_TAKEN | §6.7 |
PATCH /api/grading-scales/:publicId | .update | UpdateGradingScaleDto→GradingScaleDto | .update | JWT+Role | ExamGrading_UPDATE | N/A | N/A | GRADING_SCALE_NOT_FOUND, EXAM_VERSION_CONFLICT, name taken | §6.7 |
DELETE /api/grading-scales/:publicId | .remove | ?version=→message | .remove | JWT+Role | ExamGrading_DELETE | N/A | N/A | GRADING_SCALE_NOT_FOUND, EXAM_VERSION_CONFLICT, GRADING_SCALE_IN_USE | §6.7 |
PUT /api/grading-scales/:publicId/bands | .replaceBands | ReplaceGradingScaleBandsDto→GradingScaleDto | .replaceBands | JWT+Role | ExamGrading_UPDATE | N/A | N/A | GRADING_SCALE_NOT_FOUND, EXAM_VERSION_CONFLICT, GRADING_SCALE_BAND_INVALID | §6.7 |
GET /api/exam-routine-entries/:publicId/marks | ExamMarksController.getSheet | →ExamMarksSheetDto | ExamMarksSheetService.getSheet | JWT+Role | ExamMarks_READ | N/A | N/A | EXAM_ROUTINE_ENTRY_NOT_FOUND | §6.13 |
PUT /api/exam-routine-entries/:publicId/marks | .saveSheet | SaveExamMarksDto→ExamMarksSheetDto | .saveMarks | JWT+Role | ExamMarks_UPDATE | N/A | N/A | see §8.3 | §8.3 |
POST /api/exam-routine-entries/:publicId/marks/submit | ExamMarksWorkflowController.submit | ExamMarksVersionDto→ExamMarksSubmissionDto | ExamMarksWorkflowService.submit | JWT+Role | ExamMarks_UPDATE | N/A | MARKS_SUBMITTED_NOTIFY | see §8.4 | §8.4 |
POST /api/exam-routine-entries/:publicId/marks/return | .returnMarks | ReturnExamMarksDto→ExamMarksSubmissionDto | .returnMarks | JWT+Role | ExamMarksVerify_UPDATE | N/A | N/A | EXAM_MARKS_TRANSITION_INVALID, EXAM_MARKS_SUBMISSION_VERSION_CONFLICT, EXAM_MARKS_SUBMISSION_NOT_FOUND | §6.14 |
POST /api/exam-routine-entries/:publicId/marks/approve | .approve | ExamMarksVersionDto→ExamMarksSubmissionDto | .approve | JWT+Role | ExamMarksVerify_UPDATE | N/A | N/A | same three as return | §6.14 |
POST /api/exam-routine-entries/:publicId/marks/finalize | .finalize | ExamMarksVersionDto→ExamMarksSubmissionDto | .finalize | JWT+Role | ExamMarksVerify_UPDATE | N/A | N/A | same three as return | §6.14 |
POST /api/exam-marks/:publicId/correct | ExamMarkCorrectionsController.correct | CorrectExamMarkDto→ExamMarkDetailDto | ExamMarkCorrectionsService.correct | JWT+Role | ExamMarksCorrect_UPDATE | N/A | N/A | EXAM_MARK_NOT_FOUND, EXAM_ROUTINE_ENTRY_NOT_FOUND, EXAM_MARKS_TRANSITION_INVALID, EXAM_MARK_ABSENT_CANNOT_SCORE, EXAM_MARK_EXCEEDS_CEILING | §6.15 |
GET /api/exam-marks/:publicId/corrections | .listCorrections | →ExamMarkCorrectionDto[] | .listCorrections | JWT+Role | ExamMarksCorrect_READ | N/A | N/A | EXAM_MARK_NOT_FOUND | §6.15 |
GET /api/exams/:publicId/results | ExamResultsController.list | ListExamResultsQueryDto→ExamResultSummaryDto[] | ExamResultsService.list | JWT+Role | ExamResults_READ | N/A | N/A | EXAM_NOT_FOUND | §8.5 |
POST /api/exams/:publicId/results/compute | .compute | →ComputeResultsResponseDto | ExamResultsComputeService.compute | JWT+Role | ExamResults_UPDATE | N/A | RESULT_COMPUTE (if roll > 500) | EXAM_NOT_FOUND | §8.5 |
POST /api/exams/:publicId/results/publish | .publish | →message | ExamResultsService.publish | JWT+Role | ExamResultPublish_UPDATE | N/A | RESULT_PUBLISH_NOTIFY | EXAM_RESULTS_NOT_ALL_FINALIZED, EXAM_RESULTS_STALE, EXAM_RESULTS_ALREADY_PUBLISHED | §6.16 |
POST /api/exams/:publicId/results/unpublish | .unpublish | →message | .unpublish | JWT+Role | ExamResultPublish_UPDATE | N/A | N/A | EXAM_RESULTS_NOT_PUBLISHED | §6.16 |
GET /api/exams/:publicId/results/:studentPublicId | .detail | →ExamResultDetailDto | .detail | JWT+Role | ExamResults_READ | N/A | N/A | EXAM_RESULT_NOT_FOUND | §6.16 |
POST /api/exams/:publicId/results/:studentPublicId/withhold | .withhold | WithholdOrReleaseResultDto→ExamResultSummaryDto | .withhold | JWT+Role | ExamResultPublish_UPDATE | N/A | N/A | EXAM_RESULT_NOT_FOUND, EXAM_VERSION_CONFLICT | §6.16 |
POST /api/exams/:publicId/results/:studentPublicId/release | .release | WithholdOrReleaseResultDto→ExamResultSummaryDto | .release | JWT+Role | ExamResultPublish_UPDATE | N/A | N/A | EXAM_RESULT_NOT_FOUND, EXAM_VERSION_CONFLICT | §6.16 |
GET /api/exam-notification-settings | ExamNotificationSettingsController.list | →ExamNotificationRuleDto[] | ExamNotificationRulesService.list | JWT+Role | ExamNotificationSettings_READ | N/A | N/A | — | §6.17 |
PUT /api/exam-notification-settings/:event | .update | UpdateExamNotificationRuleDto→ExamNotificationRuleDto | .update | JWT+Role | ExamNotificationSettings_UPDATE | N/A | N/A | EXAM_NOTIFICATION_RULE_NOT_FOUND, EXAM_NOTIFICATION_RULE_INVALID, EXAM_VERSION_CONFLICT | §6.17 |
GET /api/exam-reminder-rules | ExamReminderRulesController.list | →ExamReminderRuleDto[] | ExamReminderRulesService.list | JWT+Role | ExamNotificationSettings_READ | N/A | N/A | — | §6.18 |
PUT /api/exam-reminder-rules | .replace | ReplaceExamReminderRulesDto→ExamReminderRuleDto[] | .replace | JWT+Role | ExamNotificationSettings_UPDATE | N/A | N/A | EXAM_REMINDER_RULE_INVALID, EXAM_VERSION_CONFLICT, EXAM_REMINDER_RULES_TOO_CLOSE | §6.18 |
GET /api/mobile/student/exams | StudentExamsPortalController.listExams | →ExamPortalListDto | ExamPortalReadsService.listExams | JWT+Role, audience | none | N/A | N/A | STUDENT_NOT_FOUND (own row missing) | §6.21 |
GET /api/mobile/student/exams/dashboard | .getDashboard | →ExamPortalDashboardDto | .getDashboard | JWT+Role, audience | none | N/A | N/A | STUDENT_NOT_FOUND | §6.21 |
GET /api/mobile/student/exams/:examPublicId | .getExamDetail | →ExamPortalDetailDto | .getExamDetail | JWT+Role, audience | none | N/A | N/A | EXAM_NOT_FOUND | §6.21 |
GET /api/mobile/student/exams/:examPublicId/result | .getExamResult | →ExamPortalResultDto | .getExamResult | JWT+Role, audience | none | N/A | N/A | EXAM_NOT_FOUND | §8.6 |
GET /api/mobile/student/exams/:examPublicId/attachments/:attachmentPublicId/download | .downloadAttachment | →binary stream | .streamAttachment | JWT+Role, audience | none | N/A | N/A | EXAM_NOT_FOUND, EXAM_ATTACHMENT_NOT_FOUND, EXAM_ATTACHMENT_NOT_AUDIENCE_VISIBLE | §5 |
GET /api/mobile/student/academic-history | .listAcademicHistory | →ExamPortalHistoryDto | .listAcademicHistory | JWT+Role, audience | none | N/A | N/A | STUDENT_NOT_FOUND | §6.21 |
GET /api/mobile/guardian/children/:id/exams | GuardianExamsPortalController.listExams | →ExamPortalListDto | ExamPortalReadsService.listExams | JWT+Role, audience | none | N/A | N/A | 404 from child resolution | §6.21 |
GET /api/mobile/guardian/children/:id/exams/dashboard | .getDashboard | →ExamPortalDashboardDto | .getDashboard | JWT+Role, audience | none | N/A | N/A | 404 from child resolution | §6.21 |
GET /api/mobile/guardian/children/:id/exams/:examPublicId | .getExamDetail | →ExamPortalDetailDto | .getExamDetail | JWT+Role, audience | none | N/A | N/A | EXAM_NOT_FOUND or child-resolution 404 | §6.21 |
GET /api/mobile/guardian/children/:id/exams/:examPublicId/result | .getExamResult | →ExamPortalResultDto | .getExamResult | JWT+Role, audience | none | N/A | N/A | EXAM_NOT_FOUND or child-resolution 404 | §8.6 |
GET /api/mobile/guardian/children/:id/exams/:examPublicId/attachments/:attachmentPublicId/download | .downloadAttachment | →binary stream | .streamAttachment | JWT+Role, audience | none | N/A | N/A | EXAM_NOT_FOUND, EXAM_ATTACHMENT_NOT_FOUND, EXAM_ATTACHMENT_NOT_AUDIENCE_VISIBLE, or child-resolution 404 | §5 |
GET /api/mobile/guardian/children/:id/academic-history | .listAcademicHistory | →ExamPortalHistoryDto | .listAcademicHistory | JWT+Role, audience | none | N/A | N/A | 404 from child resolution | §6.21 |
GET /api/mobile/teacher/exams/dashboard | TeacherExamsPortalController.getDashboard | →TeacherExamDashboardDto | TeacherExamDutiesPortalService.getDashboard | JWT+Role, audience | none | N/A | N/A | PERMISSION_INSUFFICIENT (not staff) | §6.22 |
GET /api/mobile/teacher/exams/marks-queue | .getMarksQueue | →TeacherMarksQueueDto | .listMarksQueue | JWT+Role, audience | none | N/A | N/A | PERMISSION_INSUFFICIENT | §6.22 |
GET /api/mobile/teacher/exams | .listExams | →TeacherExamListDto | TeacherExamsPortalService.listExams | JWT+Role, audience | none | N/A | N/A | PERMISSION_INSUFFICIENT | §6.22 |
GET /api/mobile/teacher/exams/:examPublicId | .getExamDetail | →TeacherExamDetailDto | .getExamDetail | JWT+Role, audience | none | N/A | N/A | EXAM_NOT_FOUND (not in scope), PERMISSION_INSUFFICIENT | §6.22 |
GET /api/mobile/teacher/exams/:examPublicId/attachments/:attachmentPublicId/download | .downloadAttachment | →binary stream | .streamAttachment | JWT+Role, audience | none | N/A | N/A | EXAM_NOT_FOUND, EXAM_ATTACHMENT_NOT_FOUND, EXAM_ATTACHMENT_NOT_AUDIENCE_VISIBLE, PERMISSION_INSUFFICIENT | §6.22 |
GET /api/mobile/teacher/invigilations | .listInvigilations | →TeacherInvigilationListDto | TeacherExamDutiesPortalService.listInvigilations | JWT+Role, audience | none | N/A | N/A | PERMISSION_INSUFFICIENT | §6.22 |
GET /api/mobile/teacher/exams/:examPublicId/entries/:entryPublicId/marks | TeacherExamMarksPortalController.getSheet | →ExamMarksSheetDto | TeacherExamMarksPortalService.getSheet | JWT+Role, audience | none | N/A | N/A | EXAM_ROUTINE_ENTRY_NOT_FOUND (not scoped), PERMISSION_INSUFFICIENT | §6.13 |
PUT /api/mobile/teacher/exams/:examPublicId/entries/:entryPublicId/marks | .saveMarks | SaveExamMarksDto→ExamMarksSheetDto | .saveMarks | JWT+Role, audience | none | N/A | N/A | see §8.3 | §8.3 |
POST /api/mobile/teacher/exams/:examPublicId/entries/:entryPublicId/marks/submit | .submit | ExamMarksVersionDto→ExamMarksSubmissionDto | .submit | JWT+Role, audience | none | N/A | MARKS_SUBMITTED_NOTIFY | see §8.4 | §8.4 |
GET /api/mobile/teacher/schedule/today | TeacherTodayPortalController.getToday | →TeacherTodayScheduleDto | TeacherTodayPortalService.getToday | JWT+Role, audience | none | N/A | N/A | PERMISSION_INSUFFICIENT | §6.23 |
13.2 Request/Response Exhaustiveness
Every route family above has a minimal-valid, full-valid (where the DTO has optional fields), success-response, and representative-error example in §8 for the routes given full treatment, and the exact DTO shape in §6 for every other route — the same fields, defaults and validators apply regardless of which section documents them. Every list endpoint's empty-result shape is {success: true, message: "...", data: []} (or {items: [], truncated: false} for the unpaginated portal lists) — none of them return a distinct "empty" envelope.
13.3 API Diagram Pack
Covered in §9: route ownership (9.1), two request sequences spanning the module's two most consequential write paths (9.2 lifecycle publish, 9.3 marks submission), an error decision tree for the audience 404-ambiguity contract (9.4), an activity diagram for the results-publish gate (9.5), a data contract map for routine-entry creation (9.6), the (empty) cache flow (9.7), and the async job flow for result computation (9.8).
13.4 Consumer Integration Notes
| Consumer | Required Knowledge | Failure Handling | Contract Stability |
|---|---|---|---|
| Admin panel (web) | Full permission catalogue per §4/§5; the two version shapes (§3) — sending a stringified integer to a routine-entry/component write 400s | Show the named error code's message; retry a 409 EXAM_VERSION_CONFLICT/EXAM_ROUTINE_ENTRY_STATE_CHANGED only after re-fetching | Stable |
| Teacher portal | No permission to check. On the dashboard, marks-queue, marks and today-schedule routes a 403 PERMISSION_INSUFFICIENT means the session's active role is not teacher, or the caller has no live staff row — switch the session to teacher, or ask an administrator to grant that role; it never means "you lack a specific grant". The exam list, exam detail, attachment download and invigilation routes need only a staff session — any non-portal active role with a live staff row — so a 403 there means a guardian or pupil session, or no live staff row | A 404 on a marks/exam route the teacher expects to see usually means the underlying class_subjects.teacher_id assignment changed, not a bug | Stable |
| Student/Guardian portal | The exam-result gate is BOTH resultsPublishedAt IS NOT NULL AND the pupil's own status <> 'withheld'; a 404 on a result route is expected and not distinguishable from "no such exam" | Never attempt to distinguish the reason behind an EXAM_NOT_FOUND on these routes — the ambiguity is intentional | Stable |
| QA | Every fixture needs a live student_class_enrollments row (status='active') for marks/results to see the pupil at all — a soft-deleted or record_status-inactive pupil silently drops out of every roll-based read | Reproduce a "missing pupil" report by checking record_status and enrolment status before assuming a code defect | Stable |
| Internal service / worker | ExamJob.RESULT_COMPUTE calls ExamResultsComputeService.computeNow directly, never re-enqueues; every job is keyed by exam publicId in its outbox dedupeKey, so a redelivered job is a safe no-op if the underlying write already landed | Retryable: every job here is idempotent by dedupe key or by upsert target | Stable |
13.5 API Tradeoffs and Rationale
| Decision | Chosen Behavior | Alternatives Considered | Why This Tradeoff | Risk | Mitigation |
|---|---|---|---|---|---|
Two version shapes in one module | String tokens (trigger-bumped) for most entities; plain integers (service-incremented) for routine entries/components | One uniform string shape everywhere | Routine-entry writes need the CURRENT exam_status re-checked in the same WHERE as the version, and the swap route needs to compute two next-values inside one transaction without a trigger race | A client library that formats every version as a string breaks silently on these two DTOs | Documented explicitly in §3; @IsInt() on the DTO turns the mistake into a 400, not silent corruption |
| Result compute: sync under 500 pupils, async over | A request-time size threshold rather than always-async | Always synchronous (times out on a large school); always asynchronous (adds latency/complexity for the common small-exam case) | Most exams are one class-group; a whole-school pre-board exam is the exception that needs the queue | A caller assuming mode: "computed" always for a large school gets resultCount: null unexpectedly | ComputeResultsResponseDto.mode is the documented signal; callers must branch on it |
| Ambiguous 404 on every audience exam/result/attachment read | One error code for "does not exist", "not in your scope", and (for results) "not yet published"/"withheld" | Distinct codes per case | An id space over a school's examinations must not become an enumeration oracle for "which classes does this child belong to" or "has this child's result come out yet" | A consumer that tries to special-case one of the three sub-reasons will misbehave when the server does not distinguish them | Documented in §5 and §8.6; never build client logic that infers a sub-reason from a 404 |
| No IP-throttle on any attachment upload/download in this module | Rely on JWT auth + row-scope alone | Throttle uploads/downloads as Assignments does | Not stated in any source comment read this session — an observed difference, not a documented design choice | An unthrottled download route is a larger DoS/scrape surface than a throttled one | Flagged here as a defect candidate, not fixed — see the final report |
| No idempotency key on exam/routine-entry create | Rely on the caller not double-submitting | A clientRequestId per Assignments' precedent | Not stated in source; the admin panel is the only client and controls its own double-submit prevention client-side | A retried POST (e.g. after a timeout) creates a duplicate draft exam or routine entry | Flagged here as a defect candidate, not fixed |
13.6 API Change Impact
| Change | Affected Consumers | Backend Impact | Data Impact | Migration Needed? | Compatibility Plan |
|---|---|---|---|---|---|
Changing exam_routine_entries.version/exam_assessment_components.version from integer to a trigger-bumped string | Admin panel's routine/component editors; the swap route | Every WHERE version = X comparison, dto.version + 1 arithmetic, and the @IsInt() validator would need rewriting | None to stored data — only the DTO/validation shape | Yes — DTO and validator change, no schema migration | Version the DTO or ship both fields during a transition window; document the cutover date as a genuine consumer-facing note |
Adding an eighth configurable exam_notification_rules event | Notification settings screen; ExamNotifierService's RULE_EVENT_BY_NOTIFY_EVENT map | New seed row via migration; new map entry | New row in exam_notification_rules | Yes | Additive — existing rows/consumers unaffected |
Exposing classId as a real filter on GET /exams/:publicId/results | Admin results screen wanting a per-class breakdown | New query field on ListExamResultsQueryDto, new predicate in ExamResultsService.list | None | No schema migration | Additive; the stale source comment already implies this was intended — see the final report |
14. Zero-Omission API Checklist
- Every controller route is documented (80 of 80 — §4, §13.1).
- Every parent route prefix and runtime URL is documented, including the two controllers declared with a parameterized controller-level path (
exams/:publicId,exams/:publicId/results). - Every DTO field, nested field, enum, default, transform, and validator is documented (§6, §7).
- Every response field, nullable field, generated field, and omitted raw-entity field is documented, including the module-wide "raw row never spread into a portal DTO" rule (§6.21).
- Every auth, guard, permission, role, public decorator, and guest identity branch is documented (§5) — there is no public/guest branch anywhere in this module.
- Every success, validation, auth, permission, not-found, conflict, rate-limit, and server-error branch is documented; there is no rate-limit branch anywhere in this module (§5, §8, §13.1).
- Every database read/write, cache hit/miss/write/invalidation, queue job, and audit log is documented; there is no cache layer and no realtime event in this module (§9, §11).
- Representative routes carry minimal, full, success, and error examples (§8); every other route's exact shape is in §6 and §13.1.
- Route, sequence, activity, and error diagrams are present (§9).
- Tradeoffs and compatibility risks are documented, including two flagged defect candidates (§13.5, §13.6).
- The API doc links to backend and features/flows (See Also).
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 — including the two registered but never-thrown codes (
EXAM_ROUTINE_ENTRY_IN_USE,EXAM_BS_DATE_OUT_OF_RANGE), called out explicitly rather than silently omitted. - Every auth guard and permission is documented.
- Every queue job is documented; this module has no cache key and no realtime event.
- Every diagram matches the current code.
- The API doc links to backend and features/flows.
See Also
- Backend doc:
/docs/developer/exams/backend - Features and flows doc:
/docs/developer/exams/feature