Naalya Handbook

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:

  1. Year unlockedassertNotLocked(campusAcademicYearId, campusId), the standard scoping gate.
  2. Forward-onlyassertCurrentOrFuture: you can never enroll into a past year, even an unlocked one. (Corrections to past years happen on existing rows, not new ones.)
  3. Duplicate protection, twice — an app-level lookup throws a 409 that includes the existing enrollment's reference and status; a Postgres 23505 catch backstops the race where two requests slip past the lookup simultaneously.
  4. 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_class rows for the class + stream flagged compulsory) and NCDC curriculum bands (compulsory_year_bands matched 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 a 409.
  • 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

ConsumerHow
GradingMatrix roster = the stream's active enrollments
ClassroomlistRoster backs the class-teacher's roster and gradebook
CBTEligibility = active enrollment in the exam's year, taking the subject, with a stream assignment
Payment chargesAudience targeting reads the student's latest active enrollment (class/stream/campus)
GuardiansAn enrollment grants linked guardians access to that campus

Where to go next

On this page