Skip to content

Attendance register (DAILY / PERIOD modes)

1. Problem distillation

  • A compliance-grade attendance register (Italian registro) keyed off the existing Department.attendanceMode (DAILY / PERIOD) — the field already exists and is already frozen after the calendar start, but nothing reads it yet.
  • DAILY (mode A): one cell per (student, school-day). PERIOD (mode B): one cell per scheduled lesson. In both, a teacher records "the roster in front of them" against a timetable lesson.
  • Taking is authorized by a set (the "Y-set"), not a single teacher: the lesson's assigned teachers, any teacher who teaches that student that day, the student's homeroom teacher (always, for their homeroom), and admin (always, any cell). Viewing reuses existing record-level visibility.
  • A record is an immutable compliance document: it survives hard-delete / archive / mid-year reorg of subject groups and timetables (denormalized snapshot, no cascade). Its value-change history is the audit log (this module is the audit log's first consumer) — there is no bespoke history table.
  • Statuses: PRESENT, ABSENT, EARLY_EXIT, LATE_ENTRY, FIELD_TRIP, DAY_TRIP; transition statuses carry a time; non-PRESENT/ABSENT carry a note; absence-type statuses carry a justification (admin + teacher settable in v1).
  • Cohort-centric UX: home-page cards (homerooms + whole-grade groups) → a student×date(×period) table → a cell modal showing provenance + change history.

Success criteria (observable behavior that proves this works): - PERIOD department: POST /attendance/take with { date, subjectGroupId, periodOrdinalPosition, entries[] } upserts one cell per student for that lesson; each create/update emits an audit_log row in the same transaction. - DAILY department: the same endpoint without a period upserts one cell per (student, date); a different Y-set teacher can later PATCH that same cell to EARLY_EXIT @ 11:30 and it is accepted (no first-lesson lock). - A teacher not in the Y-set for a student gets 403 ATTENDANCE_NOT_AUTHORIZED_FOR_STUDENT; admin can edit any cell. - Taking for a future date → 422 ATTENDANCE_FUTURE_DATE; a date with no published timetable409 ATTENDANCE_NO_PUBLISHED_TIMETABLE; a non-school-day (CLOSING / non-operative) → 422 ATTENDANCE_NOT_A_SCHOOL_DAY. - Hard-deleting the SubjectGroup after attendance is recorded leaves the records intact and readable (snapshot survives; soft-FK nulls). - GET /attendance/grid returns the cohort table with each slot resolved to a recorded cell or "not taken"; GET /attendance/records/:id/history returns the change history. - A time on a non-transition status, a note on PRESENT/ABSENT, or a justification on a non-absence status → 400 VALIDATION_FAILED.

Non-goals (in-scope-shaped things this iteration is explicitly not doing): - No referent/student read of attendance, and no referent-submitted justification flow (deferred role work). - No maxAbsenceHours threshold logic / absence-total notifications. - No trip-module integration — FIELD_TRIP/DAY_TRIP are manually selectable statuses only. - No "my lessons today" takeable endpoint for teachers (reach taking via the cohort cards → grid in v1). - No edit-window lock — any past cell is correctable by the Y-set; the audit log provides traceability. - No department_head role on attendance in v1. - No materialized/pre-seeded grid rows — the grid is computed (lazy records).


2. Patterns survey

Analogous module/spec What we'd borrow What doesn't fit
src/timetables/timetables.queries.ts (resolveEffectiveTemplatesForAcademicYear, findLessonsForView, viewWhere, buildPeriodSlotsByDay) + src/timetables/timetables.service.ts Lesson + period-slot resolution (date→weekday→effective week template→PERIOD slots→wall-clock) for recordable-cell derivation and the snapshot; the view=teacher/view=homeroom where shapes for the grid/cohort reads Timetables is config CRUD on the live graph; attendance records are frozen facts decoupled from it — we read the timetable at take-time, never own it
src/homerooms/homerooms.policy.ts + src/subject-groups/subject-groups.policy.ts (definePolicy, record-level where, roster narrowing) The AttendancePolicy read-visibility composition (teacher sees taught/tutored cohorts, admin all) — reused, not reinvented Policies gate read; attendance write authority is the narrower Y-set, asserted in-service
docs/superpowers/specs/2026-06-25-audit-log-design.md (AuditService.record(tx, …)) The value-change history — every create/update/justify/override emits an audit_log row in-tx; the cell modal renders a typed projection over those rows n/a — this module is its first consumer
src/invitations/ (Invitation polymorphic ownerType/ownerId, no owner FK) The soft-reference + snapshot durability pattern for the lesson context (subjectGroupId soft-FK SetNull + snapshot strings) Invitation rows are mutable; attendance rows are append-mostly compliance documents
src/common/utils/department-calendar.ts (effective bounds + period-set resolver) + src/common/utils/periods.ts School-day derivation — effective [start,end] minus CLOSING periods, intersected with operative week-template days Calendar utils don't know about timetable operativity (week-template dayTemplate IS NULL) — attendance composes both
src/command-center/ (cohort aggregation reads, completeness summaries, zero-filled rollups) The cohort-card + completeness-badge read shape (GET /attendance/cohorts) Command-center is admin dashboards over selection/onboarding; attendance cards are taking-oriented and teacher-visible

3. Architecture mapping

Primitive Apply? How Justify
Tenant scope yes Every attendance_records row carries tenantId (FK Restrict); all reads/writes filter it from @TenantId() Tenant-local register
Academic-year scope yes academicYearId on each row; taking targets the active AY's single PUBLISHED timetable; reads are date-ranged and resolve AY from the active year (no override — past-year registers are a future read concern) Attendance is time-scoped to the year it happened in
RBAC entity key new Add ATTENDANCE: 'attendance' to entity-keys.ts New domain
Scopes new register (field scope: status/time/note/justification/provenance) on attendance Field-level so a future referent/student role sees a narrowed subset
Actions new take — create/update a cell (incl. justification + admin override). read implicit. (justify may split out later for referents) One write verb; override is admin-in-Y-set, not a separate action
Service base custom AttendanceService Bulk roster upsert + single-cell PATCH + snapshot build + Y-set assertion + AuditService.record; not BaseTenantedCrudService Batch facts + lazy grid, not single-row CRUD
queries.ts shape include/select consts + named fns resolveRecordableCells(ctx, cohort, dateRange) (composes timetable + calendar), loadCohortCards(ctx, dept, grade) (homerooms + ungrouped grade students), loadGrid(ctx, cohort, range), buildLessonSnapshot(lesson), findRecordsForCells(...) Lazy grid + snapshot building
Error codes new ATTENDANCE_NO_PUBLISHED_TIMETABLE (409), ATTENDANCE_NOT_A_SCHOOL_DAY (422), ATTENDANCE_FUTURE_DATE (422), ATTENDANCE_NOT_AUTHORIZED_FOR_STUDENT (403), ATTENDANCE_STUDENT_NOT_IN_LESSON_ROSTER (422) Validation comes via class-validator (VALIDATION_FAILED); domain gates need codes
DTO conventions dto/scopes/attendance-register.dto.ts, dto/take-attendance.dto.ts, dto/update-attendance-cell.dto.ts, dto/list-grid-query.dto.ts, dto/cohort-cards-query.dto.ts, response DTOs Standard scope sub-DTO + bulk + PATCH + list-query
File-backed sub-resources n/a No files in v1 (justification evidence upload is deferred role work)
Custom fields no Not added to CUSTOM_FIELD_ENTITY_KEYS Register rows aren't custom-fieldable
Profile completeness no n/a

4. Data model plan

Schema deltas

  • New enum AttendanceStatus { PRESENT, ABSENT, EARLY_EXIT, LATE_ENTRY, FIELD_TRIP, DAY_TRIP }.
  • New table attendance_records:
  • Identity / keys: id, tenantId (FK Restrict), academicYearId (FK), studentId (FK Restrict), date (@db.Date), periodOrdinalPosition Int? (null = daily), attendanceMode (AttendanceMode snapshot).
  • Student snapshot: studentFirstName, studentLastName, studentIdentificationCode.
  • Period snapshot: periodLabel?, periodStartTime?, periodEndTime? (strings; insulate against day-template edits).
  • Cohort context (soft-FK SetNull + snapshot name): departmentId/departmentName, gradeId/gradeName, homeroomId?/homeroomName?.
  • Lesson context (soft-FK SetNull + snapshot; populated for PERIOD, null-able for DAILY): subjectGroupId?/subjectGroupName?, curriculumSubjectId?/subjectName?/subjectCode?, roomId?/roomName?, lessonTeacherIds String[]/lessonTeacherNames String[].
  • Value: status (AttendanceStatus), time String? (HH:mm), note String?, isJustified Boolean @default(false), justificationReason String?.
  • Provenance: recordedByUserId? (FK SetNull)/recordedByName/recordedAt, lastModifiedByUserId?/lastModifiedByName?/lastModifiedAt.
  • No bespoke history table — value history lives in audit_logs (entityType='attendance_record').
  • The attendance entity gets a record-level AttendancePolicy (src/attendance/attendance.policy.ts) reusing homeroom/SG visibility branches.

Migration shape

  • Additive — new enum + new table + indexes; no changes to existing tables, no backfill.
  • Hand-authored partial-unique indexes (Prisma can't express partial uniqueness; Postgres treats NULLs as distinct, 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;
  • CREATE UNIQUE INDEX attendance_period_unique ON attendance_records (tenant_id, student_id, date, period_ordinal_position) WHERE period_ordinal_position IS NOT NULL;
  • Hazards from chapter 12 checklist: enum creation (additive, safe — no existing rows); raw partial-unique indexes added in the same migration after prisma migrate generates the table (append the CREATE UNIQUE INDEX statements to the generated SQL — audit per ch.12).

Indexes and uniqueness

  • The two partial uniques above (one cell per daily day / per period).
  • @@index([tenantId, academicYearId, date]), @@index([tenantId, studentId, date]), @@index([tenantId, subjectGroupId, date]) (grid-by-lesson), @@index([tenantId, homeroomId, date]) (cohort grid).

5. API surface

Verb Path Decorators Request DTO Response DTO
GET /attendance/cohorts @RequireScopes(ATTENDANCE, 'read') + AttendancePolicy CohortCardsQueryDto (departmentId?, gradeId?) AttendanceCohortCardDto[] (homerooms + whole-grade groups, roster count, today's taken/total badge)
GET /attendance/grid @RequireScopes(ATTENDANCE, 'read') + AttendancePolicy GridQueryDto (cohortType, cohortId, from, to) AttendanceGridDto (students × days/day-periods; each slot a cell or "not taken")
POST /attendance/take @RequireScopes(ATTENDANCE, 'write'), @RequireAction(ATTENDANCE, 'take') TakeAttendanceDto (date, subjectGroupId, periodOrdinalPosition?, entries:[{studentId,status,time?,note?,isJustified?,justificationReason?}]) AttendanceCellDto[]
PATCH /attendance/records/:id @RequireScopes(ATTENDANCE, 'write'), @RequireAction(ATTENDANCE, 'take') UpdateAttendanceCellDto (status?, time?, note?, isJustified?, justificationReason?) AttendanceCellDto
GET /attendance/records/:id/history @RequireScopes(ATTENDANCE, 'read') + AttendancePolicy AttendanceCellHistoryDto (current provenance + typed projection over audit_logs)
  • Write authority beyond the role/action gate is the Y-set, asserted per-studentId inside AttendanceService (broad read policy, narrow write gate).
  • Validation rules (class-validator + service): time only with EARLY_EXIT/LATE_ENTRY; note forbidden on PRESENT/ABSENT; isJustified/justificationReason only on ABSENT/EARLY_EXIT/LATE_ENTRY.
  • Mode B route shape: periodOrdinalPosition required for a PERIOD department, forbidden for DAILY.

Swagger considerations

  • Error examples for the 5 new ErrorCodes in error-examples.ts.
  • The grid response is a nested mode-aware matrix — model slot as a oneOf of AttendanceCellDto | NotTakenSlotDto so the FE can discriminate.

6. RBAC seed plan

Seed file Delta
PermissionScope (rbac-catalogue.ts) new: attendance.register
PermissionAction (rbac-catalogue.ts) new: attendance.take
ScopeFieldMapping (rbac-catalogue.ts) attendance.register → value + provenance fields (status, time, note, isJustified, justificationReason, recordedByName, recordedAt, lastModifiedByName, … + snapshots)
ACTION_SCOPE_REQUIREMENTS (rbac-catalogue.ts) attendance.take{ scopeKeys: ['attendance.register'] } (write access on the scope)
Role grants (roles.ts) admin(attendance, register) read+write + (attendance, take); teacher(attendance, register) read+write + (attendance, take) (Y-set narrows actual write at the service). No referent/student/department_head.
*_SCOPES runtime constant add ATTENDANCE_SCOPES = { register: [...] } to scope-fields.ts
entity-keys.ts add ATTENDANCE: 'attendance'

7. Divergence ledger

Pattern We diverge by Reason Tradeoff accepted
BaseTenantedCrudService for entities Custom AttendanceService (bulk upsert + lazy grid + snapshot) Batch facts, not single-row CRUD No generic CRUD scaffolding (not needed)
FK discipline Lesson/cohort context are soft-FKs (SetNull) + snapshot strings; no FK to Timetable/ScheduledLesson Compliance durability — register survives SG/timetable hard-delete + mid-year reorg Denormalized columns; snapshot is the legal truth, soft-FK is a convenience join
Module owns its history Value-change history offloaded to the shared audit_log Cross-cutting "who did what"; no per-domain event table Cell modal reads two sources (record provenance + audit projection)
Read authority == write authority Read via AttendancePolicy; write via the narrower in-service Y-set "See your cohorts" ≠ "may record this student" Two gates to keep in sync (documented; covered by tests)
Materialized rows Lazy/virtual grid — a row exists only once recorded; "not taken" = absence of row Avoids mass pre-seeding + stale rows on timetable/roster/calendar change Grid is computed from the live timetable at read-time
Plain composite unique Partial unique indexes on nullable periodOrdinalPosition (hand-authored SQL) One daily cell vs one per-period cell, both enforced at the DB Migration SQL hand-edited (audited per ch.12)

8. Pushback log

US says Conflicts with Proposed instead Status
Mode A attendance is taken "in the first period, by the first-period teacher" All-day mutability (a later teacher must be able to set EARLY_EXIT) + ungrouped whole-grade students have no single "first period" Mode-A cell = one per (student, school-day-with-a-lesson); no first-lesson rule; takeable/editable by the Y-set all day; "first period" is an FE prompt convention only Resolved (chat 2026-06-25)
Only "the teacher who teaches that group" takes attendance Too strict — homeroom teacher must always cover their class; substitutes/colleagues Y-set: lesson teachers + any teacher who teaches the student that day + homeroom teacher + admin Resolved (chat 2026-06-25)
(initial design) hard subjectGroupId FK on the record Register must outlive SG deletion / mid-year reorg Soft-FK SetNull + denormalized snapshot; record is a frozen document Resolved (chat 2026-06-25)
(initial design) bespoke AttendanceRecordEvent history table Duplicates the cross-cutting audit concern History lives in the shared audit_log (this module is its first consumer) Resolved (chat 2026-06-25)

9. Deferrals

  • Referent/student read + referent-submitted justifications — deferred role work — follow-up: future attendance iteration (would add a narrowed scope + the justify action split + evidence file upload).
  • maxAbsenceHours notifications / absence totals — field already exists on Department — follow-up: future "absence monitoring" spec.
  • Trip-module integrationFIELD_TRIP/DAY_TRIP manual now — follow-up: trip module auto-populates these statuses when it lands.
  • Teacher "my lessons today" takeable endpoint — reach taking via cohort cards in v1 — follow-up: when the teacher-today screen is designed (must be an attendance endpoint wrapping the timetable teacher-view with calendar + mode-A reduction + homeroom-tutor expansion + taken overlay — not the raw view=teacher).
  • Edit-window lock — none in v1 (audit trail is the traceability) — follow-up: pure tightening later if a register-lock policy is chosen.
  • department_head on attendance — deferred.
  • Past-year register reads (AY override on reads) — deferred.

10. Open questions

  • None — all resolved in chat 2026-06-25. (Depends on the audit-log spec being approved first.)

11. Verification plan

  • Unit specs:
  • attendance.status-validation.spec.tstime/note/justification field rules per status.
  • attendance.y-set.spec.ts — pure Y-set classifier (lesson teacher / teaches-student-today / homeroom tutor / admin / none → 403).
  • attendance.recordable-cells.spec.ts — school-day derivation (calendar bounds, CLOSING, non-operative week-template day) + mode-A one-cell-per-school-day vs mode-B one-cell-per-lesson + future-date + no-published-timetable.
  • attendance.snapshot.spec.tsbuildLessonSnapshot captures subject/group/room/teacher/period correctly; daily leaves lesson-context null.
  • attendance.service.spec.ts — bulk upsert emits one AuditService.record per created/updated cell in-tx; PATCH override path; admin-any-cell.
  • E2E specs: test/attendance.e2e-spec.ts
  • PERIOD: take a lesson → cells created → audit_log rows present; non-Y teacher → 403; admin overrides; SG hard-deleted → records survive + readable.
  • DAILY: one cell/day; second Y-teacher PATCHes to EARLY_EXIT @ time (no first-lesson lock).
  • Gates: future-date 422, no-published-timetable 409, non-school-day 422.
  • Reads: cohort cards (homerooms + whole-grade group), grid (mode-aware), cell history.
  • RBAC: teacher allowed, referent/student → 403.
  • Manual verification: publish a timetable → take appello for a homeroom (DAILY) and a subject group (PERIOD) → inspect grid + cell modal history.

Patterns: chapter 09 (testing), feedback_e2e_isolation_patterns.md. New chapter proposed: docs/19-attendance.md (+ docs/REFERENCE.md §4 row + §6 file-index row).


12. Sign-off

  • Approved by: Fabio Barbieri
  • Date: 2026-06-25
  • Chat reference: design walkthrough with Fabio, chat 2026-06-25 (anchoring, modes, Y-set, status model, compliance snapshot, audit-log dependency, lazy grid, no first-lesson rule).

Until this section is filled, no implementation code is written.

Sequencing: the audit-log spec must be approved and implemented first — attendance's value history depends on AuditService.record.