This commit is contained in:
2025-12-16 10:50:15 +01:00
parent 775d41e055
commit 8ed6ba1fd1
144 changed files with 5763 additions and 1985 deletions

View File

@@ -1,20 +1,64 @@
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';
/**
* League Wizard Service - Refactored to use API client
*
* This service handles league creation wizard logic without direct core dependencies.
*/
import { apiClient } from '@/lib/apiClient';
export type WizardStep = 1 | 2 | 3 | 4 | 5 | 6 | 7;
export interface LeagueConfigFormModel {
leagueId?: string;
basics: {
name: string;
description?: string;
visibility: 'public' | 'private' | 'unlisted';
gameId: string;
};
structure: {
mode: 'solo' | 'fixedTeams';
maxDrivers?: number;
maxTeams?: number;
driversPerTeam?: number;
};
championships: {
enableDriverChampionship: boolean;
enableTeamChampionship: boolean;
enableNationsChampionship: boolean;
enableTrophyChampionship: boolean;
};
scoring: {
patternId?: string;
customScoringEnabled?: boolean;
};
dropPolicy: {
strategy: 'none' | 'bestNResults' | 'dropWorstN';
n?: number;
};
timings: {
practiceMinutes?: number;
qualifyingMinutes?: number;
sprintRaceMinutes?: number;
mainRaceMinutes?: number;
sessionCount?: number;
roundsPlanned?: number;
raceDayOfWeek?: number;
raceTimeUtc?: string;
};
stewarding: {
decisionMode: 'owner_only' | 'admin_vote' | 'steward_panel';
requiredVotes?: number;
requireDefense: boolean;
defenseTimeLimit: number;
voteTimeLimit: number;
protestDeadlineHours: number;
stewardingClosesHours: number;
notifyAccusedOnProtest: boolean;
notifyOnVoteRequired: boolean;
};
}
export interface WizardErrors {
basics?: {
name?: string;
@@ -51,16 +95,18 @@ export function validateLeagueWizardStep(
if (step === 1) {
const basicsErrors: NonNullable<WizardErrors['basics']> = {};
// Use LeagueName value object for validation
const nameValidation = LeagueName.validate(form.basics.name);
if (!nameValidation.valid && nameValidation.error) {
basicsErrors.name = nameValidation.error;
// Basic name validation
if (!form.basics.name || form.basics.name.trim().length === 0) {
basicsErrors.name = 'League name is required';
} else if (form.basics.name.length < 3) {
basicsErrors.name = 'League name must be at least 3 characters';
} else if (form.basics.name.length > 100) {
basicsErrors.name = 'League name must be less than 100 characters';
}
// Use LeagueDescription value object for validation
const descValidation = LeagueDescription.validate(form.basics.description ?? '');
if (!descValidation.valid && descValidation.error) {
basicsErrors.description = descValidation.error;
// Description validation
if (form.basics.description && form.basics.description.length > 500) {
basicsErrors.description = 'Description must be less than 500 characters';
}
if (Object.keys(basicsErrors).length > 0) {
@@ -84,47 +130,23 @@ export function validateLeagueWizardStep(
// Step 3: Structure (solo vs teams)
if (step === 3) {
const structureErrors: NonNullable<WizardErrors['structure']> = {};
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.maxDrivers > 100) {
structureErrors.maxDrivers = 'Max drivers cannot exceed 100';
}
} 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;
@@ -210,24 +232,27 @@ export function hasWizardErrors(errors: WizardErrors): boolean {
});
}
export interface CreateLeagueResult {
leagueId: string;
seasonId?: string;
success: boolean;
}
/**
* Pure mapping from LeagueConfigFormModel to the creation command.
* Driver ownership is handled by the caller.
* Create a league via API.
*/
export function buildCreateLeagueCommandFromConfig(
export async function createLeagueFromConfig(
form: LeagueConfigFormModel,
ownerId: string,
): CreateLeagueWithSeasonAndScoringCommand {
): Promise<CreateLeagueResult> {
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
@@ -237,52 +262,23 @@ export function buildCreateLeagueCommandFromConfig(
typeof structure.driversPerTeam === 'number' && structure.driversPerTeam > 0
? structure.driversPerTeam
: 0;
maxTeams = teams;
maxDrivers = teams > 0 && perTeam > 0 ? teams * perTeam : 0;
}
return {
const result = await apiClient.leagues.create({
name: form.basics.name.trim(),
description: (form.basics.description ?? '').trim(),
visibility: form.basics.visibility,
isPublic: form.basics.visibility === 'public',
maxMembers: maxDrivers,
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',
});
return {
leagueId: result.leagueId,
success: result.success,
};
}
/**
* 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<CreateLeagueWithSeasonAndScoringResultDTO> {
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.