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_exam → durationMinutes × 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:
| Type | Auto-graded? | Correct when |
|---|---|---|
mcq_single / mcq_multi | Yes | Selected ids exactly match the correct set (multi = set equality, not overlap) |
short_text | Yes | Normalized match per matchMode (exact / case_insensitive / trimmed) |
long_text / math | No — 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 .../grades— saveGrades, a draft: record manual marks forlong_text/mathanswers without changing status. Each mark is validated0 ≤ awardedMarks ≤ question.marks.POST .../finalise— the only path tograded. SetsfinalScore= Σ of allawardedMarks(auto + manual), stampsgradedAt/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:
- Only the latest
attemptNumbergraded attempt syncs — an earlier attempt finalised later can't overwrite a newer one. - 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. - The score scales:
finalScore / exam.totalMarks × assessment.maxScore, rounded half-up, then upsertsassessment_scoreon(assessmentId, enrollmentId)withenteredBy = 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
Authoring & Access
Subject-scoped exam creation, the draft-publish-close lifecycle, per-stream assignment rules, and the relationship-based CbtAccessService.
Grading Engine
The competency grading system — a pure compute engine over per-level config, with banded results and write-through to the gradebook.