Skip to content

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:

SCHOOL → YEAR → DEPARTMENTS → GRADES → ROOMS → STAFF → TEACHERS → STUDENTS → ROLLOVER → COMPLETE

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 SetupStep enum still carries orphaned CURRICULUM and INVITATIONS values (dropping a PG enum value is destructive). Both are absent from SETUP_STEP_ORDER/SETUP_GROUPS/the handler registry, so the state machine can never route to them; parseSetupStep throws if a row somehow holds one. Migration 20260702160000_remove_invitations_setup_step rehomes any tenant parked on INVITATIONS to ROLLOVER (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 off SETUP_STEP_ORDER, not the raw SetupStep enum — 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-facing enum: list is constrained. New step-enum DTO fields must follow this — never enum: 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 deletedRoom.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 by departmentId[], gradeId[], status[]).
  • POST /configure/setup/people-import/rollover — body { decision: APPLY | SKIP, retain?: uuid[], leave?: uuid[] }. APPLY returns RolloverSummaryDto (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):

  1. Re-read AcademicYear.rolloverAppliedAt inside the tx; non-null → ROLLOVER_ALREADY_APPLIED (409). Defends against concurrent applies.
  2. Load eligible students (status ∈ {ENROLLED, PRE_ENROLLED} in the active year). Validate retain[] ∪ leave[] ⊆ eligible and retain[] ∩ leave[] = ∅; otherwise ROLLOVER_INVALID_STUDENT_IDS (400) with offending ids in data.
  3. Build a promotion map from (departmentId, gradeId)NEXT { departmentId, gradeId } or TERMINAL, by walking departments and grades ordered by ordinalPosition. 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 returns TERMINAL.
  4. 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).
  1. Write: one updateMany for LEAVE, one for GRADUATE, one per distinct (departmentId, gradeId) target for PROMOTE; finally tx.academicYear.update({ rolloverAppliedAt: now() }).

Invariants:

  • One-shot per active year. Once rolloverAppliedAt is 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 → TEACHERS on the apply decision; the mandatory FE decision is UX-only.
  • gradeId / departmentId mutate only through applyRollover() and the import pipeline. Regular PATCH on a student still rejects them; UpdateStudentEnrollmentDto.status does 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 by entityType=teacher,staff (+ status, name, email), rows carry the invitation lifecycle status + isOverdue.
  • Send via POST /invitations/send{ recipients: [{ id, type }], acceptUrlBase }, mixed types, 1–500, throttled 6/min, returns per-recipient results. ⚠️ onboarding's entityType is lowercase (teacher/staff); the send type is the uppercase RecipientType enum — the FE uppercases when building recipients.

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 200 on a backward move is not confirmation the draft was saved. The returned data is 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 data when it passes.
  • Omitting data on a backward move is fully supported and preferred when the user made no savable change — it skips validation + save entirely (no swallowed warn, 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 mismatchdto.currentStep !== backendStep produces 409 SETUP_STEP_MISMATCH. This prevents stale-frontend navigation when another session has advanced the wizard.
  • Invalid targetisValidTarget() rejects skip-forward (targetIdx > currentIdx + 1) with 400 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 deleted and warnings are optional: a department drop fills deleted (incl. the optional deleted.curricula); a GRADES grade-add can return warnings with no deleted block at all. Any empty standalone SubjectGroups cleared by the cascade are not surfaced (matching the CURRICULUM step). Warnings carry an i18n code + typed data — the FE owns the human copy (same convention as the timetable diagnostics).
  • Plumbing (all forward / same-step / back-nav paths surface it): bulkSync/bulkSyncGrades return SetupStepSideEffects | void (src/common/interfaces/setup-configurable.interface.ts) → StepHandler.saveSetupService.submitStep spreads it onto the state. Steps with no in-app cascade or warning (rooms, curriculum, the people steps) return void, so the field never appears for them.
  • The runtime shape is the common interface SetupStepSideEffects; the FE-facing Swagger schema is SetupSideEffectsDto (src/setup/dto/setup-side-effects.dto.ts), which implements it (along with SetupWarningDto) 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)