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 timetable → 409 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 integration —
FIELD_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.ts — time/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.ts — buildLessonSnapshot 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.