Skip to content

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_grades applied + seeded; full src/grades/ module + app wiring; targeted suites green (e2e 12/12, unit 866/866); chapter 21 docs written. report-finals deferred. 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 run npm run build / npm test / npm run lint or create commits unless the user asks. Each task documents the exact npx jest <file> and git commands; 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. Targeted npx jest <single-spec> is acceptable as the TDD inner loop where the user has opted into execution.
  • Migrations: read docs/12-migrations.md before npx 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): read is never an action; new scope needs a rbac-catalogue.ts entry and a scope-fields.ts runtime constant; both must agree.
  • Naming: the Prisma model is GradeEntry (the academic-year-level model is already named Grade — do not collide). Routes/docs say "grades". Entity key is GRADE_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/@RequireAction use EntityKey.GRADE_ENTRIES; scope record, action record; the action-requirement key is grade_entries.record.
  • Types (docs/13-typing-conventions.md): no any; build Prisma data with explicit fields / pickDefined; response DTO fields reference real DTO classes (no unknown[] / 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 finalOverride pin. 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 TenantgradeEntries GradeEntry[]; on AcademicYeargradeEntries GradeEntry[]; on StudentgradeEntries 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):

  GRADE_ENTRIES: 'grade_entries',
  • 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(...)):

  grades: new Set(Object.keys(GRADES_SCOPES)),
  • 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 attendance row): { 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.take row): { 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 studiopermission_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 AppException exposes .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 in src/evaluation-scales/evaluation-scales.queries.ts — the curriculum service calls it at curriculum.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 });
}

teacherIdForUser and loadTaughtSubjectGroupIds are defined in Task 7's grades.queries.ts. If executing tasks strictly in order, add minimal stubs in grades.queries.ts now (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 a GradeScaleContext) 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 in src/attendance/*.spec.ts and src/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.ts loaders
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 on SubjectGroup/CurriculumSubject against schema.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 → audit grade_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 as RecordGradeDto minus studentId/subjectGroupId (a mark can't move student/SG), all optional. Use class-validator optionals.

  • 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+record structure from AttendanceService.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 computed effectiveMark.

  • 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 load rosterCount (via loadSubjectGroupRosterIds().size or a count), gradeCount (prisma.gradeEntry.count({ where: { subjectGroupId } })), lastGradedAt (findFirst orderBy recordedAt desc). Return { cards }.

  • getSubjectGroupGradebook: load sg (404 if missing); for a teacher, assert it's in their taught set (else GRADE_NOT_AUTHORIZED — gradebook is a write-surface view); load scaleCtx once; load the roster (ids → student names); load all gradeEntry for the SG (optional assignmentDate range), include criterionMarks; map each via toGradeEntryDto(row, scaleCtx); group by studentId and emit every roster student (empty grades allowed). 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/me resolves the caller's own student row and returns only own grades grouped by SG; referent getStudentGrades for a non-linked student returns empty (visibility where excludes); homeroom view groups by student then subject.

  • Step 2: Run to verify failurenpx 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 passnpx 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/updated verbs; not-visible id → GRADE_NOT_FOUND.

  • Step 2: Run to verify failurenpx jest src/grades/grades.service.spec.ts -t history → FAIL.

  • Step 3: Implement — load the entry through buildGradeVisibilityWhere AND { id } (404 GRADE_NOT_FOUND); load its scaleCtx to map current via toGradeEntryDto; history = await this.audit.historyFor(tenantId, 'grade_entry', id). Import AuditLogEntryResponseDto type from ../audit-log for the DTO. Mirror AttendanceService.cellHistory (attendance.service.ts:634).

  • Step 4: Run to verify passnpx 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/history such that me and the static prefixes are not shadowed by :id. There is no bare GET /grades/:id, so collisions are limited — but keep me and :id/history declared before any future param-only GET. Mirror the decorator imports from src/attendance/attendance.controller.ts.

  • Step 2: Implement grades.swagger.ts@ApiGradesController() + one Api* decorator per route (operation summary + response type + the 6 error examples on the write routes). Mirror src/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 — add GradesModule to 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 — mirror docs/19-attendance.md structure: mental model (criteria-derived overridable mark, two dates), data model (soft-FK snapshot + the finalOverride ?? avg formula + 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 a GradeType; the override semantics).

  • Step 2: Update docs/REFERENCE.md — add a grades/ module-map row (§4), a "Record or read a grade" file-index row (§6) listing src/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 — add grades/ 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.