Skip to content

Timetable Templates — Day/Week Templates + Department/Grade Assignment

Combined design for US-35 and US-36. The two stories ship as one module because the assignment surface (US-36) is meaningless without the template catalog (US-35), and they share an RBAC entity, a Prisma boundary, and a read endpoint. Splitting into two specs would force cross-spec invariants that are simpler to keep in one place.


1. Problem distillation

  • A school needs a catalog of weekly schedules to assign to its grades. Schedules are composed bottom-up: timed slots (period/interval/lunch) form a DayTemplate; a Monday-to-Sunday mapping of day templates forms a WeekTemplate; finally, week templates are bound to grades (directly or via their department) for a given academic year.
  • The catalog is tenant-flat lifetime config (not AY-scoped). The assignment binding is AY-scoped: the same week template can be assigned to a department/grade for AY 2026 and re-assigned in AY 2027.
  • The assignment binding follows a two-level cascade that mirrors EvaluationScale's curriculum-tree pattern: grade-level row wins, falling back to department-level row, falling back to "no effective template".
  • Capacity checks never block writes. Admin can always assign any week template to any grade or department. Lunch over-capacity surfaces as a read-time alert on every affected grade row in the assignments tree.
  • Custom slot types (US-35 S13), break-room capacity (US-36 S4), student-assignment prerequisite (US-36 S5), AY carryover (US-36 S8), and post-publication freeze (US-35 S11) are out of scope — explicitly cut after brainstorming; see §9.

Success criteria (observable behavior that proves this works): - Admin can create a DayTemplate with N ordered slots (each PERIOD/INTERVAL/LUNCH), each LUNCH slot picks one configured CanteenLunchShift. The list endpoint computes per-slot start/end times from the day template's anchor startTime + each slot's durationMinutes. - Admin can create a WeekTemplate that maps each day of the week to a DayTemplate or null (NA / non-operative), with at least one operative day required. - Admin can assign a WeekTemplate to a Department (default for all its grades) and override per-Grade in the same AY. The read endpoint returns the resolved tree: [{ department, grades: [{ grade, students, effectiveWeekTemplate, alerts }] }]. - A LUNCH_CAPACITY_EXCEEDED alert is surfaced on every grade row whose effective week template binds it to a lunch shift where the aggregate student count (across all grades sharing that shift via department-level fallback or direct grade-level binding) exceeds the shift's capacity. Alert is read-only — write paths never reject on capacity. - Modifying a DayTemplate that's referenced by a Week Template re-validates the structural invariants (≥1 PERIOD slot, LUNCH→lunch shift FK). Modifying a Week Template with assignments does not re-validate capacity (alerts recompute live on read). - Day/Week templates can be duplicated; templates in use cannot be deleted; uniqueness is enforced per-type, per-tenant, case-insensitive.

Non-goals (in-scope-shaped things this iteration is explicitly not doing): - No timetable generation — US-38 is its own future spec; we land the inputs, not the engine. - No publication state — there is no PublishedTimetable row to freeze templates against. Modifications are always allowed. - No carryover on AY rollover — assignments are AY-scoped and a new AY starts empty. Admin re-assigns. - No homeroom/subject-group prerequisite gate — US-33/33.1 doesn't exist yet, so the "students must be assigned to homerooms before you can assign a week template" gate is structurally impossible. Will land with US-33. - No break-room capacity check — the Room.lunchShifts concept exists but a Break-Room flag does not, and the user's read model only mentions Lunch alerts. Defer until product calls for it. - No custom Slot Type CRUD — slot type is a Prisma enum (PERIOD | INTERVAL | LUNCH). Custom slot types and rename-propagation defer until a real use-case lands.


2. Patterns survey

Analogous module/spec What we'd borrow What doesn't fit
docs/superpowers/specs/2026-05-18-evaluation-scales-design.md Live-inherit cascade: each level holds a nullable FK; resolver is a pure helper lower ?? higher ?? null. Faithful copy at the resolution layer — see §3 (Architecture mapping) and §4 (Data model). Also: tenant-flat catalog, ON DELETE Restrict on FKs feeding the cascade, in-use predicate guarding delete + values mutation. Eval-scales inlines the FK on the entity itself (Curriculum.gradingScaleId). We can't inline on Department/Grade because the assignment is AY-scoped — each (entity, AY) pair needs its own row. We adopt the resolution shape (lower ?? higher ?? null) but materialize it as two dedicated child tables (DepartmentWeekTemplateAssignment, GradeWeekTemplateAssignment) instead of inline FKs.
src/curriculum/ Module-with-multiple-controllers pattern (curriculum CRUD + study plans + selection window all live in one module). Bespoke service (not BaseTenantedCrudService). syncEntities<> util for the "PATCH sends full ordered list" pattern used by slots and week-template days. Curriculum is AY-scoped at the catalog level; our catalog is tenant-flat. No setup-wizard touchpoint expected for templates (admin configures post-setup).
src/rooms/ CanteenLunchShift is already in the schema as a child of Room. We add lunchShiftId String? on TimeSlot with onDelete: Restrict to forbid deleting a shift that's referenced by a LUNCH slot. Mirrors the gradingScaleId Restrict guard. Rooms is AY-scoped (@@unique([academicYearId, code])); shifts live under rooms. Lunch shifts are AY-scoped while templates are tenant-flat — this means a LUNCH slot referencing a shift that gets deleted on AY rollover would orphan. See §4 for the open-deletion-strategy decision.
src/departments/ Department + nested Grade sub-resource. We hang WeekTemplateAssignment rows off both, with onDelete: Cascade on entity removal. Same scope-key convention (configuration) is one option for our entity. Departments has its own configuration scope. We need a separate timetable_templates.configuration scope — combining would over-share visibility (a teacher with departments read access would suddenly see all templates).
docs/superpowers/specs/2026-05-20-rus4-curriculum-selection-design.md Bespoke verb action (open_selection_window, edit_selection_window) for sub-resource writes that don't fit create/delete. We use assign as the verb gating both PUT and DELETE on the assignment endpoint. The selection-window is (AY, Department)-scoped with derived status; our assignment is also AY-scoped but has no derived status — it's a static binding.

3. Architecture mapping

Primitive Apply? How Justify
Tenant scope yes DayTemplate.tenantId, WeekTemplate.tenantId, DepartmentWeekTemplateAssignment.tenantId, GradeWeekTemplateAssignment.tenantId. TimeSlot and WeekTemplateDay are child tables — they inherit tenant via parent FK. All service reads use the standard tenantId = :tenantId filter; no platform-preset exception. Standard tenant invariant. No cross-tenant catalog (unlike eval-scales which has presets).
Academic-year scope mixed No for DayTemplate/WeekTemplate (tenant-flat catalog, matches eval-scales). Yes for the two assignment tables (academicYearId column + composite unique). Reads on GET /timetable-assignments accept AcademicYearQueryDto; writes always target the ACTIVE AY (per the codebase convention: "writes ignore any override and target tenant's ACTIVE year"). Templates are reusable lifetime config; assignments are time-scoped bindings.
RBAC entity key new TIMETABLE_TEMPLATES added to src/common/constants/entity-keys.ts. New domain concern; piggybacking on DEPARTMENTS would over-share visibility.
Scopes new One scope: configuration (same field-set across day templates, week templates, and assignments — they're admin-only config with no PII filtering). Seeded in prisma/seed/rbac-catalogue.ts. *_SCOPES runtime constant: TIMETABLE_TEMPLATES_SCOPES = [] (descriptor-only, no field-level filtering needed). Matches departments/rooms/curriculum "single configuration scope" pattern. Per feedback_rbac_drift_check every seeded scope needs a matching *_SCOPES constant.
Actions new create, delete, assign. (update is implicit per feedback_rbac_no_update_action — PATCH uses scope only. read is implicit per feedback_rbac_actions_convention.) create covers POST on day/week templates and POST /:id/duplicate. delete covers DELETE on templates. assign covers PUT and DELETE on the two assignment endpoints — one verb because the lifecycle of an assignment binding is a single concern. Mirrors the curriculum selection-window pattern of domain-verb actions for sub-resource writes. Open Q in §10 whether to split assign/unassign.
Service base custom Bespoke TimetableTemplatesService — does not extend BaseTenantedCrudService. The module has three controllers, two-level cascade resolver, and a tree-shaped read endpoint with computed alerts; the base service generic shape doesn't earn its keep here. Matches EvaluationScalesService and CurriculumService precedent. The base service is for single-entity CRUD with per-scope field projection. We have three entities, a sub-resource (slots), a sub-resource (week-template-days), two assignment tables, and a tree-shaped read.
queries.ts shape standard Single timetable-templates.queries.ts with: dayTemplateDetailInclude (slots ordered by ordinalPosition), weekTemplateDetailInclude (days ordered MON→SUN with embedded day-template scalar projection), resolveEffectiveAssignment(gradeRow, departmentRow) pure helper (gradeRow?.weekTemplate ?? departmentRow?.weekTemplate ?? null), loadAssignmentTreeForAcademicYear(tenantId, academicYearId) named function (issues the joined read across Department × Grade × both assignment tables), computeLunchAlertsForTree(tree) pure helper. Standard pattern. The pure helpers are testable in isolation (eval-scales precedent).
Error codes new DAY_TEMPLATE_NOT_FOUND, DAY_TEMPLATE_NAME_TAKEN, DAY_TEMPLATE_REQUIRES_PERIOD, DAY_TEMPLATE_LUNCH_SHIFT_REQUIRED, DAY_TEMPLATE_LUNCH_SHIFT_NOT_FOUND, DAY_TEMPLATE_IN_USE, WEEK_TEMPLATE_NOT_FOUND, WEEK_TEMPLATE_NAME_TAKEN, WEEK_TEMPLATE_REQUIRES_OPERATIVE_DAY, WEEK_TEMPLATE_DAY_TEMPLATE_NOT_FOUND, WEEK_TEMPLATE_IN_USE, WEEK_TEMPLATE_ASSIGNMENT_TARGET_NOT_FOUND. Per-code params shapes in docs/06-error-handling.md style; Swagger examples in error-examples.ts. Per-code granularity is the codebase convention.
DTO conventions standard Plain Create*Dto, Update*Dto (via PartialType), *ResponseDto files under dto/. No scope sub-DTOs — single scope, single admin role, no field-level visibility filtering. List endpoints get dedicated List*ResponseDto. Alert DTO is TimetableAssignmentAlertDto { code: 'LUNCH_CAPACITY_EXCEEDED', params: { lunchShiftId, shiftCapacity, studentsInShift } } so alerts are i18n keys + payload (FE renders the copy). Matches eval-scales DTO discipline and feedback_swagger_jsdoc_is_public.
File-backed sub-resources n/a — no file uploads on any template entity
Custom fields n/a — templates are pure system config; no admin-extensible columns
Profile completeness n/a — admin-owned configuration; no person record involved

4. Data model plan

Schema deltas

enum SlotType {
  PERIOD
  INTERVAL
  LUNCH

  @@map("slot_type")
}

enum DayOfWeek {
  MON
  TUE
  WED
  THU
  FRI
  SAT
  SUN

  @@map("day_of_week")
}

/// Tenant-flat catalog: a named "shape of a day" — anchor start time + ordered
/// list of typed slots. Reused across week templates. NOT scoped to AY.
model DayTemplate {
  id        String   @id @default(uuid()) @db.Uuid
  tenantId  String   @map("tenant_id") @db.Uuid
  tenant    Tenant   @relation(fields: [tenantId], references: [id], onDelete: Cascade)
  name      String   @db.VarChar(150)
  /// Wall-clock anchor in "HH:mm". Each slot's start/end is computed:
  /// slot[i].start = startTime + sum(slot[<i].durationMinutes); slot[i].end = slot[i].start + durationMinutes.
  /// Storage is durations only — chronology/contiguity/non-overlap are emergent.
  startTime String   @map("start_time") @db.VarChar(5)
  createdAt DateTime @default(now()) @map("created_at")
  updatedAt DateTime @updatedAt @map("updated_at")

  slots             TimeSlot[]
  weekTemplateDays  WeekTemplateDay[]

  @@unique([tenantId, name])
  @@index([tenantId])
  @@map("day_templates")
}

model TimeSlot {
  id              String   @id @default(uuid()) @db.Uuid
  dayTemplateId   String   @map("day_template_id") @db.Uuid
  dayTemplate     DayTemplate @relation(fields: [dayTemplateId], references: [id], onDelete: Cascade)
  type            SlotType
  name            String   @db.VarChar(100)
  durationMinutes Int      @map("duration_minutes")
  ordinalPosition Int      @map("ordinal_position")
  /// Required when type = LUNCH; rejected otherwise. FK to existing
  /// CanteenLunchShift row. ON DELETE Restrict: a shift in use by a slot
  /// cannot be deleted from rooms config.
  lunchShiftId    String?  @map("lunch_shift_id") @db.Uuid
  lunchShift      CanteenLunchShift? @relation(fields: [lunchShiftId], references: [id], onDelete: Restrict)
  createdAt       DateTime @default(now()) @map("created_at")
  updatedAt       DateTime @updatedAt @map("updated_at")

  @@unique([dayTemplateId, ordinalPosition])
  @@index([dayTemplateId])
  @@index([lunchShiftId])
  @@map("time_slots")
}

/// Tenant-flat catalog: a named MON-SUN map onto day templates. NOT scoped to AY.
model WeekTemplate {
  id        String   @id @default(uuid()) @db.Uuid
  tenantId  String   @map("tenant_id") @db.Uuid
  tenant    Tenant   @relation(fields: [tenantId], references: [id], onDelete: Cascade)
  name      String   @db.VarChar(150)
  createdAt DateTime @default(now()) @map("created_at")
  updatedAt DateTime @updatedAt @map("updated_at")

  days                  WeekTemplateDay[]
  departmentAssignments DepartmentWeekTemplateAssignment[]
  gradeAssignments      GradeWeekTemplateAssignment[]

  @@unique([tenantId, name])
  @@index([tenantId])
  @@map("week_templates")
}

/// Always exactly 7 rows per WeekTemplate (one per DayOfWeek), seeded on
/// create. `dayTemplateId = null` is a non-operative day (NA).
model WeekTemplateDay {
  id             String       @id @default(uuid()) @db.Uuid
  weekTemplateId String       @map("week_template_id") @db.Uuid
  weekTemplate   WeekTemplate @relation(fields: [weekTemplateId], references: [id], onDelete: Cascade)
  dayOfWeek      DayOfWeek    @map("day_of_week")
  dayTemplateId  String?      @map("day_template_id") @db.Uuid
  dayTemplate    DayTemplate? @relation(fields: [dayTemplateId], references: [id], onDelete: Restrict)
  createdAt      DateTime     @default(now()) @map("created_at")
  updatedAt      DateTime     @updatedAt @map("updated_at")

  @@unique([weekTemplateId, dayOfWeek])
  @@index([dayTemplateId])
  @@map("week_template_days")
}

/// AY-scoped department-level binding. Resolves as the fallback when a Grade
/// has no own row. Modeled as a separate child table (not an inline FK on
/// Department) because the binding is AY-scoped.
model DepartmentWeekTemplateAssignment {
  id             String       @id @default(uuid()) @db.Uuid
  tenantId       String       @map("tenant_id") @db.Uuid
  tenant         Tenant       @relation(fields: [tenantId], references: [id], onDelete: Cascade)
  academicYearId String       @map("academic_year_id") @db.Uuid
  academicYear   AcademicYear @relation(fields: [academicYearId], references: [id], onDelete: Cascade)
  departmentId   String       @map("department_id") @db.Uuid
  department     Department   @relation(fields: [departmentId], references: [id], onDelete: Cascade)
  weekTemplateId String       @map("week_template_id") @db.Uuid
  weekTemplate   WeekTemplate @relation(fields: [weekTemplateId], references: [id], onDelete: Restrict)
  createdAt      DateTime     @default(now()) @map("created_at")
  updatedAt      DateTime     @updatedAt @map("updated_at")

  @@unique([academicYearId, departmentId])
  @@index([tenantId, academicYearId])
  @@index([weekTemplateId])
  @@map("department_week_template_assignments")
}

/// AY-scoped grade-level binding. Takes precedence over the department row in
/// the resolver. Mirrors `DepartmentWeekTemplateAssignment` exactly.
model GradeWeekTemplateAssignment {
  id             String       @id @default(uuid()) @db.Uuid
  tenantId       String       @map("tenant_id") @db.Uuid
  tenant         Tenant       @relation(fields: [tenantId], references: [id], onDelete: Cascade)
  academicYearId String       @map("academic_year_id") @db.Uuid
  academicYear   AcademicYear @relation(fields: [academicYearId], references: [id], onDelete: Cascade)
  gradeId        String       @map("grade_id") @db.Uuid
  grade          Grade        @relation(fields: [gradeId], references: [id], onDelete: Cascade)
  weekTemplateId String       @map("week_template_id") @db.Uuid
  weekTemplate   WeekTemplate @relation(fields: [weekTemplateId], references: [id], onDelete: Restrict)
  createdAt      DateTime     @default(now()) @map("created_at")
  updatedAt      DateTime     @updatedAt @map("updated_at")

  @@unique([academicYearId, gradeId])
  @@index([tenantId, academicYearId])
  @@index([weekTemplateId])
  @@map("grade_week_template_assignments")
}

Side edits on existing models (back-relations only): - Tenant: + dayTemplates DayTemplate[], + weekTemplates WeekTemplate[], + departmentWeekTemplateAssignments, + gradeWeekTemplateAssignments. - CanteenLunchShift: + timeSlots TimeSlot[]. - Department: + weekTemplateAssignments DepartmentWeekTemplateAssignment[]. - Grade: + weekTemplateAssignments GradeWeekTemplateAssignment[]. - AcademicYear: + departmentWeekTemplateAssignments, + gradeWeekTemplateAssignments.

Migration shape

  • Additive only. Five new tables + two new enums. No column drops, no renames, no backfills, no data movement.
  • Hazard checklist (chapter 12): no destructive operations, no constraints on existing tables, no enum value removals. Safe single-pass migrate dev.
  • One open structural question (see §10 O-3): the lunchShiftId Restrict guard on TimeSlot means a CanteenLunchShift deletion (or AY rollover that drops shifts) will fail if any slot references it. The choice is between (a) Restrict + admin must remove the LUNCH slot first, or (b) SetNull + the slot is silently degraded into an "orphan" LUNCH slot. The spec ships with Restrict (a) as the safer default.

Indexes and uniqueness

  • @@unique([tenantId, name]) on DayTemplate and WeekTemplate — per-type, per-tenant, case-insensitive uniqueness enforced at the service layer using the eval-scales findFirst + mode: 'insensitive' pattern; the DB-level unique catches concurrent races but is case-sensitive (Postgres default).
  • @@unique([dayTemplateId, ordinalPosition]) on TimeSlot — no gaps, no duplicates. The service ensures ordinals are sequential 1..n on every write (eval-scales NON_SEQUENTIAL_ORDINALS precedent).
  • @@unique([weekTemplateId, dayOfWeek]) on WeekTemplateDay — exactly one row per (week, day-of-week). All 7 rows are seeded on WeekTemplate create; PATCH only mutates the dayTemplateId column on existing rows.
  • @@unique([academicYearId, departmentId]) and @@unique([academicYearId, gradeId]) on the two assignment tables — at most one binding per (AY, target).
  • @@index([tenantId, academicYearId]) on both assignment tables — supports the tree-loading query.

5. API surface

All endpoints under the TIMETABLE_TEMPLATES entity. Standard @ProtectedResource() chain.

Day Templates

Verb Path Decorators Request DTO Response DTO
GET /day-templates @RequireScopes(TIMETABLE_TEMPLATES, 'configuration') (none) DayTemplateListResponseDto[] — each row: { id, name, startTime, endTime, slotNames: string[], usedInWeekTemplates: string[] }
GET /day-templates/:id @RequireScopes(..., 'configuration') (none) DayTemplateResponseDto{ id, name, startTime, endTime, slots: TimeSlotResponseDto[], usedInWeekTemplates: { id, name }[] }
POST /day-templates @RequireScopes(..., 'configuration'), @RequireAction(..., 'create') CreateDayTemplateDto DayTemplateResponseDto
PATCH /day-templates/:id @RequireScopes(..., 'configuration') UpdateDayTemplateDto (full ordered slots list — syncEntities<> pattern) DayTemplateResponseDto
POST /day-templates/:id/duplicate @RequireScopes(..., 'configuration'), @RequireAction(..., 'create') (none) DayTemplateResponseDto
DELETE /day-templates/:id @RequireScopes(..., 'configuration'), @RequireAction(..., 'delete') (none) 204

TimeSlotResponseDto: { id, type, name, durationMinutes, ordinalPosition, lunchShiftId, lunchShiftLabel?, startTime, endTime }. startTime/endTime are computed at projection time, not stored. lunchShiftLabel is a denormalised display string ("Shift A 12:30-13:15") so the FE doesn't have to join into rooms.

Week Templates

Verb Path Decorators Request DTO Response DTO
GET /week-templates @RequireScopes(..., 'configuration') (none) WeekTemplateListResponseDto[]{ id, name, operativeDays: [{ dayOfWeek, dayTemplateName, startTime, endTime }], usedInDepartments: string[] }
GET /week-templates/:id @RequireScopes(..., 'configuration') (none) WeekTemplateResponseDto — full 7-day map (including NA entries) + assigned-department list
POST /week-templates @RequireScopes(..., 'configuration'), @RequireAction(..., 'create') CreateWeekTemplateDto (7-entry map, ≥1 non-null) WeekTemplateResponseDto
PATCH /week-templates/:id @RequireScopes(..., 'configuration') UpdateWeekTemplateDto (partial 7-entry map) WeekTemplateResponseDto
POST /week-templates/:id/duplicate @RequireScopes(..., 'configuration'), @RequireAction(..., 'create') (none) WeekTemplateResponseDto
DELETE /week-templates/:id @RequireScopes(..., 'configuration'), @RequireAction(..., 'delete') (none) 204

Timetable Assignments

Verb Path Decorators Request DTO Response DTO
GET /timetable-assignments @RequireScopes(..., 'configuration') AcademicYearQueryDto AssignmentTreeResponseDto{ academicYearId, departments: [{ id, name, weekTemplate, grades: [{ id, name, studentsCount, effectiveWeekTemplate, source: 'GRADE' \| 'DEPARTMENT' \| null, alerts: AlertDto[] }] }] }
PUT /timetable-assignments/department/:departmentId @RequireScopes(..., 'configuration'), @RequireAction(..., 'assign') { weekTemplateId: string } DepartmentAssignmentResponseDto
DELETE /timetable-assignments/department/:departmentId @RequireScopes(..., 'configuration'), @RequireAction(..., 'assign') (none) 204
PUT /timetable-assignments/grade/:gradeId @RequireScopes(..., 'configuration'), @RequireAction(..., 'assign') { weekTemplateId: string } GradeAssignmentResponseDto
DELETE /timetable-assignments/grade/:gradeId @RequireScopes(..., 'configuration'), @RequireAction(..., 'assign') (none) 204

Writes target the tenant's ACTIVE AY (codebase convention — writes ignore any ?academicYear= query). Read accepts AcademicYearQueryDto to inspect a different AY's tree (mostly DRAFT or completed years).

Swagger considerations

  • AlertDto: oneOf shape parameterised by code. v1 has one code (LUNCH_CAPACITY_EXCEEDED), so a flat shape suffices, but we structure it as a discriminated union so additional alerts can land without breaking FE.
  • usedInWeekTemplates / usedInDepartments are returned as string[] of names on the list endpoints (user UX spec) and as { id, name }[] on the detail endpoints (FE link target needs the id).
  • Error examples for every code in §3 wired into error-examples.ts.

6. RBAC seed plan

Seed file Delta
PermissionScope (prisma/seed/rbac-catalogue.ts) + { entity: 'TIMETABLE_TEMPLATES', scope: 'configuration' }
PermissionAction (prisma/seed/rbac-catalogue.ts) + { entity: 'TIMETABLE_TEMPLATES', action: 'create' }, + { ..., action: 'delete' }, + { ..., action: 'assign' }
ScopeFieldMapping (prisma/seed/rbac-catalogue.ts) none — the configuration scope is descriptor-only (no field-level filtering).
Role grants (prisma/seed/roles.ts) ADMIN: (TIMETABLE_TEMPLATES, 'configuration'), (TIMETABLE_TEMPLATES, 'create'), (TIMETABLE_TEMPLATES, 'delete'), (TIMETABLE_TEMPLATES, 'assign'). No other role gets any permission on this entity in v1.
*_SCOPES runtime constant (src/common/constants/scope-fields.ts) + export const TIMETABLE_TEMPLATES_SCOPES = [] as const; (descriptor-only, drift-check satisfied).

The drift-check spec (rbac-catalogue.drift.spec.ts) re-runs unchanged and verifies the new entry.


7. Divergence ledger

Pattern We diverge by Reason Tradeoff accepted
Eval-scales cascade: inline nullable FK on each level (Curriculum.gradingScaleId, StudyPlan.gradingScaleId, …) We use two child tables (DepartmentWeekTemplateAssignment, GradeWeekTemplateAssignment) instead of inlining weekTemplateId on Department/Grade Assignment is AY-scoped: a single column on the parent entity can only hold one binding, but we need one per AY. Adding two new tables is the minimum change that preserves the cascade resolver's shape. Two extra tables, two extra join steps for tree loading. Acceptable because the cardinality is low (≤ ~50 grades × N AYs per tenant) and the resolver stays a pure two-input function (gradeRow?.weekTemplate ?? departmentRow?.weekTemplate ?? null), identical in shape to eval-scales.
US-36 S3 says "the assignment is blocked if the capacity would be exceeded" We never block on capacity. Assignment always succeeds; capacity overflow surfaces as a read-time alert per affected grade. Direct user override during brainstorm (2026-05-25): "block at write, always assign, show the alert on read only" → interpreted as "no block, always assign, alert on read". Simpler model, no write-time predicate, no review-flag state column. Admin can save assignments that are not physically realisable. Mitigated by the alert surfacing on every tree read; admin sees the problem immediately and re-assigns.
Capacity alert visibility = aggregate predicate (sum students across grades sharing a shift) Alert is always presented per-grade in the tree, even when the offending predicate is aggregate (e.g., 3 grades share a shift, sum overflows: each of the 3 grades gets the alert). Direct user instruction: "lunch capacity check must be visible per grade." The aggregate is the only correct predicate (cross-grade sums matter), so we evaluate aggregate and broadcast the alert per-grade. Aggregate alerts shared across multiple grades may look duplicative on the FE; resolved by the alert payload carrying studentsInShift which the FE can render to clarify "you and others share this shift".
Slot type as Prisma enum (PERIOD | INTERVAL | LUNCH) US-35 S13 specifies a custom Slot Type CRUD with rename-propagation User explicit cut: "default only for now". If custom slot types land later, the migration promotes the enum to a SlotType table with FK on TimeSlot.slotTypeId. The current enum values become seeded rows. Schema-level cost ≈ 1 destructive migration when revisited; acceptable.

8. Pushback log

US says Conflicts with Proposed instead Status
US-35 S3: "Time Slots are ordered chronologically, contiguous with no gaps, non-overlapping, ≥1 PERIOD slot present" The "start time + sequential durations" data model makes chronology/contiguity/non-overlap automatic — they cannot be violated by construction Only enforce ≥1 PERIOD slot on the server. Chronology is emergent; not validated. Resolved — kept in §1 success criteria
US-35 S5: "system automatically assigns a progressive name (e.g. Period 1, Period 2) and admin can override" Backend has no opinion on naming; FE/UX seeds defaults FE picks default names client-side; backend just persists whatever name the DTO carries. No server-side auto-naming logic. Resolved — confirmed in user UX walkthrough
US-35 S9/S10: "modifying a referenced Day/Week Template re-validates capacity and warns with 'flag for review'" We deleted the review-flag state; alerts surface live at read time DayTemplate edit re-validates structural invariants (≥1 PERIOD, LUNCH→lunch-shift FK). Week Template edits re-validate "≥1 operative day". No capacity re-validation on write; the next assignments-tree GET shows the alert. Resolved — confirmed in brainstorm
US-35 S11: "Day/Week Template modifications blocked after timetable publication" US-38 doesn't exist; no PublishedTimetable state to check Deferred. Lands with US-38. Resolved — deferred
US-35 S12: "duplicate includes the same Department and Grade assignments as the original" Assignments have @@unique([academicYearId, target]) — copying assignments to a duplicate would violate uniqueness (two templates pointing to the same dept) Duplicate copies template structure only (slots / day map). Admin re-assigns the duplicate explicitly. Open — user to confirm. The US text appears self-contradictory; I propose dropping the assignment copy from duplicate semantics
US-35 S13: custom Slot Type CRUD + rename propagation User cut "default only for now" Defer. Slot type stays a Prisma enum. Resolved — deferred
US-36 S3: "assignment is blocked if capacity would be exceeded" User override: never block on capacity Always assign; surface alert on read. Resolved — see §7 divergence row 2
US-36 S4: break-room capacity check No Break-Room concept exists in src/rooms/; user UX mentions only lunch alerts Deferred. Lands when rooms gain a breakRoom: boolean flag and product calls for it. Resolved — deferred
US-36 S5: "student assignments to Homerooms/Subject Groups must be complete before week template assignment" US-33/33.1 doesn't exist; precondition unrepresentable Deferred to US-33 landing. Resolved — deferred
US-36 S8: AY carryover of assignments AY rollover is its own scope (docs/superpowers/specs/2026-05-12-rollover-setup-design.md territory) Deferred. New AY starts empty; admin re-assigns. Resolved — deferred
US-36 S10: "All active grades must have effective week template before timetable generation" US-38 doesn't exist Deferred to US-38. Resolved — deferred

9. Deferrals

  • Custom Slot Type CRUD (US-35 S13) — defer until product calls for it; today's enum is sufficient. Follow-up: revisit at next iteration; if it lands, the enum-to-table migration is the canonical refactor pattern (~1 destructive migration, well-scoped).
  • Post-publication freeze (US-35 S11) — lands with US-38 (Timetable Generation) which introduces PublishedTimetable state.
  • Capacity re-validation on template edits (US-35 S9/S10 "review flag") — superseded by live read-time alerts in §3 architecture; no follow-up needed unless product wants persistent flags.
  • Break-room capacity check (US-36 S4) — defer until rooms gain a breakRoom flag and aggregate capacity. Follow-up: tracked as part of US-36 iteration N+1 once break-room semantics are agreed.
  • Student-assignment prerequisite (US-36 S5) — blocked on US-33/33.1; lands when homerooms / subject groups exist. The assignment service should add the prerequisite check as a non-breaking precondition gate when those tables become available.
  • AY carryover (US-36 S8) — lands with the AY rollover iteration that already exists for academic years; extend the rollover handler with a "carry assignments" toggle.
  • Pre-generation aggregate gate (US-36 S10) — lands with US-38.
  • Permission for non-admin roles — only ADMIN gets any permission on TIMETABLE_TEMPLATES in v1. Teachers reading their own timetable comes via a downstream "view published timetable" surface (TUS-05 / RUS-05 territory), not this catalog.

10. Open questions

  • O-1: assign vs split assign + unassign action keys. Single assign gates both PUT and DELETE on assignment endpoints (simpler, fewer seed rows). Split adds unassign (mirrors the symmetry of HTTP verbs, more granular RBAC if a future role gets one and not the other). Recommendation ACCEPTED: single assign for v1; split is a non-breaking later change.
  • O-2: Duplicate semantics for Week Template assignments (see §8 pushback row 5). US text says "duplicate carries over Department and Grade assignments"; this conflicts with @@unique([academicYearId, target]). Recommendation ACCEPTED: duplicate copies template structure only (slots / day map), no assignments. Admin re-assigns explicitly.
  • O-3: lunchShiftId cascade on CanteenLunchShift delete: Restrict (admin must remove the LUNCH slot first; safer) vs SetNull (slot becomes orphaned, type=LUNCH with null shift; needs read-time guard). Recommendation ACCEPTED: Restrict for v1; the admin-error message points to the offending slot.
  • O-4: Multiple LUNCH slots per Day Template? My §1 reading assumes yes (school with two waves). User UX did not address this explicitly. CONFIRMED. Same question for multiple INTERVAL slots — same answer should apply. -> ANSWER: YES
  • O-5: Capacity alert participation when a grade's effective week template's lunch slot binds shift S, and another grade's department-level fallback also binds shift S via a different week template — do both grades' student counts contribute to the same aggregate? My §1 reading says yes (the shift is the only physical constraint, the path through templates doesn't matter). CONFIRMED.
  • O-6: Admin renames a DayTemplate referenced by a WeekTemplate. The eval-scales pattern allows rename always (name is independent of identity). I propose the same: rename never blocked. CONFIRMED.

Must be empty (all resolved) before sign-off.


11. Verification plan

Unit specs

  • day-templates.service.spec.ts — create/update/delete/duplicate guards: name uniqueness (case-insensitive, per tenant); ≥1 PERIOD slot; LUNCH→lunch-shift FK required + must exist + must be tenant-scoped (visible through Room.tenantId); ordinal-position sequential; in-use guard on delete (referenced by WeekTemplateDay.dayTemplateId).
  • week-templates.service.spec.ts — create with 7-day map; ≥1 operative day on create + update; in-use guard on delete (referenced by either assignment table); duplicate copies the day map but not assignments.
  • timetable-assignments.service.spec.ts — PUT-as-upsert (create + replace); resolver fall-through (grade-level wins, department-level fallback, null fallback); DELETE removes only the level addressed; AY-scope isolation (assignments in AY 2026 don't leak into AY 2027).
  • timetable-templates.queries.spec.ts — pure helpers: resolveEffectiveAssignment(gradeRow, departmentRow) truth table; computeSlotStartEndTimes(startTime, slots) cumulative addition; computeLunchAlertsForTree(tree, lunchShiftCapacities) aggregate predicate per shift.
  • DTO specs — CreateDayTemplateDto (ordinal sequential validator, LUNCH→shift required), CreateWeekTemplateDto (≥1 operative), assignment DTOs.
  • rbac-catalogue.drift.spec.ts — verifies the new seed entries + TIMETABLE_TEMPLATES_SCOPES constant.

E2E specs

  • day-templates.e2e-spec.ts — covers US-35 S2 (lunch FK), S3 (≥1 PERIOD), S4 (start-time derives slot times), S6+S7 (week-template day map / ≥1 operative day — split into the week-templates e2e), S8 (name uniqueness), S12 (duplicate), S14 (delete-in-use).
  • week-templates.e2e-spec.ts — covers US-35 S6, S7, S8, S12, S15 (delete-when-assigned guard against US-36 assignment table).
  • timetable-assignments.e2e-spec.ts — covers US-36 S2 (grade-over-department precedence; resolver), S6 (replace path), S7 (remove path with fallback to department / null when no department row), S9 (new grade with no assignment surfaces in the tree with effectiveWeekTemplate: null), and the lunch-capacity alert path (US-36 S3 redirected: assignment succeeds, alert appears on the affected grade rows).
  • E2E discipline per feedback_e2e_isolation_patterns: Date.now() stamping on names, deleteMany in afterAll for the five new tables (delete order: assignments → week-template-days → time-slots → week-templates → day-templates).

Manual verification

  • Configure 2 lunch shifts in rooms (capacities 50 and 100).
  • Create a Day Template "Standard 8h" with PERIOD/INTERVAL/LUNCH/PERIOD slots, lunch picks shift-50.
  • Create a Week Template "Mon-Fri Standard" mapping all weekdays to "Standard 8h", weekend NA.
  • Assign "Mon-Fri Standard" to department "Liceo Scientifico" (no per-grade override).
  • Add a third grade to "Liceo Scientifico" with 30 students (existing two have 30 and 25). Total 85 students sharing shift-50.
  • Expected: GET /timetable-assignments returns all 3 grades with LUNCH_CAPACITY_EXCEEDED alert (sum 85 > capacity 50).
  • Then PUT /timetable-assignments/grade/<one-grade-id> to a week template using shift-100. Expected: the other two grades still alert (sum 55 > 50 ... still over), the moved grade clears.
  • Then PUT /timetable-assignments/grade/<another-grade-id> to shift-100 as well. Expected: 30 < 50 on shift-50, all alerts clear.

Patterns: chapter 09 (testing), and feedback_e2e_isolation_patterns for E2E discipline.


12. Sign-off

  • Approved by: Fabio Barbieri
  • Date: 2026-05-25
  • Chat reference: Approved in chat 2026-05-25 — design walked through, all six §10 open questions confirmed/resolved inline in the spec file by the user, plan agent approved via ExitPlanMode.