Naalya Handbook
Grading

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:

  1. 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.
  2. Field sanity — maxScore > 0, weight in (0, 100] (DTO validates too; the service is the backstop for internal callers).
  3. Year gates — the subject resolves the campus, then assertNotLocked(cay, campusId) and assertTermMatchesYear — the standard request-scoping pair.
  4. Cross-campus integrity — the term's campus must equal the subject's campus.
  5. Group validity — the groupId must 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.
  6. 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:

GuardCloses
Every assessmentId belongs to the declared subject/term/streamSmuggling a score into another gradebook via a stray id
0 ≤ score ≤ maxScoreTypos beyond the scale
CBT-linked assessments reject manual writesHand-editing a column that CBT sync owns — sync is that column's only writer
The assessment's year must be unlockedEditing a sealed year through the score API
Per-enrollment auth re-resolves the student's real streamTrusting 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

CheckAllowed forUsed by
assertCanManageAssessmentsInStreamSubject-teacher in that streamAssessment create/update/delete
assertCanGradeStreamSubject-teacher or class-teacher of the streamMatrix, batch upsert
assertCanEnterScoreSame, against the enrollment's actual streamEach 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.

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

On this page