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:
yearis unique, including soft-deleted rows — recreating "2025" after deleting it is a409, not a resurrection.orderis a unique, monotonic integer. Omit it and the service assignsnextOrder()=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'sorderto the current year's. There used to be anisFutureboolean — 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-localstartDate/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:
// 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:
/** 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
| Endpoint | Does | Guard rails |
|---|---|---|
POST academic-year | Create global year | year unique incl. soft-deleted; order auto or validated |
PATCH academic-year/:id/set-current | Move the global current pointer | Single global current |
POST .../start-current | Bind campus to global current + set current | Idempotent upsert |
POST ... | Bind campus to any year | JSONB snapshots; born unlocked, not current |
PATCH .../:id/set-current | Switch campus current year | Unlocked only; one transaction |
PATCH .../:id/lock / unlock | Seal / reopen | Sets/clears lockedAt + lockedById |
PATCH .../:id / DELETE .../:id | Edit / soft-delete binding | Unlocked only; delete attachment-gated |
GET campus/:id/academic-context | Current binding + current term in one call | Derived, 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
Request Scoping
How the lock is enforced on every write — assertNotLocked and the request-year vs row-year split.
Scoped Entities
The tables counted by delete protection, and what is deliberately not year-scoped.
Error Handling and Logging
The error envelope the attachments report rides in.
Auditing
Lock, unlock, and set-current are audited writes — how the audit trail works.
Request Scoping
How campusAcademicYearId travels on every year-scoped request — the required-DTO rule, the one exemption, and the scope service behind every write.
Scoped Entities
The eleven entities pinned to a campus academic year, the structural data that deliberately is not, and children that scope through their parent.