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, docs/workflows.md §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:
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. 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:
| Group ID | Label | Required | Steps |
|---|---|---|---|
school-identity |
School Identity | Yes | SCHOOL, YEAR, DEPARTMENTS, GRADES, ROOMS |
people-import |
People Import | Yes | STUDENTS, TEACHERS, STAFF |
curriculum-structure |
Curriculum Structure | Yes | CURRICULUM |
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 |
POST |
/configure/setup/curriculum-presets/expand |
Expand a preset into form-ready study plan data (no DB writes) |
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)).
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
│ ├── teachers-step.dto.ts
│ ├── staff-step.dto.ts
│ └── curriculum-step.dto.ts
├── step-handlers/
│ ├── index.ts # StepHandler interface, STEP_HANDLERS token, StepHandlerRegistration
│ ├── school.handler.ts # delegates to SchoolService
│ ├── year.handler.ts # delegates to AcademicYearsService
│ ├── departments.handler.ts # delegates to DepartmentsService
│ ├── grades.handler.ts # delegates to DepartmentsService
│ ├── rooms.handler.ts # delegates to RoomsService
│ ├── students.handler.ts # delegates to StudentsService
│ ├── teachers.handler.ts # delegates to TeachersService
│ ├── staff.handler.ts # delegates to StaffService
│ └── curriculum.handler.ts # delegates to CurriculumService
├── 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, period sync |
CurriculumModule |
src/curriculum/ |
CurriculumService — curricula, study plans, subjects, presets |
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¶
@Injectable()
export class DepartmentsStepHandler implements StepHandler {
constructor(
private readonly departmentsService: DepartmentsService,
private readonly academicYearsService: AcademicYearsService,
) {}
async load(tenantId: string): Promise<StepData> {
const year = await this.academicYearsService.requireActiveYear(tenantId);
return this.departmentsService.getSetupSummary(tenantId, year.id);
}
async save(tenantId: string, data: object): Promise<void> {
const year = await this.academicYearsService.requireActiveYear(tenantId);
await this.departmentsService.bulkSync(tenantId, year.id, data as DepartmentsStepDataDto);
}
async isComplete(tenantId: string): Promise<boolean> {
const year = await this.academicYearsService.getActiveYear(tenantId);
if (!year) return false;
return this.departmentsService.isConfigured(tenantId, year.id);
}
}
The data as SpecificDto 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, CURRICULUM | Yes | Delegates to domain service bulkSync() |
Domain service existence check | departments.handler.ts (typical), school.handler.ts (simple) |
| 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 |
Year-scoped config services (departments, rooms, curriculum) 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 + Periods¶
The YEAR step uses named top-level keys: { academicYear, terms?, closingPeriods?, extraPeriods? }. All three period arrays are optional — admins may save the year with no periods and add them later via the per-period CRUD or the YEAR PATCH endpoint. Each period type maps to the Period model's type field (TERM, CLOSING, EXTRA).
Validations:
- Academic year: endDate > startDate
- Each period: endDate > startDate, dates must fall within academic year bounds
- Same-type overlap: periods of the same type cannot overlap (CLOSING can overlap with TERM)
- No duplicate period names across all types within the academic year
gracePeriodEnding defaults to 14 days before the earliest TERM startDate when terms are provided and the caller omits it; with no terms it stays null until set.
DEPARTMENTS Step — Business Rules¶
Validations: - Ordinal positions must be sequential starting from 1 - Department names must be unique (case-insensitive) within the academic year - P2002 unique constraint violation caught as belt-and-suspenders
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 1 - Per-department: grade names unique (case-insensitive) - All department IDs must belong to the tenant's academic year
CURRICULUM Step — Nested Curricula + Study Plans¶
The CURRICULUM step uses a nested structure: { departments: [{ departmentId, curricula: [{ id?, name, grades: [{ gradeId, studyPlan? }] }] }] }. Each curriculum belongs to a department and links to one or more grades. Study plans (one per curriculum+grade) contain subjects, option blocks, and rules. Study plans are optional for draft saves but required for completion.
Study plans have: subjectLabel?, subjects[] (name, yearLessons?, weeklyLessons?, isMandatory, level?), optionBlocks[] (name, hours, min/maxSelections, subjects[]), rules[] (constraint, quantity, scopeType, blockIndex?, targetType, targetLevel?).
Rules constrain subject selection: constraint (AT_LEAST / EXACTLY / AT_MOST), scopeType (ENTIRE_PLAN / BLOCK), targetType (SUBJECTS / SUBJECTS_OF_LEVEL). BLOCK-scoped rules reference blocks by array index.
Preset catalog: Static TypeScript constants in src/curriculum/constants/curriculum-presets.ts define 14 pre-built curricula (IB PYP/MYP/DP, Italian state programs, Liceo variants, IGCSE). The preset expansion endpoint (POST /configure/setup/curriculum-presets/expand) accepts { presetKey, gradeIds } and returns form-ready study plan data via CurriculumService.expandPreset() with no DB writes.
Validations:
- All departmentIds must belong to the tenant's academic year
- All gradeIds must belong to the specified department
- Curriculum names unique per department (case-insensitive)
- Each subject and option block must have at least one of yearLessons or weeklyLessons
- Option block: minSelections ≤ maxSelections ≤ subject count
- Subject and block names unique within their study plan
- Rules: BLOCK scope requires valid blockIndex; ENTIRE_PLAN must not have blockIndex; SUBJECTS_OF_LEVEL requires targetLevel; SUBJECTS must not have targetLevel
- P2002 unique constraint catch as belt-and-suspenders
Completion gate: Every grade in the tenant's academic year must have at least one curriculum with at least one study plan.
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 |
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.
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 1..n sequence |
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 |
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 |
NOT_FOUND |
404 | Tenant not found |
Full error code catalogue: chapter 06 and docs/error-codes.md.
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/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:
docs/architecture.md§7 — state machine, groups, API endpoints, per-step validation rules - Add-a-step checklist:
docs/workflows.md§10 — 7-step guide for adding a new setup step - DTO patterns: chapter 05 — general input/response DTO conventions
- Error handling: chapter 06 and
docs/error-codes.md— full error code catalogue - RBAC:
docs/rbac-strategy.md— permission model (setup wizard bypasses scope/action guards)