Timetable Features and Flows
Complete feature list, actor journeys, state flows, business rules, edge cases, and diagrams for Timetable.
Timetable Features and Flows
1. Documentation Evidence
| Source Type | Files or Docs | What Was Extracted |
|---|---|---|
| Schema | packages/db/src/schema/school/timetable.ts, packages/db/src/migrations/0019_timetable_module.sql | State model, constraint behavior, lifecycle. |
| API | apps/api/src/modules/timetable/**, apps/api/src/modules/mobile/{teacher,student,guardian}/*-schedule-portal.controller.ts | Route surface, actors, auth. |
| Backend | Sibling backend doc and the same service files | Business behavior and side effects. |
| Error codes | apps/api/src/common/types/error-codes.ts | Exact failure conditions and remedies. |
| Permissions | packages/db/src/authorization/permission-catalog.ts | Who can do what. |
2. Feature Summary
| Field | Value |
|---|---|
| Module | Timetable |
| Submodule | Timeslot Manager, Timetable Builder, Portal Schedule (teacher/student/guardian) |
| Primary user value | A school defines the daily structure of each class's week once, lays lessons into it, and publishes a version that every pupil, parent, and teacher can then read as their own schedule — without any two lessons double-booking a teacher or a room. |
| Actors | Admin (Timetable Publisher role, in practice superadmin-only for publishing), teacher, student, guardian. |
| Main entry points | Admin panel screens (Timeslot Manager, Timetable Builder), three mobile "my schedule" screens. |
| Main outputs | Persisted timeslot/timetable/entry rows, archived version snapshots, read-only schedule views. |
| Related docs | API, backend. |
3. Actor Matrix
| Actor | Can Do | Cannot Do | Auth Requirement | Notes |
|---|---|---|---|---|
Admin holding Timetables_* | Read timeslots/timetable, create/update/delete timeslots, create/discard a draft, place/clear draft entries, check slot availability, view version history | Publish or unpublish (needs TimetablePublish_UPDATE too), edit a slot or entry that reaches a published row without also holding TimetablePublish_UPDATE | Admin JWT + permission | Timetables_READ is granted to no seeded role — a school must create a role deliberately to expose staff-wide scheduling visibility. |
Admin holding TimetablePublish_UPDATE (superadmin, in practice) | Publish a draft, unpublish a live timetable, and (combined with Timetables_*) edit a timeslot that reaches a published row | Nothing beyond what the combined permission set allows | Admin JWT + permission; listed in SUPERADMIN_ONLY_MODULES | Splitting this out means an ordinary admin role, which receives everything not superadmin-only, never silently receives the power to change what every family reads. |
| Teacher | Read their own published weekly teaching schedule | See any other teacher's schedule, see draft schedules, see unpublished versions | User JWT, audience-asserted (assertTeacherStaffRow), no permission code | Their own staff.id is resolved server-side; a nullable local is never used to build the row filter. |
| Student | Read their own class's published weekly schedule | See any other class's schedule, see drafts | User JWT, audience-asserted (assertPortalAudience), no permission code | Scoped to a live enrollment in the current academic session — a prior-year enrollment is excluded even if still open. |
| Guardian | Read one of their own children's published weekly schedule | Read any child that is not theirs, see drafts | User JWT, audience-asserted plus explicit ward-ownership check, no permission code | The only role for which the ward id is a URL parameter — every other portal actor reads implicitly "their own" data with no parameter at all. |
4. Capability Matrix
| Capability | Surface | Actor | Route/Trigger | State Read | State Written | Linked API Section |
|---|---|---|---|---|---|---|
| List a class's timeslots | Admin | Admin | GET /classes/:id/timeslots | class_timeslots | — | API §8.1 |
| Create a timeslot | Admin | Admin | POST /classes/:id/timeslots | classes | class_timeslots | API §8.2 |
| Update a timeslot | Admin | Admin | PATCH /classes/:id/timeslots/:timeslotId | class_timeslots | class_timeslots (+ cascade to entries) | API §8.3 |
| Delete a timeslot | Admin | Admin | DELETE /classes/:id/timeslots/:timeslotId | — | class_timeslots | API §8.4 |
| Copy a day's timeslots | Admin | Admin | POST /classes/:id/timeslots/copy | source day's class_timeslots | class_timeslots (bulk) | API §8.5 |
| Read the live grid | Admin | Admin | GET /classes/:id/timetable | class_timetables, class_timeslots, class_timetable_entries | — | API §8.6 |
| List every version (live, then archived) | Admin | Admin | GET /classes/:id/timetable/versions | class_timetables, class_timetable_archives | — | API §8.7 |
| Read one version, live or archived | Admin | Admin | GET /classes/:id/timetable/versions/:versionNo | class_timetables, class_timetable_archives | — | API §8.8 |
| Create/restore a draft | Admin | Admin | POST /classes/:id/timetable/draft | class_timetables, optionally class_timetable_archives or published entries | class_timetables, class_timetable_entries | API §8.9 |
| Discard a draft | Admin | Admin | DELETE /classes/:id/timetable/draft | — | class_timetables (+ cascade) | API §8.10 |
| Place/update a lesson | Admin | Admin | PUT /classes/:id/timetable/draft/entries/:timeslotId | draft, timeslot, subject, teacher, room, conflicts | class_timetable_entries | API §8.11 |
| Clear a lesson | Admin | Admin | DELETE /classes/:id/timetable/draft/entries/:timeslotId | — | class_timetable_entries | API §8.12 |
| Check slot availability | Admin | Admin | GET .../timetable/slots/:timeslotId/availability | published entries | — | API §8.13 |
| Publish the draft | Admin | Admin (superadmin) | POST /classes/:id/timetable/publish | both headers, draft entries | archive insert, header delete, header update | API §8.14 |
| Unpublish the timetable | Admin | Admin (superadmin) | POST /classes/:id/timetable/unpublish | published header, entries | archive insert, header delete | API §8.15 |
| Read own teaching schedule | Mobile | Teacher | GET /teacher/schedule | published entries scoped to caller's staff.id | — | API §8.16 |
| Read own class schedule | Mobile | Student | GET /student/schedule | published entries scoped to caller's live enrollment | — | API §8.17 |
| Read a child's schedule | Mobile | Guardian | GET /guardian/children/:id/schedule | published entries scoped to a verified ward | — | API §8.18 |
| Read my day, period by period | Mobile | Student | GET /mobile/student/schedule/today | session, calendar day, live enrolment, published entries and breaks (60 s cache) | — | API §8.19 |
| Read my child's day, period by period | Mobile | Guardian | GET /mobile/guardian/children/:id/schedule/today | verified ward, then as above | — | API §8.20 |
5. User-Facing Flows
5.1 Build a class's daily structure (Timeslot Manager)
Summary
An administrator defines, for one class, which hours of which days are periods, breaks, lunch, activities, study hall, or free time. This structure is created once per class and is what the Timetable Builder later lays lessons onto.
Preconditions
- Caller holds
Timetables_CREATE/Timetables_READ. - The class exists.
Main Flow
| Step | Actor/System | Action | Result | Source |
|---|---|---|---|---|
| 1 | Admin | Opens the Timeslot Manager for a class. | GET .../timeslots returns all 7 days, most empty. | API §8.1 |
| 2 | Admin | Adds a period, e.g. Monday 08:00–09:00. | POST .../timeslots creates the row. | API §8.2 |
| 3 | Backend | Validates bounds and non-overlap. | Accepted, or a specific error code. | Backend §6.2 |
| 4 | Admin | Repeats for every period, break, and lunch of the day. | Each call is independent and immediately visible. | — |
| 5 | Admin | Uses "copy to other days" once Monday looks right. | POST .../timeslots/copy clones the whole day. | API §8.5 |
Sequence Diagram
Branches and Edge Cases
| Branch | Condition | Behavior | Error/Result |
|---|---|---|---|
| Overlap | New slot overlaps an existing one on the same day | Reject | TIMESLOT_OVERLAPS_EXISTING |
| Bad time bounds | endMinute <= startMinute, or outside [0,1440] | Reject | TIMESLOT_TIMES_INVALID |
| Bad label | Control character, or over 64 chars | Reject | TIMESLOT_LABEL_INVALID |
| Copy from an empty day | Source day has zero slots | Reject, never a silent no-op | TIMESLOT_COPY_SOURCE_EMPTY |
| Copy onto itself | Target list includes the source day | Reject | TIMESLOT_COPY_SOURCE_IN_TARGET |
5.2 Lay lessons into the week (Timetable Builder — draft editing)
Summary
An administrator opens the Timetable Builder for a class, which shows either the existing draft or, if none exists, offers to fork the published version or start empty. They then drag subjects into periods, optionally assigning a teacher and a room, and the grid saves each placement immediately.
Preconditions
- Caller holds
Timetables_UPDATE. - A current academic session exists and this class belongs to it.
- The class has at least one
period-type timeslot.
Main Flow
| Step | Actor/System | Action | Result | Source |
|---|---|---|---|---|
| 1 | Admin | Opens the builder. | GET .../timetable resolves draft, else published, else empty. | API §8.6 |
| 2 | Admin | If no draft, clicks "Start editing". | POST .../timetable/draft forks published or creates empty. | API §8.9 |
| 3 | Admin | Drags a subject onto a period. | PUT .../draft/entries/:timeslotId with classSubjectId only. | API §8.11 |
| 4 | Admin | Opens the lesson dialog to add a teacher/room/notes. | Same route, full body — an update of the same row. | API §8.11 |
| 5 | Backend | Checks the subject belongs to the class, the teacher/room exist, and no published conflict exists elsewhere. | Accepted, or a named conflict. | Backend §7.3 |
| 6 | Admin | Repeats for every period. | — | — |
Sequence Diagram
Branches and Edge Cases
| Branch | Condition | Behavior | Error/Result |
|---|---|---|---|
| Non-teaching slot | Dropped on a break/lunch/activity/study_hall/free period | Reject | TIMETABLE_SLOT_NOT_TEACHING |
| Subject not assigned | classSubjectId belongs to a different class | Reject | TIMETABLE_SUBJECT_NOT_IN_CLASS |
| Proactive conflict | The chosen teacher/room is already committed to a published lesson elsewhere at an overlapping time | Reject, with the other class/subject/teacher/room named | TIMETABLE_TEACHER_CONFLICT / TIMETABLE_ROOM_CONFLICT |
| Own class excluded | The conflict search excludes the class currently being edited | The operator can freely re-place their own class's lessons while editing | (no error for self-conflicts) |
| Duplicate placement | The same timeslot is dropped on twice in quick succession | Idempotent upsert — the second call updates, not duplicates | 200 both times |
| Clear a cell | Admin clicks "clear" on an occupied or empty cell | Idempotent — clearing an empty cell is not an error | 200 either way |
| Stale edit | Two admins edit the same entry, the second supplies a stale version | Reject | TIMETABLE_VERSION_CONFLICT |
5.3 Publish a timetable
Summary
Once a draft looks right, an administrator with publishing rights makes it live. The previously published version, if any, is preserved in history; the new draft becomes the version every pupil, parent, and teacher reads.
Preconditions
- Caller holds
TimetablePublish_UPDATE. - A draft exists and has at least one entry.
- The class's academic session is the current one.
- Every entry's teacher/room/subject reference is still live.
Main Flow
| Step | Actor/System | Action | Result | Source |
|---|---|---|---|---|
| 1 | Admin | Clicks "Publish". | POST .../timetable/publish. | API §8.14 |
| 2 | Backend | Locks both headers, checks draft exists and is non-empty. | Proceeds or rejects. | Backend §7.4 |
| 3 | Backend | Checks session currency and every reference's liveness. | Proceeds or rejects with detail. | Backend §7.4 |
| 4 | Backend | If a published version exists, snapshots it and deletes it. | Archive row created. | Backend §6.5 |
| 5 | Backend | Promotes the draft to published. | Every entry's status cascades to published. | Backend §6.5 |
| 6 | Backend | If a cross-class conflict fires at that instant, names it and rolls back. | Publish fails with the named conflict. | Backend §7.4 |
| 7 | System (portals) | The next request to any of the three schedule routes sees the new version. | — | — |
Sequence Diagram
Branches and Edge Cases
| Branch | Condition | Behavior | Error/Result |
|---|---|---|---|
| No draft, no published | Nothing to publish | Reject | TIMETABLE_DRAFT_MISSING |
| No draft, published exists | Idempotent retry of an already-successful publish | Return the published version | 200, no error |
| Empty draft | Zero entries | Reject | TIMETABLE_DRAFT_EMPTY |
| Non-current session | The class's session is not marked current | Reject | TIMETABLE_SESSION_NOT_CURRENT |
| Zero current sessions | No academic_sessions row is current at all | Reject | TIMETABLE_NO_CURRENT_SESSION |
| Decayed reference | A teacher was soft-deleted, or a room/subject deactivated, since assignment | Reject, naming every affected entry | TIMETABLE_STALE_REFERENCES |
| Cross-class conflict at promotion | The database's own EXCLUDE constraint fires | Reject, naming the conflicting class/teacher/room | TIMETABLE_TEACHER_CONFLICT / TIMETABLE_ROOM_CONFLICT |
| Concurrent deadlock | A simultaneous timeslot edit takes locks in the opposite order | Retried once automatically | TIMETABLE_CONCURRENT_UPDATE only if the retry also fails |
5.4 Restore an older version
Summary
An administrator who wants to go back to how a class's timetable looked before a change can rebuild a draft from any archived version and continue editing from there, without losing the ability to publish something new.
Preconditions
- No draft currently exists (creating one is refused otherwise).
- At least one archived version exists for the class.
Main Flow
| Step | Actor/System | Action | Result | Source |
|---|---|---|---|---|
| 1 | Admin | Opens version history. | GET .../timetable/versions lists the live version (if any) plus every archive. | API §8.7 |
| 2 | Admin | Selects an ARCHIVED version, clicks "Restore as draft". | POST .../timetable/draft { fromVersionNo } — restore reads archives only; the live version already has its own draft/edit path. | API §8.9 |
| 3 | Backend | Resolves each archived entry against current data. | Most resolve; some may be skipped and reported. | Backend §6.4 |
| 4 | Admin | Reviews the new draft, including any skipped-entry notices. | — | — |
| 5 | Admin | Continues editing or publishes directly. | Same flows as §5.2/§5.3. | — |
Branches and Edge Cases
| Branch | Condition | Behavior | Error/Result |
|---|---|---|---|
| Timeslot no longer exists | The archived timeslotId was deleted since | Skip that entry, report timeslot_missing | skipped[], call still succeeds |
| Timeslot moved | The slot exists but its day/time/type changed since | Skip that entry, report timeslot_moved | skipped[] |
| Subject no longer assigned | The archived classSubjectId is no longer assigned to the class | Skip that entry, report class_subject_missing | skipped[] |
| Requested version does not exist | Bad fromVersionNo | Reject the whole call | TIMETABLE_VERSION_NOT_FOUND |
| A draft already exists | Restore attempted while editing is already in progress | Reject | TIMETABLE_DRAFT_EXISTS |
5.5 Read a personal schedule (teacher, student, guardian)
Summary
A teacher, a pupil, or a guardian opens a "my schedule"/"my child's schedule" screen in the mobile app and sees their own published week, with subject, time, teacher name (for pupils/guardians), and room — nothing more.
Preconditions
- Caller is authenticated with the corresponding role.
- For a guardian: the requested child id must belong to that guardian.
Main Flow
| Step | Actor/System | Action | Result | Source |
|---|---|---|---|---|
| 1 | Actor | Opens the schedule screen. | App calls the corresponding portal route. | API §8.16–8.18 |
| 2 | Backend | Asserts audience (and, for guardian, ward ownership). | Proceeds or 403/404. | Backend §7.5 |
| 3 | Backend | Resolves the current academic session. | If none, returns an empty week. | Backend §7.5 |
| 4 | Backend | Runs the one shared published-schedule query, scoped to the caller's own rows. | Returns entries in week order. | Backend §7.5 |
| 5 | Actor | Views the week. | — | — |
Sequence Diagram
Branches and Edge Cases
| Branch | Condition | Behavior | Error/Result |
|---|---|---|---|
| No current session | No academic_sessions row marked current | Return empty week | 200, entries: [] |
| Nothing scheduled | No published entries match the scope | Return empty week | 200, entries: [] |
| Guardian, foreign child id | Requested child does not belong to this guardian | Reject before any schedule row is read | 404 (never 403) |
| Student, prior-year enrollment | Pupil holds a still-open enrollment from a past session | Excluded by the session filter | Current-year schedule only |
| Teacher, multiple classes | The same teacher teaches several classes in a week | Each entry carries its own className | — |
5.5a See today's periods (student, guardian)
Summary
A pupil, or a guardian reading one child, sees today's class slots with each marked finished, in progress or upcoming, and the next moment the day changes, so the client can refresh exactly then.
Main Flow
| Step | Actor/System | Action | Result | Source |
|---|---|---|---|---|
| 1 | Pupil | GET /api/mobile/student/schedule/today | Own class's day. | StudentSchedulePortalController.myToday |
| 1' | Guardian | GET /api/mobile/guardian/children/:id/schedule/today | The ward's day. | GuardianSchedulePortalController.childToday |
| 2 | System | Decides the calendar status, then derives each slot's state at the server clock. | PupilTodayScheduleDto. | PortalTodayService.forStudent |
Branches and Edge Cases
| Branch | Condition | Behavior | Result |
|---|---|---|---|
| No current session | No session is flagged current | No periods. | 200, calendar.status = "no_session" |
| Outside the session | Today is before the start or on or after the end date | No periods. | 200, outside_session |
| Calendar not set | No calendar row for today | No periods; not treated as a holiday. | 200, not_set |
| Holiday or weekend | The calendar row does not expect attendance | No periods. | 200, non_operating |
| Not enrolled | An operating day, but no active enrolment in the current session | No periods. | 200, no_class |
| Teaching day | Operating day and an enrolled class | The class's slots, each with a state. | 200, operating |
| Not a ward | Guardian route, id is not their child | Indistinguishable from a missing id. | 404 STUDENT_NOT_FOUND |
| Wrong audience | Active role is not the route's audience | Refused before any lookup. | 403 PERMISSION_INSUFFICIENT |
The day's slots are cached for 60 seconds per pupil and date; a timetable publish or calendar edit appears within 60 seconds. A slow or unavailable Redis never delays the response.
6. Admin Flows
6.1 Create (timeslot)
Covered fully in §5.1. Route POST /classes/:id/timeslots, permission Timetables_CREATE, mutation writes class_timeslots, no cache to invalidate, no audit log beyond the row's own timestamps.
6.2 List / Read detail (timeslots and timetable)
Covered in §5.1/§5.2. Both are read-only; no side effects.
6.3 Update (timeslot)
Covered in §5.1 and API §8.3. Requires Timetables_UPDATE and TimetablePublish_UPDATE, because the write can cascade onto published entries.
6.4 Delete (timeslot)
Covered in API §8.4. Refused while occupied (TIMESLOT_IN_USE).
6.5 Reorder
Not applicable — there is no manual ordering. Slots sort by startMinute, which is total because the overlap constraint makes ties impossible; "reordering" a day is editing the times themselves.
6.6 Activate/deactivate
Not applicable to any table in this module. Timeslots and timetable headers have no active/inactive flag; a timeslot exists or is deleted, and a timetable version is draft, published, or archived.
6.7 Soft delete / Restore
Not applicable — every delete in this module is a hard delete (class_timeslots, class_timetables, class_timetable_entries). The closest analog to "restore" is §5.4 (rebuilding a draft from an archived version), which is a create operation, not an undelete.
6.8 Export/import
Not applicable — no export or import endpoint exists for this module.
6.9 Moderation
Not applicable — no content moderation concept exists in this module.
6.10 Manual retry
The closest analog is retrying a 409 TIMETABLE_CONCURRENT_UPDATE publish, which the backend has already attempted once automatically before surfacing the error; a client-side retry is a second manual attempt.
7. Lifecycle and State Transitions
7.1 Timetable version lifecycle
| Entity | From | Event/Action | To | Guard Condition | Side Effects |
|---|---|---|---|---|---|
class_timetables | (none) | POST .../draft (empty/fork) | draft | No existing draft; current session | New header, possibly copied entries |
class_timetables | (none) | POST .../draft { fromVersionNo } | draft | No existing draft; archive exists; current session | New header, entries resolved per-item |
class_timetables | draft | DELETE .../draft | (deleted) | Draft exists | Entries cascade-deleted |
class_timetables | draft | POST .../publish | published | Non-empty, current session, no stale refs, no cross-class conflict | Old published archived+deleted first; entries cascade to published |
class_timetables | published | POST .../unpublish | (deleted, snapshotted) | Published exists | Archived first, then deleted; entries cascade-deleted |
class_timetables | published | POST .../publish again | new published (old archived) | New draft existed | Old published archived+deleted, new draft promoted |
class_timetable_archives | (created at publish/unpublish) | — | (terminal — never updated) | — | — |
A class_timetables row's status is the only lifecycle field, and it moves in exactly one direction per row: draft → published, or a row is deleted outright (discard, or the delete-before-promote/delete-at-unpublish step). There is no archived status on this table — an "archived" timetable is a wholly separate row in a wholly separate table with no foreign keys back to anything live.
Diagram:
7.2 Entry status lifecycle
An entry's status is never set by any write the entries service performs directly — it is entirely a function of its header's status, delivered by ON UPDATE cascade on the composite header foreign key.
9. Data and Side Effects by Flow
| Flow | DB Writes | Cache Effects | Jobs | Realtime | Analytics | Notifications |
|---|---|---|---|---|---|---|
| Create/update/delete/copy timeslot | class_timeslots (+ cascade to entries on update) | None | None | None | None | None |
| Create/restore draft | class_timetables, class_timetable_entries | None | None | None | None | None |
| Place/clear entry | class_timetable_entries | None | None | None | None | None |
| Publish | class_timetable_archives (insert), class_timetables (delete + update), class_timetable_entries (cascade status) | None | None | None | None | None |
| Unpublish | class_timetable_archives (insert), class_timetables (delete), class_timetable_entries (cascade delete) | None | None | None | None | None |
| Portal reads | None (read-only) | None | None | None | None | None |
This module has no cache layer, no queue, no realtime channel, no analytics event emission, and no notification integration anywhere in its read or write paths — every effect of every mutation is a synchronous database write inside the request's own transaction.
10. Error and Recovery Flows
| Scenario | Trigger | User/System Experience | Recovery | Source |
|---|---|---|---|---|
| Concurrent timeslot edit vs. publish | Two admins act on the same class at once | One of the two operations may fail with TIMETABLE_CONCURRENT_UPDATE after an automatic single retry | Client retries the failed request | Backend §6.5 |
| Stale optimistic-concurrency token | Two admins edit the same timeslot or entry | The second writer's request fails with a version-conflict code | Reload the row, reapply the change, retry | Backend §6.2/§6.4 |
| Cross-class conflict discovered at publish | A draft entry's teacher/room turns out to already be committed elsewhere by the time of promotion | Publish fails, naming the conflicting class/subject/teacher/room | Change the teacher/room/time in the draft, retry publish | Backend §7.4 |
| Reference decay before publish | A teacher assigned to a draft entry is deleted, or a room/subject deactivated | Publish is refused, listing every affected entry | Reassign the affected entries, retry publish | Backend §6.5 |
| Session rollover mid-edit | The current academic session changes while a draft is open | Further edits and publish are refused for the now-non-current session | Start a fresh draft for the new current session (fromClassPublicId on timeslot copy supports rolling structure forward) | Backend §6.4 |
| Partial restore | Some archived entries no longer resolve against current data | The draft is created anyway, missing only the unresolvable entries, reported by name and reason | Manually re-place the skipped entries | Backend §6.4 |
11. Diagrams Required Per Module
- Actor capability diagram — §3/§4 (tables serve this role; the actor set is small enough that a diagram would not add information beyond the matrix).
- High-level module flow diagram — §7.1 state diagram, §9.2 sequence diagrams throughout §5.
- Sequence diagram for each major user/admin/system flow — present in §5.1 through §5.5.
- State machine diagram for every lifecycle — §7.1, §7.2.
- Data side-effect diagram for write flows — §9 table (no diagram needed; there is no cache/job/event fan-out to diagram).
- Error branch diagram for critical flows — §5.3's publish sequence diagram includes the conflict branch explicitly; see also the API doc's
putEntryerror decision tree.
12. Mandatory Feature and Flow Deep-Dive Pack
12.1 Feature Inventory With Minor Behaviors
| Feature | Minor Behavior | Actor | Trigger | User/System Result | Backend Side Effect | Source |
|---|---|---|---|---|---|---|
| List timeslots | Always returns all 7 days, including empty ones | Admin | GET .../timeslots | Panel derives per-day tab counts with no second call | Read-only | Backend §6.3 |
| Create timeslot | Label is trimmed before the format check | Admin | POST .../timeslots | A pasted " Tiffin " succeeds as "Tiffin" | Write, no cache | Backend §6.2 |
| Update timeslot | Omitting every field but version still advances the token | Admin | PATCH .../timeslots/:id | The row's version increments even with no visible change | Trigger-driven | Backend §6.2 |
| Delete timeslot | Refuses while occupied, distinct from "not found" | Admin | DELETE .../timeslots/:id | Two different error codes for two different reasons | FK restrict | Backend §13 |
| Copy timeslots | Deduplicates the target-day list server-side | Admin | POST .../timeslots/copy | Sending [1,1,2] behaves as [1,2], never a double-insert attempt | — | Backend §6.4 |
| Copy timeslots | replace: true deletes target days first, inside the same transaction | Admin | same | A conflict on any target day rolls back the entire copy, not just that day | Transactional | Backend §6.4 |
| Read timetable | Draft wins over published when both exist, with no query param | Admin | GET .../timetable | The builder always shows "what I'm working on" by default | Read-only | Backend §6.3 |
| Read timetable | Explicit ?status= for a version that doesn't exist returns an empty grid, not a 404 | Admin | GET .../timetable?status=published | No error-retry loop on an unpublished class | Read-only | Backend §6.3 |
| Read timetable | staleReferences only populates for published reads | Admin | same | A draft's own decayed references are surfaced only at publish time, not on every read | Read-only | Backend §6.3 |
| Create draft | Forking published never skips anything | Admin | POST .../draft (no body) | Every live reference is already FK-enforced | — | Backend §6.4 |
| Create draft | Restoring from an archive can skip individual entries | Admin | POST .../draft { fromVersionNo } | skipped[] names each one and why | — | Backend §6.4 |
| Place entry | Sending only classSubjectId never clears an existing teacher/room/notes | Admin | PUT .../draft/entries/:id | The drop-then-dialog UX composes correctly | Partial update | Backend §6.4 |
| Place entry | Conflict check excludes the class being edited | Admin | same | Re-placing your own class's lessons while editing never self-refuses | — | Backend §6.4 |
| Clear entry | Clearing an already-empty slot is not an error | Admin | DELETE .../draft/entries/:id | Idempotent, safe to send twice | — | Backend §6.4 |
| Availability | Computed only among published entries | Admin | GET .../availability | A draft's own placements never make a teacher/room look unavailable | Read-only | Backend §6.3 |
| Publish | Idempotent when retried after success | Admin | POST .../publish | A retried request returns the published version rather than erroring | — | Backend §6.5 |
| Publish | Deletes the old published header before promoting, never after | Admin | same | Avoids a unique-index violation on the partial published index | — | Backend §6.5 |
| Publish | A conflict is investigated before the transaction rolls back | Admin | same | The named conflictsWith row is guaranteed accurate at report time | — | Backend §6.5 |
| Unpublish | Snapshots before deleting | Admin | POST .../unpublish | History is preserved even for a timetable that is simply taken down, not replaced | — | Backend §6.5 |
| Teacher schedule | Includes className per entry | Teacher | GET /teacher/schedule | Disambiguates a multi-class teaching week | — | Backend §7.5 |
| Student/guardian schedule | Excludes className (implicit — one class) | Student/Guardian | GET /student/schedule, GET /guardian/.../schedule | Slightly smaller payload, no redundant info | — | Backend §7.5 |
| All three portal reads | Empty week is 200, never 404 | Teacher/Student/Guardian | any | A new install or a between-enrollment pupil sees "nothing scheduled", not an error | — | Backend §7.5 |
| Guardian schedule | Ward ownership checked before any timetable row is read | Guardian | GET /guardian/children/:id/schedule | A foreign child id 404s before any query touches class_timetable_entries | — | Backend §7.5 |
| Guardian schedule | 404, never 403, on a foreign child id | Guardian | same | The id space cannot be probed via a status-code oracle | — | Backend §7.5 |
12.2 Business Process Diagram Pack
User journey (structure → content → publish → consumption):
Service blueprint (draft entry placement):
12.3 Business Rules and Policy Traceability
| Rule | Business Reason | Actor Impact | Enforced In | API Impact | Backend Impact | Tests |
|---|---|---|---|---|---|---|
| A teaching period cannot double as a break | Prevents a special period from silently acquiring a lesson | Admin sees a clear rejection when dropping on the wrong slot type | class_timetable_entries_slot_is_teaching (CHECK) + service pre-check | TIMETABLE_SLOT_NOT_TEACHING / TIMESLOT_TYPE_CHANGE_BLOCKED | Backend §6.4 | timetable-write.integration.spec.ts |
| No teacher in two places at once (published only) | The core scheduling promise this feature exists to keep | Admin is refused a conflicting placement/publish; teacher's own portal is always internally consistent | class_timetable_entries_teacher_free (EXCLUDE) | TIMETABLE_TEACHER_CONFLICT | Backend §5.2 | timetable-write.integration.spec.ts |
| No room double-booked (published only) | Physical-space scheduling guarantee | Same as above, for rooms | class_timetable_entries_room_free (EXCLUDE) | TIMETABLE_ROOM_CONFLICT | Backend §5.2 | timetable-write.integration.spec.ts |
| Drafts are unconstrained | Editing a live schedule as a fork must not self-conflict against its own parent | Admins can pencil in tentative conflicting placements while iterating | Predicate status = 'published' on both EXCLUDE constraints | (no error until publish) | Backend §5.2 | timetable-write.integration.spec.ts |
| A subject must be assigned to the class it's placed in | Prevents cross-grade subject leakage | Admin cannot place Grade 6 Mathematics into a Grade 5 timetable | class_timetable_entries_class_subject_fk + pre-check | TIMETABLE_SUBJECT_NOT_IN_CLASS | Backend §6.4 | timetable-write.integration.spec.ts |
| Only the current academic session may be edited/published | Prevents rewriting a closed year's history, and prevents an hour of editing turning out unpublishable | Admin is refused up front, at draft creation, not only at publish | assertSessionCurrent | TIMETABLE_NO_CURRENT_SESSION, TIMETABLE_SESSION_NOT_CURRENT | Backend §6.4 | timetable-write.integration.spec.ts |
| A published entry's references must stay live | No constraint can see soft-delete or is_active, so publish re-validates explicitly | Admin is told exactly which entries need fixing before a re-publish | computeStaleReferences | TIMETABLE_STALE_REFERENCES | Backend §6.3/§6.5 | timetable-write.integration.spec.ts |
| A teacher's identity in a portal is a name only | Field-level scope: object-level access alone would still leak employment status to every parent in a class | Teachers' privacy is preserved regardless of who can see the schedule row | PortalScheduleEntryDto field enumeration | Portal DTOs never include employeeCode/employmentStatus | Backend §11 | portal DTO source comments |
Timetables_READ is granted to no seeded role | The admin timetable view reveals every staff member's location at every hour — sensitive by default | No staff/teacher role sees it unless a school deliberately creates one | permission catalog | — | — | permissions-catalog.spec.ts (repo-wide) |
| Guest limitations | N/A — every route in this module requires authentication | No guest access exists anywhere in this module | JwtAuthGuard on every controller | 401 for unauthenticated calls | — | — |
12.4 Tradeoffs and Product Rationale
| Product Decision | User Benefit | Engineering Benefit | Alternative | Tradeoff | Risk |
|---|---|---|---|---|---|
| Drafts unconstrained by conflict rules | Admins can iterate freely without being blocked by their own past published state | Simpler mental model — one predicate, one predicate location | Constrain all entries always | Would make forking a published timetable to edit it immediately self-conflicting | Low — the real conflict is still caught, just later, at publish |
| Publish validates session currency again, not just at draft creation | An hour's editing session that crosses a session rollover is caught before going live, not silently accepted | Avoids a second, separate "is this stale" check elsewhere | Only check once, at draft creation | A long-lived draft could otherwise publish into a year that has since ended | Low |
| Restore skips bad entries instead of failing the whole operation | An admin can recover most of an old version even if a few subjects/timeslots have since changed | The snapshot's dual name/id storage exists specifically to make this possible | Fail the whole restore on any unresolvable entry | A restored draft may look incomplete until the admin notices the skipped[] list | Low — clearly reported, not silent |
| Empty grid instead of 404 for "no timetable yet" | A brand-new class is not an error state anywhere in the UI | Removes an entire class of client-side retry-loop bugs (this was a real, fixed defect) | 404 on a missing version | None remaining — this was tried and reverted | — |
| Guardian route uses 404, not 403, for a foreign child | Prevents turning the child-id space into an enumeration oracle | Matches the pattern already used elsewhere in the guardian portal | 403 Forbidden | None | — |
| No caching layer anywhere in this module | The grid always reflects the true, current state with zero staleness risk | No invalidation logic to get wrong on a feature with frequent admin edits | Cache the grid per class/version | Every read pays full join cost | Low at current scale; revisit if read volume grows |
12.5 Flow Edge-Case Matrix
| Flow | Edge Case | Trigger | Expected Behavior | User/System Feedback | Source |
|---|---|---|---|---|---|
| List timeslots | Empty state | Brand-new class | All 7 days returned, each with slots: [] | Empty grid, not an error | Backend §6.3 |
| Create timeslot | First use | Class's very first timeslot | Succeeds normally; no special-case code path | 201 | Backend §6.2 |
| Copy timeslots | Last item | Source day has exactly one slot | Copies that one slot to every target day | 200 | Backend §6.4 |
| Entry placement | Duplicate action | Same drop sent twice (e.g., a retried request) | Second call updates the same row via upsert | 200 both times, same final state | Backend §6.4 |
| Entry placement | Concurrent action | Two admins place different entries into the same draft simultaneously | Both succeed independently unless they target the same timeslotId | Row-level upsert isolation | Backend §6.4 |
| Session state | Expired state | Class's session is no longer current | Draft creation and publish are refused | TIMETABLE_SESSION_NOT_CURRENT | Backend §6.4/§6.5 |
| Any admin route | Permission mismatch | Caller lacks one of the required codes | 403, before the handler runs | — | API §5 |
| Portal read | Guest limitation | N/A — no guest access exists | Every route requires a JWT | 401 | Backend §11 |
| Restore draft | Missing dependency | An archived entry's timeslot or subject no longer resolves | That one entry is skipped, not the whole restore | skipped[] | Backend §6.4 |
| Portal read | Cache stale/miss | N/A — no cache exists | Every read is always current | — | Backend §8 |
| Publish | Queue failure | N/A — no queue exists in this module | Not applicable | — | Backend §9 |
| Read timetable | Unsupported filter/sort option | N/A — status is the only query parameter, and it is validated by @IsIn | An invalid value is rejected by the global pipe before the handler runs | 400 | API §6.9 |
12.6 Flow-to-Data Trace
| Flow | Reads | Writes | Cache | Jobs/Events | Response Fields |
|---|---|---|---|---|---|
| Publish | both headers, draft entries, academic_sessions | class_timetable_archives insert, class_timetables delete+update, class_timetable_entries cascade | None | None | Full ClassTimetableDto: status, versionNo, publishedAt, hasDraft, hasPublished, slots, entries, staleReferences |
| Portal read | class_timetable_entries (+joins), academic_sessions, plus role-specific scoping tables | None | None | None | academicSessionName, entries[] (audience-appropriate shape) |
12.7 Experience Quality Checklist
- The doc explains what the actor is trying to accomplish (build a week, lay in lessons, publish, and read a personal schedule).
- The doc explains what the backend does that the actor does not see (constraint enforcement, cascade propagation, stale-reference revalidation, snapshotting).
- The doc covers every minor flow and branch (§12.1).
- The doc includes admin, teacher, student, and guardian flows.
- The doc explains business logic, tradeoffs, and rationale (§12.3, §12.4).
- The doc maps every flow to API routes and backend side effects.
- The doc includes diagrams appropriate to each flow type.
- The doc covers edge cases and failure recovery (§10, §12.5).
13. Completion Checklist
- Every feature, minor action, and submodule capability is listed.
- Every actor has allowed and forbidden behavior documented.
- Every major and minor flow includes steps, branches, and diagrams.
- Every lifecycle has a transition table and state diagram.
- Every flow links to the API and backend docs.
- No changelog voice, no process references — the module is described as it stands.
See Also
- API doc:
/docs/developer/timetable/api - Backend doc:
/docs/developer/timetable/backend