335 lines
13 KiB
TypeScript
335 lines
13 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 { LeagueWithCapacityDTO } from "@/lib/types/generated/LeagueWithCapacityDTO";
|
|
import { CreateLeagueViewModel } from "@/lib/view-models/CreateLeagueViewModel";
|
|
import { LeagueMembershipsViewModel } from "@/lib/view-models/LeagueMembershipsViewModel";
|
|
import { LeagueScheduleViewModel } from "@/lib/view-models/LeagueScheduleViewModel";
|
|
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 { SubmitBlocker, ThrottleBlocker } from "@/lib/blockers";
|
|
import { 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';
|
|
|
|
|
|
/**
|
|
* League Service
|
|
*
|
|
* Orchestrates league operations by coordinating API calls and view model creation.
|
|
* All dependencies are injected via constructor.
|
|
*/
|
|
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.getAllWithCapacity();
|
|
return dto.leagues.map((league: LeagueWithCapacityDTO) => ({
|
|
id: league.id,
|
|
name: league.name,
|
|
description: league.description ?? '',
|
|
ownerId: league.ownerId,
|
|
createdAt: league.createdAt,
|
|
maxDrivers: league.settings.maxDrivers ?? 0,
|
|
usedDriverSlots: league.usedSlots,
|
|
structureSummary: 'TBD',
|
|
timingSummary: 'TBD'
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
|
|
// League memberships (roles, statuses)
|
|
const membershipsDto = await this.apiClient.getMemberships(leagueId);
|
|
|
|
const memberships: LeagueMembership[] = membershipsDto.members.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(dto.standings.map((entry: any) => entry.driverId)));
|
|
const driverDtos = 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: dto.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
|
|
*/
|
|
async getLeagueSchedule(leagueId: string): Promise<LeagueScheduleViewModel> {
|
|
const dto = await this.apiClient.getSchedule(leagueId);
|
|
return new LeagueScheduleViewModel(dto);
|
|
}
|
|
|
|
/**
|
|
* 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()) {
|
|
throw new Error('Cannot execute at this time');
|
|
}
|
|
|
|
this.submitBlocker.block();
|
|
this.throttle.block();
|
|
try {
|
|
return await this.apiClient.create(input);
|
|
} finally {
|
|
this.submitBlocker.release();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Remove a member from league
|
|
*/
|
|
async removeMember(leagueId: string, performerDriverId: string, targetDriverId: string): Promise<RemoveMemberViewModel> {
|
|
const dto = await this.apiClient.removeMember(leagueId, performerDriverId, targetDriverId);
|
|
return new RemoveMemberViewModel(dto);
|
|
}
|
|
|
|
/**
|
|
* Update a member's role in league
|
|
*/
|
|
async updateMemberRole(leagueId: string, performerDriverId: string, targetDriverId: string, newRole: string): Promise<{ success: boolean }> {
|
|
return this.apiClient.updateMemberRole(leagueId, performerDriverId, targetDriverId, newRole);
|
|
}
|
|
|
|
/**
|
|
* Get league detail with owner, membership, and sponsor info
|
|
*/
|
|
async getLeagueDetail(leagueId: string, currentDriverId: string): Promise<LeaguePageDetailViewModel | 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.getAllWithCapacity();
|
|
const leagueDto = allLeagues.leagues.find((l: any) => 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 as any).description ?? 'Description not available',
|
|
ownerId: (leagueDto as any).ownerId ?? 'owner-id',
|
|
};
|
|
|
|
// Get owner
|
|
const owner = await this.driversApiClient.getDriver(league.ownerId);
|
|
const ownerName = owner ? owner.name : `${league.ownerId.slice(0, 8)}...`;
|
|
|
|
// Get membership
|
|
const membershipsDto = await this.apiClient.getMemberships(leagueId);
|
|
const membership = membershipsDto.members.find((m: any) => m.driverId === currentDriverId);
|
|
const isAdmin = membership ? ['admin', 'owner'].includes((membership as any).role) : false;
|
|
|
|
// Get main sponsor
|
|
let mainSponsor = null;
|
|
try {
|
|
const seasons = await this.apiClient.getSeasons(leagueId);
|
|
const activeSeason = seasons.find((s) => s.status === 'active') ?? seasons[0];
|
|
if (activeSeason) {
|
|
const sponsorshipsDto = await this.apiClient.getSeasonSponsorships(activeSeason.seasonId);
|
|
const mainSponsorship = sponsorshipsDto.sponsorships.find((s: any) => s.tier === 'main' && s.status === 'active');
|
|
if (mainSponsorship) {
|
|
const sponsorResult = await this.sponsorsApiClient.getSponsor((mainSponsorship as any).sponsorId ?? (mainSponsorship as any).sponsor?.id);
|
|
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> {
|
|
try {
|
|
// Get league basic info
|
|
const allLeagues = await this.apiClient.getAllWithCapacity();
|
|
const league = allLeagues.leagues.find((l: any) => 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 driverIds = memberships.members.map(m => m.driverId);
|
|
const driverDtos = await Promise.all(driverIds.map(id => 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
|
|
const leagueRaces = await this.apiClient.getRaces(leagueId);
|
|
const allRaces = leagueRaces.races.map(r => new RaceViewModel(r as RaceDTO));
|
|
|
|
// 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[]> {
|
|
try {
|
|
const seasons = await this.apiClient.getSeasons(leagueId);
|
|
const activeSeason = seasons.find((s) => s.status === 'active') ?? seasons[0];
|
|
|
|
if (!activeSeason) return [];
|
|
|
|
const sponsorships = await this.apiClient.getSeasonSponsorships(activeSeason.seasonId);
|
|
const activeSponsorships = sponsorships.sponsorships.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;
|
|
}
|
|
}
|