Timetable API Reference
Complete API contracts for Timetable, including routes, auth, DTOs, responses, errors, examples, and integration notes.
Timetable - API Reference
Audience: Frontend engineers, mobile engineers, backend engineers, QA, and API consumers. Scope: Admin routes for the Timeslot Manager and Timetable Builder, and the mobile "my schedule" and "today" read routes owned by the teacher, student, and guardian portals.
1. Documentation Evidence
| Area | Files Inspected | What Was Verified |
|---|---|---|
| Controllers | apps/api/src/modules/timetable/timeslots/class-timeslots.controller.ts, apps/api/src/modules/timetable/entries/class-timetable.controller.ts, apps/api/src/modules/mobile/{teacher,student,guardian}/*-schedule-portal.controller.ts | Routes, methods, guards, decorators, permissions, status codes. |
| DTOs | apps/api/src/modules/timetable/dto/timeslot.dto.ts, apps/api/src/modules/timetable/dto/timetable.dto.ts, apps/api/src/modules/mobile/shared/portal-schedule.dto.ts | Request, query, response, validation, defaults. |
| Services | apps/api/src/modules/timetable/timeslots/*.ts, apps/api/src/modules/timetable/entries/*.ts, apps/api/src/modules/mobile/shared/portal-schedule.query.ts | Behavior, side effects, response mapping, errors. |
| Schema | packages/db/src/schema/school/timetable.ts, packages/db/src/migrations/0019_timetable_module.sql | IDs, enums, persisted fields, constraints. |
| Error registry | apps/api/src/common/types/error-codes.ts (TIMESLOT_*, TIMETABLE_*) | Every error code and its raising condition. |
| Permissions | packages/db/src/authorization/permission-catalog.ts | Timetables, TimetablePublish, Schedule module grants. |
2. Module Summary
| Field | Value |
|---|---|
| Module name | Timetable |
| Module slug | timetable |
| Primary actors | admin (Timeslot Manager, Timetable Builder), teacher, student, guardian (read-only "my schedule") |
| API surfaces | admin, mobile |
| Base route prefixes | /api/classes/:classPublicId/timeslots, /api/classes/:classPublicId/timetable, /api/mobile/teacher/schedule, /api/mobile/teacher/schedule/today, /api/mobile/student/schedule, /api/mobile/student/schedule/today, /api/mobile/guardian/children/:id/schedule, /api/mobile/guardian/children/:id/schedule/today |
| Auth model | JwtAuthGuard + RoleGuard on every route. Admin routes additionally require @Permissions(...); the six portal routes carry no @Permissions() and assert their audience in code instead. |
| Persistence | PostgreSQL only — class_timeslots, class_timetables, class_timetable_entries, class_timetable_archives. No Redis, BullMQ, or realtime dependency. |
| Runtime source of truth | The database rows and their constraints — no cache layer exists to disagree with them. |
| Sibling docs | Backend, features/flows |
3. Concepts and Terminology
| Term | Meaning | Source File | Used By |
|---|---|---|---|
| Timeslot | One row of class_timeslots: a day/time-range/type for one class. Structure, not content. | packages/db/src/schema/school/timetable.ts | ClassTimeslotsController, every entry route |
| Timetable (header) | One row of class_timetables: a versioned draft or published state of a class's schedule. | same | ClassTimetableController |
| Entry | One row of class_timetable_entries: a lesson (subject + optional teacher/room) placed into one timeslot of one timetable version. | same | Draft/publish routes |
slotType | period | break | lunch | activity | study_hall | free. Only period may hold an entry. | timeslot.dto.ts | All timeslot and entry DTOs |
dayOfWeek | 0–6, 0 = Sunday, matching Postgres EXTRACT(DOW). The admin surface only ever writes 0–5 in practice but the schema and API accept 0–6. | timeslot.dto.ts | All timeslot and entry DTOs |
startMinute / endMinute | Integer minutes from local midnight, half-open range [start, end). endMinute is at most 1440. | timeslot.dto.ts | All timeslot and entry DTOs |
version | Server-owned optimistic-concurrency token on class_timeslots and class_timetable_entries. Echo it back on update; it is a bigint in the database, serialized as a plain number. | schema, DTOs | Timeslot update, entry upsert |
versionNo | The integer that identifies one archived or published version of a class's whole timetable — distinct from the row-level version token above. | timetable.dto.ts | Draft restore, version listing |
| Draft / Published | The two values of class_timetables.status. At most one of each per class, enforced by partial unique indexes. | schema | ClassTimetableDto.status |
| Stale reference | A published entry whose teacher was soft-deleted, or whose room/subject assignment was deactivated, after the entry was created. No constraint can detect this at write time. | class-timetable-read.service.ts | ClassTimetableDto.staleReferences, publish |
4. API Surface Map
| Surface | Method | Path | Actor | Auth/Guard | Permission | Controller | Purpose |
|---|---|---|---|---|---|---|---|
| Admin | GET | /api/classes/:classPublicId/timeslots | Admin | JwtAuthGuard, RoleGuard | Timetables_READ | ClassTimeslotsController | List every day's timeslots for a class. |
| Admin | POST | /api/classes/:classPublicId/timeslots | Admin | same | Timetables_CREATE | ClassTimeslotsController | Create one timeslot. |
| Admin | PATCH | /api/classes/:classPublicId/timeslots/:timeslotId | Admin | same | Timetables_UPDATE, TimetablePublish_UPDATE | ClassTimeslotsController | Update a timeslot's day/time/type/label. |
| Admin | DELETE | /api/classes/:classPublicId/timeslots/:timeslotId | Admin | same | Timetables_DELETE, TimetablePublish_UPDATE | ClassTimeslotsController | Hard-delete an unoccupied timeslot. |
| Admin | POST | /api/classes/:classPublicId/timeslots/copy | Admin | same | Timetables_CREATE, Timetables_DELETE, TimetablePublish_UPDATE | ClassTimeslotsController | Copy one day's slots onto one or more other days. |
| Admin | GET | /api/classes/:classPublicId/timetable | Admin | same | Timetables_READ | ClassTimetableController | Read the live grid (draft, published, or an explicit version). |
| Admin | GET | /api/classes/:classPublicId/timetable/versions | Admin | same | Timetables_READ | ClassTimetableController | List every version — the currently live one first, then archived versions. |
| Admin | GET | /api/classes/:classPublicId/timetable/versions/:versionNo | Admin | same | Timetables_READ | ClassTimetableController | Read one version by its number, live or archived. |
| Admin | POST | /api/classes/:classPublicId/timetable/draft | Admin | same | Timetables_UPDATE | ClassTimetableController | Create or restore a draft. |
| Admin | DELETE | /api/classes/:classPublicId/timetable/draft | Admin | same | Timetables_DELETE | ClassTimetableController | Discard the current draft. |
| Admin | PUT | /api/classes/:classPublicId/timetable/draft/entries/:timeslotId | Admin | same | Timetables_UPDATE | ClassTimetableController | Place or update a lesson in one slot of the draft. |
| Admin | DELETE | /api/classes/:classPublicId/timetable/draft/entries/:timeslotId | Admin | same | Timetables_UPDATE | ClassTimetableController | Clear a slot in the draft. |
| Admin | GET | /api/classes/:classPublicId/timetable/slots/:timeslotId/availability | Admin | same | Timetables_READ | ClassTimetableController | Which teachers/rooms are free for a slot. |
| Admin | POST | /api/classes/:classPublicId/timetable/publish | Admin | same | TimetablePublish_UPDATE | ClassTimetableController | Publish the current draft. |
| Admin | POST | /api/classes/:classPublicId/timetable/unpublish | Admin | same | TimetablePublish_UPDATE | ClassTimetableController | Unpublish the current timetable. |
| Mobile | GET | /api/mobile/teacher/schedule | Teacher | JwtAuthGuard, RoleGuard | None — audience asserted in code | TeacherSchedulePortalController | The caller's own weekly teaching schedule. |
| Mobile | GET | /api/mobile/teacher/schedule/today | Teacher | same | None — audience asserted in code | TeacherTodayPortalController | The caller's own periods for today — class periods, exam periods for the class-subjects they teach, and invigilation duties — with live state. |
| Mobile | GET | /api/mobile/student/schedule | Student | same | None — audience asserted in code | StudentSchedulePortalController | The caller's own class's weekly schedule. |
| Mobile | GET | /api/mobile/student/schedule/today | Student | same | None — audience asserted in code | StudentSchedulePortalController | The caller's own class's periods for today, with live state. |
| Mobile | GET | /api/mobile/guardian/children/:id/schedule | Guardian | same | None — audience asserted in code | GuardianSchedulePortalController | One ward's weekly class schedule. |
| Mobile | GET | /api/mobile/guardian/children/:id/schedule/today | Guardian | same | None — audience asserted in code | GuardianSchedulePortalController | One ward's periods for today, with live state. |
Route prefixes for the two admin controllers come from their own @Controller() decorators (classes/:classPublicId/timeslots, classes/:classPublicId/timetable); TimetableModule adds no further prefix. The four portal controllers share their existing mobile-module @Controller("teacher"|"student"|"guardian") prefixes rather than being declared inside TimetableModule — a class declared in two modules would register its routes twice.
5. Auth, Identity, and Permissions
| Surface | Guard/Decorator | Identity Shape | Permission | Guest Allowed | Notes |
|---|---|---|---|---|---|
| Admin (read) | JwtAuthGuard, RoleGuard, @Permissions("Timetables_READ") | req.user.id | Timetables_READ | No | Granted to no seeded role — a school must create a role deliberately to expose staff-wide scheduling data. |
| Admin (create/update/delete on timeslots that could touch a published grid) | same + multi-code @Permissions(...) | req.user.id | Timetables_UPDATE/_DELETE/_CREATE and TimetablePublish_UPDATE | No | RoleGuard requires every listed code. TimetablePublish_UPDATE is also in SUPERADMIN_ONLY_MODULES, so an admin role never receives it automatically even though the catalog's default sweep grants everything else. |
| Admin (publish/unpublish) | same | req.user.id | TimetablePublish_UPDATE | No | Superadmin-only in practice for the reason above. |
| Mobile (teacher/student/guardian schedule) | JwtAuthGuard, RoleGuard, no @Permissions() | req.user (AuthUser) resolved via @CurrentAdmin() | None — Schedule_READ gates the screen, not the API route | No | Each controller asserts its own audience (assertTeacherStaffRow, assertPortalAudience) and, for the guardian route, additionally resolves ward ownership (GuardianPortalService.resolveOwnChildId) before any schedule row is read. Because these routes carry no @Permissions(), they must be listed in NO_PERMISSION_ADMIN_HANDLERS and in test/structure/route-permissions.spec.ts, or they 403 for every caller including superadmin. |
Schedule is the one permission module granted to staff, teacher, student, and guardian roles — the guardian role's first grant of any kind, made safe here because the guardian route takes an explicit ward parameter and asserts ownership of it, unlike an earlier withdrawn attempt to route guardians through a pupil-audience endpoint.
6. DTO and Model Reference
6.1 TimeslotDto (response)
| Field | Type | Required | Default | Validation | Example | Source |
|---|---|---|---|---|---|---|
id | string (uuid) | Yes | — | — | "018f..." | timeslot.dto.ts |
dayOfWeek | number | Yes | — | 0–6, 0 = Sunday | 1 | timeslot.dto.ts |
startMinute | number | Yes | — | 0–1440 | 480 | timeslot.dto.ts |
endMinute | number | Yes | — | 0–1440 | 540 | timeslot.dto.ts |
slotType | string enum | Yes | — | period | break | lunch | activity | study_hall | free | "period" | timeslot.dto.ts |
label | string | null | Yes (nullable) | null | — | "Tiffin" | timeslot.dto.ts |
version | number | Yes | — | server-generated | 3 | timeslot.dto.ts |
6.2 ClassTimeslotsDto (response, GET .../timeslots)
| Field | Type | Required | Default | Validation | Example | Source |
|---|---|---|---|---|---|---|
classPublicId | string | Yes | — | — | — | timeslot.dto.ts |
days | Array<{ dayOfWeek: number; slots: TimeslotDto[] }> | Yes | — | Always exactly 7 entries, dayOfWeek 0–6, in order | — | timeslot.dto.ts |
There is no aggregate/summary endpoint; the admin panel derives its per-day tab counts from this one response.
6.3 CreateTimeslotDto (request, POST .../timeslots)
| Field | Type | Required | Default | Validation | Example | Source |
|---|---|---|---|---|---|---|
dayOfWeek | number | Yes | — | @IsInt, @Min(0), @Max(6) | 1 | timeslot.dto.ts |
startMinute | number | Yes | — | @IsInt, @Min(0), @Max(1440) | 480 | timeslot.dto.ts |
endMinute | number | Yes | — | @IsInt, @Min(0), @Max(1440) | 540 | timeslot.dto.ts |
slotType | string enum | Yes | — | @IsIn(TIMESLOT_TYPES) | "period" | timeslot.dto.ts |
label | string | null | No | — | @IsString, @MaxLength(64); trimmed server-side before write | "Tiffin" | timeslot.dto.ts |
6.4 UpdateTimeslotDto (request, PATCH .../timeslots/:timeslotId)
Every field is optional except version, which is required.
| Field | Type | Required | Default | Validation | Example | Source |
|---|---|---|---|---|---|---|
dayOfWeek | number | No | unchanged | @IsInt, @Min(0), @Max(6) | 2 | timeslot.dto.ts |
startMinute | number | No | unchanged | @IsInt, @Min(0), @Max(1440) | 500 | timeslot.dto.ts |
endMinute | number | No | unchanged | @IsInt, @Min(0), @Max(1440) | 560 | timeslot.dto.ts |
slotType | string enum | No | unchanged | @IsIn(TIMESLOT_TYPES) | "break" | timeslot.dto.ts |
label | string | null | No | unchanged | @IsString, @MaxLength(64) | null | timeslot.dto.ts |
version | number | Yes | — | @IsInt, @Min(0) | 3 | timeslot.dto.ts |
A silently optional concurrency token is a token that is silently not checked, so version is required here even though every other field is optional; omitting it is a 400 from the global validation pipe.
6.5 CopyTimeslotsDto (request, POST .../timeslots/copy)
| Field | Type | Required | Default | Validation | Example | Source |
|---|---|---|---|---|---|---|
fromDayOfWeek | number | Yes | — | @IsInt, @Min(0), @Max(6) | 1 | timeslot.dto.ts |
toDaysOfWeek | number[] | Yes | — | @ArrayNotEmpty, @ArrayMaxSize(7), each @IsInt, @Min(0), @Max(6); deduplicated server-side; must not contain fromDayOfWeek | [2, 3, 4] | timeslot.dto.ts |
replace | boolean | Yes | — | @IsBoolean | true | timeslot.dto.ts |
fromClassPublicId | string (uuid) | No | the target class | @IsUUID | — | timeslot.dto.ts |
fromClassPublicId defaulting to the target class is what makes copying between classes (a session rollover from one year's class to the next) possible in the same endpoint used for same-class day duplication.
6.6 TimetableEntryDto (response)
| Field | Type | Required | Default | Validation | Example | Source |
|---|---|---|---|---|---|---|
timeslotId | string (uuid) | Yes | — | — | — | timetable.dto.ts |
classSubjectId | string (uuid) | Yes | — | — | — | timetable.dto.ts |
subjectName | string | Yes | — | — | "Mathematics" | timetable.dto.ts |
subjectCode | string | Yes | — | — | "MATH" | timetable.dto.ts |
teacherId | string | null (uuid) | Yes (nullable) | null | a staff uuid — staff has no public_id | — | timetable.dto.ts |
teacherName | string | null | Yes (nullable) | null | — | "Ram Sharma" | timetable.dto.ts |
roomId | number | null | Yes (nullable) | null | rooms.id, the integer, never a public id | — | timetable.dto.ts |
roomName | string | null | Yes (nullable) | null | — | "Room 204" | timetable.dto.ts |
notes | string | null | Yes (nullable) | null | — | — | timetable.dto.ts |
version | number | Yes | — | server-generated | 3 | timetable.dto.ts |
6.7 StaleReferenceDto (response, nested in ClassTimetableDto)
| Field | Type | Required | Default | Validation | Example | Source |
|---|---|---|---|---|---|---|
timeslotId | string (uuid) | Yes | — | — | — | timetable.dto.ts |
kind | string enum | Yes | — | teacher_deleted | room_inactive | class_subject_inactive | "teacher_deleted" | timetable.dto.ts |
referenceName | string | null | Yes (nullable) | null | The last-known name of the decayed reference. | "Ram Sharma" | timetable.dto.ts |
6.8 ClassTimetableDto (response, GET .../timetable)
| Field | Type | Required | Default | Validation | Example | Source |
|---|---|---|---|---|---|---|
classPublicId | string | Yes | — | — | — | timetable.dto.ts |
status | string enum | Yes | — | draft | published — which version this response actually is | "draft" | timetable.dto.ts |
versionNo | number | null | Yes (nullable) | null | null only for a brand-new class with neither draft nor published version | — | timetable.dto.ts |
publishedAt | string | null (ISO 8601) | Yes (nullable) | null | — | — | timetable.dto.ts |
hasDraft | boolean | Yes | — | So the panel can offer "edit" without a second call | — | timetable.dto.ts |
hasPublished | boolean | Yes | — | So the panel can offer "view published" without a second call | — | timetable.dto.ts |
slots | TimeslotDto[] | Yes | [] | Ordered by dayOfWeek, then startMinute | — | timetable.dto.ts |
entries | TimetableEntryDto[] | Yes | [] | Keyed to slots by timeslotId | — | timetable.dto.ts |
staleReferences | StaleReferenceDto[] | Yes | [] | Populated only when status === "published"; always [] for a draft response | — | timetable.dto.ts |
6.9 ReadTimetableQueryDto (query, GET .../timetable)
| Field | Type | Required | Default | Validation | Example | Source |
|---|---|---|---|---|---|---|
status | string enum | No | resolved server-side (draft, else published, else empty) | @IsIn(["draft","published"]) | "published" | timetable.dto.ts |
6.10 PutTimetableEntryDto (request, PUT .../draft/entries/:timeslotId)
One body serves both a drag-and-drop (sending classSubjectId alone) and the follow-up lesson-details dialog (sending the rest). A field omitted (undefined) is left untouched on an existing row; only an explicit value or explicit null changes it.
| Field | Type | Required | Default | Validation | Example | Source |
|---|---|---|---|---|---|---|
classSubjectId | string (uuid) | Yes | — | @IsUUID | — | timetable.dto.ts |
teacherId | string | null (uuid) | No | left unchanged if omitted | @IsUUID; a staff uuid, not a public id | — | timetable.dto.ts |
roomId | number | null | No | left unchanged if omitted | @IsInt; rooms.id | — | timetable.dto.ts |
notes | string | null | No | left unchanged if omitted | @IsString, @MaxLength(500); trimmed server-side | — | timetable.dto.ts |
version | number | No | — | @IsInt, @Min(0); enforced only when supplied, since the same endpoint both creates and updates | 3 | timetable.dto.ts |
6.11 CreateDraftDto (request, POST .../timetable/draft)
| Field | Type | Required | Default | Validation | Example | Source |
|---|---|---|---|---|---|---|
fromVersionNo | number | No | forks the published version, or creates an empty draft | @IsInt, @Min(1) | 4 | timetable.dto.ts |
6.12 SkippedEntryDto (response, nested in CreateDraftResultDto)
| Field | Type | Required | Default | Validation | Example | Source |
|---|---|---|---|---|---|---|
timeslotId | string | null | Yes (nullable) | — | — | — | timetable.dto.ts |
subjectName | string | null | Yes (nullable) | — | — | — | timetable.dto.ts |
reason | string enum | Yes | — | timeslot_missing | timeslot_moved | class_subject_missing | "timeslot_moved" | timetable.dto.ts |
6.13 CreateDraftResultDto (response, POST .../timetable/draft)
| Field | Type | Required | Default | Validation | Example | Source |
|---|---|---|---|---|---|---|
versionNo | number | Yes | — | — | 5 | timetable.dto.ts |
skipped | SkippedEntryDto[] | Yes | [] — non-empty only when fromVersionNo was given and some entries could not resolve | — | — | timetable.dto.ts |
6.14 TimetableVersionSummaryDto (response, GET .../timetable/versions)
| Field | Type | Required | Default | Validation | Example | Source |
|---|---|---|---|---|---|---|
versionNo | number | Yes | — | — | 4 | timetable.dto.ts |
publishedAt | string (ISO 8601) | Yes | — | — | — | timetable.dto.ts |
archivedAt | string | null | Yes (nullable) | — | null for the row that is currently live | — | timetable.dto.ts |
publishedByName | string | null | Yes (nullable) | null | — | "Admin User" | timetable.dto.ts |
entryCount | number | Yes | — | The live row's count is selectEntryRows(...).length; an archived row's is the stored snapshot's entries.length | 28 | timetable.dto.ts |
isLive | boolean | Yes | — | true for exactly one row, or none when nothing is published | true | timetable.dto.ts |
The live version — the currently published header, not yet archived — is always first when present, ahead of every archived row.
6.15 ArchivedTimetableDto / ArchivedTimetableEntryDto (response, GET .../timetable/versions/:versionNo)
Enumerated from the stored snapshot, never the raw jsonb — this route is Timetables_READ-gated so notes is included here, but internal ids (timeslotId, classSubjectId, teacherId) that exist only to make restore possible are not part of this read shape. versionNo may name either an archived version or the currently live one; the live version's entries are read fresh and mapped through the same projection, so the shape returned is identical either way.
| Field | Type | Required | Default | Validation | Example | Source |
|---|---|---|---|---|---|---|
versionNo | number | Yes | — | — | — | timetable.dto.ts |
publishedAt | string (ISO 8601) | Yes | — | — | — | timetable.dto.ts |
archivedAt | string | null | Yes (nullable) | — | null when versionNo names the currently live version | — | timetable.dto.ts |
publishedByName | string | null | Yes (nullable) | null | — | — | timetable.dto.ts |
entries | ArchivedTimetableEntryDto[] | Yes | — | See below | — | timetable.dto.ts |
ArchivedTimetableEntryDto: dayOfWeek, startMinute, endMinute (all number, required), slotType (string enum, required), label (string \| null), subjectName (string \| null), teacherName (string \| null), roomName (string \| null), notes (string \| null).
6.16 SlotAvailabilityDto (response, GET .../timetable/slots/:timeslotId/availability)
| Field | Type | Required | Default | Validation | Example | Source |
|---|---|---|---|---|---|---|
timeslotId | string (uuid) | Yes | — | — | — | timetable.dto.ts |
freeTeachers | Array<{ id: string; name: string }> | Yes | [] | Active, teaching-designation staff not already committed to a published, overlapping entry | — | timetable.dto.ts |
freeRooms | Array<{ id: number; name: string }> | Yes | [] | Active rooms not already committed to a published, overlapping entry | — | timetable.dto.ts |
6.17 ConflictDetailsDto / ConflictsWithDto (response, embedded in 409 error bodies)
The body both TIMETABLE_TEACHER_CONFLICT and TIMETABLE_ROOM_CONFLICT carry, in the details field of the error response. Obtained by a second query run inside the same transaction, before rollback, so the conflicting row is provably not yet moved.
ConflictDetailsDto: dayOfWeek, startMinute, endMinute (number, required), timeslotId (string, required), conflictsWith (ConflictsWithDto, required).
ConflictsWithDto: classPublicId (string), className (string), subjectName (string), teacherId (string \| null), teacherName (string \| null), roomId (number \| null), roomName (string \| null).
6.18 Portal DTOs
PortalScheduleEntryDto (response, student/guardian schedule entries):
| Field | Type | Required | Default | Validation | Example | Source |
|---|---|---|---|---|---|---|
dayOfWeek | number | Yes | — | 0–6 | 1 | portal-schedule.dto.ts |
startMinute | number | Yes | — | — | 480 | portal-schedule.dto.ts |
endMinute | number | Yes | — | — | 540 | portal-schedule.dto.ts |
slotType | string | Yes | — | period | break | lunch | activity | study_hall | free | "period" | portal-schedule.dto.ts |
label | string | null | Yes (nullable) | null | — | "Tiffin" | portal-schedule.dto.ts |
subjectName | string | null | Yes (nullable) | null | Only set for period slots | "Mathematics" | portal-schedule.dto.ts |
subjectCode | string | null | Yes (nullable) | null | — | "MATH" | portal-schedule.dto.ts |
teacherName | string | null | Yes (nullable) | null | A name only — no staff id, employee code, or employment status is ever included | "Ram Sharma" | portal-schedule.dto.ts |
roomName | string | null | Yes (nullable) | null | — | "Room 204" | portal-schedule.dto.ts |
TeacherScheduleEntryDto extends PortalScheduleEntryDto with four additional fields:
| Field | Type | Required | Default | Validation | Example | Source |
|---|---|---|---|---|---|---|
className | string | null | Yes (nullable) | null | Needed only for the teacher view, since one teacher's week spans multiple classes | "Grade 5 A" | portal-schedule.dto.ts |
classPublicId | string (uuid) | Yes | — | The class's public_id | "0198f2c4-6b1e-7c3a-9d52-3a4f1b8e7a10" | portal-schedule.dto.ts |
subjectId | string (uuid) | Yes | — | The subject's public_id | "0198f2c4-6b1e-7c3a-9d52-3a4f1b8e7a11" | portal-schedule.dto.ts |
classSubjectId | string (uuid) | Yes | — | The class_subjects row's id, which is its public identifier | "0198f2c4-6b1e-7c3a-9d52-3a4f1b8e7a12" | portal-schedule.dto.ts |
PortalScheduleDto / TeacherScheduleDto (the envelopes):
| Field | Type | Required | Default | Validation | Example | Source |
|---|---|---|---|---|---|---|
academicSessionName | string | null | Yes (nullable) | null | null when no session is marked current | "2026-27" | portal-schedule.dto.ts |
entries | PortalScheduleEntryDto[] or TeacherScheduleEntryDto[] | Yes | [] | An empty array is a normal, successful response — never treated as an error | — | portal-schedule.dto.ts |
PupilTodayScheduleDto (response of both schedule/today routes; mobile/shared/portal-today.dto.ts):
| Field | Type | Required | Notes |
|---|---|---|---|
serverTime | string | Yes | The instant the response was computed, ISO 8601 UTC. Period states are evaluated against it. |
timezone | string | Yes | The school's IANA timezone, for example "Asia/Kathmandu". |
date | string | Yes | The school-local date, YYYY-MM-DD. |
bsDate | { year, month, day} | null | Yes (nullable) | The Bikram Sambat date (1-based month). null outside the converter's range. |
dayOfWeek | number | Yes | 0–6, 0 = Sunday, of the school-local date. |
academicSessionName | string | null | Yes (nullable) | The current session's name; null when no session is current. |
calendar | PupilTodayCalendarDto | Yes | Why the day looks the way it does; see below. |
periods | PupilTodayPeriodDto[] | Yes | The class's slots for today in start order. Empty unless calendar.status is operating. |
currentPeriodId | string | null | Yes (nullable) | id of the first period whose state is in_progress. |
nextPeriodId | string | null | Yes (nullable) | id of the first period whose state is upcoming. |
nextTransitionAt | string | null | Yes (nullable) | The earliest slot start or end later than serverTime, ISO 8601 UTC, so a client can refresh at that moment. null when no boundary remains. |
summary | { total, finished, remaining } | Yes | Teaching periods only (slotType = "period"). remaining = total - finished, so it includes an in-progress period. All 0 when periods is empty. |
PupilTodayCalendarDto:
| Field | Type | Notes |
|---|---|---|
status | enum | One of operating, non_operating, not_set, outside_session, no_session, no_class. See the decision order in §8.23. |
classification | string | null | The calendar day's classification (for example school_day). null for no_session, outside_session and not_set. |
eventName | string | null | The calendar day's event name, when it has one. |
isOperatingDay | boolean | null | Whether attendance is expected. null for no_session, outside_session and not_set. |
PupilTodayPeriodDto:
| Field | Type | Notes |
|---|---|---|
id | string | <startMinute>-<endMinute>-<index>, where index is the slot's position in the day. Stable within a day; the same slot has the same id in both audiences. |
state | "finished" | "in_progress" | "upcoming" | finished once serverTime >= endsAt; in_progress once serverTime >= startsAt; otherwise upcoming. |
startMinute, endMinute | number | Minutes from school-local midnight. |
startsAt, endsAt | string | The slot boundaries as ISO 8601 UTC instants, built from the school-local date and minute. |
slotType | string | period, break, lunch, activity, study_hall or free. Only period carries a subject. |
label, subjectName, subjectCode, teacherName, roomName | string | null | As in PortalScheduleEntryDto; teacherName is a name only. |
Fields deliberately absent from every portal DTO, and why: notes (operator free text — "Cover for Mrs Sharma, medical leave" is a staff medical disclosure with a column name that gives no hint of that), teacherId, roomId, timetableId, timeslotId, version, publishedByUserId (internal identity/concurrency plumbing a read-only screen has no use for), and any teacher field beyond a name (no employeeCode, no employmentStatus — the field-level control that keeps this endpoint from telling every parent in a class whether a teacher has been terminated).
7. Enum Reference
| Enum | Value | Meaning | Runtime Effect | Source |
|---|---|---|---|---|
timeslot_type / TIMESLOT_TYPES | period | A teaching period. | The only value an entry may occupy. | packages/db/src/schema/school/timetable.ts, timeslot.dto.ts |
break | A short non-teaching gap. | Renders from the timeslot row itself; cannot hold an entry. | same | |
lunch | The midday break. | same | same | |
activity | A non-subject activity period (assembly, sport, club). | same | same | |
study_hall | Unsupervised or self-study time. | same | same | |
free | An unstructured free period. | same | same | |
timetable_status / TIMETABLE_STATUSES | draft | The editable working copy. Unconstrained by the two exclusion constraints. | Read/write via the draft routes; never visible to portal reads. | same, timetable.dto.ts |
published | The live, school-facing version. Constrained by both exclusion constraints; the only status portal reads ever see. | Read via GET .../timetable?status=published and the three weekly portal routes. | same | |
StaleReferenceDto.kind | teacher_deleted | The entry's teacher was soft-deleted after assignment. | Surfaced in staleReferences; blocks publish if found on the draft. | timetable.dto.ts |
room_inactive | The entry's room was deactivated (rooms.is_active = false) after assignment. | same | same | |
class_subject_inactive | The entry's subject assignment was deactivated (class_subjects.is_active = false). | same | same | |
SkippedEntryDto.reason | timeslot_missing | The archived entry's timeslotId no longer resolves for this class. | The entry is omitted from the restored draft. | timetable.dto.ts |
timeslot_moved | The timeslot still exists but its day/time/type has since changed. | same | same | |
class_subject_missing | The archived classSubjectId no longer resolves for this class. | same | same |
8. Endpoint Reference
8.1 GET /api/classes/:classPublicId/timeslots
Purpose
Returns every day of the week, 0–6, in one response — including days with no slots at all. There is no aggregate/summary endpoint; the admin panel derives its per-day tab counts directly from this response's days array.
Source Evidence
| Evidence | Path |
|---|---|
| Controller | apps/api/src/modules/timetable/timeslots/class-timeslots.controller.ts |
| Service | apps/api/src/modules/timetable/timeslots/class-timeslots-read.service.ts |
| Schema | packages/db/src/schema/school/timetable.ts |
| Tests | apps/api/src/modules/timetable/__tests__/timeslots.integration.spec.ts |
Auth and Permissions
- Auth: JWT.
- Guard chain:
JwtAuthGuard,RoleGuard. - Permission:
Timetables_READ. - Guest support: No.
- Rate limit: None module-specific.
- Idempotency: N/A (read).
Request
| Part | Required | Details |
|---|---|---|
| Headers | Yes | Authorization: Bearer <token>. |
| Params | Yes | classPublicId — the class's uuid. |
| Query | No | None. |
| Body | No | — |
Response
{
"success": true,
"message": "Timeslots fetched.",
"data": {
"classPublicId": "018f2f3e-...-...",
"days": [
{ "dayOfWeek": 0, "slots": [] },
{
"dayOfWeek": 1,
"slots": [
{
"id": "018f2f40-...",
"dayOfWeek": 1,
"startMinute": 480,
"endMinute": 540,
"slotType": "period",
"label": null,
"version": 0
}
]
}
]
}
}Side Effects
None — read-only.
Error Cases
| HTTP Status | Error Code | Condition | User-Facing Meaning | Source |
|---|---|---|---|---|
404 | CLASS_NOT_FOUND | classPublicId does not resolve. | The class does not exist. | class-timeslots-read.service.ts |
Edge Cases
- A class with no timeslots at all returns all 7 days with empty
slotsarrays — never a 404. - Slots are ordered
dayOfWeekthenstartMinutewithin each day.
Example Requests
curl -X GET "$API_URL/api/classes/018f2f3e-.../timeslots" \
-H "Authorization: Bearer TOKEN"8.2 POST /api/classes/:classPublicId/timeslots
Purpose
Creates one timeslot for a class. Used by the Timeslot Manager to build up a class's daily structure one period, break, or lunch at a time.
Source Evidence
| Evidence | Path |
|---|---|
| Controller | apps/api/src/modules/timetable/timeslots/class-timeslots.controller.ts |
| DTO | apps/api/src/modules/timetable/dto/timeslot.dto.ts (CreateTimeslotDto) |
| Service | apps/api/src/modules/timetable/timeslots/class-timeslots-write.service.ts |
| Schema | packages/db/src/schema/school/timetable.ts |
| Tests | apps/api/src/modules/timetable/__tests__/timeslots.integration.spec.ts |
Auth and Permissions
- Auth: JWT.
- Guard chain:
JwtAuthGuard,RoleGuard. - Permission:
Timetables_CREATE. - Guest support: No.
- Rate limit: None module-specific.
- Idempotency: Not idempotent — repeated calls create repeated rows unless they would overlap.
Request
| Part | Required | Details |
|---|---|---|
| Headers | Yes | Authorization, Content-Type: application/json. |
| Params | Yes | classPublicId. |
| Query | No | — |
| Body | Yes | CreateTimeslotDto — see §6.3. |
{ "dayOfWeek": 1, "startMinute": 480, "endMinute": 540, "slotType": "period", "label": null }Response
201, TimeslotDto (§6.1), wrapped in the standard envelope with message "Timeslot created.".
Side Effects
- Database write: one
class_timeslotsinsert. - No cache, queue, realtime, notification, or external call.
Error Cases
| HTTP Status | Error Code | Condition | User-Facing Meaning | Source |
|---|---|---|---|---|
404 | CLASS_NOT_FOUND | classPublicId does not resolve. | — | class-timeslots-write.service.ts |
409 | TIMESLOT_OVERLAPS_EXISTING | Overlaps another slot of the same class/day. | Pick a non-overlapping time. | class_timeslots_no_overlap |
422 | TIMESLOT_TIMES_INVALID | endMinute <= startMinute, or out of [0,1440]. | Fix the times. | class_timeslots_minutes_bounded |
422 | TIMESLOT_DAY_INVALID | dayOfWeek outside 0..6. | Fix the day. | class_timeslots_day_bounded |
422 | TIMESLOT_LABEL_INVALID | Control character, or over 64 chars after trim. | Fix the label. | class_timeslots_label_format |
Edge Cases
labelomitted ornullis valid; a leading/trailing-space label is trimmed server-side before validation against the format CHECK, so a pasted" Tiffin "succeeds as"Tiffin".- A value for
startMinute/endMinutelarger than a Postgressmallint(32767) raises22003at the database level before any CHECK runs — this is not translated to a module error code and surfaces as an unmapped500if the DTO's own@Max(1440)bound is somehow bypassed; the DTO bound is the actual first line of defense.
Example Requests
curl -X POST "$API_URL/api/classes/018f2f3e-.../timeslots" \
-H "Authorization: Bearer TOKEN" -H "Content-Type: application/json" \
-d '{"dayOfWeek":1,"startMinute":480,"endMinute":540,"slotType":"period"}'8.3 PATCH /api/classes/:classPublicId/timeslots/:timeslotId
Purpose
Updates a timeslot's day, time range, type, or label, with optimistic concurrency. Because moving or retyping a slot cascades onto every class_timetable_entries row that occupies it — including published ones — this route requires an additional permission beyond the ordinary update code.
Source Evidence
| Evidence | Path |
|---|---|
| Controller | apps/api/src/modules/timetable/timeslots/class-timeslots.controller.ts |
| DTO | timeslot.dto.ts (UpdateTimeslotDto) |
| Service | class-timeslots-write.service.ts |
| Schema | packages/db/src/schema/school/timetable.ts (composite key 2 on class_timetable_entries) |
| Tests | timeslots.integration.spec.ts |
Auth and Permissions
- Auth: JWT.
- Guard chain:
JwtAuthGuard,RoleGuard. - Permission:
Timetables_UPDATEandTimetablePublish_UPDATE(both required). - Guest support: No.
- Rate limit: None module-specific.
- Idempotency: Effectively idempotent for the same
version(a staleversionfails on retry).
Request
| Part | Required | Details |
|---|---|---|
| Headers | Yes | Authorization, Content-Type. |
| Params | Yes | classPublicId, timeslotId. |
| Query | No | — |
| Body | Yes | UpdateTimeslotDto — all fields optional except version (§6.4). |
Minimal valid request (no field changes, only advancing the version by touching one field or resending it):
{ "version": 3 }Full valid request:
{ "dayOfWeek": 2, "startMinute": 500, "endMinute": 560, "slotType": "break", "label": null, "version": 3 }Response
200, TimeslotDto, message "Timeslot updated.".
Side Effects
- Database write:
class_timeslotsupdate. - Cascading write: if
dayOfWeek/startMinute/endMinute/slotTypechange, everyclass_timetable_entriesrow referencing this slot is rewritten viaON UPDATE cascade, including published rows.
Error Cases
| HTTP Status | Error Code | Condition | User-Facing Meaning | Source |
|---|---|---|---|---|
404 | TIMESLOT_NOT_FOUND | Slot absent, or belongs to a different class. | — | class-timeslots-write.service.ts |
409 | TIMESLOT_VERSION_CONFLICT | version in the body is stale. | Reload and retry. | same |
409 | TIMESLOT_OVERLAPS_EXISTING | New day/time overlaps another slot of the class. | — | class_timeslots_no_overlap |
422 | TIMESLOT_TIMES_INVALID / TIMESLOT_DAY_INVALID / TIMESLOT_LABEL_INVALID | As in create. | — | respective CHECKs |
409 | TIMESLOT_TYPE_CHANGE_BLOCKED | Converting an occupied period into a non-teaching type. | Clear the lesson from this slot first. | cascade onto class_timetable_entries_slot_is_teaching |
Edge Cases
- Omitting every field but
versionis valid: the service self-assigns the existing value of one column so theUPDATEstatement is non-trivial and thebump_row_versiontrigger still fires, advancingversion. label: nullexplicitly clears an existing label (distinct from omittinglabel, which leaves it unchanged).
Example Requests
curl -X PATCH "$API_URL/api/classes/018f2f3e-.../timeslots/018f2f40-..." \
-H "Authorization: Bearer TOKEN" -H "Content-Type: application/json" \
-d '{"startMinute":500,"endMinute":560,"version":3}'8.4 DELETE /api/classes/:classPublicId/timeslots/:timeslotId
Purpose
Hard-deletes a timeslot. Refused while any lesson still occupies it.
Source Evidence
| Evidence | Path |
|---|---|
| Controller | class-timeslots.controller.ts |
| Service | class-timeslots-write.service.ts |
| Schema | class_timetable_entries_timeslot_fk (ON DELETE restrict) |
| Tests | timeslots.integration.spec.ts |
Auth and Permissions
- Permission:
Timetables_DELETEandTimetablePublish_UPDATE. - Guest support: No.
- Idempotency: Not idempotent — a second call 404s.
Request
| Part | Required | Details |
|---|---|---|
| Params | Yes | classPublicId, timeslotId. |
Response
200, data: null, message "Timeslot deleted.".
Side Effects
Database write: class_timeslots delete, refused by ON DELETE restrict if occupied.
Error Cases
| HTTP Status | Error Code | Condition | User-Facing Meaning | Source |
|---|---|---|---|---|
404 | TIMESLOT_NOT_FOUND | Slot absent, or belongs to a different class. | — | class-timeslots-write.service.ts |
409 | TIMESLOT_IN_USE | A lesson still occupies the slot (23001 on the timeslot FK). | Clear the entry first. | class_timetable_entries_timeslot_fk |
Edge Cases
Deleting a slot that never existed and one that is currently occupied both fail, with different codes — the caller cannot distinguish "wrong id" from "occupied" without reading the error code.
Example Requests
curl -X DELETE "$API_URL/api/classes/018f2f3e-.../timeslots/018f2f40-..." \
-H "Authorization: Bearer TOKEN"8.5 POST /api/classes/:classPublicId/timeslots/copy
Purpose
Clones one day's timeslots onto one or more other days in a single transaction — the fast path for building a school's typical five- or six-day-identical week, and, via fromClassPublicId, for rolling a schedule forward into a new class at the start of a session.
Source Evidence
| Evidence | Path |
|---|---|
| Controller | class-timeslots.controller.ts |
| DTO | timeslot.dto.ts (CopyTimeslotsDto) |
| Service | class-timeslots-write.service.ts |
| Tests | timeslots.integration.spec.ts |
Auth and Permissions
- Permission:
Timetables_CREATE,Timetables_DELETE, andTimetablePublish_UPDATE(all three required —copycan both create and, withreplace: true, destroy existing timeslots). - Guest support: No.
- Idempotency: Not idempotent with
replace: false(re-running duplicates or overlaps); idempotent in effect withreplace: trueagainst an unchanged source day.
Request
{ "fromDayOfWeek": 1, "toDaysOfWeek": [2, 3, 4, 5], "replace": true, "fromClassPublicId": null }See §6.5 for the full field table.
Response
200, ClassTimeslotsDto (the full 7-day listing after the copy), message "Timeslots copied.".
Side Effects
- Database writes: optional bulk delete of the target days' slots (
replace: true), then bulk insert of the copied slots — all in one transaction. - No cache, queue, or realtime side effect.
Error Cases
| HTTP Status | Error Code | Condition | User-Facing Meaning | Source |
|---|---|---|---|---|
422 | TIMESLOT_COPY_SOURCE_IN_TARGET | toDaysOfWeek includes fromDayOfWeek. | Self-copy is incoherent — remove it from the target list. | pre-check in copy() |
422 | TIMESLOT_COPY_SOURCE_EMPTY | The source day has zero slots. | A silent no-op across up to 6 days is refused rather than silently doing nothing. | pre-check in copy() |
409 | TIMESLOT_IN_USE | With replace: true, a target day's slot is occupied and cannot be deleted. | The whole copy rolls back. | cascade from the delete step |
409 | TIMESLOT_OVERLAPS_EXISTING | With replace: false, a copied slot overlaps an existing one on a target day. | — | class_timeslots_no_overlap |
Edge Cases
toDaysOfWeekis deduplicated server-side ([1, 1, 2]behaves as[1, 2]) so a client sending duplicates does not attempt the same insert twice inside one transaction.- The entire operation is one transaction: a failure on the third of four target days rolls back all four, never leaving some days copied and others not.
fromClassPublicIdomitted defaults to the target class itself.
Example Requests
curl -X POST "$API_URL/api/classes/018f2f3e-.../timeslots/copy" \
-H "Authorization: Bearer TOKEN" -H "Content-Type: application/json" \
-d '{"fromDayOfWeek":1,"toDaysOfWeek":[2,3,4,5],"replace":true}'8.6 GET /api/classes/:classPublicId/timetable
Purpose
Reads the live grid. Defaults to the draft if one exists, else the published version, else an empty grid — a brand-new class is not an error. Pass ?status=draft or ?status=published to require that exact version.
Source Evidence
| Evidence | Path |
|---|---|
| Controller | class-timetable.controller.ts |
| Query DTO | timetable.dto.ts (ReadTimetableQueryDto) |
| Service | class-timetable-read.service.ts |
| Tests | timetable-write.integration.spec.ts |
Auth and Permissions
- Permission:
Timetables_READ. - Guest support: No.
- Idempotency: N/A (read).
Request
| Part | Required | Details |
|---|---|---|
| Params | Yes | classPublicId. |
| Query | No | status?: "draft" | "published". |
Response
{
"success": true,
"message": "Timetable fetched.",
"data": {
"classPublicId": "018f2f3e-...",
"status": "draft",
"versionNo": 5,
"publishedAt": null,
"hasDraft": true,
"hasPublished": true,
"slots": [ /* TimeslotDto[] */ ],
"entries": [ /* TimetableEntryDto[] */ ],
"staleReferences": []
}
}Side Effects
None — read-only.
Error Cases
| HTTP Status | Error Code | Condition | User-Facing Meaning | Source |
|---|---|---|---|---|
404 | CLASS_NOT_FOUND | classPublicId does not resolve. | — | resolveClass |
An explicit ?status= naming a version that does not exist is not an error — see the next row.
Edge Cases
- No draft, no published, no explicit
status: returnsstatus: "draft",versionNo: null,hasDraft: false,hasPublished: false, emptyslots/entries/staleReferences. An empty class is a normal state, not a404. - Explicit
?status=publishedon a class with no published version: same empty-grid shape,statusechoed back as"published"— deliberately not a404(an earlier design that did 404 here produced a permanent error-retry loop in the admin panel for the ordinary case of an unpublished class). staleReferencesis populated only forstatus === "published"; a draft response'sstaleReferencesis always[]even if the draft's own references have decayed (that check runs only at publish time for drafts).
Example Requests
curl -X GET "$API_URL/api/classes/018f2f3e-.../timetable?status=published" \
-H "Authorization: Bearer TOKEN"8.7 GET /api/classes/:classPublicId/timetable/versions
Purpose
Lists every version of a class's timetable for a version-history screen: the currently live version first (if one exists), then every archived version, newest first.
Source Evidence
| Evidence | Path |
|---|---|
| Controller | class-timetable.controller.ts |
| Service | class-timetable-read.service.ts (listVersions) |
| Schema | class_timetables, class_timetable_archives |
Auth and Permissions
- Permission:
Timetables_READ.
Response
200, TimetableVersionSummaryDto[] (§6.14), message "Versions fetched.". An empty array is
normal for a class that has never published. The read runs inside one read-only REPEATABLE READ
transaction over both tables, so a concurrent publish or unpublish cannot duplicate, drop, or hide
a version between the live lookup and the archive lookup.
Error Cases
| HTTP Status | Error Code | Condition |
|---|---|---|
404 | CLASS_NOT_FOUND | classPublicId does not resolve. |
Example Requests
curl -X GET "$API_URL/api/classes/018f2f3e-.../timetable/versions" -H "Authorization: Bearer TOKEN"8.8 GET /api/classes/:classPublicId/timetable/versions/:versionNo
Purpose
Reads one version in full, for a read-only "view this version" screen — the version named by
versionNo may be archived or the one currently live.
Source Evidence
| Evidence | Path |
|---|---|
| Controller | class-timetable.controller.ts |
| Service | class-timetable-read.service.ts (readVersion) |
| Schema | class_timetables, class_timetable_archives |
Auth and Permissions
- Permission:
Timetables_READ.
Response
200, ArchivedTimetableDto (§6.15), message "Version fetched.". An archived match is checked
first; if none exists, versionNo is matched against the currently PUBLISHED header on this class
— all three of classId, status = 'published' and versionNo must hold, or the request 404s.
That predicate is deliberate: a versionNo matching neither branch always 404s, even when the
class has a live version under a different number — the screen titles itself from the requested
versionNo, so silently substituting the live week would show it under the wrong heading.
Error Cases
| HTTP Status | Error Code | Condition |
|---|---|---|
404 | CLASS_NOT_FOUND | classPublicId does not resolve. |
404 | TIMETABLE_VERSION_NOT_FOUND | No archive AND no currently-published header with that versionNo for this class. |
Example Requests
curl -X GET "$API_URL/api/classes/018f2f3e-.../timetable/versions/4" -H "Authorization: Bearer TOKEN"8.9 POST /api/classes/:classPublicId/timetable/draft
Purpose
Creates a new draft. With no body, forks the currently-published version (or creates an empty draft if none exists). With fromVersionNo, rebuilds the draft from that archived version instead — resolving each entry against the current schema state, and skipping (never silently dropping, never failing the whole call) any entry whose referenced timeslot or subject assignment no longer matches.
Source Evidence
| Evidence | Path |
|---|---|
| Controller | class-timetable.controller.ts |
| DTO | timetable.dto.ts (CreateDraftDto) |
| Service | class-timetable-draft-write.service.ts (createDraft) |
| Util | class-timetable-draft.util.ts (resolveArchivedEntry, assertSessionCurrent, nextVersionNo) |
Auth and Permissions
- Permission:
Timetables_UPDATE. - Idempotency: Not idempotent — a second call while a draft already exists is refused.
Request
{ "fromVersionNo": 4 }Or an empty body {} to fork the published version.
Response
201, CreateDraftResultDto (§6.13), message "Draft created.".
Side Effects
- Database writes: one
class_timetablesinsert (status: "draft"), plus a bulkclass_timetable_entriesinsert for every entry that resolved. - Locks: both of this class's headers,
FOR UPDATE,ORDER BY id— the same orderpublishlocks in, so the two operations cannot deadlock against each other.
Error Cases
| HTTP Status | Error Code | Condition | Source |
|---|---|---|---|
404 | CLASS_NOT_FOUND | classPublicId does not resolve. | resolveClass |
409 | TIMETABLE_DRAFT_EXISTS | A draft already exists for this class. | pre-check + class_timetables_one_draft_per_class |
409 | TIMETABLE_NO_CURRENT_SESSION | No academic_sessions row is marked current at all. | assertSessionCurrent |
422 | TIMETABLE_SESSION_NOT_CURRENT | This class's session is not the current one. | assertSessionCurrent |
404 | TIMETABLE_VERSION_NOT_FOUND | fromVersionNo does not resolve to an archive of this class. | createDraft |
Edge Cases
- Restoring from an archive whose referenced timeslot has since moved, or whose subject is no longer assigned to the class, skips that entry with a specific
reasonin the response'sskipped[]— the call still succeeds. - Forking a published version with zero entries is valid — the new draft simply has zero entries (publishing it later will then fail with
TIMETABLE_DRAFT_EMPTY).
Example Requests
curl -X POST "$API_URL/api/classes/018f2f3e-.../timetable/draft" \
-H "Authorization: Bearer TOKEN" -H "Content-Type: application/json" -d '{}'8.10 DELETE /api/classes/:classPublicId/timetable/draft
Purpose
Discards the current draft, discarding all its unpublished edits.
Source Evidence
| Evidence | Path |
|---|---|
| Controller | class-timetable.controller.ts |
| Service | class-timetable-draft-write.service.ts (discardDraft) |
Auth and Permissions
- Permission:
Timetables_DELETE. - Idempotency: Not idempotent — a second call 409s.
Response
200, data: null, message "Draft discarded.".
Side Effects
Database write: class_timetables delete (status = 'draft'); every class_timetable_entries row of that header is deleted by ON DELETE cascade.
Error Cases
| HTTP Status | Error Code | Condition |
|---|---|---|
409 | TIMETABLE_DRAFT_MISSING | No draft exists for this class. |
Example Requests
curl -X DELETE "$API_URL/api/classes/018f2f3e-.../timetable/draft" -H "Authorization: Bearer TOKEN"8.11 PUT /api/classes/:classPublicId/timetable/draft/entries/:timeslotId
Purpose
Places or updates a lesson in one timeslot of the current draft. An upsert — safe to retry after a timeout — that also serves as the drag-and-drop endpoint (send classSubjectId alone) and the lesson-details dialog (send the rest alongside it).
Source Evidence
| Evidence | Path |
|---|---|
| Controller | class-timetable.controller.ts |
| DTO | timetable.dto.ts (PutTimetableEntryDto) |
| Service | class-timetable-draft-write.service.ts (putEntry) |
| Util | class-timetable-conflict.util.ts (findConflict) |
Auth and Permissions
- Permission:
Timetables_UPDATE. - Idempotency: Yes, by design — safe to retry.
Request
Minimal (drop-only):
{ "classSubjectId": "018f2f41-..." }Full:
{
"classSubjectId": "018f2f41-...",
"teacherId": "018f2f42-...",
"roomId": 12,
"notes": "Bring lab coats",
"version": 2
}Response
200, TimetableEntryDto (§6.6), message "Entry saved.".
Side Effects
- Database write:
class_timetable_entriesINSERT ... ON CONFLICT (timetable_id, timeslot_id) DO UPDATE. - Read-only conflict lookup (
findConflict) before the write, excluding the class being edited from the search.
Error Cases
| HTTP Status | Error Code | Condition | Source |
|---|---|---|---|
409 | TIMETABLE_DRAFT_MISSING | No draft exists for this class. | putEntry |
404 | TIMESLOT_NOT_FOUND | timeslotId absent, or belongs to a different class. | putEntry |
422 | TIMETABLE_SLOT_NOT_TEACHING | The slot is not slotType: "period". | pre-check |
422 | TIMETABLE_SUBJECT_NOT_IN_CLASS | classSubjectId does not belong to this class. | pre-check + class_timetable_entries_class_subject_fk |
404 | STAFF_NOT_FOUND | Supplied teacherId does not resolve. | pre-check |
404 | ROOM_NOT_FOUND | Supplied roomId does not resolve. | pre-check |
409 | TIMETABLE_TEACHER_CONFLICT | The teacher is already committed to a published, overlapping lesson elsewhere in this session. | findConflict, backstopped by class_timetable_entries_teacher_free |
409 | TIMETABLE_ROOM_CONFLICT | The room is already assigned during an overlapping published lesson. | findConflict, backstopped by class_timetable_entries_room_free |
409 | TIMETABLE_VERSION_CONFLICT | Caller supplied version, and the row has since changed. | setWhere on the upsert |
422 | TIMETABLE_NOTES_INVALID | Notes blank-padded, control characters, or over 500 chars. | class_timetable_entries_notes_format |
Edge Cases
- Sending only
classSubjectIdnever clears an existingteacherId/roomId/noteson that slot — only fields present in the body are overwritten. - The conflict check excludes the class currently being edited: publishing deletes that class's own published rows before promoting, so its own published entries can never be the thing a draft edit collides with.
versionomitted entirely (a fresh placement) is always accepted; supplied and stale, it is rejected even though the write would otherwise have succeeded.
Example Requests
curl -X PUT "$API_URL/api/classes/018f2f3e-.../timetable/draft/entries/018f2f40-..." \
-H "Authorization: Bearer TOKEN" -H "Content-Type: application/json" \
-d '{"classSubjectId":"018f2f41-..."}'8.12 DELETE /api/classes/:classPublicId/timetable/draft/entries/:timeslotId
Purpose
Clears a slot in the draft. Idempotent — clearing an already-empty slot is not an error.
Source Evidence
| Evidence | Path |
|---|---|
| Controller | class-timetable.controller.ts |
| Service | class-timetable-draft-write.service.ts (removeEntry) |
Auth and Permissions
- Permission:
Timetables_UPDATE. - Idempotency: Yes.
Response
200, data: null, message "Entry cleared.".
Error Cases
| HTTP Status | Error Code | Condition |
|---|---|---|
409 | TIMETABLE_DRAFT_MISSING | No draft exists for this class. |
Example Requests
curl -X DELETE "$API_URL/api/classes/018f2f3e-.../timetable/draft/entries/018f2f40-..." \
-H "Authorization: Bearer TOKEN"8.13 GET /api/classes/:classPublicId/timetable/slots/:timeslotId/availability
Purpose
Returns which teachers and rooms are free for a given slot's day and minute range — the complement of the conflict check run on every drop, surfaced proactively so the builder UI can grey out or highlight options before the operator even attempts a placement.
Source Evidence
| Evidence | Path |
|---|---|
| Controller | class-timetable.controller.ts |
| Service | class-timetable-read.service.ts (availability) |
Auth and Permissions
- Permission:
Timetables_READ.
Response
200, SlotAvailabilityDto (§6.16), message "Availability fetched.".
Error Cases
| HTTP Status | Error Code | Condition |
|---|---|---|
404 | CLASS_NOT_FOUND | classPublicId does not resolve. |
404 | TIMESLOT_NOT_FOUND | timeslotId absent, or belongs to a different class. |
Edge Cases
Computed among published entries only, scoped to the class's academic session — a draft's own conflicting placements never make a teacher/room appear unavailable here.
Example Requests
curl -X GET "$API_URL/api/classes/018f2f3e-.../timetable/slots/018f2f40-.../availability" \
-H "Authorization: Bearer TOKEN"8.14 POST /api/classes/:classPublicId/timetable/publish
Purpose
Publishes the current draft: archives the superseded published version (if any), then promotes the draft. This is the action that makes a schedule visible to every pupil and parent in the class.
Source Evidence
| Evidence | Path |
|---|---|
| Controller | class-timetable.controller.ts |
| Service | class-timetable-publish.service.ts (publish) |
| Util | class-timetable-conflict.util.ts, class-timetable-draft.util.ts |
| Tests | timetable-write.integration.spec.ts |
Auth and Permissions
- Permission:
TimetablePublish_UPDATE(superadmin-only in practice — see §5). - Idempotency: Idempotent in the useful sense — retried with no draft but a published version already present, returns that version rather than failing.
Request
No body. HttpCode(200) overrides Nest's default 201 for a POST.
Response
200, ClassTimetableDto (§6.8, status: "published"), message "Timetable published.".
Side Effects
- Database writes: optional archive insert + published-header delete (if a published version existed), then the draft header's
statusupdate to"published". - Cascading write: the header's
statuschange cascades to every entry via the composite header FK, which is the exact moment the two EXCLUDE constraints are checked. - No cache, queue, or realtime side effect.
Error Cases
| HTTP Status | Error Code | Condition | Source |
|---|---|---|---|
409 | TIMETABLE_DRAFT_MISSING | No draft and no published version exists. | publish |
422 | TIMETABLE_DRAFT_EMPTY | The draft has zero entries. | pre-check |
409 | TIMETABLE_NO_CURRENT_SESSION | No session is marked current. | assertSessionCurrent |
422 | TIMETABLE_SESSION_NOT_CURRENT | This class's session is not current. | assertSessionCurrent |
422 | TIMETABLE_STALE_REFERENCES | A draft entry's teacher/room/subject has decayed since assignment. | computeStaleReferences; response details.staleReferences |
409 | TIMETABLE_TEACHER_CONFLICT | A cross-class teacher clash was found among the draft's entries. | class_timetable_entries_teacher_free, named via findConflict; response details.conflictsWith |
409 | TIMETABLE_ROOM_CONFLICT | A cross-class room clash. | class_timetable_entries_room_free; response details.conflictsWith |
409 | TIMETABLE_CONCURRENT_UPDATE | A 40001/40P01 deadlock survived one retry. | withDeadlockRetry |
Edge Cases
- Retrying a publish that already succeeded (no draft left, published version present) returns that published version with a
200, not an error. - A conflict found during promotion is investigated before the surrounding transaction rolls back, so the named
conflictsWithrow is guaranteed still accurate at the moment it is reported.
Example Requests
curl -X POST "$API_URL/api/classes/018f2f3e-.../timetable/publish" -H "Authorization: Bearer TOKEN"8.15 POST /api/classes/:classPublicId/timetable/unpublish
Purpose
Removes the currently published timetable after archiving it, returning the class to having no live schedule.
Source Evidence
| Evidence | Path |
|---|---|
| Controller | class-timetable.controller.ts |
| Service | class-timetable-publish.service.ts (unpublish) |
Auth and Permissions
- Permission:
TimetablePublish_UPDATE. - Idempotency: Not idempotent — a second call 404s.
Response
200, data: null, message "Timetable unpublished.".
Side Effects
Database writes: archive insert, then delete of the published header (entries cascade away).
Error Cases
| HTTP Status | Error Code | Condition |
|---|---|---|
404 | TIMETABLE_NOT_FOUND | No published timetable exists for this class. |
Example Requests
curl -X POST "$API_URL/api/classes/018f2f3e-.../timetable/unpublish" -H "Authorization: Bearer TOKEN"8.16 GET /api/mobile/teacher/schedule
Purpose
Returns the calling teacher's own published weekly teaching schedule for the current academic session.
Source Evidence
| Evidence | Path |
|---|---|
| Controller | apps/api/src/modules/mobile/teacher/teacher-schedule-portal.controller.ts |
| Query | apps/api/src/modules/mobile/shared/portal-schedule.query.ts |
| DTO | apps/api/src/modules/mobile/shared/portal-schedule.dto.ts |
Auth and Permissions
- Auth: JWT.
- Guard chain:
JwtAuthGuard,RoleGuard. - Permission: None — audience asserted by
assertTeacherStaffRow, which resolves and returns the caller's own livestaff.id(never a boolean), becauseclasses.class_teacher_idandclass_timetable_entries.teacher_idare both nullable and a predicate built from anundefinedlocal would otherwise match the whole school. - Guest support: No.
Response
200, TeacherScheduleDto, message "Schedule fetched.".
{
"success": true,
"message": "Schedule fetched.",
"data": {
"academicSessionName": "2026-27",
"entries": [
{
"dayOfWeek": 1,
"startMinute": 480,
"endMinute": 540,
"slotType": "period",
"label": null,
"subjectName": "Mathematics",
"subjectCode": "MATH",
"teacherName": "Ram Sharma",
"roomName": "Room 204",
"className": "Grade 5 A",
"classPublicId": "0198f2c4-6b1e-7c3a-9d52-3a4f1b8e7a10",
"subjectId": "0198f2c4-6b1e-7c3a-9d52-3a4f1b8e7a11",
"classSubjectId": "0198f2c4-6b1e-7c3a-9d52-3a4f1b8e7a12"
}
]
}
}Side Effects
None — read-only.
Error Cases
No module-specific error code is raised by this route; an audience-assertion failure returns the shared portal 403.
Edge Cases
- No current academic session: returns
academicSessionName: null,entries: []— not an error. - A teacher with no assigned periods returns
entries: []. - Entries include
classNamebecause one teacher's week spans multiple classes. - Every entry is a published lesson, so
classPublicId,subjectIdandclassSubjectIdare always present; each is a public uuid, never an integer key.
Example Requests
curl -X GET "$API_URL/api/mobile/teacher/schedule" -H "Authorization: Bearer TOKEN"8.17 GET /api/mobile/student/schedule
Purpose
Returns the calling pupil's own class's published weekly schedule for the current academic session.
Source Evidence
| Evidence | Path |
|---|---|
| Controller | apps/api/src/modules/mobile/student/student-schedule-portal.controller.ts |
| Query | apps/api/src/modules/mobile/shared/portal-schedule.query.ts |
Auth and Permissions
- Permission: None —
assertPortalAudience(actor, "student")only confirms the session is a pupil session, never which pupil; the row scope is theWHEREclause built from a live enrollment (students.deleted_at IS NULL,students.record_status = 'active',studentClassEnrollments.status = 'active', scoped to the current academic session). - Guest support: No.
Response
200, PortalScheduleDto, message "Schedule fetched." — same entry shape as the teacher route minus className (every row already belongs to the pupil's one enrolled class).
Error Cases
None module-specific.
Edge Cases
- No current session, or no live enrollment:
entries: []. - A pupil holding an unclosed prior-year enrollment alongside the current year's is filtered to the current session only — without that filter this route would interleave two classes' weeks.
Example Requests
curl -X GET "$API_URL/api/mobile/student/schedule" -H "Authorization: Bearer TOKEN"8.18 GET /api/mobile/guardian/children/:id/schedule
Purpose
Returns one of the calling guardian's children's published weekly class schedule for the current academic session.
Source Evidence
| Evidence | Path |
|---|---|
| Controller | apps/api/src/modules/mobile/guardian/guardian-schedule-portal.controller.ts |
| Query | apps/api/src/modules/mobile/shared/portal-schedule.query.ts |
| Service | GuardianPortalService.resolveOwnChildId |
Auth and Permissions
- Permission: None —
assertPortalAudience(actor, "guardian")confirms audience only;resolveOwnChildIdis the actual authorization decision and runs before any schedule row is read. - Guest support: No.
Request
| Part | Required | Details |
|---|---|---|
| Params | Yes | id — the child's students.id. |
Response
200, PortalScheduleDto.
Error Cases
| HTTP Status | Error Code | Condition | User-Facing Meaning |
|---|---|---|---|
404 | (from resolveOwnChildId, not a TIMETABLE_* code) | id does not resolve to one of the caller's own children. | Not 403 — a 403 would confirm the row exists, turning the id space into an enumeration oracle against a roll of children. |
Edge Cases
- The ward check runs first and can 404 before any timetable row is touched; only after it succeeds can an empty week ever be returned. Reversing that order would not itself leak data (an empty week from a foreign child id is indistinguishable from an own child with no timetable), but it would mean authorization ran after the query rather than before it.
- No current session, or the child has no live enrollment:
entries: [],200.
Example Requests
curl -X GET "$API_URL/api/mobile/guardian/children/018f2f43-.../schedule" -H "Authorization: Bearer TOKEN"8.19 GET /api/mobile/student/schedule/today
Purpose
The periods of the caller's own class for today, each marked finished, in progress or upcoming against the server clock in the school's timezone, with the next moment the day changes. Feeds the pupil dashboard's "today" card.
Source Evidence
| Evidence | Path |
|---|---|
| Controller | apps/api/src/modules/mobile/student/student-schedule-portal.controller.ts (myToday) |
| Service | apps/api/src/modules/mobile/shared/portal-today.service.ts |
| Period state | apps/api/src/modules/mobile/shared/portal-today.ts |
| Queries | apps/api/src/modules/mobile/shared/portal-today.query.ts |
| DTO | apps/api/src/modules/mobile/shared/portal-today.dto.ts |
| Cache helper | apps/api/src/common/utils/cache-race.util.ts |
Auth and Permissions
- Guard chain:
JwtAuthGuard,RoleGuard. - Permission: None —
assertPortalAudience(actor, "student"). The handler keyStudentSchedulePortalController.myTodayis inNO_PERMISSION_ADMIN_HANDLERS. - The route takes no id. The pupil is resolved from the token (
students.user_id = actor.id,deleted_atonly).
Request
No params, query or body.
Response
{
"message": "Schedule fetched.",
"data": {
"serverTime": "2026-10-06T04:30:12.000Z",
"timezone": "Asia/Kathmandu",
"date": "2026-10-06",
"bsDate": { "year": 2083, "month": 6, "day": 20 },
"dayOfWeek": 2,
"academicSessionName": "2082/83",
"calendar": {
"status": "operating",
"classification": "school_day",
"eventName": null,
"isOperatingDay": true
},
"periods": [
{
"id": "540-585-0",
"state": "finished",
"startMinute": 540,
"endMinute": 585,
"startsAt": "2026-10-06T03:15:00.000Z",
"endsAt": "2026-10-06T04:00:00.000Z",
"slotType": "period",
"label": null,
"subjectName": "Mathematics",
"subjectCode": "MATH",
"teacherName": "Ram Sharma",
"roomName": "Room 204"
}
],
"currentPeriodId": null,
"nextPeriodId": null,
"nextTransitionAt": null,
"summary": { "total": 1, "finished": 1, "remaining": 0 }
},
"errorCode": null
}Fields are described under Portal DTOs in §6.18.
Calendar status
calendar.status is decided in a fixed order and the first match wins:
| Order | status | Condition |
|---|---|---|
| 1 | no_session | No academic session is flagged current. |
| 2 | outside_session | Today is before the session's start_date or on or after its end_date (the range is half-open). |
| 3 | not_set | The calendar has no row for today. This is not a holiday: the calendar has not decided. |
| 4 | non_operating | The calendar row says attendance is not expected (a weekend or holiday classification). |
| 5 | no_class | An operating day, but the pupil has no active enrolment in the current session, or the pupil's record is deleted or not active. classification, eventName and isOperatingDay still describe the day. |
| 6 | operating | An operating day and an enrolled class. periods holds that class's slots for the weekday. |
Every status other than operating returns an empty periods list and a zero summary. An operating day for a class with no published slots on this weekday also returns an empty list.
Side Effects
- Database reads: the school clock, the current session, the calendar day, the pupil's active enrolment, and the class's published timetable entries merged with its breaks, lunches and activities.
- Cache: the clock-independent base is cached for 60 seconds; see Caching below. No writes.
Caching
- Key:
portal:today:with the pupil's student id and the school-local date. The cached value is the session name, the calendar block and the day's slot rows. Period states,serverTimeandnextTransitionAtare recomputed on every request from the cached rows, so they are never stale. - TTL 60 seconds. Nothing invalidates the key: a timetable publish or unpublish, a calendar edit, an enrolment change or a pupil status change is visible within 60 seconds.
- The read goes through
cacheGetFast: the Redis read races a 250 ms budget, and a timeout, an error or an outage is treated as a miss, so the route falls back to the database. The write is fire-and-forget (cacheSetFast). - The key is reachable only after identity is resolved, so the pupil route and the guardian route share one entry per pupil and day.
Error Cases
| HTTP Status | Error Code | Condition |
|---|---|---|
403 | PERMISSION_INSUFFICIENT | Active role's scopeKind is not student. |
404 | STUDENT_NOT_FOUND | The caller has no live students row (deleted_at is null is the only test here). |
Edge Cases
- An inactive or deleted-profile pupil who still holds a
studentsrow passes the identity check and receives200withcalendar.status = "no_class"on an operating day. - Slot instants are built from the school-local date and minute, not from host-local time, so the response does not depend on the server's timezone.
currentPeriodIdandnextPeriodIdrefer to any slot type, including breaks;summarycounts onlyperiodslots.bsDateisnullwhen the date is outside the converter's range; the response is still200.
Example Requests
curl -X GET "$API_URL/api/mobile/student/schedule/today" \
-H "Authorization: Bearer TOKEN"8.20 GET /api/mobile/guardian/children/:id/schedule/today
Purpose
One ward's periods for today, with the same payload as §8.19.
Source Evidence
| Evidence | Path |
|---|---|
| Controller | apps/api/src/modules/mobile/guardian/guardian-schedule-portal.controller.ts (childToday) |
| Ward check | GuardianPortalService.resolveOwnChildId |
| Service | apps/api/src/modules/mobile/shared/portal-today.service.ts |
Auth and Permissions
- Guard chain:
JwtAuthGuard,RoleGuard. - Permission: None —
assertPortalAudience(actor, "guardian"), thenresolveOwnChildIdas the authorization decision. The handler keyGuardianSchedulePortalController.childTodayis inNO_PERMISSION_ADMIN_HANDLERS. - The ward check runs before the cache or any schedule row is read.
Request
Params: id — the child's student id (uuid; a non-UUID value is a 400 from ParseUUIDPipe). No query or body.
Response
200, PupilTodayScheduleDto — identical to §8.19, with message: "Schedule fetched.". Calendar status, caching and edge cases are those of §8.19.
Error Cases
| HTTP Status | Error Code | Condition |
|---|---|---|
403 | PERMISSION_INSUFFICIENT | Active role's scopeKind is not guardian. |
404 | STUDENT_NOT_FOUND | id is not one of the caller's own children, or does not exist. Both answer identically. |
Example Requests
curl -X GET "$API_URL/api/mobile/guardian/children/018f2f43-.../schedule/today" \
-H "Authorization: Bearer TOKEN"8.21 GET /api/mobile/teacher/schedule/today
Purpose
The caller's own periods for today, each marked finished, in progress or upcoming against the server clock in the school's timezone, with the next moment the day changes. Feeds the teacher dashboard's "today" card.
Source Evidence
| Evidence | Path |
|---|---|
| Controller | apps/api/src/modules/mobile/teacher/teacher-today-portal.controller.ts (getToday) |
| Service | apps/api/src/modules/mobile/teacher/teacher-today-portal.service.ts |
| Audience check | assertTeacherStaffRow in apps/api/src/modules/mobile/shared/portal-audience.ts |
Auth and Permissions
- Guard chain:
JwtAuthGuard,RoleGuard. - Permission: None —
assertTeacherStaffRowrequires the active role namedteacherand resolves the caller's livestaffrow. The handler keyTeacherTodayPortalController.getTodayis inNO_PERMISSION_ADMIN_HANDLERS. - The route takes no id; the teacher is resolved from the token.
Request
No query, params or body.
Response
200, TeacherTodayScheduleDto, message: "Today's schedule fetched.": serverTime, timezone, date, bsDate, dayOfWeek, calendar (classification, eventName, isOperatingDay), periods, currentPeriodId, nextPeriodId, nextTransitionAt, summary (total, finished, remaining). Each period carries kind (class, exam or exam_invigilation), state (finished, in_progress or upcoming), start and end as minutes and instants, className, subjectName, room, and the public uuids classPublicId, subjectId and classSubjectId (always present, for every kind). Periods are ordered by start time; finished periods are kept.
The periods come from three sources that never suppress one another: the caller's published timetable entries for today, exam routine entries for class-subjects the caller currently teaches, and exam invigilation duties. Exam duties are returned on a non-operating day; isOperatingDay is informational. An exam_invigilation period names the class being invigilated, which the caller may neither teach nor be class teacher of; opening that class's roster through GET /api/mobile/teacher/classes/:publicId/roster then answers 404, so a client must not assume every period's classPublicId is openable.
Error Cases
| HTTP Status | Error Code | Condition |
|---|---|---|
403 | PERMISSION_INSUFFICIENT | No active role, a guardian or pupil session, or no live staff row. |
Example Requests
curl -X GET "$API_URL/api/mobile/teacher/schedule/today" \
-H "Authorization: Bearer TOKEN"9. Flow Diagrams
9.1 Route Ownership
9.2 Request Sequence — Entry Upsert
9.3 Error Branch — putEntry
9.4 Auth and Permission Flow — Admin vs. Portal
10. Pagination, Sorting, Filtering, and Search
No endpoint in this module paginates. GET .../timeslots always returns all 7 days; GET .../timetable always returns the full grid for one version; GET .../timetable/versions returns the live version (if any) plus every archived version for the class (bounded naturally by how many times the class has published/unpublished); the weekly portal routes return one week's entries with no upper bound beyond what a week can hold, and the schedule/today routes return one day's. There is no page/pageSize/cursor parameter anywhere in this module, no sort parameter (ordering is always dayOfWeek then startMinute, which is total because overlapping rows are unrepresentable), and no search/filter parameter.
11. Caching, Jobs, and External Integrations
| Integration | Used? | Details | Source |
|---|---|---|---|
| Redis cache | Only the pupil and guardian schedule/today routes | portal:today: keys, 60-second TTL, one entry per pupil and school-local date, holding the session name, calendar block and the day's slots; period states are recomputed per request. Reads use cacheGetFast (250 ms budget, a slow or unavailable Redis is a miss); writes are fire-and-forget. Nothing invalidates the key, so a publish, unpublish or calendar edit is visible within 60 seconds. The admin routes, the three weekly schedule routes and the teacher schedule/today route read the database directly. | mobile/shared/portal-today.service.ts; common/utils/cache-race.util.ts |
| BullMQ | No | No queue, producer, or processor exists for this module. | — |
| Realtime (Socket.IO) | No | No gateway or emitted event exists for this module. | — |
| External API | No | — | — |
13. Mandatory Deep API Documentation Pack
13.1 Route-by-Route Completeness Matrix
| Route | Controller Method | DTOs | Service Method | Guards | Permissions | Cache | Jobs | DB Touches | Errors | Tests | Documented? |
|---|---|---|---|---|---|---|---|---|---|---|---|
GET /classes/:id/timeslots | ClassTimeslotsController.list | ClassTimeslotsDto | ClassTimeslotsReadService.listByClass | Jwt, Role | Timetables_READ | N/A | N/A | class_timeslots (read) | CLASS_NOT_FOUND | timeslots.integration.spec.ts | Yes |
POST /classes/:id/timeslots | .create | CreateTimeslotDto→TimeslotDto | ClassTimeslotsWriteService.create | Jwt, Role | Timetables_CREATE | N/A | N/A | class_timeslots (insert) | CLASS_NOT_FOUND, TIMESLOT_OVERLAPS_EXISTING, TIMESLOT_TIMES_INVALID, TIMESLOT_DAY_INVALID, TIMESLOT_LABEL_INVALID | timeslots.integration.spec.ts | Yes |
PATCH /classes/:id/timeslots/:timeslotId | .update | UpdateTimeslotDto→TimeslotDto | ClassTimeslotsWriteService.update | Jwt, Role | Timetables_UPDATE + TimetablePublish_UPDATE | N/A | N/A | class_timeslots (update), cascades to class_timetable_entries | TIMESLOT_NOT_FOUND, TIMESLOT_VERSION_CONFLICT, TIMESLOT_OVERLAPS_EXISTING, TIMESLOT_TIMES_INVALID, TIMESLOT_DAY_INVALID, TIMESLOT_LABEL_INVALID, TIMESLOT_TYPE_CHANGE_BLOCKED | timeslots.integration.spec.ts | Yes |
DELETE /classes/:id/timeslots/:timeslotId | .remove | — | ClassTimeslotsWriteService.remove | Jwt, Role | Timetables_DELETE + TimetablePublish_UPDATE | N/A | N/A | class_timeslots (delete) | TIMESLOT_NOT_FOUND, TIMESLOT_IN_USE | timeslots.integration.spec.ts | Yes |
POST /classes/:id/timeslots/copy | .copy | CopyTimeslotsDto→ClassTimeslotsDto | ClassTimeslotsWriteService.copy | Jwt, Role | Timetables_CREATE + Timetables_DELETE + TimetablePublish_UPDATE | N/A | N/A | class_timeslots (bulk delete/insert) | TIMESLOT_COPY_SOURCE_IN_TARGET, TIMESLOT_COPY_SOURCE_EMPTY, TIMESLOT_IN_USE, TIMESLOT_OVERLAPS_EXISTING | timeslots.integration.spec.ts | Yes |
GET /classes/:id/timetable | .read | ReadTimetableQueryDto→ClassTimetableDto | ClassTimetableReadService.read | Jwt, Role | Timetables_READ | N/A | N/A | class_timetables, class_timeslots, class_timetable_entries (+joins) | CLASS_NOT_FOUND | timetable-write.integration.spec.ts | Yes |
GET /classes/:id/timetable/versions | .listVersions | TimetableVersionSummaryDto[] | ClassTimetableReadService.listVersions | Jwt, Role | Timetables_READ | N/A | N/A | class_timetables, class_timetable_archives, users (in one REPEATABLE READ transaction) | CLASS_NOT_FOUND | timetable-write.integration.spec.ts | Yes |
GET /classes/:id/timetable/versions/:versionNo | .readVersion | ArchivedTimetableDto | ClassTimetableReadService.readVersion | Jwt, Role | Timetables_READ | N/A | N/A | class_timetables, class_timetable_archives, users (in one REPEATABLE READ transaction) | CLASS_NOT_FOUND, TIMETABLE_VERSION_NOT_FOUND | timetable-write.integration.spec.ts | Yes |
POST /classes/:id/timetable/draft | .createDraft | CreateDraftDto→CreateDraftResultDto | ClassTimetableDraftWriteService.createDraft | Jwt, Role | Timetables_UPDATE | N/A | N/A | class_timetables (insert), class_timetable_entries (bulk insert), class_timetable_archives (read, if restoring) | CLASS_NOT_FOUND, TIMETABLE_DRAFT_EXISTS, TIMETABLE_NO_CURRENT_SESSION, TIMETABLE_SESSION_NOT_CURRENT, TIMETABLE_VERSION_NOT_FOUND | timetable-write.integration.spec.ts | Yes |
DELETE /classes/:id/timetable/draft | .discardDraft | — | ClassTimetableDraftWriteService.discardDraft | Jwt, Role | Timetables_DELETE | N/A | N/A | class_timetables (delete), cascade to entries | TIMETABLE_DRAFT_MISSING | timetable-write.integration.spec.ts | Yes |
PUT /classes/:id/timetable/draft/entries/:timeslotId | .putEntry | PutTimetableEntryDto→TimetableEntryDto | ClassTimetableDraftWriteService.putEntry | Jwt, Role | Timetables_UPDATE | N/A | N/A | class_timetable_entries (upsert) | 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, TIMETABLE_NOTES_INVALID | timetable-write.integration.spec.ts | Yes |
DELETE /classes/:id/timetable/draft/entries/:timeslotId | .removeEntry | — | ClassTimetableDraftWriteService.removeEntry | Jwt, Role | Timetables_UPDATE | N/A | N/A | class_timetable_entries (delete) | TIMETABLE_DRAFT_MISSING | timetable-write.integration.spec.ts | Yes |
GET /classes/:id/timetable/slots/:timeslotId/availability | .availability | SlotAvailabilityDto | ClassTimetableReadService.availability | Jwt, Role | Timetables_READ | N/A | N/A | class_timeslots, class_timetable_entries, staff, rooms, designations | CLASS_NOT_FOUND, TIMESLOT_NOT_FOUND | timetable-write.integration.spec.ts | Yes |
POST /classes/:id/timetable/publish | .publish | ClassTimetableDto | ClassTimetablePublishService.publish | Jwt, Role | TimetablePublish_UPDATE | N/A | N/A | class_timetables, class_timetable_archives, class_timetable_entries (cascade) | 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 | timetable-write.integration.spec.ts | Yes |
POST /classes/:id/timetable/unpublish | .unpublish | — | ClassTimetablePublishService.unpublish | Jwt, Role | TimetablePublish_UPDATE | N/A | N/A | class_timetables (delete), class_timetable_archives (insert) | TIMETABLE_NOT_FOUND | timetable-write.integration.spec.ts | Yes |
GET /teacher/schedule | TeacherSchedulePortalController.mySchedule | TeacherScheduleDto | readPublishedSchedule | Jwt, Role | None (audience-asserted) | N/A | N/A | class_timetable_entries (+joins), academic_sessions | (none module-specific) | — | Yes |
GET /student/schedule | StudentSchedulePortalController.mySchedule | PortalScheduleDto | readPublishedSchedule | Jwt, Role | None (audience-asserted) | N/A | N/A | class_timetable_entries (+joins), student_class_enrollments, students | (none module-specific) | — | Yes |
GET /mobile/student/schedule/today | StudentSchedulePortalController.myToday | PupilTodayScheduleDto | PortalTodayService.resolveOwnStudentId, .forStudent | Jwt, Role | None (audience-asserted) | portal:today: (60 s) | N/A | students, academic_sessions, academic_calendar_days, student_class_enrollments, class_timetable_entries (+joins), class_timeslots | STUDENT_NOT_FOUND | portal-today specs | Yes |
GET /guardian/children/:id/schedule | GuardianSchedulePortalController.childSchedule | PortalScheduleDto | readPublishedSchedule | Jwt, Role | None (audience-asserted) | N/A | N/A | class_timetable_entries (+joins), student_class_enrollments, plus GuardianPortalService.resolveOwnChildId's own tables | (none module-specific; a ward-ownership failure 404s) | — | Yes |
GET /mobile/teacher/schedule/today | TeacherTodayPortalController.getToday | TeacherTodayScheduleDto | TeacherTodayPortalService.getToday | Jwt, Role | None (audience-asserted) | N/A | N/A | class_timetable_entries, exam_routine_entries, exam_invigilators (+joins), academic_calendar_days | PERMISSION_INSUFFICIENT | N/A | Yes |
GET /mobile/guardian/children/:id/schedule/today | GuardianSchedulePortalController.childToday | PupilTodayScheduleDto | GuardianPortalService.resolveOwnChildId, PortalTodayService.forStudent | Jwt, Role | None (audience-asserted) | portal:today: (60 s) | N/A | as above, plus the ward check's tables | STUDENT_NOT_FOUND | portal-today specs | Yes |
13.2 Request/Response Exhaustiveness
Covered per-endpoint in §8: every mutation route includes a minimal and full request example where applicable, every route documents its success envelope, empty-list/empty-grid behavior is documented for the list-shaped and grid-shaped reads, and every validation, domain, and auth/permission error is enumerated per endpoint with its exact code and condition.
13.3 API Diagram Pack
See §9 for the route-ownership graph, the entry-upsert sequence diagram, the putEntry error decision tree, and the auth/permission flow. A cache-flow diagram and an async/job-flow diagram are omitted deliberately — neither a cache nor a queue exists anywhere in this module (§11).
13.4 Consumer Integration Notes
| Consumer | Required Knowledge | Failure Handling | Contract Stability |
|---|---|---|---|
| Admin panel (Timeslot Manager) | version is mandatory on every PATCH; update/remove/copy need two or three permission codes simultaneously. | Display TIMESLOT_VERSION_CONFLICT as "someone else changed this, reload"; TIMESLOT_IN_USE as "clear the lesson first". | Stable — route names and handler contracts are treated as frozen. |
| Admin panel (Timetable Builder) | The draft/published resolution order on GET .../timetable; that staleReferences only appears on published reads; that publish can fail with a named conflict carrying details.conflictsWith. | Render TIMETABLE_TEACHER_CONFLICT/TIMETABLE_ROOM_CONFLICT using the exact conflictsWith payload rather than a generic message; render TIMETABLE_STALE_REFERENCES's details.staleReferences as an actionable per-entry list. | Stable. |
| Mobile apps (teacher/student/guardian) | All three routes return entries: [] on "nothing scheduled" — never treat an empty array as an error state. The guardian route 404s on a foreign child id; a 403 is never returned for that case. | Render an empty week as "no schedule published yet", not as a failure. | Stable. |
| QA | Every entries-side write path shares one constraint-translation map (translateTimetableWriteError); every timeslot write path shares another (CONSTRAINT_RULES in class-timeslots-write.service.ts). Reproducing a specific error usually means violating the named constraint directly via two concurrent requests, not via a single malformed payload. | — | — |
| Internal services | None of these routes are called by any other backend module or job. | — | — |
13.5 API Tradeoffs and Rationale
| Decision | Chosen Behavior | Alternatives Considered | Why This Tradeoff | Risk | Mitigation |
|---|---|---|---|---|---|
| No pagination anywhere in this module | Every read returns its full, natural-sized result set | Cursor/page pagination on versions or the grid | A class's timeslots, one version's entries, and one week's schedule are all naturally bounded (at most a few dozen rows); pagination would add client complexity for no real benefit. | An unusually large archive history could make GET .../versions slow. | Revisit if archive counts per class grow large (see backend doc §16.8). |
GET .../timetable never 404s for a missing explicit ?status= | Empty grid, echoed status | 404 TIMETABLE_NOT_FOUND | An earlier version of this route did exactly that and produced a permanent error-retry loop in the admin panel for the ordinary case of an unpublished class. | None currently known. | Covered by an integration test asserting the empty-grid shape. |
Portal routes return entries: [] instead of 404 when nothing is scheduled | Empty array, 200 | 404 SCHEDULE_NOT_FOUND | "Nothing is scheduled" is a normal, common state (a new class, a between-enrollment pupil, a teacher with no periods); treating it as an error would make every fresh mobile install hit an error screen. | None. | — |
| Guardian route 404s (not 403) on a foreign child id | 404 | 403 Forbidden | A 403 confirms the row exists, turning the id space into an enumeration oracle. | None additional — this is itself the mitigation. | — |
Entry upsert (PUT) instead of separate create/update verbs | One idempotent PUT | Separate POST (create) and PATCH (update) | The drop-then-dialog UX flow naturally produces two writes to the same slot in quick succession; a single idempotent upsert avoids the client having to track whether the slot was already occupied. | A caller cannot distinguish "created" from "updated" from the response alone (both return 200/the same DTO). | Acceptable — the builder UI does not need that distinction. |
version optional on entry upsert, required on timeslot update | Asymmetric requirement | Require version on both | The entry route serves both create (no prior version to send) and update; the timeslot route only ever updates an existing row. | None. | — |
13.6 API Change Impact
| Change | Affected Consumers | Backend Impact | Data Impact | Migration Needed? | Compatibility Plan |
|---|---|---|---|---|---|
Adding a new slotType value | Admin panel, all three portal apps | New enum value added to timeslot_type; every place matching on the six current values needs review | Existing rows unaffected | Yes — ALTER TYPE ... ADD VALUE | Coordinate a mobile app release before any slot is created with the new type, since older clients render an unrecognized slotType as an unstyled fallback. |
Renaming a TimetableEntryDto/PortalScheduleEntryDto field | Admin panel, all three portal apps | DTO and mapper update | None | No | Version the DTO or add the new field alongside the old one for a deprecation window; this module currently has no versioning scheme for its DTOs. |
| Adding a fourth portal audience | New mobile controller | New controller + NO_PERMISSION_ADMIN_HANDLERS/route-permissions.spec.ts entries | None | No | Must reuse readPublishedSchedule/portal-schedule.dto.ts rather than re-deriving the query, per the shared-query rationale in the backend doc. |
14. Zero-Omission API Checklist
- Every controller route is documented (18 routes across 5 controllers).
- Every parent route prefix and runtime URL is documented.
- Every DTO field, nested field, enum, default, and validator is documented.
- Every response field, nullable field, and generated field is documented; portal DTOs' deliberately-omitted fields are documented and why.
- Every auth, guard, permission, and audience-assertion branch is documented.
- Every success, validation, auth, permission, not-found, conflict, and server-error branch is documented.
- Every database read/write and cascade is documented; cache/queue/realtime/notification/audit are documented as absent where absent.
- Every route has examples for minimal and/or full requests, success responses, and representative failures.
- Route ownership, sequence, error-decision, and auth-flow diagrams are included.
- Every tradeoff and compatibility risk is documented.
- This doc links to backend and features/flows.
15. Integration Checklist
- Every route from controllers is documented.
- Every DTO field is documented.
- Every enum value is documented.
- Every response envelope is documented.
- Every error code is documented.
- Every auth guard and permission is documented.
- Cache/queue/realtime/external calls are documented as absent.
- Every diagram matches the current code.
- This doc links to backend and features/flows.
See Also
- Backend doc:
/docs/developer/timetable/backend - Features and flows doc:
/docs/developer/timetable/feature