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 | 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/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. |
| Migration | packages/db/src/migrations/0011_class_module.sql, packages/db/src/migrations/down/0011_class_module.down.sql | Table/type creation order and the down migration's stated drop-order reasoning. |
| 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) | 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 | 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. - 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 — no cache layer exists for any list in this module. |
| 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 | 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, RoomsService, ClassesReadService, ClassesWriteService, ClassRosterService | ClassesReadService | 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 | Both | The sole writer of student_class_enrollments, and the one shared definition of class occupancy. Performs no authorization. |
The import list is load-bearing, and so is what is absent. AcademicStructureModule imports EnrollmentsCoreModule, PeopleModule, RoleModule, and ActivityModule. 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.
ClassesReadService is the one export of AcademicStructureModule — verified: no other module currently imports AcademicStructureModule, so this export is not yet consumed elsewhere, but exists for a future module that needs to resolve a class by id without duplicating the join logic in 6.4.
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
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
query-boolean.ts
__tests__/
grades.service.integration.spec.ts
sections.service.integration.spec.ts
rooms.service.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
__tests__/
fixtures.ts
class-enrollments.service.integration.spec.ts
class-occupancy.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. |
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. |
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. |
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. |
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. |
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. |
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.
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. | — |
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 | — | — | — |
create(dto) | RoomsController.create | (building, roomNumber) pre-check (active-only) | rooms insert | — | 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 | — | ROOM_NOT_FOUND (404); ROOM_NUMBER_TAKEN (409). |
remove(publicId) | RoomsController.remove | Lookup; explicit classes.room_id existence check | rooms delete | — | ROOM_NOT_FOUND (404); ROOM_IN_USE (409, from the 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".
Search matches three fields at once. roomNumber, building, and name are all ILIKE-searched together via or(...), unlike sections/grades which search only name.
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.
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
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 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 |
|---|---|---|---|---|---|
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 |
This is the module's only cache interaction, and it is entirely one-directional. No service in AcademicStructureModule or EnrollmentsCoreModule calls getSoft/setSoft — every findAll/findOne/findOptions across grades, sections, rooms, and classes reads the database live, on every request, unlike the school module's LookupsService/PersonClassificationsService/SchoolProfileService, all three of which are cache-aside. ClassEnrollmentsService 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.
Why no list in this module is cached. Not stated explicitly anywhere in the code; verified by the absence of any RedisCacheService injection in GradesService, SectionsService, RoomsService, ClassesReadService, or ClassRosterService. Every read here is either infrequent relative to writes (grades/sections/rooms, edited rarely) or must reflect a live, second-by-second occupancy figure that caching would immediately stale (classes, the roster) — caching the class list specifically would also require invalidating it on every enrolment write from a module (EnrollmentsCoreModule) that does not otherwise know this module's cache-key shape.
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. A caller holding fullClasses_*grants but noStudents_*grants can see and edit every class, but cannot touch a single enrolment 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) are called explicitly byClassRosterServiceandClassEnrollmentsController— never byClassEnrollmentsService, which cannot 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. - 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 | 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_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.
14. Observability
| Signal | Location | Purpose |
|---|---|---|
| Log | AllExceptionsFilter (Logger) | Every exception reaching the HTTP boundary is logged at error, including any Postgres 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. |
| Metric | None declared | No dedicated metric exists for any operation in this module. |
15. Testing and Validation
Seven 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__/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. |
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 | 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 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. |
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.