Skoolsewa - School Management System Docs
Developer ResourcesExams

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

AreaFiles InspectedWhat Was Verified
Controllersapps/api/src/modules/exams/**/*.controller.ts (19 files), apps/api/src/modules/mobile/teacher/teacher-today-portal.controller.tsRoutes, methods, guards, decorators, status codes
DTOsapps/api/src/modules/exams/**/dto/*.ts (25 files)Request, query, response, validation
Servicesapps/api/src/modules/exams/**/*.service.ts (33 files)Behavior, side effects, response mapping, errors
Schemapackages/db/src/schema/school/exams.ts (1722 lines)IDs, enums, persisted fields, constraints, version column types
Error registryapps/api/src/common/types/error-codes.ts (grep EXAM_, GRADING_)50 EXAM_* + 5 GRADING_SCALE_* = 55 codes (§7, §8)
Jobspackages/jobs/src/index.ts (ExamJob enum)QueueName.EXAMS, 9 job members, payload shapes
Route baselineapps/api/test/structure/structure.baseline.jsonThe authoritative route list this module contributes 80 routes to

2. Module Summary

FieldValue
Module nameExams
Module slugexams
Primary actorsadmin, teacher (audience), student (audience), guardian (audience)
API surfacesAdmin (/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 modelJwtAuthGuard (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
PersistencePostgreSQL (Drizzle), attachment bytes via @skoolsewa/storage, async work via BullMQ (@skoolsewa/jobs)
Runtime source of truthexams.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 docsBackend, Features and flows

3. Concepts and Terminology

TermMeaningSource FileUsed By
publicIdExternal UUID identifier for an exam, exam type, grading scale, routine entry, attachment, or mark — never the internal integer/serial PKexams.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)
statusThe stored exam lifecycle: draft/published/completed/cancelled/archivedexams.statusList/detail responses, every lifecycle route's WHERE clause
displayStatusThe DERIVED status shown to a viewer: draft/scheduled/in_progress/completed/cancelled/archived — computed from status + startDate against the school's own "today", never storedEXAM_DISPLAY_STATUSES, exam-display-status.util.tsList/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
examStatusA denormalized COPY of the parent exam's status, carried on exam_routine_entries and exam_invigilators at write timeschemaRow-scope predicates that need the exam's status without a join (audience reads, availability, publish-preview)
versionAn optimistic-concurrency token — not one shape across this module. See the callout immediately below.mixedEvery write route that targets an existing row
stateThe paper's marks submission state machine: draft/submitted/returned/approved/finalizedMARKS_SUBMISSION_STATESMarks sheet, workflow routes
attendanceA pupil's presence for one mark cell: present/absent/withheld/not_applicableEXAM_MARK_ATTENDANCE_VALUESMarks sheet, corrections
classSubjectIdThe 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 againstclass_subjects schemaCreate routine entry, teacher marks-scope predicate
examClassIdexam_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 removedschemaExamClassesService.replace
rollThe 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 timeexam-marks-sheet.service.ts, exam-results-compute.service.tsMarks 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.tsMarks sheet read/write, submit
canPublish / warningsThe 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: falseexam-conflicts-report.dto.tsGET /exams/:publicId/publish-preview
two-pass conflict detectionEvery 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.tsPublish, routine create/patch/remove, swap, invigilator/component replace
scopeKindThe 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_kindEvery 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 stores version as bigint, but the service layer stringifies it on read (String(row.version), or the shared versionOf()/matchesVersion() helpers in apps/api/src/modules/people/shared/row-version.ts) and every DTO types the field string. A stale token is refused with EXAM_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_entries and exam_assessment_components type version as a JSON number on 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 same UPDATE's WHERE clause. A client must send back the exact integer last read, and the server — not a trigger — decides the next one. ExamRoutineWriteService.patch names the refusal EXAM_ROUTINE_ENTRY_STATE_CHANGED, not EXAM_VERSION_CONFLICT, because the same WHERE clause also re-checks exam_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

SurfaceMethodPathActorAuth/GuardPermissionControllerPurpose
AdminGET/api/exam-typesAdminJWT+RoleExamTypes_READExamTypesControllerList exam types
AdminPOST/api/exam-typesAdminJWT+RoleExamTypes_CREATEExamTypesControllerCreate exam type
AdminPATCH/api/exam-types/:publicIdAdminJWT+RoleExamTypes_UPDATEExamTypesControllerUpdate exam type
AdminDELETE/api/exam-types/:publicIdAdminJWT+RoleExamTypes_DELETEExamTypesControllerDelete unused exam type
AdminGET/api/examsAdminJWT+RoleExams_READExamsControllerList exams
AdminPOST/api/examsAdminJWT+RoleExams_CREATEExamsControllerCreate a draft
AdminGET/api/exams/:publicIdAdminJWT+RoleExams_READExamsControllerOne exam
AdminPATCH/api/exams/:publicIdAdminJWT+RoleExams_UPDATEExamsControllerUpdate
AdminDELETE/api/exams/:publicIdAdminJWT+RoleExams_DELETEExamsControllerDelete draft with no class scope
AdminPOST/api/exams/:publicId/publishAdminJWT+RoleExamPublish_UPDATEExamLifecycleControllerPublish
AdminPOST/api/exams/:publicId/unpublishAdminJWT+RoleExamPublish_UPDATEExamLifecycleControllerReturn to draft
AdminPOST/api/exams/:publicId/cancelAdminJWT+RoleExamCancel_UPDATEExamLifecycleControllerCancel
AdminPOST/api/exams/:publicId/reinstateAdminJWT+RoleExamCancel_UPDATEExamLifecycleControllerReturn a cancelled exam to draft
AdminPOST/api/exams/:publicId/completeAdminJWT+RoleExams_UPDATEExamLifecycleControllerMark a published exam completed
AdminPOST/api/exams/:publicId/archiveAdminJWT+RoleExams_UPDATEExamLifecycleControllerArchive
AdminPOST/api/exams/:publicId/unarchiveAdminJWT+RoleExams_UPDATEExamLifecycleControllerUnarchive
AdminGET/api/exams/:publicId/classesAdminJWT+RoleExams_READExamClassesControllerThe exam's class scope
AdminPUT/api/exams/:publicId/classesAdminJWT+RoleExams_UPDATEExamClassesControllerReplace class scope
AdminGET/api/exams/:publicId/attachmentsAdminJWT+RoleExams_READExamAttachmentsControllerList attachments
AdminPOST/api/exams/:publicId/attachmentsAdminJWT+RoleExams_UPDATEExamAttachmentsControllerUpload attachment
AdminDELETE/api/exams/:publicId/attachments/:attachmentPublicIdAdminJWT+RoleExams_UPDATEExamAttachmentsControllerRemove attachment
AdminGET/api/exams/:publicId/routineAdminJWT+RoleExamRoutines_READExamRoutineControllerList routine entries
AdminPOST/api/exams/:publicId/routineAdminJWT+RoleExamRoutines_CREATEExamRoutineControllerAdd one paper
AdminGET/api/exams/:publicId/conflictsAdminJWT+RoleExamRoutines_READExamRoutineControllerEvery room/class/invigilator collision
AdminGET/api/exams/:publicId/publish-previewAdminJWT+RoleExams_READExamRoutineControllerWhat publishing would do
AdminPOST/api/exam-routine-entries/swapAdminJWT+RoleExamRoutines_UPDATEExamRoutineEntryControllerSwap two papers' schedules
AdminPATCH/api/exam-routine-entries/:publicIdAdminJWT+RoleExamRoutines_UPDATEExamRoutineEntryControllerReschedule/edit one entry
AdminDELETE/api/exam-routine-entries/:publicIdAdminJWT+RoleExamRoutines_DELETEExamRoutineEntryControllerRemove one entry
AdminPOST/api/exam-routine-entries/:publicId/completeAdminJWT+RoleExamRoutines_UPDATEExamRoutineEntryControllerMark exam-day flag complete
AdminGET/api/exam-routine-entries/:publicId/availabilityAdminJWT+RoleExamRoutines_READExamRoutineEntryControllerFree staff/rooms for this slot
AdminPUT/api/exam-routine-entries/:publicId/componentsAdminJWT+RoleExamRoutines_UPDATEExamRoutineEntryControllerReplace assessment components
AdminPUT/api/exam-routine-entries/:publicId/invigilatorsAdminJWT+RoleExamRoutines_UPDATEExamRoutineEntryControllerReplace invigilator assignment
AdminGET/api/exam-dayAdminJWT+RoleExamRoutines_READExamDayControllerEvery paper sitting today
AdminGET/api/exam-dashboardAdminJWT+RoleExams_READExamDashboardControllerDashboard totals
AdminGET/api/grading-scalesAdminJWT+RoleExamGrading_READGradingScalesControllerList grading scales
AdminPOST/api/grading-scalesAdminJWT+RoleExamGrading_CREATEGradingScalesControllerCreate
AdminPATCH/api/grading-scales/:publicIdAdminJWT+RoleExamGrading_UPDATEGradingScalesControllerUpdate
AdminDELETE/api/grading-scales/:publicIdAdminJWT+RoleExamGrading_DELETEGradingScalesControllerDelete unused scale
AdminPUT/api/grading-scales/:publicId/bandsAdminJWT+RoleExamGrading_UPDATEGradingScalesControllerReplace bands
AdminGET/api/exam-routine-entries/:publicId/marksAdminJWT+RoleExamMarks_READExamMarksControllerRead marks sheet
AdminPUT/api/exam-routine-entries/:publicId/marksAdminJWT+RoleExamMarks_UPDATEExamMarksControllerSave marks
AdminPOST/api/exam-routine-entries/:publicId/marks/submitAdminJWT+RoleExamMarks_UPDATEExamMarksWorkflowControllerSubmit for review
AdminPOST/api/exam-routine-entries/:publicId/marks/returnAdminJWT+RoleExamMarksVerify_UPDATEExamMarksWorkflowControllerReturn to teacher
AdminPOST/api/exam-routine-entries/:publicId/marks/approveAdminJWT+RoleExamMarksVerify_UPDATEExamMarksWorkflowControllerApprove
AdminPOST/api/exam-routine-entries/:publicId/marks/finalizeAdminJWT+RoleExamMarksVerify_UPDATEExamMarksWorkflowControllerFinalize
AdminPOST/api/exam-marks/:publicId/correctAdminJWT+RoleExamMarksCorrect_UPDATEExamMarkCorrectionsControllerCorrect a finalized mark
AdminGET/api/exam-marks/:publicId/correctionsAdminJWT+RoleExamMarksCorrect_READExamMarkCorrectionsControllerCorrection history
AdminGET/api/exams/:publicId/resultsAdminJWT+RoleExamResults_READExamResultsControllerList results
AdminPOST/api/exams/:publicId/results/computeAdminJWT+RoleExamResults_UPDATEExamResultsControllerCompute/recompute every result
AdminPOST/api/exams/:publicId/results/publishAdminJWT+RoleExamResultPublish_UPDATEExamResultsControllerPublish results
AdminPOST/api/exams/:publicId/results/unpublishAdminJWT+RoleExamResultPublish_UPDATEExamResultsControllerUnpublish results
AdminGET/api/exams/:publicId/results/:studentPublicIdAdminJWT+RoleExamResults_READExamResultsControllerOne pupil's full result
AdminPOST/api/exams/:publicId/results/:studentPublicId/withholdAdminJWT+RoleExamResultPublish_UPDATEExamResultsControllerWithhold one result
AdminPOST/api/exams/:publicId/results/:studentPublicId/releaseAdminJWT+RoleExamResultPublish_UPDATEExamResultsControllerRelease a withheld result
AdminGET/api/exam-notification-settingsAdminJWT+RoleExamNotificationSettings_READExamNotificationSettingsControllerList notification rules
AdminPUT/api/exam-notification-settings/:eventAdminJWT+RoleExamNotificationSettings_UPDATEExamNotificationSettingsControllerUpdate one rule
AdminGET/api/exam-reminder-rulesAdminJWT+RoleExamNotificationSettings_READExamReminderRulesControllerList reminder rules
AdminPUT/api/exam-reminder-rulesAdminJWT+RoleExamNotificationSettings_UPDATEExamReminderRulesControllerReplace the full rule set
StudentGET/api/mobile/student/examsPupilJWT+Role, audiencenoneStudentExamsPortalControllerMy own exams
StudentGET/api/mobile/student/exams/dashboardPupilJWT+Role, audiencenoneStudentExamsPortalControllerMy exams dashboard
StudentGET/api/mobile/student/exams/:examPublicIdPupilJWT+Role, audiencenoneStudentExamsPortalControllerOne of my exams
StudentGET/api/mobile/student/exams/:examPublicId/resultPupilJWT+Role, audiencenoneStudentExamsPortalControllerMy published result
StudentGET/api/mobile/student/exams/:examPublicId/attachments/:attachmentPublicId/downloadPupilJWT+Role, audiencenoneStudentExamsPortalControllerDownload an attachment
StudentGET/api/mobile/student/academic-historyPupilJWT+Role, audiencenoneStudentExamsPortalControllerMy published exam-result history
GuardianGET/api/mobile/guardian/children/:id/examsGuardianJWT+Role, audiencenoneGuardianExamsPortalControllerA child's exams
GuardianGET/api/mobile/guardian/children/:id/exams/dashboardGuardianJWT+Role, audiencenoneGuardianExamsPortalControllerA child's exams dashboard
GuardianGET/api/mobile/guardian/children/:id/exams/:examPublicIdGuardianJWT+Role, audiencenoneGuardianExamsPortalControllerOne of a child's exams
GuardianGET/api/mobile/guardian/children/:id/exams/:examPublicId/resultGuardianJWT+Role, audiencenoneGuardianExamsPortalControllerA child's published result
GuardianGET/api/mobile/guardian/children/:id/exams/:examPublicId/attachments/:attachmentPublicId/downloadGuardianJWT+Role, audiencenoneGuardianExamsPortalControllerDownload an attachment
GuardianGET/api/mobile/guardian/children/:id/academic-historyGuardianJWT+Role, audiencenoneGuardianExamsPortalControllerA child's published result history
TeacherGET/api/mobile/teacher/exams/dashboardTeacherJWT+Role, audiencenoneTeacherExamsPortalControllerMy exams dashboard
TeacherGET/api/mobile/teacher/exams/marks-queueTeacherJWT+Role, audiencenoneTeacherExamsPortalControllerPapers still needing my marks
TeacherGET/api/mobile/teacher/examsAny staff member (no teacher role needed)JWT+Role, audiencenoneTeacherExamsPortalControllerExams I teach or invigilate
TeacherGET/api/mobile/teacher/exams/:examPublicIdAny staff member (no teacher role needed)JWT+Role, audiencenoneTeacherExamsPortalControllerOne exam I teach or invigilate
TeacherGET/api/mobile/teacher/exams/:examPublicId/attachments/:attachmentPublicId/downloadAny staff member (no teacher role needed)JWT+Role, audiencenoneTeacherExamsPortalControllerDownload an attachment
TeacherGET/api/mobile/teacher/invigilationsAny staff member (no teacher role needed)JWT+Role, audiencenoneTeacherExamsPortalControllerMy own invigilation duties
TeacherGET/api/mobile/teacher/exams/:examPublicId/entries/:entryPublicId/marksTeacherJWT+Role, audiencenoneTeacherExamMarksPortalControllerRead marks sheet for my paper
TeacherPUT/api/mobile/teacher/exams/:examPublicId/entries/:entryPublicId/marksTeacherJWT+Role, audiencenoneTeacherExamMarksPortalControllerSave marks for my paper
TeacherPOST/api/mobile/teacher/exams/:examPublicId/entries/:entryPublicId/marks/submitTeacherJWT+Role, audiencenoneTeacherExamMarksPortalControllerSubmit my paper's marks
TeacherGET/api/mobile/teacher/schedule/todayTeacherJWT+Role, audiencenoneTeacherTodayPortalController (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

SurfaceGuard/DecoratorIdentity ShapePermissionGuest AllowedNotes
AdminJwtAuthGuard, RoleGuard, @Permissions("Module_ACTION")req.user.id, active role's permission grantsPer-route (see §4)NoStandard admin permission model.
Student portalJwtAuthGuard, RoleGuard (no @Permissions())req.user resolved to a live students row via assertPortalAudience(actor, "student") + students.userId = actor.idN/ANoThe lookup is structural — the route takes no id parameter, so nothing a caller supplies can point at another pupil.
Guardian portalsamereq.user resolved + :id (child) bound to a linked child via GuardianPortalService.resolveOwnChildIdN/ANoThe child-binding check runs FIRST, before any exam row is read. An unlinked :id answers 404, never 403.
Teacher portalsamereq.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 onN/ANoRoleGuard 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/todaysameTeacher, via assertTeacherStaffRowN/ANoDeclared 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_classes includes a class the pupil is active-enrolled in for THAT exam's own academic session — never "the current session" — so a promoted pupil keeps reading a past, still-published exam. draft exams 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 in exam_invigilators for at least one entry. The marks read/write/submit routes narrow further — see §6.13's callout — to class_subjects.teacher_id = me OR exam_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

FieldTypeRequiredDefaultValidationExampleSource
academicSessionIduuidYes—@IsUUID(); immutable after create—create-exam.dto.ts:14
examTypeIduuidYes—@IsUUID()—
gradingScaleIduuidNo—@IsUUID(); omit for a marks-only exam with no grade/GPA—
namestringYes—1-160 chars"First Terminal Examination 2083"
descriptionstringNo—≤2000 chars; audience-visible—
internalNotestringNo—≤2000 chars; administrator-internal, never shown to any audience—
startDateISO date (YYYY-MM-DD)Yes—@IsDateString({strict:true})"2026-09-20"
endDateISO dateYes—@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.

FieldTypeRequiredNotes
versionstringYesThe exam's own version token.
examTypeIduuidNoRe-resolved; 404 EXAM_TYPE_NOT_FOUND if unknown.
gradingScaleIduuid | nullNonull clears it (marks-only). 404 GRADING_SCALE_NOT_FOUND if the given id does not exist.
namestringNo1-160 chars.
description, internalNotestring | nullNonull clears; same length caps as create.
startDate, endDateISO dateNoNarrowing (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)

FieldTypeNotes
publicIduuid
academicSession{publicId, name}
academicSessionLockedbooleanDetail 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} | nullDetail only.
namestring
description, internalNotestring | nullDetail only. internalNote is administrator-internal — see §6 note under Portal DTOs for why it never leaves this admin shape.
statusenumStored.
displayStatusenumDerived — see §3, §7.
startDate, endDateISO date
publishedAt, cancelledAt, completedAt, archivedAt, resultsPublishedAtDate | nullDetail only, except resultsPublishedAt which also appears on the list row.
cancellationReasonstring | nullDetail only; administrator-internal — never sent to a family.
publicCancellationNotestring | nullDetail only; audience-visible — this and only this is what a portal cancellation view shows.
archivedFromStatusstring | nullDetail only — which status (completed or cancelled) unarchive will restore.
classesExamClassRefDto[]Detail only.
classCountnumberList only.
versionstringOpaque token — see §3's version callout.
createdAt, updatedAtDateDetail only.

6.3 ExamClassRefDto / ReplaceExamClassesDto

FieldTypeNotes
classIdintInternal 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.
classPublicIduuid
gradeName, sectionName, shiftstring

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

FieldTypeRequiredNotes
file (upload)multipart/form-data binaryYesOne 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"NoDefaults administrator_only. Set audience for a file the sitting classes' families may download.
publicId (response)uuid
originalName, mimeType, sizeBytesmixed
visibilityenum
sortOrderint
createdAtDate

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:

FieldTypeRequiredNotes
versionstringYes
reasonstringNo≤500 chars; administrator-internal, never sent to a family.
publicNotestringNo≤500 chars; audience-visible — surfaces as ExamDetailDto.publicCancellationNote and ExamPortal*Dto.publicCancellationNote.

6.6 ExamTypeDto / CreateExamTypeDto / UpdateExamTypeDto

FieldTypeRequired (create)Notes
namestringYes1-120 chars. Active-scoped unique — 409 EXAM_TYPE_NAME_TAKEN.
codestringYes^[A-Z0-9_]{1,16}$. Immutable after create — UpdateExamTypeDto does not accept it at all. Active-scoped unique — 409 EXAM_TYPE_CODE_TAKEN.
sortOrderintNo (default 0)≥0.
isActivebooleanNo (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

FieldTypeRequired (create)ValidationNotes
namestringYes≤120 chars, no leading/trailing whitespace, no control chars; active-scoped unique — 409 GRADING_SCALE_NAME_TAKEN
usesGpabooleanYes
conditionalPassMaxFailedSubjectsintNo (default 0)0..10How many failed subjects a pupil may carry and still be passed_with_conditions rather than failed.
isDefaultbooleanNo (default false)—Setting true atomically clears the previous default inside the same transaction — never two rows with isDefault: true.
isActivebooleanupdate 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):

FieldTypeRequiredNotes
classIduuidYesThe class's publicId; must already be in the exam's exam_classes scope, else 404 CLASS_NOT_FOUND.
classSubjectIduuidYesclass_subjects.id (the uuid PK — no separate publicId on that table); must belong to classId, else 422 EXAM_ROUTINE_SUBJECT_NOT_TAUGHT.
examDateISO dateYesMust fall within [exam.startDate, exam.endDate], else 422 EXAM_ROUTINE_DATE_OUTSIDE_PERIOD.
startMinute, endMinuteint 0..1440YesendMinute must exceed startMinute.
roomIdint | nullNoInteger rooms.id, never a publicId.
examCenter, notes (audience-visible), internalNote (administrator-only)string | nullNo≤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

EnumValueMeaningRuntime EffectSource
ExamStatusdraft|published|completed|cancelled|archivedStored lifecycleGates every lifecycle transition's WHERE clauseexams.ts schema, exam-common.dto.ts
ExamDisplayStatusdraft|scheduled|in_progress|completed|cancelled|archivedDerived, time-sensitive status shown to viewersComputed per request from status+startDate+today; never persistedexam-common.dto.ts (EXAM_DISPLAY_STATUSES)
ExamAttachmentVisibilityadministrator_only|audienceWho may download the fileDrives every audience download's 404-vs-200 decisionexam-attachment.dto.ts
ExamComponentKindtheory|practical|internal|viva|project|otherThe kind of assessment a component representsDisplay grouping only; scoring treats every kind identicallyexam-component.dto.ts
MarksSubmissionStatedraft|submitted|returned|approved|finalizedThe paper's marks workflow stateGates which transitions are legal and whether the sheet is editableexam-marks.dto.ts (MARKS_SUBMISSION_STATES)
ExamMarkAttendancepresent|absent|withheld|not_applicablePer-cell presencepresent is the only value that may carry a non-null markexam-marks.dto.ts
ExamResultStatuspassed|failed|passed_with_conditions|incomplete|absent|withheldComputed (or manually withheld) overall outcomeFilter value on GET .../results; withheld is the only one a route sets directly rather than compute derivingexam-result.dto.ts
ExamNotificationEventpublished|schedule_changed|cancelled|invigilation_assigned|exam_tomorrow|exam_today|result_publishedThe 7 configurable notification-rule keysOne exam_notification_rules row each, seeded by migrationexam-notification-rule.dto.ts (EXAM_NOTIFICATION_EVENT)
NotificationChannelprovider-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 definenotification-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

EvidencePath
Controllerapps/api/src/modules/exams/authoring/admin/exams.controller.ts
DTOapps/api/src/modules/exams/authoring/core/dto/create-exam.dto.ts
Serviceapps/api/src/modules/exams/authoring/core/exam-write.service.ts
Schemapackages/db/src/schema/school/exams.ts (exams)
Testsapps/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 clientRequestId on this DTO; a replayed request creates a second draft exam.

Request

PartRequiredDetails
HeadersYesAuthorization: Bearer <token>
BodyYesCreateExamDto (§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: ActivityAuditInterceptor records the mutating request under module Exams, action CREATE.
  • External API calls: none.

Error Cases

HTTP StatusError CodeConditionUser-Facing MeaningSource
400class-validator errorsMissing/invalid fieldFix the request bodyglobal ValidationPipe
400VALIDATION_FAILEDendDate before startDateFix the datesexam-write.service.ts:44
404ACADEMIC_SESSION_NOT_FOUNDacademicSessionId does not resolveChoose a valid sessionexam-write.service.ts:57
409EXAM_SESSION_NOT_ACTIVEThe academic session is retiredChoose an active sessionexam-write.service.ts:64
404EXAM_TYPE_NOT_FOUNDexamTypeId does not resolveChoose a valid exam typeexam-write.service.ts:76
404GRADING_SCALE_NOT_FOUNDgradingScaleId given but unknownChoose a valid scale or omit itexam-write.service.ts:90
403PERMISSION_INSUFFICIENTCaller lacks Exams_CREATENot authorizedRoleGuard

Edge Cases

  • Empty description/internalNote string is stored as null (trimmed-then-falsy collapse), not an empty string.
  • startDate === endDate is legal — a one-day exam.
  • Omitting gradingScaleId produces a marks-only exam: results still compute (obtained/max/percentage) but never carry a gradeLabel/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/json
curl -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

EvidencePath
Controllerapps/api/src/modules/exams/authoring/admin/exam-lifecycle.controller.ts
DTOapps/api/src/modules/exams/authoring/core/dto/exam-lifecycle.dto.ts (VersionDto)
Serviceapps/api/src/modules/exams/authoring/core/exam-lifecycle.service.ts
Schemapackages/db/src/schema/school/exams.ts (exams, exam_routine_entries exclusion constraints)
Testsapps/api/src/modules/exams/__tests__/

Auth and Permissions

  • Auth: JWT required.
  • Guard chain: JwtAuthGuard, RoleGuard.
  • Permission: ExamPublish_UPDATE — deliberately separate from Exams_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) version is a version conflict, not a silent no-op.

Request

PartRequiredDetails
BodyYesVersionDto — { version: string }
{ "version": "0" }

Response

200 with "Exam published." and the full ExamDetailDto.

Side Effects

  • Database writes: exams (status→published, publishedAt, publishedByUserId), cascades exam_status onto every exam_routine_entries/exam_invigilators row for this exam in the same statement.
  • Cache: none.
  • BullMQ jobs: ExamJob.PUBLISH_NOTIFY enqueued via the outbox in the same transaction, deduped on exam-published-<publicId>-v<version>.
  • Realtime events: none.
  • Notifications: exam.published — see §11.
  • Audit logs: ActivityAuditInterceptor, module ExamPublish, action UPDATE.
  • External API calls: none.

Error Cases

HTTP StatusError CodeConditionUser-Facing MeaningSource
404EXAM_NOT_FOUNDNo exam with that idNot foundexam-lifecycle.service.ts:49
409EXAM_HAS_NO_CLASSESNo class in scopeAttach at least one class first:98
409EXAM_ROUTINE_ENTRY_HAS_NO_COMPONENTSAt least one paper has zero assessment componentsAdd a component to every paper:125
409GRADING_SCALE_HAS_NO_FLOOR_BANDThe attached scale has no band starting at 0%Fix the grading scale:142
409EXAM_ALREADY_PUBLISHEDAlready publishedNo action needed:186
409EXAM_STATE_TRANSITION_INVALIDAny other non-draft statusPublish is only available from draft:189 (invalidTransition())
409EXAM_ROOM_CONFLICTPublishing would double-book a roomReassign the room:201
409EXAM_CLASS_CONFLICTPublishing would clash a class's own scheduleReschedule one paper:211
409EXAM_INVIGILATOR_CONFLICTPublishing would double-book an invigilatorReassign the invigilator:222
409EXAM_VERSION_CONFLICTStale versionReload 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: null never checks GRADING_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

EvidencePath
Controllersapps/api/src/modules/exams/marks/admin/exam-marks.controller.ts, apps/api/src/modules/exams/portal/teacher/teacher-exam-marks-portal.controller.ts
DTOapps/api/src/modules/exams/marks/core/dto/exam-marks.dto.ts
Serviceapps/api/src/modules/exams/marks/core/exam-marks-sheet.service.ts, apps/api/src/modules/exams/portal/teacher/teacher-exam-marks-portal.service.ts
Schemapackages/db/src/schema/school/exams.ts (exam_marks, exam_marks_submissions)
Testsapps/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_marks upsert on (componentId, studentId).
  • Cache: none.
  • BullMQ jobs: none directly (submission, not save, is what notifies).
  • Notifications: none.
  • Audit logs: ActivityAuditInterceptor, module ExamMarks, action UPDATE (admin route) or the teacher-portal's own handler key.

Error Cases

HTTP StatusError CodeConditionUser-Facing MeaningSource
404EXAM_ROUTINE_ENTRY_NOT_FOUNDEntry does not exist (admin), or — on the teacher route — exists but the caller is not scoped to it (identical 404 either way)Not foundexam-shared-resolvers.service.ts:120, teacher-exam-marks-portal.service.ts:26
409EXAM_MARKS_NOT_YET_OPENThe gate (§3) is closedWait until the paper's end time, or ask an admin to mark it complete earlyexam-marks-sheet.service.ts:112
409EXAM_MARKS_NOT_EDITABLESubmission state is past draft/returnedThe sheet is locked — ask a verifier to return it:122
404STUDENT_NOT_FOUNDstudentId not on the live rollRefresh the roster:136
404EXAM_COMPONENT_NOT_FOUNDcomponentId not on this entryRefresh the component list:145
422EXAM_MARK_ABSENT_CANNOT_SCORENon-present attendance with a non-null markClear the mark or set attendance to present:152
422EXAM_MARK_EXCEEDS_CEILINGobtainedMarks exceeds the component's maxMarksLower 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: null on a present row clears a previously-saved score back to "not scored", which is distinct from 0.
  • The teacher route's entryPublicId not belonging to the examPublicId in 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

EvidencePath
Controllersapps/api/src/modules/exams/marks/admin/exam-marks-workflow.controller.ts, apps/api/src/modules/exams/portal/teacher/teacher-exam-marks-portal.controller.ts
DTOapps/api/src/modules/exams/marks/core/dto/exam-marks-workflow.dto.ts
Serviceapps/api/src/modules/exams/marks/core/exam-marks-workflow.service.ts
Testsapps/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-submitted state is 409 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, via onConflictDoNothing racing safely against a concurrent first save; then transitioned to submitted).
  • BullMQ jobs: ExamJob.MARKS_SUBMITTED_NOTIFY via the outbox, deduped on exam-marks-submitted-<publicId>-v<version>.
  • Notifications: exam.marks_submitted.
  • Audit logs: yes.

Error Cases

HTTP StatusError CodeConditionSource
409EXAM_MARKS_NOT_YET_OPENGate closed:104
409EXAM_MARKS_INCOMPLETE≥1 present pupil has no mark on ≥1 component:111, names every missing pupil in details.students
409EXAM_MARKS_TRANSITION_INVALIDCurrent state is not draft/returned:119
409EXAM_MARKS_SUBMISSION_VERSION_CONFLICTStale version:286 (versionConflict())
409EXAM_MARKS_SUBMISSION_NOT_FOUNDInternal-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

  • getOrCreateSubmission loses 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_marks row identically to one with a present/null row — 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

EvidencePath
Controllerapps/api/src/modules/exams/results/admin/exam-results.controller.ts
DTOapps/api/src/modules/exams/results/core/dto/exam-result.dto.ts
Servicesapps/api/src/modules/exams/results/core/exam-results.service.ts, exam-results-compute.service.ts
Schemapackages/db/src/schema/school/exams.ts (exam_results, exam_subject_results)

Auth and Permissions

  • Permission: ExamResults_READ (list/detail), ExamResults_UPDATE (compute).

Request (list)

PartRequiredDetails
ParamsYes:publicId — the exam.
QueryNostatus? (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_results upsert on (examId, studentId), exam_subject_results delete-then-reinsert per pupil, inside a per-exam Postgres advisory-lock transaction.
  • BullMQ jobs: ExamJob.RESULT_COMPUTE when 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 StatusError CodeConditionSource
404EXAM_NOT_FOUNDExam does not existexam-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 overwrites classId — that column records which class the pupil SAT the exam in, at first compute, permanently.
  • A withheld result IS still returned by GET .../results to an admin caller (only the audience surface hides it).
  • isStale: true persists until the next compute call — nothing clears it automatically, and publish refuses 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

EvidencePath
Controllersapps/api/src/modules/exams/portal/student/student-exams-portal.controller.ts, apps/api/src/modules/exams/portal/guardian/guardian-exams-portal.controller.ts
Serviceapps/api/src/modules/exams/portal/core/exam-portal-reads.service.ts (getExamResult), student-exams-portal.service.ts, guardian-exams-portal.service.ts
DTOapps/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 via assertPortalAudience/GuardianPortalService.resolveOwnChildId.
  • Rate limit: none.

Request

PartRequiredDetails
ParamsYes (guardian only):id — the child's students.id.
ParamsYes: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 StatusError CodeConditionSource
404EXAM_NOT_FOUNDExam 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 fourexam-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 withheld is 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 404 from 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

EndpointPagination TypeDefault SizeMax SizeSort FieldsFiltersResult Cap
GET /examspage/size20100updatedAt (default), name, startDatesearch, academicSessionId, examTypeId, status (7-value display-status vocabulary, translated to SQL)none beyond page size
GET /exam-typespage/size20100sortOrder (default), namesearch, isActivepagination=false allowed, capped 1000
GET /grading-scalespage/size20100n/a (always name asc)includeInactive (boolean)pagination=false allowed, capped 1000
GET /exams/:id/resultspage/size20100n/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/examsnone (unpaginated)——fixed: startDate descnone1000, with truncated: true on overflow
GET .../teacher/exams, .../teacher/invigilationsnone (unpaginated)——fixednone1000, with truncated
GET .../teacher/exams/marks-queuenone——fixed: exam date/start-minute ascnoneno 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

IntegrationUsed?DetailsSource
Redis cacheNoNo route in this module reads or writes a cache key.—
BullMQYesQueueName.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 APINoAttachment 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

RouteController MethodDTOsService MethodGuardsPermissionsCacheJobsErrorsDocumented?
GET /api/exam-typesExamTypesController.findAllListExamTypesQueryDto→ExamTypeDto[]ExamTypesService.findAllJWT+RoleExamTypes_READN/AN/A—Yes
POST /api/exam-typesExamTypesController.createCreateExamTypeDto→ExamTypeDtoExamTypesService.createJWT+RoleExamTypes_CREATEN/AN/AEXAM_TYPE_NAME_TAKEN, EXAM_TYPE_CODE_TAKEN§6.6
PATCH /api/exam-types/:publicId.updateUpdateExamTypeDto→ExamTypeDto.updateJWT+RoleExamTypes_UPDATEN/AN/AEXAM_TYPE_NOT_FOUND, name/code taken§6.6
DELETE /api/exam-types/:publicId.removenone→message.removeJWT+RoleExamTypes_DELETEN/AN/AEXAM_TYPE_NOT_FOUND, EXAM_TYPE_IN_USE§6.6
GET /api/examsExamsController.findAllListExamsQueryDto→ExamListItemDto[]ExamDetailService.listJWT+RoleExams_READN/AN/A—§10
POST /api/exams.createCreateExamDto→ExamDetailDtoExamWriteService.createJWT+RoleExams_CREATEN/AN/Asee §8.1§8.1
GET /api/exams/:publicId.findOne→ExamDetailDtoExamDetailService.detailJWT+RoleExams_READN/AN/AEXAM_NOT_FOUND§6.2
PATCH /api/exams/:publicId.updateUpdateExamDto→ExamDetailDtoExamWriteService.updateJWT+RoleExams_UPDATEN/AN/AEXAM_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.removeJWT+RoleExams_DELETEN/AN/AEXAM_VERSION_CONFLICT, EXAM_STATE_TRANSITION_INVALID§6.2
POST /api/exams/:publicId/publishExamLifecycleController.publishVersionDto→ExamDetailDtoExamLifecycleService.publishJWT+RoleExamPublish_UPDATEN/APUBLISH_NOTIFYsee §8.2§8.2
POST /api/exams/:publicId/unpublish.unpublishVersionDto→ExamDetailDto.unpublishJWT+RoleExamPublish_UPDATEN/AN/AEXAM_CANNOT_UNPUBLISH_WITH_FINALIZED_MARKS, EXAM_NOT_PUBLISHED, EXAM_STATE_TRANSITION_INVALID, EXAM_VERSION_CONFLICT§6.5
POST /api/exams/:publicId/cancel.cancelCancelExamDto→ExamDetailDto.cancelJWT+RoleExamCancel_UPDATEN/ACANCEL_NOTIFYEXAM_CANNOT_CANCEL_AFTER_RESULTS_PUBLISHED, EXAM_STATE_TRANSITION_INVALID, EXAM_VERSION_CONFLICT§6.5
POST /api/exams/:publicId/reinstate.reinstateVersionDto→ExamDetailDto.reinstateJWT+RoleExamCancel_UPDATEN/AN/AEXAM_STATE_TRANSITION_INVALID, EXAM_VERSION_CONFLICT§6.5
POST /api/exams/:publicId/complete.completeVersionDto→ExamDetailDto.completeJWT+RoleExams_UPDATEN/AN/AEXAM_CANNOT_COMPLETE_BEFORE_LAST_PAPER, EXAM_STATE_TRANSITION_INVALID, EXAM_VERSION_CONFLICT§6.5
POST /api/exams/:publicId/archive.archiveVersionDto→ExamDetailDto.archiveJWT+RoleExams_UPDATEN/AN/AEXAM_STATE_TRANSITION_INVALID, EXAM_VERSION_CONFLICT§6.5
POST /api/exams/:publicId/unarchive.unarchiveVersionDto→ExamDetailDto.unarchiveJWT+RoleExams_UPDATEN/AN/AEXAM_STATE_TRANSITION_INVALID, EXAM_VERSION_CONFLICT§6.5
GET /api/exams/:publicId/classesExamClassesController.list→ExamClassRefDto[]ExamClassesService.listJWT+RoleExams_READN/AN/AEXAM_NOT_FOUND§6.3
PUT /api/exams/:publicId/classes.replaceReplaceExamClassesDto→ExamClassRefDto[].replaceJWT+RoleExams_UPDATEN/AN/AVALIDATION_FAILED, EXAM_CLASS_HAS_RECORDED_MARKS§6.3
GET /api/exams/:publicId/attachmentsExamAttachmentsController.list→ExamAttachmentDto[]ExamAttachmentsService.listJWT+RoleExams_READN/AN/AEXAM_NOT_FOUND§6.4
POST /api/exams/:publicId/attachments.uploadmultipart→ExamAttachmentDto.uploadJWT+RoleExams_UPDATEN/AN/AUPLOAD_FILE_REQUIRED, UPLOAD_VALIDATION_FAILED, UPLOAD_STORAGE_FAILED§6.4
DELETE /api/exams/:publicId/attachments/:attachmentPublicId.remove→message.removeJWT+RoleExams_UPDATEN/AN/AEXAM_ATTACHMENT_NOT_FOUND§6.4
GET /api/exams/:publicId/routineExamRoutineController.list→ExamRoutineEntryDto[]ExamRoutineReadService.listForExamJWT+RoleExamRoutines_READN/AN/AEXAM_NOT_FOUND§6.8
POST /api/exams/:publicId/routine.createCreateExamRoutineEntryDto→ExamRoutineEntryDtoExamRoutineWriteService.createJWT+RoleExamRoutines_CREATEN/AN/AEXAM_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→ExamConflictsReportDtoExamPublishPreviewReadService.conflictsReportJWT+RoleExamRoutines_READN/AN/AEXAM_NOT_FOUND§6.12
GET /api/exams/:publicId/publish-preview.publishPreview→ExamPublishPreviewDto.publishPreviewJWT+RoleExams_READN/AN/AEXAM_NOT_FOUND§6.12
POST /api/exam-routine-entries/swapExamRoutineEntryController.swapSwapExamRoutineEntriesDto→{entry, otherEntry}ExamRoutineSwapService.swapJWT+RoleExamRoutines_UPDATEN/ASCHEDULE_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.patchPatchExamRoutineEntryDto→ExamRoutineEntryDtoExamRoutineWriteService.patchJWT+RoleExamRoutines_UPDATEN/ASCHEDULE_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.removeJWT+RoleExamRoutines_DELETEN/ASCHEDULE_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.completeJWT+RoleExamRoutines_UPDATEN/AN/AEXAM_ROUTINE_ENTRY_NOT_FOUND (idempotent success if already complete)§4
GET /api/exam-routine-entries/:publicId/availability.availability→ExamEntryAvailabilityDtoExamRoutineReadService.availabilityJWT+RoleExamRoutines_READN/AN/AEXAM_ROUTINE_ENTRY_NOT_FOUND§6.11
PUT /api/exam-routine-entries/:publicId/components.replaceComponentsPutExamComponentsDto→ExamComponentDto[]ExamComponentsWriteService.replaceJWT+RoleExamRoutines_UPDATEN/AN/AEXAM_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.replaceInvigilatorsPutExamInvigilatorsDto→ExamInvigilatorDto[]ExamInvigilatorsWriteService.replaceJWT+RoleExamRoutines_UPDATEN/AINVIGILATION_NOTIFY (published, on change)EXAM_INVIGILATOR_NOT_FOUND, conflict codes, PERMISSION_INSUFFICIENT§6.10
GET /api/exam-dayExamDayController.today→ExamDayDtoExamDayReadService.todayJWT+RoleExamRoutines_READN/AN/A—§6.20
GET /api/exam-dashboardExamDashboardController.totalsExamDashboardQueryDto→ExamDashboardDtoExamDashboardService.totalsJWT+RoleExams_READN/AN/AACADEMIC_SESSION_NOT_FOUND§6.19
GET /api/grading-scalesGradingScalesController.listListGradingScalesQueryDto→GradingScaleDto[]GradingScalesService.listJWT+RoleExamGrading_READN/AN/A—§6.7
POST /api/grading-scales.createCreateGradingScaleDto→GradingScaleDto.createJWT+RoleExamGrading_CREATEN/AN/AGRADING_SCALE_NAME_TAKEN§6.7
PATCH /api/grading-scales/:publicId.updateUpdateGradingScaleDto→GradingScaleDto.updateJWT+RoleExamGrading_UPDATEN/AN/AGRADING_SCALE_NOT_FOUND, EXAM_VERSION_CONFLICT, name taken§6.7
DELETE /api/grading-scales/:publicId.remove?version=→message.removeJWT+RoleExamGrading_DELETEN/AN/AGRADING_SCALE_NOT_FOUND, EXAM_VERSION_CONFLICT, GRADING_SCALE_IN_USE§6.7
PUT /api/grading-scales/:publicId/bands.replaceBandsReplaceGradingScaleBandsDto→GradingScaleDto.replaceBandsJWT+RoleExamGrading_UPDATEN/AN/AGRADING_SCALE_NOT_FOUND, EXAM_VERSION_CONFLICT, GRADING_SCALE_BAND_INVALID§6.7
GET /api/exam-routine-entries/:publicId/marksExamMarksController.getSheet→ExamMarksSheetDtoExamMarksSheetService.getSheetJWT+RoleExamMarks_READN/AN/AEXAM_ROUTINE_ENTRY_NOT_FOUND§6.13
PUT /api/exam-routine-entries/:publicId/marks.saveSheetSaveExamMarksDto→ExamMarksSheetDto.saveMarksJWT+RoleExamMarks_UPDATEN/AN/Asee §8.3§8.3
POST /api/exam-routine-entries/:publicId/marks/submitExamMarksWorkflowController.submitExamMarksVersionDto→ExamMarksSubmissionDtoExamMarksWorkflowService.submitJWT+RoleExamMarks_UPDATEN/AMARKS_SUBMITTED_NOTIFYsee §8.4§8.4
POST /api/exam-routine-entries/:publicId/marks/return.returnMarksReturnExamMarksDto→ExamMarksSubmissionDto.returnMarksJWT+RoleExamMarksVerify_UPDATEN/AN/AEXAM_MARKS_TRANSITION_INVALID, EXAM_MARKS_SUBMISSION_VERSION_CONFLICT, EXAM_MARKS_SUBMISSION_NOT_FOUND§6.14
POST /api/exam-routine-entries/:publicId/marks/approve.approveExamMarksVersionDto→ExamMarksSubmissionDto.approveJWT+RoleExamMarksVerify_UPDATEN/AN/Asame three as return§6.14
POST /api/exam-routine-entries/:publicId/marks/finalize.finalizeExamMarksVersionDto→ExamMarksSubmissionDto.finalizeJWT+RoleExamMarksVerify_UPDATEN/AN/Asame three as return§6.14
POST /api/exam-marks/:publicId/correctExamMarkCorrectionsController.correctCorrectExamMarkDto→ExamMarkDetailDtoExamMarkCorrectionsService.correctJWT+RoleExamMarksCorrect_UPDATEN/AN/AEXAM_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[].listCorrectionsJWT+RoleExamMarksCorrect_READN/AN/AEXAM_MARK_NOT_FOUND§6.15
GET /api/exams/:publicId/resultsExamResultsController.listListExamResultsQueryDto→ExamResultSummaryDto[]ExamResultsService.listJWT+RoleExamResults_READN/AN/AEXAM_NOT_FOUND§8.5
POST /api/exams/:publicId/results/compute.compute→ComputeResultsResponseDtoExamResultsComputeService.computeJWT+RoleExamResults_UPDATEN/ARESULT_COMPUTE (if roll > 500)EXAM_NOT_FOUND§8.5
POST /api/exams/:publicId/results/publish.publish→messageExamResultsService.publishJWT+RoleExamResultPublish_UPDATEN/ARESULT_PUBLISH_NOTIFYEXAM_RESULTS_NOT_ALL_FINALIZED, EXAM_RESULTS_STALE, EXAM_RESULTS_ALREADY_PUBLISHED§6.16
POST /api/exams/:publicId/results/unpublish.unpublish→message.unpublishJWT+RoleExamResultPublish_UPDATEN/AN/AEXAM_RESULTS_NOT_PUBLISHED§6.16
GET /api/exams/:publicId/results/:studentPublicId.detail→ExamResultDetailDto.detailJWT+RoleExamResults_READN/AN/AEXAM_RESULT_NOT_FOUND§6.16
POST /api/exams/:publicId/results/:studentPublicId/withhold.withholdWithholdOrReleaseResultDto→ExamResultSummaryDto.withholdJWT+RoleExamResultPublish_UPDATEN/AN/AEXAM_RESULT_NOT_FOUND, EXAM_VERSION_CONFLICT§6.16
POST /api/exams/:publicId/results/:studentPublicId/release.releaseWithholdOrReleaseResultDto→ExamResultSummaryDto.releaseJWT+RoleExamResultPublish_UPDATEN/AN/AEXAM_RESULT_NOT_FOUND, EXAM_VERSION_CONFLICT§6.16
GET /api/exam-notification-settingsExamNotificationSettingsController.list→ExamNotificationRuleDto[]ExamNotificationRulesService.listJWT+RoleExamNotificationSettings_READN/AN/A—§6.17
PUT /api/exam-notification-settings/:event.updateUpdateExamNotificationRuleDto→ExamNotificationRuleDto.updateJWT+RoleExamNotificationSettings_UPDATEN/AN/AEXAM_NOTIFICATION_RULE_NOT_FOUND, EXAM_NOTIFICATION_RULE_INVALID, EXAM_VERSION_CONFLICT§6.17
GET /api/exam-reminder-rulesExamReminderRulesController.list→ExamReminderRuleDto[]ExamReminderRulesService.listJWT+RoleExamNotificationSettings_READN/AN/A—§6.18
PUT /api/exam-reminder-rules.replaceReplaceExamReminderRulesDto→ExamReminderRuleDto[].replaceJWT+RoleExamNotificationSettings_UPDATEN/AN/AEXAM_REMINDER_RULE_INVALID, EXAM_VERSION_CONFLICT, EXAM_REMINDER_RULES_TOO_CLOSE§6.18
GET /api/mobile/student/examsStudentExamsPortalController.listExams→ExamPortalListDtoExamPortalReadsService.listExamsJWT+Role, audiencenoneN/AN/ASTUDENT_NOT_FOUND (own row missing)§6.21
GET /api/mobile/student/exams/dashboard.getDashboard→ExamPortalDashboardDto.getDashboardJWT+Role, audiencenoneN/AN/ASTUDENT_NOT_FOUND§6.21
GET /api/mobile/student/exams/:examPublicId.getExamDetail→ExamPortalDetailDto.getExamDetailJWT+Role, audiencenoneN/AN/AEXAM_NOT_FOUND§6.21
GET /api/mobile/student/exams/:examPublicId/result.getExamResult→ExamPortalResultDto.getExamResultJWT+Role, audiencenoneN/AN/AEXAM_NOT_FOUND§8.6
GET /api/mobile/student/exams/:examPublicId/attachments/:attachmentPublicId/download.downloadAttachment→binary stream.streamAttachmentJWT+Role, audiencenoneN/AN/AEXAM_NOT_FOUND, EXAM_ATTACHMENT_NOT_FOUND, EXAM_ATTACHMENT_NOT_AUDIENCE_VISIBLE§5
GET /api/mobile/student/academic-history.listAcademicHistory→ExamPortalHistoryDto.listAcademicHistoryJWT+Role, audiencenoneN/AN/ASTUDENT_NOT_FOUND§6.21
GET /api/mobile/guardian/children/:id/examsGuardianExamsPortalController.listExams→ExamPortalListDtoExamPortalReadsService.listExamsJWT+Role, audiencenoneN/AN/A404 from child resolution§6.21
GET /api/mobile/guardian/children/:id/exams/dashboard.getDashboard→ExamPortalDashboardDto.getDashboardJWT+Role, audiencenoneN/AN/A404 from child resolution§6.21
GET /api/mobile/guardian/children/:id/exams/:examPublicId.getExamDetail→ExamPortalDetailDto.getExamDetailJWT+Role, audiencenoneN/AN/AEXAM_NOT_FOUND or child-resolution 404§6.21
GET /api/mobile/guardian/children/:id/exams/:examPublicId/result.getExamResult→ExamPortalResultDto.getExamResultJWT+Role, audiencenoneN/AN/AEXAM_NOT_FOUND or child-resolution 404§8.6
GET /api/mobile/guardian/children/:id/exams/:examPublicId/attachments/:attachmentPublicId/download.downloadAttachment→binary stream.streamAttachmentJWT+Role, audiencenoneN/AN/AEXAM_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.listAcademicHistoryJWT+Role, audiencenoneN/AN/A404 from child resolution§6.21
GET /api/mobile/teacher/exams/dashboardTeacherExamsPortalController.getDashboard→TeacherExamDashboardDtoTeacherExamDutiesPortalService.getDashboardJWT+Role, audiencenoneN/AN/APERMISSION_INSUFFICIENT (not staff)§6.22
GET /api/mobile/teacher/exams/marks-queue.getMarksQueue→TeacherMarksQueueDto.listMarksQueueJWT+Role, audiencenoneN/AN/APERMISSION_INSUFFICIENT§6.22
GET /api/mobile/teacher/exams.listExams→TeacherExamListDtoTeacherExamsPortalService.listExamsJWT+Role, audiencenoneN/AN/APERMISSION_INSUFFICIENT§6.22
GET /api/mobile/teacher/exams/:examPublicId.getExamDetail→TeacherExamDetailDto.getExamDetailJWT+Role, audiencenoneN/AN/AEXAM_NOT_FOUND (not in scope), PERMISSION_INSUFFICIENT§6.22
GET /api/mobile/teacher/exams/:examPublicId/attachments/:attachmentPublicId/download.downloadAttachment→binary stream.streamAttachmentJWT+Role, audiencenoneN/AN/AEXAM_NOT_FOUND, EXAM_ATTACHMENT_NOT_FOUND, EXAM_ATTACHMENT_NOT_AUDIENCE_VISIBLE, PERMISSION_INSUFFICIENT§6.22
GET /api/mobile/teacher/invigilations.listInvigilations→TeacherInvigilationListDtoTeacherExamDutiesPortalService.listInvigilationsJWT+Role, audiencenoneN/AN/APERMISSION_INSUFFICIENT§6.22
GET /api/mobile/teacher/exams/:examPublicId/entries/:entryPublicId/marksTeacherExamMarksPortalController.getSheet→ExamMarksSheetDtoTeacherExamMarksPortalService.getSheetJWT+Role, audiencenoneN/AN/AEXAM_ROUTINE_ENTRY_NOT_FOUND (not scoped), PERMISSION_INSUFFICIENT§6.13
PUT /api/mobile/teacher/exams/:examPublicId/entries/:entryPublicId/marks.saveMarksSaveExamMarksDto→ExamMarksSheetDto.saveMarksJWT+Role, audiencenoneN/AN/Asee §8.3§8.3
POST /api/mobile/teacher/exams/:examPublicId/entries/:entryPublicId/marks/submit.submitExamMarksVersionDto→ExamMarksSubmissionDto.submitJWT+Role, audiencenoneN/AMARKS_SUBMITTED_NOTIFYsee §8.4§8.4
GET /api/mobile/teacher/schedule/todayTeacherTodayPortalController.getToday→TeacherTodayScheduleDtoTeacherTodayPortalService.getTodayJWT+Role, audiencenoneN/AN/APERMISSION_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

ConsumerRequired KnowledgeFailure HandlingContract 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 400sShow the named error code's message; retry a 409 EXAM_VERSION_CONFLICT/EXAM_ROUTINE_ENTRY_STATE_CHANGED only after re-fetchingStable
Teacher portalNo 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 rowA 404 on a marks/exam route the teacher expects to see usually means the underlying class_subjects.teacher_id assignment changed, not a bugStable
Student/Guardian portalThe 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 intentionalStable
QAEvery 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 readReproduce a "missing pupil" report by checking record_status and enrolment status before assuming a code defectStable
Internal service / workerExamJob.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 landedRetryable: every job here is idempotent by dedupe key or by upsert targetStable

13.5 API Tradeoffs and Rationale

DecisionChosen BehaviorAlternatives ConsideredWhy This TradeoffRiskMitigation
Two version shapes in one moduleString tokens (trigger-bumped) for most entities; plain integers (service-incremented) for routine entries/componentsOne uniform string shape everywhereRoutine-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 raceA client library that formats every version as a string breaks silently on these two DTOsDocumented explicitly in §3; @IsInt() on the DTO turns the mistake into a 400, not silent corruption
Result compute: sync under 500 pupils, async overA request-time size threshold rather than always-asyncAlways 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 queueA caller assuming mode: "computed" always for a large school gets resultCount: null unexpectedlyComputeResultsResponseDto.mode is the documented signal; callers must branch on it
Ambiguous 404 on every audience exam/result/attachment readOne error code for "does not exist", "not in your scope", and (for results) "not yet published"/"withheld"Distinct codes per caseAn 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 themDocumented 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 moduleRely on JWT auth + row-scope aloneThrottle uploads/downloads as Assignments doesNot stated in any source comment read this session — an observed difference, not a documented design choiceAn unthrottled download route is a larger DoS/scrape surface than a throttled oneFlagged here as a defect candidate, not fixed — see the final report
No idempotency key on exam/routine-entry createRely on the caller not double-submittingA clientRequestId per Assignments' precedentNot stated in source; the admin panel is the only client and controls its own double-submit prevention client-sideA retried POST (e.g. after a timeout) creates a duplicate draft exam or routine entryFlagged here as a defect candidate, not fixed

13.6 API Change Impact

ChangeAffected ConsumersBackend ImpactData ImpactMigration Needed?Compatibility Plan
Changing exam_routine_entries.version/exam_assessment_components.version from integer to a trigger-bumped stringAdmin panel's routine/component editors; the swap routeEvery WHERE version = X comparison, dto.version + 1 arithmetic, and the @IsInt() validator would need rewritingNone to stored data — only the DTO/validation shapeYes — DTO and validator change, no schema migrationVersion 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 eventNotification settings screen; ExamNotifierService's RULE_EVENT_BY_NOTIFY_EVENT mapNew seed row via migration; new map entryNew row in exam_notification_rulesYesAdditive — existing rows/consumers unaffected
Exposing classId as a real filter on GET /exams/:publicId/resultsAdmin results screen wanting a per-class breakdownNew query field on ListExamResultsQueryDto, new predicate in ExamResultsService.listNoneNo schema migrationAdditive; 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

On this page

Exams - API Reference1. Documentation Evidence2. Module Summary3. Concepts and Terminology4. API Surface Map5. Auth, Identity, and Permissions6. DTO and Model Reference6.1 CreateExamDto6.1a UpdateExamDto6.2 ExamListItemDto / ExamDetailDto (response)6.3 ExamClassRefDto / ReplaceExamClassesDto6.4 ExamAttachmentUploadDto / ExamAttachmentDto6.5 VersionDto / CancelExamDto (lifecycle request bodies)6.6 ExamTypeDto / CreateExamTypeDto / UpdateExamTypeDto6.7 GradingScaleDto and bands6.8 Routine entry DTOs6.9 PutExamComponentsDto / ExamComponentDto6.10 PutExamInvigilatorsDto / ExamInvigilatorDto6.11 ExamEntryAvailabilityDto6.12 ExamConflictsReportDto / ExamPublishPreviewDto6.13 Marks sheet DTOs6.14 Marks workflow request DTOs6.15 Mark correction DTOs6.16 Result DTOs6.17 ExamNotificationRuleDto / UpdateExamNotificationRuleDto6.18 ExamReminderRuleDto / reminder-rule replacement DTOs6.19 ExamDashboardDto / ExamDashboardQueryDto6.20 ExamDayDto6.21 Portal shared DTOs (student/guardian)6.22 Teacher portal DTOs6.23 schedule/today DTOs (modules/mobile/teacher/)7. Enum Reference8. Endpoint Reference8.1 POST /api/examsPurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesEdge CasesExample Requests8.2 POST /api/exams/:publicId/publishPurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesEdge CasesExample Requests8.3 PUT /api/exam-routine-entries/:publicId/marks (admin) and the teacher-portal mirrorPurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesEdge CasesExample Requests8.4 POST /api/exam-routine-entries/:publicId/marks/submit (and the teacher-portal mirror)PurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesEdge Cases8.5 GET /api/exams/:publicId/results and POST /api/exams/:publicId/results/computePurposeSource EvidenceAuth and PermissionsRequest (list)Response (list)Response (compute)Side EffectsError CasesEdge Cases8.6 GET /api/mobile/student/exams/:examPublicId/result (and the guardian mirror)PurposeSource EvidenceAuth and PermissionsRequestResponseError CasesEdge Cases9. Flow Diagrams9.1 Route Ownership9.2 Request Sequence — publish9.3 Request Sequence — marks submission state machine9.4 Error Branch — audience exam read9.5 Activity Diagram — result publish9.6 Data Contract Map — routine entry create9.7 Cache Flow9.8 Async/Job Flow — result compute over the sync cap10. Pagination, Sorting, Filtering, and Search11. Caching, Jobs, and External Integrations13. Mandatory Deep API Documentation Pack13.1 Route-by-Route Completeness Matrix13.2 Request/Response Exhaustiveness13.3 API Diagram Pack13.4 Consumer Integration Notes13.5 API Tradeoffs and Rationale13.6 API Change Impact14. Zero-Omission API Checklist15. Integration ChecklistSee Also