Setup Wizard¶
The setup wizard configures a school's first academic year. A single Tenant.setupStep drives navigation; handlers delegate persistence to domain services. Read CRUD patterns for domain writes and import pipeline for people imports.
1. State and access¶
The authoritative order is SETUP_STEP_ORDER in src/setup/constants/setup-steps.ts:
Completion is setupStep === COMPLETE. It is terminal: every subsequent wizard submission returns 409 SETUP_ALREADY_COMPLETE, including backward navigation. Reopening the wizard would re-enable destructive setup import semantics.
The database enum also contains CURRICULUM and INVITATIONS, but they are not routable steps. parseSetupStep accepts only the ordered set (and maps null to SCHOOL). Swagger step enums must use SETUP_STEP_ORDER, not all database enum values.
Groups are navigation ranges, not separate state machines:
| Group | Steps |
|---|---|
school-identity |
SCHOOL, YEAR, DEPARTMENTS, GRADES, ROOMS |
people-import |
STAFF, TEACHERS, STUDENTS, ROLLOVER |
SetupController uses JWT authentication and RolesGuard. Overview is readable by any authenticated user; state, navigation and rollover routes require admin. Setup does not use entity scope/action guards.
2. Routes and navigation¶
All paths are relative to /api/v1.
| Method | Path | Purpose |
|---|---|---|
| GET | /configure/setup/overview |
Group status: NOT_STARTED, IN_PROGRESS or DONE |
| GET | /configure/setup/:groupId |
Global current step and its persisted data |
| POST | /configure/setup/:groupId |
Save or navigate with { currentStep, targetStep, data? } |
| GET | /configure/setup/people-import/rollover/students |
Paginated rollover review |
| POST | /configure/setup/people-import/rollover |
Apply or skip rollover |
ParseGroupIdPipe returns 404 for unknown groups. A valid group ID does not filter state or change navigation rules. POST routes return 200 on success.
| Navigation | Save behavior | Completion gate |
|---|---|---|
| Forward by one step | Data required when the handler has a DTO; validate and save | Handler isComplete() must pass |
| Same step | Optional data; validation/save failures surface | None |
| Backward | Optional best-effort save; failures are logged and swallowed | None |
Skipping forward is invalid. The client's current step must match the stored step. Forward and backward advances use a conditional update against the captured step so competing sessions cannot silently overwrite navigation.
A successful backward move is not confirmation that a draft was saved. The response reloads the target step. Clients should retain incomplete drafts locally or omit unsavable data when navigating backward.
Important errors:
| Code | HTTP | Meaning |
|---|---|---|
SETUP_ALREADY_COMPLETE |
409 | Wizard is terminal |
SETUP_STEP_MISMATCH |
409 | Client step differs from stored state |
SETUP_STEP_CONCURRENT_ADVANCE |
409 | State changed between read and conditional advance |
SETUP_INVALID_NAVIGATION |
400 | Invalid target, including skipped forward steps |
SETUP_DATA_REQUIRED |
400 | Missing data for a forward form submission |
SETUP_STEP_INCOMPLETE |
400 | Completion check failed |
VALIDATION_FAILED |
400 | Input DTO validation failed |
SETUP_VALIDATION_FAILED |
400 | Domain setup constraint failed |
The error renderer owns wire messages and safe data; internal exception parameters are not a public response contract. See error handling.
3. Handler registry¶
SetupModule assembles STEP_HANDLERS. Each registration contains a step, an optional DTO constructor and a handler:
interface StepHandler {
load(tenantId: string): Promise<StepData>;
save(
tenantId: string,
data: object,
command?: StepSaveCommand,
): Promise<SetupStepSideEffects | void>;
isComplete(tenantId: string): Promise<boolean>;
}
StepSaveCommand carries the authenticated RecordAccessContext to delegated writers. Preserve it when adding handlers so actor attribution and authorization remain intact. Programmatic callers may omit it where the domain service permits that.
| Handler shape | Steps | Persistence and completion |
|---|---|---|
Generic FormStepHandler |
DEPARTMENTS, ROOMS | Active-year bulkSync, getSetupSummary, isConfigured |
| Bespoke form handlers | SCHOOL, YEAR, GRADES | Tenant singleton, year, or nested-grade service methods |
| People import handlers | STAFF, TEACHERS, STUDENTS | No-op save; separate import routes persist records |
| Rollover handler | ROLLOVER | No-op save; dedicated apply route |
| No handler | COMPLETE | Terminal state |
People-step completion uses the tenant's corresponding import-handshake timestamp, not the existence of a person row. ROLLOVER uses the students handshake; applying rollover is not a prerequisite for navigation to COMPLETE.
Year-scoped form services implement YearScopedSetupConfigurable in src/common/interfaces/setup-configurable.interface.ts. Handlers call services, not Prisma. Domain services own validation, synchronization, transaction boundaries and dependent-row handling.
4. School, year, departments and grades¶
SCHOOL persists the school singleton. YEAR accepts { academicYear: { name, gracePeriodEnding? } }; the name is 2–100 characters. The wizard creates the active year. Calendar bounds and TERM/CLOSING periods belong to departments.
Pre-year completeness¶
gracePeriodEnding is an explicit deadline, nullable when omitted. It must precede the earliest department calendar start in the same year:
src/common/utils/assert-pre-year-grace-period.ts enforces both directions:
- Year writes reject an invalid deadline with
422 ACADEMIC_YEAR_GRACE_PERIOD_INVALID. - Department writes reject an invalid calendar start with
400 SETUP_VALIDATION_FAILED. - A year without departments can accept a deadline.
- A department PATCH checks this relation when its start changes; unrelated edits do not become a repair gate for the existing start.
After setup, year names are DRAFT-only, while the deadline is editable in DRAFT or ACTIVE. Department calendars remain subject to the live temporal calendar guards. Family curriculum-selection windows end at the deadline, so mid-year curriculum changes require an authorized school editor. Automatic family reminders use these dates; see notifications.
Department synchronization¶
DEPARTMENTS requires unique names and contiguous zero-based ordinal positions. Each department supplies calendar start/end dates. Optional TERM and CLOSING arrays must remain within those bounds; same-type periods cannot overlap or repeat a name.
attendanceMode defaults to DAILY and studentPlatformAccess to false on create. Omitting either on update preserves it. Setup bulk synchronization permits initial attendance-mode configuration independently of the live PATCH freeze rule.
Submitted arrays are authoritative. Dropping a department can delete its students, grades and curricula through explicit domain cleanup before structural deletion. Deleted students release their invitations and login accounts exactly as a single delete does (membership lifecycle). Rooms survive with a null department. Empty standalone subject groups must be cleared before their restricted curriculum/grade parents can be removed. This setup cleanup does not authorize equivalent destructive behavior on live department DELETE routes.
Grade synchronization¶
GRADES accepts { departments: [{ id, grades: [...] }] }. All departments in the active year must be represented, each with at least one grade. IDs must belong to that year; grade names and positions are validated per department.
Removing grades invokes dependent cleanup before deleting the restricted parent rows; the grade's students are deleted and release their accounts as in a department drop. If a curriculum exists and the resulting grade catalogue contains uncovered grades, synchronization can return a non-blocking CURRICULUM_COVERAGE_BROKEN warning. Follow the domain service's cleanup rather than duplicating deletes in a handler.
Canonical implementations: src/departments/departments.service.ts, src/academic-years/academic-years.service.ts, src/rooms/rooms.service.ts.
5. Rollover¶
The review route and apply command operate on eligible ENROLLED/PRE_ENROLLED students in the active year. The review exposes the source cohort, terminal-grade flag and suggested target cohort. The wizard state embeds its initial page plus total count and rolloverAppliedAt.
The command accepts { decision: 'APPLY' | 'SKIP', retain?: string[], leave?: string[] }. SKIP returns no body; APPLY returns counts and appliedAt. Neither command advances the wizard.
StudentsService.applyRollover owns the operation:
- Acquire the enrollment/schedule graph lock and recheck the year's one-shot marker inside the transaction.
- Validate disjoint retain/leave lists against eligible students.
- Resolve each student's governing placement for the captured school day, with the head cohort as the fallback used by this setup operation.
- Build promotion targets from department and grade ordering. Leaving sets LEFT; terminal promotion sets GRADUATED; retained students stay in place; promoted students retain their participation status.
- Resolve boundaries per source cohort. The rollover boundary is pinned no earlier than the day after the source department's calendar end.
- Reject conflicting booked placement changes before writing. A promotion with identical scheduled date and destination can coalesce; exits do not.
- Write through the placement helpers, close membership/selection episodes on exit, record structural audit events, mark timetable review where needed, and store
rolloverAppliedAt.
This is a temporal operation; do not replace it with direct updates of Student.departmentId and gradeId. Ordinary student PATCH also supports authorized placement commands through the placement write path.
APPLY is one-shot per active year. A successful student re-import during setup clears the marker in its import transaction; post-setup imports do not. The setup rollover path does not perform student account revocation, so it must not be reused as a general post-setup exit command.
Rollover errors are ROLLOVER_ALREADY_APPLIED (409), ROLLOVER_INVALID_STUDENT_IDS (400, including incompatible pending changes), and ROLLOVER_NO_STUDENTS (409).
6. Save side effects¶
A successful save may include sideEffects on its POST response:
{
deleted?: {
departments?: Array<{ id: string; name: string }>;
grades?: Array<{ id: string; name: string }>;
students?: Array<{ id: string; firstName: string; lastName: string }>;
curricula?: Array<{ id: string; name: string }>;
};
warnings?: Array<{
code: 'CURRICULUM_COVERAGE_BROKEN';
data: { grades: Array<{ id: string; name: string }> };
}>;
}
The field describes that save only; it is absent from GET state and omitted when no deletion or warning needs reporting. Empty standalone group cleanup is not listed. Clients own presentation of typed warning codes.
The type chain is SetupStepSideEffects → StepHandler.save → SetupService.submitStep → SetupSideEffectsDto. Keep runtime and Swagger shapes aligned.
7. DTOs and extension pattern¶
SetupPostDto.data is an optional object. SetupService.validateStepData chooses the registered DTO from the current step, calls plainToInstance, then validates with whitelist and forbidNonWhitelisted. Import/review steps have no registered DTO.
Nested arrays need @ValidateNested({ each: true }) and @Type(() => ItemDto). Input row IDs may be optional; response subclasses can redeclare them as required with declare. Date fields using @IsDate() need conversion through @Type(() => Date).
Swagger uses per-step response DTOs with currentStep as a discriminator. Register them in setup.swagger.ts; they document the union and do not choose runtime handlers.
To add a step, follow workflows. Update ordering, groups, DTOs, handler registration and response documentation together. Prefer the generic form handler when the domain service implements the existing year-scoped contract.
Useful sources:
| File | Responsibility |
|---|---|
src/setup/setup.service.ts |
Navigation, validation and conditional advances |
src/setup/setup.controller.ts |
Route authorization and caller context |
src/setup/setup.module.ts |
Handler registration |
src/setup/step-handlers/index.ts |
Handler and command contracts |
src/setup/constants/setup-steps.ts |
Routable order and parsing |
src/setup/constants/setup-groups.ts |
Group ranges |
src/setup/dto/setup-state-response.dto.ts |
State union |
src/students/students.service.ts |
Rollover review and apply |
src/common/utils/sync-entities.ts |
Shared authoritative-array synchronization |
Handler tests instantiate classes with mocked domain services; state-machine tests provide mock registrations. DTO tests use class-validator directly. See testing.
8. Work after setup¶
Curricula are managed through /curricula, including POST /curricula/presets/expand. Invitations use GET /dashboard/onboarding and POST /invitations/send; they are not wizard steps. Onboarding list entity types are lowercase, while send recipients use the uppercase RecipientType values.
The command-center next-steps cards guide follow-up work. Keep those workflows separate from wizard completion so delegated staff can operate through their own permissions.