import { LeaguesApiClient } from "@/lib/api/leagues/LeaguesApiClient"; import { DriversApiClient } from "@/lib/api/drivers/DriversApiClient"; import { SponsorsApiClient } from "@/lib/api/sponsors/SponsorsApiClient"; import { RacesApiClient } from "@/lib/api/races/RacesApiClient"; import { CreateLeagueInputDTO } from "@/lib/types/generated/CreateLeagueInputDTO"; import { CreateLeagueOutputDTO } from "@/lib/types/generated/CreateLeagueOutputDTO"; import { CreateLeagueViewModel } from "@/lib/view-models/CreateLeagueViewModel"; import { LeagueAdminScheduleViewModel } from "@/lib/view-models/LeagueAdminScheduleViewModel"; import { LeagueMembershipsViewModel } from "@/lib/view-models/LeagueMembershipsViewModel"; import { LeagueScheduleViewModel, type LeagueScheduleRaceViewModel } from "@/lib/view-models/LeagueScheduleViewModel"; import { LeagueSeasonSummaryViewModel } from "@/lib/view-models/LeagueSeasonSummaryViewModel"; import { LeagueStandingsViewModel } from "@/lib/view-models/LeagueStandingsViewModel"; import { LeagueStatsViewModel } from "@/lib/view-models/LeagueStatsViewModel"; import { LeagueSummaryViewModel } from "@/lib/view-models/LeagueSummaryViewModel"; import { RemoveMemberViewModel } from "@/lib/view-models/RemoveMemberViewModel"; import { LeaguePageDetailViewModel } from "@/lib/view-models/LeaguePageDetailViewModel"; import { LeagueDetailPageViewModel, SponsorInfo } from "@/lib/view-models/LeagueDetailPageViewModel"; import { RaceViewModel } from "@/lib/view-models/RaceViewModel"; import type { LeagueAdminRosterJoinRequestViewModel } from "@/lib/view-models/LeagueAdminRosterJoinRequestViewModel"; import type { LeagueAdminRosterMemberViewModel } from "@/lib/view-models/LeagueAdminRosterMemberViewModel"; import type { MembershipRole } from "@/lib/types/MembershipRole"; import type { LeagueRosterJoinRequestDTO } from "@/lib/types/generated/LeagueRosterJoinRequestDTO"; import { SubmitBlocker, ThrottleBlocker } from "@/lib/blockers"; import type { RaceDTO } from "@/lib/types/generated/RaceDTO"; import { LeagueStatsDTO } from "@/lib/types/generated/LeagueStatsDTO"; import { LeagueScoringConfigDTO } from "@/lib/types/generated/LeagueScoringConfigDTO"; import type { LeagueMembership } from "@/lib/types/LeagueMembership"; import type { LeagueSeasonSummaryDTO } from '@/lib/types/generated/LeagueSeasonSummaryDTO'; import type { LeagueScheduleDTO } from '@/lib/types/generated/LeagueScheduleDTO'; import type { CreateLeagueScheduleRaceInputDTO } from '@/lib/types/generated/CreateLeagueScheduleRaceInputDTO'; import type { CreateLeagueScheduleRaceOutputDTO } from '@/lib/types/generated/CreateLeagueScheduleRaceOutputDTO'; import type { UpdateLeagueScheduleRaceInputDTO } from '@/lib/types/generated/UpdateLeagueScheduleRaceInputDTO'; import type { LeagueScheduleRaceMutationSuccessDTO } from '@/lib/types/generated/LeagueScheduleRaceMutationSuccessDTO'; import type { LeagueSeasonSchedulePublishOutputDTO } from '@/lib/types/generated/LeagueSeasonSchedulePublishOutputDTO'; /** * League Service * * Orchestrates league operations by coordinating API calls and view model creation. * All dependencies are injected via constructor. */ function parseIsoDate(value: string, fallback: Date): Date { const parsed = new Date(value); if (Number.isNaN(parsed.getTime())) return fallback; return parsed; } function getBestEffortIsoDate(race: RaceDTO): string | undefined { const anyRace = race as unknown as { scheduledAt?: unknown; date?: unknown }; if (typeof anyRace.scheduledAt === 'string') return anyRace.scheduledAt; if (typeof anyRace.date === 'string') return anyRace.date; return undefined; } function getOptionalStringField(race: RaceDTO, key: string): string | undefined { const anyRace = race as unknown as Record; const value = anyRace[key]; return typeof value === 'string' ? value : undefined; } function getOptionalBooleanField(race: RaceDTO, key: string): boolean | undefined { const anyRace = race as unknown as Record; const value = anyRace[key]; return typeof value === 'boolean' ? value : undefined; } function mapLeagueScheduleDtoToRaceViewModels(dto: LeagueScheduleDTO, now: Date = new Date()): LeagueScheduleRaceViewModel[] { return dto.races.map((race) => { const iso = getBestEffortIsoDate(race); const scheduledAt = iso ? parseIsoDate(iso, new Date(0)) : new Date(0); const isPast = scheduledAt.getTime() < now.getTime(); const isUpcoming = !isPast; const status = getOptionalStringField(race, 'status') ?? (isPast ? 'completed' : 'scheduled'); return { id: race.id, name: race.name, scheduledAt, isPast, isUpcoming, status, track: getOptionalStringField(race, 'track'), car: getOptionalStringField(race, 'car'), sessionType: getOptionalStringField(race, 'sessionType'), isRegistered: getOptionalBooleanField(race, 'isRegistered'), }; }); } export class LeagueService { private readonly submitBlocker = new SubmitBlocker(); private readonly throttle = new ThrottleBlocker(500); constructor( private readonly apiClient: LeaguesApiClient, private readonly driversApiClient?: DriversApiClient, private readonly sponsorsApiClient?: SponsorsApiClient, private readonly racesApiClient?: RacesApiClient ) {} /** * Get all leagues with view model transformation */ async getAllLeagues(): Promise { const dto = await this.apiClient.getAllWithCapacityAndScoring(); const leagues = Array.isArray((dto as any)?.leagues) ? ((dto as any).leagues as any[]) : []; return leagues.map((league) => ({ id: league.id, name: league.name, description: league.description, logoUrl: league.logoUrl ?? null, // Use API-provided logo URL ownerId: league.ownerId, createdAt: league.createdAt, maxDrivers: league.settings?.maxDrivers ?? 0, usedDriverSlots: league.usedSlots ?? 0, structureSummary: league.scoring?.scoringPresetName ?? 'Custom rules', scoringPatternSummary: league.scoring?.scoringPatternSummary, timingSummary: league.timingSummary ?? '', ...(league.category ? { category: league.category } : {}), ...(league.scoring ? { scoring: league.scoring } : {}), })); } /** * Get league standings with view model transformation */ async getLeagueStandings(leagueId: string, currentUserId: string): Promise { // Core standings (positions, points, driverIds) const dto = await this.apiClient.getStandings(leagueId); const standings = ((dto as any)?.standings ?? []) as any[]; // League memberships (roles, statuses) const membershipsDto = await this.apiClient.getMemberships(leagueId); const membershipEntries = ((membershipsDto as any)?.members ?? (membershipsDto as any)?.memberships ?? []) as any[]; const memberships: LeagueMembership[] = membershipEntries.map((m) => ({ driverId: m.driverId, leagueId, role: (m.role as LeagueMembership['role']) ?? 'member', joinedAt: m.joinedAt, status: 'active', })); // Resolve unique drivers that appear in standings const driverIds: string[] = Array.from(new Set(standings.map((entry: any) => entry.driverId))); const driverDtos = this.driversApiClient ? await Promise.all(driverIds.map((id: string) => this.driversApiClient!.getDriver(id))) : []; const drivers = driverDtos.filter((d): d is NonNullable => d !== null); const dtoWithExtras = { standings, drivers, memberships }; return new LeagueStandingsViewModel(dtoWithExtras, currentUserId); } /** * Get league statistics */ async getLeagueStats(): Promise { const dto = await this.apiClient.getTotal(); return new LeagueStatsViewModel(dto); } /** * Get league schedule * * Service boundary: returns ViewModels only (no DTOs / mappers in UI). */ async getLeagueSchedule(leagueId: string): Promise { const dto = await this.apiClient.getSchedule(leagueId); const races = mapLeagueScheduleDtoToRaceViewModels(dto); return new LeagueScheduleViewModel(races); } /** * Admin schedule editor API (ViewModel boundary) */ async getLeagueSeasonSummaries(leagueId: string): Promise { const dtos = await this.apiClient.getSeasons(leagueId); return dtos.map((dto) => new LeagueSeasonSummaryViewModel(dto)); } async getAdminSchedule(leagueId: string, seasonId: string): Promise { const dto = await this.apiClient.getSchedule(leagueId, seasonId); const races = mapLeagueScheduleDtoToRaceViewModels(dto); return new LeagueAdminScheduleViewModel({ seasonId: dto.seasonId, published: dto.published, races, }); } async publishAdminSchedule(leagueId: string, seasonId: string): Promise { await this.apiClient.publishSeasonSchedule(leagueId, seasonId); return this.getAdminSchedule(leagueId, seasonId); } async unpublishAdminSchedule(leagueId: string, seasonId: string): Promise { await this.apiClient.unpublishSeasonSchedule(leagueId, seasonId); return this.getAdminSchedule(leagueId, seasonId); } async createAdminScheduleRace( leagueId: string, seasonId: string, input: { track: string; car: string; scheduledAtIso: string }, ): Promise { const payload: CreateLeagueScheduleRaceInputDTO = { ...input, example: '' }; await this.apiClient.createSeasonScheduleRace(leagueId, seasonId, payload); return this.getAdminSchedule(leagueId, seasonId); } async updateAdminScheduleRace( leagueId: string, seasonId: string, raceId: string, input: Partial<{ track: string; car: string; scheduledAtIso: string }>, ): Promise { const payload: UpdateLeagueScheduleRaceInputDTO = { ...input, example: '' }; await this.apiClient.updateSeasonScheduleRace(leagueId, seasonId, raceId, payload); return this.getAdminSchedule(leagueId, seasonId); } async deleteAdminScheduleRace(leagueId: string, seasonId: string, raceId: string): Promise { await this.apiClient.deleteSeasonScheduleRace(leagueId, seasonId, raceId); return this.getAdminSchedule(leagueId, seasonId); } /** * Legacy DTO methods (kept for existing callers) */ /** * Get league schedule DTO (season-scoped) * * Admin UI uses the raw DTO so it can render `published` and do CRUD refreshes. */ async getLeagueScheduleDto(leagueId: string, seasonId: string): Promise { return this.apiClient.getSchedule(leagueId, seasonId); } /** * Publish a league season schedule */ async publishLeagueSeasonSchedule(leagueId: string, seasonId: string): Promise { return this.apiClient.publishSeasonSchedule(leagueId, seasonId); } /** * Unpublish a league season schedule */ async unpublishLeagueSeasonSchedule(leagueId: string, seasonId: string): Promise { return this.apiClient.unpublishSeasonSchedule(leagueId, seasonId); } /** * Create a schedule race for a league season */ async createLeagueSeasonScheduleRace( leagueId: string, seasonId: string, input: CreateLeagueScheduleRaceInputDTO, ): Promise { return this.apiClient.createSeasonScheduleRace(leagueId, seasonId, input); } /** * Update a schedule race for a league season */ async updateLeagueSeasonScheduleRace( leagueId: string, seasonId: string, raceId: string, input: UpdateLeagueScheduleRaceInputDTO, ): Promise { return this.apiClient.updateSeasonScheduleRace(leagueId, seasonId, raceId, input); } /** * Delete a schedule race for a league season */ async deleteLeagueSeasonScheduleRace( leagueId: string, seasonId: string, raceId: string, ): Promise { return this.apiClient.deleteSeasonScheduleRace(leagueId, seasonId, raceId); } /** * Get seasons for a league */ async getLeagueSeasons(leagueId: string): Promise { return this.apiClient.getSeasons(leagueId); } /** * Get league memberships */ async getLeagueMemberships(leagueId: string, currentUserId: string): Promise { const dto = await this.apiClient.getMemberships(leagueId); return new LeagueMembershipsViewModel(dto, currentUserId); } /** * Create a new league */ async createLeague(input: CreateLeagueInputDTO): Promise { if (!this.submitBlocker.canExecute() || !this.throttle.canExecute()) { return { success: false, leagueId: '' } as CreateLeagueOutputDTO; } this.submitBlocker.block(); this.throttle.block(); try { return await this.apiClient.create(input); } finally { this.submitBlocker.release(); } } /** * Remove a member from league * * Overload: * - Legacy: removeMember(leagueId, performerDriverId, targetDriverId) * - Admin roster: removeMember(leagueId, targetDriverId) (actor derived from session) */ async removeMember(leagueId: string, targetDriverId: string): Promise<{ success: boolean }>; async removeMember(leagueId: string, performerDriverId: string, targetDriverId: string): Promise; async removeMember(leagueId: string, arg1: string, arg2?: string): Promise<{ success: boolean } | RemoveMemberViewModel> { if (arg2 === undefined) { const dto = await this.apiClient.removeRosterMember(leagueId, arg1); return { success: dto.success }; } const dto = await this.apiClient.removeMember(leagueId, arg1, arg2); return new RemoveMemberViewModel(dto as any); } /** * Update a member's role in league * * Overload: * - Legacy: updateMemberRole(leagueId, performerDriverId, targetDriverId, newRole) * - Admin roster: updateMemberRole(leagueId, targetDriverId, newRole) (actor derived from session) */ async updateMemberRole(leagueId: string, targetDriverId: string, newRole: MembershipRole): Promise<{ success: boolean }>; async updateMemberRole(leagueId: string, performerDriverId: string, targetDriverId: string, newRole: string): Promise<{ success: boolean }>; async updateMemberRole(leagueId: string, arg1: string, arg2: string, arg3?: string): Promise<{ success: boolean }> { if (arg3 === undefined) { const dto = await this.apiClient.updateRosterMemberRole(leagueId, arg1, arg2); return { success: dto.success }; } return this.apiClient.updateMemberRole(leagueId, arg1, arg2, arg3); } /** * Admin roster: members list as ViewModels */ async getAdminRosterMembers(leagueId: string): Promise { const dtos = await this.apiClient.getAdminRosterMembers(leagueId); return dtos.map((dto) => ({ driverId: dto.driverId, driverName: dto.driver?.name ?? dto.driverId, role: (dto.role as MembershipRole) ?? 'member', joinedAtIso: dto.joinedAt, })); } /** * Admin roster: join requests list as ViewModels */ async getAdminRosterJoinRequests(leagueId: string): Promise { const dtos = await this.apiClient.getAdminRosterJoinRequests(leagueId); return dtos.map((dto) => ({ id: dto.id, leagueId: dto.leagueId, driverId: dto.driverId, driverName: this.resolveJoinRequestDriverName(dto), requestedAtIso: dto.requestedAt, message: dto.message, })); } async approveJoinRequest(leagueId: string, joinRequestId: string): Promise<{ success: boolean }> { const dto = await this.apiClient.approveRosterJoinRequest(leagueId, joinRequestId); return { success: dto.success }; } async rejectJoinRequest(leagueId: string, joinRequestId: string): Promise<{ success: boolean }> { const dto = await this.apiClient.rejectRosterJoinRequest(leagueId, joinRequestId); return { success: dto.success }; } private resolveJoinRequestDriverName(dto: LeagueRosterJoinRequestDTO): string { const driver = dto.driver as any; const name = driver && typeof driver === 'object' ? (driver.name as string | undefined) : undefined; return name ?? dto.driverId; } /** * Get league detail with owner, membership, and sponsor info */ async getLeagueDetail(leagueId: string, currentDriverId: string): Promise { if (!this.driversApiClient) return null; // For now, assume league data comes from getAllWithCapacity or a new endpoint // Since API may not have detailed league, we'll mock or assume // In real implementation, add getLeagueDetail to API const allLeagues = await this.apiClient.getAllWithCapacityAndScoring(); const leagues = Array.isArray((allLeagues as any)?.leagues) ? ((allLeagues as any).leagues as any[]) : []; const leagueDto = leagues.find((l) => l?.id === leagueId); if (!leagueDto) return null; // LeagueWithCapacityDTO already carries core fields; fall back to placeholder description/owner when not provided const league = { id: leagueDto.id, name: leagueDto.name, description: leagueDto.description ?? 'Description not available', ownerId: leagueDto.ownerId ?? 'owner-id', }; // Get owner const owner = await this.driversApiClient.getDriver(league.ownerId); const ownerName = owner ? (owner as any).name : `${league.ownerId.slice(0, 8)}...`; // Get membership const membershipsDto = await this.apiClient.getMemberships(leagueId); const members = Array.isArray((membershipsDto as any)?.members) ? ((membershipsDto as any).members as any[]) : []; const membership = members.find((m: any) => m?.driverId === currentDriverId); const isAdmin = membership ? ['admin', 'owner'].includes((membership as any).role) : false; // Get main sponsor let mainSponsor = null; if (this.sponsorsApiClient) { try { const seasons = await this.apiClient.getSeasons(leagueId); const seasonList = Array.isArray(seasons) ? (seasons as any[]) : []; const activeSeason = seasonList.find((s) => s?.status === 'active') ?? seasonList[0]; if (activeSeason) { const sponsorshipsDto = await this.apiClient.getSeasonSponsorships(activeSeason.seasonId); const sponsorships = Array.isArray((sponsorshipsDto as any)?.sponsorships) ? ((sponsorshipsDto as any).sponsorships as any[]) : []; const mainSponsorship = sponsorships.find((s: any) => s?.tier === 'main' && s?.status === 'active'); if (mainSponsorship) { const sponsorId = (mainSponsorship as any).sponsorId ?? (mainSponsorship as any).sponsor?.id; if (sponsorId) { const sponsorResult = await this.sponsorsApiClient.getSponsor(sponsorId); const sponsor = (sponsorResult as any)?.sponsor ?? null; if (sponsor) { mainSponsor = { name: sponsor.name, logoUrl: sponsor.logoUrl ?? '', websiteUrl: sponsor.websiteUrl ?? '', }; } } } } } catch (error) { console.warn('Failed to load main sponsor:', error); } } return new LeaguePageDetailViewModel({ league: { id: league.id, name: league.name, game: 'iRacing', tier: 'standard', season: 'Season 1', description: league.description, drivers: 0, races: 0, completedRaces: 0, totalImpressions: 0, avgViewsPerRace: 0, engagement: 0, rating: 0, seasonStatus: 'active', seasonDates: { start: new Date().toISOString(), end: new Date().toISOString() }, sponsorSlots: { main: { available: true, price: 800, benefits: [] }, secondary: { available: 2, total: 2, price: 250, benefits: [] } } }, drivers: [], races: [] }); } /** * Get comprehensive league detail page data */ async getLeagueDetailPageData(leagueId: string): Promise { if (!this.driversApiClient || !this.sponsorsApiClient) return null; try { // Get league basic info const allLeagues = await this.apiClient.getAllWithCapacityAndScoring(); const leagues = Array.isArray((allLeagues as any)?.leagues) ? ((allLeagues as any).leagues as any[]) : []; const league = leagues.find((l) => l?.id === leagueId); if (!league) return null; // Get owner const owner = await this.driversApiClient.getDriver(league.ownerId); // League scoring configuration is not exposed separately yet; use null to indicate "not configured" in the UI const scoringConfig: LeagueScoringConfigDTO | null = null; // Drivers list is limited to those present in memberships until a dedicated league-drivers endpoint exists const memberships = await this.apiClient.getMemberships(leagueId); const membershipMembers = Array.isArray((memberships as any)?.members) ? ((memberships as any).members as any[]) : []; const driverIds = membershipMembers.map((m: any) => m?.driverId).filter((id: any): id is string => typeof id === 'string'); const driverDtos = await Promise.all(driverIds.map((id: string) => this.driversApiClient!.getDriver(id))); const drivers = driverDtos.filter((d: any): d is NonNullable => d !== null); // Get all races for this league via the leagues API helper // Service boundary hardening: tolerate `null/undefined` arrays from API. const leagueRaces = await this.apiClient.getRaces(leagueId); const allRaces = (leagueRaces.races ?? []).map((race) => new RaceViewModel(race)); // League stats endpoint currently returns global league statistics rather than per-league values const leagueStats: LeagueStatsDTO = { totalMembers: league.usedSlots, totalRaces: allRaces.length, averageRating: 0, }; // Get sponsors const sponsors = await this.getLeagueSponsors(leagueId); return new LeagueDetailPageViewModel( league, owner, scoringConfig, drivers, memberships, allRaces, leagueStats, sponsors ); } catch (error) { console.error('Failed to load league detail page data:', error); return null; } } /** * Get sponsors for a league */ private async getLeagueSponsors(leagueId: string): Promise { if (!this.sponsorsApiClient) return []; try { const seasons = await this.apiClient.getSeasons(leagueId); const seasonList = Array.isArray(seasons) ? (seasons as any[]) : []; const activeSeason = seasonList.find((s) => s?.status === 'active') ?? seasonList[0]; if (!activeSeason) return []; const sponsorships = await this.apiClient.getSeasonSponsorships(activeSeason.seasonId); const sponsorshipList = Array.isArray((sponsorships as any)?.sponsorships) ? ((sponsorships as any).sponsorships as any[]) : []; const activeSponsorships = sponsorshipList.filter((s: any) => s?.status === 'active'); const sponsorInfos: SponsorInfo[] = []; for (const sponsorship of activeSponsorships) { const sponsorResult = await this.sponsorsApiClient.getSponsor((sponsorship as any).sponsorId ?? (sponsorship as any).sponsor?.id); const sponsor = (sponsorResult as any)?.sponsor ?? null; if (sponsor) { // Tagline is not supplied by the sponsor API in this build; callers may derive one from marketing content if needed sponsorInfos.push({ id: sponsor.id, name: sponsor.name, logoUrl: sponsor.logoUrl ?? '', websiteUrl: sponsor.websiteUrl ?? '', tier: ((sponsorship as any).tier as 'main' | 'secondary') ?? 'secondary', tagline: '', }); } } // Sort: main sponsors first, then secondary sponsorInfos.sort((a, b) => { if (a.tier === 'main' && b.tier !== 'main') return -1; if (a.tier !== 'main' && b.tier === 'main') return 1; return 0; }); return sponsorInfos; } catch (error) { console.warn('Failed to load sponsors:', error); return []; } } /** * Get league scoring presets */ async getScoringPresets(): Promise { const result = await this.apiClient.getScoringPresets(); return result.presets; } }