Naalya Handbook
CBT Exams

Student Runtime & Grading

The attempt lifecycle — start/resume, lazy expiry, all-or-nothing auto-grading, teacher finalise, and the write-through to the gradebook.

The student half lives at cbt/student/* (guarded @RequireUserType(STUDENT)), driven by CbtAttemptService with CbtStudentAccessService deciding eligibility. Eligibility is precise: an active enrollment in the exam's academic year whose stream takes the subject and has a cbt_examination_x_stream assignment for this exam — anything less is a 403 not assigned to your stream.

Attempt lifecycle

Start or resume (startOrResume) — one endpoint does both. The exam must be published; the window check reads the assignment's availableFrom/To, not the exam (the exam has no dates — see the overview). An existing in_progress attempt that hasn't passed expiresAt is resumed as-is; otherwise a new attempt is minted, provided the cap allows it. The cap counts all non-voided attempts, which produces the least obvious rule in the module:

An expired-but-unfinalized attempt still consumes a slot

Expiry is lazy — no scheduler sweeps attempts. An in_progress attempt past its expiresAt only transitions to expired when the next saveAnswer or getAttempt touches it. Until then it sits in in_progress, and either way it counts against maxAttempts. Walking away from an attempt is not a free retry.

The time budget (budgetMs): whole_examdurationMinutes × 60000; per_question → Σ of every question's timeLimitSeconds. Neither configured → starting throws ("no configured time budget"). expiresAt = startedAt + budget, fixed at start — saving answers doesn't extend it.

Saving answers (PUT attempts/:attemptId/answers/:questionId): owner-checked, question must belong to the exam, then upserted on (attemptId, questionId) — a re-answer overwrites, which is what lets a student change their mind. If the attempt is past expiry, the save is rejected after lazily finalising the attempt as expired.

What the student sees: getAttempt returns the attempt, saved answers, and questions serialized student-safe (toStudentQuestion) — isCorrect flags and accepted answers stripped. MCQ options are shuffled with a deterministic seeded Fisher-Yates keyed on attemptId:questionId, so the order is random per attempt but stable across resume — a student who refreshes doesn't get re-shuffled options mid-exam.

Submit is idempotent: any terminal status just returns as-is, so a double-click or a retry after a network blip can't double-finalise.

Auto-grading

Runs inside finalize (at submit or lazy expiry), all-or-nothing — full marks or zero, no partial credit:

TypeAuto-graded?Correct when
mcq_single / mcq_multiYesSelected ids exactly match the correct set (multi = set equality, not overlap)
short_textYesNormalized match per matchMode (exact / case_insensitive / trimmed)
long_text / mathNo — left ungraded (awardedMarks: null)Teacher marks manually

finalize upserts every objective answer with awardedMarks / isCorrect / autoGraded: true, sets the provisional autoScore (against maxAutoScore = Σ marks of objective questions), and parks the attempt in submitted or expired. It never sets graded.

Teacher grading

Staff review at GET cbt/examinations/:examId/attempts/:attemptId — the staff view includes what the student view strips (answers with awardedMarks, isCorrect). Two write endpoints:

  • PUT .../gradessaveGrades, a draft: record manual marks for long_text/math answers without changing status. Each mark is validated 0 ≤ awardedMarks ≤ question.marks.
  • POST .../finalise — the only path to graded. Sets finalScore = Σ of all awardedMarks (auto + manual), stamps gradedAt/gradedById. Re-callable — a teacher can correct a mark and finalise again.

Gradebook write-through

Finalise triggers AssessmentGradeSyncService.syncFromCbtAttempt (grading/assessment-grade-sync.service.ts) — the single port between CBT and Grading:

  1. Only the latest attemptNumber graded attempt syncs — an earlier attempt finalised later can't overwrite a newer one.
  2. It resolves the student's enrolled stream first, because assessments are per-stream, then finds the assessment linked via (sourceCbtExaminationId, streamId). No linked assessment → no sync, silently.
  3. The score scales: finalScore / exam.totalMarks × assessment.maxScore, rounded half-up, then upserts assessment_score on (assessmentId, enrollmentId) with enteredBy = gradedById, and recomputes the subject-term grade.

Sync failures are logged, never thrown — a broken link can't fail the grading call a teacher just made. Deleting an exam nulls sourceCbtExaminationId on its assessments (releaseExamLinks) but keeps every score already written.

Where to go next

On this page