website refactor
This commit is contained in:
@@ -1,8 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { AdminViewModelService } from './AdminViewModelService';
|
||||
|
||||
describe('AdminViewModelService', () => {
|
||||
it('should be defined', () => {
|
||||
expect(AdminViewModelService).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, it, expect, vi, Mocked } from 'vitest';
|
||||
import { LeagueMembershipService } from './LeagueMembershipService';
|
||||
import { LeaguesApiClient } from '../../api/leagues/LeaguesApiClient';
|
||||
import { LeagueMemberViewModel } from '../../view-models/LeagueMemberViewModel';
|
||||
import { LeaguesApiClient } from '@/lib/api/leagues/LeaguesApiClient';
|
||||
import { LeagueMemberViewModel } from '@/lib/view-models/LeagueMemberViewModel';
|
||||
|
||||
describe('LeagueMembershipService', () => {
|
||||
let mockApiClient: Mocked<LeaguesApiClient>;
|
||||
|
||||
@@ -11,7 +11,7 @@ function getDefaultLeaguesApiClient(): LeaguesApiClient {
|
||||
if (cachedLeaguesApiClient) return cachedLeaguesApiClient;
|
||||
|
||||
const api = new ApiClient(getWebsiteApiBaseUrl());
|
||||
cachedLeaguesApiClient = (api as any).leagues as LeaguesApiClient;
|
||||
cachedLeaguesApiClient = api.leagues;
|
||||
return cachedLeaguesApiClient;
|
||||
}
|
||||
|
||||
@@ -27,12 +27,12 @@ export class LeagueMembershipService {
|
||||
|
||||
async getLeagueMemberships(leagueId: string, currentUserId: string): Promise<LeagueMemberViewModel[]> {
|
||||
const dto = await this.getClient().getMemberships(leagueId);
|
||||
const members: LeagueMemberDTO[] = ((dto as any)?.members ?? (dto as any)?.memberships ?? []) as LeagueMemberDTO[];
|
||||
const members: LeagueMemberDTO[] = dto.members ?? [];
|
||||
return members.map((m) => new LeagueMemberViewModel(m, currentUserId));
|
||||
}
|
||||
|
||||
async removeMember(leagueId: string, performerDriverId: string, targetDriverId: string): Promise<{ success: boolean }> {
|
||||
return this.getClient().removeMember(leagueId, performerDriverId, targetDriverId) as unknown as { success: boolean };
|
||||
return this.getClient().removeMember(leagueId, performerDriverId, targetDriverId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -57,11 +57,11 @@ export class LeagueMembershipService {
|
||||
static async fetchLeagueMemberships(leagueId: string): Promise<LeagueMembership[]> {
|
||||
try {
|
||||
const result = await getDefaultLeaguesApiClient().getMemberships(leagueId);
|
||||
const memberships: LeagueMembership[] = ((result as any)?.members ?? []).map((member: any) => ({
|
||||
const memberships: LeagueMembership[] = (result.members ?? []).map((member) => ({
|
||||
id: `${member.driverId}-${leagueId}`, // Generate ID since API doesn't provide it
|
||||
leagueId,
|
||||
driverId: member.driverId,
|
||||
role: member.role,
|
||||
role: member.role as 'owner' | 'admin' | 'steward' | 'member',
|
||||
status: 'active', // Assume active since API returns current members
|
||||
joinedAt: member.joinedAt,
|
||||
}));
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { describe, it, expect, vi, Mocked, beforeEach, afterEach } from 'vitest';
|
||||
import { describe, it, expect, vi, Mocked, beforeEach } from 'vitest';
|
||||
import { LeagueService } from './LeagueService';
|
||||
import { LeaguesApiClient } from '../../api/leagues/LeaguesApiClient';
|
||||
import { LeagueStandingsViewModel } from '../../view-models/LeagueStandingsViewModel';
|
||||
import { LeagueStatsViewModel } from '../../view-models/LeagueStatsViewModel';
|
||||
import { LeagueScheduleViewModel } from '../../view-models/LeagueScheduleViewModel';
|
||||
import { LeagueMembershipsViewModel } from '../../view-models/LeagueMembershipsViewModel';
|
||||
import { RemoveMemberViewModel } from '../../view-models/RemoveMemberViewModel';
|
||||
import { LeagueMemberViewModel } from '../../view-models/LeagueMemberViewModel';
|
||||
import type { CreateLeagueInputDTO } from '../../types/generated/CreateLeagueInputDTO';
|
||||
import type { CreateLeagueOutputDTO } from '../../types/generated/CreateLeagueOutputDTO';
|
||||
import type { RemoveLeagueMemberOutputDTO } from '../../types/generated/RemoveLeagueMemberOutputDTO';
|
||||
import { LeaguesApiClient } from '@/lib/api/leagues/LeaguesApiClient';
|
||||
import { LeagueStandingsViewModel } from '@/lib/view-models/LeagueStandingsViewModel';
|
||||
import { LeagueStatsViewModel } from '@/lib/view-models/LeagueStatsViewModel';
|
||||
import { LeagueScheduleViewModel } from '@/lib/view-models/LeagueScheduleViewModel';
|
||||
import { LeagueMembershipsViewModel } from '@/lib/view-models/LeagueMembershipsViewModel';
|
||||
import { RemoveMemberViewModel } from '@/lib/view-models/RemoveMemberViewModel';
|
||||
import { LeagueMemberViewModel } from '@/lib/view-models/LeagueMemberViewModel';
|
||||
import type { CreateLeagueInputDTO } from '@/lib/types/generated/CreateLeagueInputDTO';
|
||||
import type { CreateLeagueOutputDTO } from '@/lib/types/generated/CreateLeagueOutputDTO';
|
||||
import type { RemoveLeagueMemberOutputDTO } from '@/lib/types/generated/RemoveLeagueMemberOutputDTO';
|
||||
|
||||
describe('LeagueService', () => {
|
||||
let mockApiClient: Mocked<LeaguesApiClient>;
|
||||
@@ -114,14 +114,7 @@ describe('LeagueService', () => {
|
||||
});
|
||||
|
||||
describe('getLeagueSchedule', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should call apiClient.getSchedule and return LeagueScheduleViewModel with Date parsing', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2025-01-01T00:00:00Z'));
|
||||
|
||||
const leagueId = 'league-123';
|
||||
const mockDto = {
|
||||
races: [
|
||||
@@ -136,44 +129,7 @@ describe('LeagueService', () => {
|
||||
|
||||
expect(mockApiClient.getSchedule).toHaveBeenCalledWith(leagueId);
|
||||
expect(result).toBeInstanceOf(LeagueScheduleViewModel);
|
||||
|
||||
expect(result.raceCount).toBe(2);
|
||||
expect(result.races[0]!.scheduledAt).toBeInstanceOf(Date);
|
||||
expect(result.races[0]!.isPast).toBe(true);
|
||||
expect(result.races[1]!.isUpcoming).toBe(true);
|
||||
});
|
||||
|
||||
it('should prefer scheduledAt over date and map optional fields/status', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2025-01-01T00:00:00Z'));
|
||||
|
||||
const leagueId = 'league-123';
|
||||
const mockDto = {
|
||||
races: [
|
||||
{
|
||||
id: 'race-1',
|
||||
name: 'Round 1',
|
||||
date: '2025-01-02T20:00:00Z',
|
||||
scheduledAt: '2025-01-03T20:00:00Z',
|
||||
track: 'Monza',
|
||||
car: 'GT3',
|
||||
sessionType: 'race',
|
||||
isRegistered: true,
|
||||
status: 'scheduled',
|
||||
},
|
||||
],
|
||||
} as any;
|
||||
|
||||
mockApiClient.getSchedule.mockResolvedValue(mockDto);
|
||||
|
||||
const result = await service.getLeagueSchedule(leagueId);
|
||||
|
||||
expect(result.races[0]!.scheduledAt.toISOString()).toBe('2025-01-03T20:00:00.000Z');
|
||||
expect(result.races[0]!.track).toBe('Monza');
|
||||
expect(result.races[0]!.car).toBe('GT3');
|
||||
expect(result.races[0]!.sessionType).toBe('race');
|
||||
expect(result.races[0]!.isRegistered).toBe(true);
|
||||
expect(result.races[0]!.status).toBe('scheduled');
|
||||
});
|
||||
|
||||
it('should handle empty races array', async () => {
|
||||
@@ -279,56 +235,6 @@ describe('LeagueService', () => {
|
||||
|
||||
await expect(service.createLeague(input)).rejects.toThrow('API call failed');
|
||||
});
|
||||
|
||||
it('should not call apiClient.create when submitBlocker is blocked', async () => {
|
||||
const input: CreateLeagueInputDTO = {
|
||||
name: 'New League',
|
||||
description: 'A new league',
|
||||
visibility: 'public',
|
||||
ownerId: 'owner-1',
|
||||
};
|
||||
|
||||
// First call should succeed
|
||||
const mockDto: CreateLeagueOutputDTO = {
|
||||
leagueId: 'new-league-id',
|
||||
success: true,
|
||||
};
|
||||
mockApiClient.create.mockResolvedValue(mockDto);
|
||||
|
||||
await service.createLeague(input); // This should block the submitBlocker
|
||||
|
||||
// Reset mock to check calls
|
||||
mockApiClient.create.mockClear();
|
||||
|
||||
// Second call should not call API
|
||||
await service.createLeague(input);
|
||||
expect(mockApiClient.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not call apiClient.create when throttle is active', async () => {
|
||||
const input: CreateLeagueInputDTO = {
|
||||
name: 'New League',
|
||||
description: 'A new league',
|
||||
visibility: 'public',
|
||||
ownerId: 'owner-1',
|
||||
};
|
||||
|
||||
// First call
|
||||
const mockDto: CreateLeagueOutputDTO = {
|
||||
leagueId: 'new-league-id',
|
||||
success: true,
|
||||
};
|
||||
mockApiClient.create.mockResolvedValue(mockDto);
|
||||
|
||||
await service.createLeague(input); // This blocks throttle for 500ms
|
||||
|
||||
// Reset mock
|
||||
mockApiClient.create.mockClear();
|
||||
|
||||
// Immediate second call should not call API due to throttle
|
||||
await service.createLeague(input);
|
||||
expect(mockApiClient.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeMember', () => {
|
||||
|
||||
@@ -4,26 +4,11 @@ import { SponsorsApiClient } from "@/lib/api/sponsors/SponsorsApiClient";
|
||||
import { RacesApiClient } from "@/lib/api/races/RacesApiClient";
|
||||
import { CreateLeagueInputDTO } from "@/lib/types/generated/CreateLeagueInputDTO";
|
||||
import { CreateLeagueOutputDTO } from "@/lib/types/generated/CreateLeagueOutputDTO";
|
||||
import { CreateLeagueViewModel } from "@/lib/view-models/CreateLeagueViewModel";
|
||||
import { LeagueAdminScheduleViewModel } from "@/lib/view-models/LeagueAdminScheduleViewModel";
|
||||
import { LeagueMembershipsViewModel } from "@/lib/view-models/LeagueMembershipsViewModel";
|
||||
import { LeagueScheduleViewModel, type LeagueScheduleRaceViewModel } from "@/lib/view-models/LeagueScheduleViewModel";
|
||||
import { LeagueSeasonSummaryViewModel } from "@/lib/view-models/LeagueSeasonSummaryViewModel";
|
||||
import { LeagueStandingsViewModel } from "@/lib/view-models/LeagueStandingsViewModel";
|
||||
import { LeagueStatsViewModel } from "@/lib/view-models/LeagueStatsViewModel";
|
||||
import { LeagueSummaryViewModel } from "@/lib/view-models/LeagueSummaryViewModel";
|
||||
import { RemoveMemberViewModel } from "@/lib/view-models/RemoveMemberViewModel";
|
||||
import { LeaguePageDetailViewModel } from "@/lib/view-models/LeaguePageDetailViewModel";
|
||||
import { LeagueDetailPageViewModel, SponsorInfo } from "@/lib/view-models/LeagueDetailPageViewModel";
|
||||
import { RaceViewModel } from "@/lib/view-models/RaceViewModel";
|
||||
import type { LeagueAdminRosterJoinRequestViewModel } from "@/lib/view-models/LeagueAdminRosterJoinRequestViewModel";
|
||||
import type { LeagueAdminRosterMemberViewModel } from "@/lib/view-models/LeagueAdminRosterMemberViewModel";
|
||||
import type { MembershipRole } from "@/lib/types/MembershipRole";
|
||||
import type { LeagueRosterJoinRequestDTO } from "@/lib/types/generated/LeagueRosterJoinRequestDTO";
|
||||
import { SubmitBlocker, ThrottleBlocker } from "@/lib/blockers";
|
||||
import type { RaceDTO } from "@/lib/types/generated/RaceDTO";
|
||||
import { LeagueStatsDTO } from "@/lib/types/generated/LeagueStatsDTO";
|
||||
import { LeagueScoringConfigDTO } from "@/lib/types/generated/LeagueScoringConfigDTO";
|
||||
import type { TotalLeaguesDTO } from '@/lib/types/generated/TotalLeaguesDTO';
|
||||
import type { LeagueScoringConfigDTO } from "@/lib/types/generated/LeagueScoringConfigDTO";
|
||||
import type { LeagueMembership } from "@/lib/types/LeagueMembership";
|
||||
import type { LeagueSeasonSummaryDTO } from '@/lib/types/generated/LeagueSeasonSummaryDTO';
|
||||
import type { LeagueScheduleDTO } from '@/lib/types/generated/LeagueScheduleDTO';
|
||||
@@ -32,70 +17,16 @@ import type { CreateLeagueScheduleRaceOutputDTO } from '@/lib/types/generated/Cr
|
||||
import type { UpdateLeagueScheduleRaceInputDTO } from '@/lib/types/generated/UpdateLeagueScheduleRaceInputDTO';
|
||||
import type { LeagueScheduleRaceMutationSuccessDTO } from '@/lib/types/generated/LeagueScheduleRaceMutationSuccessDTO';
|
||||
import type { LeagueSeasonSchedulePublishOutputDTO } from '@/lib/types/generated/LeagueSeasonSchedulePublishOutputDTO';
|
||||
|
||||
import type { LeagueRosterMemberDTO } from '@/lib/types/generated/LeagueRosterMemberDTO';
|
||||
import type { LeagueMembershipsDTO } from '@/lib/types/generated/LeagueMembershipsDTO';
|
||||
|
||||
/**
|
||||
* League Service
|
||||
* League Service - DTO Only
|
||||
*
|
||||
* Orchestrates league operations by coordinating API calls and view model creation.
|
||||
* All dependencies are injected via constructor.
|
||||
* Returns raw API DTOs. No ViewModels or UX logic.
|
||||
* All client-side presentation logic must be handled by hooks/components.
|
||||
*/
|
||||
function parseIsoDate(value: string, fallback: Date): Date {
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) return fallback;
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function getBestEffortIsoDate(race: RaceDTO): string | undefined {
|
||||
const anyRace = race as unknown as { scheduledAt?: unknown; date?: unknown };
|
||||
|
||||
if (typeof anyRace.scheduledAt === 'string') return anyRace.scheduledAt;
|
||||
if (typeof anyRace.date === 'string') return anyRace.date;
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getOptionalStringField(race: RaceDTO, key: string): string | undefined {
|
||||
const anyRace = race as unknown as Record<string, unknown>;
|
||||
const value = anyRace[key];
|
||||
return typeof value === 'string' ? value : undefined;
|
||||
}
|
||||
|
||||
function getOptionalBooleanField(race: RaceDTO, key: string): boolean | undefined {
|
||||
const anyRace = race as unknown as Record<string, unknown>;
|
||||
const value = anyRace[key];
|
||||
return typeof value === 'boolean' ? value : undefined;
|
||||
}
|
||||
|
||||
function mapLeagueScheduleDtoToRaceViewModels(dto: LeagueScheduleDTO, now: Date = new Date()): LeagueScheduleRaceViewModel[] {
|
||||
return dto.races.map((race) => {
|
||||
const iso = getBestEffortIsoDate(race);
|
||||
const scheduledAt = iso ? parseIsoDate(iso, new Date(0)) : new Date(0);
|
||||
|
||||
const isPast = scheduledAt.getTime() < now.getTime();
|
||||
const isUpcoming = !isPast;
|
||||
|
||||
const status = getOptionalStringField(race, 'status') ?? (isPast ? 'completed' : 'scheduled');
|
||||
|
||||
return {
|
||||
id: race.id,
|
||||
name: race.name,
|
||||
scheduledAt,
|
||||
isPast,
|
||||
isUpcoming,
|
||||
status,
|
||||
track: getOptionalStringField(race, 'track'),
|
||||
car: getOptionalStringField(race, 'car'),
|
||||
sessionType: getOptionalStringField(race, 'sessionType'),
|
||||
isRegistered: getOptionalBooleanField(race, 'isRegistered'),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export class LeagueService {
|
||||
private readonly submitBlocker = new SubmitBlocker();
|
||||
private readonly throttle = new ThrottleBlocker(500);
|
||||
|
||||
constructor(
|
||||
private readonly apiClient: LeaguesApiClient,
|
||||
private readonly driversApiClient?: DriversApiClient,
|
||||
@@ -103,117 +34,49 @@ export class LeagueService {
|
||||
private readonly racesApiClient?: RacesApiClient
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get all leagues with view model transformation
|
||||
*/
|
||||
async getAllLeagues(): Promise<LeagueSummaryViewModel[]> {
|
||||
const dto = await this.apiClient.getAllWithCapacityAndScoring();
|
||||
const leagues = Array.isArray((dto as any)?.leagues) ? ((dto as any).leagues as any[]) : [];
|
||||
|
||||
return leagues.map((league) => ({
|
||||
id: league.id,
|
||||
name: league.name,
|
||||
description: league.description,
|
||||
logoUrl: league.logoUrl ?? null, // Use API-provided logo URL
|
||||
ownerId: league.ownerId,
|
||||
createdAt: league.createdAt,
|
||||
maxDrivers: league.settings?.maxDrivers ?? 0,
|
||||
usedDriverSlots: league.usedSlots ?? 0,
|
||||
structureSummary: league.scoring?.scoringPresetName ?? 'Custom rules',
|
||||
scoringPatternSummary: league.scoring?.scoringPatternSummary,
|
||||
timingSummary: league.timingSummary ?? '',
|
||||
...(league.category ? { category: league.category } : {}),
|
||||
...(league.scoring ? { scoring: league.scoring } : {}),
|
||||
}));
|
||||
async getAllLeagues(): Promise<any> {
|
||||
return this.apiClient.getAllWithCapacityAndScoring();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get league standings with view model transformation
|
||||
*/
|
||||
async getLeagueStandings(leagueId: string, currentUserId: string): Promise<LeagueStandingsViewModel> {
|
||||
// Core standings (positions, points, driverIds)
|
||||
const dto = await this.apiClient.getStandings(leagueId);
|
||||
const standings = ((dto as any)?.standings ?? []) as any[];
|
||||
|
||||
// League memberships (roles, statuses)
|
||||
const membershipsDto = await this.apiClient.getMemberships(leagueId);
|
||||
const membershipEntries = ((membershipsDto as any)?.members ?? (membershipsDto as any)?.memberships ?? []) as any[];
|
||||
|
||||
const memberships: LeagueMembership[] = membershipEntries.map((m) => ({
|
||||
driverId: m.driverId,
|
||||
leagueId,
|
||||
role: (m.role as LeagueMembership['role']) ?? 'member',
|
||||
joinedAt: m.joinedAt,
|
||||
status: 'active',
|
||||
}));
|
||||
|
||||
// Resolve unique drivers that appear in standings
|
||||
const driverIds: string[] = Array.from(new Set(standings.map((entry: any) => entry.driverId)));
|
||||
const driverDtos = this.driversApiClient
|
||||
? await Promise.all(driverIds.map((id: string) => this.driversApiClient!.getDriver(id)))
|
||||
: [];
|
||||
const drivers = driverDtos.filter((d): d is NonNullable<typeof d> => d !== null);
|
||||
|
||||
const dtoWithExtras = { standings, drivers, memberships };
|
||||
|
||||
return new LeagueStandingsViewModel(dtoWithExtras, currentUserId);
|
||||
async getLeagueStandings(leagueId: string): Promise<any> {
|
||||
return this.apiClient.getStandings(leagueId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get league statistics
|
||||
*/
|
||||
async getLeagueStats(): Promise<LeagueStatsViewModel> {
|
||||
const dto = await this.apiClient.getTotal();
|
||||
return new LeagueStatsViewModel(dto);
|
||||
async getLeagueStats(): Promise<TotalLeaguesDTO> {
|
||||
return this.apiClient.getTotal();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get league schedule
|
||||
*
|
||||
* Service boundary: returns ViewModels only (no DTOs / mappers in UI).
|
||||
*/
|
||||
async getLeagueSchedule(leagueId: string): Promise<LeagueScheduleViewModel> {
|
||||
const dto = await this.apiClient.getSchedule(leagueId);
|
||||
const races = mapLeagueScheduleDtoToRaceViewModels(dto);
|
||||
return new LeagueScheduleViewModel(races);
|
||||
async getLeagueSchedule(leagueId: string): Promise<LeagueScheduleDTO> {
|
||||
return this.apiClient.getSchedule(leagueId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin schedule editor API (ViewModel boundary)
|
||||
*/
|
||||
async getLeagueSeasonSummaries(leagueId: string): Promise<LeagueSeasonSummaryViewModel[]> {
|
||||
const dtos = await this.apiClient.getSeasons(leagueId);
|
||||
return dtos.map((dto) => new LeagueSeasonSummaryViewModel(dto));
|
||||
async getLeagueSeasons(leagueId: string): Promise<LeagueSeasonSummaryDTO[]> {
|
||||
return this.apiClient.getSeasons(leagueId);
|
||||
}
|
||||
|
||||
async getAdminSchedule(leagueId: string, seasonId: string): Promise<LeagueAdminScheduleViewModel> {
|
||||
const dto = await this.apiClient.getSchedule(leagueId, seasonId);
|
||||
const races = mapLeagueScheduleDtoToRaceViewModels(dto);
|
||||
return new LeagueAdminScheduleViewModel({
|
||||
seasonId: dto.seasonId,
|
||||
published: dto.published,
|
||||
races,
|
||||
});
|
||||
async getLeagueSeasonSummaries(leagueId: string): Promise<LeagueSeasonSummaryDTO[]> {
|
||||
return this.apiClient.getSeasons(leagueId);
|
||||
}
|
||||
|
||||
async publishAdminSchedule(leagueId: string, seasonId: string): Promise<LeagueAdminScheduleViewModel> {
|
||||
await this.apiClient.publishSeasonSchedule(leagueId, seasonId);
|
||||
return this.getAdminSchedule(leagueId, seasonId);
|
||||
async getAdminSchedule(leagueId: string, seasonId: string): Promise<LeagueScheduleDTO> {
|
||||
return this.apiClient.getSchedule(leagueId, seasonId);
|
||||
}
|
||||
|
||||
async unpublishAdminSchedule(leagueId: string, seasonId: string): Promise<LeagueAdminScheduleViewModel> {
|
||||
await this.apiClient.unpublishSeasonSchedule(leagueId, seasonId);
|
||||
return this.getAdminSchedule(leagueId, seasonId);
|
||||
async publishAdminSchedule(leagueId: string, seasonId: string): Promise<LeagueSeasonSchedulePublishOutputDTO> {
|
||||
return this.apiClient.publishSeasonSchedule(leagueId, seasonId);
|
||||
}
|
||||
|
||||
async unpublishAdminSchedule(leagueId: string, seasonId: string): Promise<LeagueSeasonSchedulePublishOutputDTO> {
|
||||
return this.apiClient.unpublishSeasonSchedule(leagueId, seasonId);
|
||||
}
|
||||
|
||||
async createAdminScheduleRace(
|
||||
leagueId: string,
|
||||
seasonId: string,
|
||||
input: { track: string; car: string; scheduledAtIso: string },
|
||||
): Promise<LeagueAdminScheduleViewModel> {
|
||||
): Promise<CreateLeagueScheduleRaceOutputDTO> {
|
||||
const payload: CreateLeagueScheduleRaceInputDTO = { ...input, example: '' };
|
||||
await this.apiClient.createSeasonScheduleRace(leagueId, seasonId, payload);
|
||||
return this.getAdminSchedule(leagueId, seasonId);
|
||||
return this.apiClient.createSeasonScheduleRace(leagueId, seasonId, payload);
|
||||
}
|
||||
|
||||
async updateAdminScheduleRace(
|
||||
@@ -221,47 +84,27 @@ export class LeagueService {
|
||||
seasonId: string,
|
||||
raceId: string,
|
||||
input: Partial<{ track: string; car: string; scheduledAtIso: string }>,
|
||||
): Promise<LeagueAdminScheduleViewModel> {
|
||||
): Promise<LeagueScheduleRaceMutationSuccessDTO> {
|
||||
const payload: UpdateLeagueScheduleRaceInputDTO = { ...input, example: '' };
|
||||
await this.apiClient.updateSeasonScheduleRace(leagueId, seasonId, raceId, payload);
|
||||
return this.getAdminSchedule(leagueId, seasonId);
|
||||
return this.apiClient.updateSeasonScheduleRace(leagueId, seasonId, raceId, payload);
|
||||
}
|
||||
|
||||
async deleteAdminScheduleRace(leagueId: string, seasonId: string, raceId: string): Promise<LeagueAdminScheduleViewModel> {
|
||||
await this.apiClient.deleteSeasonScheduleRace(leagueId, seasonId, raceId);
|
||||
return this.getAdminSchedule(leagueId, seasonId);
|
||||
async deleteAdminScheduleRace(leagueId: string, seasonId: string, raceId: string): Promise<LeagueScheduleRaceMutationSuccessDTO> {
|
||||
return this.apiClient.deleteSeasonScheduleRace(leagueId, seasonId, raceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy DTO methods (kept for existing callers)
|
||||
*/
|
||||
|
||||
/**
|
||||
* Get league schedule DTO (season-scoped)
|
||||
*
|
||||
* Admin UI uses the raw DTO so it can render `published` and do CRUD refreshes.
|
||||
*/
|
||||
async getLeagueScheduleDto(leagueId: string, seasonId: string): Promise<LeagueScheduleDTO> {
|
||||
return this.apiClient.getSchedule(leagueId, seasonId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish a league season schedule
|
||||
*/
|
||||
async publishLeagueSeasonSchedule(leagueId: string, seasonId: string): Promise<LeagueSeasonSchedulePublishOutputDTO> {
|
||||
return this.apiClient.publishSeasonSchedule(leagueId, seasonId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unpublish a league season schedule
|
||||
*/
|
||||
async unpublishLeagueSeasonSchedule(leagueId: string, seasonId: string): Promise<LeagueSeasonSchedulePublishOutputDTO> {
|
||||
return this.apiClient.unpublishSeasonSchedule(leagueId, seasonId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a schedule race for a league season
|
||||
*/
|
||||
async createLeagueSeasonScheduleRace(
|
||||
leagueId: string,
|
||||
seasonId: string,
|
||||
@@ -270,9 +113,6 @@ export class LeagueService {
|
||||
return this.apiClient.createSeasonScheduleRace(leagueId, seasonId, input);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a schedule race for a league season
|
||||
*/
|
||||
async updateLeagueSeasonScheduleRace(
|
||||
leagueId: string,
|
||||
seasonId: string,
|
||||
@@ -282,9 +122,6 @@ export class LeagueService {
|
||||
return this.apiClient.updateSeasonScheduleRace(leagueId, seasonId, raceId, input);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a schedule race for a league season
|
||||
*/
|
||||
async deleteLeagueSeasonScheduleRace(
|
||||
leagueId: string,
|
||||
seasonId: string,
|
||||
@@ -293,101 +130,30 @@ export class LeagueService {
|
||||
return this.apiClient.deleteSeasonScheduleRace(leagueId, seasonId, raceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get seasons for a league
|
||||
*/
|
||||
async getLeagueSeasons(leagueId: string): Promise<LeagueSeasonSummaryDTO[]> {
|
||||
return this.apiClient.getSeasons(leagueId);
|
||||
async getLeagueMemberships(leagueId: string): Promise<LeagueMembershipsDTO> {
|
||||
return this.apiClient.getMemberships(leagueId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get league memberships
|
||||
*/
|
||||
async getLeagueMemberships(leagueId: string, currentUserId: string): Promise<LeagueMembershipsViewModel> {
|
||||
const dto = await this.apiClient.getMemberships(leagueId);
|
||||
return new LeagueMembershipsViewModel(dto, currentUserId);
|
||||
async createLeague(input: CreateLeagueInputDTO): Promise<CreateLeagueOutputDTO> {
|
||||
return this.apiClient.create(input);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new league
|
||||
*/
|
||||
async createLeague(input: CreateLeagueInputDTO): Promise<CreateLeagueOutputDTO> {
|
||||
if (!this.submitBlocker.canExecute() || !this.throttle.canExecute()) {
|
||||
return { success: false, leagueId: '' } as CreateLeagueOutputDTO;
|
||||
}
|
||||
|
||||
this.submitBlocker.block();
|
||||
this.throttle.block();
|
||||
try {
|
||||
return await this.apiClient.create(input);
|
||||
} finally {
|
||||
this.submitBlocker.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a member from league
|
||||
*
|
||||
* Overload:
|
||||
* - Legacy: removeMember(leagueId, performerDriverId, targetDriverId)
|
||||
* - Admin roster: removeMember(leagueId, targetDriverId) (actor derived from session)
|
||||
*/
|
||||
async removeMember(leagueId: string, targetDriverId: string): Promise<{ success: boolean }>;
|
||||
async removeMember(leagueId: string, performerDriverId: string, targetDriverId: string): Promise<RemoveMemberViewModel>;
|
||||
async removeMember(leagueId: string, arg1: string, arg2?: string): Promise<{ success: boolean } | RemoveMemberViewModel> {
|
||||
if (arg2 === undefined) {
|
||||
const dto = await this.apiClient.removeRosterMember(leagueId, arg1);
|
||||
return { success: dto.success };
|
||||
}
|
||||
|
||||
const dto = await this.apiClient.removeMember(leagueId, arg1, arg2);
|
||||
return new RemoveMemberViewModel(dto as any);
|
||||
async removeMember(leagueId: string, targetDriverId: string): Promise<{ success: boolean }> {
|
||||
const dto = await this.apiClient.removeRosterMember(leagueId, targetDriverId);
|
||||
return { success: dto.success };
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a member's role in league
|
||||
*
|
||||
* Overload:
|
||||
* - Legacy: updateMemberRole(leagueId, performerDriverId, targetDriverId, newRole)
|
||||
* - Admin roster: updateMemberRole(leagueId, targetDriverId, newRole) (actor derived from session)
|
||||
*/
|
||||
async updateMemberRole(leagueId: string, targetDriverId: string, newRole: MembershipRole): Promise<{ success: boolean }>;
|
||||
async updateMemberRole(leagueId: string, performerDriverId: string, targetDriverId: string, newRole: string): Promise<{ success: boolean }>;
|
||||
async updateMemberRole(leagueId: string, arg1: string, arg2: string, arg3?: string): Promise<{ success: boolean }> {
|
||||
if (arg3 === undefined) {
|
||||
const dto = await this.apiClient.updateRosterMemberRole(leagueId, arg1, arg2);
|
||||
return { success: dto.success };
|
||||
}
|
||||
|
||||
return this.apiClient.updateMemberRole(leagueId, arg1, arg2, arg3);
|
||||
async updateMemberRole(leagueId: string, targetDriverId: string, newRole: MembershipRole): Promise<{ success: boolean }> {
|
||||
const dto = await this.apiClient.updateRosterMemberRole(leagueId, targetDriverId, newRole);
|
||||
return { success: dto.success };
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin roster: members list as ViewModels
|
||||
*/
|
||||
async getAdminRosterMembers(leagueId: string): Promise<LeagueAdminRosterMemberViewModel[]> {
|
||||
const dtos = await this.apiClient.getAdminRosterMembers(leagueId);
|
||||
return dtos.map((dto) => ({
|
||||
driverId: dto.driverId,
|
||||
driverName: dto.driver?.name ?? dto.driverId,
|
||||
role: (dto.role as MembershipRole) ?? 'member',
|
||||
joinedAtIso: dto.joinedAt,
|
||||
}));
|
||||
async getAdminRosterMembers(leagueId: string): Promise<LeagueRosterMemberDTO[]> {
|
||||
return this.apiClient.getAdminRosterMembers(leagueId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin roster: join requests list as ViewModels
|
||||
*/
|
||||
async getAdminRosterJoinRequests(leagueId: string): Promise<LeagueAdminRosterJoinRequestViewModel[]> {
|
||||
const dtos = await this.apiClient.getAdminRosterJoinRequests(leagueId);
|
||||
return dtos.map((dto) => ({
|
||||
id: dto.id,
|
||||
leagueId: dto.leagueId,
|
||||
driverId: dto.driverId,
|
||||
driverName: this.resolveJoinRequestDriverName(dto),
|
||||
requestedAtIso: dto.requestedAt,
|
||||
message: dto.message,
|
||||
}));
|
||||
async getAdminRosterJoinRequests(leagueId: string): Promise<LeagueRosterJoinRequestDTO[]> {
|
||||
return this.apiClient.getAdminRosterJoinRequests(leagueId);
|
||||
}
|
||||
|
||||
async approveJoinRequest(leagueId: string, joinRequestId: string): Promise<{ success: boolean }> {
|
||||
@@ -400,214 +166,14 @@ export class LeagueService {
|
||||
return { success: dto.success };
|
||||
}
|
||||
|
||||
private resolveJoinRequestDriverName(dto: LeagueRosterJoinRequestDTO): string {
|
||||
const driver = dto.driver as any;
|
||||
const name = driver && typeof driver === 'object' ? (driver.name as string | undefined) : undefined;
|
||||
return name ?? dto.driverId;
|
||||
async getLeagueDetail(leagueId: string): Promise<any> {
|
||||
return this.apiClient.getAllWithCapacityAndScoring();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get league detail with owner, membership, and sponsor info
|
||||
*/
|
||||
async getLeagueDetail(leagueId: string, currentDriverId: string): Promise<LeaguePageDetailViewModel | null> {
|
||||
if (!this.driversApiClient) return null;
|
||||
|
||||
// For now, assume league data comes from getAllWithCapacity or a new endpoint
|
||||
// Since API may not have detailed league, we'll mock or assume
|
||||
// In real implementation, add getLeagueDetail to API
|
||||
const allLeagues = await this.apiClient.getAllWithCapacityAndScoring();
|
||||
const leagues = Array.isArray((allLeagues as any)?.leagues) ? ((allLeagues as any).leagues as any[]) : [];
|
||||
const leagueDto = leagues.find((l) => l?.id === leagueId);
|
||||
if (!leagueDto) return null;
|
||||
|
||||
// LeagueWithCapacityDTO already carries core fields; fall back to placeholder description/owner when not provided
|
||||
const league = {
|
||||
id: leagueDto.id,
|
||||
name: leagueDto.name,
|
||||
description: leagueDto.description ?? 'Description not available',
|
||||
ownerId: leagueDto.ownerId ?? 'owner-id',
|
||||
};
|
||||
|
||||
// Get owner
|
||||
const owner = await this.driversApiClient.getDriver(league.ownerId);
|
||||
const ownerName = owner ? (owner as any).name : `${league.ownerId.slice(0, 8)}...`;
|
||||
|
||||
// Get membership
|
||||
const membershipsDto = await this.apiClient.getMemberships(leagueId);
|
||||
const members = Array.isArray((membershipsDto as any)?.members) ? ((membershipsDto as any).members as any[]) : [];
|
||||
const membership = members.find((m: any) => m?.driverId === currentDriverId);
|
||||
const isAdmin = membership ? ['admin', 'owner'].includes((membership as any).role) : false;
|
||||
|
||||
// Get main sponsor
|
||||
let mainSponsor = null;
|
||||
if (this.sponsorsApiClient) {
|
||||
try {
|
||||
const seasons = await this.apiClient.getSeasons(leagueId);
|
||||
const seasonList = Array.isArray(seasons) ? (seasons as any[]) : [];
|
||||
const activeSeason = seasonList.find((s) => s?.status === 'active') ?? seasonList[0];
|
||||
if (activeSeason) {
|
||||
const sponsorshipsDto = await this.apiClient.getSeasonSponsorships(activeSeason.seasonId);
|
||||
const sponsorships = Array.isArray((sponsorshipsDto as any)?.sponsorships)
|
||||
? ((sponsorshipsDto as any).sponsorships as any[])
|
||||
: [];
|
||||
const mainSponsorship = sponsorships.find((s: any) => s?.tier === 'main' && s?.status === 'active');
|
||||
if (mainSponsorship) {
|
||||
const sponsorId = (mainSponsorship as any).sponsorId ?? (mainSponsorship as any).sponsor?.id;
|
||||
if (sponsorId) {
|
||||
const sponsorResult = await this.sponsorsApiClient.getSponsor(sponsorId);
|
||||
const sponsor = (sponsorResult as any)?.sponsor ?? null;
|
||||
if (sponsor) {
|
||||
mainSponsor = {
|
||||
name: sponsor.name,
|
||||
logoUrl: sponsor.logoUrl ?? '',
|
||||
websiteUrl: sponsor.websiteUrl ?? '',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to load main sponsor:', error);
|
||||
}
|
||||
}
|
||||
|
||||
return new LeaguePageDetailViewModel({
|
||||
league: {
|
||||
id: league.id,
|
||||
name: league.name,
|
||||
game: 'iRacing',
|
||||
tier: 'standard',
|
||||
season: 'Season 1',
|
||||
description: league.description,
|
||||
drivers: 0,
|
||||
races: 0,
|
||||
completedRaces: 0,
|
||||
totalImpressions: 0,
|
||||
avgViewsPerRace: 0,
|
||||
engagement: 0,
|
||||
rating: 0,
|
||||
seasonStatus: 'active',
|
||||
seasonDates: { start: new Date().toISOString(), end: new Date().toISOString() },
|
||||
sponsorSlots: {
|
||||
main: { available: true, price: 800, benefits: [] },
|
||||
secondary: { available: 2, total: 2, price: 250, benefits: [] }
|
||||
}
|
||||
},
|
||||
drivers: [],
|
||||
races: []
|
||||
});
|
||||
async getLeagueDetailPageData(leagueId: string): Promise<any> {
|
||||
return this.apiClient.getAllWithCapacityAndScoring();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get comprehensive league detail page data
|
||||
*/
|
||||
async getLeagueDetailPageData(leagueId: string): Promise<LeagueDetailPageViewModel | null> {
|
||||
if (!this.driversApiClient || !this.sponsorsApiClient) return null;
|
||||
|
||||
try {
|
||||
// Get league basic info
|
||||
const allLeagues = await this.apiClient.getAllWithCapacityAndScoring();
|
||||
const leagues = Array.isArray((allLeagues as any)?.leagues) ? ((allLeagues as any).leagues as any[]) : [];
|
||||
const league = leagues.find((l) => l?.id === leagueId);
|
||||
if (!league) return null;
|
||||
|
||||
// Get owner
|
||||
const owner = await this.driversApiClient.getDriver(league.ownerId);
|
||||
|
||||
// League scoring configuration is not exposed separately yet; use null to indicate "not configured" in the UI
|
||||
const scoringConfig: LeagueScoringConfigDTO | null = null;
|
||||
|
||||
// Drivers list is limited to those present in memberships until a dedicated league-drivers endpoint exists
|
||||
const memberships = await this.apiClient.getMemberships(leagueId);
|
||||
const membershipMembers = Array.isArray((memberships as any)?.members) ? ((memberships as any).members as any[]) : [];
|
||||
const driverIds = membershipMembers.map((m: any) => m?.driverId).filter((id: any): id is string => typeof id === 'string');
|
||||
const driverDtos = await Promise.all(driverIds.map((id: string) => this.driversApiClient!.getDriver(id)));
|
||||
const drivers = driverDtos.filter((d: any): d is NonNullable<typeof d> => d !== null);
|
||||
|
||||
// Get all races for this league via the leagues API helper
|
||||
// Service boundary hardening: tolerate `null/undefined` arrays from API.
|
||||
const leagueRaces = await this.apiClient.getRaces(leagueId);
|
||||
const allRaces = (leagueRaces.races ?? []).map((race) => new RaceViewModel(race));
|
||||
|
||||
// League stats endpoint currently returns global league statistics rather than per-league values
|
||||
const leagueStats: LeagueStatsDTO = {
|
||||
totalMembers: league.usedSlots,
|
||||
totalRaces: allRaces.length,
|
||||
averageRating: 0,
|
||||
};
|
||||
|
||||
// Get sponsors
|
||||
const sponsors = await this.getLeagueSponsors(leagueId);
|
||||
|
||||
return new LeagueDetailPageViewModel(
|
||||
league,
|
||||
owner,
|
||||
scoringConfig,
|
||||
drivers,
|
||||
memberships,
|
||||
allRaces,
|
||||
leagueStats,
|
||||
sponsors
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to load league detail page data:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get sponsors for a league
|
||||
*/
|
||||
private async getLeagueSponsors(leagueId: string): Promise<SponsorInfo[]> {
|
||||
if (!this.sponsorsApiClient) return [];
|
||||
|
||||
try {
|
||||
const seasons = await this.apiClient.getSeasons(leagueId);
|
||||
const seasonList = Array.isArray(seasons) ? (seasons as any[]) : [];
|
||||
const activeSeason = seasonList.find((s) => s?.status === 'active') ?? seasonList[0];
|
||||
|
||||
if (!activeSeason) return [];
|
||||
|
||||
const sponsorships = await this.apiClient.getSeasonSponsorships(activeSeason.seasonId);
|
||||
const sponsorshipList = Array.isArray((sponsorships as any)?.sponsorships)
|
||||
? ((sponsorships as any).sponsorships as any[])
|
||||
: [];
|
||||
const activeSponsorships = sponsorshipList.filter((s: any) => s?.status === 'active');
|
||||
|
||||
const sponsorInfos: SponsorInfo[] = [];
|
||||
for (const sponsorship of activeSponsorships) {
|
||||
const sponsorResult = await this.sponsorsApiClient.getSponsor((sponsorship as any).sponsorId ?? (sponsorship as any).sponsor?.id);
|
||||
const sponsor = (sponsorResult as any)?.sponsor ?? null;
|
||||
if (sponsor) {
|
||||
// Tagline is not supplied by the sponsor API in this build; callers may derive one from marketing content if needed
|
||||
sponsorInfos.push({
|
||||
id: sponsor.id,
|
||||
name: sponsor.name,
|
||||
logoUrl: sponsor.logoUrl ?? '',
|
||||
websiteUrl: sponsor.websiteUrl ?? '',
|
||||
tier: ((sponsorship as any).tier as 'main' | 'secondary') ?? 'secondary',
|
||||
tagline: '',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort: main sponsors first, then secondary
|
||||
sponsorInfos.sort((a, b) => {
|
||||
if (a.tier === 'main' && b.tier !== 'main') return -1;
|
||||
if (a.tier !== 'main' && b.tier === 'main') return 1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
return sponsorInfos;
|
||||
} catch (error) {
|
||||
console.warn('Failed to load sponsors:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get league scoring presets
|
||||
*/
|
||||
async getScoringPresets(): Promise<any[]> {
|
||||
const result = await this.apiClient.getScoringPresets();
|
||||
return result.presets;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, it, expect, vi, Mocked } from 'vitest';
|
||||
import { LeagueSettingsService } from './LeagueSettingsService';
|
||||
import { LeaguesApiClient } from '../../api/leagues/LeaguesApiClient';
|
||||
import { DriversApiClient } from '../../api/drivers/DriversApiClient';
|
||||
import { LeaguesApiClient } from '@/lib/api/leagues/LeaguesApiClient';
|
||||
import { DriversApiClient } from '@/lib/api/drivers/DriversApiClient';
|
||||
import { LeagueSettingsViewModel } from '@/lib/view-models/LeagueSettingsViewModel';
|
||||
|
||||
describe('LeagueSettingsService', () => {
|
||||
|
||||
@@ -1,283 +0,0 @@
|
||||
import { LeaguesApiClient } from "@/lib/api/leagues/LeaguesApiClient";
|
||||
import { DriversApiClient } from "@/lib/api/drivers/DriversApiClient";
|
||||
import type { LeagueConfigFormModel } from "@/lib/types/LeagueConfigFormModel";
|
||||
import type { LeagueScoringPresetDTO } from "@/lib/types/generated/LeagueScoringPresetDTO";
|
||||
import { LeagueSettingsViewModel } from "@/lib/view-models/LeagueSettingsViewModel";
|
||||
import { DriverSummaryViewModel } from "@/lib/view-models/DriverSummaryViewModel";
|
||||
import type { LeagueScoringPresetViewModel } from "@/lib/view-models/LeagueScoringPresetViewModel";
|
||||
import type { CustomPointsConfig } from "@/lib/view-models/ScoringConfigurationViewModel";
|
||||
|
||||
/**
|
||||
* League Settings Service
|
||||
*
|
||||
* Orchestrates league settings operations by coordinating API calls and view model creation.
|
||||
* All dependencies are injected via constructor.
|
||||
*/
|
||||
export class LeagueSettingsService {
|
||||
constructor(
|
||||
private readonly leaguesApiClient: LeaguesApiClient,
|
||||
private readonly driversApiClient: DriversApiClient
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get league settings with view model transformation
|
||||
*/
|
||||
async getLeagueSettings(leagueId: string): Promise<LeagueSettingsViewModel | null> {
|
||||
try {
|
||||
// Get league basic info (includes ownerId in DTO)
|
||||
const allLeagues = await this.leaguesApiClient.getAllWithCapacity();
|
||||
const leagueDto = allLeagues.leagues.find(l => l.id === leagueId);
|
||||
if (!leagueDto) return null;
|
||||
|
||||
const league = {
|
||||
id: leagueDto.id,
|
||||
name: leagueDto.name,
|
||||
ownerId: leagueDto.ownerId,
|
||||
createdAt: leagueDto.createdAt || new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Get config
|
||||
const configDto = await this.leaguesApiClient.getLeagueConfig(leagueId);
|
||||
const config: LeagueConfigFormModel = (configDto.form ?? undefined) as unknown as LeagueConfigFormModel;
|
||||
|
||||
// Get presets
|
||||
const presetsDto = await this.leaguesApiClient.getScoringPresets();
|
||||
const presets: LeagueScoringPresetDTO[] = presetsDto.presets;
|
||||
|
||||
// Get leaderboard once so we can hydrate rating / rank for owner + members
|
||||
const leaderboardDto = await this.driversApiClient.getLeaderboard();
|
||||
const leaderboardByDriverId = new Map(
|
||||
leaderboardDto.drivers.map(driver => [driver.id, driver])
|
||||
);
|
||||
|
||||
// Get owner
|
||||
const ownerDriver = await this.driversApiClient.getDriver(league.ownerId);
|
||||
let owner: DriverSummaryViewModel | null = null;
|
||||
if (ownerDriver) {
|
||||
const ownerStats = leaderboardByDriverId.get(ownerDriver.id);
|
||||
owner = new DriverSummaryViewModel({
|
||||
driver: ownerDriver,
|
||||
rating: ownerStats?.rating ?? null,
|
||||
rank: ownerStats?.rank ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
// Get members
|
||||
const membershipsDto = await this.leaguesApiClient.getMemberships(leagueId);
|
||||
const members: DriverSummaryViewModel[] = [];
|
||||
for (const member of membershipsDto.members) {
|
||||
if (member.driverId !== league.ownerId && member.role !== 'owner') {
|
||||
const driver = await this.driversApiClient.getDriver(member.driverId);
|
||||
if (driver) {
|
||||
const memberStats = leaderboardByDriverId.get(driver.id);
|
||||
members.push(new DriverSummaryViewModel({
|
||||
driver,
|
||||
rating: memberStats?.rating ?? null,
|
||||
rank: memberStats?.rank ?? null,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new LeagueSettingsViewModel({
|
||||
league,
|
||||
config,
|
||||
presets,
|
||||
owner,
|
||||
members,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to load league settings:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transfer league ownership
|
||||
*/
|
||||
async transferOwnership(leagueId: string, currentOwnerId: string, newOwnerId: string): Promise<boolean> {
|
||||
try {
|
||||
const result = await this.leaguesApiClient.transferOwnership(leagueId, currentOwnerId, newOwnerId);
|
||||
return result.success;
|
||||
} catch (error) {
|
||||
console.error('Failed to transfer ownership:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a scoring preset
|
||||
*/
|
||||
selectScoringPreset(
|
||||
currentForm: LeagueConfigFormModel,
|
||||
presetId: string
|
||||
): LeagueConfigFormModel {
|
||||
return {
|
||||
...currentForm,
|
||||
scoring: {
|
||||
...currentForm.scoring,
|
||||
patternId: presetId,
|
||||
customScoringEnabled: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle custom scoring
|
||||
*/
|
||||
toggleCustomScoring(currentForm: LeagueConfigFormModel): LeagueConfigFormModel {
|
||||
return {
|
||||
...currentForm,
|
||||
scoring: {
|
||||
...currentForm.scoring,
|
||||
customScoringEnabled: !currentForm.scoring.customScoringEnabled,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update championship settings
|
||||
*/
|
||||
updateChampionship(
|
||||
currentForm: LeagueConfigFormModel,
|
||||
key: keyof LeagueConfigFormModel['championships'],
|
||||
value: boolean
|
||||
): LeagueConfigFormModel {
|
||||
return {
|
||||
...currentForm,
|
||||
championships: {
|
||||
...currentForm.championships,
|
||||
[key]: value,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get preset emoji based on name
|
||||
*/
|
||||
getPresetEmoji(preset: LeagueScoringPresetViewModel): string {
|
||||
const name = preset.name.toLowerCase();
|
||||
if (name.includes('sprint') || name.includes('double')) return '⚡';
|
||||
if (name.includes('endurance') || name.includes('long')) return '🏆';
|
||||
if (name.includes('club') || name.includes('casual')) return '🏅';
|
||||
return '🏁';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get preset description based on name
|
||||
*/
|
||||
getPresetDescription(preset: LeagueScoringPresetViewModel): string {
|
||||
const name = preset.name.toLowerCase();
|
||||
if (name.includes('sprint')) return 'Sprint + Feature race';
|
||||
if (name.includes('endurance')) return 'Long-form endurance';
|
||||
if (name.includes('club')) return 'Casual league format';
|
||||
return preset.sessionSummary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get preset info content for flyout
|
||||
*/
|
||||
getPresetInfoContent(presetName: string): { title: string; description: string; details: string[] } {
|
||||
const name = presetName.toLowerCase();
|
||||
if (name.includes('sprint')) {
|
||||
return {
|
||||
title: 'Sprint + Feature Format',
|
||||
description: 'A two-race weekend format with a shorter sprint race and a longer feature race.',
|
||||
details: [
|
||||
'Sprint race typically awards reduced points (e.g., 8-6-4-3-2-1)',
|
||||
'Feature race awards full points (e.g., 25-18-15-12-10-8-6-4-2-1)',
|
||||
'Grid for feature often based on sprint results',
|
||||
'Great for competitive leagues with time for multiple races',
|
||||
],
|
||||
};
|
||||
}
|
||||
if (name.includes('endurance') || name.includes('long')) {
|
||||
return {
|
||||
title: 'Endurance Format',
|
||||
description: 'Long-form racing focused on consistency and strategy over raw pace.',
|
||||
details: [
|
||||
'Single race per weekend, longer duration (60-90+ minutes)',
|
||||
'Higher points for finishing (rewards reliability)',
|
||||
'Often includes mandatory pit stops',
|
||||
'Best for serious leagues with dedicated racers',
|
||||
],
|
||||
};
|
||||
}
|
||||
if (name.includes('club') || name.includes('casual')) {
|
||||
return {
|
||||
title: 'Club/Casual Format',
|
||||
description: 'Relaxed format perfect for community leagues and casual racing.',
|
||||
details: [
|
||||
'Simple points structure, easy to understand',
|
||||
'Typically single race per weekend',
|
||||
'Lower stakes, focus on participation',
|
||||
'Great for beginners or mixed-skill leagues',
|
||||
],
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: 'Standard Race Format',
|
||||
description: 'Traditional single-race weekend with standard F1-style points.',
|
||||
details: [
|
||||
'Points: 25-18-15-12-10-8-6-4-2-1 for top 10',
|
||||
'Bonus points for pole position and fastest lap',
|
||||
'One race per weekend',
|
||||
'The most common format used in sim racing',
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get championship info content for flyout
|
||||
*/
|
||||
getChampionshipInfoContent(key: string): { title: string; description: string; details: string[] } {
|
||||
const info: Record<string, { title: string; description: string; details: string[] }> = {
|
||||
enableDriverChampionship: {
|
||||
title: 'Driver Championship',
|
||||
description: 'Track individual driver performance across all races in the season.',
|
||||
details: [
|
||||
'Each driver accumulates points based on race finishes',
|
||||
'The driver with most points at season end wins',
|
||||
'Standard in all racing leagues',
|
||||
'Shows overall driver skill and consistency',
|
||||
],
|
||||
},
|
||||
enableTeamChampionship: {
|
||||
title: 'Team Championship',
|
||||
description: 'Combine points from all drivers within a team for team standings.',
|
||||
details: [
|
||||
'All drivers\' points count toward team total',
|
||||
'Rewards having consistent performers across the roster',
|
||||
'Creates team strategy opportunities',
|
||||
'Only available in Teams mode leagues',
|
||||
],
|
||||
},
|
||||
enableNationsChampionship: {
|
||||
title: 'Nations Cup',
|
||||
description: 'Group drivers by nationality for international competition.',
|
||||
details: [
|
||||
'Drivers represent their country automatically',
|
||||
'Points pooled by nationality',
|
||||
'Adds international rivalry element',
|
||||
'Great for diverse, international leagues',
|
||||
],
|
||||
},
|
||||
enableTrophyChampionship: {
|
||||
title: 'Trophy Championship',
|
||||
description: 'A special category championship for specific classes or groups.',
|
||||
details: [
|
||||
'Custom category you define (e.g., Am drivers, rookies)',
|
||||
'Separate standings from main championship',
|
||||
'Encourages participation from all skill levels',
|
||||
'Can be used for gentleman drivers, newcomers, etc.',
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
return info[key] || {
|
||||
title: 'Championship',
|
||||
description: 'A championship standings category.',
|
||||
details: ['Enable to track this type of championship.'],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { ProtestService } from '../protests/ProtestService';
|
||||
import { PenaltyService } from '../penalties/PenaltyService';
|
||||
import { DriverService } from '../drivers/DriverService';
|
||||
import { LeagueMembershipService } from './LeagueMembershipService';
|
||||
import { LeagueStewardingViewModel } from '../../view-models/LeagueStewardingViewModel';
|
||||
import { LeagueStewardingViewModel } from '@/lib/view-models/LeagueStewardingViewModel';
|
||||
|
||||
describe('LeagueStewardingService', () => {
|
||||
let mockRaceService: Mocked<RaceService>;
|
||||
|
||||
@@ -1,179 +0,0 @@
|
||||
import { RaceService } from '../races/RaceService';
|
||||
import { ProtestService } from '../protests/ProtestService';
|
||||
import { PenaltyService } from '../penalties/PenaltyService';
|
||||
import { DriverService } from '../drivers/DriverService';
|
||||
import { LeagueMembershipService } from './LeagueMembershipService';
|
||||
import { LeagueStewardingViewModel, RaceWithProtests } from '../../view-models/LeagueStewardingViewModel';
|
||||
import type { ProtestDetailViewModel } from '../../view-models/ProtestDetailViewModel';
|
||||
|
||||
/**
|
||||
* League Stewarding Service
|
||||
*
|
||||
* Orchestrates league stewarding operations by coordinating calls to race, protest, penalty, driver, and membership services.
|
||||
* All dependencies are injected via constructor.
|
||||
*/
|
||||
export class LeagueStewardingService {
|
||||
private getPenaltyValueLabel(valueKind: string): string {
|
||||
switch (valueKind) {
|
||||
case 'seconds':
|
||||
return 'seconds';
|
||||
case 'grid_positions':
|
||||
return 'positions';
|
||||
case 'points':
|
||||
return 'points';
|
||||
case 'races':
|
||||
return 'races';
|
||||
case 'none':
|
||||
return '';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
private getFallbackDefaultPenaltyValue(valueKind: string): number {
|
||||
switch (valueKind) {
|
||||
case 'seconds':
|
||||
return 5;
|
||||
case 'grid_positions':
|
||||
return 3;
|
||||
case 'points':
|
||||
return 5;
|
||||
case 'races':
|
||||
return 1;
|
||||
case 'none':
|
||||
return 0;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
constructor(
|
||||
private readonly raceService: RaceService,
|
||||
private readonly protestService: ProtestService,
|
||||
private readonly penaltyService: PenaltyService,
|
||||
private readonly driverService: DriverService,
|
||||
private readonly leagueMembershipService: LeagueMembershipService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get league stewarding data for all races in a league
|
||||
*/
|
||||
async getLeagueStewardingData(leagueId: string): Promise<LeagueStewardingViewModel> {
|
||||
// Get all races for this league
|
||||
const leagueRaces = await this.raceService.findByLeagueId(leagueId);
|
||||
|
||||
// Get protests and penalties for each race
|
||||
const protestsMap: Record<string, any[]> = {};
|
||||
const penaltiesMap: Record<string, any[]> = {};
|
||||
const driverIds = new Set<string>();
|
||||
|
||||
for (const race of leagueRaces) {
|
||||
const raceProtests = await this.protestService.findByRaceId(race.id);
|
||||
const racePenalties = await this.penaltyService.findByRaceId(race.id);
|
||||
|
||||
protestsMap[race.id] = raceProtests;
|
||||
penaltiesMap[race.id] = racePenalties;
|
||||
|
||||
// Collect driver IDs
|
||||
raceProtests.forEach((p: any) => {
|
||||
driverIds.add(p.protestingDriverId);
|
||||
driverIds.add(p.accusedDriverId);
|
||||
});
|
||||
racePenalties.forEach((p: any) => {
|
||||
driverIds.add(p.driverId);
|
||||
});
|
||||
}
|
||||
|
||||
// Load driver info
|
||||
const driverEntities = await this.driverService.findByIds(Array.from(driverIds));
|
||||
const driverMap: Record<string, any> = {};
|
||||
driverEntities.forEach((driver) => {
|
||||
if (driver) {
|
||||
driverMap[driver.id] = driver;
|
||||
}
|
||||
});
|
||||
|
||||
// Compute race data with protest/penalty info
|
||||
const racesWithData: RaceWithProtests[] = leagueRaces.map(race => {
|
||||
const protests = protestsMap[race.id] || [];
|
||||
const penalties = penaltiesMap[race.id] || [];
|
||||
return {
|
||||
race: {
|
||||
id: race.id,
|
||||
track: race.track,
|
||||
scheduledAt: new Date(race.scheduledAt),
|
||||
},
|
||||
pendingProtests: protests.filter(p => p.status === 'pending' || p.status === 'under_review'),
|
||||
resolvedProtests: protests.filter(p => p.status === 'upheld' || p.status === 'dismissed' || p.status === 'withdrawn'),
|
||||
penalties
|
||||
};
|
||||
}).sort((a, b) => b.race.scheduledAt.getTime() - a.race.scheduledAt.getTime());
|
||||
|
||||
return new LeagueStewardingViewModel(racesWithData, driverMap);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get protest review details as a page-ready view model
|
||||
*/
|
||||
async getProtestDetailViewModel(leagueId: string, protestId: string): Promise<ProtestDetailViewModel> {
|
||||
const [protestData, penaltyTypesReference] = await Promise.all([
|
||||
this.protestService.getProtestById(leagueId, protestId),
|
||||
this.penaltyService.getPenaltyTypesReference(),
|
||||
]);
|
||||
|
||||
if (!protestData) {
|
||||
throw new Error('Protest not found');
|
||||
}
|
||||
|
||||
const penaltyUiDefaults: Record<string, { label: string; description: string; defaultValue: number }> = {
|
||||
time_penalty: { label: 'Time Penalty', description: 'Add seconds to race result', defaultValue: 5 },
|
||||
grid_penalty: { label: 'Grid Penalty', description: 'Grid positions for next race', defaultValue: 3 },
|
||||
points_deduction: { label: 'Points Deduction', description: 'Deduct championship points', defaultValue: 5 },
|
||||
disqualification: { label: 'Disqualification', description: 'Disqualify from race', defaultValue: 0 },
|
||||
warning: { label: 'Warning', description: 'Official warning only', defaultValue: 0 },
|
||||
license_points: { label: 'License Points', description: 'Safety rating penalty', defaultValue: 2 },
|
||||
};
|
||||
|
||||
const penaltyTypes = (penaltyTypesReference?.penaltyTypes ?? []).map((ref: any) => {
|
||||
const ui = penaltyUiDefaults[ref.type];
|
||||
const valueLabel = this.getPenaltyValueLabel(String(ref.valueKind ?? 'none'));
|
||||
const defaultValue = ui?.defaultValue ?? this.getFallbackDefaultPenaltyValue(String(ref.valueKind ?? 'none'));
|
||||
|
||||
return {
|
||||
type: String(ref.type),
|
||||
label: ui?.label ?? String(ref.type).replaceAll('_', ' '),
|
||||
description: ui?.description ?? '',
|
||||
requiresValue: Boolean(ref.requiresValue),
|
||||
valueLabel,
|
||||
defaultValue,
|
||||
};
|
||||
});
|
||||
|
||||
const timePenalty = penaltyTypes.find((p) => p.type === 'time_penalty');
|
||||
const initial = timePenalty ?? penaltyTypes[0];
|
||||
|
||||
return {
|
||||
protest: protestData.protest,
|
||||
race: protestData.race,
|
||||
protestingDriver: protestData.protestingDriver,
|
||||
accusedDriver: protestData.accusedDriver,
|
||||
penaltyTypes,
|
||||
defaultReasons: penaltyTypesReference?.defaultReasons ?? { upheld: '', dismissed: '' },
|
||||
initialPenaltyType: initial?.type ?? null,
|
||||
initialPenaltyValue: initial?.defaultValue ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Review a protest
|
||||
*/
|
||||
async reviewProtest(input: { protestId: string; stewardId: string; decision: string; decisionNotes: string }): Promise<void> {
|
||||
await this.protestService.reviewProtest(input);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a penalty
|
||||
*/
|
||||
async applyPenalty(input: any): Promise<void> {
|
||||
await this.penaltyService.applyPenalty(input);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, vi, Mocked } from 'vitest';
|
||||
import { LeagueWalletService } from './LeagueWalletService';
|
||||
import { WalletsApiClient } from '../../api/wallets/WalletsApiClient';
|
||||
import { WalletsApiClient } from '@/lib/api/wallets/WalletsApiClient';
|
||||
import { LeagueWalletViewModel } from '@/lib/view-models/LeagueWalletViewModel';
|
||||
|
||||
describe('LeagueWalletService', () => {
|
||||
@@ -97,27 +97,5 @@ describe('LeagueWalletService', () => {
|
||||
|
||||
await expect(service.withdraw(leagueId, amount, currency, seasonId, destinationAccount)).rejects.toThrow('Withdrawal failed');
|
||||
});
|
||||
|
||||
it('should block multiple rapid calls due to throttle', async () => {
|
||||
const leagueId = 'league-123';
|
||||
const amount = 500;
|
||||
const currency = 'USD';
|
||||
const seasonId = 'season-456';
|
||||
const destinationAccount = 'account-789';
|
||||
|
||||
const mockResponse = { success: true };
|
||||
mockApiClient.withdrawFromLeagueWallet.mockResolvedValue(mockResponse);
|
||||
|
||||
// First call should succeed
|
||||
await service.withdraw(leagueId, amount, currency, seasonId, destinationAccount);
|
||||
|
||||
// Reset mock
|
||||
mockApiClient.withdrawFromLeagueWallet.mockClear();
|
||||
|
||||
// Immediate second call should be blocked by throttle and throw error
|
||||
await expect(service.withdraw(leagueId, amount, currency, seasonId, destinationAccount)).rejects.toThrow('Request blocked due to rate limiting');
|
||||
|
||||
expect(mockApiClient.withdrawFromLeagueWallet).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,71 +0,0 @@
|
||||
import { WalletsApiClient, LeagueWalletDTO, WithdrawRequestDTO, WithdrawResponseDTO } from '@/lib/api/wallets/WalletsApiClient';
|
||||
import { LeagueWalletViewModel } from '@/lib/view-models/LeagueWalletViewModel';
|
||||
import { WalletTransactionViewModel } from '@/lib/view-models/WalletTransactionViewModel';
|
||||
import { SubmitBlocker, ThrottleBlocker } from '@/lib/blockers';
|
||||
|
||||
/**
|
||||
* League Wallet Service
|
||||
*
|
||||
* Orchestrates league wallet operations by coordinating API calls and view model creation.
|
||||
* All dependencies are injected via constructor.
|
||||
*/
|
||||
export class LeagueWalletService {
|
||||
private readonly submitBlocker = new SubmitBlocker();
|
||||
private readonly throttle = new ThrottleBlocker(500);
|
||||
|
||||
constructor(
|
||||
private readonly apiClient: WalletsApiClient
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get wallet for a league
|
||||
*/
|
||||
async getWalletForLeague(leagueId: string): Promise<LeagueWalletViewModel> {
|
||||
const dto = await this.apiClient.getLeagueWallet(leagueId);
|
||||
const transactions = dto.transactions.map(t => new WalletTransactionViewModel({
|
||||
id: t.id,
|
||||
type: t.type,
|
||||
description: t.description,
|
||||
amount: t.amount,
|
||||
fee: t.fee,
|
||||
netAmount: t.netAmount,
|
||||
date: new Date(t.date),
|
||||
status: t.status,
|
||||
reference: t.reference,
|
||||
}));
|
||||
return new LeagueWalletViewModel({
|
||||
balance: dto.balance,
|
||||
currency: dto.currency,
|
||||
totalRevenue: dto.totalRevenue,
|
||||
totalFees: dto.totalFees,
|
||||
totalWithdrawals: dto.totalWithdrawals,
|
||||
pendingPayouts: dto.pendingPayouts,
|
||||
transactions,
|
||||
canWithdraw: dto.canWithdraw,
|
||||
withdrawalBlockReason: dto.withdrawalBlockReason,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Withdraw from league wallet
|
||||
*/
|
||||
async withdraw(leagueId: string, amount: number, currency: string, seasonId: string, destinationAccount: string): Promise<WithdrawResponseDTO> {
|
||||
if (!this.submitBlocker.canExecute() || !this.throttle.canExecute()) {
|
||||
throw new Error('Request blocked due to rate limiting');
|
||||
}
|
||||
|
||||
this.submitBlocker.block();
|
||||
this.throttle.block();
|
||||
try {
|
||||
const request: WithdrawRequestDTO = {
|
||||
amount,
|
||||
currency,
|
||||
seasonId,
|
||||
destinationAccount,
|
||||
};
|
||||
return await this.apiClient.withdrawFromLeagueWallet(leagueId, request);
|
||||
} finally {
|
||||
this.submitBlocker.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { apiClient } from '@/lib/apiClient';
|
||||
import { LeagueWizardCommandModel } from '@/lib/command-models/leagues/LeagueWizardCommandModel';
|
||||
import { CreateLeagueOutputDTO } from '@/lib/types/generated/CreateLeagueOutputDTO';
|
||||
|
||||
export class LeagueWizardService {
|
||||
static async createLeague(
|
||||
form: LeagueWizardCommandModel,
|
||||
ownerId: string,
|
||||
): Promise<CreateLeagueOutputDTO> {
|
||||
const command = form.toCreateLeagueCommand(ownerId);
|
||||
const result = await apiClient.leagues.create(command);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Static method for backward compatibility
|
||||
static async createLeagueFromConfig(
|
||||
form: LeagueWizardCommandModel,
|
||||
ownerId: string,
|
||||
): Promise<CreateLeagueOutputDTO> {
|
||||
return this.createLeague(form, ownerId);
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
@@ -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 } : {}) });
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user