Assessments & Scores
Per-stream assessments with a group weight budget, the lazy score-entry matrix, and the guarded upsert that feeds the compute engine.
An assessment is one gradable item — a test, an exercise, an exam column — inside a config group. The defining fact: assessments are per stream. The entity keys on (subjectId, termId, streamId, groupId) plus maxScore, weight, position, and an optional sourceCbtExaminationId; a partial-unique index allows at most one live assessment per (sourceCbtExaminationId, streamId), which is what makes the CBT link one-to-one per stream.
Its child, assessment_score, holds one number per student: unique on (assessmentId, enrollmentId, schoolId) — the tenant column sits inside the key so tenant-scoped upserts can match their ON CONFLICT target, the same pattern as CBT's answer table.
Creating an assessment — the guard sequence
AssessmentService.create runs six checks, in order, each with a reason:
- Relationship auth —
assertCanManageAssessmentsInStream: the caller must be a subject-teacher of that subject in that stream. Deliberately narrower than score entry (no class-teacher branch): class teachers oversee results, they don't restructure the gradebook. - Field sanity —
maxScore > 0,weightin(0, 100](DTO validates too; the service is the backstop for internal callers). - Year gates — the subject resolves the campus, then
assertNotLocked(cay, campusId)andassertTermMatchesYear— the standard request-scoping pair. - Cross-campus integrity — the term's campus must equal the subject's campus.
- Group validity — the
groupIdmust belong to the grading config for (the term's year, the subject's level). You can't hang an assessment on another level's config. - Weight budget — sibling weights in
(subject, term, group, stream)plus the incoming one may not exceed 100. Only over-allocation is rejected: strict-sum-to-100 can't be enforced while assessments are added one at a time, and the engine normalises by the actual sum anyway.
Then it persists — and either links CBT (if sourceCbtExaminationId came along) or triggers recomputeSubjectTerm.
Score entry
Two endpoints carry the whole teacher workflow:
GET /grading/scores/matrix builds the entry grid — columns are the stream's assessments by position, rows are active enrollments. It's lazy: no score rows are pre-created; a cell without a row is simply empty. Auth is assertCanGradeStream (subject-teacher or class-teacher of the stream — wider than assessment management, on purpose).
PUT /grading/scores upserts a batch, with five guards worth knowing because each closes a real hole:
| Guard | Closes |
|---|---|
Every assessmentId belongs to the declared subject/term/stream | Smuggling a score into another gradebook via a stray id |
0 ≤ score ≤ maxScore | Typos beyond the scale |
| CBT-linked assessments reject manual writes | Hand-editing a column that CBT sync owns — sync is that column's only writer |
| The assessment's year must be unlocked | Editing a sealed year through the score API |
| Per-enrollment auth re-resolves the student's real stream | Trusting the streamId param — it's display input, never authorization input |
After the upsert, each distinct enrollment gets recomputeEnrollmentSubjectTerm — the grade row updates in the same request, so the teacher sees the new grade immediately.
Who may do what
| Check | Allowed for | Used by |
|---|---|---|
assertCanManageAssessmentsInStream | Subject-teacher in that stream | Assessment create/update/delete |
assertCanGradeStream | Subject-teacher or class-teacher of the stream | Matrix, batch upsert |
assertCanEnterScore | Same, against the enrollment's actual stream | Each row of the upsert |
Teaching joins key on StaffProfile id
subject_x_teacher / class_x_teacher reference the StaffProfile id, not the JWT user id — GradingAccessService resolves the staff profile first (resolveStaffId) before any check. Query these joins with the raw user id and every teacher looks unauthorized; it's the module's most common integration mistake.
The CBT link
POST /grading/assessments/:id/link-cbt-exam ties an assessment to an exam (per stream, via the partial-unique index). From that moment the column is machine-fed: manual score writes are rejected, and AssessmentGradeSyncService.syncFromCbtAttempt — latest graded attempt only, scaled finalScore / totalMarks × maxScore, half-up — is the sole writer. Sync failures are logged, never thrown; unlinking (or deleting the exam) keeps every score already written. This is deliberately the single port between CBT and Grading — resist adding a second.
Where to go next
Grading Engine
The competency grading system — a pure compute engine over per-level config, with banded results and write-through to the gradebook.
Lesson Planning
Schemes of work and weekly lesson plans — Tiptap documents scoped from the parent scheme, draft/publish only, teaching-relationship access.