website refactor
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { describe, it, expect, vi, Mocked } from 'vitest';
|
||||
import { LeagueMembershipService } from './LeagueMembershipService';
|
||||
import { LeaguesApiClient } from '../../api/leagues/LeaguesApiClient';
|
||||
import { LeagueMemberViewModel } from '../../view-models/LeagueMemberViewModel';
|
||||
import { LeaguesApiClient } from '@/lib/api/leagues/LeaguesApiClient';
|
||||
import { LeagueMemberViewModel } from '@/lib/view-models/LeagueMemberViewModel';
|
||||
|
||||
describe('LeagueMembershipService', () => {
|
||||
let mockApiClient: Mocked<LeaguesApiClient>;
|
||||
|
||||
@@ -11,7 +11,7 @@ function getDefaultLeaguesApiClient(): LeaguesApiClient {
|
||||
if (cachedLeaguesApiClient) return cachedLeaguesApiClient;
|
||||
|
||||
const api = new ApiClient(getWebsiteApiBaseUrl());
|
||||
cachedLeaguesApiClient = (api as any).leagues as LeaguesApiClient;
|
||||
cachedLeaguesApiClient = api.leagues;
|
||||
return cachedLeaguesApiClient;
|
||||
}
|
||||
|
||||
@@ -27,12 +27,12 @@ export class LeagueMembershipService {
|
||||
|
||||
async getLeagueMemberships(leagueId: string, currentUserId: string): Promise<LeagueMemberViewModel[]> {
|
||||
const dto = await this.getClient().getMemberships(leagueId);
|
||||
const members: LeagueMemberDTO[] = ((dto as any)?.members ?? (dto as any)?.memberships ?? []) as LeagueMemberDTO[];
|
||||
const members: LeagueMemberDTO[] = dto.members ?? [];
|
||||
return members.map((m) => new LeagueMemberViewModel(m, currentUserId));
|
||||
}
|
||||
|
||||
async removeMember(leagueId: string, performerDriverId: string, targetDriverId: string): Promise<{ success: boolean }> {
|
||||
return this.getClient().removeMember(leagueId, performerDriverId, targetDriverId) as unknown as { success: boolean };
|
||||
return this.getClient().removeMember(leagueId, performerDriverId, targetDriverId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -57,11 +57,11 @@ export class LeagueMembershipService {
|
||||
static async fetchLeagueMemberships(leagueId: string): Promise<LeagueMembership[]> {
|
||||
try {
|
||||
const result = await getDefaultLeaguesApiClient().getMemberships(leagueId);
|
||||
const memberships: LeagueMembership[] = ((result as any)?.members ?? []).map((member: any) => ({
|
||||
const memberships: LeagueMembership[] = (result.members ?? []).map((member) => ({
|
||||
id: `${member.driverId}-${leagueId}`, // Generate ID since API doesn't provide it
|
||||
leagueId,
|
||||
driverId: member.driverId,
|
||||
role: member.role,
|
||||
role: member.role as 'owner' | 'admin' | 'steward' | 'member',
|
||||
status: 'active', // Assume active since API returns current members
|
||||
joinedAt: member.joinedAt,
|
||||
}));
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { describe, it, expect, vi, Mocked, beforeEach, afterEach } from 'vitest';
|
||||
import { describe, it, expect, vi, Mocked, beforeEach } from 'vitest';
|
||||
import { LeagueService } from './LeagueService';
|
||||
import { LeaguesApiClient } from '../../api/leagues/LeaguesApiClient';
|
||||
import { LeagueStandingsViewModel } from '../../view-models/LeagueStandingsViewModel';
|
||||
import { LeagueStatsViewModel } from '../../view-models/LeagueStatsViewModel';
|
||||
import { LeagueScheduleViewModel } from '../../view-models/LeagueScheduleViewModel';
|
||||
import { LeagueMembershipsViewModel } from '../../view-models/LeagueMembershipsViewModel';
|
||||
import { RemoveMemberViewModel } from '../../view-models/RemoveMemberViewModel';
|
||||
import { LeagueMemberViewModel } from '../../view-models/LeagueMemberViewModel';
|
||||
import type { CreateLeagueInputDTO } from '../../types/generated/CreateLeagueInputDTO';
|
||||
import type { CreateLeagueOutputDTO } from '../../types/generated/CreateLeagueOutputDTO';
|
||||
import type { RemoveLeagueMemberOutputDTO } from '../../types/generated/RemoveLeagueMemberOutputDTO';
|
||||
import { LeaguesApiClient } from '@/lib/api/leagues/LeaguesApiClient';
|
||||
import { LeagueStandingsViewModel } from '@/lib/view-models/LeagueStandingsViewModel';
|
||||
import { LeagueStatsViewModel } from '@/lib/view-models/LeagueStatsViewModel';
|
||||
import { LeagueScheduleViewModel } from '@/lib/view-models/LeagueScheduleViewModel';
|
||||
import { LeagueMembershipsViewModel } from '@/lib/view-models/LeagueMembershipsViewModel';
|
||||
import { RemoveMemberViewModel } from '@/lib/view-models/RemoveMemberViewModel';
|
||||
import { LeagueMemberViewModel } from '@/lib/view-models/LeagueMemberViewModel';
|
||||
import type { CreateLeagueInputDTO } from '@/lib/types/generated/CreateLeagueInputDTO';
|
||||
import type { CreateLeagueOutputDTO } from '@/lib/types/generated/CreateLeagueOutputDTO';
|
||||
import type { RemoveLeagueMemberOutputDTO } from '@/lib/types/generated/RemoveLeagueMemberOutputDTO';
|
||||
|
||||
describe('LeagueService', () => {
|
||||
let mockApiClient: Mocked<LeaguesApiClient>;
|
||||
@@ -114,14 +114,7 @@ describe('LeagueService', () => {
|
||||
});
|
||||
|
||||
describe('getLeagueSchedule', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should call apiClient.getSchedule and return LeagueScheduleViewModel with Date parsing', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2025-01-01T00:00:00Z'));
|
||||
|
||||
const leagueId = 'league-123';
|
||||
const mockDto = {
|
||||
races: [
|
||||
@@ -136,44 +129,7 @@ describe('LeagueService', () => {
|
||||
|
||||
expect(mockApiClient.getSchedule).toHaveBeenCalledWith(leagueId);
|
||||
expect(result).toBeInstanceOf(LeagueScheduleViewModel);
|
||||
|
||||
expect(result.raceCount).toBe(2);
|
||||
expect(result.races[0]!.scheduledAt).toBeInstanceOf(Date);
|
||||
expect(result.races[0]!.isPast).toBe(true);
|
||||
expect(result.races[1]!.isUpcoming).toBe(true);
|
||||
});
|
||||
|
||||
it('should prefer scheduledAt over date and map optional fields/status', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2025-01-01T00:00:00Z'));
|
||||
|
||||
const leagueId = 'league-123';
|
||||
const mockDto = {
|
||||
races: [
|
||||
{
|
||||
id: 'race-1',
|
||||
name: 'Round 1',
|
||||
date: '2025-01-02T20:00:00Z',
|
||||
scheduledAt: '2025-01-03T20:00:00Z',
|
||||
track: 'Monza',
|
||||
car: 'GT3',
|
||||
sessionType: 'race',
|
||||
isRegistered: true,
|
||||
status: 'scheduled',
|
||||
},
|
||||
],
|
||||
} as any;
|
||||
|
||||
mockApiClient.getSchedule.mockResolvedValue(mockDto);
|
||||
|
||||
const result = await service.getLeagueSchedule(leagueId);
|
||||
|
||||
expect(result.races[0]!.scheduledAt.toISOString()).toBe('2025-01-03T20:00:00.000Z');
|
||||
expect(result.races[0]!.track).toBe('Monza');
|
||||
expect(result.races[0]!.car).toBe('GT3');
|
||||
expect(result.races[0]!.sessionType).toBe('race');
|
||||
expect(result.races[0]!.isRegistered).toBe(true);
|
||||
expect(result.races[0]!.status).toBe('scheduled');
|
||||
});
|
||||
|
||||
it('should handle empty races array', async () => {
|
||||
@@ -279,56 +235,6 @@ describe('LeagueService', () => {
|
||||
|
||||
await expect(service.createLeague(input)).rejects.toThrow('API call failed');
|
||||
});
|
||||
|
||||
it('should not call apiClient.create when submitBlocker is blocked', async () => {
|
||||
const input: CreateLeagueInputDTO = {
|
||||
name: 'New League',
|
||||
description: 'A new league',
|
||||
visibility: 'public',
|
||||
ownerId: 'owner-1',
|
||||
};
|
||||
|
||||
// First call should succeed
|
||||
const mockDto: CreateLeagueOutputDTO = {
|
||||
leagueId: 'new-league-id',
|
||||
success: true,
|
||||
};
|
||||
mockApiClient.create.mockResolvedValue(mockDto);
|
||||
|
||||
await service.createLeague(input); // This should block the submitBlocker
|
||||
|
||||
// Reset mock to check calls
|
||||
mockApiClient.create.mockClear();
|
||||
|
||||
// Second call should not call API
|
||||
await service.createLeague(input);
|
||||
expect(mockApiClient.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not call apiClient.create when throttle is active', async () => {
|
||||
const input: CreateLeagueInputDTO = {
|
||||
name: 'New League',
|
||||
description: 'A new league',
|
||||
visibility: 'public',
|
||||
ownerId: 'owner-1',
|
||||
};
|
||||
|
||||
// First call
|
||||
const mockDto: CreateLeagueOutputDTO = {
|
||||
leagueId: 'new-league-id',
|
||||
success: true,
|
||||
};
|
||||
mockApiClient.create.mockResolvedValue(mockDto);
|
||||
|
||||
await service.createLeague(input); // This blocks throttle for 500ms
|
||||
|
||||
// Reset mock
|
||||
mockApiClient.create.mockClear();
|
||||
|
||||
// Immediate second call should not call API due to throttle
|
||||
await service.createLeague(input);
|
||||
expect(mockApiClient.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeMember', () => {
|
||||
|
||||
@@ -4,26 +4,11 @@ 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 { TotalLeaguesDTO } from '@/lib/types/generated/TotalLeaguesDTO';
|
||||
import type { 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';
|
||||
@@ -32,70 +17,16 @@ import type { CreateLeagueScheduleRaceOutputDTO } from '@/lib/types/generated/Cr
|
||||
import type { UpdateLeagueScheduleRaceInputDTO } from '@/lib/types/generated/UpdateLeagueScheduleRaceInputDTO';
|
||||
import type { LeagueScheduleRaceMutationSuccessDTO } from '@/lib/types/generated/LeagueScheduleRaceMutationSuccessDTO';
|
||||
import type { LeagueSeasonSchedulePublishOutputDTO } from '@/lib/types/generated/LeagueSeasonSchedulePublishOutputDTO';
|
||||
|
||||
import type { LeagueRosterMemberDTO } from '@/lib/types/generated/LeagueRosterMemberDTO';
|
||||
import type { LeagueMembershipsDTO } from '@/lib/types/generated/LeagueMembershipsDTO';
|
||||
|
||||
/**
|
||||
* League Service
|
||||
* League Service - DTO Only
|
||||
*
|
||||
* Orchestrates league operations by coordinating API calls and view model creation.
|
||||
* All dependencies are injected via constructor.
|
||||
* Returns raw API DTOs. No ViewModels or UX logic.
|
||||
* All client-side presentation logic must be handled by hooks/components.
|
||||
*/
|
||||
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,
|
||||
@@ -103,117 +34,49 @@ export class LeagueService {
|
||||
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 } : {}),
|
||||
}));
|
||||
async getAllLeagues(): Promise<any> {
|
||||
return this.apiClient.getAllWithCapacityAndScoring();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
async getLeagueStandings(leagueId: string): Promise<any> {
|
||||
return this.apiClient.getStandings(leagueId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get league statistics
|
||||
*/
|
||||
async getLeagueStats(): Promise<LeagueStatsViewModel> {
|
||||
const dto = await this.apiClient.getTotal();
|
||||
return new LeagueStatsViewModel(dto);
|
||||
async getLeagueStats(): Promise<TotalLeaguesDTO> {
|
||||
return this.apiClient.getTotal();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
async getLeagueSchedule(leagueId: string): Promise<LeagueScheduleDTO> {
|
||||
return this.apiClient.getSchedule(leagueId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 getLeagueSeasons(leagueId: string): Promise<LeagueSeasonSummaryDTO[]> {
|
||||
return this.apiClient.getSeasons(leagueId);
|
||||
}
|
||||
|
||||
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 getLeagueSeasonSummaries(leagueId: string): Promise<LeagueSeasonSummaryDTO[]> {
|
||||
return this.apiClient.getSeasons(leagueId);
|
||||
}
|
||||
|
||||
async publishAdminSchedule(leagueId: string, seasonId: string): Promise<LeagueAdminScheduleViewModel> {
|
||||
await this.apiClient.publishSeasonSchedule(leagueId, seasonId);
|
||||
return this.getAdminSchedule(leagueId, seasonId);
|
||||
async getAdminSchedule(leagueId: string, seasonId: string): Promise<LeagueScheduleDTO> {
|
||||
return this.apiClient.getSchedule(leagueId, seasonId);
|
||||
}
|
||||
|
||||
async unpublishAdminSchedule(leagueId: string, seasonId: string): Promise<LeagueAdminScheduleViewModel> {
|
||||
await this.apiClient.unpublishSeasonSchedule(leagueId, seasonId);
|
||||
return this.getAdminSchedule(leagueId, seasonId);
|
||||
async publishAdminSchedule(leagueId: string, seasonId: string): Promise<LeagueSeasonSchedulePublishOutputDTO> {
|
||||
return this.apiClient.publishSeasonSchedule(leagueId, seasonId);
|
||||
}
|
||||
|
||||
async unpublishAdminSchedule(leagueId: string, seasonId: string): Promise<LeagueSeasonSchedulePublishOutputDTO> {
|
||||
return this.apiClient.unpublishSeasonSchedule(leagueId, seasonId);
|
||||
}
|
||||
|
||||
async createAdminScheduleRace(
|
||||
leagueId: string,
|
||||
seasonId: string,
|
||||
input: { track: string; car: string; scheduledAtIso: string },
|
||||
): Promise<LeagueAdminScheduleViewModel> {
|
||||
): Promise<CreateLeagueScheduleRaceOutputDTO> {
|
||||
const payload: CreateLeagueScheduleRaceInputDTO = { ...input, example: '' };
|
||||
await this.apiClient.createSeasonScheduleRace(leagueId, seasonId, payload);
|
||||
return this.getAdminSchedule(leagueId, seasonId);
|
||||
return this.apiClient.createSeasonScheduleRace(leagueId, seasonId, payload);
|
||||
}
|
||||
|
||||
async updateAdminScheduleRace(
|
||||
@@ -221,47 +84,27 @@ export class LeagueService {
|
||||
seasonId: string,
|
||||
raceId: string,
|
||||
input: Partial<{ track: string; car: string; scheduledAtIso: string }>,
|
||||
): Promise<LeagueAdminScheduleViewModel> {
|
||||
): Promise<LeagueScheduleRaceMutationSuccessDTO> {
|
||||
const payload: UpdateLeagueScheduleRaceInputDTO = { ...input, example: '' };
|
||||
await this.apiClient.updateSeasonScheduleRace(leagueId, seasonId, raceId, payload);
|
||||
return this.getAdminSchedule(leagueId, seasonId);
|
||||
return this.apiClient.updateSeasonScheduleRace(leagueId, seasonId, raceId, payload);
|
||||
}
|
||||
|
||||
async deleteAdminScheduleRace(leagueId: string, seasonId: string, raceId: string): Promise<LeagueAdminScheduleViewModel> {
|
||||
await this.apiClient.deleteSeasonScheduleRace(leagueId, seasonId, raceId);
|
||||
return this.getAdminSchedule(leagueId, seasonId);
|
||||
async deleteAdminScheduleRace(leagueId: string, seasonId: string, raceId: string): Promise<LeagueScheduleRaceMutationSuccessDTO> {
|
||||
return this.apiClient.deleteSeasonScheduleRace(leagueId, seasonId, raceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
@@ -270,9 +113,6 @@ export class LeagueService {
|
||||
return this.apiClient.createSeasonScheduleRace(leagueId, seasonId, input);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a schedule race for a league season
|
||||
*/
|
||||
async updateLeagueSeasonScheduleRace(
|
||||
leagueId: string,
|
||||
seasonId: string,
|
||||
@@ -282,9 +122,6 @@ export class LeagueService {
|
||||
return this.apiClient.updateSeasonScheduleRace(leagueId, seasonId, raceId, input);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a schedule race for a league season
|
||||
*/
|
||||
async deleteLeagueSeasonScheduleRace(
|
||||
leagueId: string,
|
||||
seasonId: string,
|
||||
@@ -293,101 +130,30 @@ export class LeagueService {
|
||||
return this.apiClient.deleteSeasonScheduleRace(leagueId, seasonId, raceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get seasons for a league
|
||||
*/
|
||||
async getLeagueSeasons(leagueId: string): Promise<LeagueSeasonSummaryDTO[]> {
|
||||
return this.apiClient.getSeasons(leagueId);
|
||||
async getLeagueMemberships(leagueId: string): Promise<LeagueMembershipsDTO> {
|
||||
return this.apiClient.getMemberships(leagueId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get league memberships
|
||||
*/
|
||||
async getLeagueMemberships(leagueId: string, currentUserId: string): Promise<LeagueMembershipsViewModel> {
|
||||
const dto = await this.apiClient.getMemberships(leagueId);
|
||||
return new LeagueMembershipsViewModel(dto, currentUserId);
|
||||
async createLeague(input: CreateLeagueInputDTO): Promise<CreateLeagueOutputDTO> {
|
||||
return this.apiClient.create(input);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
async removeMember(leagueId: string, targetDriverId: string): Promise<{ success: boolean }> {
|
||||
const dto = await this.apiClient.removeRosterMember(leagueId, targetDriverId);
|
||||
return { success: dto.success };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
async updateMemberRole(leagueId: string, targetDriverId: string, newRole: MembershipRole): Promise<{ success: boolean }> {
|
||||
const dto = await this.apiClient.updateRosterMemberRole(leagueId, targetDriverId, newRole);
|
||||
return { success: dto.success };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
}));
|
||||
async getAdminRosterMembers(leagueId: string): Promise<LeagueRosterMemberDTO[]> {
|
||||
return this.apiClient.getAdminRosterMembers(leagueId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 getAdminRosterJoinRequests(leagueId: string): Promise<LeagueRosterJoinRequestDTO[]> {
|
||||
return this.apiClient.getAdminRosterJoinRequests(leagueId);
|
||||
}
|
||||
|
||||
async approveJoinRequest(leagueId: string, joinRequestId: string): Promise<{ success: boolean }> {
|
||||
@@ -400,214 +166,14 @@ export class LeagueService {
|
||||
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;
|
||||
async getLeagueDetail(leagueId: string): Promise<any> {
|
||||
return this.apiClient.getAllWithCapacityAndScoring();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: []
|
||||
});
|
||||
async getLeagueDetailPageData(leagueId: string): Promise<any> {
|
||||
return this.apiClient.getAllWithCapacityAndScoring();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, it, expect, vi, Mocked } from 'vitest';
|
||||
import { LeagueSettingsService } from './LeagueSettingsService';
|
||||
import { LeaguesApiClient } from '../../api/leagues/LeaguesApiClient';
|
||||
import { DriversApiClient } from '../../api/drivers/DriversApiClient';
|
||||
import { LeaguesApiClient } from '@/lib/api/leagues/LeaguesApiClient';
|
||||
import { DriversApiClient } from '@/lib/api/drivers/DriversApiClient';
|
||||
import { LeagueSettingsViewModel } from '@/lib/view-models/LeagueSettingsViewModel';
|
||||
|
||||
describe('LeagueSettingsService', () => {
|
||||
|
||||
@@ -1,283 +0,0 @@
|
||||
import { LeaguesApiClient } from "@/lib/api/leagues/LeaguesApiClient";
|
||||
import { DriversApiClient } from "@/lib/api/drivers/DriversApiClient";
|
||||
import type { LeagueConfigFormModel } from "@/lib/types/LeagueConfigFormModel";
|
||||
import type { LeagueScoringPresetDTO } from "@/lib/types/generated/LeagueScoringPresetDTO";
|
||||
import { LeagueSettingsViewModel } from "@/lib/view-models/LeagueSettingsViewModel";
|
||||
import { DriverSummaryViewModel } from "@/lib/view-models/DriverSummaryViewModel";
|
||||
import type { LeagueScoringPresetViewModel } from "@/lib/view-models/LeagueScoringPresetViewModel";
|
||||
import type { CustomPointsConfig } from "@/lib/view-models/ScoringConfigurationViewModel";
|
||||
|
||||
/**
|
||||
* League Settings Service
|
||||
*
|
||||
* Orchestrates league settings operations by coordinating API calls and view model creation.
|
||||
* All dependencies are injected via constructor.
|
||||
*/
|
||||
export class LeagueSettingsService {
|
||||
constructor(
|
||||
private readonly leaguesApiClient: LeaguesApiClient,
|
||||
private readonly driversApiClient: DriversApiClient
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get league settings with view model transformation
|
||||
*/
|
||||
async getLeagueSettings(leagueId: string): Promise<LeagueSettingsViewModel | null> {
|
||||
try {
|
||||
// Get league basic info (includes ownerId in DTO)
|
||||
const allLeagues = await this.leaguesApiClient.getAllWithCapacity();
|
||||
const leagueDto = allLeagues.leagues.find(l => l.id === leagueId);
|
||||
if (!leagueDto) return null;
|
||||
|
||||
const league = {
|
||||
id: leagueDto.id,
|
||||
name: leagueDto.name,
|
||||
ownerId: leagueDto.ownerId,
|
||||
createdAt: leagueDto.createdAt || new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Get config
|
||||
const configDto = await this.leaguesApiClient.getLeagueConfig(leagueId);
|
||||
const config: LeagueConfigFormModel = (configDto.form ?? undefined) as unknown as LeagueConfigFormModel;
|
||||
|
||||
// Get presets
|
||||
const presetsDto = await this.leaguesApiClient.getScoringPresets();
|
||||
const presets: LeagueScoringPresetDTO[] = presetsDto.presets;
|
||||
|
||||
// Get leaderboard once so we can hydrate rating / rank for owner + members
|
||||
const leaderboardDto = await this.driversApiClient.getLeaderboard();
|
||||
const leaderboardByDriverId = new Map(
|
||||
leaderboardDto.drivers.map(driver => [driver.id, driver])
|
||||
);
|
||||
|
||||
// Get owner
|
||||
const ownerDriver = await this.driversApiClient.getDriver(league.ownerId);
|
||||
let owner: DriverSummaryViewModel | null = null;
|
||||
if (ownerDriver) {
|
||||
const ownerStats = leaderboardByDriverId.get(ownerDriver.id);
|
||||
owner = new DriverSummaryViewModel({
|
||||
driver: ownerDriver,
|
||||
rating: ownerStats?.rating ?? null,
|
||||
rank: ownerStats?.rank ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
// Get members
|
||||
const membershipsDto = await this.leaguesApiClient.getMemberships(leagueId);
|
||||
const members: DriverSummaryViewModel[] = [];
|
||||
for (const member of membershipsDto.members) {
|
||||
if (member.driverId !== league.ownerId && member.role !== 'owner') {
|
||||
const driver = await this.driversApiClient.getDriver(member.driverId);
|
||||
if (driver) {
|
||||
const memberStats = leaderboardByDriverId.get(driver.id);
|
||||
members.push(new DriverSummaryViewModel({
|
||||
driver,
|
||||
rating: memberStats?.rating ?? null,
|
||||
rank: memberStats?.rank ?? null,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new LeagueSettingsViewModel({
|
||||
league,
|
||||
config,
|
||||
presets,
|
||||
owner,
|
||||
members,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to load league settings:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transfer league ownership
|
||||
*/
|
||||
async transferOwnership(leagueId: string, currentOwnerId: string, newOwnerId: string): Promise<boolean> {
|
||||
try {
|
||||
const result = await this.leaguesApiClient.transferOwnership(leagueId, currentOwnerId, newOwnerId);
|
||||
return result.success;
|
||||
} catch (error) {
|
||||
console.error('Failed to transfer ownership:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a scoring preset
|
||||
*/
|
||||
selectScoringPreset(
|
||||
currentForm: LeagueConfigFormModel,
|
||||
presetId: string
|
||||
): LeagueConfigFormModel {
|
||||
return {
|
||||
...currentForm,
|
||||
scoring: {
|
||||
...currentForm.scoring,
|
||||
patternId: presetId,
|
||||
customScoringEnabled: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle custom scoring
|
||||
*/
|
||||
toggleCustomScoring(currentForm: LeagueConfigFormModel): LeagueConfigFormModel {
|
||||
return {
|
||||
...currentForm,
|
||||
scoring: {
|
||||
...currentForm.scoring,
|
||||
customScoringEnabled: !currentForm.scoring.customScoringEnabled,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update championship settings
|
||||
*/
|
||||
updateChampionship(
|
||||
currentForm: LeagueConfigFormModel,
|
||||
key: keyof LeagueConfigFormModel['championships'],
|
||||
value: boolean
|
||||
): LeagueConfigFormModel {
|
||||
return {
|
||||
...currentForm,
|
||||
championships: {
|
||||
...currentForm.championships,
|
||||
[key]: value,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get preset emoji based on name
|
||||
*/
|
||||
getPresetEmoji(preset: LeagueScoringPresetViewModel): string {
|
||||
const name = preset.name.toLowerCase();
|
||||
if (name.includes('sprint') || name.includes('double')) return '⚡';
|
||||
if (name.includes('endurance') || name.includes('long')) return '🏆';
|
||||
if (name.includes('club') || name.includes('casual')) return '🏅';
|
||||
return '🏁';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get preset description based on name
|
||||
*/
|
||||
getPresetDescription(preset: LeagueScoringPresetViewModel): string {
|
||||
const name = preset.name.toLowerCase();
|
||||
if (name.includes('sprint')) return 'Sprint + Feature race';
|
||||
if (name.includes('endurance')) return 'Long-form endurance';
|
||||
if (name.includes('club')) return 'Casual league format';
|
||||
return preset.sessionSummary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get preset info content for flyout
|
||||
*/
|
||||
getPresetInfoContent(presetName: string): { title: string; description: string; details: string[] } {
|
||||
const name = presetName.toLowerCase();
|
||||
if (name.includes('sprint')) {
|
||||
return {
|
||||
title: 'Sprint + Feature Format',
|
||||
description: 'A two-race weekend format with a shorter sprint race and a longer feature race.',
|
||||
details: [
|
||||
'Sprint race typically awards reduced points (e.g., 8-6-4-3-2-1)',
|
||||
'Feature race awards full points (e.g., 25-18-15-12-10-8-6-4-2-1)',
|
||||
'Grid for feature often based on sprint results',
|
||||
'Great for competitive leagues with time for multiple races',
|
||||
],
|
||||
};
|
||||
}
|
||||
if (name.includes('endurance') || name.includes('long')) {
|
||||
return {
|
||||
title: 'Endurance Format',
|
||||
description: 'Long-form racing focused on consistency and strategy over raw pace.',
|
||||
details: [
|
||||
'Single race per weekend, longer duration (60-90+ minutes)',
|
||||
'Higher points for finishing (rewards reliability)',
|
||||
'Often includes mandatory pit stops',
|
||||
'Best for serious leagues with dedicated racers',
|
||||
],
|
||||
};
|
||||
}
|
||||
if (name.includes('club') || name.includes('casual')) {
|
||||
return {
|
||||
title: 'Club/Casual Format',
|
||||
description: 'Relaxed format perfect for community leagues and casual racing.',
|
||||
details: [
|
||||
'Simple points structure, easy to understand',
|
||||
'Typically single race per weekend',
|
||||
'Lower stakes, focus on participation',
|
||||
'Great for beginners or mixed-skill leagues',
|
||||
],
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: 'Standard Race Format',
|
||||
description: 'Traditional single-race weekend with standard F1-style points.',
|
||||
details: [
|
||||
'Points: 25-18-15-12-10-8-6-4-2-1 for top 10',
|
||||
'Bonus points for pole position and fastest lap',
|
||||
'One race per weekend',
|
||||
'The most common format used in sim racing',
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get championship info content for flyout
|
||||
*/
|
||||
getChampionshipInfoContent(key: string): { title: string; description: string; details: string[] } {
|
||||
const info: Record<string, { title: string; description: string; details: string[] }> = {
|
||||
enableDriverChampionship: {
|
||||
title: 'Driver Championship',
|
||||
description: 'Track individual driver performance across all races in the season.',
|
||||
details: [
|
||||
'Each driver accumulates points based on race finishes',
|
||||
'The driver with most points at season end wins',
|
||||
'Standard in all racing leagues',
|
||||
'Shows overall driver skill and consistency',
|
||||
],
|
||||
},
|
||||
enableTeamChampionship: {
|
||||
title: 'Team Championship',
|
||||
description: 'Combine points from all drivers within a team for team standings.',
|
||||
details: [
|
||||
'All drivers\' points count toward team total',
|
||||
'Rewards having consistent performers across the roster',
|
||||
'Creates team strategy opportunities',
|
||||
'Only available in Teams mode leagues',
|
||||
],
|
||||
},
|
||||
enableNationsChampionship: {
|
||||
title: 'Nations Cup',
|
||||
description: 'Group drivers by nationality for international competition.',
|
||||
details: [
|
||||
'Drivers represent their country automatically',
|
||||
'Points pooled by nationality',
|
||||
'Adds international rivalry element',
|
||||
'Great for diverse, international leagues',
|
||||
],
|
||||
},
|
||||
enableTrophyChampionship: {
|
||||
title: 'Trophy Championship',
|
||||
description: 'A special category championship for specific classes or groups.',
|
||||
details: [
|
||||
'Custom category you define (e.g., Am drivers, rookies)',
|
||||
'Separate standings from main championship',
|
||||
'Encourages participation from all skill levels',
|
||||
'Can be used for gentleman drivers, newcomers, etc.',
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
return info[key] || {
|
||||
title: 'Championship',
|
||||
description: 'A championship standings category.',
|
||||
details: ['Enable to track this type of championship.'],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { ProtestService } from '../protests/ProtestService';
|
||||
import { PenaltyService } from '../penalties/PenaltyService';
|
||||
import { DriverService } from '../drivers/DriverService';
|
||||
import { LeagueMembershipService } from './LeagueMembershipService';
|
||||
import { LeagueStewardingViewModel } from '../../view-models/LeagueStewardingViewModel';
|
||||
import { LeagueStewardingViewModel } from '@/lib/view-models/LeagueStewardingViewModel';
|
||||
|
||||
describe('LeagueStewardingService', () => {
|
||||
let mockRaceService: Mocked<RaceService>;
|
||||
|
||||
@@ -1,179 +0,0 @@
|
||||
import { RaceService } from '../races/RaceService';
|
||||
import { ProtestService } from '../protests/ProtestService';
|
||||
import { PenaltyService } from '../penalties/PenaltyService';
|
||||
import { DriverService } from '../drivers/DriverService';
|
||||
import { LeagueMembershipService } from './LeagueMembershipService';
|
||||
import { LeagueStewardingViewModel, RaceWithProtests } from '../../view-models/LeagueStewardingViewModel';
|
||||
import type { ProtestDetailViewModel } from '../../view-models/ProtestDetailViewModel';
|
||||
|
||||
/**
|
||||
* League Stewarding Service
|
||||
*
|
||||
* Orchestrates league stewarding operations by coordinating calls to race, protest, penalty, driver, and membership services.
|
||||
* All dependencies are injected via constructor.
|
||||
*/
|
||||
export class LeagueStewardingService {
|
||||
private getPenaltyValueLabel(valueKind: string): string {
|
||||
switch (valueKind) {
|
||||
case 'seconds':
|
||||
return 'seconds';
|
||||
case 'grid_positions':
|
||||
return 'positions';
|
||||
case 'points':
|
||||
return 'points';
|
||||
case 'races':
|
||||
return 'races';
|
||||
case 'none':
|
||||
return '';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
private getFallbackDefaultPenaltyValue(valueKind: string): number {
|
||||
switch (valueKind) {
|
||||
case 'seconds':
|
||||
return 5;
|
||||
case 'grid_positions':
|
||||
return 3;
|
||||
case 'points':
|
||||
return 5;
|
||||
case 'races':
|
||||
return 1;
|
||||
case 'none':
|
||||
return 0;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
constructor(
|
||||
private readonly raceService: RaceService,
|
||||
private readonly protestService: ProtestService,
|
||||
private readonly penaltyService: PenaltyService,
|
||||
private readonly driverService: DriverService,
|
||||
private readonly leagueMembershipService: LeagueMembershipService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get league stewarding data for all races in a league
|
||||
*/
|
||||
async getLeagueStewardingData(leagueId: string): Promise<LeagueStewardingViewModel> {
|
||||
// Get all races for this league
|
||||
const leagueRaces = await this.raceService.findByLeagueId(leagueId);
|
||||
|
||||
// Get protests and penalties for each race
|
||||
const protestsMap: Record<string, any[]> = {};
|
||||
const penaltiesMap: Record<string, any[]> = {};
|
||||
const driverIds = new Set<string>();
|
||||
|
||||
for (const race of leagueRaces) {
|
||||
const raceProtests = await this.protestService.findByRaceId(race.id);
|
||||
const racePenalties = await this.penaltyService.findByRaceId(race.id);
|
||||
|
||||
protestsMap[race.id] = raceProtests;
|
||||
penaltiesMap[race.id] = racePenalties;
|
||||
|
||||
// Collect driver IDs
|
||||
raceProtests.forEach((p: any) => {
|
||||
driverIds.add(p.protestingDriverId);
|
||||
driverIds.add(p.accusedDriverId);
|
||||
});
|
||||
racePenalties.forEach((p: any) => {
|
||||
driverIds.add(p.driverId);
|
||||
});
|
||||
}
|
||||
|
||||
// Load driver info
|
||||
const driverEntities = await this.driverService.findByIds(Array.from(driverIds));
|
||||
const driverMap: Record<string, any> = {};
|
||||
driverEntities.forEach((driver) => {
|
||||
if (driver) {
|
||||
driverMap[driver.id] = driver;
|
||||
}
|
||||
});
|
||||
|
||||
// Compute race data with protest/penalty info
|
||||
const racesWithData: RaceWithProtests[] = leagueRaces.map(race => {
|
||||
const protests = protestsMap[race.id] || [];
|
||||
const penalties = penaltiesMap[race.id] || [];
|
||||
return {
|
||||
race: {
|
||||
id: race.id,
|
||||
track: race.track,
|
||||
scheduledAt: new Date(race.scheduledAt),
|
||||
},
|
||||
pendingProtests: protests.filter(p => p.status === 'pending' || p.status === 'under_review'),
|
||||
resolvedProtests: protests.filter(p => p.status === 'upheld' || p.status === 'dismissed' || p.status === 'withdrawn'),
|
||||
penalties
|
||||
};
|
||||
}).sort((a, b) => b.race.scheduledAt.getTime() - a.race.scheduledAt.getTime());
|
||||
|
||||
return new LeagueStewardingViewModel(racesWithData, driverMap);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get protest review details as a page-ready view model
|
||||
*/
|
||||
async getProtestDetailViewModel(leagueId: string, protestId: string): Promise<ProtestDetailViewModel> {
|
||||
const [protestData, penaltyTypesReference] = await Promise.all([
|
||||
this.protestService.getProtestById(leagueId, protestId),
|
||||
this.penaltyService.getPenaltyTypesReference(),
|
||||
]);
|
||||
|
||||
if (!protestData) {
|
||||
throw new Error('Protest not found');
|
||||
}
|
||||
|
||||
const penaltyUiDefaults: Record<string, { label: string; description: string; defaultValue: number }> = {
|
||||
time_penalty: { label: 'Time Penalty', description: 'Add seconds to race result', defaultValue: 5 },
|
||||
grid_penalty: { label: 'Grid Penalty', description: 'Grid positions for next race', defaultValue: 3 },
|
||||
points_deduction: { label: 'Points Deduction', description: 'Deduct championship points', defaultValue: 5 },
|
||||
disqualification: { label: 'Disqualification', description: 'Disqualify from race', defaultValue: 0 },
|
||||
warning: { label: 'Warning', description: 'Official warning only', defaultValue: 0 },
|
||||
license_points: { label: 'License Points', description: 'Safety rating penalty', defaultValue: 2 },
|
||||
};
|
||||
|
||||
const penaltyTypes = (penaltyTypesReference?.penaltyTypes ?? []).map((ref: any) => {
|
||||
const ui = penaltyUiDefaults[ref.type];
|
||||
const valueLabel = this.getPenaltyValueLabel(String(ref.valueKind ?? 'none'));
|
||||
const defaultValue = ui?.defaultValue ?? this.getFallbackDefaultPenaltyValue(String(ref.valueKind ?? 'none'));
|
||||
|
||||
return {
|
||||
type: String(ref.type),
|
||||
label: ui?.label ?? String(ref.type).replaceAll('_', ' '),
|
||||
description: ui?.description ?? '',
|
||||
requiresValue: Boolean(ref.requiresValue),
|
||||
valueLabel,
|
||||
defaultValue,
|
||||
};
|
||||
});
|
||||
|
||||
const timePenalty = penaltyTypes.find((p) => p.type === 'time_penalty');
|
||||
const initial = timePenalty ?? penaltyTypes[0];
|
||||
|
||||
return {
|
||||
protest: protestData.protest,
|
||||
race: protestData.race,
|
||||
protestingDriver: protestData.protestingDriver,
|
||||
accusedDriver: protestData.accusedDriver,
|
||||
penaltyTypes,
|
||||
defaultReasons: penaltyTypesReference?.defaultReasons ?? { upheld: '', dismissed: '' },
|
||||
initialPenaltyType: initial?.type ?? null,
|
||||
initialPenaltyValue: initial?.defaultValue ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Review a protest
|
||||
*/
|
||||
async reviewProtest(input: { protestId: string; stewardId: string; decision: string; decisionNotes: string }): Promise<void> {
|
||||
await this.protestService.reviewProtest(input);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a penalty
|
||||
*/
|
||||
async applyPenalty(input: any): Promise<void> {
|
||||
await this.penaltyService.applyPenalty(input);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, vi, Mocked } from 'vitest';
|
||||
import { LeagueWalletService } from './LeagueWalletService';
|
||||
import { WalletsApiClient } from '../../api/wallets/WalletsApiClient';
|
||||
import { WalletsApiClient } from '@/lib/api/wallets/WalletsApiClient';
|
||||
import { LeagueWalletViewModel } from '@/lib/view-models/LeagueWalletViewModel';
|
||||
|
||||
describe('LeagueWalletService', () => {
|
||||
@@ -97,27 +97,5 @@ describe('LeagueWalletService', () => {
|
||||
|
||||
await expect(service.withdraw(leagueId, amount, currency, seasonId, destinationAccount)).rejects.toThrow('Withdrawal failed');
|
||||
});
|
||||
|
||||
it('should block multiple rapid calls due to throttle', async () => {
|
||||
const leagueId = 'league-123';
|
||||
const amount = 500;
|
||||
const currency = 'USD';
|
||||
const seasonId = 'season-456';
|
||||
const destinationAccount = 'account-789';
|
||||
|
||||
const mockResponse = { success: true };
|
||||
mockApiClient.withdrawFromLeagueWallet.mockResolvedValue(mockResponse);
|
||||
|
||||
// First call should succeed
|
||||
await service.withdraw(leagueId, amount, currency, seasonId, destinationAccount);
|
||||
|
||||
// Reset mock
|
||||
mockApiClient.withdrawFromLeagueWallet.mockClear();
|
||||
|
||||
// Immediate second call should be blocked by throttle and throw error
|
||||
await expect(service.withdraw(leagueId, amount, currency, seasonId, destinationAccount)).rejects.toThrow('Request blocked due to rate limiting');
|
||||
|
||||
expect(mockApiClient.withdrawFromLeagueWallet).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,71 +0,0 @@
|
||||
import { WalletsApiClient, LeagueWalletDTO, WithdrawRequestDTO, WithdrawResponseDTO } from '@/lib/api/wallets/WalletsApiClient';
|
||||
import { LeagueWalletViewModel } from '@/lib/view-models/LeagueWalletViewModel';
|
||||
import { WalletTransactionViewModel } from '@/lib/view-models/WalletTransactionViewModel';
|
||||
import { SubmitBlocker, ThrottleBlocker } from '@/lib/blockers';
|
||||
|
||||
/**
|
||||
* League Wallet Service
|
||||
*
|
||||
* Orchestrates league wallet operations by coordinating API calls and view model creation.
|
||||
* All dependencies are injected via constructor.
|
||||
*/
|
||||
export class LeagueWalletService {
|
||||
private readonly submitBlocker = new SubmitBlocker();
|
||||
private readonly throttle = new ThrottleBlocker(500);
|
||||
|
||||
constructor(
|
||||
private readonly apiClient: WalletsApiClient
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get wallet for a league
|
||||
*/
|
||||
async getWalletForLeague(leagueId: string): Promise<LeagueWalletViewModel> {
|
||||
const dto = await this.apiClient.getLeagueWallet(leagueId);
|
||||
const transactions = dto.transactions.map(t => new WalletTransactionViewModel({
|
||||
id: t.id,
|
||||
type: t.type,
|
||||
description: t.description,
|
||||
amount: t.amount,
|
||||
fee: t.fee,
|
||||
netAmount: t.netAmount,
|
||||
date: new Date(t.date),
|
||||
status: t.status,
|
||||
reference: t.reference,
|
||||
}));
|
||||
return new LeagueWalletViewModel({
|
||||
balance: dto.balance,
|
||||
currency: dto.currency,
|
||||
totalRevenue: dto.totalRevenue,
|
||||
totalFees: dto.totalFees,
|
||||
totalWithdrawals: dto.totalWithdrawals,
|
||||
pendingPayouts: dto.pendingPayouts,
|
||||
transactions,
|
||||
canWithdraw: dto.canWithdraw,
|
||||
withdrawalBlockReason: dto.withdrawalBlockReason,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Withdraw from league wallet
|
||||
*/
|
||||
async withdraw(leagueId: string, amount: number, currency: string, seasonId: string, destinationAccount: string): Promise<WithdrawResponseDTO> {
|
||||
if (!this.submitBlocker.canExecute() || !this.throttle.canExecute()) {
|
||||
throw new Error('Request blocked due to rate limiting');
|
||||
}
|
||||
|
||||
this.submitBlocker.block();
|
||||
this.throttle.block();
|
||||
try {
|
||||
const request: WithdrawRequestDTO = {
|
||||
amount,
|
||||
currency,
|
||||
seasonId,
|
||||
destinationAccount,
|
||||
};
|
||||
return await this.apiClient.withdrawFromLeagueWallet(leagueId, request);
|
||||
} finally {
|
||||
this.submitBlocker.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { apiClient } from '@/lib/apiClient';
|
||||
import { LeagueWizardCommandModel } from '@/lib/command-models/leagues/LeagueWizardCommandModel';
|
||||
import { CreateLeagueOutputDTO } from '@/lib/types/generated/CreateLeagueOutputDTO';
|
||||
|
||||
export class LeagueWizardService {
|
||||
static async createLeague(
|
||||
form: LeagueWizardCommandModel,
|
||||
ownerId: string,
|
||||
): Promise<CreateLeagueOutputDTO> {
|
||||
const command = form.toCreateLeagueCommand(ownerId);
|
||||
const result = await apiClient.leagues.create(command);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Static method for backward compatibility
|
||||
static async createLeagueFromConfig(
|
||||
form: LeagueWizardCommandModel,
|
||||
ownerId: string,
|
||||
): Promise<CreateLeagueOutputDTO> {
|
||||
return this.createLeague(form, ownerId);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user