Timetable Backend Documentation
Backend architecture, data model, services, cache, queues, runtime rules, and operational behavior for Timetable.
Timetable Backend Documentation
1. Documentation Evidence
| Area | Files Inspected | Verified Details |
|---|---|---|
| Module wiring | apps/api/src/modules/timetable/timetable.module.ts | Imports, controllers, providers. |
| Timeslot controller/services | apps/api/src/modules/timetable/timeslots/*.ts | Routes, reads, writes, constraint translation. |
| Timetable entry controller/services | apps/api/src/modules/timetable/entries/*.ts | Draft lifecycle, publish/unpublish, conflict detection, snapshotting. |
| DTOs | apps/api/src/modules/timetable/dto/timeslot.dto.ts, apps/api/src/modules/timetable/dto/timetable.dto.ts | Request/response shapes and validation. |
| Portal reads | apps/api/src/modules/mobile/shared/portal-schedule.dto.ts, apps/api/src/modules/mobile/shared/portal-schedule.query.ts, apps/api/src/modules/mobile/{teacher,student,guardian}/*-schedule-portal.controller.ts | Shared read query, audience scoping, field withholding. |
| Schema | packages/db/src/schema/school/timetable.ts | Tables, enums, constraints, relations. |
| Migration | packages/db/src/migrations/0019_timetable_module.sql | Extension, ordering, three EXCLUDE constraints, triggers. |
| Error codes | apps/api/src/common/types/error-codes.ts (TIMESLOT_*, TIMETABLE_*) | Every error code this module raises and its condition. |
| Permissions | packages/db/src/authorization/permission-catalog.ts | Timetables, TimetablePublish, Schedule grants and withholding. |
2. Backend Scope and Boundaries
Owns
- The structure of a class's teaching week:
class_timeslots— one row per day-of-week/time-range/type, admin CRUD plus a day-to-day(s) copy. - The draft-and-publish lifecycle of a class's timetable:
class_timetablesheaders andclass_timetable_entriesrows, admin create/discard draft, per-slot entry upsert/clear, publish, unpublish. - Version history:
class_timetable_archives, a jsonb snapshot of every superseded or unpublished version, and the restore path (POST .../draft?fromVersionNo=) that rebuilds a draft from one. - Cross-class conflict detection among published entries: no teacher or room may be double-booked at an overlapping time, in the same academic session.
- Three read-only mobile "my schedule" surfaces: teacher, student, and guardian-on-behalf-of-a-child, each scoped to the caller's own rows and returning a reduced, name-only field set.
Does Not Own
- Class, grade, section, and room definitions —
packages/db/src/schema/school/classes.tsand theclasses/roomsmodules. This module references them by id and refuses to hard-delete a class that still has timeslots or timetables (CLASS_HAS_TIMETABLE, enforced in the classes module, not here). - Staff and teacher identity, including soft-delete —
packages/db/src/schema/school/people.ts. This module never trustsstaff.deleted_atat write time (no constraint can see it) and instead revalidates at publish and at read time. - Subject-to-class assignment —
class_subjects, owned by the Subjects module. This module only checks that aclassSubjectIdbelongs to the class being edited. - Academic session lifecycle, including which session is "current" —
academic_sessions. This module readsis_currentto refuse edits against a stale year but does not manage sessions itself. - Authentication and role/permission evaluation —
JwtAuthGuard,RoleGuard,RoleModule. This module declares@Permissions()codes and importsRoleModuleso the guard can resolve, but does not implement guard logic.
Source of Truth
| Concern | Source of Truth | Notes |
|---|---|---|
| A slot's day/time/type | class_timeslots row | The builder cannot invent a time the manager never created; every entry holds a foreign key to the slot it occupies. |
| Whether a version is draft or published | class_timetables.status | An entry's own status column is never written directly — it arrives only through the header composite foreign key's ON UPDATE cascade. |
| Teacher/room availability at a given time | Published class_timetable_entries rows, filtered by the three EXCLUDE constraints | class_timetable_entries_teacher_free and ..._room_free are the actual invariant; findConflict() is a read-only mirror of the same predicate used for user-facing messages, never the enforcement itself. |
| Whether a reference (teacher/room/subject) is still valid | staff.deleted_at, rooms.is_active, class_subjects.is_active, checked at read and publish time | No foreign key or CHECK can see a soft-delete or an active flag; ClassTimetableReadService.computeStaleReferences is the sole place this is computed. |
| Which academic session is current | academic_sessions.is_current | A partial unique index enforces at most one; zero is reachable and is not an error. |
3. Module Composition
| Module | Type | Path | Controllers | Providers | Exports | Responsibility |
|---|---|---|---|---|---|---|
TimetableModule | Leaf, admin-facing | apps/api/src/modules/timetable/timetable.module.ts | ClassTimeslotsController, ClassTimetableController | ClassTimeslotsReadService, ClassTimeslotsWriteService, ClassTimetableReadService, ClassTimetableDraftWriteService, ClassTimetablePublishService | None | Composes the Timeslot Manager and the Timetable Builder into one admin module and imports RoleModule so RoleGuard can resolve. |
| Mobile portal modules (teacher/student/guardian) | Existing, unmodified module shells | apps/api/src/modules/mobile/{teacher,student,guardian}/ | TeacherSchedulePortalController, StudentSchedulePortalController, GuardianSchedulePortalController | (declared elsewhere in those modules) | — | Each declares one schedule controller sharing its module's existing @Controller() prefix, because a duplicate class in two modules would register its routes twice. |
TimetableModule declares both controllers directly rather than through sub-modules, because TimetableModule is the name registered in ADMIN_MODULES for Swagger inclusion, and Swagger's include does not recurse — a module named there that declares no controllers of its own contributes nothing to the document, with no error and no warning.
The write side of the timetable builder is deliberately two services, not one: ClassTimetableDraftWriteService owns draft creation, discard, and the per-slot entry upsert/clear; ClassTimetablePublishService owns publish and unpublish. They are split because publish and unpublish run under a genuinely different locking discipline — both take class_timetables FOR UPDATE, ORDER BY id, which draft editing does not need — not because either service alone would be too large.
4. File and Directory Map
apps/api/src/modules/timetable/
timetable.module.ts
dto/
timeslot.dto.ts
timetable.dto.ts
timeslots/
class-timeslots.controller.ts
class-timeslots-read.service.ts
class-timeslots-write.service.ts
entries/
class-timetable.controller.ts
class-timetable-read.service.ts
class-timetable-draft-write.service.ts
class-timetable-publish.service.ts
class-timetable-conflict.util.ts
class-timetable-draft.util.ts
class-timetable-write.errors.ts
timetable-snapshot.types.ts
__tests__/
timeslots.integration.spec.ts
timetable-write.integration.spec.ts
apps/api/src/modules/mobile/
shared/
portal-schedule.dto.ts
portal-schedule.query.ts
portal-today.ts
portal-today.dto.ts
portal-today.query.ts
portal-today.service.ts
teacher/teacher-schedule-portal.controller.ts
student/student-schedule-portal.controller.ts
guardian/guardian-schedule-portal.controller.ts
packages/db/src/schema/school/timetable.ts
packages/db/src/migrations/0019_timetable_module.sql| File | Purpose | Key Exports | Notes |
|---|---|---|---|
timetable.module.ts | Wires both admin controllers and all five providers, imports RoleModule. | TimetableModule | Omitting RoleModule fails at boot with UnknownDependenciesException, not at build. |
timeslots/class-timeslots.controller.ts | Admin routes for the Timeslot Manager. | ClassTimeslotsController | update/remove/copy require TimetablePublish_UPDATE in addition to their base permission. |
timeslots/class-timeslots-read.service.ts | Reads for class_timeslots; resolveClassId/loadDto are public so the write service can reuse them inside its own transaction. | ClassTimeslotsReadService | Every field is enumerated in toDto, never spread. |
timeslots/class-timeslots-write.service.ts | Create/update/remove/copy for timeslots, with a shared constraint-to-error-code map. | ClassTimeslotsWriteService | update() implements optimistic concurrency by putting version in the WHERE clause, then re-reading to distinguish "gone" from "stale". |
entries/class-timetable.controller.ts | Admin routes for the Timetable Builder: read grid, versions, draft lifecycle, entry upsert/clear, availability, publish/unpublish. | ClassTimetableController | Route table and handler names are frozen by design; do not rename without updating both the API doc and route-permissions.spec.ts. |
entries/class-timetable-read.service.ts | Builds the live grid DTO (draft-or-published resolution), the version list (live plus archived), one version's detail (live or archived), and slot availability. | ClassTimetableReadService, ResolvedClass, EntryRow | computeStaleReferences is the one place soft-delete/is_active decay is detected for published rows. listVersions and readVersion both run inside one read-only REPEATABLE READ transaction, since each reads class_timetables and class_timetable_archives together. |
entries/class-timetable-draft-write.service.ts | Create/restore a draft, discard a draft, upsert/clear one entry. | ClassTimetableDraftWriteService | putEntry is an onConflictDoUpdate upsert; version is enforced only when the caller supplies it. |
entries/class-timetable-publish.service.ts | Archive-then-delete-then-promote publish, and unpublish. | ClassTimetablePublishService | Runs the promotion inside a SAVEPOINT so a 23P01 can be investigated before the outer transaction rolls back; retries once on genuine deadlock (40001/40P01). |
entries/class-timetable-conflict.util.ts | Read-only mirror of the two EXCLUDE constraints on published entries, for producing a named conflict. | findConflict, throwConflict, ConflictMatch | Never the enforcement mechanism — a convenience for a better error message and for locating which row a 23P01 came from. |
entries/class-timetable-draft.util.ts | Session-currency assertion, next version number, archive-entry resolution for restore, snapshot building. | assertSessionCurrent, nextVersionNo, resolveArchivedEntry, toDraftSourceEntry, buildSnapshotEntries | resolveArchivedEntry is what makes fromVersionNo restore skip-safe rather than all-or-nothing. |
entries/class-timetable-write.errors.ts | One shared constraint-to-error-code map for every entries write path. | translateTimetableWriteError | The two EXCLUDE constraints are also mapped here as a backstop for the case where findConflict's proactive lookup found nothing because the conflicting row moved. |
entries/timetable-snapshot.types.ts | The shape stored in class_timetable_archives.snapshot. | TimetableSnapshot, TimetableSnapshotEntry | Carries both names (archive readability) and ids (timeslotId, classSubjectId — restore feasibility). |
mobile/shared/portal-schedule.dto.ts | The reduced, audience-safe response shape shared by all three portal reads. | PortalScheduleEntryDto, TeacherScheduleEntryDto, PortalScheduleDto, TeacherScheduleDto | Every field is enumerated by hand — no DTO here is ever built by spreading a wider entity. |
mobile/shared/portal-schedule.query.ts | The one query behind all three schedule surfaces; the caller supplies the authorization predicate. | readPublishedSchedule, currentAcademicSession, composeClassName, PortalScheduleRow, PublishedScheduleRow | Fixes status = 'published' and the current session server-side; never accepts a session id from the caller. Lesson rows additionally carry the class, subject and class-subject public ids, which only the teacher controllers map onto the wire. |
mobile/shared/portal-today.service.ts | Resolves a pupil's day: session, calendar status, class slots; caches the clock-independent base. | PortalTodayService | Takes a resolved student id; the callers prove identity first. |
mobile/shared/portal-today.ts | Pure derivation of period states, current and next period, next transition and the summary at a given instant. | buildPupilToday, PupilTodayRow | No database, no clock. |
mobile/shared/portal-today.query.ts | The class's week rows with breaks and lunches merged in, and the pupil's active enrolled class. | readClassWeekRows, enrolledClassId | enrolledClassId is the row-scope layer: live enrolment, session pinned, pupil not deleted and active. |
mobile/shared/portal-today.dto.ts | Response shapes and the status vocabulary. | PupilTodayScheduleDto, PUPIL_TODAY_STATUSES | — |
5. Data Model
5.1 Schema Source
packages/db/src/schema/school/timetable.ts
timeslotTypeEnum
timetableStatusEnum
classTimeslots
classTimetables
classTimetableEntries
classTimetableArchives
(+ relations for all four tables)
packages/db/src/migrations/0019_timetable_module.sql
CREATE EXTENSION btree_gist
class_subjects_id_class_unique (added by hand, must precede the entries FK)
three CREATE TABLE statements + one ALTER TABLE ADD CONSTRAINT UNIQUE
five foreign keys
eight indexes
three EXCLUDE constraints (no drizzle builder exists for these)
three bump_row_version triggers5.2 Tables and Collections
class_timeslots
One slot in one class's week — the structure a timetable is laid onto.
| Column | Type | Nullable | Default | Index/Constraint | Relation | Notes |
|---|---|---|---|---|---|---|
id | uuid (uuidv7) | No | generated | PK | Referenced by class_timetable_entries (composite) | The uuid is the public identifier; there is no separate public_id, matching class_subjects and student_class_enrollments. |
class_id | integer | No | — | FK → classes.id, ON DELETE restrict | many-to-one to classes | Restrict, not cascade: a class with live timeslots is not deletable (see CLASS_HAS_TIMETABLE in the classes module). |
day_of_week | smallint | No | — | CHECK >= 0 AND <= 6 | — | 0 = Sunday, matching Postgres EXTRACT(DOW). A smallint, not a pgEnum, because adding Saturday classes needs a row, not ALTER TYPE ... ADD VALUE (no IF NOT EXISTS, no replayable down migration). The admin surface offers six days; every read renders all seven. |
start_minute | smallint | No | — | CHECK bounded, part of class_timeslots_no_overlap | — | Minutes from local midnight. Integer, not time/timestamptz, because a school day is wall-clock local with no timezone and no instant, and the overlap guarantee needs a range type — Postgres has int4range and no timerange. |
end_minute | smallint | No | — | CHECK <= 1440 AND > start_minute | — | 1440 is the exclusive upper bound; minute 1439 is the last taught minute of the day. Ranges are half-open [start, end), so a slot ending at 600 sits beside one starting at 600 without colliding. A period spanning midnight is not representable, by design. |
slot_type | timeslot_type enum | No | — | part of the entry FK target and of class_timetable_entries_slot_is_teaching (by cascade) | — | period | break | lunch | activity | study_hall | free. Only period may hold a lesson. |
label | text | Yes | NULL | CHECK class_timeslots_label_format | — | Operator wording, e.g. "Tiffin". Trimmed by the service before writing (the CHECK rejects a leading/trailing space); max 64 characters; no control characters. |
version | bigint | No | 0 | — | — | Optimistic-concurrency token, server-owned. Maintained exclusively by the bump_row_version BEFORE UPDATE trigger. Never indexed. |
created_at | timestamptz | No | now() | — | — | — |
updated_at | timestamptz | No | now() | — | — | Stamped by the same trigger that bumps version; $onUpdateFn is deliberately absent so there is exactly one writer for the pair. |
Constraints and indexes not in the column list above:
class_timeslots_entry_fk_target—UNIQUE(id, class_id, day_of_week, start_minute, end_minute, slot_type). Trivially true (already covered by the PK) but exists solely as the composite foreign-key target forclass_timetable_entries, so an entry provably matches its slot's current time. Declared asunique(...), notuniqueIndex(...), because drizzle-kit emits allADD CONSTRAINT ... FOREIGN KEYbefore allCREATE INDEX; auniqueIndexwould not exist yet when the referencing FK is created and the migration would fail on a clean database with42830.class_timeslots_class_day_idx—btree(class_id, day_of_week). Serves the per-class, per-day listing and the overlap check's equality columns.class_timeslots_no_overlap—EXCLUDE USING gist (class_id WITH =, day_of_week WITH =, int4range(start_minute, end_minute) WITH &&), declared in the migration (no drizzle builder forEXCLUDE). No two slots of one class on one day may overlap. Requires thebtree_gistextension for the=operators.
class_timetables
One version — draft or published — of one class's timetable, header only.
| Column | Type | Nullable | Default | Index/Constraint | Relation | Notes |
|---|---|---|---|---|---|---|
id | uuid (uuidv7) | No | generated | PK, part of the entries' header FK target | Referenced by class_timetable_entries (composite) | — |
class_id | integer | No | — | part of class_timetables_class_session_fk | many-to-one to classes | Not a plain FK to classes.id alone — see the composite FK below. |
academic_session_id | integer | No | — | part of class_timetables_class_session_fk | — | Denormalised from the class and made provably equal to it by the composite FK, matching the construction student_class_enrollments already uses. |
version_no | integer | No | — | CHECK > 0; part of class_timetables_version_unique | — | Allocated as max(version_no) + 1 over both this class's headers and its archives (nextVersionNo), safe only because one_draft_per_class serialises concurrent forks under a FOR UPDATE lock. |
status | timetable_status enum | No | 'draft' | part of the header FK target; two partial unique indexes | — | draft | published. No third member: an earlier archived state was a terminal dead end with no exit path — superseded versions are now snapshots in class_timetable_archives, outside the FK graph entirely. |
published_at | timestamptz | Yes | NULL | CHECK class_timetables_published_at_matches_status | — | Stated as an equivalence with status: draft ⇔ null, published ⇔ not null. Neither incoherent row is representable. |
published_by_user_id | uuid | Yes | NULL | FK → users.id, ON DELETE set null | many-to-one to users | set null because a published timetable outlives the account that published it; the fact of publication remains true. |
version | bigint | No | 0 | — | — | Optimistic-concurrency token, server-owned, same discipline as class_timeslots.version. |
created_at / updated_at | timestamptz | No | now() | — | — | updated_at maintained by bump_row_version. |
Constraints:
class_timetables_class_session_fk— composite(class_id, academic_session_id)→classes(id, academic_session_id),ON DELETE restrict ON UPDATE restrict.class_timetables_version_unique—UNIQUE(class_id, version_no).class_timetables_entry_fk_target—UNIQUE(id, class_id, academic_session_id, status). This is the mechanism the whole publish design rests on: becausestatusis part of this key and the entries' header FK isON UPDATE cascade, promoting a header fromdrafttopublishedcascades the new status onto every one of its entries in one statement — which is the exact moment the two EXCLUDE constraints onclass_timetable_entriesfire. It also makes writingstatusdirectly on an entry impossible, since the tuple must match a header row.class_timetables_one_draft_per_class—UNIQUE(class_id) WHERE status = 'draft'(partial unique index).class_timetables_one_published_per_class—UNIQUE(class_id) WHERE status = 'published'(partial unique index). Not deferrable — this is whypublish()deletes the superseded published header before promoting the draft rather than promoting first.
class_timetable_entries
One lesson — a subject, and optionally a teacher and a room — in one slot of one timetable version.
| Column | Type | Nullable | Default | Index/Constraint | Relation | Notes |
|---|---|---|---|---|---|---|
id | uuid (uuidv7) | No | generated | PK | — | — |
timetable_id | uuid | No | — | part of the header FK, part of ..._one_per_slot | many-to-one to class_timetables | — |
class_id | integer | No | — | part of the header FK and the timeslot FK | — | Copy of the header's class, kept in lockstep by the header FK. |
academic_session_id | integer | No | — | part of the header FK | — | Copy of the header's session. |
status | timetable_status enum | No | — | part of the header FK; predicate on both EXCLUDE constraints | — | Never written directly. Arrives only through the header composite FK's ON UPDATE cascade when the header's status changes. |
timeslot_id | uuid | No | — | part of the timeslot FK, part of ..._one_per_slot | many-to-one to class_timeslots | — |
day_of_week / start_minute / end_minute / slot_type | as in class_timeslots | No | — | part of the timeslot FK; used directly by both EXCLUDE constraints | — | Copies of the timeslot's own columns, kept in lockstep by the timeslot FK's ON UPDATE cascade. They exist because a conflict spans classes and no constraint can reach across a join — these copies let the EXCLUDE constraints be expressed on this table alone. |
class_subject_id | uuid | No | — | FK class_timetable_entries_class_subject_fk → class_subjects(id, class_id), ON DELETE restrict | many-to-one to class_subjects | The composite target class_subjects_id_class_unique had to be added by hand in the migration — class_subjects originally carried only a PK on id, which does not satisfy a foreign key on (id, class_id). |
teacher_id | uuid | Yes | NULL | FK → staff.id, ON DELETE set null; predicate of class_timetable_entries_teacher_free; served by class_timetable_entries_teacher_published_idx | many-to-one to staff | Nullable so an administrator can place a subject before assigning a teacher. staff soft-deletes, so ON DELETE almost never fires on the normal path — a soft-deleted teacher keeps their reservation until publish-time revalidation or an admin read surfaces it as stale. |
room_id | integer | Yes | NULL | FK → rooms.id, ON DELETE set null; predicate of class_timetable_entries_room_free | many-to-one to rooms | The room EXCLUDE constraint is predicated on room_id IS NOT NULL, deliberately not mirrored from the teacher constraint's predicate — copying it literally would have let every lesson with no assigned teacher leave the room index, so two classes could double-book a room and publish cleanly. |
notes | text | Yes | NULL | CHECK class_timetable_entries_notes_format | — | Free text up to 500 characters; same trim/control-character rule as class_timeslots.label. |
version | bigint | No | 0 | — | — | Optimistic-concurrency token. |
created_at / updated_at | timestamptz | No | now() | — | — | updated_at maintained by bump_row_version. |
Constraints and indexes:
class_timetable_entries_header_fk— composite(timetable_id, class_id, academic_session_id, status)→class_timetables(id, class_id, academic_session_id, status),ON DELETE cascade ON UPDATE cascade. Deleting a header deletes its entries; promoting a header rewrites every entry'sstatus.class_timetable_entries_timeslot_fk— composite(timeslot_id, class_id, day_of_week, start_minute, end_minute, slot_type)→class_timeslots(id, class_id, day_of_week, start_minute, end_minute, slot_type),ON DELETE restrict ON UPDATE cascade. Deleting an occupied slot is refused (TIMESLOT_IN_USE, SQLSTATE23001); moving a slot's day/time/type rewrites every entry that occupies it, including published ones.class_timetable_entries_class_subject_fk— composite(class_subject_id, class_id)→class_subjects(id, class_id),ON DELETE restrict. Prevents placing a subject assigned to a different class.class_timetable_entries_slot_is_teaching— CHECKslot_type = 'period'. Fires on two distinct writes with an identical constraint name: placing a lesson in a non-period slot (mapped toTIMETABLE_SLOT_NOT_TEACHINGby the entry write path's own pre-check), and converting an occupied period into a break/lunch/etc. by cascade from a timeslot edit (mapped toTIMESLOT_TYPE_CHANGE_BLOCKEDby the timeslot write path).class_timetable_entries_notes_format— CHECK, same shape as the label check onclass_timeslots.class_timetable_entries_one_per_slot—UNIQUE(timetable_id, timeslot_id). One lesson per slot per version; the target ofputEntry'sonConflictDoUpdate.class_timetable_entries_teacher_free—EXCLUDE USING gist (academic_session_id WITH =, teacher_id WITH =, day_of_week WITH =, int4range(start_minute, end_minute) WITH &&) WHERE (status = 'published' AND teacher_id IS NOT NULL). Only published rows are constrained — see the rationale below.class_timetable_entries_room_free— same shape, predicated onroom_id IS NOT NULLinstead.- Plain btree indexes:
class_timetable_entries_timetable_idx(timetable_id),class_timetable_entries_class_status_idx(class_id, status),class_timetable_entries_class_subject_idx(class_subject_id),class_timetable_entries_timeslot_idx(timeslot_id). class_timetable_entries_teacher_published_idx— partial btree onteacher_id WHERE status = 'published' AND teacher_id IS NOT NULL, serving the teacher portal's weekly read.
Why only published rows are constrained. Drafts are deliberately unconstrained: two administrators may pencil the same teacher into drafts for different classes, and the second publish is refused rather than the first edit. Constraining drafts would make a fork collide with the published parent it was copied from, making it impossible to edit a live timetable at all. Within one draft a conflict is unreachable anyway — one entry per timeslot, and one class's own timeslots cannot overlap — so every real conflict is cross-class, and those are caught atomically the moment a schedule goes live, carried down from the header by the header FK's ON UPDATE cascade.
class_timetable_archives
A superseded or unpublished timetable version, kept as a snapshot and deliberately outside the foreign-key graph.
| Column | Type | Nullable | Default | Index/Constraint | Relation | Notes |
|---|---|---|---|---|---|---|
id | uuid (uuidv7) | No | generated | PK | — | — |
class_id | integer | No | — | part of class_timetable_archives_version_unique; served by class_timetable_archives_class_idx | no FK | Bare column, deliberately. Pointing this at classes.id with ON DELETE restrict would make any class that ever published a timetable permanently undeletable, and undeletable with an unmapped 500 — ClassesWriteService.remove() hard-deletes and its translator does not know this constraint's name. A dangling class_id after a class is hard-deleted is correct: the archive records what was true, and archives become unreachable through the API (every read is keyed on the class) but remain a record, not a feature. |
academic_session_id | integer | No | — | — | no FK | Same reasoning. |
version_no | integer | No | — | part of class_timetable_archives_version_unique | — | — |
published_at | timestamptz | No | — | — | — | Copied from the header at archive time. |
published_by_user_id | uuid | Yes | NULL | — | no FK | — |
archived_at | timestamptz | No | now() | served by class_timetable_archives_class_idx | — | — |
snapshot | jsonb | No | — | — | — | { entries: TimetableSnapshotEntry[] }. See §5.4 below for the exact shape. |
Constraints and indexes:
class_timetable_archives_version_unique—UNIQUE(class_id, version_no).class_timetable_archives_class_idx—btree(class_id, archived_at), servingGET .../timetable/versions(ordereddesc(archived_at)).
No bump_row_version trigger — an archive is never updated after being written.
5.3 The Snapshot Shape
interface TimetableSnapshotEntry {
dayOfWeek: number;
startMinute: number;
endMinute: number;
slotType: string;
label: string | null;
timeslotId: string; // for restore
classSubjectId: string; // for restore
subjectName: string; // for readable history
subjectCode: string;
teacherId: string | null;
teacherName: string | null;
roomId: number | null;
roomName: string | null;
notes: string | null;
}
interface TimetableSnapshot { entries: TimetableSnapshotEntry[]; }Both names and ids are stored, and each half exists for a different reason. Names make an archive readable after the referenced row is renamed or removed — history should not change retroactively. Ids (timeslotId, classSubjectId) make restore implementable at all: class_timetable_entries requires both, and neither is recoverable from a subject's display name alone.
5.4 Relationship Diagram
5.5 Index Rationale
| Index/Constraint | Columns | Type | Query/Invariant Supported | Tradeoff |
|---|---|---|---|---|
class_timeslots_entry_fk_target | id, class_id, day_of_week, start_minute, end_minute, slot_type | unique constraint | Composite FK target for class_timetable_entries_timeslot_fk. | No storage cost beyond the PK; must be declared as unique(), never uniqueIndex(), to avoid drizzle-kit statement ordering failures. |
class_timeslots_class_day_idx | class_id, day_of_week | btree | GET /classes/:id/timeslots per-day grouping; overlap-check equality columns. | Small write overhead on every timeslot insert/update. |
class_timeslots_no_overlap | class_id, day_of_week, int4range(start_minute,end_minute) | GiST EXCLUDE | No two slots of one class on one day may overlap. | Requires btree_gist; GiST index maintenance cost on every write. |
class_timetables_one_draft_per_class | class_id (partial, status='draft') | unique btree | At most one draft per class. | Partial index — cheap, since most classes have zero or one draft row. |
class_timetables_one_published_per_class | class_id (partial, status='published') | unique btree | At most one published version per class. | Not deferrable, which is why publish must delete-then-promote rather than promote-then-delete. |
class_timetable_entries_one_per_slot | timetable_id, timeslot_id | unique btree | One lesson per slot per version; target of the entry upsert. | — |
class_timetable_entries_teacher_free | academic_session_id, teacher_id, day_of_week, int4range(start,end) (partial, published + teacher not null) | GiST EXCLUDE | No teacher double-booked among published rows, per session. | Partial predicate keeps drafts unconstrained on purpose (see §5.2). |
class_timetable_entries_room_free | academic_session_id, room_id, day_of_week, int4range(start,end) (partial, published + room not null) | GiST EXCLUDE | No room double-booked among published rows, per session. | Predicated on room_id, not teacher_id — an intentional asymmetry so teacher-less lessons still reserve their room. |
class_timetable_entries_teacher_published_idx | teacher_id (partial, published + teacher not null) | btree | Backs the teacher portal's weekly read. | Partial, so unpublished/teacher-less rows never bloat it. |
class_timetable_archives_class_idx | class_id, archived_at | btree | GET .../timetable/versions, ordered newest-first. | — |
6. Services and Responsibilities
6.1 ClassTimeslotsReadService
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
listByClass(classPublicId) | ClassTimeslotsController.list | classes, class_timeslots | — | — | CLASS_NOT_FOUND |
resolveClassId(executor, classPublicId) | Controller and ClassTimeslotsWriteService | classes | — | — | CLASS_NOT_FOUND |
loadDto(executor, id, classId) | Controller and write service (post-write re-read) | class_timeslots | — | — | TIMESLOT_NOT_FOUND |
resolveClassId and loadDto are public/exported so the write service can pass its own open transaction handle through them and read the just-written row inside the same transaction, instead of racing a second pooled connection against an uncommitted write.
6.2 ClassTimeslotsWriteService
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
create(classPublicId, dto) | Controller | classes | class_timeslots insert | — | CLASS_NOT_FOUND, TIMESLOT_OVERLAPS_EXISTING, TIMESLOT_TIMES_INVALID, TIMESLOT_DAY_INVALID, TIMESLOT_LABEL_INVALID |
update(classPublicId, timeslotId, dto) | Controller | class_timeslots | class_timeslots update (version-guarded) | Cascades new day/time/type onto any class_timetable_entries occupying the slot | TIMESLOT_NOT_FOUND, TIMESLOT_VERSION_CONFLICT, plus the create-time validation codes, plus TIMESLOT_TYPE_CHANGE_BLOCKED |
remove(classPublicId, timeslotId) | Controller | — | class_timeslots delete | Refused if occupied | TIMESLOT_NOT_FOUND, TIMESLOT_IN_USE |
copy(classPublicId, dto) | Controller | class_timeslots (source day) | Optional delete of target days, then inserts | One transaction; refuses partway through on any conflict | TIMESLOT_COPY_SOURCE_IN_TARGET, TIMESLOT_COPY_SOURCE_EMPTY, TIMESLOT_IN_USE, TIMESLOT_OVERLAPS_EXISTING |
Input normalization: label is trimmed server-side before every write (trimLabel), because the CHECK constraint rejects leading/trailing whitespace and an untrimmed paste would otherwise be a 422 the user did not cause. Validation order in create/update: class resolution, then the DTO's own class-validator rules (already run by the global pipe before the service method executes), then the database CHECK/EXCLUDE constraints via translatePgConstraint. Transaction boundary: every write method opens its own db.transaction. Concurrency: update uses optimistic concurrency — version is included in the WHERE clause, and zero affected rows triggers a re-read inside the same transaction to distinguish "row is gone" (TIMESLOT_NOT_FOUND) from "token is stale" (TIMESLOT_VERSION_CONFLICT). version/updated_at are never set by the service — a bump_row_version trigger owns both, which is also why an update whose only field is version still writes a self-assignment so the trigger fires and the token advances.
6.3 ClassTimetableReadService
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
resolveClass(executor, classPublicId) | Controller, all write services | classes | — | — | CLASS_NOT_FOUND |
read(classPublicId, status?) | Controller | class_timetables, class_timeslots, entry joins | — | — | (delegates to loadTimetableDto) |
loadTimetableDto(executor, classPublicId, classRow, status?) | read, publish, unpublish | as above | — | — | none directly; an explicit status that does not exist yields an empty grid, never a 404 |
listVersions(classPublicId) | Controller | class_timetables, class_timetable_archives, users (one REPEATABLE READ transaction) | — | — | CLASS_NOT_FOUND |
readVersion(classPublicId, versionNo) | Controller | class_timetables, class_timetable_archives, users (one REPEATABLE READ transaction) | — | — | CLASS_NOT_FOUND, TIMETABLE_VERSION_NOT_FOUND |
availability(classPublicId, timeslotId) | Controller | class_timeslots, class_timetable_entries, staff, rooms, designations | — | — | CLASS_NOT_FOUND, TIMESLOT_NOT_FOUND |
selectTimeslotDtos(executor, classId) | internal, loadTimetableDto | class_timeslots | — | — | — |
selectEntryRows(executor, timetableId, extraWhere?) | internal and all write services | joined entries/subjects/timeslots/staff/rooms | — | — | — |
toEntryDto(row) (static) | internal, draft-write's re-read | — | — | — | — |
computeStaleReferences(rows) (static) | loadTimetableDto (published only), publish (draft entries, pre-promotion) | — | — | — | — |
loadTimetableDto is the single place that resolves "which version does the caller mean": with an explicit status, that exact version must exist or the response is an empty grid with versionNo: null and that requested status echoed back — deliberately not a 404, because an earlier design that did 404 here produced a permanent error-retry loop in the admin panel (eleven failed requests and a Save button stuck on "Saving…") for the completely ordinary case of a class with no published version yet. With no explicit status, the draft wins if one exists, else the published version, else the same empty grid, reported as status: "draft" because an empty class's next action is to start a draft.
computeStaleReferences is the one place a soft-deleted teacher, an inactive room, or an inactive class-subject assignment is surfaced on an otherwise-valid published entry — no foreign key or CHECK constraint can see deleted_at or is_active, so this check runs both on every read of the published grid and again, on the draft's entries, immediately before publish promotes them.
6.4 ClassTimetableDraftWriteService
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
createDraft(classPublicId, dto) | Controller | class_timetables (locked FOR UPDATE), academic_sessions, optionally class_timetable_archives or published entries | class_timetables insert, class_timetable_entries bulk insert | Locks both this class's headers in id order — the same order publish locks in, so the two never deadlock against each other | TIMETABLE_DRAFT_EXISTS, TIMETABLE_NO_CURRENT_SESSION, TIMETABLE_SESSION_NOT_CURRENT, TIMETABLE_VERSION_NOT_FOUND |
discardDraft(classPublicId) | Controller | — | class_timetables delete (draft only) | Entries cascade away via ON DELETE cascade | TIMETABLE_DRAFT_MISSING |
putEntry(classPublicId, timeslotId, dto) | Controller | class_timetables (draft), class_timeslots, class_subjects, staff, rooms, a proactive conflict lookup | class_timetable_entries upsert (onConflictDoUpdate) | — | TIMETABLE_DRAFT_MISSING, TIMESLOT_NOT_FOUND, TIMETABLE_SLOT_NOT_TEACHING, TIMETABLE_SUBJECT_NOT_IN_CLASS, STAFF_NOT_FOUND, ROOM_NOT_FOUND, TIMETABLE_TEACHER_CONFLICT, TIMETABLE_ROOM_CONFLICT, TIMETABLE_VERSION_CONFLICT |
removeEntry(classPublicId, timeslotId) | Controller | class_timetables (draft) | class_timetable_entries delete | Idempotent — no error if the slot was already empty | TIMETABLE_DRAFT_MISSING |
createDraft with fromVersionNo resolves each archived entry against the current schema state (resolveArchivedEntry): a slot that no longer exists (timeslot_missing), a slot whose time/type has since moved (timeslot_moved), or a subject no longer assigned to the class (class_subject_missing) is skipped and reported in the response's skipped array, never silently dropped and never failing the whole restore. With no body at all, it forks the currently-published version — which can never skip anything, since every live entry's references are still FK-enforced — or creates an empty draft if there is no published version either.
putEntry is an upsert via onConflictDoUpdate targeting (timetable_id, timeslot_id). version is enforced only when the caller supplies it (via a setWhere clause on the conflict update), because the same endpoint both creates (no version to send) and updates (a version from the last read) an entry — a required-on-both-sides token could be satisfied by neither. A field omitted from the request body is left untouched on an existing row; only classSubjectId is unconditionally written, which is what lets a plain drag-and-drop (sending only classSubjectId) coexist with the follow-up lesson-details dialog (sending the rest) without either clobbering the other's fields. Before writing, putEntry runs findConflict as "a convenience, not the control" — a read-only courtesy check against the same predicate the EXCLUDE constraints enforce, excluding the class being edited itself (its own published rows are about to be deleted by the eventual publish, so they can never be a real clash).
6.5 ClassTimetablePublishService
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
publish(actor, classPublicId) | Controller | class_timetables (both headers, FOR UPDATE), academic_sessions, draft entry rows | Insert into class_timetable_archives (if a published version existed), delete the old published header, update the draft header to published | Cascades the new status onto every draft entry via the header FK, which is the exact trigger for the two EXCLUDE constraints | TIMETABLE_DRAFT_MISSING, TIMETABLE_DRAFT_EMPTY, TIMETABLE_NO_CURRENT_SESSION, TIMETABLE_SESSION_NOT_CURRENT, TIMETABLE_STALE_REFERENCES, TIMETABLE_TEACHER_CONFLICT, TIMETABLE_ROOM_CONFLICT, TIMETABLE_CONCURRENT_UPDATE |
unpublish(classPublicId) | Controller | class_timetables (published, FOR UPDATE) | Insert into class_timetable_archives, delete the published header | — | TIMETABLE_NOT_FOUND |
archiveAndDelete (private, static) | publish, unpublish | entry rows of the header being retired | archive insert, header delete | — | WRITE_RETURNED_NO_ROW (defensive — a published row missing publishedAt would violate its own CHECK) |
promoteOrReportConflict (private) | publish | — | class_timetables update, inside a nested transaction (SAVEPOINT) | Investigates a 23P01 before the outer rollback | TIMETABLE_TEACHER_CONFLICT, TIMETABLE_ROOM_CONFLICT |
withDeadlockRetry (private) | publish | — | — | Retries the whole operation exactly once on 40001/40P01 | TIMETABLE_CONCURRENT_UPDATE on a second failure |
publish is idempotent in the useful sense: if there is no draft but a published version already exists (a retried request after the first attempt actually succeeded), it returns that published version rather than raising TIMETABLE_DRAFT_MISSING.
Deleting the superseded published header happens before promoting the draft, in that exact order, because class_timetables_one_published_per_class is a non-deferrable unique index — promoting first would raise 23505 on it. The promotion itself runs inside a nested tx.transaction(), which drizzle implements as a SAVEPOINT under a single Postgres session: if the promotion raises 23P01 (an exclusion violation from the EXCLUDE constraints), the SAVEPOINT rolls back but the outer transaction is still usable, which lets promoteOrReportConflict run findConflict against the still-consistent (not-yet-rolled-back) database state to name exactly which entry clashed, before the outer transaction eventually rolls back and the whole publish fails.
7. Runtime Flows
7.1 Create a timeslot
| Step | Code Path | Behavior | Failure Case |
|---|---|---|---|
| 1 | ClassTimeslotsController.create | Receives DTO, already class-validator-checked. | 400 from the global pipe on shape errors. |
| 2 | ClassTimeslotsWriteService.create | Trims label, opens a transaction, resolves the class. | CLASS_NOT_FOUND |
| 3 | INSERT into class_timeslots | Database enforces day/minute bounds and no-overlap. | TIMESLOT_DAY_INVALID, TIMESLOT_TIMES_INVALID, TIMESLOT_OVERLAPS_EXISTING, TIMESLOT_LABEL_INVALID |
| 4 | ClassTimeslotsReadService.loadDto (same tx) | Re-reads the just-written row. | — |
7.2 Update a timeslot that is occupied
Moving a slot's day or minutes cascades onto every class_timetable_entries row occupying it via ON UPDATE cascade on the timeslot composite FK — including published rows, which is why update/remove/copy all require TimetablePublish_UPDATE in addition to their base Timetables_* permission.
7.3 Place a lesson into a draft slot (putEntry)
| Branch | Condition | Behavior | Error/Result |
|---|---|---|---|
| No draft | Class has no draft header | Reject before touching the slot | TIMETABLE_DRAFT_MISSING |
| Non-teaching slot | slot.slotType !== "period" | Reject before the insert attempt | TIMETABLE_SLOT_NOT_TEACHING |
| Subject not on class | classSubjectId resolves to a different class | Reject | TIMETABLE_SUBJECT_NOT_IN_CLASS |
| Teacher/room missing | Supplied id does not resolve | Reject | STAFF_NOT_FOUND / ROOM_NOT_FOUND |
| Proactive conflict | findConflict finds a published clash outside this class | Reject with the named conflict | TIMETABLE_TEACHER_CONFLICT / TIMETABLE_ROOM_CONFLICT |
| Stale version | Caller supplied version, row has since changed | setWhere leaves the row untouched, RETURNING yields nothing | TIMETABLE_VERSION_CONFLICT |
| Success | All checks pass | Upsert, re-read, return | 200/creation with entry data |
7.4 Publish
| Step | Code Path | Behavior | Failure Case |
|---|---|---|---|
| 1 | ClassTimetablePublishService.publish | Locks both headers FOR UPDATE, ORDER BY id (same order createDraft uses, avoiding a deadlock between the two operations). | — |
| 2 | Draft resolution | No draft + published exists → idempotent return; no draft + no published → error. | TIMETABLE_DRAFT_MISSING |
| 3 | Emptiness check | Draft has zero entries. | TIMETABLE_DRAFT_EMPTY |
| 4 | Session check | assertSessionCurrent against the class's academic session. | TIMETABLE_NO_CURRENT_SESSION, TIMETABLE_SESSION_NOT_CURRENT |
| 5 | Stale reference check | computeStaleReferences on the draft's own entries — a soft-deleted teacher, inactive room, or inactive subject assignment blocks publish outright. | TIMETABLE_STALE_REFERENCES (with details.staleReferences) |
| 6 | Archive-and-delete | Only if a published version already existed. | WRITE_RETURNED_NO_ROW (defensive) |
| 7 | Promote | SAVEPOINT-wrapped UPDATE ... SET status = 'published'; cascades to every entry, tripping the two EXCLUDE constraints if a cross-class conflict exists. | TIMETABLE_TEACHER_CONFLICT, TIMETABLE_ROOM_CONFLICT |
| 8 | Deadlock retry | The whole operation is retried once on 40001/40P01. | TIMETABLE_CONCURRENT_UPDATE on a second failure |
7.5 The three portal reads
Each controller derives its own row-scope predicate (the teacher's own staff.id; the pupil's own currently-active enrollment; a specific ward resolved and authorization-checked before any schedule row is touched) and passes it into the single shared readPublishedSchedule function, which unconditionally ANDs it with status = 'published' and the server-resolved current academic session. None of the three accepts a session id or a class id from the caller. All three return an empty entries: [] array rather than an error when there is no current academic session or nothing scheduled — a class with no published timetable, a pupil between enrollments, and a teacher with no periods are all ordinary states, not failures.
The guardian read is the one branch with two authorization steps in a fixed order: resolveOwnChildId runs and can 404 before any timetable row is read. Reversing that order — read first, return an empty week on no match — would still not leak data (an empty week from a foreign child id is indistinguishable from an own child with no timetable), but it would mean the authorization decision ran after the query rather than before it, which this codebase treats as a defect regardless of whether it happens to be currently harmless.
GET /api/mobile/student/schedule/today and GET /api/mobile/guardian/children/:id/schedule/today are served by PortalTodayService (mobile/shared/portal-today.service.ts), provided by both portal modules.
- The controller proves identity:
resolveOwnStudentIdfor the pupil (audience assertion, thenstudents.user_id = actor.idwithdeleted_atonly; a miss is404 STUDENT_NOT_FOUND), orGuardianPortalService.resolveOwnChildIdfor the guardian (a non-ward is404 STUDENT_NOT_FOUND). forStudentreads the school timezone and the school-local date, and looks upportal:today:for that pupil and date throughcacheGetFast.- On a miss,
computeBasedecides the calendar status in a fixed order and stops at the first match:no_session,outside_session(half-open[start_date, end_date)),not_set(no calendar row),non_operating(the row does not expect attendance),no_class(an operating day with no active enrolment for the pupil in the current session), otherwiseoperatingwith the class's published week rows filtered to today's weekday. The base is written back withcacheSetFastfor 60 seconds. buildPupilTodayderives each slot's state atserverTime, the current and next period ids, the earliest future boundary and the teaching-period summary. Only anoperatingday has periods. This step runs on every request and its result is never cached.
Breaks, lunches and activities are class_timeslots rows with no timetable entry, so the day is the lesson rows plus those rows merged in week order. enrolledClassId is the row-scope layer: an active enrolment in the current session for a pupil whose deleted_at is null and record_status is active; a miss gives no_class, never a 404.
7.6 Version history — listVersions and readVersion
Both methods run their entire body inside one db.transaction(fn, { isolationLevel: "repeatable read", accessMode: "read only" }), because each reads class_timetables and class_timetable_archives together and a plain READ COMMITTED transaction takes a fresh snapshot per statement. Without REPEATABLE READ, a publish or unpublish landing between the two SELECTs can duplicate a version (once live, once archived), drop one entirely, or make an unpublish briefly show "nothing published" while the version already sits in the archive. A read-only REPEATABLE READ transaction cannot raise a serialization failure, so neither method carries a retry wrapper — precedent: apps/api/src/modules/backup/shared/backup-manifest.service.ts.
listVersions always resolves the live header first and puts it first in the returned array when present, rather than relying on archivedAt ordering to put it there: publishedAt is written from the application clock and archivedAt from the database clock (now() at transaction start), so the two can disagree under skew.
readVersion's live branch is a predicate, not a fallback — classId, status = 'published', and versionNo must all match, or the request 404s with TIMETABLE_VERSION_NOT_FOUND unchanged. It reuses the archive branch's own entry projection (selectEntryRows then buildSnapshotEntries, mapped through the same allowlist that trims timeslotId, classSubjectId, subjectCode, teacherId and roomId) so the live detail and an archived detail can never drift into two different shapes.
8. Caching
The admin routes and the three weekly schedule routes cache nothing and read PostgreSQL directly. The two schedule/today routes cache one value.
| Cache Key Pattern | Builder | Value | TTL | Invalidation | Caller |
|---|---|---|---|---|---|
portal:today:shape:1-student:<studentId>-date:<YYYY-MM-DD> | CacheKeyUtil.build("portal:today:", [...]) | PupilTodayBase: session name, calendar block, the day's slot rows | 60s | None. The date in the key rolls the entry at school midnight; a publish, unpublish, calendar edit, enrolment change or pupil status change is visible within the TTL. | PortalTodayService.forStudent |
The cached value holds nothing that depends on the clock. Period states, serverTime, nextTransitionAt and the summary are recomputed per request by buildPupilToday. The read uses cacheGetFast and the write cacheSetFast (apps/api/src/common/utils/cache-race.util.ts): the shared Redis client queues commands during an outage instead of rejecting them, so an awaited read would hold the request open. cacheGetFast races the read against a 250 ms budget and returns a miss on timeout or error, and the service computes from the database. cacheSetFast does not await, so a slow or lost write costs the next reader a miss and never delays the response. The entry is written only after identity is resolved, so the pupil and guardian routes share it.
9. BullMQ, Schedulers, and Async Work
None. No queue, job, or scheduled task is declared, produced, or consumed by this module. All work — including publish's archive/delete/promote sequence — is synchronous within the request's transaction.
10. Realtime and Events
None. No Socket.IO gateway, room, or emitted event exists in this module. A change to a published timetable is visible to the portal reads only on their next request.
11. Security, Auth, and Abuse Controls
- Guards. Every controller in this module (
ClassTimeslotsController,ClassTimetableController, and the three portal controllers) carries@UseGuards(JwtAuthGuard, RoleGuard). - Permissions. The two admin controllers gate every route with
@Permissions(...).RoleGuardrequires every code listed, so a multi-code route (e.g.update/remove/copyon timeslots, orpublish/unpublish) is refused unless the caller holds all of them.TimetablePublish_UPDATEis additionally listed inSUPERADMIN_ONLY_MODULES, meaning anadminrole never receives it automatically even though the catalog's "grant everything not superadmin-only" sweep would otherwise include it. - Portal audience assertion, not
@Permissions(). The three schedule portal routes carry no@Permissions()decorator at all; they assert their audience in code (assertTeacherStaffRow,assertPortalAudience). This means each one must also be listed inNO_PERMISSION_ADMIN_HANDLERSand intest/structure/route-permissions.spec.ts— omitting either makes the route 403 for every caller including superadmin, while the unit suite stays green because integration specs call the services directly and never pass through the guard. - Object-level vs. field-level scope.
Timetables_READis granted to no seeded role — the admin timetable views are considered sensitive enough (revealing every staff member's location at every hour, every day) that no role is trusted with them by default; a school that wants staff to browse timetables must create a role for it deliberately.Schedule_READ(self-service reads) is granted to staff, teacher, student, and guardian roles. The portal DTOs (PortalScheduleEntryDto,TeacherScheduleEntryDto) enumerate every field by hand rather than reusingClassTeacherDto(which carriesemployeeCodeandemploymentStatus) — a teacher's identity in a portal response is a name and nothing else. - Guest identity. Not applicable; every route in this module requires an authenticated JWT.
- Rate limits. No module-specific rate limiter; relies on the platform-wide default.
- Input normalization.
labelandnotesare trimmed server-side before every write, to match the database CHECK constraints that reject leading/trailing whitespace. - Sensitive data redaction. The 404-first ordering on the guardian route (§7.5) is a deliberate anti-enumeration control: a foreign child id returns
404, not403, so the id space cannot be probed to distinguish "exists but not yours" from "does not exist" via a status-code oracle. - Audit logs.
class_timetables.published_by_user_idis the only audit trail this module writes — who published which version, preserved into the archive snapshot on the next publish or unpublish. - Fail-closed behavior. Every conflict, stale-reference, and session-currency check runs inside the write's own transaction and raises before any partial state is committed; a
40001/40P01deadlock is retried once and then surfaced asTIMETABLE_CONCURRENT_UPDATErather than silently succeeding on a corrupted read.
13. Error Handling
| Error Code | HTTP Status | Thrown By | Condition | Client Action |
|---|---|---|---|---|
CLASS_NOT_FOUND | 404 | resolveClassId / resolveClass (both modules) | classPublicId does not resolve. | Verify the id; likely a stale link. |
TIMESLOT_NOT_FOUND | 404 | ClassTimeslotsReadService.loadDto, ClassTimeslotsWriteService.update/remove, entry write path | Timeslot id absent, or belongs to a different class. | Reload the timeslot list. |
TIMESLOT_OVERLAPS_EXISTING | 409 | class_timeslots_no_overlap (EXCLUDE) | New/updated slot overlaps another slot of the same class on the same day. | Pick a non-overlapping time. |
TIMESLOT_TIMES_INVALID | 422 | class_timeslots_minutes_bounded, or the DTO's own @Min/@Max | startMinute/endMinute out of [0, 1440] or end <= start. Note: a value larger than smallint raises 22003 before any CHECK runs and is not translatable by name. | Fix the payload. |
TIMESLOT_DAY_INVALID | 422 | class_timeslots_day_bounded, or the DTO bound | dayOfWeek outside 0..6. | Fix the payload. |
TIMESLOT_LABEL_INVALID | 422 | class_timeslots_label_format | Control character in label, or over 64 characters after trim. | Fix the payload. |
TIMESLOT_IN_USE | 409 | 23001 on class_timetable_entries_timeslot_fk | Deleting a slot a lesson still occupies. | Clear the entry first. |
TIMESLOT_TYPE_CHANGE_BLOCKED | 409 | cascade onto class_timetable_entries_slot_is_teaching | Converting an occupied period into a non-teaching slot type. | Clear the entry first. |
TIMESLOT_VERSION_CONFLICT | 409 | ClassTimeslotsWriteService.update | version in the request is stale. | Reload and retry. |
TIMESLOT_COPY_SOURCE_EMPTY | 422 | ClassTimeslotsWriteService.copy | Source day has zero slots. | Pick a source day that has slots. |
TIMESLOT_COPY_SOURCE_IN_TARGET | 422 | ClassTimeslotsWriteService.copy | toDaysOfWeek includes fromDayOfWeek. | Remove the source day from the target list. |
TIMETABLE_NOT_FOUND | 404 | ClassTimetablePublishService.unpublish | No published timetable exists for the class. | — |
TIMETABLE_DRAFT_EXISTS | 409 | class_timetables_one_draft_per_class, or the pre-check in createDraft | A draft already exists for this class. | Discard the existing draft first, or continue editing it. |
TIMETABLE_DRAFT_MISSING | 409 | entry write path, discardDraft, publish | No draft exists for the operation attempted. | Create a draft first. |
TIMETABLE_DRAFT_EMPTY | 422 | publish | Draft has zero entries. | Add at least one lesson before publishing. |
TIMETABLE_VERSION_NO_TAKEN | 409 | class_timetables_version_unique | Extremely rare race on version allocation. | Retry the operation. |
TIMETABLE_TEACHER_CONFLICT | 409 | class_timetable_entries_teacher_free, or findConflict | A teacher is already committed to a published lesson at an overlapping time in this session. | Choose a different teacher or time; response includes details.conflictsWith. |
TIMETABLE_ROOM_CONFLICT | 409 | class_timetable_entries_room_free, or findConflict | A room is already assigned during an overlapping time in this session. | Choose a different room or time; response includes details.conflictsWith. |
TIMETABLE_SUBJECT_NOT_IN_CLASS | 422 | 23503 on class_timetable_entries_class_subject_fk, or the pre-check in putEntry | classSubjectId does not belong to this class. | Choose a subject actually assigned to this class. |
TIMETABLE_SLOT_NOT_TEACHING | 422 | pre-check in putEntry; class_timetable_entries_slot_is_teaching as backstop | A lesson was placed into a non-period slot. | Choose a teaching-period slot. |
TIMETABLE_NOTES_INVALID | 422 | class_timetable_entries_notes_format | Notes blank-padded, contains control characters, or exceeds 500 characters. | Fix the payload. |
TIMETABLE_STALE_REFERENCES | 422 | publish | A draft entry's teacher/room/subject assignment has decayed (soft-deleted or deactivated) since it was chosen. | Response includes details.staleReferences; fix each entry, then retry publish. |
TIMETABLE_VERSION_CONFLICT | 409 | putEntry (via setWhere) | Caller supplied a stale version on an entry upsert. | Reload and retry. |
TIMETABLE_NO_CURRENT_SESSION | 409 | assertSessionCurrent | No academic_sessions row is marked current at all. | An operator must mark a session current. |
TIMETABLE_SESSION_NOT_CURRENT | 422 | assertSessionCurrent | This class's session exists but is not the current one. | Editing/publishing is refused for non-current sessions. |
TIMETABLE_CONCURRENT_UPDATE | 409 | withDeadlockRetry | 40001/40P01 survived one retry. | Retry the request. |
TIMETABLE_VERSION_NOT_FOUND | 404 | readVersion, createDraft with fromVersionNo | readVersion: versionNo matches neither an archive row nor the currently-published header for this class. createDraft: the requested archived versionNo does not exist for this class — restore reads archives only. | Verify the version number against GET .../versions. |
STAFF_NOT_FOUND | 404 | putEntry | Supplied teacherId does not resolve to a staff row. | — |
ROOM_NOT_FOUND | 404 | putEntry | Supplied roomId does not resolve to a room row. | — |
WRITE_RETURNED_NO_ROW | 500 | a RETURNING clause that yields nothing on a success path, or a published header read back with a null published_at | Defensive — should be unreachable. class_timetables_published_at_matches_status forbids the second. | Report as a bug. |
14. Observability
| Signal | Location | Purpose |
|---|---|---|
| Log | Framework-default NestJS request/exception logging on every controller | Failure visibility; no module-specific Logger calls exist in this module's services. |
| Audit | class_timetables.published_by_user_id, carried into class_timetable_archives.published_by_user_id on every archive | The only accountability trail this module writes: who published which version, preserved permanently even after the header itself is deleted. |
| Metric | None | No named metric is emitted by this module. |
15. Testing and Validation
| Test Type | Files | Coverage |
|---|---|---|
| Integration | apps/api/src/modules/timetable/__tests__/timeslots.integration.spec.ts | Timeslot CRUD, overlap/bounds/label constraints, copy behavior. |
| Integration | apps/api/src/modules/timetable/__tests__/timetable-write.integration.spec.ts | Draft lifecycle, entry upsert, publish/unpublish, conflict detection, stale-reference blocking, session-currency checks. |
Validation commands, from the repository root: pnpm --filter @skoolsewa/api test runs the whole suite; pnpm --filter @skoolsewa/api test:int runs the integration specs alone; pnpm --filter @skoolsewa/api test:structure asserts the route and guard fingerprint and is not collected by pnpm test, so a route change leaves it red at HEAD with nothing reporting it.
16. Mandatory Backend Deep-Dive Pack
16.1 Submodule Coverage Matrix
| Unit | Type | Owns | Depends On | Called By | Calls | State Touched | Failure Modes |
|---|---|---|---|---|---|---|---|
ClassTimeslotsController | Controller | Timeslot Manager routes | ClassTimeslotsReadService, ClassTimeslotsWriteService | HTTP | Both services | — | Guard/permission failures, DTO validation |
ClassTimeslotsReadService | Service | Timeslot reads | Database | Controller, ClassTimeslotsWriteService | class_timeslots, classes | Read-only | CLASS_NOT_FOUND, TIMESLOT_NOT_FOUND |
ClassTimeslotsWriteService | Service | Timeslot writes | Database, ClassTimeslotsReadService | Controller | class_timeslots | Insert/update/delete | See §13 timeslot codes |
ClassTimetableController | Controller | Timetable Builder routes | ClassTimetableReadService, ClassTimetableDraftWriteService, ClassTimetablePublishService | HTTP | All three services | — | Guard/permission failures, DTO validation |
ClassTimetableReadService | Service | Grid/version/availability reads | Database | Controller, both write services | class_timetables, class_timeslots, class_timetable_entries, class_timetable_archives, joins into class_subjects/subjects/staff/users/rooms/designations | Read-only | CLASS_NOT_FOUND, TIMESLOT_NOT_FOUND, TIMETABLE_VERSION_NOT_FOUND |
ClassTimetableDraftWriteService | Service | Draft lifecycle, entry upsert/clear | Database, ClassTimetableReadService, class-timetable-conflict.util, class-timetable-draft.util, class-timetable-write.errors | Controller | class_timetables, class_timetable_entries, reads class_timetable_archives/class_subjects/staff/rooms | Insert/delete/upsert | See §13 timetable codes |
ClassTimetablePublishService | Service | Publish/unpublish swap | Database, ClassTimetableReadService, class-timetable-conflict.util, class-timetable-draft.util, class-timetable-write.errors | Controller | class_timetables, class_timetable_archives, class_timetable_entries (via cascade) | Insert/delete/update, SAVEPOINT | TIMETABLE_DRAFT_MISSING/EMPTY, session codes, stale-reference, conflict codes, TIMETABLE_CONCURRENT_UPDATE |
class-timetable-conflict.util.ts (findConflict, throwConflict) | Pure helper functions | Read-only mirror of the two EXCLUDE predicates | Database (executor param) | Draft-write's proactive check, publish's post-23P01 investigation | reads class_timetable_entries and joins | — | Never itself the source of truth; a false negative here is caught by the real constraint. |
class-timetable-draft.util.ts | Pure helper functions | Session-currency, version numbering, archive resolution, snapshot building | Database (executor param) | createDraft, publish, unpublish (via archiveAndDelete) | reads academic_sessions, class_timetables, class_timetable_archives, class_timeslots, class_subjects | — | TIMETABLE_NO_CURRENT_SESSION, TIMETABLE_SESSION_NOT_CURRENT |
class-timetable-write.errors.ts (translateTimetableWriteError) | Pure function | Shared constraint-name → error-code map for the entries write path | translatePgConstraint (@/common/utils/pg-constraint.util) | Every catch block in both entries write services | — | — | Rethrows untouched if no rule matches |
timetable-snapshot.types.ts | Type-only module | The archive's jsonb shape | — | Draft/publish services, read service | — | — | — |
portal-schedule.dto.ts | DTO module | Reduced, audience-safe response shapes | — | The three portal controllers | — | — | — |
portal-schedule.query.ts | Query module | The one shared read query and session/name helpers | Database | The three portal controllers | reads class_timetable_entries joined widely, academic_sessions | Read-only | — |
teacher-schedule-portal.controller.ts | Controller | GET /teacher/schedule | portal-audience, portal-schedule.query | HTTP | readPublishedSchedule | — | Audience assertion failure → 403 (from the shared assertion helper, not this module) |
student-schedule-portal.controller.ts | Controller | GET /student/schedule | portal-audience, portal-schedule.query, studentClassEnrollments/students | HTTP | readPublishedSchedule | — | Same |
guardian-schedule-portal.controller.ts | Controller | GET /guardian/children/:id/schedule | portal-audience, GuardianPortalService, portal-schedule.query | HTTP | resolveOwnChildId, readPublishedSchedule | — | 404 on a non-owned child, before any schedule row is read |
Every file in apps/api/src/modules/timetable/ and the three portal controller files it depends on is represented above. assertTeacherStaffRow, assertPortalAudience, and GuardianPortalService.resolveOwnChildId are imported from shared mobile-portal infrastructure that predates this module and is documented in the mobile/portal module's own docs, not duplicated here.
16.2 UML and Architecture Diagram Pack
No Redis, BullMQ, or realtime dependency exists for this module, so the deployment diagram is deliberately just API and PostgreSQL.
16.3 Code Flow Narrative — publish
- Entry point.
ClassTimetableController.publish, gated onTimetablePublish_UPDATE. - Command shape.
{ actor: AuthUser, classPublicId: string }— no body. - Auth/identity.
actor.idbecomespublished_by_user_id. - Input normalization. None needed; the only input is the path parameter.
- Validation order. Class resolution → header lock → draft existence → draft non-emptiness → session currency → stale-reference check.
- Reads performed. Both headers (
FOR UPDATE,ORDER BY id), the draft's full entry rows (joined), the current-session flag. - Business decisions. Idempotent-return branch (no draft, published exists); refusal branches (no draft at all, empty draft, wrong session, stale references).
- Writes performed. Optional archive insert + published-header delete, then the draft-to-published
UPDATE. - Transaction boundary. One outer transaction for the whole operation; the promotion step runs in a nested transaction (SAVEPOINT) so a constraint failure there can be investigated without losing the outer transaction.
- Cache behavior. None — no cache exists for this module.
- Jobs/events/notifications. None.
- Response mapping.
loadTimetableDtore-reads the now-published grid, inside the same (committing) transaction. - Error handling.
translateTimetableWriteErrorcatches any constraint violation not already handled explicitly; a 23P01 is intercepted earlier bypromoteOrReportConflictto attachdetails.conflictsWith. - Logs/metrics/audit.
published_by_user_idis the only audit artifact; no explicit log line is emitted. - Known tradeoffs. Publish takes a full-table-row lock on both headers rather than a narrower lock, trading a small amount of contention for the guarantee that
createDraftandpublishcan never deadlock against each other (same lock order).
| Step | Code Location | What Happens | Why It Happens | Failure/Edge Case |
|---|---|---|---|---|
| 1 | ClassTimetablePublishService.publish | Opens the outer transaction, locks headers. | Serializes concurrent publish/draft-create against this class. | — |
| 2 | draft/published branch | Idempotent return, or TIMETABLE_DRAFT_MISSING. | A retried publish after a successful one should not fail. | Retried request with network timeout on the first attempt. |
| 3 | emptiness + session checks | TIMETABLE_DRAFT_EMPTY, session codes. | An hour's editing should not discover unpublishability only at the end. | Session changed underneath an in-progress edit. |
| 4 | computeStaleReferences | TIMETABLE_STALE_REFERENCES. | No constraint can see soft-delete or is_active. | A teacher assigned to a draft slot was deleted after the draft was built. |
| 5 | archiveAndDelete (conditional) | Snapshot + delete of the superseded published header. | Non-deferrable unique index forces delete-before-promote. | — |
| 6 | promoteOrReportConflict | SAVEPOINT UPDATE; on 23P01, look up and name the conflict. | The database is the only source of truth for a genuine cross-class race. | Two admins publish overlapping drafts for different classes at the same instant. |
| 7 | withDeadlockRetry | Retry once on 40001/40P01. | A concurrent timeslot PATCH and a publish take tables in opposite lock orders. | Confirmed reachable — see the schema/service docblocks. |
16.4 Data Layer Deep Dive
Covered exhaustively in §5 above for all four tables: full field tables, nullability, index rationale, constraint rationale, FK delete/update behavior, soft-delete interaction (none of these tables soft-delete; §6.3/§7 explain how the module compensates for referenced tables that do), versioning fields, audit fields (published_by_user_id), timezone interpretation (timestamptz for wall-clock instants, smallint minutes for wall-clock times), the jsonb snapshot schema, and migration-ordering history (the two hand-added statements in 0019_timetable_module.sql).
16.5 Business Logic and Invariant Catalog
| Invariant | Enforced By | Why It Exists | Failure Error | Tests |
|---|---|---|---|---|
| A slot's time range is well-formed and bounded to one day | class_timeslots_minutes_bounded, class_timeslots_day_bounded | Times are wall-clock local; midnight-spanning periods are not representable. | TIMESLOT_TIMES_INVALID, TIMESLOT_DAY_INVALID | timeslots.integration.spec.ts |
| No two slots of one class on one day overlap | class_timeslots_no_overlap (EXCLUDE) | The entire reason time is an integer range rather than a time column. | TIMESLOT_OVERLAPS_EXISTING | timeslots.integration.spec.ts |
Only period slots may hold a lesson | class_timetable_entries_slot_is_teaching (CHECK), pre-checked in putEntry | Special periods render from the slot row itself and must never acquire a teacher/subject by accident. | TIMETABLE_SLOT_NOT_TEACHING, TIMESLOT_TYPE_CHANGE_BLOCKED | timetable-write.integration.spec.ts |
| At most one draft and one published version per class | Two partial unique indexes | The versioned-header design that lets editing happen without disturbing the live schedule. | TIMETABLE_DRAFT_EXISTS | timetable-write.integration.spec.ts |
An entry's status always matches its header's | class_timetable_entries_header_fk composite, ON UPDATE cascade | Removes an entire class of drift bug — status is never writable except through the header. | (unenforceable to violate directly; cascade is automatic) | — |
| No teacher is in two places at once, among published rows | class_timetable_entries_teacher_free (EXCLUDE, partial) | The core scheduling guarantee. | TIMETABLE_TEACHER_CONFLICT | timetable-write.integration.spec.ts |
| No room hosts two classes at once, among published rows | class_timetable_entries_room_free (EXCLUDE, partial) | Same guarantee for physical space. | TIMETABLE_ROOM_CONFLICT | timetable-write.integration.spec.ts |
| Drafts are never conflict-constrained | Absence of any EXCLUDE on non-published rows; predicate status = 'published' | Lets two admins pencil in the same teacher in separate drafts without either edit being refused; the collision is caught once, at publish. | — (no error on draft write; caught at publish) | timetable-write.integration.spec.ts |
| A class's own published rows never conflict with its own draft edit | excludeClassId in findConflict's draft-time call | Publishing deletes the class's own published rows before promoting, so they can never be the thing a publish collides with. | — | timetable-write.integration.spec.ts |
| A subject placed into a class must be assigned to that class | class_timetable_entries_class_subject_fk composite, pre-checked in putEntry | Prevents Grade 6 Mathematics from landing in Grade 5's timetable. | TIMETABLE_SUBJECT_NOT_IN_CLASS | timetable-write.integration.spec.ts |
| A published entry's teacher/room/subject reference must still be live | computeStaleReferences, run at read time and pre-publish | No constraint can see deleted_at/is_active. | TIMETABLE_STALE_REFERENCES (publish only; read surfaces it as staleReferences without blocking) | timetable-write.integration.spec.ts |
| Only the current academic session may be edited or published | assertSessionCurrent | Prevents an hour of editing turning out unpublishable, and prevents rewriting history for a closed year. | TIMETABLE_NO_CURRENT_SESSION, TIMETABLE_SESSION_NOT_CURRENT | timetable-write.integration.spec.ts |
version tokens are server-owned and monotonic | bump_row_version trigger on all three live tables | Optimistic concurrency without a second writer to the same columns. | TIMESLOT_VERSION_CONFLICT, TIMETABLE_VERSION_CONFLICT | both integration specs |
| Restoring from an archive never fails wholesale on one bad entry | resolveArchivedEntry's per-entry skip logic | The snapshot stores both names and ids specifically so a partial restore is possible. | (reported via skipped[], not an error) | timetable-write.integration.spec.ts |
| The version-history list always includes the currently live version | listVersions reads class_timetables (status = 'published') as well as class_timetable_archives | A class published exactly once has no archive row yet; reading archives alone hid the live version entirely. | — (structural: the live row is unconditionally prepended when present) | timetable-write.integration.spec.ts |
A versionNo never resolves to a version other than the one requested | readVersion's live branch is a predicate — classId AND status = 'published' AND versionNo must all match | The version-detail screen titles itself from the caller's versionNo; an unguarded fallback would show the live week under a stale version's heading. | TIMETABLE_VERSION_NOT_FOUND | timetable-write.integration.spec.ts |
listVersions and readVersion never observe a torn state across class_timetables and class_timetable_archives | Both run inside one REPEATABLE READ, READ ONLY transaction | Two SELECTs under READ COMMITTED can straddle a concurrent publish/unpublish, duplicating, dropping, or hiding a version. | — (structural; a read-only REPEATABLE READ transaction cannot itself fail with a serialization error) | timetable-write.integration.spec.ts |
16.6 Tradeoffs, Alternatives, and ADR Notes
| Decision | Context | Chosen Option | Alternatives | Why Chosen | Tradeoffs | Revisit Trigger |
|---|---|---|---|---|---|---|
| Time representation | Need an overlap guarantee on class time ranges | smallint minutes-from-midnight + int4range EXCLUDE | time/timestamptz columns | Postgres has no timerange; a custom range type adds migration complexity for one consumer; the API speaking raw integers avoids "24:00"-style edge cases a HH:MM contract could not express. | Clients must format minutes themselves; a period spanning midnight is unrepresentable (accepted, since school days do not). | If cross-midnight scheduling is ever required. |
day_of_week as smallint, not pgEnum | Need to add a seventh teaching day without a breaking migration | Bounded smallint (0–6) with a CHECK | pgEnum of weekday names | ALTER TYPE ... ADD VALUE has no IF NOT EXISTS and no replayable down migration. | The number is meaningless without a label map, maintained in two repositories. | If Postgres ever supports transactional/idempotent enum value addition. |
| Draft/published as a versioned header, not a per-entry status | A schedule must be editable while staying live | class_timetables header with status, entries FK'd to it composite-key | A status column directly on each entry | A per-entry status cannot hold a published row and its draft replacement simultaneously. | Publish must delete-then-promote (non-deferrable unique index); slightly more moving parts than a single-table design. | — |
| Draft entries unconstrained by the EXCLUDE constraints | Editing a live schedule as a fork must not immediately self-conflict | Predicate status = 'published' on both EXCLUDE constraints | Constrain all entries regardless of status | A fork of a published timetable would collide with the parent it was copied from the instant it was created. | A genuine cross-class conflict in two drafts is invisible until publish. | — |
| Archive table has no foreign keys | A class must remain deletable even after publishing a timetable | Bare class_id/academic_session_id/published_by_user_id columns, no FK | ON DELETE restrict to classes | The alternative makes any class that ever published a timetable permanently undeletable, with an unmapped 500 from ClassesWriteService.remove(). | Archives become unreachable through the API once their class is hard-deleted (every read is keyed by class). | If class hard-delete is ever replaced by soft-delete. |
| Snapshot stores both names and ids | Archive must be both human-readable and restorable | Both fields, always | Names only | Names-only cannot support fromVersionNo restore — neither timeslotId nor classSubjectId is recoverable from a display name. | Slightly larger jsonb payload per archived entry. | — |
findConflict is a read-only mirror, never the enforcement | Need a named, user-facing conflict message | Separate helper duplicating the EXCLUDE predicate | Rely on the raw 23P01 alone | A bare SQLSTATE carries no information about which class/teacher/room clashed. | Two implementations of the same predicate must be kept in sync by hand; a divergence would show a false negative in the UI while the database still correctly refuses the write. | If Postgres ever surfaces conflicting-row details in the exclusion-violation error itself. |
| Promotion runs inside a SAVEPOINT | A 23P01 during publish must be attributable to a specific entry | Nested tx.transaction() (SAVEPOINT) around just the UPDATE | Catch the error after the whole outer transaction rolls back | After a full rollback, the conflicting row may itself have moved by the time it is looked up. | Slightly more complex control flow in promoteOrReportConflict. | — |
| Publish/unpublish split into a separate service from draft editing | Two operations lock the same tables in a stricter discipline than draft editing needs | ClassTimetablePublishService | One combined write service | createDraft and publish both take FOR UPDATE ORDER BY id; keeping that discipline visible in its own service avoids draft-editing code accidentally reusing (or forgetting) it. | Two services share ClassTimetableReadService and duplicate no logic, but a reader must know both exist. | — |
| No caching layer | Every read query is a live join across up to seven tables | No Redis cache | Cache the grid per class/version | The grid changes on every admin edit during active building; a cache would need aggressive invalidation on a table with no natural TTL story, for a query that is not the system's hot path. | Every grid read pays full join cost. | If read volume on the builder screen ever becomes a bottleneck. |
16.7 Operational Runbook
| Operation | How to Inspect | Healthy State | Failure Signal | Recovery |
|---|---|---|---|---|
| Publish contention | Application logs for TIMETABLE_CONCURRENT_UPDATE | Rare, isolated occurrences | Repeated 40001/40P01 for the same class in a short window | Client retries the publish; if persistent, check for a stuck long-running transaction holding the header lock. |
| Exclusion-constraint failures | TIMETABLE_TEACHER_CONFLICT/TIMETABLE_ROOM_CONFLICT response details.conflictsWith | Occasional, resolved by the operator choosing a different teacher/room/time | A conflict naming the editing class itself | Should be unreachable — excludeClassId covers the draft-time path; if seen at publish time, the class's own published rows were not deleted first, which is a code defect. |
| Orphaned archives | Query class_timetable_archives for class_id values absent from classes | Expected once a class is hard-deleted after publishing at least once | — | None needed; this is documented, intended behavior, not a fault. |
| Stale references at publish | TIMETABLE_STALE_REFERENCES response details.staleReferences | Rare — only after a teacher is deleted or a room/subject deactivated mid-edit | Recurring on the same class | Operator must re-select a live teacher/room/subject for the named slots, then retry publish. |
16.8 Backend Risk Register
| Risk | Area | Impact | Current Mitigation | Remaining Gap |
|---|---|---|---|---|
| Deadlock between a timeslot edit and a publish | Concurrency, class_timeslots/class_timetables/class_timetable_entries lock order | A publish or timeslot update can fail with TIMETABLE_CONCURRENT_UPDATE under load | withDeadlockRetry retries once | A second failure surfaces to the caller as an explicit error rather than transparently retrying further; acceptable, since the alternative is an unbounded retry loop. |
findConflict's predicate drifting from the real EXCLUDE constraints | Data integrity of user-facing error messages | A conflict message could omit details.conflictsWith while the database still correctly refuses the write | Both are hand-derived from the same schema docblock and migration comments | No automated check that the two predicates stay identical; a schema change to either EXCLUDE constraint must be manually mirrored in class-timetable-conflict.util.ts. |
| Archive growth with no retention policy | Storage | class_timetable_archives grows unbounded — one row per publish/unpublish, forever | None | No pruning or size cap exists; acceptable at current volume, worth revisiting if archive counts per class become large. |
17. Zero-Omission Backend Checklist
- Every file in the module directory is represented or explicitly marked non-runtime.
- Every controller, service, provider, processor, scheduler, helper, mapper, DTO, enum, and schema is documented.
- Every method with business behavior has a code-flow narrative (publish in full; others summarized in §6/§7 tables).
- Every table/collection/cache object/job payload has field-level detail.
- Every index, constraint, relation, and delete behavior has rationale.
- Every lifecycle/status transition has a state diagram and transition table.
- Every read/write/action/job flow has sequence and activity diagrams.
- Every business invariant is cataloged.
- Every cache key, invalidation path, queue job, realtime event, and external call is documented (all absent, and stated as such).
- Every architectural tradeoff is documented with alternatives and revisit triggers.
- Every operational failure mode has a runbook entry.
18. Backend Completion Checklist
- Module boundaries are documented.
- Every controller, service, DTO, schema file, job, cache key, and event is covered.
- Every database table/collection has a field table and relationship diagram.
- Every runtime flow has a diagram and branch notes.
- API, feature/flows, and TDD docs are linked below.
- No claim is made without a source file or documented source reference.
See Also
- API doc:
/docs/developer/timetable/api - Features and flows doc:
/docs/developer/timetable/feature