Naalya Handbook
Academic-Year Scoping

Locking & Lifecycle

Creating, starting, and sealing academic years — the lock write-gate, transactional set-current, order-derived phase, and delete protection.

A year's life: created globally once for the whole school, started per campus, pointed at as current, and eventually locked — the one state that blocks writes. This page walks each transition and the guard rails around it.

Create the global year

POST academic-year creates the school-wide row — year, name, startDate, endDate. Two guard rails:

  • year is unique, including soft-deleted rows — recreating "2025" after deleting it is a 409, not a resurrection.
  • order is a unique, monotonic integer. Omit it and the service assigns nextOrder() = MAX + 1; supply it and it's validated unique. This one column replaces every stored time flag: past / current / future is always derived by comparing a year's order to the current year's. There used to be an isFuture boolean — it was removed precisely because stored flags drift; never reintroduce one.

PATCH academic-year/:id/set-current moves the single global current pointer (one isCurrent row school-wide).

Start it on a campus

Two doors into a campus_x_academic_year binding, both under campus/:campusId/academic-year:

  • POST .../start-current — the everyday path. Finds the global current year, upserts the binding (idempotent — clicking twice is safe), and sets it current for the campus. This is the "Start 2026 Academic Year" button in the staff hub.
  • POST ... (plain create) — bind the campus to any year with campus-local startDate/endDate. Used for future years and catch-up setups. The binding snapshots both campus and year into JSONB columns at creation, starts unlocked and not current.

Set-current is one transaction

Switching the campus's current year touches three rows, and they must move together:

apps/server/src/app/academic-year/campus-academic-year.service.ts
// Flag and campus pointer must move together or not at all — the staff
// hub reads both and treats disagreement as "no current year".
await this.dataSource.transaction(async (manager) => {
  await manager.update(CampusXAcademicYearEntity,
    { campusId, isCurrent: true }, { isCurrent: false });  // 1. unset all siblings
  await manager.update(CampusXAcademicYearEntity,
    { id }, { isCurrent: true });                          // 2. set the target
  await manager.update(CampusEntity,
    { id: campusId }, { currentAcademicYearId: id });      // 3. move the denormalised pointer
});

That comment is the why: the frontend reads both the flag and campus.currentAcademicYearId and treats disagreement as "no current year" — a crash between separate updates would blank the whole hub. The row must also be unlocked to be set current (assertRowUnlocked), the same rule that guards update and remove on the binding itself.

Lock and unlock

PATCH .../:id/lock stamps lockedAt + lockedById; unlock clears them. That timestamp is the entire write-gate mechanism: every year-scoped mutation anywhere in the API funnels through assertNotLocked, which checks nothing but this column (see Request Scoping). isCurrent never gates anything — a school can lock the current year during an exam-board freeze and the whole campus goes read-only while still displaying normally.

Delete protection

Deleting a binding — or a global year, via all its bindings — first counts every table registered in CAMPUS_YEAR_RESOURCES:

apps/server/src/app/academic-year/academic-year-attachment.helper.ts
/** Year-scoped tables counted before soft-deleting a campus academic year. */
const CAMPUS_YEAR_RESOURCES = [
  { resource: 'campus_term', entity: CampusTermEntity },
  { resource: 'enrollment', entity: EnrollmentEntity },
  // ...eleven in total
] as const;

Any rows at all → ConflictException (409) whose body carries a per-resource attachment report (enrollment: 214, grade: 1893, …) in the standard error envelope's attachments field — the frontend renders it as "why you can't delete this". Registering your entity in this array is step 2 of making it year-scoped; forget it and your data becomes silently deletable-around.

The endpoint map

EndpointDoesGuard rails
POST academic-yearCreate global yearyear unique incl. soft-deleted; order auto or validated
PATCH academic-year/:id/set-currentMove the global current pointerSingle global current
POST .../start-currentBind campus to global current + set currentIdempotent upsert
POST ...Bind campus to any yearJSONB snapshots; born unlocked, not current
PATCH .../:id/set-currentSwitch campus current yearUnlocked only; one transaction
PATCH .../:id/lock / unlockSeal / reopenSets/clears lockedAt + lockedById
PATCH .../:id / DELETE .../:idEdit / soft-delete bindingUnlocked only; delete attachment-gated
GET campus/:id/academic-contextCurrent binding + current term in one callDerived, never stored — the staff hub's default view

Current limitations — say them out loud

No rollover automation. start-current activates a binding, but nothing carries students, enrollments, or terms into the new year — promotion is manual re-enrollment today. No @RequirePermissions on the year controllers — they're guarded by JWT only; flagged as follow-up work in the API repo. Both are known, deliberate gaps, not oversights to "fix" in passing.

Where to go next

On this page