Skip to content

Timetable — Revision & Republish (staged modify-then-publish)

1. Problem distillation

  • A PUBLISHED timetable is live: attendance resolves the register by status:'PUBLISHED' and reads lesson/activity rows straight off it (attendance.queries.ts:230, loadLessonForTake), and teacher/student read-views read the same rows. Today, editing a published timetable applies immediately and live (mutateAndDiagnose PUBLISHED branch), so consumers see every intermediate state of a multi-step reorg.
  • Worse, a multi-step reorg on a published timetable is often impossible: each step is diagnosed live and any step that transiently introduces an ERROR is rolled back (TIMETABLE_EDIT_WOULD_VIOLATE), so "move A out of the way, then move B into its slot" cannot be expressed.
  • The FE needs a modify-then-republish flow: batch a set of edits shielded from consumers, then promote them atomically, with a signal ("Modifiche non pubblicate") that a published timetable has staged edits and a "Pubblica modifiche" action that promotes them.
  • Notification of consumers (teachers/students) on each publish is desired later — this iteration only leaves the seam.

Success criteria (observable behavior that proves this works): - Editing a PUBLISHED timetable's content directly is rejected; the admin must open a revision (a real DRAFT copy) and edit that. - While a revision is open and being edited, attendance and read-views on the published timetable are byte-for-byte unchanged — no consumer observes staged edits. - Publishing a revision swaps its content onto the published timetable atomically (single transaction; consumers flip old→new at commit), keeping the same published id, refreshing publishedAt, and deleting the revision draft. - A published timetable's DTO exposes publishedAt, hasPendingChanges (an open revision exists), and openRevisionId (which draft to open/publish); a revision draft's DTO exposes revisionOfId. - Discarding a revision (DELETE the draft) leaves the published timetable untouched and clears hasPendingChanges. - Already-taken attendance cells survive a republish unchanged (they are self-contained anchor snapshots).

Non-goals (in-scope-shaped things this iteration is explicitly not doing): - Actually notifying teachers/students on publish (only the post-commit seam is defined). - Retaining a version history of superseded published content (hard-delete on swap; audit log records the action, not prior grids). - Any change to the diagnostics engine, the publish gate rules, generation, or attendance take logic. - Concurrent multi-admin editing of the same revision (last-write-wins on the shared draft is acceptable for admin-only v1). - Renaming a published timetable is unchanged (metadata, allowed live) — only content is revision-gated.


2. Patterns survey

Analogous module/spec What we'd borrow What doesn't fit
src/timetables/timetables.service.ts::duplicate (207–) The deep-copy of a whole timetable's content (lessons via createMany, breaks/activities nested per-row with audience/duty/teacher sub-rows) — extract as copyTimetableContent(tx, fromId, toId, tenantId) reused by duplicate, revision-start, and the swap. duplicate mints a brand-new standalone container named "… (copy)"; a revision is linked (revisionOfId) and named after its parent, and the swap copies into an existing target after clearing it.
src/timetables/timetables.service.ts::setStatus (1535–) + mutateAndDiagnose (558–) Row-lock discipline (FOR UPDATE on publish, FOR SHARE re-read on edit) that serialises publish against edits; the publish gate (zero ERRORs, AY ACTIVE); computeDiagnostics(buildDiagnosticsSnapshot(...)). The PUBLISHED-edit apply-and-maybe-rollback branch is removed (published content becomes immutable); the swap locks the draft row (edits take FOR SHARE there), not only the published row.
docs/superpowers/specs/2026-07-13-timetable-activities-design.md + attendance ch19 Attendance records are deep anchor snapshots (AttendanceRecord, schema.prisma:538) keyed by (date, startTick, subjectGroupId/combinedClassId/activityId) with no FK to Timetable.id or ScheduledLesson.id — so minting fresh content rows on swap (and changing lesson ids) cannot orphan history. Attendance always resolves the PUBLISHED timetable; there is exactly one, and the swap preserves that (stable id), so attendance needs zero changes.
src/timetables/timetables.service.ts partial-unique timetables_tenant_ay_published_unique (at most one PUBLISHED per AY) The single-PUBLISHED invariant — the swap never creates a second published row, so the index is never stressed; a @@unique([revisionOfId]) mirrors it for "at most one open revision per published". — fits cleanly.
src/common/utils/after-commit-queue.ts (used by teachers/staff/students/invitations services) The post-commit fan-out seam for the deferred publish notification. Notification transport itself is out of scope this iteration.

3. Architecture mapping

Primitive Apply? How Justify
Tenant scope yes All reads/writes stay tenant-scoped via TimetablesPolicy.where(ctx) + RLS on timetables and child tables (unchanged). revisionOfId references a same-tenant Timetable.id. No new table; existing RLS coverage applies to the new columns.
Academic-year scope yes A revision is a DRAFT in the same (tenant, academicYearId) as its parent; the swap re-checks the AY is ACTIVE (reusing TIMETABLE_PUBLISH_AY_NOT_ACTIVE). Publish gating already keys on the AY; revision inherits the parent's AY.
RBAC entity key existing EntityKey.TIMETABLES No delta. Revision endpoints live under the same entity.
Scopes existing configuration (descriptor-only) No delta; content edits on a revision use the existing @RequireScopes(TIMETABLES, 'write'). TIMETABLE_SCOPES = { configuration: [] } (scope-fields.ts:347) unchanged.
Actions existing create / publish / deleteno new keys POST /:id/revisioncreate; POST /:id/publish-revisionpublish; discard is the existing DELETE /:iddelete. Reusing actions avoids RBAC seed + drift-guard churn; all routes are admin-only via TimetablesPolicy anyway.
Service base custom (TimetablesService) New methods startRevision, publishRevision; mutateAndDiagnose PUBLISHED branch changes to reject; extract copyTimetableContent. Module is already a bespoke service (flat DTOs, FLAT_DTO_ENTITIES), not BaseTenantedCrudService.
queries.ts shape extend Add publishedAt, revisionOfId, and inverse revision: { select: { id: true } } to timetableSummaryInclude; new copyTimetableContent(tx, fromId, toId, tenantId); clearTimetableContent(tx, timetableId); a findOpenRevision(tx, publishedId) helper. Named functions only, per repo convention.
Error codes new: TIMETABLE_EDIT_REQUIRES_REVISION, TIMETABLE_NOT_PUBLISHED, TIMETABLE_NOT_A_REVISION. removed: TIMETABLE_EDIT_WOULD_VIOLATE (its scenario no longer exists). reused: TIMETABLE_PUBLISH_BLOCKED, TIMETABLE_PUBLISH_AY_NOT_ACTIVE, TIMETABLE_READ_ONLY, TIMETABLE_NOT_FOUND, TIMETABLE_PUBLISH_CONFLICT (first publish only). See §5 for params + i18n; new codes need messages{en,it}, examples, and DIAGNOSTIC/params-map entries (project_backend_error_i18n).
DTO conventions extend flat DTO TimetableResponseDto gains publishedAt, hasPendingChanges, openRevisionId, revisionOfId. timetables stays in FLAT_DTO_ENTITIES; additive fields only.
File-backed sub-resources n/a — no files.
Custom fields n/a — timetable has none.
Profile completeness n/a — not a person entity.

4. Data model plan

Schema deltas

  • Timetable.publishedAt DateTime? @map("published_at") — timestamp of the most recent successful publish/republish; null until first published; never cleared on unpublish.
  • Timetable.revisionOfId String? @map("revision_of_id") @db.Uuid + self-relation revisionOf/revision (relation name "TimetableRevision", onDelete: Cascade): a DRAFT with revisionOfId = P is the open revision of published P. Inverse to-one revision gives P its open revision.
model Timetable {
  // … existing …
  publishedAt   DateTime?  @map("published_at")
  revisionOfId  String?    @map("revision_of_id") @db.Uuid
  revisionOf    Timetable? @relation("TimetableRevision", fields: [revisionOfId], references: [id], onDelete: Cascade)
  revision      Timetable? @relation("TimetableRevision")

  @@unique([revisionOfId])   // at most one open revision per published timetable
  @@index([tenantId, academicYearId])
  @@map("timetables")
}

Migration shape

  • Additive. Two nullable columns + one self-FK + one unique index. No column drops/renames.
  • Data backfill: optional one-liner so existing published rows don't render publishedAt: nullUPDATE timetables SET published_at = updated_at WHERE status = 'PUBLISHED'; (dev/stage only; greenfield, resettable).
  • Hazards from chapter 12 checklist:
  • No uncommitted migration in tree (verified — last is 20260713151952_remove_day_bounds); nothing to fold.
  • Not a new tenant-bearing model → no rls-coverage.ts / tenanted-models.ts delta (columns on an already-covered model).
  • @@unique([revisionOfId]) on a nullable column: Postgres treats NULLs as distinct, so all existing rows (revisionOfId NULL) pass; safe to add on populated table. Prisma emits a plain CREATE UNIQUE INDEX; confirm it is not concurrent-required (table is small, brief lock acceptable).
  • Self-FK onDelete: Cascade: deleting a published P cascades to its open revision R — intended (no orphan drafts).

Indexes and uniqueness

  • @@unique([revisionOfId]) — enforces "≤1 open revision per published"; also the race backstop for concurrent startRevision (second insert → P2002).
  • Existing timetables_tenant_ay_published_unique (partial unique on PUBLISHED per AY) — untouched; the swap never creates a second PUBLISHED row.

5. API surface

Verb Path Decorators Request DTO Response DTO
POST /timetables/:id/revision @RequireAction(TIMETABLES, 'create'), @AppliesPolicy(TimetablesPolicy) — (id = the PUBLISHED timetable) TimetableResponseDto (the revision draft R)
POST /timetables/:id/publish-revision @RequireAction(TIMETABLES, 'publish'), @AppliesPolicy(TimetablesPolicy) — (id = the revision draft R) TimetableResponseDto (the updated published P)
GET/PATCH/DELETE existing /:id + /:id/lessons|breaks|activities… unchanged unchanged unchanged (now operate on the revision draft by its id)
PUT /timetables/:id/status unchanged UpdateTimetableStatusDto TimetableResponseDtofirst publish of a never-published DRAFT still stamps publishedAt; rejects with TIMETABLE_NOT_A_REVISION guidance if called on a revision draft (revisions promote via publish-revision, not status)

TimetableResponseDto additive fields (list + detail + every response returning it — create, rename, duplicate, status, revision, publish-revision):

publishedAt: string | null;      // ISO; null until first published
hasPendingChanges: boolean;      // true iff an open revision exists (only meaningful for PUBLISHED)
openRevisionId: string | null;   // the open revision draft's id, else null  (set on a PUBLISHED timetable)
revisionOfId: string | null;     // the published timetable this draft revises, else null  (set on a revision DRAFT)

Behavior: - startRevision: load P; if not PUBLISHEDTIMETABLE_NOT_PUBLISHED; if ARCHIVEDTIMETABLE_READ_ONLY. Idempotent: if an open revision already exists, return it (200); else deep-copy P's content into a new DRAFT R (revisionOfId = P.id, name = P.name, status = DRAFT) and return it (201). Concurrent create race → P2002 on @@unique([revisionOfId]) → re-fetch & return the existing revision. - Editing R: the existing lesson/break/activity CRUD, addressed by R's id — DRAFT rules, frictionless, no live impact. - Direct content mutation on a PUBLISHED timetable (any lesson/break/activity create/patch/delete): mutateAndDiagnose PUBLISHED branch now throws 409 TIMETABLE_EDIT_REQUIRES_REVISION { id }. - publishRevision: load R; if R.revisionOfId == null or R.status != DRAFTTIMETABLE_NOT_A_REVISION; resolve P = R.revisionOf; AY must be ACTIVE (TIMETABLE_PUBLISH_AY_NOT_ACTIVE). One tx (lock ordering: R FOR UPDATE then P FOR UPDATE — edits take FOR SHARE on R, so locking R serialises them; publish/archival on P takes FOR UPDATE): assert P still PUBLISHED & not ARCHIVED; computeDiagnostics on R → any ERROR ⇒ TIMETABLE_PUBLISH_BLOCKED { errorCount } (rollback); else clearTimetableContent(tx, P.id), copyTimetableContent(tx, R.id, P.id), set P.publishedAt = now(), delete R. Enqueue the (deferred) publish notification on the after-commit queue. Return P. - Discard: DELETE /timetables/:R — existing draft delete; P untouched.

New error codes (all AppException, i18n messages{en,it} + examples + params-map per project_backend_error_i18n): - TIMETABLE_EDIT_REQUIRES_REVISION409 — params { id } — "This timetable is published; open a revision to edit it." - TIMETABLE_NOT_PUBLISHED409 — params { id, status } — "A revision can only be started from a published timetable." - TIMETABLE_NOT_A_REVISION409 — params { id } — "Only a revision draft can be published as changes; use the status endpoint for a first publish."

Removed: TIMETABLE_EDIT_WOULD_VIOLATE (+ its error-examples.ts row, DIAGNOSTIC_TEXT_PARAMS/params entries, and the COLLECTION_ENRICHMENTS passthrough row) — the live-edit-of-published scenario it guarded no longer exists.

Swagger considerations

  • New timetables.swagger.ts blocks ApiStartRevision / ApiPublishRevision with FE-facing copy (contract only). Document the four new DTO fields with @ApiProperty/@ApiPropertyOptional.
  • Error examples for the three new codes; drop the TIMETABLE_EDIT_WOULD_VIOLATE example.
  • Controller JSDoc is FE-facing (feedback_swagger_jsdoc_is_public) — no BE internals (tx shape, lock order) in method JSDoc; those live in service inline comments.

6. RBAC seed plan

Seed file Delta
PermissionScope (rbac-catalogue.ts) none
PermissionAction (rbac-catalogue.ts) none (reuse create / publish / delete)
ScopeFieldMapping (rbac-catalogue.ts) none
Role grants (roles.ts) none — ADMIN already auto-grants all TIMETABLES actions via ALL/ALL_WRITE; TimetablesPolicy is admin-only
*_SCOPES runtime constant none (TIMETABLE_SCOPES unchanged)

7. Divergence ledger

Pattern We diverge by Reason Tradeoff accepted
mutateAndDiagnose applies edits live on PUBLISHED (apply + rollback-on-ERROR) Making PUBLISHED content immutable — direct edits rejected; all post-publish edits go through a revision Only way to guarantee consumers never see mid-edit state; also removes the impossible-multi-step-reorg friction Loses the one-click live tweak; "emergency change" becomes open-revision → edit → publish (2 clicks). Removes TIMETABLE_EDIT_WOULD_VIOLATE.
Republish = "go live" (FE hint's mental model) Republish = atomic swap of staged content onto the stable published id; edits were never live until swap Our model has no staging layer; a revision draft is the staging FE contract grows beyond the 2 fields (revision id in play); documented in §8
duplicate mints a standalone copy Revision is a linked draft (revisionOfId) with a @@unique "one per published" guard; swap copies into the existing published row then hard-deletes the draft Stable published id + hard-delete (no version trail) per sign-off Copies content twice per cycle (start + swap); negligible at timetable scale
Metadata + content share the PUBLISHED freeze Rename stays allowed live on PUBLISHED; only content is revision-gated A label change is not consumer-schedule-visible Slight asymmetry (rename live, content staged) — documented

8. Pushback log

US says Conflicts with Proposed instead Status
Add publishedAt + hasPendingChanges; reuse PUT /:id/status {PUBLISHED} as idempotent republish on the same id Our published-timetable is read live by consumers; an in-place live edit is already visible mid-reorg, and PUT status on the same id can't stage anything Staging via a revision draft: edit a separate DRAFT id, promote atomically with POST /:id/publish-revision; expose openRevisionId + revisionOfId alongside the two requested fields Resolved (chat 2026-07-15)
hasPendingChanges = lastContentChangeAt > publishedAt (compare timestamps) Deletes can't be detected from surviving-row max-timestamps; timestamp compare has clock-edge subtleties In the staging model, "pending changes" = "an open revision exists" — a boolean derived from the presence of the linked draft, no timestamp math Resolved (chat 2026-07-15)
"changes not yet published/live" badge copy In our model a revision's edits are genuinely not-live until swap — so the copy is actually accurate here (unlike the earlier in-place model) Keep "Modifiche non pubblicate"; it is truthful under staging Resolved

9. Deferrals

  • Consumer notification on publish — only the after-commit seam is added (enqueue a TimetablePublished event in publishRevision and first publish). No transport/recipients/opt-in. — follow-up: future spec; note in memory.
  • Version history of superseded published content — hard-delete on swap; audit log captures who/when, not the prior grid. — follow-up: switch clearTimetableContent+delete to an archive-copy if a trail is ever wanted (cheap to revisit).
  • Concurrent editing of the same revision by two admins — last-write-wins on the shared draft. — follow-up: revisit if multi-admin timetable editing becomes real.
  • Attendance/read-view convenience "get the current published timetable" endpoint — out of scope; consumers already resolve PUBLISHED internally. — follow-up: none.

10. Open questions

  • Shield consumers from mid-edit state? → yes, staging (Model B) (chat 2026-07-15).
  • Stable published id (B2) vs id-churn (B1)? → B2, stable id (chat 2026-07-15).
  • Forbid direct edits to PUBLISHED (revision-only)? → yes (chat 2026-07-15).
  • Reuse PUT status vs dedicated publish-revision endpoint? → dedicated POST /:id/publish-revision (chat 2026-07-15).
  • Archive vs hard-delete the superseded content? → hard-delete; audit log covers who-did-what (chat 2026-07-15).

(All resolved — ready for sign-off.)


11. Verification plan

  • Unit specs (src/timetables/timetables.service.spec.ts):
  • startRevision: happy path deep-copies content + sets revisionOfId/DRAFT; non-PUBLISHED target → TIMETABLE_NOT_PUBLISHED; ARCHIVED → TIMETABLE_READ_ONLY; idempotent when a revision already exists (returns the same draft, no second copy).
  • publishRevision: content of R lands on P with P.id unchanged; R deleted; publishedAt refreshed; ERROR in RTIMETABLE_PUBLISH_BLOCKED + full rollback (P untouched, R intact); AY not ACTIVE → TIMETABLE_PUBLISH_AY_NOT_ACTIVE; R.revisionOfId == nullTIMETABLE_NOT_A_REVISION.
  • mutateAndDiagnose: PUBLISHED content edit now → TIMETABLE_EDIT_REQUIRES_REVISION (lesson, break, activity all); DRAFT edits unchanged.
  • Response mapping: hasPendingChanges/openRevisionId/revisionOfId/publishedAt correct for a plain draft, a published-with-revision, and a revision draft.
  • Unit specs (src/timetables/timetables.queries.spec.ts): copyTimetableContent fidelity — lessons (both anchors), breaks (+ audience + duties), activities (+ audience + teachers) all reproduced; clearTimetableContent removes all three families + sub-rows.
  • E2E (test/timetables.e2e-spec.ts): publish a timetable → direct lesson edit rejected (409) → start revision → make a multi-step reorg that passes through a transiently-invalid state → published timetable + attendance views observe no change during editing → publish-revision → published reflects all edits atomically, revision gone, publishedAt refreshed, hasPendingChanges=false; discard path leaves published intact; a previously-taken attendance cell (anchor snapshot) still reads correctly after a republish. E2E isolation per feedback_e2e_isolation_patterns.md.
  • Manual verification: drive the flow via the verify skill against a seeded tenant (publish → revision → edit → republish), confirming consumer reads are frozen until swap.

12. Sign-off

  • Approved by: Fabio Barbieri
  • Date: 2026-07-15
  • Chat reference: design walkthrough with Fabio, chat 2026-07-15 (FE gap report → Model B staging; confirmed shield-consumers, stable id, forbid-direct-edit, dedicated publish-revision endpoint, hard-delete; "approved" in chat)

Until this section is filled, no implementation code is written. When you fill it, flip the frontmatter status: to Approved in the same edit.