Classes API Reference
Complete API contracts for grades, sections, rooms, classes, and student class enrolments, including routes, auth, DTOs, responses, errors, and examples.
Classes - API Reference
Audience: Frontend engineers, mobile engineers, backend engineers, QA, and API consumers.
Scope: Admin-facing APIs owned by AcademicStructureModule (grades, sections, rooms, classes, and the class-side enrolment routes) and, for one read route, StudentsController. No public or /api/mobile/... route exists in this module.
1. Documentation Evidence
| Area | Files Inspected | What Was Verified |
|---|---|---|
| Controllers | apps/api/src/modules/academic-structure/grades/grades.controller.ts, .../sections/sections.controller.ts, .../rooms/rooms.controller.ts, .../classes/classes.controller.ts, .../enrollments/class-enrollments.controller.ts, and apps/api/src/modules/people/students/students.controller.ts (the :id/enrollments handler) | Routes, methods, guards, decorators, status codes. |
| DTOs | apps/api/src/modules/academic-structure/dto/grade.dto.ts, section.dto.ts, room.dto.ts, class.dto.ts, enrollment.dto.ts, roll-number.dto.ts, query-boolean.ts | Request, query, response, validation, defaults. |
| Services | grades/grades.service.ts, sections/sections.service.ts, rooms/rooms.service.ts, classes/classes-read.service.ts, classes/classes-write.service.ts, classes/class-roster.service.ts, enrollments-core/class-enrollments.service.ts, enrollments-core/class-occupancy.service.ts, enrollments-core/roll-numbers/roll-number.service.ts | Behavior, side effects, response mapping, errors. |
| Schema | packages/db/src/schema/school/classes.ts, migration packages/db/src/migrations/0011_class_module.sql, migration packages/db/src/migrations/0021_class_roll_number.sql | IDs, enums, persisted fields, constraints. |
| Constraint probe | packages/db/src/scripts/probe-class-module-constraints.sql | Confirmed accept/reject behavior underlying every named error code below. |
| Shared query base | apps/api/src/common/dto/query.dto.ts | Inherited pagination, page, size, sort, order, search fields, and which list endpoints in this module actually read sort/order (none do). |
| Response envelope | apps/api/src/common/dto/response-dto.ts | Exact success envelope shape. |
| Error envelope | apps/api/src/common/filters/all-exceptions.filter.ts | Exact error envelope shape. |
| Errors | apps/api/src/common/types/error-codes.ts (the "SCHOOL DOMAIN: classes, sections and rooms" section, including its "Class roll numbers" subsection) | Every error code this module can produce. |
| Auth | apps/api/src/modules/auth/guards/jwt-auth.guard.ts, apps/api/src/common/authorization/role.guard.ts | Guard chain and identity shape. |
| Permissions | packages/db/src/authorization/permission-catalog.ts | Permission modules/actions this module checks — Classes, Sections, Rooms; grades has no module of its own. |
| Object-level access | apps/api/src/modules/people/shared/people-access.service.ts | scopeFor/applyScope/assertCanAccess, the actual control behind every Students_* route in this document. |
| Pagination utility | apps/api/src/common/utils/pagination.util.ts | Default/max size, offset math, UNPAGINATED_HARD_CAP. |
| Seed data | packages/db/src/seed/seed-reference-data.ts, packages/db/src/seed/seed-auth.ts | The seeded 15 grades / 4 sections, and which roles hold which _READ code. |
| Wiring | apps/api/src/modules/academic-structure/academic-structure.module.ts, apps/api/src/app.module.ts | Route composition and prefix. |
| Existing docs | apps/fumadocs/content/docs/developer/documentation-formats/api-doc-format.mdx, apps/fumadocs/content/docs/developer/school/api.mdx | Format and style baseline. |
2. Module Summary
| Field | Value |
|---|---|
| Module name | AcademicStructureModule (grades, sections, rooms, classes, class-side enrolment routes); one route (:id/enrollments) lives on StudentsController in PeopleModule |
| Module slug | classes |
| Primary actors | Admin for every mutation; staff and teacher roles additionally hold Classes_READ/Sections_READ/Rooms_READ and Students_READ-scoped reads, but not Students_UPDATE — enrolment writes are administrator-only in the current seed |
| API surfaces | Admin only — no @Public() route and no /api/mobile/... route exists anywhere in this document |
| Base route prefixes | /api/grades, /api/sections, /api/rooms, /api/classes, /api/enrollments, plus /api/students/:id/enrollments (the global prefix api is set in apps/api/src/main.ts) |
| Auth model | JwtAuthGuard + RoleGuard, class-level on every controller |
| Persistence | PostgreSQL (grades, sections, rooms, classes, student_class_enrollments); MongoDB (audit log entries written explicitly on every enrolment write, via ActivityRecordService); Redis (write-only invalidation of students:list:* on every enrolment write — no read path in this module is cached) |
| Runtime source of truth | Every table listed above, read live on every request — this module caches nothing on its read side |
| Sibling docs | Backend, Features and flows |
3. Concepts and Terminology
| Term | Meaning | Source File | Used By |
|---|---|---|---|
| Grade | Nursery, LKG, UKG, or 1-12 — a read-only reference list. No permission module of its own; gated under Classes_READ. | packages/db/src/schema/school/classes.ts | GET /grades; the gradeId field on a class. |
| Section | The A/B/C/D-style subdivision a grade is split into — school-editable, full CRUD. | classes.ts | GET/POST/PATCH/DELETE /sections; the sectionId field on a class. |
| Room | A physical room — number, floor, building — school-editable, full CRUD. | classes.ts | GET/POST/PATCH/DELETE /rooms; the roomId field on a class. |
| Class | One grade, one section, one shift, in one academic session — the concrete unit a school runs. Its identity (session, grade, section, shift) is immutable after creation. | classes.ts | Every route under /classes. |
| Shift | "morning" | "day". Part of a class's identity, not an attribute — a double-shift school runs two disjoint rolls through the same rooms and often the same teachers. | classes.ts (classShiftEnum) | shift field on CreateClassDto/ClassDto; part of the identity uniqueness and the room/teacher exclusivity indexes. |
| Class teacher | A staff row whose designation has isTeaching: true. There is no Teachers entity or permission module — a teacher is defined entirely by this join. | classes.ts, people.ts, lookups.ts | classTeacherId on CreateClassDto/UpdateClassDto; ClassTeacherDto on the response. |
| Enrolled count / occupancy | The live count of active enrolments held by pupils who are neither soft-deleted nor record-inactive. Never stored — always computed by ClassOccupancyService. | enrollments-core/class-occupancy.service.ts | enrolledCount on ClassDto/ClassOptionDto; the capacity check on enrol and on capacity edits. |
| Student class enrolment | One row recording that a pupil sat in a class for a stretch of time, with a status and a start/end date. | classes.ts (studentClassEnrollments) | Every route under /classes/:publicId/enrollments and /enrollments. |
| Enrolment status | "active" | "transferred" | "withdrawn". Deliberately no "completed" — year rollover/promotion is a deferred feature. | classes.ts (enrollmentStatusEnum) | status on EnrollmentDto; the status filter on the roster. |
| Roll number | The pupil's position in a class's register. Assigned when they are enrolled; editable afterwards by an administrator; nullable, and unique only among the class's LIVE roll. | classes.ts (studentClassEnrollments.rollNumber) | rollNumber on ClassRosterEntryDto; PATCH /enrollments/:id/roll-number; POST /classes/:publicId/enrollments/renumber. |
| Roll number strategy / auto-renumber (contrast terms, owned by the school module, referenced here because they govern this module's assignment order) | roll_number_strategy ("admission_order" | "alphabetical") decides the order a RENUMBER assigns in; roll_number_auto_renumber decides whether ENROLLING a pupil triggers that renumber automatically (off by default — assignment appends instead). | packages/db/src/schema/school/school-profile.ts | Read by RollNumberService.policy; settable via PATCH /api/school-profile — see /docs/developer/school/api. |
| Object-level scope (contrast term, not owned by this module) | What specific pupils an actor's role permits them to see/act on, resolved by PeopleAccessService. The actual control behind every Students_* route, not merely the permission code. | apps/api/src/modules/people/shared/people-access.service.ts | Every roster/enrolment/history route in this document. |
Public ID (publicId) | The UUIDv7 identifier every PATCH/DELETE/single-row GET in this module addresses a row by. classes/sections/rooms/grades all follow this; staff uses its own uuid PK as its public identifier (no separate publicId column). | classes.ts | Every :publicId route param below. |
4. API Surface Map
| Surface | Method | Path | Actor | Auth/Guard | Permission | Controller | Purpose |
|---|---|---|---|---|---|---|---|
| Admin | GET | /api/grades | Admin/Staff/Teacher | JwtAuthGuard, RoleGuard | Classes_READ | GradesController | List grades — the module's only grade route. |
| Admin | GET | /api/sections | Admin/Staff/Teacher | JwtAuthGuard, RoleGuard | Sections_READ | SectionsController | List/search sections, paginated. |
| Admin | POST | /api/sections | Admin | JwtAuthGuard, RoleGuard | Sections_CREATE | SectionsController | Create a section. |
| Admin | PATCH | /api/sections/:publicId | Admin | JwtAuthGuard, RoleGuard | Sections_UPDATE | SectionsController | Rename, reorder, or retire/reactivate a section. |
| Admin | DELETE | /api/sections/:publicId | Admin | JwtAuthGuard, RoleGuard | Sections_DELETE | SectionsController | Hard delete — permitted only when no class references it. |
| Admin | GET | /api/rooms | Admin/Staff/Teacher | JwtAuthGuard, RoleGuard | Rooms_READ | RoomsController | List/search/filter rooms, paginated. |
| Admin | GET | /api/rooms/buildings | Admin/Staff/Teacher | JwtAuthGuard, RoleGuard | Rooms_READ | RoomsController | Distinct buildings of active rooms. |
| Admin | POST | /api/rooms | Admin | JwtAuthGuard, RoleGuard | Rooms_CREATE | RoomsController | Create a room. |
| Admin | PATCH | /api/rooms/:publicId | Admin | JwtAuthGuard, RoleGuard | Rooms_UPDATE | RoomsController | Rename, relocate, or retire/reactivate a room. |
| Admin | DELETE | /api/rooms/:publicId | Admin | JwtAuthGuard, RoleGuard | Rooms_DELETE | RoomsController | Hard delete — permitted only when no class references it. |
| Admin | GET | /api/classes | Admin/Staff/Teacher | JwtAuthGuard, RoleGuard | Classes_READ | ClassesController | List/filter classes; refuses pagination=false. |
| Admin | GET | /api/classes/options | Admin/Staff/Teacher | JwtAuthGuard, RoleGuard | Classes_READ | ClassesController | Unpaginated, person-data-free class list for the capacity chart and admission cascade. |
| Admin | GET | /api/classes/:publicId | Admin/Staff/Teacher | JwtAuthGuard, RoleGuard | Classes_READ | ClassesController | Read a single class. |
| Admin | POST | /api/classes | Admin | JwtAuthGuard, RoleGuard | Classes_CREATE | ClassesController | Create a class. |
| Admin | PATCH | /api/classes/:publicId | Admin | JwtAuthGuard, RoleGuard | Classes_UPDATE | ClassesController | Update a class — identity fields are permanently excluded. |
| Admin | DELETE | /api/classes/:publicId | Admin | JwtAuthGuard, RoleGuard | Classes_DELETE | ClassesController | Hard delete — permitted only when no enrolment references it. |
| Admin | GET | /api/classes/:publicId/students | Admin/Staff/Teacher (scoped) | JwtAuthGuard, RoleGuard | Students_READ | ClassEnrollmentsController | A class's roster — row-scoped by object-level access. |
| Admin | POST | /api/classes/:publicId/enrollments | Admin (Students_UPDATE holder) | JwtAuthGuard, RoleGuard | Students_UPDATE | ClassEnrollmentsController | Enrol a pupil, or transfer them from another class. |
| Admin | DELETE | /api/classes/:publicId/enrollments/:studentId | Admin (Students_UPDATE holder) | JwtAuthGuard, RoleGuard | Students_UPDATE | ClassEnrollmentsController | Withdraw a pupil from this class. |
| Admin | PATCH | /api/enrollments/:id | Admin (Students_UPDATE holder) | JwtAuthGuard, RoleGuard | Students_UPDATE | ClassEnrollmentsController | Correct an enrolment's date/status. classId is not patchable. |
| Admin | PATCH | /api/enrollments/:id/roll-number | Admin (Students_UPDATE holder) | JwtAuthGuard, RoleGuard | Students_UPDATE | ClassEnrollmentsController | Set or clear one pupil's roll number. Moves nobody else. |
| Admin | POST | /api/classes/:publicId/enrollments/renumber | Admin (Students_UPDATE holder) | JwtAuthGuard, RoleGuard | Students_UPDATE | ClassEnrollmentsController | Renumber a class's whole live roll in the school's configured order — the only operation that moves an existing pupil's number. |
| Admin | DELETE | /api/enrollments/:id | Admin (Students_UPDATE holder) | JwtAuthGuard, RoleGuard | Students_UPDATE | ClassEnrollmentsController | Remove an enrolment entered in error — no status trace left. |
| Admin | GET | /api/students/:id/enrollments | Admin/Staff/Teacher (scoped) | JwtAuthGuard, RoleGuard | Students_READ | StudentsController | A pupil's own class history, newest first. Lives here, not on a class controller — see 13.4. |
No alias routes, no restore endpoints (none of sections/rooms/classes has deleted_at), and no create/update/delete route for grades at all — verified against the full contents of every controller file. ClassEnrollmentsController declares a bare @Controller() with no class-level path prefix; every route it owns states its full path in the method decorator ("classes/:publicId/students", "classes/:publicId/enrollments", "classes/:publicId/enrollments/renumber", "enrollments/:id", "enrollments/:id/roll-number"), which is why the enrolment-adjacent rows above mix two different top-level prefixes (/classes/... and /enrollments/...) from the same controller class. PATCH /api/enrollments/:id/roll-number and POST /classes/:publicId/enrollments/renumber are the only two routes in this module gated on Students_UPDATE that do not create, close, or delete an enrolment row — they edit or reassign roll_number in place on rows that already exist.
5. Auth, Identity, and Permissions
| Surface | Guard/Decorator | Identity Shape | Permission | Guest Allowed | Notes |
|---|---|---|---|---|---|
| Every route above | @UseGuards(JwtAuthGuard, RoleGuard) at the controller class level | req.user populated by the JWT strategy; activeRole resolved from it | One of the codes in the surface map above | No | No route in this module carries @Public(). |
- Auth is mandatory on every route.
JwtAuthGuardrejects a missing/invalid token with401 AUTH_UNAUTHENTICATEDbeforeRoleGuardever runs. - Every handler declares a permission.
RoleGuard's fail-open branch never applies here — every handler in every controller in this module declares a@Permissions(...), androute-permissions.spec.tsasserts the same in CI. - Grades has no permission module of its own.
PERMISSION_MODULESlistsClasses,Sections,Rooms— noGradesentry exists.GET /api/gradesis gated byClasses_READ, and there is no other route to gate: noPOST/PATCH/DELETE /api/gradesexists in this codebase. - Two permission surfaces, not one.
Classes_*/Sections_*/Rooms_*gate the class-setup screens.Students_READ/Students_UPDATEgate every roster/enrolment/history route in this document, including the sixStudents_UPDATE-gated mutations declared onClassEnrollmentsController(enrol, withdraw, correct, remove, set/clear a roll number, renumber) — a caller who holds fullClasses_*grants but noStudents_*grants can manage every class in the school but cannot touch a single enrolment or roll number. Students_READ/Students_UPDATEare not the whole story — object-level scope is the actual control.PeopleAccessService.scopeFor/applyScopenarrow every roster and history read to the pupils the caller's role permits;PeopleAccessService.assertCanAccessrefuses every enrolment write on an out-of-scope pupil with404, deliberately never403— a403on a specific id would confirm that id exists, which turns the pupil id space into an enumeration oracle. A client integrating against this module must treat every404from an enrolment route as potentially meaning "exists, but not visible to you," not only "does not exist."POST .../enrollments/renumberchecks every live pupil's scope individually, before writing anything. There is no class-level scope to check instead —ClassEnrollmentsController.renumberresolves the class's live roll and runsassertCanAccessonce per pupil ahead of the write, so the first pupil outside the caller's scope produces404with the whole class still unchanged.PATCH /enrollments/:id/roll-numberneeds no loop — it names exactly one pupil, checked the same way every other single-pupil write is.- The seeded
staff/teacherroles hold every_READcode in this module but noStudents_UPDATE.packages/db/src/seed/seed-auth.ts'sSTAFF_PERMISSIONS(shared by reference withTEACHER_PERMISSIONS) includesClasses_READ,Sections_READ,Rooms_READ, andStudents_READ— but notStudents_UPDATE. A caller integrating a teacher-facing screen should not assume that role can enrol or withdraw a pupil; only an explicitly administrative role currently can. - Active-role scoping. Permissions resolve from the caller's currently-active role only. A user holding several roles who has not selected one is refused with
403 AUTH_ACTIVE_ROLE_REQUIRED; a user with no role at all gets403 PERMISSION_ROLE_NOT_ASSIGNED. - Superadmin bypass. A role with
is_superadmin = trueskips both the permission list and the object-level scope check entirely, keyed on the boolean flag, never on the role'snamestring. - Permission catalog mechanics.
Classes,Sections,Roomsare each declared once inPERMISSION_MODULES, and the full catalog is generated as every module crossed with every action inPERMISSION_ACTIONS(CREATE,READ,UPDATE,DELETE,RESTORE).Classes_RESTORE,Sections_RESTORE, andRooms_RESTOREall exist as valid, seeded, grantable permission codes even though no route in this module ever checks any of them — none ofsections/rooms/classeshasdeleted_at. - Deploy-time grant gap.
pnpm --filter @skoolsewa/api permissions:synccreates the permission rows for this module but grants them only to roles named"superadmin"or"admin"— verified againstsync-permissions.ts:90, and this product seeds no role literally named"admin".pnpm --filter @skoolsewa/db db:seed:prodis the command that actually applies thestaff/teachergrants a client integration should expect to see live. - Headers parsed but not trusted: not applicable — no route in this module reads any identity-bearing header other than the standard
Authorization: Bearer <jwt>consumed byJwtAuthGuard.
6. DTO and Model Reference
6.1 GradeDto (response)
| Field | Type | Required | Default | Validation | Example | Source |
|---|---|---|---|---|---|---|
id | number | Yes | Server-generated | Internal serial id | 5 | grade.dto.ts |
publicId | string (UUID) | Yes | Server-generated | — | "018f2a1e-..." | grade.dto.ts |
name | string | Yes | — | — | "5" | grade.dto.ts |
code | string | Yes | — | Stable across a rename | "G5" | grade.dto.ts |
sortOrder | number | Yes | — | Nursery = 0, LKG = 1, UKG = 2, then grade n at n + 2 | 7 | grade.dto.ts |
isActive | boolean | Yes | true | — | true | grade.dto.ts |
createdAt / updatedAt | string (ISO date) | Yes | Server-generated | — | — | grade.dto.ts |
6.2 ListGradesQueryDto (query, extends QueryDto)
| Field | Type | Required | Default | Validation | Example | Source |
|---|---|---|---|---|---|---|
isActive | boolean | No | Unset (no filter) | Query-string boolean transform (QueryBoolean), then @IsBoolean | ?isActive=true | grade.dto.ts |
search, pagination, page, size, sort, order | Inherited from QueryDto | No | See 6.11 | sort/order are inherited but never read by GradesService.findAll — ordering is always sortOrder, lower(name), id | — | query.dto.ts |
6.3 SectionDto (response)
| Field | Type | Required | Default | Validation | Example | Source |
|---|---|---|---|---|---|---|
id | number | Yes | Server-generated | Internal serial id | 2 | section.dto.ts |
publicId | string (UUID) | Yes | Server-generated | — | — | section.dto.ts |
name | string | Yes | — | — | "A" | section.dto.ts |
sortOrder | number | Yes | — | Not unique | 0 | section.dto.ts |
isActive | boolean | Yes | true | — | true | section.dto.ts |
createdAt / updatedAt | string (ISO date) | Yes | Server-generated | — | — | section.dto.ts |
6.4 CreateSectionDto (body)
| Field | Type | Required | Default | Validation | Example | Source |
|---|---|---|---|---|---|---|
name | string | Yes | — | @IsString, @MinLength(1), @MaxLength(32), @Matches(/^\S(.*\S)?$/) (no surrounding whitespace) | "E" | section.dto.ts |
sortOrder | number | No | Computed as COALESCE(max(sort_order), -1) + 1 in the same transaction as the insert | @IsInt, @Min(0) | 4 | section.dto.ts |
6.5 UpdateSectionDto (body)
| Field | Type | Required | Default | Validation | Example | Source |
|---|---|---|---|---|---|---|
name | string | No | Unchanged if omitted | Same as create | "E" | section.dto.ts |
sortOrder | number | No | Unchanged if omitted | @IsInt, @Min(0) | 2 | section.dto.ts |
isActive | boolean | No | Unchanged if omitted | @IsBoolean | false | section.dto.ts |
6.6 ListSectionsQueryDto (query, extends QueryDto)
| Field | Type | Required | Default | Validation | Example | Source |
|---|---|---|---|---|---|---|
isActive | boolean | No | Unset (no filter) | Query-string boolean transform | ?isActive=true | section.dto.ts |
search, pagination, page, size, sort, order | Inherited | No | See 6.11 | sort/order inherited but unused — order is always sortOrder, lower(name), id | — | query.dto.ts |
6.7 RoomDto (response)
| Field | Type | Required | Default | Validation | Example | Source |
|---|---|---|---|---|---|---|
id | number | Yes | Server-generated | Internal serial id | 10 | room.dto.ts |
publicId | string (UUID) | Yes | Server-generated | — | — | room.dto.ts |
roomNumber | string | Yes | — | — | "101" | room.dto.ts |
name | string | null | Yes (nullable) | null | — | "Science Lab" | room.dto.ts |
floor | number | Yes | — | -5..200; 0 is ground | 1 | room.dto.ts |
building | string | Yes | — | — | "Main Block" | room.dto.ts |
isActive | boolean | Yes | true | — | true | room.dto.ts |
classes | RoomClassDto[] | Yes | [] | Active classes of the CURRENT academic session using this room, batch-loaded by RoomOccupancyService.classesByRoom — the same rule and ordering (grade sort order, then section name) the asset room picker uses; a non-current session's class never appears | [{ "id": "018f2a70-...", "label": "Grade 5 — A — Morning" }] | room.dto.ts, room-occupancy.service.ts |
assetCount | number | Yes | 0 | Non-deleted assets placed in this room whose status is not disposed, from RoomOccupancyService.assetCountByRoom | 2 | room.dto.ts, room-occupancy.service.ts |
createdAt / updatedAt | string (ISO date) | Yes | Server-generated | — | — | room.dto.ts |
RoomClassDto is { id: string (uuid), label: string } — the same shape as the asset catalog's AssetRoomClassDto (documented in the assets module's own API doc), because both surfaces describe the same underlying occupancy through the one shared RoomOccupancyService.
6.8 CreateRoomDto (body)
| Field | Type | Required | Default | Validation | Example | Source |
|---|---|---|---|---|---|---|
roomNumber | string | Yes | — | @IsString, @MinLength(1), @MaxLength(32), no surrounding whitespace | "101" | room.dto.ts |
name | string | null | No | null | @ValidateIf(value !== null && value !== undefined) then @IsString, @MinLength(1), @MaxLength(128) — ValidateIf, not plain @IsOptional, so an explicit null is accepted while an empty string is still rejected | "Science Lab" | room.dto.ts |
floor | number | Yes | — | @IsInt, @Min(-5), @Max(200) | 1 | room.dto.ts |
building | string | Yes | — | @IsString, @MinLength(1), @MaxLength(128) | "Main Block" | room.dto.ts |
6.9 UpdateRoomDto (body)
| Field | Type | Required | Default | Validation | Example | Source |
|---|---|---|---|---|---|---|
roomNumber | string | No | Unchanged if omitted | Same as create | "102" | room.dto.ts |
name | string | null | No | Unchanged if omitted; explicit null clears it | Same ValidateIf pattern as create | null | room.dto.ts |
floor | number | No | Unchanged if omitted | @IsInt, @Min(-5), @Max(200) | 2 | room.dto.ts |
building | string | No | Unchanged if omitted | Same as create | "Annexe" | room.dto.ts |
isActive | boolean | No | Unchanged if omitted | @IsBoolean | false | room.dto.ts |
6.10 ListRoomsQueryDto (query, extends QueryDto)
| Field | Type | Required | Default | Validation | Example | Source |
|---|---|---|---|---|---|---|
isActive | boolean | No | Unset (no filter) | Query-string boolean transform | ?isActive=true | room.dto.ts |
building | string | No | Unset (no filter) | @IsString, @MaxLength(128), matched case-insensitively | ?building=Main+Block | room.dto.ts |
floor | number | No | Unset (no filter) | Query-string integer transform (QueryInt), then @IsInt | ?floor=1 | room.dto.ts |
search, pagination, page, size, sort, order | Inherited | No | See 6.11 | search matches roomNumber, building, and name together; sort/order inherited but unused | — | query.dto.ts |
6.11 QueryDto — shared base
| Field | Type | Required | Default | Validation | Example | Source |
|---|---|---|---|---|---|---|
pagination | boolean | No | true | Query-string boolean transform | ?pagination=false | query.dto.ts |
page | number | No | 1 | @IsInt, @Min(1) | ?page=2 | query.dto.ts |
size | number | No | 20 | @IsInt, @Min(1) — silently clamped to 100 by PaginationUtil, not rejected | ?size=50 | query.dto.ts |
sort | string | No | "updatedAt" | @IsString — inherited by every list DTO in this module but read by none of them; every list here has a fixed, structural ordering instead | ?sort=name | query.dto.ts |
order | "asc" | "desc" | No | "desc" | @IsEnum(["asc", "desc"]) — same "inherited, unused" note as sort | ?order=asc | query.dto.ts |
search | string | No | — (no filter) | @IsString, @MaxLength(100), trimmed; dropped entirely when empty | ?search=sci | query.dto.ts |
This is the one deviation from the school module's QueryDto usage worth calling out explicitly: LookupsService in the school module does branch on query.sort; no service in this document does. sort/order are inherited fields on every list query DTO in this module and are accepted without error, but every list endpoint's ordering is fixed and structural (position, then name, then id for grades/sections/rooms; grade/section/shift/id for classes; enrolment date descending for a pupil's history). A client sending ?sort=name&order=asc receives a 200 with no error, and the response is not sorted by name.
6.12 ClassAcademicSessionDto (nested response)
| Field | Type | Notes |
|---|---|---|
id | number | — |
publicId | string (UUID) | — |
name | string | e.g. "2026-27". |
isCurrent | boolean | — |
6.13 ClassTeacherDto (nested response)
| Field | Type | Notes |
|---|---|---|
id | string (UUID) | The staff row's own PK — staff has no separate publicId. |
fullName | string | — |
employeeCode | string | — |
employmentStatus | string | The full enum: active, on_leave, suspended, resigned, terminated, retired. Not a boolean derived from deletedAt — staff soft-deletes, so a class can outlive its teacher's employment, and a boolean would report a resigned/terminated/retired teacher as "still employed," the most common departure and exactly the case this field exists to surface. |
isTeaching | boolean | Derived live from the designation, so a designation whose isTeaching was flipped after assignment shows the truth, not a stale assumption baked in at assignment time. |
6.14 ClassDto (response)
| Field | Type | Required | Default | Validation | Example | Source |
|---|---|---|---|---|---|---|
id | number | Yes | Server-generated | Internal serial id | — | class.dto.ts |
publicId | string (UUID) | Yes | Server-generated | — | — | class.dto.ts |
academicSession | ClassAcademicSessionDto | Yes | — | — | — | class.dto.ts |
grade | GradeDto | Yes | — | — | — | class.dto.ts |
section | SectionDto | Yes | — | — | — | class.dto.ts |
shift | "morning" | "day" | Yes | — | — | "morning" | class.dto.ts |
name | string | null | Yes (nullable) | null | Never identity | "5A Morning" | class.dto.ts |
capacity | number | Yes | — | 1..500 | 40 | class.dto.ts |
enrolledCount | number | Yes | Computed | Live count, filtered on students.deletedAt/recordStatus; never stored | 37 | class.dto.ts; computed in classes-read.service.ts via ClassOccupancyService |
room | RoomSummaryDto | null | Yes (nullable) | null | The home room's own fields — RoomDto without classes and assetCount, which only the Rooms list carries | — | class.dto.ts, room.dto.ts |
classTeacher | ClassTeacherDto | null | Yes (nullable) | null | — | — | class.dto.ts |
isActive | boolean | Yes | true | — | true | class.dto.ts |
createdAt / updatedAt | string (ISO date) | Yes | Server-generated | — | — | class.dto.ts |
6.15 CreateClassDto (body)
| Field | Type | Required | Default | Validation | Example | Source |
|---|---|---|---|---|---|---|
academicSessionId | number | Yes | — | @IsInt, @Min(1) | 7 | class.dto.ts |
gradeId | number | Yes | — | @IsInt, @Min(1) | 5 | class.dto.ts |
sectionId | number | Yes | — | @IsInt, @Min(1) | 2 | class.dto.ts |
shift | "morning" | "day" | Yes | — | @IsIn(CLASS_SHIFTS) | "morning" | class.dto.ts |
capacity | number | Yes | — | @IsInt, @Min(1), @Max(500) | 40 | class.dto.ts |
name | string | null | No | null | @ValidateIf, then @IsString, @MinLength(1), @MaxLength(128), no surrounding whitespace | "5A Morning" | class.dto.ts |
classTeacherId | string | null (UUID) | No | null | @ValidateIf, then @IsUUID | "018f2a20-..." | class.dto.ts |
roomId | string | null (UUID — the room's publicId) | No | null | @ValidateIf, then @IsUUID | "018f2a21-..." | class.dto.ts |
6.16 UpdateClassDto (body)
| Field | Type | Required | Default | Validation | Example | Source |
|---|---|---|---|---|---|---|
name | string | null | No | Unchanged if omitted | Same as create | "5A Morning" | class.dto.ts |
capacity | number | No | Unchanged if omitted | @IsInt, @Min(1), @Max(500) | 35 | class.dto.ts |
classTeacherId | string | null (UUID) | No | Unchanged if omitted; explicit null clears it | @ValidateIf, then @IsUUID | null | class.dto.ts |
roomId | string | null (UUID) | No | Unchanged if omitted; explicit null clears it | @ValidateIf, then @IsUUID | null | class.dto.ts |
isActive | boolean | No | Unchanged if omitted | @IsBoolean | false | class.dto.ts |
allowOverCapacity | boolean | No | false | @IsBoolean | true | class.dto.ts |
academicSessionId, gradeId, sectionId, and shift are permanently absent from this DTO — not merely optional. A class's identity is immutable after creation; the composite foreign key from student_class_enrollments carries ON UPDATE restrict with no exception. Sending any of the four in the body produces 400 VALIDATION_FAILED ("property X should not exist") from the global forbidNonWhitelisted validator, before ClassesWriteService ever runs.
6.17 ListClassesQueryDto (query, extends QueryDto)
| Field | Type | Required | Default | Validation | Example | Source |
|---|---|---|---|---|---|---|
academicSessionId | number | No | The session marked isCurrent | QueryInt, @IsInt | ?academicSessionId=7 | class.dto.ts |
gradeId | number | No | Unset (no filter) | QueryInt, @IsInt | ?gradeId=5 | class.dto.ts |
sectionId | number | No | Unset (no filter) | QueryInt, @IsInt | ?sectionId=2 | class.dto.ts |
shift | "morning" | "day" | No | Unset (no filter) | @IsIn(CLASS_SHIFTS) | ?shift=morning | class.dto.ts |
roomId | number | No | Unset (no filter) | QueryInt, @IsInt — the internal integer id, not publicId, unlike the body DTOs | ?roomId=10 | class.dto.ts |
classTeacherId | string (UUID) | No | Unset (no filter) | @IsUUID | ?classTeacherId=018f... | class.dto.ts |
hasRoom | boolean | No | Unset (no filter) | Query-string boolean transform | ?hasRoom=false | class.dto.ts |
hasClassTeacher | boolean | No | Unset (no filter) | Query-string boolean transform | ?hasClassTeacher=true | class.dto.ts |
isActive | boolean | No | Unset (no filter) | Query-string boolean transform | ?isActive=true | class.dto.ts |
search, pagination, page, size, sort, order | Inherited | No | See 6.11 | pagination=false is refused on this endpoint specifically — see 8.10; sort/order inherited but unused | — | query.dto.ts |
6.18 ClassOptionsQueryDto (query, not a QueryDto subtype)
| Field | Type | Required | Default | Validation | Example | Source |
|---|---|---|---|---|---|---|
academicSessionId | number | No | The session containing date when date is sent, otherwise the session marked isCurrent. Wins over date when both are sent. | QueryInt, @IsInt | ?academicSessionId=7 | class.dto.ts |
date | string | No | None | @IsDateString({ strict: true }), @Matches(/^\d{4}-\d{2}-\d{2}$/) — a real calendar date in YYYY-MM-DD form, never a datetime | ?date=2026-10-06 | class.dto.ts |
date lists the classes of the active academic session whose [start_date, end_date) contains it, whether or not that session is the current one — the attendance screen sends the register date so that a school that has already rolled the current flag forward still sees the classes that can take attendance today. The end date is exclusive. An explicit academicSessionId wins over date. When no active session contains the date the response is { items: [], truncated: false }, not an error. A malformed or impossible date (2026-02-30, 2026-13-01, a datetime) is a 400 validation error.
Deliberately not a QueryDto subtype — this endpoint is always unpaginated by construction, so there is no pagination/page/size/sort/order/search to accept.
6.19 ClassOptionDto / ClassOptionsPayloadDto (response)
| Field | Type | Notes |
|---|---|---|
publicId | string (UUID) | — |
grade | GradeDto | — |
section | SectionDto | — |
shift | "morning" | "day" | — |
name | string | null | — |
capacity | number | — |
enrolledCount | number | Live, same computation as ClassDto. |
isActive | boolean | — |
ClassOptionDto carries no room and no class teacher — this is the endpoint's whole reason to exist alongside GET /classes, so a caller who needs only "which classes exist and how full are they" never receives staff identity.
ClassOptionsPayloadDto — the endpoint's data field — is { items: ClassOptionDto[], truncated: boolean }, an object, not an array. truncated has nowhere else to live: ResponseDto's pagination fields (count/currentPage/totalPage) only populate when a pagination object with numeric count/page/size is passed to its constructor, which never happens here since the endpoint is unpaginated by definition — a caller cannot page past the cap and must be told when it was hit some other way.
6.20 EnrollmentClassDto / EnrollmentDto (response)
| Field | Type | Notes |
|---|---|---|
publicId | string (UUID) | The class's public id. |
name | string | null | — |
shift | "morning" | "day" | — |
grade | GradeDto | — |
section | SectionDto | — |
academicSession | ClassAcademicSessionDto | — |
EnrollmentDto:
| Field | Type | Notes |
|---|---|---|
id | string (UUID) | The enrolment row's own PK. |
status | "active" | "transferred" | "withdrawn" | — |
enrolledOn | string (ISO date, YYYY-MM-DD) | — |
endedOn | string | null (ISO date) | — |
class | EnrollmentClassDto | No capacity, no room, no class teacher — a pupil's own history is not a staff directory. |
createdAt / updatedAt | string (ISO date) | — |
6.21 ClassRosterStudentDto / ClassRosterEntryDto (response)
ClassRosterStudentDto — deliberately narrower than StudentDto: no date of birth, address, phone, guardian, or anything from the medical columns (those are gated by StudentMedical_READ and a roster has no use for them).
| Field | Type | Notes |
|---|---|---|
id | string (UUID) | The student's own PK. |
studentId | string | e.g. "SID-2026-0005". |
admissionNumber | string | e.g. "2026/0005". |
fullName | string | Joined from firstName/middleName/lastName, filtered of blanks. |
recordStatus | string | "active" | "inactive". |
ClassRosterEntryDto:
| Field | Type | Notes |
|---|---|---|
enrollmentId | string (UUID) | — |
rollNumber | number | null | The pupil's position in the class register, and the order this list is returned in (ASC NULLS LAST). null when the class has not been numbered yet, or an administrator has cleared it — such a pupil sorts last, not first. Unique among the class's LIVE roll only. |
student | ClassRosterStudentDto | — |
status | "active" | "transferred" | "withdrawn" | — |
enrolledOn | string (ISO date) | — |
endedOn | string | null (ISO date) | — |
6.22 CreateEnrollmentDto (body)
| Field | Type | Required | Default | Validation | Example | Source |
|---|---|---|---|---|---|---|
studentId | string (UUID) | Yes | — | @IsUUID | "018f2a22-..." | enrollment.dto.ts |
enrolledOn | string (ISO date) | No | Today in Asia/Kathmandu | @IsISO8601 | "2026-04-15" | enrollment.dto.ts |
allowOverCapacity | boolean | No | false | @IsBoolean | true | enrollment.dto.ts |
6.23 UpdateEnrollmentDto (body)
| Field | Type | Required | Default | Validation | Example | Source |
|---|---|---|---|---|---|---|
enrolledOn | string (ISO date) | No | Unchanged if omitted | @IsISO8601 | "2026-04-16" | enrollment.dto.ts |
status | "transferred" | "withdrawn" | No | Unchanged if omitted | @IsIn(["transferred", "withdrawn"]) — "active" is not a settable value here, since the only way to (re)activate is POST .../enrollments | "withdrawn" | enrollment.dto.ts |
classId is deliberately not a field on this DTO at all. Moving a pupil between classes is always POST /classes/:publicId/enrollments, which takes the capacity lock and writes the predecessor row; an in-place classId change here would bypass both. Sending it produces 400 VALIDATION_FAILED — unknown field.
6.24 ListRosterQueryDto (query, extends QueryDto)
| Field | Type | Required | Default | Validation | Example | Source |
|---|---|---|---|---|---|---|
status | "active" | "transferred" | "withdrawn" | No | "active" | @IsIn(ENROLLMENT_STATUSES) | ?status=withdrawn | enrollment.dto.ts |
search, pagination, page, size, sort, order | Inherited | No | pagination=false is refused — see 8.16; search/sort/order inherited but unused — ordering is always studentId, id | — | — | query.dto.ts |
6.25 ListStudentEnrollmentsQueryDto (query, extends QueryDto)
| Field | Type | Required | Default | Validation | Example | Source |
|---|---|---|---|---|---|---|
academicSessionId | number | No | Unset (all sessions) | QueryInt, @IsInt | ?academicSessionId=7 | enrollment.dto.ts |
search, pagination, page, size, sort, order | Inherited | No | search/sort/order inherited but unused — ordering is always enrolledOn DESC, id DESC | — | — | query.dto.ts |
6.26 SetRollNumberDto (body)
| Field | Type | Required | Default | Validation | Example | Source |
|---|---|---|---|---|---|---|
rollNumber | number | null | No | — | @IsOptional, @IsInt, @Min(1) — the @Min(1) constraint does not run against an explicit null, since class-validator skips value validators on a null/undefined field once @IsOptional is present | 12, or null to unnumber the pupil | roll-number.dto.ts |
There is no "leave unchanged" request shape on this route. @IsOptional() admits both an absent key and an explicit null, and the controller reads dto.rollNumber ?? null (class-enrollments.controller.ts:241) — so {"rollNumber": 12}, {"rollNumber": null}, and {} are only two distinct effects: the first sets the number, and the other two both clear it. A caller wanting to leave the current number untouched must not call this route at all.
6.27 RollNumberAssignment (response array, renumber only)
Not a class-based DTO with @ApiProperty decorators — a plain TypeScript interface (roll-number.service.ts), returned as-is inside the response envelope's data array by POST /classes/:publicId/enrollments/renumber.
| Field | Type | Notes |
|---|---|---|
enrollmentId | string (UUID) | — |
studentId | string (UUID) | — |
rollNumber | number | The pupil's newly-assigned number, always >= 1 and never null — every live pupil returned here has just been given a number, by construction of the renumber write. |
7. Enum Reference
| Enum | Value | Meaning | Runtime Effect | Source |
|---|---|---|---|---|
Shift (class_shift) | morning | The morning timetable. | Part of a class's identity; scopes room/teacher exclusivity. | classes.ts |
Shift (class_shift) | day | The day timetable. | Same. | classes.ts |
Enrolment status (enrollment_status) | active | The pupil currently sits in this class. | Counted toward occupancy; at most one per pupil per session. | classes.ts |
Enrolment status (enrollment_status) | transferred | The pupil moved to a different class in the same session. | Never counted toward occupancy; always carries a non-null endedOn. | classes.ts |
Enrolment status (enrollment_status) | withdrawn | The pupil left this class/school entirely. | Never counted toward occupancy; always carries a non-null endedOn. | classes.ts |
Employment status (contrast term — owned by the people module, surfaced on ClassTeacherDto) | active, on_leave, suspended, resigned, terminated, retired | The class teacher's current employment state. | active is the only value implying "currently employed"; the other five are all forms of departure/inactivity a class can still name a teacher against. | packages/db/src/schema/school/people.ts |
Record status (contrast term — owned by the people module, surfaced on ClassRosterStudentDto) | active, inactive | Whether the pupil is currently attending. | ClassOccupancyService and ClassRosterService both filter to active only. | people.ts |
Roll number strategy (roll_number_strategy, contrast term — owned by the school module) | admission_order | Sort by students.admissionNumber, then students.id. | The order a POST .../enrollments/renumber assigns in when this is the school's configured strategy. Also the shipped default. | packages/db/src/schema/school/school-profile.ts |
Roll number strategy (roll_number_strategy, contrast term — owned by the school module) | alphabetical | Sort by firstName, middleName, lastName, then students.id, case-insensitively. | Same, for the alphabetical strategy. Changing the strategy alone moves nobody — it only changes the order the NEXT renumber uses. | school-profile.ts |
"completed" is not a value of enrollment_status — see 5.2 in the backend doc for why. There is no "manual" value on roll_number_strategy — an administrator setting one pupil's number via PATCH /enrollments/:id/roll-number is an override, not a strategy, and adding a third enum value would make "what a renumber does" unanswerable for a school that had ever used it.
8. Endpoint Reference
8.1 GET /api/grades
Purpose
Returns every grade — Nursery, LKG, UKG, and 1-12 by default, plus whatever an operator has added directly to the table (there is no product route to do so). Called by the class-setup screen and the admission form's grade select.
Source Evidence
| Evidence | Path |
|---|---|
| Controller | grades/grades.controller.ts |
| DTO | dto/grade.dto.ts (ListGradesQueryDto, GradeDto) |
| Service | grades/grades.service.ts (findAll) |
| Schema | packages/db/src/schema/school/classes.ts |
| Tests | academic-structure/__tests__/grades.service.integration.spec.ts |
Auth and Permissions
- Auth: Required.
- Guard chain:
JwtAuthGuard→RoleGuard. - Permission:
Classes_READ(grades has no permission module of its own). - Guest support: None.
- Rate limit: None module-specific.
- Idempotency: N/A (read).
Request
| Part | Required | Details |
|---|---|---|
| Headers | Yes | Authorization: Bearer <jwt>. |
| Params | No | — |
| Query | No | search, isActive, pagination, page, size (sort/order accepted but ignored). |
| Body | No | — |
GET /api/grades?isActive=true&pagination=false HTTP/1.1Response
{
"message": "Grades fetched.",
"data": [
{ "id": 1, "publicId": "018f2a1e-...", "name": "Nursery", "code": "NURSERY", "sortOrder": 0, "isActive": true, "createdAt": "2026-01-10T04:15:00.000Z", "updatedAt": "2026-01-10T04:15:00.000Z" },
{ "id": 4, "publicId": "018f2a1f-...", "name": "1", "code": "G1", "sortOrder": 3, "isActive": true, "createdAt": "2026-01-10T04:15:00.000Z", "updatedAt": "2026-01-10T04:15:00.000Z" }
],
"errorCode": null
}count/currentPage/totalPage are omitted here because pagination=false was requested.
Side Effects
None. A plain, uncached SELECT.
Error Cases
| HTTP Status | Error Code | Condition | User-Facing Meaning | Source |
|---|---|---|---|---|
401 | AUTH_UNAUTHENTICATED | Missing/invalid JWT. | Re-authenticate. | jwt-auth.guard.ts |
403 | PERMISSION_INSUFFICIENT | Active role lacks Classes_READ. | Not authorized to view grades. | role.guard.ts |
400 | VALIDATION_FAILED | An invalid query value. | Fix the query string. | Global ValidationPipe |
Edge Cases
- No route to create/update/delete a grade —
?on aPOST/PATCH/DELETEto this path is a plain404 Not Found(no handler registered), not a domain error. sort/orderaccepted, silently ignored — ordering is alwayssortOrder, name, id.pagination=false: capped atUNPAGINATED_HARD_CAP(1000), a ceiling far above the realistic 15-row seeded set.
Example Requests
curl -X GET "$API_URL/api/grades" \
-H "Authorization: Bearer TOKEN"8.2 GET /api/sections
Purpose
Lists sections, paginated and optionally filtered. Called by the section-setup screen and any dropdown needing the full list (typically pagination=false&isActive=true).
Source Evidence
| Evidence | Path |
|---|---|
| Controller | sections/sections.controller.ts |
| DTO | dto/section.dto.ts (ListSectionsQueryDto, SectionDto) |
| Service | sections/sections.service.ts (findAll) |
| Tests | academic-structure/__tests__/sections.service.integration.spec.ts |
Auth and Permissions
- Permission:
Sections_READ. Guard chain and guest support as in 8.1.
Request
| Part | Required | Details |
|---|---|---|
| Query | No | search, isActive, pagination, page, size (sort/order accepted but ignored). |
GET /api/sections?search=a&isActive=true HTTP/1.1Response
{
"message": "Sections fetched.",
"data": [
{ "id": 1, "publicId": "018f2a20-...", "name": "A", "sortOrder": 0, "isActive": true, "createdAt": "2026-01-10T04:15:00.000Z", "updatedAt": "2026-01-10T04:15:00.000Z" }
],
"errorCode": null,
"count": 1,
"currentPage": 1,
"totalPage": 1
}Side Effects
None — uncached read.
Error Cases
Same as 8.1, substituting Sections_READ.
Edge Cases
- Empty
search(?search=): filter dropped entirely, matching the school module's behavior. sizeabove100: silently clamped.- Fixed ordering:
sortOrder, lower(name), id;sort/orderaccepted, ignored.
Example Requests
curl -X GET "$API_URL/api/sections?pagination=false&isActive=true" \
-H "Authorization: Bearer TOKEN"8.3 POST /api/sections
Purpose
Creates a new section. Called from the section-setup screen's "add" action.
Source Evidence
| Evidence | Path |
|---|---|
| Controller | sections/sections.controller.ts |
| DTO | dto/section.dto.ts (CreateSectionDto) |
| Service | sections/sections.service.ts (create, assertNameFree) |
| Schema | classes.ts (sections_name_unique) |
| Tests | academic-structure/__tests__/sections.service.integration.spec.ts |
Auth and Permissions
- Permission:
Sections_CREATE. - Idempotency: None — a resubmitted identical request creates a second section unless the name collides.
Request
Minimal valid request:
{ "name": "E" }Full valid request:
{ "name": "E", "sortOrder": 4 }Response
{
"message": "Section created.",
"data": {
"id": 5, "publicId": "018f2a25-...", "name": "E", "sortOrder": 4,
"isActive": true, "createdAt": "2026-02-01T09:00:00.000Z", "updatedAt": "2026-02-01T09:00:00.000Z"
},
"errorCode": null
}Side Effects
- Database:
SELECT(name-uniqueness pre-check) thenINSERT, both inside one transaction (so a concurrently-omittedsortOrderis computed against a consistent snapshot). - No cache, no jobs, no realtime, no audit call beyond the automatic interceptor.
Error Cases
| HTTP Status | Error Code | Condition | User-Facing Meaning | Source |
|---|---|---|---|---|
400 | VALIDATION_FAILED | name missing/too long/whitespace-padded, or an unknown field. | Fix the request. | Global ValidationPipe |
409 | SECTION_NAME_TAKEN | Another active section already has this name, case-insensitively. | Choose a different name. | sections.service.ts |
409 | RESOURCE_ALREADY_EXISTS | A race past the pre-check hit the DB's own unique index. | Refresh and retry. | Global unique-violation fallback |
401 / 403 | See 8.1 | — | — | — |
Edge Cases
sortOrderomitted: computed asCOALESCE(max(sort_order), -1) + 1— a school's very first section (empty table) still succeeds, because of theCOALESCE.- Two sections sharing a
sortOrder: allowed, ties break onlower(name). - Name held only by a retired section: reusable, since the unique index is partial on
is_active.
Example Requests
curl -X POST "$API_URL/api/sections" \
-H "Authorization: Bearer TOKEN" -H "Content-Type: application/json" \
-d '{"name":"E"}'8.4 PATCH /api/sections/:publicId
Purpose
Renames, reorders, or retires/reactivates a section.
Source Evidence
| Evidence | Path |
|---|---|
| Controller | sections/sections.controller.ts |
| DTO | dto/section.dto.ts (UpdateSectionDto) |
| Service | sections/sections.service.ts (update) |
| Tests | academic-structure/__tests__/sections.service.integration.spec.ts |
Auth and Permissions
- Permission:
Sections_UPDATE.
Request
{ "sortOrder": 2 }Response
Same shape as 8.3's create response, reflecting the applied patch.
Side Effects
SELECT (lookup, and a name pre-check only if the name actually changed case-insensitively) then UPDATE. No cache invalidation — this module caches nothing.
Error Cases
| HTTP Status | Error Code | Condition | Source |
|---|---|---|---|
404 | SECTION_NOT_FOUND | No section for the given publicId. | sections.service.ts |
409 | SECTION_NAME_TAKEN | Renamed to a name another active section already holds. | sections.service.ts |
400 | VALIDATION_FAILED | Invalid field. | Global |
Edge Cases
- Renaming to the same name, different case (
"A"→"a"): a no-op, not a self-clash. - Retiring a section still referenced by a class: allowed unconditionally — no reference check on
PATCH, only onDELETE.
Example Requests
curl -X PATCH "$API_URL/api/sections/018f2a20-..." \
-H "Authorization: Bearer TOKEN" -H "Content-Type: application/json" \
-d '{"isActive":false}'8.5 DELETE /api/sections/:publicId
Purpose
Permanently removes a section created in error. Blocked while any class still references it.
Source Evidence
| Evidence | Path |
|---|---|
| Controller | sections/sections.controller.ts |
| Service | sections/sections.service.ts (remove) |
| Schema | classes.ts (classes_section_id_sections_id_fk, ON DELETE restrict) |
Auth and Permissions
- Permission:
Sections_DELETE.
Response
{ "message": "Section deleted.", "data": null, "errorCode": null }Side Effects
DELETE, catching the real foreign-key violation (23503) and translating it into a named 409.
Error Cases
| HTTP Status | Error Code | Condition | Source |
|---|---|---|---|
404 | SECTION_NOT_FOUND | No section for the given publicId. | sections.service.ts |
409 | SECTION_IN_USE | At least one class (active or retired) still has section_id pointing here. | sections.service.ts |
Edge Cases
- Retired section: deletable exactly like an active one — retirement status has no bearing on deletability.
Example Requests
curl -X DELETE "$API_URL/api/sections/018f2a20-..." \
-H "Authorization: Bearer TOKEN"8.6 GET /api/rooms
Purpose
Lists rooms, optionally filtered by search text, building, floor, or isActive. Called by the room-setup screen and the class-creation form's room select.
Source Evidence
| Evidence | Path |
|---|---|
| Controller | rooms/rooms.controller.ts |
| DTO | dto/room.dto.ts (ListRoomsQueryDto, RoomDto, RoomClassDto) |
| Service | rooms/rooms.service.ts (findAll), rooms/room-occupancy.service.ts (classesByRoom, assetCountByRoom) |
| Tests | academic-structure/__tests__/rooms.service.integration.spec.ts, academic-structure/__tests__/rooms-occupancy.integration.spec.ts |
Auth and Permissions
- Permission:
Rooms_READ.
Request
GET /api/rooms?building=Main+Block&floor=1&pagination=false HTTP/1.1Response
{
"message": "Rooms fetched.",
"data": [
{ "id": 10, "publicId": "018f2a30-...", "roomNumber": "101", "name": "Science Lab", "floor": 1, "building": "Main Block", "isActive": true, "classes": [{ "id": "018f2a70-...", "label": "Grade 5 — A — Morning" }], "assetCount": 2, "createdAt": "2026-01-10T04:15:00.000Z", "updatedAt": "2026-01-10T04:15:00.000Z" }
],
"errorCode": null
}classes and assetCount are filled with one batch call each across the whole page — never once per row — by RoomOccupancyService, the same service GET /api/asset-catalog/rooms uses for its own classes field. classes lists only the CURRENT academic session's active classes; assetCount counts non-deleted assets in the room whose status is not disposed.
Side Effects
None — uncached read.
Error Cases
Same shape as 8.1, substituting Rooms_READ.
Edge Cases
searchmatchesroomNumber,building, andnametogether — a search for the room's name text finds it even without matching the number.floor=0(ground floor): honored as a real filter value, not treated as "no filter."
Example Requests
curl -X GET "$API_URL/api/rooms?isActive=true&pagination=false" \
-H "Authorization: Bearer TOKEN"8.7 POST /api/rooms
Purpose
Creates a new room.
Source Evidence
| Evidence | Path |
|---|---|
| Controller | rooms/rooms.controller.ts |
| DTO | dto/room.dto.ts (CreateRoomDto) |
| Service | rooms/rooms.service.ts (create, assertNumberFree) |
| Schema | classes.ts (rooms_building_number_unique) |
Auth and Permissions
- Permission:
Rooms_CREATE.
Request
Minimal valid request:
{ "roomNumber": "101", "floor": 1, "building": "Main Block" }Full valid request:
{ "roomNumber": "101", "name": "Science Lab", "floor": 1, "building": "Main Block" }Response
{
"message": "Room created.",
"data": { "id": 11, "publicId": "018f2a31-...", "roomNumber": "101", "name": "Science Lab", "floor": 1, "building": "Main Block", "isActive": true, "classes": [], "assetCount": 0, "createdAt": "2026-02-01T09:00:00.000Z", "updatedAt": "2026-02-01T09:00:00.000Z" },
"errorCode": null
}A freshly created room always returns classes: [] and assetCount: 0 — nothing can reference a room that did not exist a moment ago — but the response is still built through RoomOccupancyService, the same path GET /api/rooms and PATCH /api/rooms/:publicId use, rather than a hardcoded empty value.
Error Cases
| HTTP Status | Error Code | Condition | Source |
|---|---|---|---|
400 | VALIDATION_FAILED | Invalid field, e.g. floor out of -5..200. | Global |
409 | ROOM_NUMBER_TAKEN | Another active room in the same building already has this number. | rooms.service.ts |
409 | RESOURCE_ALREADY_EXISTS | Race past the pre-check. | Global fallback |
Edge Cases
- Same number, different building: accepted — uniqueness is scoped to the building.
nameomitted vs. sent as"":""is rejected by the DTO'sMatches/ValidateIfcombination before the service runs — an explicitnull, or omission, is required to leave it unset.
Example Requests
curl -X POST "$API_URL/api/rooms" \
-H "Authorization: Bearer TOKEN" -H "Content-Type: application/json" \
-d '{"roomNumber":"101","floor":1,"building":"Main Block"}'8.8 PATCH /api/rooms/:publicId
Purpose
Renames, relocates, or retires/reactivates a room.
Source Evidence
| Evidence | Path |
|---|---|
| Controller | rooms/rooms.controller.ts |
| Service | rooms/rooms.service.ts (update), rooms/room-occupancy.service.ts |
Auth and Permissions
- Permission:
Rooms_UPDATE.
Request
{ "roomNumber": "102", "floor": 2 }The response's classes and assetCount reflect the room's occupancy AFTER the update — moving a room to a different building or floor never changes which classes or assets it holds, so they carry over unless something else already changed them.
Error Cases
| HTTP Status | Error Code | Condition | Source |
|---|---|---|---|
404 | ROOM_NOT_FOUND | No room for the given publicId. | rooms.service.ts |
409 | ROOM_NUMBER_TAKEN | The new (building, roomNumber) pair collides with another active room. | rooms.service.ts |
Edge Cases
- The
(building, roomNumber)pair is re-checked only when it actually changes and the result stays/becomes active — sending the room's own current values back is always a no-op-safe request. name: null: clears it.name: "": rejected with400.
Example Requests
curl -X PATCH "$API_URL/api/rooms/018f2a30-..." \
-H "Authorization: Bearer TOKEN" -H "Content-Type: application/json" \
-d '{"name":null}'8.9 DELETE /api/rooms/:publicId
Purpose
Permanently removes a room created in error. Blocked while any class still references it.
Source Evidence
| Evidence | Path |
|---|---|
| Controller | rooms/rooms.controller.ts |
| Service | rooms/rooms.service.ts (remove) |
| Schema | classes.ts (classes_room_id_rooms_id_fk, ON DELETE set null — not restrict) |
Auth and Permissions
- Permission:
Rooms_DELETE.
Error Cases
| HTTP Status | Error Code | Condition | Source |
|---|---|---|---|
404 | ROOM_NOT_FOUND | No room for the given publicId. | rooms.service.ts |
409 | ROOM_IN_USE | A class still holds room_id pointing here. | rooms.service.ts — an explicit pre-check, not a caught foreign-key violation, because classes.room_id is ON DELETE set null and would otherwise let the delete succeed and silently blank the class's room. |
Edge Cases
- This is the one delete guard in the whole module that is not a translated database error — see the backend doc's 6.3 for the full reasoning.
Example Requests
curl -X DELETE "$API_URL/api/rooms/018f2a30-..." \
-H "Authorization: Bearer TOKEN"8.10 GET /api/classes
Purpose
Lists classes for a given academic session (defaulting to whichever is marked current), with every filter combination the class-setup screen needs, and each row's live occupancy count. Refuses pagination=false — see the edge cases.
Source Evidence
| Evidence | Path |
|---|---|
| Controller | classes/classes.controller.ts |
| DTO | dto/class.dto.ts (ListClassesQueryDto, ClassDto) |
| Service | classes/classes-read.service.ts (findAll) |
| Tests | academic-structure/__tests__/classes-read.service.integration.spec.ts |
Auth and Permissions
- Permission:
Classes_READ.
Request
GET /api/classes?academicSessionId=7&gradeId=5&shift=morning HTTP/1.1Response
{
"message": "Classes fetched.",
"data": [
{
"id": 3, "publicId": "018f2a40-...",
"academicSession": { "id": 7, "publicId": "018f2a41-...", "name": "2026-27", "isCurrent": true },
"grade": { "id": 5, "publicId": "018f2a1e-...", "name": "5", "code": "G5", "sortOrder": 5, "isActive": true, "createdAt": "...", "updatedAt": "..." },
"section": { "id": 1, "publicId": "018f2a20-...", "name": "A", "sortOrder": 0, "isActive": true, "createdAt": "...", "updatedAt": "..." },
"shift": "morning",
"name": null,
"capacity": 40,
"enrolledCount": 37,
"room": { "id": 10, "publicId": "018f2a30-...", "roomNumber": "101", "name": "Science Lab", "floor": 1, "building": "Main Block", "isActive": true, "createdAt": "...", "updatedAt": "..." },
"classTeacher": { "id": "018f2a50-...", "fullName": "Sita Sharma", "employeeCode": "EMP-0007", "employmentStatus": "active", "isTeaching": true },
"isActive": true, "createdAt": "...", "updatedAt": "..."
}
],
"errorCode": null,
"count": 1, "currentPage": 1, "totalPage": 1
}Side Effects
SELECT joined across academic_sessions, grades, sections, rooms, staff, users, designations, plus a COUNT(*), plus one grouped occupancy query via ClassOccupancyService.countForMany. No cache.
Error Cases
| HTTP Status | Error Code | Condition | Source |
|---|---|---|---|
400 | PAGINATION_LIMIT_INVALID | pagination=false was requested. | classes-read.service.ts |
400 | VALIDATION_FAILED | Invalid query value. | Global |
401 / 403 | See 8.1 | — | — |
Edge Cases
pagination=falseis always refused. The row embeds the class teacher's full name and employee code — staff identity a caller holding onlyClasses_READshould not receive unbounded, mirroring/students//staff's own refusal. Use 8.11 for an unpaginated, person-data-free alternative.- No
academicSessionIdgiven, no session current:{ "data": [], "count": 0, "currentPage": 1, "totalPage": 0 }, not an error. sort/orderaccepted, ignored — ordering is alwaysgrade.sortOrder, section.sortOrder, shift, id.searchmatchesclasses.nameandgrades.name/grades.code/sections.nametogether — an unnamed class is still findable by grade/section text.
Example Requests
curl -X GET "$API_URL/api/classes?gradeId=5&shift=morning" \
-H "Authorization: Bearer TOKEN"8.11 GET /api/classes/options
Purpose
The unpaginated, person-data-free class list that serves both the admission form's grade → section → shift cascade and the capacity chart. Called instead of 8.10 whenever the caller needs "which classes exist and how full are they" without staff identity attached.
Source Evidence
| Evidence | Path |
|---|---|
| Controller | classes/classes.controller.ts — declared before @Get(":publicId") so options is never swallowed as a publicId value |
| DTO | dto/class.dto.ts (ClassOptionsQueryDto, ClassOptionDto, ClassOptionsPayloadDto) |
| Service | classes/classes-read.service.ts (findOptions) |
Auth and Permissions
- Permission:
Classes_READ(same as the full list).
Request
GET /api/classes/options?academicSessionId=7 HTTP/1.1
GET /api/classes/options?date=2026-10-06 HTTP/1.1Response
{
"message": "Class options fetched.",
"data": {
"items": [
{ "publicId": "018f2a40-...", "grade": { "...": "GradeDto" }, "section": { "...": "SectionDto" }, "shift": "morning", "name": null, "capacity": 40, "enrolledCount": 37, "isActive": true }
],
"truncated": false
},
"errorCode": null
}data is an object, not an array — the one endpoint in this document shaped this way. A client must read data.items as the list, and check data.truncated rather than relying on pagination metadata, which this endpoint never returns.
Side Effects
SELECT on classes joined only to grades/sections (no room, no teacher, no staff join at all), filtered to active-only, limited to UNPAGINATED_HARD_CAP + 1 rows to detect truncation with no second COUNT(*), then one grouped occupancy query.
Error Cases
Same shape as 8.10, minus PAGINATION_LIMIT_INVALID (not applicable — this endpoint is always unpaginated). VALIDATION_FAILED (400) also covers a date that is not a real YYYY-MM-DD calendar date (2026-02-30, 2026-13-01, a datetime).
Edge Cases
- Excludes retired classes unconditionally, unlike the full list, which defaults to showing both.
- Over
UNPAGINATED_HARD_CAP(1000) active classes in the resolved session:truncated: true, and the response silently omits the excess. - No current session and none given:
{ "items": [], "truncated": false }. dateresolves the active session whose[start_date, end_date)contains it (end exclusive), current or not. An explicitacademicSessionIdwins overdate. When no active session contains the date:{ "items": [], "truncated": false }, not an error.
Example Requests
curl -X GET "$API_URL/api/classes/options?academicSessionId=7" \
-H "Authorization: Bearer TOKEN"
curl -X GET "$API_URL/api/classes/options?date=2026-10-06" \
-H "Authorization: Bearer TOKEN"8.12 GET /api/classes/:publicId
Purpose
Returns a single class's full detail, including its live occupancy.
Source Evidence
| Evidence | Path |
|---|---|
| Controller | classes/classes.controller.ts |
| Service | classes/classes-read.service.ts (findOne → loadDto) |
| Tests | academic-structure/__tests__/classes-read.service.integration.spec.ts |
Auth and Permissions
- Permission:
Classes_READ.
Response
Same shape as one row of 8.10's data array.
Error Cases
| HTTP Status | Error Code | Condition | Source |
|---|---|---|---|
404 | CLASS_NOT_FOUND | No class for the given publicId. | classes-read.service.ts |
Edge Cases
- Route ordering: this route is registered after
@Get("options"), so/api/classes/optionsis never matched here aspublicId = "options".
Example Requests
curl -X GET "$API_URL/api/classes/018f2a40-..." \
-H "Authorization: Bearer TOKEN"8.13 POST /api/classes
Purpose
Creates a new class: one grade, one section, one shift, in one academic session.
Source Evidence
| Evidence | Path |
|---|---|
| Controller | classes/classes.controller.ts |
| DTO | dto/class.dto.ts (CreateClassDto) |
| Service | classes/classes-write.service.ts (create) |
| Schema | classes.ts (classes_identity_unique, classes_room_per_shift_unique, classes_teacher_per_shift_unique) |
| Tests | academic-structure/__tests__/classes-write.service.integration.spec.ts |
Auth and Permissions
- Permission:
Classes_CREATE. - Idempotency: None — a resubmitted identical request is refused with
CLASS_IDENTITY_TAKEN, not silently deduplicated, so a client that retries a genuinely-failed request safely gets a409rather than a second class.
Request
Minimal valid request:
{ "academicSessionId": 7, "gradeId": 5, "sectionId": 1, "shift": "morning", "capacity": 40 }Full valid request:
{
"academicSessionId": 7, "gradeId": 5, "sectionId": 1, "shift": "morning",
"capacity": 40, "name": "5A Morning",
"classTeacherId": "018f2a50-...", "roomId": "018f2a30-..."
}Response
Same shape as 8.12, reflecting the created class with enrolledCount: 0.
Side Effects
- Reads: session/grade/section (existence + active), room (if given, existence + active), staff+designation (if given, existence + not soft-deleted +
isTeaching). - Writes: one
INSERTinside a transaction. - No cache, no jobs, no realtime.
Error Cases
| HTTP Status | Error Code | Condition | Source |
|---|---|---|---|
404 | ACADEMIC_SESSION_NOT_FOUND / GRADE_NOT_FOUND / SECTION_NOT_FOUND | The named entity does not resolve. | classes-write.service.ts |
422 | CLASS_SESSION_INACTIVE / CLASS_GRADE_INACTIVE / CLASS_SECTION_INACTIVE | The entity resolves but is retired. | classes-write.service.ts |
404 | ROOM_NOT_FOUND | roomId given but does not resolve. | classes-write.service.ts |
422 | CLASS_ROOM_INACTIVE | The resolved room is retired. | classes-write.service.ts |
404 | CLASS_TEACHER_NOT_FOUND | classTeacherId given but does not resolve, or resolves to a soft-deleted staff row. | classes-write.service.ts |
422 | CLASS_TEACHER_NOT_TEACHING | The resolved staff member's designation is not a teaching one. | classes-write.service.ts |
409 | CLASS_IDENTITY_TAKEN | (session, grade, section, shift) already exists — including a retired class. | classes-write.service.ts (translate) |
409 | CLASS_ROOM_OCCUPIED | The room already holds an active class this shift, this session. | classes-write.service.ts |
409 | CLASS_TEACHER_ALREADY_ASSIGNED | The teacher already holds an active class this shift, this session. | classes-write.service.ts |
409 | CLASS_CAPACITY_INVALID / CLASS_NAME_INVALID | A CHECK reached the database directly (not realistically reachable past the DTO's own validation). | classes-write.service.ts |
400 | VALIDATION_FAILED | capacity outside 1..500, invalid shift, etc. | Global |
Edge Cases
- Same identity as a retired class: refused — identity uniqueness is not partial on
is_active. - Same room/teacher, different shift: accepted.
classTeacherId/roomIdomitted: no error — both are optional.
Example Requests
curl -X POST "$API_URL/api/classes" \
-H "Authorization: Bearer TOKEN" -H "Content-Type: application/json" \
-d '{"academicSessionId":7,"gradeId":5,"sectionId":1,"shift":"morning","capacity":40}'8.14 PATCH /api/classes/:publicId
Purpose
Edits a class's name, capacity, room, class teacher, or active flag. Identity fields can never be part of this request.
Source Evidence
| Evidence | Path |
|---|---|
| Controller | classes/classes.controller.ts |
| DTO | dto/class.dto.ts (UpdateClassDto) |
| Service | classes/classes-write.service.ts (update) |
| Tests | academic-structure/__tests__/classes-write.service.integration.spec.ts |
Auth and Permissions
- Permission:
Classes_UPDATE.
Request
{ "capacity": 35 }{ "capacity": 20, "allowOverCapacity": true }Response
Same shape as 8.12.
Side Effects
SELECT ... FOR UPDATE (locks the row for the duration of the transaction), an occupancy re-count only if capacity is present, room/teacher resolution only if either field is present, then UPDATE.
Error Cases
| HTTP Status | Error Code | Condition | Source |
|---|---|---|---|
404 | CLASS_NOT_FOUND | No class for the given publicId. | classes-write.service.ts |
422 | CLASS_CAPACITY_BELOW_ENROLLED | capacity lowered below the live enrolment count, without allowOverCapacity. | classes-write.service.ts |
404 | ROOM_NOT_FOUND / CLASS_TEACHER_NOT_FOUND | The given id does not resolve — checked only when the field is present. | classes-write.service.ts |
422 | CLASS_ROOM_INACTIVE / CLASS_TEACHER_NOT_TEACHING | The resolved value is retired/non-teaching — checked only when the resolved id differs from what is already stored. | classes-write.service.ts |
409 | CLASS_ROOM_OCCUPIED / CLASS_TEACHER_ALREADY_ASSIGNED | The new room/teacher is already held elsewhere this shift. | classes-write.service.ts |
400 | VALIDATION_FAILED | An identity field (academicSessionId/gradeId/sectionId/shift) is present in the body. | Global (forbidNonWhitelisted) |
Edge Cases
- Sending an identity field: always
400, regardless of whether it would have matched the current value. - Re-sending the class's own current
roomId/classTeacherId: not re-validated for active status — otherwise a class holding a since-retired resource could never be edited at all, including to deactivate it. roomId/classTeacherId: null: clears the assignment.isActive: false: allowed unconditionally, with no enrolment check.
Example Requests
curl -X PATCH "$API_URL/api/classes/018f2a40-..." \
-H "Authorization: Bearer TOKEN" -H "Content-Type: application/json" \
-d '{"isActive":false}'8.15 DELETE /api/classes/:publicId
Purpose
Permanently removes a class created in error. Blocked while any enrolment — of any status — references it.
Source Evidence
| Evidence | Path |
|---|---|
| Controller | classes/classes.controller.ts |
| Service | classes/classes-write.service.ts (remove) |
| Schema | classes.ts (student_class_enrollments_class_session_fk, ON DELETE restrict) |
Auth and Permissions
- Permission:
Classes_DELETE.
Error Cases
| HTTP Status | Error Code | Condition | Source |
|---|---|---|---|
404 | CLASS_NOT_FOUND | No class for the given publicId. | classes-write.service.ts |
409 | CLASS_HAS_ENROLLMENTS | Any student_class_enrollments row, of any status, still references this class. | classes-write.service.ts (translate, from the real 23503) |
Edge Cases
- The recommended correction for a mistaken class with pupils already enrolled: deactivate (
PATCH {"isActive": false}), not delete — identity is not freed either way.
Example Requests
curl -X DELETE "$API_URL/api/classes/018f2a40-..." \
-H "Authorization: Bearer TOKEN"8.16 GET /api/classes/:publicId/students
Purpose
A class's roster, narrowed to the pupils the caller's role can see. Called by the class detail screen's roster tab.
Source Evidence
| Evidence | Path |
|---|---|
| Controller | enrollments/class-enrollments.controller.ts (roll) |
| DTO | dto/enrollment.dto.ts (ListRosterQueryDto, ClassRosterEntryDto) |
| Service | classes/class-roster.service.ts (findByClass) |
| Access | apps/api/src/modules/people/shared/people-access.service.ts |
Auth and Permissions
- Permission:
Students_READ. - Guest support: None.
- Object-level scope applies. See 5.
Request
GET /api/classes/018f2a40-.../students?status=active HTTP/1.1Response
{
"message": "Class roll fetched.",
"data": [
{ "enrollmentId": "018f2a60-...", "rollNumber": 1, "student": { "id": "018f2a61-...", "studentId": "SID-2026-0005", "admissionNumber": "2026/0005", "fullName": "Anisha Thapa", "recordStatus": "active" }, "status": "active", "enrolledOn": "2026-04-15", "endedOn": null }
],
"errorCode": null,
"count": 1, "currentPage": 1, "totalPage": 1
}The list is ordered by rollNumber ASC NULLS LAST, then student.studentId, then student.id — an unnumbered pupil (rollNumber: null) always renders last, never first.
Side Effects
SELECT joined across student_class_enrollments/students/users, with the caller's object-level scope ANDed into the WHERE clause. No cache.
Error Cases
| HTTP Status | Error Code | Condition | Source |
|---|---|---|---|
404 | CLASS_NOT_FOUND | No class for the given publicId. | class-roster.service.ts |
400 | PAGINATION_LIMIT_INVALID | pagination=false requested. | class-enrollments.controller.ts |
403 | PERMISSION_INSUFFICIENT | Missing Students_READ. | role.guard.ts |
Edge Cases
pagination=falseis always refused — a class roll is every enrolled child's name in one response, for the same reason/studentsrefuses it.- A caller's scope narrower than the class: sees fewer rows than the class actually holds, never an error.
statusomitted: defaults to"active".- A pupil soft-deleted or
recordStatus: "inactive": excluded even if their enrolment row is stillactive— the same filterClassOccupancyServiceapplies.
Example Requests
curl -X GET "$API_URL/api/classes/018f2a40-.../students?status=withdrawn" \
-H "Authorization: Bearer TOKEN"8.17 POST /api/classes/:publicId/enrollments
Purpose
Enrols a pupil into a class, or — if the pupil already holds an active enrolment elsewhere in the same session — transfers them. One route does both; the backend decides which case applies.
Source Evidence
| Evidence | Path |
|---|---|
| Controller | enrollments/class-enrollments.controller.ts (enrol) |
| DTO | dto/enrollment.dto.ts (CreateEnrollmentDto, EnrollmentDto) |
| Service | enrollments-core/class-enrollments.service.ts (enroll) |
| Schema | classes.ts (student_class_enrollments_one_active_per_session_unique, ..._dates_ordered) |
| Tests | enrollments-core/__tests__/class-enrollments.service.integration.spec.ts |
Auth and Permissions
- Permission:
Students_UPDATE. - Object-level scope applies before the class is even resolved —
PeopleAccessService.assertCanAccessruns beforeClassEnrollmentsService.enrollis called. - Idempotency: The one endpoint in this module with real idempotency built in. Resubmitting an identical enrol request against the same target class for an already-active pupil is a no-op, returning the existing row unchanged.
Request
{ "studentId": "018f2a61-..." }{ "studentId": "018f2a61-...", "enrolledOn": "2026-04-20", "allowOverCapacity": true }Response
{
"message": "Pupil enrolled.",
"data": {
"id": "018f2a70-...", "status": "active", "enrolledOn": "2026-04-20", "endedOn": null,
"class": { "publicId": "018f2a40-...", "name": null, "shift": "morning", "grade": { "...": "GradeDto" }, "section": { "...": "SectionDto" }, "academicSession": { "...": "ClassAcademicSessionDto" } },
"createdAt": "...", "updatedAt": "..."
},
"errorCode": null
}Side Effects
- Reads: class context, pupil existence/
deletedAt/admissionDate, the pupil's active row this session (if any), both classes lockedFOR UPDATEin ascending id order, live occupancy under that lock. - Writes: predecessor
UPDATEon a transfer, thenINSERT, both inside one transaction. A roll number is assigned in the same transaction, immediately after the insert —max(rollNumber) + 1among the class's live roll by default, or the whole class is renumbered instead ifschool_profile.roll_number_auto_renumberis on. This assignment is not reflected onEnrollmentDto— read it back from 8.16 or 8.23. - Cache:
students:list:*cleared. - Audit:
recordActivity(ENROLLorTRANSFER), naming the pupil, the old/new class, and — on an override — the capacity numbers. The roll-number assignment itself is not named in this audit entry.
Error Cases
| HTTP Status | Error Code | Condition | Source |
|---|---|---|---|
404 | CLASS_NOT_FOUND | The class does not resolve. | class-enrollments.service.ts |
404 | STUDENT_NOT_FOUND | studentId does not resolve. | class-enrollments.service.ts |
409 | CLASS_INACTIVE | The class is retired — checked both before and after the lock. | class-enrollments.service.ts |
409 | ENROLLMENT_STUDENT_DELETED | The pupil's record is soft-deleted. | class-enrollments.service.ts |
409 | ENROLLMENT_DATE_OUTSIDE_SESSION | enrolledOn falls outside the class's academic session dates. | class-enrollments.service.ts |
409 | ENROLLMENT_DATE_BEFORE_ADMISSION | enrolledOn precedes the pupil's own admission date. | class-enrollments.service.ts |
409 | ENROLLMENT_DATE_INVALID | A back-dated transfer would close the predecessor before its own start date. | class-enrollments.service.ts |
409 | CLASS_AT_CAPACITY | The class is full and allowOverCapacity is not set — the counts are named in the message text. | class-enrollments.service.ts |
409 | ENROLLMENT_ALREADY_ACTIVE | A race past the predecessor read hit the partial unique index. | class-enrollments.service.ts (translate) |
404 | (assertCanAccess, no distinct code — resolves to a generic not-found) | The pupil is outside the caller's object-level scope. | people-access.service.ts |
400 | VALIDATION_FAILED | Invalid studentId/enrolledOn. | Global |
Edge Cases
- Re-submitting the same enrol request for a pupil already active in the target class: no-op, the existing row is returned unchanged.
- Pupil already active in a different class, same session: treated as a transfer — the predecessor is closed
transferredand a freshactiverow opened, atomically. - Two simultaneous cross-transfers (X→Y and Y→X): both classes always locked in ascending
idorder regardless of which request calls which class "target" — deadlock-free by construction. enrolledOnomitted: defaults to today inAsia/Kathmandu, not UTC.- Class retired between the initial read and the lock being granted: caught by the second
isActivecheck, taken under the lock.
Example Requests
curl -X POST "$API_URL/api/classes/018f2a40-.../enrollments" \
-H "Authorization: Bearer TOKEN" -H "Content-Type: application/json" \
-d '{"studentId":"018f2a61-...","allowOverCapacity":true}'8.18 DELETE /api/classes/:publicId/enrollments/:studentId
Purpose
Withdraws a pupil from this class — records that they left, distinct from removing a mistaken entry.
Source Evidence
| Evidence | Path |
|---|---|
| Controller | enrollments/class-enrollments.controller.ts (withdraw) |
| Service | enrollments-core/class-enrollments.service.ts (withdraw) |
| Tests | enrollments-core/__tests__/class-enrollments.service.integration.spec.ts |
Auth and Permissions
- Permission:
Students_UPDATE, plus object-level scope.
Response
{
"message": "Pupil withdrawn.",
"data": { "id": "018f2a70-...", "status": "withdrawn", "enrolledOn": "2026-04-20", "endedOn": "2026-09-08", "class": { "...": "EnrollmentClassDto" }, "createdAt": "...", "updatedAt": "..." },
"errorCode": null
}Side Effects
Class locked FOR UPDATE; UPDATE on the pupil's active row (status, endedOn); students:list:* cache cleared; recordActivity (WITHDRAW).
Error Cases
| HTTP Status | Error Code | Condition | Source |
|---|---|---|---|
404 | CLASS_NOT_FOUND | No class for the given publicId. | class-enrollments.service.ts |
404 | ENROLLMENT_NOT_FOUND | No active row for this pupil in this class. | class-enrollments.service.ts |
Edge Cases
endedOnis computed asmax(enrolledOn, today), never plain "today" — a future-dated active enrolment withdrawn on its own start date would otherwise violate the dates-ordered CHECK.- A pupil enrolled by mistake: withdrawal is the wrong tool — see 8.20.
Example Requests
curl -X DELETE "$API_URL/api/classes/018f2a40-.../enrollments/018f2a61-..." \
-H "Authorization: Bearer TOKEN"8.19 PATCH /api/enrollments/:id
Purpose
Corrects an enrolment's date and/or status without going through enrol/withdraw — for example, backfilling a historical record.
Source Evidence
| Evidence | Path |
|---|---|
| Controller | enrollments/class-enrollments.controller.ts (update) |
| DTO | dto/enrollment.dto.ts (UpdateEnrollmentDto) |
| Service | enrollments-core/class-enrollments.service.ts (updateEnrollment) |
Auth and Permissions
- Permission:
Students_UPDATE, plus object-level scope, resolved by reading the enrolment'sstudentIdfirst.
Request
{ "enrolledOn": "2026-04-16" }{ "status": "withdrawn" }Error Cases
| HTTP Status | Error Code | Condition | Source |
|---|---|---|---|
404 | ENROLLMENT_NOT_FOUND | No row for the given id. | class-enrollments.service.ts |
400 | VALIDATION_FAILED | classId sent (not a field on this DTO), or status outside ["transferred", "withdrawn"]. | Global / DTO |
Edge Cases
classIdin the body: always400— unknown field. Moving a pupil is alwaysPOST /classes/:publicId/enrollments.statusset fromactivetotransferred/withdrawn, noendedOngiven: computed the samemax(enrolledOn, today)way as withdraw.enrolledOnmoved past an already-setendedOn:endedOnpulled forward to match, rather than left to violate the CHECK.
Example Requests
curl -X PATCH "$API_URL/api/enrollments/018f2a70-..." \
-H "Authorization: Bearer TOKEN" -H "Content-Type: application/json" \
-d '{"status":"withdrawn"}'8.20 DELETE /api/enrollments/:id
Purpose
Removes an enrolment entered in error — the wrong class was picked, or a duplicate slipped past a race. Leaves no trace in the transferred/withdrawn counters, distinct from withdrawal.
Source Evidence
| Evidence | Path |
|---|---|
| Controller | enrollments/class-enrollments.controller.ts (remove) |
| Service | enrollments-core/class-enrollments.service.ts (removeEnrollment) |
Auth and Permissions
- Permission:
Students_UPDATE, plus object-level scope.
Response
{ "message": "Enrolment removed.", "data": null, "errorCode": null }Side Effects
Owning class locked FOR UPDATE; hard DELETE; students:list:* cache cleared; recordActivity (ENROLLMENT_DELETE).
Error Cases
| HTTP Status | Error Code | Condition | Source |
|---|---|---|---|
404 | ENROLLMENT_NOT_FOUND | No row for the given id. | class-enrollments.service.ts |
Edge Cases
- Removing an
activerow: allowed — no different from removing a closed one. - Why this exists alongside withdraw: offering only "withdraw" would force an operator to record a fictional departure for a pupil who never actually left the class — see the feature doc's 12.3.
Example Requests
curl -X DELETE "$API_URL/api/enrollments/018f2a70-..." \
-H "Authorization: Bearer TOKEN"8.21 GET /api/students/:id/enrollments
Purpose
A pupil's own class history, newest first — the one active row per academic year, plus any transfers and withdrawals. Called by the pupil profile screen's enrolment-history section.
Source Evidence
| Evidence | Path |
|---|---|
| Controller | apps/api/src/modules/people/students/students.controller.ts (findEnrollments) — not a controller in this module |
| DTO | dto/enrollment.dto.ts (ListStudentEnrollmentsQueryDto, EnrollmentDto) |
| Service | enrollments-core/class-enrollments.service.ts (listForStudent) |
Auth and Permissions
- Permission:
Students_READ. - Object-level scope applies, resolved exactly as every other single-record route on
StudentsController:assertCanAccessbefore the read.
Request
GET /api/students/018f2a61-.../enrollments?academicSessionId=7 HTTP/1.1Response
{
"message": "Class enrolments fetched.",
"data": [
{ "id": "018f2a70-...", "status": "active", "enrolledOn": "2026-04-20", "endedOn": null, "class": { "...": "EnrollmentClassDto" }, "createdAt": "...", "updatedAt": "..." }
],
"errorCode": null,
"count": 1, "currentPage": 1, "totalPage": 1
}Side Effects
SELECT joined across student_class_enrollments/classes/grades/sections/academic_sessions. No cache.
Error Cases
| HTTP Status | Error Code | Condition | Source |
|---|---|---|---|
404 | (scope-derived not-found) | The pupil is outside the caller's object-level scope, or does not exist — indistinguishable. | people-access.service.ts |
400 | VALIDATION_FAILED | Invalid academicSessionId. | Global |
Edge Cases
- Route ownership. Lives on
StudentsController, not a class controller — matching the convention:id/guardiansand:id/medicalalready set on the same controller. Putting it here would giveAcademicStructureModulea/students/*prefix nothing else in this repo uses. sort/order/searchaccepted, ignored — ordering is alwaysenrolledOn DESC, id DESC.- No enrolments yet (pupil admitted, not yet placed):
data: []. academicSessionIdgiven: narrows to that session's rows only, typically one active plus any transfers/withdrawals within it.
Example Requests
curl -X GET "$API_URL/api/students/018f2a61-.../enrollments" \
-H "Authorization: Bearer TOKEN"8.22 PATCH /api/enrollments/:id/roll-number
Purpose
Sets or clears one pupil's roll number. Shuffles nobody else — an administrator who wants the whole class resequenced uses 8.23 instead. Called by the class roster screen's per-row number editor.
Source Evidence
| Evidence | Path |
|---|---|
| Controller | enrollments/class-enrollments.controller.ts (setRollNumber) |
| DTO | dto/roll-number.dto.ts (SetRollNumberDto) |
| Service | enrollments-core/roll-numbers/roll-number.service.ts (setManual) |
| Schema | classes.ts (student_class_enrollments_class_roll_unique, ..._roll_number_positive) |
| Tests | enrollments-core/__tests__/roll-number.service.integration.spec.ts |
Auth and Permissions
- Permission:
Students_UPDATE. - Object-level scope applies, resolved by reading the enrolment's
studentIdfirst (ClassEnrollmentsController.pupilForEnrollment) — identical to 8.19. - Idempotency: setting the same value twice, or clearing an already-
nullnumber, both succeed as ordinary writes; there is no distinct no-op branch as there is on 8.17.
Request
{ "rollNumber": 12 }{ "rollNumber": null }Response
{ "message": "Roll number updated.", "data": null, "errorCode": null }Side Effects
- Reads: the enrolment's
id/status. - Writes:
UPDATE student_class_enrollments SET roll_number = $1 WHERE id = $2, inside a transaction the controller opens. - Cache: none — unlike enrol/withdraw/correct/remove, this write does not clear
students:list:*. A pupil's roll number is not shown on the student list. - Audit: none explicit — relies entirely on the automatic
ActivityAuditInterceptor, which records that aStudentsaction ran but not the pupil's previous number.
Error Cases
| HTTP Status | Error Code | Condition | Source |
|---|---|---|---|
404 | ENROLLMENT_NOT_FOUND | No row for the given id (from pupilForEnrollment, before the roll-number logic runs). | class-enrollments.controller.ts |
404 | (scope-derived not-found) | The pupil is outside the caller's object-level scope. | people-access.service.ts |
422 | ROLL_NUMBER_INVALID | rollNumber is present and below 1 (the DTO's own @Min(1) already narrows this; the service re-checks). | roll-number.service.ts |
422 | ROLL_NUMBER_ENROLLMENT_INACTIVE | The enrolment named by :id does not exist by the time setManual re-reads it — narrowly reachable given pupilForEnrollment already resolved it moments earlier. | roll-number.service.ts |
409 | ROLL_NUMBER_ENROLLMENT_INACTIVE | The enrolment exists but is transferred/withdrawn — the same error code as the row above, at a different HTTP status. A closed enrolment keeps the number it had; editing it would rewrite what the register said on a day that has already passed. | roll-number.service.ts |
409 | ROLL_NUMBER_TAKEN | Another live pupil in the class already holds the requested number. | roll-number.service.ts (isUniqueViolation) |
400 | VALIDATION_FAILED | rollNumber is present, non-null, and not an integer >= 1. | Global / DTO |
Edge Cases
- This route never moves a second pupil. Compare 8.23, the only route that does.
rollNumber: null(or an omitted key): unnumbers the pupil, who then sorts last on the roster (8.16) until a renumber, not first.- A closed enrolment (
transferred/withdrawn): always409, regardless of what value is sent, includingnull. - Two callers racing to set the same number on two different pupils in the same class: the loser gets
409 ROLL_NUMBER_TAKEN, read off the driver error's.causechain by constraint name, never an unmapped500.
Example Requests
curl -X PATCH "$API_URL/api/enrollments/018f2a70-.../roll-number" \
-H "Authorization: Bearer TOKEN" -H "Content-Type: application/json" \
-d '{"rollNumber":12}'curl -X PATCH "$API_URL/api/enrollments/018f2a70-.../roll-number" \
-H "Authorization: Bearer TOKEN" -H "Content-Type: application/json" \
-d '{"rollNumber":null}'8.23 POST /api/classes/:publicId/enrollments/renumber
Purpose
Renumbers a class's entire live roll, 1..n, in the school's configured order (school_profile.roll_number_strategy). The only operation in this module that moves an existing pupil's number. Called by an explicit "renumber this class" action, never automatically from a screen the way enrolment is.
Source Evidence
| Evidence | Path |
|---|---|
| Controller | enrollments/class-enrollments.controller.ts (renumber) |
| Service | enrollments-core/roll-numbers/roll-number.service.ts (resolveClassId, liveStudentIds, renumber) |
| Schema | classes.ts (student_class_enrollments_class_roll_unique) |
| Tests | enrollments-core/__tests__/roll-number.service.integration.spec.ts |
Auth and Permissions
- Permission:
Students_UPDATE. - Object-level scope applies to every pupil on the roll, checked before any write — there is no class-level scope to substitute. See 5.
- Idempotency: re-running against an already-correctly-ordered class reassigns the identical numbers — a safe, repeatable operation, though every row is still rewritten (clear-then-set) rather than skipped.
Request
No body. publicId in the path names the class.
POST /api/classes/018f2a40-.../enrollments/renumber HTTP/1.1Response
{
"message": "Class renumbered.",
"data": [
{ "enrollmentId": "018f2a60-...", "studentId": "018f2a61-...", "rollNumber": 1 },
{ "enrollmentId": "018f2a62-...", "studentId": "018f2a63-...", "rollNumber": 2 }
],
"errorCode": null
}data is the full RollNumberAssignment[] — see 6.27. Every LIVE pupil in the class appears exactly once, in the newly-assigned order; a pupil excluded from the roll (soft-deleted, recordStatus: "inactive", or already transferred/withdrawn) does not appear at all.
Side Effects
- Reads:
resolveClassId(the class);liveStudentIds(every live pupil, for the scope loop);renumber's ownSELECT— the live roll joined tostudents/users. - Writes: two phases, both inside one transaction — every live row's
roll_numbercleared tonull, then every row rewritten1..nin the resolved order. - Cache: none — same as 8.22.
- Audit: none explicit — relies entirely on the automatic interceptor, which records that the action ran but not which pupil received which number; the full mapping is only visible in the response body itself, not the audit log.
Error Cases
| HTTP Status | Error Code | Condition | Source |
|---|---|---|---|
422 | CLASS_NOT_FOUND | publicId does not resolve to a class — a 422, not the 404 every other CLASS_NOT_FOUND thrower in this module returns; see the backend doc's 6.9. | roll-number.service.ts (resolveClassId) |
404 | (scope-derived not-found) | The FIRST live pupil outside the caller's object-level scope, found before any write. | people-access.service.ts |
403 | PERMISSION_INSUFFICIENT | Missing Students_UPDATE. | role.guard.ts |
Edge Cases
- An empty class (no live pupils) succeeds with
data: []—renumberreturns early rather than performing a no-op two-phase write. - Excludes a soft-deleted pupil and a
recordStatus: "inactive"pupil from the resequenced roll, identically to occupancy and the roster — neither consumes a position. - A class holding pupils already numbered
1,3,4(a gap from a departed pupil at2): renumbers to1,2,3— this route is how a school compacts gaps, since automatic assignment never does. - Changing
roll_number_strategyon the school profile between two renumbers: the second renumber uses whatever strategy is current at THAT call — this route never accepts an explicit override in its own request body.
Example Requests
curl -X POST "$API_URL/api/classes/018f2a40-.../enrollments/renumber" \
-H "Authorization: Bearer TOKEN"8.24 GET /api/rooms/buildings
Purpose
Lists the distinct buildings of active rooms — the building filter's own value source, so a client never has to derive it from GET /api/rooms itself. Mirrors GET /api/asset-catalog/rooms/buildings in the assets module: both call the same RoomOccupancyService.buildings().
Source Evidence
| Evidence | Path |
|---|---|
| Controller | rooms/rooms.controller.ts (buildings) |
| Service | rooms/room-occupancy.service.ts (buildings) |
| Tests | academic-structure/__tests__/rooms-occupancy.integration.spec.ts |
Auth and Permissions
- Permission:
Rooms_READ— the same codeGET /api/roomsuses, not a separate one.
Request
No query parameters.
GET /api/rooms/buildings HTTP/1.1Response
{
"message": "Buildings fetched.",
"data": ["Annex", "Main Block"],
"errorCode": null
}data is a plain string[], not paginated and not wrapped in RoomDto — the response schema is declared inline with @ApiOkResponse rather than through ApiResponseDto, because there is no DTO class for a bare array of strings.
Side Effects
None — uncached read.
Error Cases
Same shape as 8.1, substituting Rooms_READ.
Edge Cases
- Ordered case-insensitively (
ORDER BY lower(building)), so"annex"and"Annex"sort together regardless of which rooms were entered first. - A building whose only room is inactive (
is_active = false) does not appear — the sameWHERE is_activethe asset catalog's equivalent route uses. - An empty room table returns
data: [], not an error.
Example Requests
curl -X GET "$API_URL/api/rooms/buildings" \
-H "Authorization: Bearer TOKEN"9. Flow Diagrams
9.1 Route Ownership
9.2 Request Sequence — enrol/transfer
9.3 Request Sequence — renumber a class's roll
9.4 Error Branch — mutation endpoints
10. Pagination, Sorting, Filtering, and Search
| Endpoint | Pagination Type | Default Size | Max Size | Sort Fields Honored | Filters | Result Cap |
|---|---|---|---|---|---|---|
GET /grades | page/size, or pagination=false | 20 | 100 | None — fixed sortOrder, name, id | search, isActive | 1000 when unpaginated |
GET /sections | page/size, or pagination=false | 20 | 100 | None — fixed sortOrder, name, id | search, isActive | 1000 when unpaginated |
GET /rooms | page/size, or pagination=false | 20 | 100 | None — fixed building, floor, roomNumber, id | search (number+building+name), isActive, building, floor | 1000 when unpaginated |
GET /classes | page/size only — pagination=false refused | 20 | 100 | None — fixed grade.sortOrder, section.sortOrder, shift, id | search, academicSessionId, gradeId, sectionId, shift, roomId, classTeacherId, hasRoom, hasClassTeacher, isActive | N/A |
GET /classes/options | Always unpaginated, no pagination field | N/A | N/A | None — fixed grade.sortOrder, section.sortOrder, shift, id | academicSessionId or date | 1000, with truncated: true on overflow |
GET /classes/:publicId/students | page/size only — pagination=false refused | 20 | 100 | None — fixed studentId, id | status (default active) | N/A |
PATCH /enrollments/:id list side (GET /students/:id/enrollments) | page/size, or pagination=false | 20 | 100 | None — fixed enrolledOn DESC, id DESC | academicSessionId | 1000 when unpaginated |
Shared pagination utility: PaginationUtil throughout — normalize() clamps page/size, getDrizzleParams() returns {limit, offset} or undefined, buildMetadata() builds the envelope's {count, page, size}.
No broad-search detection or relevance scoring exists anywhere in this module — every search filter is a plain ILIKE '%term%' (escaped via escapeLikePattern) against one or more fixed columns, never a ranked or full-text search.
Cache behavior per query: none — every list in this module is read live, on every request (see the backend doc's 8. Caching).
Empty result behavior: every list endpoint returns { "data": [], "count": 0 } (or the array/object equivalent) rather than an error, including when the resolved academic session has no classes, when a search matches nothing, or when a filter combination is impossible.
11. Caching, Jobs, and External Integrations
| Integration | Used? | Details | Source |
|---|---|---|---|
| Redis cache | No — read side | No list or single-row read in this module is cache-aside; every read is live. | packages/redis, absent from every service in this module except the enrolment writer. |
| Redis cache | Yes — write-only invalidation | Every enrolment write (enroll, withdraw, updateEnrollment, removeEnrollment) clears students:list:*, a prefix owned and populated by StudentsService, not this module. Neither roll-number route does — PATCH /enrollments/:id/roll-number and POST .../enrollments/renumber inject no RedisCacheService and clear nothing, since a pupil's roll number is not shown on the student list. | enrollments-core/class-enrollments.service.ts; enrollments-core/roll-numbers/roll-number.service.ts (absence verified). |
| BullMQ | No | No queue, job, or processor exists anywhere in this module. | Verified by the absence of any BullMQ import across every file in 1. |
| External API | No | No third-party integration exists in this module. | — |
| MongoDB (audit) | Yes | Every enrolment write calls ActivityRecordService.recordActivity explicitly, in addition to the automatic interceptor every mutating route in the codebase already gets. Grade/section/room/class mutations rely on the automatic interceptor alone. So do both roll-number routes — RollNumberService calls no explicit audit method; a renumber's full old-number-to-new-number mapping is visible only in that call's own response body, never in the audit log. | enrollments-core/class-enrollments.service.ts; enrollments-core/roll-numbers/roll-number.service.ts (absence verified). |
13. Mandatory Deep API Documentation Pack
13.1 Route-by-Route Completeness Matrix
| Route | Controller Method | DTOs | Service Method | Guards | Permissions | Cache | Jobs | DB Touches | Errors | Tests | Documented? |
|---|---|---|---|---|---|---|---|---|---|---|---|
GET /api/grades | GradesController.findAll | ListGradesQueryDto, GradeDto | GradesService.findAll | JwtAuthGuard, RoleGuard | Classes_READ | N/A | N/A | grades | 400/401/403 | grades.service.integration.spec.ts | Yes |
GET /api/sections | SectionsController.findAll | ListSectionsQueryDto, SectionDto | SectionsService.findAll | Same | Sections_READ | N/A | N/A | sections | 400/401/403 | sections.service.integration.spec.ts | Yes |
POST /api/sections | SectionsController.create | CreateSectionDto, SectionDto | SectionsService.create | Same | Sections_CREATE | N/A | N/A | sections | 400/409/401/403 | sections.service.integration.spec.ts | Yes |
PATCH /api/sections/:publicId | SectionsController.update | UpdateSectionDto, SectionDto | SectionsService.update | Same | Sections_UPDATE | N/A | N/A | sections | 400/404/409 | sections.service.integration.spec.ts | Yes |
DELETE /api/sections/:publicId | SectionsController.remove | — | SectionsService.remove | Same | Sections_DELETE | N/A | N/A | sections, classes (FK) | 404/409 | sections.service.integration.spec.ts | Yes |
GET /api/rooms | RoomsController.findAll | ListRoomsQueryDto, RoomDto | RoomsService.findAll, RoomOccupancyService.classesByRoom/.assetCountByRoom | Same | Rooms_READ | N/A | N/A | rooms, classes+joins, assets | 400/401/403 | rooms.service.integration.spec.ts, rooms-occupancy.integration.spec.ts | Yes |
GET /api/rooms/buildings | RoomsController.buildings | — (response: string[]) | RoomOccupancyService.buildings | Same | Rooms_READ | N/A | N/A | rooms | 401/403 | rooms-occupancy.integration.spec.ts | Yes |
POST /api/rooms | RoomsController.create | CreateRoomDto, RoomDto | RoomsService.create | Same | Rooms_CREATE | N/A | N/A | rooms | 400/409 | rooms.service.integration.spec.ts | Yes |
PATCH /api/rooms/:publicId | RoomsController.update | UpdateRoomDto, RoomDto | RoomsService.update | Same | Rooms_UPDATE | N/A | N/A | rooms | 400/404/409 | rooms.service.integration.spec.ts | Yes |
DELETE /api/rooms/:publicId | RoomsController.remove | — | RoomsService.remove | Same | Rooms_DELETE | N/A | N/A | rooms, classes (explicit check) | 404/409 | rooms.service.integration.spec.ts | Yes |
GET /api/classes | ClassesController.findAll | ListClassesQueryDto, ClassDto | ClassesReadService.findAll | Same | Classes_READ | N/A | N/A | classes + 5 joins | 400/401/403 | classes-read.service.integration.spec.ts | Yes |
GET /api/classes/options | ClassesController.findOptions | ClassOptionsQueryDto, ClassOptionsPayloadDto | ClassesReadService.findOptions | Same | Classes_READ | N/A | N/A | classes + 2 joins | 401/403 | classes-read.service.integration.spec.ts | Yes |
GET /api/classes/:publicId | ClassesController.findOne | ClassDto | ClassesReadService.findOne/loadDto | Same | Classes_READ | N/A | N/A | Same as list, single row | 404 | classes-read.service.integration.spec.ts | Yes |
POST /api/classes | ClassesController.create | CreateClassDto, ClassDto | ClassesWriteService.create | Same | Classes_CREATE | N/A | N/A | academic_sessions, grades, sections, rooms, staff+designations, classes | 400/404/409/422 | classes-write.service.integration.spec.ts | Yes |
PATCH /api/classes/:publicId | ClassesController.update | UpdateClassDto, ClassDto | ClassesWriteService.update | Same | Classes_UPDATE | N/A | N/A | classes (locked), rooms, staff+designations | 400/404/409/422 | classes-write.service.integration.spec.ts | Yes |
DELETE /api/classes/:publicId | ClassesController.remove | — | ClassesWriteService.remove | Same | Classes_DELETE | N/A | N/A | classes, student_class_enrollments (FK) | 404/409 | classes-write.service.integration.spec.ts | Yes |
GET /api/classes/:publicId/students | ClassEnrollmentsController.roll | ListRosterQueryDto, ClassRosterEntryDto | ClassRosterService.findByClass | Same | Students_READ | N/A | N/A | student_class_enrollments, students, users (scoped) | 400/404/403 | — (covered indirectly via PeopleAccessService tests) | Yes |
POST /api/classes/:publicId/enrollments | ClassEnrollmentsController.enrol | CreateEnrollmentDto, EnrollmentDto | ClassEnrollmentsService.enroll | Same | Students_UPDATE | Invalidates students:list:* | N/A | classes (locked), students, student_class_enrollments | 400/404/409 | class-enrollments.service.integration.spec.ts | Yes |
DELETE /api/classes/:publicId/enrollments/:studentId | ClassEnrollmentsController.withdraw | — | ClassEnrollmentsService.withdraw | Same | Students_UPDATE | Same | N/A | Same tables | 404 | class-enrollments.service.integration.spec.ts | Yes |
PATCH /api/enrollments/:id | ClassEnrollmentsController.update | UpdateEnrollmentDto, EnrollmentDto | ClassEnrollmentsService.updateEnrollment | Same | Students_UPDATE | Same | N/A | student_class_enrollments, classes (locked) | 400/404 | class-enrollments.service.integration.spec.ts | Yes |
PATCH /api/enrollments/:id/roll-number | ClassEnrollmentsController.setRollNumber | SetRollNumberDto | RollNumberService.setManual | Same | Students_UPDATE | None | N/A | student_class_enrollments | 400/404/409/422 | roll-number.service.integration.spec.ts | Yes |
POST /api/classes/:publicId/enrollments/renumber | ClassEnrollmentsController.renumber | — (response: RollNumberAssignment[]) | RollNumberService.renumber, resolveClassId, liveStudentIds | Same | Students_UPDATE | None | N/A | student_class_enrollments, students, users (read) | 403/404/422 | roll-number.service.integration.spec.ts | Yes |
DELETE /api/enrollments/:id | ClassEnrollmentsController.remove | — | ClassEnrollmentsService.removeEnrollment | Same | Students_UPDATE | Same | N/A | Same tables | 404 | class-enrollments.service.integration.spec.ts | Yes |
GET /api/students/:id/enrollments | StudentsController.findEnrollments | ListStudentEnrollmentsQueryDto, EnrollmentDto | ClassEnrollmentsService.listForStudent | Same | Students_READ | N/A | N/A | student_class_enrollments + 4 joins | 400/404 | — | Yes |
13.2 Request/Response Exhaustiveness
Every endpoint above includes a minimal and (where the DTO has optional fields) a full valid request example in 8, a success response example, and its complete error table. Public/guest requests are not applicable anywhere in this module (no route accepts an unauthenticated caller). Validation-error and domain-error examples are covered in each endpoint's Error Cases table rather than repeated as separate JSON bodies, since the envelope shape ({statusCode, errorCode, message}) is identical across every one — see 2 and the school module's own api.mdx for the shared shape.
13.3 API Diagram Pack
Provided: route ownership (9.1), representative request sequences for the module's two most complex flows — enrol/transfer (9.2) and renumber (9.3) — and an error decision tree covering every route family (9.4). Per-endpoint activity diagrams and per-endpoint sequence diagrams are provided in the features and flows doc, which this document links to rather than duplicating, since the same diagrams would otherwise appear twice with identical content.
13.4 Consumer Integration Notes
| Consumer | Required Knowledge | Failure Handling | Contract Stability |
|---|---|---|---|
| Admin web app (class setup) | Classes_CREATE/_UPDATE/_DELETE, Sections_*, Rooms_*; the four identity fields are permanently immutable after creation | Display the exact errorCode — CLASS_IDENTITY_TAKEN/CLASS_ROOM_OCCUPIED/CLASS_TEACHER_ALREADY_ASSIGNED are all distinguishable and actionable | Stable. |
| Admin web app (admission form) | GET /classes/options for the grade/section/shift cascade and capacity display — not GET /classes, which requires pagination and carries staff identity | A 409 CLASS_AT_CAPACITY on enrol should surface the exact message text (it carries the numbers); resubmitting with allowOverCapacity: true is the documented recovery | Stable. |
| Admin web app (enrolment desk) | Students_UPDATE is required for every write here — a caller with only Classes_* grants cannot enrol/withdraw/correct/remove | Every 404 on a pupil-scoped route may mean "exists but out of scope," not only "does not exist" — do not render a generic "not found" without accounting for this | Stable. |
| Attendance register (a consumer of the roster, not owned by this module — see its own docs) | ClassRosterEntryDto.rollNumber is the field the register renders and sorts by; an unnumbered pupil (null) sorts last, never first | A register showing every pupil as unnumbered means the class has not been through admission-order numbering or a renumber yet, not a fetch failure | Stable. |
| Admin web app (roster screen, roll-number editing) | PATCH /enrollments/:id/roll-number for a single edit; POST /classes/:publicId/enrollments/renumber to resequence the whole class — never build a client-side loop calling the first route repeatedly to simulate the second, since that would take the class-roll unique index one collision at a time instead of the two-phase clear-then-write the service performs atomically | 409 ROLL_NUMBER_TAKEN on the single-edit route names the specific collision; a renumber's response body is the only place the full old-to-new mapping is visible, since it is not written to the audit log | Stable. |
| Teacher-facing screens | The seeded teacher role holds every _READ code in this module but no Students_UPDATE — build read-only class/roster views for this role, not write actions | 403 PERMISSION_INSUFFICIENT on any enrolment write attempt is expected, not a bug | Stable. |
| QA | The constraint probe (probe-class-module-constraints.sql) is the authoritative fixture for every accept/reject edge case; every named error code in this document has a corresponding probe case or integration test | Reproduce a defect by finding the matching probe case first — if none exists, the gap is real and worth adding one for | Stable. |
Internal service (a future consumer of ClassesReadService, currently the module's only export) | loadDto(executor, publicId) accepts either the pooled Database or an open transaction — pass the caller's own tx when composing a write that needs a fresh class read inside it | N/A — not yet consumed by any other module | Experimental (unconsumed today). |
13.5 API Tradeoffs and Rationale
| Decision | Chosen Behavior | Alternatives Considered | Why This Tradeoff | Risk | Mitigation |
|---|---|---|---|---|---|
| Two class-list endpoints instead of one with a "slim" flag | GET /classes (paginated, full detail) and GET /classes/options (unpaginated, no staff identity) | One endpoint, a query flag choosing the response shape | A single response shape can stay honest about exactly what data it needs to carry for each real use case, rather than growing a conditional shape | Two response shapes to document and keep conceptually aligned | Deliberately structured differently enough (data array vs. data object) that a client cannot confuse them at the type level. |
pagination=false refused on the class list and the class roster, but not on grades/sections/rooms | Named PAGINATION_LIMIT_INVALID refusal on person-adjacent data specifically | Cap the unpaginated read size instead of refusing outright, as grades/sections/rooms do | The class list embeds staff identity; the roster is literally a list of children's names — both are a bigger disclosure than a capped reference-table read | A client written against the school module's pattern (where every list accepts pagination=false) will be surprised here | Both refusals are documented per-endpoint and the alternative (GET /classes/options) is named explicitly. |
| Enrol and transfer share one route | POST /classes/:publicId/enrollments decides the case from the pupil's existing state | Separate POST .../enroll and POST .../transfer endpoints | A client does not need to know in advance whether a pupil is already enrolled elsewhere — the backend already has to check this to validate the request either way | A client cannot force a strict "enrol only, error if already elsewhere" semantic through this endpoint alone | Documented explicitly in the endpoint's purpose and edge cases. |
Withdraw and remove as two distinct DELETE routes on two different resources | DELETE .../enrollments/:studentId (withdraw, keeps history) vs. DELETE /enrollments/:id (remove, no trace) | One DELETE /enrollments/:id with a body flag choosing the semantics | DELETE with a body is unconventional and easy to send incorrectly (many HTTP clients drop or warn on a DELETE body); two distinct resources make the choice unambiguous from the URL alone | A client must know which resource identifier it has (a class + student pair, vs. an enrolment id) to pick the right route | Both are documented with explicit cross-references to each other. |
Correction endpoint (PATCH /enrollments/:id) permanently excludes classId | Moving a pupil is only ever POST /classes/:publicId/enrollments | Allow classId on the correction PATCH, applying the same capacity/lock logic inline | A second code path implementing the identical capacity-lock-transfer logic would be a second place to keep correct | None — this is a closed decision, not an open risk. | N/A. |
Automatic roll-number assignment on enrolment appends by default; only an explicit POST .../renumber call moves an existing pupil | RollNumberService.assignOnEnroll branches on school_profile.roll_number_auto_renumber, off by default | Always resequence the whole class on every admission, matching the configured strategy continuously | A roll number has already been read out in class and recorded against attendance history by the time the next pupil joins — resequencing on every admission would silently rewrite that history mid-year for every school that has not explicitly opted in | A school wanting alphabetical order maintained continuously must set roll_number_auto_renumber: true and understand that every admission then rewrites the whole class's numbers, not just appends one | If schools consistently expect continuous alphabetical ordering without knowing to set this flag, the admission-form UX (not the API contract) is the place to surface it explicitly. |
| Set-one-number and renumber-the-class are two distinct routes, not one route with a mode flag | PATCH /enrollments/:id/roll-number (one pupil, never moves another) vs. POST /classes/:publicId/enrollments/renumber (the whole live roll, the only route that moves an existing number) | One PATCH accepting either a single number or a {mode: "renumber"} body | The two operations have entirely different authorization shapes — one checks one pupil's scope, the other must check every live pupil's scope before writing anything — collapsing them into one route would make that authorization branch implicit in a request body field instead of explicit in the route chosen | A client must know which of the two effects it wants before choosing a route; there is no combined "set this one AND resequence everyone else around it" call | None identified — the two operations' authorization shapes differ enough that this is unlikely to be revisited. |
13.6 API Change Impact
| Change | Affected Consumers | Backend Impact | Data Impact | Migration Needed? | Compatibility Plan |
|---|---|---|---|---|---|
Adding a Grades permission module and CRUD routes | Admin web app (class setup) | New controller/service, following the Sections/Rooms pattern exactly | None — existing grades rows are untouched | No | Additive; GET /api/grades is unaffected. |
Adding sort/order support to any list in this module | Any consumer currently ignoring the accepted-but-unused fields | Each service's orderBy clause would need to branch on the query value, matching the school module's LookupsService pattern | None | No | Additive and backward-compatible — a client already sending sort/order (currently ignored) would simply start seeing it honored. |
Adding a completed value to enrollment_status | Every consumer reading EnrollmentDto.status | New enum value in the schema, a migration, and a promotion/rollover feature to write it | Existing rows are unaffected; no backfill implied | Yes — schema migration required | A dated, deliberate feature addition, not a silent behavior change — see the backend doc's note on why the value is absent today. |
| Scoping capacity overrides to a per-class allowance instead of unbounded | Enrolment desk | ClassEnrollmentsService.enroll would need a new check against an accumulated override count/limit | New column or side-table to track override history per class | Likely yes | Would need a deprecation window if the current unconditional-override behavior is ever tightened. |
14. Zero-Omission API Checklist
- Every controller route is documented (23 routes across 6 controllers/1 read handler).
- Every parent route prefix and runtime URL is documented, including
ClassEnrollmentsController's bare@Controller()and its two distinct path prefixes. - Every DTO field, nested field, enum, default, transform, and validator is documented.
- Every response field, nullable field, generated field, and stripped-out field is documented — including the
id/createdAtfields grades/sections/rooms/classes DO return (unlike the school module'sSchoolProfileDto, nothing is stripped here). - Every auth, guard, permission, and object-level-scope branch is documented, including the two-tier
Classes_*/Sections_*/Rooms_*vs.Students_*permission surface. - Every success, validation, auth, permission, not-found, conflict, and rate-limit-shaped (
PAGINATION_LIMIT_INVALID) branch is documented. - Every database read/write, cache invalidation, and audit-log call is documented — including this module's deliberate absence of read-side caching.
- Every route has examples for minimal request, full request (where applicable), success response, and representative failures.
- Route ownership, request sequence, and error-branch diagrams are provided; per-endpoint sequence/activity diagrams live in the features and flows doc and are linked rather than duplicated.
- Every tradeoff and compatibility risk is documented.
- This document links to backend, features/flows.
14b. The consumer portal — teacher surface
Two routes under /api/mobile let a teacher read their own classes and the pupils on them.
| Method | Path | Audience | Returns |
|---|---|---|---|
| GET | /api/mobile/teacher/classes | staff | paginated PortalClassDto |
| GET | /api/mobile/teacher/classes/{publicId}/roster | staff | paginated PortalRosterEntryDto |
GET /api/mobile/teacher/classes takes the pagination parameters plus one optional filter:
| Query parameter | Type | Default | Meaning |
|---|---|---|---|
scope | homeroom | teaching | homeroom | homeroom lists the classes the caller is class teacher of. teaching lists those and every class where the caller is the teacher of a live subject. Any other value answers 400 from the validation pipe. |
Each PortalClassDto row carries isClassTeacher: true when the caller is the class's class
teacher, false when the class is listed only because the caller teaches a subject in it. A class
the caller reaches by both relations appears once, with isClassTeacher: true.
{publicId} is classes.public_id. Note this differs from the guardian portal, which addresses a
pupil by students.id: classes.id is a serial integer and never appears on a public surface,
while students has no public_id column at all and its primary key is already a uuid. The two
rules look inconsistent and are not — each follows its own table.
What "my classes" means
Two relations connect a teacher to a class.
- Homeroom —
classes.class_teacher_id. It records a homeroom assignment rather than a teaching one. A partial unique index permits at most one active class per teacher per academic session per shift, and there are two shifts — so at most two. - Teaching — a
class_subjectsrow whoseteacher_idis the caller.class_subjectshas no soft-delete column;is_activeis its suspension flag, and a suspended row grants nothing.
The default scope walks the homeroom relation only, so a subject teacher who is nobody's class
teacher sees an empty list there and gets their classes with scope=teaching. The teaching scope is
one OR in the WHERE clause over an EXISTS on class_subjects, never a join, which is why a
class matching both relations is returned once.
Both scopes are filtered to is_active classes in the current academic session. Deactivating a
class does not clear its class_teacher_id, and nothing else scopes a class to a year, so without
both filters a teacher would keep reading rosters from every year they ever held a class.
Authorization
Neither handler declares @Permissions(), and the reason differs from the guardian and student
portals. A teacher DOES hold Students_READ, but combined with scope_kind = 'all' that makes the
shared people scope resolver return every row — so a permission-based gate here would be no gate at
all.
Teacher-ness on this surface is instead: the active role named teacher, plus a live
staff row for the caller, plus the class-teacher or subject-teacher relation applied in SQL. The
staff.deleted_at filter is load-bearing — a dismissed teacher who is also a parent keeps a live
users row, because the person record is only removed when every profile is gone.
The roster opens for the class teacher of the class, and for the teacher of a live class_subjects
row in a class of the current academic session. class_subjects carries no session column, so the
subject-teacher branch re-checks the session; without it a teacher would keep reading the roll of
every past year's class they once taught.
A class that exists but is not the caller's answers 404 CLASS_NOT_FOUND. The class is resolved by
public id AND the relation in one query, never fetched and compared afterwards.
Why the roster is not the admin roster
This surface does not reuse the admin roster reader. That method applies the shared people scope
internally, which for a custom portal role resolves to "no rows" — so a legitimate class teacher
would receive 200 with an empty roster for their own class: silent, no log line, and
indistinguishable from a class with no pupils. The portal owns its own roster query, scoped by the
relation check it has already performed, and names its own soft-delete and record-status filters.
PortalRosterEntryDto carries id, student id, admission number, full name and enrolment status.
No date of birth, address, phone, email, guardian, ethnicity, or medical field. A class or subject teacher
reads other families' children through this DTO, so its field list is stated rather than inherited.
pagination=false is refused with 400 PAGINATION_LIMIT_INVALID.
15. Integration Checklist
- Every route from every controller is documented.
- Every DTO field is documented.
- Every enum value is documented.
- Every response envelope is documented.
- Every error code is documented.
- Every auth guard, permission, and object-level scope check is documented.
- Every cache key (and this module's near-total absence of read-side caching) and every audit-log call is documented.
- Every diagram matches the current code, verified against the source files in 1.
- This document links to backend and features/flows.
See Also
- Backend doc:
/docs/developer/classes/backend - Features and flows doc:
/docs/developer/classes/feature
Classes Backend Documentation
Backend architecture, data model, services, locking, security, and runtime rules for grades, sections, rooms, classes, and student class enrolments.
Subjects Features and Flows
Complete feature list, actor journeys, state flows, business rules, edge cases, and diagrams for the school's subject catalogue and its per-class teaching assignments.