Classes Features and Flows
Complete feature list, actor journeys, state flows, business rules, edge cases, and diagrams for grades, sections, rooms, classes, and student class enrolments.
Classes Features and Flows
1. Documentation Evidence
| Source Type | Files or Docs | What Was Extracted |
|---|---|---|
| Backend doc | /docs/developer/classes/backend and the services it documents (grades.service.ts, sections.service.ts, rooms.service.ts, classes-read.service.ts, classes-write.service.ts, class-roster.service.ts, class-enrollments.service.ts, class-occupancy.service.ts) | Business behavior, locking, capacity math, and cache/audit side effects. |
| API doc | /docs/developer/classes/api and the controllers it documents | Route surface, actors, permissions, and response-visible behavior. |
| Schema | packages/db/src/schema/school/classes.ts, migration packages/db/src/migrations/0011_class_module.sql | Constraints that drive the edge cases and error branches below. |
| Constraint probe | packages/db/src/scripts/probe-class-module-constraints.sql | Confirmed accept/reject behavior for every unique index and CHECK constraint. |
2. Feature Summary
| Field | Value |
|---|---|
| Module | Classes (grades, sections, rooms, classes, student class enrolments) |
| Submodule | Grades (read-only, no permission module of its own), Sections, Rooms — each independently permissioned — plus Classes and the enrolment sub-resources, which sit under the Students permission module rather than a permission of their own |
| Primary user value | Lets the office define what the school actually runs in a year — the grades and sections it teaches, the physical rooms it has, and the concrete classes (a grade, a section and a shift, in one academic session) those combine into — and then lets it place, move, and withdraw pupils from those classes with the capacity, room and teacher conflicts a real timetable creates all caught before they become an operational mess. |
| Actors | Admin for every write in the module. Staff and teachers hold read access to grades, sections, rooms and classes (populating forms and dropdowns), plus Students_READ-scoped access to a class roster and a pupil's own enrolment history, but not the write side of enrolment — moving, withdrawing, correcting or deleting an enrolment requires Students_UPDATE, which the seeded staff/teacher roles do not hold. No guest or unauthenticated actor, and no /api/mobile/... route, touches this module. |
| Main entry points | GET /api/grades; GET/POST/PATCH/DELETE /api/sections; GET/POST/PATCH/DELETE /api/rooms; GET/POST/PATCH/DELETE /api/classes, GET /api/classes/options; GET /api/classes/:publicId/students, POST /api/classes/:publicId/enrollments, DELETE /api/classes/:publicId/enrollments/:studentId, PATCH/DELETE /api/enrollments/:id; GET /api/students/:id/enrollments. |
| Main outputs | Persisted grades (seed-only), sections, rooms, classes and student_class_enrollments rows; the response DTOs consumed by the class-setup screens, the admission form's grade/section/shift cascade, the capacity chart, a class's own roster screen, and a pupil's enrolment history. |
| Related docs | API, Backend. |
3. Actor Matrix
| Actor | Can Do | Cannot Do | Auth Requirement | Notes |
|---|---|---|---|---|
| Guest (unauthenticated) | Nothing. | Everything in this module. | None — and none is accepted. | Every route requires a valid JWT; there is no @Public() route anywhere in AcademicStructureModule, and no /api/mobile/... route exposes any part of this module. |
| Logged-in user without the relevant permission | Nothing module-specific beyond whatever their own role already grants elsewhere. | List grades/sections/rooms/classes; create, update, or delete a section/room/class; touch any enrolment. | JWT + active role, but lacking Classes_*/Sections_*/Rooms_*/Students_*. | Refused with 403 PERMISSION_INSUFFICIENT (or 403 AUTH_ACTIVE_ROLE_REQUIRED if several roles are held and none is selected). |
Staff or teacher (seeded staff/teacher role) | List grades, sections, rooms and classes (Classes_READ, Sections_READ, Rooms_READ); list a class's roster and a pupil's own enrolment history, both narrowed to the pupils their role's object-level scope permits (Students_READ). | Create, update, or delete a section, room, or class; enrol, transfer, withdraw, correct, or remove any enrolment — every one of those requires Students_UPDATE, which STAFF_PERMISSIONS/TEACHER_PERMISSIONS do not include. | JWT + active role holding the read codes above. | The class-setup screens (creating sections, rooms, classes) and the enrolment desk are both administrator-only in the current seed, even though a teacher can see a class's roster once a pupil is already in it. |
Admin holding the relevant _READ permission | List grades/sections/rooms/classes, view a single class, view the unpaginated options list. | Any mutation. | JWT + active role with the corresponding _READ code. | Read access is granted independently per entity — an admin could hold Rooms_READ without Sections_READ. |
Admin holding _CREATE/_UPDATE/_DELETE | The corresponding mutation on sections, rooms, or classes. | Anything outside the permission actually granted — Sections_UPDATE alone does not permit Sections_DELETE. | JWT + active role with the specific action permission. | Every action is its own permission code; there is no "manage classes" grant implying all four. Grades has no write permission at all — see 12.3. |
Admin holding Students_UPDATE | Enrol/transfer, withdraw, correct, or remove an enrolment. | Bypass the pupil-scope check — assertCanAccess still applies even to a caller who otherwise holds every permission this module defines, short of superadmin. | JWT + active role with Students_UPDATE, plus the target pupil inside the caller's object-level scope. | The permission code is a precondition; PeopleAccessService is the actual control — see 12.3. |
| Superadmin | Everything in this module, unconditionally, including any pupil regardless of scope. | Nothing is withheld. | JWT + a role flagged is_superadmin. | The bypass is keyed on the boolean flag, never on a role's display name. |
| Worker/system | No automated actor touches this module. | Everything, since none exists. | N/A | No job, scheduler, or queue reads or writes grades, sections, rooms, classes, or enrolments — verified against the backend doc's Sections 9-10 (BullMQ and Realtime, both "not applicable"). |
4. Capability Matrix
| Capability | Surface | Actor | Route/Trigger | State Read | State Written | Linked API Section |
|---|---|---|---|---|---|---|
| List grades | Admin/Staff/Teacher | Any holder of Classes_READ | GET /api/grades | grades | — | 8.1 |
| List/search sections | Admin/Staff/Teacher | Any holder of Sections_READ | GET /api/sections | sections | — | 8.2 |
| Create a section | Admin | Sections_CREATE | POST /api/sections | sections (name pre-check) | sections | 8.3 |
| Rename/reorder/retire a section | Admin | Sections_UPDATE | PATCH /api/sections/:publicId | sections | sections | 8.4 |
| Delete a section | Admin | Sections_DELETE | DELETE /api/sections/:publicId | sections, classes (FK check) | sections | 8.5 |
| List/search/filter rooms | Admin/Staff/Teacher | Any holder of Rooms_READ | GET /api/rooms | rooms | — | 8.6 |
| Create a room | Admin | Rooms_CREATE | POST /api/rooms | rooms (number pre-check) | rooms | 8.7 |
| Rename/relocate/retire a room | Admin | Rooms_UPDATE | PATCH /api/rooms/:publicId | rooms | rooms | 8.8 |
| Delete a room | Admin | Rooms_DELETE | DELETE /api/rooms/:publicId | rooms, classes (existence check) | rooms | 8.9 |
| List/search/filter classes | Admin/Staff/Teacher | Any holder of Classes_READ | GET /api/classes | classes joined to grade/section/room/teacher, plus a live occupancy count | — | 8.10 |
| Read class options for the capacity chart and admission cascade | Admin/Staff/Teacher | Classes_READ | GET /api/classes/options | classes (active only), grade/section, live occupancy | — | 8.11 |
| Read one class | Admin/Staff/Teacher | Classes_READ | GET /api/classes/:publicId | Same joins as the list, single row | — | 8.12 |
| Create a class | Admin | Classes_CREATE | POST /api/classes | academic_sessions, grades, sections, rooms, staff+designations (all existence/active checks) | classes | 8.13 |
| Update a class | Admin | Classes_UPDATE | PATCH /api/classes/:publicId | Same lookups as create, plus a locked read of the target row and a live occupancy count | classes | 8.14 |
| Delete a class | Admin | Classes_DELETE | DELETE /api/classes/:publicId | classes, student_class_enrollments (FK check) | classes | 8.15 |
| View a class's roster | Admin/Staff/Teacher (row-scoped) | Students_READ | GET /api/classes/:publicId/students | student_class_enrollments joined to students/users, row-scoped | — | 8.16 |
| Enrol or transfer a pupil into a class | Admin (Students_UPDATE holder) | Students_UPDATE | POST /api/classes/:publicId/enrollments | classes (locked), students, academic_sessions, existing active enrolment | student_class_enrollments; closes the predecessor row on a transfer | 8.17 |
| Withdraw a pupil from a class | Admin (Students_UPDATE holder) | Students_UPDATE | DELETE /api/classes/:publicId/enrollments/:studentId | student_class_enrollments | student_class_enrollments (status/endedOn) | 8.18 |
| Correct an enrolment's date/status | Admin (Students_UPDATE holder) | Students_UPDATE | PATCH /api/enrollments/:id | student_class_enrollments | student_class_enrollments | 8.19 |
| Remove an enrolment entered in error | Admin (Students_UPDATE holder) | Students_UPDATE | DELETE /api/enrollments/:id | student_class_enrollments | student_class_enrollments (deleted) | 8.20 |
| View a pupil's own class history | Admin/Staff/Teacher (row-scoped) | Students_READ | GET /api/students/:id/enrollments | student_class_enrollments joined to classes/grades/sections/academic_sessions | — | 8.21 |
| Populate a class-setup dropdown | Admin (indirect) | Any admin creating a class | GET /api/grades, GET /api/sections?pagination=false&isActive=true, GET /api/rooms?pagination=false&isActive=true | Same as the list endpoints above | — | 8.1, 8.2, 8.6 |
| Populate the admission form's grade/section/shift cascade and capacity chart | Admin/Staff (indirect) | Any actor admitting a pupil | GET /api/classes/options?academicSessionId=... | classes, live occupancy | — | 8.11 |
5. User-Facing Flows
5.1 List grades
Summary
An admin, staff member, or teacher opens a grade select — the admission form, the class-setup screen — and the full grade list loads. There is no create, update, or delete for a grade anywhere in this module; grades are seed data.
Preconditions
- Valid JWT with an active role holding
Classes_READ— grades have no permission module of their own.
Main Flow
| Step | Actor/System | Action | Result | Source |
|---|---|---|---|---|
| 1 | Admin/Staff/Teacher | Opens a screen needing the grade list. | GET /api/grades. | Controller. |
| 2 | Backend | Reads grades, ordered by sortOrder then name then id. | Every grade returned, seed-populated (Nursery, LKG, UKG, 1-12) plus whatever an operator has added directly. | Service. |
Sequence Diagram
Branches and Edge Cases
| Branch | Condition | Behavior | Error/Result |
|---|---|---|---|
sort/order query params supplied | e.g. ?sort=name&order=asc | Silently ignored — ListGradesQueryDto inherits both from QueryDto, but GradesService.findAll's orderBy is a fixed clause that never reads either field. | Always sorted sortOrder, name, id, regardless of what was requested. |
pagination=false | Reading the whole table for a select box | Allowed and capped at PaginationUtil.UNPAGINATED_HARD_CAP (1000) — grades are a reference table, not a person table. | Full list in one response. |
| Missing permission | Role lacks Classes_READ | Refused before any query runs. | 403 PERMISSION_INSUFFICIENT. |
| No route to add a grade | An admin wants "Playgroup" added | Not possible through this API — see 12.3. | Requires a direct database write or a future migration; there is no POST /api/grades. |
5.2 List, create, rename, reorder, retire, and delete a section
Summary
An admin manages the A/B/C/D-style subdivisions a grade is split into, from a section-setup screen. The shape mirrors departments/designations in the school module: hard delete, guarded by whatever still references the row.
Preconditions
Sections_READ/_CREATE/_UPDATE/_DELETEas appropriate.- Create/rename: no other active section shares the name, case-insensitively.
- Delete: no class currently references this section.
Main Flow
| Step | Actor/System | Action | Result | Source |
|---|---|---|---|---|
| 1 | Admin | Lists, adds, renames, reorders, retires, or deletes a section. | GET/POST/PATCH/DELETE /api/sections[...]. | Controller. |
| 2 | Backend | Validates existence/name-freedom, applies the change inside a transaction on create (to compute sortOrder safely), invalidates the section cache on every write. | Row created/updated/deleted. | Service. |
Sequence Diagram
Branches and Edge Cases
| Branch | Condition | Behavior | Error/Result |
|---|---|---|---|
sortOrder omitted on create | No value supplied | Computed inside the same transaction as the insert as COALESCE(max(sort_order), -1) + 1 — the COALESCE matters because max() over an empty table is NULL, and NULL + 1 into a NOT NULL column would be a 23502 on a school's very first section. | Row placed one past the current highest position. |
Two sections share a sortOrder | sortOrder is deliberately not unique | Allowed by design — ordering is (sort_order, lower(name)), so a tie breaks alphabetically. | No error, deterministic order. |
| Retired section's name reused | A retired section's name is claimed by a new one | Allowed — sections_name_unique is partial on is_active. | Success; the retired row keeps its old name and is not renamed by this. |
| Section still referenced by a class | DELETE on a section any class (active or retired) points at | Refused, naming the blocker. | 409 SECTION_IN_USE. |
| Not found | Deleted/invalid publicId | — | 404 SECTION_NOT_FOUND. |
sort/order query params supplied on the list | e.g. ?sort=name | Silently ignored, same as grades — the service's orderBy is fixed. | Always sorted sortOrder, name, id. |
5.3 List, create, update, and delete a room
Summary
An admin manages the school's physical rooms — number, floor, building — typically maintained by facilities staff who have no reason to create classes.
Preconditions
Rooms_READ/_CREATE/_UPDATE/_DELETEas appropriate.- Create/rename: no other active room shares
(building, roomNumber), case-insensitively. - Delete: no class currently references this room.
Main Flow
| Step | Actor/System | Action | Result | Source |
|---|---|---|---|---|
| 1 | Admin | Lists, filters (by building/floor/search), adds, edits, or deletes a room. | GET/POST/PATCH/DELETE /api/rooms[...]. | Controller. |
| 2 | Backend | Checks the (building, roomNumber) pair only when it actually changes and the result stays active; hard-deletes only after an explicit pre-check against classes.room_id. | Row created/updated/deleted. | Service. |
Sequence Diagram
Branches and Edge Cases
| Branch | Condition | Behavior | Error/Result |
|---|---|---|---|
| Same room number, different building | e.g. "101" in "Main Block" and "101" in "Annexe" | Allowed — uniqueness is scoped to the building, not global. | Success in both. |
| Room-in-use check | DELETE on a room a class points at | Cannot rely on a caught foreign-key violation — classes.room_id is ON DELETE set null, so the database would otherwise let the delete succeed and silently blank the class's room. The service runs an explicit pre-check instead. | 409 ROOM_IN_USE, from application logic, not a translated 23503. |
floor at a boundary | floor exactly -5 or 200 | Accepted — the bounds are inclusive. | Success. |
Empty-string name | PATCH {"name": ""} | Rejected by the DTO's ValidateIf/Matches combination before the service runs — an explicit null is the only way to clear it. | 400 VALIDATION_FAILED. |
| Retired room's number reused | A retired room's (building, roomNumber) claimed by a new one | Allowed — the unique index is partial on is_active. | Success. |
5.4 List and filter classes
Summary
An admin, staff member, or teacher opens the class-setup or class-listing screen for a given academic session (defaulting to whichever session is marked current) and sees every class with its live enrolment count, room, and class teacher.
Preconditions
Classes_READ.paginationmust not befalse— see the edge case below.
Main Flow
| Step | Actor/System | Action | Result | Source |
|---|---|---|---|---|
| 1 | Actor | Opens the class list, optionally filtering by grade, section, shift, room, class teacher, hasRoom/hasClassTeacher, isActive, or a free-text search. | GET /api/classes?.... | Controller. |
| 2 | Backend | Resolves the target academic session (explicit academicSessionId, or whichever session is isCurrent), builds the filter, and computes each row's live occupancy with one grouped query. | Rows returned with grade/section/room/teacher joined and enrolledCount populated. | Service. |
Sequence Diagram
Branches and Edge Cases
| Branch | Condition | Behavior | Error/Result |
|---|---|---|---|
pagination=false requested | The row embeds the class teacher's full name and employee code — staff identity | Refused outright, the same way /students and /staff refuse it — a caller holding only Classes_READ would otherwise be handed up to 1000 rows of staff identity. | 400 PAGINATION_LIMIT_INVALID. |
No academicSessionId given and no session is isCurrent | A fresh database, or the office has not yet marked a session current | Returns { data: [], totalCount: 0 } rather than throwing — nothing in the schema requires a current session to exist. | Empty state, not an error. |
sort/order query params supplied | e.g. ?sort=name | Silently ignored — the DTO inherits both from QueryDto, but the ordering is always grade.sortOrder, section.sortOrder, shift, id. | Fixed structural order regardless of the request. |
search matching an unnamed class | classes.name is nullable | Matched anyway — the search also matches grades.name, grades.code, and sections.name, so an unnamed class ("Grade 5 A") is still findable by grade/section text. | Search hits, even with no class name. |
hasRoom=false and hasClassTeacher=false together | Both filters supplied | Both applied — classes with neither assigned. | Narrower result, no conflict. |
5.5 Read the class options list
Summary
The admission form's grade → section → shift cascade, and the capacity chart, both call the same unpaginated, person-data-free endpoint rather than the full class list — the admission clerk does not hold Students_READ scope over every pupil in the school, but does need to know which classes exist and how full they are.
Preconditions
Classes_READ.
Main Flow
| Step | Actor/System | Action | Result | Source |
|---|---|---|---|---|
| 1 | Actor/screen | Opens the admission form, or the capacity chart. | GET /api/classes/options?academicSessionId=.... | Controller. |
| 2 | Backend | Reads every active class in the resolved session — no room, no teacher, no pagination — and attaches the live occupancy count per class. | { items, truncated }. | Service. |
Sequence Diagram
Branches and Edge Cases
| Branch | Condition | Behavior | Error/Result |
|---|---|---|---|
| Response shape | This is the one endpoint in the module whose data is an object, not an array | data: { items, truncated } — truncated has nowhere else to live, since ResponseDto's pagination fields only apply to a genuinely paginated list. | Client must read data.items, not data, as the array. |
| Over 1000 active classes in the session | Realistic only for a very large or long-lived deployment | One extra row is fetched past the cap to detect truncation with no second COUNT(*), then sliced back to UNPAGINATED_HARD_CAP (1000). | truncated: true; the response silently omits the excess rows rather than erroring. |
| Retired classes | isActive: false | Excluded unconditionally — this endpoint is hardcoded to active-only, unlike the full class list which defaults to showing both. | Retired classes never appear in the admission cascade or the capacity chart. |
| No current session and none given | Fresh database | { items: [], truncated: false }. | Empty state, not an error. |
5.6 Read a single class
Summary
An admin opens a class's detail view — from the list, or by direct link — and sees the full ClassDto, including its live enrolment count.
Preconditions
Classes_READ.- The class exists.
Main Flow
| Step | Actor/System | Action | Result | Source |
|---|---|---|---|---|
| 1 | Actor | Opens a class's detail screen. | GET /api/classes/:publicId. | Controller. |
| 2 | Backend | Loads the row with every join, then computes its occupancy with the same shared countFor the list uses. | ClassDto. | Service. |
Branches and Edge Cases
| Branch | Condition | Behavior | Error/Result |
|---|---|---|---|
| Not found | Deleted/invalid publicId | — | 404 CLASS_NOT_FOUND. |
| Route ordering | :publicId could otherwise swallow the literal options segment | Guarded by declaration order in the controller — @Get("options") is registered before @Get(":publicId"), so /api/classes/options never resolves as a class whose id is the string "options". | Correct routing; a defect here would surface as a confusing 404. |
5.7 Create a class
Summary
An admin sets up a new class: one grade, one section, one shift, in one academic session, with a capacity and optionally a room and a class teacher.
Preconditions
Classes_CREATE.- The academic session, grade, and section all exist and are active.
- No existing class already has this exact
(session, grade, section, shift)identity — including one that has since been retired. - If a room is given: it exists and is active, and no other active class already holds that room in the same session and shift.
- If a class teacher is given: the staff member exists, is not soft-deleted, holds a teaching designation, and is not already class teacher of another active class in the same session and shift.
Main Flow
| Step | Actor/System | Action | Result | Source |
|---|---|---|---|---|
| 1 | Admin | Submits the "add class" form. | POST /api/classes {academicSessionId, gradeId, sectionId, shift, capacity, name?, classTeacherId?, roomId?}. | Controller. |
| 2 | Backend | Validates the session, grade, and section exist and are active; resolves and validates the room and teacher if given. | Proceeds or rejects with a named 404/422. | Service. |
| 3 | Backend | Inserts the row inside a transaction; any unique-index collision is translated after the fact. | New class persisted, enrolledCount: 0. | Service. |
Sequence Diagram
Branches and Edge Cases
| Branch | Condition | Behavior | Error/Result |
|---|---|---|---|
| Retired session/grade/section | Any of the three resolves but is isActive: false | Rejected before the insert. | 422 CLASS_SESSION_INACTIVE / CLASS_GRADE_INACTIVE / CLASS_SECTION_INACTIVE. |
| Duplicate identity | Same (session, grade, section, shift) already exists in this session — even if the existing row is retired | Refused — identity uniqueness is total, not partial on is_active, deliberately. | 409 CLASS_IDENTITY_TAKEN. |
| Room double-booked in the same shift | Same room, same session, same shift already held by another active class | Refused. | 409 CLASS_ROOM_OCCUPIED. |
| Room shared across shifts | Same room, same session, different shift | Allowed — a room may run a morning class and a day class. | Success. |
| Teacher double-booked in the same shift | Same staff member already class teacher of another active class, same session, same shift | Refused. | 409 CLASS_TEACHER_ALREADY_ASSIGNED. |
| Teacher not a teacher | classTeacherId resolves to a staff row whose designation has isTeaching: false | Refused — a teacher is a staff row with a teaching designation, and this is the only place that distinction is enforced. | 422 CLASS_TEACHER_NOT_TEACHING. |
| Teacher soft-deleted or unknown | classTeacherId does not resolve, or resolves to a soft-deleted staff row | — | 404 CLASS_TEACHER_NOT_FOUND. |
| Capacity out of range | Below 1 or above 500 | Rejected by the DTO before the service runs. | 400 VALIDATION_FAILED. |
| Name omitted | No name supplied | Stored as NULL — a class's display name is never its identity. | Success; the class is identified by grade/section/shift everywhere the UI needs a label. |
5.8 Update a class
Summary
An admin edits a class's name, capacity, room, class teacher, or active flag. The four identity fields — session, grade, section, shift — can never be part of this request.
Preconditions
Classes_UPDATE.- The class exists.
- Lowering capacity below the live enrolment count requires
allowOverCapacity: true.
Main Flow
| Step | Actor/System | Action | Result | Source |
|---|---|---|---|---|
| 1 | Admin | Submits an edit — any subset of name, capacity, classTeacherId, roomId, isActive. | PATCH /api/classes/:publicId. | Controller. |
| 2 | Backend | Takes a row lock on the target class inside a transaction, re-validates only the fields that actually changed, and applies the patch. | Row updated. | Service. |
Sequence Diagram
Branches and Edge Cases
| Branch | Condition | Behavior | Error/Result |
|---|---|---|---|
| Identity fields sent | Body includes academicSessionId/gradeId/sectionId/shift | Rejected before the service ever runs — those fields do not exist on UpdateClassDto, and the global validator forbids unknown fields. | 400 VALIDATION_FAILED. The database itself would refuse a session change with an unmapped 500 (ON UPDATE restrict on the enrolment table's composite FK) if this ever reached it — see 12.3. |
| Capacity lowered below live enrolment, no override | capacity: 20 on a class with 25 active pupils | Refused, naming the exact counts in the message. | 422 CLASS_CAPACITY_BELOW_ENROLLED. |
Same, with allowOverCapacity: true | Same scenario, override set | Allowed — the class becomes a class whose stated capacity is below its actual roll. | Success; no separate audit entry is written for this specific override (contrast with the enrolment-side override, which is logged). |
| Room re-sent unchanged | roomId equals the class's current room | Not re-validated for active status — the retired-lookup check only fires when the resolved id differs from what is already stored. | Success, even if the room has since been retired — otherwise a class holding a now-retired room could never be edited at all, including to deactivate it. |
| Class teacher re-sent unchanged | Same logic as room | Same behavior — no re-check if unchanged. | Success, even if the teacher's designation has since lost isTeaching. |
roomId/classTeacherId set to null | Explicit null in the body | Clears the assignment. | Success; the row's room_id/class_teacher_id becomes NULL. |
| Concurrent capacity edit and enrolment | Two operators act on the same class simultaneously | Serialized by the FOR UPDATE row lock this update takes — the enrolment path takes the same lock, so one operation waits for the other to commit before its own capacity check runs. | Correct, not racy. |
isActive: false | Deactivating the class | Allowed unconditionally, with no enrolment check — an earlier design that blocked this on an active roll made deactivation and hard-delete mutually impossible for a class with even one pupil. | Success; existing enrolments are entirely untouched, and the class's identity is not freed — see 12.3. |
5.9 Delete a class
Summary
An admin permanently removes a class created in error. Blocked while any enrolment — of any status — still references it.
Preconditions
Classes_DELETE.- No
student_class_enrollmentsrow references this class.
Main Flow
| Step | Actor/System | Action | Result | Source |
|---|---|---|---|---|
| 1 | Admin | Chooses "delete" on a class. | DELETE /api/classes/:publicId. | Controller. |
| 2 | Backend | Attempts the delete; the composite foreign key from student_class_enrollments is the actual backstop, translated into a named error. | Deleted, or refused. | Service. |
Branches and Edge Cases
| Branch | Condition | Behavior | Error/Result |
|---|---|---|---|
| Any enrolment references it | Active, transferred, or withdrawn — the FK has no status filter | Refused. | 409 CLASS_HAS_ENROLLMENTS. |
| Not found | Deleted/invalid publicId | — | 404 CLASS_NOT_FOUND. |
| The recommended correction path | An admin picked the wrong grade/section at creation, and pupils are already enrolled | Deactivate the class instead of deleting it, and create the correct one — identity is never freed by deactivation, so the mistaken class stays visible in history under its own name forever. | Documented behavior, not an error. |
5.10 View a class's roster
Summary
An admin, staff member, or teacher opens a class and sees the pupils currently (or previously, by status filter) enrolled — scoped to exactly the pupils their role is permitted to see, never the class's full roll unconditionally.
Preconditions
Students_READ.- The class exists.
paginationmust not befalse.
Main Flow
| Step | Actor/System | Action | Result | Source |
|---|---|---|---|---|
| 1 | Actor | Opens a class's roster tab. | GET /api/classes/:publicId/students?status=active. | Controller. |
| 2 | Backend | Resolves the caller's object-level scope for students, ANDs it into the roster query, and filters to pupils who are neither soft-deleted nor record-inactive. | Rows returned. | Service. |
Sequence Diagram
Branches and Edge Cases
| Branch | Condition | Behavior | Error/Result |
|---|---|---|---|
pagination=false requested | A class roll is every enrolled child's name in one response | Refused, for the same reason /students and /staff refuse it. | 400 PAGINATION_LIMIT_INVALID. |
| Caller's scope is narrower than the class | e.g. a "Parent Portal"-style role whose scope_kind defaults to self | Sees only the pupils their scope covers, potentially zero, never the whole class. | Fewer rows than the class actually holds, no error. |
status omitted | No status query param | Defaults to active — a withdrawn or transferred pupil is excluded unless explicitly asked for. | Only currently-enrolled pupils shown. |
| A listed pupil is soft-deleted or record-inactive | The child left the school entirely | Excluded from the roster even if their student_class_enrollments row is still active — the same filter ClassOccupancyService applies, so the roster and the capacity figure on the same screen always agree. | Not shown. |
| Class not found | Invalid/deleted publicId | — | 404 CLASS_NOT_FOUND. |
5.11 Enrol or transfer a pupil into a class
Summary
One route does both jobs. Admitting a new pupil and moving an already-enrolled pupil to a different class in the same session are the same call — the backend decides which case applies from what it finds.
Preconditions
Students_UPDATE.- The target pupil is inside the caller's object-level scope.
- The target class exists and is active.
- The pupil is not soft-deleted.
- The enrolment date falls within the class's academic session and on or after the pupil's admission date.
- The class has room, or
allowOverCapacity: trueis set.
Main Flow
| Step | Actor/System | Action | Result | Source |
|---|---|---|---|---|
| 1 | Admin | Submits the pupil id (and optionally a back/forward-dated enrolledOn) against the target class. | POST /api/classes/:publicId/enrollments {studentId, enrolledOn?, allowOverCapacity?}. | Controller. |
| 2 | Backend | Checks the pupil is in scope, resolves the class, validates the pupil and the date, then locks both the target class and (on a transfer) the pupil's current class in ascending id order. | Rows locked. | Service. |
| 3 | Backend | Recomputes occupancy under the lock, refuses if full without an override, then closes any predecessor row and inserts the new one in the same transaction. | Enrolled, or transferred. | Service. |
Sequence Diagram
Branches and Edge Cases
| Branch | Condition | Behavior | Error/Result |
|---|---|---|---|
| Pupil already active in the target class | Re-submitting the same enrolment | No-op — the existing row is returned unchanged. | Success, idempotent. |
| Pupil already active in a different class, same session | A move | Treated as a transfer: the predecessor row is closed transferred with endedOn set to the new enrolledOn, and a fresh active row is inserted, all in one transaction. | Success; the predecessor's history is preserved, not deleted. |
Pupil already active in this session, enrolledOn before the predecessor's own enrolledOn | A back-dated transfer that would close the predecessor before it started | Refused before it reaches Postgres — closing with endedOn earlier than enrolledOn would otherwise violate the dates-ordered CHECK as an unmapped 500. | 409 ENROLLMENT_DATE_INVALID. |
| Class is full | enrolledCount >= capacity, allowOverCapacity not set | Refused, with the exact counts named in the message (the response envelope carries no separate structured field for them). | 409 CLASS_AT_CAPACITY. |
| Class is full, override set | allowOverCapacity: true | Allowed. Recorded in the activity log with the pupil's id, the capacity, the pre-enrolment count, and the override flag — via an explicit recordActivity call, because the automatic audit interceptor sees neither the request body nor a target id. | Success, audited. |
enrolledOn outside the class's academic session | Date before startDate or after endDate | Refused. | 409 ENROLLMENT_DATE_OUTSIDE_SESSION. |
enrolledOn before the pupil's own admission date | A pupil cannot be enrolled in a class before the school admitted them | Refused. | 409 ENROLLMENT_DATE_BEFORE_ADMISSION. |
| Class retired | isActive: false, checked once on the initial read and again after the lock is taken | Refused both times — the re-check after the lock exists because the class could have been deactivated between the first read and the lock being granted. | 409 CLASS_INACTIVE. |
| Pupil soft-deleted | students.deletedAt set | Refused — the pupil's record must be restored first. | 409 ENROLLMENT_STUDENT_DELETED. |
| Two simultaneous transfers, X→Y and Y→X | A genuine cross-transfer race | Deadlock-free by construction — both classes are always locked in ascending id order regardless of which is the "source" and which the "target" for either request. | Both requests proceed serially; neither raises 40P01. |
| Two concurrent enrolments for the same pupil, same session | A race past the pupil-lookup step | The loser hits the partial unique index directly. | 409 ENROLLMENT_ALREADY_ACTIVE, retryable on a re-read. |
enrolledOn omitted | No date supplied | Defaults to today in Asia/Kathmandu — not a UTC "today", since the column is a plain DATE and the zone is UTC+5:45. | Correct local date, even near midnight UTC. |
| Caller outside the pupil's scope | The pupil is real but not visible to the caller's role | Refused before the class is even touched. | 404 (never 403 — see 12.3). |
5.12 Withdraw a pupil from a class
Summary
An admin records that a pupil has left the school (or this class) — distinct from deleting an enrolment entered by mistake.
Preconditions
Students_UPDATE.- The target pupil is inside the caller's object-level scope.
- An active enrolment for this pupil in this class exists.
Main Flow
| Step | Actor/System | Action | Result | Source |
|---|---|---|---|---|
| 1 | Admin | Withdraws a pupil from their current class. | DELETE /api/classes/:publicId/enrollments/:studentId. | Controller. |
| 2 | Backend | Locks the class, finds the pupil's active row in it, and closes it withdrawn with an end date of today (or the enrolment date itself, if it is future-dated). | Row updated, not deleted. | Service. |
Sequence Diagram
Branches and Edge Cases
| Branch | Condition | Behavior | Error/Result |
|---|---|---|---|
| No active enrolment for this pupil in this class | Already withdrawn/transferred, or never enrolled here | — | 404 ENROLLMENT_NOT_FOUND. |
Future-dated enrolledOn withdrawn today | The pupil's active row was itself back-dated into the future | endedOn is max(enrolledOn, today), never simply "today" — a same-day-as-enrolled withdrawal for a future enrolment would otherwise violate the dates-ordered CHECK. | Success; endedOn equals enrolledOn in that specific case. |
| Withdrawal vs. deletion | An enrolment was entered by mistake, not a genuine departure | Withdrawal is the wrong tool — see 5.13. Using it anyway corrupts the count of pupils who genuinely left the year. | Documented guidance, not an API-level distinction. |
5.13 Correct an enrolment's date or status
Summary
An admin fixes a typo'd enrolment date, or manually flips a status without going through the enrol/withdraw endpoints — for example, backfilling a historical record during data migration.
Preconditions
Students_UPDATE.- The target pupil (resolved from the enrolment row) is inside the caller's scope.
- The enrolment exists.
Main Flow
| Step | Actor/System | Action | Result | Source |
|---|---|---|---|---|
| 1 | Admin | Edits enrolledOn and/or status on a specific enrolment row. | PATCH /api/enrollments/:id {enrolledOn?, status?}. | Controller. |
| 2 | Backend | Resolves the pupil from the enrolment to run the scope check, locks the owning class, and applies the patch with the same date-vs-status coherence rule the withdraw path uses. | Row updated. | Service. |
Branches and Edge Cases
| Branch | Condition | Behavior | Error/Result |
|---|---|---|---|
classId in the body | An attempt to move the pupil through this route | Not a field on UpdateEnrollmentDto at all — moving a pupil is always POST /classes/:publicId/enrollments, which takes the capacity lock and writes the predecessor row; a second in-place path would bypass both. | 400 VALIDATION_FAILED if sent — unknown field. |
status changed from active to transferred/withdrawn, no endedOn supplied | Manually closing a row | endedOn computed the same max(enrolledOn, today) way as withdraw. | Success. |
enrolledOn moved past an already-set endedOn | A correction that would leave the row incoherent | endedOn is pulled forward to match, rather than leaving a violated CHECK to reach the database. | Success, silently coherent. |
| Enrolment not found | Invalid/deleted id | — | 404 ENROLLMENT_NOT_FOUND. |
| Caller outside the pupil's scope | Resolved after reading the enrolment, before any write | Refused. | 404 (never 403). |
5.14 Remove an enrolment entered in error
Summary
An admin deletes a row that should never have existed — the wrong class was picked, or a duplicate submission slipped past a race. Distinct from withdrawal: this leaves no trace in the transferred/withdrawn counters.
Preconditions
Students_UPDATE.- The target pupil (resolved from the enrolment row) is inside the caller's scope.
- The enrolment exists.
Main Flow
| Step | Actor/System | Action | Result | Source |
|---|---|---|---|---|
| 1 | Admin | Chooses "remove" on a mistaken enrolment. | DELETE /api/enrollments/:id. | Controller. |
| 2 | Backend | Locks the owning class, hard-deletes the row, and records the deletion explicitly in the activity log. | Row gone. | Service. |
Branches and Edge Cases
| Branch | Condition | Behavior | Error/Result |
|---|---|---|---|
| A pupil's only enrolment for a session is removed | The pupil now has none for that session | Allowed — nothing requires a pupil to hold an enrolment for every session. | Success; the pupil simply has no class recorded for that year until re-enrolled. |
Removing an active row | The most common real case: fixing a same-day mis-click | Allowed, no different from removing a closed one. | Success. |
| Enrolment not found | Invalid/deleted id | — | 404 ENROLLMENT_NOT_FOUND. |
| Why this exists alongside withdraw | Offering only "withdraw" would force an operator to record a fictional departure for a child who never actually left | Deliberate design decision — see 12.3. | Documented, not an edge case a client needs to branch on. |
5.15 View a pupil's own class history
Summary
A pupil's profile screen shows every class they have ever held an enrolment in, across every session, newest first — the one active row per year plus any transfers and withdrawals.
Preconditions
Students_READ.- The caller's scope covers this pupil.
Main Flow
| Step | Actor/System | Action | Result | Source |
|---|---|---|---|---|
| 1 | Actor | Opens a pupil's profile "enrolment history" section. | GET /api/students/:id/enrollments?academicSessionId=?. | Controller (on StudentsController, not a class-side route). |
| 2 | Backend | Confirms the pupil is in scope, then reads every enrolment row for that pupil, optionally narrowed to one session, ordered newest-enrolled-first. | Rows returned. | Service. |
Branches and Edge Cases
| Branch | Condition | Behavior | Error/Result |
|---|---|---|---|
| Route ownership | Lives on StudentsController, not a controller in this module | Follows the convention its neighbours (:id/guardians, :id/medical) already set — putting it on the class side would give AcademicStructureModule a /students/* prefix nothing else in the repo uses. | N/A — a routing/architecture fact, not a runtime branch. |
sort/order/search query params | Inherited from QueryDto | Silently ignored — the list is always ordered enrolledOn DESC, id DESC, and no search filter is applied. | Fixed newest-first order regardless of what was requested. |
| No enrolments yet | A pupil admitted but not yet placed in a class | data: []. | Empty state, not an error. |
| Caller outside the pupil's scope | Pupil real but not visible to the caller | — | 404 (never 403). |
academicSessionId given | Narrowed to one year | Only that session's rows (typically one active plus any transfers/withdrawals within it). | Filtered result. |
5.16 See and change a pupil's class from the pupil's own screens
The class module makes a pupil's class settable from the class roster. These three surfaces put it where the office actually looks a pupil up.
Where it appears
| Surface | What it shows |
|---|---|
GET /students — the roll | currentClass on every row; the admin table renders it as a link to the class. |
GET /students/:id | The same object on the pupil's record. |
PATCH /students/:id | Accepts classId, enrolledOn and allowOverCapacity to move the pupil. |
What "the pupil's class" means. Their active enrolment in the current academic session.
Both halves matter. Without status = 'active' a pupil who transferred out still reads as being in
the class they left; without the session pin they read as being in last year's class all through the
gap between sessions — a confident wrong answer where null is the correct one. A pupil with no
class, and every pupil while no session is current, reads null. That is an ordinary state: a pupil
can be admitted before their class is decided.
currentClass carries classId, gradeName, sectionName, shift and the optional class name.
It deliberately carries no capacity or occupancy — those belong to the class, and the one screen
that needs them reads /classes/options, the same endpoint the capacity chart uses, so the two can
never disagree.
Changing it. PATCH /students/:id with a classId enrols or transfers, in the same transaction
as the rest of the pupil's edit, through the same enroll() the class roster uses. So a transfer made
here closes the previous enrolment as transferred and writes the same activity record as one made
from the roster.
Three rules are worth knowing before using it:
- Omitting
classIdleaves the class alone, and no value clears it. Taking a pupil out of a class with nowhere to put them is a withdrawal — it needs a date and a reason and it changes the class's roll — so it stays on the class roster where those are in view. - The class must belong to the current session. A class from another session is refused with
ENROLLMENT_SESSION_NOT_CURRENT. The roster route does not apply this rule: it names its class in the URL, so choosing another year there is a deliberate act. This form sends a bare id, and without the rule it would return 200, write a second active enrolment in a session the pupil record does not read from, and appear to have done nothing. - The pupil's
versionnow covers their class. Every enrolment write touchesstudents.updated_at, so a transfer made from the class roster invalidates a pupil edit form that was already open. Saving it is refused withPEOPLE_STALE_RECORDrather than silently moving the pupil back.
Errors it can return: CLASS_NOT_FOUND, CLASS_INACTIVE, CLASS_AT_CAPACITY (retry with
allowOverCapacity: true), ENROLLMENT_SESSION_NOT_CURRENT, ENROLLMENT_DATE_OUTSIDE_SESSION,
ENROLLMENT_DATE_BEFORE_ADMISSION, PEOPLE_STALE_RECORD.
Cache. The roll is cached for 120 seconds. Every enrolment write drops that cache, and so does a
change of which session is current — without the second, moving is_current would leave every pupil
showing last year's class for two minutes.
6. Admin Flows
6.1 Create
Sections, rooms, and classes each have their own POST — see 5.2, 5.3, 5.7. Grades do not — see 12.3 for why there is no POST /api/grades.
6.2 List / Read Detail
Grades/sections/rooms have list-only surfaces (no single-resource "read one" route — the list is the only read). Classes have both: 5.4 for the list and 5.6 for a single row, plus the person-data-free 5.5 variant.
6.3 Update
See 5.2, 5.3, 5.8. Every update is a PATCH with independently optional fields; a class's four identity fields are permanently excluded from the update DTO.
6.4 Reorder
Applies to grades and sections only, via sort_order — grades are seed-managed and have no reorder route; sections can set sortOrder explicitly on create or update. Rooms and classes carry no position column — a room list is ordered by building/floor/number, and a class list by grade/section/shift, both fixed.
6.5 Activate/deactivate
Covered by isActive on sections/rooms/classes — see 5.2, 5.3, and the isActive branch of 5.8. For a class specifically, deactivation is unconditional — it never checks for existing enrolments, and it never frees the class's identity, only its room and class-teacher slots (see 7).
6.6 Soft delete
Not applicable. None of grades, sections, rooms, or classes has a deleted_at column — is_active is the retirement mechanism instead, for the same reason as the school module's lookup tables: a soft-deleted row never fires ON DELETE restrict/set null correctly.
6.7 Restore
Not applicable — there is nothing to restore. The permission catalog still generates Sections_RESTORE/Rooms_RESTORE/Classes_RESTORE (every module gets every action by the catalog's own generation rule), but no route in this module ever checks any of them. Grades has no _RESTORE code because grades has no permission module at all.
6.8 Hard delete
See 5.2, 5.3, and 5.9. Every one names the exact blocking reference rather than surfacing an unmapped foreign-key failure — the room case is the sharpest version of this, since classes.room_id ON DELETE set null would otherwise let the delete succeed and silently blank a class's room.
6.9 Export/import
Not owned by this module. apps/api/src/common/types/error-codes.ts defines import-time codes for other reference tables, but no IMPORT_UNKNOWN_GRADE/SECTION/ROOM/CLASS code exists in this document's error section — bulk import of pupils into classes, if it exists elsewhere, is outside this module's scope.
6.10 Moderation
Not applicable. Nothing in this module requires approval or a review queue — every mutation an actor with the right permission (and, for enrolments, the right scope) makes takes effect immediately.
6.11 Manual retry
Not applicable. No job, queue, or async operation exists in this module to retry — every operation is a single synchronous request/response, wrapped in a database transaction where correctness requires it (class creation, class update, every enrolment write).
6.12 Enrolment desk (this module's distinctive admin surface)
The four enrolment-specific routes — enrol/transfer (5.11), withdraw (5.12), correct (5.13), and remove (5.14) — are the one part of this module with no equivalent in the school module's lookup CRUD. All four share one row-lock discipline (the owning class is always locked FOR UPDATE before the write), one constraint-translation table, and the same PeopleAccessService.assertCanAccess boundary check performed by the caller, not the service.
7. Lifecycle and State Transitions
7.1 A class's isActive flag
Not a state machine — a boolean, exactly like sections/rooms/the school module's lookup tables. The distinguishing fact for a class specifically is what deactivation does not do:
| Entity | From | Event/Action | To | Guard Condition | Side Effects |
|---|---|---|---|---|---|
Class | isActive: true | PATCH isActive: false | isActive: false | None — always allowed, even with active enrolments. | Removed from GET /classes/options and the default admission cascade; identity is not freed — the same (session, grade, section, shift) cannot be recreated while this row exists at all, active or not. Room and class-teacher slots are released — the two partial unique indexes exclude inactive rows. |
Class | isActive: false | PATCH isActive: true | isActive: true | The room/teacher exclusivity checks re-apply the moment the class goes active again, since the partial indexes now include it. | Reappears in the options list and default listings. |
Class | Either | DELETE | Row no longer exists | No enrolment (any status) references it. | Permanent removal; identity genuinely freed only now. |
7.2 A student's enrolment status
A real, enforced state machine — the only one in this module, and one of very few in this codebase enforced partly by a database CHECK rather than by the service alone.
| From | Event/Action | To | Guard Condition | Side Effects |
|---|---|---|---|---|
| (none) | POST /classes/:publicId/enrollments, no prior active row this session | active | Class active, has room (or override), date valid. | New row, endedOn: null. |
active (in class A) | POST /classes/:publicId/enrollments for class B, same session | active (in B); the class-A row becomes transferred | Same validations as a fresh enrolment, applied to the target class. | Predecessor's endedOn set to the new enrolledOn; both writes in one transaction. |
active | DELETE /classes/:publicId/enrollments/:studentId | withdrawn | An active row exists for this pupil in this class. | endedOn set to max(enrolledOn, today). |
active | PATCH /enrollments/:id {status: "transferred"|"withdrawn"} | The requested status | Enrolment exists. | endedOn computed the same way as withdraw, unless already coherent. |
active, transferred, or withdrawn | DELETE /enrollments/:id | Row deleted | Enrolment exists. | No status trace left at all — distinct from withdrawal, which the CHECK constraints require to carry a status and an end date. |
There is deliberately no completed status. Year rollover and promotion are a deferred feature; an enrolment in a past academic session simply stays active forever, which is unambiguous because the row names its own session and academic_sessions.is_current says which year is now.
9. Data and Side Effects by Flow
| Flow | DB Writes | Cache Effects | Jobs | Realtime | Analytics | Notifications |
|---|---|---|---|---|---|---|
| List grades | None | None — ClassesReadService/GradesService do not cache reads in this module | None | None | None | None |
| Create/update/delete a section | sections | None — this module's list reads are not cache-aside, unlike the school module's lookups | None | None | None | None |
| Create/update/delete a room | rooms | None | None | None | None | None |
| List/read classes / options | None | None | None | None | None | None |
| Create/update a class | classes | None | None | None | None | None |
| Delete a class | classes | None | None | None | None | None |
| View a roster | None | None | None | None | None | None |
| Enrol/transfer a pupil | student_class_enrollments (insert; and an UPDATE on the predecessor when transferring) | students:list:* cleared — an enrolment changes what a student-list row shows for "current class" | None | None | None | None |
| Withdraw a pupil | student_class_enrollments (update) | students:list:* cleared | None | None | None | None |
| Correct an enrolment | student_class_enrollments (update) | students:list:* cleared | None | None | None | None |
| Remove an enrolment | student_class_enrollments (delete) | students:list:* cleared | None | None | None | None |
| View a pupil's history | None | None | None | None | None | None |
Every enrolment write also produces an explicit ActivityRecordService.recordActivity call — ENROLL/TRANSFER/WITHDRAW/ENROLLMENT_UPDATE/ENROLLMENT_DELETE — in addition to whatever the global ActivityAuditInterceptor records automatically from the route's own @Permissions() and status code. See the backend doc's Observability section for why both exist.
10. Error and Recovery Flows
| Scenario | Trigger | User/System Experience | Recovery | Source |
|---|---|---|---|---|
| Class identity already taken | Creating a class with the same session/grade/section/shift as an existing (even retired) class | 409 CLASS_IDENTITY_TAKEN. | Choose a different grade/section/shift, or reactivate the existing class instead. | ClassesWriteService. |
| Room or teacher double-booked in a shift | Creating/updating a class into a room or teacher already committed that shift | 409 CLASS_ROOM_OCCUPIED / CLASS_TEACHER_ALREADY_ASSIGNED. | Pick a different room/teacher, or a different shift. | ClassesWriteService. |
| Class at capacity | Enrolling into a full class without an override | 409 CLASS_AT_CAPACITY, with the counts in the message. | Resubmit with allowOverCapacity: true, or choose a different class. | ClassEnrollmentsService. |
| Capacity lowered below the live roll | Editing a class's capacity field below its current enrolment | 422 CLASS_CAPACITY_BELOW_ENROLLED. | Resubmit with allowOverCapacity: true, or withdraw/transfer pupils first. | ClassesWriteService. |
| Hard delete blocked by a reference | Deleting a section/room still used by a class, or a class still holding an enrolment | 409 naming exactly what is blocking it. | Retire instead of deleting, or clear the blocking references first. | SectionsService/RoomsService/ClassesWriteService. |
| Enrolment date outside the session, or before admission | A back- or forward-dated enrolment that does not make sense | 409 ENROLLMENT_DATE_OUTSIDE_SESSION / ENROLLMENT_DATE_BEFORE_ADMISSION. | Correct the date. | ClassEnrollmentsService. |
| Pupil out of the caller's scope | The studentId/:id resolves to a real pupil the caller's role cannot see | 404 (never 403, so the id space is not an enumeration oracle). | Confirm the pupil is real via a route the caller's scope does cover, or request broader scope. | PeopleAccessService.assertCanAccess. |
| Name-uniqueness race on a section/room | Two concurrent creates for the identical name/number | The winner succeeds; the loser gets a generic 409 RESOURCE_ALREADY_EXISTS instead of the friendlier named code. | Refresh and retry with a different name, or confirm the existing one is what was intended. | Global unique-violation fallback in AllExceptionsFilter. |
| Simultaneous cross-transfer | Two pupils traded between the same two classes at once | Both requests proceed serially — deadlock-free by the ascending-id lock order. | No recovery needed; this is not an error case. | ClassEnrollmentsService. |
| Permission denied | Active role lacks the route's required permission | 403, distinguishing "no permission" from "no role selected" from "no role assigned." | Select or request the correct role/permission. | RoleGuard. |
11. Diagrams Required Per Module
- Actor capability diagram: 3. Actor Matrix and 4. Capability Matrix.
- High-level module flow diagram: the route-ownership diagram in the API doc §9.1.
- Sequence diagram for each major flow: provided per-flow in 5.
- State machine diagrams for both lifecycles: 7.1 and 7.2.
- Data side-effect diagram for write flows: 9.
- Error branch diagram: 6.8 and the API doc's §9.3.
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 grades/sections/rooms/classes | sort/order query fields inherited but never read | Any reader | Any GET with ?sort=name | No effect — order is always the fixed structural one | None | grades.service.ts, sections.service.ts, rooms.service.ts, classes-read.service.ts |
| List classes | Refuses pagination=false | Any reader | GET /api/classes?pagination=false | 400, not a large response | None — the request is rejected before any query runs | classes-read.service.ts |
| List classes | Defaults to the current academic session, and returns empty rather than erroring when none is current | Any reader | GET /api/classes with no academicSessionId | Empty list on a fresh database, not a 500 | None — read-only | classes-read.service.ts |
GET /classes/options | data is an object ({items, truncated}), not an array — the only such endpoint in the module | Any reader | GET /api/classes/options | A client must read data.items | None | class.dto.ts |
| Update a class | Re-sending the room/teacher unchanged skips the retired-active re-check | Admin | PATCH with roomId equal to the current value, where that room has since been retired | Success, not 422 CLASS_ROOM_INACTIVE | None — otherwise a class holding a retired resource could never be edited at all | classes-write.service.ts |
| Update a class | Identity fields are absent from the DTO, not merely ignored | Admin | PATCH {"academicSessionId": 9} | 400 VALIDATION_FAILED — unknown field, not a silent no-op | None | class.dto.ts |
| Deactivate a class | No enrolment check at all | Admin | PATCH {"isActive": false} on a class with active pupils | Success | Existing enrolments untouched; room/teacher slots released | classes-write.service.ts |
| Delete a class | Identity is only truly freed on hard delete, never on deactivation | Admin | DELETE after isActive: false on the same class | The identity (session, grade, section, shift) becomes creatable again only now | Row removed | classes.ts schema (classes_identity_unique, not partial) |
| Enrol a pupil | Same-target re-submission is a no-op, not an error | Admin | POST .../enrollments for a pupil already active in that exact class | The existing row is returned, unmodified | None | class-enrollments.service.ts |
| Enrol a pupil | Over-capacity override is audited with counts, capacity edits are not | Admin | allowOverCapacity: true on an enrolment vs. on a capacity edit | Both succeed | Only the enrolment path calls recordActivity with capacity/enrolledBefore/allowOverCapacity metadata | class-enrollments.service.ts vs. classes-write.service.ts |
| Withdraw a pupil | endedOn is max(enrolledOn, today), never plain "today" | Admin | Withdrawing a future-dated active enrolment on the same day it was entered | endedOn equals enrolledOn, not a date before it | Prevents a CHECK violation from a naive "today" | class-enrollments.service.ts |
| Withdraw vs. remove | Two distinct deletion-shaped actions for two distinct real events | Admin | DELETE .../enrollments/:studentId vs. DELETE /enrollments/:id | A status + end date vs. no trace at all | The former updates a row, the latter deletes it | class-enrollments.controller.ts |
| Correct an enrolment | classId is not a field on the correction DTO | Admin | PATCH /enrollments/:id {"classId": 5} | 400 VALIDATION_FAILED | None | enrollment.dto.ts |
| View a roster | Occupancy filter and roster filter share the exact same predicate | Any scoped reader | GET /classes/:publicId/students | A pupil excluded from the capacity count is also excluded from the roster | None — read-only, but a deliberately shared filter, not two independent ones that could drift | class-roster.service.ts, class-occupancy.service.ts |
| View a pupil's history | sort/order/search inherited but unused; order is always newest-first | Any scoped reader | GET /students/:id/enrollments?sort=enrolledOn&order=asc | Still newest-first | None | class-enrollments.service.ts (listForStudent) |
| Grade management | No create/update/delete route exists at all | Admin | Attempting to add a grade via the API | Not possible — no POST/PATCH/DELETE /api/grades handler exists | N/A | grades.controller.ts |
Rules from the format are honored above: no restore/retry/fallback/cache-miss/duplicate-action/permission-failure behavior has been grouped away, even where the underlying rule might look too small to mention on its own.
12.2 Business Process Diagram Pack
12.3 Business Rules and Policy Traceability
| Rule | Business Reason | Actor Impact | Enforced In | API Impact | Backend Impact | Tests |
|---|---|---|---|---|---|---|
A class is session-scoped, and its identity — (academic_session_id, grade_id, section_id, shift) — is immutable after creation. | The enrolment table's composite foreign key targets (id, academic_session_id) with ON UPDATE restrict, so any change to the session once one enrolment exists is refused by Postgres as an unmapped 500. | An admin who picked the wrong session must deactivate and recreate — never edit in place. | UpdateClassDto omits all four identity fields entirely; the global forbidNonWhitelisted validator rejects them if sent. | PATCH with any identity field always 400s. | classes_id_session_unique + the composite FK on student_class_enrollments. | probe-class-module-constraints.sql. |
Identity uniqueness is total, not partial on is_active. | Deactivating a class must not free its identity — two rows that were ever the same class in the same year would make every historical roster unresolvable. | An admin cannot recreate an identical class while a retired one with the same identity still exists; the correction is deactivate-and-recreate under a different identity, or reactivate the original. | classes_identity_unique is a plain unique index, with no WHERE is_active clause. | POST with an identity matching any existing row, active or retired, is 409 CLASS_IDENTITY_TAKEN. | Schema design, documented in the table's own docblock. | probe-class-module-constraints.sql — "identity NOT freed by deactivating the holder". |
Room and class-teacher uniqueness ARE partial on is_active. | A retired class must release the resources it held — a room or a teacher tied up by a class nobody runs anymore would be an operational dead end. | An admin can reassign a retired class's room/teacher to a new class immediately. | classes_room_per_shift_unique / classes_teacher_per_shift_unique, both WHERE ... AND is_active. | Creating/updating into a room or teacher held only by a retired class succeeds. | Deliberately asymmetric with identity uniqueness above — documented in the schema. | probe-class-module-constraints.sql. |
| A room may be shared across shifts, and so may a class teacher — but not within one shift. | The same physical room and the same person can genuinely run a morning class and a day class; nobody can run two classes at once. | An admin can double-book a room/teacher across shifts freely, never within one. | Both partial unique indexes are scoped to (academic_session_id, shift, ...). | Same room/teacher, different shift, is accepted. | — | probe-class-module-constraints.sql. |
Occupancy is always computed live, never stored, and the query filters both students.deleted_at IS NULL and students.record_status = 'active'. | students soft-deletes, and ON DELETE cascade never fires for a soft delete — so a deleted pupil keeps an active enrolment row, and without the filter every capacity figure in the school would be inflated by everyone who ever left. | Every actor sees an occupancy number that matches the roster they can see, and matches reality. | ClassOccupancyService.countForMany — the one shared query every occupancy consumer calls. | enrolledCount on every ClassDto/ClassOptionDto reflects live, real attendance. | — | class-occupancy.service.integration.spec.ts. |
| Capacity warns, it does not block. | A school regularly needs to admit "just one more" pupil into an already-full class, and refusing that outright would push the decision outside the system entirely. | An admin with allowOverCapacity: true can always proceed; one without it is stopped and told exactly how full the class is. | 409 CLASS_AT_CAPACITY unless the flag is set. The numbers are in the message text, not a structured field — AllExceptionsFilter returns only {statusCode, errorCode, message}. | An override is recorded in the activity log by an explicit recordActivity call, because the automatic audit interceptor sees neither the body nor a target id. | class-enrollments.service.integration.spec.ts. | |
GET /classes refuses pagination=false; GET /classes/options is the unpaginated, person-data-free alternative. | The full class list embeds staff identity (the class teacher's name and employee code) — handing that unbounded to any Classes_READ holder is a bigger disclosure than the capacity chart or the admission cascade need. | An admission clerk who needs "which classes exist and how full are they" gets exactly that, with no staff directory attached. | ClassesReadService.findAll throws PAGINATION_LIMIT_INVALID; findOptions is unpaginated by construction and returns {items, truncated}. | Two routes exist for what looks like one need, on purpose. | — | — |
A class roster is row-scoped, never a raw Students_READ grant to the whole class. | role.scope_kind defaults to self, and an operator who builds a narrow-scoped role and grants it Students_READ must get the same narrow result from a class roster that they would from GET /students — not the entire class regardless of scope. | An actor with a narrow scope sees a roster narrowed the same way; an actor with full scope sees the whole class. | PeopleAccessService.scopeFor/applyScope, ANDed into the roster query exactly as StudentsService.findAll does it. | GET /classes/:publicId/students can return fewer rows than the class actually holds, with no error. | This exact gap has recurred in this repo before, which is why the scope is applied rather than assumed. | — |
Enrolment write routes call assertCanAccess, which throws 404, never 403. | A 403 on a specific pupil id confirms that pupil exists, turning the id space into an enumeration oracle for anyone who can guess or brute-force ids. | A caller outside their scope sees the same "not found" whether the pupil is real or not. | Called explicitly by ClassEnrollmentsController before every write, and by StudentsController for the read-only history endpoint. | Every out-of-scope pupil id, real or not, returns 404. | The permission code (Students_UPDATE/Students_READ) is a precondition; the scope check is the actual control. | — |
ClassEnrollmentsService performs no authorization of its own. | It lives in EnrollmentsCoreModule, which imports only ActivityModule — reaching PeopleAccessService would recreate a PeopleModule ↔ AcademicStructureModule cycle, and this codebase has zero forwardRef usages. | None directly, but it is why the scope check always happens at the controller boundary, in more than one place (this controller, and StudentsService's own update path). | EnrollmentsCoreModule's own module docblock. | N/A — an architecture fact, not user-visible behavior. | Every caller of the service is trusted to have checked scope first; each has its own integration test asserting an out-of-scope caller gets 404. | class-enrollments.service.integration.spec.ts. |
enrollment_status has no completed value. | Year rollover and promotion are a deliberately deferred feature; adding a status nothing writes would make an unimplemented feature look implemented. | An enrolment in a past session simply stays active forever — unambiguous, because the row names its own session. | enrollmentStatusEnum = ["active", "transferred", "withdrawn"]. | No client can request or receive a "completed" status. | — | — |
| Withdraw and delete-an-enrolment are different acts, both exposed. | Withdrawal records that a child genuinely left; deletion removes a row entered in error. Collapsing them to one action forces an operator to record a fiction either way. | An operator has the correct tool for each real-world situation. | Two separate routes, two separate service methods, two separate audit actions (WITHDRAW vs. ENROLLMENT_DELETE). | DELETE .../enrollments/:studentId (withdraw, keeps history) vs. DELETE /enrollments/:id (remove, no trace). | — | — |
Seeds for grades/sections are guarded on an empty table, never ON CONFLICT DO NOTHING. | sections has no stable code column — a targetless upsert would create a duplicate section after an operator renames one on the setup screen. | A school's own edits to the seeded baseline survive every future deployment's seed run untouched. | seedAcademicStructure() in seed-reference-data.ts. | N/A. | — | — |
permissions:sync grants new permissions only to superadmin (and to a role literally named admin, which does not exist in this product). | The sync script's role selection predicate is unrelated to the seed's role/permission grant list — verified: sync-permissions.ts selects role.name IN ("superadmin", "admin"). | An operator who runs only permissions:sync after a deploy would see staff/teacher still unable to read grades/sections/rooms/classes until db:seed:prod also runs. | apps/api/scripts/sync-permissions.ts. | N/A. | db:seed:prod (not permissions:sync) is what applies the STAFF_PERMISSIONS/TEACHER_PERMISSIONS grants seen in 3. | — |
| Grades have no create/update/delete route, and no permission module of their own. | Grades are Nursery/LKG/UKG plus 1-12 — a fixed enough set in practice that no write surface was built; reads are gated under Classes_READ because a grade is meaningless without the class module around it. | An admin who wants a grade the seed does not name (e.g. "Playgroup") cannot add it through the product today, despite the schema's own docblock describing that as the intended future use of the table. | No Grades entry exists in PERMISSION_MODULES. | GET /api/grades is the only route; no POST/PATCH/DELETE. | A real, documented gap, not an oversight in this document. | — |
12.4 Tradeoffs and Product Rationale
| Product Decision | User Benefit | Engineering Benefit | Alternative | Tradeoff | Risk |
|---|---|---|---|---|---|
| Identity uniqueness total, room/teacher uniqueness partial | A retired class's room/teacher become reusable immediately; a retired class's identity stays permanently reserved | One index shape expresses two different intents without a service-level workaround | Make all three partial, as an earlier design did | An earlier version of this exact design trapped a class that could be neither deactivated nor deleted once enrolled — the partial-identity version cancels out its own escape hatch | Low today — the current shape was chosen specifically to close that trap. |
| Capacity as a soft warning, not a hard cap | An admin can always admit "one more" pupil when the school's real-world judgment says so | No separate "request an exception" workflow needed | Hard-block enrolment at capacity | Every over-capacity admission needs a human decision every time, with no system memory of "we always allow 42 in this room" | Low — the override is explicit and audited per use. |
Two class-reading endpoints (GET /classes and GET /classes/options) instead of one | The admission cascade and capacity chart never leak staff identity to a caller who only needs grade/section/shift/occupancy | A single response shape can stay honest about what data it actually needs to carry | One endpoint with an optional "slim" query flag | Two response shapes to keep in sync conceptually, though they diverge structurally on purpose (array vs. {items, truncated}) | Low — the two are deliberately different enough that confusing them is unlikely. |
ClassEnrollmentsService holds zero authorization logic | Keeps the module dependency graph acyclic with zero forwardRef anywhere in the app | A pure domain writer is trivially testable without mocking authorization | Give the service direct access to PeopleAccessService | Every current and future caller must remember to call assertCanAccess first — a mistake here is a real information-disclosure risk, not merely a style violation | Moderate, mitigated by every existing caller having its own integration test asserting the 404 on an out-of-scope pupil. |
No completed enrolment status | Avoids representing a feature (year rollover/promotion) that does not exist yet, which would otherwise look implemented to every reader of the schema | Every check constraint and query stays honest about what is actually enforced | Add completed now, unused, for forward-compatibility | Reading "which pupils finished this class" requires knowing the session is past and status is active, rather than a dedicated status | Low — clearly documented in the schema's own comment; the gap is intentional and named. |
| Withdraw and delete-enrolment as two separate routes | An operator always has the semantically correct action available, never forced to lie about what happened | Two small, single-purpose service methods rather than one method branching on caller intent | One DELETE that always hard-deletes, with a separate "mark as left" flag on the pupil instead | A UI must present two distinct actions rather than one, and must not let an operator reach for the wrong one out of habit | Low — the API and this document both name the distinction explicitly. |
Grades read-only, gated under Classes_READ with no dedicated permission module | Simpler permission catalog for a set of values that, in practice, almost never changes | One fewer module × five actions in the generated catalog | Give grades the same full CRUD treatment as sections/rooms | A school that genuinely needs a grade the seed does not name has no product path to add it | Moderate — a real, documented gap rather than a hypothetical one; see 12.3. |
12.5 Flow Edge-Case Matrix
| Flow | Edge Case | Trigger | Expected Behavior | User/System Feedback | Source |
|---|---|---|---|---|---|
| List classes | Empty state | No classes in the resolved session | data: [], count: 0 | Empty list rendered, no error | classes-read.service.ts |
| List classes | First use | The very first class ever created in a session | Appears on the next matching list call | Immediate visibility — no cache to be stale | classes-read.service.ts (no caching in this module) |
| Enrol a pupil | Last seat | enrolledCount === capacity - 1 before the write | Accepted; the class is now exactly full | Success, no warning | class-occupancy.service.ts |
| Enrol a pupil | Duplicate action | The same enrol request submitted twice in quick succession, same target class | Second call is a no-op — the existing active row is returned unchanged | Success both times, one row exists | class-enrollments.service.ts |
| Enrol a pupil | Concurrent action | Two operators enrol the same pupil into two different classes simultaneously | The loser hits the partial unique index directly | 409 ENROLLMENT_ALREADY_ACTIVE | class-enrollments.service.ts (translate) |
| Enrol a pupil | Expired state | The class's academic session has already ended (endDate in the past) | Not blocked by session end date alone — only enrolledOn falling outside [startDate, endDate] is checked, and a caller can still enrol with a date inside a closed session's range | Success — there is no separate "session is closed" guard beyond the date range | class-enrollments.service.ts |
| Enrol/withdraw/correct/remove | Permission mismatch | Caller holds Students_READ but not Students_UPDATE | Refused before any query runs | 403 PERMISSION_INSUFFICIENT | RoleGuard |
| Enrol/withdraw/correct/remove | Guest limitation | N/A — no guest actor can reach any route in this module | N/A | N/A | Actor matrix |
| Create a class | Missing dependency | gradeId/sectionId does not resolve | Refused before any write | 404 GRADE_NOT_FOUND / SECTION_NOT_FOUND | classes-write.service.ts |
| List grades/sections/rooms/classes | Cache stale/miss | N/A — this module caches nothing | N/A | N/A | Backend doc §8 ("Not applicable — no service in this module reads or writes Redis") |
| Enrol a pupil | Queue failure | N/A — no queue involved anywhere in this module | N/A | N/A | Backend doc §9 ("Not applicable") |
| List classes | Unsupported filter or sort option | ?sort=nonexistent | Silently ignored — the ordering is always the fixed structural one | No error, just an order different from what may have been expected | classes-read.service.ts |
| Update a class | Concurrent capacity edit and enrolment | Two operators act on the same class at once | Serialized by the shared FOR UPDATE row lock | Correct, not racy — the second operation simply waits | classes-write.service.ts, class-enrollments.service.ts |
12.6 Flow-to-Data Trace
| Flow | Reads | Writes | Cache | Jobs/Events | Response Fields |
|---|---|---|---|---|---|
| List classes | classes + grade/section/room/teacher joins, live occupancy | — | None | None | id, publicId, academicSession, grade, section, shift, name, capacity, enrolledCount, room, classTeacher, isActive, createdAt, updatedAt |
| Class options | classes (active), grade/section, live occupancy | — | None | None | items[]: {publicId, grade, section, shift, name, capacity, enrolledCount, isActive}, truncated |
| Create/update class | Session/grade/section/room/teacher lookups, live occupancy on capacity change | classes | None | None | Same fields as list |
| Delete class | classes, student_class_enrollments (FK) | classes | None | None | null, or 409 CLASS_HAS_ENROLLMENTS |
| Class roster | student_class_enrollments join students/users, scoped | — | None | None | enrollmentId, student{id, studentId, admissionNumber, fullName, recordStatus}, status, enrolledOn, endedOn |
| Enrol/transfer | classes (locked), students, predecessor student_class_enrollments | student_class_enrollments (insert + predecessor update) | students:list:* cleared | Activity log entry | EnrollmentDto |
| Withdraw | classes (locked), student_class_enrollments | student_class_enrollments (update) | students:list:* cleared | Activity log entry | EnrollmentDto |
| Correct | student_class_enrollments, classes (locked) | student_class_enrollments (update) | students:list:* cleared | Activity log entry | EnrollmentDto |
| Remove | student_class_enrollments, classes (locked) | student_class_enrollments (delete) | students:list:* cleared | Activity log entry | null |
| Pupil history | student_class_enrollments join classes/grades/sections/academic_sessions | — | None | None | EnrollmentDto[] (no capacity/room/teacher fields) |
12.7 Experience Quality Checklist
- The doc explains what the actor is trying to accomplish (define the year's classes; place, move, and remove pupils from them correctly).
- The doc explains what the backend does that the actor does not see (row locking, the ascending-lock-order deadlock avoidance, the occupancy/roster shared filter, the identity-vs-resource uniqueness asymmetry).
- The doc covers every minor flow and branch, including ones a casual read of the controllers would group away (re-sent-unchanged room/teacher skipping re-validation,
sort/orderbeing silently ignored everywhere in this module). - The doc includes admin, staff/teacher, and (explicitly, by absence) guest and worker/system coverage.
- The doc explains business logic, tradeoffs, and rationale (§12.3, §12.4).
- The doc maps every flow to API routes and backend side effects (§12.6, and every flow's Main Flow table).
- The doc includes diagrams appropriate to each flow type (sequence per flow, two state diagrams, one error-branch flowchart, two flow-to-data diagrams).
- The doc covers edge cases and failure recovery (§10, §12.5).
13. Completion Checklist
- Every feature, minor action, and submodule capability is listed (§4, §12.1).
- Every actor has allowed and forbidden behavior listed (§3) — including the actors that do not exist for this module (guest, worker/system), stated explicitly rather than omitted.
- Every major and minor flow includes steps, branches, and diagrams (§5).
- Both real lifecycles (
isActiveon classes,enrollment_status) have transition tables and state diagrams (§7). - Every flow links to the API and backend docs.
- No claim in this document is unverified against the current source files cited in the backend and API docs, including the constraint probe's accept/reject cases.
See Also
- API doc:
/docs/developer/classes/api - Backend doc:
/docs/developer/classes/backend
Academic Sessions API Reference
Complete API contracts for the academic sessions lookup, including routes, auth, DTOs, responses, errors, and the split enforcement of the "one current session" rule.
Classes Backend Documentation
Backend architecture, data model, services, locking, security, and runtime rules for grades, sections, rooms, classes, and student class enrolments.