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. It is not frozen by activation — post-setup, admins adjust it via PATCH /academic-years/:id, which accepts the field while the year is DRAFT or ACTIVE (rename stays DRAFT-only).
Pre-year completeness (the gracePeriodEnding invariant)¶
gracePeriodEnding is the deadline by which the school's data must be complete, and that only means something before the school opens. So for every academic year:
Why it is load-bearing, not cosmetic. A CurriculumSelectionWindow always ends at gracePeriodEnding (ch05 / REFERENCE §Curriculum selection window). With this invariant, a window can only ever read OPEN before the year starts — which is what makes a referent's curriculum selection a pre-year declaration ("my child does this next year") and keeps every mid-year change school-managed. Without it the field was documented as a late-registration deadline that by construction ran into the year, and a referent could submit a first selection in November.
Accepted consequence: a student enrolling in October has no referent self-service path to a curriculum — an authorized school-side editor sets it. That is the intended trade, not an oversight.
Enforced on both sides, in the service layer (the rule spans academic_years × departments, which no row-level CHECK can see; a trigger would be the codebase's first):
| Write path | Refusal |
|---|---|
YEAR step bulkSync, PATCH /academic-years/:id |
ACADEMIC_YEAR_GRACE_PERIOD_INVALID (422), reason: 'on_or_after_year_start', naming gracePeriodEnding + earliestCalendarStart + departmentId |
DEPARTMENTS step bulkSync, POST /departments, PATCH /departments/:id |
SETUP_VALIDATION_FAILED (400), reason: 'calendar_start_before_grace_period', field locating the offending calendarStartDate |
Both live in src/common/utils/assert-pre-year-grace-period.ts — one file so the two sides cannot drift into different definitions of "the year starts".
Three behaviours worth knowing:
- A year with no departments accepts any deadline. The wizard establishes the year before its calendars exist; refusing there would be a false positive.
POST /academic-yearsis exempt for the same reason (a fresh year has no departments). - Only the incoming value is validated. A year already in violation (seeded, imported, or written before this rule existed) is never blocked from repair — moving the deadline earlier always passes. Only making it worse is refused.
- Only a moved start is judged. Since the calendar thaw (2026-07-30) a running year's calendar is editable again (only ARCHIVED refuses, and that
ACADEMIC_YEAR_NOT_EDITABLEstill fires before this check). The department-side check runs only whencalendarStartDateactually changes — a legacy year already in violation is never blocked from editing its end date or periods by a start the patch is not touching.
The wizard's step order is what makes the pair expressible: YEAR collects the deadline, DEPARTMENTS collects the calendars, so a violating combination first becomes reachable in the DEPARTMENTS step (or on back-navigation to YEAR).
Automatic family reminders (2026-08-25). These same authoritative dates
drive the notification catalogue without changing their write semantics.
Incomplete actionable curriculum choices are checked when a department window
opens, 14 days and 7 days before gracePeriodEnding, and on the deadline.
Referent-actionable profile information is checked 14 days and 7 days before
the earliest calendarStartDate among a referent's active-year linked children,
on that start date, and on the first day after gracePeriodEnding. Only active
referent accounts with a writable linked child (or missing information on their
own referent profile) are eligible, and missing field names are never copied
into a notification. Per-recipient milestone claims suppress repeat/concurrent
sends. Manual reminder buttons and their authorization remain deferred; see
chapter 23 §5.
DEPARTMENTS Step — Business Rules¶
Validations:
- Ordinal positions must be sequential starting from 0
- Department names must be unique (case-insensitive) within the academic year
- The earliest submitted
calendarStartDatemust fall after the year'sgracePeriodEnding(see Pre-year completeness above) — the whole submitted set is judged by its earliest start, and thefieldlocator points at that element - 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 toDAILYon create; omitted on update = leave unchanged. The wizard deliberately bypasses the freeze gate that protects this field on the livePATCH /departments/:idroute — setup is initial configuration and may legitimately run after the department'scalendarStartDate(mid-year onboarding).studentPlatformAccess(boolean) — defaults tofalseon 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.
- Exits close memberships too (2026-09-03). LEAVE and GRADUATE close the placement and every homeroom, subject-group and curriculum-selection episode at the rollover boundary — the same closure the PATCH exit performs (contract §2b #22) — emitting the membership audit events. No access revocation runs here: at setup time no student holds a login.
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(+status, name, email, and repeatabledepartmentId/gradeId/homeroomIdUUIDs), rows carry the invitation lifecyclestatus+isOverdue. Cohort values union within one dimension and the selected dimensions intersect. Students match their dated display cohort, referents through linked active-year students, and teachers through department membership plus effective teaching / homeroom leadership; staff return an empty page while any cohort filter is active because they have no modeled cohort relation. - 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 FormStepHandler(mockService as any, mockYears as any);
Every form-shaped step (departments, school, year, grades, teachers, staff, students) shares this one generic adapter — there is no per-step handler class. 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/form-step.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)