Timetable two-step roster iteration 2 — break audience parity and weekday supervisors¶
1. Problem distillation¶
- Break rosters can select only departments, curricula, grades and homerooms, while activity rosters can also select tracks and individual students. This blocks legitimate per-student lunch/break participation.
- One activity owns a repeat-set
weekdays[], but every supervising-teacher row currently applies to the whole set. The backend therefore cannot express a different supervisor on Tuesday without splitting the activity identity. - Weekday-aware supervision is authorization- and diagnostics-relevant: it must flow through timetable conflicts, teacher views, attendance day resolution, activity-register access, revision copies and retained versions.
- The activity remains one logical entity. Its name, audience, room, time and attendance cohort are still shared across the repeat-set; only supervisor coverage varies by weekday.
Success criteria (observable behavior that proves this works):
- A break accepts
{ trackId }and{ studentId }audience selectors, echoes their names, resolves them into the live participant union, and carries them through duplicate/revision copies and teacher-day duty detail. - An activity created for Monday and Tuesday can assign T1 to Monday and T2 to Tuesday. Diagnostics charge/conflict T1 only on Monday and T2 only on Tuesday.
- The teacher-day surface shows that activity only to the supervisor assigned on the requested weekday; the activity register admits the same dated supervisor and rejects a teacher assigned only on a different weekday.
- Existing clients may continue sending
teacherIds; those teachers supervise every weekday in the activity repeat-set, including weekdays added by a later legacy-style edit. - Copy, revision, publish-revision and archived-version retention preserve the supervisor schedule exactly.
Non-goals (in-scope-shaped things this iteration is explicitly not doing):
- Per-weekday activity time, room, audience or name. Those still require a second activity.
- Client-side fan-out into independent single-day activities.
- A new audience-to-student count endpoint or
studentCountresponse field. - Replacing the existing write-time audience collapse with a granularity-preserving request model.
- Making breaks attendance anchors.
2. Patterns survey¶
| Analogous module/spec | What we'd borrow | What doesn't fit |
|---|---|---|
prisma/schema.prisma ScheduledBreakAudience + src/timetables/timetables.queries.ts resolveBreakAudiences |
Nullable one-of selector refs, live union resolution, raw one-of CHECK and NULLS-NOT-DISTINCT selector index | The existing row has four refs; TRACK needs the dated curriculum-selection track and STUDENT needs a direct student limb |
prisma/schema.prisma ScheduledActivity / ScheduledActivityTeacher + 2026-07-13-timetable-activities-design.md |
One stable activity id with a repeat-set, optional supervisors, real teacher occupancy, attendance anchor semantics | The original design deliberately made the teacher relation uniform across the repeat-set; that is the product gap being revised |
src/timetables/timetables.queries.ts toSnapshotActivities |
Expand one activity row into one diagnostics slot per weekday | Teacher ids must now be selected for the expanded weekday instead of copied wholesale to every occurrence |
src/attendance/attendance-day.queries.ts loadDaySlots + src/attendance/attendance-access.service.ts assertTeacherCohortRead |
The requested date is already converted to a weekday and activity supervisors already feed the expected slot and the cohort gate | Both paths currently treat row existence as activity-wide and must apply the weekday subset |
src/timetables/timetables.queries.ts copyTimetableContent |
Deep-copy timetable-owned children during duplicate, revision, republish and archive retention | The teacher child copy must include its weekday coverage; break audience copy must include the two new refs |
On-axis assessment (chapter 16 §4–§5): break selector expansion and timetable-child copying are paved extensions. Weekday-qualified activity supervision is a new dimension on an existing relation, but it stays inside the existing timetable snapshot and attendance expected-schedule seams; it introduces no new module or cross-module write coordinator.
3. Architecture mapping¶
| Primitive | Apply? | How | Justify |
|---|---|---|---|
| Tenant scope | yes | Existing required tenantId columns, service filters and Class-S RLS policies remain unchanged |
No new tenant-bearing model is introduced |
| Academic-year scope | indirect | Breaks/activities remain scoped through timetableId -> Timetable.academicYearId; attendance resolves the version governing the requested date |
Existing timetable and expected-schedule contract |
| RBAC entity key | existing TIMETABLES / ATTENDANCE |
No entity-key delta | CRUD and attendance routes do not change ownership |
| Scopes | existing | Timetable writes keep timetables.configuration:write; attendance reads/writes keep their current register scopes |
No new fields are independently permissioned |
| Actions | none | Existing timetable and attendance actions apply | This refines stored content and dated authorization only |
| Service base | custom | TimetablesService, timetable query helpers and attendance services |
These modules do not use BaseTenantedCrudService for aggregate slot mutations |
queries.ts shape |
existing includes + named functions | Widen break/activity graph includes; keep audience resolution and copy logic in timetables.queries.ts; dated activity access remains in attendance |
Canonical module pattern |
| Error codes | existing | Use VALIDATION_FAILED for mutually exclusive legacy/new teacher fields, duplicate teacher assignments, empty/duplicate/out-of-activity weekday subsets |
No new client recovery branch is needed |
| DTO conventions | existing flat timetable DTOs | Extend scheduled-break.dto.ts and scheduled-activity.dto.ts; extend attendance-owned duty DTO for break selector kinds |
Public contracts stay in their owning modules |
| File-backed sub-resources | n/a | No files | — |
| Custom fields | no | Not a custom-fields entity | — |
| Profile completeness | no | No person-profile field changes | — |
4. Data model plan¶
Schema deltas¶
ScheduledBreakAudience: add nullabletrackId -> CurriculumTrackandstudentId -> Studentrelations. Keep the existing inferred-kind six-ref shape; do not add a redundant kind column.ScheduledActivityTeacher: addweekdays DayOfWeek[] @default([]).- An empty stored array means “every weekday in
ScheduledActivity.weekdays” and is the storage form written by the legacyteacherIdscontract. - A non-empty value is a duplicate-free subset of the parent activity's weekdays and is the storage form written by the new weekday-aware contract.
- The existing
@@unique([activityId, teacherId])remains: one teacher has one coverage declaration per activity, avoiding redundant global-plus-specific rows. - No new models, tenant columns, RLS policies or tenant registries.
When ScheduledActivity.weekdays changes without a teacher payload, the same timetable mutation transaction intersects every explicit teacher subset with the new repeat-set and deletes assignments whose intersection is empty. An empty legacy/global assignment remains empty, so it automatically follows added or removed activity weekdays. Adding a weekday never widens an explicit subset implicitly.
Migration shape¶
- Additive / destructive / renaming: additive nullable break columns and foreign keys, one non-null activity-teacher array with a constant empty-array default, plus replacement of the existing break raw CHECK/index definitions to include the two new refs.
- Data backfill: no explicit update.
ADD COLUMN ... NOT NULL DEFAULT '{}'supplies the global sentinel to existing rows and to old application instances that omit the new field, preserving their current all-weekdays meaning. - Hazards from chapter 12 checklist:
- The break selector unique index is dropped and recreated wider. Existing rows have both new refs null, so their existing uniqueness is preserved; old application writes remain valid during a rolling deploy.
- The two new break foreign keys are added on nullable, initially-null columns, so no orphan cleanup is required.
- No activity-teacher raw CHECK is added: the empty array is a valid global sentinel. Duplicate and parent-subset validation stays service-enforced because a normal CHECK cannot inspect the parent row.
- Update chapter 12 and
test/db-constraints.e2e-spec.tsfor the widened break CHECK/index and the activity-teacher default. The existing break CHECK/index names are retained. - Repository inspection on 2026-08-26 found no uncommitted migration to fold.
Indexes and uniqueness¶
- Recreate
scheduled_break_audiences_selector_keyover(break_id, COALESCE(department_id,...), COALESCE(curriculum_id,...), COALESCE(grade_id,...), COALESCE(track_id,...), COALESCE(homeroom_id,...), COALESCE(student_id,...)). - Retain
scheduled_activity_teachers_activity_id_teacher_id_key; the new array is not part of identity. - No weekday index is needed: activity children are loaded through
activityId, and the repeat-set is at most seven enum values.
5. API surface¶
| Verb | Path | Decorators | Request DTO | Response DTO |
|---|---|---|---|---|
| POST | /timetables/:id/breaks |
existing @RequireScopes(TIMETABLES, 'write') |
audience[] additionally accepts { trackId } and { studentId } |
each audience row additionally exposes nullable track and student named refs |
| PATCH | /timetables/:id/breaks/:breakId |
existing | Same break audience extension; list remains full-replace | Existing mutation envelope, widened break response |
| POST | /timetables/:id/activities |
existing @RequireScopes(TIMETABLES, 'write') |
New teachers?: { teacherId; weekdays?: DayOfWeek[] }[]; legacy teacherIds?: string[] remains accepted; the two fields are mutually exclusive |
teachers[] gains effective weekdays: DayOfWeek[] |
| PATCH | /timetables/:id/activities/:activityId |
existing | teachers, when present, full-replaces assignments; omitted child weekdays means all effective activity weekdays. teacherIds retains legacy full-replace/all-weekdays semantics. |
Existing mutation envelope, widened activity response |
| GET | /timetables/:id/lessons |
existing faceted read | No query delta | breaks[] and activities[] carry the widened responses |
| GET | /attendance/teacher-day and activity register surfaces |
existing attendance guards | No request delta; requested date supplies the weekday | Duty audience gains TRACK/STUDENT; activity cards and cohort access use only supervisors covering that weekday |
Teacher assignment normalization rules:
- Duplicate
teacherIdentries, duplicate weekdays, an empty explicit weekday list, a weekday outside the effective activity repeat-set, or bothteachersandteacherIdsproduce400 VALIDATION_FAILED. - The response always emits the effective weekday array, including for a stored empty/global assignment, so clients never have to interpret the storage sentinel.
- A teacher facet keeps an activity when that teacher covers at least one occurrence; the response's per-teacher weekdays tell the weekly board which occurrences belong to that teacher.
Weekday-aware consumers:
- Diagnostics
toSnapshotActivities: filterteacherIds/names per expanded weekday; teacher conflict, availability and budget behavior then follows without category changes. - Expected attendance day: carry only supervisors whose assignment covers the requested weekday. The frozen day shape therefore stores the correct supervisor ids/names.
- Activity cohort authorization:
assertTeacherCohortReadalready receives the requested date; its supervisor query requiresweekdays isEmpty OR weekdays has weekday(date). - Teacher day: derives from the corrected expected slots and needs no independent supervisor rule.
- Timetable copy/revision/archive: copy teacher
weekdaysand breaktrackId/studentIdexactly. - Timetable publish notifications still include every distinct activity supervisor assigned on at least one weekday; notifications are weekly-version announcements, not day cards.
Swagger considerations¶
- Document
teachersvs legacyteacherIdsmutual exclusivity and full-replace semantics. - Document that response teacher weekdays are effective values and that omitting assignment weekdays means the activity's whole repeat-set.
- Extend break audience descriptions and teacher-day duty audience enum/docs to TRACK/STUDENT.
- No new error examples are required because
VALIDATION_FAILEDis existing behavior.
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 |
7. Divergence ledger¶
| Pattern | We diverge by | Reason | Tradeoff accepted |
|---|---|---|---|
| Original activity design: one undifferentiated teacher row per activity | Add weekday coverage to the existing row | Supervisors genuinely vary by weekday while the activity identity and other fields stay shared | Every teacher-sensitive occurrence consumer must select by weekday |
Frontend suggestion: nullable scalar weekday and widened (activityId, teacherId, weekday) unique |
Store one non-null weekday array per (activityId, teacherId), with empty as global |
Preserves the existing unique identity, represents “all days” without N rows, and avoids PostgreSQL nullable-unique/global-plus-specific ambiguity | Subset-to-parent is an app invariant rather than a declarative FK/CHECK |
Activity audience uses explicit kind |
Break audience continues inferring kind from exactly one non-null ref | Break has no ref-less EVERYONE case, so a kind column would duplicate derivable state and force an unnecessary backfill | Break and activity storage remain intentionally asymmetric while the product capability is symmetric |
| Break create fans out one row per weekday | Activity remains one row with weekdays[] |
Stable activity/attendance identity and shared later edits are explicit product requirements | Only teacher coverage may vary inside the repeat-set |
8. Pushback log¶
| US says | Conflicts with | Proposed instead | Status |
|---|---|---|---|
Add a break kind column, or widen the nullable-ref shape |
A kind column is redundant for a selector that always has exactly one ref | Widen the existing one-of shape to six refs and keep the CHECK/index as the source of truth | Resolved — approved 2026-08-26 |
Add nullable scalar weekday to each activity-teacher row |
The existing relation has one row per teacher; nullable composite uniqueness permits awkward duplicate/global-plus-specific states | Add non-null weekdays[] to that one row, with empty = all activity days |
Resolved — approved amendment 2026-08-26 |
teacherIds becomes a new object list |
Immediate replacement would break deployed clients | Add teachers and retain teacherIds as a mutually exclusive compatibility input |
Resolved — approved 2026-08-26 |
| Frontend may fan out N single-day activities | Breaks stable identity, attendance cohort identity and later edits | Keep server-owned single activity identity; no client fan-out | Resolved — rejected as requested |
9. Deferrals¶
- Audience
studentCount/ audience-resolution endpoint — lower-priority observability enhancement; no request in this iteration — follow-up: future timetable roster iteration. - Granularity-preserving cascade picker contract — current collapsed rows remain the persisted truth — follow-up: future audience request-shape design.
- Per-weekday activity time/room/audience/name — requires either occurrence overrides or a series/occurrence model, neither needed for supervisor variation — follow-up: new design if product asks.
10. Open questions¶
- Approve the widened six-ref break audience without adding a break
kindcolumn. - Approve one non-null
weekdays[]per activity teacher (empty = all days) plus the additiveteachersrequest field and legacyteacherIdscompatibility. - Confirm the audience count endpoint and write-time-collapse redesign remain deferred.
11. Verification plan¶
- Unit specs:
src/timetables/timetables.service.spec.ts: break selector validation/copy projection; activity teacher normalization, legacy compatibility, invalid subsets, full replacement and weekday-shrink reconciliation.src/timetables/timetables.queries.spec.ts: TRACK/STUDENT break union resolution; weekday-specific activity snapshot teachers; copied child columns; teacher-view inclusion.src/attendance/attendance-day.queries.spec.ts: duty TRACK/STUDENT names and activity supervisor filtering for the requested weekday.src/attendance/attendance-access.service.spec.ts: same-day supervisor admitted, different-day supervisor refused, global assignment admitted.- Existing diagnostics and expected-schedule/expected-attendance specs: conflict, availability, budget and teacher-day behavior use only the weekday's supervisors.
- E2E specs:
test/timetables.e2e-spec.ts: create/read/update a student/track break audience; create a two-day activity with different supervisors; duplicate/revision round-trip; weekday-specific diagnostics.test/attendance-activities.e2e-spec.ts: each supervisor sees/opens only their assigned weekday's activity register; a supervisor assigned on another weekday receives 403.test/db-constraints.e2e-spec.ts: widened break CHECK/index rejects malformed direct rows and the activity-teacher empty default preserves legacy/global assignments.- Manual verification: frontend two-step dialog creates one two-day activity, edits Tuesday supervision independently, reopens with the same selection, and shows the two teachers on their respective teacher-day agendas.
Per repository policy, implementation verification commands run only when explicitly requested.
12. Sign-off¶
- Approved by: Fabio Barbieri
- Date: 2026-08-26
- Chat reference: “go on implementing” in chat after review of the draft design and ordered implementation plan.
- Approved amendment: Fabio Barbieri approved the Prisma-compatible empty-array global sentinel in chat on 2026-08-26 after implementation review established that Prisma scalar lists cannot be optional. This replaces only the internal
NULL = globalrepresentation; the public contract and all other approved decisions are unchanged.
Until this section is filled, no implementation code is written. When it is filled, flip frontmatter status: to Approved in the same edit.