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 authassertCanManageAssessmentsInStream: 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 sanitymaxScore > 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 idGradingAccessService 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