Academic Sessions Backend Documentation
Backend architecture, data model, service behavior, and the split enforcement of the "one current session" rule for academic sessions.
Academic Sessions - Backend Documentation
1. Documentation Evidence
| Area | Files Inspected | Verified Details |
|---|---|---|
| Module wiring | apps/api/src/modules/academic-sessions/academic-sessions.module.ts | Imports (RoleModule), controller, provider, export. |
| Controller | apps/api/src/modules/academic-sessions/academic-sessions.controller.ts | Route ownership, permission decorators, thin-controller boundary. |
| Service | apps/api/src/modules/academic-sessions/academic-sessions.service.ts | Business logic, transactions, cache, error translation. |
| DTOs | apps/api/src/modules/academic-sessions/dto/academic-session.dto.ts | Contracts and validation. |
| Schema | packages/db/src/schema/school/academic-sessions.ts | Table, indexes, check constraint. |
| Tests | apps/api/src/modules/academic-sessions/__tests__/academic-sessions.service.integration.spec.ts | Behaviours proven against a real Postgres database. |
2. Backend Scope and Boundaries
Owns
- CRUD for academic sessions (
academic_sessionstable). - The handover of the "current session" flag between rows, made atomic within one database transaction.
- Refusal to unset the last current session (the half of the invariant the database cannot express).
- Listing with search,
isActive/isCurrentfilters, sort, and pagination.
Does Not Own
- Auth/identity — delegated to
JwtAuthGuardandRoleGuard(importsRoleModuleforRoleService). - Any downstream module's use of
academic_sessions.idas a foreign key. As of this doc, nothing else in the schema references the table, so a delete cannot yet produce a23503foreign-key conflict — the service's own docblock onremove()notes this is expected once classes/enrolment land.
Source of Truth
| Concern | Source of Truth | Notes |
|---|---|---|
| Which sessions exist, and which is current | academic_sessions table | The Redis cache is a fail-soft read-through layer over it, never authoritative on its own. |
| At most one current session | The partial unique index academic_sessions_one_current_idx | Enforced by Postgres; the service does not separately guard against a second true row — it relies on the index and translates the resulting constraint violation. |
| At least one current session | AcademicSessionsService.update | Cannot be expressed as a database constraint; enforced only in application code. |
3. Module Composition
| Module | Type | Path | Controllers | Providers | Exports | Responsibility |
|---|---|---|---|---|---|---|
AcademicSessionsModule | Leaf | apps/api/src/modules/academic-sessions/ | AcademicSessionsController | AcademicSessionsService | AcademicSessionsService | Owns the academic sessions API end to end. |
RoleModule is imported because RoleGuard is applied at the controller class level and depends on RoleService — the module's own docblock notes that a class-level guard whose module never imports its dependency compiles and unit-tests clean, then fails at InstanceLoader naming the consumer, not the missing provider.
4. File and Directory Map
apps/api/src/modules/academic-sessions/
academic-sessions.module.ts
academic-sessions.controller.ts
academic-sessions.service.ts
dto/
academic-session.dto.ts
__tests__/
academic-sessions.service.integration.spec.ts| File | Purpose | Key Exports |
|---|---|---|
academic-sessions.module.ts | Wires controller, service, and the RoleModule dependency of the class-level guard. | AcademicSessionsModule |
academic-sessions.controller.ts | Four HTTP routes; validates permissions, delegates everything else. | AcademicSessionsController |
academic-sessions.service.ts | All business logic: transactions, cache, validation, error translation. | AcademicSessionsService |
dto/academic-session.dto.ts | Request/response/query contracts and validation. | AcademicSessionDto, CreateAcademicSessionDto, UpdateAcademicSessionDto, ListAcademicSessionsQueryDto |
5. Data Model
5.1 Schema Source
packages/db/src/schema/school/academic-sessions.ts5.2 Table: academic_sessions
| Column | Type | Nullable | Default | Index/Constraint | Notes |
|---|---|---|---|---|---|
id | serial | No | generated | PK | Internal id, still returned in every response. |
public_id | uuid | No | uuid7() | UNIQUE | The id every route param addresses a row by. |
name | text | No | — | academic_sessions_name_unique — case-insensitive UNIQUE on lower(name) | e.g. "2026-27"; trimmed by the service before comparison and write. |
start_date | date | No | — | Part of academic_sessions_dates_ordered CHECK | ISO YYYY-MM-DD. |
end_date | date | No | — | Part of academic_sessions_dates_ordered CHECK | Must be strictly after start_date. |
is_current | boolean | No | false | academic_sessions_one_current_idx — partial UNIQUE on (is_current) WHERE is_current | At most one row may hold true; rows holding false are not indexed at all and never collide with each other. |
is_active | boolean | No | true | — | Retirement flag; no deleted_at exists on this table. |
created_at | timestamptz | No | now() | — | |
updated_at | timestamptz | No | now(), $onUpdateFn | — |
There is no deleted_at on this table, matching the other lookup tables in the school domain: a soft delete never fires ON DELETE restrict, so a soft-deleted session would keep resolving for any record still pointing at it while disappearing from the admin's list. Retirement is is_active = false; hard deletion (DELETE /api/academic-sessions/:publicId) is permitted only when the session is not the current one, and, once other tables reference academic_sessions.id, will additionally be constrained by whatever ON DELETE behavior those foreign keys declare.
5.3 Constraints in Detail
academic_sessions_name_unique— a unique index onlower(name), not onnamedirectly."2026-27"and"2026-27 "differ to a human by nothing and to a plain unique index by everything, so the service trims the name before every write and this index folds case.academic_sessions_one_current_idx— a partial unique index onis_currentWHERE is_current. This is the "at most one" half of the current-session invariant; see Section 6 for the "at least one" half, which lives in the service instead.academic_sessions_dates_ordered— a CHECK constraint requiringend_date > start_date. A zero-length or inverted session is treated as a broken row, not an unusual one, because every date-range query against it would silently return nothing.
6. Services and Responsibilities
6.1 AcademicSessionsService
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
findAll(query) | Controller GET | academic_sessions, Redis | — | Cache read-through, setSoft on miss | — |
create(dto) | Controller POST | academic_sessions (name check) | academic_sessions (and possibly an UPDATE to unset the incumbent) | Cache invalidation | ACADEMIC_SESSION_NAME_TAKEN, ACADEMIC_SESSION_DATES_INVALID, ACADEMIC_SESSION_CURRENT_CONFLICT |
update(publicId, dto) | Controller PATCH | academic_sessions | academic_sessions (and possibly the incumbent unset) | Cache invalidation | ACADEMIC_SESSION_NOT_FOUND, ACADEMIC_SESSION_NAME_TAKEN, ACADEMIC_SESSION_DATES_INVALID, ACADEMIC_SESSION_CURRENT_REQUIRED, ACADEMIC_SESSION_CURRENT_CONFLICT |
remove(publicId) | Controller DELETE | academic_sessions | academic_sessions (delete) | Cache invalidation | ACADEMIC_SESSION_NOT_FOUND, ACADEMIC_SESSION_IS_CURRENT |
The current-session handover, in both create and update
Both methods, when asked to set isCurrent: true, run the following inside one db.transaction:
UPDATE academic_sessions SET is_current = false ... WHERE is_current = true(excluding the row being updated, inupdate's case).- The
INSERT(create) orUPDATE(update) that sets the new row'sis_current = true.
The unset always happens first. Reversing the order — inserting a second is_current = true row before clearing the old one — would violate academic_sessions_one_current_idx at the write itself, regardless of which request the caller intended to win. When two requests race to make different sessions current, one succeeds and the other's UPDATE/INSERT is caught by translate() and reported as 409 ACADEMIC_SESSION_CURRENT_CONFLICT.
The two halves of the invariant
- At most one current — enforced by
academic_sessions_one_current_idx. The service never separately checks for an existing current row before writing; it relies on the index and translates the violation if the race happens. - At least one current — enforced only in
update(): ifdto.isCurrent === falseand the existing row is the current one, the request is refused with409 ACADEMIC_SESSION_CURRENT_REQUIREDbefore any write is attempted. This cannot be pushed into the database, because no CHECK or index can require that a row with a given value continues to exist — a constraint only ever restricts what CAN be written, it cannot force a write to happen.
Validation order in update
Dates are validated dto-over-existing-row, not from the DTO alone: a PATCH supplying only startDate is checked against the row's stored endDate (and vice versa). Checking only the supplied field would let a caller invert the range across two separate requests, and the resulting failure would surface as an unmapped 23514 from the CHECK constraint rather than a named ACADEMIC_SESSION_DATES_INVALID.
Delete
remove() refuses when the target is the current session (409 ACADEMIC_SESSION_IS_CURRENT), then does a hard DELETE. There is no soft delete on this table.
Error translation
AcademicSessionsService.translate() maps constraint violations to named error codes by walking the error's cause chain for the constraint name (academic_sessions_one_current_idx, academic_sessions_name_unique, academic_sessions_dates_ordered), because Drizzle's wrapped driver error does not carry a .constraint property directly on itself — the same pattern the service's own comment cites as having caused a live defect once already in PersonWriterService.translate. Everything not matched is rethrown unchanged.
7. Runtime Flows
7.1 Create, including handover
7.2 Update — making a different session current
7.3 Refused unset of the only current session
| Step | Code Path | Behavior |
|---|---|---|
| 1 | Controller.update | Receives PATCH { isCurrent: false }. |
| 2 | Service.update | Loads existing row; sees existing.isCurrent === true and dto.isCurrent === false. |
| 3 | Service.update | Throws ConflictException with ACADEMIC_SESSION_CURRENT_REQUIRED before any write. |
8. Caching
| Cache Key Pattern | Builder | TTL | Invalidation | Caller |
|---|---|---|---|---|
academic-sessions:list:... | CacheKeyUtil.build folding search, isActive, isCurrent, sort, order, pagination, page, size | 3600s | delPatternSoft("academic-sessions:*") on every create/update/remove | findAll |
The key carries no per-caller scope segment: a session record has no per-person data and no permission-gated column, so every caller who can reach the route sees the same rows, and there is nothing a shared key could leak — noted directly in the service's own comment.
Academic Sessions Features and Flows
Feature list, actor journeys, business rules, and edge cases for the academic sessions module.
Academic Sessions API Reference
Complete API contracts for the academic sessions lookup, including routes, auth, DTOs, responses, errors, and the split enforcement of the "one current session" rule.