website refactor

This commit is contained in:
2026-01-12 01:01:49 +01:00
parent 5ca6023a5a
commit fefd8d1cd6
294 changed files with 4628 additions and 4991 deletions

View File

@@ -1,8 +0,0 @@
import { describe, it, expect } from 'vitest';
import { AdminViewModelService } from './AdminViewModelService';
describe('AdminViewModelService', () => {
it('should be defined', () => {
expect(AdminViewModelService).toBeDefined();
});
});

View File

@@ -1,44 +0,0 @@
import type { UserDto, DashboardStats, UserListResponse } from '@/lib/api/admin/AdminApiClient';
import { AdminUserViewModel, DashboardStatsViewModel, UserListViewModel } from '@/lib/view-models/AdminUserViewModel';
/**
* AdminViewModelService
*
* Service layer responsible for mapping API DTOs to View Models.
* This is where the transformation from API data to UI-ready state happens.
*/
export class AdminViewModelService {
/**
* Map a single user DTO to a View Model
*/
static mapUser(dto: UserDto): AdminUserViewModel {
return new AdminUserViewModel(dto);
}
/**
* Map an array of user DTOs to View Models
*/
static mapUsers(dtos: UserDto[]): AdminUserViewModel[] {
return dtos.map(dto => this.mapUser(dto));
}
/**
* Map dashboard stats DTO to View Model
*/
static mapDashboardStats(dto: DashboardStats): DashboardStatsViewModel {
return new DashboardStatsViewModel(dto);
}
/**
* Map user list response to View Model
*/
static mapUserList(response: UserListResponse): UserListViewModel {
return new UserListViewModel({
users: response.users,
total: response.total,
page: response.page,
limit: response.limit,
totalPages: response.totalPages,
});
}
}

View File

@@ -1,8 +1,8 @@
import { describe, it, expect, vi, Mocked } from 'vitest';
import { AnalyticsService } from './AnalyticsService';
import { AnalyticsApiClient } from '../../api/analytics/AnalyticsApiClient';
import { RecordPageViewOutputViewModel } from '../../view-models/RecordPageViewOutputViewModel';
import { RecordEngagementOutputViewModel } from '../../view-models/RecordEngagementOutputViewModel';
import { AnalyticsApiClient } from '@/lib/api/analytics/AnalyticsApiClient';
import { RecordPageViewOutputViewModel } from '@/lib/view-models/RecordPageViewOutputViewModel';
import { RecordEngagementOutputViewModel } from '@/lib/view-models/RecordEngagementOutputViewModel';
describe('AnalyticsService', () => {
let mockApiClient: Mocked<AnalyticsApiClient>;

View File

@@ -1,33 +0,0 @@
import { AnalyticsApiClient } from '../../api/analytics/AnalyticsApiClient';
import { RecordPageViewOutputViewModel } from '../../view-models/RecordPageViewOutputViewModel';
import { RecordEngagementOutputViewModel } from '../../view-models/RecordEngagementOutputViewModel';
import { RecordPageViewInputDTO } from '../../types/generated/RecordPageViewInputDTO';
import { RecordEngagementInputDTO } from '../../types/generated/RecordEngagementInputDTO';
/**
* Analytics Service
*
* Orchestrates analytics operations by coordinating API calls.
* All dependencies are injected via constructor.
*/
export class AnalyticsService {
constructor(
private readonly apiClient: AnalyticsApiClient
) {}
/**
* Record a page view
*/
async recordPageView(input: RecordPageViewInputDTO): Promise<RecordPageViewOutputViewModel> {
const result = await this.apiClient.recordPageView(input);
return new RecordPageViewOutputViewModel(result);
}
/**
* Record an engagement event
*/
async recordEngagement(input: RecordEngagementInputDTO): Promise<RecordEngagementOutputViewModel> {
const result = await this.apiClient.recordEngagement(input);
return new RecordEngagementOutputViewModel(result);
}
}

View File

@@ -1,6 +1,6 @@
import { describe, it, expect, vi, Mocked } from 'vitest';
import { DashboardService } from './DashboardService';
import { AnalyticsApiClient } from '../../api/analytics/AnalyticsApiClient';
import { AnalyticsApiClient } from '@/lib/api/analytics/AnalyticsApiClient';
import { AnalyticsDashboardViewModel, AnalyticsMetricsViewModel } from '../../view-models';
describe('DashboardService', () => {

View File

@@ -1,31 +0,0 @@
import { AnalyticsDashboardViewModel } from '@/lib/view-models/AnalyticsDashboardViewModel';
import { AnalyticsMetricsViewModel } from '@/lib/view-models/AnalyticsMetricsViewModel';
import { AnalyticsApiClient } from '../../api/analytics/AnalyticsApiClient';
/**
* Dashboard Service
*
* Orchestrates dashboard operations by coordinating API calls and view model creation.
* All dependencies are injected via constructor.
*/
export class DashboardService {
constructor(
private readonly apiClient: AnalyticsApiClient
) {}
/**
* Get dashboard data with view model transformation
*/
async getDashboardData(): Promise<AnalyticsDashboardViewModel> {
const dto = await this.apiClient.getDashboardData();
return new AnalyticsDashboardViewModel(dto);
}
/**
* Get analytics metrics with view model transformation
*/
async getAnalyticsMetrics(): Promise<AnalyticsMetricsViewModel> {
const dto = await this.apiClient.getAnalyticsMetrics();
return new AnalyticsMetricsViewModel(dto);
}
}

View File

@@ -1,7 +1,7 @@
import { describe, it, expect, vi, Mocked } from 'vitest';
import { AuthService } from './AuthService';
import { AuthApiClient } from '../../api/auth/AuthApiClient';
import { SessionViewModel } from '../../view-models/SessionViewModel';
import { AuthApiClient } from '@/lib/api/auth/AuthApiClient';
import { SessionViewModel } from '@/lib/view-models/SessionViewModel';
describe('AuthService', () => {
let mockApiClient: Mocked<AuthApiClient>;

View File

@@ -1,75 +0,0 @@
import { AuthApiClient } from '../../api/auth/AuthApiClient';
import { SessionViewModel } from '../../view-models/SessionViewModel';
import type { LoginParamsDTO } from '../../types/generated/LoginParamsDTO';
import type { SignupParamsDTO } from '../../types/generated/SignupParamsDTO';
import type { ForgotPasswordDTO } from '../../types/generated/ForgotPasswordDTO';
import type { ResetPasswordDTO } from '../../types/generated/ResetPasswordDTO';
/**
* Auth Service
*
* Orchestrates authentication operations by coordinating API calls.
* All dependencies are injected via constructor.
*/
export class AuthService {
constructor(
private readonly apiClient: AuthApiClient
) {}
/**
* Sign up a new user
*/
async signup(params: SignupParamsDTO): Promise<SessionViewModel> {
try {
const dto = await this.apiClient.signup(params);
return new SessionViewModel(dto.user);
} catch (error) {
throw error;
}
}
/**
* Log in an existing user
*/
async login(params: LoginParamsDTO): Promise<SessionViewModel> {
try {
const dto = await this.apiClient.login(params);
return new SessionViewModel(dto.user);
} catch (error) {
throw error;
}
}
/**
* Log out the current user
*/
async logout(): Promise<void> {
try {
await this.apiClient.logout();
} catch (error) {
throw error;
}
}
/**
* Forgot password - send reset link
*/
async forgotPassword(params: ForgotPasswordDTO): Promise<{ message: string; magicLink?: string }> {
try {
return await this.apiClient.forgotPassword(params);
} catch (error) {
throw error;
}
}
/**
* Reset password with token
*/
async resetPassword(params: ResetPasswordDTO): Promise<{ message: string }> {
try {
return await this.apiClient.resetPassword(params);
} catch (error) {
throw error;
}
}
}

View File

@@ -1,7 +1,7 @@
import { describe, it, expect, vi, Mocked } from 'vitest';
import { SessionService } from './SessionService';
import { AuthApiClient } from '../../api/auth/AuthApiClient';
import { SessionViewModel } from '../../view-models/SessionViewModel';
import { AuthApiClient } from '@/lib/api/auth/AuthApiClient';
import { SessionViewModel } from '@/lib/view-models/SessionViewModel';
describe('SessionService', () => {
let mockApiClient: Mocked<AuthApiClient>;

View File

@@ -1,5 +1,5 @@
import { SessionViewModel } from '@/lib/view-models/SessionViewModel';
import { AuthApiClient } from '../../api/auth/AuthApiClient';
import { AuthApiClient } from '@/lib/api/auth/AuthApiClient';
/**
* Session Service

View File

@@ -1,7 +1,7 @@
import { describe, it, expect, vi, Mocked } from 'vitest';
import { DashboardService } from './DashboardService';
import { DashboardApiClient } from '../../api/dashboard/DashboardApiClient';
import { DashboardOverviewViewModel } from '../../view-models/DashboardOverviewViewModel';
import { DashboardApiClient } from '@/lib/api/dashboard/DashboardApiClient';
import { DashboardOverviewViewModel } from '@/lib/view-models/DashboardOverviewViewModel';
describe('DashboardService', () => {
let mockApiClient: Mocked<DashboardApiClient>;

View File

@@ -1,113 +0,0 @@
import { DashboardOverviewViewModel } from '../../view-models/DashboardOverviewViewModel';
import { DashboardApiClient } from '../../api/dashboard/DashboardApiClient';
import type { DashboardOverviewDTO } from '../../types/generated/DashboardOverviewDTO';
import type { DashboardOverviewViewModelData } from '../../view-models/DashboardOverviewViewModelData';
/**
* Dashboard Service
*
* Orchestrates dashboard operations by coordinating API calls and view model creation.
* All dependencies are injected via constructor.
*/
export class DashboardService {
constructor(
private readonly apiClient: DashboardApiClient
) {}
/**
* Get dashboard overview data with view model transformation
* Returns the ViewModel for backward compatibility
*/
async getDashboardOverview(): Promise<DashboardOverviewViewModel> {
const dto = await this.apiClient.getDashboardOverview();
// Convert DTO to ViewModelData format for the ViewModel
const viewModelData: DashboardOverviewViewModelData = {
currentDriver: dto.currentDriver ? {
id: dto.currentDriver.id,
name: dto.currentDriver.name,
avatarUrl: dto.currentDriver.avatarUrl || '',
country: dto.currentDriver.country,
totalRaces: dto.currentDriver.totalRaces,
wins: dto.currentDriver.wins,
podiums: dto.currentDriver.podiums,
rating: dto.currentDriver.rating ?? 0,
globalRank: dto.currentDriver.globalRank ?? 0,
consistency: dto.currentDriver.consistency ?? 0,
} : undefined,
myUpcomingRaces: dto.myUpcomingRaces.map(race => ({
id: race.id,
track: race.track,
car: race.car,
scheduledAt: new Date(race.scheduledAt).toISOString(),
status: race.status,
isMyLeague: race.isMyLeague,
})),
otherUpcomingRaces: dto.otherUpcomingRaces.map(race => ({
id: race.id,
track: race.track,
car: race.car,
scheduledAt: new Date(race.scheduledAt).toISOString(),
status: race.status,
isMyLeague: race.isMyLeague,
})),
upcomingRaces: dto.upcomingRaces.map(race => ({
id: race.id,
track: race.track,
car: race.car,
scheduledAt: new Date(race.scheduledAt).toISOString(),
status: race.status,
isMyLeague: race.isMyLeague,
})),
activeLeaguesCount: dto.activeLeaguesCount,
nextRace: dto.nextRace ? {
id: dto.nextRace.id,
track: dto.nextRace.track,
car: dto.nextRace.car,
scheduledAt: new Date(dto.nextRace.scheduledAt).toISOString(),
status: dto.nextRace.status,
isMyLeague: dto.nextRace.isMyLeague,
} : undefined,
recentResults: dto.recentResults.map(result => ({
id: result.raceId,
track: result.raceName,
car: '',
position: result.position,
date: new Date(result.finishedAt).toISOString(),
})),
leagueStandingsSummaries: dto.leagueStandingsSummaries.map(standing => ({
leagueId: standing.leagueId,
leagueName: standing.leagueName,
position: standing.position,
points: standing.points,
totalDrivers: standing.totalDrivers,
})),
feedSummary: {
notificationCount: dto.feedSummary.notificationCount,
items: dto.feedSummary.items.map(item => ({
id: item.id,
type: item.type,
headline: item.headline,
body: item.body,
timestamp: new Date(item.timestamp).toISOString(),
ctaHref: item.ctaHref,
ctaLabel: item.ctaLabel,
})),
},
friends: dto.friends.map(friend => ({
id: friend.id,
name: friend.name,
avatarUrl: friend.avatarUrl || '',
country: friend.country,
})),
};
return new DashboardOverviewViewModel(viewModelData);
}
/**
* Get raw DTO for page queries
*/
async getDashboardOverviewDTO(): Promise<DashboardOverviewDTO> {
return await this.apiClient.getDashboardOverview();
}
}

View File

@@ -1,7 +1,7 @@
import { describe, it, expect, vi, Mocked } from 'vitest';
import { DriverRegistrationService } from './DriverRegistrationService';
import { DriversApiClient } from '../../api/drivers/DriversApiClient';
import { DriverRegistrationStatusViewModel } from '../../view-models/DriverRegistrationStatusViewModel';
import { DriversApiClient } from '@/lib/api/drivers/DriversApiClient';
import { DriverRegistrationStatusViewModel } from '@/lib/view-models/DriverRegistrationStatusViewModel';
describe('DriverRegistrationService', () => {
let mockApiClient: Mocked<DriversApiClient>;

View File

@@ -1,25 +0,0 @@
import type { DriversApiClient } from '../../api/drivers/DriversApiClient';
import { DriverRegistrationStatusViewModel } from '../../view-models/DriverRegistrationStatusViewModel';
/**
* Driver Registration Service
*
* Orchestrates driver registration status operations by coordinating API calls and view model creation.
* All dependencies are injected via constructor.
*/
export class DriverRegistrationService {
constructor(
private readonly apiClient: DriversApiClient
) {}
/**
* Get driver registration status for a specific race
*/
async getDriverRegistrationStatus(
driverId: string,
raceId: string
): Promise<DriverRegistrationStatusViewModel> {
const dto = await this.apiClient.getRegistrationStatus(driverId, raceId);
return new DriverRegistrationStatusViewModel(dto);
}
}

View File

@@ -1,9 +1,9 @@
import { describe, it, expect, vi, Mocked } from 'vitest';
import { DriverService } from './DriverService';
import { DriversApiClient } from '../../api/drivers/DriversApiClient';
import { DriverLeaderboardViewModel } from '../../view-models/DriverLeaderboardViewModel';
import { DriverViewModel } from '../../view-models/DriverViewModel';
import { CompleteOnboardingViewModel } from '../../view-models/CompleteOnboardingViewModel';
import { DriversApiClient } from '@/lib/api/drivers/DriversApiClient';
import { DriverLeaderboardViewModel } from '@/lib/view-models/DriverLeaderboardViewModel';
import { DriverViewModel } from '@/lib/view-models/DriverViewModel';
import { CompleteOnboardingViewModel } from '@/lib/view-models/CompleteOnboardingViewModel';
describe('DriverService', () => {
let mockApiClient: Mocked<DriversApiClient>;

View File

@@ -41,7 +41,7 @@ export class DriverService {
if (!dto) {
return null;
}
return new DriverViewModel({ ...dto, avatarUrl: (dto as any).avatarUrl ?? null });
return new DriverViewModel({ ...dto, avatarUrl: dto.avatarUrl ?? null });
}
/**
@@ -113,7 +113,7 @@ export class DriverService {
extendedProfile: dto.extendedProfile
? {
socialHandles: dto.extendedProfile.socialHandles.map((h) => ({
platform: h.platform as any,
platform: h.platform as 'twitter' | 'youtube' | 'twitch' | 'discord',
handle: h.handle,
url: h.url,
})),
@@ -121,8 +121,8 @@ export class DriverService {
id: a.id,
title: a.title,
description: a.description,
icon: a.icon as any,
rarity: a.rarity as any,
icon: a.icon as 'trophy' | 'medal' | 'star' | 'crown' | 'target' | 'zap',
rarity: a.rarity as 'common' | 'rare' | 'epic' | 'legendary',
earnedAt: a.earnedAt,
})),
racingStyle: dto.extendedProfile.racingStyle,

View File

@@ -1,8 +1,8 @@
import { describe, it, expect, vi, Mocked } from 'vitest';
import { LandingService } from './LandingService';
import { RacesApiClient } from '../../api/races/RacesApiClient';
import { LeaguesApiClient } from '../../api/leagues/LeaguesApiClient';
import { TeamsApiClient } from '../../api/teams/TeamsApiClient';
import { RacesApiClient } from '@/lib/api/races/RacesApiClient';
import { LeaguesApiClient } from '@/lib/api/leagues/LeaguesApiClient';
import { TeamsApiClient } from '@/lib/api/teams/TeamsApiClient';
import { HomeDiscoveryViewModel } from '@/lib/view-models/HomeDiscoveryViewModel';
describe('LandingService', () => {

View File

@@ -1,103 +0,0 @@
import { RacesApiClient } from '@/lib/api/races/RacesApiClient';
import { LeaguesApiClient } from '@/lib/api/leagues/LeaguesApiClient';
import { TeamsApiClient } from '@/lib/api/teams/TeamsApiClient';
import { AuthApiClient } from '@/lib/api/auth/AuthApiClient';
import type { AllLeaguesWithCapacityDTO } from '@/lib/types/generated/AllLeaguesWithCapacityDTO';
import type { GetAllTeamsOutputDTO } from '@/lib/types/generated/GetAllTeamsOutputDTO';
import type { RacesPageDataDTO } from '@/lib/types/generated/RacesPageDataDTO';
import type { LeagueWithCapacityDTO } from '@/lib/types/generated/LeagueWithCapacityDTO';
import type { TeamListItemDTO } from '@/lib/types/generated/TeamListItemDTO';
import type { SignupParamsDTO } from '@/lib/types/generated/SignupParamsDTO';
import type { AuthSessionDTO } from '@/lib/types/generated/AuthSessionDTO';
import { RacesPageViewModel } from '@/lib/view-models/RacesPageViewModel';
import { HomeDiscoveryViewModel } from '@/lib/view-models/HomeDiscoveryViewModel';
import { LeagueCardViewModel } from '@/lib/view-models/LeagueCardViewModel';
import { TeamCardViewModel } from '@/lib/view-models/TeamCardViewModel';
import { UpcomingRaceCardViewModel } from '@/lib/view-models/UpcomingRaceCardViewModel';
import { EmailSignupViewModel } from '@/lib/view-models/EmailSignupViewModel';
export class LandingService {
constructor(
private readonly racesApi: RacesApiClient,
private readonly leaguesApi: LeaguesApiClient,
private readonly teamsApi: TeamsApiClient,
private readonly authApi: AuthApiClient,
) {}
async getHomeDiscovery(): Promise<HomeDiscoveryViewModel> {
const [racesDto, leaguesDto, teamsDto] = await Promise.all([
this.racesApi.getPageData() as Promise<RacesPageDataDTO>,
this.leaguesApi.getAllWithCapacity() as Promise<AllLeaguesWithCapacityDTO>,
this.teamsApi.getAll() as Promise<GetAllTeamsOutputDTO>,
]);
const racesVm = new RacesPageViewModel(racesDto);
const topLeagues = (leaguesDto?.leagues || []).slice(0, 4).map(
(league: LeagueWithCapacityDTO) => new LeagueCardViewModel({
id: league.id,
name: league.name,
description: league.description ?? 'Competitive iRacing league',
}),
);
const teams = (teamsDto?.teams || []).slice(0, 4).map(
(team: TeamListItemDTO) =>
new TeamCardViewModel({
id: team.id,
name: team.name,
tag: team.tag,
description: team.description,
logoUrl: team.logoUrl,
}),
);
const upcomingRaces = racesVm.upcomingRaces.slice(0, 4).map(
race =>
new UpcomingRaceCardViewModel({
id: race.id,
track: race.track,
car: race.car,
scheduledAt: race.scheduledAt,
}),
);
return new HomeDiscoveryViewModel({
topLeagues,
teams,
upcomingRaces,
});
}
/**
* Sign up for early access with email
* Uses the auth signup endpoint
*/
async signup(email: string): Promise<EmailSignupViewModel> {
try {
// Create signup params with default values for early access
const signupParams: SignupParamsDTO = {
email,
password: 'temp_password_' + Math.random().toString(36).substring(7), // Temporary password
displayName: email.split('@')[0] || 'user', // Use email prefix as display name, fallback to 'user'
};
const session: AuthSessionDTO = await this.authApi.signup(signupParams);
if (session?.user?.userId) {
return new EmailSignupViewModel(email, 'Welcome to GridPilot! Check your email to confirm.', 'success');
} else {
return new EmailSignupViewModel(email, 'Signup successful but session not created.', 'error');
}
} catch (error: any) {
// Handle specific error cases
if (error?.status === 429) {
return new EmailSignupViewModel(email, 'Too many requests. Please try again later.', 'error');
}
if (error?.status === 409) {
return new EmailSignupViewModel(email, 'This email is already registered.', 'info');
}
return new EmailSignupViewModel(email, 'Something broke. Try again?', 'error');
}
}
}

View File

@@ -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>;

View File

@@ -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,
}));

View File

@@ -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', () => {

View File

@@ -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;

View File

@@ -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', () => {

View File

@@ -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.'],
};
}
}

View File

@@ -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>;

View File

@@ -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);
}
}

View File

@@ -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();
});
});
});

View File

@@ -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();
}
}
}

View File

@@ -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);
}
}

View File

@@ -1,9 +1,9 @@
import { describe, it, expect, vi, Mocked } from 'vitest';
import { AvatarService } from './AvatarService';
import { MediaApiClient } from '../../api/media/MediaApiClient';
import { RequestAvatarGenerationViewModel } from '../../view-models/RequestAvatarGenerationViewModel';
import { AvatarViewModel } from '../../view-models/AvatarViewModel';
import { UpdateAvatarViewModel } from '../../view-models/UpdateAvatarViewModel';
import { MediaApiClient } from '@/lib/api/media/MediaApiClient';
import { RequestAvatarGenerationViewModel } from '@/lib/view-models/RequestAvatarGenerationViewModel';
import { AvatarViewModel } from '@/lib/view-models/AvatarViewModel';
import { UpdateAvatarViewModel } from '@/lib/view-models/UpdateAvatarViewModel';
describe('AvatarService', () => {
let mockApiClient: Mocked<MediaApiClient>;

View File

@@ -1,47 +0,0 @@
import { RequestAvatarGenerationInputDTO } from '@/lib/types/generated/RequestAvatarGenerationInputDTO';
import { UpdateAvatarInputDTO } from '@/lib/types/generated/UpdateAvatarInputDTO';
import { AvatarViewModel } from '@/lib/view-models/AvatarViewModel';
import { RequestAvatarGenerationViewModel } from '@/lib/view-models/RequestAvatarGenerationViewModel';
import { UpdateAvatarViewModel } from '@/lib/view-models/UpdateAvatarViewModel';
import type { MediaApiClient } from '../../api/media/MediaApiClient';
/**
* Avatar Service
*
* Orchestrates avatar operations by coordinating API calls and view model creation.
* All dependencies are injected via constructor.
*/
export class AvatarService {
constructor(
private readonly apiClient: MediaApiClient
) {}
/**
* Request avatar generation with view model transformation
*/
async requestAvatarGeneration(input: RequestAvatarGenerationInputDTO): Promise<RequestAvatarGenerationViewModel> {
const dto = await this.apiClient.requestAvatarGeneration(input);
return new RequestAvatarGenerationViewModel(dto);
}
/**
* Get avatar for driver with view model transformation
*/
async getAvatar(driverId: string): Promise<AvatarViewModel> {
const dto = await this.apiClient.getAvatar(driverId);
// Convert GetAvatarOutputDTO to AvatarDTO format
const avatarDto = {
driverId: driverId,
avatarUrl: dto.avatarUrl
};
return new AvatarViewModel(avatarDto);
}
/**
* Update avatar for driver with view model transformation
*/
async updateAvatar(input: UpdateAvatarInputDTO): Promise<UpdateAvatarViewModel> {
const dto = await this.apiClient.updateAvatar(input);
return new UpdateAvatarViewModel(dto);
}
}

View File

@@ -1,9 +1,9 @@
import { describe, it, expect, vi, Mocked } from 'vitest';
import { MediaService } from './MediaService';
import { MediaApiClient } from '../../api/media/MediaApiClient';
import { MediaViewModel } from '../../view-models/MediaViewModel';
import { UploadMediaViewModel } from '../../view-models/UploadMediaViewModel';
import { DeleteMediaViewModel } from '../../view-models/DeleteMediaViewModel';
import { MediaApiClient } from '@/lib/api/media/MediaApiClient';
import { MediaViewModel } from '@/lib/view-models/MediaViewModel';
import { UploadMediaViewModel } from '@/lib/view-models/UploadMediaViewModel';
import { DeleteMediaViewModel } from '@/lib/view-models/DeleteMediaViewModel';
describe('MediaService', () => {
let mockApiClient: Mocked<MediaApiClient>;

View File

@@ -1,44 +0,0 @@
import { DeleteMediaViewModel } from '@/lib/view-models/DeleteMediaViewModel';
import { MediaViewModel } from '@/lib/view-models/MediaViewModel';
import { UploadMediaViewModel } from '@/lib/view-models/UploadMediaViewModel';
import type { MediaApiClient } from '../../api/media/MediaApiClient';
// Local request shape mirroring the media upload API contract until a generated type is available
type UploadMediaRequest = { file: File; type: string; category?: string };
/**
* Media Service
*
* Orchestrates media operations by coordinating API calls and view model creation.
* All dependencies are injected via constructor.
*/
export class MediaService {
constructor(
private readonly apiClient: MediaApiClient
) {}
/**
* Upload media file with view model transformation
*/
async uploadMedia(input: UploadMediaRequest): Promise<UploadMediaViewModel> {
const dto = await this.apiClient.uploadMedia(input);
return new UploadMediaViewModel(dto);
}
/**
* Get media by ID with view model transformation
*/
async getMedia(mediaId: string): Promise<MediaViewModel> {
const dto = await this.apiClient.getMedia(mediaId);
return new MediaViewModel(dto);
}
/**
* Delete media by ID with view model transformation
*/
async deleteMedia(mediaId: string): Promise<DeleteMediaViewModel> {
const dto = await this.apiClient.deleteMedia(mediaId);
return new DeleteMediaViewModel(dto);
}
}

View File

@@ -1,60 +0,0 @@
import { MediaApiClient } from '@/lib/api/media/MediaApiClient';
import { DriversApiClient } from '@/lib/api/drivers/DriversApiClient';
import { RequestAvatarGenerationInputDTO } from '@/lib/types/generated/RequestAvatarGenerationInputDTO';
import { CompleteOnboardingInputDTO } from '@/lib/types/generated/CompleteOnboardingInputDTO';
import { RequestAvatarGenerationOutputDTO } from '@/lib/types/generated/RequestAvatarGenerationOutputDTO';
import { ValidateFaceInputDTO } from '@/lib/types/generated/ValidateFaceInputDTO';
import { ValidateFaceOutputDTO } from '@/lib/types/generated/ValidateFaceOutputDTO';
import { RequestAvatarGenerationViewModel } from '@/lib/view-models/RequestAvatarGenerationViewModel';
import { CompleteOnboardingViewModel } from '@/lib/view-models/CompleteOnboardingViewModel';
import { AvatarGenerationViewModel } from '@/lib/view-models/AvatarGenerationViewModel';
/**
* Onboarding Service
*
* Handles the complete onboarding flow including avatar generation and profile creation.
*/
export class OnboardingService {
constructor(
private readonly mediaApiClient: MediaApiClient,
private readonly driversApiClient: DriversApiClient
) {}
/**
* Validate face photo using the API
*/
async validateFacePhoto(photoData: string): Promise<{ isValid: boolean; errorMessage?: string }> {
const input: ValidateFaceInputDTO = { imageData: photoData };
const dto: ValidateFaceOutputDTO = await this.mediaApiClient.validateFacePhoto(input);
return { isValid: dto.isValid, errorMessage: dto.errorMessage };
}
/**
* Generate avatars based on face photo and suit color
* This method wraps the API call and returns a ViewModel
*/
async generateAvatars(
userId: string,
facePhotoData: string,
suitColor: string
): Promise<AvatarGenerationViewModel> {
const input: RequestAvatarGenerationInputDTO = {
userId,
facePhotoData,
suitColor,
};
const dto: RequestAvatarGenerationOutputDTO = await this.mediaApiClient.requestAvatarGeneration(input);
return new AvatarGenerationViewModel(dto);
}
/**
* Complete onboarding process
*/
async completeOnboarding(
input: CompleteOnboardingInputDTO
): Promise<CompleteOnboardingViewModel> {
const dto = await this.driversApiClient.completeOnboarding(input);
return new CompleteOnboardingViewModel(dto);
}
}

View File

@@ -1,8 +1,8 @@
import { describe, it, expect, vi, Mocked } from 'vitest';
import { MembershipFeeService } from './MembershipFeeService';
import { PaymentsApiClient } from '../../api/payments/PaymentsApiClient';
import { PaymentsApiClient } from '@/lib/api/payments/PaymentsApiClient';
import { MembershipFeeViewModel } from '../../view-models';
import type { MembershipFeeDto } from '../../types/generated';
import type { MembershipFeeDto } from '@/lib/types/generated';
describe('MembershipFeeService', () => {
let mockApiClient: Mocked<PaymentsApiClient>;

View File

@@ -1,34 +0,0 @@
import { MembershipFeeDTO } from '@/lib/types/generated/MembershipFeeDTO';
import type { MemberPaymentDTO } from '@/lib/types/generated/MemberPaymentDTO';
import { MembershipFeeViewModel } from '@/lib/view-models/MembershipFeeViewModel';
import { PaymentsApiClient } from '../../api/payments/PaymentsApiClient';
// Response shape as returned by the membership-fees payments endpoint; mirrors the API contract until a generated type is introduced
export interface GetMembershipFeesOutputDto {
fee: MembershipFeeDTO | null;
payments: MemberPaymentDTO[];
}
/**
* Membership Fee Service
*
* Orchestrates membership fee operations by coordinating API calls and view model creation.
* All dependencies are injected via constructor.
*/
export class MembershipFeeService {
constructor(
private readonly apiClient: PaymentsApiClient
) {}
/**
* Get membership fees by league ID with view model transformation
*/
async getMembershipFees(leagueId: string): Promise<{ fee: MembershipFeeViewModel | null; payments: MemberPaymentDTO[] }> {
const dto: GetMembershipFeesOutputDto = await this.apiClient.getMembershipFees({ leagueId });
return {
fee: dto.fee ? new MembershipFeeViewModel(dto.fee) : null,
// Expose raw member payment DTOs; callers may map these into UI-specific view models if needed
payments: dto.payments,
};
}
}

View File

@@ -1,6 +1,6 @@
import { describe, it, expect, vi, Mocked } from 'vitest';
import { PaymentService } from './PaymentService';
import { PaymentsApiClient } from '../../api/payments/PaymentsApiClient';
import { PaymentsApiClient } from '@/lib/api/payments/PaymentsApiClient';
import { PaymentViewModel, MembershipFeeViewModel, PrizeViewModel, WalletViewModel } from '../../view-models';
describe('PaymentService', () => {

View File

@@ -1,92 +0,0 @@
import { MembershipFeeViewModel } from '@/lib/view-models/MembershipFeeViewModel';
import { PaymentViewModel } from '@/lib/view-models/PaymentViewModel';
import { PrizeViewModel } from '@/lib/view-models/PrizeViewModel';
import { WalletViewModel } from '@/lib/view-models/WalletViewModel';
import type { PaymentsApiClient } from '../../api/payments/PaymentsApiClient';
import type { PaymentDTO } from '../../types/generated/PaymentDTO';
import type { PrizeDTO } from '../../types/generated/PrizeDTO';
// Local payment creation request matching the Payments API contract until a shared generated type is introduced
type CreatePaymentRequest = {
type: 'sponsorship' | 'membership_fee';
amount: number;
payerId: string;
payerType: 'sponsor' | 'driver';
leagueId: string;
seasonId?: string;
};
/**
* Payment Service
*
* Orchestrates payment operations by coordinating API calls and view model creation.
* All dependencies are injected via constructor.
*/
export class PaymentService {
constructor(
private readonly apiClient: PaymentsApiClient
) {}
/**
* Get all payments with optional filters
*/
async getPayments(leagueId?: string, payerId?: string): Promise<PaymentViewModel[]> {
const query = (leagueId || payerId) ? { ...(leagueId && { leagueId }), ...(payerId && { payerId }) } : undefined;
const dto = await this.apiClient.getPayments(query);
return (dto?.payments || []).map((payment: PaymentDTO) => new PaymentViewModel(payment));
}
/**
* Get single payment by ID
*/
async getPayment(paymentId: string): Promise<PaymentViewModel> {
// Note: Assuming the API returns a single payment from the list
const dto = await this.apiClient.getPayments();
const payment = (dto?.payments || []).find((p: PaymentDTO) => p.id === paymentId);
if (!payment) {
throw new Error(`Payment with ID ${paymentId} not found`);
}
return new PaymentViewModel(payment);
}
/**
* Create a new payment
*/
async createPayment(input: CreatePaymentRequest): Promise<PaymentViewModel> {
const dto = await this.apiClient.createPayment(input);
return new PaymentViewModel(dto.payment);
}
/**
* Get membership fees for a league
*/
async getMembershipFees(leagueId: string, driverId?: string): Promise<MembershipFeeViewModel | null> {
const dto = await this.apiClient.getMembershipFees({ leagueId, ...(driverId && { driverId }) });
return dto.fee ? new MembershipFeeViewModel(dto.fee) : null;
}
/**
* Get prizes with optional filters
*/
async getPrizes(leagueId?: string, seasonId?: string): Promise<PrizeViewModel[]> {
const query = (leagueId || seasonId) ? { ...(leagueId && { leagueId }), ...(seasonId && { seasonId }) } : undefined;
const dto = await this.apiClient.getPrizes(query);
return (dto?.prizes || []).map((prize: PrizeDTO) => new PrizeViewModel(prize));
}
/**
* Get wallet for a league
*/
async getWallet(leagueId: string): Promise<WalletViewModel> {
const dto = await this.apiClient.getWallet({ leagueId });
return new WalletViewModel({ ...dto.wallet, transactions: dto.transactions });
}
/**
* Get payment history for a user (driver)
*/
async getPaymentHistory(payerId: string): Promise<PaymentViewModel[]> {
return await this.getPayments(undefined, payerId);
}
}

View File

@@ -1,6 +1,6 @@
import { describe, it, expect, vi, Mocked } from 'vitest';
import { WalletService } from './WalletService';
import { PaymentsApiClient } from '../../api/payments/PaymentsApiClient';
import { PaymentsApiClient } from '@/lib/api/payments/PaymentsApiClient';
import { WalletViewModel } from '../../view-models';
describe('WalletService', () => {

View File

@@ -1,37 +0,0 @@
import { WalletViewModel } from '@/lib/view-models/WalletViewModel';
import { PaymentsApiClient } from '../../api/payments/PaymentsApiClient';
import { FullTransactionDto } from '../../view-models/WalletTransactionViewModel';
/**
* Wallet Service
*
* Orchestrates wallet operations by coordinating API calls and view model creation.
* All dependencies are injected via constructor.
*/
export class WalletService {
constructor(
private readonly apiClient: PaymentsApiClient
) {}
/**
* Get wallet by driver ID with view model transformation
*/
async getWallet(leagueId?: string): Promise<WalletViewModel> {
const { wallet, transactions } = await this.apiClient.getWallet({ leagueId });
// Convert TransactionDTO to FullTransactionDto format
const convertedTransactions: FullTransactionDto[] = transactions.map(t => ({
id: t.id,
type: t.type as 'sponsorship' | 'membership' | 'withdrawal' | 'prize',
description: t.description,
amount: t.amount,
fee: t.amount * 0.05, // Calculate fee (5%)
netAmount: t.amount * 0.95, // Calculate net amount
date: new Date(t.createdAt),
status: 'completed',
referenceId: t.referenceId
}));
return new WalletViewModel({ ...wallet, transactions: convertedTransactions });
}
}

View File

@@ -1,6 +1,6 @@
import { describe, it, expect, vi, Mocked } from 'vitest';
import { PenaltyService } from './PenaltyService';
import { PenaltiesApiClient } from '../../api/penalties/PenaltiesApiClient';
import { PenaltiesApiClient } from '@/lib/api/penalties/PenaltiesApiClient';
describe('PenaltyService', () => {
let mockApiClient: Mocked<PenaltiesApiClient>;

View File

@@ -1,5 +1,5 @@
import { PenaltiesApiClient } from '../../api/penalties/PenaltiesApiClient';
import type { PenaltyTypesReferenceDTO } from '../../types/PenaltyTypesReferenceDTO';
import { PenaltiesApiClient } from '@/lib/api/penalties/PenaltiesApiClient';
import type { PenaltyTypesReferenceDTO } from '@/lib/types/PenaltyTypesReferenceDTO';
/**
* Penalty Service

View File

@@ -1,4 +1,4 @@
import type { FeatureState, PolicyApiClient, PolicySnapshotDto } from '../../api/policy/PolicyApiClient';
import type { FeatureState, PolicyApiClient, PolicySnapshotDto } from '@/lib/api/policy/PolicyApiClient';
export interface CapabilityEvaluationResult {
isLoading: boolean;

View File

@@ -1,9 +1,9 @@
import { describe, it, expect, vi, Mocked } from 'vitest';
import { ProtestService } from './ProtestService';
import { ProtestsApiClient } from '../../api/protests/ProtestsApiClient';
import { ProtestViewModel } from '../../view-models/ProtestViewModel';
import { RaceViewModel } from '../../view-models/RaceViewModel';
import { ProtestDriverViewModel } from '../../view-models/ProtestDriverViewModel';
import { ProtestsApiClient } from '@/lib/api/protests/ProtestsApiClient';
import { ProtestViewModel } from '@/lib/view-models/ProtestViewModel';
import { RaceViewModel } from '@/lib/view-models/RaceViewModel';
import { ProtestDriverViewModel } from '@/lib/view-models/ProtestDriverViewModel';
describe('ProtestService', () => {
let mockApiClient: Mocked<ProtestsApiClient>;

View File

@@ -1,166 +0,0 @@
import { ProtestsApiClient } from '../../api/protests/ProtestsApiClient';
import { ProtestViewModel } from '../../view-models/ProtestViewModel';
import { RaceViewModel } from '../../view-models/RaceViewModel';
import { ProtestDriverViewModel } from '../../view-models/ProtestDriverViewModel';
import type { LeagueAdminProtestsDTO } from '../../types/generated/LeagueAdminProtestsDTO';
import type { ApplyPenaltyCommandDTO } from '../../types/generated/ApplyPenaltyCommandDTO';
import type { RequestProtestDefenseCommandDTO } from '../../types/generated/RequestProtestDefenseCommandDTO';
import type { ReviewProtestCommandDTO } from '../../types/generated/ReviewProtestCommandDTO';
import type { DriverDTO } from '../../types/generated/DriverDTO';
import type { FileProtestCommandDTO } from '../../types/generated/FileProtestCommandDTO';
import type { ProtestIncidentDTO } from '../../types/generated/ProtestIncidentDTO';
export interface ProtestParticipant {
id: string;
name: string;
}
export interface FileProtestInput {
raceId: string;
leagueId?: string;
protestingDriverId: string;
accusedDriverId: string;
lap: string;
timeInRace?: string;
description: string;
comment?: string;
proofVideoUrl?: string;
}
/**
* Protest Service
*
* Orchestrates protest operations by coordinating API calls and view model creation.
* All dependencies are injected via constructor.
*/
export class ProtestService {
constructor(
private readonly apiClient: ProtestsApiClient
) {}
/**
* Get protests for a league with view model transformation
*/
async getLeagueProtests(leagueId: string): Promise<{
protests: ProtestViewModel[];
racesById: LeagueAdminProtestsDTO['racesById'];
driversById: LeagueAdminProtestsDTO['driversById'];
}> {
const dto = await this.apiClient.getLeagueProtests(leagueId);
return {
protests: dto.protests.map(protest => new ProtestViewModel(protest)),
racesById: dto.racesById,
driversById: dto.driversById,
};
}
/**
* Get a single protest by ID from league protests
*/
async getProtestById(leagueId: string, protestId: string): Promise<{
protest: ProtestViewModel;
race: RaceViewModel;
protestingDriver: ProtestDriverViewModel;
accusedDriver: ProtestDriverViewModel;
} | null> {
const dto = await this.apiClient.getLeagueProtest(leagueId, protestId);
const protest = dto.protests[0];
if (!protest) return null;
const race = Object.values(dto.racesById)[0];
if (!race) return null;
// Cast to the correct type for indexing
const driversById = dto.driversById as unknown as Record<string, DriverDTO>;
const protestingDriver = driversById[protest.protestingDriverId];
const accusedDriver = driversById[protest.accusedDriverId];
if (!protestingDriver || !accusedDriver) return null;
return {
protest: new ProtestViewModel(protest),
race: new RaceViewModel(race),
protestingDriver: new ProtestDriverViewModel(protestingDriver),
accusedDriver: new ProtestDriverViewModel(accusedDriver),
};
}
/**
* Apply a penalty
*/
async applyPenalty(input: ApplyPenaltyCommandDTO): Promise<void> {
await this.apiClient.applyPenalty(input);
}
/**
* Request protest defense
*/
async requestDefense(input: RequestProtestDefenseCommandDTO): Promise<void> {
await this.apiClient.requestDefense(input);
}
/**
* Review protest
*/
async reviewProtest(input: { protestId: string; stewardId: string; decision: string; decisionNotes: string }): Promise<void> {
const normalizedDecision =
input.decision.toLowerCase() === 'upheld' ? 'uphold' : input.decision.toLowerCase();
const command: ReviewProtestCommandDTO = {
protestId: input.protestId,
stewardId: input.stewardId,
decision: normalizedDecision,
decisionNotes: input.decisionNotes,
};
await this.apiClient.reviewProtest(command);
}
/**
* Find protests by race ID
*/
async findByRaceId(raceId: string): Promise<any[]> {
const dto = await this.apiClient.getRaceProtests(raceId);
return dto.protests;
}
/**
* Validate file protest input
* @throws Error with descriptive message if validation fails
*/
validateFileProtestInput(input: FileProtestInput): void {
if (!input.accusedDriverId) {
throw new Error('Please select the driver you are protesting against.');
}
if (!input.lap || parseInt(input.lap, 10) < 0) {
throw new Error('Please enter a valid lap number.');
}
if (!input.description.trim()) {
throw new Error('Please describe what happened.');
}
}
/**
* Construct file protest command from input
*/
constructFileProtestCommand(input: FileProtestInput): FileProtestCommandDTO {
this.validateFileProtestInput(input);
const incident: ProtestIncidentDTO = {
lap: parseInt(input.lap, 10),
description: input.description.trim(),
...(input.timeInRace ? { timeInRace: parseInt(input.timeInRace, 10) } : {}),
};
const command: FileProtestCommandDTO = {
raceId: input.raceId,
protestingDriverId: input.protestingDriverId,
accusedDriverId: input.accusedDriverId,
incident,
...(input.comment?.trim() ? { comment: input.comment.trim() } : {}),
...(input.proofVideoUrl?.trim() ? { proofVideoUrl: input.proofVideoUrl.trim() } : {}),
};
return command;
}
}

View File

@@ -1,10 +1,10 @@
import { describe, it, expect, vi, Mocked } from 'vitest';
import { RaceResultsService } from './RaceResultsService';
import { RacesApiClient } from '../../api/races/RacesApiClient';
import { RaceResultsDetailViewModel } from '../../view-models/RaceResultsDetailViewModel';
import { RaceWithSOFViewModel } from '../../view-models/RaceWithSOFViewModel';
import { ImportRaceResultsSummaryViewModel } from '../../view-models/ImportRaceResultsSummaryViewModel';
import type { RaceResultsDetailDTO, RaceWithSOFDTO } from '../../types/generated';
import { RacesApiClient } from '@/lib/api/races/RacesApiClient';
import { RaceResultsDetailViewModel } from '@/lib/view-models/RaceResultsDetailViewModel';
import { RaceWithSOFViewModel } from '@/lib/view-models/RaceWithSOFViewModel';
import { ImportRaceResultsSummaryViewModel } from '@/lib/view-models/ImportRaceResultsSummaryViewModel';
import type { RaceResultsDetailDTO, RaceWithSOFDTO } from '@/lib/types/generated';
describe('RaceResultsService', () => {
let mockApiClient: Mocked<RacesApiClient>;

View File

@@ -1,178 +0,0 @@
import { RacesApiClient } from '../../api/races/RacesApiClient';
import { RaceResultsDetailViewModel } from '../../view-models/RaceResultsDetailViewModel';
import { RaceWithSOFViewModel } from '../../view-models/RaceWithSOFViewModel';
import { ImportRaceResultsSummaryViewModel } from '../../view-models/ImportRaceResultsSummaryViewModel';
import type { ImportRaceResultsDTO } from '../../types/generated/ImportRaceResultsDTO';
import { v4 as uuidv4 } from 'uuid';
// Define types
type ImportRaceResultsInputDto = ImportRaceResultsDTO;
type ImportRaceResultsSummaryDto = {
success: boolean;
raceId: string;
driversProcessed: number;
resultsRecorded: number;
errors?: string[];
};
export interface ImportResultRowDTO {
id: string;
raceId: string;
driverId: string;
position: number;
fastestLap: number;
incidents: number;
startPosition: number;
}
export interface CSVRow {
driverId: string;
position: number;
fastestLap: number;
incidents: number;
startPosition: number;
}
/**
* Race Results Service
*
* Orchestrates race results operations including viewing, importing, and SOF calculations.
* All dependencies are injected via constructor.
*/
export class RaceResultsService {
constructor(
private readonly apiClient: RacesApiClient
) {}
/**
* Get race results detail with view model transformation
*/
async getResultsDetail(raceId: string, currentUserId?: string): Promise<RaceResultsDetailViewModel> {
const dto = await this.apiClient.getResultsDetail(raceId);
return new RaceResultsDetailViewModel(dto, currentUserId || '');
}
/**
* Get race with strength of field calculation
*/
async getWithSOF(raceId: string): Promise<RaceWithSOFViewModel> {
const dto = await this.apiClient.getWithSOF(raceId);
return new RaceWithSOFViewModel(dto);
}
/**
* Import race results and get summary
*/
async importResults(raceId: string, input: ImportRaceResultsInputDto): Promise<ImportRaceResultsSummaryViewModel> {
const dto = await this.apiClient.importResults(raceId, input);
return new ImportRaceResultsSummaryViewModel(dto);
}
/**
* Parse CSV content and validate results
* @throws Error with descriptive message if validation fails
*/
parseCSV(content: string): CSVRow[] {
const lines = content.trim().split('\n');
if (lines.length < 2) {
throw new Error('CSV file is empty or invalid');
}
const headerLine = lines[0]!;
const header = headerLine.toLowerCase().split(',').map((h) => h.trim());
const requiredFields = ['driverid', 'position', 'fastestlap', 'incidents', 'startposition'];
for (const field of requiredFields) {
if (!header.includes(field)) {
throw new Error(`Missing required field: ${field}`);
}
}
const rows: CSVRow[] = [];
for (let i = 1; i < lines.length; i++) {
const line = lines[i];
if (!line) {
continue;
}
const values = line.split(',').map((v) => v.trim());
if (values.length !== header.length) {
throw new Error(
`Invalid row ${i}: expected ${header.length} columns, got ${values.length}`,
);
}
const row: Record<string, string> = {};
header.forEach((field, index) => {
row[field] = values[index] ?? '';
});
const driverId = row['driverid'] ?? '';
const position = parseInt(row['position'] ?? '', 10);
const fastestLap = parseFloat(row['fastestlap'] ?? '');
const incidents = parseInt(row['incidents'] ?? '', 10);
const startPosition = parseInt(row['startposition'] ?? '', 10);
if (!driverId || driverId.length === 0) {
throw new Error(`Row ${i}: driverId is required`);
}
if (Number.isNaN(position) || position < 1) {
throw new Error(`Row ${i}: position must be a positive integer`);
}
if (Number.isNaN(fastestLap) || fastestLap < 0) {
throw new Error(`Row ${i}: fastestLap must be a non-negative number`);
}
if (Number.isNaN(incidents) || incidents < 0) {
throw new Error(`Row ${i}: incidents must be a non-negative integer`);
}
if (Number.isNaN(startPosition) || startPosition < 1) {
throw new Error(`Row ${i}: startPosition must be a positive integer`);
}
rows.push({ driverId, position, fastestLap, incidents, startPosition });
}
const positions = rows.map((r) => r.position);
const uniquePositions = new Set(positions);
if (positions.length !== uniquePositions.size) {
throw new Error('Duplicate positions found in CSV');
}
const driverIds = rows.map((r) => r.driverId);
const uniqueDrivers = new Set(driverIds);
if (driverIds.length !== uniqueDrivers.size) {
throw new Error('Duplicate driver IDs found in CSV');
}
return rows;
}
/**
* Transform parsed CSV rows into ImportResultRowDTO array
*/
transformToImportResults(rows: CSVRow[], raceId: string): ImportResultRowDTO[] {
return rows.map((row) => ({
id: uuidv4(),
raceId,
driverId: row.driverId,
position: row.position,
fastestLap: row.fastestLap,
incidents: row.incidents,
startPosition: row.startPosition,
}));
}
/**
* Parse CSV file content and transform to import results
* @throws Error with descriptive message if parsing or validation fails
*/
parseAndTransformCSV(content: string, raceId: string): ImportResultRowDTO[] {
const rows = this.parseCSV(content);
return this.transformToImportResults(rows, raceId);
}
}

View File

@@ -1,167 +0,0 @@
import { RacesApiClient } from '../../api/races/RacesApiClient';
import { RaceDetailEntryViewModel } from '../../view-models/RaceDetailEntryViewModel';
import { RaceDetailUserResultViewModel } from '../../view-models/RaceDetailUserResultViewModel';
import { RaceDetailViewModel } from '../../view-models/RaceDetailViewModel';
import { RacesPageViewModel } from '../../view-models/RacesPageViewModel';
import { RaceStatsViewModel } from '../../view-models/RaceStatsViewModel';
import type { RaceDetailsViewModel } from '../../view-models/RaceDetailsViewModel';
import type { FileProtestCommandDTO } from '../../types/generated/FileProtestCommandDTO';
import type { RaceStatsDTO } from '../../types/generated/RaceStatsDTO';
/**
* Race Service
*
* Orchestrates race operations by coordinating API calls and view model creation.
* All dependencies are injected via constructor.
*/
export class RaceService {
constructor(
private readonly apiClient: RacesApiClient
) {}
/**
* Get race detail with view model transformation
*/
async getRaceDetail(
raceId: string,
driverId: string
): Promise<RaceDetailViewModel> {
const dto = await this.apiClient.getDetail(raceId, driverId);
return new RaceDetailViewModel(dto, driverId);
}
/**
* Get race details for pages/components (DTO-free shape)
*/
async getRaceDetails(
raceId: string,
driverId: string
): Promise<RaceDetailsViewModel> {
const dto: any = await this.apiClient.getDetail(raceId, driverId);
const raceDto: any = dto?.race ?? null;
const leagueDto: any = dto?.league ?? null;
const registrationDto: any = dto?.registration ?? {};
const isUserRegistered = Boolean(registrationDto.isUserRegistered ?? registrationDto.isRegistered ?? false);
const canRegister = Boolean(registrationDto.canRegister);
const status = String(raceDto?.status ?? '');
const canReopenRace = status === 'completed' || status === 'cancelled';
return {
race: raceDto
? {
id: String(raceDto.id ?? ''),
track: String(raceDto.track ?? ''),
car: String(raceDto.car ?? ''),
scheduledAt: String(raceDto.scheduledAt ?? ''),
status,
sessionType: String(raceDto.sessionType ?? ''),
}
: null,
league: leagueDto
? {
id: String(leagueDto.id ?? ''),
name: String(leagueDto.name ?? ''),
description: leagueDto.description ?? null,
settings: leagueDto.settings,
}
: null,
entryList: (dto?.entryList ?? []).map((entry: any) => new RaceDetailEntryViewModel(entry, driverId)),
registration: {
canRegister,
isUserRegistered,
},
userResult: dto?.userResult ? new RaceDetailUserResultViewModel(dto.userResult) : null,
canReopenRace,
error: dto?.error,
};
}
/**
* Get races page data with view model transformation
*/
async getRacesPageData(): Promise<RacesPageViewModel> {
const dto = await this.apiClient.getPageData();
return new RacesPageViewModel(dto);
}
/**
* Get races page data filtered by league
*/
async getLeagueRacesPageData(leagueId: string): Promise<RacesPageViewModel> {
const dto = await this.apiClient.getPageData(leagueId);
return new RacesPageViewModel(dto);
}
/**
* Get all races page data with view model transformation
* Currently same as getRacesPageData, but can be extended for different filtering
*/
async getAllRacesPageData(): Promise<RacesPageViewModel> {
const dto = await this.apiClient.getPageData();
return new RacesPageViewModel(dto);
}
/**
* Get total races statistics with view model transformation
*/
async getRacesTotal(): Promise<RaceStatsViewModel> {
const dto: RaceStatsDTO = await this.apiClient.getTotal();
return new RaceStatsViewModel(dto);
}
/**
* Register for a race
*/
async registerForRace(raceId: string, leagueId: string, driverId: string): Promise<void> {
await this.apiClient.register(raceId, { raceId, leagueId, driverId });
}
/**
* Withdraw from a race
*/
async withdrawFromRace(raceId: string, driverId: string): Promise<void> {
await this.apiClient.withdraw(raceId, { raceId, driverId });
}
/**
* Cancel a race
*/
async cancelRace(raceId: string): Promise<void> {
await this.apiClient.cancel(raceId);
}
/**
* Complete a race
*/
async completeRace(raceId: string): Promise<void> {
await this.apiClient.complete(raceId);
}
/**
* Re-open a race
*/
async reopenRace(raceId: string): Promise<void> {
await this.apiClient.reopen(raceId);
}
/**
* File a protest
*/
async fileProtest(input: FileProtestCommandDTO): Promise<void> {
await this.apiClient.fileProtest(input);
}
/**
* Find races by league ID
*
* The races API does not currently expose a league-filtered listing endpoint in this build,
* so this method deliberately signals that the operation is unavailable instead of making
* assumptions about URL structure.
*/
async findByLeagueId(leagueId: string): Promise<RacesPageViewModel['races']> {
const page = await this.getLeagueRacesPageData(leagueId);
return page.races;
}
}

View File

@@ -1,9 +1,9 @@
import { describe, it, expect, vi, Mocked } from 'vitest';
import { RaceStewardingService } from './RaceStewardingService';
import { RacesApiClient } from '../../api/races/RacesApiClient';
import { ProtestsApiClient } from '../../api/protests/ProtestsApiClient';
import { PenaltiesApiClient } from '../../api/penalties/PenaltiesApiClient';
import { RaceStewardingViewModel } from '../../view-models/RaceStewardingViewModel';
import { RacesApiClient } from '@/lib/api/races/RacesApiClient';
import { ProtestsApiClient } from '@/lib/api/protests/ProtestsApiClient';
import { PenaltiesApiClient } from '@/lib/api/penalties/PenaltiesApiClient';
import { RaceStewardingViewModel } from '@/lib/view-models/RaceStewardingViewModel';
describe('RaceStewardingService', () => {
let mockRacesApiClient: Mocked<RacesApiClient>;

View File

@@ -1,70 +0,0 @@
import { RacesApiClient } from '../../api/races/RacesApiClient';
import { ProtestsApiClient } from '../../api/protests/ProtestsApiClient';
import { PenaltiesApiClient } from '../../api/penalties/PenaltiesApiClient';
import { RaceStewardingViewModel } from '../../view-models/RaceStewardingViewModel';
/**
* Race Stewarding Service
*
* Orchestrates race stewarding operations by coordinating API calls for race details,
* protests, and penalties, and returning a unified view model.
*/
export class RaceStewardingService {
constructor(
private readonly racesApiClient: RacesApiClient,
private readonly protestsApiClient: ProtestsApiClient,
private readonly penaltiesApiClient: PenaltiesApiClient
) {}
/**
* Get race stewarding data with view model transformation
*/
async getRaceStewardingData(raceId: string, driverId: string): Promise<RaceStewardingViewModel> {
// Fetch all data in parallel
const [raceDetail, protests, penalties] = await Promise.all([
this.racesApiClient.getDetail(raceId, driverId),
this.protestsApiClient.getRaceProtests(raceId),
this.penaltiesApiClient.getRacePenalties(raceId),
]);
// Convert API responses to match RaceStewardingViewModel expectations
const convertedProtests = {
protests: protests.protests.map(p => ({
id: p.id,
protestingDriverId: p.protestingDriverId,
accusedDriverId: p.accusedDriverId,
incident: {
lap: p.lap,
description: p.description
},
filedAt: p.filedAt,
status: p.status
})),
driverMap: Object.entries(protests.driverMap).reduce((acc, [id, name]) => {
acc[id] = { id, name: name as string };
return acc;
}, {} as Record<string, { id: string; name: string }>)
};
const convertedPenalties = {
penalties: penalties.penalties.map(p => ({
id: p.id,
driverId: p.driverId,
type: p.type,
value: p.value,
reason: p.reason,
notes: p.notes
})),
driverMap: Object.entries(penalties.driverMap).reduce((acc, [id, name]) => {
acc[id] = { id, name: name as string };
return acc;
}, {} as Record<string, { id: string; name: string }>)
};
return new RaceStewardingViewModel({
raceDetail,
protests: convertedProtests,
penalties: convertedPenalties,
});
}
}

View File

@@ -1,9 +1,9 @@
import { describe, it, expect, vi, Mocked } from 'vitest';
import { SponsorService } from './SponsorService';
import { SponsorsApiClient } from '../../api/sponsors/SponsorsApiClient';
import { SponsorViewModel } from '../../view-models/SponsorViewModel';
import { SponsorDashboardViewModel } from '../../view-models/SponsorDashboardViewModel';
import { SponsorSponsorshipsViewModel } from '../../view-models/SponsorSponsorshipsViewModel';
import { SponsorsApiClient } from '@/lib/api/sponsors/SponsorsApiClient';
import { SponsorViewModel } from '@/lib/view-models/SponsorViewModel';
import { SponsorDashboardViewModel } from '@/lib/view-models/SponsorDashboardViewModel';
import { SponsorSponsorshipsViewModel } from '@/lib/view-models/SponsorSponsorshipsViewModel';
describe('SponsorService', () => {
let mockApiClient: Mocked<SponsorsApiClient>;

View File

@@ -1,109 +0,0 @@
import type { SponsorsApiClient } from '../../api/sponsors/SponsorsApiClient';
import { SponsorViewModel } from '../../view-models/SponsorViewModel';
import { SponsorDashboardViewModel } from '../../view-models/SponsorDashboardViewModel';
import { SponsorSponsorshipsViewModel } from '../../view-models/SponsorSponsorshipsViewModel';
import type { CreateSponsorInputDTO } from '../../types/generated/CreateSponsorInputDTO';
import type { SponsorDTO } from '../../types/generated/SponsorDTO';
/**
* Sponsor Service
*
* Orchestrates sponsor operations by coordinating API calls and view model creation.
* All dependencies are injected via constructor.
*/
export class SponsorService {
constructor(
private readonly apiClient: SponsorsApiClient
) {}
/**
* Get all sponsors with view model transformation
*/
async getAllSponsors(): Promise<SponsorViewModel[]> {
const dto = await this.apiClient.getAll();
return (dto?.sponsors || []).map((sponsor: SponsorDTO) => new SponsorViewModel(sponsor));
}
/**
* Get sponsor dashboard with view model transformation
*/
async getSponsorDashboard(sponsorId: string): Promise<SponsorDashboardViewModel | null> {
const dto = await this.apiClient.getDashboard(sponsorId);
if (!dto) {
return null;
}
return new SponsorDashboardViewModel(dto);
}
/**
* Get sponsor sponsorships with view model transformation
*/
async getSponsorSponsorships(sponsorId: string): Promise<SponsorSponsorshipsViewModel | null> {
const dto = await this.apiClient.getSponsorships(sponsorId);
if (!dto) {
return null;
}
return new SponsorSponsorshipsViewModel(dto);
}
/**
* Create a new sponsor
*/
async createSponsor(input: CreateSponsorInputDTO): Promise<any> {
return await this.apiClient.create(input);
}
/**
* Get sponsorship pricing
*/
async getSponsorshipPricing(): Promise<any> {
return await this.apiClient.getPricing();
}
/**
* Get sponsor billing information
*/
async getBilling(sponsorId: string): Promise<{
paymentMethods: any[];
invoices: any[];
stats: any;
}> {
return await this.apiClient.getBilling(sponsorId);
}
/**
* Get available leagues for sponsorship
*/
async getAvailableLeagues(): Promise<any[]> {
return await this.apiClient.getAvailableLeagues();
}
/**
* Get detailed league information
*/
async getLeagueDetail(leagueId: string): Promise<{
league: any;
drivers: any[];
races: any[];
}> {
return await this.apiClient.getLeagueDetail(leagueId);
}
/**
* Get sponsor settings
*/
async getSettings(sponsorId: string): Promise<{
profile: any;
notifications: any;
privacy: any;
}> {
return await this.apiClient.getSettings(sponsorId);
}
/**
* Update sponsor settings
*/
async updateSettings(sponsorId: string, input: any): Promise<void> {
return await this.apiClient.updateSettings(sponsorId, input);
}
}

View File

@@ -1,8 +1,8 @@
import { describe, it, expect, vi, Mocked } from 'vitest';
import { SponsorshipService } from './SponsorshipService';
import { SponsorsApiClient } from '../../api/sponsors/SponsorsApiClient';
import { SponsorshipPricingViewModel } from '../../view-models/SponsorshipPricingViewModel';
import { SponsorSponsorshipsViewModel } from '../../view-models/SponsorSponsorshipsViewModel';
import { SponsorsApiClient } from '@/lib/api/sponsors/SponsorsApiClient';
import { SponsorshipPricingViewModel } from '@/lib/view-models/SponsorshipPricingViewModel';
import { SponsorSponsorshipsViewModel } from '@/lib/view-models/SponsorSponsorshipsViewModel';
describe('SponsorshipService', () => {
let mockApiClient: Mocked<SponsorsApiClient>;

View File

@@ -1,72 +0,0 @@
import type { SponsorsApiClient } from '../../api/sponsors/SponsorsApiClient';
import { SponsorshipPricingViewModel } from '../../view-models/SponsorshipPricingViewModel';
import { SponsorSponsorshipsViewModel } from '../../view-models/SponsorSponsorshipsViewModel';
import { SponsorshipRequestViewModel } from '../../view-models/SponsorshipRequestViewModel';
import type { GetPendingSponsorshipRequestsOutputDTO } from '../../types/generated/GetPendingSponsorshipRequestsOutputDTO';
import type { SponsorshipRequestDTO } from '../../types/generated/SponsorshipRequestDTO';
/**
* Sponsorship Service
*
* Orchestrates sponsorship operations by coordinating API calls and view model creation.
* All dependencies are injected via constructor.
*/
export class SponsorshipService {
constructor(
private readonly apiClient: SponsorsApiClient
) {}
/**
* Get sponsorship pricing with view model transformation
*/
async getSponsorshipPricing(): Promise<SponsorshipPricingViewModel> {
// Pricing shape isn't finalized in the API yet.
// Keep a predictable, UI-friendly structure until a dedicated DTO is introduced.
const dto = await this.apiClient.getPricing();
const main =
dto.pricing.find((p) => p.entityType === 'league' || p.entityType === 'main')?.price ?? 0;
const secondary =
dto.pricing.find((p) => p.entityType === 'driver' || p.entityType === 'secondary')?.price ?? 0;
return new SponsorshipPricingViewModel({
mainSlotPrice: main,
secondarySlotPrice: secondary,
currency: 'USD',
});
}
/**
* Get sponsor sponsorships with view model transformation
*/
async getSponsorSponsorships(sponsorId: string): Promise<SponsorSponsorshipsViewModel | null> {
const dto = await this.apiClient.getSponsorships(sponsorId);
if (!dto) {
return null;
}
return new SponsorSponsorshipsViewModel(dto);
}
/**
* Get pending sponsorship requests for an entity
*/
async getPendingSponsorshipRequests(params: { entityType: string; entityId: string }): Promise<SponsorshipRequestViewModel[]> {
const dto = (await this.apiClient.getPendingSponsorshipRequests(params)) as unknown as GetPendingSponsorshipRequestsOutputDTO;
const requests = (dto as any).requests as SponsorshipRequestDTO[];
return (requests ?? []).map((r: SponsorshipRequestDTO) => new SponsorshipRequestViewModel(r));
}
/**
* Accept a sponsorship request
*/
async acceptSponsorshipRequest(requestId: string, respondedBy: string): Promise<void> {
await this.apiClient.acceptSponsorshipRequest(requestId, { respondedBy });
}
/**
* Reject a sponsorship request
*/
async rejectSponsorshipRequest(requestId: string, respondedBy: string, reason?: string): Promise<void> {
await this.apiClient.rejectSponsorshipRequest(requestId, { respondedBy, ...(reason ? { reason } : {}) });
}
}

View File

@@ -1,6 +1,6 @@
import { describe, it, expect, vi } from 'vitest';
import { TeamJoinService } from './TeamJoinService';
import type { TeamsApiClient } from '../../api/teams/TeamsApiClient';
import type { TeamsApiClient } from '@/lib/api/teams/TeamsApiClient';
describe('TeamJoinService', () => {
let service: TeamJoinService;

View File

@@ -1,49 +0,0 @@
import { TeamJoinRequestViewModel } from '@/lib/view-models/TeamJoinRequestViewModel';
import type { TeamsApiClient } from '../../api/teams/TeamsApiClient';
import type { TeamJoinRequestDTO } from '../../types/generated/TeamJoinRequestDTO';
// Wrapper for the team join requests collection returned by the teams API in this build
// Mirrors the current API response shape until a generated DTO is available.
type TeamJoinRequestsDto = {
requests: TeamJoinRequestDTO[];
};
/**
* Team Join Service
*
* Orchestrates team join/leave operations by coordinating API calls and view model creation.
* All dependencies are injected via constructor.
*/
export class TeamJoinService {
constructor(
private readonly apiClient: TeamsApiClient
) {}
/**
* Get team join requests with view model transformation
*/
async getJoinRequests(teamId: string, currentUserId: string, isOwner: boolean): Promise<TeamJoinRequestViewModel[]> {
const dto = await this.apiClient.getJoinRequests(teamId) as TeamJoinRequestsDto | null;
return (dto?.requests || []).map((r: TeamJoinRequestDTO) => new TeamJoinRequestViewModel(r, currentUserId, isOwner));
}
/**
* Approve a team join request
*
* The teams API currently exposes read-only join requests in this build; approving
* a request requires a future management endpoint, so this method fails explicitly.
*/
async approveJoinRequest(): Promise<never> {
throw new Error('Not implemented: API endpoint for approving join requests');
}
/**
* Reject a team join request
*
* Rejection of join requests is also not available yet on the backend, so callers
* must treat this as an unsupported operation rather than a silent no-op.
*/
async rejectJoinRequest(): Promise<never> {
throw new Error('Not implemented: API endpoint for rejecting join requests');
}
}

View File

@@ -1,9 +1,9 @@
import { describe, it, expect, vi, Mocked } from 'vitest';
import { TeamService } from './TeamService';
import { TeamsApiClient } from '../../api/teams/TeamsApiClient';
import { TeamSummaryViewModel } from '../../view-models/TeamSummaryViewModel';
import { TeamDetailsViewModel } from '../../view-models/TeamDetailsViewModel';
import { TeamMemberViewModel } from '../../view-models/TeamMemberViewModel';
import { TeamsApiClient } from '@/lib/api/teams/TeamsApiClient';
import { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
import { TeamDetailsViewModel } from '@/lib/view-models/TeamDetailsViewModel';
import { TeamMemberViewModel } from '@/lib/view-models/TeamMemberViewModel';
describe('TeamService', () => {
let mockApiClient: Mocked<TeamsApiClient>;

View File

@@ -1,113 +0,0 @@
import { TeamDetailsViewModel } from '@/lib/view-models/TeamDetailsViewModel';
import { TeamMemberViewModel } from '@/lib/view-models/TeamMemberViewModel';
import { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
import { CreateTeamViewModel } from '@/lib/view-models/CreateTeamViewModel';
import { UpdateTeamViewModel } from '@/lib/view-models/UpdateTeamViewModel';
import { DriverTeamViewModel } from '@/lib/view-models/DriverTeamViewModel';
import type { TeamsApiClient } from '../../api/teams/TeamsApiClient';
import type { GetAllTeamsOutputDTO } from '@/lib/types/generated/GetAllTeamsOutputDTO';
import type { TeamListItemDTO } from '@/lib/types/generated/TeamListItemDTO';
import type { GetTeamDetailsOutputDTO } from '@/lib/types/generated/GetTeamDetailsOutputDTO';
import type { GetTeamMembersOutputDTO } from '@/lib/types/generated/GetTeamMembersOutputDTO';
import type { TeamMemberDTO } from '@/lib/types/generated/TeamMemberDTO';
import type { CreateTeamInputDTO } from '@/lib/types/generated/CreateTeamInputDTO';
import type { CreateTeamOutputDTO } from '@/lib/types/generated/CreateTeamOutputDTO';
import type { UpdateTeamInputDTO } from '@/lib/types/generated/UpdateTeamInputDTO';
import type { UpdateTeamOutputDTO } from '@/lib/types/generated/UpdateTeamOutputDTO';
import type { GetDriverTeamOutputDTO } from '@/lib/types/generated/GetDriverTeamOutputDTO';
import type { GetTeamMembershipOutputDTO } from '@/lib/types/generated/GetTeamMembershipOutputDTO';
/**
* Team Service
*
* Orchestrates team operations by coordinating API calls and view model creation.
* All dependencies are injected via constructor.
*/
export class TeamService {
constructor(
private readonly apiClient: TeamsApiClient
) {}
/**
* Get all teams with view model transformation
*/
async getAllTeams(): Promise<TeamSummaryViewModel[]> {
const dto: GetAllTeamsOutputDTO | null = await this.apiClient.getAll();
return (dto?.teams || []).map((team: TeamListItemDTO) => new TeamSummaryViewModel(team));
}
/**
* Get team details with view model transformation
*/
async getTeamDetails(teamId: string, currentUserId: string): Promise<TeamDetailsViewModel | null> {
const dto: GetTeamDetailsOutputDTO | null = await this.apiClient.getDetails(teamId);
if (!dto) {
return null;
}
return new TeamDetailsViewModel(dto, currentUserId);
}
/**
* Get team members with view model transformation
*/
async getTeamMembers(teamId: string, currentUserId: string, teamOwnerId: string): Promise<TeamMemberViewModel[]> {
const dto: GetTeamMembersOutputDTO = await this.apiClient.getMembers(teamId);
return dto.members.map((member: TeamMemberDTO) => new TeamMemberViewModel(member, currentUserId, teamOwnerId));
}
/**
* Create a new team with view model transformation
*/
async createTeam(input: CreateTeamInputDTO): Promise<CreateTeamViewModel> {
const dto: CreateTeamOutputDTO = await this.apiClient.create(input);
return new CreateTeamViewModel(dto);
}
/**
* Update team with view model transformation
*/
async updateTeam(teamId: string, input: UpdateTeamInputDTO): Promise<UpdateTeamViewModel> {
const dto: UpdateTeamOutputDTO = await this.apiClient.update(teamId, input);
return new UpdateTeamViewModel(dto);
}
/**
* Get driver's team with view model transformation
*/
async getDriverTeam(driverId: string): Promise<DriverTeamViewModel | null> {
const dto: GetDriverTeamOutputDTO | null = await this.apiClient.getDriverTeam(driverId);
return dto ? new DriverTeamViewModel(dto) : null;
}
/**
* Get team membership for a driver
*/
async getMembership(teamId: string, driverId: string): Promise<GetTeamMembershipOutputDTO | null> {
return this.apiClient.getMembership(teamId, driverId);
}
/**
* Remove a driver from the team
*
* The backend does not yet expose a dedicated endpoint for removing team memberships,
* so this method fails explicitly to avoid silently ignoring removal requests.
*/
async removeMembership(teamId: string, driverId: string): Promise<void> {
void teamId;
void driverId;
throw new Error('Team membership removal is not supported in this build');
}
/**
* Update team membership role
*
* Role updates for team memberships are not supported by the current API surface;
* callers must treat this as an unavailable operation.
*/
async updateMembership(teamId: string, driverId: string, role: string): Promise<void> {
void teamId;
void driverId;
void role;
throw new Error('Team membership role updates are not supported in this build');
}
}