Enrollment
The row that puts a student in a class for one academic year — statuses, compulsory subjects, stream moves, and the modules that read it.
An enrollment is the link between a student and the academic structure for one year: student + campus year + class (+ stream), unique per (studentId, campusAcademicYearId) at the database level. It's the most-consumed row in the system — grading builds its roster from it, CBT checks eligibility through it, payment charges target it — which is why its guard rails are the strictest.
The entity
enrollment (@TenantScoped, @Unique(['studentId','campusAcademicYearId'])): campusId, studentId, campusAcademicYearId, classId, nullable streamId, status, dateEnrolled, and a unique human reference shaped <enrollmentNo>/<year> (e.g. enr_a1B2c/2025 — the student's lifelong enrollmentNo is minted on their first enrollment and stored on the profile).
Its child, student_x_subject (unique per (enrollmentId, subjectId)), carries one flag that drives most of the rules below: isCompulsory.
Status is a 12-value enum (pending, active, revoked, expired, suspended, transferred, graduated, dropped, withdrawn, rejected, cancelled, excluded) — but only two are ever stamped by lifecycle code: creation defaults to pending, and staff confirm is the only path to active. Everything else is set via the generic update endpoint; don't invent new transitions in code without a reason.
Creating one — the guard chain
EnrollmentService.create runs, in order:
- Year unlocked —
assertNotLocked(campusAcademicYearId, campusId), the standard scoping gate. - Forward-only —
assertCurrentOrFuture: you can never enroll into a past year, even an unlocked one. (Corrections to past years happen on existing rows, not new ones.) - Duplicate protection, twice — an app-level lookup throws a
409that includes the existing enrollment's reference and status; a Postgres23505catch backstops the race where two requests slip past the lookup simultaneously. - After insert: compulsory subjects are assigned (below), the student's linked guardians get campus access synced (placement drives guardian visibility), and any supplied electives are added.
Guardians have their own door: enrollMyChild (guardian-enrollment service) verifies the guardian-student link, proposes the next class via getNextClass, resolves the campus's current year, and delegates to create with status: pending — staff then confirm, which requires a stream ("compulsory subjects are stream-specific"), re-syncs those subjects, and stamps active.
Subjects — compulsory vs elective
- Compulsory assignment is idempotent and unions two sources: school overrides (
subject_x_classrows for the class + stream flagged compulsory) and NCDC curriculum bands (compulsory_year_bandsmatched to the class's year band — a no-op until curriculum data lands). It requires a stream; a stream-less pending enrollment simply has no subjects yet. - Electives must actually be offered to the class/stream (
subject_x_class), can't be added without a stream, and duplicates are a409. - A compulsory subject cannot be removed from an enrollment —
400 "Cannot remove a compulsory subject". Compulsory rows only disappear via structural changes: a subject unassigned from the stream hard-deletes its rows; a soft-deleted subject soft-deletes them in code (the DB cascade can't see soft deletes).
Stream moves
updateStream moves a student between streams of the same class only (there is no class-move endpoint — that's a new-year re-enrollment). On a move it drops subjects exclusive to the old stream, keeps ones shared with the new, then re-syncs compulsory subjects. POST stream/:streamId/sync-compulsory backfills a whole stream's active + pending enrollments after subject changes.
Reading it
Lists are cursor-paginated (search = reference only — names live on the joined profile) with the usual filters, and enrollment owns the platform's one sanctioned year exemption: campusAcademicYearId is required unless studentId is present — the person-history read (see Request Scoping). GET stream/:streamId/roster returns the stream's enrollments in a year across all statuses — the view is year-bound, so filtering to active is the caller's choice.
Who consumes it
| Consumer | How |
|---|---|
| Grading | Matrix roster = the stream's active enrollments |
| Classroom | listRoster backs the class-teacher's roster and gradebook |
| CBT | Eligibility = active enrollment in the exam's year, taking the subject, with a stream assignment |
| Payment charges | Audience targeting reads the student's latest active enrollment (class/stream/campus) |
| Guardians | An enrollment grants linked guardians access to that campus |
Where to go next
Assigning Roles
Attaching a role to a staff user with a campus scope — the assign endpoint, automatic assignment, why scope never lives on a permission, and gotchas.
Classroom
The teacher workspace — relationship-authorized rosters, grade matrices, and gradebooks over one stream, with admin fallback on reads only.