This commit is contained in:
2025-12-18 14:48:37 +01:00
parent b476bb7e99
commit f54fa5de5b
23 changed files with 699 additions and 543 deletions

View File

@@ -2,6 +2,7 @@
import React, { useEffect, useState, FormEvent, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/lib/auth/AuthContext';
import {
FileText,
Users,
@@ -23,13 +24,8 @@ import Heading from '@/components/ui/Heading';
import LeagueReviewSummary from '@/components/leagues/LeagueReviewSummary';
import Input from '@/components/ui/Input';
import {
validateLeagueWizardStep,
validateAllLeagueWizardSteps,
hasWizardErrors,
createLeagueFromConfig,
applyScoringPresetToConfig,
} from '@/lib/leagueWizardService';
import { LeagueWizardCommandModel } from '@/lib/command-models/leagues/LeagueWizardCommandModel';
import { LeagueWizardService } from '@/lib/services/leagues/LeagueWizardService';
import type { LeagueScoringPresetDTO } from '@core/racing/application/ports/LeagueScoringPresetProvider';
import type { LeagueConfigFormModel } from '@core/racing/application';
import { LeagueBasicsSection } from './LeagueBasicsSection';
@@ -156,7 +152,7 @@ function stepToStepName(step: Step): StepName {
}
}
import type { WizardErrors } from '@/lib/leagueWizardService';
import { WizardErrors } from '@/lib/types/WizardErrors';
function getDefaultSeasonStartDate(): string {
// Default to next Saturday
@@ -241,6 +237,7 @@ function createDefaultForm(): LeagueWizardFormModel {
export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLeagueWizardProps) {
const router = useRouter();
const { session } = useAuth();
const step = stepNameToStep(stepName);
const [loading, setLoading] = useState(false);
@@ -314,12 +311,12 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
}, []);
const validateStep = (currentStep: Step): boolean => {
const stepErrors = validateLeagueWizardStep(form, currentStep);
const stepErrors = LeagueWizardCommandModel.validateLeagueWizardStep(form, currentStep);
setErrors((prev) => ({
...prev,
...stepErrors,
}));
return !hasWizardErrors(stepErrors);
return !LeagueWizardCommandModel.hasWizardErrors(stepErrors);
};
const goToNextStep = () => {
@@ -348,13 +345,22 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
event.preventDefault();
if (loading) return;
const allErrors = validateAllLeagueWizardSteps(form);
const ownerId = session?.user.userId;
if (!ownerId) {
setErrors((prev) => ({
...prev,
submit: 'You must be logged in to create a league',
}));
return;
}
const allErrors = LeagueWizardCommandModel.validateAllLeagueWizardSteps(form);
setErrors((prev) => ({
...prev,
...allErrors,
}));
if (hasWizardErrors(allErrors)) {
if (LeagueWizardCommandModel.hasWizardErrors(allErrors)) {
onStepChange('basics');
return;
}
@@ -366,7 +372,7 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
});
try {
const result = await createLeagueFromConfig(form);
const result = await LeagueWizardService.createLeagueFromConfig(form, ownerId);
// Clear the draft on successful creation
clearFormStorage();
router.push(`/leagues/${result.leagueId}`);
@@ -386,7 +392,7 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
// Handler for scoring preset selection - delegates to application-level config helper
const handleScoringPresetChange = (patternId: string) => {
setForm((prev) => applyScoringPresetToConfig(prev, patternId));
setForm((prev) => LeagueWizardCommandModel.applyScoringPresetToConfig(prev, patternId));
};
const steps = [

View File

@@ -0,0 +1,292 @@
import { WizardStep } from '@/lib/types/WizardStep';
import { WizardErrors } from '@/lib/types/WizardErrors';
import { CreateLeagueInputDTO } from '@/lib/types/CreateLeagueInputDTO';
import { LeagueWizardValidationMessages } from '@/lib/display-objects/LeagueWizardValidationMessages';
import { ScoringPresetApplier } from '@/lib/utilities/ScoringPresetApplier';
type LeagueWizardFormData = {
leagueId: string | undefined;
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 class LeagueWizardCommandModel {
leagueId: string | undefined;
basics: LeagueWizardFormData['basics'];
structure: LeagueWizardFormData['structure'];
championships: LeagueWizardFormData['championships'];
scoring: LeagueWizardFormData['scoring'];
dropPolicy: LeagueWizardFormData['dropPolicy'];
timings: LeagueWizardFormData['timings'];
stewarding: LeagueWizardFormData['stewarding'];
constructor(initial: Partial<LeagueWizardFormData> = {}) {
this.leagueId = initial.leagueId;
this.basics = {
name: '',
description: '',
visibility: 'public',
gameId: '',
...initial.basics,
};
this.structure = {
mode: 'solo',
...initial.structure,
};
this.championships = {
enableDriverChampionship: true,
enableTeamChampionship: false,
enableNationsChampionship: false,
enableTrophyChampionship: false,
...initial.championships,
};
this.scoring = {
...initial.scoring,
};
this.dropPolicy = {
strategy: 'none',
...initial.dropPolicy,
};
this.timings = {
...initial.timings,
};
this.stewarding = {
decisionMode: 'owner_only',
requireDefense: false,
defenseTimeLimit: 24,
voteTimeLimit: 48,
protestDeadlineHours: 24,
stewardingClosesHours: 168,
notifyAccusedOnProtest: true,
notifyOnVoteRequired: true,
...initial.stewarding,
};
}
validateStep(step: WizardStep): WizardErrors {
const errors: WizardErrors = {};
// Step 1: Basics
if (step === 1) {
const basicsErrors: NonNullable<WizardErrors['basics']> = {};
if (!this.basics.name || this.basics.name.trim().length === 0) {
basicsErrors.name = LeagueWizardValidationMessages.LEAGUE_NAME_REQUIRED;
} else if (this.basics.name.length < 3) {
basicsErrors.name = LeagueWizardValidationMessages.LEAGUE_NAME_TOO_SHORT;
} else if (this.basics.name.length > 100) {
basicsErrors.name = LeagueWizardValidationMessages.LEAGUE_NAME_TOO_LONG;
}
if (this.basics.description && this.basics.description.length > 500) {
basicsErrors.description = LeagueWizardValidationMessages.DESCRIPTION_TOO_LONG;
}
if (Object.keys(basicsErrors).length > 0) {
errors.basics = basicsErrors;
}
}
// Step 2: Visibility
if (step === 2) {
const basicsErrors: NonNullable<WizardErrors['basics']> = {};
if (!this.basics.visibility) {
basicsErrors.visibility = LeagueWizardValidationMessages.VISIBILITY_REQUIRED;
}
if (Object.keys(basicsErrors).length > 0) {
errors.basics = basicsErrors;
}
}
// Step 3: Structure
if (step === 3) {
const structureErrors: NonNullable<WizardErrors['structure']> = {};
if (this.structure.mode === 'solo') {
if (!this.structure.maxDrivers || this.structure.maxDrivers <= 0) {
structureErrors.maxDrivers = LeagueWizardValidationMessages.MAX_DRIVERS_INVALID_SOLO;
} else if (this.structure.maxDrivers > 100) {
structureErrors.maxDrivers = LeagueWizardValidationMessages.MAX_DRIVERS_TOO_HIGH;
}
} else if (this.structure.mode === 'fixedTeams') {
if (!this.structure.maxTeams || this.structure.maxTeams <= 0) {
structureErrors.maxTeams = LeagueWizardValidationMessages.MAX_TEAMS_INVALID_TEAM;
}
if (!this.structure.driversPerTeam || this.structure.driversPerTeam <= 0) {
structureErrors.driversPerTeam = LeagueWizardValidationMessages.DRIVERS_PER_TEAM_INVALID;
}
}
if (Object.keys(structureErrors).length > 0) {
errors.structure = structureErrors;
}
}
// Step 4: Timings
if (step === 4) {
const timingsErrors: NonNullable<WizardErrors['timings']> = {};
if (!this.timings.qualifyingMinutes || this.timings.qualifyingMinutes <= 0) {
timingsErrors.qualifyingMinutes = LeagueWizardValidationMessages.QUALIFYING_DURATION_INVALID;
}
if (!this.timings.mainRaceMinutes || this.timings.mainRaceMinutes <= 0) {
timingsErrors.mainRaceMinutes = LeagueWizardValidationMessages.MAIN_RACE_DURATION_INVALID;
}
if (Object.keys(timingsErrors).length > 0) {
errors.timings = timingsErrors;
}
}
// Step 5: Scoring
if (step === 5) {
const scoringErrors: NonNullable<WizardErrors['scoring']> = {};
if (!this.scoring.patternId && !this.scoring.customScoringEnabled) {
scoringErrors.patternId = LeagueWizardValidationMessages.SCORING_PRESET_OR_CUSTOM_REQUIRED;
}
if (Object.keys(scoringErrors).length > 0) {
errors.scoring = scoringErrors;
}
}
return errors;
}
validateAll(): 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(this.validateStep(1));
merge(this.validateStep(2));
merge(this.validateStep(3));
merge(this.validateStep(4));
merge(this.validateStep(5));
return aggregate;
}
static 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;
});
}
applyScoringPreset(patternId: string): void {
this.scoring = {
...this.scoring,
patternId,
customScoringEnabled: false,
};
this.timings = ScoringPresetApplier.applyToTimings(patternId, this.timings);
}
toCreateLeagueCommand(ownerId: string): CreateLeagueInputDTO {
let maxMembers: number;
if (this.structure.mode === 'solo') {
maxMembers = this.structure.maxDrivers ?? 0;
} else {
const teams = this.structure.maxTeams ?? 0;
const perTeam = this.structure.driversPerTeam ?? 0;
maxMembers = teams * perTeam;
}
return {
name: this.basics.name.trim(),
description: this.basics.description?.trim() ?? '',
isPublic: this.basics.visibility === 'public',
maxMembers,
ownerId,
};
}
// Static methods for backward compatibility with component usage
static validateLeagueWizardStep(form: LeagueWizardFormData, step: WizardStep): WizardErrors {
const instance = new LeagueWizardCommandModel(form);
return instance.validateStep(step);
}
static validateAllLeagueWizardSteps(form: LeagueWizardFormData): WizardErrors {
const instance = new LeagueWizardCommandModel(form);
return instance.validateAll();
}
static applyScoringPresetToConfig(form: LeagueWizardFormData, patternId: string): LeagueWizardFormData {
const instance = new LeagueWizardCommandModel(form);
instance.applyScoringPreset(patternId);
return {
leagueId: instance.leagueId,
basics: instance.basics,
structure: instance.structure,
championships: instance.championships,
scoring: instance.scoring,
dropPolicy: instance.dropPolicy,
timings: instance.timings,
stewarding: instance.stewarding,
};
}
}

View File

@@ -0,0 +1,37 @@
import { LeagueRole } from '@/lib/types/LeagueRole';
export interface LeagueRoleDisplayData {
text: string;
badgeClasses: string;
}
export class LeagueRoleDisplay {
/**
* Centralized display configuration for league membership roles.
*/
static getLeagueRoleDisplay(role: LeagueRole): LeagueRoleDisplayData {
switch (role) {
case 'owner':
return {
text: 'Owner',
badgeClasses: 'bg-yellow-500/10 text-yellow-500 border-yellow-500/30',
};
case 'admin':
return {
text: 'Admin',
badgeClasses: 'bg-purple-500/10 text-purple-400 border-purple-500/30',
};
case 'steward':
return {
text: 'Steward',
badgeClasses: 'bg-blue-500/10 text-blue-400 border-blue-500/30',
};
case 'member':
default:
return {
text: 'Member',
badgeClasses: 'bg-primary-blue/10 text-primary-blue border-primary-blue/30',
};
}
}
}

View File

@@ -0,0 +1,14 @@
export class LeagueWizardValidationMessages {
static readonly LEAGUE_NAME_REQUIRED = 'League name is required';
static readonly LEAGUE_NAME_TOO_SHORT = 'League name must be at least 3 characters';
static readonly LEAGUE_NAME_TOO_LONG = 'League name must be less than 100 characters';
static readonly DESCRIPTION_TOO_LONG = 'Description must be less than 500 characters';
static readonly VISIBILITY_REQUIRED = 'Visibility is required';
static readonly MAX_DRIVERS_INVALID_SOLO = 'Max drivers must be greater than 0 for solo leagues';
static readonly MAX_DRIVERS_TOO_HIGH = 'Max drivers cannot exceed 100';
static readonly MAX_TEAMS_INVALID_TEAM = 'Max teams must be greater than 0 for team leagues';
static readonly DRIVERS_PER_TEAM_INVALID = 'Drivers per team must be greater than 0';
static readonly QUALIFYING_DURATION_INVALID = 'Qualifying duration must be greater than 0 minutes';
static readonly MAIN_RACE_DURATION_INVALID = 'Main race duration must be greater than 0 minutes';
static readonly SCORING_PRESET_OR_CUSTOM_REQUIRED = 'Select a scoring preset or enable custom scoring';
}

View File

@@ -1,98 +0,0 @@
'use client';
import { apiClient } from '@/lib/apiClient';
/**
* Membership role types - these are defined locally to avoid core dependencies
*/
export type MembershipRole = 'owner' | 'admin' | 'steward' | 'member';
export type MembershipStatus = 'active' | 'inactive' | 'pending';
/**
* Lightweight league membership model for UI.
*/
export interface LeagueMembership {
id: string;
leagueId: string;
driverId: string;
role: MembershipRole;
status: MembershipStatus;
joinedAt: string;
}
// In-memory cache for memberships (populated via API calls)
const leagueMemberships = new Map<string, LeagueMembership[]>();
/**
* Get a specific membership from cache.
*/
export function getMembership(leagueId: string, driverId: string): LeagueMembership | null {
const list = leagueMemberships.get(leagueId);
if (!list) return null;
return list.find((m) => m.driverId === driverId) ?? null;
}
/**
* Get all members of a league from cache.
*/
export function getLeagueMembers(leagueId: string): LeagueMembership[] {
return [...(leagueMemberships.get(leagueId) ?? [])];
}
/**
* Fetch and cache memberships for a league via API.
*/
export async function fetchLeagueMemberships(leagueId: string): Promise<LeagueMembership[]> {
try {
const result = await apiClient.leagues.getMemberships(leagueId);
const memberships: LeagueMembership[] = result.members.map(member => ({
id: `${member.driverId}-${leagueId}`, // Generate ID since API doesn't provide it
leagueId,
driverId: member.driverId,
role: member.role as MembershipRole,
status: 'active' as MembershipStatus, // Assume active since API returns current members
joinedAt: member.joinedAt,
}));
setLeagueMemberships(leagueId, memberships);
return memberships;
} catch (error) {
console.error('Failed to fetch league memberships:', error);
return [];
}
}
/**
* Set memberships in cache (for use after API calls).
*/
export function setLeagueMemberships(leagueId: string, memberships: LeagueMembership[]): void {
leagueMemberships.set(leagueId, memberships);
}
/**
* Clear cached memberships for a league.
*/
export function clearLeagueMemberships(leagueId: string): void {
leagueMemberships.delete(leagueId);
}
/**
* Derive a driver's primary league from cached memberships.
* Prefers any active membership and returns the first matching league.
*/
export function getPrimaryLeagueIdForDriver(driverId: string): string | null {
for (const [leagueId, members] of leagueMemberships.entries()) {
if (members.some((m) => m.driverId === driverId && m.status === 'active')) {
return leagueId;
}
}
return null;
}
/**
* Check if a driver is owner or admin of a league.
*/
export function isOwnerOrAdmin(leagueId: string, driverId: string): boolean {
const membership = getMembership(leagueId, driverId);
if (!membership) return false;
return membership.role === 'owner' || membership.role === 'admin';
}

View File

@@ -1,73 +0,0 @@
/**
* League role types - defined locally to avoid core dependencies
*/
export type MembershipRole = 'owner' | 'admin' | 'steward' | 'member';
export type LeagueRole = MembershipRole;
export function isLeagueOwnerRole(role: LeagueRole): boolean {
return role === 'owner';
}
export function isLeagueAdminRole(role: LeagueRole): boolean {
return role === 'admin';
}
export function isLeagueStewardRole(role: LeagueRole): boolean {
return role === 'steward';
}
export function isLeagueMemberRole(role: LeagueRole): boolean {
return role === 'member';
}
/**
* Returns true for roles that should be treated as having elevated permissions.
* This keeps UI logic open for future roles like steward, streamer, sponsor.
*/
export function isLeagueAdminOrHigherRole(role: LeagueRole): boolean {
return role === 'owner' || role === 'admin' || role === 'steward';
}
/**
* Ordering helper for sorting memberships in tables.
*/
export function getLeagueRoleOrder(role: LeagueRole): number {
const order: Record<LeagueRole, number> = {
owner: 0,
admin: 1,
steward: 2,
member: 3,
};
return order[role] ?? 99;
}
/**
* Centralized display configuration for league membership roles.
*/
export function getLeagueRoleDisplay(
role: LeagueRole,
): { text: string; badgeClasses: string } {
switch (role) {
case 'owner':
return {
text: 'Owner',
badgeClasses: 'bg-yellow-500/10 text-yellow-500 border-yellow-500/30',
};
case 'admin':
return {
text: 'Admin',
badgeClasses: 'bg-purple-500/10 text-purple-400 border-purple-500/30',
};
case 'steward':
return {
text: 'Steward',
badgeClasses: 'bg-blue-500/10 text-blue-400 border-blue-500/30',
};
case 'member':
default:
return {
text: 'Member',
badgeClasses: 'bg-primary-blue/10 text-primary-blue border-primary-blue/30',
};
}
}

View File

@@ -1,334 +0,0 @@
/**
* 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;
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<WizardErrors['basics']> = {};
// 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';
}
// 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) {
errors.basics = basicsErrors;
}
}
// Step 2: Visibility (ranked/unranked)
if (step === 2) {
const basicsErrors: NonNullable<WizardErrors['basics']> = {};
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<WizardErrors['structure']> = {};
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 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';
}
if (!form.structure.driversPerTeam || form.structure.driversPerTeam <= 0) {
structureErrors.driversPerTeam =
'Drivers per team must be greater than 0';
}
}
if (Object.keys(structureErrors).length > 0) {
errors.structure = structureErrors;
}
}
// Step 4: Schedule (timings)
if (step === 4) {
const timingsErrors: NonNullable<WizardErrors['timings']> = {};
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<WizardErrors['scoring']> = {};
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;
});
}
export interface CreateLeagueResult {
leagueId: string;
seasonId?: string;
success: boolean;
}
/**
* Create a league via API.
*/
export async function createLeagueFromConfig(
form: LeagueConfigFormModel,
ownerId: string,
): Promise<CreateLeagueResult> {
const structure = form.structure;
let maxDrivers: number;
if (structure.mode === 'solo') {
maxDrivers =
typeof structure.maxDrivers === 'number' && structure.maxDrivers > 0
? structure.maxDrivers
: 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;
maxDrivers = teams > 0 && perTeam > 0 ? teams * perTeam : 0;
}
const result = await apiClient.leagues.create({
name: form.basics.name.trim(),
description: (form.basics.description ?? '').trim(),
isPublic: form.basics.visibility === 'public',
maxMembers: maxDrivers,
ownerId,
});
return {
leagueId: result.leagueId,
success: result.success,
};
}
/**
* 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<LeagueConfigFormModel['timings']> = {
...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,
};
}

View File

@@ -1,35 +1,68 @@
import { LeagueMemberDTO } from '@/lib/types/generated/LeagueMemberDTO';
import { LeagueMemberViewModel } from '@/lib/view-models/LeagueMemberViewModel';
import type { LeaguesApiClient } from '../../api/leagues/LeaguesApiClient';
import { apiClient } from '@/lib/apiClient';
import { LeagueMembership } from '@/lib/types/LeagueMembership';
import { MembershipRole } from '@/lib/types/MembershipRole';
import { MembershipStatus } from '@/lib/types/MembershipStatus';
// TODO: Move to generated types when available
type LeagueMembershipsDTO = {
members: LeagueMemberDTO[];
};
/**
* League Membership Service
*
* Orchestrates league membership operations by coordinating API calls and view model creation.
* All dependencies are injected via constructor.
*/
export class LeagueMembershipService {
constructor(
private readonly apiClient: LeaguesApiClient
) {}
// In-memory cache for memberships (populated via API calls)
private static leagueMemberships = new Map<string, LeagueMembership[]>();
/**
* Get league memberships with view model transformation
* Get a specific membership from cache.
*/
async getLeagueMemberships(leagueId: string, currentUserId: string): Promise<LeagueMemberViewModel[]> {
const dto: LeagueMembershipsDTO = await this.apiClient.getMemberships(leagueId);
return dto.members.map((member: LeagueMemberDTO) => new LeagueMemberViewModel(member, currentUserId));
static getMembership(leagueId: string, driverId: string): LeagueMembership | null {
const list = this.leagueMemberships.get(leagueId);
if (!list) return null;
return list.find((m) => m.driverId === driverId) ?? null;
}
/**
* Remove a member from league
* Get all members of a league from cache.
*/
async removeMember(leagueId: string, performerDriverId: string, targetDriverId: string): Promise<{ success: boolean }> {
return await this.apiClient.removeMember(leagueId, performerDriverId, targetDriverId);
static getLeagueMembers(leagueId: string): LeagueMembership[] {
return [...(this.leagueMemberships.get(leagueId) ?? [])];
}
/**
* Fetch and cache memberships for a league via API.
*/
static async fetchLeagueMemberships(leagueId: string): Promise<LeagueMembership[]> {
try {
const result = await apiClient.leagues.getMemberships(leagueId);
const memberships: LeagueMembership[] = result.members.map(member => ({
id: `${member.driverId}-${leagueId}`, // Generate ID since API doesn't provide it
leagueId,
driverId: member.driverId,
role: member.role as MembershipRole,
status: 'active' as MembershipStatus, // Assume active since API returns current members
joinedAt: member.joinedAt,
}));
this.setLeagueMemberships(leagueId, memberships);
return memberships;
} catch (error) {
console.error('Failed to fetch league memberships:', error);
return [];
}
}
/**
* Set memberships in cache (for use after API calls).
*/
static setLeagueMemberships(leagueId: string, memberships: LeagueMembership[]): void {
this.leagueMemberships.set(leagueId, memberships);
}
/**
* Clear cached memberships for a league.
*/
static clearLeagueMemberships(leagueId: string): void {
this.leagueMemberships.delete(leagueId);
}
/**
* Get iterator for cached memberships (for utility functions).
*/
static getCachedMembershipsIterator(): IterableIterator<[string, LeagueMembership[]]> {
return this.leagueMemberships.entries();
}
}

View File

@@ -0,0 +1,26 @@
import { apiClient } from '@/lib/apiClient';
import { LeagueWizardCommandModel } from '@/lib/command-models/leagues/LeagueWizardCommandModel';
import { CreateLeagueResult } from '@/lib/types/CreateLeagueResult';
export class LeagueWizardService {
static async createLeague(
form: LeagueWizardCommandModel,
ownerId: string,
): Promise<CreateLeagueResult> {
const command = form.toCreateLeagueCommand(ownerId);
const result = await apiClient.leagues.create(command);
return {
leagueId: result.leagueId,
success: result.success,
};
}
// Static method for backward compatibility
static async createLeagueFromConfig(
form: LeagueWizardCommandModel,
ownerId: string,
): Promise<CreateLeagueResult> {
return this.createLeague(form, ownerId);
}
}

View File

@@ -0,0 +1,7 @@
export interface CreateLeagueInputDTO {
name: string;
description: string;
isPublic: boolean;
maxMembers: number;
ownerId: string;
}

View File

@@ -0,0 +1,5 @@
export interface CreateLeagueResult {
leagueId: string;
seasonId?: string;
success: boolean;
}

View File

@@ -0,0 +1,50 @@
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;
};
}

View File

@@ -0,0 +1,14 @@
import { MembershipRole } from './MembershipRole';
import { MembershipStatus } from './MembershipStatus';
/**
* Lightweight league membership model for UI.
*/
export interface LeagueMembership {
id: string;
leagueId: string;
driverId: string;
role: MembershipRole;
status: MembershipStatus;
joinedAt: string;
}

View File

@@ -0,0 +1,3 @@
import { MembershipRole } from './MembershipRole';
export type LeagueRole = MembershipRole;

View File

@@ -0,0 +1 @@
export type MembershipRole = 'owner' | 'admin' | 'steward' | 'member';

View File

@@ -0,0 +1 @@
export type MembershipStatus = 'active' | 'inactive' | 'pending';

View File

@@ -0,0 +1,21 @@
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;
}

View File

@@ -0,0 +1 @@
export type WizardStep = 1 | 2 | 3 | 4 | 5 | 6 | 7;

View File

@@ -0,0 +1,26 @@
import { LeagueMembershipService } from '@/lib/services/leagues/LeagueMembershipService';
import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility';
export class LeagueMembershipUtility {
/**
* Derive a driver's primary league from cached memberships.
* Prefers any active membership and returns the first matching league.
*/
static getPrimaryLeagueIdForDriver(driverId: string): string | null {
for (const [leagueId, members] of LeagueMembershipService.getCachedMembershipsIterator()) {
if (members.some((m) => m.driverId === driverId && m.status === 'active')) {
return leagueId;
}
}
return null;
}
/**
* Check if a driver is owner or admin of a league.
*/
static isOwnerOrAdmin(leagueId: string, driverId: string): boolean {
const membership = LeagueMembershipService.getMembership(leagueId, driverId);
if (!membership) return false;
return LeagueRoleUtility.isLeagueAdminOrHigherRole(membership.role);
}
}

View File

@@ -0,0 +1,40 @@
import { LeagueRole } from '@/lib/types/LeagueRole';
export class LeagueRoleUtility {
static isLeagueOwnerRole(role: LeagueRole): boolean {
return role === 'owner';
}
static isLeagueAdminRole(role: LeagueRole): boolean {
return role === 'admin';
}
static isLeagueStewardRole(role: LeagueRole): boolean {
return role === 'steward';
}
static isLeagueMemberRole(role: LeagueRole): boolean {
return role === 'member';
}
/**
* Returns true for roles that should be treated as having elevated permissions.
* This keeps UI logic open for future roles like steward, streamer, sponsor.
*/
static isLeagueAdminOrHigherRole(role: LeagueRole): boolean {
return role === 'owner' || role === 'admin' || role === 'steward';
}
/**
* Ordering helper for sorting memberships in tables.
*/
static getLeagueRoleOrder(role: LeagueRole): number {
const order: Record<LeagueRole, number> = {
owner: 0,
admin: 1,
steward: 2,
member: 3,
};
return order[role] ?? 99;
}
}

View File

@@ -0,0 +1,50 @@
// TODO: Move this business logic to core domain layer - scoring presets and their timing rules are domain concepts
type Timings = {
practiceMinutes?: number;
qualifyingMinutes?: number;
sprintRaceMinutes?: number;
mainRaceMinutes?: number;
sessionCount?: number;
roundsPlanned?: number;
raceDayOfWeek?: number;
raceTimeUtc?: string;
};
export class ScoringPresetApplier {
static applyToTimings(patternId: string, currentTimings: Timings): Timings {
const lowerPresetId = patternId.toLowerCase();
let updatedTimings: Timings = { ...currentTimings };
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 updatedTimings;
}
}

View File

@@ -11,6 +11,7 @@
"noUnusedLocals": true,
"noUnusedParameters": true,
"noEmitOnError": true,
"types": ["react", "react-dom"],
"plugins": [
{
"name": "next"

33
package-lock.json generated
View File

@@ -259,6 +259,7 @@
"eslint": "^8.57.0",
"eslint-config-next": "15.5.7",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-unused-imports": "^3.0.0",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.18",
"typescript": "^5.6.0"
@@ -7477,6 +7478,38 @@
"semver": "bin/semver.js"
}
},
"node_modules/eslint-plugin-unused-imports": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-unused-imports/-/eslint-plugin-unused-imports-3.2.0.tgz",
"integrity": "sha512-6uXyn6xdINEpxE1MtDjxQsyXB37lfyO2yKGVVgtD7WEWQGORSOZjgrD6hBhvGv4/SO+TOlS+UnC6JppRqbuwGQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"eslint-rule-composer": "^0.3.0"
},
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
},
"peerDependencies": {
"@typescript-eslint/eslint-plugin": "6 - 7",
"eslint": "8"
},
"peerDependenciesMeta": {
"@typescript-eslint/eslint-plugin": {
"optional": true
}
}
},
"node_modules/eslint-rule-composer": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/eslint-rule-composer/-/eslint-rule-composer-0.3.0.tgz",
"integrity": "sha512-bt+Sh8CtDmn2OajxvNO+BX7Wn4CIWMpTRm3MaiKPCQcnnlm0CS2mhui6QaoeQugs+3Kj2ESKEEGJUdVafwhiCg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/eslint-scope": {
"version": "7.2.2",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz",