Grades (criteria-based marks, v1) Implementation Plan¶
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [x]) syntax for tracking.
Goal: Ship a grades module where an assigned teacher records criteria-derived marks (one at a time), admin overrides any, and referents/students read their own — with the subject mark computed from per-criterion marks (overridable), value-history in the audit log, and SG-cards → gradebook / homeroom / student reads.
Architecture: Mirrors the attendance module spine — a custom GradesService (no BaseTenantedCrudService), soft-FK + snapshot columns, AuditService as the value-history trail (grades = 2nd consumer), and a read-visibility builder separate from in-service write authority. The grade value rides on the existing evaluation-scales primitives (proposeFinalFromCriteria, retrofitVote) and the (subject, grade) scale cascade (resolveEffectiveCellScale). Anchor = SubjectGroup.
Tech Stack: NestJS 11, Prisma (PostgreSQL), Jest (unit) + Supertest (e2e), class-validator DTOs, Swagger via <domain>.swagger.ts.
Canonical spec: docs/superpowers/specs/2026-06-26-grades-criteria-based-v1-design.md (Approved 2026-06-26).
✅ STATUS (2026-06-30): ALL 14 TASKS IMPLEMENTED + VERIFIED in tree. Migration
20260629151340_add_gradesapplied + seeded; fullsrc/grades/module + app wiring; targeted suites green (e2e 12/12, unit 866/866); chapter 21 docs written.report-finalsdeferred. Boxes below are checked to reflect this; the per-task commit steps are folded into the single 2026-06-30 consolidation commit (which the user runs).
Global Constraints¶
- Verification & git are user-owned (per
CLAUDE.md): do NOT runnpm run build/npm test/npm run lintor create commits unless the user asks. Each task documents the exactnpx jest <file>andgitcommands; treat the "run test" and "commit" steps as the intended verification + commit boundary — surface the command + expected result, let the user run the full suite and commit. Targetednpx jest <single-spec>is acceptable as the TDD inner loop where the user has opted into execution. - Migrations: read
docs/12-migrations.mdbeforenpx prisma migrate dev; check for an uncommitted migration and fold; audit generated SQL against the hazard checklist. This plan's migration is purely additive (2 new tables + 1 enum, no backfill). - RBAC invariants (
docs/04-rbac.md):readis never an action; new scope needs arbac-catalogue.tsentry and ascope-fields.tsruntime constant; both must agree. - Naming: the Prisma model is
GradeEntry(the academic-year-level model is already namedGrade— do not collide). Routes/docs say "grades". Entity key isGRADE_ENTRIES = 'grade_entries'(corrected during execution —'grades'is already taken by the academic grade-LEVEL entity, which is also labelled'Grades'; the new entity is labelled 'Student Grades'). All@RequireScope/@RequireScopes/@RequireActionuseEntityKey.GRADE_ENTRIES; scoperecord, actionrecord; the action-requirement key isgrade_entries.record. - Types (
docs/13-typing-conventions.md): noany; build Prismadatawith explicit fields /pickDefined; response DTO fields reference real DTO classes (nounknown[]/type:'object'). - Swagger discipline: controller-method JSDoc + DTO JSDoc are FE-facing contract copy — no backend internals (Prisma, tx, guard semantics).
- Effective mark is computed, not stored — except the optional
finalOverridepin. Reads recompute via the stored criterion marks + the snapshotted scale ids.
File Structure¶
prisma/schema.prisma # + enum GradeType, models GradeEntry, GradeCriterionMark
prisma/migrations/<ts>_add_grades/ # additive migration (2 tables + enum)
src/common/constants/entity-keys.ts # + GRADES: 'grades'
src/common/constants/scope-fields.ts # + GRADES_SCOPES + SCOPE_KEYS_BY_ENTITY row
src/common/constants/error-codes.ts # + 6 ErrorCode + params shapes
src/common/constants/error-examples.ts # + 6 examples
prisma/seed/rbac-catalogue.ts # + entity/scope/action/requirement/field-mapping
prisma/seed/roles.ts # + grants (admin/teacher record; referent/student read)
src/grades/
├── grades.module.ts # PrismaModule + AuditLogModule; controller + service
├── grades.controller.ts # @Controller('grades'), 9 routes
├── grades.service.ts # custom service: record / update / remove / reads / history
├── grades.queries.ts # include/select consts + loaders
├── grades.compute.ts # pure: computeEffectiveMark, expandDirectValue
├── grades.scale-context.ts # loadScaleContext + pure validators/pickers
├── grades.visibility.ts # pure gradeVisibilityWhere + async builder
├── grades.swagger.ts # Api* decorators
├── dto/
│ ├── record-grade.dto.ts # write (POST)
│ ├── update-grade.dto.ts # write (PATCH)
│ ├── grade-entry-response.dto.ts # GradeEntryResponseDto + GradeCriterionMarkDto
│ ├── subject-group-cards.dto.ts # query + SubjectGroupCardDto + response
│ ├── subject-group-gradebook.dto.ts # gradebook view
│ ├── homeroom-grades.dto.ts # homeroom overview
│ ├── student-grades.dto.ts # student detail / me (grouped by SG)
│ ├── grades-range-query.dto.ts # optional from/to read filter
│ └── grade-history.dto.ts # { current, history[] }
│ └── index.ts
└── index.ts # public barrel (GradesModule, GradesService)
src/app.module.ts # register GradesModule
test/grades.e2e-spec.ts # e2e
docs/21-grades.md # new chapter
docs/REFERENCE.md, CLAUDE.md # module map + file index rows
Task 1: Schema + migration¶
Files:
- Modify: prisma/schema.prisma (add enum + 2 models + back-relations on Tenant, AcademicYear, Student)
- Create: prisma/migrations/<timestamp>_add_grades/migration.sql
Interfaces:
- Produces: Prisma models GradeEntry, GradeCriterionMark; enum GradeType { WRITTEN, ORAL, PRACTICAL }.
- Step 1: Add the enum + models to
prisma/schema.prisma
Place the enum near the other domain enums and the models after SubjectGroupAssignment:
enum GradeType {
WRITTEN
ORAL
PRACTICAL
@@map("grade_type")
}
/// One mark event for one student in one subject-group. The subject mark is
/// criteria-derived: effective = `finalOverride ?? avg(criterionMarks mapped to
/// the subject scale)` — computed at read, never stored (the override is the one
/// pinned value). Soft references (no FK) on the teaching context survive
/// SG/curriculum/homeroom reorg, exactly like AttendanceRecord. Hard FKs only on
/// the three identities a mark can never lose: tenant, academicYear, student.
model GradeEntry {
id String @id @default(uuid()) @db.Uuid
tenantId String @map("tenant_id") @db.Uuid
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
academicYearId String @map("academic_year_id") @db.Uuid
academicYear AcademicYear @relation(fields: [academicYearId], references: [id], onDelete: Cascade)
studentId String @map("student_id") @db.Uuid
student Student @relation(fields: [studentId], references: [id], onDelete: Restrict)
// ── Soft references (plain columns, NO FK) ──
subjectGroupId String @map("subject_group_id") @db.Uuid
curriculumSubjectId String @map("curriculum_subject_id") @db.Uuid
homeroomId String? @map("homeroom_id") @db.Uuid
gradeId String @map("grade_id") @db.Uuid
departmentId String @map("department_id") @db.Uuid
// ── Snapshot columns (written once, never re-synced) ──
studentFirstName String @map("student_first_name") @db.VarChar(150)
studentLastName String @map("student_last_name") @db.VarChar(150)
studentIdentificationCode String? @map("student_identification_code") @db.VarChar(150)
subjectName String @map("subject_name") @db.VarChar(150)
subjectCode String? @map("subject_code")
subjectGroupName String @map("subject_group_name") @db.VarChar(150)
gradeName String @map("grade_name") @db.VarChar(150)
homeroomName String? @map("homeroom_name") @db.VarChar(150)
subjectScaleId String @map("subject_scale_id") @db.Uuid
criteriaScaleId String @map("criteria_scale_id") @db.Uuid
// ── Value ──
type GradeType
assignmentDate DateTime @map("assignment_date") @db.Date
note String? @db.VarChar(2000)
/// A value string on the subject scale; null ⇒ effective = average of criteria.
finalOverride String? @map("final_override") @db.VarChar(50)
// ── Provenance ──
recordedByUserId String @map("recorded_by_user_id") @db.Uuid
recordedByName String @map("recorded_by_name") @db.VarChar(300)
recordedAt DateTime @default(now()) @map("recorded_at")
lastModifiedByUserId String? @map("last_modified_by_user_id") @db.Uuid
lastModifiedByName String? @map("last_modified_by_name") @db.VarChar(300)
lastModifiedAt DateTime @updatedAt @map("last_modified_at")
criterionMarks GradeCriterionMark[]
@@index([tenantId, academicYearId])
@@index([subjectGroupId])
@@index([studentId])
@@index([homeroomId])
@@index([curriculumSubjectId])
@@map("grade_entries")
}
/// One per *set* criterion of a GradeEntry. `criterionId` is a soft column (no
/// FK) so it survives a criterion edit/delete; the name is snapshotted. `value`
/// is a value string on the criteria scale.
model GradeCriterionMark {
id String @id @default(uuid()) @db.Uuid
gradeEntryId String @map("grade_entry_id") @db.Uuid
gradeEntry GradeEntry @relation(fields: [gradeEntryId], references: [id], onDelete: Cascade)
criterionId String @map("criterion_id") @db.Uuid
criterionName String @map("criterion_name") @db.VarChar(150)
value String @db.VarChar(50)
createdAt DateTime @default(now()) @map("created_at")
@@unique([gradeEntryId, criterionId])
@@index([gradeEntryId])
@@map("grade_criterion_marks")
}
Add back-relations: on Tenant → gradeEntries GradeEntry[]; on AcademicYear → gradeEntries GradeEntry[]; on Student → gradeEntries GradeEntry[].
- Step 2: Generate the migration
Run: npx prisma migrate dev --name add_grades
Expected: a new prisma/migrations/<ts>_add_grades/migration.sql creating grade_type enum + grade_entries + grade_criterion_marks, and npx prisma generate runs.
If Docker/DB is down, hand-author migration.sql (additive — CREATE TYPE grade_type AS ENUM (...), two CREATE TABLEs with the FKs above, the indexes, and the @@unique).
- Step 3: Audit the generated SQL (chapter 12 checklist)
Confirm: only CREATE TYPE / CREATE TABLE / CREATE INDEX; FKs are ON DELETE CASCADE (tenant/AY/gradeEntry children) and ON DELETE RESTRICT (student); no DROP, no ALTER ... NOT NULL on existing tables, no data backfill. No partial-unique needed (grades are a stream).
- Step 4: Commit
git add prisma/schema.prisma prisma/migrations
git commit -m "feat(grades): add GradeEntry + GradeCriterionMark schema and migration"
Task 2: RBAC plumbing (entity key, scope, catalogue, roles)¶
Files:
- Modify: src/common/constants/entity-keys.ts
- Modify: src/common/constants/scope-fields.ts (GRADE_ENTRY_SCOPES + ENTITY_SCOPE_REGISTRY row)
- Modify: prisma/seed/rbac-catalogue.ts
- Modify: prisma/seed/roles.ts
- Modify (drift-guard wiring — discovered during execution; a new entity trips these):
- src/common/constants/entity-groups.ts — add EntityKey.GRADE_ENTRIES to a group (CLASS_COMPOSITION), or entity-groups.spec.ts fails ("covers every EntityKey exactly once").
- src/permissions/rbac-catalogue.drift.spec.ts — this spec keeps its OWN mirror RUNTIME_SCOPE_FIELDS + knownRuntimeArrays; add grade_entries: GRADE_ENTRY_SCOPES to both, or 3 drift assertions fail.
- docs/REFERENCE.md §4 — add a | `grades/` | … | row as soon as src/grades/ exists, or docs-module-map.drift.spec.ts fails ("no undocumented modules"). (Pulled forward from Task 14.)
Interfaces:
- Produces: EntityKey.GRADE_ENTRIES = 'grades'; scope grades.record; action grades.record; GRADES_SCOPES.record field list; role grants.
- Step 1: Add the entity key
In src/common/constants/entity-keys.ts, after ATTENDANCE: 'attendance', (NOT GRADES — that key already exists for the academic grade-level entity):
- Step 2: Add the runtime scope-field constant
In src/common/constants/scope-fields.ts, after ATTENDANCE_SCOPES:
// Grades = field-level scope: the mark value fields + the read-exposed
// snapshot context. directValue is a transient write-only shortcut (not a
// stored column) so it is NOT listed here; the write route uses the singular
// @RequireScope flat-DTO form (FieldWriteGuard bypassed).
export const GRADES_SCOPES = {
record: [
'type',
'assignmentDate',
'note',
'finalOverride',
'criterionMarks',
// read-exposed snapshot context
'studentId',
'studentFirstName',
'studentLastName',
'studentIdentificationCode',
'subjectGroupId',
'subjectGroupName',
'subjectName',
'subjectCode',
'gradeName',
'homeroomName',
'recordedByName',
'recordedAt',
'lastModifiedByName',
'lastModifiedAt',
],
} as const;
And add the SCOPE_KEYS_BY_ENTITY row (next to attendance: new Set(...)):
- Step 3: Add catalogue entries in
prisma/seed/rbac-catalogue.ts
Import the constant alongside the others (ATTENDANCE_SCOPES, GRADES_SCOPES). Then add:
- ENTITIES array (after the
attendancerow):{ key: 'grades', label: 'Grades', description: 'Student marks: criteria-derived, teacher-authored, family-visible', sortOrder: 20 } - SCOPES map:
grades: [{ key: 'record', label: 'Record', description: 'Mark value: type, dates, note, criterion marks, override + provenance', sortOrder: 1 }] - ACTIONS array (after the
attendance.takerow):{ entityKey: 'grades', key: 'record', label: 'Record Grade', description: 'Create / edit / delete a grade entry (incl. admin override)', sortOrder: 1 } - ACTION_SCOPE_REQUIREMENTS:
'grades.record': { scopeKeys: ['grades.record'] }, -
FIELD_MAPPINGS array:
{ entityKey: 'grades', scopeKey: 'record', tableName: 'grade_entries', fields: GRADES_SCOPES.record }, -
Step 4: Add role grants in
prisma/seed/roles.ts
Mirror attendance: grant grades.record scope + grades.record action to admin and teacher; let referent/student receive read via the same othersScopes: 'ALL_READ' channel attendance uses (so the policy narrows rows). Concretely:
- Add 'grades.record' to the teacher base scope set (the set that already contains 'attendance.register').
- Add 'grades.record' to the teacher actions set (the set that already contains 'attendance.take').
- Ensure admin's all-scopes/all-actions grant covers grades.* (admin already gets everything via its wildcard grant — confirm in the file).
- Confirm the grades entity's othersScopes: 'ALL_READ' (or equivalent read-grant for non-employee roles) is set the same way the attendance block sets it, so referent + student get grades.read.
- Step 5: Re-seed locally to verify the catalogue loads
Run: npx prisma db seed
Expected: no error; grades entity + record scope/action present (spot-check with npx prisma studio → permission_scopes / permission_actions, or trust the seed exit code).
- Step 6: Commit
git add src/common/constants/entity-keys.ts src/common/constants/scope-fields.ts prisma/seed/rbac-catalogue.ts prisma/seed/roles.ts
git commit -m "feat(grades): RBAC entity/scope/action + role grants"
Task 3: Error codes + examples¶
Files:
- Modify: src/common/constants/error-codes.ts
- Modify: src/common/constants/error-examples.ts
- Test: src/common/constants/error-examples.drift.spec.ts (existing drift guard — asserts every code has an example)
Interfaces:
- Produces: ErrorCode.GRADE_NOT_FOUND | GRADE_NOT_AUTHORIZED | GRADE_STUDENT_NOT_IN_SUBJECT_GROUP | GRADE_CRITERION_INVALID | GRADE_VALUE_NOT_ON_SCALE | GRADE_SUBJECT_NOT_GRADABLE.
- Step 1: Run the drift spec to confirm it's green before changes
Run: npx jest src/common/constants/error-examples.drift.spec.ts
Expected: PASS.
- Step 2: Add the enum members
In src/common/constants/error-codes.ts, after the attendance block:
// Grades
GRADE_NOT_FOUND = 'GRADE_NOT_FOUND',
GRADE_NOT_AUTHORIZED = 'GRADE_NOT_AUTHORIZED',
GRADE_STUDENT_NOT_IN_SUBJECT_GROUP = 'GRADE_STUDENT_NOT_IN_SUBJECT_GROUP',
GRADE_CRITERION_INVALID = 'GRADE_CRITERION_INVALID',
GRADE_VALUE_NOT_ON_SCALE = 'GRADE_VALUE_NOT_ON_SCALE',
GRADE_SUBJECT_NOT_GRADABLE = 'GRADE_SUBJECT_NOT_GRADABLE',
And in the ErrorParamsMap interface, after the attendance params:
[ErrorCode.GRADE_NOT_FOUND]: { gradeId: string };
[ErrorCode.GRADE_NOT_AUTHORIZED]: { subjectGroupId: string };
[ErrorCode.GRADE_STUDENT_NOT_IN_SUBJECT_GROUP]: { studentId: string; subjectGroupId: string };
[ErrorCode.GRADE_CRITERION_INVALID]: { criterionId: string };
[ErrorCode.GRADE_VALUE_NOT_ON_SCALE]: { value: string; scaleId: string };
[ErrorCode.GRADE_SUBJECT_NOT_GRADABLE]: { curriculumSubjectId: string; gradeId: string };
- Step 3: Run the drift spec to confirm it now FAILS (missing examples)
Run: npx jest src/common/constants/error-examples.drift.spec.ts
Expected: FAIL — the 6 new codes have no example.
- Step 4: Add the examples in
src/common/constants/error-examples.ts
Follow the existing shape (one entry per code with code, message, params/data matching the params shape). Example for two; replicate the pattern for all six:
[ErrorCode.GRADE_NOT_AUTHORIZED]: {
code: ErrorCode.GRADE_NOT_AUTHORIZED,
message: 'You are not an assigned teacher of this subject group',
params: { subjectGroupId: '…uuid…' },
},
[ErrorCode.GRADE_VALUE_NOT_ON_SCALE]: {
code: ErrorCode.GRADE_VALUE_NOT_ON_SCALE,
message: 'The value is not a valid value on the resolved scale',
params: { value: '11', scaleId: '…uuid…' },
},
(Match the file's actual example structure — read a neighbouring attendance example first and copy its shape exactly.)
- Step 5: Run the drift spec to confirm green
Run: npx jest src/common/constants/error-examples.drift.spec.ts
Expected: PASS.
- Step 6: Commit
git add src/common/constants/error-codes.ts src/common/constants/error-examples.ts
git commit -m "feat(grades): error codes + examples"
Task 4: Pure compute module¶
Files:
- Create: src/grades/grades.compute.ts
- Test: src/grades/grades.compute.spec.ts
Interfaces:
- Consumes: proposeFinalFromCriteria, retrofitVote, EvaluationScaleWithValues from ../evaluation-scales.
- Produces:
- interface CriterionMarkInput { criterionId: string; value: string }
- computeEffectiveMark(criterionMarks: CriterionMarkInput[], finalOverride: string | null, criteriaScale: EvaluationScaleWithValues, subjectScale: EvaluationScaleWithValues): string | null
- expandDirectValue(directValue: string, criterionIds: string[], subjectScale: EvaluationScaleWithValues, criteriaScale: EvaluationScaleWithValues): CriterionMarkInput[]
- Step 1: Write the failing test
// src/grades/grades.compute.spec.ts
import {
computeEffectiveMark,
expandDirectValue,
} from './grades.compute';
import type { EvaluationScaleWithValues } from '../evaluation-scales';
// Numeric scale 0..max where value === String(i), numericEq === i.
function numericScale(id: string, max: number): EvaluationScaleWithValues {
return {
id,
type: 'NUMERIC',
values: Array.from({ length: max + 1 }, (_, i) => ({
value: String(i),
label: String(i),
ordinalPosition: i,
numericEq: i,
min: null,
max: null,
})),
} as unknown as EvaluationScaleWithValues;
}
const ten = numericScale('scale-10', 10);
const hundred = numericScale('scale-100', 100);
describe('computeEffectiveMark', () => {
it('averages criterion marks onto the subject scale (same scale)', () => {
expect(
computeEffectiveMark(
[{ criterionId: 'a', value: '6' }, { criterionId: 'b', value: '8' }],
null,
ten,
ten,
),
).toBe('7');
});
it('returns the override when present (ignores the average)', () => {
expect(
computeEffectiveMark([{ criterionId: 'a', value: '6' }], '9', ten, ten),
).toBe('9');
});
it('returns null when there is nothing to compute', () => {
expect(computeEffectiveMark([], null, ten, ten)).toBeNull();
});
});
describe('expandDirectValue round-trips through computeEffectiveMark', () => {
it('same scale: direct 7 over 3 criteria → effective 7', () => {
const marks = expandDirectValue('7', ['a', 'b', 'c'], ten, ten);
expect(marks).toEqual([
{ criterionId: 'a', value: '7' },
{ criterionId: 'b', value: '7' },
{ criterionId: 'c', value: '7' },
]);
expect(computeEffectiveMark(marks, null, ten, ten)).toBe('7');
});
it('cross scale: direct 70 (0..100) → criteria on 0..10 → effective 70', () => {
const marks = expandDirectValue('70', ['a', 'b'], hundred, ten);
expect(marks.every((m) => m.value === '7')).toBe(true);
expect(computeEffectiveMark(marks, null, ten, hundred)).toBe('70');
});
});
- Step 2: Run to verify it fails
Run: npx jest src/grades/grades.compute.spec.ts
Expected: FAIL — Cannot find module './grades.compute'.
- Step 3: Implement
src/grades/grades.compute.ts
import {
proposeFinalFromCriteria,
retrofitVote,
type EvaluationScaleWithValues,
} from '../evaluation-scales';
export interface CriterionMarkInput {
criterionId: string;
value: string;
}
/**
* Effective subject mark: the pinned `finalOverride` when present, otherwise the
* average of the criterion marks mapped onto the subject scale. Null when there
* is no override and no criterion mark (nothing to compute).
*/
export function computeEffectiveMark(
criterionMarks: CriterionMarkInput[],
finalOverride: string | null,
criteriaScale: EvaluationScaleWithValues,
subjectScale: EvaluationScaleWithValues,
): string | null {
if (finalOverride != null) return finalOverride;
if (criterionMarks.length === 0) return null;
return proposeFinalFromCriteria(
criterionMarks.map((m) => m.value),
criteriaScale,
subjectScale,
);
}
/**
* Direct-entry expansion: turn one subject-scale value into a full criterion set
* (every criterion gets the retrofit of `directValue` onto the criteria scale)
* so the criteria average maps back to `directValue`.
*/
export function expandDirectValue(
directValue: string,
criterionIds: string[],
subjectScale: EvaluationScaleWithValues,
criteriaScale: EvaluationScaleWithValues,
): CriterionMarkInput[] {
const v = retrofitVote(directValue, subjectScale, criteriaScale);
return criterionIds.map((criterionId) => ({ criterionId, value: v }));
}
- Step 4: Run to verify it passes
Run: npx jest src/grades/grades.compute.spec.ts
Expected: PASS (5 tests).
- Step 5: Commit
git add src/grades/grades.compute.ts src/grades/grades.compute.spec.ts
git commit -m "feat(grades): pure effective-mark compute + direct-value expansion"
Task 5: Scale-context loader + pure validators¶
Files:
- Create: src/grades/grades.scale-context.ts
- Test: src/grades/grades.scale-context.spec.ts (pure helpers only; the prisma loader is e2e-covered)
Interfaces:
- Consumes: resolveEffectiveCellScale, EvaluationScaleWithValues from ../evaluation-scales; AppException, ErrorCode.
- Produces:
- interface GradeScaleContext { subjectScale: EvaluationScaleWithValues; criteriaScale: EvaluationScaleWithValues; criteria: { id: string; name: string }[] }
- loadScaleContext(prisma, curriculumSubjectId: string, gradeId: string): Promise<GradeScaleContext> (throws GRADE_SUBJECT_NOT_GRADABLE)
- pickCriteriaForGrade(rows: { id: string; name: string; gradeId: string | null }[], gradeId: string): { id: string; name: string }[]
- assertValueOnScale(value: string, scale: EvaluationScaleWithValues): void
- assertCriterionInSubject(criterionId: string, criteria: { id: string }[]): void
- Step 1: Write the failing test (pure helpers)
// src/grades/grades.scale-context.spec.ts
import {
pickCriteriaForGrade,
assertValueOnScale,
assertCriterionInSubject,
} from './grades.scale-context';
import { AppException } from '../common/exceptions/app.exception';
import { ErrorCode } from '../common/constants/error-codes';
import type { EvaluationScaleWithValues } from '../evaluation-scales';
const scale = {
id: 's1',
type: 'NUMERIC',
values: [
{ value: '6', label: '6', ordinalPosition: 0, numericEq: 6, min: null, max: null },
{ value: '7', label: '7', ordinalPosition: 1, numericEq: 7, min: null, max: null },
],
} as unknown as EvaluationScaleWithValues;
describe('pickCriteriaForGrade (replace semantics)', () => {
const rows = [
{ id: 'd1', name: 'Default A', gradeId: null },
{ id: 'd2', name: 'Default B', gradeId: null },
{ id: 'g1', name: 'Grade A', gradeId: 'grade-9' },
];
it('uses grade-specific rows when present', () => {
expect(pickCriteriaForGrade(rows, 'grade-9')).toEqual([{ id: 'g1', name: 'Grade A' }]);
});
it('falls back to default rows when no grade override', () => {
expect(pickCriteriaForGrade(rows, 'grade-10')).toEqual([
{ id: 'd1', name: 'Default A' },
{ id: 'd2', name: 'Default B' },
]);
});
});
describe('assertValueOnScale', () => {
it('passes for a value on the scale', () => {
expect(() => assertValueOnScale('7', scale)).not.toThrow();
});
it('throws GRADE_VALUE_NOT_ON_SCALE otherwise', () => {
try {
assertValueOnScale('11', scale);
fail('should throw');
} catch (e) {
expect(e).toBeInstanceOf(AppException);
expect((e as AppException).code).toBe(ErrorCode.GRADE_VALUE_NOT_ON_SCALE);
}
});
});
describe('assertCriterionInSubject', () => {
it('throws GRADE_CRITERION_INVALID for an unknown criterion', () => {
try {
assertCriterionInSubject('nope', [{ id: 'g1' }]);
fail('should throw');
} catch (e) {
expect((e as AppException).code).toBe(ErrorCode.GRADE_CRITERION_INVALID);
}
});
});
Note: confirm
AppExceptionexposes.code(it does — used across the codebase). If the property differs, assert on the thrown code via the field the codebase uses.
- Step 2: Run to verify it fails
Run: npx jest src/grades/grades.scale-context.spec.ts
Expected: FAIL — module not found.
- Step 3: Implement
src/grades/grades.scale-context.ts
import { HttpStatus } from '@nestjs/common';
import type { PrismaService } from '../prisma';
import { AppException } from '../common/exceptions/app.exception';
import { ErrorCode } from '../common/constants/error-codes';
import {
resolveEffectiveCellScale,
type EvaluationScaleWithValues,
} from '../evaluation-scales';
export interface GradeScaleContext {
subjectScale: EvaluationScaleWithValues;
criteriaScale: EvaluationScaleWithValues;
criteria: { id: string; name: string }[];
}
/** Replace semantics: grade-specific criteria rows win; else the gradeId-null defaults. */
export function pickCriteriaForGrade(
rows: { id: string; name: string; gradeId: string | null }[],
gradeId: string,
): { id: string; name: string }[] {
const gradeRows = rows.filter((r) => r.gradeId === gradeId);
const chosen = gradeRows.length > 0 ? gradeRows : rows.filter((r) => r.gradeId === null);
return chosen.map((r) => ({ id: r.id, name: r.name }));
}
export function assertValueOnScale(value: string, scale: EvaluationScaleWithValues): void {
if (!scale.values.some((v) => v.value === value)) {
throw new AppException(
ErrorCode.GRADE_VALUE_NOT_ON_SCALE,
'The value is not a valid value on the resolved scale',
HttpStatus.BAD_REQUEST,
{ params: { value, scaleId: scale.id } },
);
}
}
export function assertCriterionInSubject(
criterionId: string,
criteria: { id: string }[],
): void {
if (!criteria.some((c) => c.id === criterionId)) {
throw new AppException(
ErrorCode.GRADE_CRITERION_INVALID,
'This criterion does not belong to the subject for this grade',
HttpStatus.UNPROCESSABLE_ENTITY,
{ params: { criterionId } },
);
}
}
const REF_SELECT = { id: true, name: true, type: true } as const;
const SCALE_WITH_VALUES = {
select: {
id: true,
type: true,
values: { orderBy: { ordinalPosition: 'asc' as const } },
},
};
/**
* Resolve the effective subject scale + criteria scale (both loaded with their
* values) and the criteria list for a (curriculumSubject, grade). Subject-scale
* cascade: cell → subject → CurriculumGrade → Curriculum. Criteria scale falls
* back to the effective subject scale. Throws GRADE_SUBJECT_NOT_GRADABLE when no
* subject scale resolves anywhere.
*/
export async function loadScaleContext(
prisma: PrismaService,
curriculumSubjectId: string,
gradeId: string,
): Promise<GradeScaleContext> {
const subject = await prisma.curriculumSubject.findUnique({
where: { id: curriculumSubjectId },
select: {
curriculumId: true,
gradingScale: { select: REF_SELECT },
criteriaGradingScale: { select: REF_SELECT },
curriculum: { select: { gradingScale: { select: REF_SELECT } } },
hours: {
where: { gradeId },
select: {
gradingScale: { select: REF_SELECT },
criteriaGradingScale: { select: REF_SELECT },
},
},
criteria: { select: { id: true, name: true, gradeId: true } },
},
});
if (!subject) {
throw new AppException(
ErrorCode.GRADE_SUBJECT_NOT_GRADABLE,
'No grading scale resolves for this subject and grade',
HttpStatus.UNPROCESSABLE_ENTITY,
{ params: { curriculumSubjectId, gradeId } },
);
}
const curriculumGrade = await prisma.curriculumGrade.findFirst({
where: { curriculumId: subject.curriculumId, gradeId },
select: { gradingScale: { select: REF_SELECT } },
});
const cell = subject.hours[0] ?? null;
const subjectRef = resolveEffectiveCellScale(
cell?.gradingScale ?? null,
subject.gradingScale ?? null,
curriculumGrade?.gradingScale ?? null,
subject.curriculum.gradingScale ?? null,
);
if (!subjectRef) {
throw new AppException(
ErrorCode.GRADE_SUBJECT_NOT_GRADABLE,
'No grading scale resolves for this subject and grade',
HttpStatus.UNPROCESSABLE_ENTITY,
{ params: { curriculumSubjectId, gradeId } },
);
}
// Criteria scale: cell → subject criteria scale, else fall back to the subject scale.
const criteriaRef =
cell?.criteriaGradingScale ?? subject.criteriaGradingScale ?? subjectRef;
const ids = Array.from(new Set([subjectRef.id, criteriaRef.id]));
const scales = await prisma.evaluationScale.findMany({
where: { id: { in: ids } },
...SCALE_WITH_VALUES,
});
const byId = new Map(scales.map((s) => [s.id, s as unknown as EvaluationScaleWithValues]));
return {
subjectScale: byId.get(subjectRef.id)!,
criteriaScale: byId.get(criteriaRef.id)!,
criteria: pickCriteriaForGrade(subject.criteria, gradeId),
};
}
Implementation note (inline, not JSDoc): if
resolveEffectiveCellScale's parameter order differs from(cell, subject, grade, curriculum), match its actual signature insrc/evaluation-scales/evaluation-scales.queries.ts— the curriculum service calls it atcurriculum.service.ts:154.
- Step 4: Run to verify the pure-helper tests pass
Run: npx jest src/grades/grades.scale-context.spec.ts
Expected: PASS.
- Step 5: Commit
git add src/grades/grades.scale-context.ts src/grades/grades.scale-context.spec.ts
git commit -m "feat(grades): scale-context loader + criteria/value validators"
Task 6: Read-visibility builder¶
Files:
- Create: src/grades/grades.visibility.ts
- Test: src/grades/grades.visibility.spec.ts (pure gradeVisibilityWhere)
Interfaces:
- Consumes: RecordAccessContext, Prisma.GradeEntryWhereInput, PrismaService.
- Produces:
- interface GradeVisibilityInputs { taughtSubjectGroupIds: string[]; teacherId: string | null }
- gradeVisibilityWhere(ctx: RecordAccessContext, inputs: GradeVisibilityInputs): Prisma.GradeEntryWhereInput
- buildGradeVisibilityWhere(prisma: PrismaService, ctx: RecordAccessContext, academicYearId: string): Promise<Prisma.GradeEntryWhereInput>
- Step 1: Write the failing test
// src/grades/grades.visibility.spec.ts
import { gradeVisibilityWhere } from './grades.visibility';
import type { RecordAccessContext } from '../common';
function ctx(partial: Partial<RecordAccessContext>): RecordAccessContext {
return {
tenantId: 't1',
userId: 'u1',
roles: [],
isPlatformAdmin: false,
parameters: { departmentIds: [] } as never,
teacherDepartmentIds: [],
parameterDimensions: [],
...partial,
};
}
describe('gradeVisibilityWhere', () => {
it('admin → tenant-only', () => {
expect(gradeVisibilityWhere(ctx({ roles: ['admin'] }), { taughtSubjectGroupIds: [], teacherId: null }))
.toEqual({ tenantId: 't1' });
});
it('teacher → OR(taught SGs, current-homeroom-tutor)', () => {
const w = gradeVisibilityWhere(ctx({ roles: ['teacher'] }), {
taughtSubjectGroupIds: ['sg1', 'sg2'],
teacherId: 'teacher-1',
});
expect(w).toEqual({
tenantId: 't1',
OR: [
{ subjectGroupId: { in: ['sg1', 'sg2'] } },
{ student: { homeroomAssignment: { homeroom: { homeroomTeacherId: 'teacher-1' } } } },
],
});
});
it('teacher with no SGs and no teacherId → match nothing', () => {
expect(gradeVisibilityWhere(ctx({ roles: ['teacher'] }), { taughtSubjectGroupIds: [], teacherId: null }))
.toEqual({ tenantId: 't1', id: { in: [] } });
});
it('referent → linked students via the student FK', () => {
expect(gradeVisibilityWhere(ctx({ roles: ['referent'] }), { taughtSubjectGroupIds: [], teacherId: null }))
.toEqual({
tenantId: 't1',
student: { referents: { some: { referent: { userId: 'u1', tenantId: 't1' } } } },
});
});
it('student → own student row', () => {
expect(gradeVisibilityWhere(ctx({ roles: ['student'] }), { taughtSubjectGroupIds: [], teacherId: null }))
.toEqual({ tenantId: 't1', student: { userId: 'u1', tenantId: 't1' } });
});
it('unknown role → match nothing', () => {
expect(gradeVisibilityWhere(ctx({ roles: ['hr'] }), { taughtSubjectGroupIds: [], teacherId: null }))
.toEqual({ tenantId: 't1', id: { in: [] } });
});
});
- Step 2: Run to verify it fails
Run: npx jest src/grades/grades.visibility.spec.ts
Expected: FAIL — module not found.
- Step 3: Implement
src/grades/grades.visibility.ts
import type { Prisma } from '../generated/prisma/client';
import type { PrismaService } from '../prisma';
import type { RecordAccessContext } from '../common';
import { teacherIdForUser, loadTaughtSubjectGroupIds } from './grades.queries';
export interface GradeVisibilityInputs {
taughtSubjectGroupIds: string[];
teacherId: string | null;
}
const MATCH_NOTHING = { in: [] as string[] };
/** Pure where-builder. The teacher branch needs ids resolved by the async wrapper. */
export function gradeVisibilityWhere(
ctx: RecordAccessContext,
inputs: GradeVisibilityInputs,
): Prisma.GradeEntryWhereInput {
const base: Prisma.GradeEntryWhereInput = { tenantId: ctx.tenantId };
if (ctx.isPlatformAdmin || ctx.roles.includes('admin')) return base;
if (ctx.roles.includes('teacher')) {
const or: Prisma.GradeEntryWhereInput[] = [];
if (inputs.taughtSubjectGroupIds.length > 0) {
or.push({ subjectGroupId: { in: inputs.taughtSubjectGroupIds } });
}
if (inputs.teacherId) {
or.push({
student: {
homeroomAssignment: { homeroom: { homeroomTeacherId: inputs.teacherId } },
},
});
}
if (or.length === 0) return { ...base, id: MATCH_NOTHING };
return { ...base, OR: or };
}
if (ctx.roles.includes('referent')) {
return {
...base,
student: { referents: { some: { referent: { userId: ctx.userId, tenantId: ctx.tenantId } } } },
};
}
if (ctx.roles.includes('student')) {
return { ...base, student: { userId: ctx.userId, tenantId: ctx.tenantId } };
}
return { ...base, id: MATCH_NOTHING };
}
/** Resolve the teacher-branch inputs (one query) then shape the where. */
export async function buildGradeVisibilityWhere(
prisma: PrismaService,
ctx: RecordAccessContext,
academicYearId: string,
): Promise<Prisma.GradeEntryWhereInput> {
if (ctx.isPlatformAdmin || ctx.roles.includes('admin') || !ctx.roles.includes('teacher')) {
return gradeVisibilityWhere(ctx, { taughtSubjectGroupIds: [], teacherId: null });
}
const teacherId = await teacherIdForUser(prisma, ctx.userId, academicYearId);
const taughtSubjectGroupIds = teacherId
? await loadTaughtSubjectGroupIds(prisma, teacherId, academicYearId)
: [];
return gradeVisibilityWhere(ctx, { taughtSubjectGroupIds, teacherId });
}
teacherIdForUserandloadTaughtSubjectGroupIdsare defined in Task 7'sgrades.queries.ts. If executing tasks strictly in order, add minimal stubs ingrades.queries.tsnow (Task 7 fills the bodies) so this compiles; the pure spec doesn't import them.
- Step 4: Run to verify the pure tests pass
Run: npx jest src/grades/grades.visibility.spec.ts
Expected: PASS (6 tests).
- Step 5: Commit
git add src/grades/grades.visibility.ts src/grades/grades.visibility.spec.ts
git commit -m "feat(grades): read-visibility where-builder (teacher SGs ∪ tutees; referent/student)"
Task 7: Write path — GradesService.record (POST)¶
Files:
- Create: src/grades/grades.queries.ts
- Create: src/grades/dto/record-grade.dto.ts
- Create: src/grades/dto/grade-entry-response.dto.ts
- Create: src/grades/grades.service.ts (the record method + helpers; later tasks add more methods)
- Test: src/grades/grades.service.spec.ts
Interfaces:
- Consumes: loadScaleContext, computeEffectiveMark, expandDirectValue, assertValueOnScale, assertCriterionInSubject, AuditService, resolveActiveYear.
- Produces (in grades.queries.ts):
- teacherIdForUser(prisma, userId, academicYearId): Promise<string | null>
- loadTaughtSubjectGroupIds(prisma, teacherId, academicYearId): Promise<string[]>
- loadSubjectGroupForGrade(prisma, tenantId, academicYearId, sgId): Promise<SgGradeContext | null> where SgGradeContext = { id; name; curriculumSubjectId; gradeId; departmentId; homeroomId: string|null; subjectName; subjectCode: string|null; gradeName; homeroomName: string|null; teacherIds: string[] }
- loadSubjectGroupRosterIds(prisma, sg): Promise<Set<string>> (homeroom-bound → homeroom roster; standalone → SG assignments)
- loadStudentSnapshot(prisma, studentId): Promise<{ firstName; lastName; identificationCode: string|null } | null>
- gradeEntryInclude (const) + toGradeEntryDto(row, scaleCtx): GradeEntryResponseDto
- Produces (DTOs): RecordGradeDto, GradeEntryResponseDto, GradeCriterionMarkDto.
- Produces (service): GradesService.record(tenantId, dto, ctx): Promise<GradeEntryResponseDto> + private resolveWriteMarks(dto, scaleCtx): { criterionMarks: CriterionMarkInput[]; finalOverride: string | null }.
- Step 1: Write the failing service test
// src/grades/grades.service.spec.ts (record cases)
import { GradesService } from './grades.service';
import { AppException } from '../common/exceptions/app.exception';
import { ErrorCode } from '../common/constants/error-codes';
// Minimal hand-rolled fakes — assert on the gate decisions, not Prisma internals.
// (Follow the mocking style in src/attendance/* specs.)
describe('GradesService.record', () => {
it('rejects a non-assigned teacher with GRADE_NOT_AUTHORIZED', async () => {
// Arrange a service whose loadSubjectGroupForGrade returns an SG with
// teacherIds = ['other'] and a ctx for a teacher resolving to 'me'.
// Expect record(...) to reject with code GRADE_NOT_AUTHORIZED.
});
it('rejects a student not on the roster with GRADE_STUDENT_NOT_IN_SUBJECT_GROUP', async () => {});
it('per-criterion mode: stores criterion marks and returns the averaged effective mark', async () => {});
it('direct mode (subject has criteria): expands criteria, override null, effective == directValue', async () => {});
it('override pins a final that diverges from the average', async () => {});
it('criteria-less subject: directValue is stored as finalOverride', async () => {});
it('rejects a criterion value off the criteria scale (GRADE_VALUE_NOT_ON_SCALE)', async () => {});
});
Right-size the fakes: the cleanest approach is to extract
resolveWriteMarks(pure, given aGradeScaleContext) and unit-test it directly for the four input modes, then cover the gate ordering (authority → roster → scale → validate → persist) with one or two integration-style tests using a Prisma test double. Mirror the test-double pattern used insrc/attendance/*.spec.tsandsrc/subject-groups/*.spec.ts.
- Step 2: Run to verify it fails
Run: npx jest src/grades/grades.service.spec.ts
Expected: FAIL — module not found.
- Step 3: Implement
grades.queries.tsloaders
import type { PrismaService } from '../prisma';
import type { Prisma } from '../generated/prisma/client';
export async function teacherIdForUser(
prisma: PrismaService,
userId: string,
academicYearId: string,
): Promise<string | null> {
const t = await prisma.teacher.findFirst({
where: { userId, academicYearId },
select: { id: true },
});
return t?.id ?? null;
}
export async function loadTaughtSubjectGroupIds(
prisma: PrismaService,
teacherId: string,
academicYearId: string,
): Promise<string[]> {
const rows = await prisma.subjectGroupTeacher.findMany({
where: { teacherId, subjectGroup: { academicYearId } },
select: { subjectGroupId: true },
});
return rows.map((r) => r.subjectGroupId);
}
export interface SgGradeContext {
id: string;
name: string;
curriculumSubjectId: string;
gradeId: string;
departmentId: string;
homeroomId: string | null;
subjectName: string;
subjectCode: string | null;
gradeName: string;
homeroomName: string | null;
teacherIds: string[];
}
export async function loadSubjectGroupForGrade(
prisma: PrismaService,
tenantId: string,
academicYearId: string,
subjectGroupId: string,
): Promise<SgGradeContext | null> {
const sg = await prisma.subjectGroup.findFirst({
where: { id: subjectGroupId, tenantId, academicYearId },
select: {
id: true,
name: true,
curriculumSubjectId: true,
gradeId: true,
homeroomId: true,
curriculumSubject: {
select: { name: true, code: true, curriculum: { select: { departmentId: true } } },
},
grade: { select: { name: true } },
homeroom: { select: { name: true } },
teachers: { select: { teacherId: true } },
},
});
if (!sg) return null;
return {
id: sg.id,
name: sg.name,
curriculumSubjectId: sg.curriculumSubjectId,
gradeId: sg.gradeId,
departmentId: sg.curriculumSubject.curriculum.departmentId,
homeroomId: sg.homeroomId,
subjectName: sg.curriculumSubject.name,
subjectCode: sg.curriculumSubject.code,
gradeName: sg.grade.name,
homeroomName: sg.homeroom?.name ?? null,
teacherIds: sg.teachers.map((t) => t.teacherId),
};
}
export async function loadSubjectGroupRosterIds(
prisma: PrismaService,
sg: { id: string; homeroomId: string | null },
): Promise<Set<string>> {
if (sg.homeroomId) {
const rows = await prisma.homeroomAssignment.findMany({
where: { homeroomId: sg.homeroomId },
select: { studentId: true },
});
return new Set(rows.map((r) => r.studentId));
}
const rows = await prisma.subjectGroupAssignment.findMany({
where: { subjectGroupId: sg.id },
select: { studentId: true },
});
return new Set(rows.map((r) => r.studentId));
}
export async function loadStudentSnapshot(
prisma: PrismaService,
studentId: string,
): Promise<{ firstName: string; lastName: string; identificationCode: string | null } | null> {
const s = await prisma.student.findUnique({
where: { id: studentId },
select: { firstName: true, lastName: true, identificationCode: true },
});
return s ?? null;
}
export const gradeEntryInclude = {
criterionMarks: { orderBy: { createdAt: 'asc' as const } },
} satisfies Prisma.GradeEntryInclude;
Confirm the actual column names on
Student(firstName/lastName/identificationCode) and onSubjectGroup/CurriculumSubjectagainstschema.prisma; the attendance queries use the same fields.
- Step 4: Implement the DTOs
src/grades/dto/record-grade.dto.ts:
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
ArrayMinSize, IsArray, IsEnum, IsISO8601, IsOptional, IsString,
IsUUID, Length, ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
import { GradeType } from '../../generated/prisma/client';
export class CriterionMarkInputDto {
@ApiProperty({ format: 'uuid' })
@IsUUID()
criterionId: string;
@ApiProperty({ description: 'A value string on the criteria scale' })
@IsString()
@Length(1, 50)
value: string;
}
/**
* Record one mark. Provide exactly one input mode:
* - `criterionMarks` (per-criterion; optional `finalOverride` pins the final),
* - `directValue` (back-fills every criterion so the average equals it),
* - `finalOverride` alone (pins the final; criteria-less subjects use this).
*/
export class RecordGradeDto {
@ApiProperty({ format: 'uuid' })
@IsUUID()
studentId: string;
@ApiProperty({ format: 'uuid' })
@IsUUID()
subjectGroupId: string;
@ApiProperty({ enum: GradeType })
@IsEnum(GradeType)
type: GradeType;
@ApiProperty({ format: 'date', example: '2026-03-12' })
@IsISO8601()
assignmentDate: string;
@ApiPropertyOptional({ maxLength: 2000 })
@IsOptional()
@IsString()
@Length(1, 2000)
note?: string;
@ApiPropertyOptional({ type: [CriterionMarkInputDto] })
@IsOptional()
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => CriterionMarkInputDto)
criterionMarks?: CriterionMarkInputDto[];
@ApiPropertyOptional({ description: 'Subject-scale value; fills every criterion' })
@IsOptional()
@IsString()
@Length(1, 50)
directValue?: string;
@ApiPropertyOptional({ description: 'Subject-scale value; pins the final mark' })
@IsOptional()
@IsString()
@Length(1, 50)
finalOverride?: string;
}
src/grades/dto/grade-entry-response.dto.ts:
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { GradeType } from '../../generated/prisma/client';
export class GradeCriterionMarkDto {
@ApiProperty({ format: 'uuid' }) criterionId: string;
@ApiProperty() criterionName: string;
@ApiProperty() value: string;
}
export class GradeEntryResponseDto {
@ApiProperty({ format: 'uuid' }) id: string;
@ApiProperty({ format: 'uuid' }) studentId: string;
@ApiProperty() studentFirstName: string;
@ApiProperty() studentLastName: string;
@ApiProperty({ format: 'uuid' }) subjectGroupId: string;
@ApiProperty() subjectGroupName: string;
@ApiProperty() subjectName: string;
@ApiPropertyOptional({ nullable: true }) subjectCode: string | null;
@ApiProperty({ enum: GradeType }) type: GradeType;
@ApiProperty({ format: 'date' }) assignmentDate: string;
@ApiPropertyOptional({ nullable: true }) note: string | null;
@ApiProperty({ nullable: true, description: 'override ?? computed average; null only when no marks+no override' })
effectiveMark: string | null;
@ApiPropertyOptional({ nullable: true }) finalOverride: string | null;
@ApiProperty({ type: [GradeCriterionMarkDto] }) criterionMarks: GradeCriterionMarkDto[];
@ApiProperty() recordedByName: string;
@ApiProperty() recordedAt: Date;
@ApiPropertyOptional({ nullable: true }) lastModifiedByName: string | null;
@ApiProperty() lastModifiedAt: Date;
}
Add toGradeEntryDto to grades.queries.ts (takes the loaded row incl. criterionMarks + the GradeScaleContext to compute effectiveMark via computeEffectiveMark).
- Step 5: Implement
GradesService.record
import { HttpStatus, Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma';
import { AuditService } from '../audit-log';
import { AppException } from '../common/exceptions/app.exception';
import { ErrorCode } from '../common/constants/error-codes';
import { resolveActiveYear } from '../common/utils';
import type { RecordAccessContext } from '../common';
import {
computeEffectiveMark,
expandDirectValue,
type CriterionMarkInput,
} from './grades.compute';
import {
loadScaleContext,
assertValueOnScale,
assertCriterionInSubject,
type GradeScaleContext,
} from './grades.scale-context';
import {
teacherIdForUser,
loadSubjectGroupForGrade,
loadSubjectGroupRosterIds,
loadStudentSnapshot,
gradeEntryInclude,
toGradeEntryDto,
} from './grades.queries';
import type { RecordGradeDto } from './dto/record-grade.dto';
import type { GradeEntryResponseDto } from './dto/grade-entry-response.dto';
const GRADE_ENTITY_TYPE = 'grade_entry';
function parseDateOnly(s: string): Date {
return new Date(`${s.slice(0, 10)}T00:00:00.000Z`);
}
@Injectable()
export class GradesService {
constructor(
private readonly prisma: PrismaService,
private readonly audit: AuditService,
) {}
async record(
tenantId: string,
dto: RecordGradeDto,
ctx: RecordAccessContext,
): Promise<GradeEntryResponseDto> {
const academicYearId = await resolveActiveYear(this.prisma, tenantId);
const sg = await loadSubjectGroupForGrade(this.prisma, tenantId, academicYearId, dto.subjectGroupId);
if (!sg) {
throw new AppException(ErrorCode.NOT_FOUND, 'Subject group not found', HttpStatus.NOT_FOUND, {
params: { entity: 'subject_group', id: dto.subjectGroupId },
});
}
const isAdmin = ctx.roles.includes('admin') || ctx.isPlatformAdmin;
if (!isAdmin) {
const teacherId = await teacherIdForUser(this.prisma, ctx.userId, academicYearId);
if (!teacherId || !sg.teacherIds.includes(teacherId)) {
throw new AppException(ErrorCode.GRADE_NOT_AUTHORIZED, 'You are not an assigned teacher of this subject group', HttpStatus.FORBIDDEN, {
params: { subjectGroupId: sg.id },
});
}
}
const rosterIds = await loadSubjectGroupRosterIds(this.prisma, sg);
if (!rosterIds.has(dto.studentId)) {
throw new AppException(ErrorCode.GRADE_STUDENT_NOT_IN_SUBJECT_GROUP, 'This student is not on the subject group roster', HttpStatus.UNPROCESSABLE_ENTITY, {
params: { studentId: dto.studentId, subjectGroupId: sg.id },
});
}
const scaleCtx = await loadScaleContext(this.prisma, sg.curriculumSubjectId, sg.gradeId);
const { criterionMarks, finalOverride } = this.resolveWriteMarks(dto, scaleCtx);
for (const m of criterionMarks) {
assertCriterionInSubject(m.criterionId, scaleCtx.criteria);
assertValueOnScale(m.value, scaleCtx.criteriaScale);
}
if (finalOverride != null) assertValueOnScale(finalOverride, scaleCtx.subjectScale);
const studentSnap = await loadStudentSnapshot(this.prisma, dto.studentId);
if (!studentSnap) {
throw new AppException(ErrorCode.NOT_FOUND, 'Student not found', HttpStatus.NOT_FOUND, {
params: { entity: 'student', id: dto.studentId },
});
}
const criterionName = new Map(scaleCtx.criteria.map((c) => [c.id, c.name]));
const row = await this.prisma.$transaction(async (tx) => {
const actor = await this.audit.resolveActor(tx, ctx);
const created = await tx.gradeEntry.create({
data: {
tenantId,
academicYearId,
studentId: dto.studentId,
subjectGroupId: sg.id,
curriculumSubjectId: sg.curriculumSubjectId,
homeroomId: sg.homeroomId,
gradeId: sg.gradeId,
departmentId: sg.departmentId,
studentFirstName: studentSnap.firstName,
studentLastName: studentSnap.lastName,
studentIdentificationCode: studentSnap.identificationCode,
subjectName: sg.subjectName,
subjectCode: sg.subjectCode,
subjectGroupName: sg.name,
gradeName: sg.gradeName,
homeroomName: sg.homeroomName,
subjectScaleId: scaleCtx.subjectScale.id,
criteriaScaleId: scaleCtx.criteriaScale.id,
type: dto.type,
assignmentDate: parseDateOnly(dto.assignmentDate),
note: dto.note ?? null,
finalOverride,
recordedByUserId: ctx.userId,
recordedByName: actor.name,
criterionMarks: {
create: criterionMarks.map((m) => ({
criterionId: m.criterionId,
criterionName: criterionName.get(m.criterionId) ?? '',
value: m.value,
})),
},
},
include: gradeEntryInclude,
});
const effectiveMark = computeEffectiveMark(criterionMarks, finalOverride, scaleCtx.criteriaScale, scaleCtx.subjectScale);
await this.audit.record(tx, actor, {
entityType: GRADE_ENTITY_TYPE,
entityId: created.id,
action: `${GRADE_ENTITY_TYPE}.created`,
data: {
type: created.type,
assignmentDate: dto.assignmentDate,
finalOverride,
effectiveMark,
criterionMarks: criterionMarks,
},
});
return created;
});
return toGradeEntryDto(row, scaleCtx);
}
/** Resolve the input mode into stored criterion marks + override. */
private resolveWriteMarks(
dto: RecordGradeDto,
scaleCtx: GradeScaleContext,
): { criterionMarks: CriterionMarkInput[]; finalOverride: string | null } {
if (dto.directValue != null && dto.criterionMarks?.length) {
throw new AppException(ErrorCode.VALIDATION_FAILED, 'Provide either criterionMarks or directValue, not both', HttpStatus.BAD_REQUEST);
}
// Criteria-less subject: only a direct subject value makes sense.
if (scaleCtx.criteria.length === 0) {
const v = dto.finalOverride ?? dto.directValue;
if (v == null) {
throw new AppException(ErrorCode.VALIDATION_FAILED, 'This subject has no criteria; provide a subject value', HttpStatus.BAD_REQUEST);
}
return { criterionMarks: [], finalOverride: v };
}
// Subject with criteria.
if (dto.criterionMarks?.length) {
return { criterionMarks: dto.criterionMarks, finalOverride: dto.finalOverride ?? null };
}
if (dto.directValue != null) {
return {
criterionMarks: expandDirectValue(
dto.directValue,
scaleCtx.criteria.map((c) => c.id),
scaleCtx.subjectScale,
scaleCtx.criteriaScale,
),
finalOverride: null,
};
}
if (dto.finalOverride != null) {
return { criterionMarks: [], finalOverride: dto.finalOverride };
}
throw new AppException(ErrorCode.VALIDATION_FAILED, 'Provide criterionMarks, directValue, or finalOverride', HttpStatus.BAD_REQUEST);
}
}
- Step 6: Fill in the test bodies + run
Implement the cases from Step 1 (favor unit-testing resolveWriteMarks directly + a couple of gate-order tests with a Prisma double).
Run: npx jest src/grades/grades.service.spec.ts
Expected: PASS.
- Step 7: Commit
git add src/grades/grades.queries.ts src/grades/grades.service.ts src/grades/dto/record-grade.dto.ts src/grades/dto/grade-entry-response.dto.ts src/grades/grades.service.spec.ts
git commit -m "feat(grades): record a mark (authority + roster + scale validation + audit)"
Task 8: Edit + delete — update / remove¶
Files:
- Create: src/grades/dto/update-grade.dto.ts
- Modify: src/grades/grades.service.ts (+ update, remove)
- Modify: src/grades/grades.service.spec.ts
Interfaces:
- Produces: UpdateGradeDto = { type?; assignmentDate?; note?; criterionMarks?; directValue?; finalOverride? } (all optional; same mode rules).
- Produces: GradesService.update(tenantId, id, dto, ctx): Promise<GradeEntryResponseDto>; GradesService.remove(tenantId, id, ctx): Promise<void>.
- Step 1: Write failing tests — admin override → audit verb
grade_entry.admin_override; assigned-teacher edit →grade_entry.updated; non-assigned/non-admin →GRADE_NOT_AUTHORIZED; delete by admin → auditgrade_entry.deleted+ row gone; edit re-validates criterion values.
// add to grades.service.spec.ts
describe('GradesService.update', () => {
it('admin editing a mark the teacher could not write → admin_override verb', async () => {});
it('assigned teacher edit → updated verb', async () => {});
it('non-assigned non-admin → GRADE_NOT_AUTHORIZED', async () => {});
it('re-validates a changed criterion value against the scale', async () => {});
});
describe('GradesService.remove', () => {
it('deletes + audits grade_entry.deleted', async () => {});
});
- Step 2: Run to verify failure
Run: npx jest src/grades/grades.service.spec.ts -t "update|remove"
Expected: FAIL.
-
Step 3: Implement
UpdateGradeDto— same fields asRecordGradeDtominusstudentId/subjectGroupId(a mark can't move student/SG), all optional. Useclass-validatoroptionals. -
Step 4: Implement
update+remove
update: load the entry by id through buildGradeVisibilityWhere (must be visible); re-derive sg context from the entry's stored subjectGroupId/gradeId/curriculumSubjectId; re-run the assigned-teacher authority check (admin bypasses); reload scaleCtx; if any value field present, recompute the full criterion set via resolveWriteMarks over the merged DTO (a partial edit that omits all three value modes leaves marks unchanged — only type/assignmentDate/note change); revalidate; in one $transaction replace the children (deleteMany + create) when marks changed, update scalars + lastModifiedBy*, and audit.record with the verb:
- grade_entry.admin_override when isAdmin && the caller is not an assigned teacher of the SG,
- else grade_entry.updated.
remove: load by id through visibility (404 GRADE_NOT_FOUND if not visible); authority check (assigned teacher or admin); in one $transaction audit.record({ action: grade_entry.deleted, data: <final value snapshot> }) then delete (children cascade). Return void.
Mirror the merge/verb/
$transaction+resolveActor+recordstructure fromAttendanceService.updateCell(src/attendance/attendance.service.ts:292).
- Step 5: Run to verify pass
Run: npx jest src/grades/grades.service.spec.ts
Expected: PASS.
- Step 6: Commit
git add src/grades/grades.service.ts src/grades/dto/update-grade.dto.ts src/grades/grades.service.spec.ts
git commit -m "feat(grades): edit + delete a mark (override verb, audited)"
Task 9: Reads — SG cards + gradebook¶
Files:
- Create: src/grades/dto/subject-group-cards.dto.ts, src/grades/dto/subject-group-gradebook.dto.ts, src/grades/dto/grades-range-query.dto.ts
- Modify: src/grades/grades.service.ts (+ listSubjectGroupCards, getSubjectGroupGradebook), src/grades/grades.queries.ts
- Modify: src/grades/grades.service.spec.ts
Interfaces:
- Produces:
- SubjectGroupCardsQueryDto { departmentId?; gradeId?; homeroomId? }, SubjectGroupCardDto { id; name; subjectName; gradeName; homeroomName: string|null; rosterCount; gradeCount; lastGradedAt: Date|null }, SubjectGroupCardsResponseDto { cards: SubjectGroupCardDto[] }
- GradesRangeQueryDto { from?; to? }
- SubjectGroupGradebookDto { subjectGroupId; subjectName; criteria: {id;name}[]; students: { studentId; studentName; grades: GradeEntryResponseDto[] }[] }
- GradesService.listSubjectGroupCards(tenantId, query, ctx), GradesService.getSubjectGroupGradebook(tenantId, id, query, ctx)
-
Step 1: Write failing tests — cards scoped to a teacher's taught SGs (admin sees all); gradebook returns the roster (each student, possibly empty
grades) and each grade carries the computedeffectiveMark. -
Step 2: Run to verify failure
Run: npx jest src/grades/grades.service.spec.ts -t "cards|gradebook"
Expected: FAIL.
-
Step 3: Implement DTOs + queries + service
-
listSubjectGroupCards: admin → all SGs for the active AY (optionally filtered by department/grade/homeroom); teacher →id IN loadTaughtSubjectGroupIds; empty for teacher with no taught SGs. For each SG loadrosterCount(vialoadSubjectGroupRosterIds().sizeor a count),gradeCount(prisma.gradeEntry.count({ where: { subjectGroupId } })),lastGradedAt(findFirst orderBy recordedAt desc). Return{ cards }. -
getSubjectGroupGradebook: loadsg(404 if missing); for a teacher, assert it's in their taught set (elseGRADE_NOT_AUTHORIZED— gradebook is a write-surface view); loadscaleCtxonce; load the roster (ids → student names); load allgradeEntryfor the SG (optionalassignmentDaterange), includecriterionMarks; map each viatoGradeEntryDto(row, scaleCtx); group bystudentIdand emit every roster student (emptygradesallowed). Return{ subjectGroupId, subjectName, criteria: scaleCtx.criteria, students }. -
Step 4: Run to verify pass
Run: npx jest src/grades/grades.service.spec.ts
Expected: PASS.
- Step 5: Commit
git add src/grades/dto/subject-group-cards.dto.ts src/grades/dto/subject-group-gradebook.dto.ts src/grades/dto/grades-range-query.dto.ts src/grades/grades.service.ts src/grades/grades.queries.ts src/grades/grades.service.spec.ts
git commit -m "feat(grades): SG cards index + gradebook read"
Task 10: Reads — homeroom + student + me¶
Files:
- Create: src/grades/dto/homeroom-grades.dto.ts, src/grades/dto/student-grades.dto.ts
- Modify: src/grades/grades.service.ts (+ getHomeroomGrades, getStudentGrades, getMyGrades), src/grades/grades.queries.ts
- Modify: src/grades/grades.service.spec.ts
Interfaces:
- Produces:
- HomeroomGradesDto { homeroomId; homeroomName; students: { studentId; studentName; subjects: { subjectGroupId; subjectName; grades: GradeEntryResponseDto[] }[] }[] }
- StudentGradesDto { studentId; studentName; subjects: { subjectGroupId; subjectName; grades: GradeEntryResponseDto[] }[] }
- GradesService.getHomeroomGrades(tenantId, homeroomId, query, ctx), getStudentGrades(tenantId, studentId, query, ctx), getMyGrades(tenantId, query, ctx)
-
Step 1: Write failing tests — student
/grades/meresolves the caller's own student row and returns only own grades grouped by SG; referentgetStudentGradesfor a non-linked student returns empty (visibility where excludes); homeroom view groups by student then subject. -
Step 2: Run to verify failure —
npx jest src/grades/grades.service.spec.ts -t "homeroom|student|me"→ FAIL. -
Step 3: Implement
All three reads: resolve academicYearId; build where = buildGradeVisibilityWhere(prisma, ctx, academicYearId) AND the scope filter:
- getHomeroomGrades: AND [visibility, { homeroomId }] (+ optional date range). Group rows by studentId then subjectGroupId. To compute effectiveMark per row, load scale contexts per distinct (curriculumSubjectId, gradeId) once and reuse (Map keyed by curriculumSubjectId:gradeId). Pull the homeroom name (404 if missing).
- getStudentGrades: AND [visibility, { studentId }]. Group by subjectGroupId. Same per-(subject,grade) scale-context memoization.
- getMyGrades: resolve the caller's student id (prisma.student.findFirst({ where: { userId: ctx.userId, tenantId, academicYearId }})); if none → empty; else delegate to the same grouping with { studentId } (visibility already restricts to student-own, so this is belt-and-suspenders).
Add a loadScaleContextsForRows(prisma, rows) helper in grades.queries.ts that memoizes loadScaleContext per distinct (curriculumSubjectId, gradeId) so the mapper recomputes effectiveMark without N scale loads.
-
Step 4: Run to verify pass —
npx jest src/grades/grades.service.spec.ts→ PASS. -
Step 5: Commit
git add src/grades/dto/homeroom-grades.dto.ts src/grades/dto/student-grades.dto.ts src/grades/grades.service.ts src/grades/grades.queries.ts src/grades/grades.service.spec.ts
git commit -m "feat(grades): homeroom overview + student detail + /me reads"
Task 11: History read¶
Files:
- Create: src/grades/dto/grade-history.dto.ts
- Modify: src/grades/grades.service.ts (+ getGradeHistory)
- Modify: src/grades/grades.service.spec.ts
Interfaces:
- Produces: GradeHistoryDto { current: GradeEntryResponseDto; history: AuditLogEntryResponseDto[] }; GradesService.getGradeHistory(tenantId, id, ctx).
-
Step 1: Write failing test — history of an edited mark returns newest-first
created/updatedverbs; not-visible id →GRADE_NOT_FOUND. -
Step 2: Run to verify failure —
npx jest src/grades/grades.service.spec.ts -t history→ FAIL. -
Step 3: Implement — load the entry through
buildGradeVisibilityWhereAND{ id }(404GRADE_NOT_FOUND); load itsscaleCtxto mapcurrentviatoGradeEntryDto;history = await this.audit.historyFor(tenantId, 'grade_entry', id). ImportAuditLogEntryResponseDtotype from../audit-logfor the DTO. MirrorAttendanceService.cellHistory(attendance.service.ts:634). -
Step 4: Run to verify pass —
npx jest src/grades/grades.service.spec.ts→ PASS. -
Step 5: Commit
git add src/grades/dto/grade-history.dto.ts src/grades/grades.service.ts src/grades/grades.service.spec.ts
git commit -m "feat(grades): mark value-history endpoint (audit projection)"
Task 12: Controller + swagger + module wiring¶
Files:
- Create: src/grades/grades.controller.ts, src/grades/grades.swagger.ts, src/grades/grades.module.ts, src/grades/dto/index.ts, src/grades/index.ts
- Modify: src/app.module.ts
Interfaces:
- Consumes: every service method + DTO above.
- Produces: GradesModule, GradesController (9 routes), GradesService exported from the barrel.
- Step 1: Implement the controller (
@Controller('grades')+@ProtectedResource()). Routes — writes use the singular@RequireScope+@RequireAction(flat DTO, field-filter bypassed), reads use@RequireScopes(GRADES,'read')+@RequireRoles(...)+@AggregateResponse()(bespoke shapes, like attendance):
@Post() @RequireScope(EntityKey.GRADE_ENTRIES,'record','write') @RequireAction(EntityKey.GRADE_ENTRIES,'record') record(@TenantId() t, @Body() dto: RecordGradeDto, @AccessContext() ctx)
@Patch(':id') @RequireScope(EntityKey.GRADE_ENTRIES,'record','write') @RequireAction(EntityKey.GRADE_ENTRIES,'record') update(@TenantId() t, @Param('id', ParseUUIDPipe) id, @Body() dto: UpdateGradeDto, @AccessContext() ctx)
@Delete(':id') @RequireScope(EntityKey.GRADE_ENTRIES,'record','write') @RequireAction(EntityKey.GRADE_ENTRIES,'record') @HttpCode(204) remove(...)
@Get('subject-groups') @RequireScopes(EntityKey.GRADE_ENTRIES,'read') @RequireRoles('admin','teacher') @AggregateResponse() listSubjectGroupCards(@Query() q: SubjectGroupCardsQueryDto, ...)
@Get('subject-group/:id') @RequireScopes(EntityKey.GRADE_ENTRIES,'read') @RequireRoles('admin','teacher') @AggregateResponse() getSubjectGroupGradebook(@Param('id', ParseUUIDPipe) id, @Query() q: GradesRangeQueryDto, ...)
@Get('homeroom/:id') @RequireScopes(EntityKey.GRADE_ENTRIES,'read') @RequireRoles('admin','teacher') @AggregateResponse() getHomeroomGrades(...)
@Get('student/:id') @RequireScopes(EntityKey.GRADE_ENTRIES,'read') @RequireRoles('admin','teacher','referent') @AggregateResponse() getStudentGrades(...)
@Get('me') @RequireScopes(EntityKey.GRADE_ENTRIES,'read') @RequireRoles('student') @AggregateResponse() getMyGrades(...)
@Get(':id/history') @RequireScopes(EntityKey.GRADE_ENTRIES,'read') @RequireRoles('admin','teacher','referent','student') @AggregateResponse() getGradeHistory(@Param('id', ParseUUIDPipe) id, ...)
Route order: declare the static segments (
subject-groups,subject-group/:id,homeroom/:id,student/:id,me) and:id/historysuch thatmeand the static prefixes are not shadowed by:id. There is no bareGET /grades/:id, so collisions are limited — but keepmeand:id/historydeclared before any future param-only GET. Mirror the decorator imports fromsrc/attendance/attendance.controller.ts.
-
Step 2: Implement
grades.swagger.ts—@ApiGradesController()+ oneApi*decorator per route (operation summary + response type + the 6 error examples on the write routes). Mirrorsrc/attendance/attendance.swagger.ts. Keep summaries as FE contract copy. -
Step 3: Implement
grades.module.ts
import { Module } from '@nestjs/common';
import { PrismaModule } from '../prisma';
import { AuditLogModule } from '../audit-log';
import { GradesController } from './grades.controller';
import { GradesService } from './grades.service';
@Module({
imports: [PrismaModule, AuditLogModule],
controllers: [GradesController],
providers: [GradesService],
exports: [GradesService],
})
export class GradesModule {}
src/grades/index.ts → export GradesModule, GradesService, and the DTOs needed externally. src/grades/dto/index.ts → re-export all DTOs.
-
Step 4: Register in
src/app.module.ts— addGradesModuleto the imports array (no special ordering needed —/grades/*does not collide with other controllers' param routes). -
Step 5: Sanity-compile
Run: npx tsc --noEmit -p tsconfig.json (source only — pre-existing unrelated spec errors may remain; confirm no src/grades/** errors)
Expected: no errors originating in src/grades.
- Step 6: Commit
git add src/grades/grades.controller.ts src/grades/grades.swagger.ts src/grades/grades.module.ts src/grades/index.ts src/grades/dto/index.ts src/app.module.ts
git commit -m "feat(grades): controller, swagger, module wiring"
Task 13: E2E¶
Files:
- Create: test/grades.e2e-spec.ts
Interfaces:
- Consumes: the running app + seeded fixtures. Follow test/helpers/app.helper.ts + feedback_e2e_isolation_patterns.md; model after test/eligible-pickers.e2e-spec.ts and an attendance e2e for the auth/role bootstrapping.
- Step 1: Write the e2e covering the spec success criteria
Scenarios (each asserts status + body shape):
- assigned teacher POST /grades per-criterion → 201, effectiveMark = average, criterionMarks echoed.
- assigned teacher POST /grades directValue → criteria back-filled, effectiveMark == directValue.
- POST /grades finalOverride diverging from the average → effectiveMark == override.
- non-assigned teacher POST → 403 GRADE_NOT_AUTHORIZED.
- student not on roster → 422 GRADE_STUDENT_NOT_IN_SUBJECT_GROUP.
- criterion value off scale → 400 GRADE_VALUE_NOT_ON_SCALE.
- admin PATCH a teacher's mark → 200; GET /grades/:id/history shows created then admin_override.
- DELETE /grades/:id by admin → 204; history shows grade_entry.deleted.
- GET /grades/subject-groups as teacher → only taught SGs; click → GET /grades/subject-group/:id gradebook with roster.
- referent GET /grades/student/:linkedId → only linked; non-linked → empty/forbidden.
- student GET /grades/me → only own grades grouped by SG.
- Step 2: Run the e2e
Run: npx jest --config test/jest-e2e.json test/grades.e2e-spec.ts
Expected: PASS (all scenarios). (Requires a running DB + seed; the helper bootstraps the app.)
- Step 3: Commit
git add test/grades.e2e-spec.ts
git commit -m "test(grades): e2e for record/override/delete/reads + RBAC"
Task 14: Docs¶
Files:
- Create: docs/21-grades.md
- Modify: docs/REFERENCE.md (§4 module map row + §6 file-index row + §7 glossary entry), CLAUDE.md (module map line + chapter table row)
Interfaces: none (documentation).
-
Step 1: Write
docs/21-grades.md— mirrordocs/19-attendance.mdstructure: mental model (criteria-derived overridable mark, two dates), data model (soft-FK snapshot + thefinalOverride ?? avgformula + the three input modes + criteria-less degenerate), scale resolution (cascade +proposeFinalFromCriteria/retrofitVote), write authority (assigned SG teachers + admin), read visibility (async builder: teacher SGs ∪ tutees; referent linked; student own), routes/DTOs/error-code map, audit verbs, recipes (add aGradeType; the override semantics). -
Step 2: Update
docs/REFERENCE.md— add agrades/module-map row (§4), a "Record or read a grade" file-index row (§6) listingsrc/grades/*+ the touched constants/seed files, a glossary entry for GradeEntry / effective mark, and a §8 "deeper" row → chapter 21. -
Step 3: Update
CLAUDE.md— addgrades/to the Project Structure module list and a| Grades | docs/21-grades.md |row in the chapter table. -
Step 4: Commit
git add docs/21-grades.md docs/REFERENCE.md CLAUDE.md
git commit -m "docs(grades): chapter 21 + REFERENCE + CLAUDE module map"
Self-Review¶
1. Spec coverage (each §-requirement → task):
- Criteria-derived overridable mark + 3 input modes + criteria-less → Tasks 4, 5, 7 (resolveWriteMarks, compute).
- Two dates / provenance / snapshots / soft-FKs → Task 1 schema; Task 7 persist.
- New GRADES/record/record RBAC + role grants → Task 2. Scope-field constant ↔ catalogue agreement → Task 2.
- 6 error codes → Task 3.
- Scale cascade resolution + validation → Task 5.
- Write authority (assigned SG teachers + admin; admin_override verb) → Tasks 7, 8.
- Read visibility (teacher SGs ∪ tutees; referent linked; student own; immediate) → Task 6, applied in Tasks 9–11.
- Reads: SG cards → gradebook, homeroom, student, /me, history → Tasks 9, 10, 11.
- Audit verbs created/updated/deleted/admin_override + historyFor → Tasks 7, 8, 11.
- Controller/module/app wiring + flat-DTO write pattern → Task 12.
- Deferrals (period finals, weighting, publish gate, configurable types) → not implemented, by design (spec §9).
- Verification → Task 13 e2e; docs → Task 14.
2. Placeholder scan: the only intentionally-prose tasks are 8–11 (service methods that mirror an explicit canonical method + already-shown patterns) and 12–14 (boilerplate/docs that mirror named canonical files). Each names the exact canonical file + the precise behavior; novel logic (compute, scale-context, visibility, resolveWriteMarks, record) is shown in full. No "TBD"/"implement later".
3. Type consistency: CriterionMarkInput (compute) ↔ criterionMarks in service/DTO; GradeScaleContext shape identical across Tasks 5/7/9/10/11; gradeVisibilityWhere/buildGradeVisibilityWhere signatures stable across Tasks 6/9/10/11; toGradeEntryDto(row, scaleCtx) signature stable across Tasks 7/9/10/11; entity-type string 'grade_entry' consistent across audit calls; error codes match the §5 table exactly.