cleanup
This commit is contained in:
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import React, { useEffect, useState, FormEvent, useCallback } from 'react';
|
import React, { useEffect, useState, FormEvent, useCallback } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useAuth } from '@/lib/auth/AuthContext';
|
||||||
import {
|
import {
|
||||||
FileText,
|
FileText,
|
||||||
Users,
|
Users,
|
||||||
@@ -23,13 +24,8 @@ import Heading from '@/components/ui/Heading';
|
|||||||
import LeagueReviewSummary from '@/components/leagues/LeagueReviewSummary';
|
import LeagueReviewSummary from '@/components/leagues/LeagueReviewSummary';
|
||||||
import Input from '@/components/ui/Input';
|
import Input from '@/components/ui/Input';
|
||||||
|
|
||||||
import {
|
import { LeagueWizardCommandModel } from '@/lib/command-models/leagues/LeagueWizardCommandModel';
|
||||||
validateLeagueWizardStep,
|
import { LeagueWizardService } from '@/lib/services/leagues/LeagueWizardService';
|
||||||
validateAllLeagueWizardSteps,
|
|
||||||
hasWizardErrors,
|
|
||||||
createLeagueFromConfig,
|
|
||||||
applyScoringPresetToConfig,
|
|
||||||
} from '@/lib/leagueWizardService';
|
|
||||||
import type { LeagueScoringPresetDTO } from '@core/racing/application/ports/LeagueScoringPresetProvider';
|
import type { LeagueScoringPresetDTO } from '@core/racing/application/ports/LeagueScoringPresetProvider';
|
||||||
import type { LeagueConfigFormModel } from '@core/racing/application';
|
import type { LeagueConfigFormModel } from '@core/racing/application';
|
||||||
import { LeagueBasicsSection } from './LeagueBasicsSection';
|
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 {
|
function getDefaultSeasonStartDate(): string {
|
||||||
// Default to next Saturday
|
// Default to next Saturday
|
||||||
@@ -241,6 +237,7 @@ function createDefaultForm(): LeagueWizardFormModel {
|
|||||||
|
|
||||||
export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLeagueWizardProps) {
|
export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLeagueWizardProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { session } = useAuth();
|
||||||
|
|
||||||
const step = stepNameToStep(stepName);
|
const step = stepNameToStep(stepName);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -314,12 +311,12 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const validateStep = (currentStep: Step): boolean => {
|
const validateStep = (currentStep: Step): boolean => {
|
||||||
const stepErrors = validateLeagueWizardStep(form, currentStep);
|
const stepErrors = LeagueWizardCommandModel.validateLeagueWizardStep(form, currentStep);
|
||||||
setErrors((prev) => ({
|
setErrors((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
...stepErrors,
|
...stepErrors,
|
||||||
}));
|
}));
|
||||||
return !hasWizardErrors(stepErrors);
|
return !LeagueWizardCommandModel.hasWizardErrors(stepErrors);
|
||||||
};
|
};
|
||||||
|
|
||||||
const goToNextStep = () => {
|
const goToNextStep = () => {
|
||||||
@@ -348,13 +345,22 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (loading) return;
|
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) => ({
|
setErrors((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
...allErrors,
|
...allErrors,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
if (hasWizardErrors(allErrors)) {
|
if (LeagueWizardCommandModel.hasWizardErrors(allErrors)) {
|
||||||
onStepChange('basics');
|
onStepChange('basics');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -366,7 +372,7 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await createLeagueFromConfig(form);
|
const result = await LeagueWizardService.createLeagueFromConfig(form, ownerId);
|
||||||
// Clear the draft on successful creation
|
// Clear the draft on successful creation
|
||||||
clearFormStorage();
|
clearFormStorage();
|
||||||
router.push(`/leagues/${result.leagueId}`);
|
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
|
// Handler for scoring preset selection - delegates to application-level config helper
|
||||||
const handleScoringPresetChange = (patternId: string) => {
|
const handleScoringPresetChange = (patternId: string) => {
|
||||||
setForm((prev) => applyScoringPresetToConfig(prev, patternId));
|
setForm((prev) => LeagueWizardCommandModel.applyScoringPresetToConfig(prev, patternId));
|
||||||
};
|
};
|
||||||
|
|
||||||
const steps = [
|
const steps = [
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
37
apps/website/lib/display-objects/LeagueRoleDisplay.ts
Normal file
37
apps/website/lib/display-objects/LeagueRoleDisplay.ts
Normal 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',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
@@ -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';
|
|
||||||
}
|
|
||||||
@@ -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',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,35 +1,68 @@
|
|||||||
import { LeagueMemberDTO } from '@/lib/types/generated/LeagueMemberDTO';
|
import { apiClient } from '@/lib/apiClient';
|
||||||
import { LeagueMemberViewModel } from '@/lib/view-models/LeagueMemberViewModel';
|
import { LeagueMembership } from '@/lib/types/LeagueMembership';
|
||||||
import type { LeaguesApiClient } from '../../api/leagues/LeaguesApiClient';
|
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 {
|
export class LeagueMembershipService {
|
||||||
constructor(
|
// In-memory cache for memberships (populated via API calls)
|
||||||
private readonly apiClient: LeaguesApiClient
|
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[]> {
|
static getMembership(leagueId: string, driverId: string): LeagueMembership | null {
|
||||||
const dto: LeagueMembershipsDTO = await this.apiClient.getMemberships(leagueId);
|
const list = this.leagueMemberships.get(leagueId);
|
||||||
return dto.members.map((member: LeagueMemberDTO) => new LeagueMemberViewModel(member, currentUserId));
|
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 }> {
|
static getLeagueMembers(leagueId: string): LeagueMembership[] {
|
||||||
return await this.apiClient.removeMember(leagueId, performerDriverId, targetDriverId);
|
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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
26
apps/website/lib/services/leagues/LeagueWizardService.ts
Normal file
26
apps/website/lib/services/leagues/LeagueWizardService.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
7
apps/website/lib/types/CreateLeagueInputDTO.ts
Normal file
7
apps/website/lib/types/CreateLeagueInputDTO.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export interface CreateLeagueInputDTO {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
isPublic: boolean;
|
||||||
|
maxMembers: number;
|
||||||
|
ownerId: string;
|
||||||
|
}
|
||||||
5
apps/website/lib/types/CreateLeagueResult.ts
Normal file
5
apps/website/lib/types/CreateLeagueResult.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export interface CreateLeagueResult {
|
||||||
|
leagueId: string;
|
||||||
|
seasonId?: string;
|
||||||
|
success: boolean;
|
||||||
|
}
|
||||||
50
apps/website/lib/types/LeagueConfigFormModel.ts
Normal file
50
apps/website/lib/types/LeagueConfigFormModel.ts
Normal 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;
|
||||||
|
};
|
||||||
|
}
|
||||||
14
apps/website/lib/types/LeagueMembership.ts
Normal file
14
apps/website/lib/types/LeagueMembership.ts
Normal 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;
|
||||||
|
}
|
||||||
3
apps/website/lib/types/LeagueRole.ts
Normal file
3
apps/website/lib/types/LeagueRole.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { MembershipRole } from './MembershipRole';
|
||||||
|
|
||||||
|
export type LeagueRole = MembershipRole;
|
||||||
1
apps/website/lib/types/MembershipRole.ts
Normal file
1
apps/website/lib/types/MembershipRole.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export type MembershipRole = 'owner' | 'admin' | 'steward' | 'member';
|
||||||
1
apps/website/lib/types/MembershipStatus.ts
Normal file
1
apps/website/lib/types/MembershipStatus.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export type MembershipStatus = 'active' | 'inactive' | 'pending';
|
||||||
21
apps/website/lib/types/WizardErrors.ts
Normal file
21
apps/website/lib/types/WizardErrors.ts
Normal 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;
|
||||||
|
}
|
||||||
1
apps/website/lib/types/WizardStep.ts
Normal file
1
apps/website/lib/types/WizardStep.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export type WizardStep = 1 | 2 | 3 | 4 | 5 | 6 | 7;
|
||||||
26
apps/website/lib/utilities/LeagueMembershipUtility.ts
Normal file
26
apps/website/lib/utilities/LeagueMembershipUtility.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
40
apps/website/lib/utilities/LeagueRoleUtility.ts
Normal file
40
apps/website/lib/utilities/LeagueRoleUtility.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
50
apps/website/lib/utilities/ScoringPresetApplier.ts
Normal file
50
apps/website/lib/utilities/ScoringPresetApplier.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
"noUnusedLocals": true,
|
"noUnusedLocals": true,
|
||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
"noEmitOnError": true,
|
"noEmitOnError": true,
|
||||||
|
"types": ["react", "react-dom"],
|
||||||
"plugins": [
|
"plugins": [
|
||||||
{
|
{
|
||||||
"name": "next"
|
"name": "next"
|
||||||
|
|||||||
33
package-lock.json
generated
33
package-lock.json
generated
@@ -259,6 +259,7 @@
|
|||||||
"eslint": "^8.57.0",
|
"eslint": "^8.57.0",
|
||||||
"eslint-config-next": "15.5.7",
|
"eslint-config-next": "15.5.7",
|
||||||
"eslint-plugin-import": "^2.32.0",
|
"eslint-plugin-import": "^2.32.0",
|
||||||
|
"eslint-plugin-unused-imports": "^3.0.0",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"tailwindcss": "^3.4.18",
|
"tailwindcss": "^3.4.18",
|
||||||
"typescript": "^5.6.0"
|
"typescript": "^5.6.0"
|
||||||
@@ -7477,6 +7478,38 @@
|
|||||||
"semver": "bin/semver.js"
|
"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": {
|
"node_modules/eslint-scope": {
|
||||||
"version": "7.2.2",
|
"version": "7.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz",
|
||||||
|
|||||||
Reference in New Issue
Block a user