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 (mutateAndDiagnosePUBLISHED 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 / delete — no new keys |
POST /:id/revision → create; POST /:id/publish-revision → publish; discard is the existing DELETE /:id → delete. |
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;nulluntil first published; never cleared on unpublish.Timetable.revisionOfId String? @map("revision_of_id") @db.Uuid+ self-relationrevisionOf/revision(relation name"TimetableRevision",onDelete: Cascade): a DRAFT withrevisionOfId = Pis the open revision of publishedP. Inverse to-onerevisiongivesPits 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: null—UPDATE 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.tsdelta (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 plainCREATE UNIQUE INDEX; confirm it is not concurrent-required (table is small, brief lock acceptable).- Self-FK
onDelete: Cascade: deleting a publishedPcascades to its open revisionR— intended (no orphan drafts).
Indexes and uniqueness¶
@@unique([revisionOfId])— enforces "≤1 open revision per published"; also the race backstop for concurrentstartRevision(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 |
TimetableResponseDto — first 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 PUBLISHED → TIMETABLE_NOT_PUBLISHED; if ARCHIVED → TIMETABLE_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 != DRAFT → TIMETABLE_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_REVISION — 409 — params { id } — "This timetable is published; open a revision to edit it."
- TIMETABLE_NOT_PUBLISHED — 409 — params { id, status } — "A revision can only be started from a published timetable."
- TIMETABLE_NOT_A_REVISION — 409 — 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.tsblocksApiStartRevision/ApiPublishRevisionwith 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_VIOLATEexample. - 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
TimetablePublishedevent inpublishRevisionand 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 statusvs dedicated publish-revision endpoint? → dedicatedPOST /: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 + setsrevisionOfId/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 ofRlands onPwithP.idunchanged;Rdeleted;publishedAtrefreshed; ERROR inR→TIMETABLE_PUBLISH_BLOCKED+ full rollback (P untouched, R intact); AY not ACTIVE →TIMETABLE_PUBLISH_AY_NOT_ACTIVE;R.revisionOfId == null→TIMETABLE_NOT_A_REVISION.mutateAndDiagnose: PUBLISHED content edit now →TIMETABLE_EDIT_REQUIRES_REVISION(lesson, break, activity all); DRAFT edits unchanged.- Response mapping:
hasPendingChanges/openRevisionId/revisionOfId/publishedAtcorrect for a plain draft, a published-with-revision, and a revision draft. - Unit specs (
src/timetables/timetables.queries.spec.ts):copyTimetableContentfidelity — lessons (both anchors), breaks (+ audience + duties), activities (+ audience + teachers) all reproduced;clearTimetableContentremoves 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,publishedAtrefreshed,hasPendingChanges=false; discard path leaves published intact; a previously-taken attendance cell (anchor snapshot) still reads correctly after a republish. E2E isolation perfeedback_e2e_isolation_patterns.md. - Manual verification: drive the flow via the
verifyskill 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.