Files
gridpilot.gg/apps/website/lib/services/leagues/LeagueService.ts
2025-12-31 15:39:28 +01:00

615 lines
24 KiB
TypeScript

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<string, unknown>;
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<string, unknown>;
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<LeagueSummaryViewModel[]> {
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<LeagueStandingsViewModel> {
// 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<typeof d> => d !== null);
const dtoWithExtras = { standings, drivers, memberships };
return new LeagueStandingsViewModel(dtoWithExtras, currentUserId);
}
/**
* Get league statistics
*/
async getLeagueStats(): Promise<LeagueStatsViewModel> {
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<LeagueScheduleViewModel> {
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<LeagueSeasonSummaryViewModel[]> {
const dtos = await this.apiClient.getSeasons(leagueId);
return dtos.map((dto) => new LeagueSeasonSummaryViewModel(dto));
}
async getAdminSchedule(leagueId: string, seasonId: string): Promise<LeagueAdminScheduleViewModel> {
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<LeagueAdminScheduleViewModel> {
await this.apiClient.publishSeasonSchedule(leagueId, seasonId);
return this.getAdminSchedule(leagueId, seasonId);
}
async unpublishAdminSchedule(leagueId: string, seasonId: string): Promise<LeagueAdminScheduleViewModel> {
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<LeagueAdminScheduleViewModel> {
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<LeagueAdminScheduleViewModel> {
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<LeagueAdminScheduleViewModel> {
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<LeagueScheduleDTO> {
return this.apiClient.getSchedule(leagueId, seasonId);
}
/**
* Publish a league season schedule
*/
async publishLeagueSeasonSchedule(leagueId: string, seasonId: string): Promise<LeagueSeasonSchedulePublishOutputDTO> {
return this.apiClient.publishSeasonSchedule(leagueId, seasonId);
}
/**
* Unpublish a league season schedule
*/
async unpublishLeagueSeasonSchedule(leagueId: string, seasonId: string): Promise<LeagueSeasonSchedulePublishOutputDTO> {
return this.apiClient.unpublishSeasonSchedule(leagueId, seasonId);
}
/**
* Create a schedule race for a league season
*/
async createLeagueSeasonScheduleRace(
leagueId: string,
seasonId: string,
input: CreateLeagueScheduleRaceInputDTO,
): Promise<CreateLeagueScheduleRaceOutputDTO> {
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<LeagueScheduleRaceMutationSuccessDTO> {
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<LeagueScheduleRaceMutationSuccessDTO> {
return this.apiClient.deleteSeasonScheduleRace(leagueId, seasonId, raceId);
}
/**
* Get seasons for a league
*/
async getLeagueSeasons(leagueId: string): Promise<LeagueSeasonSummaryDTO[]> {
return this.apiClient.getSeasons(leagueId);
}
/**
* Get league memberships
*/
async getLeagueMemberships(leagueId: string, currentUserId: string): Promise<LeagueMembershipsViewModel> {
const dto = await this.apiClient.getMemberships(leagueId);
return new LeagueMembershipsViewModel(dto, currentUserId);
}
/**
* Create a new league
*/
async createLeague(input: CreateLeagueInputDTO): Promise<CreateLeagueOutputDTO> {
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<RemoveMemberViewModel>;
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<LeagueAdminRosterMemberViewModel[]> {
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<LeagueAdminRosterJoinRequestViewModel[]> {
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<LeaguePageDetailViewModel | null> {
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<LeagueDetailPageViewModel | null> {
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<typeof d> => 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<SponsorInfo[]> {
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<any[]> {
const result = await this.apiClient.getScoringPresets();
return result.presets;
}
}