615 lines
24 KiB
TypeScript
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;
|
|
}
|
|
} |