Files
gridpilot.gg/apps/website/lib/services/leagues/LeagueService.ts

279 lines
11 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 { 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 { LeagueDetailViewModel } from "@/lib/view-models/LeagueDetailViewModel";
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 { LeagueScoringConfigDTO } from "@/lib/types/LeagueScoringConfigDTO";
import { LeagueStatsDTO } from "@/lib/types/generated/LeagueStatsDTO";
import { LeagueMembershipsDTO } from "@/lib/types/generated/LeagueMembershipsDTO";
/**
* 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) => new LeagueSummaryViewModel(league));
}
/**
* 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);
// Resolve unique drivers that appear in standings
const driverIds = Array.from(new Set(dto.standings.map(entry => entry.driverId)));
const driverDtos = await Promise.all(driverIds.map(id => this.driversApiClient.getDriver(id)));
const drivers = driverDtos.filter((d): d is NonNullable<typeof d> => d !== null);
const dtoWithExtras = {
standings: dto.standings,
drivers,
memberships: membershipsDto.members,
};
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 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<void> {
if (!this.submitBlocker.canExecute() || !this.throttle.canExecute()) return;
this.submitBlocker.block();
this.throttle.block();
try {
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<LeagueDetailViewModel | 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 => 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 => m.driverId === currentDriverId);
const isAdmin = membership ? ['admin', 'owner'].includes(membership.role) : false;
// Get main sponsor
let mainSponsor = null;
try {
const seasonsDto = await this.apiClient.getSeasons(leagueId);
const activeSeason = seasonsDto.seasons.find((s: any) => s.status === 'active') ?? seasonsDto.seasons[0];
if (activeSeason) {
const sponsorshipsDto = await this.apiClient.getSeasonSponsorships(activeSeason.id);
const mainSponsorship = sponsorshipsDto.sponsorships.find((s: any) => s.tier === 'main' && s.status === 'active');
if (mainSponsorship) {
const sponsor = await this.sponsorsApiClient.getSponsor(mainSponsorship.sponsorId);
if (sponsor) {
mainSponsor = {
name: sponsor.name,
logoUrl: sponsor.logoUrl ?? '',
websiteUrl: sponsor.websiteUrl ?? '',
};
}
}
}
} catch (error) {
console.warn('Failed to load main sponsor:', error);
}
return new LeagueDetailViewModel(
league.id,
league.name,
league.description,
league.ownerId,
ownerName,
mainSponsor,
isAdmin
);
}
/**
* 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 => 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): 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 = await this.apiClient.getTotal();
// 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.seasons.find((s: any) => s.status === 'active') ?? seasons.seasons[0];
if (!activeSeason) return [];
const sponsorships = await this.apiClient.getSeasonSponsorships(activeSeason.id);
const activeSponsorships = sponsorships.sponsorships.filter((s: any) => s.status === 'active');
const sponsorInfos: SponsorInfo[] = [];
for (const sponsorship of activeSponsorships) {
const sponsor = await this.sponsorsApiClient.getSponsor(sponsorship.sponsorId);
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.tier,
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 [];
}
}
}