Naalya Handbook
CBT Exams

CBT Exams

The computer-based-testing domain — exam, questions, per-stream assignments, and attempts, with the exam window living on the stream join.

CBT (computer-based testing) lets staff author an exam under a subject, assign it to streams (each with its own availability window and attempt cap), and have students sit it online with auto-grading for objective questions and a write-through into the gradebook. It's the largest new domain in the API: four controllers and seven services under apps/server/src/app/cbt/, wired in cbt.module.ts, which imports AcademicYearModule (for the year gates) and GradingModule (for the gradebook sync).

The entities, and why they're shaped this way

cbt_examination is the root. It belongs to a subject (subjectId + ownerStaffId) and is year-scoped (campusAcademicYearId + termId, with campusId denormalised for filters). Notable columns: a unique reference slug (minted as SlugPrefix.CBT_EXAM), a status lifecycle, a timingMode, a derived totalMarks (always Σ of question marks — recomputed on every question change, stamped at publish, never set directly), an optional passMark, and a monitoring jsonb (recordVideo, recordAudio, trackTabSwitch, trackCopyPaste, trackFocus, requireFullscreen, faceCheck — all default true except faceCheck).

cbt_examination_x_stream is the assignment join, and it carries the scheduling: availableFrom / availableTo and maxAttempts (default 1), unique per (examinationId, streamId). This is the single most important modeling decision in the domain:

The window is per stream

An exam has no dates of its own. The same exam can open Monday morning for Stream A and Wednesday for Stream B, with different attempt caps. The student runtime reads the assignment's window and cap — never the exam — so anything you build that reasons about "when is this exam open" must join through cbt_examination_x_stream.

cbt_examination_question rows hang directly off the exam — there are no sections and no shared question bank; questions are exam-owned, ordered by position, each with type, prompt, marks, optional timeLimitSeconds, and a per-type config jsonb (accepted answers for short text, match mode, etc.). cbt_examination_question_option holds MCQ options, where isCorrect is literally the answer key.

cbt_attempt is one sitting: (examinationId, studentId, attemptNumber) unique, with status, startedAt/expiresAt, the provisional autoScore/maxAutoScore, and the teacher-owned finalScore/gradedAt/gradedById. cbt_attempt_answer upserts one row per question on the unique key (attemptId, questionId, schoolId) — the tenant column is deliberately inside the key so tenant-scoped upserts can match their ON CONFLICT target (a comment in the entity explains this; copy the pattern for any upserted child table).

The enums

EnumValuesNotes
Exam statusdraftpublishedclosedOnly drafts are editable; see the lifecycle
Timing modewhole_exam / per_questionwhole_exam needs durationMinutes; per_question needs timeLimitSeconds on every question
Question typesmcq_single, mcq_multi, short_text, long_text, mathFirst three auto-grade; the last two are always manual
Attempt statusin_progress, submitted, expired, graded, voidedgraded is only ever set by a teacher

Everything above lives in dto/cbt.types.ts; answer payloads are { selectedOptionIds?, text?, expression? }.

Scope in one sentence each

  • Tenant: all CBT entities are @TenantScoped() — the usual automatic school_id wall.
  • Year: the exam carries campusAcademicYearId; attempts, answers, questions, and assignments scope through it — none carry their own year column.
  • Access: no @RequirePermissions on CBT routes — authorization is relationship-based in CbtAccessService ("teaching is authorization"), with campus-scoped CBT_EXAM permission as the admin fallback.

Where to go next

On this page