Skoolsewa - Ecommerce Docs
Developer ResourcesClasses

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

AreaFiles InspectedWhat Was Verified
Controllersapps/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.
DTOsapps/api/src/modules/academic-structure/dto/grade.dto.ts, section.dto.ts, room.dto.ts, class.dto.ts, enrollment.dto.ts, query-boolean.tsRequest, query, response, validation, defaults.
Servicesgrades/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.tsBehavior, side effects, response mapping, errors.
Schemapackages/db/src/schema/school/classes.ts, migration packages/db/src/migrations/0011_class_module.sqlIDs, enums, persisted fields, constraints.
Constraint probepackages/db/src/scripts/probe-class-module-constraints.sqlConfirmed accept/reject behavior underlying every named error code below.
Shared query baseapps/api/src/common/dto/query.dto.tsInherited pagination, page, size, sort, order, search fields, and which list endpoints in this module actually read sort/order (none do).
Response envelopeapps/api/src/common/dto/response-dto.tsExact success envelope shape.
Error envelopeapps/api/src/common/filters/all-exceptions.filter.tsExact error envelope shape.
Errorsapps/api/src/common/types/error-codes.ts (the "SCHOOL DOMAIN: classes, sections and rooms" section)Every error code this module can produce.
Authapps/api/src/modules/auth/guards/jwt-auth.guard.ts, apps/api/src/common/authorization/role.guard.tsGuard chain and identity shape.
Permissionspackages/db/src/authorization/permission-catalog.tsPermission modules/actions this module checks — Classes, Sections, Rooms; grades has no module of its own.
Object-level accessapps/api/src/modules/people/shared/people-access.service.tsscopeFor/applyScope/assertCanAccess, the actual control behind every Students_* route in this document.
Pagination utilityapps/api/src/common/utils/pagination.util.tsDefault/max size, offset math, UNPAGINATED_HARD_CAP.
Seed datapackages/db/src/seed/seed-reference-data.ts, packages/db/src/seed/seed-auth.tsThe seeded 15 grades / 4 sections, and which roles hold which _READ code.
Wiringapps/api/src/modules/academic-structure/academic-structure.module.ts, apps/api/src/app.module.tsRoute composition and prefix.
Existing docsapps/fumadocs/content/docs/developer/documentation-formats/api-doc-format.mdx, apps/fumadocs/content/docs/developer/school/api.mdxFormat and style baseline.

2. Module Summary

FieldValue
Module nameAcademicStructureModule (grades, sections, rooms, classes, class-side enrolment routes); one route (:id/enrollments) lives on StudentsController in PeopleModule
Module slugclasses
Primary actorsAdmin 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 surfacesAdmin 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 modelJwtAuthGuard + RoleGuard, class-level on every controller
PersistencePostgreSQL (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 truthEvery table listed above, read live on every request — this module caches nothing on its read side
Sibling docsBackend, Features and flows

3. Concepts and Terminology

TermMeaningSource FileUsed By
GradeNursery, 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.tsGET /grades; the gradeId field on a class.
SectionThe A/B/C/D-style subdivision a grade is split into — school-editable, full CRUD.classes.tsGET/POST/PATCH/DELETE /sections; the sectionId field on a class.
RoomA physical room — number, floor, building — school-editable, full CRUD.classes.tsGET/POST/PATCH/DELETE /rooms; the roomId field on a class.
ClassOne 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.tsEvery 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 teacherA 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.tsclassTeacherId on CreateClassDto/UpdateClassDto; ClassTeacherDto on the response.
Enrolled count / occupancyThe 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.tsenrolledCount on ClassDto/ClassOptionDto; the capacity check on enrol and on capacity edits.
Student class enrolmentOne 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.tsEvery 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.tsEvery :publicId route param below.

4. API Surface Map

SurfaceMethodPathActorAuth/GuardPermissionControllerPurpose
AdminGET/api/gradesAdmin/Staff/TeacherJwtAuthGuard, RoleGuardClasses_READGradesControllerList grades — the module's only grade route.
AdminGET/api/sectionsAdmin/Staff/TeacherJwtAuthGuard, RoleGuardSections_READSectionsControllerList/search sections, paginated.
AdminPOST/api/sectionsAdminJwtAuthGuard, RoleGuardSections_CREATESectionsControllerCreate a section.
AdminPATCH/api/sections/:publicIdAdminJwtAuthGuard, RoleGuardSections_UPDATESectionsControllerRename, reorder, or retire/reactivate a section.
AdminDELETE/api/sections/:publicIdAdminJwtAuthGuard, RoleGuardSections_DELETESectionsControllerHard delete — permitted only when no class references it.
AdminGET/api/roomsAdmin/Staff/TeacherJwtAuthGuard, RoleGuardRooms_READRoomsControllerList/search/filter rooms, paginated.
AdminPOST/api/roomsAdminJwtAuthGuard, RoleGuardRooms_CREATERoomsControllerCreate a room.
AdminPATCH/api/rooms/:publicIdAdminJwtAuthGuard, RoleGuardRooms_UPDATERoomsControllerRename, relocate, or retire/reactivate a room.
AdminDELETE/api/rooms/:publicIdAdminJwtAuthGuard, RoleGuardRooms_DELETERoomsControllerHard delete — permitted only when no class references it.
AdminGET/api/classesAdmin/Staff/TeacherJwtAuthGuard, RoleGuardClasses_READClassesControllerList/filter classes; refuses pagination=false.
AdminGET/api/classes/optionsAdmin/Staff/TeacherJwtAuthGuard, RoleGuardClasses_READClassesControllerUnpaginated, person-data-free class list for the capacity chart and admission cascade.
AdminGET/api/classes/:publicIdAdmin/Staff/TeacherJwtAuthGuard, RoleGuardClasses_READClassesControllerRead a single class.
AdminPOST/api/classesAdminJwtAuthGuard, RoleGuardClasses_CREATEClassesControllerCreate a class.
AdminPATCH/api/classes/:publicIdAdminJwtAuthGuard, RoleGuardClasses_UPDATEClassesControllerUpdate a class — identity fields are permanently excluded.
AdminDELETE/api/classes/:publicIdAdminJwtAuthGuard, RoleGuardClasses_DELETEClassesControllerHard delete — permitted only when no enrolment references it.
AdminGET/api/classes/:publicId/studentsAdmin/Staff/Teacher (scoped)JwtAuthGuard, RoleGuardStudents_READClassEnrollmentsControllerA class's roster — row-scoped by object-level access.
AdminPOST/api/classes/:publicId/enrollmentsAdmin (Students_UPDATE holder)JwtAuthGuard, RoleGuardStudents_UPDATEClassEnrollmentsControllerEnrol a pupil, or transfer them from another class.
AdminDELETE/api/classes/:publicId/enrollments/:studentIdAdmin (Students_UPDATE holder)JwtAuthGuard, RoleGuardStudents_UPDATEClassEnrollmentsControllerWithdraw a pupil from this class.
AdminPATCH/api/enrollments/:idAdmin (Students_UPDATE holder)JwtAuthGuard, RoleGuardStudents_UPDATEClassEnrollmentsControllerCorrect an enrolment's date/status. classId is not patchable.
AdminDELETE/api/enrollments/:idAdmin (Students_UPDATE holder)JwtAuthGuard, RoleGuardStudents_UPDATEClassEnrollmentsControllerRemove an enrolment entered in error — no status trace left.
AdminGET/api/students/:id/enrollmentsAdmin/Staff/Teacher (scoped)JwtAuthGuard, RoleGuardStudents_READStudentsControllerA 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

SurfaceGuard/DecoratorIdentity ShapePermissionGuest AllowedNotes
Every route above@UseGuards(JwtAuthGuard, RoleGuard) at the controller class levelreq.user populated by the JWT strategy; activeRole resolved from itOne of the codes in the surface map aboveNoNo route in this module carries @Public().
  • Auth is mandatory on every route. JwtAuthGuard rejects a missing/invalid token with 401 AUTH_UNAUTHENTICATED before RoleGuard ever 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(...), and route-permissions.spec.ts asserts the same in CI.
  • Grades has no permission module of its own. PERMISSION_MODULES lists Classes, Sections, Rooms — no Grades entry exists. GET /api/grades is gated by Classes_READ, and there is no other route to gate: no POST/PATCH/DELETE /api/grades exists in this codebase.
  • Two permission surfaces, not one. Classes_*/Sections_*/Rooms_* gate the class-setup screens. Students_READ/Students_UPDATE gate every roster/enrolment/history route in this document, including the four declared on ClassEnrollmentsController — a caller who holds full Classes_* grants but no Students_* grants can manage every class in the school but cannot touch a single enrolment.
  • Students_READ/Students_UPDATE are not the whole story — object-level scope is the actual control. PeopleAccessService.scopeFor/applyScope narrow every roster and history read to the pupils the caller's role permits; PeopleAccessService.assertCanAccess refuses every enrolment write on an out-of-scope pupil with 404, deliberately never 403 — a 403 on 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 every 404 from an enrolment route as potentially meaning "exists, but not visible to you," not only "does not exist."
  • The seeded staff/teacher roles hold every _READ code in this module but no Students_UPDATE. packages/db/src/seed/seed-auth.ts's STAFF_PERMISSIONS (shared by reference with TEACHER_PERMISSIONS) includes Classes_READ, Sections_READ, Rooms_READ, and Students_READ — but not Students_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 gets 403 PERMISSION_ROLE_NOT_ASSIGNED.
  • Superadmin bypass. A role with is_superadmin = true skips both the permission list and the object-level scope check entirely, keyed on the boolean flag, never on the role's name string.
  • Permission catalog mechanics. Classes, Sections, Rooms are each declared once in PERMISSION_MODULES, and the full catalog is generated as every module crossed with every action in PERMISSION_ACTIONS (CREATE, READ, UPDATE, DELETE, RESTORE). Classes_RESTORE, Sections_RESTORE, and Rooms_RESTORE all exist as valid, seeded, grantable permission codes even though no route in this module ever checks any of them — none of sections/rooms/classes has deleted_at.
  • Deploy-time grant gap. pnpm --filter @skoolsewa/api permissions:sync creates the permission rows for this module but grants them only to roles named "superadmin" or "admin" — verified against sync-permissions.ts:90, and this product seeds no role literally named "admin". pnpm --filter @skoolsewa/db db:seed:prod is the command that actually applies the staff/teacher grants 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 by JwtAuthGuard.

6. DTO and Model Reference

6.1 GradeDto (response)

FieldTypeRequiredDefaultValidationExampleSource
idnumberYesServer-generatedInternal serial id5grade.dto.ts
publicIdstring (UUID)YesServer-generated"018f2a1e-..."grade.dto.ts
namestringYes"5"grade.dto.ts
codestringYesStable across a rename"G5"grade.dto.ts
sortOrdernumberYesNursery = 0, LKG = 1, UKG = 2, then grade n at n + 27grade.dto.ts
isActivebooleanYestruetruegrade.dto.ts
createdAt / updatedAtstring (ISO date)YesServer-generatedgrade.dto.ts

6.2 ListGradesQueryDto (query, extends QueryDto)

FieldTypeRequiredDefaultValidationExampleSource
isActivebooleanNoUnset (no filter)Query-string boolean transform (QueryBoolean), then @IsBoolean?isActive=truegrade.dto.ts
search, pagination, page, size, sort, orderInherited from QueryDtoNoSee 6.11sort/order are inherited but never read by GradesService.findAll — ordering is always sortOrder, lower(name), idquery.dto.ts

6.3 SectionDto (response)

FieldTypeRequiredDefaultValidationExampleSource
idnumberYesServer-generatedInternal serial id2section.dto.ts
publicIdstring (UUID)YesServer-generatedsection.dto.ts
namestringYes"A"section.dto.ts
sortOrdernumberYesNot unique0section.dto.ts
isActivebooleanYestruetruesection.dto.ts
createdAt / updatedAtstring (ISO date)YesServer-generatedsection.dto.ts

6.4 CreateSectionDto (body)

FieldTypeRequiredDefaultValidationExampleSource
namestringYes@IsString, @MinLength(1), @MaxLength(32), @Matches(/^\S(.*\S)?$/) (no surrounding whitespace)"E"section.dto.ts
sortOrdernumberNoComputed as COALESCE(max(sort_order), -1) + 1 in the same transaction as the insert@IsInt, @Min(0)4section.dto.ts

6.5 UpdateSectionDto (body)

FieldTypeRequiredDefaultValidationExampleSource
namestringNoUnchanged if omittedSame as create"E"section.dto.ts
sortOrdernumberNoUnchanged if omitted@IsInt, @Min(0)2section.dto.ts
isActivebooleanNoUnchanged if omitted@IsBooleanfalsesection.dto.ts

6.6 ListSectionsQueryDto (query, extends QueryDto)

FieldTypeRequiredDefaultValidationExampleSource
isActivebooleanNoUnset (no filter)Query-string boolean transform?isActive=truesection.dto.ts
search, pagination, page, size, sort, orderInheritedNoSee 6.11sort/order inherited but unused — order is always sortOrder, lower(name), idquery.dto.ts

6.7 RoomDto (response)

FieldTypeRequiredDefaultValidationExampleSource
idnumberYesServer-generatedInternal serial id10room.dto.ts
publicIdstring (UUID)YesServer-generatedroom.dto.ts
roomNumberstringYes"101"room.dto.ts
namestring | nullYes (nullable)null"Science Lab"room.dto.ts
floornumberYes-5..200; 0 is ground1room.dto.ts
buildingstringYes"Main Block"room.dto.ts
isActivebooleanYestruetrueroom.dto.ts
createdAt / updatedAtstring (ISO date)YesServer-generatedroom.dto.ts

6.8 CreateRoomDto (body)

FieldTypeRequiredDefaultValidationExampleSource
roomNumberstringYes@IsString, @MinLength(1), @MaxLength(32), no surrounding whitespace"101"room.dto.ts
namestring | nullNonull@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
floornumberYes@IsInt, @Min(-5), @Max(200)1room.dto.ts
buildingstringYes@IsString, @MinLength(1), @MaxLength(128)"Main Block"room.dto.ts

6.9 UpdateRoomDto (body)

FieldTypeRequiredDefaultValidationExampleSource
roomNumberstringNoUnchanged if omittedSame as create"102"room.dto.ts
namestring | nullNoUnchanged if omitted; explicit null clears itSame ValidateIf pattern as createnullroom.dto.ts
floornumberNoUnchanged if omitted@IsInt, @Min(-5), @Max(200)2room.dto.ts
buildingstringNoUnchanged if omittedSame as create"Annexe"room.dto.ts
isActivebooleanNoUnchanged if omitted@IsBooleanfalseroom.dto.ts

6.10 ListRoomsQueryDto (query, extends QueryDto)

FieldTypeRequiredDefaultValidationExampleSource
isActivebooleanNoUnset (no filter)Query-string boolean transform?isActive=trueroom.dto.ts
buildingstringNoUnset (no filter)@IsString, @MaxLength(128), matched case-insensitively?building=Main+Blockroom.dto.ts
floornumberNoUnset (no filter)Query-string integer transform (QueryInt), then @IsInt?floor=1room.dto.ts
search, pagination, page, size, sort, orderInheritedNoSee 6.11search matches roomNumber, building, and name together; sort/order inherited but unusedquery.dto.ts

6.11 QueryDto — shared base

FieldTypeRequiredDefaultValidationExampleSource
paginationbooleanNotrueQuery-string boolean transform?pagination=falsequery.dto.ts
pagenumberNo1@IsInt, @Min(1)?page=2query.dto.ts
sizenumberNo20@IsInt, @Min(1) — silently clamped to 100 by PaginationUtil, not rejected?size=50query.dto.ts
sortstringNo"updatedAt"@IsStringinherited by every list DTO in this module but read by none of them; every list here has a fixed, structural ordering instead?sort=namequery.dto.ts
order"asc" | "desc"No"desc"@IsEnum(["asc", "desc"]) — same "inherited, unused" note as sort?order=ascquery.dto.ts
searchstringNo— (no filter)@IsString, @MaxLength(100), trimmed; dropped entirely when empty?search=sciquery.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)

FieldTypeNotes
idnumber
publicIdstring (UUID)
namestringe.g. "2026-27".
isCurrentboolean

6.13 ClassTeacherDto (nested response)

FieldTypeNotes
idstring (UUID)The staff row's own PK — staff has no separate publicId.
fullNamestring
employeeCodestring
employmentStatusstringThe full enum: active, on_leave, suspended, resigned, terminated, retired. Not a boolean derived from deletedAtstaff 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.
isTeachingbooleanDerived 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)

FieldTypeRequiredDefaultValidationExampleSource
idnumberYesServer-generatedInternal serial idclass.dto.ts
publicIdstring (UUID)YesServer-generatedclass.dto.ts
academicSessionClassAcademicSessionDtoYesclass.dto.ts
gradeGradeDtoYesclass.dto.ts
sectionSectionDtoYesclass.dto.ts
shift"morning" | "day"Yes"morning"class.dto.ts
namestring | nullYes (nullable)nullNever identity"5A Morning"class.dto.ts
capacitynumberYes1..50040class.dto.ts
enrolledCountnumberYesComputedLive count, filtered on students.deletedAt/recordStatus; never stored37class.dto.ts; computed in classes-read.service.ts via ClassOccupancyService
roomRoomDto | nullYes (nullable)nullclass.dto.ts
classTeacherClassTeacherDto | nullYes (nullable)nullclass.dto.ts
isActivebooleanYestruetrueclass.dto.ts
createdAt / updatedAtstring (ISO date)YesServer-generatedclass.dto.ts

6.15 CreateClassDto (body)

FieldTypeRequiredDefaultValidationExampleSource
academicSessionIdnumberYes@IsInt, @Min(1)7class.dto.ts
gradeIdnumberYes@IsInt, @Min(1)5class.dto.ts
sectionIdnumberYes@IsInt, @Min(1)2class.dto.ts
shift"morning" | "day"Yes@IsIn(CLASS_SHIFTS)"morning"class.dto.ts
capacitynumberYes@IsInt, @Min(1), @Max(500)40class.dto.ts
namestring | nullNonull@ValidateIf, then @IsString, @MinLength(1), @MaxLength(128), no surrounding whitespace"5A Morning"class.dto.ts
classTeacherIdstring | null (UUID)Nonull@ValidateIf, then @IsUUID"018f2a20-..."class.dto.ts
roomIdstring | null (UUID — the room's publicId)Nonull@ValidateIf, then @IsUUID"018f2a21-..."class.dto.ts

6.16 UpdateClassDto (body)

FieldTypeRequiredDefaultValidationExampleSource
namestring | nullNoUnchanged if omittedSame as create"5A Morning"class.dto.ts
capacitynumberNoUnchanged if omitted@IsInt, @Min(1), @Max(500)35class.dto.ts
classTeacherIdstring | null (UUID)NoUnchanged if omitted; explicit null clears it@ValidateIf, then @IsUUIDnullclass.dto.ts
roomIdstring | null (UUID)NoUnchanged if omitted; explicit null clears it@ValidateIf, then @IsUUIDnullclass.dto.ts
isActivebooleanNoUnchanged if omitted@IsBooleanfalseclass.dto.ts
allowOverCapacitybooleanNofalse@IsBooleantrueclass.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)

FieldTypeRequiredDefaultValidationExampleSource
academicSessionIdnumberNoThe session marked isCurrentQueryInt, @IsInt?academicSessionId=7class.dto.ts
gradeIdnumberNoUnset (no filter)QueryInt, @IsInt?gradeId=5class.dto.ts
sectionIdnumberNoUnset (no filter)QueryInt, @IsInt?sectionId=2class.dto.ts
shift"morning" | "day"NoUnset (no filter)@IsIn(CLASS_SHIFTS)?shift=morningclass.dto.ts
roomIdnumberNoUnset (no filter)QueryInt, @IsIntthe internal integer id, not publicId, unlike the body DTOs?roomId=10class.dto.ts
classTeacherIdstring (UUID)NoUnset (no filter)@IsUUID?classTeacherId=018f...class.dto.ts
hasRoombooleanNoUnset (no filter)Query-string boolean transform?hasRoom=falseclass.dto.ts
hasClassTeacherbooleanNoUnset (no filter)Query-string boolean transform?hasClassTeacher=trueclass.dto.ts
isActivebooleanNoUnset (no filter)Query-string boolean transform?isActive=trueclass.dto.ts
search, pagination, page, size, sort, orderInheritedNoSee 6.11pagination=false is refused on this endpoint specifically — see 8.10; sort/order inherited but unusedquery.dto.ts

6.18 ClassOptionsQueryDto (query, not a QueryDto subtype)

FieldTypeRequiredDefaultValidationExampleSource
academicSessionIdnumberNoThe session marked isCurrentQueryInt, @IsInt?academicSessionId=7class.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)

FieldTypeNotes
publicIdstring (UUID)
gradeGradeDto
sectionSectionDto
shift"morning" | "day"
namestring | null
capacitynumber
enrolledCountnumberLive, same computation as ClassDto.
isActiveboolean

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)

FieldTypeNotes
publicIdstring (UUID)The class's public id.
namestring | null
shift"morning" | "day"
gradeGradeDto
sectionSectionDto
academicSessionClassAcademicSessionDto

EnrollmentDto:

FieldTypeNotes
idstring (UUID)The enrolment row's own PK.
status"active" | "transferred" | "withdrawn"
enrolledOnstring (ISO date, YYYY-MM-DD)
endedOnstring | null (ISO date)
classEnrollmentClassDtoNo capacity, no room, no class teacher — a pupil's own history is not a staff directory.
createdAt / updatedAtstring (ISO date)

6.21 ClassRosterStudentDto / ClassRosterEntryDto (response)

ClassRosterStudentDtodeliberately 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).

FieldTypeNotes
idstring (UUID)The student's own PK.
studentIdstringe.g. "SID-2026-0005".
admissionNumberstringe.g. "2026/0005".
fullNamestringJoined from firstName/middleName/lastName, filtered of blanks.
recordStatusstring"active" | "inactive".

ClassRosterEntryDto:

FieldTypeNotes
enrollmentIdstring (UUID)
studentClassRosterStudentDto
status"active" | "transferred" | "withdrawn"
enrolledOnstring (ISO date)
endedOnstring | null (ISO date)

6.22 CreateEnrollmentDto (body)

FieldTypeRequiredDefaultValidationExampleSource
studentIdstring (UUID)Yes@IsUUID"018f2a22-..."enrollment.dto.ts
enrolledOnstring (ISO date)NoToday in Asia/Kathmandu@IsISO8601"2026-04-15"enrollment.dto.ts
allowOverCapacitybooleanNofalse@IsBooleantrueenrollment.dto.ts

6.23 UpdateEnrollmentDto (body)

FieldTypeRequiredDefaultValidationExampleSource
enrolledOnstring (ISO date)NoUnchanged if omitted@IsISO8601"2026-04-16"enrollment.dto.ts
status"transferred" | "withdrawn"NoUnchanged 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)

FieldTypeRequiredDefaultValidationExampleSource
status"active" | "transferred" | "withdrawn"No"active"@IsIn(ENROLLMENT_STATUSES)?status=withdrawnenrollment.dto.ts
search, pagination, page, size, sort, orderInheritedNopagination=false is refused — see 8.16; search/sort/order inherited but unused — ordering is always studentId, idquery.dto.ts

6.25 ListStudentEnrollmentsQueryDto (query, extends QueryDto)

FieldTypeRequiredDefaultValidationExampleSource
academicSessionIdnumberNoUnset (all sessions)QueryInt, @IsInt?academicSessionId=7enrollment.dto.ts
search, pagination, page, size, sort, orderInheritedNosearch/sort/order inherited but unused — ordering is always enrolledOn DESC, id DESCquery.dto.ts

7. Enum Reference

EnumValueMeaningRuntime EffectSource
Shift (class_shift)morningThe morning timetable.Part of a class's identity; scopes room/teacher exclusivity.classes.ts
Shift (class_shift)dayThe day timetable.Same.classes.ts
Enrolment status (enrollment_status)activeThe pupil currently sits in this class.Counted toward occupancy; at most one per pupil per session.classes.ts
Enrolment status (enrollment_status)transferredThe 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)withdrawnThe 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, retiredThe 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, inactiveWhether 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

EvidencePath
Controllergrades/grades.controller.ts
DTOdto/grade.dto.ts (ListGradesQueryDto, GradeDto)
Servicegrades/grades.service.ts (findAll)
Schemapackages/db/src/schema/school/classes.ts
Testsacademic-structure/__tests__/grades.service.integration.spec.ts

Auth and Permissions

  • Auth: Required.
  • Guard chain: JwtAuthGuardRoleGuard.
  • Permission: Classes_READ (grades has no permission module of its own).
  • Guest support: None.
  • Rate limit: None module-specific.
  • Idempotency: N/A (read).

Request

PartRequiredDetails
HeadersYesAuthorization: Bearer <jwt>.
ParamsNo
QueryNosearch, isActive, pagination, page, size (sort/order accepted but ignored).
BodyNo
GET /api/grades?isActive=true&pagination=false HTTP/1.1

Response

{
  "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 StatusError CodeConditionUser-Facing MeaningSource
401AUTH_UNAUTHENTICATEDMissing/invalid JWT.Re-authenticate.jwt-auth.guard.ts
403PERMISSION_INSUFFICIENTActive role lacks Classes_READ.Not authorized to view grades.role.guard.ts
400VALIDATION_FAILEDAn invalid query value.Fix the query string.Global ValidationPipe

Edge Cases

  • No route to create/update/delete a grade — ? on a POST/PATCH/DELETE to this path is a plain 404 Not Found (no handler registered), not a domain error.
  • sort/order accepted, silently ignored — ordering is always sortOrder, name, id.
  • pagination=false: capped at UNPAGINATED_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

EvidencePath
Controllersections/sections.controller.ts
DTOdto/section.dto.ts (ListSectionsQueryDto, SectionDto)
Servicesections/sections.service.ts (findAll)
Testsacademic-structure/__tests__/sections.service.integration.spec.ts

Auth and Permissions

  • Permission: Sections_READ. Guard chain and guest support as in 8.1.

Request

PartRequiredDetails
QueryNosearch, isActive, pagination, page, size (sort/order accepted but ignored).
GET /api/sections?search=a&isActive=true HTTP/1.1

Response

{
  "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.
  • size above 100: silently clamped.
  • Fixed ordering: sortOrder, lower(name), id; sort/order accepted, 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

EvidencePath
Controllersections/sections.controller.ts
DTOdto/section.dto.ts (CreateSectionDto)
Servicesections/sections.service.ts (create, assertNameFree)
Schemaclasses.ts (sections_name_unique)
Testsacademic-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) then INSERT, both inside one transaction (so a concurrently-omitted sortOrder is computed against a consistent snapshot).
  • No cache, no jobs, no realtime, no audit call beyond the automatic interceptor.

Error Cases

HTTP StatusError CodeConditionUser-Facing MeaningSource
400VALIDATION_FAILEDname missing/too long/whitespace-padded, or an unknown field.Fix the request.Global ValidationPipe
409SECTION_NAME_TAKENAnother active section already has this name, case-insensitively.Choose a different name.sections.service.ts
409RESOURCE_ALREADY_EXISTSA race past the pre-check hit the DB's own unique index.Refresh and retry.Global unique-violation fallback
401 / 403See 8.1

Edge Cases

  • sortOrder omitted: computed as COALESCE(max(sort_order), -1) + 1 — a school's very first section (empty table) still succeeds, because of the COALESCE.
  • Two sections sharing a sortOrder: allowed, ties break on lower(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

EvidencePath
Controllersections/sections.controller.ts
DTOdto/section.dto.ts (UpdateSectionDto)
Servicesections/sections.service.ts (update)
Testsacademic-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 StatusError CodeConditionSource
404SECTION_NOT_FOUNDNo section for the given publicId.sections.service.ts
409SECTION_NAME_TAKENRenamed to a name another active section already holds.sections.service.ts
400VALIDATION_FAILEDInvalid 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 on DELETE.

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

EvidencePath
Controllersections/sections.controller.ts
Servicesections/sections.service.ts (remove)
Schemaclasses.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 StatusError CodeConditionSource
404SECTION_NOT_FOUNDNo section for the given publicId.sections.service.ts
409SECTION_IN_USEAt 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

EvidencePath
Controllerrooms/rooms.controller.ts
DTOdto/room.dto.ts (ListRoomsQueryDto, RoomDto)
Servicerooms/rooms.service.ts (findAll)
Testsacademic-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.1

Response

{
  "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

  • search matches roomNumber, building, and name together — 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

EvidencePath
Controllerrooms/rooms.controller.ts
DTOdto/room.dto.ts (CreateRoomDto)
Servicerooms/rooms.service.ts (create, assertNumberFree)
Schemaclasses.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 StatusError CodeConditionSource
400VALIDATION_FAILEDInvalid field, e.g. floor out of -5..200.Global
409ROOM_NUMBER_TAKENAnother active room in the same building already has this number.rooms.service.ts
409RESOURCE_ALREADY_EXISTSRace past the pre-check.Global fallback

Edge Cases

  • Same number, different building: accepted — uniqueness is scoped to the building.
  • name omitted vs. sent as "": "" is rejected by the DTO's Matches/ValidateIf combination before the service runs — an explicit null, 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

EvidencePath
Controllerrooms/rooms.controller.ts
Servicerooms/rooms.service.ts (update)

Auth and Permissions

  • Permission: Rooms_UPDATE.

Request

{ "roomNumber": "102", "floor": 2 }

Error Cases

HTTP StatusError CodeConditionSource
404ROOM_NOT_FOUNDNo room for the given publicId.rooms.service.ts
409ROOM_NUMBER_TAKENThe 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 with 400.

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

EvidencePath
Controllerrooms/rooms.controller.ts
Servicerooms/rooms.service.ts (remove)
Schemaclasses.ts (classes_room_id_rooms_id_fk, ON DELETE set nullnot restrict)

Auth and Permissions

  • Permission: Rooms_DELETE.

Error Cases

HTTP StatusError CodeConditionSource
404ROOM_NOT_FOUNDNo room for the given publicId.rooms.service.ts
409ROOM_IN_USEA 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

EvidencePath
Controllerclasses/classes.controller.ts
DTOdto/class.dto.ts (ListClassesQueryDto, ClassDto)
Serviceclasses/classes-read.service.ts (findAll)
Testsacademic-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.1

Response

{
  "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 StatusError CodeConditionSource
400PAGINATION_LIMIT_INVALIDpagination=false was requested.classes-read.service.ts
400VALIDATION_FAILEDInvalid query value.Global
401 / 403See 8.1

Edge Cases

  • pagination=false is always refused. The row embeds the class teacher's full name and employee code — staff identity a caller holding only Classes_READ should not receive unbounded, mirroring /students//staff's own refusal. Use 8.11 for an unpaginated, person-data-free alternative.
  • No academicSessionId given, no session current: { "data": [], "count": 0, "currentPage": 1, "totalPage": 0 }, not an error.
  • sort/order accepted, ignored — ordering is always grade.sortOrder, section.sortOrder, shift, id.
  • search matches classes.name and grades.name/grades.code/sections.name together — 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

EvidencePath
Controllerclasses/classes.controller.ts — declared before @Get(":publicId") so options is never swallowed as a publicId value
DTOdto/class.dto.ts (ClassOptionsQueryDto, ClassOptionDto, ClassOptionsPayloadDto)
Serviceclasses/classes-read.service.ts (findOptions)

Auth and Permissions

  • Permission: Classes_READ (same as the full list).

Request

GET /api/classes/options?academicSessionId=7 HTTP/1.1

Response

{
  "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

EvidencePath
Controllerclasses/classes.controller.ts
Serviceclasses/classes-read.service.ts (findOneloadDto)
Testsacademic-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 StatusError CodeConditionSource
404CLASS_NOT_FOUNDNo class for the given publicId.classes-read.service.ts

Edge Cases

  • Route ordering: this route is registered after @Get("options"), so /api/classes/options is never matched here as publicId = "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

EvidencePath
Controllerclasses/classes.controller.ts
DTOdto/class.dto.ts (CreateClassDto)
Serviceclasses/classes-write.service.ts (create)
Schemaclasses.ts (classes_identity_unique, classes_room_per_shift_unique, classes_teacher_per_shift_unique)
Testsacademic-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 a 409 rather 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 INSERT inside a transaction.
  • No cache, no jobs, no realtime.

Error Cases

HTTP StatusError CodeConditionSource
404ACADEMIC_SESSION_NOT_FOUND / GRADE_NOT_FOUND / SECTION_NOT_FOUNDThe named entity does not resolve.classes-write.service.ts
422CLASS_SESSION_INACTIVE / CLASS_GRADE_INACTIVE / CLASS_SECTION_INACTIVEThe entity resolves but is retired.classes-write.service.ts
404ROOM_NOT_FOUNDroomId given but does not resolve.classes-write.service.ts
422CLASS_ROOM_INACTIVEThe resolved room is retired.classes-write.service.ts
404CLASS_TEACHER_NOT_FOUNDclassTeacherId given but does not resolve, or resolves to a soft-deleted staff row.classes-write.service.ts
422CLASS_TEACHER_NOT_TEACHINGThe resolved staff member's designation is not a teaching one.classes-write.service.ts
409CLASS_IDENTITY_TAKEN(session, grade, section, shift) already exists — including a retired class.classes-write.service.ts (translate)
409CLASS_ROOM_OCCUPIEDThe room already holds an active class this shift, this session.classes-write.service.ts
409CLASS_TEACHER_ALREADY_ASSIGNEDThe teacher already holds an active class this shift, this session.classes-write.service.ts
409CLASS_CAPACITY_INVALID / CLASS_NAME_INVALIDA CHECK reached the database directly (not realistically reachable past the DTO's own validation).classes-write.service.ts
400VALIDATION_FAILEDcapacity 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/roomId omitted: 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

EvidencePath
Controllerclasses/classes.controller.ts
DTOdto/class.dto.ts (UpdateClassDto)
Serviceclasses/classes-write.service.ts (update)
Testsacademic-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 StatusError CodeConditionSource
404CLASS_NOT_FOUNDNo class for the given publicId.classes-write.service.ts
422CLASS_CAPACITY_BELOW_ENROLLEDcapacity lowered below the live enrolment count, without allowOverCapacity.classes-write.service.ts
404ROOM_NOT_FOUND / CLASS_TEACHER_NOT_FOUNDThe given id does not resolve — checked only when the field is present.classes-write.service.ts
422CLASS_ROOM_INACTIVE / CLASS_TEACHER_NOT_TEACHINGThe resolved value is retired/non-teaching — checked only when the resolved id differs from what is already stored.classes-write.service.ts
409CLASS_ROOM_OCCUPIED / CLASS_TEACHER_ALREADY_ASSIGNEDThe new room/teacher is already held elsewhere this shift.classes-write.service.ts
400VALIDATION_FAILEDAn 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

EvidencePath
Controllerclasses/classes.controller.ts
Serviceclasses/classes-write.service.ts (remove)
Schemaclasses.ts (student_class_enrollments_class_session_fk, ON DELETE restrict)

Auth and Permissions

  • Permission: Classes_DELETE.

Error Cases

HTTP StatusError CodeConditionSource
404CLASS_NOT_FOUNDNo class for the given publicId.classes-write.service.ts
409CLASS_HAS_ENROLLMENTSAny 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

EvidencePath
Controllerenrollments/class-enrollments.controller.ts (roll)
DTOdto/enrollment.dto.ts (ListRosterQueryDto, ClassRosterEntryDto)
Serviceclasses/class-roster.service.ts (findByClass)
Accessapps/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.1

Response

{
  "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 StatusError CodeConditionSource
404CLASS_NOT_FOUNDNo class for the given publicId.class-roster.service.ts
400PAGINATION_LIMIT_INVALIDpagination=false requested.class-enrollments.controller.ts
403PERMISSION_INSUFFICIENTMissing Students_READ.role.guard.ts

Edge Cases

  • pagination=false is always refused — a class roll is every enrolled child's name in one response, for the same reason /students refuses it.
  • A caller's scope narrower than the class: sees fewer rows than the class actually holds, never an error.
  • status omitted: defaults to "active".
  • A pupil soft-deleted or recordStatus: "inactive": excluded even if their enrolment row is still active — the same filter ClassOccupancyService applies.

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

EvidencePath
Controllerenrollments/class-enrollments.controller.ts (enrol)
DTOdto/enrollment.dto.ts (CreateEnrollmentDto, EnrollmentDto)
Serviceenrollments-core/class-enrollments.service.ts (enroll)
Schemaclasses.ts (student_class_enrollments_one_active_per_session_unique, ..._dates_ordered)
Testsenrollments-core/__tests__/class-enrollments.service.integration.spec.ts

Auth and Permissions

  • Permission: Students_UPDATE.
  • Object-level scope applies before the class is even resolvedPeopleAccessService.assertCanAccess runs before ClassEnrollmentsService.enroll is 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 locked FOR UPDATE in ascending id order, live occupancy under that lock.
  • Writes: predecessor UPDATE on a transfer, then INSERT, both inside one transaction.
  • Cache: students:list:* cleared.
  • Audit: recordActivity (ENROLL or TRANSFER), naming the pupil, the old/new class, and — on an override — the capacity numbers.

Error Cases

HTTP StatusError CodeConditionSource
404CLASS_NOT_FOUNDThe class does not resolve.class-enrollments.service.ts
404STUDENT_NOT_FOUNDstudentId does not resolve.class-enrollments.service.ts
409CLASS_INACTIVEThe class is retired — checked both before and after the lock.class-enrollments.service.ts
409ENROLLMENT_STUDENT_DELETEDThe pupil's record is soft-deleted.class-enrollments.service.ts
409ENROLLMENT_DATE_OUTSIDE_SESSIONenrolledOn falls outside the class's academic session dates.class-enrollments.service.ts
409ENROLLMENT_DATE_BEFORE_ADMISSIONenrolledOn precedes the pupil's own admission date.class-enrollments.service.ts
409ENROLLMENT_DATE_INVALIDA back-dated transfer would close the predecessor before its own start date.class-enrollments.service.ts
409CLASS_AT_CAPACITYThe class is full and allowOverCapacity is not set — the counts are named in the message text.class-enrollments.service.ts
409ENROLLMENT_ALREADY_ACTIVEA 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
400VALIDATION_FAILEDInvalid 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 transferred and a fresh active row opened, atomically.
  • Two simultaneous cross-transfers (X→Y and Y→X): both classes always locked in ascending id order regardless of which request calls which class "target" — deadlock-free by construction.
  • enrolledOn omitted: defaults to today in Asia/Kathmandu, not UTC.
  • Class retired between the initial read and the lock being granted: caught by the second isActive check, 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

EvidencePath
Controllerenrollments/class-enrollments.controller.ts (withdraw)
Serviceenrollments-core/class-enrollments.service.ts (withdraw)
Testsenrollments-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 StatusError CodeConditionSource
404CLASS_NOT_FOUNDNo class for the given publicId.class-enrollments.service.ts
404ENROLLMENT_NOT_FOUNDNo active row for this pupil in this class.class-enrollments.service.ts

Edge Cases

  • endedOn is computed as max(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

EvidencePath
Controllerenrollments/class-enrollments.controller.ts (update)
DTOdto/enrollment.dto.ts (UpdateEnrollmentDto)
Serviceenrollments-core/class-enrollments.service.ts (updateEnrollment)

Auth and Permissions

  • Permission: Students_UPDATE, plus object-level scope, resolved by reading the enrolment's studentId first.

Request

{ "enrolledOn": "2026-04-16" }
{ "status": "withdrawn" }

Error Cases

HTTP StatusError CodeConditionSource
404ENROLLMENT_NOT_FOUNDNo row for the given id.class-enrollments.service.ts
400VALIDATION_FAILEDclassId sent (not a field on this DTO), or status outside ["transferred", "withdrawn"].Global / DTO

Edge Cases

  • classId in the body: always 400 — unknown field. Moving a pupil is always POST /classes/:publicId/enrollments.
  • status set from active to transferred/withdrawn, no endedOn given: computed the same max(enrolledOn, today) way as withdraw.
  • enrolledOn moved past an already-set endedOn: endedOn pulled 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

EvidencePath
Controllerenrollments/class-enrollments.controller.ts (remove)
Serviceenrollments-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 StatusError CodeConditionSource
404ENROLLMENT_NOT_FOUNDNo row for the given id.class-enrollments.service.ts

Edge Cases

  • Removing an active row: 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

EvidencePath
Controllerapps/api/src/modules/people/students/students.controller.ts (findEnrollments) — not a controller in this module
DTOdto/enrollment.dto.ts (ListStudentEnrollmentsQueryDto, EnrollmentDto)
Serviceenrollments-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: assertCanAccess before the read.

Request

GET /api/students/018f2a61-.../enrollments?academicSessionId=7 HTTP/1.1

Response

{
  "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 StatusError CodeConditionSource
404(scope-derived not-found)The pupil is outside the caller's object-level scope, or does not exist — indistinguishable.people-access.service.ts
400VALIDATION_FAILEDInvalid academicSessionId.Global

Edge Cases

  • Route ownership. Lives on StudentsController, not a class controller — matching the convention :id/guardians and :id/medical already set on the same controller. Putting it here would give AcademicStructureModule a /students/* prefix nothing else in this repo uses.
  • sort/order/search accepted, ignored — ordering is always enrolledOn DESC, id DESC.
  • No enrolments yet (pupil admitted, not yet placed): data: [].
  • academicSessionId given: 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

EndpointPagination TypeDefault SizeMax SizeSort Fields HonoredFiltersResult Cap
GET /gradespage/size, or pagination=false20100None — fixed sortOrder, name, idsearch, isActive1000 when unpaginated
GET /sectionspage/size, or pagination=false20100None — fixed sortOrder, name, idsearch, isActive1000 when unpaginated
GET /roomspage/size, or pagination=false20100None — fixed building, floor, roomNumber, idsearch (number+building+name), isActive, building, floor1000 when unpaginated
GET /classespage/size onlypagination=false refused20100None — fixed grade.sortOrder, section.sortOrder, shift, idsearch, academicSessionId, gradeId, sectionId, shift, roomId, classTeacherId, hasRoom, hasClassTeacher, isActiveN/A
GET /classes/optionsAlways unpaginated, no pagination fieldN/AN/ANone — fixed grade.sortOrder, section.sortOrder, shift, idacademicSessionId only1000, with truncated: true on overflow
GET /classes/:publicId/studentspage/size onlypagination=false refused20100None — fixed studentId, idstatus (default active)N/A
PATCH /enrollments/:id list side (GET /students/:id/enrollments)page/size, or pagination=false20100None — fixed enrolledOn DESC, id DESCacademicSessionId1000 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

IntegrationUsed?DetailsSource
Redis cacheNo — read sideNo 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 cacheYes — write-only invalidationEvery 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
BullMQNoNo queue, job, or processor exists anywhere in this module.Verified by the absence of any BullMQ import across every file in 1.
External APINoNo third-party integration exists in this module.
MongoDB (audit)YesEvery 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

RouteController MethodDTOsService MethodGuardsPermissionsCacheJobsDB TouchesErrorsTestsDocumented?
GET /api/gradesGradesController.findAllListGradesQueryDto, GradeDtoGradesService.findAllJwtAuthGuard, RoleGuardClasses_READN/AN/Agrades400/401/403grades.service.integration.spec.tsYes
GET /api/sectionsSectionsController.findAllListSectionsQueryDto, SectionDtoSectionsService.findAllSameSections_READN/AN/Asections400/401/403sections.service.integration.spec.tsYes
POST /api/sectionsSectionsController.createCreateSectionDto, SectionDtoSectionsService.createSameSections_CREATEN/AN/Asections400/409/401/403sections.service.integration.spec.tsYes
PATCH /api/sections/:publicIdSectionsController.updateUpdateSectionDto, SectionDtoSectionsService.updateSameSections_UPDATEN/AN/Asections400/404/409sections.service.integration.spec.tsYes
DELETE /api/sections/:publicIdSectionsController.removeSectionsService.removeSameSections_DELETEN/AN/Asections, classes (FK)404/409sections.service.integration.spec.tsYes
GET /api/roomsRoomsController.findAllListRoomsQueryDto, RoomDtoRoomsService.findAllSameRooms_READN/AN/Arooms400/401/403rooms.service.integration.spec.tsYes
POST /api/roomsRoomsController.createCreateRoomDto, RoomDtoRoomsService.createSameRooms_CREATEN/AN/Arooms400/409rooms.service.integration.spec.tsYes
PATCH /api/rooms/:publicIdRoomsController.updateUpdateRoomDto, RoomDtoRoomsService.updateSameRooms_UPDATEN/AN/Arooms400/404/409rooms.service.integration.spec.tsYes
DELETE /api/rooms/:publicIdRoomsController.removeRoomsService.removeSameRooms_DELETEN/AN/Arooms, classes (explicit check)404/409rooms.service.integration.spec.tsYes
GET /api/classesClassesController.findAllListClassesQueryDto, ClassDtoClassesReadService.findAllSameClasses_READN/AN/Aclasses + 5 joins400/401/403classes-read.service.integration.spec.tsYes
GET /api/classes/optionsClassesController.findOptionsClassOptionsQueryDto, ClassOptionsPayloadDtoClassesReadService.findOptionsSameClasses_READN/AN/Aclasses + 2 joins401/403classes-read.service.integration.spec.tsYes
GET /api/classes/:publicIdClassesController.findOneClassDtoClassesReadService.findOne/loadDtoSameClasses_READN/AN/ASame as list, single row404classes-read.service.integration.spec.tsYes
POST /api/classesClassesController.createCreateClassDto, ClassDtoClassesWriteService.createSameClasses_CREATEN/AN/Aacademic_sessions, grades, sections, rooms, staff+designations, classes400/404/409/422classes-write.service.integration.spec.tsYes
PATCH /api/classes/:publicIdClassesController.updateUpdateClassDto, ClassDtoClassesWriteService.updateSameClasses_UPDATEN/AN/Aclasses (locked), rooms, staff+designations400/404/409/422classes-write.service.integration.spec.tsYes
DELETE /api/classes/:publicIdClassesController.removeClassesWriteService.removeSameClasses_DELETEN/AN/Aclasses, student_class_enrollments (FK)404/409classes-write.service.integration.spec.tsYes
GET /api/classes/:publicId/studentsClassEnrollmentsController.rollListRosterQueryDto, ClassRosterEntryDtoClassRosterService.findByClassSameStudents_READN/AN/Astudent_class_enrollments, students, users (scoped)400/404/403— (covered indirectly via PeopleAccessService tests)Yes
POST /api/classes/:publicId/enrollmentsClassEnrollmentsController.enrolCreateEnrollmentDto, EnrollmentDtoClassEnrollmentsService.enrollSameStudents_UPDATEInvalidates students:list:*N/Aclasses (locked), students, student_class_enrollments400/404/409class-enrollments.service.integration.spec.tsYes
DELETE /api/classes/:publicId/enrollments/:studentIdClassEnrollmentsController.withdrawClassEnrollmentsService.withdrawSameStudents_UPDATESameN/ASame tables404class-enrollments.service.integration.spec.tsYes
PATCH /api/enrollments/:idClassEnrollmentsController.updateUpdateEnrollmentDto, EnrollmentDtoClassEnrollmentsService.updateEnrollmentSameStudents_UPDATESameN/Astudent_class_enrollments, classes (locked)400/404class-enrollments.service.integration.spec.tsYes
DELETE /api/enrollments/:idClassEnrollmentsController.removeClassEnrollmentsService.removeEnrollmentSameStudents_UPDATESameN/ASame tables404class-enrollments.service.integration.spec.tsYes
GET /api/students/:id/enrollmentsStudentsController.findEnrollmentsListStudentEnrollmentsQueryDto, EnrollmentDtoClassEnrollmentsService.listForStudentSameStudents_READN/AN/Astudent_class_enrollments + 4 joins400/404Yes

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

ConsumerRequired KnowledgeFailure HandlingContract Stability
Admin web app (class setup)Classes_CREATE/_UPDATE/_DELETE, Sections_*, Rooms_*; the four identity fields are permanently immutable after creationDisplay the exact errorCodeCLASS_IDENTITY_TAKEN/CLASS_ROOM_OCCUPIED/CLASS_TEACHER_ALREADY_ASSIGNED are all distinguishable and actionableStable.
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 identityA 409 CLASS_AT_CAPACITY on enrol should surface the exact message text (it carries the numbers); resubmitting with allowOverCapacity: true is the documented recoveryStable.
Admin web app (enrolment desk)Students_UPDATE is required for every write here — a caller with only Classes_* grants cannot enrol/withdraw/correct/removeEvery 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 thisStable.
Teacher-facing screensThe 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 actions403 PERMISSION_INSUFFICIENT on any enrolment write attempt is expected, not a bugStable.
QAThe 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 testReproduce a defect by finding the matching probe case first — if none exists, the gap is real and worth adding one forStable.
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 itN/A — not yet consumed by any other moduleExperimental (unconsumed today).

13.5 API Tradeoffs and Rationale

DecisionChosen BehaviorAlternatives ConsideredWhy This TradeoffRiskMitigation
Two class-list endpoints instead of one with a "slim" flagGET /classes (paginated, full detail) and GET /classes/options (unpaginated, no staff identity)One endpoint, a query flag choosing the response shapeA single response shape can stay honest about exactly what data it needs to carry for each real use case, rather than growing a conditional shapeTwo response shapes to document and keep conceptually alignedDeliberately 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/roomsNamed PAGINATION_LIMIT_INVALID refusal on person-adjacent data specificallyCap the unpaginated read size instead of refusing outright, as grades/sections/rooms doThe 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 readA client written against the school module's pattern (where every list accepts pagination=false) will be surprised hereBoth refusals are documented per-endpoint and the alternative (GET /classes/options) is named explicitly.
Enrol and transfer share one routePOST /classes/:publicId/enrollments decides the case from the pupil's existing stateSeparate POST .../enroll and POST .../transfer endpointsA 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 wayA client cannot force a strict "enrol only, error if already elsewhere" semantic through this endpoint aloneDocumented explicitly in the endpoint's purpose and edge cases.
Withdraw and remove as two distinct DELETE routes on two different resourcesDELETE .../enrollments/:studentId (withdraw, keeps history) vs. DELETE /enrollments/:id (remove, no trace)One DELETE /enrollments/:id with a body flag choosing the semanticsDELETE 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 aloneA client must know which resource identifier it has (a class + student pair, vs. an enrolment id) to pick the right routeBoth are documented with explicit cross-references to each other.
Correction endpoint (PATCH /enrollments/:id) permanently excludes classIdMoving a pupil is only ever POST /classes/:publicId/enrollmentsAllow classId on the correction PATCH, applying the same capacity/lock logic inlineA second code path implementing the identical capacity-lock-transfer logic would be a second place to keep correctNone — this is a closed decision, not an open risk.N/A.

13.6 API Change Impact

ChangeAffected ConsumersBackend ImpactData ImpactMigration Needed?Compatibility Plan
Adding a Grades permission module and CRUD routesAdmin web app (class setup)New controller/service, following the Sections/Rooms pattern exactlyNone — existing grades rows are untouchedNoAdditive; GET /api/grades is unaffected.
Adding sort/order support to any list in this moduleAny consumer currently ignoring the accepted-but-unused fieldsEach service's orderBy clause would need to branch on the query value, matching the school module's LookupsService patternNoneNoAdditive and backward-compatible — a client already sending sort/order (currently ignored) would simply start seeing it honored.
Adding a completed value to enrollment_statusEvery consumer reading EnrollmentDto.statusNew enum value in the schema, a migration, and a promotion/rollover feature to write itExisting rows are unaffected; no backfill impliedYes — schema migration requiredA 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 unboundedEnrolment deskClassEnrollmentsService.enroll would need a new check against an accumulated override count/limitNew column or side-table to track override history per classLikely yesWould 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/createdAt fields grades/sections/rooms/classes DO return (unlike the school module's SchoolProfileDto, 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.

MethodPathAudienceReturns
GET/api/mobile/teacher/classesstaffpaginated PortalClassDto
GET/api/mobile/teacher/classes/{publicId}/rosterstaffpaginated 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

On this page

Classes - API Reference1. Documentation Evidence2. Module Summary3. Concepts and Terminology4. API Surface Map5. Auth, Identity, and Permissions6. DTO and Model Reference6.1 GradeDto (response)6.2 ListGradesQueryDto (query, extends QueryDto)6.3 SectionDto (response)6.4 CreateSectionDto (body)6.5 UpdateSectionDto (body)6.6 ListSectionsQueryDto (query, extends QueryDto)6.7 RoomDto (response)6.8 CreateRoomDto (body)6.9 UpdateRoomDto (body)6.10 ListRoomsQueryDto (query, extends QueryDto)6.11 QueryDto — shared base6.12 ClassAcademicSessionDto (nested response)6.13 ClassTeacherDto (nested response)6.14 ClassDto (response)6.15 CreateClassDto (body)6.16 UpdateClassDto (body)6.17 ListClassesQueryDto (query, extends QueryDto)6.18 ClassOptionsQueryDto (query, not a QueryDto subtype)6.19 ClassOptionDto / ClassOptionsPayloadDto (response)6.20 EnrollmentClassDto / EnrollmentDto (response)6.21 ClassRosterStudentDto / ClassRosterEntryDto (response)6.22 CreateEnrollmentDto (body)6.23 UpdateEnrollmentDto (body)6.24 ListRosterQueryDto (query, extends QueryDto)6.25 ListStudentEnrollmentsQueryDto (query, extends QueryDto)7. Enum Reference8. Endpoint Reference8.1 GET /api/gradesPurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesEdge CasesExample Requests8.2 GET /api/sectionsPurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesEdge CasesExample Requests8.3 POST /api/sectionsPurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesEdge CasesExample Requests8.4 PATCH /api/sections/:publicIdPurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesEdge CasesExample Requests8.5 DELETE /api/sections/:publicIdPurposeSource EvidenceAuth and PermissionsResponseSide EffectsError CasesEdge CasesExample Requests8.6 GET /api/roomsPurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesEdge CasesExample Requests8.7 POST /api/roomsPurposeSource EvidenceAuth and PermissionsRequestResponseError CasesEdge CasesExample Requests8.8 PATCH /api/rooms/:publicIdPurposeSource EvidenceAuth and PermissionsRequestError CasesEdge CasesExample Requests8.9 DELETE /api/rooms/:publicIdPurposeSource EvidenceAuth and PermissionsError CasesEdge CasesExample Requests8.10 GET /api/classesPurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesEdge CasesExample Requests8.11 GET /api/classes/optionsPurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesEdge CasesExample Requests8.12 GET /api/classes/:publicIdPurposeSource EvidenceAuth and PermissionsResponseError CasesEdge CasesExample Requests8.13 POST /api/classesPurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesEdge CasesExample Requests8.14 PATCH /api/classes/:publicIdPurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesEdge CasesExample Requests8.15 DELETE /api/classes/:publicIdPurposeSource EvidenceAuth and PermissionsError CasesEdge CasesExample Requests8.16 GET /api/classes/:publicId/studentsPurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesEdge CasesExample Requests8.17 POST /api/classes/:publicId/enrollmentsPurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesEdge CasesExample Requests8.18 DELETE /api/classes/:publicId/enrollments/:studentIdPurposeSource EvidenceAuth and PermissionsResponseSide EffectsError CasesEdge CasesExample Requests8.19 PATCH /api/enrollments/:idPurposeSource EvidenceAuth and PermissionsRequestError CasesEdge CasesExample Requests8.20 DELETE /api/enrollments/:idPurposeSource EvidenceAuth and PermissionsResponseSide EffectsError CasesEdge CasesExample Requests8.21 GET /api/students/:id/enrollmentsPurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesEdge CasesExample Requests9. Flow Diagrams9.1 Route Ownership9.2 Request Sequence — enrol/transfer9.3 Error Branch — mutation endpoints10. Pagination, Sorting, Filtering, and Search11. Caching, Jobs, and External Integrations13. Mandatory Deep API Documentation Pack13.1 Route-by-Route Completeness Matrix13.2 Request/Response Exhaustiveness13.3 API Diagram Pack13.4 Consumer Integration Notes13.5 API Tradeoffs and Rationale13.6 API Change Impact14. Zero-Omission API Checklist14b. The consumer portal — teacher surfaceWhat "my classes" meansAuthorizationWhy the roster is not the admin roster15. Integration ChecklistSee Also