Setup Wizard¶
The tenant setup wizard drives first-time configuration for a new school. It combines a flat global state machine stored on the Tenant model with a group routing layer for frontend navigation, and a handler registry that keeps each step's persistence logic isolated and testable.
Cross-references: chapter 05 for general DTO patterns, chapter 06 for error codes, chapter 11 §10 for the add-a-step checklist.
1. State Machine¶
Linear progression through all steps, stored as a single setupStep enum on the Tenant row:
Curricula are not a setup step. They are didactic config owned by principals/department heads, who the single onboarding admin can't onboard before finishing setup — so curriculum building moves entirely post-setup onto the live /curricula DRAFT⇄READY surface, driven by the command-center "next steps" to-do (GET /dashboard/next-steps). Neither are invitations (removed 2026-07-02): sending credentials happens post-setup via the operator surface (GET /dashboard/onboarding to list/filter recipients + POST /invitations/send), also nudged by the next-steps to-do — the wizard never owned any of that data, so the step was pure navigation ceremony. Within people-import, STUDENTS stays immediately before ROLLOVER — rollover operates on imported students and its isComplete() piggybacks on studentsImportedAt.
The Prisma
SetupStepenum still carries orphanedCURRICULUMandINVITATIONSvalues (dropping a PG enum value is destructive). Both are absent fromSETUP_STEP_ORDER/SETUP_GROUPS/the handler registry, so the state machine can never route to them;parseSetupStepthrows if a row somehow holds one. Migration20260702160000_remove_invitations_setup_steprehomes any tenant parked onINVITATIONStoROLLOVER(the final, freely advanceable step — flipping it to COMPLETE would silently switch the tenant into post-setup semantics).Because the enum object retains the orphans, the Swagger
@ApiProperty({ enum })on every step field (steps,currentStep,previousStep,nextStep,targetStep) is keyed offSETUP_STEP_ORDER, not the rawSetupStepenum — otherwise the orphans surface in the OpenAPI doc / Scalar as selectable values. Keep the runtime type (SetupStep) and@IsEnum(SetupStep)validator as-is; only the doc-facingenum:list is constrained. New step-enum DTO fields must follow this — neverenum: SetupStep.
Setup completion is derived from setupStep === COMPLETE. There is no separate completion timestamp.
Forward navigation requires the current step's handler to confirm completion via isComplete(). The check runs after save() but before the step pointer advances — so form steps that persist data are gated automatically. Import steps (STUDENTS, TEACHERS, STAFF) use a separate /import endpoint and are still gated: their isComplete() checks that at least one record exists. The ROLLOVER step has no DTO and a no-op save(); its apply/skip action goes through a dedicated POST (see §6) and its isComplete() reuses the STUDENTS handshake (studentsImportedAt) so the backend never blocks advance on whether apply ran — the mandatory FE decision is enforced client-side, and the only hard invariant (one-shot apply) lives on the apply route itself. Steps without handlers (COMPLETE) pass through freely.
Same-step saves (drafts) and back-navigation do not check completion.
2. Groups¶
Steps are organized into logical groups for frontend navigation. Groups are constant ranges — the overview API maps each group to its steps and computes a NOT_STARTED | IN_PROGRESS | DONE status:
Groups are ordered school-identity → people-import (SETUP_GROUP_ORDER); each group's steps array is in SETUP_STEP_ORDER order, because getOverview() derives the group's first/last step index from steps[0] / steps[last].
| Group ID | Label | Required | Steps |
|---|---|---|---|
school-identity |
School Identity | Yes | SCHOOL, YEAR, DEPARTMENTS, GRADES, ROOMS |
people-import |
People Import | Yes | STAFF, TEACHERS, STUDENTS, ROLLOVER |
(The curriculum-structure group was removed with the CURRICULUM step; the standalone invitations group was removed with the INVITATIONS step.)
3. API Endpoints¶
All endpoints require JWT authentication only — no scope or action guards apply, because the tenant admin is the only active user during setup.
| Method | Path | Description |
|---|---|---|
GET |
/configure/setup/overview |
High-level summary of all groups with computed status (NOT_STARTED, IN_PROGRESS, DONE) |
GET |
/configure/setup/:groupId |
Full wizard state for the active step — :groupId is validated but not used for filtering |
GET |
/configure/setup/people-import/rollover/students |
Paginated, filterable review list for the ROLLOVER step (id, name, dept, grade, status, isTerminalGrade, default-promote targetGradeId/targetGradeName + targetDepartmentId/targetDepartmentName) |
POST |
/configure/setup/people-import/rollover |
Apply or skip rollover. APPLY returns a RolloverSummaryDto; SKIP returns 200 with no body. Neither advances the wizard step |
POST |
/configure/setup/:groupId |
Submit step data / navigate — :groupId validated but state machine logic is global |
The :groupId param is validated by ParseGroupIdPipe — unknown group IDs produce 404. The param does not affect state machine logic; it is a routing hint for the frontend only. POST endpoints return HTTP 200 (@HttpCode(200)).
Preset expansion relocated to the live curriculum surface — POST /curricula/presets/expand (CurriculumController, gated @RequireScope(CURRICULA, 'configuration', 'read')), since curriculum building is now post-setup. The CurriculumService.expandPreset logic is unchanged; only the route moved.
4. Module Layout¶
src/setup/
├── constants/
│ ├── setup-steps.ts # SetupStep enum, SETUP_STEP_ORDER, navigation helpers
│ └── setup-groups.ts # SetupGroupId enum, group definitions, SETUP_GROUP_ORDER
├── dto/
│ ├── setup-post.dto.ts # POST request body (currentStep + targetStep + data)
│ ├── setup-state-response.dto.ts # GET response + Swagger discriminated union
│ ├── setup-overview-response.dto.ts # Group overview summary
│ ├── import-columns.dto.ts # Import column definitions
│ └── steps/ # Per-step input + response DTOs
│ ├── school-step.dto.ts
│ ├── year-step.dto.ts
│ ├── departments-step.dto.ts
│ ├── grades-step.dto.ts
│ ├── rooms-step.dto.ts
│ ├── students-step.dto.ts
│ ├── rollover-step.dto.ts # State, query, apply, summary, review item DTOs
│ ├── teachers-step.dto.ts
│ └── staff-step.dto.ts
├── step-handlers/
│ ├── index.ts # StepHandler interface, STEP_HANDLERS token, StepHandlerRegistration
│ ├── form-step.handler.ts # Generic YearScopedSetupConfigurable adapter — serves DEPARTMENTS, ROOMS (one instance each, built in setup.module.ts factory)
│ ├── school-identity/ # SCHOOL, YEAR, GRADES handlers (bespoke shapes)
│ │ ├── school.handler.ts
│ │ ├── year.handler.ts
│ │ └── grades.handler.ts
│ └── people/ # STAFF, TEACHERS, STUDENTS, ROLLOVER handlers
│ ├── students.handler.ts
│ ├── rollover.handler.ts # load = review state; save = no-op; isComplete = STUDENTS handshake
│ ├── teachers.handler.ts
│ └── staff.handler.ts
├── interfaces/
│ └── step-data.interface.ts # StepData = object | null
├── pipes/
│ └── parse-group-id.pipe.ts # Validates :groupId path param (404 for unknown)
├── setup.controller.ts # 3 endpoints (overview, getState, submitStep)
├── setup.service.ts # State machine orchestration (DI-wired registry)
├── setup.module.ts # Imports domain modules, registers handler providers
├── setup.swagger.ts
└── index.ts # Public exports
Supporting Domain Modules¶
Handlers delegate all data access to domain services. SetupModule imports these modules:
| Module | Path | Provides |
|---|---|---|
SchoolModule |
src/school/ |
SchoolService — school identity upsert |
AcademicYearsModule |
src/academic-years/ |
AcademicYearsService — active year lookups + year scalars (name/grace/description); periods live per-department |
DepartmentsModule |
src/departments/ |
DepartmentsService — departments + grades bulk sync |
RoomsModule |
src/rooms/ |
RoomsService — rooms, types, lunch shifts |
StudentsModule |
src/students/ |
StudentsService — student import summary |
TeachersModule |
src/teachers/ |
TeachersService — teacher import summary |
StaffModule |
src/staff/ |
StaffService — staff import summary |
5. Handler Pattern¶
The StepHandler Interface¶
Defined in src/setup/step-handlers/index.ts. Each data step implements three methods:
| Method | Signature | Purpose |
|---|---|---|
load |
(tenantId) => Promise<StepData> |
Read persisted data for the step (form fields, summaries) |
save |
(tenantId, data) => Promise<void> |
Persist validated step data (delegates to domain service) |
isComplete |
(tenantId) => Promise<boolean> |
Gate forward navigation — must return true to advance |
Handlers are @Injectable() classes — domain services are injected via constructor. Handlers never touch Prisma directly; all data access goes through the injected service.
Handler Example¶
Year-scoped Form steps (DEPARTMENTS, ROOMS) are served by a single
generic FormStepHandler, not a per-step class. SetupModule constructs one
instance per step in its provider factory, parameterised by the domain service that
implements YearScopedSetupConfigurable:
export class FormStepHandler<TSummary, TDto extends object>
implements StepHandler
{
constructor(
private readonly service: YearScopedSetupConfigurable<TSummary, TDto>,
private readonly years: Pick<
AcademicYearsService,
'requireActiveYear' | 'getActiveYear'
>,
) {}
async load(tenantId: string): Promise<StepData> {
const year = await this.years.requireActiveYear(tenantId);
return (await this.service.getSetupSummary(tenantId, year.id)) as StepData;
}
async save(
tenantId: string,
data: object,
): Promise<SetupStepSideEffects | void> {
const year = await this.years.requireActiveYear(tenantId);
return this.service.bulkSync(tenantId, year.id, data as TDto);
}
async isComplete(tenantId: string): Promise<boolean> {
const year = await this.years.getActiveYear(tenantId);
if (!year) return false;
return this.service.isConfigured(tenantId, year.id);
}
}
Bespoke handler classes remain only where the shape diverges from
YearScopedSetupConfigurable: School (tenant-scoped bulkSync with no yearId,
getSchoolData load), Year (tenant-scoped on AcademicYearsService), Grades
(sub-resource methods on DepartmentsService), and the people-import steps
(Students / Teachers / Staff / Rollover).
The data as TDto cast in save() is safe — DTO validation runs in
SetupService.validateStepData() before the handler is called.
DI-Wired Registry¶
SetupModule registers each handler as a provider and wires them into a multi-provider token (STEP_HANDLERS). SetupService receives all registrations via @Inject(STEP_HANDLERS) and builds the step-to-handler map in its constructor.
export interface StepHandlerRegistration {
step: SetupStep;
dto?: new () => object; // Optional — steps without a DTO skip validation
handler: StepHandler;
}
Steps without a dto entry (import handlers) skip DTO validation in validateStepData(). Steps not present in the registry at all (COMPLETE) return null data and pass through freely.
Handler Archetypes¶
| Archetype | Steps | Has DTO? | save() behavior |
isComplete() criteria |
Canonical file |
|---|---|---|---|---|---|
| Form | SCHOOL, YEAR, DEPARTMENTS, GRADES, ROOMS | Yes | Delegates to domain service bulkSync() |
Domain service existence check | form-step.handler.ts (generic — DEPARTMENTS/ROOMS), school.handler.ts (bespoke — tenant-scoped) |
| Import | STUDENTS, TEACHERS, STAFF | No | No-op — data arrives via separate /import endpoint |
Import handshake — tenant.{students,teachers,staff}ImportedAt set inside the import tx |
students.handler.ts |
| Review | ROLLOVER | No | No-op — action arrives via dedicated POST (apply/skip), see §6 | Piggy-backs on the preceding Import handshake (studentsImportedAt). The one-shot apply guard lives on the apply route, not the navigation gate |
rollover.handler.ts |
Year-scoped config services (departments, rooms) implement YearScopedSetupConfigurable<TSummary, TDto> from common/interfaces/setup-configurable.interface.ts. The interface fixes the three-method contract — bulkSync(tenantId, yearId, dto), getSetupSummary(tenantId, yearId), isConfigured(tenantId, yearId) — so Form handlers can mechanically wire load() → getSetupSummary, save() → bulkSync, isComplete() → isConfigured. New year-scoped config modules should adopt the interface; it documents intent and keeps every handler boilerplate-symmetric.
6. Step Details¶
YEAR Step — Academic Year¶
The YEAR step uses named top-level keys: { academicYear }. The academic year carries no calendar of its own — start/end dates and the period sets (TERM/CLOSING) are collected per-department in the DEPARTMENTS step. The year itself is just { name, gracePeriodEnding?, description? }.
Validations: - Name length 2–100.
gracePeriodEnding is a single tenant-wide date, set explicitly (no derivation from any term set). Left null when omitted.
DEPARTMENTS Step — Business Rules¶
Validations: - Ordinal positions must be sequential starting from 0 - Department names must be unique (case-insensitive) within the academic year - P2002 unique constraint violation caught as belt-and-suspenders
Per-department calendar (mandatory — the year has no calendar of its own):
- calendarStartDate / calendarEndDate (required) — each department's own first/last day.
- terms / closingPeriods (optional arrays) — each department's period sets, validated against that department's own date bounds (endDate >= startDate — a single-day period like Festa della Repubblica is valid, in-bounds, no same-type overlap, no duplicate names within the department).
The calendar window and the period sets round-trip through getSetupSummary — the GET echoes each department's calendarStartDate/calendarEndDate plus its terms/closingPeriods (grouped by PeriodType, each item { id, name, startDate, endDate }), in the same shape the input DepartmentItemDto accepts. The wizard can therefore pre-fill the step on re-entry and re-submit the GET response verbatim with no client-side re-derivation.
Optional per-department flags (round-trip through getSetupSummary):
- attendanceMode (DAILY | PERIOD) — defaults to DAILY on create; omitted on update = leave unchanged. The wizard deliberately bypasses the freeze gate that protects this field on the live PATCH /departments/:id route — setup is initial configuration and may legitimately run after the department's calendarStartDate (mid-year onboarding).
- studentPlatformAccess (boolean) — defaults to false on create; omitted on update = leave unchanged.
Delete cascade (setup-context only). Dropping a department from the submitted array makes syncEntities delete it. Student.departmentId/gradeId are onDelete: Restrict, so the raw delete would throw P2003 once students have been imported (the common "go back from STAFF and remove a department" case). The wizard is the "fix on the fly" surface, so bulkSync.onDelete cascades in order: clear the department's pending STUDENT invitations (via InvitationsService.deletePendingByStudentDepartment — must run before the students vanish, since it resolves recipients by looking them up in the department) → delete its students (their referent links, guardians, curriculum selection, and homeroom/SG assignments all cascade at the DB level) → clear any empty standalone SubjectGroups of the department's curricula (guardAndClearSubjectGroupsForCurriculum — these are admin-created, now empty because the students are gone — SubjectGroup.curriculumSubjectId is Restrict, so this must precede the department delete that cascades the CurriculumSubjects) → delete its grades → delete the departments (each Curriculum and its grid cascade at the DB level). Rooms are not deleted — Room.departmentId is SetNull, so they survive unassigned. This relaxation is wizard-only: the live DELETE /departments/:id (remove()) keeps its protective CONFLICT refusal so an admin can't silently wipe a populated department post-setup. The SG clear is defensive (Subject Groups are created manually only, never auto-seeded) — it mirrors the CURRICULUM step's own guardAndClear* and covers any standalone SGs an admin created under these curricula. See docs/superpowers/specs/2026-06-23-setup-side-effects-map-design.md.
When a cascade actually deletes dependents, the POST response carries a transient sideEffects block so the FE can surface it (e.g. a "removing 'Drop Dept' also deleted 2 grades, 25 students and 1 curriculum" toast) — deleted.curricula is populated on a department drop; any cleared empty standalone SGs are not surfaced. See "Surfacing cascade deletions" below.
GRADES Step — Nested Department Structure¶
The GRADES step uses a nested department structure: { departments: [{ id, grades: [...] }] }. Each department entry includes its UUID and a grades array. The GET response includes department metadata (name, ordinalPosition) alongside any persisted grades.
Validations: - All tenant departments must be represented (every department needs at least one grade) - Per-department: ordinal positions sequential from 0 - Per-department: grade names unique (case-insensitive) - All department IDs must belong to the tenant's academic year
Delete cascade (setup-context only). Same hazard as the DEPARTMENTS step: dropping a grade from the array deletes it, but Student.gradeId is onDelete: Restrict. bulkSyncGrades.onDelete therefore deletes the grade's students first (sub-entities cascade at the DB level), then clears any empty standalone SubjectGroups for the grade (guardAndClearSubjectGroupsForGrades(tx, {}, gradeIds) — SubjectGroup.gradeId is also Restrict; now empty after the student delete), then the grade rows. Pending STUDENT invitations are department-scoped, not grade-scoped, so they're left to the DEPARTMENTS-step cascade.
Coverage warning (grade add/edit). After the grade sync, bulkSyncGrades re-runs the curriculum coverage predicate (a grade is uncovered iff it has no CurriculumSubjectHours cell — the same check CurriculumService.isConfigured gates on). When a curriculum already exists and some AY grade is now uncovered (the common case: an admin goes back and adds a grade after the curriculum was built), the POST response carries a non-blocking sideEffects.warnings entry { code: 'CURRICULUM_COVERAGE_BROKEN', data: { grades } } naming the uncovered grades. It is gated on a curriculum existing so the initial GRADES-before-CURRICULUM forward pass doesn't warn about every grade. Deleting a grade never breaks coverage (the grade leaves the AY), so this only fires on add/edit.
ROLLOVER Step — Promote / Retain / Leave Imported Students¶
The ROLLOVER step sits immediately after STUDENTS as the final people-import step (and the last data step before COMPLETE). Schools almost always import a roster that reflects last-year grades; ROLLOVER lets the admin shift that roster forward one grade in bulk before the year flips. It is setup-context only — year-end rollover (transitioning between academic years post-go-live) is a separate, future surface.
Shape: no DTO on the wizard POST (save() is a no-op). The step has two auxiliary routes:
GET /configure/setup/people-import/rollover/students— paginated review list (filterable bydepartmentId[],gradeId[],status[]).POST /configure/setup/people-import/rollover— body{ decision: APPLY | SKIP, retain?: uuid[], leave?: uuid[] }. APPLY returnsRolloverSummaryDto(counts of promoted/retained/leaved/graduated +appliedAt); SKIP returns 200 with no body.
The wizard GET /configure/setup/people-import returns RolloverStepStateDto for this step: { rolloverAppliedAt, totalStudents, initialPage }. The first page of the review list is embedded so the FE renders the step's first screen with no follow-up call (same convention every other step uses).
Apply algorithm (single prisma.$transaction inside StudentsService.applyRollover):
- Re-read
AcademicYear.rolloverAppliedAtinside the tx; non-null →ROLLOVER_ALREADY_APPLIED(409). Defends against concurrent applies. - Load eligible students (
status ∈ {ENROLLED, PRE_ENROLLED}in the active year). Validateretain[] ∪ leave[]⊆ eligible andretain[] ∩ leave[]= ∅; otherwiseROLLOVER_INVALID_STUDENT_IDS(400) with offending ids indata. - Build a promotion map from
(departmentId, gradeId)→NEXT { departmentId, gradeId }orTERMINAL, by walking departments and grades ordered byordinalPosition. At a grade's last position the map crosses to the first grade of the next department; at the last grade of the last department the map returnsTERMINAL. - Bucket each eligible student:
| Membership | Bucket | Mutation |
|---|---|---|
id ∈ leave[] |
LEAVE | status = LEFT |
id ∈ retain[] |
RETAIN | — |
Otherwise + TERMINAL |
GRADUATE | status = GRADUATED |
Otherwise + NEXT |
PROMOTE | departmentId, gradeId = target. Status unchanged (PRE_ENROLLED survives). |
- Write: one
updateManyfor LEAVE, one for GRADUATE, one per distinct(departmentId, gradeId)target for PROMOTE; finallytx.academicYear.update({ rolloverAppliedAt: now() }).
Invariants:
- One-shot per active year. Once
rolloverAppliedAtis set, APPLY 409s. The only path back is a re-import inside setup context (see below). - Step is freely advanceable once students are imported. Backend never blocks
ROLLOVER → TEACHERSon the apply decision; the mandatory FE decision is UX-only. gradeId/departmentIdmutate only throughapplyRollover()and the import pipeline. Regular PATCH on a student still rejects them;UpdateStudentEnrollmentDto.statusdoes accept the four enum values for post-setup admin CRUD.- PRE_ENROLLED survives promotion. The participation gate is independent of the rollover ceremony.
Re-import reset. Inside StudentsService.createStudentImportRecords, the import transaction re-reads tenant.setupStep; if it is not COMPLETE, the active year's rolloverAppliedAt is cleared in the same transaction. Post-setup admin imports never touch the column. This is what makes "re-import" the only path back from an applied rollover during setup, and the gate that keeps post-setup imports inert with respect to it.
StudentStatus enum. The set is {ENROLLED, PRE_ENROLLED, GRADUATED, LEFT}. RETAINED was dropped (its semantic — held back but still attending — folds into ENROLLED); PRE_ENROLLED covers rostered-but-not-yet-participating students.
Error codes added: ROLLOVER_ALREADY_APPLIED (409), ROLLOVER_INVALID_STUDENT_IDS (400, carries offending ids in data), ROLLOVER_NO_STUDENTS (409, defensive — wizard prereqs should prevent it).
Reference: docs/superpowers/specs/2026-05-12-rollover-setup-design.md (design doc with the full decision log and risks).
Invitations are post-setup (step removed 2026-07-02)¶
The wizard briefly carried a final non-gating INVITATIONS handoff step (added 2026-06-25 with the CURRICULUM removal). It was removed: the step owned no wizard data (load() returned null, save() was a no-op, isComplete() was always true), so it was pure navigation ceremony around the operator surface the admin can use at any time:
- List / filter / multi-select recipients via
GET /dashboard/onboarding— paginated, filter byentityType=teacher,staff(+status, name, email), rows carry the invitation lifecyclestatus+isOverdue. - Send via
POST /invitations/send—{ recipients: [{ id, type }], acceptUrlBase }, mixed types, 1–500, throttled 6/min, returns per-recipient results. ⚠️ onboarding'sentityTypeis lowercase (teacher/staff); the sendtypeis the uppercaseRecipientTypeenum — the FE uppercases when buildingrecipients.
The follow-through lives in the command-center next-steps to-do (GET /dashboard/next-steps), whose first card is "assign curriculum authors" (cta includes INVITATIONS) and whose final card is "invite referents/students" — that behavior is unchanged. See docs/superpowers/specs/2026-06-25-setup-curriculum-removal-and-admin-next-steps-design.md and the 2026-07-02-next-steps-remove-heuristics-design.md rollback (cards are marked done manually — no data inference).
Curricula are post-setup. With the CURRICULUM step gone, curricula are built on the live /curricula DRAFT⇄READY surface by the delegated authors, and preset expansion moved to POST /curricula/presets/expand (see §3). The grading-scale catalog (three platform-owned presets, visible day-one) and POST /evaluation-scales remain ad-hoc lifetime config with no state-machine concern.
7. Navigation & State Machine Rules¶
The service method submitStep() in src/setup/setup.service.ts orchestrates all navigation. It reads the backend's setupStep from the tenant row and routes to one of three modes:
| Mode | Condition | Data handling | Completion check | Step pointer |
|---|---|---|---|---|
| Forward | targetIdx === currentIdx + 1 |
Required if step has DTO; validated + saved | isComplete() must return true |
Advances to targetStep |
| Same-step | targetIdx === currentIdx |
Optional; validated + saved if present | Not checked | Stays on current step |
| Backward | targetIdx < currentIdx |
Optional; best-effort save (errors caught + logged) | Not checked | Moves to targetStep |
Draft saves & the back-nav soft-failure contract (FE)¶
Draft persistence is direction-dependent, and the FE must treat each direction differently:
| Direction | Invalid data outcome |
FE responsibility |
|---|---|---|
| Forward | 400 VALIDATION_FAILED / SETUP_STEP_INCOMPLETE — move blocked |
Render data.errors[] on the form, stay put |
| Same-step (draft) | 400 VALIDATION_FAILED — surfaced (handleSameStepSave does not swallow) |
Same as forward, minus the completion gate |
| Backward | 200 — move succeeds, the draft is silently discarded |
See below |
Back-navigation is intentionally fail-soft: handleBackNavigation validates + saves best-effort but swallows any failure (logged server-side as a warn with the field-level errors) so the user's explicit "go back" is never blocked by a half-filled form. What this means for the FE:
- A
200on a backward move is not confirmation the draft was saved. The returneddatais the freshly-loaded target step, never an echo of what you sent — don't read it back as proof of persistence. - Don't rely on the server to retain an in-progress edit on the way back. If a partial/invalid edit must survive a back-and-return, keep it in FE state, or validate against the step's rules client-side and only attach
datawhen it passes. - Omitting
dataon a backward move is fully supported and preferred when the user made no savable change — it skips validation + save entirely (no swallowedwarn, no wasted round-trip). - There is no back-nav error to branch on. The only failure a backward move surfaces is
409 SETUP_STEP_CONCURRENT_ADVANCE(another session moved the step) — handled identically to forward: reload state and retry. A swallowed draft is not an error condition the FE should detect or react to.
Guard Conditions¶
- Step mismatch —
dto.currentStep !== backendStepproduces409 SETUP_STEP_MISMATCH. This prevents stale-frontend navigation when another session has advanced the wizard. - Invalid target —
isValidTarget()rejects skip-forward (targetIdx > currentIdx + 1) with400 SETUP_INVALID_NAVIGATION.
Completion¶
When targetStep === COMPLETE, the service calls completeSetup() which sets setupStep = 'COMPLETE' on the tenant row. Completion is derived from this value — there is no separate completion timestamp.
Surfacing cascade deletions (sideEffects)¶
A POST /configure/setup/:groupId normally returns the freshly-loaded state for the resulting step (same shape as the GET). When a save cascade-deletes dependents in-app (the DEPARTMENTS / GRADES steps removing students/grades/curricula to honour the Restrict FKs — see §6) or produces a warning (a GRADES add leaving a grade uncovered), the response additionally carries a transient sideEffects block:
{
"currentStep": "DEPARTMENTS",
"data": { /* remaining departments */ },
"sideEffects": {
"deleted": {
"departments": [{ "id": "...", "name": "Drop Dept" }],
"grades": [{ "id": "...", "name": "6th Grade" }],
"students": [{ "id": "...", "firstName": "Mario", "lastName": "Rossi" }],
"curricula": [{ "id": "...", "name": "IB DP" }]
},
"warnings": [
{ "code": "CURRICULUM_COVERAGE_BROKEN", "data": { "grades": [{ "id": "...", "name": "New Grade" }] } }
]
}
}
- It is transient — present only on the POST response right after a cascading/warning save, never on GET state, and omitted when the save deleted nothing and warned nothing.
- Both
deletedandwarningsare optional: a department drop fillsdeleted(incl. the optionaldeleted.curricula); a GRADES grade-add can returnwarningswith nodeletedblock at all. Any empty standalone SubjectGroups cleared by the cascade are not surfaced (matching the CURRICULUM step). Warnings carry an i18ncode+ typeddata— the FE owns the human copy (same convention as the timetable diagnostics). - Plumbing (all forward / same-step / back-nav paths surface it):
bulkSync/bulkSyncGradesreturnSetupStepSideEffects | void(src/common/interfaces/setup-configurable.interface.ts) →StepHandler.save→SetupService.submitStepspreads it onto the state. Steps with no in-app cascade or warning (rooms, curriculum, the people steps) returnvoid, so the field never appears for them. - The runtime shape is the
commoninterfaceSetupStepSideEffects; the FE-facing Swagger schema isSetupSideEffectsDto(src/setup/dto/setup-side-effects.dto.ts), whichimplementsit (along withSetupWarningDto) so the two can't drift. - Full design + the per-step side-effects map:
docs/superpowers/specs/2026-06-23-setup-side-effects-map-design.md.
8. DTO Patterns¶
Input vs Response DTOs¶
Input DTOs have id?: string (optional — absent on create, present on update). Response DTOs extend the input and redeclare id as required using the declare keyword:
// Input
class DepartmentItemDto {
@IsOptional() @IsUUID() id?: string;
@IsString() name: string;
// ...
}
// Response
class DepartmentFormDataItemDto extends DepartmentItemDto {
declare id: string; // Guaranteed present — no runtime overhead, types only
}
Canonical example: src/setup/dto/steps/departments-step.dto.ts
Nested Validation¶
Array fields use @ValidateNested({ each: true }) + @Type(() => ItemDto) from class-transformer. The @Type decorator is required for class-transformer to instantiate the correct class during plainToInstance():
@IsDefined()
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => DepartmentItemDto)
departments: DepartmentItemDto[];
Polymorphic data Field¶
SetupPostDto.data is typed as Record<string, unknown> with @IsOptional() @IsObject(). The actual DTO validation happens in the service layer via plainToInstance(DtoClass, data) + validate() — not in the controller's validation pipe. This is because the correct DTO class depends on currentStep, which is only known at runtime.
Options applied during validation: { whitelist: true, forbidNonWhitelisted: true }.
Swagger Discriminated Union¶
Per-step state DTOs in src/setup/dto/setup-state-response.dto.ts (e.g., SetupSchoolStateDto, SetupYearStateDto) exist for OpenAPI documentation only. They use currentStep as the discriminator property and are registered via @ApiExtraModels() in setup.swagger.ts. They are not used at runtime.
Date Handling¶
Period date fields use @IsDate() @Type(() => Date). The @Type(() => Date) decorator is critical — class-transformer converts ISO strings from JSON into Date objects before @IsDate() runs validation. See chapter 05 for general date handling rules.
9. Shared Utilities¶
Utilities that were previously in shared.ts have been redistributed to their natural homes:
Common utilities (src/common/utils/)¶
| Utility | File | Purpose |
|---|---|---|
syncEntities<T>() / SyncConfig<T> |
sync-entities.ts |
Generic upsert: computes deletes from existingIds - submittedIds, then loops submitted items calling onCreate or onUpdate callbacks |
assertSequentialPositions() |
validation-helpers.ts |
Validates that ordinal positions form a contiguous 0..n-1 sequence (0-based) |
assertUniqueNames() |
validation-helpers.ts |
Case-insensitive + trimmed duplicate detection |
Domain services (replaced utility functions)¶
| Old utility | New location | Notes |
|---|---|---|
requireAcademicYear() |
AcademicYearsService.requireActiveYear() |
Throws SETUP_VALIDATION_FAILED if no active year |
getAcademicYear() |
AcademicYearsService.getActiveYear() |
Returns null if no active year |
Domain validation files¶
| Utility | File | Purpose |
|---|---|---|
timeToMinutes() |
src/rooms/rooms.validation.ts |
Parses "HH:mm" to minutes since midnight |
assertNonOverlappingShifts() |
src/rooms/rooms.validation.ts |
Validates endTime > startTime and no time-window overlaps |
assertAtLeastOneHours() |
src/curriculum/curriculum.validation.ts |
Throws if both yearLessons and weeklyLessons are null/undefined |
assertMinMaxSelections() |
src/curriculum/curriculum.validation.ts |
Validates minSelections ≤ maxSelections ≤ subject count |
assertValidRules() |
src/curriculum/curriculum.validation.ts |
Validates rule scope/target consistency |
SyncConfig<T> Interface¶
interface SyncConfig<T> {
existingIds: Set<string>; // IDs currently in DB
submitted: T[]; // Items from the request
getId: (item: T) => string | undefined; // Extract ID (undefined = new item)
onDelete: (ids: string[]) => Promise<void>;
onCreate: (item: T) => Promise<void>;
onUpdate: (id: string, item: T) => Promise<void>;
}
Domain services call syncEntities() internally — handlers never call it directly. The DepartmentsService.bulkSync() method is the canonical usage.
10. Validation Layering¶
Three layers, from broadest to narrowest:
1. DTO validation (class-validator decorators) — Field presence, types, formats, enum membership. Applied in validateStepData() via plainToInstance() + validate(). Failures produce VALIDATION_FAILED with a data.errors[] array of { field, rule, params? }.
2. Handler business rules — Called inside each handler's save() method before persistence. Examples: assertSequentialPositions() for ordinals, assertUniqueNames() for duplicates, date range checks in the YEAR handler, department ownership checks in the GRADES handler. Failures produce SETUP_VALIDATION_FAILED with params.reason.
3. Prisma constraints (belt-and-suspenders) — P2002 unique constraint catch in DEPARTMENTS and GRADES handlers. These should never fire if business rules are correct, but act as a safety net against race conditions.
Error Codes¶
| Code | HTTP | When |
|---|---|---|
VALIDATION_FAILED |
400 | DTO field validation fails |
SETUP_VALIDATION_FAILED |
400 | Business rule violation (date range, ordinal gap, duplicate name, missing year) |
SETUP_STEP_INCOMPLETE |
400 | Forward navigation when isComplete() returns false (blanket gate backstop). Completeness reasons are surfaced at save time as SETUP_VALIDATION_FAILED — e.g. the GRADES step's missing_departments |
SETUP_DATA_REQUIRED |
400 | Forward navigation without data on a step that has a DTO |
SETUP_INVALID_NAVIGATION |
400 | Attempted skip-forward |
SETUP_STEP_MISMATCH |
409 | currentStep in request body differs from backend state |
SETUP_STEP_CONCURRENT_ADVANCE |
409 | The conditional updateMany advance found zero matching rows — a concurrent session moved the step between read and write. Forward and back navigation share this code so the FE can treat them identically. |
ROLLOVER_ALREADY_APPLIED |
409 | APPLY on a year whose rolloverAppliedAt is non-null |
ROLLOVER_INVALID_STUDENT_IDS |
400 | retain[] / leave[] contains an id outside eligible students or the two arrays overlap |
ROLLOVER_NO_STUDENTS |
409 | APPLY with zero eligible students (defensive) |
NOT_FOUND |
404 | Tenant not found |
Full error code catalogue: chapter 06.
11. Testing Patterns¶
DTO Specs¶
Use plainToInstance() + validate() from class-validator. Each spec defines a toDto() helper that merges overrides onto a validData constant:
function toDto(overrides: Record<string, unknown> = {}) {
return plainToInstance(SchoolStepDataDto, { ...validData, ...overrides });
}
it('should fail country with invalid codes', async () => {
for (const code of ['XX', 'usa', '']) {
const errors = await validate(toDto({ country: code }));
expect(errors.some((e) => e.property === 'country')).toBe(true);
}
});
Canonical: src/setup/dto/steps/school-step.dto.spec.ts
Handler Specs¶
Instantiate the handler directly, mocking injected domain services as plain objects with jest.fn() stubs:
const mockService = { getSetupSummary: jest.fn(), bulkSync: jest.fn(), isConfigured: jest.fn() };
const mockYears = { requireActiveYear: jest.fn(), getActiveYear: jest.fn() };
const handler = new DepartmentsStepHandler(mockService as any, mockYears as any);
Test all three methods (load, save, isComplete) — verify the handler delegates to the correct service method with the right arguments. Canonical: src/setup/step-handlers/departments.handler.spec.ts
Service Spec¶
Full state machine coverage. Uses Test.createTestingModule with mocked PrismaService and STEP_HANDLERS token providing mock handler registrations. Covers: state transitions, mismatch errors, data loading per step, forward/backward/same-step navigation, completion gating.
Canonical: src/setup/setup.service.spec.ts
Controller Spec¶
Thin delegation tests verifying the controller passes tenantId to the service. Also asserts @HttpCode(200) metadata on POST via Reflect.getMetadata().
Canonical: src/setup/setup.controller.spec.ts
12. Key Implementation Files¶
| File | Role |
|---|---|
src/setup/constants/setup-steps.ts |
SetupStep enum, SETUP_STEP_ORDER array, navigation helpers |
src/setup/constants/setup-groups.ts |
SetupGroupId enum, group registry, SETUP_GROUP_ORDER |
src/setup/setup.service.ts |
State machine orchestration, getOverview(), submitStep() |
src/setup/setup.controller.ts |
3 route handlers (overview, getState, submitStep) |
src/setup/setup.module.ts |
Imports domain modules, registers DI-wired handler providers |
src/setup/pipes/parse-group-id.pipe.ts |
Validates :groupId param, 404 for unknown |
src/setup/step-handlers/index.ts |
StepHandler interface, STEP_HANDLERS token, StepHandlerRegistration |
src/setup/step-handlers/people/rollover.handler.ts |
ROLLOVER handler — load returns review state, save is a no-op |
src/setup/dto/steps/rollover-step.dto.ts |
Rollover state, query, apply, summary, and review item DTOs |
src/students/students.service.ts |
applyRollover, getRolloverStepState, getRolloverReviewPage; re-import reset of rolloverAppliedAt |
src/setup/dto/setup-post.dto.ts |
POST request body shape |
src/setup/dto/setup-state-response.dto.ts |
GET response + Swagger discriminated union |
src/common/utils/sync-entities.ts |
syncEntities() generic upsert helper |
src/common/utils/validation-helpers.ts |
assertSequentialPositions(), assertUniqueNames() |
13. Cross-References¶
- Architecture overview: chapter 01 — system design, data access patterns
- Add-a-step checklist: chapter 11 §10 — 7-step guide for adding a new setup step
- DTO patterns: chapter 05 — general input/response DTO conventions
- Error handling: chapter 06 — full error code catalogue
- RBAC: chapter 04 — permission model (setup wizard bypasses scope/action guards)