Skoolsewa - Ecommerce Docs
Developer ResourcesClasses

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

AreaFiles InspectedVerified Details
Module wiringapps/api/src/modules/academic-structure/academic-structure.module.ts, apps/api/src/modules/enrollments-core/enrollments-core.module.tsImports, providers, controllers, exports, and the acyclic dependency graph both docblocks state.
Controllersgrades/grades.controller.ts, sections/sections.controller.ts, rooms/rooms.controller.ts, classes/classes.controller.ts, enrollments/class-enrollments.controller.tsRoute ownership, guards, permissions, thin-controller boundaries.
Servicesgrades/grades.service.ts, sections/sections.service.ts, rooms/rooms.service.ts, classes/classes-read.service.ts, classes/classes-write.service.ts, classes/class-roster.service.ts, enrollments-core/class-enrollments.service.ts, enrollments-core/class-occupancy.service.ts, enrollments-core/class-enrollments.mapper.tsBusiness logic, validation order, locking, response mapping, side effects.
DTOsdto/grade.dto.ts, dto/section.dto.ts, dto/room.dto.ts, dto/class.dto.ts, dto/enrollment.dto.ts, dto/query-boolean.tsRequest/response contracts and validation.
Schemapackages/db/src/schema/school/classes.tsTables, enums, constraints, indexes, relations — including the docblock's own stated reasoning for every non-obvious choice.
Migrationpackages/db/src/migrations/0011_class_module.sql, packages/db/src/migrations/down/0011_class_module.down.sqlTable/type creation order and the down migration's stated drop-order reasoning.
Constraint probepackages/db/src/scripts/probe-class-module-constraints.sql57 accept/reject cases against the real generated schema, both directions, all passing.
Seed datapackages/db/src/seed/seed-reference-data.ts, packages/db/src/seed/seed-auth.tsThe 15 seeded grades and 4 seeded sections, the empty-table seed guard, and which roles are granted which read codes.
Errorsapps/api/src/common/types/error-codes.ts (the "SCHOOL DOMAIN: classes, sections and rooms" section)Every error code this module can produce.
Authorizationpackages/db/src/authorization/permission-catalog.ts, packages/db/src/seed/seed-auth.ts, apps/api/src/common/authorization/role.guard.tsPermission modules/actions, role grants, guard behavior.
Object-level accessapps/api/src/modules/people/shared/people-access.service.ts, apps/api/src/modules/people/shared/people-permissions.service.tsscopeFor/applyScope/assertCanAccess — the actual control behind the Students_* permission codes.
Paginationapps/api/src/common/utils/pagination.util.tsDefault/max size, UNPAGINATED_HARD_CAP.
Deploy scriptsapps/api/scripts/sync-permissions.tsWhich roles a permission sync actually grants to, versus what the full seed grants.
Testsacademic-structure/__tests__/*.integration.spec.ts, enrollments-core/__tests__/*.integration.spec.tsReal-database coverage for every service in this module.
Wiring into the appapps/api/src/app.module.tsAcademicStructureModule 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 outside AcademicStructureModule in 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 rosterClassRosterService, 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.ts and its own module own the year a class belongs to; this module only references academic_sessions.id and reads startDate/endDate/isCurrent off it.
  • Staff and their designations. packages/db/src/schema/school/people.ts owns staff; this module only reads a staff row (and its designation's isTeaching flag) to validate and display a class teacher. A teacher is a staff row with a teaching designation — there is no teacher entity and no Teachers permission module.
  • Students. packages/db/src/schema/school/people.ts owns students, created/updated/soft-deleted by the people module. This module reads students.deletedAt/admissionDate/recordStatus to validate an enrolment and to filter occupancy and the roster, but never writes any of those columns.
  • Object-level access scoping. PeopleAccessService/PeoplePermissionsService (in PeopleModule) own scopeFor/applyScope/assertCanAccess; this module's roster service and enrolment controller call into them rather than reimplementing scope logic.
  • Activity/audit recording infrastructure. ActivityRecordService (in ActivityModule) owns the Mongo write; ClassEnrollmentsService calls it for detail the automatic interceptor cannot see, but does not own the mechanism.
  • Authentication. JwtAuthGuard is imported, not implemented, by every controller in this module.
  • Redis connectivity. RedisCacheService is imported by ClassEnrollmentsService for 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

ConcernSource of TruthNotes
Grade/section/room existence, name, active flaggrades/sections/rooms tablesRead live on every request — no cache layer exists for any list in this module.
A class's identity, capacity, room, and class teacherclasses rowThe four identity columns are immutable after creation — see 5.2.
How full a class isComputed live by ClassOccupancyService, never a stored columnFilters student_class_enrollments.status = 'active' AND students.deletedAt IS NULL AND students.recordStatus = 'active' — see 6.8.
Which classes a pupil has held, and whenstudent_class_enrollments, one row per stintClassEnrollmentsService is the sole writer.
Whether a caller may act on a specific pupilPeopleAccessService.assertCanAccess, called by the controller, never by the enrolment service itselfSee 11. Security.
Which academic year a GET /classes/GET /classes/options request without an explicit academicSessionId resolves toacademic_sessions.is_currentNothing 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

ModuleTypePathControllersProvidersExportsResponsibility
AcademicStructureModuleAggregate/leaf hybridapps/api/src/modules/academic-structure/academic-structure.module.tsGradesController, SectionsController, RoomsController, ClassesController, ClassEnrollmentsControllerGradesService, SectionsService, RoomsService, ClassesReadService, ClassesWriteService, ClassRosterServiceClassesReadServiceOwns grade/section/room/class CRUD and the class-side enrolment routes.
EnrollmentsCoreModuleLeafapps/api/src/modules/enrollments-core/enrollments-core.module.tsNoneClassEnrollmentsService, ClassOccupancyServiceBothThe 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 ← AcademicStructureModule

which 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
FilePurposeKey ExportsNotes
academic-structure.module.tsWires all five controllers and their services together.AcademicStructureModuleImports EnrollmentsCoreModule, PeopleModule, RoleModule, ActivityModule.
grades/grades.controller.tsSingle GET route.GradesControllerNo create/update/delete handler exists at all.
grades/grades.service.tsRead-only list logic.GradesServiceGated by Classes_READ, not a Grades permission — there is no such module.
sections/sections.controller.tsFull CRUD on /sections.SectionsControllerMirrors the school module's lookup-CRUD shape.
sections/sections.service.tsSection business logic.SectionsServiceSort-order computed inside a transaction on create.
rooms/rooms.controller.tsFull CRUD on /rooms.RoomsController
rooms/rooms.service.tsRoom business logic.RoomsServiceDelete guard is an explicit pre-check, not a caught FK violation — see 6.3.
classes/classes.controller.tsList/options/read/create/update/delete on /classes.ClassesController@Get("options") declared before @Get(":publicId") — declaration order determines route matching.
classes/classes-read.service.tsEvery 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.tsCreate/update/delete for classes.ClassesWriteServiceSplits "not found"/"retired" validation (read-then-check) from "already taken" (write-then-translate).
classes/class-roster.service.tsA class's pupil list, object-scope-filtered.ClassRosterServiceDepends on PeopleAccessService/PeoplePermissionsService from PeopleModule.
enrollments/class-enrollments.controller.tsAll five enrolment-adjacent routes: roster, enrol, withdraw, correct, remove.ClassEnrollmentsControllerThe only place in this module that calls PeopleAccessService.assertCanAccess.
dto/class.dto.tsClass DTOs, options DTOs, and both list query DTOs.ClassDto, ClassOptionDto, ClassOptionsPayloadDto, CreateClassDto, UpdateClassDto, ListClassesQueryDto, ClassOptionsQueryDto, ClassTeacherDto, ClassAcademicSessionDto, CLASS_SHIFTSUpdateClassDto omits all four identity fields by design.
dto/enrollment.dto.tsEnrolment and roster DTOs.EnrollmentDto, EnrollmentClassDto, ClassRosterEntryDto, ClassRosterStudentDto, CreateEnrollmentDto, UpdateEnrollmentDto, ListRosterQueryDto, ListStudentEnrollmentsQueryDto, ENROLLMENT_STATUSESClassRosterStudentDto is deliberately narrower than StudentDto — no DOB, address, phone, guardian, or medical fields.
dto/query-boolean.tsShared QueryBoolean()/QueryInt() transforms.QueryBoolean, QueryIntUsed by every boolean/integer query filter in this module — query params arrive as strings.
enrollments-core/class-enrollments.service.tsThe sole writer of student_class_enrollments, plus the per-pupil history read.ClassEnrollmentsServiceMarked SPLIT-EXEMPT in its own header — five methods sharing one lock discipline, one translation table, one cache-invalidation path.
enrollments-core/class-enrollments.mapper.tsFlat-row-to-EnrollmentDto mapping for listForStudent.toEnrollmentDto, EnrollmentQueryRowSplit out to keep the service under this repo's line-count convention.
enrollments-core/class-occupancy.service.tsThe one shared occupancy computation.ClassOccupancyServicecountForMany 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
  studentClassEnrollments

Every 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

ColumnTypeNullableDefaultIndex/ConstraintRelationNotes
idserialNoauto-incrementPKReferenced by classes.grade_idInternal integer id.
public_iduuidNouuid7() (app-level $defaultFn)UNIQUEN/A
nametextNogrades_name_unique — unique on lower(name), partial WHERE is_active; format CHECK ≤ 64 charsN/Ae.g. "5", "Nursery".
codetextNogrades_code_unique — unique on code, partial WHERE is_active; format CHECK ^[A-Z0-9_]{1,16}$N/AStable across a rename — the value a report or import quotes.
sort_orderintegerNogrades_sort_order_bounded CHECK >= 0N/ADeliberately 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_activebooleanNotruegrades_is_active_idx (btree)N/ARetirement flag — see the module-wide note below.
created_attimestamptzNonow()N/A
updated_attimestamptzNonow(), $onUpdateFnN/ABumped 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

ColumnTypeNullableDefaultIndex/ConstraintRelationNotes
idserialNoauto-incrementPKReferenced by classes.section_id
public_iduuidNouuid7()UNIQUEN/A
nametextNosections_name_unique — unique on lower(name), partial WHERE is_active; format CHECK ≤ 32 charsN/A"A", "B", …
sort_orderintegerNosections_sort_order_bounded CHECK >= 0N/ANot unique, same reasoning as grades.sort_order.
is_activebooleanNotruesections_is_active_idx (btree)N/A
created_at / updated_attimestamptzNonow() (+ $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

ColumnTypeNullableDefaultIndex/ConstraintRelationNotes
idserialNoauto-incrementPKReferenced by classes.room_id
public_iduuidNouuid7()UNIQUEN/A
room_numbertextNoPart of rooms_building_number_unique; format CHECK ≤ 32 charsN/AFree text, e.g. "101".
nametextYesnullrooms_name_format CHECK, NULL-admitting, ≤ 128 charsN/ARejects an empty string specifically — what an unfilled optional form field sends; a genuine NULL (nothing sent) is unconstrained.
floorintegerNorooms_floor_bounded CHECK -5..200 inclusiveN/AAn 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.
buildingtextNoPart of rooms_building_number_unique; format CHECK ≤ 128 charsN/A
is_activebooleanNotruerooms_is_active_idx (btree)N/A
created_at / updated_attimestamptzNonow() (+ $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.

ColumnTypeNullableDefaultIndex/ConstraintRelationNotes
idserialNoauto-incrementPK; part of classes_id_session_uniqueTarget of the enrolment table's composite FK
public_iduuidNouuid7()UNIQUEN/A
academic_session_idintegerNoFK → academic_sessions.id ON DELETE restrict; part of every identity/uniqueness index belowclasses.academicSessionImmutable after creation — see the docblock note below.
grade_idintegerNoFK → grades.id ON DELETE restrict; classes_grade_idx (btree); part of classes_identity_uniqueclasses.gradeImmutable.
section_idintegerNoFK → sections.id ON DELETE restrict; part of classes_identity_uniqueclasses.sectionImmutable.
shiftclass_shift enumNoPart of classes_identity_unique and both partial exclusivity indexesN/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.
nametextYesnullclasses_name_format CHECK, NULL-admitting, ≤ 128 charsN/AOptional display name ("5A Morning") — never identity.
capacityintegerNoclasses_capacity_bounded CHECK 1..500N/AThe 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_iduuidYesnullFK → staff.id ON DELETE set null; classes_class_teacher_idx (btree); part of classes_teacher_per_shift_uniqueclasses.classTeacherstaff 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_idintegerYesnullFK → rooms.id ON DELETE set null; classes_room_idx (btree); part of classes_room_per_shift_uniqueclasses.room
is_activebooleanNotrueN/ARetirement flag with an asymmetry from every other is_active in this schema — see below.
created_at / updated_attimestamptzNonow() (+ $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.

ColumnTypeNullableDefaultIndex/ConstraintRelationNotes
iduuid (v7 PK)Noapp-generatedPK
student_iduuidNoFK → students.id ON DELETE cascade; student_class_enrollments_student_idx (btree)studentClassEnrollments.studentcascade, 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_idintegerNoHalf of the composite FK below (no inline .references(), matching staff.designation_id)
academic_session_idintegerNoOther half of the composite FKDenormalised, deliberately. See below.
statusenrollment_status enumNo'active'Part of the partial unique index and the two coherence CHECKs belowN/A"active" | "transferred" | "withdrawn" — no "completed", see 5.2 note.
enrolled_ondateNostudent_class_enrollments_dates_ordered CHECK (with ended_on)N/A
ended_ondateYesnullstudent_class_enrollments_dates_ordered, student_class_enrollments_ended_matches_statusN/A
created_at / updated_attimestamptzNonow() (+ $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_orderedended_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/ConstraintColumnsTypeQuery/Invariant SupportedTradeoff
grades_name_uniquelower(name), WHERE is_activeunique btree (partial, expression)Case-insensitive name uniqueness among active grades.Retiring a grade frees its name for reuse.
grades_code_uniquecode, WHERE is_activeunique btree (partial)Same, for the stable code value.
grades_is_active_idxis_activebtreeThe active-only filter every select box applies.
sections_name_uniquelower(name), WHERE is_activeunique btree (partial, expression)Case-insensitive name uniqueness among active sections.
sections_is_active_idxis_activebtreeSame as grades.
rooms_building_number_uniquelower(building), lower(room_number), WHERE is_activeunique btree (partial, expression)Case-insensitive (building, number) pairing, scoped per building.
rooms_is_active_idxis_activebtreeSame as grades.
classes_id_session_uniqueid, academic_session_idunique table constraintExists 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_uniqueacademic_session_id, grade_id, section_id, shiftunique btree, not partialTotal 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_uniqueacademic_session_id, shift, room_id, WHERE room_id IS NOT NULL AND is_activeunique btree (partial)A room may run one class per shift, per session.A retired class releases its room immediately.
classes_teacher_per_shift_uniqueacademic_session_id, shift, class_teacher_id, WHERE class_teacher_id IS NOT NULL AND is_activeunique btree (partial)Same, for a class teacher.
classes_capacity_boundedcapacityCHECK 1..500Fat-finger guard.Blocks correcting a capacity for a class already pushed past 500 by repeated overrides.
classes_name_formatnameCHECK, NULL-admittingTrim/length format on the optional display name.
classes_academic_session_idx, classes_grade_idx, classes_room_idx, classes_class_teacher_idxEach columnbtreeThe class list's filters (gradeId, roomId, classTeacherId) and the session-scoping every read applies.
student_class_enrollments_one_active_per_session_uniquestudent_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 restrictMakes 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_orderedenrolled_on, ended_onCHECKRejects an end date before a start date.
student_class_enrollments_ended_matches_statusstatus, ended_onCHECKRejects an active row with an end date, or a closed row without one.
student_class_enrollments_active_class_idxclass_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_idxstudent_idbtreeA pupil's own enrolment-history read.

6. Services and Responsibilities

6.1 GradesService

MethodCalled ByReadsWritesSide EffectsErrors
findAll(query)GradesController.findAllgrades, filtered by search/isActiveNone

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), idquery.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

MethodCalled ByReadsWritesSide EffectsErrors
findAll(query)SectionsController.findAllsections, filtered by search/isActive
create(dto)SectionsController.createName-uniqueness pre-check (active-only)sections insert, inside a transactionSECTION_NAME_TAKEN (409, pre-check); a race surfaces as RESOURCE_ALREADY_EXISTS from the global fallback.
update(publicId, dto)SectionsController.updateLookup by publicId; name pre-check only if the name actually changed case-insensitivelysections updateSECTION_NOT_FOUND (404); SECTION_NAME_TAKEN (409).
remove(publicId)SectionsController.removeLookup by publicIdsections deleteSECTION_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

MethodCalled ByReadsWritesSide EffectsErrors
findAll(query)RoomsController.findAllrooms, filtered by search/isActive/building/floor
create(dto)RoomsController.create(building, roomNumber) pre-check (active-only)rooms insertROOM_NUMBER_TAKEN (409, pre-check).
update(publicId, dto)RoomsController.updateLookup; pre-check only when the pair actually changes and the result stays/becomes activerooms updateROOM_NOT_FOUND (404); ROOM_NUMBER_TAKEN (409).
remove(publicId)RoomsController.removeLookup; explicit classes.room_id existence checkrooms deleteROOM_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

MethodCalled ByReadsWritesSide EffectsErrors
findAll(query)ClassesController.findAllclasses joined to session/grade/section/room/teacher; ClassOccupancyService.countForManyPAGINATION_LIMIT_INVALID (400) if pagination=false.
findOptions(query)ClassesController.findOptionsclasses (active only) joined to grade/section; ClassOccupancyService.countForMany
findOne(publicId)ClassesController.findOneDelegates to loadDto on the pooled DatabaseCLASS_NOT_FOUND (404).
loadDto(executor, publicId)findOne; ClassesWriteService after every writeSame joins as findAll, single row; ClassOccupancyService.countForCLASS_NOT_FOUND (404).
resolveSessionId(requested) (private)findAll, findOptionsacademic_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, idListClassesQueryDto 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

MethodCalled ByReadsWritesSide EffectsErrors
create(dto)ClassesController.createSession/grade/section existence+active; room/teacher resolution if givenclasses insert, inside a transactionACADEMIC_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.updateLocked row read (FOR UPDATE); ClassOccupancyService.countFor if capacity changes; room/teacher re-resolution only if the value actually changesclasses update, inside a transactionCLASS_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.removeLookup by publicIdclasses deleteCLASS_NOT_FOUND (404); CLASS_HAS_ENROLLMENTS (409, from the composite FK, translated).
findSessionOrThrow/findGradeOrThrow/findSectionOrThrow/resolveRoom/resolveTeacher (private)create/updateSingle-row lookupsSee above.
assertActive/assertTeacherUsable (private, static)create/updateCLASS_*_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

MethodCalled ByReadsWritesSide EffectsErrors
findByClass(actor, classPublicId, query)ClassEnrollmentsController.rollclasses (existence); PeoplePermissionsService.heldPermissions; PeopleAccessService.scopeFor; student_class_enrollments joined to students/users, scopedCLASS_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.

MethodCalled ByReadsWritesSide EffectsErrors
enroll(tx, input, actor)ClassEnrollmentsController.enrolClass 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 lockstudent_class_enrollments insert; predecessor UPDATE on a transferRedisCacheService.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.withdrawClass context; class locked FOR UPDATE; the pupil's active row in this classstudent_class_enrollments update (status, endedOn)Same cache + activity pattern (WITHDRAW)CLASS_NOT_FOUND/ENROLLMENT_NOT_FOUND (404/404).
updateEnrollment(tx, id, dto, actor)ClassEnrollmentsController.updateThe row; its owning class, locked FOR UPDATEstudent_class_enrollments updateSame cache + activity pattern (ENROLLMENT_UPDATE)ENROLLMENT_NOT_FOUND (404).
removeEnrollment(tx, id, actor)ClassEnrollmentsController.removeThe row; its owning class, locked FOR UPDATEstudent_class_enrollments deleteSame cache + activity pattern (ENROLLMENT_DELETE)ENROLLMENT_NOT_FOUND (404).
listForStudent(studentId, query)StudentsController.findEnrollmentsstudent_class_enrollments joined to classes/grades/sections/academic_sessions
resolveClassContext(tx, input) (private)Every writeOne row, by classId or classPublicIdReturns null on no match; callers throw CLASS_NOT_FOUND.
invalidateStudentsCache() (private)Every writedelPatternSoftNever 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 enrolledOninput.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

MethodCalled ByReadsWritesSide EffectsErrors
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 methodOne 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)

StepCode PathBehaviorFailure Case
1ClassesController.findAllReceives the request, applies no logic of its own.DTO validation error (400).
2ClassesReadService.findAllRefuses pagination=false.400 PAGINATION_LIMIT_INVALID.
3resolveSessionIdResolves the explicit or current session.Returns null, handled as empty result, never thrown.
4selectRows/buildWhereBuilds the filtered, joined query.
5ClassOccupancyService.countForManyOne grouped query for every returned class id.

7.2 Create a class

StepCode PathBehaviorFailure Case
1-3findSessionOrThrow/findGradeOrThrow/findSectionOrThrowExistence and active checks, in that order.404/422 per entity.
4resolveRoomExistence + active check, only if roomId given.404 ROOM_NOT_FOUND/422 CLASS_ROOM_INACTIVE.
5resolveTeacher + assertTeacherUsableExistence, not-soft-deleted, isTeaching.404 CLASS_TEACHER_NOT_FOUND/422 CLASS_TEACHER_NOT_TEACHING.
6db.transaction(...)Single-statement insert.Constraint violation, caught by .catch(translate).
7ClassesReadService.loadDtoFresh, 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 PatternBuilderValueTTLInvalidationCaller
students:list:*STUDENTS_CACHE_PREFIX constant, cleared with delPatternSoftN/A — this module never reads or writes the cached value itselfOwned by StudentsService, not this moduleCleared 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, and route-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_UPDATE gate every enrolment route, including the class-side roster and enrol/withdraw routes declared on ClassEnrollmentsController. A caller holding full Classes_* grants but no Students_* 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/grades is gated by Classes_READ — verified against PERMISSION_MODULES in packages/db/src/authorization/permission-catalog.ts, which lists Classes, Sections, Rooms but not Grades.
  • The object-level access boundary — the actual control behind Students_*. PeopleAccessService.scopeFor/applyScope (read paths: the roster, a pupil's enrolment history) and PeopleAccessService.assertCanAccess (write paths: enrol, withdraw, correct, remove) are called explicitly by ClassRosterService and ClassEnrollmentsController — never by ClassEnrollmentsService, which cannot reach PeopleModule without recreating the module cycle documented in 3. assertCanAccess throws 404, never 403, on an out-of-scope pupil — deliberately, since a 403 on 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, Rooms are each declared once in PERMISSION_MODULES; the full catalog is the cartesian product of every module and every action in PERMISSION_ACTIONS (CREATE, READ, UPDATE, DELETE, RESTORE). Classes_RESTORE, Sections_RESTORE, and Rooms_RESTORE all exist as valid, grantable, seeded permission codes — but no route in this module ever checks any of them, since none of sections/rooms/classes has deleted_at and every deletion is a hard delete.
  • Superadmin bypass. Keyed on the is_superadmin boolean, never on a role's display name, checked before the permission list and before the object-level scope check — a superadmin's assertCanAccess call 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:sync creates the Classes_*/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:90 selects role.name IN ("superadmin", "admin"), and this product seeds no role literally named "admin" (verified: no name: "admin" insert exists in seed-auth.ts). pnpm --filter @skoolsewa/db db:seed:prod is the command that actually applies the STAFF_PERMISSIONS/TEACHER_PERMISSIONS grants (Classes_READ, Sections_READ, Rooms_READ) an operator running only permissions:sync after 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 a Matches(NO_SURROUNDING_WHITESPACE) DTO rule mirroring the schema's own POSIX-class CHECK, and trimmed server-side before comparison or persistence. ILIKE search terms are always escaped through escapeLikePattern.
  • Sensitive data redaction. ClassDto/ClassOptionDto deliberately 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; ClassRosterStudentDto is narrower than StudentDto (no DOB, address, phone, guardian, or medical fields) since a roster has no use for them.
  • Audit logs. No recordActivity call accompanies any grade/section/room/class create/update/delete — those rely entirely on the automatic ActivityAuditInterceptor. Every enrolment write does call recordActivity explicitly, 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 CodeHTTP StatusThrown ByConditionClient Action
CLASS_NOT_FOUND404ClassesReadService.loadDto, ClassesWriteService.remove, ClassEnrollmentsService.resolveClassContext-derived callers, ClassRosterService.findByClassNo classes row for the given publicId/id.Refresh; the class may have been deleted.
CLASS_INACTIVE409ClassEnrollmentsService.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_TAKEN409ClassesWriteService.translateclasses_identity_unique refused — deliberately not partial on is_active.Choose a different grade/section/shift, or reactivate the existing class.
CLASS_ROOM_OCCUPIED409ClassesWriteService.translateclasses_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_ASSIGNED409ClassesWriteService.translateclasses_teacher_per_shift_unique refused.Pick a different teacher, or a different shift.
CLASS_TEACHER_NOT_FOUND404ClassesWriteService.assertTeacherUsableclassTeacherId does not resolve, or resolves to a soft-deleted staff row.Confirm the staff id.
CLASS_TEACHER_NOT_TEACHING422ClassesWriteService.assertTeacherUsableThe resolved staff member's designation has isTeaching: false.Choose a staff member whose designation is a teaching one.
CLASS_CAPACITY_INVALID409ClassesWriteService.translateclasses_capacity_bounded refused (only reachable if the DTO's own 1..500 bound is somehow bypassed).Fix the capacity value.
CLASS_NAME_INVALID409ClassesWriteService.translateclasses_name_format refused.Fix the name (no surrounding whitespace, ≤ 128 chars).
CLASS_AT_CAPACITY409ClassEnrollmentsService.enrollenrolledCount >= capacity, allowOverCapacity not set.Resubmit with allowOverCapacity: true, or choose a different class.
CLASS_CAPACITY_BELOW_ENROLLED422ClassesWriteService.updateA PATCH would set capacity below the class's live enrolment count.Resubmit with allowOverCapacity: true, or reduce the roll first.
CLASS_HAS_ENROLLMENTS409ClassesWriteService.translatestudent_class_enrollments_class_session_fk refused a delete.Deactivate instead, or clear every enrolment first.
CLASS_SESSION_INACTIVE422ClassesWriteService.assertActiveThe chosen academic session is retired.Choose an active session.
CLASS_GRADE_INACTIVE422ClassesWriteService.assertActiveThe chosen grade is retired.Choose an active grade.
CLASS_SECTION_INACTIVE422ClassesWriteService.assertActiveThe chosen section is retired.Choose an active section.
CLASS_ROOM_INACTIVE422ClassesWriteService.assertActiveThe chosen (newly-changing) room is retired.Choose an active room, or leave the existing one in place.
SECTION_NOT_FOUND404SectionsService.findOrThrow; ClassesWriteService.findSectionOrThrowNo sections row for the given publicId/id.Refresh.
SECTION_NAME_TAKEN409SectionsService.assertNameFree / .translateAnother active section already has this name, case-insensitively.Choose a different name.
SECTION_NAME_INVALID409SectionsService.translatesections_name_format refused.Fix the name.
SECTION_IN_USE409SectionsService.translateclasses_section_id_sections_id_fk refused a delete.Deactivate instead, or move/retire the referencing classes first.
ROOM_NOT_FOUND404RoomsService.findOrThrow; ClassesWriteService.resolveRoom-derived callersNo rooms row for the given publicId/id.Refresh.
ROOM_NUMBER_TAKEN409RoomsService.assertNumberFree / .translateAnother active room in the same building already has this number.Choose a different number/building.
ROOM_FIELD_INVALID409RoomsService.translaterooms_room_number_format/rooms_building_format/rooms_name_format refused.Fix the offending field.
ROOM_FLOOR_INVALID409RoomsService.translaterooms_floor_bounded refused.Fix the floor (-5..200).
ROOM_IN_USE409RoomsService.remove (explicit pre-check)A class still holds room_id pointing at this room.Deactivate instead, or move the referencing classes first.
GRADE_NOT_FOUND404ClassesWriteService.findGradeOrThrowgradeId does not resolve.Confirm the grade id — never reachable from a grades route itself, since none mutates.
ENROLLMENT_NOT_FOUND404ClassEnrollmentsService.withdraw/updateEnrollment/removeEnrollment; ClassEnrollmentsController.pupilForEnrollmentNo matching (or no active, for withdraw) student_class_enrollments row.Refresh; the row may have already been withdrawn/removed.
ENROLLMENT_ALREADY_ACTIVE409ClassEnrollmentsService.translatestudent_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_DELETED409ClassEnrollmentsService.enrollThe pupil's students row is soft-deleted.Restore the pupil's record first.
ENROLLMENT_DATE_INVALID409ClassEnrollmentsService.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_SESSION409ClassEnrollmentsService.enrollenrolledOn falls outside [session.startDate, session.endDate].Correct the date, or confirm the target session.
ENROLLMENT_DATE_BEFORE_ADMISSION409ClassEnrollmentsService.enrollenrolledOn precedes the pupil's own admissionDate.Correct the date.
ENROLLMENT_STATE_INVALID409ClassEnrollmentsService.translatestudent_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_INVALID400ClassesReadService.findAll; ClassEnrollmentsController.rollpagination=false requested on the class list or a class roster.Remove the pagination=false query param.
RESOURCE_ALREADY_EXISTS409Global (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_FAILED400Global ValidationPipeA 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_UNAUTHENTICATED401JwtAuthGuardMissing/invalid JWT.Re-authenticate.
PERMISSION_INSUFFICIENT403RoleGuardActive 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 409AllExceptionsFilter 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

SignalLocationPurpose
LogAllExceptionsFilter (Logger)Every exception reaching the HTTP boundary is logged at error, including any Postgres cause chain.
Audit (automatic)ActivityAuditInterceptor, globalEvery 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 callsOnly 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.
MetricNone declaredNo 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.tsGradesService's read-only list behavior.
  • academic-structure/__tests__/sections.service.integration.spec.tsSectionsService CRUD, including the transactional sortOrder computation and the partial-unique-index reuse-after-retirement behavior.
  • academic-structure/__tests__/rooms.service.integration.spec.tsRoomsService CRUD, including the explicit ROOM_IN_USE pre-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.sql

Validation commands:

pnpm --filter @skoolsewa/api build
pnpm --filter @skoolsewa/db build
pnpm --filter @skoolsewa/api test:structure

16. Mandatory Backend Deep-Dive Pack

16.1 Submodule Coverage Matrix

UnitTypeOwnsDepends OnCalled ByCallsState TouchedFailure Modes
GradesControllerController/grades (GET only)GradesServiceHTTPGradesService.findAllNone directlyGuard 401/403.
GradesServiceServiceRead-only grade listDatabaseGradesControllerDrizzle queriesgrades (read)None domain-specific.
SectionsControllerController/sections full CRUDSectionsServiceHTTPSectionsService methodsNone directlyDTO 400; guard 401/403.
SectionsServiceServiceSection business rulesDatabaseSectionsControllerDrizzle queries, one transaction on createsections404/409 domain errors.
RoomsControllerController/rooms full CRUDRoomsServiceHTTPRoomsService methodsNone directlySame as above.
RoomsServiceServiceRoom business rulesDatabaseRoomsControllerDrizzle queriesrooms; reads classes on delete404/409 domain errors.
ClassesControllerController/classes list/options/read/create/update/deleteClassesReadService, ClassesWriteServiceHTTPBoth servicesNone directlyDTO 400; guard 401/403.
ClassesReadServiceServiceEvery read path for classes, including occupancy joinsDatabase, ClassOccupancyServiceClassesController, ClassesWriteService (via loadDto)Drizzle queries, ClassOccupancyServiceclasses (read)404; 400 PAGINATION_LIMIT_INVALID.
ClassesWriteServiceServiceCreate/update/delete for classesDatabase, ClassOccupancyService, ClassesReadServiceClassesControllerDrizzle queries, two transactions, ClassOccupancyService.countForclasses404/409/422 domain errors.
ClassRosterServiceServiceA class's scoped pupil rosterDatabase, PeopleAccessService, PeoplePermissionsServiceClassEnrollmentsControllerDrizzle queries, scope resolutionstudent_class_enrollments, students (read)404 CLASS_NOT_FOUND.
ClassEnrollmentsControllerControllerThe five enrolment-adjacent routes on AcademicStructureModuleClassEnrollmentsService, ClassRosterService, PeopleAccessService, PeoplePermissionsServiceHTTPThose fourNone directly404/403/400/409.
ClassEnrollmentsServiceServiceSole writer of student_class_enrollments; per-pupil history readDatabase, RedisCacheService, ClassOccupancyService, ActivityRecordServiceThis controller; StudentsController (read); StudentsService (write, elsewhere)Drizzle queries, transactions passed by callers, delPatternSoft, recordActivitystudent_class_enrollments404/409 domain errors; unmapped constraint → 500 if translate() is ever missing a branch.
ClassOccupancyServiceServiceThe one shared occupancy computationDbExecutor (no injected dependency beyond the query)ClassesReadService, ClassesWriteService, ClassEnrollmentsServiceOne grouped Drizzle querystudent_class_enrollments, students (read)None — pure read, never throws.
class-enrollments.mapper.ts (toEnrollmentDto)Pure functionFlat-row-to-EnrollmentDto mappingClassEnrollmentsService.listForStudentNone — pure function.
AcademicStructureModuleModuleComposition of the five controllers/six providers aboveEnrollmentsCoreModule, PeopleModule, RoleModule, ActivityModuleAppModuleBoot-time InstanceLoader failure if a dependency is uncomposed.
EnrollmentsCoreModuleModuleClassEnrollmentsService, ClassOccupancyServiceActivityModule onlyAcademicStructureModule, PeopleModuleSame 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

InvariantEnforced ByWhy It ExistsFailure ErrorTests
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

DecisionContextChosen OptionAlternativesWhy ChosenTradeoffsRevisit 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

OperationHow to InspectHealthy StateFailure SignalRecovery
Class capacity/occupancy disagreementSELECT 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 enrolledCountThe query and the API agree exactly — both use the identical predicate.A UI-reported count that does not match this queryConfirm 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 deleteExpected — deactivate instead of deleting, per the documented correction path.
Enrolment write failing with an unmapped 500Application logs for the Postgres SQLSTATE and constraint name in the error's .cause chainEvery constraint from migration 0011 has a named branch in one of the two translate() functionsA 500 where a 409/422 was expectedThe 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:prodSELECT 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_READOnly superadmin (and any role named admin, which does not exist) holds the new permission rowsRun pnpm --filter @skoolsewa/db db:seed:prodpermissions:sync alone never grants the staff/teacher reads.
Seed did not populate grades/sectionsSELECT count(*) FROM grades (expect 15), SELECT count(*) FROM sections (expect ≥ 4)Both populated from the seeded baselineEither table reporting 0Run 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

RiskAreaImpactCurrent MitigationRemaining Gap
ClassEnrollmentsService trusts every caller to have already checked scope.Module boundary designA 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: trueA 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 surfaceA 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 invalidationIf 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_active as a flag for classes/sections/rooms, enrollment_status as 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