Skip to content

Attendance Register 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 (- [ ]) syntax for tracking.

Goal: A compliance-grade attendance register keyed off Department.attendanceMode (DAILY/PERIOD), where teachers record the roster in front of them, every change is audited, and records survive deletion/reorg of the operational graph.

Architecture: attendance_records are immutable compliance documents — deep snapshot + soft-FKs (no cascade from SubjectGroup/Timetable). Recordable cells are computed lazily from the single PUBLISHED timetable ∩ the department's school-days (no materialized grid). Write authority is the in-service "Y-set"; read visibility is a reused record-level policy. Value-change history lives in the shipped audit_log (this module is its first consumer). A bespoke AttendanceService (not BaseTenantedCrudService) does bulk roster upsert + per-cell PATCH + reads.

Tech Stack: NestJS 11, Prisma (PostgreSQL), class-validator, Jest + Supertest. Depends on src/audit-log/ (shipped).

Spec: docs/superpowers/specs/2026-06-25-attendance-design.md (Approved 2026-06-25). Audit-log dependency is already implemented and verified.

Global Constraints

  • Tenant isolation: every attendance_records row carries tenantId; every read/write filters it via @TenantId(). Never from the body.
  • AY scope: attendance is the active AY's; resolve via resolveActiveYear(this.prisma, tenantId) (src/common/utils/resolve-active-year.ts). Taking targets the active AY's single PUBLISHED timetable.
  • A record is a frozen compliance document: deep snapshot + soft-FKs (onDelete: SetNull). Hard FKs only tenant, academicYear, student (Restrict). No FK to Timetable/ScheduledLesson, no cascade from SubjectGroup.
  • Value-change history lives in audit_log via AuditService.record(tx, actor, params) (in-tx). There is no bespoke attendance-history table.
  • Lazy grid: a row exists only once recorded (findFirst-then-create/update). "Not taken" = absence of row.
  • Status field rules: time only on EARLY_EXIT/LATE_ENTRY; note forbidden on PRESENT/ABSENT; justification only on ABSENT/EARLY_EXIT/LATE_ENTRY.
  • Write authority = Y-set (asserted in-service per student); read = AttendancePolicy. Two gates.
  • Roles v1: admin + teacher only.
  • Do not run build/test/lint/migrate unless the user asks (per CLAUDE.md). The user owns git — no commits between tasks; leave the tree staged.
  • Before npx prisma migrate dev: read docs/12-migrations.md; the migration appends hand-authored partial unique indexes (Prisma can't express them).

Key upstream signatures (confirmed, mirror exactly)

  • resolveActiveYear(prisma, tenantId, academicYearId?) => Promise<string>src/common/utils/resolve-active-year.ts.
  • resolveEffectiveTemplatesForAcademicYear(db, tenantId, academicYearId) => Promise<Map<gradeId, EffectiveTemplate>> where EffectiveTemplate.periodSlotsByDay: Record<DayOfWeek, Array<{ periodOrdinalPosition: number; periodRank: number; wallStart: string; wallEnd: string }>>src/timetables/timetables.queries.ts.
  • findLessonsForView(db, where: Prisma.ScheduledLessonWhereInput) => Promise<LessonResponseRow[]>src/timetables/timetables.queries.ts (hydrates subjectGroup + teachers + room).
  • definePolicy<TWhere>({ entityKey, buildBase, branches, parametricBranches? }) + NEVER_MATCH_WHEREsrc/common/utils/entity-access-policy.ts. @AppliesPolicy(Policy)src/common/decorators/applies-policy.decorator.ts.
  • AuditService.resolveActor(tx, ctx) => Promise<AuditActor> and AuditService.record(tx, actor, { entityType, entityId, action, data }) => Promise<void>src/audit-log (shipped).
  • RecordAccessContextsrc/common/interfaces/record-access-context.interface.ts (tenantId, userId, roles[], isPlatformAdmin, parameters, teacherDepartmentIds, parameterDimensions).
  • DayOfWeek enum MON..SUN; AttendanceMode enum DAILY|PERIOD; PeriodType TERM|CLOSINGsrc/generated/prisma/client.
  • AppException(code, message, HttpStatus, { params })src/common/exceptions/app.exception.ts.

Task 1: Schema — AttendanceStatus enum + attendance_records table + partial-unique migration

Files: - Modify: prisma/schema.prisma - Create: prisma/migrations/<timestamp>_add_attendance/migration.sql (generated, then hand-append partial uniques)

Interfaces: - Produces: enum AttendanceStatus; model AttendanceRecord (delegate prisma.attendanceRecord); back-relations on Tenant, AcademicYear, Student.

  • Step 1: Add the enum + model to prisma/schema.prisma
enum AttendanceStatus {
  PRESENT
  ABSENT
  EARLY_EXIT
  LATE_ENTRY
  FIELD_TRIP
  DAY_TRIP

  @@map("attendance_status")
}

/// Compliance attendance register row. Immutable historical fact: deep snapshot
/// of the teaching context at record time + soft-FKs (SetNull) so it survives
/// hard-delete / archive / mid-year reorg of subject groups & timetables. Value
/// history lives in audit_logs (entityType="attendance_record"); this table only
/// holds current state. One cell per (student, date) in DAILY mode
/// (period_ordinal_position NULL) or per (student, date, period) in PERIOD mode —
/// enforced by two partial unique indexes (see migration).
model AttendanceRecord {
  id             String         @id @default(uuid()) @db.Uuid
  tenantId       String         @map("tenant_id") @db.Uuid
  tenant         Tenant         @relation(fields: [tenantId], references: [id], onDelete: Restrict)
  academicYearId String         @map("academic_year_id") @db.Uuid
  academicYear   AcademicYear   @relation(fields: [academicYearId], references: [id], onDelete: Restrict)

  studentId                 String  @map("student_id") @db.Uuid
  student                   Student @relation(fields: [studentId], references: [id], onDelete: Restrict)
  studentFirstName          String  @map("student_first_name") @db.VarChar(200)
  studentLastName           String  @map("student_last_name") @db.VarChar(200)
  studentIdentificationCode String? @map("student_identification_code") @db.VarChar(100)

  date                  DateTime @db.Date
  periodOrdinalPosition Int?     @map("period_ordinal_position") /// NULL = daily cell
  periodLabel           String?  @map("period_label") @db.VarChar(100)
  periodStartTime       String?  @map("period_start_time") @db.VarChar(5)
  periodEndTime         String?  @map("period_end_time") @db.VarChar(5)
  attendanceMode        AttendanceMode @map("attendance_mode")

  departmentId   String  @map("department_id") @db.Uuid
  departmentName String  @map("department_name") @db.VarChar(200)
  gradeId        String  @map("grade_id") @db.Uuid
  gradeName      String  @map("grade_name") @db.VarChar(200)
  homeroomId     String? @map("homeroom_id") @db.Uuid
  homeroomName   String? @map("homeroom_name") @db.VarChar(200)

  subjectGroupId      String?  @map("subject_group_id") @db.Uuid
  subjectGroupName    String?  @map("subject_group_name") @db.VarChar(200)
  curriculumSubjectId String?  @map("curriculum_subject_id") @db.Uuid
  subjectName         String?  @map("subject_name") @db.VarChar(200)
  subjectCode         String?  @map("subject_code") @db.VarChar(100)
  roomId              String?  @map("room_id") @db.Uuid
  roomName            String?  @map("room_name") @db.VarChar(200)
  lessonTeacherIds    String[] @map("lesson_teacher_ids") @db.Uuid
  lessonTeacherNames  String[] @map("lesson_teacher_names")

  status              AttendanceStatus
  time                String?  @db.VarChar(5)
  note                String?
  isJustified         Boolean  @default(false) @map("is_justified")
  justificationReason String?  @map("justification_reason")

  recordedByUserId   String?  @map("recorded_by_user_id") @db.Uuid
  recordedByName     String   @map("recorded_by_name") @db.VarChar(200)
  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(200)
  lastModifiedAt     DateTime @updatedAt @map("last_modified_at")

  @@index([tenantId, academicYearId, date])
  @@index([tenantId, studentId, date])
  @@index([tenantId, subjectGroupId, date])
  @@index([tenantId, homeroomId, date])
  @@map("attendance_records")
}
  • Step 2: Add back-relations

Tenant model: attendanceRecords AttendanceRecord[]. AcademicYear model: attendanceRecords AttendanceRecord[]. Student model: attendanceRecords AttendanceRecord[].

  • Step 3: Generate the migration

Run: npx prisma migrate dev --name add_attendance --create-only (--create-only so we can hand-append the partial uniques before applying.) Expected: a new migration folder with CREATE TYPE "attendance_status", CREATE TABLE "attendance_records", four CREATE INDEX, FKs.

  • Step 4: Hand-append the two partial unique indexes

At the END of the generated migration.sql, append:

-- One DAILY cell per (tenant, student, date) — partial uniques can't be
-- expressed in Prisma schema; NULLs are distinct in Postgres so a plain
-- composite unique would not dedupe daily cells.
CREATE UNIQUE INDEX "attendance_daily_unique"
  ON "attendance_records" ("tenant_id", "student_id", "date")
  WHERE "period_ordinal_position" IS NULL;

-- One PERIOD cell per (tenant, student, date, period).
CREATE UNIQUE INDEX "attendance_period_unique"
  ON "attendance_records" ("tenant_id", "student_id", "date", "period_ordinal_position")
  WHERE "period_ordinal_position" IS NOT NULL;
  • Step 5: Audit the SQL (chapter 12), then apply

Confirm additive-only (CREATE TYPE/TABLE/INDEX + FKs, no DROP, no data change). Then: Run: npx prisma migrate dev (applies it) and npx prisma generate. Expected: DB in sync; prisma.attendanceRecord delegate available.


Task 2: RBAC wiring (entity attendance, field scope register, action take)

Files: - Modify: src/common/constants/entity-keys.ts, src/common/constants/scope-fields.ts - Modify: prisma/seed/rbac-catalogue.ts, prisma/seed/roles.ts - Modify: src/permissions/rbac-catalogue.drift.spec.ts, src/common/constants/entity-groups.ts - Modify: docs/REFERENCE.md (§4 module-map row — done in Task 9, but the drift guard only needs the folder to exist; add the row in Task 9)

Interfaces: - Produces: EntityKey.ATTENDANCE='attendance'; scope attendance.register (field-level); action attendance.take requiring attendance.register write; admin + teacher grants.

  • Step 1: Entity key — in entity-keys.ts add after AUDIT_LOG:
  ATTENDANCE: 'attendance',
  • Step 2: Scope constant + registry — in scope-fields.ts add (after AUDIT_LOG_SCOPES):
// Attendance register = field-level scope (status/time/note/justification +
// provenance snapshots). NOT flat — a future referent/student role sees a
// narrowed subset. Snapshot context columns are read-exposed via this scope.
export const ATTENDANCE_SCOPES = {
  register: [
    'status',
    'time',
    'note',
    'isJustified',
    'justificationReason',
    'date',
    'periodOrdinalPosition',
    'periodLabel',
    'periodStartTime',
    'periodEndTime',
    'attendanceMode',
    'studentId',
    'studentFirstName',
    'studentLastName',
    'studentIdentificationCode',
    'departmentId',
    'departmentName',
    'gradeId',
    'gradeName',
    'homeroomId',
    'homeroomName',
    'subjectGroupId',
    'subjectGroupName',
    'curriculumSubjectId',
    'subjectName',
    'subjectCode',
    'roomId',
    'roomName',
    'lessonTeacherIds',
    'lessonTeacherNames',
    'recordedByUserId',
    'recordedByName',
    'recordedAt',
    'lastModifiedByUserId',
    'lastModifiedByName',
    'lastModifiedAt',
  ] as const,
} as const;

Add to ENTITY_SCOPE_REGISTRY:

  attendance: new Set(Object.keys(ATTENDANCE_SCOPES)),

Do not add attendance to FLAT_DTO_ENTITIES (it is field-level).

  • Step 3: rbac-catalogue — in prisma/seed/rbac-catalogue.ts:

ENTITIES (after audit_log, sortOrder 19):

  { key: 'attendance', label: 'Attendance', description: 'Compliance attendance register (DAILY/PERIOD)', sortOrder: 19 },

SCOPES:

  attendance: [
    { key: 'register', label: 'Register', description: 'Attendance cells: status, time, note, justification, and provenance', sortOrder: 1 },
  ],

ACTIONS array (add):

  { entityKey: 'attendance', key: 'take', label: 'Take Attendance', description: 'Create or update an attendance cell (incl. justification + admin override)', sortOrder: 1 },

ACTION_REQUIREMENTS map (add):

  'attendance.take': { scopeKeys: ['attendance.register'] },

FIELD_MAPPINGS array (add — import ATTENDANCE_SCOPES at top of the file alongside the other _SCOPES imports):

  { entityKey: 'attendance', scopeKey: 'register', tableName: 'attendance_records', fields: ATTENDANCE_SCOPES.register },

  • Step 4: Teacher + admin grants — in prisma/seed/roles.ts:

Add 'attendance.register' to TEACHER_WRITE_SCOPE_KEYS:

const TEACHER_WRITE_SCOPE_KEYS = new Set([
  'teachers.identity',
  'teachers.contacts',
  'teachers.documents',
  'teachers.health',
  'attendance.register',
]);

Grant the teacher the take action — in the teacher seedRoleForTenant({...}) call (the one with key: 'teacher'), add an actions field (teacher currently has none):

    nativeScopes: {
      excludedKeys: TEACHER_EXCLUDED_SCOPE_KEYS,
      access: { writeScopeKeys: TEACHER_WRITE_SCOPE_KEYS },
    },
    othersScopes: 'ALL_READ',
    actions: new Set(['attendance.take']),

Admin auto-inherits attendance.register (ALL_WRITE) + attendance.take (ALL) — no edit needed. principal/hr/department_head get latent read on attendance.register; the routes are role-gated to admin+teacher so this is inert (mirrors timetables).

  • Step 5: Update the two drift guards

src/permissions/rbac-catalogue.drift.spec.ts: import ATTENDANCE_SCOPES and add to RUNTIME_SCOPE_FIELDS:

  attendance: ATTENDANCE_SCOPES,
Also add ...Object.values(ATTENDANCE_SCOPES) to the knownRuntimeArrays set in the "uses the runtime arrays by reference" test (so the FIELD_MAPPINGS reference check passes).

src/common/constants/entity-groups.ts: add EntityKey.ATTENDANCE to the CLASS_COMPOSITION group (it sits with homerooms/subject-groups operationally) and update that group's description to mention attendance.

  • Step 6: Re-seed

Run: npx prisma db seed Expected: completes; drift guard passes (attendance.register has its runtime constant + FIELD_MAPPINGS row).


Task 3: Error codes

Files: - Modify: src/common/constants/error-codes.ts, src/common/constants/error-examples.ts

Interfaces: - Produces: ErrorCode.ATTENDANCE_NO_PUBLISHED_TIMETABLE, ATTENDANCE_NOT_A_SCHOOL_DAY, ATTENDANCE_FUTURE_DATE, ATTENDANCE_NOT_AUTHORIZED_FOR_STUDENT, ATTENDANCE_STUDENT_NOT_IN_LESSON_ROSTER.

  • Step 1: Add the codes in error-codes.ts (alongside the other entries):
  ATTENDANCE_NO_PUBLISHED_TIMETABLE = 'ATTENDANCE_NO_PUBLISHED_TIMETABLE',
  ATTENDANCE_NOT_A_SCHOOL_DAY = 'ATTENDANCE_NOT_A_SCHOOL_DAY',
  ATTENDANCE_FUTURE_DATE = 'ATTENDANCE_FUTURE_DATE',
  ATTENDANCE_NOT_AUTHORIZED_FOR_STUDENT = 'ATTENDANCE_NOT_AUTHORIZED_FOR_STUDENT',
  ATTENDANCE_STUDENT_NOT_IN_LESSON_ROSTER = 'ATTENDANCE_STUDENT_NOT_IN_LESSON_ROSTER',
  • Step 2: Add one Swagger example per code in error-examples.ts, mirroring an existing entry's shape ({ summary, value: { statusCode, code, message, timestamp, path } }). Use status codes: 409 / 422 / 422 / 403 / 422 respectively.

Task 4: DTOs

Files: - Create: src/attendance/dto/take-attendance.dto.ts, update-attendance-cell.dto.ts, cohort-cards-query.dto.ts, grid-query.dto.ts, attendance-response.dto.ts

Interfaces: - Produces: TakeAttendanceDto, AttendanceEntryInputDto, UpdateAttendanceCellDto, CohortCardsQueryDto, GridQueryDto, and response DTOs AttendanceCellDto, AttendanceCohortCardDto, AttendanceGridDto, AttendanceCellHistoryDto.

  • Step 1: Write the input DTOs (take-attendance.dto.ts):
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
  IsArray, IsBoolean, IsEnum, IsISO8601, IsInt, IsOptional,
  IsString, IsUUID, Matches, Min, ValidateNested,
} from 'class-validator';
import { AttendanceStatus } from '../../generated/prisma/client';

export class AttendanceEntryInputDto {
  @IsUUID() @ApiProperty()
  studentId: string;

  @IsEnum(AttendanceStatus) @ApiProperty({ enum: AttendanceStatus })
  status: AttendanceStatus;

  @IsOptional() @Matches(/^\d{2}:\d{2}$/) @ApiPropertyOptional({ example: '11:30' })
  time?: string;

  @IsOptional() @IsString() @ApiPropertyOptional()
  note?: string;

  @IsOptional() @IsBoolean() @ApiPropertyOptional()
  isJustified?: boolean;

  @IsOptional() @IsString() @ApiPropertyOptional()
  justificationReason?: string;
}

export class TakeAttendanceDto {
  @IsISO8601() @ApiProperty({ example: '2026-06-25' })
  date: string;

  @IsUUID() @ApiProperty()
  subjectGroupId: string;

  @IsOptional() @Type(() => Number) @IsInt() @Min(0)
  @ApiPropertyOptional({ description: 'Required for PERIOD departments; omitted for DAILY.' })
  periodOrdinalPosition?: number;

  @IsArray() @ValidateNested({ each: true }) @Type(() => AttendanceEntryInputDto)
  @ApiProperty({ type: [AttendanceEntryInputDto] })
  entries: AttendanceEntryInputDto[];
}

update-attendance-cell.dto.ts:

import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsEnum, IsOptional, IsString, Matches } from 'class-validator';
import { AttendanceStatus } from '../../generated/prisma/client';

export class UpdateAttendanceCellDto {
  @IsOptional() @IsEnum(AttendanceStatus) @ApiPropertyOptional({ enum: AttendanceStatus })
  status?: AttendanceStatus;

  @IsOptional() @Matches(/^\d{2}:\d{2}$/) @ApiPropertyOptional()
  time?: string;

  @IsOptional() @IsString() @ApiPropertyOptional()
  note?: string;

  @IsOptional() @IsBoolean() @ApiPropertyOptional()
  isJustified?: boolean;

  @IsOptional() @IsString() @ApiPropertyOptional()
  justificationReason?: string;
}

  • Step 2: Write the query DTOs (cohort-cards-query.dto.ts, grid-query.dto.ts):

// cohort-cards-query.dto.ts
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsUUID } from 'class-validator';
export class CohortCardsQueryDto {
  @IsOptional() @IsUUID() @ApiPropertyOptional()
  departmentId?: string;
  @IsOptional() @IsUUID() @ApiPropertyOptional()
  gradeId?: string;
}
// grid-query.dto.ts
import { ApiProperty } from '@nestjs/swagger';
import { IsEnum, IsISO8601, IsUUID } from 'class-validator';
export enum AttendanceCohortType { HOMEROOM = 'homeroom', GRADE_GROUP = 'grade-group' }
export class GridQueryDto {
  @IsEnum(AttendanceCohortType) @ApiProperty({ enum: AttendanceCohortType })
  cohortType: AttendanceCohortType;
  @IsUUID() @ApiProperty({ description: 'homeroomId, or gradeId for grade-group' })
  cohortId: string;
  @IsISO8601() @ApiProperty({ example: '2026-06-01' })
  from: string;
  @IsISO8601() @ApiProperty({ example: '2026-06-30' })
  to: string;
}

  • Step 3: Write the response DTOs (attendance-response.dto.ts) — AttendanceCellDto (one record projected; all register fields), AttendanceCohortCardDto ({ cohortType, cohortId, name, departmentName, gradeName, rosterCount, takenToday, totalToday }), AttendanceGridDto ({ mode, students: [{ studentId, name }], days: [{ date, slots: [{ periodOrdinalPosition?, cell: AttendanceCellDto | null }] }] }), AttendanceCellHistoryDto ({ current: AttendanceCellDto, history: AuditLogEntryResponseDto[] }). Use @ApiProperty referencing real DTO classes; no unknown[].

Task 5: Pure status-field validation helper (TDD)

Files: - Create: src/attendance/attendance-status-rules.ts, src/attendance/attendance-status-rules.spec.ts

Interfaces: - Produces: assertValidStatusFields(input: { status, time?, note?, isJustified?, justificationReason? }): void — throws AppException(VALIDATION_FAILED, …, 400) on rule violation.

  • Step 1: Failing test (attendance-status-rules.spec.ts):
import { assertValidStatusFields } from './attendance-status-rules';
import { AttendanceStatus } from '../generated/prisma/client';
import { AppException } from '../common/exceptions/app.exception';

describe('assertValidStatusFields', () => {
  it('accepts PRESENT with no extras', () => {
    expect(() => assertValidStatusFields({ status: AttendanceStatus.PRESENT })).not.toThrow();
  });
  it('rejects time on a non-transition status', () => {
    expect(() => assertValidStatusFields({ status: AttendanceStatus.PRESENT, time: '09:00' })).toThrow(AppException);
  });
  it('accepts time on EARLY_EXIT', () => {
    expect(() => assertValidStatusFields({ status: AttendanceStatus.EARLY_EXIT, time: '11:30', note: 'dentist' })).not.toThrow();
  });
  it('rejects note on ABSENT', () => {
    expect(() => assertValidStatusFields({ status: AttendanceStatus.ABSENT, note: 'x' })).toThrow(AppException);
  });
  it('rejects justification on FIELD_TRIP', () => {
    expect(() => assertValidStatusFields({ status: AttendanceStatus.FIELD_TRIP, isJustified: true })).toThrow(AppException);
  });
  it('accepts justification on ABSENT', () => {
    expect(() => assertValidStatusFields({ status: AttendanceStatus.ABSENT, isJustified: true, justificationReason: 'flu' })).not.toThrow();
  });
});
  • Step 2: Run → fail (npx jest src/attendance/attendance-status-rules.spec.ts → module not found).

  • Step 3: Implement (attendance-status-rules.ts):

import { HttpStatus } from '@nestjs/common';
import { AttendanceStatus } from '../generated/prisma/client';
import { AppException } from '../common/exceptions/app.exception';
import { ErrorCode } from '../common/constants/error-codes';

const TRANSITION = new Set<AttendanceStatus>([
  AttendanceStatus.EARLY_EXIT,
  AttendanceStatus.LATE_ENTRY,
]);
const NOTE_FORBIDDEN = new Set<AttendanceStatus>([
  AttendanceStatus.PRESENT,
  AttendanceStatus.ABSENT,
]);
const JUSTIFIABLE = new Set<AttendanceStatus>([
  AttendanceStatus.ABSENT,
  AttendanceStatus.EARLY_EXIT,
  AttendanceStatus.LATE_ENTRY,
]);

export interface StatusFields {
  status: AttendanceStatus;
  time?: string;
  note?: string;
  isJustified?: boolean;
  justificationReason?: string;
}

export function assertValidStatusFields(f: StatusFields): void {
  const fail = (field: string, rule: string) => {
    throw new AppException(
      ErrorCode.VALIDATION_FAILED,
      `Invalid attendance field "${field}" for status ${f.status}`,
      HttpStatus.BAD_REQUEST,
      { params: { errors: [{ field, rule }] } },
    );
  };
  if (f.time && !TRANSITION.has(f.status)) fail('time', 'only-on-transition-status');
  if (f.note && NOTE_FORBIDDEN.has(f.status)) fail('note', 'forbidden-on-present-absent');
  if ((f.isJustified || f.justificationReason) && !JUSTIFIABLE.has(f.status)) {
    fail('isJustified', 'only-on-absence-status');
  }
}
  • Step 4: Run → pass.

Task 6: Pure Y-set write-authority classifier (TDD)

Files: - Create: src/attendance/attendance-authority.ts, src/attendance/attendance-authority.spec.ts

Interfaces: - Produces: canWriteStudentCell(actor: { isAdmin: boolean; teacherId: string | null }, facts: { lessonTeacherIds: string[]; homeroomTeacherId: string | null; teachesStudentTodayTeacherIds: string[] }): boolean.

  • Step 1: Failing test:
import { canWriteStudentCell } from './attendance-authority';

const facts = (o: Partial<Parameters<typeof canWriteStudentCell>[1]> = {}) => ({
  lessonTeacherIds: [], homeroomTeacherId: null, teachesStudentTodayTeacherIds: [], ...o,
});

describe('canWriteStudentCell', () => {
  it('admin can always write', () => {
    expect(canWriteStudentCell({ isAdmin: true, teacherId: null }, facts())).toBe(true);
  });
  it('lesson teacher can write', () => {
    expect(canWriteStudentCell({ isAdmin: false, teacherId: 't1' }, facts({ lessonTeacherIds: ['t1'] }))).toBe(true);
  });
  it('homeroom teacher can write even if not the lesson teacher', () => {
    expect(canWriteStudentCell({ isAdmin: false, teacherId: 't9' }, facts({ homeroomTeacherId: 't9' }))).toBe(true);
  });
  it('teacher who teaches the student today can write', () => {
    expect(canWriteStudentCell({ isAdmin: false, teacherId: 't5' }, facts({ teachesStudentTodayTeacherIds: ['t5', 't6'] }))).toBe(true);
  });
  it('unrelated teacher cannot write', () => {
    expect(canWriteStudentCell({ isAdmin: false, teacherId: 'tX' }, facts({ lessonTeacherIds: ['t1'], homeroomTeacherId: 't9', teachesStudentTodayTeacherIds: ['t5'] }))).toBe(false);
  });
  it('non-admin with no teacherId cannot write', () => {
    expect(canWriteStudentCell({ isAdmin: false, teacherId: null }, facts({ lessonTeacherIds: ['t1'] }))).toBe(false);
  });
});
  • Step 2: Run → fail.

  • Step 3: Implement:

export interface AttendanceActor {
  isAdmin: boolean;
  teacherId: string | null;
}
export interface StudentAuthorityFacts {
  lessonTeacherIds: string[];
  homeroomTeacherId: string | null;
  teachesStudentTodayTeacherIds: string[];
}

export function canWriteStudentCell(
  actor: AttendanceActor,
  facts: StudentAuthorityFacts,
): boolean {
  if (actor.isAdmin) return true;
  const t = actor.teacherId;
  if (!t) return false;
  return (
    facts.lessonTeacherIds.includes(t) ||
    facts.homeroomTeacherId === t ||
    facts.teachesStudentTodayTeacherIds.includes(t)
  );
}
  • Step 4: Run → pass.

Task 7: School-day + recordable-cell resolver (TDD on the pure part)

Files: - Create: src/common/utils/day-of-week.ts (+ spec), src/attendance/attendance-calendar.ts (+ spec)

Interfaces: - Produces: toDayOfWeek(date: Date): DayOfWeek; isSchoolDay(date, { calendarStartDate, calendarEndDate, closingPeriods, operativeWeekdays }): boolean; enumerateSchoolDays(from, to, calendar): Date[].

  • Step 1: Failing test (src/common/utils/day-of-week.spec.ts):
import { toDayOfWeek } from './day-of-week';
import { DayOfWeek } from '../../generated/prisma/client';
describe('toDayOfWeek', () => {
  it('maps a Monday to MON', () => {
    expect(toDayOfWeek(new Date('2026-06-22'))).toBe(DayOfWeek.MON); // 2026-06-22 is a Monday
  });
  it('maps a Sunday to SUN', () => {
    expect(toDayOfWeek(new Date('2026-06-21'))).toBe(DayOfWeek.SUN);
  });
});
  • Step 2: Run → fail. Step 3: Implement (day-of-week.ts):

import { DayOfWeek } from '../../generated/prisma/client';
const MAP: DayOfWeek[] = [
  DayOfWeek.SUN, DayOfWeek.MON, DayOfWeek.TUE, DayOfWeek.WED,
  DayOfWeek.THU, DayOfWeek.FRI, DayOfWeek.SAT,
];
export function toDayOfWeek(date: Date): DayOfWeek {
  return MAP[date.getUTCDay()];
}
Step 4: Run → pass.

  • Step 5: Failing test (src/attendance/attendance-calendar.spec.ts) for isSchoolDay:
import { isSchoolDay } from './attendance-calendar';
import { DayOfWeek } from '../generated/prisma/client';

const cal = {
  calendarStartDate: new Date('2026-09-01'),
  calendarEndDate: new Date('2027-06-30'),
  closingPeriods: [{ startDate: new Date('2026-12-23'), endDate: new Date('2027-01-06') }],
  operativeWeekdays: new Set<DayOfWeek>([DayOfWeek.MON, DayOfWeek.TUE, DayOfWeek.WED, DayOfWeek.THU, DayOfWeek.FRI]),
};

describe('isSchoolDay', () => {
  it('true for a normal weekday in range', () => {
    expect(isSchoolDay(new Date('2026-09-07'), cal)).toBe(true); // Monday
  });
  it('false outside the calendar bounds', () => {
    expect(isSchoolDay(new Date('2026-08-31'), cal)).toBe(false);
  });
  it('false inside a CLOSING period', () => {
    expect(isSchoolDay(new Date('2026-12-25'), cal)).toBe(false);
  });
  it('false on a non-operative weekday (Sat)', () => {
    expect(isSchoolDay(new Date('2026-09-05'), cal)).toBe(false);
  });
});
  • Step 6: Run → fail. Step 7: Implement (attendance-calendar.ts):

import { DayOfWeek } from '../generated/prisma/client';
import { toDayOfWeek } from '../common/utils/day-of-week';

export interface AttendanceCalendar {
  calendarStartDate: Date;
  calendarEndDate: Date;
  closingPeriods: { startDate: Date; endDate: Date }[];
  operativeWeekdays: ReadonlySet<DayOfWeek>;
}

export function isSchoolDay(date: Date, cal: AttendanceCalendar): boolean {
  if (date < cal.calendarStartDate || date > cal.calendarEndDate) return false;
  if (!cal.operativeWeekdays.has(toDayOfWeek(date))) return false;
  for (const c of cal.closingPeriods) {
    if (date >= c.startDate && date <= c.endDate) return false;
  }
  return true;
}

export function enumerateSchoolDays(from: Date, to: Date, cal: AttendanceCalendar): Date[] {
  const out: Date[] = [];
  for (let d = new Date(from); d <= to; d.setUTCDate(d.getUTCDate() + 1)) {
    if (isSchoolDay(d, cal)) out.push(new Date(d));
  }
  return out;
}
Step 8: Run → pass.

operativeWeekdays is derived per grade from the effective week template (a weekday is operative iff its dayTemplate is non-null). The service builds it from resolveEffectiveTemplatesForAcademicYear (Task 8).


Task 8: Queries — lesson snapshot, cohort loaders, grid loader, Y-set fact loaders

Files: - Create: src/attendance/attendance.queries.ts (+ attendance.queries.spec.ts for the pure mapper)

Interfaces: - Produces: buildLessonSnapshot(lesson) → the snapshot column bag; loadPublishedTimetableId(db, tenantId, ayId); loadDepartmentCalendar(db, tenantId, ayId, departmentId)AttendanceCalendar; loadHomeroomRoster(db, homeroomId); loadGradeGroupRoster(db, tenantId, ayId, gradeId) (ungrouped); loadLessonForTake(db, timetableId, subjectGroupId, weekday, periodOrdinalPosition?); loadTeachesStudentTodayMap(db, timetableId, weekday, studentIds)Map<studentId, teacherId[]>; findCellsForCohort(db, where).

  • Step 1: Failing test for buildLessonSnapshot (pure mapper) — assert it copies subject/group/room/teacher/period names into the snapshot bag and leaves nothing undefined-vs-null inconsistent. (Construct a fake hydrated lesson row matching findLessonsForView's include shape; assert the returned object's subjectGroupName, subjectName, subjectCode, roomName, lessonTeacherIds, lessonTeacherNames.)

  • Step 2: Run → fail. Step 3: Implement attendance.queries.ts with:

  • buildLessonSnapshot(lesson) mapping the hydrated ScheduledLesson (subjectGroup.curriculumSubject {name, code}, subjectGroup.teachers ordered {teacherId, teacher:{firstName,lastName}}, room {name}) into { subjectGroupId, subjectGroupName, curriculumSubjectId, subjectName, subjectCode, roomId, roomName, lessonTeacherIds[], lessonTeacherNames[] }.
  • loadPublishedTimetableIddb.timetable.findFirst({ where: { tenantId, academicYearId, status: 'PUBLISHED' }, select: { id: true } }); throw ATTENDANCE_NO_PUBLISHED_TIMETABLE (409) if null.
  • loadDepartmentCalendar → read Department.calendarStartDate/calendarEndDate, Period.findMany({ type: 'CLOSING' }), and the grade's operativeWeekdays from resolveEffectiveTemplatesForAcademicYear (weekday operative iff periodSlotsByDay[weekday].length > 0). Returns AttendanceCalendar.
  • loadHomeroomRosterdb.homeroomAssignment.findMany({ where: { homeroomId }, include: { student: { select: studentSnapshotSelect } } }).
  • loadGradeGroupRosterdb.student.findMany({ where: { tenantId, academicYearId, gradeId, homeroomAssignment: null } , select: studentSnapshotSelect }).
  • loadLessonForTakefindLessonsForView(db, { timetableId, subjectGroupId, weekday, ...(period!=null?{periodOrdinalPosition:period}:{}) }) → first row; the lesson's roster comes from the SG (homeroom-bound → homeroom roster; standalone → SG assignments).
  • loadTeachesStudentTodayMap → for the weekday, find all lessons (findLessonsForView({ timetableId, weekday })) whose roster includes each studentId; build Map<studentId, teacherId[]> (union of teacherIds across that student's lessons that day).
  • findCellsForCohortdb.attendanceRecord.findMany({ where }).

  • Step 4: Run → pass (the buildLessonSnapshot spec; the DB loaders are exercised by the e2e in Task 10).


Task 9: AttendancePolicy + AttendanceService + controller + module + app wiring

Files: - Create: src/attendance/attendance.policy.ts, attendance.service.ts, attendance.controller.ts, attendance.swagger.ts, attendance.module.ts, index.ts - Modify: src/app.module.ts, docs/REFERENCE.md (§4 row) - Test: src/attendance/attendance.service.spec.ts (unit for the take-path authorization + audit emission, using mocked tx)

Interfaces: - Consumes: everything from Tasks 4–8; AuditService (src/audit-log). - Produces: AttendanceService.take, .updateCell, .listCohorts, .grid, .cellHistory; AttendancePolicy; GET /attendance/cohorts, GET /attendance/grid, POST /attendance/take, PATCH /attendance/records/:id, GET /attendance/records/:id/history.

  • Step 1: Write AttendancePolicy (attendance.policy.ts) — mirror HomeroomsPolicy shape but over Prisma.AttendanceRecordWhereInput. admin/hr/principal/staff pass-through; teacher narrowed by subjectGroupId in (their SGs) OR homeroomId in (homerooms they tutor) — for v1 simplest correct: teacher sees records whose gradeId/departmentIdteacherDepartmentIds (read is broad; write is the Y-set). Use definePolicy({ entityKey: EntityKey.ATTENDANCE, buildBase: (ctx) => ({ tenantId: ctx.tenantId }), branches: [admin/hr/principal/staff 'pass-through', { role: 'teacher', build: (ctx, base) => ctx.teacherDepartmentIds.length ? { ...base, departmentId: { in: ctx.teacherDepartmentIds } } : NEVER_MATCH_WHERE }] }). (referent/student deferred — omit.)

  • Step 2: Write the failing service unit test (attendance.service.spec.ts) covering the take authorization + audit emission with a mocked prisma.$transaction/tx + mocked AuditService:

  • admin take creates a cell + calls audit.record once per entry with action: 'attendance_record.created'.
  • a teacher not in the Y-set for a student → ATTENDANCE_NOT_AUTHORIZED_FOR_STUDENT (403).
  • future date → ATTENDANCE_FUTURE_DATE. (Mock the query-layer loaders so the test stays unit-level: stub loadPublishedTimetableId, loadLessonForTake, calendar, rosters, loadTeachesStudentTodayMap via jest.mock('./attendance.queries').)

  • Step 3: Run → fail. Step 4: Implement AttendanceService (attendance.service.ts). Core take() flow inside one $transaction:

  • academicYearId = await resolveActiveYear(prisma, tenantId).
  • parse date; reject ATTENDANCE_FUTURE_DATE if date > todayUtc().
  • timetableId = await loadPublishedTimetableId(...) (throws 409 if none).
  • weekday = toDayOfWeek(date); resolve the lesson via loadLessonForTake(...) — 422 if no such lesson; derive subjectGroup + roster + buildLessonSnapshot.
  • calendar = await loadDepartmentCalendar(...); reject ATTENDANCE_NOT_A_SCHOOL_DAY if !isSchoolDay(date, calendar).
  • resolve actor: isAdmin = ctx.roles.includes('admin'); teacherId = await teacherByUserIdAndYear(...) (null if not a teacher); auditActor = await audit.resolveActor(tx, ctx).
  • teachesTodayMap = await loadTeachesStudentTodayMap(...); for each entry: assert the student ∈ lesson roster (ATTENDANCE_STUDENT_NOT_IN_LESSON_ROSTER), run assertValidStatusFields, build per-student facts { lessonTeacherIds: snapshot.lessonTeacherIds, homeroomTeacherId, teachesStudentTodayTeacherIds: teachesTodayMap.get(studentId) ?? [] }, and if (!canWriteStudentCell({ isAdmin, teacherId }, facts)) throw ATTENDANCE_NOT_AUTHORIZED_FOR_STUDENT (403).
  • upsert the cell (findFirst by partial-unique tuple (tenantId, studentId, date, periodOrdinalPosition) → create or update), set provenance (recordedBy* on create; lastModifiedBy* on update), and await audit.record(tx, auditActor, { entityType: 'attendance_record', entityId: cell.id, action: created ? 'attendance_record.created' : 'attendance_record.updated', data: { status, time, note, isJustified, justificationReason } }).
  • periodOrdinalPosition is null for DAILY departments (read Department.attendanceMode), the request's value for PERIOD (reject if missing in PERIOD / present in DAILY).
  • updateCell() mirrors step 8 on an existing record id (404 if not found via policy where), re-validates status fields, re-checks the Y-set using the record's stored snapshot + a fresh teachesTodayMap, audits with attendance_record.updated / .justified when only justification changed / .admin_override when an admin edits a cell they don't otherwise own.
  • listCohorts(), grid(), cellHistory() are reads gated by AttendancePolicy.where(ctx); cellHistory calls the shipped AuditService read (filter entityType='attendance_record', entityId).

  • Step 5: Run → pass (service unit).

  • Step 6: Controller (attendance.controller.ts) — @Controller('attendance') @ProtectedResource(), the 5 routes from the spec §5 with @RequireScopes(ATTENDANCE,'read'|'write'), @RequireAction(ATTENDANCE,'take') on the two writes, @AppliesPolicy(AttendancePolicy) + @AggregateResponse() on the reads, @TenantId() + @AccessContext(). Swagger in attendance.swagger.ts.

  • Step 7: Module + barrel + app wiringattendance.module.ts (imports: [PrismaModule, AuditLogModule], controller, AttendanceService); index.ts exports the module + service; register AttendanceModule in src/app.module.ts (after AuditLogModule). Add the |attendance/| row to docs/REFERENCE.md §4 (satisfies the docs-module-map drift guard).


Task 10: E2E

Files: - Test: test/attendance.e2e-spec.ts

Interfaces: - Consumes: createTestApp, loginAsAdmin, loginAsTeacher, loginAsReferent (test/helpers).

  • Step 1: Write the e2e. Bootstrap like test/timetables.e2e-spec.ts (cookie agents; discover tenant + active AY). Seed prerequisites via the existing seeded fixtures (the E2E school already has departments/grades/homerooms/subject-groups/a publishable timetable — publish one via PUT /api/v1/timetables/:id/status {status:'PUBLISHED'} as admin). Cover:
  • PERIOD dept: POST /api/v1/attendance/take for a subject group + period → cells created; GET /attendance/records/:id/history shows one attendance_record.created audit row; GET /audit-log?entityType=attendance_record&entityId=… (admin) shows it.
  • DAILY dept: take without a period → one cell/day; a second authorized teacher PATCHes it to EARLY_EXIT + time (no first-lesson lock).
  • Y-set: a teacher not teaching/ tutoring the student → 403 ATTENDANCE_NOT_AUTHORIZED_FOR_STUDENT; admin overrides any cell.
  • Gates: future date → 422 ATTENDANCE_FUTURE_DATE; (temporarily) no published timetable → 409 ATTENDANCE_NO_PUBLISHED_TIMETABLE.
  • Compliance: hard-delete the subject group after recording (prisma.subjectGroup.delete) → the record still reads (snapshot intact, subjectGroupId null).
  • Reads: GET /attendance/cohorts (homerooms + grade-group cards), GET /attendance/grid (mode-aware), referent → 403.

  • Step 2: Run npx jest --config test/jest-e2e.json test/attendance.e2e-spec.ts → pass.


Self-Review

Spec coverage: record shape + snapshot + soft-FKs (Task 1) ✓; DAILY/PERIOD cell semantics + partial uniques (Task 1) ✓; RBAC entity/scope/action + teacher grant (Task 2) ✓; error codes (Task 3) ✓; status field rules (Task 5) ✓; Y-set authority (Task 6) ✓; school-day + recordable cells off PUBLISHED timetable (Tasks 7–8) ✓; lazy upsert + audit-log history (Task 9) ✓; 5 endpoints + policy + reads (Task 9) ✓; compliance survival + gates + RBAC e2e (Task 10) ✓. Deferred items (referent/student, maxAbsenceHours, trips, takeable endpoint, edit-window) correctly absent.

Placeholder scan: pure helpers (Tasks 5–7) have complete code + tests; the DB loaders (Task 8) and service flow (Task 9) are specified as concrete numbered logic against confirmed upstream signatures. Two implementation details to confirm against the live types at build time (flagged inline, not hidden): the exact element type returned by findLessonsForView (for buildLessonSnapshot's field access) and Homeroom.homeroomTeacherId nullability (the design assumes nullable). Neither changes the task structure.

Type consistency: AttendanceStatus/AttendanceMode/DayOfWeek from generated client throughout; canWriteStudentCell/assertValidStatusFields/isSchoolDay/toDayOfWeek signatures defined in Tasks 5–7 and consumed verbatim in Task 9; ATTENDANCE_SCOPES defined in Task 2 and referenced by the drift spec + FIELD_MAPPINGS; AuditService.record/resolveActor consumed exactly as shipped.

Known migration hazard: partial unique indexes are hand-appended (Task 1 Step 4) — migrate dev --create-only then audit then apply, per chapter 12.