Attendance Register — Compliance Cells, Y-set Authority & Audit History¶
The attendance register (Italian registro) is a legal compliance document: who was present, absent, or in transition, for which lesson, on which day — recorded by an authorized teacher or admin and never silently mutated by structural reorg. Each row is an immutable historical fact carrying a deep snapshot of the teaching context at record time. Value changes are not destructive: every edit appends to the audit log (chapter 20), and the cell's own columns hold only current state.
Plan: 2026-06-25-attendance.md. Builds on the single PUBLISHED Timetable (chapter 18) the takeable grid derives from, on department school-day calendars (departments), and on the audit-log module (chapter 20) — attendance is its first consumer.
1. Mental model¶
Attendance is taken per lesson but recorded per cell. A cell is one (student, school-day) fact, scoped by attendance mode:
- DAILY mode — one cell per student per school-day;
periodOrdinalPosition IS NULL. The register asks "was the student at school today?". - PERIOD mode — one cell per student per scheduled lesson;
periodOrdinalPositionis the PERIOD slot ordinal. The register asks "was the student in this period's lesson?".
Mode is a property of the department (Department.attendanceMode, AttendanceMode { DAILY, PERIOD }), resolved from the lesson's grade → department at take time and frozen onto the row. Different departments in one tenant can run different modes simultaneously.
A take always targets a (subjectGroup, date, period?) lesson on the PUBLISHED timetable. The roster of that lesson defines who can be recorded; the Y-set (§5) defines who the caller is authorized to record.
2. Data model — the immutable snapshot¶
One model, AttendanceRecord (prisma/schema.prisma), plus enum AttendanceStatus { PRESENT, ABSENT, EARLY_EXIT, LATE_ENTRY, FIELD_TRIP, DAY_TRIP }.
The row is a deep snapshot. At create time the service copies the entire teaching context into denormalized columns — student name + identification code, department/grade/homeroom ids and names, subject-group/curriculum-subject/subject name + code, room id + name, the lesson's teacher ids + names, the resolved period label/start/end, and the attendance mode. None of these are re-synced afterward; the snapshot is the legal record of what was true on the day, not a live join.
Soft FKs. The only hard Restrict foreign keys are tenant, academicYear, and student — the three identities a compliance record can never lose. Everything else (subjectGroupId, curriculumSubjectId, roomId, homeroomId, departmentId, gradeId, lessonTeacherIds[]) is a plain column with no FK. This is deliberate: a subject group can be hard-deleted, a timetable re-published, a student moved mid-year — and the historical attendance rows survive untouched, still naming the context by the snapshot's *Name/*Code columns. (The schema doc-comment says "SetNull"; in the migration these columns simply carry no constraint — same intent, the names persist regardless.)
Two partial unique indexes, hand-authored in the migration (20260625133520_add_attendance/migration.sql) because Prisma can't express a WHERE on a unique:
attendance_daily_unique ON (tenant_id, student_id, date) WHERE period_ordinal_position IS NULL
attendance_period_unique ON (tenant_id, student_id, date, period_ordinal_position) WHERE period_ordinal_position IS NOT NULL
A plain composite unique would not dedupe daily cells: Postgres treats NULLs as distinct, so two NULL-period rows would both pass. The split index enforces "one daily cell" and "one cell per period" independently. See chapter 12 for why raw-SQL partial indexes live in the migration, not the schema.
Provenance columns track authorship without a history table: recordedByUserId / recordedByName / recordedAt (set once on create) and lastModifiedByUserId / lastModifiedByName / lastModifiedAt (@updatedAt, refreshed on every edit). Names are snapshotted so a deactivated user still reads correctly.
3. School-day derivation¶
A cell is recordable only on a date that is both a teaching day of the published timetable and a school day of the lesson's department calendar. isSchoolDay(date, cal) (attendance-calendar.ts) returns true iff:
- the date is within
[Department.calendarStartDate, calendarEndDate], - the weekday is operative — the grade's effective week template has ≥1 period slot on that weekday (
buildOperativeWeekdays), and - the date is not inside any
CLOSINGperiod of that department.
The calendar is assembled per take by loadDepartmentCalendar(...) (attendance.queries.ts), which reads the department's calendar bounds + its CLOSING periods and the operative-weekday set derived from resolveEffectiveTemplatesForAcademicYear(...) (reused from chapter 18's timetable queries — same effective-template resolution the timetable engine uses). enumerateSchoolDays(from, to, cal) walks a range; the take path only needs the single-day isSchoolDay check.
4. Take flow — POST /attendance/take¶
AttendanceService.take (attendance.service.ts) records one lesson's worth of cells in a single pass. In order:
- Resolve active year (
resolveActiveYear) and parsedateto a UTC-midnight date-only. - Future-date gate —
date > today→422 ATTENDANCE_FUTURE_DATE. - Published-timetable gate —
loadPublishedTimetableIdthrows409 ATTENDANCE_NO_PUBLISHED_TIMETABLEif no PUBLISHED timetable exists for the year. Attendance is meaningless without the schedule it derives from. - Lesson lookup —
loadLessonForTake(timetableId, subjectGroupId, weekday, periodOrdinalPosition?). No match →404 SCHEDULED_LESSON_NOT_FOUND. The hydrated lesson is projected to aLessonSnapshot(buildLessonSnapshot) — the lesson-level column bag, minus per-student fields. - School-day gate — build the department calendar for the lesson's grade, then
isSchoolDay→422 ATTENDANCE_NOT_A_SCHOOL_DAYif not. - Period-column resolution — DAILY ⇒
periodOrdinalPositionmust be omitted (else400 VALIDATION_FAILED) and the column isNULL; PERIOD ⇒ it is required (else400) and the supplied ordinal is resolved against the effective template (resolvePeriodSlot) to a human label (Period N) + wall-clock start/end frozen onto the row. - Roster + facts — load the SG roster ids (
loadSubjectGroupRosterIds— homeroom roster when homeroom-bound, the SG's own assignments when standalone), per-student snapshots (loadStudentSnapshots), and the day's teaches-map (loadTeachesStudentTodayMap) for Y-set checks. - Per-entry loop inside one
$transaction—resolveActor(tx, ctx)once, then for each entry: - roster check →
422 ATTENDANCE_STUDENT_NOT_IN_LESSON_ROSTERif the student isn't on the lesson roster; - status-field validation (
assertValidStatusFields, §7); - Y-set authority (
canWriteStudentCell, §5) →403 ATTENDANCE_NOT_AUTHORIZED_FOR_STUDENTif denied; - upsert — find the existing cell by
(tenant, student, date, periodOrdinalPosition); update it (verbattendance_record.updated) or create it with the full snapshot (verbattendance_record.created); audit.record(tx, actor, { entityType: 'attendance_record', entityId, action, data })— value-only payload (status/time/note/isJustified/justificationReason).
The whole batch is atomic: any entry that fails a gate rolls back the lesson's take. Response is { records: AttendanceCellDto[] }.
5. Write authority — the Y-set (read < write inversion)¶
The single most non-obvious pattern here: write authority is broader than read visibility. The usual assumption "you can only write what you can read" is inverted — a teacher who only sees their own department's records can write a cell for a student they don't otherwise see, if they witnessed the event.
canWriteStudentCell(actor, facts) (attendance-authority.ts) — the "Y-set" — grants a teacher write to a single student's cell iff any of:
- they are a lesson teacher of the targeted lesson (
facts.lessonTeacherIds), or - they are the student's homeroom (tutor) teacher (
facts.homeroomTeacherId), or - they teach the student in any lesson that same day (
facts.teachesStudentTodayTeacherIds, built byloadTeachesStudentTodayMap).
Admins always pass. The rationale: a teacher who sees a student leave early can record the early exit even if it isn't their own lesson. This authority is enforced in the service, per entry — not in the policy. It is re-checked on every updateCell using the record's stored lessonTeacherIds snapshot plus a fresh today-map (the published timetable can change between take and edit).
Read visibility (§6) is the stricter-looking
AttendancePolicy; write authority (this section) is the broader in-service Y-set. Document both when reasoning about who can do what.
6. Read visibility — AttendancePolicy¶
AttendancePolicy (attendance.policy.ts) is a standard record-access policy (see chapter 04) filtering AttendanceRecordWhereInput:
- admin —
pass-through(all tenant records). - teacher — narrowed to
departmentId IN (ctx.teacherDepartmentIds), matching against the row's denormalizeddepartmentIdsnapshot column. A teacher with no departments resolves toNEVER_MATCH_WHERE(fail-closed). DEPARTMENTparametric branch — samedepartmentId IN (...)narrowing.- every other role — no branch, so the resolver fails closed (
NEVER_MATCH).
The policy gates the read endpoints (grid, cohorts, history) and the lookup half of updateCell (the existing row must be visible before it can be edited). On top of the policy, every route carries a hard @RequireRoles('admin', 'teacher') for an early 403 at the role gate.
v1 roles = admin + teacher only. hr / principal / staff / referent / student attendance read is deferred — those branches are intentionally absent, not forgotten.
RBAC catalogue (see chapter 04 for the full entity/scope/action model — not duplicated here): entity EntityKey.ATTENDANCE = 'attendance', one field-level scope register (status/time/note/justification + provenance), one action take gated on attendance.register. The teacher role seed grants both attendance.register and attendance.take. The take and PATCH routes use @RequireScope(ATTENDANCE, 'register', 'write') + @RequireAction(ATTENDANCE, 'take'); the read routes use @RequireScopes(ATTENDANCE, 'read').
7. Status field rules¶
assertValidStatusFields(f) (attendance-status-rules.ts) enforces three field/status invariants, all throwing 400 VALIDATION_FAILED:
| Field | Allowed on | Rule |
|---|---|---|
time (HH:mm) |
EARLY_EXIT, LATE_ENTRY (TRANSITION_STATUSES) |
the moment of a transition only |
note |
anything except PRESENT, ABSENT (NOTE_FORBIDDEN_STATUSES) |
a note implies a qualified status; plain present/absent carries none |
isJustified / justificationReason |
ABSENT, EARLY_EXIT, LATE_ENTRY (JUSTIFIABLE_STATUSES) |
justification only applies to absence-type statuses |
On take these are hard 400s. On updateCell the rules are reached after a coerce step (§8) that proactively clears now-illegal fields, so a status change rarely 400s the caller.
8. Cell edit / override / justify — PATCH /attendance/records/:id¶
AttendanceService.updateCell edits one existing cell. All five value fields are optional; the merge is coerce-then-validate:
- Load the cell through
AttendancePolicyvisibility; not visible →404 NOT_FOUND. - Merge each field (
dto.x !== undefined ? dto.x : existing.x). - Coerce to keep the row legal across a status change rather than 400-ing: a non-transition status clears
time; aPRESENT/ABSENTstatus clearsnote; a non-justifiable status clearsisJustified+justificationReason. ThenassertValidStatusFieldsruns as a backstop. - Y-set re-check (§5) using the stored
lessonTeacherIds+ fresh today-map (re-derived from the current PUBLISHED timetable, if any). Denied →403 ATTENDANCE_NOT_AUTHORIZED_FOR_STUDENT. - Update + audit inside one
$transaction, picking the audit verb by intent: attendance_record.admin_override— an admin edits a cell a teacher in their position could not write (isAdmin && !teacherWouldWrite);attendance_record.justified— only the justification fields changed (isJustified/justificationReason, no status/time/note);attendance_record.updated— otherwise.
9. History as an audit projection¶
There is no bespoke attendance-history table. The cell row holds only current state; the full value timeline lives in audit_logs (entityType = 'attendance_record', entityId = cell id). GET /attendance/records/:id/history loads the (policy-visible) current cell and calls audit.historyFor(tenantId, 'attendance_record', id), returning { current: AttendanceCellDto, history: AuditLogEntryResponseDto[] }. The history entries carry the created/updated/justified/admin_override verbs and the value snapshot at each event. See chapter 20 for the audit-log model and historyFor projection.
10. Reads — cohort cards & grid¶
GET /attendance/cohorts (listCohorts) — the attendance home page. Returns one card per takeable cohort: homeroom cards (AttendanceCohortType.HOMEROOM) and grade-group cards (GRADE_GROUP, the ungrouped students of a grade — homeroomAssignment IS NULL). Each card carries rosterCount, takenToday, totalToday (distinct students with a cell dated today). Department scoping: an explicit departmentId filter wins, else admins see all, teachers see their teacherDepartmentIds (and an empty set short-circuits to { cohorts: [] }). Optional gradeId narrows further.
GET /attendance/grid?cohortType=&cohortId=&from=&to= (grid) — a lazy grid over a date range. Returns { mode, students[], cells[] }: mode is the cohort's department attendance mode; students is the current roster (so the FE can render empty rows); cells is the flat set of recorded cells, filtered by AttendancePolicy. A missing (studentId, date, period?) tuple means "not taken". The FE pivots cells by (studentId, date, periodOrdinalPosition) into the visible matrix — the BE never materializes empty cells. Unknown homeroom/grade → 404 NOT_FOUND.
11. Routes, DTOs & error-code map¶
Five endpoints, all under @Controller('attendance') + @ProtectedResource():
| Route | Guard | DTO in → out |
|---|---|---|
GET /attendance/cohorts |
read + roles admin/teacher |
CohortCardsQueryDto → AttendanceCohortsResponseDto |
GET /attendance/grid |
read + roles admin/teacher |
GridQueryDto → AttendanceGridDto |
POST /attendance/take |
register:write + action take |
TakeAttendanceDto → AttendanceTakeResponseDto |
PATCH /attendance/records/:id |
register:write + action take |
UpdateAttendanceCellDto → AttendanceCellDto |
GET /attendance/records/:id/history |
read + roles admin/teacher |
— → AttendanceCellHistoryDto |
TakeAttendanceDto = { date, subjectGroupId, periodOrdinalPosition?, entries[] }; each AttendanceEntryInputDto = { studentId, status, time?, note?, isJustified?, justificationReason? }.
Error codes (chapter 06 delegates per-domain codes here):
| Code | HTTP | Thrown when | params |
|---|---|---|---|
ATTENDANCE_NO_PUBLISHED_TIMETABLE |
409 | take with no PUBLISHED timetable for the year | — |
ATTENDANCE_NOT_A_SCHOOL_DAY |
422 | date outside the department school-day set | { date } |
ATTENDANCE_FUTURE_DATE |
422 | take for a date after today | { date } |
ATTENDANCE_NOT_AUTHORIZED_FOR_STUDENT |
403 | Y-set denies the caller for that student (take or edit) | { studentId } |
ATTENDANCE_STUDENT_NOT_IN_LESSON_ROSTER |
422 | entry's student not on the lesson roster | { studentId } |
Reused codes: SCHEDULED_LESSON_NOT_FOUND (404) — no lesson matches the (SG, weekday, period) lookup; VALIDATION_FAILED (400) — period-column mismatch (DAILY-with-period / PERIOD-without) and status-field rule violations; NOT_FOUND (404) — record/homeroom/grade/department lookup misses.
12. Recipes & gotchas¶
Add a status — extend enum AttendanceStatus (schema + migration, new enum value) and slot it into the right set(s) in attendance-status-rules.ts (TRANSITION_STATUSES / NOTE_FORBIDDEN_STATUSES / JUSTIFIABLE_STATUSES). If it carries new fields, extend assertValidStatusFields + the value payload in take/updateCell. No FE enum is rendered backend-side — the status enum surfaces through the DTOs' @ApiProperty({ enum }).
DAILY vs PERIOD period column — the single rule that trips people: DAILY rows have periodOrdinalPosition = NULL and must omit the field on take; PERIOD rows require it. The two partial unique indexes depend on this — never write a PERIOD cell with a NULL period or a DAILY cell with a non-NULL one, or the dedupe guarantee breaks.
Snapshot columns are written once — buildLessonSnapshot + the per-student snapshot are copied at create and never re-synced. If a subject is renamed or a teacher reassigned after the fact, historical rows keep the old names by design (compliance: the record reflects the day, not the present). Do not add a "refresh snapshot" path; if you need current context, join live off the soft-FK ids (which may now be dangling).
Y-set vs policy — when debugging "why can/can't X write this cell", check canWriteStudentCell (write) and AttendancePolicy (read) separately. They diverge on purpose (§5).