Classes Backend Documentation
Backend architecture, data model, services, locking, security, and runtime rules for grades, sections, rooms, classes, and student class enrolments.
Classes Backend Documentation
1. Documentation Evidence
| Area | Files Inspected | Verified Details |
|---|---|---|
| Module wiring | apps/api/src/modules/academic-structure/academic-structure.module.ts, apps/api/src/modules/enrollments-core/enrollments-core.module.ts | Imports, providers, controllers, exports, and the acyclic dependency graph both docblocks state. |
| Controllers | grades/grades.controller.ts, sections/sections.controller.ts, rooms/rooms.controller.ts, classes/classes.controller.ts, enrollments/class-enrollments.controller.ts | Route ownership, guards, permissions, thin-controller boundaries. |
| Services | grades/grades.service.ts, sections/sections.service.ts, rooms/rooms.service.ts, classes/classes-read.service.ts, classes/classes-write.service.ts, classes/class-roster.service.ts, enrollments-core/class-enrollments.service.ts, enrollments-core/class-occupancy.service.ts, enrollments-core/class-enrollments.mapper.ts, enrollments-core/roll-numbers/roll-number.service.ts | Business logic, validation order, locking, response mapping, side effects. |
| DTOs | dto/grade.dto.ts, dto/section.dto.ts, dto/room.dto.ts, dto/class.dto.ts, dto/enrollment.dto.ts, dto/roll-number.dto.ts, dto/query-boolean.ts | Request/response contracts and validation. |
| Schema | packages/db/src/schema/school/classes.ts | Tables, enums, constraints, indexes, relations — including the docblock's own stated reasoning for every non-obvious choice. roll_number and its partial unique index/CHECK live here, on studentClassEnrollments. |
| Roll number policy source | packages/db/src/schema/school/school-profile.ts (rollNumberStrategyEnum, roll_number_strategy, roll_number_auto_renumber) | The two singleton-row columns RollNumberService.policy reads — owned by the school module, referenced here because they govern this module's assignment order. |
| Migration | packages/db/src/migrations/0011_class_module.sql, packages/db/src/migrations/down/0011_class_module.down.sql, packages/db/src/migrations/0021_class_roll_number.sql | Table/type creation order, the down migration's stated drop-order reasoning, and the hand-added admission-order backfill in 0021. |
| Constraint probe | packages/db/src/scripts/probe-class-module-constraints.sql | 57 accept/reject cases against the real generated schema, both directions, all passing. |
| Seed data | packages/db/src/seed/seed-reference-data.ts, packages/db/src/seed/seed-auth.ts | The 15 seeded grades and 4 seeded sections, the empty-table seed guard, and which roles are granted which read codes. |
| Errors | apps/api/src/common/types/error-codes.ts (the "SCHOOL DOMAIN: classes, sections and rooms" section, including its "Class roll numbers" subsection at line 1327) | Every error code this module can produce. |
| Authorization | packages/db/src/authorization/permission-catalog.ts, packages/db/src/seed/seed-auth.ts, apps/api/src/common/authorization/role.guard.ts | Permission modules/actions, role grants, guard behavior. |
| Object-level access | apps/api/src/modules/people/shared/people-access.service.ts, apps/api/src/modules/people/shared/people-permissions.service.ts | scopeFor/applyScope/assertCanAccess — the actual control behind the Students_* permission codes. |
| Pagination | apps/api/src/common/utils/pagination.util.ts | Default/max size, UNPAGINATED_HARD_CAP. |
| Deploy scripts | apps/api/scripts/sync-permissions.ts | Which roles a permission sync actually grants to, versus what the full seed grants. |
| Tests | academic-structure/__tests__/*.integration.spec.ts, enrollments-core/__tests__/*.integration.spec.ts (including roll-number.service.integration.spec.ts) | Real-database coverage for every service in this module. |
| Wiring into the app | apps/api/src/app.module.ts | AcademicStructureModule composition. |
2. Backend Scope and Boundaries
Owns
- Grades — Nursery, LKG, UKG and grades 1-12, a read-only reference list with no permission module of its own; gated under
Classes_READ.GradesService/GradesController. - Sections — the A/B/C/D-style subdivisions a grade is split into, full CRUD, hard-deleted and reference-guarded like the school module's lookup tables.
SectionsService/SectionsController. - Rooms — physical rooms (number, floor, building), full CRUD, hard-deleted and reference-guarded.
RoomsService/RoomsController. - Classes — the concrete unit a school runs: one grade, one section, one shift, in one academic session, with a capacity, an optional room, and an optional class teacher. Read is split into list/options/single-row (
ClassesReadService); writes are create/update/delete (ClassesWriteService). - Student class enrolments — which pupil sits in which class, and when they stopped. The sole writer is
ClassEnrollmentsService, deliberately outsideAcademicStructureModulein a leaf module (EnrollmentsCoreModule) — see 3 for why. - Class occupancy — the one shared, live-computed definition of "how full is this class," used by capacity checks, the class list, the options endpoint, and the roster.
ClassOccupancyService. - A class's roster —
ClassRosterService, which is the one read path in this module that applies the people-domain object-level scope. - Class roll numbers — a pupil's position in their class's register, assigned when they are enrolled and editable afterwards by an administrator. The sole writer is
RollNumberService(enrollments-core/roll-numbers/roll-number.service.ts), called fromClassEnrollmentsService.enrollon every admission and fromClassEnrollmentsControlleron the two roll-number-specific routes — see 6.9 and 7.6/7.7. - Name/number-uniqueness enforcement for sections (case-insensitive, active-only) and rooms (case-insensitive, scoped to building, active-only), and identity/room/teacher-exclusivity enforcement for classes (see 5.2).
- Delete-time referential checks that produce a mapped error instead of an unmapped foreign-key failure, or — for rooms specifically — instead of a silent
ON DELETE set null.
Does Not Own
- Academic sessions.
packages/db/src/schema/school/academic-sessions.tsand its own module own the year a class belongs to; this module only referencesacademic_sessions.idand readsstartDate/endDate/isCurrentoff it. - Staff and their designations.
packages/db/src/schema/school/people.tsownsstaff; this module only reads a staff row (and its designation'sisTeachingflag) to validate and display a class teacher. A teacher is astaffrow with a teaching designation — there is no teacher entity and noTeacherspermission module. - Students.
packages/db/src/schema/school/people.tsownsstudents, created/updated/soft-deleted by the people module. This module readsstudents.deletedAt/admissionDate/recordStatusto validate an enrolment and to filter occupancy and the roster, but never writes any of those columns. - Object-level access scoping.
PeopleAccessService/PeoplePermissionsService(inPeopleModule) ownscopeFor/applyScope/assertCanAccess; this module's roster service and enrolment controller call into them rather than reimplementing scope logic. - Activity/audit recording infrastructure.
ActivityRecordService(inActivityModule) owns the Mongo write;ClassEnrollmentsServicecalls it for detail the automatic interceptor cannot see, but does not own the mechanism. - Authentication.
JwtAuthGuardis imported, not implemented, by every controller in this module. - Redis connectivity.
RedisCacheServiceis imported byClassEnrollmentsServicefor exactly one purpose — invalidating the student list cache another module owns — never for caching this module's own reads (see 8. Caching).
Source of Truth
| Concern | Source of Truth | Notes |
|---|---|---|
| Grade/section/room existence, name, active flag | grades/sections/rooms tables | Read live on every request, except the room list, whose rows are cached (see 8). |
| A class's identity, capacity, room, and class teacher | classes row | The four identity columns are immutable after creation — see 5.2. |
| How full a class is | Computed live by ClassOccupancyService, never a stored column | Filters student_class_enrollments.status = 'active' AND students.deletedAt IS NULL AND students.recordStatus = 'active' — see 6.8. |
| Which classes a pupil has held, and when | student_class_enrollments, one row per stint | ClassEnrollmentsService is the sole writer. |
| Whether a caller may act on a specific pupil | PeopleAccessService.assertCanAccess, called by the controller, never by the enrolment service itself | See 11. Security. |
Which academic year a GET /classes/GET /classes/options request without an explicit academicSessionId resolves to | academic_sessions.is_current, except GET /classes/options?date=, which resolves to the active session whose [start_date, end_date) contains the date through CalendarDayService.sessionContaining | Nothing in the schema requires exactly one current session to exist; both read paths return an empty result rather than throwing when none is. |
3. Module Composition
| Module | Type | Path | Controllers | Providers | Exports | Responsibility |
|---|---|---|---|---|---|---|
AcademicStructureModule | Aggregate/leaf hybrid | apps/api/src/modules/academic-structure/academic-structure.module.ts | GradesController, SectionsController, RoomsController, ClassesController, ClassEnrollmentsController | GradesService, SectionsService, RoomOccupancyService, RoomsService, ClassesReadService, ClassesWriteService, ClassRosterService | ClassesReadService, RoomOccupancyService | Owns grade/section/room/class CRUD and the class-side enrolment routes. |
EnrollmentsCoreModule | Leaf | apps/api/src/modules/enrollments-core/enrollments-core.module.ts | None | ClassEnrollmentsService, ClassOccupancyService, RollNumberService | All three | The sole writer of student_class_enrollments, the one shared definition of class occupancy, and the sole writer of roll_number. Performs no authorization. |
The import list is load-bearing, and so is what is absent. AcademicStructureModule imports EnrollmentsCoreModule, PeopleModule, RoleModule, ActivityModule, and AttendanceSharedModule (for CalendarDayService.sessionContaining; that module declares no controller and imports nothing, so it cannot close a cycle). PeopleModule itself imports EnrollmentsCoreModule — so StudentsService can write an enrolment inside the same transaction as a pupil, on a path this document does not otherwise cover. That composition only works because EnrollmentsCoreModule is a leaf: it imports ActivityModule and nothing else, giving the graph
ActivityModule ← EnrollmentsCoreModule ← PeopleModule ← AcademicStructureModulewhich is acyclic. Had ClassEnrollmentsService needed PeopleAccessService directly, EnrollmentsCoreModule would need to import PeopleModule, closing the graph into a PeopleModule ↔ AcademicStructureModule cycle. Nest refuses that at InstanceLoader unless both sides use forwardRef, and there is no forwardRef anywhere in this application — this codebase's one prior module cycle produced ReferenceError: Cannot access 'MONGODB' before initialization at boot, with check-types and build both green. So authorization for every enrolment write lives at the controller boundary, and ClassEnrollmentsService is a pure domain writer. RoleModule is imported for RoleGuard's dependency on RoleService, applied at controller level on every controller in this module.
AcademicStructureModule exports two services. ClassesReadService is not currently imported by any other module — it exists for a future module that needs to resolve a class by id without duplicating the join logic in 6.4. RoomOccupancyService IS consumed elsewhere: the assets module's AssetCatalogModule imports AcademicStructureModule specifically to inject it into AssetRoomsLookupController, so the asset register's room picker (/api/asset-catalog/rooms) and this module's own GET /api/rooms describe a room's classes and buildings through the identical query rather than two copies of the same join — see 6.10. This is the one cross-module edge into AcademicStructureModule from outside its own controllers, and it runs one direction only: nothing in AcademicStructureModule imports anything from the assets module, so no cycle is created.
RollNumberService reads school_profile directly, not through SchoolProfileService. RollNumberService.policy (roll-numbers/roll-number.service.ts:63-76) selects rollNumberStrategy/rollNumberAutoRenumber off the schoolProfile table with its own injected Database, rather than calling SchoolProfileModule's exported service. EnrollmentsCoreModule importing SchoolProfileModule for one two-column read would add a second cross-domain import to a module this document's own composition rules keep deliberately minimal, and SchoolProfileService.get() additionally resolves the school's principal on every call — work this policy read has no use for. The cost is that a PATCH /api/school-profile write invalidates SchoolProfileService's own school:profile cache key (/docs/developer/school/backend) but RollNumberService.policy never reads that cache at all — it is a live, uncached row read on every enrolment and every renumber call, so the two paths cannot disagree on staleness, only on whichever value is currently committed.
4. File and Directory Map
apps/api/src/modules/academic-structure/
academic-structure.module.ts
grades/
grades.controller.ts
grades.service.ts
sections/
sections.controller.ts
sections.service.ts
rooms/
rooms.controller.ts
rooms.service.ts
room-occupancy.service.ts
classes/
classes.controller.ts
classes-read.service.ts
classes-write.service.ts
class-roster.service.ts
enrollments/
class-enrollments.controller.ts
dto/
index.ts
grade.dto.ts
section.dto.ts
room.dto.ts
class.dto.ts
enrollment.dto.ts
roll-number.dto.ts
query-boolean.ts
__tests__/
grades.service.integration.spec.ts
sections.service.integration.spec.ts
rooms.service.integration.spec.ts
rooms-occupancy.integration.spec.ts
classes-read.service.integration.spec.ts
classes-write.service.integration.spec.ts
apps/api/src/modules/enrollments-core/
enrollments-core.module.ts
class-enrollments.service.ts
class-enrollments.mapper.ts
class-occupancy.service.ts
roll-numbers/
roll-number.service.ts
__tests__/
fixtures.ts
class-enrollments.service.integration.spec.ts
class-occupancy.service.integration.spec.ts
roll-number.service.integration.spec.ts
packages/db/src/schema/school/
classes.ts # class_shift, enrollment_status enums; grades, sections, rooms, classes,
# student_class_enrollments tables and their relations
packages/db/src/seed/
seed-reference-data.ts # seedAcademicStructure() — 15 grades, 4 sections, empty-table-guarded| File | Purpose | Key Exports | Notes |
|---|---|---|---|
academic-structure.module.ts | Wires all five controllers and their services together. | AcademicStructureModule | Imports EnrollmentsCoreModule, PeopleModule, RoleModule, ActivityModule, AttendanceSharedModule. |
grades/grades.controller.ts | Single GET route. | GradesController | No create/update/delete handler exists at all. |
grades/grades.service.ts | Read-only list logic. | GradesService | Gated by Classes_READ, not a Grades permission — there is no such module. |
sections/sections.controller.ts | Full CRUD on /sections. | SectionsController | Mirrors the school module's lookup-CRUD shape. |
sections/sections.service.ts | Section business logic. | SectionsService | Sort-order computed inside a transaction on create. |
rooms/rooms.controller.ts | Full CRUD on /rooms. | RoomsController | — |
rooms/rooms.service.ts | Room business logic. | RoomsService | Delete guard is an explicit pre-check, not a caught FK violation — see 6.3. |
rooms/room-occupancy.service.ts | Batch-loaded occupancy facts (classes, buildings, asset count) shared by this module's RoomsController and the assets module's AssetRoomsLookupController. | RoomOccupancyService | See 6.10. Exported from AcademicStructureModule so AssetCatalogModule can inject it. |
classes/classes.controller.ts | List/options/read/create/update/delete on /classes. | ClassesController | @Get("options") declared before @Get(":publicId") — declaration order determines route matching. |
classes/classes-read.service.ts | Every read path for classes. | ClassesReadService, ClassRow (exported type) | loadDto is public so the write service can reuse it inside its own transaction. |
classes/classes-write.service.ts | Create/update/delete for classes. | ClassesWriteService | Splits "not found"/"retired" validation (read-then-check) from "already taken" (write-then-translate). |
classes/class-roster.service.ts | A class's pupil list, object-scope-filtered. | ClassRosterService | Depends on PeopleAccessService/PeoplePermissionsService from PeopleModule. |
enrollments/class-enrollments.controller.ts | All five enrolment-adjacent routes: roster, enrol, withdraw, correct, remove. | ClassEnrollmentsController | The only place in this module that calls PeopleAccessService.assertCanAccess. |
dto/class.dto.ts | Class DTOs, options DTOs, and both list query DTOs. | ClassDto, ClassOptionDto, ClassOptionsPayloadDto, CreateClassDto, UpdateClassDto, ListClassesQueryDto, ClassOptionsQueryDto, ClassTeacherDto, ClassAcademicSessionDto, CLASS_SHIFTS | UpdateClassDto omits all four identity fields by design. |
dto/enrollment.dto.ts | Enrolment and roster DTOs. | EnrollmentDto, EnrollmentClassDto, ClassRosterEntryDto, ClassRosterStudentDto, CreateEnrollmentDto, UpdateEnrollmentDto, ListRosterQueryDto, ListStudentEnrollmentsQueryDto, ENROLLMENT_STATUSES | ClassRosterStudentDto is deliberately narrower than StudentDto — no DOB, address, phone, guardian, or medical fields. ClassRosterEntryDto.rollNumber is nullable, and the roster is ordered by it. |
dto/roll-number.dto.ts | The body of PATCH /enrollments/:id/roll-number. | SetRollNumberDto | rollNumber?: number | null — a present integer >= 1 sets it, null unnumbers the pupil, and it is otherwise optional. |
dto/query-boolean.ts | Shared QueryBoolean()/QueryInt() transforms. | QueryBoolean, QueryInt | Used by every boolean/integer query filter in this module — query params arrive as strings. |
enrollments-core/class-enrollments.service.ts | The sole writer of student_class_enrollments, plus the per-pupil history read. | ClassEnrollmentsService | Marked SPLIT-EXEMPT in its own header — five methods sharing one lock discipline, one translation table, one cache-invalidation path. Calls RollNumberService.assignOnEnroll from inside enroll's own transaction, immediately after the insert (class-enrollments.service.ts:294). |
enrollments-core/class-enrollments.mapper.ts | Flat-row-to-EnrollmentDto mapping for listForStudent. | toEnrollmentDto, EnrollmentQueryRow | Split out to keep the service under this repo's line-count convention. |
enrollments-core/class-occupancy.service.ts | The one shared occupancy computation. | ClassOccupancyService | countForMany is the only grouped-query path; a per-row loop calling countFor would be an N+1 no gate catches. |
enrollments-core/roll-numbers/roll-number.service.ts | The sole writer of student_class_enrollments.roll_number. | RollNumberService, RollNumberStrategy, RollNumberPolicy, RollNumberAssignment | The one file in this module in its own subdirectory rather than flat under enrollments-core/ — see 6.9. |
5. Data Model
5.1 Schema Source
packages/db/src/schema/school/classes.ts
classShiftEnum # "morning" | "day"
enrollmentStatusEnum # "active" | "transferred" | "withdrawn"
grades
sections
rooms
classes
studentClassEnrollmentsEvery text column in this file is constrained by ~ '^[^[:space:]](.*[^[:space:]])?$', not col = btrim(col). Two reasons the schema's own docblock states: btrim(x) with one argument strips spaces only — a tab or newline still satisfies col = btrim(col), and lower('A ') does not collide with 'a' under the case-folded unique indexes below, so the hole the check exists to close would stay open. It also rejects an all-whitespace value, which a plain char_length(col) > 0 would accept. The POSIX class [[:space:]] is deliberate too — \s inside a Drizzle sql template literal is a JavaScript NonEscapeCharacter, so the cooked string contains a bare s and the constraint would silently become "must not start with the letter s"; [[:space:]] needs no backslash and cannot be mangled that way. The one accepted limit: glibc's [[:space:]] under en_US.UTF-8 does not classify U+00A0 NO-BREAK SPACE as whitespace, so a name padded with one is still representable — the realistic inputs (a pasted trailing space, a tab, a newline) are covered, and the constraint probe asserts exactly which of these it rejects rather than assuming.
5.2 Tables and Collections
grades
| Column | Type | Nullable | Default | Index/Constraint | Relation | Notes |
|---|---|---|---|---|---|---|
id | serial | No | auto-increment | PK | Referenced by classes.grade_id | Internal integer id. |
public_id | uuid | No | uuid7() (app-level $defaultFn) | UNIQUE | N/A | — |
name | text | No | — | grades_name_unique — unique on lower(name), partial WHERE is_active; format CHECK ≤ 64 chars | N/A | e.g. "5", "Nursery". |
code | text | No | — | grades_code_unique — unique on code, partial WHERE is_active; format CHECK ^[A-Z0-9_]{1,16}$ | N/A | Stable across a rename — the value a report or import quotes. |
sort_order | integer | No | — | grades_sort_order_bounded CHECK >= 0 | N/A | Deliberately not unique. A unique sort_order would give reordering two grades no non-colliding intermediate state — every swap would need a temporary out-of-range value, and re-seeding would collide on a column the seed does not own. Ordering is (sort_order, lower(name)), which is deterministic without uniqueness. |
is_active | boolean | No | true | grades_is_active_idx (btree) | N/A | Retirement flag — see the module-wide note below. |
created_at | timestamptz | No | now() | — | N/A | — |
updated_at | timestamptz | No | now(), $onUpdateFn | — | N/A | Bumped by the application, not a DB trigger. |
Both unique indexes are partial on is_active — retiring "UKG" and recreating it later must not be blocked by a full unique index naming a row the admin screen (which filters is_active) does not list.
sections
| Column | Type | Nullable | Default | Index/Constraint | Relation | Notes |
|---|---|---|---|---|---|---|
id | serial | No | auto-increment | PK | Referenced by classes.section_id | — |
public_id | uuid | No | uuid7() | UNIQUE | N/A | — |
name | text | No | — | sections_name_unique — unique on lower(name), partial WHERE is_active; format CHECK ≤ 32 chars | N/A | "A", "B", … |
sort_order | integer | No | — | sections_sort_order_bounded CHECK >= 0 | N/A | Not unique, same reasoning as grades.sort_order. |
is_active | boolean | No | true | sections_is_active_idx (btree) | N/A | — |
created_at / updated_at | timestamptz | No | now() (+ $onUpdateFn) | — | N/A | — |
No code column, unlike grades. A section has no stable machine identity worth preserving across a rename — "A" renamed to "Alpha" is the same section under a new label, and nothing external quotes it. That absence is exactly why the seed guards on an empty table rather than using ON CONFLICT — see 15 and seed-reference-data.ts.
rooms
| Column | Type | Nullable | Default | Index/Constraint | Relation | Notes |
|---|---|---|---|---|---|---|
id | serial | No | auto-increment | PK | Referenced by classes.room_id | — |
public_id | uuid | No | uuid7() | UNIQUE | N/A | — |
room_number | text | No | — | Part of rooms_building_number_unique; format CHECK ≤ 32 chars | N/A | Free text, e.g. "101". |
name | text | Yes | null | rooms_name_format CHECK, NULL-admitting, ≤ 128 chars | N/A | Rejects an empty string specifically — what an unfilled optional form field sends; a genuine NULL (nothing sent) is unconstrained. |
floor | integer | No | — | rooms_floor_bounded CHECK -5..200 inclusive | N/A | An integer, not text — free text yields "1st"/"First"/"1"/"Ground floor" as four distinct floors, and a picker grouped by building-then-floor would sort lexicographically ("10" before "2"). Zero is ground, negatives are basements. |
building | text | No | — | Part of rooms_building_number_unique; format CHECK ≤ 128 chars | N/A | — |
is_active | boolean | No | true | rooms_is_active_idx (btree) | N/A | — |
created_at / updated_at | timestamptz | No | now() (+ $onUpdateFn) | — | N/A | — |
rooms_building_number_unique — unique on (lower(building), lower(room_number)), partial WHERE is_active. Scoped to the building, not global — two buildings each having a "101" is the normal case, and a global unique index would force an operator to invent prefixes the school does not otherwise use.
classes
A class the school runs: one grade, one section, one shift, in one academic session.
| Column | Type | Nullable | Default | Index/Constraint | Relation | Notes |
|---|---|---|---|---|---|---|
id | serial | No | auto-increment | PK; part of classes_id_session_unique | Target of the enrolment table's composite FK | — |
public_id | uuid | No | uuid7() | UNIQUE | N/A | — |
academic_session_id | integer | No | — | FK → academic_sessions.id ON DELETE restrict; part of every identity/uniqueness index below | classes.academicSession | Immutable after creation — see the docblock note below. |
grade_id | integer | No | — | FK → grades.id ON DELETE restrict; classes_grade_idx (btree); part of classes_identity_unique | classes.grade | Immutable. |
section_id | integer | No | — | FK → sections.id ON DELETE restrict; part of classes_identity_unique | classes.section | Immutable. |
shift | class_shift enum | No | — | Part of classes_identity_unique and both partial exclusivity indexes | N/A | "morning" | "day". Part of a class's identity, not a mere attribute — a double-shift school runs two disjoint rolls through the same rooms and often the same teachers, so "Grade 5 A" morning and "Grade 5 A" day are two classes, not one class with a label. Immutable. |
name | text | Yes | null | classes_name_format CHECK, NULL-admitting, ≤ 128 chars | N/A | Optional display name ("5A Morning") — never identity. |
capacity | integer | No | — | classes_capacity_bounded CHECK 1..500 | N/A | The bound is a fat-finger guard (catching a typed 4000), not a domain rule — a class deliberately pushed past 500 by repeated allowOverCapacity overrides can never have its capacity corrected to match its roll. |
class_teacher_id | uuid | Yes | null | FK → staff.id ON DELETE set null; classes_class_teacher_idx (btree); part of classes_teacher_per_shift_unique | classes.classTeacher | staff soft-deletes, so ON DELETE set null never fires for a departed teacher — a class genuinely outlives its teacher's employment, and the read side reports employmentStatus so the UI can say the person has left rather than presenting them as current staff. |
room_id | integer | Yes | null | FK → rooms.id ON DELETE set null; classes_room_idx (btree); part of classes_room_per_shift_unique | classes.room | — |
is_active | boolean | No | true | — | N/A | Retirement flag with an asymmetry from every other is_active in this schema — see below. |
created_at / updated_at | timestamptz | No | now() (+ $onUpdateFn) | — | N/A | — |
The four identity columns are immutable after creation, and this is not tidiness. academic_session_id, grade_id, section_id, and shift are absent from UpdateClassDto. The composite unique constraint classes_id_session_unique ((id, academic_session_id)) is the target of student_class_enrollments' composite foreign key, which carries no ON UPDATE action — Postgres's default NO ACTION refuses any change to academic_session_id the instant one enrolment exists, surfacing as an unmapped 23503 (a 500). designations.department_id carries the identical warning for the identical construction, and prescribes the identical answer: deactivate and recreate.
Why identity uniqueness is total and room/teacher uniqueness is partial. classes_identity_unique — unique on (academic_session_id, grade_id, section_id, shift) — is a plain unique index with no WHERE is_active clause, unlike every other unique index in this file. An earlier design put AND is_active on all three, plus a service rule refusing to deactivate a class holding enrolments — and the two rules cancelled each other: after one pupil enrolled, the class could be neither deactivated (the service rule) nor hard-deleted (ON DELETE restrict from the enrolment row), so it was permanently stuck in exactly the case the partial index existed to unstick. A class's identity is its name within its year, and history must not be ambiguous — two rows that were ever "2026-27 / Grade 5 / A / morning" would make every past roster unresolvable, so identity stays reserved forever and deactivation is always permitted with no enrolment check at all. A room and a class teacher are resources, not identity, and a retired class must release them — so classes_room_per_shift_unique and classes_teacher_per_shift_unique do keep WHERE ... AND is_active, each scoped to (academic_session_id, shift, ...) so the same room or the same person can run a morning class and a day class but never two of the same shift.
student_class_enrollments
Which pupil sits in which class, and when they stopped.
| Column | Type | Nullable | Default | Index/Constraint | Relation | Notes |
|---|---|---|---|---|---|---|
id | uuid (v7 PK) | No | app-generated | PK | — | — |
student_id | uuid | No | — | FK → students.id ON DELETE cascade; student_class_enrollments_student_idx (btree) | studentClassEnrollments.student | cascade, matching student_guardian and for the same stated reason: a restrict fires two levels down from a users hard delete, naming a table the caller never touched, with no erasure path at all. Note what cascade does not do: students soft-deletes, and a soft delete never fires ON DELETE, which is why every occupancy count separately filters students.deleted_at IS NULL. |
class_id | integer | No | — | Half of the composite FK below (no inline .references(), matching staff.designation_id) | — | — |
academic_session_id | integer | No | — | Other half of the composite FK | — | Denormalised, deliberately. See below. |
status | enrollment_status enum | No | 'active' | Part of the partial unique index and the two coherence CHECKs below | N/A | "active" | "transferred" | "withdrawn" — no "completed", see 5.2 note. |
roll_number | smallint | Yes | null | student_class_enrollments_class_roll_unique — unique on (class_id, roll_number), partial WHERE status = 'active' AND roll_number IS NOT NULL; student_class_enrollments_roll_number_positive CHECK, NULL-admitting, roll_number > 0 | N/A | The pupil's position in this class's register. Added by migration 0021_class_roll_number.sql. See below. |
enrolled_on | date | No | — | student_class_enrollments_dates_ordered CHECK (with ended_on) | N/A | — |
ended_on | date | Yes | null | student_class_enrollments_dates_ordered, student_class_enrollments_ended_matches_status | N/A | — |
created_at / updated_at | timestamptz | No | now() (+ $onUpdateFn) | — | N/A | — |
Why academic_session_id is denormalised here, and made safe by a composite FK. The invariant this table exists to enforce is "one active enrolment per pupil per session," and Postgres cannot build a partial unique index across a join — the session lives on classes, the pupil lives on this row. Without a local copy, the rule is unenforceable in the database and becomes a service convention, exactly the shape of invariant that survives review and then fails under concurrency. The copy is made safe, not merely conventional, by student_class_enrollments_class_session_fk — (class_id, academic_session_id) → classes(id, academic_session_id), ON DELETE restrict ON UPDATE restrict — the same construction staff already uses for (department_id, designation_id). A composite FK is MATCH SIMPLE by default, which skips validation entirely when any column of the pair is NULL — the hole that let (NULL, <a designation>) into staff and required a companion CHECK there. Both class_id and academic_session_id on this table are NOT NULL, so that skip condition is unreachable and no companion CHECK is needed — verified, not assumed, by the constraint probe.
The one-active-per-session invariant, precisely. student_class_enrollments_one_active_per_session_unique — unique on (student_id, academic_session_id), partial WHERE status = 'active'. A pupil accumulates any number of closed rows per session and at most one open one, which is exactly what makes a transfer representable: close the predecessor, open the successor, and the partial index never sees two 'active' rows at once.
The two coherence CHECKs. student_class_enrollments_dates_ordered — ended_on IS NULL OR ended_on >= enrolled_on — rejects a closed enrolment whose end predates its own start. student_class_enrollments_ended_matches_status — (status = 'active' AND ended_on IS NULL) OR (status <> 'active' AND ended_on IS NOT NULL) — an active row carrying an end date, and a closed row carrying none, are both incoherent: the first claims the pupil is both in the class and has left it; the second claims they left and names no date.
roll_number, precisely — nullable, partial-unique, and freed on departure. Three legitimate states carry no number (classes.ts:440-443): a row imported before the school configured its numbering, a pupil an administrator has deliberately unnumbered pending a renumber, and a closed enrolment whose class was later renumbered without it. student_class_enrollments_class_roll_unique is partial on both status = 'active' and roll_number IS NOT NULL — so any number of rows may carry no number at all — and it is scoped to class_id alone, per class rather than per academic session, which is correct because a class row is itself identity-scoped to one session: two DIFFERENT classes may each legitimately hold a roll number 1 (classes.ts:510-515,522), and a departed pupil's number becomes immediately reusable within the same class while their closed row keeps the number it had, for history. The index is also the module's concurrency control for automatic assignment: two admissions into the same class computing the same max + 1 collide here with 23505, and RollNumberService.assignNext retries once against a re-read maximum rather than taking a class-wide advisory lock — see 6.9. student_class_enrollments_roll_number_positive admits NULL explicitly and rejects 0 or a negative value; a CHECK evaluates to NULL (not false) on a NULL input regardless, but stating the admission keeps a reader from having to know that rule to read the constraint.
No completed status
An earlier design had one, defined as "the session ended with the pupil in this class," and nothing anywhere wrote it — no route, no job, no scheduled task. An enum value with no writer is a dead branch that makes a missing feature look implemented: every check constraint and every query mentioning it would read as though year rollover were handled. An enrolment in a past session simply stays active, and that is unambiguous because the row names its own session and academic_sessions.is_current says which year is now. Closing a year belongs to a promotion feature that will add the value back together with the thing that writes it.
5.3 Relationship Diagram
Index rationale
| Index/Constraint | Columns | Type | Query/Invariant Supported | Tradeoff |
|---|---|---|---|---|
grades_name_unique | lower(name), WHERE is_active | unique btree (partial, expression) | Case-insensitive name uniqueness among active grades. | Retiring a grade frees its name for reuse. |
grades_code_unique | code, WHERE is_active | unique btree (partial) | Same, for the stable code value. | — |
grades_is_active_idx | is_active | btree | The active-only filter every select box applies. | — |
sections_name_unique | lower(name), WHERE is_active | unique btree (partial, expression) | Case-insensitive name uniqueness among active sections. | — |
sections_is_active_idx | is_active | btree | Same as grades. | — |
rooms_building_number_unique | lower(building), lower(room_number), WHERE is_active | unique btree (partial, expression) | Case-insensitive (building, number) pairing, scoped per building. | — |
rooms_is_active_idx | is_active | btree | Same as grades. | — |
classes_id_session_unique | id, academic_session_id | unique table constraint | Exists solely so student_class_enrollments can carry a composite FK onto (id, academic_session_id) — see the immutability note in 5.2. | drizzle-kit emits every ADD CONSTRAINT ... FOREIGN KEY before every CREATE INDEX; a uniqueIndex here instead would fail migration 0011 on a clean database with 42830. |
classes_identity_unique | academic_session_id, grade_id, section_id, shift | unique btree, not partial | Total identity uniqueness — deactivation never frees a class's name within its year. | A retired class's identity is permanently reserved; the correction is deactivate-and-recreate under a different identity, or reactivate. |
classes_room_per_shift_unique | academic_session_id, shift, room_id, WHERE room_id IS NOT NULL AND is_active | unique btree (partial) | A room may run one class per shift, per session. | A retired class releases its room immediately. |
classes_teacher_per_shift_unique | academic_session_id, shift, class_teacher_id, WHERE class_teacher_id IS NOT NULL AND is_active | unique btree (partial) | Same, for a class teacher. | — |
classes_capacity_bounded | capacity | CHECK 1..500 | Fat-finger guard. | Blocks correcting a capacity for a class already pushed past 500 by repeated overrides. |
classes_name_format | name | CHECK, NULL-admitting | Trim/length format on the optional display name. | — |
classes_academic_session_idx, classes_grade_idx, classes_room_idx, classes_class_teacher_idx | Each column | btree | The class list's filters (gradeId, roomId, classTeacherId) and the session-scoping every read applies. | — |
student_class_enrollments_one_active_per_session_unique | student_id, academic_session_id, WHERE status = 'active' | unique btree (partial) | The invariant this table exists to enforce — one open enrolment per pupil per session. | A transfer must close the predecessor in the same transaction as opening the successor, or the write is refused. |
student_class_enrollments_class_session_fk | (class_id, academic_session_id) → classes(id, academic_session_id) | composite FK, ON DELETE restrict ON UPDATE restrict | Makes the denormalised academic_session_id provably equal to the class's own. | ON UPDATE restrict is what makes a class's session immutable once one enrolment exists. |
student_class_enrollments_dates_ordered | enrolled_on, ended_on | CHECK | Rejects an end date before a start date. | — |
student_class_enrollments_ended_matches_status | status, ended_on | CHECK | Rejects an active row with an end date, or a closed row without one. | — |
student_class_enrollments_active_class_idx | class_id, WHERE status = 'active' | btree (partial) | The exact index ClassOccupancyService's grouped query scans. | A closed enrolment never counts toward capacity, and never needs to be scanned for it. |
student_class_enrollments_student_idx | student_id | btree | A pupil's own enrolment-history read. | — |
student_class_enrollments_class_roll_unique | class_id, roll_number, WHERE status = 'active' AND roll_number IS NOT NULL | unique btree (partial) | One roll number per class among its LIVE roll; also the concurrency control RollNumberService.assignNext retries against on a race. | A closed enrolment keeps the number it had for history while the position itself becomes reusable. |
student_class_enrollments_roll_number_positive | roll_number | CHECK, NULL-admitting | Rejects 0 or a negative roll number. | — |
6. Services and Responsibilities
6.1 GradesService
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
findAll(query) | GradesController.findAll | grades, filtered by search/isActive | — | None | — |
Read-only, and deliberately so — the service's own docblock states this is a lookup gated like its parent (Classes_READ), not PersonClassifications' exception to that rule. Ordering is fixed: sortOrder, lower(name), id — query.sort/query.order (both inherited from QueryDto) are never read. search is honored via escapeLikePattern-escaped ILIKE. Pagination follows the standard PaginationUtil shape, including the UNPAGINATED_HARD_CAP fallback for pagination=false — grades is a reference table like the school module's lookups, not a person table.
6.2 SectionsService
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
findAll(query) | SectionsController.findAll | sections, filtered by search/isActive | — | — | — |
create(dto) | SectionsController.create | Name-uniqueness pre-check (active-only) | sections insert, inside a transaction | — | SECTION_NAME_TAKEN (409, pre-check); a race surfaces as RESOURCE_ALREADY_EXISTS from the global fallback. |
update(publicId, dto) | SectionsController.update | Lookup by publicId; name pre-check only if the name actually changed case-insensitively | sections update | — | SECTION_NOT_FOUND (404); SECTION_NAME_TAKEN (409). |
remove(publicId) | SectionsController.remove | Lookup by publicId | sections delete | — | SECTION_NOT_FOUND (404); SECTION_IN_USE (409, from the classes_section_id_sections_id_fk FK, translated). |
sortOrder on create. When omitted, computed inside the same transaction as the insert as COALESCE(max(sort_order), -1) + 1. The COALESCE is load-bearing: max() over an empty table is NULL, and NULL + 1 into a NOT NULL column is a 23502 on a school's very first section without it.
Validation order. Existence before uniqueness before write, matching every other lookup service in this codebase. A rename that only changes casing is accepted as a no-op, not rejected as a self-clash — checked via nextName.toLowerCase() !== existing.name.toLowerCase().
Hard delete, guarded by an explicit ON DELETE restrict. classes_section_id_sections_id_fk genuinely rejects a delete while a class points at the section; translate() maps that 23503 onto SECTION_IN_USE by matching the constraint name walked from the driver error's .cause chain (Drizzle's wrapper does not itself carry .constraint).
No caching. Unlike the school module's LookupsService, no method here calls RedisCacheService — every read is live.
6.3 RoomsService
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
findAll(query) | RoomsController.findAll | rooms, filtered by search/isActive/building/floor; then RoomOccupancyService.classesByRoom/.assetCountByRoom, ONE batch call each across the whole page | — | Redis, academic-structure:rooms:list:*, 1h TTL — see 8 | — |
create(dto) | RoomsController.create | (building, roomNumber) pre-check (active-only) | rooms insert | Cache invalidated (academic-structure:rooms:*) | ROOM_NUMBER_TAKEN (409, pre-check). |
update(publicId, dto) | RoomsController.update | Lookup; pre-check only when the pair actually changes and the result stays/becomes active | rooms update | Cache invalidated | ROOM_NOT_FOUND (404); ROOM_NUMBER_TAKEN (409). |
remove(publicId) | RoomsController.remove | Lookup; explicit classes.room_id existence check; explicit assets.room_id existence check | rooms delete | Cache invalidated | ROOM_NOT_FOUND (404); ROOM_IN_USE (409, from either explicit check, not a caught FK). |
Why ROOM_IN_USE cannot come from a caught 23503, and this is the one delete guard in the module that differs structurally from the others. classes.room_id is ON DELETE set null — deleting a room a class points at succeeds at the database and silently blanks the class's room instead of raising a foreign-key violation. The refusal here is therefore an explicit SELECT ... FROM classes WHERE room_id = $1 LIMIT 1 pre-check, run before the DELETE, not a translated exception. translate() still matches classes_room_id_rooms_id_fk defensively in case the schema ever tightens the FK to restrict, but that branch is not reachable today — verified by reading the schema's actual onDelete: "set null". The same shape guards a room still holding an asset (assets.room_id).
Search matches three fields at once. roomNumber, building, and name are all ILIKE-searched together via or(...), unlike sections/grades which search only name.
classes and assetCount are computed, never stored and never cached. findAll loads both through RoomOccupancyService for every row on the current page — one query for classes, one for assets, never one per room — on every call, including a cache hit. create/update call the same service for the single row they return. See 8 for why the occupancy is kept out of the cache.
RoomOccupancyService is injected through Nest's DI container (AcademicStructureModule provides and exports it). Tests that construct RoomsService or AssetRoomsLookupController directly pass an instance built on the same db.
6.4 ClassesReadService
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
findAll(query) | ClassesController.findAll | classes joined to session/grade/section/room/teacher; ClassOccupancyService.countForMany | — | — | PAGINATION_LIMIT_INVALID (400) if pagination=false. |
findOptions(query) | ClassesController.findOptions | classes (active only) joined to grade/section; ClassOccupancyService.countForMany | — | — | — |
findOne(publicId) | ClassesController.findOne | Delegates to loadDto on the pooled Database | — | — | CLASS_NOT_FOUND (404). |
loadDto(executor, publicId) | findOne; ClassesWriteService after every write | Same joins as findAll, single row; ClassOccupancyService.countFor | — | — | CLASS_NOT_FOUND (404). |
resolveSessionId(requested) (private) | findAll, findOptions | academic_sessions.isCurrent when no id is given | — | — | — |
selectRows(executor) / buildWhere(query, sessionId) / toDto(row, count) (private) | Internal | — | — | — | — |
loadDto is public, not private, and that is the whole reason ClassesWriteService never opens a second connection to shape its own response. It accepts a DbExecutor — either the pooled Database or an open transaction — so ClassesWriteService.create/.update can pass their own tx and observe the just-written, not-yet-committed row rather than racing a second pooled connection against the open transaction (the exact bug class this repo's DbExecutor docblock names as having shipped once, in blog-post-media.service.ts).
pagination=false is refused on findAll, but not on findOptions. The full list embeds the class teacher's full name and employee code — staff identity a caller holding only Classes_READ should not receive unbounded. findOptions carries no such field (no room, no teacher) and is unpaginated by construction, capped at UNPAGINATED_HARD_CAP + 1 rows fetched (one over, to detect truncation with no second COUNT(*)) then sliced back to the cap.
Session resolution returns null, not an exception, when no session is current. resolveSessionId returns undefined-means-"use current" / null-means-"none is current" to its two callers, both of which return an empty result rather than throw — nothing in the schema requires exactly one is_current = true row to exist at any given moment.
Fixed ordering, sort/order unused. Every list is ordered grade.sortOrder, section.sortOrder, shift, id — ListClassesQueryDto inherits sort/order from QueryDto, but findAll's orderBy clause never reads either field.
search reaches beyond the class's own (nullable) name. Because classes.name can be NULL, matching only it would make every unnamed class unsearchable — the search also matches grades.name, grades.code, and sections.name.
6.5 ClassesWriteService
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
create(dto) | ClassesController.create | Session/grade/section existence+active; room/teacher resolution if given | classes insert, inside a transaction | — | ACADEMIC_SESSION_NOT_FOUND/GRADE_NOT_FOUND/SECTION_NOT_FOUND/ROOM_NOT_FOUND/CLASS_TEACHER_NOT_FOUND (404); CLASS_SESSION_INACTIVE/CLASS_GRADE_INACTIVE/CLASS_SECTION_INACTIVE/CLASS_ROOM_INACTIVE/CLASS_TEACHER_NOT_TEACHING (422); CLASS_IDENTITY_TAKEN/CLASS_ROOM_OCCUPIED/CLASS_TEACHER_ALREADY_ASSIGNED/CLASS_CAPACITY_INVALID/CLASS_NAME_INVALID (409, translated). |
update(publicId, dto) | ClassesController.update | Locked row read (FOR UPDATE); ClassOccupancyService.countFor if capacity changes; room/teacher re-resolution only if the value actually changes | classes update, inside a transaction | — | CLASS_NOT_FOUND (404); CLASS_CAPACITY_BELOW_ENROLLED (422); ROOM_NOT_FOUND/CLASS_TEACHER_NOT_FOUND (404, only if changing); CLASS_ROOM_INACTIVE/CLASS_TEACHER_NOT_TEACHING (422, only if changing); same translated 409s as create. |
remove(publicId) | ClassesController.remove | Lookup by publicId | classes delete | — | CLASS_NOT_FOUND (404); CLASS_HAS_ENROLLMENTS (409, from the composite FK, translated). |
findSessionOrThrow/findGradeOrThrow/findSectionOrThrow/resolveRoom/resolveTeacher (private) | create/update | Single-row lookups | — | — | See above. |
assertActive/assertTeacherUsable (private, static) | create/update | — | — | — | CLASS_*_INACTIVE; CLASS_TEACHER_NOT_FOUND/CLASS_TEACHER_NOT_TEACHING. |
translate(error) (private, static) | Every write, via .catch() | — | — | — | Maps every unique index and CHECK from migration 0011 onto a named code. |
Why validation and conflict mapping are split. "Not found"/"retired" is read before the write, because no database constraint can turn a bad gradeId into anything better than an unmapped foreign-key violation — a 500. "Already taken" (identity, room, teacher) is left to the database's own unique indexes and translated after the fact, which is what actually makes the check atomic under a race — a service-level pre-check-then-insert without a transaction has the identical TOCTOU gap the school module's LookupsService documents for department/designation names.
Code flow — create(), precisely. 1) Resolve and active-check the session, then the grade, then the section — each is a distinct round trip, in that order, because a bad session should fail before a bad grade is even looked up. 2) If roomId is given, resolve it and active-check it, but only if it is being newly set — this branch has no "unchanged" exception because create has no prior state. 3) If classTeacherId is given, resolve the staff row (joined to its designation for isTeaching), and assert it is neither soft-deleted nor non-teaching. 4) Insert inside a transaction, catching any unique-index violation into translate(). 5) Reload the fresh row via ClassesReadService.loadDto rather than constructing the response by hand, so the response always reflects exactly what the database now holds (including enrolledCount: 0, computed the same way every other read computes it, not hardcoded).
Code flow — update(), precisely. The entire method runs inside one transaction. 1) SELECT ... FOR UPDATE on the target row — this lock is what makes the capacity/occupancy check mean anything: without it, one operator could lower capacity while another enrols the pupil that pushes the class over it, with no override ever recorded, because the two operations would race past each other's read. 2) name/isActive are applied unconditionally if present. 3) If capacity changed, ClassOccupancyService.countFor runs inside the same lock, and the write is refused unless the new capacity covers the live roll or allowOverCapacity is set. 4) roomId/classTeacherId, if present, are resolved, but the retired-active check fires only when the resolved id differs from what is already stored — re-sending the class's own current room/teacher is always accepted, even if that room/teacher has since been retired or lost its teaching flag, because otherwise a class holding a now-invalid resource could never be edited again, including to deactivate it. 5) The patch is applied and loadDto reloads the fresh row on the same tx, so the response reflects the just-committed (within the still-open transaction) state.
remove() has no explicit pre-check, unlike rooms. student_class_enrollments_class_session_fk is ON DELETE restrict (not set null), so an actual foreign-key violation is reachable and correctly reflects "this class has enrolments" — no explicit existence-check query is needed before the DELETE, only a translate() branch on the constraint name.
Transactions. create and update both wrap their reads-then-writes in db.transaction(...); remove does not need one, since its only guard is the database's own FK.
6.6 ClassRosterService
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
findByClass(actor, classPublicId, query) | ClassEnrollmentsController.roll | classes (existence); PeoplePermissionsService.heldPermissions; PeopleAccessService.scopeFor; student_class_enrollments joined to students/users, scoped | — | — | CLASS_NOT_FOUND (404). |
The permission is a precondition, PeopleAccessService.scopeFor is the control. Students_READ alone does not decide who may read a roster — scope.kind (defaulting to self per role) is resolved and ANDed into the query's WHERE exactly as StudentsService.findAll applies it. The service's own docblock states this is not a theoretical concern: an operator who builds a narrow-scoped role and grants it Students_READ would otherwise get the correct (empty-or-narrow) result from GET /students but the entire class roll from this route if the scope were skipped here — an incident this repo's own history records twice.
The row filters match the occupancy count, deliberately. students.deletedAt IS NULL AND students.recordStatus = 'active' — the exact same pair ClassOccupancyService applies. Without this alignment, a class's roster and its capacity figure on the same screen could disagree in a way nobody could explain from the UI.
Fixed ordering, unused sort/order/search. Ordered students.studentId, students.id always; ListRosterQueryDto inherits sort/order/search from QueryDto but none is read.
6.7 ClassEnrollmentsService
Marked SPLIT-EXEMPT in its own file header: five methods — enroll, withdraw, updateEnrollment, removeEnrollment, listForStudent — sharing one lock discipline, one constraint-translation table, and one cache-invalidation path, deliberately kept in one file rather than scattered across single-import modules.
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
enroll(tx, input, actor) | ClassEnrollmentsController.enrol | Class context (session dates, capacity, shift, grade/section names); students (deletedAt, admissionDate); the pupil's active row in this session, if any; both classes locked FOR UPDATE in ascending id order; ClassOccupancyService.countFor under that lock | student_class_enrollments insert; predecessor UPDATE on a transfer | RedisCacheService.delPatternSoft("students:list:*"); ActivityRecordService.recordActivity (ENROLL or TRANSFER) | CLASS_NOT_FOUND/STUDENT_NOT_FOUND (404); CLASS_INACTIVE/ENROLLMENT_STUDENT_DELETED/ENROLLMENT_DATE_OUTSIDE_SESSION/ENROLLMENT_DATE_BEFORE_ADMISSION/ENROLLMENT_DATE_INVALID/CLASS_AT_CAPACITY (409); plus translate()'s constraint-mapped codes on a race. |
withdraw(tx, classPublicId, studentId, actor) | ClassEnrollmentsController.withdraw | Class context; class locked FOR UPDATE; the pupil's active row in this class | student_class_enrollments update (status, endedOn) | Same cache + activity pattern (WITHDRAW) | CLASS_NOT_FOUND/ENROLLMENT_NOT_FOUND (404/404). |
updateEnrollment(tx, id, dto, actor) | ClassEnrollmentsController.update | The row; its owning class, locked FOR UPDATE | student_class_enrollments update | Same cache + activity pattern (ENROLLMENT_UPDATE) | ENROLLMENT_NOT_FOUND (404). |
removeEnrollment(tx, id, actor) | ClassEnrollmentsController.remove | The row; its owning class, locked FOR UPDATE | student_class_enrollments delete | Same cache + activity pattern (ENROLLMENT_DELETE) | ENROLLMENT_NOT_FOUND (404). |
listForStudent(studentId, query) | StudentsController.findEnrollments | student_class_enrollments joined to classes/grades/sections/academic_sessions | — | — | — |
resolveClassContext(tx, input) (private) | Every write | One row, by classId or classPublicId | — | — | Returns null on no match; callers throw CLASS_NOT_FOUND. |
invalidateStudentsCache() (private) | Every write | — | — | delPatternSoft | Never throws — fail-soft. |
translate(error) (private, static) | Every write, via .catch() | — | — | — | Maps every constraint from migration 0011 this service can hit onto a named code. |
Deliberately no authorization — see 3. Every method trusts its caller to have already resolved assertCanAccess; the controller does this before calling in, and StudentsService's own update path does the same on a separate route this document does not otherwise cover.
Code flow — enroll(), in full. 1) Resolve the target class's context — session id, dates, capacity, shift, and the grade/section names the eventual capacity-error message will quote. A missing class is 404 CLASS_NOT_FOUND; a retired one (checked here, on the unlocked read) is 409 CLASS_INACTIVE. 2) Read the pupil's admissionDate/deletedAt; a soft-deleted pupil is refused with ENROLLMENT_STUDENT_DELETED — restoring the record is the only path forward. 3) Resolve enrolledOn — input.enrolledOn, or today in Asia/Kathmandu via getCurrentNepalDateString() — and validate it against both the class's session date range and the pupil's own admission date; either failure is a distinct, named 409. 4) Read the pupil's current active row in this session, in any class — at most one can exist, guaranteed by the partial unique index. If it is already this class, return it unchanged (idempotent re-submission). 5) If it is a different class, this is a transfer; a back-dated enrolledOn earlier than the predecessor's own is refused as ENROLLMENT_DATE_INVALID before it reaches Postgres, since closing the predecessor with an earlier endedOn would otherwise violate ..._dates_ordered as an unmapped 500 on the single most common flow in the module. 6) Lock both classes (or just the target, if this is a fresh enrolment) in ascending id order — this is what makes a simultaneous X→Y / Y→X cross-transfer deadlock-free regardless of which request calls which class the "source." 7) Re-check the target's isActive after the lock is granted, not trusted from the unlocked read in step 1 — a concurrent deactivation between the two reads must still be caught. 8) Recompute occupancy under the lock via ClassOccupancyService.countFor, and refuse with CLASS_AT_CAPACITY (numbers named in the message text) unless there is room or allowOverCapacity is set. 9) Inside a try, close the predecessor (if any) and insert the new row; any constraint violation reaching the catch goes through translate(). 10) Invalidate the students-list cache. 11) Call recordActivity with the module Students, the action ENROLL or TRANSFER, the pupil's id as resourceId, a changes entry naming the old/new class, and metadata carrying capacity/enrolledBefore/allowOverCapacity — the audit interceptor sees only the actor/module/action/status of the HTTP request, never this level of detail.
Code flow — withdraw(). Resolves the class, locks it, finds the pupil's active row in it (a 404 ENROLLMENT_NOT_FOUND if none), and closes it with endedOn = max(enrolledOn, today) — never plain "today," because a future-dated active enrolment withdrawn on its own start date would otherwise violate the dates-ordered CHECK with a naive "today."
Code flow — updateEnrollment(). Locks the enrolment's owning class (for the same reason every other write does — serializing against a concurrent capacity edit or a concurrent enrol/transfer touching the same class), then applies enrolledOn/status independently. If status is set and the row was active, endedOn is computed the same max(enrolledOn, today) way as withdraw; otherwise, if the resulting endedOn would precede the resulting enrolledOn, it is pulled forward to match rather than left to violate the CHECK.
Code flow — removeEnrollment(). Locks the owning class, then hard-deletes the row — no status transition, no trace left in the transferred/withdrawn counters. Exists because offering only withdraw would force an operator to record a fictional departure for a pupil enrolled into the wrong class by mistake.
listForStudent() has no authorization and no lock — it is a plain read. Ordered enrolledOn DESC, id DESC always (tie-broken on id for stable pagination); query.sort/order/search (all inherited from QueryDto) are never read. Mapped through toEnrollmentDto in the separate mapper file — split out to keep this service under the repo's per-file line-count convention, not for any behavioral reason.
Cache invalidation is coarse and deliberate. Every write clears the entire students:list:* prefix, not a scoped subset — a student-list row shows "current class," and StudentsService itself clears the same prefix on every student write; an enrolment written from this service bypasses that path entirely and must invalidate the identical keys or the list would serve a stale class for the remainder of the TTL.
6.8 ClassOccupancyService
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
countFor(tx, classId) | ClassesReadService.loadDto; ClassesWriteService.update (capacity check); ClassEnrollmentsService.enroll (capacity check) | Delegates to countForMany with a single-element array | — | — | — |
countForMany(tx, classIds) | ClassesReadService.findAll/findOptions; ClassRosterService shares its filter, not this method | One grouped COUNT(*) ... GROUP BY class_id over student_class_enrollments joined to students, filtered status = 'active' AND students.deletedAt IS NULL AND students.recordStatus = 'active' | — | — | — |
The one shared definition, used everywhere a seat is counted. Both filters on students are required and neither is obvious: deletedAt IS NULL exists because students soft-deletes and ON DELETE cascade never fires for a soft delete, so a deleted pupil would otherwise keep inflating every class's count forever; recordStatus = 'active' exists because a pupil marked inactive (a distinct enum from soft-delete) is not attending, and counting them holds a seat nobody occupies.
countForMany is the one grouped-query path, on purpose. A class absent from the grouped result (because it has zero live enrolments) is pre-seeded into the returned map with 0 before the query even runs, so a missing key reads 0, never undefined — every caller can safely .get(id) ?? 0 without that fallback ever actually firing on a class with real pupils. A loop calling countFor once per row would be an N+1 that no gate in this repo would catch, because every individual call would itself pass.
6.9 RollNumberService
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
policy(executor?) | assignOnEnroll; renumber (when no explicit strategy is given) | school_profile row 1 (rollNumberStrategy, rollNumberAutoRenumber) | — | — | Never throws — defaults to { strategy: "admission_order", autoRenumber: false } when no profile row exists. |
assignOnEnroll(executor, enrollmentId, classId) | ClassEnrollmentsService.enroll, inside its own transaction, immediately after the insert | policy | Delegates to renumber or assignNext | — | Whatever the delegate throws. |
assignNext(executor, enrollmentId, classId, attempt?) (private) | assignOnEnroll | nextFreeNumber | UPDATE studentClassEnrollments SET rollNumber | — | Retries itself once on 23505 against the roll index; rethrows on a second collision. |
nextFreeNumber(executor, classId) (private) | assignNext | coalesce(max(roll_number), 0) + 1 over the class's live roll | — | — | — |
renumber(executor, classId, strategy?) | assignOnEnroll (auto-renumber on); ClassEnrollmentsController.renumber | The class's live roll — studentClassEnrollments joined to students/users, filtered status = 'active' AND students.deletedAt IS NULL AND students.recordStatus = 'active' | Clears every live row's roll_number to null, then writes 1..n in the resolved order | — | Whatever a constraint violation on the second phase raises — unmapped, since this path runs inside the caller's transaction and is not wrapped in its own try/catch. |
resolveClassId(classPublicId, executor?) | ClassEnrollmentsController.renumber | classes (by publicId) | — | — | CLASS_NOT_FOUND (UnprocessableEntityException, not NotFoundException — a 422, not a 404; verified at roll-number.service.ts:249-254). |
liveStudentIds(classId, executor?) | ClassEnrollmentsController.renumber, before the write | studentClassEnrollments joined to students, same live-roll filter as renumber | — | — | — |
setManual(executor, enrollmentId, rollNumber) | ClassEnrollmentsController.setRollNumber, inside a transaction | The enrolment's id/status | UPDATE studentClassEnrollments SET rollNumber | — | ROLL_NUMBER_INVALID (422) below 1; ROLL_NUMBER_ENROLLMENT_INACTIVE (422 if the enrolment does not exist at all, 409 if it exists but is closed — two different HTTP statuses under one error code, verified at roll-number.service.ts:316-330); ROLL_NUMBER_TAKEN (409) on a collision. |
isUniqueViolation(error) (private, module-level function) | assignNext, setManual | — | — | — | Narrows on the constraint NAME via pgErrorText, not the bare SQLSTATE — student_class_enrollments carries a second unique index (one active enrolment per pupil per session) that also raises 23505 and must not be misread as a roll collision. |
Assignment appends; it never renumbers, unless the school has asked it to. assignOnEnroll reads the school's policy and branches: autoRenumber: true resequences the whole class by strategy on every admission; the default (false) hands the new pupil max(roll_number) + 1 among the class's live roll and moves nobody else. The service's own docblock states the reason for the default: a roll number has already been read out in class, written on exercise books, and recorded against attendance history by the time the next pupil joins, so treating alphabetical as a continuously-maintained order would shift every later pupil's number on every mid-year admission.
The unique index is the concurrency control, not an advisory lock. Two admissions into one class at the same instant can both read the same nextFreeNumber. assignNext does not take a SELECT ... FOR UPDATE on the class or an advisory lock — the class-roll unique index refuses the loser's UPDATE with 23505, and assignNext retries exactly once against a freshly re-read maximum, which terminates because the retry's read sees the row the first transaction committed. A class-wide lock would serialize every admission in the school for a counter this cheap.
renumber writes in two phases because the unique index is checked per row, not at statement end. Writing new numbers directly into the existing roll collides with the class's own current numbering — giving roll 1 to the pupil who currently holds 2 violates student_class_enrollments_class_roll_unique against whoever still holds 1, since a plain unique index cannot be deferred to statement end. Phase one clears every live row's roll_number to null; phase two writes 1..n in the resolved order. Both phases run inside the caller's transaction — the enrolment transaction for an auto-renumber, or the explicit transaction ClassEnrollmentsController.renumber opens — so a failure between the phases never leaves a class permanently unnumbered.
renumber's sort order, precisely. alphabetical compares firstName, then middleName, then lastName, then studentId last as a stable tiebreak for two identically-named pupils — localeCompare with sensitivity: "base". admission_order compares admissionNumber then studentId. Either order excludes a soft-deleted or record-inactive pupil from the roll entirely — the same predicate ClassOccupancyService and the roster apply — so a departed child never consumes a position in the renumbered sequence.
A collision on setManual answers a typed 409, not a 500. Drizzle wraps the driver error, so neither .code nor .constraint sits at the top level of what a catch receives — isUniqueViolation walks .cause via pgErrorCode/pgErrorText (apps/api/src/common/utils/pg-constraint.util.ts) to find the SQLSTATE and the constraint name at whatever depth the driver buried them, capped at five levels as a cycle guard. The service's own docblock notes this was caught by an integration test, not by inspection — the happy path is unaffected by the bug the first version of this function had, which read only the top level and turned a genuine roll-number collision into an unmapped 500.
resolveClassId and setManual's missing-enrolment branch both throw UnprocessableEntityException, not NotFoundException — so both surface as 422, not 404, despite naming an error code (CLASS_NOT_FOUND, ROLL_NUMBER_ENROLLMENT_INACTIVE) that reads like a not-found. Verified at roll-number.service.ts:249-254 and :316-321 respectively — this is the code's actual behavior, not a documentation simplification.
6.10 RoomOccupancyService
The one service two controllers in two different modules call for the same three facts about a room: which classes sit in it, which buildings exist, and how many assets it holds. Before this service existed, AssetRoomsLookupController (assets module) carried its own copy of the class-batch query; this service is that query moved to the ONE place both callers reach, so a future change to the label rule or the ordering only has one implementation to edit.
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
classesByRoom(roomIds, executor?) | RoomsService.findAll/.withOccupancy; AssetRoomsLookupController.list | classes joined to grades, sections, academic_sessions, filtered classes.isActive = true AND academic_sessions.isCurrent = true, ordered by grades.sortOrder then sections.name | — | — | Never throws — an empty roomIds array short-circuits to an empty Map before any query runs. |
buildings(executor?) | RoomsController.buildings; AssetRoomsLookupController.buildings | SELECT DISTINCT building FROM rooms WHERE is_active = true, ordered by lower(building) | — | — | — |
assetCountByRoom(roomIds, executor?) | RoomsService.findAll/.withOccupancy | assets, filtered room_id IN (...) AND deleted_at IS NULL AND status <> 'disposed', grouped by room_id | — | — | — |
Every requested id is guaranteed a key in the returned Map, even with no matches. classesByRoom/assetCountByRoom seed the map with every id in roomIds ([]/0 respectively) before running a single query, so a caller never writes map.get(id) ?? fallback defensively — the fallback is already the value for a room with nothing. This matters because RoomsService.findAll/.withOccupancy and AssetRoomsLookupController.list both build their response by mapping over rows and reading straight out of these maps.
classesByRoom's label rule. A class's own name, when set and non-blank, is the label; otherwise "<grade name> — <section name> — <Morning|Day>", the same words the Classes screen uses, so a double-shift school's two classes sharing a room are told apart. The asset room lookup (GET /api/asset-catalog/rooms) calls this same method, so a room's classes read identically in both places.
executor defaults to the injected pooled Database, never this.db called from inside a caller's transaction. Every method takes executor?: DbExecutor as its last parameter and resolves executor ?? this.db internally, so a caller already inside a db.transaction(async (tx) => ...) block passes tx and stays on one connection; a caller outside a transaction omits it and gets the pooled connection.
Two controllers construct this service two different ways, and both are deliberate. RoomsController/RoomsService receive it through ordinary Nest DI, because AcademicStructureModule provides and exports it. AssetRoomsLookupController also receives it through DI in the running app (AssetCatalogModule imports AcademicStructureModule for exactly this), but both RoomsService and AssetRoomsLookupController declare their RoomOccupancyService constructor parameter with a default value — roomOccupancy: RoomOccupancyService = new RoomOccupancyService(db) — so the handful of tests written before this service existed, which construct RoomsService/AssetRoomsLookupController directly with new and only the arguments they already knew about, keep compiling and keep passing: the default only fires when the argument is undefined, which is exactly the direct-construction case and never the DI case.
No import cycle. AssetCatalogModule importing AcademicStructureModule is the only edge between the two module trees, and it runs one direction only — AcademicStructureModule's own imports (EnrollmentsCoreModule, PeopleModule, RoleModule, ActivityModule) touch nothing under the assets module, so there is no path back.
7. Runtime Flows
7.1 List classes (session resolution, filters, live occupancy)
| Step | Code Path | Behavior | Failure Case |
|---|---|---|---|
| 1 | ClassesController.findAll | Receives the request, applies no logic of its own. | DTO validation error (400). |
| 2 | ClassesReadService.findAll | Refuses pagination=false. | 400 PAGINATION_LIMIT_INVALID. |
| 3 | resolveSessionId | Resolves the explicit or current session. | Returns null, handled as empty result, never thrown. |
| 4 | selectRows/buildWhere | Builds the filtered, joined query. | — |
| 5 | ClassOccupancyService.countForMany | One grouped query for every returned class id. | — |
7.2 Create a class
| Step | Code Path | Behavior | Failure Case |
|---|---|---|---|
| 1-3 | findSessionOrThrow/findGradeOrThrow/findSectionOrThrow | Existence and active checks, in that order. | 404/422 per entity. |
| 4 | resolveRoom | Existence + active check, only if roomId given. | 404 ROOM_NOT_FOUND/422 CLASS_ROOM_INACTIVE. |
| 5 | resolveTeacher + assertTeacherUsable | Existence, not-soft-deleted, isTeaching. | 404 CLASS_TEACHER_NOT_FOUND/422 CLASS_TEACHER_NOT_TEACHING. |
| 6 | db.transaction(...) | Single-statement insert. | Constraint violation, caught by .catch(translate). |
| 7 | ClassesReadService.loadDto | Fresh, fully-joined read of the new row. | — |
7.3 Update a class (capacity guard, locked)
7.4 Enrol or transfer a pupil (the module's most complex flow)
7.5 Withdraw a pupil
7.6 Set or clear one pupil's roll number
| Step | Code Path | Behavior | Failure Case |
|---|---|---|---|
| 1 | ClassEnrollmentsController.pupilForEnrollment | Resolves the pupil so the scope check has a target — a missing row is 404 before any scope check, indistinguishable from an out-of-scope row. | 404 ENROLLMENT_NOT_FOUND. |
| 2 | assertPupilInScope | Object-level access check, unchanged from every other enrolment write. | 404 on an out-of-scope pupil. |
| 3 | RollNumberService.setManual | Validates the number is >= 1 if not null (DTO-level @Min(1) already narrows this, but the service re-checks). | 422 ROLL_NUMBER_INVALID. |
| 4 | Enrolment lookup | Missing row vs. a closed row are two different HTTP statuses under the same error code. | 422 (missing) / 409 (closed) ROLL_NUMBER_ENROLLMENT_INACTIVE. |
| 5 | UPDATE ... SET roll_number | The write, inside a transaction the controller opens. | 409 ROLL_NUMBER_TAKEN on a collision, read off .cause. |
Shuffles nobody else, and unnumbering is a legitimate state. This route moves only the one enrolment named in the path — rollNumber: null clears it, and the pupil then sorts last on the roster (ClassRosterService's NULLS LAST order) until an administrator runs a renumber. An administrator who wants the whole class resequenced uses 7.7, never this route.
7.7 Renumber a class's roll
| Step | Code Path | Behavior | Failure Case |
|---|---|---|---|
| 1 | RollNumberService.resolveClassId | The only entity lookup on this route — publicId to the internal integer id. | 422 CLASS_NOT_FOUND (UnprocessableEntityException, not a 404 — see 6.9). |
| 2-3 | liveStudentIds + a scope-check loop | Every live pupil's scope is asserted before the write begins — a class-wide write must not be reachable for a caller who cannot see one of the children in it, and there is no class-level scope to substitute. | 404 on the first out-of-scope pupil found; nothing has been written yet. |
| 4 | db.transaction(...) | Opens the transaction the two-phase write runs inside. | — |
| 5 | renumber(tx, classId) | Clears, then reassigns, in the school's configured strategy (or an explicit override, though this route never passes one). | An unmapped error on the second phase's write is a 500 — see 6.9. |
The only operation that moves an existing pupil's number. Every other write in this module either appends (assignOnEnroll with auto-renumber off) or edits one row in isolation (7.6). This route is the deliberately explicit act of resequencing a whole class — the service's own docblock states why it is not automatic: a roll number has already been read out in class and recorded against attendance history by the time a resequence would otherwise fire.
Soft-deleted and record-inactive pupils are excluded, so a departed child never consumes a position. renumber's own SELECT carries the identical students.deletedAt IS NULL AND students.recordStatus = 'active' predicate ClassOccupancyService and the roster both apply — verified at roll-number.service.ts:184-191.
Include, for every enrolment flow above: success path, validation failure, permission failure (guard, before the controller runs), missing-entity, duplicate-request (idempotent re-submission for enroll), race/concurrency behavior (ascending-lock-order for cross-transfers; the partial unique index for concurrent fresh enrolments, and for concurrent roll-number assignment), and the guest-vs-logged-in branch (not applicable — no route in this module accepts an unauthenticated caller).
8. Caching
| Cache Key Pattern | Builder | Value | TTL | Invalidation | Caller |
|---|---|---|---|---|---|
academic-structure:rooms:list:* | CacheKeyUtil.build, keyed on every findAll filter/page param | The room rows and totalCount only — never classes or assetCount | 1 hour (RoomsService.CACHE_TTL_SECONDS) | Whole prefix cleared (delPatternSoft) on create/update/remove | RoomsService.findAll/.invalidate |
students:list:* | STUDENTS_CACHE_PREFIX constant, cleared with delPatternSoft | N/A — this module never reads or writes the cached value itself | Owned by StudentsService, not this module | Cleared unconditionally on every enrolment write (enroll, withdraw, updateEnrollment, removeEnrollment) | ClassEnrollmentsService.invalidateStudentsCache |
RoomsService.findAll is cache-aside; every other list in this module reads the database live. GradesService, SectionsService, ClassesReadService, and ClassRosterService inject no RedisCacheService and answer every findAll/findOne/findOptions straight from Postgres, on every request — grades/sections are edited rarely enough that caching would buy little, and the class list and roster must reflect a live, second-by-second occupancy figure that caching would immediately stale. RoomsService is the one exception: its list is keyed by every filter/page combination, so a ?building= filter caller and an unfiltered caller never collide on one key, and any write to rooms clears the entire prefix rather than trying to invalidate one key.
Only the room rows are cached; occupancy is read on every call. Which classes use a room and how many assets sit in it change through writes to classes and assets in other modules, which cannot know this cache key's shape. So findAll caches the room rows and totalCount alone, and on every call — cache hit or miss — adds classes and assetCount through RoomOccupancyService in one batch query each. The room's own fields (number, floor, building, isActive) are served from the cache and stay exact, because every write to them goes through RoomsService and clears the whole prefix.
ClassEnrollmentsService's students:list invalidation is the module's other cache interaction, and it is entirely one-directional. It injects RedisCacheService for exactly one reason: an enrolment changes what a students:list row shows for "current class," and StudentsService clears that same prefix on its own writes — an enrolment written from this module bypasses StudentsService entirely and must invalidate the identical keys, or the student list would serve a stale class for the remainder of whatever TTL StudentsService set.
9. BullMQ, Schedulers, and Async Work
Not applicable. No queue, job, or scheduler touches any table in this module — verified by the absence of any BullMQ import across every file listed in 4.
10. Realtime and Events
Not applicable. No service in this module emits a Socket.IO event or any other realtime signal.
11. Security, Auth, and Abuse Controls
- Guards. Every controller carries
@UseGuards(JwtAuthGuard, RoleGuard)at the class level (grades.controller.ts,sections.controller.ts,rooms.controller.ts,classes.controller.ts,class-enrollments.controller.ts) — no@Public()route exists anywhere in this module. - Every handler declares a permission.
RoleGuard's fail-open branch (a handler with zero@Permissions()on a non-administrative controller) never applies here — every handler in every controller declares one, androute-permissions.spec.ts(referenced in each controller's own class comment) asserts the same in CI. - Two distinct permission surfaces meet in this module.
Classes_*/Sections_*/Rooms_*gate the class-setup screens;Students_READ/Students_UPDATEgate every enrolment route, including the class-side roster and enrol/withdraw routes declared onClassEnrollmentsController— and both roll-number routes (PATCH /enrollments/:id/roll-number,POST /classes/:publicId/enrollments/renumber) are gated onStudents_UPDATE, the same code as every other enrolment write, not a separate roll-number permission. A caller holding fullClasses_*grants but noStudents_*grants can see and edit every class, but cannot touch a single enrolment, a single roll number, or read a roster. - Grades has no permission module of its own.
GET /api/gradesis gated byClasses_READ— verified againstPERMISSION_MODULESinpackages/db/src/authorization/permission-catalog.ts, which listsClasses,Sections,Roomsbut notGrades. - The object-level access boundary — the actual control behind
Students_*.PeopleAccessService.scopeFor/applyScope(read paths: the roster, a pupil's enrolment history) andPeopleAccessService.assertCanAccess(write paths: enrol, withdraw, correct, remove, set/clear a roll number) are called explicitly byClassRosterServiceandClassEnrollmentsController— never byClassEnrollmentsServiceorRollNumberService, neither of which can reachPeopleModulewithout recreating the module cycle documented in 3.assertCanAccessthrows404, never403, on an out-of-scope pupil — deliberately, since a403on a specific id would confirm that id exists, turning the pupil id space into an enumeration oracle for any caller who can guess or iterate ids. - A class-wide write has no class-level scope to check, so
POST /classes/:publicId/enrollments/renumberchecks every pupil individually, before writing.ClassEnrollmentsController.renumbercallsRollNumberService.liveStudentIdsand runsassertPupilInScopein a loop over every one of them before opening the transaction that callsRollNumberService.renumber— a caller who may not see even one child on that roll must not be able to move that child's number, and there is no coarser "may this caller touch this class's roster" check that would substitute.PATCH /enrollments/:id/roll-numberneeds no such loop; it names exactly one pupil, resolved the same way every other single-pupil enrolment write is. - Permission source.
Classes,Sections,Roomsare each declared once inPERMISSION_MODULES; the full catalog is the cartesian product of every module and every action inPERMISSION_ACTIONS(CREATE,READ,UPDATE,DELETE,RESTORE).Classes_RESTORE,Sections_RESTORE, andRooms_RESTOREall exist as valid, grantable, seeded permission codes — but no route in this module ever checks any of them, since none ofsections/rooms/classeshasdeleted_atand every deletion is a hard delete. - Superadmin bypass. Keyed on the
is_superadminboolean, never on a role's display name, checked before the permission list and before the object-level scope check — a superadmin'sassertCanAccesscall always succeeds regardless of scope. - Active-role scoping. Permissions and scope both resolve from the caller's currently-active role only, never the union of every role held.
- Deploy-time grant gap.
pnpm --filter @skoolsewa/api permissions:synccreates theClasses_*/Sections_*/Rooms_*permission rows on a deploy, but its own role-selection predicate grants them only to roles named"superadmin"or"admin"— verified:sync-permissions.ts:90selectsrole.name IN ("superadmin", "admin"), and this product seeds no role literally named"admin"(verified: noname: "admin"insert exists inseed-auth.ts).pnpm --filter @skoolsewa/db db:seed:prodis the command that actually applies theSTAFF_PERMISSIONS/TEACHER_PERMISSIONSgrants (Classes_READ,Sections_READ,Rooms_READ) an operator running onlypermissions:syncafter adding this module would not otherwise see land. - Input normalization. Every writable string field (
sections.name,rooms.roomNumber/building/name,classes.name) is validated by aMatches(NO_SURROUNDING_WHITESPACE)DTO rule mirroring the schema's own POSIX-class CHECK, and trimmed server-side before comparison or persistence.ILIKEsearch terms are always escaped throughescapeLikePattern. - Sensitive data redaction.
ClassDto/ClassOptionDtodeliberately differ in what staff/pupil-adjacent data they carry — the options endpoint carries neither a room nor a teacher, specifically so it can be reached without the disclosure the full list represents;ClassRosterStudentDtois narrower thanStudentDto(no DOB, address, phone, guardian, or medical fields) since a roster has no use for them. - Audit logs. No
recordActivitycall accompanies any grade/section/room/class create/update/delete — those rely entirely on the automaticActivityAuditInterceptor. Every enrolment write does callrecordActivityexplicitly, for detail (the pupil id, the old/new class, the capacity numbers on an override) the automatic interceptor cannot see — see 14. Observability. - Fail-closed behavior. Every domain error in this module — not-found, retired, identity/room/teacher conflicts, capacity, scope — is fail-closed. There is no fail-soft path anywhere in this module except the students-list cache invalidation, which is fail-soft by inheriting
RedisCacheService's own contract.
13. Error Handling
| Error Code | HTTP Status | Thrown By | Condition | Client Action |
|---|---|---|---|---|
CLASS_NOT_FOUND | 404 (except RollNumberService.resolveClassId, below) | ClassesReadService.loadDto, ClassesWriteService.remove, ClassEnrollmentsService.resolveClassContext-derived callers, ClassRosterService.findByClass | No classes row for the given publicId/id. | Refresh; the class may have been deleted. |
CLASS_NOT_FOUND (renumber route) | 422 | RollNumberService.resolveClassId, called only by ClassEnrollmentsController.renumber | Same condition as above, but thrown as UnprocessableEntityException rather than NotFoundException — verified at roll-number.service.ts:249-254, distinct from every other CLASS_NOT_FOUND thrower in this module. | Confirm the class's publicId. |
ROLL_NUMBER_TAKEN | 409 | RollNumberService.setManual | student_class_enrollments_class_roll_unique refused — another live pupil in the class already holds that number. | Choose a different number, or reload the roster to see who holds it. |
ROLL_NUMBER_INVALID | 422 | RollNumberService.setManual | The requested roll number is below 1 (the DTO's own @Min(1) already narrows this at the validation layer; the service re-checks). | Fix the number. |
ROLL_NUMBER_ENROLLMENT_INACTIVE | 422 if no such enrolment exists at all; 409 if it exists but is transferred/withdrawn | RollNumberService.setManual | The path names an enrolment id PATCH /enrollments/:id/roll-number cannot resolve, or resolves to a closed one — a closed row keeps the number it had and is not editable, since editing it would rewrite what the register said on a day that has already passed. | Confirm the enrolment id; a closed enrolment's roll number cannot be changed at all. |
CLASS_INACTIVE | 409 | ClassEnrollmentsService.enroll (checked twice — unlocked, then re-checked under the lock) | Enrolling into a class with is_active = false. | Choose a different class, or reactivate this one first. |
CLASS_IDENTITY_TAKEN | 409 | ClassesWriteService.translate | classes_identity_unique refused — deliberately not partial on is_active. | Choose a different grade/section/shift, or reactivate the existing class. |
CLASS_ROOM_OCCUPIED | 409 | ClassesWriteService.translate | classes_room_per_shift_unique refused — the room already holds an active class this shift, this session. | Pick a different room, or a different shift. |
CLASS_TEACHER_ALREADY_ASSIGNED | 409 | ClassesWriteService.translate | classes_teacher_per_shift_unique refused. | Pick a different teacher, or a different shift. |
CLASS_TEACHER_NOT_FOUND | 404 | ClassesWriteService.assertTeacherUsable | classTeacherId does not resolve, or resolves to a soft-deleted staff row. | Confirm the staff id. |
CLASS_TEACHER_NOT_TEACHING | 422 | ClassesWriteService.assertTeacherUsable | The resolved staff member's designation has isTeaching: false. | Choose a staff member whose designation is a teaching one. |
CLASS_CAPACITY_INVALID | 409 | ClassesWriteService.translate | classes_capacity_bounded refused (only reachable if the DTO's own 1..500 bound is somehow bypassed). | Fix the capacity value. |
CLASS_NAME_INVALID | 409 | ClassesWriteService.translate | classes_name_format refused. | Fix the name (no surrounding whitespace, ≤ 128 chars). |
CLASS_AT_CAPACITY | 409 | ClassEnrollmentsService.enroll | enrolledCount >= capacity, allowOverCapacity not set. | Resubmit with allowOverCapacity: true, or choose a different class. |
CLASS_CAPACITY_BELOW_ENROLLED | 422 | ClassesWriteService.update | A PATCH would set capacity below the class's live enrolment count. | Resubmit with allowOverCapacity: true, or reduce the roll first. |
CLASS_HAS_ENROLLMENTS | 409 | ClassesWriteService.translate | student_class_enrollments_class_session_fk refused a delete. | Deactivate instead, or clear every enrolment first. |
CLASS_SESSION_INACTIVE | 422 | ClassesWriteService.assertActive | The chosen academic session is retired. | Choose an active session. |
CLASS_GRADE_INACTIVE | 422 | ClassesWriteService.assertActive | The chosen grade is retired. | Choose an active grade. |
CLASS_SECTION_INACTIVE | 422 | ClassesWriteService.assertActive | The chosen section is retired. | Choose an active section. |
CLASS_ROOM_INACTIVE | 422 | ClassesWriteService.assertActive | The chosen (newly-changing) room is retired. | Choose an active room, or leave the existing one in place. |
SECTION_NOT_FOUND | 404 | SectionsService.findOrThrow; ClassesWriteService.findSectionOrThrow | No sections row for the given publicId/id. | Refresh. |
SECTION_NAME_TAKEN | 409 | SectionsService.assertNameFree / .translate | Another active section already has this name, case-insensitively. | Choose a different name. |
SECTION_NAME_INVALID | 409 | SectionsService.translate | sections_name_format refused. | Fix the name. |
SECTION_IN_USE | 409 | SectionsService.translate | classes_section_id_sections_id_fk refused a delete. | Deactivate instead, or move/retire the referencing classes first. |
ROOM_NOT_FOUND | 404 | RoomsService.findOrThrow; ClassesWriteService.resolveRoom-derived callers | No rooms row for the given publicId/id. | Refresh. |
ROOM_NUMBER_TAKEN | 409 | RoomsService.assertNumberFree / .translate | Another active room in the same building already has this number. | Choose a different number/building. |
ROOM_FIELD_INVALID | 409 | RoomsService.translate | rooms_room_number_format/rooms_building_format/rooms_name_format refused. | Fix the offending field. |
ROOM_FLOOR_INVALID | 409 | RoomsService.translate | rooms_floor_bounded refused. | Fix the floor (-5..200). |
ROOM_IN_USE | 409 | RoomsService.remove (explicit pre-check) | A class still holds room_id pointing at this room. | Deactivate instead, or move the referencing classes first. |
GRADE_NOT_FOUND | 404 | ClassesWriteService.findGradeOrThrow | gradeId does not resolve. | Confirm the grade id — never reachable from a grades route itself, since none mutates. |
ENROLLMENT_NOT_FOUND | 404 | ClassEnrollmentsService.withdraw/updateEnrollment/removeEnrollment; ClassEnrollmentsController.pupilForEnrollment | No matching (or no active, for withdraw) student_class_enrollments row. | Refresh; the row may have already been withdrawn/removed. |
ENROLLMENT_ALREADY_ACTIVE | 409 | ClassEnrollmentsService.translate | student_class_enrollments_one_active_per_session_unique refused — two operators enrolled the same pupil at the same moment. | Retryable — re-read and try again once the winner's write has landed. |
ENROLLMENT_STUDENT_DELETED | 409 | ClassEnrollmentsService.enroll | The pupil's students row is soft-deleted. | Restore the pupil's record first. |
ENROLLMENT_DATE_INVALID | 409 | ClassEnrollmentsService.enroll (pre-check) / .translate (DB backstop) | enrolledOn precedes the pupil's current enrolment in this session; or student_class_enrollments_dates_ordered refused directly. | Correct the date. |
ENROLLMENT_DATE_OUTSIDE_SESSION | 409 | ClassEnrollmentsService.enroll | enrolledOn falls outside [session.startDate, session.endDate]. | Correct the date, or confirm the target session. |
ENROLLMENT_DATE_BEFORE_ADMISSION | 409 | ClassEnrollmentsService.enroll | enrolledOn precedes the pupil's own admissionDate. | Correct the date. |
ENROLLMENT_STATE_INVALID | 409 | ClassEnrollmentsService.translate | student_class_enrollments_ended_matches_status refused — an active row with an end date, or a closed row without one. | Should not be reachable through normal API use; the service pre-computes a coherent endedOn on every status change. |
PAGINATION_LIMIT_INVALID | 400 | ClassesReadService.findAll; ClassEnrollmentsController.roll | pagination=false requested on the class list or a class roster. | Remove the pagination=false query param. |
RESOURCE_ALREADY_EXISTS | 409 | Global (AllExceptionsFilter's unique-violation fallback) | A Postgres 23505 reached the filter without a domain-specific catch — the race-condition path past a section/room name pre-check. | Retry the read; the name is now taken by whichever request won the race. |
VALIDATION_FAILED | 400 | Global ValidationPipe | A DTO field fails class-validator, or a field not declared on the DTO is present (forbidNonWhitelisted) — including any of a class's four identity fields on a PATCH. | Fix the request body against the DTO reference in the API doc. |
AUTH_UNAUTHENTICATED | 401 | JwtAuthGuard | Missing/invalid JWT. | Re-authenticate. |
PERMISSION_INSUFFICIENT | 403 | RoleGuard | Active role lacks the route's required permission. | Not recoverable client-side. |
Every response above 400 is wrapped by AllExceptionsFilter into { statusCode, errorCode, message, timestamp?, path? } — timestamp/path outside production only. A CHECK violation with no matching branch in a translate() function is a 500, not a 409 — AllExceptionsFilter has no 23514 branch — which is exactly why every constraint from migration 0011 is named explicitly in the two translate() functions above and independently verified by the constraint probe.
RollNumberService.assignOnEnroll's own exhausted-retry collision has no branch in translateEnrollmentError. It runs inside ClassEnrollmentsService.enroll's try block (class-enrollments.service.ts:294), so an error it raises reaches translateEnrollmentError (enrollments-core/shared/class-enrollments.errors.ts) — verified: that function's branches match only student_class_enrollments_one_active_per_session_unique, ..._dates_ordered, ..._ended_matches_status, and ..._class_session_fk; there is no branch for student_class_enrollments_class_roll_unique. In practice this is unreachable through normal use — assignNext already retries once internally against a re-read maximum, and only a second collision on the same class within one request would re-throw past that retry. A second collision that does occur here falls through to AllExceptionsFilter's generic 23505 → 409 RESOURCE_ALREADY_EXISTS, not an unmapped 500, because the SQLSTATE is still 23505 — but the client sees the generic code rather than a roll-number-specific one, unlike a collision on the dedicated PATCH .../roll-number route, which RollNumberService.setManual catches itself and reports as ROLL_NUMBER_TAKEN.
14. Observability
| Signal | Location | Purpose |
|---|---|---|
| Log | AllExceptionsFilter (Logger) | A refusal below 500 is logged once at warn as a single line (method, path, status, errorCode, message; no stack); that includes a Postgres unique violation or guard-trigger refusal, which answer 409. Every 5xx, every unmapped error (a foreign-key violation, for example) and every ZodError is logged at error with its stack and any cause chain. |
| Audit (automatic) | ActivityAuditInterceptor, global | Every mutating request in this module — creates/updates/deletes on grades (none exist)/sections/rooms/classes, and every enrolment route — is recorded with actor, module (Sections/Rooms/Classes/Students, from the route's own @Permissions()), action, outcome, status code, and duration. |
| Audit (explicit) | ClassEnrollmentsService.recordActivity calls | Only enrolment writes call this — the automatic interceptor sees neither the request body nor a specific target id, and this module needs both: which pupil, which old/new class, and — on an over-capacity override — the exact capacity numbers involved. sections/rooms/classes mutations rely entirely on the automatic record. RollNumberService calls no explicit audit method at all — verified: no ActivityRecordService import anywhere in roll-number.service.ts. PATCH /enrollments/:id/roll-number and POST /classes/:publicId/enrollments/renumber rely entirely on the automatic ActivityAuditInterceptor, which records that a Students action ran but not the pupil's previous number or which numbers a renumber reassigned — unlike an enrol/withdraw/transfer, where that detail is exactly what recordActivity was added to capture. |
| Metric | None declared | No dedicated metric exists for any operation in this module. |
15. Testing and Validation
Eight real-database integration suites cover this module, all against TEST_DATABASE_URL via createTestDatabase()/closeTestDatabase(), none mocking DatabaseModule, Drizzle, or any @skoolsewa/* package:
academic-structure/__tests__/grades.service.integration.spec.ts—GradesService's read-only list behavior.academic-structure/__tests__/sections.service.integration.spec.ts—SectionsServiceCRUD, including the transactionalsortOrdercomputation and the partial-unique-index reuse-after-retirement behavior.academic-structure/__tests__/rooms.service.integration.spec.ts—RoomsServiceCRUD, including the explicitROOM_IN_USEpre-check (not a caught FK).academic-structure/__tests__/classes-read.service.integration.spec.ts— list/options/single-row reads, session resolution, and occupancy joins.academic-structure/__tests__/classes-write.service.integration.spec.ts— create/update/delete, including the capacity-below-enrolled guard and the room/teacher retired-active re-check-only-if-changed behavior.enrollments-core/__tests__/class-occupancy.service.integration.spec.ts— the shared occupancy predicate, including the soft-deleted/record-inactive pupil exclusion.enrollments-core/__tests__/class-enrollments.service.integration.spec.ts— enrol/transfer/withdraw/correct/remove, including the ascending-lock-order deadlock avoidance and the date-validation branches.enrollments-core/__tests__/roll-number.service.integration.spec.ts(describe("RollNumberService (real database)")) — first-pupil assignment to1; appending the next free number without moving anybody already numbered; freeing a departed pupil's number while their closed row keeps it; refusing a number another live pupil already holds; refusing a number below1; permitting a clear tonull; refusing to edit a closed enrolment's number; renumbering1..nin admission order (compacting gaps) and alphabetically; excluding a soft-deleted pupil and arecord_status-inactive pupil from a renumber; auto-renumbering the whole class on enrolment when the policy asks for it, versus leaving everyone alone when it does not; and two classes each independently holding roll number1.
enrollments-core/__tests__/fixtures.ts supplies shared setup (a session, a grade, a section, a room, a staff member, a pupil) across the enrolment and occupancy suites, avoiding per-test duplication of that fixture graph.
The repo-wide route-permissions.spec.ts covers this module indirectly: it asserts every administrative-surface handler declares a @Permissions() decorator, which all five controllers satisfy.
The constraint probe — packages/db/src/scripts/probe-class-module-constraints.sql — is the authoritative statement of what the database permits, run in one transaction and rolled back, exercising 57 accept/reject cases across every unique index and CHECK constraint migration 0011 adds, in both directions:
psql "$TEST_DATABASE_URL" -f packages/db/src/scripts/probe-class-module-constraints.sqlValidation commands:
pnpm --filter @skoolsewa/api build
pnpm --filter @skoolsewa/db build
pnpm --filter @skoolsewa/api test:structure16. Mandatory Backend Deep-Dive Pack
16.1 Submodule Coverage Matrix
| Unit | Type | Owns | Depends On | Called By | Calls | State Touched | Failure Modes |
|---|---|---|---|---|---|---|---|
GradesController | Controller | /grades (GET only) | GradesService | HTTP | GradesService.findAll | None directly | Guard 401/403. |
GradesService | Service | Read-only grade list | Database | GradesController | Drizzle queries | grades (read) | None domain-specific. |
SectionsController | Controller | /sections full CRUD | SectionsService | HTTP | SectionsService methods | None directly | DTO 400; guard 401/403. |
SectionsService | Service | Section business rules | Database | SectionsController | Drizzle queries, one transaction on create | sections | 404/409 domain errors. |
RoomsController | Controller | /rooms full CRUD | RoomsService | HTTP | RoomsService methods | None directly | Same as above. |
RoomsService | Service | Room business rules | Database | RoomsController | Drizzle queries | rooms; reads classes on delete | 404/409 domain errors. |
ClassesController | Controller | /classes list/options/read/create/update/delete | ClassesReadService, ClassesWriteService | HTTP | Both services | None directly | DTO 400; guard 401/403. |
ClassesReadService | Service | Every read path for classes, including occupancy joins | Database, ClassOccupancyService | ClassesController, ClassesWriteService (via loadDto) | Drizzle queries, ClassOccupancyService | classes (read) | 404; 400 PAGINATION_LIMIT_INVALID. |
ClassesWriteService | Service | Create/update/delete for classes | Database, ClassOccupancyService, ClassesReadService | ClassesController | Drizzle queries, two transactions, ClassOccupancyService.countFor | classes | 404/409/422 domain errors. |
ClassRosterService | Service | A class's scoped pupil roster | Database, PeopleAccessService, PeoplePermissionsService | ClassEnrollmentsController | Drizzle queries, scope resolution | student_class_enrollments, students (read) | 404 CLASS_NOT_FOUND. |
ClassEnrollmentsController | Controller | The five enrolment-adjacent routes on AcademicStructureModule | ClassEnrollmentsService, ClassRosterService, PeopleAccessService, PeoplePermissionsService | HTTP | Those four | None directly | 404/403/400/409. |
ClassEnrollmentsService | Service | Sole writer of student_class_enrollments; per-pupil history read | Database, RedisCacheService, ClassOccupancyService, ActivityRecordService | This controller; StudentsController (read); StudentsService (write, elsewhere) | Drizzle queries, transactions passed by callers, delPatternSoft, recordActivity | student_class_enrollments | 404/409 domain errors; unmapped constraint → 500 if translate() is ever missing a branch. |
ClassOccupancyService | Service | The one shared occupancy computation | DbExecutor (no injected dependency beyond the query) | ClassesReadService, ClassesWriteService, ClassEnrollmentsService | One grouped Drizzle query | student_class_enrollments, students (read) | None — pure read, never throws. |
RollNumberService | Service | Sole writer of student_class_enrollments.roll_number | Database (injected directly; reads school_profile without going through SchoolProfileService) | ClassEnrollmentsService.enroll (via assignOnEnroll); ClassEnrollmentsController (resolveClassId, liveStudentIds, renumber, setManual) | Drizzle queries; one internal retry in assignNext | student_class_enrollments (roll_number); reads school_profile, students, users | 422 CLASS_NOT_FOUND (resolveClassId only); 422/409 ROLL_NUMBER_ENROLLMENT_INACTIVE; 422 ROLL_NUMBER_INVALID; 409 ROLL_NUMBER_TAKEN; an exhausted-retry collision on assignOnEnroll falls through to the generic 409 RESOURCE_ALREADY_EXISTS. |
class-enrollments.mapper.ts (toEnrollmentDto) | Pure function | Flat-row-to-EnrollmentDto mapping | — | ClassEnrollmentsService.listForStudent | — | — | None — pure function. |
AcademicStructureModule | Module | Composition of the five controllers/six providers above | EnrollmentsCoreModule, PeopleModule, RoleModule, ActivityModule | AppModule | — | — | Boot-time InstanceLoader failure if a dependency is uncomposed. |
EnrollmentsCoreModule | Module | ClassEnrollmentsService, ClassOccupancyService, RollNumberService | ActivityModule only | AcademicStructureModule, PeopleModule | — | — | Same boot-time failure mode; the leaf constraint is what keeps the graph acyclic. |
No provider, processor, scheduler, or mapper exists in either module directory beyond what is listed above — verified against the file map in 4.
16.2 UML and Architecture Diagram Pack
State diagrams: see 7.1 and 7.2 in the features and flows doc — a boolean flag for classes/sections/rooms, and the one real multi-value state machine in this module for enrollment_status.
16.3 Code Flow Narrative
Covered per-method in 6. Services and Responsibilities and per-flow in 7. Runtime Flows, in full detail for create()/update() on classes and every method on ClassEnrollmentsService — the only methods in this module whose branching exceeds a straightforward lookup → validate → write → (invalidate) shape.
16.4 Data Layer Deep Dive
Covered in full in 5.2 (field tables, nullability, business meaning, the immutability and total/partial-uniqueness reasoning) and the index rationale table immediately after it. No JSON column, money unit, or versioning field exists anywhere in this module's tables. Seed data dependency: grades (15 rows: Nursery, LKG, UKG, 1-12) and sections (4 rows: A, B, C, D) are populated by seedAcademicStructure() in seed-reference-data.ts, guarded on an empty table, not ON CONFLICT DO NOTHING — see 16.6 for why. rooms and classes start genuinely empty; every row a school sees is one an admin (or a future import, not currently part of this module) created.
16.5 Business Logic and Invariant Catalog
| Invariant | Enforced By | Why It Exists | Failure Error | Tests |
|---|---|---|---|---|
| A class's identity — session, grade, section, shift — is immutable after creation. | UpdateClassDto's omission of all four fields + the global forbidNonWhitelisted validator; backstopped by student_class_enrollments_class_session_fk's ON UPDATE restrict. | The composite FK carries no ON UPDATE action, so a session change once one enrolment exists would otherwise be an unmapped 23503 (a 500). | 400 VALIDATION_FAILED (DTO layer, the only reachable path). | — |
Class identity uniqueness is total, not partial on is_active. | classes_identity_unique, no WHERE clause. | Two rows that were ever the same class in the same year would make every historical roster unresolvable; an earlier partial-index design trapped a class that could be neither deactivated nor deleted once enrolled. | 409 CLASS_IDENTITY_TAKEN. | probe-class-module-constraints.sql. |
| A room/class teacher may hold at most one active class per shift, per session — but freely across shifts. | classes_room_per_shift_unique/classes_teacher_per_shift_unique, both partial WHERE ... AND is_active, scoped (academic_session_id, shift, ...). | Nobody teaches two classes at once; a room hosts two classes only if they run at different times. | 409 CLASS_ROOM_OCCUPIED/CLASS_TEACHER_ALREADY_ASSIGNED. | probe-class-module-constraints.sql. |
| Room/teacher are RESOURCES, not identity — deactivating a class releases them immediately. | The WHERE is_active clause on both partial indexes above. | A retired class must not tie up a room or a teacher forever. | N/A — allowed by design; verified as an ACCEPT case in the probe. | probe-class-module-constraints.sql ("same room, DIFFERENT shift accepted"). |
A composite designation-teacher relationship: a class teacher must be a staff row whose designation has isTeaching: true. | ClassesWriteService.assertTeacherUsable, joining staff to designations. | There is no Teachers entity — a teacher is defined entirely by this join. | 422 CLASS_TEACHER_NOT_TEACHING. | classes-write.service.integration.spec.ts. |
| Occupancy always excludes a soft-deleted or record-inactive pupil. | ClassOccupancyService's shared WHERE clause, joined against students. | students soft-deletes and ON DELETE cascade never fires for it — an unfiltered count would inflate forever as pupils leave the school. | N/A — silent correctness, not an error path. | class-occupancy.service.integration.spec.ts. |
| At most one active enrolment per pupil per academic session. | student_class_enrollments_one_active_per_session_unique, partial WHERE status='active'. | The invariant the table exists to enforce, and the reason academic_session_id is denormalised onto it at all (Postgres cannot build a partial index across a join). | 409 ENROLLMENT_ALREADY_ACTIVE on a race past the service's own predecessor read. | probe-class-module-constraints.sql; class-enrollments.service.integration.spec.ts. |
A closed enrolment's ended_on is never before its enrolled_on, and status/end-date are always coherent (active ⟺ no end date). | student_class_enrollments_dates_ordered, student_class_enrollments_ended_matches_status. | Both are otherwise-representable data-corruption states — an enrolment claiming to be both current and finished, or finished with no date. | 409 ENROLLMENT_DATE_INVALID/ENROLLMENT_STATE_INVALID. | probe-class-module-constraints.sql. |
A transfer closes the predecessor and opens the successor atomically, both classes locked in ascending id order. | ClassEnrollmentsService.enroll, the SELECT ... FOR UPDATE ... ORDER BY id. | Without a fixed lock order, a simultaneous cross-transfer (X→Y and Y→X) deadlocks (40P01); without one transaction, a crash mid-transfer could leave a pupil in two classes or none. | Deadlock avoided by construction; atomicity by the transaction boundary. | class-enrollments.service.integration.spec.ts. |
| Capacity is enforced under the same lock the occupancy count is computed under. | ClassEnrollmentsService.enroll's FOR UPDATE on the target class, taken before ClassOccupancyService.countFor runs. | Without the lock, two concurrent enrolments could both read "room for one more" and both insert, overshooting capacity with no override ever recorded. | 409 CLASS_AT_CAPACITY, correctly serialized rather than racy. | class-enrollments.service.integration.spec.ts. |
enrollment_status has no completed value; a past-session enrolment simply stays active. | enrollmentStatusEnum = ["active", "transferred", "withdrawn"]. | An enum value nothing writes would make an unimplemented feature (year rollover/promotion) look implemented. | N/A — a schema design choice, not a runtime check. | — |
| A roll number is unique among a class's LIVE roll only; a closed enrolment keeps its number for history while the position becomes reusable. | student_class_enrollments_class_roll_unique, partial WHERE status = 'active' AND roll_number IS NOT NULL, scoped (class_id, roll_number). | Without the partial predicate, a class that has ever lost a pupil could never reuse that position and every class's numbers would drift upward forever; two different classes legitimately share the same number. | 409 ROLL_NUMBER_TAKEN on the dedicated route; falls through to the generic 409 RESOURCE_ALREADY_EXISTS on an exhausted-retry collision during assignOnEnroll — see 13. | roll-number.service.integration.spec.ts; probe-class-module-constraints.sql does not cover migration 0021's constraints — see the risk register below. |
Assignment on enrolment appends max(roll_number) + 1 among the class's live roll and moves nobody else, UNLESS school_profile.roll_number_auto_renumber is on, in which case it resequences the whole class by the configured strategy. | RollNumberService.assignOnEnroll branching on policy().autoRenumber. | A roll number has already been read out in class, written on exercise books, and recorded against attendance history by the time the next pupil joins — auto-renumber off by default is the school opting OUT of a mid-year reshuffle, not a missing feature. | N/A — a policy branch, not an error path. | roll-number.service.integration.spec.ts ("renumbers the whole class on enrolment when auto-renumber is on", "leaves existing pupils alone on enrolment when auto-renumber is off"). |
Changing school_profile.roll_number_strategy moves nobody by itself — it only decides the order used the NEXT time a class is renumbered (automatically, via auto-renumber, or explicitly, via the renumber route). | RollNumberService.policy/renumber read the strategy at the moment of assignment or renumber, never on a write to school_profile itself. | SchoolProfileService.update has no knowledge of, and never calls into, any class's roll — coupling a profile edit to a mass roll rewrite would make a settings-screen save silently rewrite every attendance register in the school. | N/A — documented behavior, not an error. | school-profile.service.spec.ts (asserts the two fields round-trip; does not itself exercise a renumber). |
POST .../renumber writes in two phases — clear, then reassign — because a plain unique index is checked per row, not deferred to statement end. | RollNumberService.renumber's UPDATE ... SET roll_number = NULL followed by one UPDATE per pupil, both inside the caller's transaction. | Writing new numbers directly into the existing sequence collides with whoever still holds the number being assigned; Postgres has no way to defer a plain (non-DEFERRABLE) unique index to commit time. | An unmapped error on the write phase is a 500 — no try/catch wraps this path. | roll-number.service.integration.spec.ts (renumber tests assert the final 1..n sequence, not the intermediate cleared state). |
A renumber excludes a soft-deleted or record_status-inactive pupil from the resequenced roll, exactly as occupancy and the roster do. | RollNumberService.renumber's SELECT — students.deletedAt IS NULL AND students.recordStatus = 'active', joined the same way ClassOccupancyService and ClassRosterService filter. | A departed child must not consume a position in the freshly-renumbered sequence. | N/A — silent correctness, not an error path. | roll-number.service.integration.spec.ts ("excludes a soft-deleted pupil from a renumber", "excludes a pupil whose record_status is inactive"). |
A collision on PATCH .../roll-number is read off the driver error's .cause chain by constraint NAME, not the bare SQLSTATE, and answers 409, not 500. | RollNumberService's module-level isUniqueViolation, via pgErrorCode/pgErrorText (pg-constraint.util.ts). | student_class_enrollments carries a second unique index (one active enrolment per pupil per session) that also raises 23505; matching on the bare SQLSTATE alone would misreport that collision as a taken roll number, or vice versa. | 409 ROLL_NUMBER_TAKEN specifically, never a bare 23505 500. | roll-number.service.integration.spec.ts ("refuses a number another live pupil in the class already holds"). |
| A class-wide renumber checks every live pupil's scope BEFORE writing, since there is no class-level scope to substitute for a per-pupil one. | ClassEnrollmentsController.renumber's loop over RollNumberService.liveStudentIds, each pupil run through assertPupilInScope. | The same object-level-access gap this module's history already records for the roster — a class-wide write reachable for a caller who cannot see one of the children in it would be the identical incident in write form. | 404 on the first out-of-scope pupil found; nothing has been written yet. | — |
A class roster and a pupil's class history are both row-scoped by PeopleAccessService, never a bare Students_READ grant. | Explicit scopeFor/applyScope/assertCanAccess calls at the controller/service boundary, never inside ClassEnrollmentsService itself. | This exact gap — a narrow-scoped role seeing an entire class roll instead of its own narrow slice — has recurred in this repo's history. | 404 on an out-of-scope pupil (write paths); a narrower-than-expected result set (read paths), never an error. | class-enrollments.service.integration.spec.ts (out-of-scope caller gets 404). |
Withdraw computes endedOn = max(enrolledOn, today), never plain "today." | ClassEnrollmentsService.withdraw/updateEnrollment. | A future-dated active enrolment withdrawn on its own start date would otherwise violate ..._dates_ordered with a naive "today" earlier than enrolledOn. | Prevents 500/409 ENROLLMENT_STATE_INVALID from ever being reachable through the withdraw path. | — |
A caller wanting an entire reference table (grades, sections, rooms) asks for pagination=false; a caller wanting the full class list cannot. | PaginationUtil for the first three; ClassesReadService.findAll's explicit refusal for classes. | Grades/sections/rooms carry no person data; the class list embeds staff identity (the class teacher). | 400 PAGINATION_LIMIT_INVALID on classes only. | — |
16.6 Tradeoffs, Alternatives, and ADR Notes
| Decision | Context | Chosen Option | Alternatives | Why Chosen | Tradeoffs | Revisit Trigger |
|---|---|---|---|---|---|---|
| Identity uniqueness total; room/teacher uniqueness partial. | A class's name-within-its-year must never become ambiguous; its resources must be releasable on retirement. | Asymmetric unique-index shape — one plain, two partial. | All three partial (the module's own earlier design). | The all-partial version trapped a class that could be neither deactivated nor deleted once enrolled — the two guard rules cancelled each other out. | A retired class's identity is permanently reserved; the only correction is deactivate-and-recreate under a different identity. | If a future "merge two classes" or "rename in place" feature needs identity to be freeable, this decision needs revisiting alongside the enrolment-history implications. |
EnrollmentsCoreModule as a strict leaf, with zero authorization logic. | ClassEnrollmentsService needs to be reachable from both AcademicStructureModule and PeopleModule. | A leaf module importing only ActivityModule, with every caller resolving scope first. | Give the service its own PeopleAccessService dependency. | The alternative closes PeopleModule ↔ AcademicStructureModule into a cycle; this codebase has zero forwardRef usages and one prior cycle produced a genuine boot-time crash. | Every current and future caller must remember the scope check — a real, if mitigated, risk (see the risk register). | If a third module needs to write enrolments and cannot easily resolve scope itself, revisit whether the scope check belongs on a shared decorator instead of duplicated per-caller. |
Capacity as a soft warning (allowOverCapacity), not a hard cap. | A school occasionally needs to admit "one more" pupil past a class's stated capacity, as a real-world judgment call. | 409 unless explicitly overridden, with the override audited. | Hard-block at capacity, requiring a separate capacity-increase request. | Blocking outright pushes a routine school decision outside the system entirely, into an unaudited manual workaround. | The override has no cap of its own — repeated overrides can push a class arbitrarily past its stated capacity, bounded only by the 500 schema ceiling. | If capacity overrides are observed piling up on the same class repeatedly, that class's stated capacity is probably wrong and should be corrected instead. |
| Grades read-only, no permission module. | Grades are a fixed-enough set in practice (Nursery through 12) that a full CRUD surface was not built. | Classes_READ gates the one route; no Grades module exists in the catalog. | Give grades the same full CRUD treatment as sections/rooms. | One fewer module × five actions in the generated permission catalog, for a table that in practice never changes after seeding. | A school genuinely needing a grade the seed does not name (the schema's own docblock names "Playgroup" as an example) has no product path today. | If a real deployment needs a grade the seed does not provide, this is the concrete trigger to add the module and its routes. |
Seeds for grades/sections guarded on an empty table, not ON CONFLICT DO NOTHING. | sections has no stable code column, unlike grades. | An empty-table guard for both, for consistency between the two even though only one strictly needs it. | ON CONFLICT DO NOTHING against the name-uniqueness index, matching the school module's reference-data seeds. | A targetless ON CONFLICT against a renamed section's old name collides with nothing, silently creating a duplicate "A" after an operator renames the original to "Alpha." | Neither table is ever reactivated by a re-run if an operator has retired a seeded row — a deliberate, matching choice with the school module's own reference-data seeds. | N/A — this is a correctness fix for sections specifically, applied uniformly to grades for consistency. |
students:list:* invalidated wholesale on every enrolment write, never scoped. | An enrolment changes what a student-list row's "current class" field shows; StudentsService owns that cache prefix, not this module. | Clear the entire prefix on every write. | Compute and clear only the affected student's cache entries. | This module has no visibility into how StudentsService shapes its own cache keys — a targeted invalidation would require this module to duplicate that key-construction logic. | A burst of enrolment writes clears the entire students list cache repeatedly, at the cost of extra cache-miss reads, never a correctness cost. | If students-list read volume ever becomes cache-miss-dominated by enrolment churn specifically, this is the trigger to build a shared, targeted invalidation contract between the two modules. |
16.7 Operational Runbook
| Operation | How to Inspect | Healthy State | Failure Signal | Recovery |
|---|---|---|---|---|
| Class capacity/occupancy disagreement | SELECT class_id, count(*) FROM student_class_enrollments se JOIN students s ON s.id=se.student_id WHERE se.status='active' AND s.deleted_at IS NULL AND s.record_status='active' GROUP BY class_id, compared against the API's enrolledCount | The query and the API agree exactly — both use the identical predicate. | A UI-reported count that does not match this query | Confirm the UI is calling GET /classes/:publicId or the list, not caching a stale value client-side — the backend itself caches nothing here. |
| Stuck class (cannot deactivate or delete) | SELECT * FROM classes WHERE public_id = '...'; SELECT count(*) FROM student_class_enrollments WHERE class_id = ... | Deactivation always succeeds regardless of enrolment count; only hard delete is blocked while referenced. | A 409 CLASS_HAS_ENROLLMENTS on delete | Expected — deactivate instead of deleting, per the documented correction path. |
Enrolment write failing with an unmapped 500 | Application logs for the Postgres SQLSTATE and constraint name in the error's .cause chain | Every constraint from migration 0011 has a named branch in one of the two translate() functions | A 500 where a 409/422 was expected | The constraint name in the log names exactly which translate() branch is missing — add it, following the existing pattern, and re-run the constraint probe. |
permissions:sync run without db:seed:prod | SELECT role.name, permission.code FROM role_permission JOIN role ON ... JOIN permission ON ... WHERE permission.code LIKE 'Classes_%' | staff/teacher roles hold Classes_READ/Sections_READ/Rooms_READ | Only superadmin (and any role named admin, which does not exist) holds the new permission rows | Run pnpm --filter @skoolsewa/db db:seed:prod — permissions:sync alone never grants the staff/teacher reads. |
| Seed did not populate grades/sections | SELECT count(*) FROM grades (expect 15), SELECT count(*) FROM sections (expect ≥ 4) | Both populated from the seeded baseline | Either table reporting 0 | Run pnpm db:seed (or the production seed) — seedAcademicStructure() is idempotent via its empty-table guard and safe to re-run. |
16.8 Backend Risk Register
| Risk | Area | Impact | Current Mitigation | Remaining Gap |
|---|---|---|---|---|
ClassEnrollmentsService trusts every caller to have already checked scope. | Module boundary design | A future caller of ClassEnrollmentsService that forgets assertCanAccess would silently bypass the object-level access control entirely — no compile-time or runtime guard inside the service itself catches this. | Every existing caller (this controller; StudentsService) has its own integration test asserting an out-of-scope caller gets 404. | No shared decorator or interceptor enforces the check at the service boundary; it is a documented convention, not a mechanism. |
| Capacity overrides have no ceiling of their own. | ClassEnrollmentsService.enroll with allowOverCapacity: true | A class can be pushed arbitrarily past its stated capacity, one override at a time, bounded only by the schema's 1..500 capacity column CHECK (which does not bound the roll, only the stated number). | Each override is individually audited with the exact counts at the time. | No aggregate alert or report surfaces "this class has been over capacity N times" — an operator would have to query the activity log directly. |
| Grades cannot be extended through the product. | Permission catalog / route surface | A school needing a grade the seed does not name (the schema's own docblock's example: "Playgroup") has no path except a direct database write outside the API. | None — documented as a known gap in this document and in the schema's own comments. | No POST /api/grades exists; adding one requires a Grades permission module and a full CRUD surface, not a small patch. |
students:list:* invalidation couples this module to StudentsService's cache-key shape by convention, not by contract. | Cache invalidation | If StudentsService ever changes its cache-key prefix, this module's invalidation silently stops working — no compile-time link exists between the two. | The prefix is a shared constant (STUDENTS_CACHE_PREFIX) imported by both, not a hand-typed string duplicated in each. | A rename of the constant itself would still require updating every importer by hand; nothing enforces that both modules stay aligned beyond code review. |
Migration 0021's constraints (student_class_enrollments_class_roll_unique, student_class_enrollments_roll_number_positive) are not exercised by the constraint probe. | Test coverage | probe-class-module-constraints.sql is this module's documented "authoritative statement of what the database permits" (15), but its 57 accept/reject cases are all against migration 0011's original schema — verified: probe-class-module-constraints.sql contains no reference to roll_number at all. | roll-number.service.integration.spec.ts does exercise both constraints through the service layer (the collision and positivity tests). | A constraint reachable ONLY by bypassing the service — a raw SQL write, or a future caller that inserts/updates roll_number directly — has no probe-level accept/reject evidence, unlike every constraint from 0011. |
17. Zero-Omission Backend Checklist
- Every file in both module directories is represented (see 4).
- Every controller, service, and DTO is documented; no processor/scheduler/mapper exists beyond
class-enrollments.mapper.ts, which is documented. - Every method with business behavior has a code-flow narrative (6, 7).
- Every table has field-level detail (5.2).
- Every index/constraint/relation/delete behavior has rationale (5.2, index rationale table).
- Both lifecycles are documented —
is_activeas a flag for classes/sections/rooms,enrollment_statusas a real state machine. - Every read/write flow has a sequence diagram (7).
- Every business invariant is cataloged (16.5).
- Cache behavior — including its near-total absence — is documented precisely (8).
- Every architectural tradeoff is documented with alternatives and a revisit trigger (16.6).
- Every operational failure mode has a runbook entry (16.7).
18. Backend Completion Checklist
- Module boundaries are documented (2).
- Every controller, service, DTO, and schema file is covered.
- Every database table has a field table and relationship diagram.
- Every runtime flow has a diagram and branch notes.
- API and features/flows docs are linked below.
- No claim is made without a source file reference, including the constraint probe as the authoritative statement of accept/reject behavior.
See Also
- API doc:
/docs/developer/classes/api - Features and flows doc:
/docs/developer/classes/feature
Classes Features and Flows
Complete feature list, actor journeys, state flows, business rules, edge cases, and diagrams for grades, sections, rooms, classes, and student class enrolments.
Classes API Reference
Complete API contracts for grades, sections, rooms, classes, and student class enrolments, including routes, auth, DTOs, responses, errors, and examples.