Skoolsewa - Ecommerce Docs
Developer ResourcesClasses

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 TypeFiles or DocsWhat 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 documentsRoute surface, actors, permissions, and response-visible behavior.
Schemapackages/db/src/schema/school/classes.ts, migration packages/db/src/migrations/0011_class_module.sqlConstraints that drive the edge cases and error branches below.
Constraint probepackages/db/src/scripts/probe-class-module-constraints.sqlConfirmed accept/reject behavior for every unique index and CHECK constraint.

2. Feature Summary

FieldValue
ModuleClasses (grades, sections, rooms, classes, student class enrolments)
SubmoduleGrades (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 valueLets 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.
ActorsAdmin 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 pointsGET /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 outputsPersisted 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 docsAPI, Backend.

3. Actor Matrix

ActorCan DoCannot DoAuth RequirementNotes
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 permissionNothing 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 permissionList 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/_DELETEThe 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_UPDATEEnrol/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.
SuperadminEverything 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/systemNo automated actor touches this module.Everything, since none exists.N/ANo 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

CapabilitySurfaceActorRoute/TriggerState ReadState WrittenLinked API Section
List gradesAdmin/Staff/TeacherAny holder of Classes_READGET /api/gradesgrades8.1
List/search sectionsAdmin/Staff/TeacherAny holder of Sections_READGET /api/sectionssections8.2
Create a sectionAdminSections_CREATEPOST /api/sectionssections (name pre-check)sections8.3
Rename/reorder/retire a sectionAdminSections_UPDATEPATCH /api/sections/:publicIdsectionssections8.4
Delete a sectionAdminSections_DELETEDELETE /api/sections/:publicIdsections, classes (FK check)sections8.5
List/search/filter roomsAdmin/Staff/TeacherAny holder of Rooms_READGET /api/roomsrooms8.6
Create a roomAdminRooms_CREATEPOST /api/roomsrooms (number pre-check)rooms8.7
Rename/relocate/retire a roomAdminRooms_UPDATEPATCH /api/rooms/:publicIdroomsrooms8.8
Delete a roomAdminRooms_DELETEDELETE /api/rooms/:publicIdrooms, classes (existence check)rooms8.9
List/search/filter classesAdmin/Staff/TeacherAny holder of Classes_READGET /api/classesclasses joined to grade/section/room/teacher, plus a live occupancy count8.10
Read class options for the capacity chart and admission cascadeAdmin/Staff/TeacherClasses_READGET /api/classes/optionsclasses (active only), grade/section, live occupancy8.11
Read one classAdmin/Staff/TeacherClasses_READGET /api/classes/:publicIdSame joins as the list, single row8.12
Create a classAdminClasses_CREATEPOST /api/classesacademic_sessions, grades, sections, rooms, staff+designations (all existence/active checks)classes8.13
Update a classAdminClasses_UPDATEPATCH /api/classes/:publicIdSame lookups as create, plus a locked read of the target row and a live occupancy countclasses8.14
Delete a classAdminClasses_DELETEDELETE /api/classes/:publicIdclasses, student_class_enrollments (FK check)classes8.15
View a class's rosterAdmin/Staff/Teacher (row-scoped)Students_READGET /api/classes/:publicId/studentsstudent_class_enrollments joined to students/users, row-scoped8.16
Enrol or transfer a pupil into a classAdmin (Students_UPDATE holder)Students_UPDATEPOST /api/classes/:publicId/enrollmentsclasses (locked), students, academic_sessions, existing active enrolmentstudent_class_enrollments; closes the predecessor row on a transfer8.17
Withdraw a pupil from a classAdmin (Students_UPDATE holder)Students_UPDATEDELETE /api/classes/:publicId/enrollments/:studentIdstudent_class_enrollmentsstudent_class_enrollments (status/endedOn)8.18
Correct an enrolment's date/statusAdmin (Students_UPDATE holder)Students_UPDATEPATCH /api/enrollments/:idstudent_class_enrollmentsstudent_class_enrollments8.19
Remove an enrolment entered in errorAdmin (Students_UPDATE holder)Students_UPDATEDELETE /api/enrollments/:idstudent_class_enrollmentsstudent_class_enrollments (deleted)8.20
View a pupil's own class historyAdmin/Staff/Teacher (row-scoped)Students_READGET /api/students/:id/enrollmentsstudent_class_enrollments joined to classes/grades/sections/academic_sessions8.21
Populate a class-setup dropdownAdmin (indirect)Any admin creating a classGET /api/grades, GET /api/sections?pagination=false&isActive=true, GET /api/rooms?pagination=false&isActive=trueSame as the list endpoints above8.1, 8.2, 8.6
Populate the admission form's grade/section/shift cascade and capacity chartAdmin/Staff (indirect)Any actor admitting a pupilGET /api/classes/options?academicSessionId=...classes, live occupancy8.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

StepActor/SystemActionResultSource
1Admin/Staff/TeacherOpens a screen needing the grade list.GET /api/grades.Controller.
2BackendReads 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

BranchConditionBehaviorError/Result
sort/order query params suppliede.g. ?sort=name&order=ascSilently ignoredListGradesQueryDto 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=falseReading the whole table for a select boxAllowed and capped at PaginationUtil.UNPAGINATED_HARD_CAP (1000) — grades are a reference table, not a person table.Full list in one response.
Missing permissionRole lacks Classes_READRefused before any query runs.403 PERMISSION_INSUFFICIENT.
No route to add a gradeAn admin wants "Playgroup" addedNot 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/_DELETE as appropriate.
  • Create/rename: no other active section shares the name, case-insensitively.
  • Delete: no class currently references this section.

Main Flow

StepActor/SystemActionResultSource
1AdminLists, adds, renames, reorders, retires, or deletes a section.GET/POST/PATCH/DELETE /api/sections[...].Controller.
2BackendValidates 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

BranchConditionBehaviorError/Result
sortOrder omitted on createNo value suppliedComputed 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 sortOrdersortOrder is deliberately not uniqueAllowed by design — ordering is (sort_order, lower(name)), so a tie breaks alphabetically.No error, deterministic order.
Retired section's name reusedA retired section's name is claimed by a new oneAllowed — 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 classDELETE on a section any class (active or retired) points atRefused, naming the blocker.409 SECTION_IN_USE.
Not foundDeleted/invalid publicId404 SECTION_NOT_FOUND.
sort/order query params supplied on the liste.g. ?sort=nameSilently 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/_DELETE as appropriate.
  • Create/rename: no other active room shares (building, roomNumber), case-insensitively.
  • Delete: no class currently references this room.

Main Flow

StepActor/SystemActionResultSource
1AdminLists, filters (by building/floor/search), adds, edits, or deletes a room.GET/POST/PATCH/DELETE /api/rooms[...].Controller.
2BackendChecks 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

BranchConditionBehaviorError/Result
Same room number, different buildinge.g. "101" in "Main Block" and "101" in "Annexe"Allowed — uniqueness is scoped to the building, not global.Success in both.
Room-in-use checkDELETE on a room a class points atCannot rely on a caught foreign-key violationclasses.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 boundaryfloor exactly -5 or 200Accepted — the bounds are inclusive.Success.
Empty-string namePATCH {"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 reusedA retired room's (building, roomNumber) claimed by a new oneAllowed — 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.
  • pagination must not be false — see the edge case below.

Main Flow

StepActor/SystemActionResultSource
1ActorOpens the class list, optionally filtering by grade, section, shift, room, class teacher, hasRoom/hasClassTeacher, isActive, or a free-text search.GET /api/classes?....Controller.
2BackendResolves 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

BranchConditionBehaviorError/Result
pagination=false requestedThe row embeds the class teacher's full name and employee code — staff identityRefused 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 isCurrentA fresh database, or the office has not yet marked a session currentReturns { 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 suppliede.g. ?sort=nameSilently 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 classclasses.name is nullableMatched 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 togetherBoth filters suppliedBoth 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

StepActor/SystemActionResultSource
1Actor/screenOpens the admission form, or the capacity chart.GET /api/classes/options?academicSessionId=....Controller.
2BackendReads 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

BranchConditionBehaviorError/Result
Response shapeThis is the one endpoint in the module whose data is an object, not an arraydata: { 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 sessionRealistic only for a very large or long-lived deploymentOne 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 classesisActive: falseExcluded 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 givenFresh 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

StepActor/SystemActionResultSource
1ActorOpens a class's detail screen.GET /api/classes/:publicId.Controller.
2BackendLoads the row with every join, then computes its occupancy with the same shared countFor the list uses.ClassDto.Service.

Branches and Edge Cases

BranchConditionBehaviorError/Result
Not foundDeleted/invalid publicId404 CLASS_NOT_FOUND.
Route ordering:publicId could otherwise swallow the literal options segmentGuarded 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

StepActor/SystemActionResultSource
1AdminSubmits the "add class" form.POST /api/classes {academicSessionId, gradeId, sectionId, shift, capacity, name?, classTeacherId?, roomId?}.Controller.
2BackendValidates 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.
3BackendInserts 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

BranchConditionBehaviorError/Result
Retired session/grade/sectionAny of the three resolves but is isActive: falseRejected before the insert.422 CLASS_SESSION_INACTIVE / CLASS_GRADE_INACTIVE / CLASS_SECTION_INACTIVE.
Duplicate identitySame (session, grade, section, shift) already exists in this session — even if the existing row is retiredRefused — identity uniqueness is total, not partial on is_active, deliberately.409 CLASS_IDENTITY_TAKEN.
Room double-booked in the same shiftSame room, same session, same shift already held by another active classRefused.409 CLASS_ROOM_OCCUPIED.
Room shared across shiftsSame room, same session, different shiftAllowed — a room may run a morning class and a day class.Success.
Teacher double-booked in the same shiftSame staff member already class teacher of another active class, same session, same shiftRefused.409 CLASS_TEACHER_ALREADY_ASSIGNED.
Teacher not a teacherclassTeacherId resolves to a staff row whose designation has isTeaching: falseRefused — 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 unknownclassTeacherId does not resolve, or resolves to a soft-deleted staff row404 CLASS_TEACHER_NOT_FOUND.
Capacity out of rangeBelow 1 or above 500Rejected by the DTO before the service runs.400 VALIDATION_FAILED.
Name omittedNo name suppliedStored 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

StepActor/SystemActionResultSource
1AdminSubmits an edit — any subset of name, capacity, classTeacherId, roomId, isActive.PATCH /api/classes/:publicId.Controller.
2BackendTakes 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

BranchConditionBehaviorError/Result
Identity fields sentBody includes academicSessionId/gradeId/sectionId/shiftRejected 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 overridecapacity: 20 on a class with 25 active pupilsRefused, naming the exact counts in the message.422 CLASS_CAPACITY_BELOW_ENROLLED.
Same, with allowOverCapacity: trueSame scenario, override setAllowed — 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 unchangedroomId equals the class's current roomNot 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 unchangedSame logic as roomSame behavior — no re-check if unchanged.Success, even if the teacher's designation has since lost isTeaching.
roomId/classTeacherId set to nullExplicit null in the bodyClears the assignment.Success; the row's room_id/class_teacher_id becomes NULL.
Concurrent capacity edit and enrolmentTwo operators act on the same class simultaneouslySerialized 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: falseDeactivating the classAllowed 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_enrollments row references this class.

Main Flow

StepActor/SystemActionResultSource
1AdminChooses "delete" on a class.DELETE /api/classes/:publicId.Controller.
2BackendAttempts 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

BranchConditionBehaviorError/Result
Any enrolment references itActive, transferred, or withdrawn — the FK has no status filterRefused.409 CLASS_HAS_ENROLLMENTS.
Not foundDeleted/invalid publicId404 CLASS_NOT_FOUND.
The recommended correction pathAn admin picked the wrong grade/section at creation, and pupils are already enrolledDeactivate 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.
  • pagination must not be false.

Main Flow

StepActor/SystemActionResultSource
1ActorOpens a class's roster tab.GET /api/classes/:publicId/students?status=active.Controller.
2BackendResolves 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

BranchConditionBehaviorError/Result
pagination=false requestedA class roll is every enrolled child's name in one responseRefused, for the same reason /students and /staff refuse it.400 PAGINATION_LIMIT_INVALID.
Caller's scope is narrower than the classe.g. a "Parent Portal"-style role whose scope_kind defaults to selfSees only the pupils their scope covers, potentially zero, never the whole class.Fewer rows than the class actually holds, no error.
status omittedNo status query paramDefaults 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-inactiveThe child left the school entirelyExcluded 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 foundInvalid/deleted publicId404 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: true is set.

Main Flow

StepActor/SystemActionResultSource
1AdminSubmits the pupil id (and optionally a back/forward-dated enrolledOn) against the target class.POST /api/classes/:publicId/enrollments {studentId, enrolledOn?, allowOverCapacity?}.Controller.
2BackendChecks 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.
3BackendRecomputes 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

BranchConditionBehaviorError/Result
Pupil already active in the target classRe-submitting the same enrolmentNo-op — the existing row is returned unchanged.Success, idempotent.
Pupil already active in a different class, same sessionA moveTreated 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 enrolledOnA back-dated transfer that would close the predecessor before it startedRefused 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 fullenrolledCount >= capacity, allowOverCapacity not setRefused, 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 setallowOverCapacity: trueAllowed. 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 sessionDate before startDate or after endDateRefused.409 ENROLLMENT_DATE_OUTSIDE_SESSION.
enrolledOn before the pupil's own admission dateA pupil cannot be enrolled in a class before the school admitted themRefused.409 ENROLLMENT_DATE_BEFORE_ADMISSION.
Class retiredisActive: false, checked once on the initial read and again after the lock is takenRefused 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-deletedstudents.deletedAt setRefused — the pupil's record must be restored first.409 ENROLLMENT_STUDENT_DELETED.
Two simultaneous transfers, X→Y and Y→XA genuine cross-transfer raceDeadlock-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 sessionA race past the pupil-lookup stepThe loser hits the partial unique index directly.409 ENROLLMENT_ALREADY_ACTIVE, retryable on a re-read.
enrolledOn omittedNo date suppliedDefaults 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 scopeThe pupil is real but not visible to the caller's roleRefused 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

StepActor/SystemActionResultSource
1AdminWithdraws a pupil from their current class.DELETE /api/classes/:publicId/enrollments/:studentId.Controller.
2BackendLocks 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

BranchConditionBehaviorError/Result
No active enrolment for this pupil in this classAlready withdrawn/transferred, or never enrolled here404 ENROLLMENT_NOT_FOUND.
Future-dated enrolledOn withdrawn todayThe pupil's active row was itself back-dated into the futureendedOn 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. deletionAn enrolment was entered by mistake, not a genuine departureWithdrawal 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

StepActor/SystemActionResultSource
1AdminEdits enrolledOn and/or status on a specific enrolment row.PATCH /api/enrollments/:id {enrolledOn?, status?}.Controller.
2BackendResolves 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

BranchConditionBehaviorError/Result
classId in the bodyAn attempt to move the pupil through this routeNot 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 suppliedManually closing a rowendedOn computed the same max(enrolledOn, today) way as withdraw.Success.
enrolledOn moved past an already-set endedOnA correction that would leave the row incoherentendedOn is pulled forward to match, rather than leaving a violated CHECK to reach the database.Success, silently coherent.
Enrolment not foundInvalid/deleted id404 ENROLLMENT_NOT_FOUND.
Caller outside the pupil's scopeResolved after reading the enrolment, before any writeRefused.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

StepActor/SystemActionResultSource
1AdminChooses "remove" on a mistaken enrolment.DELETE /api/enrollments/:id.Controller.
2BackendLocks the owning class, hard-deletes the row, and records the deletion explicitly in the activity log.Row gone.Service.

Branches and Edge Cases

BranchConditionBehaviorError/Result
A pupil's only enrolment for a session is removedThe pupil now has none for that sessionAllowed — 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 rowThe most common real case: fixing a same-day mis-clickAllowed, no different from removing a closed one.Success.
Enrolment not foundInvalid/deleted id404 ENROLLMENT_NOT_FOUND.
Why this exists alongside withdrawOffering only "withdraw" would force an operator to record a fictional departure for a child who never actually leftDeliberate 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

StepActor/SystemActionResultSource
1ActorOpens a pupil's profile "enrolment history" section.GET /api/students/:id/enrollments?academicSessionId=?.Controller (on StudentsController, not a class-side route).
2BackendConfirms 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

BranchConditionBehaviorError/Result
Route ownershipLives on StudentsController, not a controller in this moduleFollows 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 paramsInherited from QueryDtoSilently 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 yetA pupil admitted but not yet placed in a classdata: [].Empty state, not an error.
Caller outside the pupil's scopePupil real but not visible to the caller404 (never 403).
academicSessionId givenNarrowed to one yearOnly 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

SurfaceWhat it shows
GET /students — the rollcurrentClass on every row; the admin table renders it as a link to the class.
GET /students/:idThe same object on the pupil's record.
PATCH /students/:idAccepts 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 classId leaves 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 version now covers their class. Every enrolment write touches students.updated_at, so a transfer made from the class roster invalidates a pupil edit form that was already open. Saving it is refused with PEOPLE_STALE_RECORD rather 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:

EntityFromEvent/ActionToGuard ConditionSide Effects
ClassisActive: truePATCH isActive: falseisActive: falseNone — 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.
ClassisActive: falsePATCH isActive: trueisActive: trueThe 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.
ClassEitherDELETERow no longer existsNo 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.

FromEvent/ActionToGuard ConditionSide Effects
(none)POST /classes/:publicId/enrollments, no prior active row this sessionactiveClass active, has room (or override), date valid.New row, endedOn: null.
active (in class A)POST /classes/:publicId/enrollments for class B, same sessionactive (in B); the class-A row becomes transferredSame validations as a fresh enrolment, applied to the target class.Predecessor's endedOn set to the new enrolledOn; both writes in one transaction.
activeDELETE /classes/:publicId/enrollments/:studentIdwithdrawnAn active row exists for this pupil in this class.endedOn set to max(enrolledOn, today).
activePATCH /enrollments/:id {status: "transferred"|"withdrawn"}The requested statusEnrolment exists.endedOn computed the same way as withdraw, unless already coherent.
active, transferred, or withdrawnDELETE /enrollments/:idRow deletedEnrolment 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

FlowDB WritesCache EffectsJobsRealtimeAnalyticsNotifications
List gradesNoneNone — ClassesReadService/GradesService do not cache reads in this moduleNoneNoneNoneNone
Create/update/delete a sectionsectionsNone — this module's list reads are not cache-aside, unlike the school module's lookupsNoneNoneNoneNone
Create/update/delete a roomroomsNoneNoneNoneNoneNone
List/read classes / optionsNoneNoneNoneNoneNoneNone
Create/update a classclassesNoneNoneNoneNoneNone
Delete a classclassesNoneNoneNoneNoneNone
View a rosterNoneNoneNoneNoneNoneNone
Enrol/transfer a pupilstudent_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"NoneNoneNoneNone
Withdraw a pupilstudent_class_enrollments (update)students:list:* clearedNoneNoneNoneNone
Correct an enrolmentstudent_class_enrollments (update)students:list:* clearedNoneNoneNoneNone
Remove an enrolmentstudent_class_enrollments (delete)students:list:* clearedNoneNoneNoneNone
View a pupil's historyNoneNoneNoneNoneNoneNone

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

ScenarioTriggerUser/System ExperienceRecoverySource
Class identity already takenCreating a class with the same session/grade/section/shift as an existing (even retired) class409 CLASS_IDENTITY_TAKEN.Choose a different grade/section/shift, or reactivate the existing class instead.ClassesWriteService.
Room or teacher double-booked in a shiftCreating/updating a class into a room or teacher already committed that shift409 CLASS_ROOM_OCCUPIED / CLASS_TEACHER_ALREADY_ASSIGNED.Pick a different room/teacher, or a different shift.ClassesWriteService.
Class at capacityEnrolling into a full class without an override409 CLASS_AT_CAPACITY, with the counts in the message.Resubmit with allowOverCapacity: true, or choose a different class.ClassEnrollmentsService.
Capacity lowered below the live rollEditing a class's capacity field below its current enrolment422 CLASS_CAPACITY_BELOW_ENROLLED.Resubmit with allowOverCapacity: true, or withdraw/transfer pupils first.ClassesWriteService.
Hard delete blocked by a referenceDeleting a section/room still used by a class, or a class still holding an enrolment409 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 admissionA back- or forward-dated enrolment that does not make sense409 ENROLLMENT_DATE_OUTSIDE_SESSION / ENROLLMENT_DATE_BEFORE_ADMISSION.Correct the date.ClassEnrollmentsService.
Pupil out of the caller's scopeThe studentId/:id resolves to a real pupil the caller's role cannot see404 (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/roomTwo concurrent creates for the identical name/numberThe 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-transferTwo pupils traded between the same two classes at onceBoth requests proceed serially — deadlock-free by the ascending-id lock order.No recovery needed; this is not an error case.ClassEnrollmentsService.
Permission deniedActive role lacks the route's required permission403, 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

FeatureMinor BehaviorActorTriggerUser/System ResultBackend Side EffectSource
List grades/sections/rooms/classessort/order query fields inherited but never readAny readerAny GET with ?sort=nameNo effect — order is always the fixed structural oneNonegrades.service.ts, sections.service.ts, rooms.service.ts, classes-read.service.ts
List classesRefuses pagination=falseAny readerGET /api/classes?pagination=false400, not a large responseNone — the request is rejected before any query runsclasses-read.service.ts
List classesDefaults to the current academic session, and returns empty rather than erroring when none is currentAny readerGET /api/classes with no academicSessionIdEmpty list on a fresh database, not a 500None — read-onlyclasses-read.service.ts
GET /classes/optionsdata is an object ({items, truncated}), not an array — the only such endpoint in the moduleAny readerGET /api/classes/optionsA client must read data.itemsNoneclass.dto.ts
Update a classRe-sending the room/teacher unchanged skips the retired-active re-checkAdminPATCH with roomId equal to the current value, where that room has since been retiredSuccess, not 422 CLASS_ROOM_INACTIVENone — otherwise a class holding a retired resource could never be edited at allclasses-write.service.ts
Update a classIdentity fields are absent from the DTO, not merely ignoredAdminPATCH {"academicSessionId": 9}400 VALIDATION_FAILED — unknown field, not a silent no-opNoneclass.dto.ts
Deactivate a classNo enrolment check at allAdminPATCH {"isActive": false} on a class with active pupilsSuccessExisting enrolments untouched; room/teacher slots releasedclasses-write.service.ts
Delete a classIdentity is only truly freed on hard delete, never on deactivationAdminDELETE after isActive: false on the same classThe identity (session, grade, section, shift) becomes creatable again only nowRow removedclasses.ts schema (classes_identity_unique, not partial)
Enrol a pupilSame-target re-submission is a no-op, not an errorAdminPOST .../enrollments for a pupil already active in that exact classThe existing row is returned, unmodifiedNoneclass-enrollments.service.ts
Enrol a pupilOver-capacity override is audited with counts, capacity edits are notAdminallowOverCapacity: true on an enrolment vs. on a capacity editBoth succeedOnly the enrolment path calls recordActivity with capacity/enrolledBefore/allowOverCapacity metadataclass-enrollments.service.ts vs. classes-write.service.ts
Withdraw a pupilendedOn is max(enrolledOn, today), never plain "today"AdminWithdrawing a future-dated active enrolment on the same day it was enteredendedOn equals enrolledOn, not a date before itPrevents a CHECK violation from a naive "today"class-enrollments.service.ts
Withdraw vs. removeTwo distinct deletion-shaped actions for two distinct real eventsAdminDELETE .../enrollments/:studentId vs. DELETE /enrollments/:idA status + end date vs. no trace at allThe former updates a row, the latter deletes itclass-enrollments.controller.ts
Correct an enrolmentclassId is not a field on the correction DTOAdminPATCH /enrollments/:id {"classId": 5}400 VALIDATION_FAILEDNoneenrollment.dto.ts
View a rosterOccupancy filter and roster filter share the exact same predicateAny scoped readerGET /classes/:publicId/studentsA pupil excluded from the capacity count is also excluded from the rosterNone — read-only, but a deliberately shared filter, not two independent ones that could driftclass-roster.service.ts, class-occupancy.service.ts
View a pupil's historysort/order/search inherited but unused; order is always newest-firstAny scoped readerGET /students/:id/enrollments?sort=enrolledOn&order=ascStill newest-firstNoneclass-enrollments.service.ts (listForStudent)
Grade managementNo create/update/delete route exists at allAdminAttempting to add a grade via the APINot possible — no POST/PATCH/DELETE /api/grades handler existsN/Agrades.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

RuleBusiness ReasonActor ImpactEnforced InAPI ImpactBackend ImpactTests
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 DecisionUser BenefitEngineering BenefitAlternativeTradeoffRisk
Identity uniqueness total, room/teacher uniqueness partialA retired class's room/teacher become reusable immediately; a retired class's identity stays permanently reservedOne index shape expresses two different intents without a service-level workaroundMake all three partial, as an earlier design didAn 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 hatchLow today — the current shape was chosen specifically to close that trap.
Capacity as a soft warning, not a hard capAn admin can always admit "one more" pupil when the school's real-world judgment says soNo separate "request an exception" workflow neededHard-block enrolment at capacityEvery 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 oneThe admission cascade and capacity chart never leak staff identity to a caller who only needs grade/section/shift/occupancyA single response shape can stay honest about what data it actually needs to carryOne endpoint with an optional "slim" query flagTwo 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 logicKeeps the module dependency graph acyclic with zero forwardRef anywhere in the appA pure domain writer is trivially testable without mocking authorizationGive the service direct access to PeopleAccessServiceEvery current and future caller must remember to call assertCanAccess first — a mistake here is a real information-disclosure risk, not merely a style violationModerate, mitigated by every existing caller having its own integration test asserting the 404 on an out-of-scope pupil.
No completed enrolment statusAvoids representing a feature (year rollover/promotion) that does not exist yet, which would otherwise look implemented to every reader of the schemaEvery check constraint and query stays honest about what is actually enforcedAdd completed now, unused, for forward-compatibilityReading "which pupils finished this class" requires knowing the session is past and status is active, rather than a dedicated statusLow — clearly documented in the schema's own comment; the gap is intentional and named.
Withdraw and delete-enrolment as two separate routesAn operator always has the semantically correct action available, never forced to lie about what happenedTwo small, single-purpose service methods rather than one method branching on caller intentOne DELETE that always hard-deletes, with a separate "mark as left" flag on the pupil insteadA UI must present two distinct actions rather than one, and must not let an operator reach for the wrong one out of habitLow — the API and this document both name the distinction explicitly.
Grades read-only, gated under Classes_READ with no dedicated permission moduleSimpler permission catalog for a set of values that, in practice, almost never changesOne fewer module × five actions in the generated catalogGive grades the same full CRUD treatment as sections/roomsA school that genuinely needs a grade the seed does not name has no product path to add itModerate — a real, documented gap rather than a hypothetical one; see 12.3.

12.5 Flow Edge-Case Matrix

FlowEdge CaseTriggerExpected BehaviorUser/System FeedbackSource
List classesEmpty stateNo classes in the resolved sessiondata: [], count: 0Empty list rendered, no errorclasses-read.service.ts
List classesFirst useThe very first class ever created in a sessionAppears on the next matching list callImmediate visibility — no cache to be staleclasses-read.service.ts (no caching in this module)
Enrol a pupilLast seatenrolledCount === capacity - 1 before the writeAccepted; the class is now exactly fullSuccess, no warningclass-occupancy.service.ts
Enrol a pupilDuplicate actionThe same enrol request submitted twice in quick succession, same target classSecond call is a no-op — the existing active row is returned unchangedSuccess both times, one row existsclass-enrollments.service.ts
Enrol a pupilConcurrent actionTwo operators enrol the same pupil into two different classes simultaneouslyThe loser hits the partial unique index directly409 ENROLLMENT_ALREADY_ACTIVEclass-enrollments.service.ts (translate)
Enrol a pupilExpired stateThe 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 rangeSuccess — there is no separate "session is closed" guard beyond the date rangeclass-enrollments.service.ts
Enrol/withdraw/correct/removePermission mismatchCaller holds Students_READ but not Students_UPDATERefused before any query runs403 PERMISSION_INSUFFICIENTRoleGuard
Enrol/withdraw/correct/removeGuest limitationN/A — no guest actor can reach any route in this moduleN/AN/AActor matrix
Create a classMissing dependencygradeId/sectionId does not resolveRefused before any write404 GRADE_NOT_FOUND / SECTION_NOT_FOUNDclasses-write.service.ts
List grades/sections/rooms/classesCache stale/missN/A — this module caches nothingN/AN/ABackend doc §8 ("Not applicable — no service in this module reads or writes Redis")
Enrol a pupilQueue failureN/A — no queue involved anywhere in this moduleN/AN/ABackend doc §9 ("Not applicable")
List classesUnsupported filter or sort option?sort=nonexistentSilently ignored — the ordering is always the fixed structural oneNo error, just an order different from what may have been expectedclasses-read.service.ts
Update a classConcurrent capacity edit and enrolmentTwo operators act on the same class at onceSerialized by the shared FOR UPDATE row lockCorrect, not racy — the second operation simply waitsclasses-write.service.ts, class-enrollments.service.ts

12.6 Flow-to-Data Trace

FlowReadsWritesCacheJobs/EventsResponse Fields
List classesclasses + grade/section/room/teacher joins, live occupancyNoneNoneid, publicId, academicSession, grade, section, shift, name, capacity, enrolledCount, room, classTeacher, isActive, createdAt, updatedAt
Class optionsclasses (active), grade/section, live occupancyNoneNoneitems[]: {publicId, grade, section, shift, name, capacity, enrolledCount, isActive}, truncated
Create/update classSession/grade/section/room/teacher lookups, live occupancy on capacity changeclassesNoneNoneSame fields as list
Delete classclasses, student_class_enrollments (FK)classesNoneNonenull, or 409 CLASS_HAS_ENROLLMENTS
Class rosterstudent_class_enrollments join students/users, scopedNoneNoneenrollmentId, student{id, studentId, admissionNumber, fullName, recordStatus}, status, enrolledOn, endedOn
Enrol/transferclasses (locked), students, predecessor student_class_enrollmentsstudent_class_enrollments (insert + predecessor update)students:list:* clearedActivity log entryEnrollmentDto
Withdrawclasses (locked), student_class_enrollmentsstudent_class_enrollments (update)students:list:* clearedActivity log entryEnrollmentDto
Correctstudent_class_enrollments, classes (locked)student_class_enrollments (update)students:list:* clearedActivity log entryEnrollmentDto
Removestudent_class_enrollments, classes (locked)student_class_enrollments (delete)students:list:* clearedActivity log entrynull
Pupil historystudent_class_enrollments join classes/grades/sections/academic_sessionsNoneNoneEnrollmentDto[] (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/order being 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 (isActive on 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

On this page

Classes Features and Flows1. Documentation Evidence2. Feature Summary3. Actor Matrix4. Capability Matrix5. User-Facing Flows5.1 List gradesSummaryPreconditionsMain FlowSequence DiagramBranches and Edge Cases5.2 List, create, rename, reorder, retire, and delete a sectionSummaryPreconditionsMain FlowSequence DiagramBranches and Edge Cases5.3 List, create, update, and delete a roomSummaryPreconditionsMain FlowSequence DiagramBranches and Edge Cases5.4 List and filter classesSummaryPreconditionsMain FlowSequence DiagramBranches and Edge Cases5.5 Read the class options listSummaryPreconditionsMain FlowSequence DiagramBranches and Edge Cases5.6 Read a single classSummaryPreconditionsMain FlowBranches and Edge Cases5.7 Create a classSummaryPreconditionsMain FlowSequence DiagramBranches and Edge Cases5.8 Update a classSummaryPreconditionsMain FlowSequence DiagramBranches and Edge Cases5.9 Delete a classSummaryPreconditionsMain FlowBranches and Edge Cases5.10 View a class's rosterSummaryPreconditionsMain FlowSequence DiagramBranches and Edge Cases5.11 Enrol or transfer a pupil into a classSummaryPreconditionsMain FlowSequence DiagramBranches and Edge Cases5.12 Withdraw a pupil from a classSummaryPreconditionsMain FlowSequence DiagramBranches and Edge Cases5.13 Correct an enrolment's date or statusSummaryPreconditionsMain FlowBranches and Edge Cases5.14 Remove an enrolment entered in errorSummaryPreconditionsMain FlowBranches and Edge Cases5.15 View a pupil's own class historySummaryPreconditionsMain FlowBranches and Edge Cases5.16 See and change a pupil's class from the pupil's own screens6. Admin Flows6.1 Create6.2 List / Read Detail6.3 Update6.4 Reorder6.5 Activate/deactivate6.6 Soft delete6.7 Restore6.8 Hard delete6.9 Export/import6.10 Moderation6.11 Manual retry6.12 Enrolment desk (this module's distinctive admin surface)7. Lifecycle and State Transitions7.1 A class's isActive flag7.2 A student's enrolment status9. Data and Side Effects by Flow10. Error and Recovery Flows11. Diagrams Required Per Module12. Mandatory Feature and Flow Deep-Dive Pack12.1 Feature Inventory With Minor Behaviors12.2 Business Process Diagram Pack12.3 Business Rules and Policy Traceability12.4 Tradeoffs and Product Rationale12.5 Flow Edge-Case Matrix12.6 Flow-to-Data Trace12.7 Experience Quality Checklist13. Completion ChecklistSee Also