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, 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 | Behavior, side effects, response mapping, errors. |
| Schema | packages/db/src/schema/school/classes.ts, migration packages/db/src/migrations/0011_class_module.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) | 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. |
| 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 | 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 | 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", "enrollments/:id"), which is why the last four rows above have two different top-level prefixes (/classes/... and /enrollments/...) from the same controller class.
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 four declared onClassEnrollmentsController— a caller who holds fullClasses_*grants but noStudents_*grants can manage every class in the school but cannot touch a single enrolment. 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."- 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 |
createdAt / updatedAt | string (ISO date) | Yes | Server-generated | — | — | room.dto.ts |
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 | RoomDto | null | Yes (nullable) | null | — | — | class.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 marked isCurrent | QueryInt, @IsInt | ?academicSessionId=7 | class.dto.ts |
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) | — |
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 |
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 |
"completed" is not a value of enrollment_status — see 5.2 in the backend doc for why.
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) |
| Service | rooms/rooms.service.ts (findAll) |
| Tests | academic-structure/__tests__/rooms.service.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, "createdAt": "2026-01-10T04:15:00.000Z", "updatedAt": "2026-01-10T04:15:00.000Z" }
],
"errorCode": null
}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, "createdAt": "2026-02-01T09:00:00.000Z", "updatedAt": "2026-02-01T09:00:00.000Z" },
"errorCode": null
}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) |
Auth and Permissions
- Permission:
Rooms_UPDATE.
Request
{ "roomNumber": "102", "floor": 2 }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.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).
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 }.
Example Requests
curl -X GET "$API_URL/api/classes/options?academicSessionId=7" \
-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-...", "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
}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. - Cache:
students:list:*cleared. - Audit:
recordActivity(ENROLLorTRANSFER), naming the pupil, the old/new class, and — on an override — the capacity numbers.
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"9. Flow Diagrams
9.1 Route Ownership
9.2 Request Sequence — enrol/transfer
9.3 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 only | 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. | enrollments-core/class-enrollments.service.ts |
| 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. | enrollments-core/class-enrollments.service.ts |
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 | Same | Rooms_READ | N/A | N/A | rooms | 400/401/403 | rooms.service.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 |
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), a representative request sequence for the module's most complex flow (9.2), and an error decision tree covering every route family (9.3). 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. |
| 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. |
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 (21 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 class teacher read their own classes.
| Method | Path | Audience | Returns |
|---|---|---|---|
| GET | /api/mobile/teacher/classes | staff | paginated PortalClassDto |
| GET | /api/mobile/teacher/classes/{publicId}/roster | staff | paginated PortalRosterEntryDto |
{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
classes.class_teacher_id is the only teacher-to-class relation in the database, and 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.
There is no subject-teacher or timetable table. A subject teacher who is nobody's class teacher therefore sees an empty list, and that is the honest current state rather than a fault. When the schedule module lands it will resolve real teaching assignments and this list widens; the route returns a collection specifically so that widening does not replace it.
The list is additionally 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
homeroom.
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: an active role that is not a portal role, plus a live
staff row for the caller, plus class_teacher_id equality 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.
A class that exists but is not the caller's answers 404 CLASS_NOT_FOUND. The class is resolved by
public id AND ownership 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
ownership 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 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.
Data Transfer Features and Flows
Complete feature list, actor journeys, state flows, business rules, edge cases, and diagrams for Data Transfer.