Skoolsewa - Ecommerce Docs
Developer ResourcesAcademic sessions

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

AreaFiles InspectedVerified Details
Module wiringapps/api/src/modules/academic-sessions/academic-sessions.module.tsImports (RoleModule), controller, provider, export.
Controllerapps/api/src/modules/academic-sessions/academic-sessions.controller.tsRoute ownership, permission decorators, thin-controller boundary.
Serviceapps/api/src/modules/academic-sessions/academic-sessions.service.tsBusiness logic, transactions, cache, error translation.
DTOsapps/api/src/modules/academic-sessions/dto/academic-session.dto.tsContracts and validation.
Schemapackages/db/src/schema/school/academic-sessions.tsTable, indexes, check constraint.
Testsapps/api/src/modules/academic-sessions/__tests__/academic-sessions.service.integration.spec.tsBehaviours proven against a real Postgres database.

2. Backend Scope and Boundaries

Owns

  • CRUD for academic sessions (academic_sessions table).
  • 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/isCurrent filters, sort, and pagination.

Does Not Own

  • Auth/identity — delegated to JwtAuthGuard and RoleGuard (imports RoleModule for RoleService).
  • Any downstream module's use of academic_sessions.id as a foreign key. As of this doc, nothing else in the schema references the table, so a delete cannot yet produce a 23503 foreign-key conflict — the service's own docblock on remove() notes this is expected once classes/enrolment land.

Source of Truth

ConcernSource of TruthNotes
Which sessions exist, and which is currentacademic_sessions tableThe Redis cache is a fail-soft read-through layer over it, never authoritative on its own.
At most one current sessionThe partial unique index academic_sessions_one_current_idxEnforced 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 sessionAcademicSessionsService.updateCannot be expressed as a database constraint; enforced only in application code.

3. Module Composition

ModuleTypePathControllersProvidersExportsResponsibility
AcademicSessionsModuleLeafapps/api/src/modules/academic-sessions/AcademicSessionsControllerAcademicSessionsServiceAcademicSessionsServiceOwns 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
FilePurposeKey Exports
academic-sessions.module.tsWires controller, service, and the RoleModule dependency of the class-level guard.AcademicSessionsModule
academic-sessions.controller.tsFour HTTP routes; validates permissions, delegates everything else.AcademicSessionsController
academic-sessions.service.tsAll business logic: transactions, cache, validation, error translation.AcademicSessionsService
dto/academic-session.dto.tsRequest/response/query contracts and validation.AcademicSessionDto, CreateAcademicSessionDto, UpdateAcademicSessionDto, ListAcademicSessionsQueryDto

5. Data Model

5.1 Schema Source

packages/db/src/schema/school/academic-sessions.ts

5.2 Table: academic_sessions

ColumnTypeNullableDefaultIndex/ConstraintNotes
idserialNogeneratedPKInternal id, still returned in every response.
public_iduuidNouuid7()UNIQUEThe id every route param addresses a row by.
nametextNoacademic_sessions_name_unique — case-insensitive UNIQUE on lower(name)e.g. "2026-27"; trimmed by the service before comparison and write.
start_datedateNoPart of academic_sessions_dates_ordered CHECKISO YYYY-MM-DD.
end_datedateNoPart of academic_sessions_dates_ordered CHECKMust be strictly after start_date.
is_currentbooleanNofalseacademic_sessions_one_current_idx — partial UNIQUE on (is_current) WHERE is_currentAt most one row may hold true; rows holding false are not indexed at all and never collide with each other.
is_activebooleanNotrueRetirement flag; no deleted_at exists on this table.
created_attimestamptzNonow()
updated_attimestamptzNonow(), $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 on lower(name), not on name directly. "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 on is_current WHERE 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 requiring end_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

MethodCalled ByReadsWritesSide EffectsErrors
findAll(query)Controller GETacademic_sessions, RedisCache read-through, setSoft on miss
create(dto)Controller POSTacademic_sessions (name check)academic_sessions (and possibly an UPDATE to unset the incumbent)Cache invalidationACADEMIC_SESSION_NAME_TAKEN, ACADEMIC_SESSION_DATES_INVALID, ACADEMIC_SESSION_CURRENT_CONFLICT
update(publicId, dto)Controller PATCHacademic_sessionsacademic_sessions (and possibly the incumbent unset)Cache invalidationACADEMIC_SESSION_NOT_FOUND, ACADEMIC_SESSION_NAME_TAKEN, ACADEMIC_SESSION_DATES_INVALID, ACADEMIC_SESSION_CURRENT_REQUIRED, ACADEMIC_SESSION_CURRENT_CONFLICT
remove(publicId)Controller DELETEacademic_sessionsacademic_sessions (delete)Cache invalidationACADEMIC_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:

  1. UPDATE academic_sessions SET is_current = false ... WHERE is_current = true (excluding the row being updated, in update's case).
  2. The INSERT (create) or UPDATE (update) that sets the new row's is_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(): if dto.isCurrent === false and the existing row is the current one, the request is refused with 409 ACADEMIC_SESSION_CURRENT_REQUIRED before 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

StepCode PathBehavior
1Controller.updateReceives PATCH { isCurrent: false }.
2Service.updateLoads existing row; sees existing.isCurrent === true and dto.isCurrent === false.
3Service.updateThrows ConflictException with ACADEMIC_SESSION_CURRENT_REQUIRED before any write.

8. Caching

Cache Key PatternBuilderTTLInvalidationCaller
academic-sessions:list:...CacheKeyUtil.build folding search, isActive, isCurrent, sort, order, pagination, page, size3600sdelPatternSoft("academic-sessions:*") on every create/update/removefindAll

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.