Authoring & Access
Subject-scoped exam creation, the draft-publish-close lifecycle, per-stream assignment rules, and the relationship-based CbtAccessService.
Everything a teacher does to an exam before students see it: create it under a subject, build questions, publish, and assign it to streams. Each step has guard rails worth understanding rather than memorizing — most of them exist because an exam is a shared artifact (several teachers, several streams) sitting inside a locked-down year.
Creating an exam
CbtExaminationService.create (cbt-examination.service.ts) is subject-scoped: the request names a subjectId, and everything else derives from it —
campusIdcomes from the subject, never from the request body. A client can't file an exam under a campus the subject doesn't belong to, because it never gets to say.- The target
campusAcademicYearIdmust be unlocked (assertNotLocked) and thetermIdmust belong to that year (assertTermMatchesYear) — the standard request-scoping gates. - A unique
referenceslug is minted, and the exam is always bornstatus: draft.
Streams are not part of creation — assignment is a separate step (below), which is what makes one exam shareable across streams ("shared-exams v2").
The lifecycle
- Draft-only editing.
update, question create/update/delete/reorder — all callassertDraft. Once published, the paper is frozen; students may already be sitting it. - Every write also checks the year (
assertExamYearIsCurrent): update, publish, close, delete, and question edits all reject when the exam's year is no longer current/unlocked. A teacher can't quietly rewrite last year's exam. - Publish validates the whole paper: at least one question, every question
marks > 0, and — forper_questiontiming — atimeLimitSecondson every question. Passing stampstotalMarks(Σ marks) onto the exam. - Delete is a soft-delete, blocked while any attempt is
in_progressorsubmitted. It callsreleaseExamLinks(nullingsourceCbtExaminationIdon linked gradebook assessments) before the soft-delete — that ordering fixes an orphaned-link bug; keep it if you touch this code.
Questions
Five types, validated in CbtQuestionService.validateQuestionPayload; per-type grading data is normalized into the config jsonb by buildConfig:
| Type | Payload rules |
|---|---|
mcq_single | ≥ 2 options, exactly 1 correct |
mcq_multi | ≥ 2 options, ≥ 1 correct |
short_text | ≥ 1 accepted answer; match mode exact / case_insensitive / trimmed |
long_text, math | Free response — manually graded (math's canonical-expression grading is "manual until then") |
totalMarks is recomputed on every question change (recomputeTotalMarks) — never write it.
Stream assignment
CbtExaminationStreamService.assign is where an exam meets its audience, with four gates in order:
- Exam not closed, year current.
- Caller may assign (
assertCanAssignStream— owner, teaches the subject for that stream, or campusMANAGE). (examinationId, streamId)unique — assigning twice is a409.- Window validity (
assertWindowValid):availableTo > availableFrom, and forwhole_examtiming the exam'sdurationMinutesmust actually fit inside the window — a 90-minute exam can't be offered in a 60-minute slot.
maxAttempts defaults to 1 and lives on the assignment, so Stream A can get two tries while Stream B gets one. Unassigning is blocked once any actively-enrolled student in the stream has a non-voided attempt — you can't pull an exam out from under students who already sat it.
Who may do what — CbtAccessService
There are no @RequirePermissions decorators on CBT routes. Authorization is relationship-based in code (cbt-access.service.ts, mirroring ClassroomAccessService): a teaching assignment is authorization, and the campus-scoped CBT_EXAM permission is the admin fallback — not the primary path.
| Action | Allowed for |
|---|---|
| Create for a subject | Teaches that subject (any subject_x_teacher row), or campus CREATE |
| Manage an exam | Teaching relationship (stream/class/subject), or campus MANAGE |
| Read an exam | Owner ∪ teaches the subject ∪ class-teacher of an assigned stream ∪ campus READ |
| Assign a stream | Owner, or teaches the subject for that stream, or campus MANAGE |
The class-teacher branch is the interesting one: a class teacher who doesn't teach the subject still sees exams assigned to their stream — oversight without authorship. The staff list endpoint composes the same three OR-branches (owner ∪ taught subjects ∪ class-teacher streams), and its streamId filter is join-backed: it pre-resolves exam ids from cbt_examination_x_stream first, short-circuiting to an empty page when there are none.
Controllers
| Controller | Base route | Guard |
|---|---|---|
CbtExaminationController | cbt/examinations | @RequireUserType(STAFF) |
CbtExaminationStreamController | cbt/examinations/:examinationId/streams | STAFF |
CbtQuestionController | cbt/examinations/:examinationId/questions | STAFF |
CbtStudentController | cbt/student | @RequireUserType(STUDENT) |
Audit snapshots exclude answer keys
Every CBT write is audited, but the snapshot functions deliberately omit the sensitive halves: exam snapshots exclude questions, question snapshots exclude prompt/options/config, attempt snapshots exclude answers and scores. An audit reader can see that a question changed, never what the answer is. Keep that property when adding audited CBT endpoints.
Where to go next
CBT Exams
The computer-based-testing domain — exam, questions, per-stream assignments, and attempts, with the exam window living on the stream join.
Student Runtime & Grading
The attempt lifecycle — start/resume, lazy expiry, all-or-nothing auto-grading, teacher finalise, and the write-through to the gradebook.