From f54fa5de5b5eabd3f7b60df35b4fcc6e1fb7b98b Mon Sep 17 00:00:00 2001 From: Marc Mintel Date: Thu, 18 Dec 2025 14:48:37 +0100 Subject: [PATCH] cleanup --- .../components/leagues/CreateLeagueWizard.tsx | 34 +- .../leagues/LeagueWizardCommandModel.ts | 292 +++++++++++++++ .../lib/display-objects/LeagueRoleDisplay.ts | 37 ++ .../LeagueWizardValidationMessages.ts | 14 + apps/website/lib/leagueMembership.ts | 98 ----- apps/website/lib/leagueRoles.ts | 73 ---- apps/website/lib/leagueWizardService.ts | 334 ------------------ .../leagues/LeagueMembershipService.ts | 81 +++-- .../services/leagues/LeagueWizardService.ts | 26 ++ .../website/lib/types/CreateLeagueInputDTO.ts | 7 + apps/website/lib/types/CreateLeagueResult.ts | 5 + .../lib/types/LeagueConfigFormModel.ts | 50 +++ apps/website/lib/types/LeagueMembership.ts | 14 + apps/website/lib/types/LeagueRole.ts | 3 + apps/website/lib/types/MembershipRole.ts | 1 + apps/website/lib/types/MembershipStatus.ts | 1 + apps/website/lib/types/WizardErrors.ts | 21 ++ apps/website/lib/types/WizardStep.ts | 1 + .../lib/utilities/LeagueMembershipUtility.ts | 26 ++ .../lib/utilities/LeagueRoleUtility.ts | 40 +++ .../lib/utilities/ScoringPresetApplier.ts | 50 +++ apps/website/tsconfig.json | 1 + package-lock.json | 33 ++ 23 files changed, 699 insertions(+), 543 deletions(-) create mode 100644 apps/website/lib/command-models/leagues/LeagueWizardCommandModel.ts create mode 100644 apps/website/lib/display-objects/LeagueRoleDisplay.ts create mode 100644 apps/website/lib/display-objects/LeagueWizardValidationMessages.ts delete mode 100644 apps/website/lib/leagueMembership.ts delete mode 100644 apps/website/lib/leagueRoles.ts delete mode 100644 apps/website/lib/leagueWizardService.ts create mode 100644 apps/website/lib/services/leagues/LeagueWizardService.ts create mode 100644 apps/website/lib/types/CreateLeagueInputDTO.ts create mode 100644 apps/website/lib/types/CreateLeagueResult.ts create mode 100644 apps/website/lib/types/LeagueConfigFormModel.ts create mode 100644 apps/website/lib/types/LeagueMembership.ts create mode 100644 apps/website/lib/types/LeagueRole.ts create mode 100644 apps/website/lib/types/MembershipRole.ts create mode 100644 apps/website/lib/types/MembershipStatus.ts create mode 100644 apps/website/lib/types/WizardErrors.ts create mode 100644 apps/website/lib/types/WizardStep.ts create mode 100644 apps/website/lib/utilities/LeagueMembershipUtility.ts create mode 100644 apps/website/lib/utilities/LeagueRoleUtility.ts create mode 100644 apps/website/lib/utilities/ScoringPresetApplier.ts diff --git a/apps/website/components/leagues/CreateLeagueWizard.tsx b/apps/website/components/leagues/CreateLeagueWizard.tsx index ef0552504..2d40f9553 100644 --- a/apps/website/components/leagues/CreateLeagueWizard.tsx +++ b/apps/website/components/leagues/CreateLeagueWizard.tsx @@ -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 = [ diff --git a/apps/website/lib/command-models/leagues/LeagueWizardCommandModel.ts b/apps/website/lib/command-models/leagues/LeagueWizardCommandModel.ts new file mode 100644 index 000000000..b913f963e --- /dev/null +++ b/apps/website/lib/command-models/leagues/LeagueWizardCommandModel.ts @@ -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 = {}) { + 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 = {}; + + 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 = {}; + + 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 = {}; + + 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 = {}; + 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 = {}; + 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, + }; + } +} \ No newline at end of file diff --git a/apps/website/lib/display-objects/LeagueRoleDisplay.ts b/apps/website/lib/display-objects/LeagueRoleDisplay.ts new file mode 100644 index 000000000..e2ee6a100 --- /dev/null +++ b/apps/website/lib/display-objects/LeagueRoleDisplay.ts @@ -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', + }; + } + } +} \ No newline at end of file diff --git a/apps/website/lib/display-objects/LeagueWizardValidationMessages.ts b/apps/website/lib/display-objects/LeagueWizardValidationMessages.ts new file mode 100644 index 000000000..b1a0e7ae0 --- /dev/null +++ b/apps/website/lib/display-objects/LeagueWizardValidationMessages.ts @@ -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'; +} \ No newline at end of file diff --git a/apps/website/lib/leagueMembership.ts b/apps/website/lib/leagueMembership.ts deleted file mode 100644 index 0c7c00388..000000000 --- a/apps/website/lib/leagueMembership.ts +++ /dev/null @@ -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(); - -/** - * 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 { - 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'; -} \ No newline at end of file diff --git a/apps/website/lib/leagueRoles.ts b/apps/website/lib/leagueRoles.ts deleted file mode 100644 index 55bacc3d8..000000000 --- a/apps/website/lib/leagueRoles.ts +++ /dev/null @@ -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 = { - 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', - }; - } -} \ No newline at end of file diff --git a/apps/website/lib/leagueWizardService.ts b/apps/website/lib/leagueWizardService.ts deleted file mode 100644 index 1add1a36c..000000000 --- a/apps/website/lib/leagueWizardService.ts +++ /dev/null @@ -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 = {}; - - // 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 = {}; - - 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 = {}; - - 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 = {}; - if (!form.timings.qualifyingMinutes || form.timings.qualifyingMinutes <= 0) { - timingsErrors.qualifyingMinutes = - 'Qualifying duration must be greater than 0 minutes'; - } - if (!form.timings.mainRaceMinutes || form.timings.mainRaceMinutes <= 0) { - timingsErrors.mainRaceMinutes = - 'Main race duration must be greater than 0 minutes'; - } - if (Object.keys(timingsErrors).length > 0) { - errors.timings = timingsErrors; - } - } - - // Step 5: Scoring - if (step === 5) { - const scoringErrors: NonNullable = {}; - if (!form.scoring.patternId && !form.scoring.customScoringEnabled) { - scoringErrors.patternId = - 'Select a scoring preset or enable custom scoring'; - } - if (Object.keys(scoringErrors).length > 0) { - errors.scoring = scoringErrors; - } - } - - // Step 6: Stewarding - no validation needed currently (all fields have defaults) - - // Step 7: Review - no validation needed, it's just review - - return errors; -} - -/** - * Helper to validate all steps (1-4) and merge errors into a single bag. - */ -export function validateAllLeagueWizardSteps( - form: LeagueConfigFormModel, -): WizardErrors { - const aggregate: WizardErrors = {}; - - const merge = (next: WizardErrors) => { - if (next.basics) { - aggregate.basics = { ...aggregate.basics, ...next.basics }; - } - if (next.structure) { - aggregate.structure = { ...aggregate.structure, ...next.structure }; - } - if (next.timings) { - aggregate.timings = { ...aggregate.timings, ...next.timings }; - } - if (next.scoring) { - aggregate.scoring = { ...aggregate.scoring, ...next.scoring }; - } - if (next.submit) { - aggregate.submit = next.submit; - } - }; - - merge(validateLeagueWizardStep(form, 1)); - merge(validateLeagueWizardStep(form, 2)); - merge(validateLeagueWizardStep(form, 3)); - merge(validateLeagueWizardStep(form, 4)); - merge(validateLeagueWizardStep(form, 5)); - - return aggregate; -} - -export function hasWizardErrors(errors: WizardErrors): boolean { - return Object.keys(errors).some((key) => { - const value = errors[key as keyof WizardErrors]; - if (!value) return false; - if (typeof value === 'string') return true; - return Object.keys(value).length > 0; - }); -} - -export interface CreateLeagueResult { - leagueId: string; - seasonId?: string; - success: boolean; -} - -/** - * Create a league via API. - */ -export async function createLeagueFromConfig( - form: LeagueConfigFormModel, - ownerId: string, -): Promise { - 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 = { - ...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, - }; -} \ No newline at end of file diff --git a/apps/website/lib/services/leagues/LeagueMembershipService.ts b/apps/website/lib/services/leagues/LeagueMembershipService.ts index c56515069..26432e604 100644 --- a/apps/website/lib/services/leagues/LeagueMembershipService.ts +++ b/apps/website/lib/services/leagues/LeagueMembershipService.ts @@ -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(); /** - * Get league memberships with view model transformation + * Get a specific membership from cache. */ - async getLeagueMemberships(leagueId: string, currentUserId: string): Promise { - 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 { + 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(); } } \ No newline at end of file diff --git a/apps/website/lib/services/leagues/LeagueWizardService.ts b/apps/website/lib/services/leagues/LeagueWizardService.ts new file mode 100644 index 000000000..034035a6c --- /dev/null +++ b/apps/website/lib/services/leagues/LeagueWizardService.ts @@ -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 { + 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 { + return this.createLeague(form, ownerId); + } +} \ No newline at end of file diff --git a/apps/website/lib/types/CreateLeagueInputDTO.ts b/apps/website/lib/types/CreateLeagueInputDTO.ts new file mode 100644 index 000000000..661064e79 --- /dev/null +++ b/apps/website/lib/types/CreateLeagueInputDTO.ts @@ -0,0 +1,7 @@ +export interface CreateLeagueInputDTO { + name: string; + description: string; + isPublic: boolean; + maxMembers: number; + ownerId: string; +} \ No newline at end of file diff --git a/apps/website/lib/types/CreateLeagueResult.ts b/apps/website/lib/types/CreateLeagueResult.ts new file mode 100644 index 000000000..d54bba56b --- /dev/null +++ b/apps/website/lib/types/CreateLeagueResult.ts @@ -0,0 +1,5 @@ +export interface CreateLeagueResult { + leagueId: string; + seasonId?: string; + success: boolean; +} \ No newline at end of file diff --git a/apps/website/lib/types/LeagueConfigFormModel.ts b/apps/website/lib/types/LeagueConfigFormModel.ts new file mode 100644 index 000000000..56eaa97d2 --- /dev/null +++ b/apps/website/lib/types/LeagueConfigFormModel.ts @@ -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; + }; +} \ No newline at end of file diff --git a/apps/website/lib/types/LeagueMembership.ts b/apps/website/lib/types/LeagueMembership.ts new file mode 100644 index 000000000..be1044173 --- /dev/null +++ b/apps/website/lib/types/LeagueMembership.ts @@ -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; +} \ No newline at end of file diff --git a/apps/website/lib/types/LeagueRole.ts b/apps/website/lib/types/LeagueRole.ts new file mode 100644 index 000000000..080035cee --- /dev/null +++ b/apps/website/lib/types/LeagueRole.ts @@ -0,0 +1,3 @@ +import { MembershipRole } from './MembershipRole'; + +export type LeagueRole = MembershipRole; \ No newline at end of file diff --git a/apps/website/lib/types/MembershipRole.ts b/apps/website/lib/types/MembershipRole.ts new file mode 100644 index 000000000..3405cace0 --- /dev/null +++ b/apps/website/lib/types/MembershipRole.ts @@ -0,0 +1 @@ +export type MembershipRole = 'owner' | 'admin' | 'steward' | 'member'; \ No newline at end of file diff --git a/apps/website/lib/types/MembershipStatus.ts b/apps/website/lib/types/MembershipStatus.ts new file mode 100644 index 000000000..a52b2a275 --- /dev/null +++ b/apps/website/lib/types/MembershipStatus.ts @@ -0,0 +1 @@ +export type MembershipStatus = 'active' | 'inactive' | 'pending'; \ No newline at end of file diff --git a/apps/website/lib/types/WizardErrors.ts b/apps/website/lib/types/WizardErrors.ts new file mode 100644 index 000000000..6eeab744a --- /dev/null +++ b/apps/website/lib/types/WizardErrors.ts @@ -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; +} \ No newline at end of file diff --git a/apps/website/lib/types/WizardStep.ts b/apps/website/lib/types/WizardStep.ts new file mode 100644 index 000000000..027842b4c --- /dev/null +++ b/apps/website/lib/types/WizardStep.ts @@ -0,0 +1 @@ +export type WizardStep = 1 | 2 | 3 | 4 | 5 | 6 | 7; \ No newline at end of file diff --git a/apps/website/lib/utilities/LeagueMembershipUtility.ts b/apps/website/lib/utilities/LeagueMembershipUtility.ts new file mode 100644 index 000000000..bf85b1918 --- /dev/null +++ b/apps/website/lib/utilities/LeagueMembershipUtility.ts @@ -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); + } +} \ No newline at end of file diff --git a/apps/website/lib/utilities/LeagueRoleUtility.ts b/apps/website/lib/utilities/LeagueRoleUtility.ts new file mode 100644 index 000000000..e8ec10504 --- /dev/null +++ b/apps/website/lib/utilities/LeagueRoleUtility.ts @@ -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 = { + owner: 0, + admin: 1, + steward: 2, + member: 3, + }; + return order[role] ?? 99; + } +} \ No newline at end of file diff --git a/apps/website/lib/utilities/ScoringPresetApplier.ts b/apps/website/lib/utilities/ScoringPresetApplier.ts new file mode 100644 index 000000000..a264e8b04 --- /dev/null +++ b/apps/website/lib/utilities/ScoringPresetApplier.ts @@ -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; + } +} \ No newline at end of file diff --git a/apps/website/tsconfig.json b/apps/website/tsconfig.json index 5cf941062..4d49d8a0d 100644 --- a/apps/website/tsconfig.json +++ b/apps/website/tsconfig.json @@ -11,6 +11,7 @@ "noUnusedLocals": true, "noUnusedParameters": true, "noEmitOnError": true, + "types": ["react", "react-dom"], "plugins": [ { "name": "next" diff --git a/package-lock.json b/package-lock.json index c162ba4b8..667137d5b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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",