Naalya Handbook
CBT Exams

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 —

  • campusId comes 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 campusAcademicYearId must be unlocked (assertNotLocked) and the termId must belong to that year (assertTermMatchesYear) — the standard request-scoping gates.
  • A unique reference slug is minted, and the exam is always born status: 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 call assertDraft. 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 — for per_question timing — a timeLimitSeconds on every question. Passing stamps totalMarks (Σ marks) onto the exam.
  • Delete is a soft-delete, blocked while any attempt is in_progress or submitted. It calls releaseExamLinks (nulling sourceCbtExaminationId on 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:

TypePayload 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, mathFree 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:

  1. Exam not closed, year current.
  2. Caller may assign (assertCanAssignStream — owner, teaches the subject for that stream, or campus MANAGE).
  3. (examinationId, streamId) unique — assigning twice is a 409.
  4. Window validity (assertWindowValid): availableTo > availableFrom, and for whole_exam timing the exam's durationMinutes must 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.

ActionAllowed for
Create for a subjectTeaches that subject (any subject_x_teacher row), or campus CREATE
Manage an examTeaching relationship (stream/class/subject), or campus MANAGE
Read an examOwner ∪ teaches the subject ∪ class-teacher of an assigned stream ∪ campus READ
Assign a streamOwner, 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

ControllerBase routeGuard
CbtExaminationControllercbt/examinations@RequireUserType(STAFF)
CbtExaminationStreamControllercbt/examinations/:examinationId/streamsSTAFF
CbtQuestionControllercbt/examinations/:examinationId/questionsSTAFF
CbtStudentControllercbt/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

On this page