import type { LeagueConfigFormModel, } from '@gridpilot/racing/application'; import type { CreateLeagueWithSeasonAndScoringCommand, CreateLeagueWithSeasonAndScoringResultDTO, } from '@gridpilot/racing/application/use-cases/CreateLeagueWithSeasonAndScoringUseCase'; import { getDriverRepository, getCreateLeagueWithSeasonAndScoringUseCase, } from '@/lib/di-container'; import { LeagueName } from '@gridpilot/racing/domain/value-objects/LeagueName'; import { LeagueDescription } from '@gridpilot/racing/domain/value-objects/LeagueDescription'; import { GameConstraints } from '@gridpilot/racing/domain/value-objects/GameConstraints'; export type WizardStep = 1 | 2 | 3 | 4 | 5 | 6 | 7; export interface WizardErrors { basics?: { name?: string; description?: string; visibility?: string; }; structure?: { maxDrivers?: string; maxTeams?: string; driversPerTeam?: string; }; timings?: { qualifyingMinutes?: string; mainRaceMinutes?: string; roundsPlanned?: string; }; scoring?: { patternId?: string; }; submit?: string; } /** * Step-scoped validation extracted from the React wizard. * Returns a fresh error bag for the given step based on the provided form model. */ export function validateLeagueWizardStep( form: LeagueConfigFormModel, step: WizardStep, ): WizardErrors { const errors: WizardErrors = {}; // Step 1: Basics (name, description, game) if (step === 1) { const basicsErrors: NonNullable = {}; // Use LeagueName value object for validation const nameValidation = LeagueName.validate(form.basics.name); if (!nameValidation.valid && nameValidation.error) { basicsErrors.name = nameValidation.error; } // Use LeagueDescription value object for validation const descValidation = LeagueDescription.validate(form.basics.description ?? ''); if (!descValidation.valid && descValidation.error) { basicsErrors.description = descValidation.error; } if (Object.keys(basicsErrors).length > 0) { errors.basics = basicsErrors; } } // Step 2: Visibility (ranked/unranked) if (step === 2) { const basicsErrors: NonNullable = {}; if (!form.basics.visibility) { basicsErrors.visibility = 'Visibility is required'; } if (Object.keys(basicsErrors).length > 0) { errors.basics = basicsErrors; } } // Step 3: Structure (solo vs teams) if (step === 3) { const structureErrors: NonNullable = {}; const gameConstraints = GameConstraints.forGame(form.basics.gameId); if (form.structure.mode === 'solo') { if (!form.structure.maxDrivers || form.structure.maxDrivers <= 0) { structureErrors.maxDrivers = 'Max drivers must be greater than 0 for solo leagues'; } else { // Validate against game constraints const driverValidation = gameConstraints.validateDriverCount( form.structure.maxDrivers, ); if (!driverValidation.valid && driverValidation.error) { structureErrors.maxDrivers = driverValidation.error; } } } else if (form.structure.mode === 'fixedTeams') { if (!form.structure.maxTeams || form.structure.maxTeams <= 0) { structureErrors.maxTeams = 'Max teams must be greater than 0 for team leagues'; } else { // Validate against game constraints const teamValidation = gameConstraints.validateTeamCount( form.structure.maxTeams, ); if (!teamValidation.valid && teamValidation.error) { structureErrors.maxTeams = teamValidation.error; } } if (!form.structure.driversPerTeam || form.structure.driversPerTeam <= 0) { structureErrors.driversPerTeam = 'Drivers per team must be greater than 0'; } // Validate total driver count if (form.structure.maxDrivers) { const driverValidation = gameConstraints.validateDriverCount( form.structure.maxDrivers, ); if (!driverValidation.valid && driverValidation.error) { structureErrors.maxDrivers = driverValidation.error; } } } if (Object.keys(structureErrors).length > 0) { errors.structure = structureErrors; } } // Step 4: Schedule (timings) if (step === 4) { const timingsErrors: NonNullable = {}; if (!form.timings.qualifyingMinutes || form.timings.qualifyingMinutes <= 0) { timingsErrors.qualifyingMinutes = 'Qualifying duration must be greater than 0 minutes'; } if (!form.timings.mainRaceMinutes || form.timings.mainRaceMinutes <= 0) { timingsErrors.mainRaceMinutes = 'Main race duration must be greater than 0 minutes'; } if (Object.keys(timingsErrors).length > 0) { errors.timings = timingsErrors; } } // Step 5: Scoring if (step === 5) { const scoringErrors: NonNullable = {}; if (!form.scoring.patternId && !form.scoring.customScoringEnabled) { scoringErrors.patternId = 'Select a scoring preset or enable custom scoring'; } if (Object.keys(scoringErrors).length > 0) { errors.scoring = scoringErrors; } } // Step 6: Stewarding - no validation needed currently (all fields have defaults) // Step 7: Review - no validation needed, it's just review return errors; } /** * Helper to validate all steps (1-4) and merge errors into a single bag. */ export function validateAllLeagueWizardSteps( form: LeagueConfigFormModel, ): WizardErrors { const aggregate: WizardErrors = {}; const merge = (next: WizardErrors) => { if (next.basics) { aggregate.basics = { ...aggregate.basics, ...next.basics }; } if (next.structure) { aggregate.structure = { ...aggregate.structure, ...next.structure }; } if (next.timings) { aggregate.timings = { ...aggregate.timings, ...next.timings }; } if (next.scoring) { aggregate.scoring = { ...aggregate.scoring, ...next.scoring }; } if (next.submit) { aggregate.submit = next.submit; } }; merge(validateLeagueWizardStep(form, 1)); merge(validateLeagueWizardStep(form, 2)); merge(validateLeagueWizardStep(form, 3)); merge(validateLeagueWizardStep(form, 4)); merge(validateLeagueWizardStep(form, 5)); return aggregate; } export function hasWizardErrors(errors: WizardErrors): boolean { return Object.keys(errors).some((key) => { const value = errors[key as keyof WizardErrors]; if (!value) return false; if (typeof value === 'string') return true; return Object.keys(value).length > 0; }); } /** * Pure mapping from LeagueConfigFormModel to the creation command. * Driver ownership is handled by the caller. */ export function buildCreateLeagueCommandFromConfig( form: LeagueConfigFormModel, ownerId: string, ): CreateLeagueWithSeasonAndScoringCommand { const structure = form.structure; let maxDrivers: number; let maxTeams: number; if (structure.mode === 'solo') { maxDrivers = typeof structure.maxDrivers === 'number' && structure.maxDrivers > 0 ? structure.maxDrivers : 0; maxTeams = 0; } else { const teams = typeof structure.maxTeams === 'number' && structure.maxTeams > 0 ? structure.maxTeams : 0; const perTeam = typeof structure.driversPerTeam === 'number' && structure.driversPerTeam > 0 ? structure.driversPerTeam : 0; maxTeams = teams; maxDrivers = teams > 0 && perTeam > 0 ? teams * perTeam : 0; } return { name: form.basics.name.trim(), description: (form.basics.description ?? '').trim(), visibility: form.basics.visibility, ownerId, gameId: form.basics.gameId, maxDrivers, maxTeams, enableDriverChampionship: form.championships.enableDriverChampionship, enableTeamChampionship: form.championships.enableTeamChampionship, enableNationsChampionship: form.championships.enableNationsChampionship, enableTrophyChampionship: form.championships.enableTrophyChampionship, scoringPresetId: form.scoring.patternId ?? 'custom', }; } /** * Thin application-level facade that: * - pulls the current driver via repository * - builds the creation command * - delegates to the create-league use case */ export async function createLeagueFromConfig( form: LeagueConfigFormModel, ): Promise { const driverRepo = getDriverRepository(); const drivers = await driverRepo.findAll(); const currentDriver = drivers[0]; if (!currentDriver) { const error = new Error( 'No driver profile found. Please create a driver profile first.', ) as Error & { code?: string }; error.code = 'NO_DRIVER'; throw error; } const useCase = getCreateLeagueWithSeasonAndScoringUseCase(); const command = buildCreateLeagueCommandFromConfig(form, currentDriver.id); return useCase.execute(command); } /** * Apply scoring preset selection and derive timings, returning a new form model. * This mirrors the previous React handler but keeps it in testable, non-UI logic. */ export function applyScoringPresetToConfig( form: LeagueConfigFormModel, patternId: string, ): LeagueConfigFormModel { const lowerPresetId = patternId.toLowerCase(); const timings = form.timings ?? ({} as LeagueConfigFormModel['timings']); let updatedTimings: NonNullable = { ...timings, }; if (lowerPresetId.includes('sprint') || lowerPresetId.includes('double')) { updatedTimings = { ...updatedTimings, practiceMinutes: 15, qualifyingMinutes: 20, sprintRaceMinutes: 20, mainRaceMinutes: 35, sessionCount: 2, }; } else if (lowerPresetId.includes('endurance') || lowerPresetId.includes('long')) { updatedTimings = { ...updatedTimings, practiceMinutes: 30, qualifyingMinutes: 30, mainRaceMinutes: 90, sessionCount: 1, }; delete (updatedTimings as { sprintRaceMinutes?: number }).sprintRaceMinutes; } else { updatedTimings = { ...updatedTimings, practiceMinutes: 20, qualifyingMinutes: 30, mainRaceMinutes: 40, sessionCount: 1, }; delete (updatedTimings as { sprintRaceMinutes?: number }).sprintRaceMinutes; } return { ...form, scoring: { ...form.scoring, patternId, customScoringEnabled: false, }, timings: updatedTimings, }; }