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_recordsrow carriestenantId; 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 singlePUBLISHEDtimetable. - A record is a frozen compliance document: deep snapshot + soft-FKs (
onDelete: SetNull). Hard FKs onlytenant,academicYear,student(Restrict). No FK toTimetable/ScheduledLesson, no cascade fromSubjectGroup. - Value-change history lives in
audit_logviaAuditService.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:
timeonly onEARLY_EXIT/LATE_ENTRY;noteforbidden onPRESENT/ABSENT; justification only onABSENT/EARLY_EXIT/LATE_ENTRY. - Write authority = Y-set (asserted in-service per student); read =
AttendancePolicy. Two gates. - Roles v1:
admin+teacheronly. - Do not run
build/test/lint/migrateunless the user asks (perCLAUDE.md). The user owns git — no commits between tasks; leave the tree staged. - Before
npx prisma migrate dev: readdocs/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>>whereEffectiveTemplate.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(hydratessubjectGroup+teachers+room).definePolicy<TWhere>({ entityKey, buildBase, branches, parametricBranches? })+NEVER_MATCH_WHERE—src/common/utils/entity-access-policy.ts.@AppliesPolicy(Policy)—src/common/decorators/applies-policy.decorator.ts.AuditService.resolveActor(tx, ctx) => Promise<AuditActor>andAuditService.record(tx, actor, { entityType, entityId, action, data }) => Promise<void>—src/audit-log(shipped).RecordAccessContext—src/common/interfaces/record-access-context.interface.ts(tenantId, userId, roles[], isPlatformAdmin, parameters, teacherDepartmentIds, parameterDimensions).DayOfWeekenumMON..SUN;AttendanceModeenumDAILY|PERIOD;PeriodTypeTERM|CLOSING—src/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.tsadd afterAUDIT_LOG:
- Step 2: Scope constant + registry — in
scope-fields.tsadd (afterAUDIT_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:
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):
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:
...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; allregisterfields),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@ApiPropertyreferencing real DTO classes; nounknown[].
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 5: Failing test (
src/attendance/attendance-calendar.spec.ts) forisSchoolDay:
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;
}
operativeWeekdaysis derived per grade from the effective week template (a weekday is operative iff itsdayTemplateis non-null). The service builds it fromresolveEffectiveTemplatesForAcademicYear(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 matchingfindLessonsForView's include shape; assert the returned object'ssubjectGroupName,subjectName,subjectCode,roomName,lessonTeacherIds,lessonTeacherNames.) -
Step 2: Run → fail. Step 3: Implement
attendance.queries.tswith: buildLessonSnapshot(lesson)mapping the hydratedScheduledLesson(subjectGroup.curriculumSubject{name, code}, subjectGroup.teachers ordered{teacherId, teacher:{firstName,lastName}}, room{name}) into{ subjectGroupId, subjectGroupName, curriculumSubjectId, subjectName, subjectCode, roomId, roomName, lessonTeacherIds[], lessonTeacherNames[] }.loadPublishedTimetableId→db.timetable.findFirst({ where: { tenantId, academicYearId, status: 'PUBLISHED' }, select: { id: true } }); throwATTENDANCE_NO_PUBLISHED_TIMETABLE(409) if null.loadDepartmentCalendar→ readDepartment.calendarStartDate/calendarEndDate,Period.findMany({ type: 'CLOSING' }), and the grade'soperativeWeekdaysfromresolveEffectiveTemplatesForAcademicYear(weekday operative iffperiodSlotsByDay[weekday].length > 0). ReturnsAttendanceCalendar.loadHomeroomRoster→db.homeroomAssignment.findMany({ where: { homeroomId }, include: { student: { select: studentSnapshotSelect } } }).loadGradeGroupRoster→db.student.findMany({ where: { tenantId, academicYearId, gradeId, homeroomAssignment: null } , select: studentSnapshotSelect }).loadLessonForTake→findLessonsForView(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; buildMap<studentId, teacherId[]>(union of teacherIds across that student's lessons that day).-
findCellsForCohort→db.attendanceRecord.findMany({ where }). -
Step 4: Run → pass (the
buildLessonSnapshotspec; 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) — mirrorHomeroomsPolicyshape but overPrisma.AttendanceRecordWhereInput. admin/hr/principal/staff pass-through; teacher narrowed bysubjectGroupId in (their SGs)ORhomeroomId in (homerooms they tutor)— for v1 simplest correct: teacher sees records whosegradeId/departmentId∈teacherDepartmentIds(read is broad; write is the Y-set). UsedefinePolicy({ 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 mockedprisma.$transaction/tx + mockedAuditService: - admin take creates a cell + calls
audit.recordonce per entry withaction: '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: stubloadPublishedTimetableId,loadLessonForTake, calendar, rosters,loadTeachesStudentTodayMapviajest.mock('./attendance.queries').) -
Step 3: Run → fail. Step 4: Implement
AttendanceService(attendance.service.ts). Coretake()flow inside one$transaction: academicYearId = await resolveActiveYear(prisma, tenantId).- parse
date; rejectATTENDANCE_FUTURE_DATEifdate > todayUtc(). timetableId = await loadPublishedTimetableId(...)(throws 409 if none).weekday = toDayOfWeek(date); resolve the lesson vialoadLessonForTake(...)— 422 if no such lesson; derivesubjectGroup+ roster +buildLessonSnapshot.calendar = await loadDepartmentCalendar(...); rejectATTENDANCE_NOT_A_SCHOOL_DAYif!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), runassertValidStatusFields, build per-student facts{ lessonTeacherIds: snapshot.lessonTeacherIds, homeroomTeacherId, teachesStudentTodayTeacherIds: teachesTodayMap.get(studentId) ?? [] }, andif (!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), andawait audit.record(tx, auditActor, { entityType: 'attendance_record', entityId: cell.id, action: created ? 'attendance_record.created' : 'attendance_record.updated', data: { status, time, note, isJustified, justificationReason } }). periodOrdinalPositionisnullforDAILYdepartments (readDepartment.attendanceMode), the request's value forPERIOD(reject if missing in PERIOD / present in DAILY).updateCell()mirrors step 8 on an existing record id (404 if not found via policywhere), re-validates status fields, re-checks the Y-set using the record's stored snapshot + a freshteachesTodayMap, audits withattendance_record.updated/.justifiedwhen only justification changed /.admin_overridewhen an admin edits a cell they don't otherwise own.-
listCohorts(),grid(),cellHistory()are reads gated byAttendancePolicy.where(ctx);cellHistorycalls the shippedAuditServiceread (filterentityType='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 inattendance.swagger.ts. -
Step 7: Module + barrel + app wiring —
attendance.module.ts(imports: [PrismaModule, AuditLogModule], controller,AttendanceService);index.tsexports the module + service; registerAttendanceModuleinsrc/app.module.ts(afterAuditLogModule). Add the|attendance/|row todocs/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 viaPUT /api/v1/timetables/:id/status {status:'PUBLISHED'}as admin). Cover: - PERIOD dept:
POST /api/v1/attendance/takefor a subject group + period → cells created;GET /attendance/records/:id/historyshows oneattendance_record.createdaudit 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 toEARLY_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 → 409ATTENDANCE_NO_PUBLISHED_TIMETABLE. - Compliance: hard-delete the subject group after recording (
prisma.subjectGroup.delete) → the record still reads (snapshot intact,subjectGroupIdnull). -
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.