date: 2026-08-12 slug: teacher-attendance-surface-iteration-3 status: Approved # Draft | Approved | Superseded clickup_us: none — two user-provided US texts (IT), 2026-08-12: "Come docente devo vedere nella dashboard … anche i duties e le attività" + "Come docente devo vedere anche le attività assegnate a me nel registro" epic: attendance related_specs: - docs/superpowers/specs/2026-08-03-teacher-attendance-surface-iteration-2-design.md - docs/superpowers/specs/2026-08-02-teacher-attendance-surface-design.md - docs/superpowers/specs/2026-07-13-timetable-activities-design.md - docs/superpowers/specs/2026-07-25-attendance-admin-day-register-design.md supersedes: none
Teacher attendance surface — iteration 3: the whole day (activity cards + duty slots) and the ACTIVITY cohort¶
1. Problem distillation¶
- The teacher agenda shows a partial day.
GET /attendance/teacher-dayrenders LESSON slots only (attendance-teacher.service.ts:69) — activity cards were an explicit iteration-1 deferral. A teacher supervising the Friday-afternoon activity or holding lunch duty sees neither, so the dashboard cannot be their day plan. - Activity registers are unreachable by their supervisors. Activities are attendance-bearing exactly like lessons (ch19), and the Y-set's clause 1 (slot teacher as-of-date) already admits a supervisor's cell writes — but the cohort fence added in iteration 2 refuses first:
ATTENDANCE_COHORT_TYPEShas no ACTIVITY shape, so a supervisor who doesn't otherwise teach or tutor those students can neither open/rowsnor get a write pastassertTeacherCohortRead. Today only a tutor/teaching-unit path or a school-wide role reaches an activity cell. - Activity rosters can span departments, which makes
attendanceModeambiguous — the write path resolves mode per student, but a register card (and a/rowsgroup) needs one mode. Product decision (Fabio, 2026-08-12): activities become single-department. A cross-department activity is modeled as two activities. - Duties must answer as-of-D the way lessons do. Break/lunch supervision lives on
ScheduledBreak/ScheduledBreakDuty— version content. The agenda must resolve duties from the timetable version effective on the queried date (ExpectedSchedule.timetableId, already computed once per request), not from "the currently published timetable". Same replace+report semantics as lessons on republish; no freeze — the day-shape manifest stays evidence-only.
Success criteria (observable behavior that proves this works):
- A supervising teacher's
today[]carries a card for each activity slot of theirs on the date, with workingneedsAttendance/recorded/inconsistencies, and itscohortopensGET /attendance/rowsand takes writes through the ordinary records routes — supervisor admitted, any other teacher403 ATTENDANCE_NOT_AUTHORIZED_FOR_COHORT. - A teacher on duty sees a
duties[]entry for each break/lunch slot they supervise on the date, carrying enough detail to render the timetable slot modal without any timetable read; asking a past date answers with the duties of the version that governed it. - An activity's roster and register never mix attendance modes: every activity has exactly one department, its audience resolves inside that department, and the card/group mode is that department's.
admin/secretary/principalresponses on existing routes are unchanged except where they name activity cohorts (new capability, not altered behavior).
Non-goals (in-scope-shaped things this iteration is explicitly not doing):
- Activity cards on the admin board (
GET /attendance/groups) — deferred (Fabio, 2026-08-12). Admins keep reaching activity cells through class rows, and gain/rows?cohortType=ACTIVITYfor free. - An
otherGroups[]analog for activities ("activities I supervise that don't meet today") — activities always meet on their weekdays; past occurrences are reachable via?date. - Attendance on breaks — duties are informational agenda items, never take anchors.
- Any change to the family surface, comms loop, or timetable diagnostics semantics beyond the audience narrowing.
2. Patterns survey¶
| Analogous module/spec | What we'd borrow | What doesn't fit |
|---|---|---|
src/attendance/attendance-teacher.service.ts (iterations 1–2) |
The card machinery is complete: DAILY day-opener duty attribution already runs over ALL slot kinds (:113), recorded/inconsistencies fold over authored cells. Activity cards = widening the kind === 'LESSON' filter (:69) + card shaping. |
modeOf (:103) resolves via SG day meta; ACTIVITY slots need their own meta loader (activity → department → attendanceMode). |
src/attendance/attendance-day.queries.ts loadDaySlots (:222-256) |
ACTIVITY slots already resolve with supervisors as teacherIds and dated rosters; the frozen loader round-trips them. Nothing to invent on the resolver side. |
Breaks are deliberately absent (no attendance) — the duties loader is a new sibling query on the same timetableId the resolver pinned. |
src/attendance/attendance-day.queries.ts loadSubjectCohortGroup (:556) |
The synthetic-group pattern for cohorts outside the student-derived catalogue: identity from the anchor row, roster = dated membership ∩ placed cohort. loadActivityCohortGroup is its third sibling. |
An activity's "membership" is audience expansion (expandAudienceOn), not assignment rows; grade/curriculum have no single value (mirror the combined-class curriculum: null treatment). |
src/attendance/attendance-access.service.ts assertTeacherCohortRead (:83) |
Iteration 2's rule: every new cohort shape is a new limb on the ONE gate that fences all four routes (/rows, /inconsistencies, POST, PATCH) at once. |
Supervisor membership (ScheduledActivityTeacher) is undated version content — the limb asks row existence, not a valid-time episode. Deliberate: same fidelity as the activity row itself. |
docs/superpowers/specs/2026-07-13-timetable-activities-design.md |
The audience model being narrowed (7 kinds, union, live-resolved) and the "activities are lessons for attendance" ruling this iteration completes. | Its EVERYONE = whole student body and its DEPARTMENT kind are exactly what single-department removes. |
src/attendance/expected/expected-audience.ts expandAudienceOn |
Pure as-of-D expansion with placement as the candidate universe for every kind — the department fence is one more intersection on the same indexes (placements already carry departmentId). |
Fits cleanly; the live expander (resolveActivityAudiences, timetables) needs the mirror change, kind semantics stay twinned. |
3. Architecture mapping¶
| Primitive | Apply? | How | Justify |
|---|---|---|---|
| Tenant scope | yes | All touched queries already fence on tenantId; the new loaders (loadTeacherDutiesOn, loadActivityCohortGroup, activity roster branch) take tenantId explicitly. ScheduledActivity.departmentId FK stays inside the tenant (validated on write). |
No new model; existing RLS rows cover every touched table. |
| Academic-year scope | yes | Duties and activity cards inherit the resolver's year/date resolution; loadActivityCohortGroup expands audience with the same as-of-D indexes keyed by academicYearId. |
Same law as every register read: one date resolution per request. |
| RBAC entity key | existing ATTENDANCE + TIMETABLES |
No delta to entity keys. | Agenda/register changes ride attendance.register; the activity department column rides timetables.write. |
| Scopes | existing | No new scope keys. | New capability is cohort-shape, not permission surface. |
| Actions | none | — | take already covers activity cells. |
| Service base | custom (existing services) | AttendanceTeacherService, AttendanceAccessService, AttendanceBoardService, TimetablesService — all pre-existing. |
No new module. |
queries.ts shape |
named functions | New: loadTeacherDutiesOn (attendance-day.queries — breaks of one timetableId+weekday where duties name the teacher, with room/co-duty/audience refs), loadActivityDayMeta (attendance.queries — activity → name + departmentId + attendanceMode), loadActivityCohortGroup (attendance-day.queries), ACTIVITY branch in loadCohortRosterIds (attendance.queries:395). |
Queries convention: mandatory named functions, no repositories. |
| Error codes | 1 new | SCHEDULED_ACTIVITY_AUDIENCE_OUT_OF_DEPARTMENT (422) — an audience ref whose target belongs to another department; params { field, id }. Catalog + examples + i18n (messages{en_US,it_IT}) + swagger notes per ch06. |
The existing SCHEDULED_ACTIVITY_AUDIENCE_EMPTY stays the empty-audience backstop. |
| DTO conventions | existing files | teacher-day-response.dto.ts (card kind, cohort widening, new TeacherDayDutyDto), day-filter-query.dto.ts (ATTENDANCE_COHORT_TYPES + 'ACTIVITY'), scheduled-activity.dto.ts (required departmentId on create, optional on update; selector loses departmentId; response gains department ref). |
Shared-const rule from iteration 2: the cohort enum widens everywhere at once. |
| File-backed sub-resources | n/a | — | No files. |
| Custom fields | no | — | No person entity touched. |
| Profile completeness | no | — | — |
4. Data model plan¶
Schema deltas¶
ScheduledActivity.departmentId— new requireduuidFK →Department,onDelete: Cascade(consistent with the audience rows' department refs). Index@@index([tenantId, departmentId])not needed for current reads — skip.ScheduledActivityAudienceKindenum: dropDEPARTMENT(redundant under a department-scoped activity;EVERYONEnow means "everyone in the activity's department").ScheduledActivityAudience: dropdepartmentIdcolumn + relation (the kind that used it is gone).- Raw CHECK
scheduled_activity_audiences_kind_ref_chk: recreate without the DEPARTMENT limb. - No attendance-side schema change:
AttendanceDaySlot, records, cohort storage untouched (the cohort type is a DTO const, not a DB enum).
Migration shape¶
- Destructive + backfill, one migration, ordered:
- Add
department_idnullable. - Backfill per activity: the department of its audience refs (grade→department, curriculum→department, homeroom→department, track→curriculum→department, student→placement department, department ref→itself). Multiple candidate departments: pick the alphabetically-first by department name (tie: id). Only-EVERYONE audiences: the tenant's alphabetically-first department. (Fabio, 2026-08-12: "simply pick one … it's safe, no real prod user with activity yet".)
- Enforce the invariant on existing rows: DELETE audience rows whose target resolves to a different department; CONVERT
DEPARTMENTrows pointing at the chosen department intoEVERYONErows; DELETE the rest of theDEPARTMENTrows. - Set
department_idNOT NULL + FK. - Recreate the kind CHECK; drop
scheduled_activity_audiences.department_id; shrink the enum (new type + cast + drop old, the standard Prisma shape — safe because step 3 removed everyDEPARTMENTrow). - Hazards (ch12): enum value removal (recreate-and-cast — audited in the generated SQL);
DROP COLUMNonscheduled_activity_audiences(hazard #3, old pods during rolling deploy — accepted: no production activity usage, flagged for deploy coordination); data-modifying migration (roster trimming) — deliberate and signed off, documented in the migration header comment. Check for an uncommitted migration to fold beforemigrate dev(ch12 rule 1a). - An activity left with zero audience rows after step 3 keeps working (roster resolves empty; diagnostics
ACTIVITY_SCOPE_EMPTYflags it) — no rows are invented.
Indexes and uniqueness¶
- None new. Existing
@@index([tenantId, timetableId])serves the duties and activity lookups;ScheduledBreakDuty.@@index([teacherId])servesloadTeacherDutiesOn.
5. API surface¶
No new routes. Changed contracts:
| Verb | Path | Decorators | Request DTO | Response DTO |
|---|---|---|---|---|
| GET | /attendance/teacher-day |
unchanged (@RequireScopes(ATTENDANCE,'read') + AttendanceTeacherPolicy) |
TeacherDayQueryDto (unchanged) |
TeacherDayResponseDto: today[] cards gain kind: 'LESSON' \| 'ACTIVITY'; ACTIVITY cards carry name = activity name, subjectName: null, cohort: {ACTIVITY, activityId}; new duties: TeacherDayDutyDto[] |
| GET | /attendance/rows, /attendance/inconsistencies |
unchanged | cohortType accepts ACTIVITY (cohortId = activity id) |
unchanged shapes; the ACTIVITY group's grade/curriculum follow the combined-class "no single value" treatment, mode = the activity department's |
| POST/PATCH | /attendance/records[/:id] |
unchanged | cohort echo accepts ACTIVITY |
write envelope may describe an ACTIVITY group; PATCH echo-less default for an activity cell becomes the activity itself for non-school-wide callers (school-wide callers keep the class) |
| POST/PATCH | /timetables/:id/activities[/:activityId] |
unchanged | create: + required departmentId; update: optional departmentId; ActivityAudienceSelectorDto loses departmentId |
ScheduledActivityResponseDto gains department: NamedRef; audience rows can no longer be DEPARTMENT-kind |
TeacherDayDutyDto (new, attendance-owned — no import from timetables DTOs): breakId, kind: BreakKind (BREAK/LUNCH), startTime/endTime/durationMinutes, roomId/roomName (nullable), dutyTeacherIds/dutyTeacherNames (full duty set, caller included — mirrors card teacherIds), audience: { kind, id, displayName }[] (department/curriculum/grade/homeroom refs resolved to names). Enough for the FE to render the same modal the timetable break slot opens, without knowing the governing timetableId. Time-ordered; empty when no version governs the date.
Swagger considerations¶
teacher-dayJSDoc (public copy): document thekinddiscriminator, that ACTIVITY cards open/rowswithcohortType=ACTIVITY, and thatdutiesare informational (no attendance is taken on a break).- New error example for
SCHEDULED_ACTIVITY_AUDIENCE_OUT_OF_DEPARTMENTon the two activity mutation routes; the day-filter enum widening shows up automatically from the const. NOT_FOUNDexample forentity: 'scheduled_activity'on cohort routes (newloadCohortRosterIdsbranch).
6. RBAC seed plan¶
| Seed file | Delta |
|---|---|
PermissionScope (rbac-catalogue.ts) |
none |
PermissionAction (rbac-catalogue.ts) |
none |
ScopeFieldMapping (rbac-catalogue.ts) |
none |
| Role grants (roles.ts) | none |
*_SCOPES runtime constant |
none |
No reseed needed — the whole iteration rides existing grants.
7. Divergence ledger¶
| Pattern | We diverge by | Reason | Tradeoff accepted |
|---|---|---|---|
Break audience model (ScheduledBreakAudience: 4 ref kinds, no EVERYONE, keeps DEPARTMENT) |
Activities lose the DEPARTMENT kind and redefine EVERYONE as department-wide; breaks stay untouched | Breaks bear no attendance — no mode to resolve, no reason to constrain | The two audience models drift apart; documented in ch18 |
| Register responses are register-shaped (anchor + cohort + counters) | duties[] are informational cards with no anchor, no cohort, no counters, inside an attendance response |
The agenda is the teacher's whole day; duties belong to the day even though no register exists on them | A second card vocabulary in one DTO — mitigated by a separate array rather than a nullable-field union |
followUpWhere limbs are relational (some/none on assignment tables) |
The ACTIVITY cohort limb materializes the roster (studentId IN (expandAudienceOn …)) |
Audience expansion has no single relational shape (6 kinds + department fence + placement universe); duplicating it in Prisma-where form would fork the semantics expandAudienceOn owns |
An IN-list bounded by roster size (school-scale, fine); semantics stay single-sourced |
Cohort limbs in assertTeacherCohortRead ask dated (valid-time) questions |
The supervisor limb asks undated row existence on ScheduledActivityTeacher |
Supervisors are version content with no valid-time table — the same fidelity as the activity row the cohort names; inventing intervals here would exceed what the schedule stores | A supervisor added after date D can open D's register (same replace+report family as every schedule edit) |
8. Pushback log¶
| US says | Conflicts with | Proposed instead | Status |
|---|---|---|---|
| "mostriamo lo stesso dettaglio che mostreremmo cliccando sullo slot intervallo/pranzo della timetable (modale)" | The dashboard FE does not know the governing timetableId, and GET /timetables/:id/lessons is a whole-grid read |
Inline the modal's fields on duties[] (own DTO, no timetable read) |
Resolved (chat 2026-08-12) |
| Activities as-is (roster may span departments) | One attendanceMode per card/group |
Single-department activities; duplicate the activity per department when needed; migration trims existing rosters | Resolved — product decision (Fabio, 2026-08-12) |
| "duties" list on the dashboard | First instinct (live-resolve from the published timetable) breaks as-of-D faithfulness | Resolve from ExpectedSchedule.timetableId — the version effective on the date, identical semantics to lessons |
Resolved (chat 2026-08-12) |
| US2 "vedere le attività nel registro" as a separate feature | — | Same deliverable: the agenda card IS the register entry point (cohort → /rows → records routes) |
Resolved (chat 2026-08-12) |
9. Deferrals¶
- Admin board activity cards (
/groupscatalogue) — not asked; admins keep class rows and gain direct/rows?cohortType=ACTIVITY— follow-up: revisit if admins ask for a per-activity board card. otherGroups[]analog for activities — activities always meet on their weekdays; nothing unreachable — follow-up: none planned.- Envelope
followUpsgating for teachers — pre-existing deferral from iteration 1, unchanged. - Family/student surface for activities — the family day view's treatment of activity cells is the family surface's own concern — follow-up: family attendance surface iterations.
- Duty conflict with the caller's own lessons — diagnostics' job at publish time, not the agenda's — follow-up: none.
10. Open questions¶
None — all resolved in chat 2026-08-12 (single-department activities + roster-trimming migration; duties via governing version; admin board deferred; US2 = same API). The §4 backfill tie-breaks (alphabetical-first department; DEPARTMENT-row conversion to EVERYONE) and the §7 undated-supervisor ruling are called out for sign-off review.
11. Verification plan¶
- Unit specs:
attendance-teacher.service.spec.ts— ACTIVITY cards render withkind, activity name, nullsubjectName, ACTIVITY cohort; mode comes from the activity's department (PERIOD →needsAttendancetrue; DAILY → day-opener only);recorded/inconsistenciesfold activity rosters;duties[]mapsloadTeacherDutiesOnrows and is empty with no governing version; a lesson-only day is byte-identical to today except the addedkind/dutiesfields.attendance-access.service.spec.ts— supervisor admitted on ACTIVITY cohort, non-supervisor refused, no-teacher-profile refused; existing limbs untouched.attendance.queries.spec.ts/attendance-day.queries.spec.ts—loadCohortRosterIdsACTIVITY branch (expansion ∩ placement, NOT_FOUND on unknown id),loadActivityCohortGroup(identity, department mode, empty-audience → empty roster),loadTeacherDutiesOn(weekday + teacher filter, audience refs resolved).attendance.service.spec.ts— PATCH echo-less default on an activity cell: teacher → ACTIVITY envelope, school-wide → class fallback unchanged; SPAN_GROUPS on a mismatched ACTIVITY echo.attendance-insights.service.spec.ts—followUpWhereACTIVITY limb narrows by materialized roster.expected-audience.spec.ts— department fence: every kind's matches intersect the activity department; EVERYONE = the department's placed students.timetables.service.spec.ts/timetables.queries.spec.ts— create/update validation (departmentIdrequired, refs out of department → 422SCHEDULED_ACTIVITY_AUDIENCE_OUT_OF_DEPARTMENT, department change revalidates existing audience),resolveActivityAudiencesdepartment fence, responsedepartmentref.- E2E specs:
test/attendance-teacher.e2e-spec.ts— the agenda carries an ACTIVITY card + a duty; a past date answers duties from the version that governed it (revision fixture per rules a–k infeedback_e2e_isolation_patterns).test/attendance-activities.e2e-spec.ts— supervisor opens/rows?cohortType=ACTIVITYand writes; a non-supervisor teacher 403s; PATCH without echo answers the activity envelope.test/timetables-activities.e2e-spec.ts— create requiresdepartmentId; out-of-department audience 422s; EVERYONE resolves department-wide in the roster.test/db-constraints.e2e-spec.ts— recreated kind CHECK still rejects a ref-less GRADE row and now rejects any DEPARTMENT kind (enum-level).- Manual verification: run the migration against a dev DB holding a multi-department activity and an EVERYONE activity; confirm the picked department, the trimmed audience rows, and the converted EVERYONE rows. Label-coverage drift suite for the new DTO fields (
kind,duties,departmentIdalready labeled).
Gates run by Fabio (never by the agent) per feedback_user_owns_gates_and_git.
12. Sign-off¶
- Approved by: Fabio
- Date: 2026-08-12
- Chat reference: "approved" in chat 2026-08-12, after review of the four called-out micro-decisions (backfill tie-breaks, undated supervisor limb, materialized follow-up limb, inlined duty detail)
Until this section is filled, no implementation code is written. When you fill it, flip the frontmatter status: to Approved in the same edit.