Exams Backend Documentation
Backend architecture, data model, services, cache, queues, runtime rules, and operational behavior for Examinations and Results.
Exams Backend Documentation
1. Documentation Evidence
| Area | Files Inspected | Verified Details |
|---|---|---|
| Module wiring | apps/api/src/modules/exams/**/*.module.ts (18 modules), apps/api/src/app.module.ts, apps/api/src/modules/mobile/mobile.module.ts, apps/api/src/config/swagger/swagger-documents.ts | Composition, ADMIN_MODULES/MOBILE_CHILDREN membership, fast-runtime-profile exclusion of the workers module |
| Controllers | 22 controllers under apps/api/src/modules/exams/**/*.controller.ts | Every route path, HTTP verb and @Permissions() code, read directly off the decorators |
| Services | 34 services/utils under apps/api/src/modules/exams/**/*.service.ts, *.util.ts | Business logic, transactions, side effects, all read in full |
| Schema | packages/db/src/schema/school/exams.ts (17 tables, 8 enums) | Every column, constraint, index, relation |
| Migrations | packages/db/src/migrations/0029_exam_module.sql … 0032_exam_schema_hardening.sql | 0029 creates the tables and enums; 0030 adds the three GiST exclusions, the version triggers, the two guard triggers and every seed row; 0031 adds the completed_at CHECK, the exam_results.class_id key and the relaxed results-publication CHECK; 0032 adds the result-percentage and finaliser CHECKs and drops six redundant indexes |
| Down migrations | packages/db/src/migrations/down/0029_exam_module.down.sql … 0032_exam_schema_hardening.down.sql | Applied newest first, each restores the schema exactly as it stood before its migration; 0029's down drops every exam table and its data, so take a pg_dump first |
| Jobs | packages/jobs/src/index.ts (QueueName.EXAMS, ExamJob, ExamSweepPayload, ExamNotifyPayload, ExamResultComputePayload) | Queue contract and payload shapes |
| Error codes | apps/api/src/common/types/error-codes.ts — 55 EXAM_*/GRADING_SCALE_* codes under the "EXAMINATIONS AND RESULTS" heading | Every code's throw site |
| Permissions | packages/db/src/authorization/permission-catalog.ts, apps/api/src/common/authorization/role.guard.ts, apps/api/test/structure/route-permissions.spec.ts | 13 Exam* modules, the NO_PERMISSION_ADMIN_HANDLERS mirror for the 20 portal handlers |
| Time composition | apps/api/src/utils/school-time/school-time.util.ts | toInstant/toInstantRange, the DST-resolution rules |
| Upload policy | packages/storage/src/upload-type.ts, packages/storage/src/upload-allowlist.ts | EXAM_ATTACHMENT private, excluded from PUBLIC_TYPES |
2. Backend Scope and Boundaries
Owns
- The school's own catalogue of examination types (
exam_types) and configurable grading scales with GPA bands (grading_scales,grading_scale_bands). - Examination authoring: create, update (period, name, notes), delete (draft-only, no class scope), class-scope replacement, attachments (administrator-only or audience-visible).
- Lifecycle: publish, unpublish, cancel, reinstate, complete (early or hourly-swept), archive, unarchive — one service, one transition per call, database-enforced concurrency.
- The routine builder: per-class, per-subject routine entries (date, time, room), assessment components (theory/practical/internal/viva/project/other), invigilator assignment, a swap operation that relocates two papers in one transaction, and a publish-preview/conflicts report.
- Marks: the sheet read/write against the live class roll, a five-state submission workflow (draft → submitted → returned/approved → finalized), and append-only corrections to already-finalized marks.
- Results: synchronous or queued computation of every pupil's per-subject and overall outcome, withhold/release of an individual result, and a separate results-publication gate from the exam's own lifecycle.
- Time-driven work: a 15-minute reminder sweep keyed on the live schedule, an hourly lifecycle sweep that auto-completes ended exams and emits "tomorrow"/"today" notifications.
- Notifications: one
ExamNotifierServiceresolving recipients from live rows and the module's own rule tables, calling the sharedNotificationService; oneExamNotificationScopeServicedoing every recipient-resolution query. - Settings: which channels/audiences each of the seven notification events uses, and the reminder-offset list (with its own scratch-slot replace algorithm).
- The teacher, student and guardian portal surfaces for exams, results, marks entry and invigilation duties — sharing no code with the admin controllers beyond the read-only
exam-shared-resolvers/exam-conflictutilities and, for marks, delegating the write itself to the admin-side marks services under a narrower predicate. - A calendar projection (
ExamCalendarProjectionService) that other modules read from — the calendar has no exam rows of its own.
Does Not Own
- Classes, sections, rooms, class-subjects, academic sessions, staff and student enrolment (
academic-structure,peoplemodules) — exams reference them but never write them. - Storage bytes themselves (
@skoolsewa/storage'sStorageManager) — the module ownsexam_attachmentsmetadata and calls the manager directly; nothing here is ever signed. - The outbox and BullMQ infrastructure (
OutboxService,@skoolsewa/jobs) — the module is a producer/consumer, not the owner. - Notification delivery mechanics (channel senders, per-user preference resolution) —
NotificationServiceand its channel drivers own that; this module supplies templates, recipients and rule configuration. - Authentication and role/permission evaluation (
JwtAuthGuard,RoleGuard) — the module supplies@Permissions()codes for its admin surface and its own audience assertions for the portal surface. - Timezone arithmetic itself —
SchoolClockServiceandapps/api/src/utils/school-time/school-time.util.tsown composing a date and a minute-of-day into an instant; every exam service that needs "now" or "is this instant past" calls through them. - The academic calendar's own day classification (
academic_calendar_days) — read, never written, byExamCalendarProjectionServiceand the publish-preview's school-day warning.
Source of Truth
| Concern | Source of Truth | Notes |
|---|---|---|
| Exam lifecycle state | exams.status (stored) | "Scheduled"/"in progress" are DERIVED (deriveExamDisplayStatus) from status = 'published' plus today's date in the school's timezone — a stored "in progress" is wrong from the minute after it is written. |
| Routine entry status copy | exam_routine_entries.exam_status, exam_invigilators.exam_status | Copies maintained ONLY by ON UPDATE cascade from exams.status; a direct write is refused by a trigger (§9's guard). |
| Marks submission state | exam_marks_submissions.state | The one authority for what a paper's marks workflow allows next; exam_marks.attendance/obtained_marks are the data the state gates. |
| Computed result | exam_results, exam_subject_results | Pure functions in exam-result-rules.ts, orchestrated by ExamResultsComputeService; is_stale is the reconciliation flag a correction sets and a recompute clears. |
| Concurrency token | version (bigint, bumped by bump_row_version() triggers) on exam_types, grading_scales, exams, exam_routine_entries, exam_assessment_components, exam_marks_submissions, exam_marks, exam_results, exam_notification_rules, exam_reminder_rules | Clients send back the last-seen version; a mismatch is EXAM_VERSION_CONFLICT / EXAM_MARKS_SUBMISSION_VERSION_CONFLICT (409). |
| Room/class/invigilator conflict-freedom | Three GiST EXCLUDE constraints (migration 0030) | Armed only while exam_status IN ('published','completed'); the database, not a service check, is what makes overlap unrepresentable once live. |
| Which recipients/channels an event uses | exam_notification_rules row for that event | Seven rows, seeded, never created or deleted by the API — only read and updated. |
| Reminder dedupe | exam_reminder_dispatches — (routine_entry_id, rule_id, exam_date, start_minute) | The row IS the claim; a reschedule changes the key and re-arms by itself. |
3. Module Composition
There is no aggregate ExamsModule. Eight admin leaf modules, one workers module, and three portal leaf modules are each composed directly by app.module.ts / mobile.module.ts, mirroring how ADMIN_MODULES and MOBILE_CHILDREN must each name a module that declares its own controllers.
| Module | Type | Path | Controllers | Providers | Exports | Responsibility |
|---|---|---|---|---|---|---|
ExamTypesAdminModule | Leaf | types/admin/exam-types-admin.module.ts | ExamTypesController | — | ExamTypesService | /api/exam-types*. |
ExamsAdminModule | Leaf | authoring/admin/exams-admin.module.ts | ExamsController, ExamLifecycleController, ExamClassesController, ExamAttachmentsController | — | — | /api/exams* except the routine group. |
ExamAuthoringCoreModule | Leaf (shared) | authoring/core/exam-authoring-core.module.ts | None | ExamDetailService, ExamWriteService, ExamLifecycleService, ExamClassesService, ExamAttachmentsService, ExamCalendarProjectionService | Same | The authoring services ExamsAdminModule calls into. |
ExamDashboardAdminModule | Leaf | dashboard/admin/exam-dashboard-admin.module.ts | ExamDashboardController | ExamDashboardService | ExamDashboardService | /api/exam-dashboard. |
ExamRoutineAdminModule | Leaf | routine/admin/exam-routine-admin.module.ts | ExamRoutineController, ExamRoutineEntryController, ExamDayController | — | — | /api/exams/:publicId/routine, /api/exam-routine-entries*, /api/exam-day. |
ExamRoutineCoreModule | Leaf (shared) | routine/core/exam-routine-core.module.ts | None | ExamRoutineReadService, ExamRoutineWriteService, ExamComponentsWriteService, ExamInvigilatorsWriteService, ExamRoutineSwapService, ExamPublishPreviewReadService, ExamDayReadService | Same | Reads, the four write seams, publish-preview, exam-day. |
ExamMarksAdminModule | Leaf | marks/admin/exam-marks-admin.module.ts | ExamMarksController, ExamMarksWorkflowController, ExamMarkCorrectionsController | — | — | /api/exam-routine-entries/:publicId/marks*, /api/exam-marks*. |
ExamMarksCoreModule | Leaf (shared) | marks/core/exam-marks-core.module.ts | None | ExamMarksSheetService, ExamMarksWorkflowService, ExamMarkCorrectionsService | Same | The sheet, the state machine, corrections. Also imported directly by TeacherExamsPortalModule. |
ExamGradingAdminModule | Leaf | grading/admin/exam-grading-admin.module.ts | GradingScalesController | GradingScalesService | GradingScalesService | /api/grading-scales*. |
ExamResultsAdminModule | Leaf | results/admin/exam-results-admin.module.ts | ExamResultsController | — | — | /api/exams/:publicId/results*. |
ExamResultsCoreModule | Leaf (shared) | results/core/exam-results-core.module.ts | None | ExamResultsService, ExamResultsComputeService | Same | Shared by ExamResultsAdminModule (the compute route) and ExamWorkersModule (the RESULT_COMPUTE job), so neither imports the other's module. |
ExamSettingsAdminModule | Leaf | settings/admin/exam-settings-admin.module.ts | ExamNotificationSettingsController, ExamReminderRulesController | ExamNotificationRulesService, ExamReminderRulesService | — | /api/exam-notification-settings, /api/exam-reminder-rules. |
ExamsSharedModule | Leaf (shared) | shared/exams-shared.module.ts | None | ExamSharedResolversService | Same | The publicId → row resolvers every other submodule reads. |
ExamWorkersModule | Leaf | workers/exam-workers.module.ts | None | JobFailureRecorderService, ExamNotificationScopeService, ExamNotifierService, ExamReminderSweepService, ExamLifecycleSweepService, ExamScheduler, ExamResultComputeAdapter (bound to ExamResultComputeRunner), ExamsQueueProcessor | — | Crons + the one processor on QueueName.EXAMS. Imported only outside API_RUNTIME_PROFILE=fast. |
ExamPortalCoreModule | Leaf (shared) | portal/core/exam-portal-core.module.ts | None | ExamPortalReadsService | ExamPortalReadsService, UploadModule | The pupil-scoped reads shared by student and guardian portals. |
StudentExamsPortalModule | Leaf | portal/student/student-exams-portal.module.ts | StudentExamsPortalController | StudentExamsPortalService | — | /api/mobile/student/exams*, /api/mobile/student/academic-history. |
GuardianExamsPortalModule | Leaf | portal/guardian/guardian-exams-portal.module.ts | GuardianExamsPortalController | GuardianExamsPortalService | — | /api/mobile/guardian/children/:id/exams*, .../academic-history. |
TeacherExamsPortalModule | Leaf | portal/teacher/teacher-exams-portal.module.ts | TeacherExamsPortalController, TeacherExamMarksPortalController | TeacherExamsPortalService, TeacherExamDutiesPortalService, TeacherExamMarksPortalService | — | /api/mobile/teacher/exams*, /api/mobile/teacher/invigilations. |
4. File and Directory Map
apps/api/src/modules/exams/
authoring/
admin/ exams.controller.ts, exam-lifecycle.controller.ts, exam-classes.controller.ts, exam-attachments.controller.ts, exams-admin.module.ts
core/ exam-detail/write/lifecycle/classes/attachments/calendar-projection services, exam-display-status.util.ts, exam-attachments.multer.ts, dto/
dashboard/
admin/ exam-dashboard.controller.ts, exam-dashboard-admin.module.ts
core/ exam-dashboard.service.ts, dto/
grading/
admin/ grading-scales.controller.ts, exam-grading-admin.module.ts
core/ grading-scales.service.ts, dto/
marks/
admin/ exam-marks.controller.ts, exam-marks-workflow.controller.ts, exam-mark-corrections.controller.ts, exam-marks-admin.module.ts
core/ exam-marks-sheet/workflow/mark-corrections services, exam-marks-gate.util.ts, dto/
portal/
core/ exam-portal-reads.service.ts, exam-portal-download.util.ts, exam-portal-core.module.ts, dto/
guardian/ guardian-exams-portal.controller.ts, guardian-exams-portal.service.ts, guardian-exams-portal.module.ts
student/ student-exams-portal.controller.ts, student-exams-portal.service.ts, student-exams-portal.module.ts
teacher/ teacher-exams-portal.controller.ts, teacher-exam-marks-portal.controller.ts, teacher-exams-portal.service.ts,
teacher-exam-duties.service.ts, teacher-exam-marks-portal.service.ts, teacher-exam-scope.util.ts, teacher-exams-portal.module.ts, dto/
results/
admin/ exam-results.controller.ts, exam-results-admin.module.ts
core/ exam-results.service.ts, exam-results-compute.service.ts, exam-result-rules.ts (+.spec.ts), exam-results-core.module.ts, dto/
routine/
admin/ exam-routine.controller.ts, exam-routine-entry.controller.ts, exam-day.controller.ts, exam-routine-admin.module.ts
core/ exam-routine-read/write/swap services, exam-components-write.service.ts, exam-invigilators-write.service.ts,
exam-publish-preview-read.service.ts, exam-day-read.service.ts, exam-routine-core.module.ts, dto/
settings/
admin/ exam-notification-settings.controller.ts, exam-reminder-rules.controller.ts, exam-notification-rules.service.ts,
exam-reminder-rules.service.ts, exam-settings-admin.module.ts, dto/
shared/
exam-conflict.util.ts, exam-shared-resolvers.service.ts, exam-write.errors.ts, exams-shared.module.ts, dto/
types/
admin/ exam-types.controller.ts, exam-types-admin.module.ts
core/ exam-types.service.ts, dto/
workers/
exam.scheduler.ts, exam-lifecycle-sweep.service.ts, exam-reminder-sweep.service.ts, exam-notification-scope.service.ts,
exam-notifier.service.ts, exam-notify-format.util.ts, exam-result-compute.contract.ts, exam-result-compute.adapter.ts,
exams-queue.processor.ts, exam-workers.module.ts
__tests__/ 10 integration specs + fixtures| File | Purpose | Key Exports | Notes |
|---|---|---|---|
shared/exam-shared-resolvers.service.ts | publicId → row resolvers | ExamSharedResolversService, ResolvedExam, ResolvedRoutineEntry, ResolvedComponent | Returns only the columns a caller needs to gate a write; never a mapped response DTO. |
shared/exam-conflict.util.ts | The two-pass conflict resolver | resolveExamConflict, runExamWriteWithConflictGuard, loadPublishedSiblingCandidates, throwExamConflict | Read by SP-1's publish flow and every routine write; pass 1 against other published/completed exams, pass 2 against the in-memory candidate set. |
shared/exam-write.errors.ts | The module's constraint-translation map | translateExamWriteError | Backstop only — the primary conflict path names the colliding row and throws before this is reached. |
authoring/core/exam-lifecycle.service.ts | Every one of the seven lifecycle transitions | ExamLifecycleService | One method per transition; every transition is one conditional UPDATE. |
authoring/core/exam-display-status.util.ts | displayStatus derivation | deriveExamDisplayStatus | Pure function of stored status + dates + today. |
routine/core/exam-routine-write.service.ts | Routine entry create/patch/remove/complete | ExamRoutineWriteService | The create path copies class_subjects.max_marks/pass_marks into a default component. |
routine/core/exam-routine-swap.service.ts | POST /exam-routine-entries/swap | ExamRoutineSwapService | The only caller of SET CONSTRAINTS ... DEFERRED in this module. |
marks/core/exam-marks-gate.util.ts | The marks-open gate | resolveMarksGate | Shared by the sheet and the submit transition so the two can never disagree. |
results/core/exam-result-rules.ts | Pure result arithmetic | computeSubjectFromMarks, computeOverall, lookupBand, round2 | No NestJS, no database, no clock — exhaustively spec'd in exam-result-rules.spec.ts. |
results/core/exam-results-compute.service.ts | Orchestrates computation | ExamResultsComputeService, EXAM_SYNC_COMPUTE_MAX_STUDENTS (500) | compute() is the request path (sync or queued); computeNow() is the worker's own entry point. |
workers/exam-result-compute.contract.ts / exam-result-compute.adapter.ts | The seam onto result computation | ExamResultComputeRunner (abstract), ExamResultComputeAdapter | The adapter calls computeNow, never compute — see §9. |
workers/exam-notifier.service.ts | Every business occurrence turned into a notification | ExamNotifierService, ExamNotifyEvent | One NotificationService.send() call per audience per event — never a compound send. |
workers/exam-notification-scope.service.ts | Every recipient-resolution query | ExamNotificationScopeService | Read-only, always inside the caller's own transaction. |
workers/exam-reminder-sweep.service.ts | The 15-minute reminder sweep | ExamReminderSweepService | Claims idempotency by inserting exam_reminder_dispatches first. |
workers/exam-lifecycle-sweep.service.ts | The hourly lifecycle sweep | ExamLifecycleSweepService | Two jobs in one run: auto-complete, then tomorrow/today notify. |
workers/exams-queue.processor.ts | The one processor on QueueName.EXAMS | ExamsQueueProcessor | Record<ExamJob, handler> — exhaustive by the type system. |
portal/teacher/teacher-exam-marks-portal.service.ts | The module's narrowest authorization boundary | TeacherExamMarksPortalService | Delegates the write whole to ExamMarksSheetService/ExamMarksWorkflowService; owns only the scoping predicate. |
portal/core/exam-portal-download.util.ts | Attachment stream + headers | openExamAttachmentStream, setExamAttachmentDownloadHeaders | Sandboxed CSP, nosniff, always attachment disposition. |
5. Data Model
5.1 Schema Source
packages/db/src/schema/school/exams.tsOne file holds all 17 tables and 8 enums for this domain. The three GiST exclusion constraints, the two bump_row_version trigger attachments' companion DDL, and the two guard triggers live in packages/db/src/migrations/0030_exam_module_constraints_and_seeds.sql, because drizzle-kit has no builder for either an EXCLUDE constraint or a trigger.
5.2 Tables and Collections
exam_types
The school's own catalogue — Terminal, Unit Test, Pre-Board — a table rather than a pgEnum so a school can add or retire a type without a migration. Thirteen rows are seeded (FIRST_TERM … SCHOLARSHIP).
| Column | Type | Nullable | Default | Index/Constraint | Notes |
|---|---|---|---|---|---|
id | serial | No | — | PK | Internal. |
public_id | uuid | No | uuidv7() | unique | External identifier. |
name | text | No | — | CHECK trimmed, no control chars, ≤120 chars; unique on lower(name) partial on is_active | Retiring frees the name for reuse. |
code | text | No | — | CHECK ^[A-Z0-9_]{1,16}$; unique partial on is_active | Immutable in practice — renaming the code would silently repoint every report keyed on it. |
sort_order | integer | No | — | CHECK >= 0 | |
is_active | boolean | No | true | No deleted_at — the partial-unique-index construction is the only lifecycle axis. | |
version | bigint | No | 0 | bumped by trigger | |
created_at/updated_at | timestamptz | No | now() |
No index on is_active: the table holds a few dozen rows, and the two partial unique indexes already cover the active-row lookups. Hard DELETE is refused by exams.exam_type_id's ON DELETE restrict the moment a type is filed against.
grading_scales
| Column | Type | Nullable | Default | Index/Constraint | Notes |
|---|---|---|---|---|---|
id | serial | No | — | PK | |
public_id | uuid | No | uuidv7() | unique | |
name | text | No | — | CHECK trimmed, ≤120 chars; unique on lower(name) partial on is_active | |
uses_gpa | boolean | No | — | ||
conditional_pass_max_failed_subjects | smallint | No | 0 | CHECK 0..10 | 0 means passed_with_conditions is never written — a CONFIGURED absence. |
is_active | boolean | No | true | ||
is_default | boolean | No | false | partial unique on is_default WHERE is_default | A constant-true predicate — every row with is_default indexes the same value, so a second collides. |
version | bigint | No | 0 | bumped by trigger | |
created_at/updated_at | timestamptz | No | now() |
Extra constraint: grading_scales_id_uses_gpa_unique — unique(id, uses_gpa), not uniqueIndex, because it is the target of grading_scale_bands' composite FK and drizzle-kit emits every ADD CONSTRAINT … FOREIGN KEY before every CREATE INDEX; a uniqueIndex here would not yet exist when the referencing FK is created, failing a clean-database migration with SQLSTATE 42830. No index on is_active: a school keeps a handful of scales.
grading_scale_bands
A band has a floor and no ceiling — see the design-decisions section. uses_gpa is a copy of the parent scale's, carried by a cascading composite FK so grading_scale_bands_gpa_complete can be a plain single-row CHECK.
| Column | Type | Nullable | Default | Index/Constraint | Notes |
|---|---|---|---|---|---|
id | serial | No | — | PK | |
scale_id | integer | No | — | Composite FK (scale_id, uses_gpa) → grading_scales(id, uses_gpa), ON DELETE/UPDATE cascade | |
uses_gpa | boolean | No | — | Copy, cascade-maintained | Never written directly by the application. |
grade_label | text | No | — | CHECK trimmed, no control chars, ≤8 chars; unique on (scale_id, lower(grade_label)) | |
min_percent | numeric(5,2) | No | — | CHECK 0..100; unique on (scale_id, min_percent) | The floor. No max_percent column exists. |
grade_point | numeric(4,2) | Yes | — | CHECK NULL OR 0..5; CHECK NOT uses_gpa OR grade_point IS NOT NULL | The second CHECK is what refuses turning GPA on for a scale whose bands have none. |
is_passing | boolean | No | — | ||
sort_order | smallint | No | 0 | ||
created_at/updated_at | timestamptz | No | now() |
Index: grading_scale_bands_scale_idx on (scale_id, min_percent) — the scan lookupBand mirrors (greatest min_percent <= pct).
exams
One examination: definition, period (an envelope, not a per-subject schedule), lifecycle and publication stamps.
| Column | Type | Nullable | Default | Index/Constraint | Notes |
|---|---|---|---|---|---|
id | serial | No | — | PK | |
public_id | uuid | No | uuidv7() | unique | |
academic_session_id | integer | No | — | FK academic_sessions.id, RESTRICT; part of exams_id_session_unique | |
exam_type_id | integer | No | — | FK exam_types.id, RESTRICT | |
grading_scale_id | integer | Yes | — | FK grading_scales.id, RESTRICT | Nullable on purpose — a school that records marks without grading gets marks-only results; RESTRICT (not SET NULL) so a scale in use can never be silently stripped from a published result. |
name | text | No | — | CHECK trimmed, no control chars, ≤160 chars | |
description | text | Yes | — | CHECK NULL OR (trimmed, ≤2000 chars) | Audience-visible. |
internal_note | text | Yes | — | CHECK NULL OR (trimmed, ≤2000 chars) | Administrator-internal; absent from every portal DTO and notification variable map. |
status | exam_status enum | No | draft | enum draft/published/completed/cancelled/archived | Only these five are stored; "scheduled"/"in progress" are derived. Part of exams_id_status_unique, the cascade target for the two child tables' status copy. |
start_date/end_date | date | No | — | CHECK end_date >= start_date | The envelope; each routine entry carries its own date inside it. |
published_at | timestamptz | Yes | — | CHECK (status='draft') = (published_at IS NULL) | Biconditional over two non-null booleans — never NULL, never passes by accident. |
published_by_user_id | uuid | Yes | — | FK users.id, SET NULL | |
cancelled_at/cancelled_by_user_id/cancellation_reason | mixed | Yes | — | CHECK: cancelled_at set iff status='cancelled' or (status='archived' and archived_from_status='cancelled') | Deliberately not a plain biconditional against status='cancelled' alone — see §6.6 for the defect this avoided. |
public_cancellation_note | text | Yes | — | CHECK format | The only cancellation text a family ever sees; cancellation_reason never reaches a template. |
completed_at | timestamptz | Yes | — | CHECK exams_completed_at_matches_status: required when status='completed', and allowed only on completed, cancelled or archived | Set by the manual complete route or the lifecycle sweep — never before every paper's end instant has passed. |
archived_at | timestamptz | Yes | — | CHECK (status='archived') = (archived_at IS NOT NULL) | |
archived_from_status | exam_status enum | Yes | — | CHECK (status='archived') = (archived_from_status IS NOT NULL) AND (archived_from_status IS NULL OR archived_from_status IN ('completed','cancelled')) | What unarchive restores. Written as an equivalence over IS NOT NULL, not a plain OR, because NULL IN (...) is NULL and a bare disjunction let an archive land with no archived_from_status at all — caught by the constraint probe. |
results_published_at/results_published_by_user_id | mixed | Yes | — | CHECK exams_results_publication_complete: a publisher implies a timestamp | The results-publication gate, independent of the exam's own lifecycle. The publisher alone may be NULL, because its key is SET NULL when that user is erased. |
version | bigint | No | 0 | bumped by trigger | |
created_at/updated_at | timestamptz | No | now() |
Indexes: exams_session_status_idx (academic_session_id, status), exams_dates_idx (start_date, end_date), exams_type_idx, exams_grading_scale_idx. exams_id_session_unique and exams_id_status_unique are unique(...) constraints, not uniqueIndex, for the same FK-ordering reason as grading_scales_id_uses_gpa_unique.
exam_classes
Which classes sit this examination.
| Column | Type | Nullable | Notes |
|---|---|---|---|
id | serial | No | PK. |
exam_id | integer | No | Composite FK (exam_id, academic_session_id) → exams(id, academic_session_id), CASCADE delete, RESTRICT update. |
class_id | integer | No | Composite FK (class_id, academic_session_id) → classes(id, academic_session_id), RESTRICT. |
academic_session_id | integer | No | Both composite FKs are NOT NULL on both columns, so MATCH SIMPLE's any-column-NULL skip is unreachable — asserted by the constraint probe. |
created_at | timestamptz | No | now(). |
Unique: exam_classes_exam_class_unique (exam_id, class_id) — one scope row per class. exam_classes_id_exam_class_unique (id, exam_id, class_id) is the composite FK target for exam_routine_entries. Indexes on exam_id, class_id.
exam_routine_entries
One subject examination for one class: when it is sat, where, by whom it is invigilated.
| Column | Type | Nullable | Default | Index/Constraint | Notes |
|---|---|---|---|---|---|
id | serial | No | — | PK | |
public_id | uuid | No | uuidv7() | unique | |
exam_id | integer | No | — | ||
exam_class_id | integer | No | — | Composite FK (exam_class_id, exam_id, class_id) → exam_classes(id, exam_id, class_id), CASCADE/RESTRICT | |
class_id | integer | No | — | ||
class_subject_id | uuid | No | — | Composite FK (class_subject_id, class_id) → class_subjects(id, class_id), RESTRICT | Proves the subject is one this class actually teaches. |
exam_status | exam_status enum | No | — | Copy, cascade-maintained; composite FK (exam_id, exam_status) → exams(id, status), CASCADE/CASCADE | Never written directly — a guard trigger refuses a change at pg_trigger_depth() = 1. See §9. |
exam_date | date | No | — | ||
start_minute/end_minute | smallint | No | — | CHECK start_minute >= 0 AND end_minute <= 1440 AND end_minute > start_minute | end_minute > start_minute (not >=) is load-bearing: int4range(600,600) is empty and overlaps nothing, so a zero-length entry would be invisible to all three exclusions while appearing live on every screen. |
room_id | integer | Yes | — | FK rooms.id, SET NULL | A deleted room must not make history undeletable; the read side reports "unassigned". |
exam_center | text | Yes | — | CHECK format, ≤160 chars | Audience-visible. |
notes | text | Yes | — | CHECK format, ≤500 chars | Audience-visible. |
internal_note | text | Yes | — | CHECK format, ≤500 chars | Administrator-internal. |
is_completed | boolean | No | false | CHECK is_completed = (completed_at IS NOT NULL) | The exam-DAY administration flag — not the marks gate; see §6.6. |
completed_at | timestamptz | Yes | — | ||
version | bigint | No | 0 | bumped by trigger | |
created_at/updated_at | timestamptz | No | now() |
Unique: exam_routine_entries_schedule_unique (id, exam_date, start_minute, end_minute, exam_status) — the composite FK target exam_invigilators carries by cascade. exam_routine_entries_one_paper_per_subject (exam_class_id, class_subject_id) — one paper per subject per class per exam; two papers are two components, not two entries. Indexes: exam_routine_entries_exam_idx, exam_routine_entries_class_subject_idx, exam_routine_entries_date_idx, exam_routine_entries_class_status_idx (class_id, exam_status) (the audience read), exam_routine_entries_room_idx partial on non-null room_id. Three GiST exclusions (migration 0030) — see §6.1.
exam_assessment_components
Theory, practical, internal, viva, project, other — every mark ceiling in the system. Mandatory: a subject examination with no component cannot be published.
| Column | Type | Nullable | Default | Index/Constraint | Notes |
|---|---|---|---|---|---|
id | serial | No | — | PK | |
routine_entry_id | integer | No | — | FK exam_routine_entries.id, CASCADE | |
kind | exam_component_kind enum | No | — | enum theory/practical/internal/viva/project/other | |
label | text | No | — | CHECK format, ≤64 chars; unique on (routine_entry_id, lower(label)) | |
max_marks | numeric(6,2) | No | — | CHECK > 0 AND <= 1000; part of exam_assessment_components_id_max_unique (id, max_marks) | The <= 1000 fat-finger guard matches class_subjects_marks_bounded, the value a default component is copied FROM. |
pass_marks | numeric(6,2) | No | — | CHECK >= 0 AND <= max_marks | |
sort_order | smallint | No | 0 | ||
version | bigint | No | 0 | bumped by trigger | Present because the components PUT is a full replacement and lowering a ceiling cascades into exam_marks. |
created_at/updated_at | timestamptz | No | now() |
Composite unique exam_assessment_components_id_entry_unique (id, routine_entry_id) is the FK target that carries a mark's routine entry membership. exam_assessment_components_id_max_unique (id, max_marks) is what carries the ceiling onto every exam_marks row — the mechanism behind "a mark can never exceed its ceiling"; see §6.2. Index: exam_assessment_components_entry_idx (routine_entry_id, sort_order).
exam_invigilators
Who invigilates which paper. The four schedule columns are copies of the parent's, cascade-maintained, which is the mechanism behind "moving a paper re-arms the invigilator exclusion" (§6.1).
| Column | Type | Nullable | Notes |
|---|---|---|---|
id | serial | No | PK. |
routine_entry_id | integer | No | Half of the composite FK to exam_routine_entries. |
staff_id | uuid | No | FK staff.id, CASCADE — NOT NULL forces CASCADE here; staff soft-deletes so this rarely fires. |
exam_date/start_minute/end_minute/exam_status | mixed | No | Composite FK (routine_entry_id, exam_date, start_minute, end_minute, exam_status) → exam_routine_entries(id, exam_date, start_minute, end_minute, exam_status), CASCADE/CASCADE. |
is_chief | boolean | No, default false | |
created_at | timestamptz | No | now(). |
Unique exam_invigilators_entry_staff_unique (routine_entry_id, staff_id) — its leading column also serves every per-entry lookup. Index: exam_invigilators_staff_date_idx (staff_id, exam_date). One GiST exclusion — see §6.1.
exam_marks_submissions
The marks workflow record for one subject examination — one row per routine entry.
| Column | Type | Nullable | Notes |
|---|---|---|---|
id | serial | No | PK; public_id unique. |
routine_entry_id | integer | No | FK exam_routine_entries.id, CASCADE; unique — one submission per entry. |
state | marks_submission_state enum | No, default draft | enum draft/submitted/returned/approved/finalized. approved has TWO exits (finalize, return) — a single-exit approved is a stuck state, caught in review. |
submitted_by_staff_id | uuid | Yes | FK staff.id, SET NULL. A SNAPSHOT of who submitted — class_subjects.teacher_id is a current-assignment pointer with no history, so reassigning a subject must not hand the new teacher every past submission's attribution. |
submitted_at | timestamptz | Yes | CHECK (state='draft') = (submitted_at IS NULL). |
reviewed_by_user_id/reviewed_at | mixed | Yes | FK users.id, SET NULL. |
return_reason | text | Yes | CHECK format, ≤500 chars — administrator-internal. |
finalized_by_user_id/finalized_at | mixed | Yes | CHECK (state='finalized') = (finalized_at IS NOT NULL), and CHECK exam_marks_submissions_finalizer_requires_finalized_at: a finaliser implies a timestamp. The finaliser alone may be NULL, because its key is SET NULL when that user is erased. |
version | bigint | No | bumped by trigger. |
created_at/updated_at | timestamptz | No | now(). |
Index: exam_marks_submissions_state_idx.
exam_marks
One pupil's mark for one component. The database guarantee described in §6.2.
| Column | Type | Nullable | Notes |
|---|---|---|---|
id | serial | No | PK; public_id unique. |
component_id | integer | No | Two composite FKs to exam_assessment_components, BOTH RESTRICT: (component_id, routine_entry_id) → (id, routine_entry_id) and (component_id, max_marks) → (id, max_marks), both ON UPDATE cascade. |
routine_entry_id | integer | No | Denormalised so the scope predicate and the marks-sheet read never join through the component. |
max_marks | numeric(6,2) | No | A COPY of the component's ceiling, cascade-maintained. The marks sheet writes the ceiling it loaded; if an administrator changed the ceiling in between, the composite key refuses the stale copy with 23503. |
student_id | uuid | No | FK students.id, CASCADE. |
attendance | exam_mark_attendance enum | No, default present | enum present/absent/withheld/not_applicable. |
obtained_marks | numeric(6,2) | Yes | CHECK attendance='present' OR obtained_marks IS NULL (disjunction, not biconditional — see §6.2); CHECK obtained_marks IS NULL OR (>= 0 AND <= max_marks). |
remarks | text | Yes | CHECK format, ≤500 chars — audience-visible. |
version | bigint | No | bumped by trigger. |
created_at/updated_at | timestamptz | No | now(). |
Unique exam_marks_component_student_unique (component_id, student_id). Indexes: exam_marks_entry_student_idx (routine_entry_id, student_id), exam_marks_student_idx. Both FKs to exam_assessment_components are declared RESTRICT deliberately and must agree: Postgres evaluates both on a parent delete, so a RESTRICT on either aborts before the other's CASCADE could run — "restrict on one, cascade on the other" is not a mixture, it is restrict. An earlier draft had CASCADE here, which would have let a full-replacement components PUT silently delete every mark for a class.
exam_mark_corrections
The record of a change to a finalized mark. Append-only, enforced by a trigger (§9).
| Column | Type | Nullable | Notes |
|---|---|---|---|
id | serial | No | PK. |
mark_id | integer | No | FK exam_marks.id, CASCADE. |
previous_attendance/new_attendance | exam_mark_attendance enum | No | Both enumerated. |
previous_obtained_marks/new_obtained_marks | numeric(6,2) | Yes | |
reason | text | No | CHECK format, ≤500 chars — administrator-internal, never a notification variable. |
corrected_by_user_id | uuid | Yes | FK users.id, SET NULL. |
corrected_by_user_public_id | uuid | No | NOT NULL snapshot beside the SET NULL FK — attribution survives an erasure of the acting user. |
corrected_at | timestamptz | No, default now() |
Index: exam_mark_corrections_mark_idx (mark_id, corrected_at).
exam_results
One computed overall result per pupil per examination.
| Column | Type | Nullable | Notes |
|---|---|---|---|
id | serial | No | PK; public_id unique. |
exam_id | integer | No | FK exams.id, CASCADE. |
student_id | uuid | No | FK students.id, CASCADE. |
class_id | integer | No | FK classes.id, RESTRICT. Deliberately NO composite anchor to the current enrolment — records which class the pupil SAT the exam in, must survive the pupil later moving class; written once at compute time, never updated. |
total_obtained | numeric(8,2) | Yes | NULL means "not computable" (absent from every counted subject); zero means "scored nothing" — kept distinct. |
total_max | numeric(8,2) | No | |
percentage | numeric(5,2) | Yes | CHECK 0..100. CHECK exam_results_percentage_matches_totals: NULL exactly when total_obtained is NULL, and otherwise within 0.01 of round(total_obtained * 100 / total_max, 2) with total_max > 0. The tolerance absorbs the compute path's floating-point half-up rounding; the divisor is NULLIF-guarded because Postgres does not promise to evaluate total_max > 0 first. |
gpa | numeric(4,2) | Yes | CHECK 0..5. |
overall_grade_label | text | Yes | |
passed_subjects/failed_subjects | smallint | No, default 0 | CHECK >= 0. |
status | exam_result_status enum | No | enum passed/failed/passed_with_conditions/incomplete/absent/withheld. |
remarks | text | Yes | CHECK format, ≤500 chars — audience-visible. |
is_stale | boolean | No, default false | Set by a correction, cleared by recompute; publication is refused while any result in the exam is stale. |
computed_at | timestamptz | No, default now() | |
version | bigint | No | bumped by trigger. |
created_at/updated_at | timestamptz | No | now(). |
Unique exam_results_exam_student_unique (exam_id, student_id) — the onConflictDoUpdate target for recompute. Indexes: exam_results_exam_status_idx (exam_id, status), exam_results_student_idx, exam_results_stale_idx (exam_id) WHERE is_stale.
exam_subject_results
One subject's contribution to a result — a snapshot, not a pointer.
| Column | Type | Nullable | Notes |
|---|---|---|---|
id | serial | No | PK. |
result_id | integer | No | FK exam_results.id, CASCADE. |
routine_entry_id | integer | No | FK exam_routine_entries.id, RESTRICT — a routine entry with a computed result cannot be deleted. |
obtained/max | numeric(6,2) | obtained Yes, max No | |
percentage | numeric(5,2) | Yes | CHECK 0..100. |
grade_label/grade_point | mixed | Yes | SNAPSHOTS, not a band_id — a published result must be immune to a band being edited afterward. |
is_passing | boolean | Yes | NULL for a subject that is neither pass nor fail (withheld, not_applicable); only an explicit false increments failed_subjects. |
attendance | exam_mark_attendance enum | No | |
counts_toward_total | boolean | No | false for not_applicable and withheld; true otherwise, including absent (an absence scores 0/max and counts). |
remarks | text | Yes | |
component_breakdown | jsonb | No | CHECK jsonb_typeof(...) = 'array' — an array of {componentId, label, kind, max, obtained, attendance} snapshots. |
created_at | timestamptz | No | now(). |
Unique exam_subject_results_result_entry_unique (result_id, routine_entry_id) — its leading column also serves every per-result read. Index: exam_subject_results_entry_idx.
exam_notification_rules
Which channels and audiences each of the seven examination notification events uses.
| Column | Type | Nullable | Default | Notes |
|---|---|---|---|---|
event | text | No | — | PK. CHECK ∈ ('published','schedule_changed','cancelled','invigilation_assigned','exam_tomorrow','exam_today','result_published'). exam.unpublished reuses the schedule_changed row rather than adding an eighth. exam.marks_submitted is deliberately absent — an operator-facing realtime signal, in-app only, with no channel configuration to offer. |
enabled | boolean | No | true | |
channels | jsonb | No | — | CHECK is a JSON array, <= 4 entries; membership validated against NOTIFICATION_CHANNEL in the service, not the DB. |
notify_students/notify_guardians | boolean | No | true/false respectively (per-event seed value) | |
notify_teachers | boolean | No | false | |
notify_invigilators | boolean | No | false | CHECK NOT notify_invigilators OR event IN ('schedule_changed','cancelled','invigilation_assigned') — an invigilator has no interest in a result or in an exam being published at all. |
updated_by_user_id | uuid | Yes | — | No FK — deliberate, matching assignment_notification_rules: the test harness truncates users with CASCADE and an FK here would wipe the seeded rows. |
version | bigint | No | 0 | bumped by trigger. |
created_at/updated_at | timestamptz | No | now() | — |
exam_reminder_rules
When reminders are sent, as offsets before a paper starts.
| Column | Type | Nullable | Default | Notes |
|---|---|---|---|---|
id | serial | No | — | PK. |
offset_minutes | integer | No | — | CHECK 15..43200; unique. The lower bound is the sweep tick — an offset below it can fall entirely between two ticks and never dispatch, with no error and no row to show for it. |
label | text | Yes | — | CHECK format, ≤64 chars. |
enabled | boolean | No | true | |
channels | jsonb | No | — | CHECK array, <= 4. |
version | bigint | No | 0 | bumped by trigger. |
created_at/updated_at | timestamptz | No | now() | — |
Three rows seeded: 4320 min (3 days), 1440 min (1 day), 180 min (3 hours). Minimum spacing between two ENABLED offsets is one tick (15 min) — a service rule (EXAM_REMINDER_RULES_TOO_CLOSE), because a CHECK cannot span rows.
exam_reminder_dispatches
What the reminder sweep has already sent — the dedupe key IS the schedule.
| Column | Type | Nullable | Notes |
|---|---|---|---|
id | serial | No | PK. |
routine_entry_id | integer | No | FK exam_routine_entries.id, CASCADE. |
rule_id | integer | No | FK exam_reminder_rules.id, CASCADE. |
exam_date/start_minute | mixed | No | Part of the unique key. |
dispatched_at | timestamptz | No, default now() |
Unique exam_reminder_dispatches_key (routine_entry_id, rule_id, exam_date, start_minute) — inserting this row IS how the sweep claims "already sent", and its leading column serves the per-entry lookups. No other index.
exam_attachments
Instructions, seating plans, circulars.
| Column | Type | Nullable | Default | Notes |
|---|---|---|---|---|
id | serial | No | — | PK; public_id unique. |
exam_id | integer | No | — | FK exams.id, CASCADE. |
storage_key | varchar(500) | No | — | Never signed; served through a portal route that checks visibility + scope. |
original_name | varchar(255) | No | — | |
mime_type | varchar(120) | No | — | |
size_bytes | integer | No | — | CHECK > 0. |
visibility | exam_attachment_visibility enum | No | administrator_only | enum administrator_only/audience. |
uploaded_by_user_id | uuid | Yes | — | FK users.id, SET NULL. |
sort_order | smallint | No | 0 | |
created_at | timestamptz | No | now() |
Index: exam_attachments_exam_idx (exam_id, sort_order). EXAM_ATTACHMENT is a private UploadType and must never be added to packages/storage/src/upload-type.ts's PUBLIC_TYPES — a public type is stored under public/, which the bucket policy grants anonymous s3:GetObject; verified current (PUBLIC_TYPES holds only AVATAR, LANDING, THUMBNAIL, PRODUCT_VIDEO).
5.3 Relationship Diagram
6. Services and Responsibilities
The module has 34 service/util files. This section groups them by area; §16.1 gives the full submodule coverage matrix.
6.1 ExamSharedResolversService (shared/exam-shared-resolvers.service.ts)
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
resolveExam(publicId) | Every routine/marks/results write | exams | none | none | EXAM_NOT_FOUND |
resolveRoutineEntry(publicId) | Every routine/marks write, both portals | exam_routine_entries joined exams | none | none | EXAM_ROUTINE_ENTRY_NOT_FOUND |
resolveComponent(routineEntryId, componentId) | Marks write paths | exam_assessment_components | none | none | EXAM_COMPONENT_NOT_FOUND |
6.2 ExamLifecycleService (authoring/core/exam-lifecycle.service.ts)
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
publish(user, publicId, version) | ExamLifecycleController.publish | exam_classes count, entry-without-component count, grading floor band | exams (conditional UPDATE, one statement) | outbox.enqueue(exam.published → PUBLISH_NOTIFY) | EXAM_HAS_NO_CLASSES, EXAM_ROUTINE_ENTRY_HAS_NO_COMPONENTS, GRADING_SCALE_HAS_NO_FLOOR_BAND, EXAM_ALREADY_PUBLISHED, EXAM_STATE_TRANSITION_INVALID, EXAM_VERSION_CONFLICT, EXAM_ROOM_CONFLICT/EXAM_CLASS_CONFLICT/EXAM_INVIGILATOR_CONFLICT |
unpublish(user, publicId, version) | .unpublish | finalized-submission count | exams | none | EXAM_CANNOT_UNPUBLISH_WITH_FINALIZED_MARKS, EXAM_NOT_PUBLISHED, EXAM_VERSION_CONFLICT |
cancel(user, publicId, version, reason, publicNote) | .cancel | exams.results_published_at | exams | outbox.enqueue(exam.cancelled → CANCEL_NOTIFY) | EXAM_CANNOT_CANCEL_AFTER_RESULTS_PUBLISHED, EXAM_STATE_TRANSITION_INVALID |
reinstate(user, publicId, version) | .reinstate | — | exams (cancelled → draft) | none | EXAM_STATE_TRANSITION_INVALID |
complete(user, publicId, version) | .complete | every routine entry's (examDate, endMinute), composed through SchoolClockService; the same "no paper ends after now" predicate (noPaperEndsAfter) is repeated inside the UPDATE, because moving a paper does not bump the exam's version and a check read before the write would miss a paper rescheduled in between | exams | none | EXAM_CANNOT_COMPLETE_BEFORE_LAST_PAPER, EXAM_STATE_TRANSITION_INVALID |
archive(user, publicId, version) | .archive | — | exams (archived_from_status = OLD status via a raw sql fragment) | none | EXAM_STATE_TRANSITION_INVALID |
unarchive(user, publicId, version) | .unarchive | — | exams (status = OLD archived_from_status) | none | EXAM_STATE_TRANSITION_INVALID |
Every transition follows the same shape: one conditional UPDATE … WHERE public_id = ? AND status IN (fromStatuses) AND version = ?. A zero-row result triggers a disambiguating re-read that never decides whether the write happens — only which error message to show (already-published vs. wrong-state vs. version conflict). bump_row_version (trigger) owns version/updated_at; the service never sets either.
6.3 ExamWriteService (authoring/core/exam-write.service.ts)
| Method | Called By | Reads | Writes | Errors |
|---|---|---|---|---|
create(dto) | ExamsController.create | academic_sessions, exam_types, grading_scales (each resolved by publicId) | exams (INSERT) | VALIDATION_FAILED (dates), ACADEMIC_SESSION_NOT_FOUND, EXAM_SESSION_NOT_ACTIVE, EXAM_TYPE_NOT_FOUND, GRADING_SCALE_NOT_FOUND |
update(publicId, dto) | .update | exams, routine entries outside a narrowed period | exams | EXAM_VERSION_CONFLICT, VALIDATION_FAILED, EXAM_TYPE_NOT_FOUND, GRADING_SCALE_NOT_FOUND, EXAM_PERIOD_HAS_ENTRIES_OUTSIDE |
remove(publicId, version) | .remove | exam_classes count | exams (DELETE) | EXAM_VERSION_CONFLICT, EXAM_STATE_TRANSITION_INVALID (only a draft exam with no class scope may be deleted) |
Widening the exam's period is never checked; narrowing it is refused when a routine entry would fall outside the new bound.
6.4 ExamClassesService / ExamAttachmentsService / ExamCalendarProjectionService / ExamDetailService
| Method | Called By | Reads | Writes | Errors |
|---|---|---|---|---|
ExamClassesService.replace(publicId, classPublicIds) | ExamClassesController.replace | classes scoped to the exam's session, current exam_classes, recorded marks under classes being removed | exam_classes (delete + insert, one transaction) | VALIDATION_FAILED (class outside session), EXAM_CLASS_HAS_RECORDED_MARKS |
ExamAttachmentsService.upload(examPublicId, file, visibility, uploadedByUserId) | ExamAttachmentsController.upload | exams | StorageManager.handleUpload (UploadType.EXAM_ATTACHMENT), exam_attachments (INSERT) | UPLOAD_FILE_REQUIRED, UPLOAD_VALIDATION_FAILED, UPLOAD_STORAGE_FAILED; a DB-insert failure after a successful upload deletes the orphaned file |
ExamAttachmentsService.remove(examPublicId, attachmentPublicId) | .remove | exam_attachments | DELETE row, then StorageManager.deleteFile (best-effort, logged on failure) | EXAM_ATTACHMENT_NOT_FOUND |
ExamCalendarProjectionService.adminProjection(sessionId) | Another module's calendar controller (out of this module's ownership) | Every exam in the session, any status, with class/routine counts | none | — |
ExamCalendarProjectionService.portalProjection(classId) | Same, for /api/mobile/calendar | Only status='published' entries for the class | none | — |
ExamDetailService.list/.detail/.toDetail/.loadRow | Every controller needing a re-read after a write | exams joined refs and classes | none | EXAM_NOT_FOUND |
6.5 Routine services (routine/core/*)
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
ExamRoutineWriteService.create(examPublicId, dto, actor) | ExamRoutineController.create | classes, exam_classes, class_subjects | exam_routine_entries INSERT + default exam_assessment_components INSERT, inside runExamWriteWithConflictGuard | none (draft) / conflict candidates loaded when the exam is published | EXAM_ROUTINE_DATE_OUTSIDE_PERIOD, CLASS_NOT_FOUND, EXAM_ROUTINE_SUBJECT_NOT_TAUGHT, EXAM_ROOM_CONFLICT/EXAM_CLASS_CONFLICT/EXAM_INVIGILATOR_CONFLICT, PERMISSION_INSUFFICIENT (published write without ExamPublish_UPDATE) |
ExamRoutineWriteService.patch(routineEntryPublicId, dto, actor) | ExamRoutineEntryController.patch | Current entry, current invigilators (if schedule touched) | exam_routine_entries (conditional UPDATE keyed on id + version + examStatus) | outbox.enqueue(exam.schedule_changed → SCHEDULE_CHANGE_NOTIFY) only when a schedule field changed on a published/completed entry | EXAM_ROUTINE_ENTRY_STATE_CHANGED, conflict codes, PERMISSION_INSUFFICIENT |
ExamRoutineWriteService.remove(routineEntryPublicId, actor) | .remove | Current entry | DELETE (conditional on examStatus) | outbox.enqueue when published | EXAM_ROUTINE_ENTRY_STATE_CHANGED, EXAM_ROUTINE_ENTRY_IN_USE/EXAM_COMPONENT_HAS_RECORDED_MARKS (23001/23503 mapped) |
ExamRoutineWriteService.complete(routineEntryPublicId) | .complete | Current entry | is_completed = true (idempotent no-op if already true) | none | — |
ExamRoutineSwapService.swap(...) | ExamRoutineEntryController.swap | Both entries, both invigilator sets, both exams' published siblings | Two UPDATEs inside one transaction, under SET CONSTRAINTS ... DEFERRED then IMMEDIATE | outbox.enqueue per side that is published | EXAM_ROUTINE_ENTRY_STATE_CHANGED, conflict codes, PERMISSION_INSUFFICIENT |
ExamComponentsWriteService.replace(routineEntryPublicId, dto, actor) | ExamRoutineEntryController.replaceComponents | Existing components | Full replacement: update-in-place by id, insert new, delete absent | none | EXAM_COMPONENT_NOT_FOUND, EXAM_VERSION_CONFLICT, EXAM_COMPONENT_HAS_RECORDED_MARKS (23001), EXAM_COMPONENT_CEILING_BELOW_RECORDED_MARK (23514 on the cascaded exam_marks row) |
ExamInvigilatorsWriteService.replace(routineEntryPublicId, dto, actor) | .replaceInvigilators | staff (live rows only) | Full replacement via delete-not-in-list + onConflictDoUpdate on isChief | outbox.enqueue(exam.invigilation_assigned → INVIGILATION_NOTIFY) only when the assigned set actually changed | EXAM_INVIGILATOR_NOT_FOUND, conflict codes, PERMISSION_INSUFFICIENT |
ExamPublishPreviewReadService.publishPreview/.conflictsReport | ExamRoutineController.publishPreview/.conflicts | Every read-only signal: class scope, components, invigilators, grading floor band, calendar day classification, live roll count, conflicts | none | none | — (read-only; warnings, not refusals, except the ones that also gate publish itself) |
ExamDayReadService.today() | ExamDayController.today | Every published/completed entry on the school's own today | none | none | — |
ExamRoutineReadService.listForExam/.loadEntryDto/.availability | Routine controllers, the swap/write services' own re-reads | Full entry shape with components + invigilators | none | none | EXAM_ROUTINE_ENTRY_NOT_FOUND |
Explain: input normalization happens at the DTO boundary (class-validator); validation order is always resolve-then-authorize-then-write; transaction boundaries wrap every write that touches more than one table; idempotency is explicit for complete() and for the invigilator/component full-replacement PUTs; retry behavior belongs to BullMQ for the async paths, not to these synchronous services; response mapping always re-reads the row after a write rather than echoing the request.
6.6 Marks services (marks/core/*)
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
resolveMarksGate(clock, executor, entry) (util) | ExamMarksSheetService, ExamMarksWorkflowService.submit | entry.examDate/endMinute/isCompleted | none | none | — |
ExamMarksSheetService.getSheet(routineEntryPublicId) | ExamMarksController.getSheet, TeacherExamMarksPortalService.getSheet | Live roll (row-scope predicate), components, existing exam_marks, submission row, the gate | none | none | EXAM_ROUTINE_ENTRY_NOT_FOUND |
ExamMarksSheetService.saveMarks(routineEntryPublicId, dto) | .saveMarks, portal saveMarks | Same, plus the live roll to validate studentId membership | exam_marks bulk onConflictDoUpdate on (component_id, student_id) | none | EXAM_MARKS_NOT_YET_OPEN, EXAM_MARKS_NOT_EDITABLE, STUDENT_NOT_FOUND, EXAM_COMPONENT_NOT_FOUND, EXAM_MARK_ABSENT_CANNOT_SCORE, EXAM_MARK_EXCEEDS_CEILING |
ExamMarksWorkflowService.submit(routineEntryPublicId, version, submittedByStaffId?) | ExamMarksWorkflowController.submit, TeacherExamMarksPortalService.submit | The gate, every present pupil's mark completeness | exam_marks_submissions (draft/returned → submitted) | outbox.enqueue(exam.marks_submitted → MARKS_SUBMITTED_NOTIFY) | EXAM_MARKS_NOT_YET_OPEN, EXAM_MARKS_INCOMPLETE (names the missing pupils), EXAM_MARKS_TRANSITION_INVALID, EXAM_MARKS_SUBMISSION_VERSION_CONFLICT |
ExamMarksWorkflowService.returnMarks(...) | .return | — | submitted/approved → returned | none | Same transition-error family |
ExamMarksWorkflowService.approve(...) | .approve | — | submitted → approved | none | Same |
ExamMarksWorkflowService.finalize(...) | .finalize | — | approved → finalized | none | Same |
ExamMarkCorrectionsService.correct(markPublicId, dto, actorUserId, activityContext) | ExamMarkCorrectionsController.correct | The mark, its routine entry, its submission state | exam_mark_corrections INSERT, exam_marks UPDATE, exam_results.is_stale = true for the whole exam | ActivityRecordService.recordActivity (per-field changes) | EXAM_MARK_NOT_FOUND, EXAM_ROUTINE_ENTRY_NOT_FOUND, EXAM_MARKS_TRANSITION_INVALID (only legal once finalized), EXAM_MARK_ABSENT_CANNOT_SCORE, EXAM_MARK_EXCEEDS_CEILING |
ExamMarkCorrectionsService.listCorrections(markPublicId) | .listCorrections | exam_mark_corrections ordered by time | none | none | EXAM_MARK_NOT_FOUND |
6.7 Results services (results/core/*)
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
ExamResultsComputeService.compute(examPublicId) | ExamResultsController.compute | The live roll (row-scope predicate) | Enqueues ExamJob.RESULT_COMPUTE when the roll exceeds 500, else runs runLocked inline | outbox.enqueue (queued path only, deduped per exam) | EXAM_NOT_FOUND |
ExamResultsComputeService.computeNow(examPublicId) | ExamResultComputeAdapter.compute (the worker's own path) | Same | Same as runLocked, always inline | none | EXAM_NOT_FOUND |
ExamResultsComputeService.runLocked (private) | Both of the above | Grading scale + bands, every routine entry's component-max totals, every mark for the roll, under pg_advisory_xact_lock keyed per exam | exam_results upsert (onConflictDoUpdate on (exam_id, student_id), class_id deliberately excluded from the update set), exam_subject_results delete-then-insert per pupil | none | — |
ExamResultsService.list/.detail | ExamResultsController.list/.detail | exam_results joined student/class names | none | none | EXAM_RESULT_NOT_FOUND |
ExamResultsService.withhold(examPublicId, studentPublicId, dto) | .withhold | exam_results row FOR UPDATE | status = 'withheld' | none | EXAM_RESULT_NOT_FOUND, EXAM_VERSION_CONFLICT |
ExamResultsService.release(examPublicId, studentPublicId, dto) | .release | exam_subject_results (unchanged by withhold), the grading scale's conditional-pass count | Recomputes status/percentage/gpa/counts via computeOverall and writes them back — no stored "status before withhold" column exists | none | EXAM_RESULT_NOT_FOUND, EXAM_VERSION_CONFLICT |
ExamResultsService.publish(examPublicId, actorUserId) | .publish | Unfinalized-entry count, stale-result count | exams.results_published_at/results_published_by_user_id (conditional on being currently null) | outbox.enqueue(exam.result_published → RESULT_PUBLISH_NOTIFY) | EXAM_RESULTS_NOT_ALL_FINALIZED, EXAM_RESULTS_STALE, EXAM_RESULTS_ALREADY_PUBLISHED |
ExamResultsService.unpublish(examPublicId) | .unpublish | — | Clears both publication columns | none | EXAM_RESULTS_NOT_PUBLISHED |
ExamResultComputeAdapter reads the exam's academic session lock state before dispatching a queued compute; when the year is locked it leaves the results as they are and logs a warning ([exam-result-compute] exam {examId} is in a locked academic year; results left as they are) instead of running computeNow. This is a soft skip rather than a thrown error, because the compute here runs from a worker, not from a request an administrator is watching.
6.8 GradingScalesService, ExamTypesService, ExamDashboardService, settings services
| Method | Called By | Notes |
|---|---|---|
GradingScalesService.create/.update/.remove/.replaceBands | GradingScalesController | isDefault toggling clears the previous default first inside the same transaction, mirroring academic_sessions_one_current_idx's own service, so the write never collides with grading_scales_one_default_idx. remove is refused (GRADING_SCALE_IN_USE) the moment exams.grading_scale_id still points at it. |
ExamTypesService.create/.update/.remove | ExamTypesController | Hard DELETE, refused by exam_type_id's RESTRICT while any exam is filed against it. Retiring a type an admin no longer wants offered is PATCH { isActive: false }, not remove. |
ExamDashboardService.totals(academicSessionPublicId?) | ExamDashboardController.totals | upcoming/ongoing split published exams on today's date; pendingMarks counts entries with no finalized submission; pendingVerification narrows to submitted. |
ExamNotificationRulesService.list/.update | ExamNotificationSettingsController | Rows are seeded, never created or deleted here. notifyInvigilators is refused outside its three applicable events (EXAM_NOTIFICATION_RULE_INVALID). |
ExamReminderRulesService.list/.replace | ExamReminderRulesController | A full-list replace with a two-phase "park on a free scratch offset, then land on the real target" algorithm — see §6.7 for why. Enforces no duplicate offsets and minimum 15-minute spacing between enabled rules. |
7. Runtime Flows
7.1 Publish an exam
| Step | Code Path | Behavior | Failure Case |
|---|---|---|---|
| 1 | ExamLifecycleController.publish | Receives request, DTO-validates version. | Malformed body → 400. |
| 2 | ExamLifecycleService.publish | Three publish-gate checks: class scope, every entry has a component, grading floor band. | EXAM_HAS_NO_CLASSES / EXAM_ROUTINE_ENTRY_HAS_NO_COMPONENTS / GRADING_SCALE_HAS_NO_FLOOR_BAND. |
| 3 | loadConflictCandidates | Loads every routine entry + invigilator BEFORE the write — after a savepoint rollback the transaction's own view of them is gone. | — |
| 4 | runExamWriteWithConflictGuard | Runs the status flip in a SAVEPOINT; a 23P01 rolls back to the savepoint, not the outer transaction. | EXAM_VERSION_CONFLICT / EXAM_ALREADY_PUBLISHED on a zero-row update. |
| 5 | Three GiST exclusions | Cascaded exam_status arms room/class/invigilator conflict-freedom for every entry at once. | 23P01, resolved to a named conflict — first against other published/completed exams, then against the candidate set itself (an exam's own collision can be entirely internal to what is being published). |
| 6 | outbox.enqueue | Writes the PUBLISH_NOTIFY outbox row in the SAME transaction as the status flip. | A dispatcher failure never rolls back the publish — it retries independently. |
7.2 Routine entry patch that touches a published exam's schedule
Include: success path (row 5-6 above); permission failure (missing ExamPublish_UPDATE on a schedule-touching write to a published entry); missing entity (EXAM_ROUTINE_ENTRY_NOT_FOUND); duplicate/concurrent write (EXAM_ROUTINE_ENTRY_STATE_CHANGED); a genuine schedule collision (EXAM_ROOM_CONFLICT/EXAM_CLASS_CONFLICT/EXAM_INVIGILATOR_CONFLICT, named against the exam's own siblings via loadPublishedSiblingCandidates when the collision is with a sibling of the same exam rather than a different one).
7.3 Swap two papers (deferred constraints)
The transient overlap between the two UPDATEs is legal only because the two exclusions are DEFERRABLE INITIALLY IMMEDIATE and this transaction explicitly defers them. SET CONSTRAINTS … IMMEDIATE forces the real check before the method returns, so a genuine collision with an unrelated third paper still raises 23P01 here, synchronously, rather than surfacing at an unrelated later commit.
7.4 Submit marks
Branch notes: guest/logged-in is not applicable (every exam route requires a session); the guest-equivalent branch here is "not this teacher's paper", which answers the identical 404 as "does not exist" — a 403 would confirm the paper exists. Cache miss/hit is not applicable — the module has no cache (§8). Async enqueue failure: outbox.enqueue writes inside the same transaction as the state flip, so a dispatcher outage never loses the submission, only delays its notification.
7.5 Compute results — synchronous vs. queued
runLocked takes pg_advisory_xact_lock keyed on the exam before re-reading the roll — a request and a queued job racing on one exam serialize rather than writing over each other's exam_results rows. See §9's design-decision note on why computeNow, never compute, is the worker's entry point.
7.6 Reminder sweep tick
Include: success path (above); a rescheduled paper re-arms by itself because its new (exam_date, start_minute) was never claimed; a cancelled/unpublished exam still claims the row (so it never re-fires) but sends nothing, checked by re-reading exams.status inside the same transaction as the claim; a per-candidate exception is caught, logged and does not stop the rest of the sweep.
8. Caching
The module declares no cache. No CacheKeyUtil import appears anywhere under apps/api/src/modules/exams/, and no Redis read/write occurs in any of the 34 service files read for this page. Every read — the marks sheet, the routine, the publish preview, the portal dashboards — goes to PostgreSQL directly, on every request. This is a deliberate absence, not a gap: the module's write paths are already gated by the version optimistic-concurrency column and, for schedule conflicts, by the database's own GiST exclusions, so a stale cache read would be the one path capable of showing a slot as free after it had just been taken.
9. BullMQ, Schedulers, and Async Work
| Queue | Job | Producer | Processor | Payload | Retry/Backoff | Idempotency |
|---|---|---|---|---|---|---|
QueueName.EXAMS | ExamJob.REMINDER_SWEEP (exam.reminder_sweep) | ExamScheduler cron 0 */15 * * * * UTC | ExamsQueueProcessor → ExamReminderSweepService.run | ExamSweepPayload {v:1, requestId} | BullMQ default retry; job id bucketed at the 15-minute period via buildJobId | The claim is exam_reminder_dispatches insert ON CONFLICT DO NOTHING, keyed on (routine_entry_id, rule_id, exam_date, start_minute) — the schedule itself is the key. |
QueueName.EXAMS | ExamJob.LIFECYCLE_SWEEP (exam.lifecycle_sweep) | ExamScheduler cron 0 0 * * * * UTC (hourly) | ExamsQueueProcessor → ExamLifecycleSweepService.run | ExamSweepPayload | Same | Auto-complete is conditional on status='published' in its own WHERE, so a re-run or an overlapping tick is a no-op zero-row update; date-triggered notifications dedupe through NotificationService's own aggregateId. |
QueueName.EXAMS | ExamJob.PUBLISH_NOTIFY | ExamLifecycleService.publish (via outbox) | notifyFromPayload("published", ...) | ExamNotifyPayload {v:1, requestId, examId} | Outbox-relayed, default retry | collapse kind — one publish per exam id. |
QueueName.EXAMS | ExamJob.SCHEDULE_CHANGE_NOTIFY | ExamRoutineWriteService.patch/.remove, ExamRoutineSwapService.swap (via outbox) | Same processor, dispatches to "schedule_changed" or "unpublished" by an extra reason field the strict payload type omits | ExamNotifyPayload & {reason?:"unpublished"} | Same | repeatable — a second reschedule for the same exam is its own event, keyed by requestId. |
QueueName.EXAMS | ExamJob.CANCEL_NOTIFY | ExamLifecycleService.cancel | notifyFromPayload("cancelled", ...) | ExamNotifyPayload | Same | collapse. |
QueueName.EXAMS | ExamJob.INVIGILATION_NOTIFY | ExamInvigilatorsWriteService.replace (only when the assigned set changed) | notifyFromPayload("invigilation_assigned", ...) | ExamNotifyPayload (with routineEntryId) | Same | collapse, keyed on the sorted assigned staff id list. |
QueueName.EXAMS | ExamJob.MARKS_SUBMITTED_NOTIFY | ExamMarksWorkflowService.submit | notifyFromPayload("marks_submitted", ...) | ExamNotifyPayload | Same | collapse, versioned by exam-marks-submitted-{entryId}-v{version}. |
QueueName.EXAMS | ExamJob.RESULT_PUBLISH_NOTIFY | ExamResultsService.publish | notifyFromPayload("result_published", ...) | ExamNotifyPayload | Same | collapse, one per exam. |
QueueName.EXAMS | ExamJob.RESULT_COMPUTE | ExamResultsComputeService.compute when the roll exceeds 500 | resultCompute.compute(examId, requestId) → ExamResultComputeAdapter → ExamResultsComputeService.computeNow | ExamResultComputePayload {v:1, requestId, examId} | Same | Deduped per exam (exam.result_compute:{examId}) — a repeated request while one is in flight is a no-op enqueue, never a second job. |
Both sweeps exclude a locked academic year. ExamLifecycleSweepService and ExamReminderSweepService both filter their candidate query on isNull(academicSessions.lockedAt) — a locked year is historical, so its exams are neither auto-completed nor still sent reminders. exam_reminder_dispatches' own claim row is not enough on its own to stop a reminder for a locked year, because the claim only prevents a duplicate of a dispatch that already ran; the lockedAt filter is what stops a new one starting.
Every enqueue goes through the transactional outbox. Every outbox.enqueue call site above runs inside the same db.transaction as the business write it accompanies — a direct queue.add() in a request handler is a defect this repo has already paid for once (a queue sweep that silently never ran). The outbox row this module writes carries aggregateType, aggregateId, eventType, targetQueue: QueueName.EXAMS, jobName: ExamJob.<member>, an optional dedupeKey, and payload.
compute vs. computeNow. ExamsQueueProcessor's handler map is Record<ExamJob, handler>, so ExamJob.RESULT_COMPUTE needs a compile-time handler even though the computation logic belongs to the results area — ExamResultComputeRunner (an abstract-class DI token) is the seam, and ExamResultComputeAdapter binds it to ExamResultsComputeService. The adapter calls computeNow, never compute: compute is the request path and itself enqueues ExamJob.RESULT_COMPUTE when the roll exceeds EXAM_SYNC_COMPUTE_MAX_STUDENTS. A worker bound to compute would re-enqueue the very job it is running, and because the enqueue carries a per-exam dedupeKey, the repeat write would be swallowed as idempotent rather than looping visibly — the queue would drain, every gate would stay green, and no result would ever be computed for exactly the large exams the asynchronous path exists to serve. computeNow always runs inline because the worker is already off the request path and has no response-time budget to protect.
Concurrency 1. ExamsQueueProcessor declares @Processor(QueueName.EXAMS, { concurrency: 1 }) — every job here is either a whole-school sweep or a single exam's notification fan-out, both idempotent by construction (a claimed dispatch row, a collapse/repeatable dedupe key), so nothing needs to run concurrently with itself.
10. Realtime and Events
The module emits no Socket.IO/SSE realtime event. The only "realtime" signal is exam.marks_submitted, and it travels through the ordinary notification pipeline (kind: "exam.marks_submitted", channels: ["in_app"], audience {kind:"role", rolePublicIds} resolved from every role currently holding ExamMarksVerify_READ) rather than the module registry modules/realtime/shared/realtime.contract.ts uses — that registry currently carries exactly one live mapping (feedback.submitted) and this module does not add to it.
| Event | Producer | Room/Target | Payload | Consumer | Reliability Notes |
|---|---|---|---|---|---|
exam.marks_submitted | ExamNotifierService.notifyMarksSubmitted | In-app notification to every role holding ExamMarksVerify_READ | {className, subjectName, teacherName?} | Admin activity/notification screen | collapse, no SMS/push/email channel offered — an operator-facing queue signal, not a family-facing message. |
11. Security, Auth, and Abuse Controls
Guards. JwtAuthGuard is global; every exam route requires a session. RoleGuard enforces every admin controller's @Permissions() code (see §13's permission table). The three portal modules carry no @Permissions() at all.
Permission catalogue (13 modules). ExamTypes, Exams, ExamPublish, ExamCancel, ExamRoutines, ExamMarks, ExamMarksVerify, ExamMarksCorrect, ExamResults, ExamResultPublish, ExamGrading, ExamNotificationSettings, ExamView (packages/db/src/authorization/permission-catalog.ts). ExamPublish and ExamResultPublish are the only two also listed in SUPERADMIN_ONLY_MODULES — both authorize a change that reaches an already-published/family-visible row, and a grant can never be revoked (grantToRoles uses onConflictDoNothing), so both start with admin restricted to superadmin and widen only by deliberate role creation. ExamView appears on zero API routes — it is the audience-screen code that makes the exams tile safe to hand to a pupil.
ExamMarks must never be granted to STAFF_PERMISSIONS/TEACHER_PERMISSIONS. It is the unscoped whole-school code; a teacher's own marks access is the scoped portal route (TeacherExamMarksPortalService), which asserts a live staff row and then filters class_subjects.teacher_id = :myStaffId. The permission catalogue's own comment names the one-line "fix" an executor might reach for — adding the code to the teacher role — as exactly the mistake this predicate exists to avoid, because the grant, once made, can never be taken back.
Portal routes carry no @Permissions() and assert their audience instead. TeacherExamsPortalController, TeacherExamMarksPortalController, StudentExamsPortalController and GuardianExamsPortalController — 20 handlers across the four — are each registered as a key in NO_PERMISSION_ADMIN_HANDLERS (apps/api/src/common/authorization/role.guard.ts) and mirrored in apps/api/test/structure/route-permissions.spec.ts; verified identical in both files this session. Missing the role.guard.ts entry makes the handler 403 for every caller including superadmin, with a fully green integration suite (the specs call services directly and never pass through the guard). Missing the spec entry fails test:structure instead. Both are required because each is the only place a different failure mode is caught.
The teacher predicate is a plain eq, never IS NOT DISTINCT FROM. subjectTeacherExists/invigilatorExists (teacher-exam-scope.util.ts) and the identical inline predicate in TeacherExamMarksPortalService.isAuthorized all compare class_subjects.teacher_id = :staffId with an ordinary equality — IS NOT DISTINCT FROM would match every unassigned (NULL) row, handing every teacher every unassigned subject's papers.
The submitted_by_staff_id OR-arm is deliberate. A teacher's access to a marks sheet is class_subjects.teacher_id = me OR exam_marks_submissions.submitted_by_staff_id = me. Reassigning a subject must not simultaneously strip the previous teacher of a submission they already made and hand the new teacher every past examination's marks — the snapshot column exists for exactly this, and the predicate is what makes the snapshot load-bearing rather than decorative.
404, never 403, on a scoped miss. Every portal read — resolveExamInScope, TeacherExamsPortalService.resolveExamInScope, TeacherExamMarksPortalService.assertAccess — answers the identical EXAM_NOT_FOUND/EXAM_ROUTINE_ENTRY_NOT_FOUND whether the exam does not exist, is not in the caller's scope, or (on the result route) has not had its results published yet. A 403 on any one of those would disclose that the exam exists and that the specific pupil sat it — an id space over a school's own children must not become an enumeration oracle.
Guardian order of operations. GuardianExamsPortalService.resolveChild calls guardianPortal.resolveOwnChildId before any exam predicate runs. A foreign childId is refused by the guardian's own ownership check first; only after it succeeds does ExamPortalReadsService ever see the request.
EXAM_ATTACHMENT is a private upload type, verified absent from PUBLIC_TYPES in packages/storage/src/upload-type.ts this session. A public type is served from the public/ prefix, which the bucket policy grants anonymous s3:GetObject — a question paper there would be readable by the whole internet before the exam is sat. The generic upload-proxy route is also closed to it (ownership-only, and an exam attachment's owner is the administrator who uploaded it), so the only path a pupil, teacher or guardian reaches an audience-visible attachment through is the portal download route, which checks the exam is published/scoped and visibility='audience' before opening the stream.
Download headers. setExamAttachmentDownloadHeaders sets X-Content-Type-Options: nosniff, Cache-Control: private, no-store, a sandboxed Content-Security-Policy: sandbox; default-src 'none', and always Content-Disposition: attachment — a question paper or seating plan is never rendered inline in the API's own origin.
Fail-closed defaults. EXAM_ATTACHMENT.visibility defaults to administrator_only; a pupil never sees an attachment unless an administrator explicitly flips it to audience. exam_notification_rules.notify_invigilators is CHECK-refused outside its three applicable events. exam_attachment.storage_key uploads are never client-named — the caller supplies a file, StorageManager decides the key.
A locked academic session makes an exam's whole record read-only, enforced at the database. Once academic_sessions.locked_at is set, Postgres triggers refuse INSERT/UPDATE/DELETE on exams, exam_classes, exam_routine_entries, exam_assessment_components, exam_invigilators, exam_marks_submissions, exam_marks, exam_results, exam_subject_results and exam_attachments — every one of them resolved back to the exam's academic_session_id (packages/db/src/migrations/0038_year_lock_records.sql). The refusal is a fixed trigger message, ACADEMIC_SESSION_LOCKED, caught by AllExceptionsFilter's GUARD_MESSAGES map and returned as 409 ACADEMIC_SESSION_LOCKED with the message "That academic year is locked, so its records can no longer change. An administrator can unlock the year to make a correction." exam_mark_corrections carries no trigger of its own — the edit to the exam_marks row a correction accompanies is what the lock actually refuses. A write nested inside a cascade (pg_trigger_depth() > 1, e.g. a pupil's erasure) always passes, so archiving or erasing a person does not require unlocking their exam history first.
ExamDetailService reads academicSessions.lockedAt alongside the exam row and exposes it on ExamDetailDto.academicSessionLocked, so the admin screen can render the exam read-only without a separate lock lookup.
Rate limits / anti-abuse. No exam-specific rate limiter exists; the module relies on the platform-wide guards (JwtAuthGuard, upload size/type validation in StorageManager) for abuse resistance. There is no bulk-export or unbounded-read route in this module — every list route is either paginated (PaginationUtil) or capped at PaginationUtil.UNPAGINATED_HARD_CAP with a truncated flag rather than an unbounded fetch.
13. Error Handling
55 EXAM_*/GRADING_SCALE_* codes are declared under the "EXAMINATIONS AND RESULTS" heading in apps/api/src/common/types/error-codes.ts (counted directly against the file this session — an earlier estimate of 62 does not match the current tree; the code is the source of truth).
| Error Code | HTTP Status | Thrown By | Condition | Client Action |
|---|---|---|---|---|
EXAM_NOT_FOUND | 404 | ExamDetailService.loadRow, ExamSharedResolversService.resolveExam, most read paths | No exam with that publicId, or (portal) not in the caller's scope. | Reload the list. |
EXAM_TYPE_NOT_FOUND | 404 | ExamTypesService.findOrThrow, ExamWriteService.create/.update | Unknown exam type. | Refresh the type list. |
EXAM_TYPE_IN_USE | 409 | ExamTypesService.translate | DELETE while an exam still references it. | Deactivate instead of deleting. |
EXAM_TYPE_CODE_TAKEN | 409 | ExamTypesService.translate | exam_types_code_unique (active types only). | Choose another code. |
EXAM_TYPE_NAME_TAKEN | 409 | ExamTypesService.translate | exam_types_name_unique (active types only). | Choose another name. |
EXAM_ALREADY_PUBLISHED | 409 | ExamLifecycleService.publish | Re-publishing an already-published exam. | Refresh; the exam is already live. |
EXAM_NOT_PUBLISHED | 409 | ExamLifecycleService.unpublish | Unpublishing a non-published exam. | Refresh state. |
EXAM_HAS_NO_CLASSES | 409 | ExamLifecycleService.publish | Publish attempted with an empty class scope. | Add at least one class. |
EXAM_CLASS_HAS_RECORDED_MARKS | 409 | ExamClassesService.replace/.translate | Removing a class from scope whose entries already carry marks. | Do not remove; archive instead. |
EXAM_SESSION_NOT_ACTIVE | 409 | ExamWriteService.create | Creating an exam in a retired academic session. | Select the current session. |
EXAM_CANNOT_COMPLETE_BEFORE_LAST_PAPER | 409 | ExamLifecycleService.complete, mirrored (not thrown, silently skipped) by ExamLifecycleSweepService | At least one routine entry's end instant is still in the future. | Wait, or complete after every paper ends. |
EXAM_CANNOT_UNPUBLISH_WITH_FINALIZED_MARKS | 409 | ExamLifecycleService.unpublish | Results already published, or at least one submission is finalized. | Cannot unpublish; use cancel/archive instead. |
EXAM_CANNOT_CANCEL_AFTER_RESULTS_PUBLISHED | 409 | ExamLifecycleService.cancel | Results already published for this exam. | Cannot cancel a graded exam. |
EXAM_STATE_TRANSITION_INVALID | 409 | Every ExamLifecycleService transition's onWrongState, ExamWriteService.remove | The exam is not in one of the transition's fromStatuses. | Reload the exam's current status. |
EXAM_PERIOD_HAS_ENTRIES_OUTSIDE | 409 | ExamWriteService.update | Narrowing startDate/endDate would exclude an existing routine entry. | Reschedule or remove the entry first. |
EXAM_VERSION_CONFLICT | 409 | Every versioned write across the module | The supplied version does not match the current row. | Reload and retry. |
EXAM_ROUTINE_DATE_OUTSIDE_PERIOD | 422 | ExamRoutineWriteService.create | examDate outside the exam's [startDate, endDate] envelope. | Pick a date inside the exam's period. |
EXAM_ROUTINE_ENTRY_NOT_FOUND | 404 | ExamSharedResolversService.resolveRoutineEntry and every caller | No entry with that publicId. | Reload the routine. |
EXAM_ROUTINE_ENTRY_IN_USE | 409 | translateExamWriteError (23001/23503 mapping) | Deleting an entry a computed result still references. | Cannot delete; results exist. |
EXAM_ROUTINE_ENTRY_HAS_NO_COMPONENTS | 409 | ExamLifecycleService.publish, flagged (not thrown) by ExamPublishPreviewReadService | A publish-time hard precondition: every entry needs >= 1 component. | Add an assessment component. |
EXAM_ROUTINE_ENTRY_STATE_CHANGED | 409 | ExamRoutineWriteService.patch/.remove, ExamRoutineSwapService.swap | Zero-row conditional update — concurrent edit or a status change landed between read and write. | Reload and retry. |
EXAM_ROUTINE_SUBJECT_NOT_TAUGHT | 422 | ExamRoutineWriteService.create, translateExamWriteError | The chosen class_subject_id does not belong to the chosen class. | Choose a subject this class teaches. |
EXAM_ROOM_CONFLICT | 409 | exam_conflict.util.ts (throwExamConflict), translateExamWriteError backstop | Two published/completed papers would share a room and overlapping time. | Choose another room or time. |
EXAM_CLASS_CONFLICT | 409 | Same | Two published/completed papers would clash for the same class. | Reschedule one of the papers. |
EXAM_INVIGILATOR_CONFLICT | 409 | Same | An invigilator would be double-booked. | Reassign the invigilator. |
EXAM_INVIGILATOR_NOT_FOUND | 404 | ExamInvigilatorsWriteService.replace | A requested staffId has no live staff row. | Choose a current staff member. |
EXAM_COMPONENT_NOT_FOUND | 404 | ExamSharedResolversService.resolveComponent, ExamComponentsWriteService.replace, ExamMarksSheetService.saveMarks | Unknown component id (scoped to the entry). | Reload the components. |
EXAM_COMPONENT_HAS_RECORDED_MARKS | 409 | translateExamWriteError (23001) | Deleting/replacing away a component that already has recorded marks. | Cannot remove; marks exist. |
EXAM_COMPONENT_CEILING_BELOW_RECORDED_MARK | 422 | translateExamWriteError (23514 on the cascaded exam_marks row) | Lowering a component's max_marks below a mark already recorded against it. | Raise the ceiling or correct the mark first. |
EXAM_MARKS_NOT_EDITABLE | 409 | ExamMarksSheetService.saveMarks | The submission has already left draft/returned. | Marks are locked; ask for a return if needed. |
EXAM_MARKS_NOT_YET_OPEN | 409 | resolveMarksGate callers | The paper's end instant has not passed and it is not manually completed. | Wait for the paper to end, or mark it complete. |
EXAM_MARKS_INCOMPLETE | 409 | ExamMarksWorkflowService.submit | A present pupil has no mark on at least one component. | Fill in every present pupil's mark (names supplied). |
EXAM_MARKS_TRANSITION_INVALID | 409 | ExamMarksWorkflowService.runTransition, ExamMarkCorrectionsService.correct | Wrong current state for the requested transition/correction. | Reload the submission state. |
EXAM_MARKS_SUBMISSION_NOT_FOUND | 409 | ExamMarksWorkflowService.runTransition | No submission row exists yet for an action other than submit. | Save marks first. |
EXAM_MARKS_SUBMISSION_VERSION_CONFLICT | 409 | ExamMarksWorkflowService.runTransition | Version mismatch on the submission row. | Reload and retry. |
EXAM_MARK_NOT_FOUND | 404 | ExamMarkCorrectionsService | Unknown mark publicId. | Reload the marks sheet. |
EXAM_MARK_EXCEEDS_CEILING | 422 | ExamMarksSheetService.saveMarks, ExamMarkCorrectionsService.correct (service check and 23514 backstop) | obtainedMarks > component.maxMarks. | Enter a mark within the ceiling. |
EXAM_MARK_ABSENT_CANNOT_SCORE | 422 | Same call sites | A non-present attendance carries a non-null mark. | Clear the mark or set attendance to present. |
EXAM_RESULTS_NOT_ALL_FINALIZED | 409 | ExamResultsService.publish | At least one routine entry has no finalized submission. | Finalize every paper's marks first. |
EXAM_RESULTS_STALE | 409 | ExamResultsService.publish | At least one result is is_stale (a correction happened since the last compute). | Recompute results first. |
EXAM_RESULTS_ALREADY_PUBLISHED | 409 | ExamResultsService.publish | Results already published for this exam. | Refresh; already live. |
EXAM_RESULTS_NOT_PUBLISHED | 409 | ExamResultsService.unpublish | Unpublishing results that are not published. | Refresh state. |
EXAM_RESULT_NOT_FOUND | 404 | ExamResultsService, ExamPortalReadsService | No result row for that pupil on this exam. | Compute results, or (portal) results not yet published. |
GRADING_SCALE_NOT_FOUND | 404 | GradingScalesService, ExamWriteService | Unknown grading scale publicId. | Reload the scale list. |
GRADING_SCALE_IN_USE | 409 | GradingScalesService.remove | Deleting a scale an exam still references. | Deactivate instead. |
GRADING_SCALE_HAS_NO_FLOOR_BAND | 409 | ExamLifecycleService.publish, flagged by ExamPublishPreviewReadService | A scale is attached with no band at min_percent = 0. | Add a floor band. |
GRADING_SCALE_BAND_INVALID | 409 | GradingScalesService.translateBandWriteError | A band violates its label/floor/GPA-completeness constraints. | Fix the offending band. |
GRADING_SCALE_NAME_TAKEN | 409 | GradingScalesService.translateWriteError | grading_scales_name_unique (active scales only). | Choose another name. |
EXAM_REMINDER_RULES_TOO_CLOSE | 409 | ExamReminderRulesService.assertMinimumSpacing | Two enabled offsets closer than the 15-minute sweep tick. | Space the offsets further apart. |
EXAM_REMINDER_RULE_INVALID | 400 | ExamReminderRulesService | Duplicate offset in one request, or an id with no matching row. | Fix the request payload. |
EXAM_NOTIFICATION_RULE_INVALID | 400 | ExamNotificationRulesService.update | Unknown channel, or notifyInvigilators on a non-applicable event. | Fix the request payload. |
EXAM_NOTIFICATION_RULE_NOT_FOUND | 404 | ExamNotificationRulesService.update | event not one of the seven known events. | Use a valid event name. |
EXAM_ATTACHMENT_NOT_FOUND | 404 | ExamAttachmentsService.remove, portal reads | No attachment with that id on this exam. | Reload the attachment list. |
EXAM_ATTACHMENT_NOT_AUDIENCE_VISIBLE | 404 | Portal/teacher attachment downloads | The attachment is administrator_only. | Not available to this caller. |
EXAM_BS_DATE_OUT_OF_RANGE | 400 | Wherever a Bikram Sambat conversion is attempted outside the supported range | BikramSambatRangeError mapped deliberately so a caller can distinguish it. | Choose a date within the supported BS range. |
ACADEMIC_SESSION_LOCKED | 409 | A database trigger (packages/db/src/migrations/0038_year_lock_records.sql), caught by AllExceptionsFilter | Any write to an exam, its routine, components, invigilators, marks, submissions, results, subject results or attachments while the exam's academic year is locked. | An administrator can unlock the year to make a correction. |
14. Observability
| Signal | Location | Purpose |
|---|---|---|
| Log | ExamsQueueProcessor (Logger) | [start]/[success] per job with requestId and duration; [failure]/[retry] on OnWorkerEvent("failed"), distinguishing a terminal failure (recorded by JobFailureRecorderService) from a retry. |
| Log | ExamScheduler | Logs and swallows an enqueue failure rather than throwing — a throwing cron handler would stop every later tick. |
| Log | ExamReminderSweepService | Per-candidate failure is caught, logged with routineEntryId/ruleId/requestId, and does not stop the rest of the sweep. |
| Log | ExamLifecycleSweepService | Logs each auto-completed exam id and the per-run tomorrow/today notified-class counts. |
| Log | ExamResultComputeAdapter | Logs the written result count for every worker-path compute, including a legitimate zero (an empty roll) — indistinguishable from a broken scope predicate without the log. |
| Log | ExamAttachmentsService | Logs (warn) a failed best-effort storage cleanup after a DB insert failure, and a failed best-effort delete on attachment removal. |
| Audit | Mongo audit_logs, via the global ActivityAuditInterceptor | Every mutating admin route is recorded automatically from its @Permissions() code (module = Exams/ExamRoutines/ExamMarks/… , action = CREATE/READ/UPDATE/DELETE). |
| Audit (detail) | ExamMarkCorrectionsService.correct → ActivityRecordService.recordActivity | The one call site in this module that supplies per-field changes the interceptor cannot see — attendance and mark before/after, plus the correction reason. |
| Metric | None declared | No named metric exists for this module; operational health is read from the log lines above and from SELECT queries against exam_reminder_dispatches/outbox_events (see §16.7). |
15. Testing and Validation
| Test Type | Files | Coverage |
|---|---|---|
| Unit | results/core/exam-result-rules.spec.ts | The five-row NULL-propagation table for computeSubjectFromMarks, plus computeOverall, lookupBand, round2 — pure functions, no database. |
| Integration | __tests__/exam-authoring.integration.spec.ts | Create/update/delete, class scope, attachments, every lifecycle transition, completion once every paper has ended, and the completion guard refusing inside the write when the version still matches. |
| Constraint probe | scripts/probe-exam-constraints.sh | Accept and reject cases for every exam CHECK, key, exclusion and trigger — including the result-percentage and finaliser CHECKs — in one rolled-back transaction against a migrated database. |
| Integration | __tests__/exam-routine.integration.spec.ts | Entry CRUD, components, invigilators, swap. |
| Integration | __tests__/exam-conflicts.integration.spec.ts | The three GiST exclusions, both conflict-resolution passes, the deferred-constraint swap. |
| Integration | __tests__/exam-marks.integration.spec.ts, exam-marks-workflow.integration.spec.ts | The sheet read/write, the five-state workflow, the marks gate. |
| Integration | __tests__/exam-grading.integration.spec.ts | Scale/band CRUD, the default-scale swap, band completeness. |
| Integration | __tests__/exam-results.integration.spec.ts | Synchronous and queued compute, withhold/release, publish/unpublish gates. |
| Integration | __tests__/exam-notifications.integration.spec.ts | ExamNotifierService fan-out per event, rule gating, invigilator applicability. |
| Integration | __tests__/exam-sweeps.integration.spec.ts | The reminder sweep's window arithmetic and dispatch claim, the lifecycle sweep's auto-complete and tomorrow/today notify. |
| Integration | __tests__/exam-portal.integration.spec.ts, teacher-today.integration.spec.ts | The three portal surfaces' scoping predicates, the 404-not-403 discipline, the teacher's own-day view. |
| Integration | __tests__/exam-types.integration.spec.ts | Type CRUD, active-partial uniqueness, RESTRICT on delete-while-in-use. |
| Structure | apps/api/test/structure/route-permissions.spec.ts | Asserts the exam module's 20 no-permission portal handlers match NO_PERMISSION_ADMIN_HANDLERS exactly. |
| Structure | apps/api/test/structure/swagger-coverage.spec.ts | Every exam route appears in exactly one of /api/api-docs / /api/mobile-docs; no shared tag across two exam controllers. |
Validation commands:
pnpm --filter @skoolsewa/api test # unit + integration, real Postgres/Redis/Mongo
pnpm --filter @skoolsewa/api test:structure # route/guard/tag fingerprint
pnpm rules # error-codes, module-size, module-shape, job-ids, env-orphans
find apps packages -name '*.tsbuildinfo' -not -path '*/node_modules/*' -delete && pnpm check-types16. Mandatory Backend Deep-Dive Pack
16.1 Submodule Coverage Matrix
| Unit | Type | Owns | Depends On | Called By | Calls | State Touched | Failure Modes |
|---|---|---|---|---|---|---|---|
ExamTypesController / ExamTypesService | Controller/service | exam_types CRUD | — | Admin | exam_types | exam_types | EXAM_TYPE_* |
ExamsController / ExamWriteService / ExamDetailService | Controller/service | exams CRUD, list/detail read shape | academic_sessions, exam_types, grading_scales | Admin | exams | exams | EXAM_NOT_FOUND, EXAM_VERSION_CONFLICT, EXAM_SESSION_NOT_ACTIVE, EXAM_PERIOD_HAS_ENTRIES_OUTSIDE |
ExamLifecycleController / ExamLifecycleService | Controller/service | The seven lifecycle transitions | ExamDetailService, SchoolClockService, OutboxService, exam-conflict.util | Admin | exams, outbox_events | exams.status and every cascaded child | Every EXAM_* transition code, three conflict codes |
ExamClassesController / ExamClassesService | Controller/service | Class-scope full replacement | ExamDetailService | Admin | exam_classes, exam_marks (recorded-marks check) | exam_classes | EXAM_CLASS_HAS_RECORDED_MARKS, VALIDATION_FAILED |
ExamAttachmentsController / ExamAttachmentsService | Controller/service | Attachment upload/list/remove | StorageManager, ExamDetailService | Admin | exam_attachments, storage | exam_attachments, object storage | EXAM_ATTACHMENT_NOT_FOUND, UPLOAD_* |
ExamCalendarProjectionService | Service | Two read-only calendar projections | — | Calendar module (out of ownership), mobile calendar route | exams, exam_classes, exam_routine_entries | none | — |
ExamDashboardController / ExamDashboardService | Controller/service | Session totals tile | SchoolClockService | Admin | exams, exam_routine_entries, exam_marks_submissions | none | ACADEMIC_SESSION_NOT_FOUND |
ExamRoutineController / ExamRoutineEntryController / ExamDayController | Controllers | Routine surface HTTP | ExamRoutineCoreModule services | Admin | — | — | — |
ExamRoutineWriteService | Service | Entry create/patch/remove/complete | ExamSharedResolversService, RoleService, OutboxService, exam-conflict.util | Controllers | exam_routine_entries, exam_assessment_components (default), outbox_events | routine entries | EXAM_ROUTINE_*, conflict codes, PERMISSION_INSUFFICIENT |
ExamRoutineSwapService | Service | Deferred-constraint two-entry swap | Same | ExamRoutineEntryController.swap | exam_routine_entries | two routine entries | Same + conflict codes |
ExamComponentsWriteService | Service | Components full replacement | ExamSharedResolversService, RoleService | ExamRoutineEntryController.replaceComponents | exam_assessment_components | components | EXAM_COMPONENT_*, EXAM_VERSION_CONFLICT |
ExamInvigilatorsWriteService | Service | Invigilators full replacement | Same, OutboxService | .replaceInvigilators | exam_invigilators, outbox_events | invigilators | EXAM_INVIGILATOR_NOT_FOUND, conflict codes |
ExamPublishPreviewReadService | Service (read-only) | Publish-preview + conflicts report | CalendarDayService, exam-conflict.util | .publishPreview/.conflicts | reads only | none | — |
ExamDayReadService | Service (read-only) | Admin's exam-day overview | SchoolClockService | ExamDayController.today | reads only | none | — |
ExamRoutineReadService | Service (read-only) | Full entry DTO assembly, availability | ExamSharedResolversService | Every routine write's own re-read, .availability | reads only | none | EXAM_ROUTINE_ENTRY_NOT_FOUND |
ExamMarksController / ExamMarksSheetService | Controller/service | Sheet get/put | ExamSharedResolversService, SchoolClockService, exam-marks-gate.util | Admin, TeacherExamMarksPortalService | exam_marks, exam_marks_submissions (read) | exam_marks | EXAM_MARKS_NOT_EDITABLE, EXAM_MARKS_NOT_YET_OPEN, mark-format codes |
ExamMarksWorkflowController / ExamMarksWorkflowService | Controller/service | Five-state workflow | Same + OutboxService | Admin, portal submit | exam_marks_submissions, outbox_events | submission state | EXAM_MARKS_TRANSITION_INVALID, EXAM_MARKS_INCOMPLETE, version conflicts |
ExamMarkCorrectionsController / ExamMarkCorrectionsService | Controller/service | Post-finalization corrections | ActivityRecordService | Admin | exam_mark_corrections, exam_marks, exam_results.is_stale | marks + results staleness | EXAM_MARKS_TRANSITION_INVALID, mark-format codes |
GradingScalesController / GradingScalesService | Controller/service | Scale + band CRUD | — | Admin | grading_scales, grading_scale_bands | scales/bands | GRADING_SCALE_* |
ExamResultsController / ExamResultsService | Controller/service | List/detail/withhold/release/publish/unpublish | — | Admin | exam_results, exam_subject_results, exams | results, exam publication stamps | EXAM_RESULTS_*, EXAM_RESULT_NOT_FOUND |
ExamResultsComputeService | Service | Sync/queued computation | OutboxService, advisory lock | .compute route, ExamResultComputeAdapter | exam_results, exam_subject_results | Same | EXAM_NOT_FOUND |
ExamResultComputeRunner / ExamResultComputeAdapter | Abstract token / service | The worker-side seam onto compute | ExamResultsComputeService | ExamsQueueProcessor | computeNow | Same | — |
ExamNotificationSettingsController / ExamNotificationRulesService | Controller/service | Notification rule read/update | — | Admin | exam_notification_rules | rule rows | EXAM_NOTIFICATION_RULE_* |
ExamReminderRulesController / ExamReminderRulesService | Controller/service | Reminder offset full-list replace | — | Admin | exam_reminder_rules | rule rows | EXAM_REMINDER_RULE* |
ExamSharedResolversService | Service | publicId resolvers | — | Nearly every other unit in the module | exams, exam_routine_entries, exam_assessment_components (read) | none | EXAM_NOT_FOUND family |
exam-conflict.util.ts | Plain functions | Two-pass conflict detection | — | Every schedule-touching write, publish-preview | reads only | none | — |
exam-write.errors.ts | Plain function | Constraint → error-code translation | pg-constraint.util | Routine/components/invigilators writes | — | none | — |
ExamTypesAdminModule etc. (18 modules) | NestJS modules | DI wiring | — | app.module.ts / mobile.module.ts | — | — | — |
ExamScheduler | Scheduler | Two crons | buildJobId, BullMQ Queue | ScheduleModule | Enqueue only | outbox_events-free (cron exemption) | Logged-and-swallowed enqueue failure |
ExamsQueueProcessor | Processor | The one QueueName.EXAMS handler map | Every worker service | BullMQ | Dispatches to workers | — | Unhandled job name throws (compile-time exhaustive, so unreachable at runtime for a live enum member) |
ExamReminderSweepService | Worker service | 15-min reminder tick | SchoolClockService, NotificationService, ExamNotificationScopeService | ExamsQueueProcessor | exam_reminder_dispatches, notification_event | Reminder claims | Per-candidate exception caught and logged |
ExamLifecycleSweepService | Worker service | Hourly auto-complete + tomorrow/today | Same | Same | exams.status, notification_event | Exam completion | — |
ExamNotifierService | Worker service | Every occurrence → notification | ExamNotificationScopeService, NotificationService | ExamsQueueProcessor, ExamReminderSweepService (indirectly via scope service reuse) | notification_event | — | Silently no-ops on a disabled rule or an unresolvable scope |
ExamNotificationScopeService | Worker service (read-only) | Every recipient-resolution query | — | ExamNotifierService, both sweeps | reads only | none | — |
TeacherExamsPortalController/Service, TeacherExamDutiesPortalService, TeacherExamMarksPortalController/Service | Controllers/services | Teacher portal surface | assertTeacherStaffRow (assertStaffRow for listExams, getExamDetail, streamAttachment and listInvigilations), ExamMarksCoreModule services | Teacher client | reads + delegated marks writes | exam_marks* (delegated) | EXAM_ROUTINE_ENTRY_NOT_FOUND (identical-404 discipline) |
StudentExamsPortalController/Service, ExamPortalReadsService | Controllers/services | Student portal surface | assertPortalAudience, students | Student client | reads only | none | EXAM_NOT_FOUND (identical-404) |
GuardianExamsPortalController/Service | Controller/service | Guardian portal surface | GuardianPortalService.resolveOwnChildId, ExamPortalReadsService | Guardian client | reads only | none | Ward-resolution 404 runs before any exam read |
16.2 UML and Architecture Diagram Pack
16.3 Code Flow Narrative
ExamLifecycleService.publish (the module's most consequential write):
| Step | Code Location | What Happens | Why It Happens | Failure/Edge Case |
|---|---|---|---|---|
| 1 | ExamLifecycleController.publish | Receives POST /exams/:publicId/publish with a version body. | Entry point; Permissions("ExamPublish_UPDATE") gates it. | Missing permission → 403 before the handler runs. |
| 2 | ExamLifecycleService.publish | Loads the exam row by publicId. | Every downstream check needs the internal id and current state. | EXAM_NOT_FOUND. |
| 3 | Same | Counts exam_classes for this exam. | A publish-gate precondition the database cannot express as a CHECK (it would need a cross-table count). | Zero → EXAM_HAS_NO_CLASSES. |
| 4 | Same | notExists query for any routine entry with no component. | Same reason — "every entry has >= 1 component" spans two tables. | Any found → EXAM_ROUTINE_ENTRY_HAS_NO_COMPONENTS. |
| 5 | Same | If a grading scale is attached, checks for a min_percent = 0 band. | The floor-band invariant also spans tables. | Missing → GRADING_SCALE_HAS_NO_FLOOR_BAND. |
| 6 | Same | Opens a db.transaction. | The status flip, its cascade, and the outbox enqueue must commit or roll back together. | — |
| 7 | loadConflictCandidates | Reads every routine entry (still draft) plus their invigilators, BEFORE the write. | After a savepoint rollback the transaction's own view of these rows would be gone; the candidates must be captured first. | — |
| 8 | runExamWriteWithConflictGuard | Runs runTransition (the conditional UPDATE) inside a nested tx.transaction() — a SAVEPOINT. | Publishing arms three exclusion constraints in ONE cascaded statement; a collision must not poison the outer transaction. | Zero-row update → re-read decides EXAM_ALREADY_PUBLISHED vs. EXAM_STATE_TRANSITION_INVALID vs. EXAM_VERSION_CONFLICT. |
| 9 | Postgres | The UPDATE exams SET status='published' cascades exam_status onto every routine entry (ON UPDATE cascade) in the same statement, arming the three GiST exclusions. | This is the entire mechanism — one row's status change is what turns "draft, unconstrained" into "published, conflict-free by construction". | A 23P01 here is caught by runExamWriteWithConflictGuard. |
| 10 | resolveExamConflict (on 23P01) | Pass 1: query other published/completed exams' entries for room/class/invigilator overlap. Pass 2: check the candidate set against itself in memory. | An exam's own collision is usually entirely INSIDE the set being published, where pass 1 finds nothing. | Neither pass finds anything → the original error is rethrown untouched (a genuine race with a row that moved). |
| 11 | ExamLifecycleService.publish | outbox.enqueue({eventType:"exam.published", jobName: ExamJob.PUBLISH_NOTIFY, dedupeKey: "exam-published-{publicId}-v{version}"}) in the same transaction. | The outbox exemption rule: an enqueue outside the write's own transaction can commit the write and lose the notification. | — |
| 12 | ExamDetailService.toDetail | Re-reads the row (never echoes the request) and maps to ExamDetailDto. | Repo convention: a write's response is a fresh read. | — |
| 13 | — | — | — | Logs: none at this layer; the eventual worker log (ExamsQueueProcessor) records the notify job. |
| 14 | — | — | — | Known tradeoff: the three publish-gate reads (steps 3-5) are NOT re-checked inside the transaction — a concurrent class-scope edit between the check and the write is closed by the transaction's own isolation on the rows it touches, not by a second read. |
ExamResultsComputeService.compute → runLocked:
| Step | Code Location | What Happens | Why It Happens | Failure/Edge Case |
|---|---|---|---|---|
| 1 | .compute(examPublicId) | Loads the exam, fetches the live roll (row-scope predicate: deleted_at, record_status='active', enrolment status='active', scoped to exam_classes). | The roll is read live, never a snapshot, so a pupil enrolled after scope was set still counts. | EXAM_NOT_FOUND. |
| 2 | Same | If roll.length > 500, enqueues ExamJob.RESULT_COMPUTE (deduped per exam) and returns {mode:"queued"} immediately. | Keeps the request path fast; large-roll computation moves off it. | A repeated request while one is queued is a no-op (dedupe key). |
| 3 | Same | Otherwise calls runLocked inline. | Small rolls compute synchronously so the admin sees the result immediately. | — |
| 4 | runLocked | SELECT pg_advisory_xact_lock(...) keyed on the exam id, inside a fresh transaction. | Serializes a request and a queued job racing on the same exam — both take this same lock. | Blocks until the other holder's transaction ends; never deadlocks (single lock, no nesting). |
| 5 | Same | Re-fetches the roll INSIDE the lock. | A student's enrolment could have changed between the pre-check (step 1) and acquiring the lock. | — |
| 6 | computeSync | Loads the grading scale + bands (if any), every routine entry's component-max total, every mark for the roll, joined to its component. | One batch of reads, not N+1 per pupil. | — |
| 7 | Same | For each pupil: computeSubjectFromMarks per relevant routine entry, lookupBand per subject and overall, computeOverall across subjects. | Pure functions from exam-result-rules.ts — no I/O, exhaustively tested. | See §16.5 for the invariant table these encode. |
| 8 | Same | INSERT ... ON CONFLICT (exam_id, student_id) DO UPDATE into exam_results; class_id is excluded from the update set. | Recompute must not retroactively change which class a pupil is recorded as having SAT the exam in. | — |
| 9 | Same | DELETE then INSERT exam_subject_results for the pupil. | Full replacement is simpler and cheaper than diffing at this table's size (one row per subject per pupil). | — |
| 10 | — | Transaction commits; lock releases. | — | A thrown error anywhere in steps 6-9 rolls back the whole compute for every pupil in this run — partial writes are not possible. |
16.4 Data Layer Deep Dive
Every table's full field-level detail is in §5.2. This subsection adds the index-rationale table the format requires.
| Index/Constraint | Columns | Type | Query/Invariant Supported | Tradeoff |
|---|---|---|---|---|
exam_routine_entries_room_free | exam_date, room_id, int4range(start_minute,end_minute) | GiST EXCLUDE, partial (published/completed), WHERE room_id IS NOT NULL | No two live papers share a room and overlapping time. | Every scheduling write against a published exam pays a GiST probe; unavoidable given the guarantee is database-enforced. |
exam_routine_entries_class_free | exam_date, class_id, int4range(start_minute,end_minute) | GiST EXCLUDE, partial | No two live papers clash for one class — and, transitively, no pupil sits two papers at once (via student_class_enrollments_one_active_per_session_unique). | Same write-time cost; this is the STUDENT conflict expressed at the cheaper, enforceable class granularity. |
exam_invigilators_free | exam_date, staff_id, int4range(start_minute,end_minute) | GiST EXCLUDE, partial | No invigilator double-booked across live papers. | Lives on the child table because an exclusion cannot span a join; every schedule move must cascade the four copied columns here first. |
exams_id_session_unique | id, academic_session_id | unique constraint | Composite FK target proving exam_classes.exam_id is in the exam's OWN session. | unique(...), not uniqueIndex, to satisfy drizzle-kit's FK-before-index emission order. |
exams_id_status_unique | id, status | unique constraint | Composite FK target that lets one UPDATE cascade status to every child row. | Same ordering constraint. |
exam_routine_entries_schedule_unique | id, exam_date, start_minute, end_minute, exam_status | unique constraint | Composite FK target for exam_invigilators' cascading schedule copy. | Widens the "unique" surface to include non-identity columns purely to serve the cascade. |
exam_routine_entries_one_paper_per_subject | exam_class_id, class_subject_id | unique | One entry per subject per class per exam — a second paper is a second component, not a second row. | Forces the components model rather than a naive "add another entry" workaround. |
exam_assessment_components_id_max_unique | id, max_marks | unique | Carries a component's ceiling onto every exam_marks row via cascade — the mechanism behind "a mark can never exceed its ceiling". | A max_marks change is a cascading write across every recorded mark for that component, not a local update. |
exam_marks_component_student_unique | component_id, student_id | unique | One mark per pupil per component — the target of the sheet's bulk onConflictDoUpdate. | — |
exam_results_exam_student_unique | exam_id, student_id | unique | One result per pupil per exam — the target of the compute upsert. | — |
exam_reminder_dispatches_key | routine_entry_id, rule_id, exam_date, start_minute | unique | The dedupe mechanism itself — claiming this row IS "already sent". | A rescheduled paper's new key was never claimed, so it re-arms with zero cleanup code; the cost is one row per (paper, rule) forever, never pruned. |
exam_reminder_rules_offset_bounded (CHECK) | offset_minutes | CHECK 15..43200 | An offset below one sweep tick can fall entirely between two ticks and never dispatch — silently. | The floor is the sweep's own period; changing the cron period without changing this CHECK would reopen the gap. |
grading_scale_bands_scale_idx | scale_id, min_percent | btree | Serves lookupBand's "greatest floor <= pct" scan. | — |
exam_routine_entries_class_status_idx | class_id, exam_status | btree | The audience read: published papers for a class on a date. | — |
exam_results_stale_idx | exam_id | btree, partial WHERE is_stale | The publish gate's stale-result count, and an operator's "what needs recomputing" view. | Index only carries rows currently stale — near-zero size in the steady state. |
16.5 Business Logic and Invariant Catalog
| Invariant | Enforced By | Why It Exists | Failure Error | Tests |
|---|---|---|---|---|
| No two live papers share a room/class/invigilator and overlapping time | 3 GiST EXCLUDE constraints, partial on published/completed | The database, not a service check, makes overlap unrepresentable once an exam is live. | EXAM_ROOM_CONFLICT/EXAM_CLASS_CONFLICT/EXAM_INVIGILATOR_CONFLICT | exam-conflicts.integration.spec.ts |
exam_status on the two child tables is never written directly | Guard trigger, pg_trigger_depth() = 1 refused | A stray UPDATE would arm/disarm the exclusions with nothing failing and nothing logged. | Postgres RAISE EXCEPTION (P0001) | Constraint probe; asserted implicitly by every routine test relying on the cascade. |
| A mark can never exceed its component's ceiling | Composite FK (component_id, max_marks) carries the ceiling onto the row; plain CHECK on one row | Cross-row invariants cannot be CHECKs; the composite FK turns it into a single-row one. | EXAM_MARK_EXCEEDS_CEILING, EXAM_COMPONENT_CEILING_BELOW_RECORDED_MARK | exam-marks.integration.spec.ts |
| An absent/withheld/not-applicable pupil can never carry a mark | CHECK disjunction attendance='present' OR obtained_marks IS NULL | Deliberately a disjunction, not a biconditional — makes "present, not yet scored" representable (the normal exam-hall workflow) while still forbidding an absence carrying a score. | EXAM_MARK_ABSENT_CANNOT_SCORE | Same |
| Only five statuses are stored; "scheduled"/"in progress" are derived | deriveExamDisplayStatus (pure function) | A stored "in progress" is wrong the minute after it is written. | — (display-only) | Implicit in exam-authoring.integration.spec.ts |
| A published exam is auto-completed once every paper has ended | ExamLifecycleSweepService.completeEndedExams, hourly | Without the sweep, a published exam past its period has no derived status at all. | — | exam-sweeps.integration.spec.ts |
| A grading band has a floor and no ceiling | Schema shape (min_percent only), lookupBand's greatest-floor scan | Makes overlapping bands and gaps both unrepresentable, provided a 0% band exists. | GRADING_SCALE_HAS_NO_FLOOR_BAND (checked because "a 0% band exists" cannot be a CHECK) | exam-grading.integration.spec.ts, exam-result-rules.spec.ts |
| A reminder never fires twice for the same (paper, rule, schedule) | exam_reminder_dispatches unique key, claimed before send | The key contains the schedule, so a reschedule re-arms without any cancellation code. | Silent skip (no error — the claim insert returns nothing) | exam-sweeps.integration.spec.ts |
| Minimum spacing between two enabled reminder offsets is one sweep tick | ExamReminderRulesService.assertMinimumSpacing (service, not CHECK) | A CHECK cannot span rows. | EXAM_REMINDER_RULES_TOO_CLOSE | Same |
| Marks entry opens on time, not on a click | resolveMarksGate — end instant passed OR isCompleted | Gating on the exam-day flag alone would permanently lock out marks entry if the flag's one route (which teachers do not hold) is never clicked. | EXAM_MARKS_NOT_YET_OPEN | exam-marks-workflow.integration.spec.ts |
| A marks submission has no stuck state | marks_submission_state enum with approved having two exits | A single-exit approved was caught in review as unreachable-from. | EXAM_MARKS_TRANSITION_INVALID | Same |
| A correction is legal only once finalized, and is append-only | Service check (state==='finalized') + append-only trigger on exam_mark_corrections | Corrections exist to record a change to a SETTLED record; UPDATE is refused always, DELETE only through cascade. | EXAM_MARKS_TRANSITION_INVALID; trigger RAISE EXCEPTION on a direct UPDATE/DELETE | exam-marks.integration.spec.ts, constraint probe |
| A correction marks every result in the exam stale | ExamMarkCorrectionsService.correct sets exam_results.is_stale=true for the exam | A correction to a finalized mark leaves the computed result silently wrong without this. | EXAM_RESULTS_STALE blocks publish until recompute | Same |
| Results publication is blocked until every paper is finalized and no result is stale | ExamResultsService.publish | Publishing results is the act that makes a child's marks visible to a family — the highest-consequence write in the module. | EXAM_RESULTS_NOT_ALL_FINALIZED, EXAM_RESULTS_STALE | exam-results.integration.spec.ts |
computeNow, never compute, from the worker | ExamResultComputeAdapter | Calling compute from the worker would re-enqueue the job it is running; the per-exam dedupe key would swallow the repeat, draining the queue with nothing computed. | — (design invariant, not a runtime error) | exam-results.integration.spec.ts (queued-path assertions) |
| A teacher reaches only their own subject/invigilation scope | subjectTeacherExists/invigilatorExists, the submitted_by_staff_id OR-arm | ExamMarks/ExamRoutines_READ must never be granted to a teacher role — see §11. | Identical 404 on any out-of-scope access | exam-portal.integration.spec.ts, teacher-today.integration.spec.ts |
A pupil's/guardian's exam visibility excludes draft only | ExamPortalReadsService.resolveExamInScope | Every other status was, at some point, a live decision for this family — including cancelled, so public_cancellation_note remains readable. | EXAM_NOT_FOUND (identical for absent/out-of-scope) | exam-portal.integration.spec.ts |
| A result reaches a family only when published AND not withheld | ExamPortalReadsService.getExamResult | Both halves of the gate are load-bearing independently. | EXAM_NOT_FOUND | Same |
EXAM_ATTACHMENT is never a public upload type | PUBLIC_TYPES set in upload-type.ts (verified this session) | A public type is anonymously readable — a question paper would leak before the exam. | — (a code-level invariant, not a runtime error) | No dedicated spec found; verified by direct code inspection |
16.6 Tradeoffs, Alternatives, and ADR Notes
| Decision | Context | Chosen Option | Alternatives | Why Chosen | Tradeoffs | Revisit Trigger |
|---|---|---|---|---|---|---|
| Conflicts as database constraints | A room/class/invigilator double-booking must be provably impossible once live. | Three GiST EXCLUDE constraints, partial on published/completed, DEFERRABLE INITIALLY IMMEDIATE. | A service-level overlap check before every write. | A service check can race (two concurrent writes both pass the check, both then write); the database check cannot. | Every schedule write against a live exam pays a GiST probe, and the conflict-resolution utility exists solely to turn a raw 23P01 into a named, actionable error. | If exclusion performance ever becomes a bottleneck at very large per-day paper counts. |
Partial on IN ('published','completed'), not 'published' alone | exam_status cascades from exams.status; marking one exam complete rewrites every entry beneath it. | Both statuses in the predicate. | 'published' only. | A 'published'-only predicate would drop a just-completed exam out of all three exclusions the instant it completes, letting another exam publish into the same room/slot/invigilator silently. | None — this is strictly the correct predicate; the alternative was a caught defect. | — |
No exams.grading_scale_id = NULL on scale delete | A grading scale referenced by a published result must not vanish from that result's history. | RESTRICT. | SET NULL. | SET NULL would silently strip the grading basis from every published result the scale produced — a result card that once showed A+ would show nothing, with no record of why. | Deleting a scale in use is refused (GRADING_SCALE_IN_USE); an admin must deactivate instead. | — |
No max_marks column on exam_routine_entries | "Components sum to the paper's total" spans rows, cannot be a single-row CHECK, and a trigger reconciling it gives one column two writers. | No total column at all — SUM(components.max_marks) computed on read. | A stored total, kept in sync by a trigger. | Removes the invariant entirely rather than defending it awkwardly — there is no second copy to drift. | Every total is a join+sum on read rather than a column read; at this table's per-exam row count the cost is negligible. | — |
submitted_by_staff_id snapshot on exam_marks_submissions | class_subjects.teacher_id is a current-assignment pointer with no history. | A snapshot column, checked by an OR in the portal predicate. | Trust class_subjects.teacher_id alone for access. | Reassigning a subject must not retroactively strip the previous teacher's already-submitted work nor hand the new teacher every past exam's marks. | One more column, one more OR branch in every access check that touches marks. | — |
computeNow as the worker's entry point, compute as the request's | The processor's handler map needs a RESULT_COMPUTE handler; the size check belongs to the request path. | Two methods, the adapter calling only the inline one. | One method with an "isWorker" flag. | A flag is easy to pass wrong once and silent when wrong; two methods make the distinction a compile-time choice at the one call site (the adapter). | Two public methods on one service instead of one. | — |
| No cache anywhere in this module | Every read is either a small reference table or already gated by version/GiST exclusions. | No cache. | A Redis-backed cache for the routine/marks-sheet reads (the highest-traffic ones). | A stale cache is the one thing that could show a slot as free after it was just taken — worse than an extra query, for a module whose entire value proposition is "provably conflict-free". | Every request round-trips to Postgres; acceptable at school-scale traffic. | If a specific read is measured as a hot path under real load. |
exam_status copy guarded by a depth-1 trigger, not by revoking column-level UPDATE privilege | Postgres has no per-column trigger scope finer than "any UPDATE touching this row". | pg_trigger_depth() = 1 check inside a BEFORE UPDATE trigger. | REVOKE UPDATE (exam_status) ON exam_routine_entries FROM api_role. | The API connects as a single role; per-column privilege revocation would also block the legitimate cascade unless carefully scoped, and the trigger approach is what the rest of this schema already uses for similar guards. | A stray direct UPDATE ... SET exam_status = ... from a future migration or a raw SQL escape hatch would still be caught, but only at runtime, not at grant time. | — |
| The guardian portal resolves the ward BEFORE the exam | A foreign childId must never reach an exam-scope query. | resolveOwnChildId first, always. | Resolve the exam first, then check the ward relationship. | The wrong order lets a foreign child id fall through to the exam service's own 404 — which still refuses, but only after skipping the guardian's own authorization decision, which is the shape this repo has already recorded as something that must never happen. | None. | — |
16.7 Operational Runbook
| Operation | How to Inspect | Healthy State | Failure Signal | Recovery |
|---|---|---|---|---|
| Reminder sweep | SELECT count(*) FROM exam_reminder_dispatches WHERE dispatched_at > now() - interval '1 hour' | Non-zero whenever a published paper's window is active | Zero rows for an hour with published exams having upcoming papers; [exams] no handler/[failure] REMINDER_SWEEP in the log | Check ExamScheduler's cron is registered (ScheduleModule is skipped in API_RUNTIME_PROFILE=fast — confirm the workers module is actually composed); check BullMQ for a stuck/failed job with buildJobId's bucketed id |
| Lifecycle sweep | Log line [exams] lifecycle-sweep completed exam=<id>; SELECT count(*) FROM exams WHERE status='published' AND <every entry ended> | The count above stays at zero within an hour of the last paper ending | A published exam visibly past its period with no completion | Manually invoke POST /exams/:publicId/complete, or re-trigger the hourly cron job |
| Result computation queue | SELECT * FROM outbox_events WHERE event_type='exam.result_compute_requested' ORDER BY created_at DESC | Rows dispatched promptly | Rows accumulating with no corresponding [success] RESULT_COMPUTE log line | Check the dispatcher; check ExamResultsComputeService.computeNow for a thrown error against a specific exam (advisory lock contention resolves itself on retry) |
| GiST exclusion health | EXPLAIN a schedule write against a large routine; SELECT conname FROM pg_constraint WHERE conname LIKE 'exam_%_free' | All three constraints present, DEFERRABLE | A 23P01 that the conflict resolver could not name (falls through to the raw constraint-name error) | Check exam-conflict.util.ts's pass-1/pass-2 logic against the specific rows involved; this is the backstop path and its firing usually means a genuinely concurrent write raced the lookup |
| Marks workflow stuck | SELECT state, count(*) FROM exam_marks_submissions GROUP BY state for a session | Distribution moves toward finalized as exam period ends | A large submitted bucket with no ExamMarksVerify activity | Check NO_PERMISSION_ADMIN_HANDLERS/permission grants for ExamMarksVerify_UPDATE holders; check the exam.marks_submitted in-app notification actually reached them (role-scoped audience resolution) |
| Notification delivery | SELECT * FROM notification_event WHERE source_module='exams' ORDER BY created_at DESC | Rows with recipients resolved per the rule's notify_* flags | A published/cancelled exam with zero notification rows | Check exam_notification_rules.enabled for the event; check ExamNotificationScopeService's scope query returned any class/routine rows at all |
| Storage cleanup | Grep API logs for Failed to clean up orphaned exam attachment / Failed to delete stored file for attachment | No such lines | Recurring cleanup failures for the same key | Investigate the storage backend directly; the DB row is already consistent (insert failed, or delete succeeded) — only the object may be orphaned |
16.8 Backend Risk Register
| Risk | Area | Impact | Current Mitigation | Remaining Gap |
|---|---|---|---|---|
A stray raw-SQL migration writes exam_status directly | Schema integrity | Silently arms/disarms a live exclusion, freeing a room or invigilator with nothing failing | exam_status_copy_is_cascade_only trigger refuses at pg_trigger_depth()=1 | None identified — the trigger is unconditional and covers every write path, including a future service bug |
| A publish's three pre-checks (class scope, components, floor band) are not re-verified inside the transaction | Concurrency | A concurrent class-scope edit landing between the check and the write could theoretically slip through | The transaction's own row-level locking on the rows it actually touches | A concurrent PUT .../classes removing the last class between step 3 and the commit is not explicitly re-checked; the constraint probe does not cover this window |
ExamMarks/ExamRoutines_READ granted to a teacher role by a future change | Authorization | Would permanently (unrevocably) hand every teacher every child's marks/routine, school-wide | Comments in the permission catalogue and in TeacherExamMarksPortalService's own docblock name this explicitly | No automated guard (e.g., a permissions:sync-time assertion) prevents the grant if someone edits seed-auth.ts directly |
The docblock in guardian-exams-portal.module.ts still describes GuardianPortalModule as not exporting GuardianPortalService | Documentation drift | A reader trusting the comment would believe the module does not compile | Verified this session: GuardianPortalModule DOES export GuardianPortalService — the comment is stale, describing a state that has since been fixed | Not fixed in this pass (out of this documentation task's ownership); flagged in the report below |
No automated test asserts EXAM_ATTACHMENT stays out of PUBLIC_TYPES | Security | A future edit to upload-type.ts could silently make exam attachments public | Code comment + this documentation page | No CI assertion found specific to this invariant (the general upload-type test suite was not part of this module's ownership to inspect exhaustively) |
| Reminder sweep dispatch rows are never pruned | Storage growth | exam_reminder_dispatches grows without bound, one row per (paper, rule) forever | None — an accepted tradeoff for the correctness the schedule-keyed design buys | No archival/retention policy found for this table |
17. Zero-Omission Backend Checklist
- Every file in the module directory is represented or explicitly marked non-runtime (DTO files summarized by directory, not individually — their shapes belong to the API doc).
- Every controller, service, provider, processor, scheduler, helper, mapper, DTO, enum, and schema is documented.
- Every method with business behavior has a code-flow narrative (§16.3 covers the two highest-complexity methods in full; §6's tables cover every other method's reads/writes/side-effects/errors).
- Every table/collection/cache object/job payload has field-level detail (§5.2, §9).
- Every index, constraint, relation, and delete behavior has rationale (§5.2, §16.4).
- Every lifecycle/status transition has a state diagram and transition table (§16.2, §6.2).
- Every read/write/action/job flow has sequence and activity diagrams (§7, §9).
- Every business invariant is cataloged (§16.5).
- Every cache key, invalidation path, queue job, realtime event, and external call is documented (§8 states the absence of a cache; §9, §10).
- Every architectural tradeoff is documented with alternatives and revisit triggers (§16.6).
- Every operational failure mode has a runbook entry (§16.7).
18. Backend Completion Checklist
- Module boundaries are documented (§2).
- Every controller, service, DTO, schema file, job, cache key, and event is covered (§3, §4, §5, §9, §10; no cache exists, stated in §8).
- Every database table/collection has a field table and relationship diagram (§5.2, §5.3).
- Every runtime flow has a diagram and branch notes (§7).
- API, feature/flows, and TDD docs are linked (below).
- No claim is made without a source file or documented source reference — every table, constraint, permission code and error code in this page was read directly from the current tree this session.
See Also
- API doc:
/docs/developer/exams/api - Features and flows doc:
/docs/developer/exams/feature
Exams Features and Flows
Complete feature list, actor journeys, state flows, business rules, edge cases, and diagrams for the examination and result module.
Exams API Reference
Complete API contracts for Examinations and Results, including routes, auth, DTOs, responses, errors, examples, and integration notes.