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, 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:

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

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 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.


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