refactor
This commit is contained in:
@@ -1,14 +1,15 @@
|
||||
import { cookies } from 'next/headers';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthService } from '../../../../lib/auth';
|
||||
import { apiClient } from '../../../../lib/apiClient';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const url = new URL(request.url);
|
||||
const returnTo = url.searchParams.get('returnTo') ?? undefined;
|
||||
|
||||
const authService = getAuthService();
|
||||
const { redirectUrl, state } = await authService.startIracingAuthRedirect(returnTo);
|
||||
const redirectUrl = apiClient.auth.getIracingAuthUrl(returnTo);
|
||||
// For now, generate a simple state - in production this should be cryptographically secure
|
||||
const state = Math.random().toString(36).substring(2, 15);
|
||||
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set('gp_demo_auth_state', state, {
|
||||
|
||||
@@ -1,11 +1,493 @@
|
||||
export class ApiClient {
|
||||
/**
|
||||
* Domain-specific API Client for GridPilot Website
|
||||
*
|
||||
* This module provides a strongly-typed HTTP client for all API operations.
|
||||
* The website should use these methods instead of directly importing core use cases.
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// Types - These mirror the API DTOs
|
||||
// ============================================================================
|
||||
|
||||
// Common Types
|
||||
export interface DriverDTO {
|
||||
id: string;
|
||||
name: string;
|
||||
avatarUrl?: string;
|
||||
iracingId?: string;
|
||||
rating?: number;
|
||||
}
|
||||
|
||||
export interface ProtestViewModel {
|
||||
id: string;
|
||||
raceId: string;
|
||||
complainantId: string;
|
||||
defendantId: string;
|
||||
description: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface LeagueMemberViewModel {
|
||||
driverId: string;
|
||||
driver?: DriverDTO;
|
||||
role: string;
|
||||
joinedAt: string;
|
||||
}
|
||||
|
||||
export interface StandingEntryViewModel {
|
||||
driverId: string;
|
||||
driver?: DriverDTO;
|
||||
position: number;
|
||||
points: number;
|
||||
wins: number;
|
||||
podiums: number;
|
||||
races: number;
|
||||
}
|
||||
|
||||
export interface ScheduledRaceViewModel {
|
||||
id: string;
|
||||
name: string;
|
||||
scheduledTime: string;
|
||||
status: string;
|
||||
trackName?: string;
|
||||
}
|
||||
|
||||
// League Types
|
||||
export interface LeagueSummaryViewModel {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
logoUrl?: string;
|
||||
coverImage?: string;
|
||||
memberCount: number;
|
||||
maxMembers: number;
|
||||
isPublic: boolean;
|
||||
ownerId: string;
|
||||
ownerName?: string;
|
||||
scoringType?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface AllLeaguesWithCapacityViewModel {
|
||||
leagues: LeagueSummaryViewModel[];
|
||||
}
|
||||
|
||||
export interface LeagueStatsDto {
|
||||
totalLeagues: number;
|
||||
}
|
||||
|
||||
export interface LeagueJoinRequestViewModel {
|
||||
id: string;
|
||||
leagueId: string;
|
||||
driverId: string;
|
||||
requestedAt: Date;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface LeagueAdminPermissionsViewModel {
|
||||
canManageMembers: boolean;
|
||||
canManageRaces: boolean;
|
||||
canManageSettings: boolean;
|
||||
canManageProtests: boolean;
|
||||
isOwner: boolean;
|
||||
isAdmin: boolean;
|
||||
}
|
||||
|
||||
export interface LeagueOwnerSummaryViewModel {
|
||||
leagueId: string;
|
||||
leagueName: string;
|
||||
memberCount: number;
|
||||
pendingRequests: number;
|
||||
}
|
||||
|
||||
export interface LeagueConfigFormModelDto {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
isPublic: boolean;
|
||||
maxMembers: number;
|
||||
// Add other config fields as needed
|
||||
}
|
||||
|
||||
export interface LeagueAdminProtestsViewModel {
|
||||
protests: ProtestViewModel[];
|
||||
}
|
||||
|
||||
export interface LeagueSeasonSummaryViewModel {
|
||||
id: string;
|
||||
name: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface LeagueMembershipsViewModel {
|
||||
members: LeagueMemberViewModel[];
|
||||
}
|
||||
|
||||
export interface LeagueStandingsViewModel {
|
||||
standings: StandingEntryViewModel[];
|
||||
}
|
||||
|
||||
export interface LeagueScheduleViewModel {
|
||||
races: ScheduledRaceViewModel[];
|
||||
}
|
||||
|
||||
export interface LeagueStatsViewModel {
|
||||
leagueId: string;
|
||||
totalRaces: number;
|
||||
completedRaces: number;
|
||||
scheduledRaces: number;
|
||||
averageSOF?: number;
|
||||
highestSOF?: number;
|
||||
lowestSOF?: number;
|
||||
}
|
||||
|
||||
export interface LeagueAdminViewModel {
|
||||
config: LeagueConfigFormModelDto;
|
||||
members: LeagueMemberViewModel[];
|
||||
joinRequests: LeagueJoinRequestViewModel[];
|
||||
}
|
||||
|
||||
export interface CreateLeagueInput {
|
||||
name: string;
|
||||
description?: string;
|
||||
isPublic: boolean;
|
||||
maxMembers: number;
|
||||
ownerId: string;
|
||||
}
|
||||
|
||||
export interface CreateLeagueOutput {
|
||||
leagueId: string;
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
// Driver Types
|
||||
export interface DriverLeaderboardItemViewModel {
|
||||
id: string;
|
||||
name: string;
|
||||
avatarUrl?: string;
|
||||
rating: number;
|
||||
wins: number;
|
||||
races: number;
|
||||
skillLevel: string;
|
||||
}
|
||||
|
||||
export interface DriversLeaderboardViewModel {
|
||||
drivers: DriverLeaderboardItemViewModel[];
|
||||
}
|
||||
|
||||
export interface DriverStatsDto {
|
||||
totalDrivers: number;
|
||||
}
|
||||
|
||||
export interface CompleteOnboardingInput {
|
||||
iracingId: string;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
export interface CompleteOnboardingOutput {
|
||||
driverId: string;
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
export interface DriverRegistrationStatusViewModel {
|
||||
isRegistered: boolean;
|
||||
raceId: string;
|
||||
driverId: string;
|
||||
}
|
||||
|
||||
// Team Types
|
||||
export interface TeamSummaryViewModel {
|
||||
id: string;
|
||||
name: string;
|
||||
logoUrl?: string;
|
||||
memberCount: number;
|
||||
rating: number;
|
||||
}
|
||||
|
||||
export interface AllTeamsViewModel {
|
||||
teams: TeamSummaryViewModel[];
|
||||
}
|
||||
|
||||
export interface TeamMemberViewModel {
|
||||
driverId: string;
|
||||
driver?: DriverDTO;
|
||||
role: string;
|
||||
joinedAt: string;
|
||||
}
|
||||
|
||||
export interface TeamJoinRequestItemViewModel {
|
||||
id: string;
|
||||
teamId: string;
|
||||
driverId: string;
|
||||
requestedAt: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface TeamDetailsViewModel {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
logoUrl?: string;
|
||||
memberCount: number;
|
||||
ownerId: string;
|
||||
members: TeamMemberViewModel[];
|
||||
}
|
||||
|
||||
export interface TeamMembersViewModel {
|
||||
members: TeamMemberViewModel[];
|
||||
}
|
||||
|
||||
export interface TeamJoinRequestsViewModel {
|
||||
requests: TeamJoinRequestItemViewModel[];
|
||||
}
|
||||
|
||||
export interface DriverTeamViewModel {
|
||||
teamId: string;
|
||||
teamName: string;
|
||||
role: string;
|
||||
joinedAt: Date;
|
||||
}
|
||||
|
||||
export interface CreateTeamInput {
|
||||
name: string;
|
||||
description?: string;
|
||||
ownerId: string;
|
||||
}
|
||||
|
||||
export interface CreateTeamOutput {
|
||||
teamId: string;
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateTeamInput {
|
||||
name?: string;
|
||||
description?: string;
|
||||
logoUrl?: string;
|
||||
}
|
||||
|
||||
export interface UpdateTeamOutput {
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
// Race Types
|
||||
export interface RaceListItemViewModel {
|
||||
id: string;
|
||||
name: string;
|
||||
leagueId: string;
|
||||
leagueName: string;
|
||||
scheduledTime: string;
|
||||
status: string;
|
||||
trackName?: string;
|
||||
}
|
||||
|
||||
export interface AllRacesPageViewModel {
|
||||
races: RaceListItemViewModel[];
|
||||
}
|
||||
|
||||
export interface RaceStatsDto {
|
||||
totalRaces: number;
|
||||
}
|
||||
|
||||
// Sponsor Types
|
||||
export interface GetEntitySponsorshipPricingResultDto {
|
||||
mainSlotPrice: number;
|
||||
secondarySlotPrice: number;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
export interface SponsorViewModel {
|
||||
id: string;
|
||||
name: string;
|
||||
logoUrl?: string;
|
||||
websiteUrl?: string;
|
||||
}
|
||||
|
||||
export interface GetSponsorsOutput {
|
||||
sponsors: SponsorViewModel[];
|
||||
}
|
||||
|
||||
export interface CreateSponsorInput {
|
||||
name: string;
|
||||
logoUrl?: string;
|
||||
websiteUrl?: string;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export interface CreateSponsorOutput {
|
||||
sponsorId: string;
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
export interface SponsorDashboardDTO {
|
||||
sponsorId: string;
|
||||
sponsorName: string;
|
||||
totalSponsorships: number;
|
||||
activeSponsorships: number;
|
||||
totalInvestment: number;
|
||||
}
|
||||
|
||||
export interface SponsorshipDetailViewModel {
|
||||
id: string;
|
||||
leagueId: string;
|
||||
leagueName: string;
|
||||
seasonId: string;
|
||||
tier: 'main' | 'secondary';
|
||||
status: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
export interface SponsorSponsorshipsDTO {
|
||||
sponsorId: string;
|
||||
sponsorName: string;
|
||||
sponsorships: SponsorshipDetailViewModel[];
|
||||
}
|
||||
|
||||
// Media Types
|
||||
export interface RequestAvatarGenerationInput {
|
||||
driverId: string;
|
||||
style?: string;
|
||||
}
|
||||
|
||||
export interface RequestAvatarGenerationOutput {
|
||||
success: boolean;
|
||||
avatarUrl?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Analytics Types
|
||||
export interface RecordPageViewInput {
|
||||
path: string;
|
||||
userId?: string;
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
export interface RecordPageViewOutput {
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
export interface RecordEngagementInput {
|
||||
eventType: string;
|
||||
eventData?: Record<string, unknown>;
|
||||
userId?: string;
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
export interface RecordEngagementOutput {
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
// Auth Types
|
||||
export interface LoginParams {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface SignupParams {
|
||||
email: string;
|
||||
password: string;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
export interface SessionData {
|
||||
userId: string;
|
||||
email: string;
|
||||
displayName?: string;
|
||||
driverId?: string;
|
||||
isAuthenticated: boolean;
|
||||
}
|
||||
|
||||
// Payments Types
|
||||
export interface PaymentViewModel {
|
||||
id: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface GetPaymentsOutput {
|
||||
payments: PaymentViewModel[];
|
||||
}
|
||||
|
||||
export interface CreatePaymentInput {
|
||||
amount: number;
|
||||
currency: string;
|
||||
leagueId: string;
|
||||
driverId: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface CreatePaymentOutput {
|
||||
paymentId: string;
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
export interface MembershipFeeViewModel {
|
||||
leagueId: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
period: string;
|
||||
}
|
||||
|
||||
export interface MemberPaymentViewModel {
|
||||
driverId: string;
|
||||
amount: number;
|
||||
paidAt: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface GetMembershipFeesOutput {
|
||||
fees: MembershipFeeViewModel[];
|
||||
memberPayments: MemberPaymentViewModel[];
|
||||
}
|
||||
|
||||
export interface PrizeViewModel {
|
||||
id: string;
|
||||
name: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
position?: number;
|
||||
}
|
||||
|
||||
export interface GetPrizesOutput {
|
||||
prizes: PrizeViewModel[];
|
||||
}
|
||||
|
||||
export interface WalletTransactionViewModel {
|
||||
id: string;
|
||||
type: 'deposit' | 'withdrawal';
|
||||
amount: number;
|
||||
description?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface WalletViewModel {
|
||||
driverId: string;
|
||||
balance: number;
|
||||
currency: string;
|
||||
transactions: WalletTransactionViewModel[];
|
||||
}
|
||||
|
||||
export interface GetWalletOutput {
|
||||
wallet: WalletViewModel;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Base API Client
|
||||
// ============================================================================
|
||||
|
||||
class BaseApiClient {
|
||||
private baseUrl: string;
|
||||
|
||||
constructor(baseUrl: string) {
|
||||
this.baseUrl = baseUrl;
|
||||
}
|
||||
|
||||
private async request<T>(method: string, path: string, data?: object): Promise<T | void> {
|
||||
protected async request<T>(method: string, path: string, data?: object): Promise<T> {
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
@@ -13,6 +495,7 @@ export class ApiClient {
|
||||
const config: RequestInit = {
|
||||
method,
|
||||
headers,
|
||||
credentials: 'include', // Include cookies for auth
|
||||
};
|
||||
|
||||
if (data) {
|
||||
@@ -22,41 +505,441 @@ export class ApiClient {
|
||||
const response = await fetch(`${this.baseUrl}${path}`, config);
|
||||
|
||||
if (!response.ok) {
|
||||
// Attempt to read error message from response body
|
||||
let errorData: any;
|
||||
let errorData: { message?: string } = { message: response.statusText };
|
||||
try {
|
||||
errorData = await response.json();
|
||||
} catch (e) {
|
||||
errorData = { message: response.statusText };
|
||||
} catch {
|
||||
// Keep default error message
|
||||
}
|
||||
throw new Error(errorData.message || `API request failed with status ${response.status}`);
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
return text ? JSON.parse(text) : undefined;
|
||||
if (!text) {
|
||||
return null as T;
|
||||
}
|
||||
return JSON.parse(text) as T;
|
||||
}
|
||||
|
||||
get<T>(path: string): Promise<T | void> {
|
||||
protected get<T>(path: string): Promise<T> {
|
||||
return this.request<T>('GET', path);
|
||||
}
|
||||
|
||||
post<T>(path: string, data: object): Promise<T | void> {
|
||||
protected post<T>(path: string, data: object): Promise<T> {
|
||||
return this.request<T>('POST', path, data);
|
||||
}
|
||||
|
||||
put<T>(path: string, data: object): Promise<T | void> {
|
||||
protected put<T>(path: string, data: object): Promise<T> {
|
||||
return this.request<T>('PUT', path, data);
|
||||
}
|
||||
|
||||
delete<T>(path: string): Promise<T | void> {
|
||||
protected delete<T>(path: string): Promise<T> {
|
||||
return this.request<T>('DELETE', path);
|
||||
}
|
||||
|
||||
patch<T>(path: string, data: object): Promise<T | void> {
|
||||
protected patch<T>(path: string, data: object): Promise<T> {
|
||||
return this.request<T>('PATCH', path, data);
|
||||
}
|
||||
}
|
||||
|
||||
// Instantiate the API client with your backend's base URL
|
||||
// You might want to get this from an environment variable
|
||||
export const api = new ApiClient(process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001');
|
||||
// ============================================================================
|
||||
// Domain-Specific API Clients
|
||||
// ============================================================================
|
||||
|
||||
class LeaguesApiClient extends BaseApiClient {
|
||||
constructor(baseUrl: string) {
|
||||
super(baseUrl);
|
||||
}
|
||||
|
||||
/** Get all leagues with capacity information */
|
||||
getAllWithCapacity(): Promise<AllLeaguesWithCapacityViewModel> {
|
||||
return this.get<AllLeaguesWithCapacityViewModel>('/leagues/all-with-capacity');
|
||||
}
|
||||
|
||||
/** Get total number of leagues */
|
||||
getTotal(): Promise<LeagueStatsDto> {
|
||||
return this.get<LeagueStatsDto>('/leagues/total-leagues');
|
||||
}
|
||||
|
||||
/** Get league standings */
|
||||
getStandings(leagueId: string): Promise<LeagueStandingsViewModel> {
|
||||
return this.get<LeagueStandingsViewModel>(`/leagues/${leagueId}/standings`);
|
||||
}
|
||||
|
||||
/** Get league schedule */
|
||||
getSchedule(leagueId: string): Promise<LeagueScheduleViewModel> {
|
||||
return this.get<LeagueScheduleViewModel>(`/leagues/${leagueId}/schedule`);
|
||||
}
|
||||
|
||||
/** Get league stats */
|
||||
getStats(leagueId: string): Promise<LeagueStatsViewModel> {
|
||||
return this.get<LeagueStatsViewModel>(`/leagues/${leagueId}/stats`);
|
||||
}
|
||||
|
||||
/** Get league memberships */
|
||||
getMemberships(leagueId: string): Promise<LeagueMembershipsViewModel> {
|
||||
return this.get<LeagueMembershipsViewModel>(`/leagues/${leagueId}/memberships`);
|
||||
}
|
||||
|
||||
/** Get league join requests */
|
||||
getJoinRequests(leagueId: string): Promise<LeagueJoinRequestViewModel[]> {
|
||||
return this.get<LeagueJoinRequestViewModel[]>(`/leagues/${leagueId}/join-requests`);
|
||||
}
|
||||
|
||||
/** Approve a join request */
|
||||
approveJoinRequest(leagueId: string, requestId: string): Promise<{ success: boolean }> {
|
||||
return this.post<{ success: boolean }>(`/leagues/${leagueId}/join-requests/approve`, { requestId });
|
||||
}
|
||||
|
||||
/** Reject a join request */
|
||||
rejectJoinRequest(leagueId: string, requestId: string, reason?: string): Promise<{ success: boolean }> {
|
||||
return this.post<{ success: boolean }>(`/leagues/${leagueId}/join-requests/reject`, { requestId, reason });
|
||||
}
|
||||
|
||||
/** Get league admin permissions */
|
||||
getAdminPermissions(leagueId: string, performerDriverId: string): Promise<LeagueAdminPermissionsViewModel> {
|
||||
return this.get<LeagueAdminPermissionsViewModel>(`/leagues/${leagueId}/permissions/${performerDriverId}`);
|
||||
}
|
||||
|
||||
/** Get league owner summary */
|
||||
getOwnerSummary(leagueId: string, ownerId: string): Promise<LeagueOwnerSummaryViewModel | null> {
|
||||
return this.get<LeagueOwnerSummaryViewModel | null>(`/leagues/${leagueId}/owner-summary/${ownerId}`);
|
||||
}
|
||||
|
||||
/** Get league full config */
|
||||
getConfig(leagueId: string): Promise<LeagueConfigFormModelDto | null> {
|
||||
return this.get<LeagueConfigFormModelDto | null>(`/leagues/${leagueId}/config`);
|
||||
}
|
||||
|
||||
/** Get league protests */
|
||||
getProtests(leagueId: string): Promise<LeagueAdminProtestsViewModel> {
|
||||
return this.get<LeagueAdminProtestsViewModel>(`/leagues/${leagueId}/protests`);
|
||||
}
|
||||
|
||||
/** Get league seasons */
|
||||
getSeasons(leagueId: string): Promise<LeagueSeasonSummaryViewModel[]> {
|
||||
return this.get<LeagueSeasonSummaryViewModel[]>(`/leagues/${leagueId}/seasons`);
|
||||
}
|
||||
|
||||
/** Get league admin data */
|
||||
getAdmin(leagueId: string): Promise<LeagueAdminViewModel> {
|
||||
return this.get<LeagueAdminViewModel>(`/leagues/${leagueId}/admin`);
|
||||
}
|
||||
|
||||
/** Create a new league */
|
||||
create(input: CreateLeagueInput): Promise<CreateLeagueOutput> {
|
||||
return this.post<CreateLeagueOutput>('/leagues', input);
|
||||
}
|
||||
|
||||
/** Remove a member from league */
|
||||
removeMember(leagueId: string, performerDriverId: string, targetDriverId: string): Promise<{ success: boolean }> {
|
||||
return this.patch<{ success: boolean }>(`/leagues/${leagueId}/members/${targetDriverId}/remove`, { performerDriverId });
|
||||
}
|
||||
|
||||
/** Update member role */
|
||||
updateMemberRole(leagueId: string, performerDriverId: string, targetDriverId: string, newRole: string): Promise<{ success: boolean }> {
|
||||
return this.patch<{ success: boolean }>(`/leagues/${leagueId}/members/${targetDriverId}/role`, { performerDriverId, newRole });
|
||||
}
|
||||
}
|
||||
|
||||
class DriversApiClient extends BaseApiClient {
|
||||
constructor(baseUrl: string) {
|
||||
super(baseUrl);
|
||||
}
|
||||
|
||||
/** Get drivers leaderboard */
|
||||
getLeaderboard(): Promise<DriversLeaderboardViewModel> {
|
||||
return this.get<DriversLeaderboardViewModel>('/drivers/leaderboard');
|
||||
}
|
||||
|
||||
/** Get total number of drivers */
|
||||
getTotal(): Promise<DriverStatsDto> {
|
||||
return this.get<DriverStatsDto>('/drivers/total-drivers');
|
||||
}
|
||||
|
||||
/** Get current driver (based on session) */
|
||||
getCurrent(): Promise<DriverDTO | null> {
|
||||
return this.get<DriverDTO | null>('/drivers/current');
|
||||
}
|
||||
|
||||
/** Complete driver onboarding */
|
||||
completeOnboarding(input: CompleteOnboardingInput): Promise<CompleteOnboardingOutput> {
|
||||
return this.post<CompleteOnboardingOutput>('/drivers/complete-onboarding', input);
|
||||
}
|
||||
|
||||
/** Get driver registration status for a race */
|
||||
getRegistrationStatus(driverId: string, raceId: string): Promise<DriverRegistrationStatusViewModel> {
|
||||
return this.get<DriverRegistrationStatusViewModel>(`/drivers/${driverId}/races/${raceId}/registration-status`);
|
||||
}
|
||||
}
|
||||
|
||||
class TeamsApiClient extends BaseApiClient {
|
||||
constructor(baseUrl: string) {
|
||||
super(baseUrl);
|
||||
}
|
||||
|
||||
/** Get all teams */
|
||||
getAll(): Promise<AllTeamsViewModel> {
|
||||
return this.get<AllTeamsViewModel>('/teams/all');
|
||||
}
|
||||
|
||||
/** Get team details */
|
||||
getDetails(teamId: string): Promise<TeamDetailsViewModel | null> {
|
||||
return this.get<TeamDetailsViewModel | null>(`/teams/${teamId}`);
|
||||
}
|
||||
|
||||
/** Get team members */
|
||||
getMembers(teamId: string): Promise<TeamMembersViewModel> {
|
||||
return this.get<TeamMembersViewModel>(`/teams/${teamId}/members`);
|
||||
}
|
||||
|
||||
/** Get team join requests */
|
||||
getJoinRequests(teamId: string): Promise<TeamJoinRequestsViewModel> {
|
||||
return this.get<TeamJoinRequestsViewModel>(`/teams/${teamId}/join-requests`);
|
||||
}
|
||||
|
||||
/** Approve a join request */
|
||||
approveJoinRequest(teamId: string, requestId: string): Promise<{ success: boolean }> {
|
||||
return this.post<{ success: boolean }>(`/teams/${teamId}/join-requests/approve`, { requestId });
|
||||
}
|
||||
|
||||
/** Reject a join request */
|
||||
rejectJoinRequest(teamId: string, requestId: string, reason?: string): Promise<{ success: boolean }> {
|
||||
return this.post<{ success: boolean }>(`/teams/${teamId}/join-requests/reject`, { requestId, reason });
|
||||
}
|
||||
|
||||
/** Create a new team */
|
||||
create(input: CreateTeamInput): Promise<CreateTeamOutput> {
|
||||
return this.post<CreateTeamOutput>('/teams', input);
|
||||
}
|
||||
|
||||
/** Update team */
|
||||
update(teamId: string, input: UpdateTeamInput): Promise<UpdateTeamOutput> {
|
||||
return this.patch<UpdateTeamOutput>(`/teams/${teamId}`, input);
|
||||
}
|
||||
|
||||
/** Get driver's team */
|
||||
getDriverTeam(driverId: string): Promise<DriverTeamViewModel | null> {
|
||||
return this.get<DriverTeamViewModel | null>(`/teams/driver/${driverId}`);
|
||||
}
|
||||
}
|
||||
|
||||
class RacesApiClient extends BaseApiClient {
|
||||
constructor(baseUrl: string) {
|
||||
super(baseUrl);
|
||||
}
|
||||
|
||||
/** Get all races */
|
||||
getAll(): Promise<AllRacesPageViewModel> {
|
||||
return this.get<AllRacesPageViewModel>('/races/all');
|
||||
}
|
||||
|
||||
/** Get total number of races */
|
||||
getTotal(): Promise<RaceStatsDto> {
|
||||
return this.get<RaceStatsDto>('/races/total-races');
|
||||
}
|
||||
}
|
||||
|
||||
class SponsorsApiClient extends BaseApiClient {
|
||||
constructor(baseUrl: string) {
|
||||
super(baseUrl);
|
||||
}
|
||||
|
||||
/** Get sponsorship pricing */
|
||||
getPricing(): Promise<GetEntitySponsorshipPricingResultDto> {
|
||||
return this.get<GetEntitySponsorshipPricingResultDto>('/sponsors/pricing');
|
||||
}
|
||||
|
||||
/** Get all sponsors */
|
||||
getAll(): Promise<GetSponsorsOutput> {
|
||||
return this.get<GetSponsorsOutput>('/sponsors');
|
||||
}
|
||||
|
||||
/** Create a new sponsor */
|
||||
create(input: CreateSponsorInput): Promise<CreateSponsorOutput> {
|
||||
return this.post<CreateSponsorOutput>('/sponsors', input);
|
||||
}
|
||||
|
||||
/** Get sponsor dashboard */
|
||||
getDashboard(sponsorId: string): Promise<SponsorDashboardDTO | null> {
|
||||
return this.get<SponsorDashboardDTO | null>(`/sponsors/dashboard/${sponsorId}`);
|
||||
}
|
||||
|
||||
/** Get sponsor sponsorships */
|
||||
getSponsorships(sponsorId: string): Promise<SponsorSponsorshipsDTO | null> {
|
||||
return this.get<SponsorSponsorshipsDTO | null>(`/sponsors/${sponsorId}/sponsorships`);
|
||||
}
|
||||
}
|
||||
|
||||
class MediaApiClient extends BaseApiClient {
|
||||
constructor(baseUrl: string) {
|
||||
super(baseUrl);
|
||||
}
|
||||
|
||||
/** Request avatar generation */
|
||||
requestAvatarGeneration(input: RequestAvatarGenerationInput): Promise<RequestAvatarGenerationOutput> {
|
||||
return this.post<RequestAvatarGenerationOutput>('/media/avatar/generate', input);
|
||||
}
|
||||
}
|
||||
|
||||
class AnalyticsApiClient extends BaseApiClient {
|
||||
constructor(baseUrl: string) {
|
||||
super(baseUrl);
|
||||
}
|
||||
|
||||
/** Record a page view */
|
||||
recordPageView(input: RecordPageViewInput): Promise<RecordPageViewOutput> {
|
||||
return this.post<RecordPageViewOutput>('/analytics/page-view', input);
|
||||
}
|
||||
|
||||
/** Record an engagement event */
|
||||
recordEngagement(input: RecordEngagementInput): Promise<RecordEngagementOutput> {
|
||||
return this.post<RecordEngagementOutput>('/analytics/engagement', input);
|
||||
}
|
||||
}
|
||||
|
||||
class AuthApiClient extends BaseApiClient {
|
||||
constructor(baseUrl: string) {
|
||||
super(baseUrl);
|
||||
}
|
||||
|
||||
/** Sign up with email */
|
||||
signup(params: SignupParams): Promise<SessionData> {
|
||||
return this.post<SessionData>('/auth/signup', params);
|
||||
}
|
||||
|
||||
/** Login with email */
|
||||
login(params: LoginParams): Promise<SessionData> {
|
||||
return this.post<SessionData>('/auth/login', params);
|
||||
}
|
||||
|
||||
/** Get current session */
|
||||
getSession(): Promise<SessionData | null> {
|
||||
return this.get<SessionData | null>('/auth/session');
|
||||
}
|
||||
|
||||
/** Logout */
|
||||
logout(): Promise<void> {
|
||||
return this.post<void>('/auth/logout', {});
|
||||
}
|
||||
|
||||
/** Start iRacing auth redirect */
|
||||
getIracingAuthUrl(returnTo?: string): string {
|
||||
const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001';
|
||||
const params = returnTo ? `?returnTo=${encodeURIComponent(returnTo)}` : '';
|
||||
return `${baseUrl}/auth/iracing/start${params}`;
|
||||
}
|
||||
}
|
||||
|
||||
class PaymentsApiClient extends BaseApiClient {
|
||||
constructor(baseUrl: string) {
|
||||
super(baseUrl);
|
||||
}
|
||||
|
||||
/** Get payments */
|
||||
getPayments(leagueId?: string, driverId?: string): Promise<GetPaymentsOutput> {
|
||||
const params = new URLSearchParams();
|
||||
if (leagueId) params.append('leagueId', leagueId);
|
||||
if (driverId) params.append('driverId', driverId);
|
||||
const query = params.toString();
|
||||
return this.get<GetPaymentsOutput>(`/payments${query ? `?${query}` : ''}`);
|
||||
}
|
||||
|
||||
/** Create a payment */
|
||||
createPayment(input: CreatePaymentInput): Promise<CreatePaymentOutput> {
|
||||
return this.post<CreatePaymentOutput>('/payments', input);
|
||||
}
|
||||
|
||||
/** Update payment status */
|
||||
updatePaymentStatus(paymentId: string, status: string): Promise<{ success: boolean }> {
|
||||
return this.patch<{ success: boolean }>('/payments/status', { paymentId, status });
|
||||
}
|
||||
|
||||
/** Get membership fees */
|
||||
getMembershipFees(leagueId: string): Promise<GetMembershipFeesOutput> {
|
||||
return this.get<GetMembershipFeesOutput>(`/payments/membership-fees?leagueId=${leagueId}`);
|
||||
}
|
||||
|
||||
/** Upsert membership fee */
|
||||
upsertMembershipFee(leagueId: string, amount: number, currency: string, period: string): Promise<{ success: boolean }> {
|
||||
return this.post<{ success: boolean }>('/payments/membership-fees', { leagueId, amount, currency, period });
|
||||
}
|
||||
|
||||
/** Update member payment */
|
||||
updateMemberPayment(leagueId: string, driverId: string, amount: number, paidAt: string): Promise<{ success: boolean }> {
|
||||
return this.patch<{ success: boolean }>('/payments/membership-fees/member-payment', { leagueId, driverId, amount, paidAt });
|
||||
}
|
||||
|
||||
/** Get prizes */
|
||||
getPrizes(leagueId?: string, seasonId?: string): Promise<GetPrizesOutput> {
|
||||
const params = new URLSearchParams();
|
||||
if (leagueId) params.append('leagueId', leagueId);
|
||||
if (seasonId) params.append('seasonId', seasonId);
|
||||
const query = params.toString();
|
||||
return this.get<GetPrizesOutput>(`/payments/prizes${query ? `?${query}` : ''}`);
|
||||
}
|
||||
|
||||
/** Create a prize */
|
||||
createPrize(name: string, amount: number, currency: string, leagueId: string, position?: number): Promise<{ prizeId: string; success: boolean }> {
|
||||
return this.post<{ prizeId: string; success: boolean }>('/payments/prizes', { name, amount, currency, leagueId, position });
|
||||
}
|
||||
|
||||
/** Award a prize */
|
||||
awardPrize(prizeId: string, driverId: string): Promise<{ success: boolean }> {
|
||||
return this.patch<{ success: boolean }>('/payments/prizes/award', { prizeId, driverId });
|
||||
}
|
||||
|
||||
/** Delete a prize */
|
||||
deletePrize(prizeId: string): Promise<{ success: boolean }> {
|
||||
return this.delete<{ success: boolean }>(`/payments/prizes?prizeId=${prizeId}`);
|
||||
}
|
||||
|
||||
/** Get wallet */
|
||||
getWallet(driverId: string): Promise<GetWalletOutput> {
|
||||
return this.get<GetWalletOutput>(`/payments/wallets?driverId=${driverId}`);
|
||||
}
|
||||
|
||||
/** Process wallet transaction */
|
||||
processWalletTransaction(driverId: string, type: 'deposit' | 'withdrawal', amount: number, description?: string): Promise<{ success: boolean }> {
|
||||
return this.post<{ success: boolean }>('/payments/wallets/transactions', { driverId, type, amount, description });
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Main API Client with Domain Namespaces
|
||||
// ============================================================================
|
||||
|
||||
class ApiClient {
|
||||
public readonly leagues: LeaguesApiClient;
|
||||
public readonly drivers: DriversApiClient;
|
||||
public readonly teams: TeamsApiClient;
|
||||
public readonly races: RacesApiClient;
|
||||
public readonly sponsors: SponsorsApiClient;
|
||||
public readonly media: MediaApiClient;
|
||||
public readonly analytics: AnalyticsApiClient;
|
||||
public readonly auth: AuthApiClient;
|
||||
public readonly payments: PaymentsApiClient;
|
||||
|
||||
constructor(baseUrl: string) {
|
||||
this.leagues = new LeaguesApiClient(baseUrl);
|
||||
this.drivers = new DriversApiClient(baseUrl);
|
||||
this.teams = new TeamsApiClient(baseUrl);
|
||||
this.races = new RacesApiClient(baseUrl);
|
||||
this.sponsors = new SponsorsApiClient(baseUrl);
|
||||
this.media = new MediaApiClient(baseUrl);
|
||||
this.analytics = new AnalyticsApiClient(baseUrl);
|
||||
this.auth = new AuthApiClient(baseUrl);
|
||||
this.payments = new PaymentsApiClient(baseUrl);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Singleton Instance
|
||||
// ============================================================================
|
||||
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001';
|
||||
|
||||
export const apiClient = new ApiClient(API_BASE_URL);
|
||||
|
||||
// Default export for convenience
|
||||
export default apiClient;
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { LeagueModule } from './modules/league/LeagueModule';
|
||||
import { DriverModule } from './modules/driver/DriverModule';
|
||||
import { TeamModule } from './modules/team/TeamModule';
|
||||
import { RaceModule } from './modules/race/RaceModule';
|
||||
import { SponsorModule } from './modules/sponsor/SponsorModule';
|
||||
import { AuthModule } from './modules/auth/AuthModule';
|
||||
import { MediaModule } from './modules/media/MediaModule';
|
||||
import { AnalyticsModule } from './modules/analytics/AnalyticsModule';
|
||||
import { LoggingModule } from './modules/logging/LoggingModule';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
LoggingModule,
|
||||
LeagueModule,
|
||||
DriverModule,
|
||||
TeamModule,
|
||||
RaceModule,
|
||||
SponsorModule,
|
||||
AuthModule,
|
||||
MediaModule,
|
||||
AnalyticsModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
@@ -6,7 +6,7 @@ import { useAuth } from '@/lib/auth/AuthContext';
|
||||
* Returns the effective driver ID for the current session.
|
||||
*
|
||||
* Prefers the authenticated user's primaryDriverId when available,
|
||||
* otherwise falls back to the demo default used across the alpha site.
|
||||
* otherwise returns an empty string (user must log in to have a driver).
|
||||
*/
|
||||
export function useEffectiveDriverId(): string {
|
||||
const { session } = useAuth();
|
||||
@@ -16,36 +16,11 @@ export function useEffectiveDriverId(): string {
|
||||
}
|
||||
| undefined;
|
||||
|
||||
// In alpha mode, if the user has no bound driver yet, fall back to the
|
||||
// first seeded driver from the in-memory repository instead of a hardcoded ID.
|
||||
// Return the user's primary driver ID if available
|
||||
if (user?.primaryDriverId) {
|
||||
return user.primaryDriverId;
|
||||
}
|
||||
|
||||
try {
|
||||
// Lazy-load to avoid importing DI facade at module evaluation time
|
||||
const { getDriverRepository } =
|
||||
require('./di-container') as typeof import('./di-container');
|
||||
const repo = getDriverRepository();
|
||||
|
||||
interface DriverRepositoryWithSyncFindAll {
|
||||
findAllSync?: () => Array<{ id: string }>;
|
||||
}
|
||||
|
||||
// In alpha/demo mode the in-memory repository exposes a synchronous finder;
|
||||
// access it via a safe dynamic lookup to keep typing compatible with the port.
|
||||
const repoWithSync = repo as DriverRepositoryWithSyncFindAll;
|
||||
const allDrivers = repoWithSync.findAllSync?.();
|
||||
if (Array.isArray(allDrivers) && allDrivers.length > 0) {
|
||||
const firstDriver = allDrivers[0];
|
||||
if (firstDriver) {
|
||||
return firstDriver.id;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore and fall back to legacy default below
|
||||
}
|
||||
|
||||
// Legacy fallback: preserved only as a last resort for demo
|
||||
// No driver ID available - user needs to log in or complete onboarding
|
||||
return '';
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { INestApplicationContext } from '@nestjs/common';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
let appContext: INestApplicationContext | null = null;
|
||||
|
||||
export async function initializeDIContainer(): Promise<void> {
|
||||
if (appContext) {
|
||||
return; // Already initialized
|
||||
}
|
||||
|
||||
appContext = await NestFactory.createApplicationContext(AppModule);
|
||||
}
|
||||
|
||||
export function getDIContainer(): INestApplicationContext {
|
||||
if (!appContext) {
|
||||
throw new Error('DI container not initialized. Call initializeDIContainer() first.');
|
||||
}
|
||||
return appContext;
|
||||
}
|
||||
|
||||
export async function getService<T>(token: string | symbol): Promise<T> {
|
||||
const container = getDIContainer();
|
||||
return container.get<T>(token);
|
||||
}
|
||||
@@ -1,81 +1,82 @@
|
||||
'use client';
|
||||
|
||||
import type {
|
||||
LeagueMembership as DomainLeagueMembership,
|
||||
MembershipRole,
|
||||
MembershipStatus,
|
||||
} from '@gridpilot/racing/domain/entities/LeagueMembership';
|
||||
import { apiClient } from '@/lib/apiClient';
|
||||
|
||||
/**
|
||||
* Lightweight league membership model mirroring the domain type but with
|
||||
* a stringified joinedAt for easier UI formatting.
|
||||
* Membership role types - these are defined locally to avoid core dependencies
|
||||
*/
|
||||
export interface LeagueMembership extends Omit<DomainLeagueMembership, 'joinedAt'> {
|
||||
export type MembershipRole = 'owner' | 'admin' | 'steward' | 'member';
|
||||
export type MembershipStatus = 'active' | 'inactive' | 'pending';
|
||||
|
||||
/**
|
||||
* Lightweight league membership model for UI.
|
||||
*/
|
||||
export interface LeagueMembership {
|
||||
id: string;
|
||||
leagueId: string;
|
||||
driverId: string;
|
||||
role: MembershipRole;
|
||||
status: MembershipStatus;
|
||||
joinedAt: string;
|
||||
}
|
||||
|
||||
// In-memory cache for memberships (populated via API calls)
|
||||
const leagueMemberships = new Map<string, LeagueMembership[]>();
|
||||
|
||||
/**
|
||||
* Initialize league memberships once from the in-memory league membership repository
|
||||
* that is seeded via the static racing seed in the DI container.
|
||||
*
|
||||
* This avoids depending on raw testing-support seed exports and keeps all demo
|
||||
* membership data flowing through the same in-memory repositories used elsewhere.
|
||||
* Get a specific membership from cache.
|
||||
*/
|
||||
(async function initializeLeagueMembershipsFromRepository() {
|
||||
if (leagueMemberships.size > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { getLeagueRepository, getLeagueMembershipRepository } = await import('./di-container');
|
||||
const leagueRepo = getLeagueRepository();
|
||||
const membershipRepo = getLeagueMembershipRepository();
|
||||
|
||||
const allLeagues = await leagueRepo.findAll();
|
||||
const byLeague = new Map<string, LeagueMembership[]>();
|
||||
|
||||
for (const league of allLeagues) {
|
||||
const memberships = await membershipRepo.getLeagueMembers(league.id);
|
||||
|
||||
const mapped: LeagueMembership[] = memberships.map((membership) => ({
|
||||
id: membership.id,
|
||||
leagueId: membership.leagueId,
|
||||
driverId: membership.driverId,
|
||||
role: membership.role,
|
||||
status: membership.status,
|
||||
joinedAt:
|
||||
membership.joinedAt instanceof Date
|
||||
? membership.joinedAt.toISOString()
|
||||
: new Date().toISOString(),
|
||||
}));
|
||||
|
||||
byLeague.set(league.id, mapped);
|
||||
}
|
||||
|
||||
for (const [leagueId, list] of byLeague.entries()) {
|
||||
leagueMemberships.set(leagueId, list);
|
||||
}
|
||||
} catch (error) {
|
||||
// In alpha/demo mode we tolerate failures here; callers will see empty memberships.
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed to initialize league memberships from repository', error);
|
||||
}
|
||||
})();
|
||||
|
||||
export function getMembership(leagueId: string, driverId: string): LeagueMembership | null {
|
||||
const list = leagueMemberships.get(leagueId);
|
||||
if (!list) return null;
|
||||
return list.find((m) => m.driverId === driverId) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all members of a league from cache.
|
||||
*/
|
||||
export function getLeagueMembers(leagueId: string): LeagueMembership[] {
|
||||
return [...(leagueMemberships.get(leagueId) ?? [])];
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a driver's primary league from in-memory league memberships.
|
||||
* Fetch and cache memberships for a league via API.
|
||||
*/
|
||||
export async function fetchLeagueMemberships(leagueId: string): Promise<LeagueMembership[]> {
|
||||
try {
|
||||
const result = await apiClient.leagues.getMemberships(leagueId);
|
||||
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 as MembershipRole,
|
||||
status: 'active' as MembershipStatus, // Assume active since API returns current members
|
||||
joinedAt: member.joinedAt,
|
||||
}));
|
||||
setLeagueMemberships(leagueId, memberships);
|
||||
return memberships;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch league memberships:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set memberships in cache (for use after API calls).
|
||||
*/
|
||||
export function setLeagueMemberships(leagueId: string, memberships: LeagueMembership[]): void {
|
||||
leagueMemberships.set(leagueId, memberships);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cached memberships for a league.
|
||||
*/
|
||||
export function clearLeagueMemberships(leagueId: string): void {
|
||||
leagueMemberships.delete(leagueId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a driver's primary league from cached memberships.
|
||||
* Prefers any active membership and returns the first matching league.
|
||||
*/
|
||||
export function getPrimaryLeagueIdForDriver(driverId: string): string | null {
|
||||
@@ -87,10 +88,11 @@ export function getPrimaryLeagueIdForDriver(driverId: string): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a driver is owner or admin of a league.
|
||||
*/
|
||||
export function isOwnerOrAdmin(leagueId: string, driverId: string): boolean {
|
||||
const membership = getMembership(leagueId, driverId);
|
||||
if (!membership) return false;
|
||||
return membership.role === 'owner' || membership.role === 'admin';
|
||||
}
|
||||
|
||||
export type { MembershipRole, MembershipStatus };
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { MembershipRole } from '@gridpilot/racing/domain/entities/LeagueMembership';
|
||||
|
||||
/**
|
||||
* League role types - defined locally to avoid core dependencies
|
||||
*/
|
||||
export type MembershipRole = 'owner' | 'admin' | 'steward' | 'member';
|
||||
export type LeagueRole = MembershipRole;
|
||||
|
||||
export function isLeagueOwnerRole(role: LeagueRole): boolean {
|
||||
|
||||
@@ -1,20 +1,64 @@
|
||||
import type {
|
||||
LeagueConfigFormModel,
|
||||
} from '@gridpilot/racing/application';
|
||||
import type {
|
||||
CreateLeagueWithSeasonAndScoringCommand,
|
||||
CreateLeagueWithSeasonAndScoringResultDTO,
|
||||
} from '@gridpilot/racing/application/use-cases/CreateLeagueWithSeasonAndScoringUseCase';
|
||||
import {
|
||||
getDriverRepository,
|
||||
getCreateLeagueWithSeasonAndScoringUseCase,
|
||||
} from '@/lib/di-container';
|
||||
import { LeagueName } from '@gridpilot/racing/domain/value-objects/LeagueName';
|
||||
import { LeagueDescription } from '@gridpilot/racing/domain/value-objects/LeagueDescription';
|
||||
import { GameConstraints } from '@gridpilot/racing/domain/value-objects/GameConstraints';
|
||||
/**
|
||||
* League Wizard Service - Refactored to use API client
|
||||
*
|
||||
* This service handles league creation wizard logic without direct core dependencies.
|
||||
*/
|
||||
|
||||
import { apiClient } from '@/lib/apiClient';
|
||||
|
||||
export type WizardStep = 1 | 2 | 3 | 4 | 5 | 6 | 7;
|
||||
|
||||
export interface LeagueConfigFormModel {
|
||||
leagueId?: string;
|
||||
basics: {
|
||||
name: string;
|
||||
description?: string;
|
||||
visibility: 'public' | 'private' | 'unlisted';
|
||||
gameId: string;
|
||||
};
|
||||
structure: {
|
||||
mode: 'solo' | 'fixedTeams';
|
||||
maxDrivers?: number;
|
||||
maxTeams?: number;
|
||||
driversPerTeam?: number;
|
||||
};
|
||||
championships: {
|
||||
enableDriverChampionship: boolean;
|
||||
enableTeamChampionship: boolean;
|
||||
enableNationsChampionship: boolean;
|
||||
enableTrophyChampionship: boolean;
|
||||
};
|
||||
scoring: {
|
||||
patternId?: string;
|
||||
customScoringEnabled?: boolean;
|
||||
};
|
||||
dropPolicy: {
|
||||
strategy: 'none' | 'bestNResults' | 'dropWorstN';
|
||||
n?: number;
|
||||
};
|
||||
timings: {
|
||||
practiceMinutes?: number;
|
||||
qualifyingMinutes?: number;
|
||||
sprintRaceMinutes?: number;
|
||||
mainRaceMinutes?: number;
|
||||
sessionCount?: number;
|
||||
roundsPlanned?: number;
|
||||
raceDayOfWeek?: number;
|
||||
raceTimeUtc?: string;
|
||||
};
|
||||
stewarding: {
|
||||
decisionMode: 'owner_only' | 'admin_vote' | 'steward_panel';
|
||||
requiredVotes?: number;
|
||||
requireDefense: boolean;
|
||||
defenseTimeLimit: number;
|
||||
voteTimeLimit: number;
|
||||
protestDeadlineHours: number;
|
||||
stewardingClosesHours: number;
|
||||
notifyAccusedOnProtest: boolean;
|
||||
notifyOnVoteRequired: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface WizardErrors {
|
||||
basics?: {
|
||||
name?: string;
|
||||
@@ -51,16 +95,18 @@ export function validateLeagueWizardStep(
|
||||
if (step === 1) {
|
||||
const basicsErrors: NonNullable<WizardErrors['basics']> = {};
|
||||
|
||||
// Use LeagueName value object for validation
|
||||
const nameValidation = LeagueName.validate(form.basics.name);
|
||||
if (!nameValidation.valid && nameValidation.error) {
|
||||
basicsErrors.name = nameValidation.error;
|
||||
// Basic name validation
|
||||
if (!form.basics.name || form.basics.name.trim().length === 0) {
|
||||
basicsErrors.name = 'League name is required';
|
||||
} else if (form.basics.name.length < 3) {
|
||||
basicsErrors.name = 'League name must be at least 3 characters';
|
||||
} else if (form.basics.name.length > 100) {
|
||||
basicsErrors.name = 'League name must be less than 100 characters';
|
||||
}
|
||||
|
||||
// Use LeagueDescription value object for validation
|
||||
const descValidation = LeagueDescription.validate(form.basics.description ?? '');
|
||||
if (!descValidation.valid && descValidation.error) {
|
||||
basicsErrors.description = descValidation.error;
|
||||
// Description validation
|
||||
if (form.basics.description && form.basics.description.length > 500) {
|
||||
basicsErrors.description = 'Description must be less than 500 characters';
|
||||
}
|
||||
|
||||
if (Object.keys(basicsErrors).length > 0) {
|
||||
@@ -84,47 +130,23 @@ export function validateLeagueWizardStep(
|
||||
// Step 3: Structure (solo vs teams)
|
||||
if (step === 3) {
|
||||
const structureErrors: NonNullable<WizardErrors['structure']> = {};
|
||||
const gameConstraints = GameConstraints.forGame(form.basics.gameId);
|
||||
|
||||
if (form.structure.mode === 'solo') {
|
||||
if (!form.structure.maxDrivers || form.structure.maxDrivers <= 0) {
|
||||
structureErrors.maxDrivers =
|
||||
'Max drivers must be greater than 0 for solo leagues';
|
||||
} else {
|
||||
// Validate against game constraints
|
||||
const driverValidation = gameConstraints.validateDriverCount(
|
||||
form.structure.maxDrivers,
|
||||
);
|
||||
if (!driverValidation.valid && driverValidation.error) {
|
||||
structureErrors.maxDrivers = driverValidation.error;
|
||||
}
|
||||
} else if (form.structure.maxDrivers > 100) {
|
||||
structureErrors.maxDrivers = 'Max drivers cannot exceed 100';
|
||||
}
|
||||
} else if (form.structure.mode === 'fixedTeams') {
|
||||
if (!form.structure.maxTeams || form.structure.maxTeams <= 0) {
|
||||
structureErrors.maxTeams =
|
||||
'Max teams must be greater than 0 for team leagues';
|
||||
} else {
|
||||
// Validate against game constraints
|
||||
const teamValidation = gameConstraints.validateTeamCount(
|
||||
form.structure.maxTeams,
|
||||
);
|
||||
if (!teamValidation.valid && teamValidation.error) {
|
||||
structureErrors.maxTeams = teamValidation.error;
|
||||
}
|
||||
}
|
||||
if (!form.structure.driversPerTeam || form.structure.driversPerTeam <= 0) {
|
||||
structureErrors.driversPerTeam =
|
||||
'Drivers per team must be greater than 0';
|
||||
}
|
||||
// Validate total driver count
|
||||
if (form.structure.maxDrivers) {
|
||||
const driverValidation = gameConstraints.validateDriverCount(
|
||||
form.structure.maxDrivers,
|
||||
);
|
||||
if (!driverValidation.valid && driverValidation.error) {
|
||||
structureErrors.maxDrivers = driverValidation.error;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Object.keys(structureErrors).length > 0) {
|
||||
errors.structure = structureErrors;
|
||||
@@ -210,24 +232,27 @@ export function hasWizardErrors(errors: WizardErrors): boolean {
|
||||
});
|
||||
}
|
||||
|
||||
export interface CreateLeagueResult {
|
||||
leagueId: string;
|
||||
seasonId?: string;
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure mapping from LeagueConfigFormModel to the creation command.
|
||||
* Driver ownership is handled by the caller.
|
||||
* Create a league via API.
|
||||
*/
|
||||
export function buildCreateLeagueCommandFromConfig(
|
||||
export async function createLeagueFromConfig(
|
||||
form: LeagueConfigFormModel,
|
||||
ownerId: string,
|
||||
): CreateLeagueWithSeasonAndScoringCommand {
|
||||
): Promise<CreateLeagueResult> {
|
||||
const structure = form.structure;
|
||||
let maxDrivers: number;
|
||||
let maxTeams: number;
|
||||
|
||||
if (structure.mode === 'solo') {
|
||||
maxDrivers =
|
||||
typeof structure.maxDrivers === 'number' && structure.maxDrivers > 0
|
||||
? structure.maxDrivers
|
||||
: 0;
|
||||
maxTeams = 0;
|
||||
} else {
|
||||
const teams =
|
||||
typeof structure.maxTeams === 'number' && structure.maxTeams > 0
|
||||
@@ -237,52 +262,23 @@ export function buildCreateLeagueCommandFromConfig(
|
||||
typeof structure.driversPerTeam === 'number' && structure.driversPerTeam > 0
|
||||
? structure.driversPerTeam
|
||||
: 0;
|
||||
maxTeams = teams;
|
||||
maxDrivers = teams > 0 && perTeam > 0 ? teams * perTeam : 0;
|
||||
}
|
||||
|
||||
return {
|
||||
const result = await apiClient.leagues.create({
|
||||
name: form.basics.name.trim(),
|
||||
description: (form.basics.description ?? '').trim(),
|
||||
visibility: form.basics.visibility,
|
||||
isPublic: form.basics.visibility === 'public',
|
||||
maxMembers: maxDrivers,
|
||||
ownerId,
|
||||
gameId: form.basics.gameId,
|
||||
maxDrivers,
|
||||
maxTeams,
|
||||
enableDriverChampionship: form.championships.enableDriverChampionship,
|
||||
enableTeamChampionship: form.championships.enableTeamChampionship,
|
||||
enableNationsChampionship: form.championships.enableNationsChampionship,
|
||||
enableTrophyChampionship: form.championships.enableTrophyChampionship,
|
||||
scoringPresetId: form.scoring.patternId ?? 'custom',
|
||||
});
|
||||
|
||||
return {
|
||||
leagueId: result.leagueId,
|
||||
success: result.success,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Thin application-level facade that:
|
||||
* - pulls the current driver via repository
|
||||
* - builds the creation command
|
||||
* - delegates to the create-league use case
|
||||
*/
|
||||
export async function createLeagueFromConfig(
|
||||
form: LeagueConfigFormModel,
|
||||
): Promise<CreateLeagueWithSeasonAndScoringResultDTO> {
|
||||
const driverRepo = getDriverRepository();
|
||||
const drivers = await driverRepo.findAll();
|
||||
const currentDriver = drivers[0];
|
||||
|
||||
if (!currentDriver) {
|
||||
const error = new Error(
|
||||
'No driver profile found. Please create a driver profile first.',
|
||||
) as Error & { code?: string };
|
||||
error.code = 'NO_DRIVER';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const useCase = getCreateLeagueWithSeasonAndScoringUseCase();
|
||||
const command = buildCreateLeagueCommandFromConfig(form, currentDriver.id);
|
||||
return useCase.execute(command);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply scoring preset selection and derive timings, returning a new form model.
|
||||
* This mirrors the previous React handler but keeps it in testable, non-UI logic.
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AnalyticsProviders, PAGE_VIEW_REPOSITORY_TOKEN, ENGAGEMENT_REPOSITORY_TOKEN } from './AnalyticsProviders';
|
||||
|
||||
@Module({
|
||||
imports: [],
|
||||
providers: AnalyticsProviders,
|
||||
exports: [PAGE_VIEW_REPOSITORY_TOKEN, ENGAGEMENT_REPOSITORY_TOKEN],
|
||||
})
|
||||
export class AnalyticsModule {}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { Provider } from '@nestjs/common';
|
||||
|
||||
// Import core interfaces
|
||||
import { IPageViewRepository } from '@gridpilot/analytics/application/repositories/IPageViewRepository';
|
||||
import { IEngagementRepository } from '@gridpilot/analytics/domain/repositories/IEngagementRepository';
|
||||
import { Logger } from '@gridpilot/shared/logging/Logger';
|
||||
|
||||
// Import implementations
|
||||
import { InMemoryPageViewRepository } from '@gridpilot/adapters/analytics/persistence/inmemory/InMemoryPageViewRepository';
|
||||
import { InMemoryEngagementRepository } from '@gridpilot/adapters/analytics/persistence/inmemory/InMemoryEngagementRepository';
|
||||
|
||||
// Import tokens
|
||||
import { LOGGER_TOKEN } from '../logging/LoggingModule';
|
||||
|
||||
// Define injection tokens
|
||||
export const PAGE_VIEW_REPOSITORY_TOKEN = Symbol('IPageViewRepository');
|
||||
export const ENGAGEMENT_REPOSITORY_TOKEN = Symbol('IEngagementRepository');
|
||||
|
||||
export const AnalyticsProviders: Provider[] = [
|
||||
{
|
||||
provide: PAGE_VIEW_REPOSITORY_TOKEN,
|
||||
useFactory: (logger: Logger) => new InMemoryPageViewRepository(logger),
|
||||
inject: [LOGGER_TOKEN],
|
||||
},
|
||||
{
|
||||
provide: ENGAGEMENT_REPOSITORY_TOKEN,
|
||||
useFactory: (logger: Logger) => new InMemoryEngagementRepository(logger),
|
||||
inject: [LOGGER_TOKEN],
|
||||
},
|
||||
];
|
||||
@@ -1,9 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthProviders, AUTH_REPOSITORY_TOKEN, USER_REPOSITORY_TOKEN } from './AuthProviders';
|
||||
|
||||
@Module({
|
||||
imports: [],
|
||||
providers: AuthProviders,
|
||||
exports: [AUTH_REPOSITORY_TOKEN, USER_REPOSITORY_TOKEN],
|
||||
})
|
||||
export class AuthModule {}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { Provider } from '@nestjs/common';
|
||||
|
||||
// Import core interfaces
|
||||
import { IAuthRepository } from '@gridpilot/identity/domain/repositories/IAuthRepository';
|
||||
import { IUserRepository } from '@gridpilot/identity/domain/repositories/IUserRepository';
|
||||
import { Logger } from '@gridpilot/shared/logging/Logger';
|
||||
|
||||
// Import implementations
|
||||
import { InMemoryAuthRepository } from '@gridpilot/adapters/identity/persistence/inmemory/InMemoryAuthRepository';
|
||||
import { InMemoryUserRepository } from '@gridpilot/adapters/identity/persistence/inmemory/InMemoryUserRepository';
|
||||
|
||||
// Import tokens
|
||||
import { LOGGER_TOKEN } from '../logging/LoggingModule';
|
||||
|
||||
// Define injection tokens
|
||||
export const AUTH_REPOSITORY_TOKEN = Symbol('IAuthRepository');
|
||||
export const USER_REPOSITORY_TOKEN = Symbol('IUserRepository');
|
||||
|
||||
export const AuthProviders: Provider[] = [
|
||||
{
|
||||
provide: AUTH_REPOSITORY_TOKEN,
|
||||
useFactory: (logger: Logger) => new InMemoryAuthRepository(logger),
|
||||
inject: [LOGGER_TOKEN],
|
||||
},
|
||||
{
|
||||
provide: USER_REPOSITORY_TOKEN,
|
||||
useFactory: (logger: Logger) => new InMemoryUserRepository(logger),
|
||||
inject: [LOGGER_TOKEN],
|
||||
},
|
||||
];
|
||||
@@ -1,9 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DriverProviders, DRIVER_REPOSITORY_TOKEN } from './DriverProviders';
|
||||
|
||||
@Module({
|
||||
imports: [],
|
||||
providers: DriverProviders,
|
||||
exports: [DRIVER_REPOSITORY_TOKEN],
|
||||
})
|
||||
export class DriverModule {}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { Provider } from '@nestjs/common';
|
||||
|
||||
// Import core interfaces
|
||||
import { IDriverRepository } from '@gridpilot/racing/domain/repositories/IDriverRepository';
|
||||
import { Logger } from '@gridpilot/shared/logging/Logger';
|
||||
|
||||
// Import implementations
|
||||
import { InMemoryDriverRepository } from '@gridpilot/adapters/racing/persistence/inmemory/InMemoryDriverRepository';
|
||||
|
||||
// Import tokens
|
||||
import { LOGGER_TOKEN } from '../logging/LoggingModule';
|
||||
|
||||
// Define injection tokens
|
||||
export const DRIVER_REPOSITORY_TOKEN = Symbol('IDriverRepository');
|
||||
|
||||
export const DriverProviders: Provider[] = [
|
||||
{
|
||||
provide: DRIVER_REPOSITORY_TOKEN,
|
||||
useFactory: (logger: Logger) => new InMemoryDriverRepository(logger),
|
||||
inject: [LOGGER_TOKEN],
|
||||
},
|
||||
];
|
||||
@@ -1,9 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { LeagueProviders, GET_LEAGUE_STANDINGS_USE_CASE_TOKEN } from './LeagueProviders';
|
||||
|
||||
@Module({
|
||||
imports: [],
|
||||
providers: LeagueProviders,
|
||||
exports: [GET_LEAGUE_STANDINGS_USE_CASE_TOKEN],
|
||||
})
|
||||
export class LeagueModule {}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { Provider } from '@nestjs/common';
|
||||
|
||||
// Import core interfaces
|
||||
import { GetLeagueStandingsUseCase } from '@gridpilot/league/application/use-cases/GetLeagueStandingsUseCase';
|
||||
import { ILeagueStandingsRepository } from '@gridpilot/league/application/ports/ILeagueStandingsRepository';
|
||||
import { Logger } from '@gridpilot/shared/logging/Logger';
|
||||
|
||||
// Import implementations
|
||||
import { GetLeagueStandingsUseCaseImpl } from '@gridpilot/league/application/use-cases/GetLeagueStandingsUseCaseImpl';
|
||||
import { InMemoryLeagueStandingsRepository } from '@gridpilot/adapters/league/persistence/inmemory/InMemoryLeagueStandingsRepository';
|
||||
|
||||
// Import tokens
|
||||
import { LOGGER_TOKEN } from '../logging/LoggingModule';
|
||||
|
||||
// Define injection tokens
|
||||
export const GET_LEAGUE_STANDINGS_USE_CASE_TOKEN = Symbol('GetLeagueStandingsUseCase');
|
||||
export const LEAGUE_STANDINGS_REPOSITORY_TOKEN = Symbol('ILeagueStandingsRepository');
|
||||
|
||||
export const LeagueProviders: Provider[] = [
|
||||
{
|
||||
provide: GET_LEAGUE_STANDINGS_USE_CASE_TOKEN,
|
||||
useFactory: (repository: ILeagueStandingsRepository, logger: Logger) => new GetLeagueStandingsUseCaseImpl(repository),
|
||||
inject: [LEAGUE_STANDINGS_REPOSITORY_TOKEN, LOGGER_TOKEN],
|
||||
},
|
||||
{
|
||||
provide: LEAGUE_STANDINGS_REPOSITORY_TOKEN,
|
||||
useFactory: (logger: Logger) => new InMemoryLeagueStandingsRepository(logger),
|
||||
inject: [LOGGER_TOKEN],
|
||||
},
|
||||
];
|
||||
@@ -1,17 +0,0 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { Logger } from '@gridpilot/shared/logging/Logger';
|
||||
import { ConsoleLogger } from '@gridpilot/adapters/logging/ConsoleLogger';
|
||||
|
||||
export const LOGGER_TOKEN = Symbol('Logger');
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: LOGGER_TOKEN,
|
||||
useClass: ConsoleLogger,
|
||||
},
|
||||
],
|
||||
exports: [LOGGER_TOKEN],
|
||||
})
|
||||
export class LoggingModule {}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MediaProviders, AVATAR_GENERATION_REPOSITORY_TOKEN } from './MediaProviders';
|
||||
|
||||
@Module({
|
||||
imports: [],
|
||||
providers: MediaProviders,
|
||||
exports: [AVATAR_GENERATION_REPOSITORY_TOKEN],
|
||||
})
|
||||
export class MediaModule {}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { Provider } from '@nestjs/common';
|
||||
|
||||
// Import core interfaces
|
||||
import { IAvatarGenerationRepository } from '@gridpilot/media/domain/repositories/IAvatarGenerationRepository';
|
||||
import { Logger } from '@gridpilot/shared/logging/Logger';
|
||||
|
||||
// Import implementations
|
||||
import { InMemoryAvatarGenerationRepository } from '@gridpilot/adapters/media/persistence/inmemory/InMemoryAvatarGenerationRepository';
|
||||
|
||||
// Import tokens
|
||||
import { LOGGER_TOKEN } from '../logging/LoggingModule';
|
||||
|
||||
// Define injection tokens
|
||||
export const AVATAR_GENERATION_REPOSITORY_TOKEN = Symbol('IAvatarGenerationRepository');
|
||||
|
||||
export const MediaProviders: Provider[] = [
|
||||
{
|
||||
provide: AVATAR_GENERATION_REPOSITORY_TOKEN,
|
||||
useFactory: (logger: Logger) => new InMemoryAvatarGenerationRepository(logger),
|
||||
inject: [LOGGER_TOKEN],
|
||||
},
|
||||
];
|
||||
@@ -1,9 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { RaceProviders, RACE_REPOSITORY_TOKEN } from './RaceProviders';
|
||||
|
||||
@Module({
|
||||
imports: [],
|
||||
providers: RaceProviders,
|
||||
exports: [RACE_REPOSITORY_TOKEN],
|
||||
})
|
||||
export class RaceModule {}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { Provider } from '@nestjs/common';
|
||||
|
||||
// Import core interfaces
|
||||
import { IRaceRepository } from '@gridpilot/racing/domain/repositories/IRaceRepository';
|
||||
import { Logger } from '@gridpilot/shared/logging/Logger';
|
||||
|
||||
// Import implementations
|
||||
import { InMemoryRaceRepository } from '@gridpilot/adapters/racing/persistence/inmemory/InMemoryRaceRepository';
|
||||
|
||||
// Import tokens
|
||||
import { LOGGER_TOKEN } from '../logging/LoggingModule';
|
||||
|
||||
// Define injection tokens
|
||||
export const RACE_REPOSITORY_TOKEN = Symbol('IRaceRepository');
|
||||
|
||||
export const RaceProviders: Provider[] = [
|
||||
{
|
||||
provide: RACE_REPOSITORY_TOKEN,
|
||||
useFactory: (logger: Logger) => new InMemoryRaceRepository(logger),
|
||||
inject: [LOGGER_TOKEN],
|
||||
},
|
||||
];
|
||||
@@ -1,9 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { SponsorProviders, SPONSOR_REPOSITORY_TOKEN } from './SponsorProviders';
|
||||
|
||||
@Module({
|
||||
imports: [],
|
||||
providers: SponsorProviders,
|
||||
exports: [SPONSOR_REPOSITORY_TOKEN],
|
||||
})
|
||||
export class SponsorModule {}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { Provider } from '@nestjs/common';
|
||||
|
||||
// Import core interfaces
|
||||
import { ISponsorRepository } from '@gridpilot/racing/domain/repositories/ISponsorRepository';
|
||||
import { Logger } from '@gridpilot/shared/logging/Logger';
|
||||
|
||||
// Import implementations
|
||||
import { InMemorySponsorRepository } from '@gridpilot/adapters/racing/persistence/inmemory/InMemorySponsorRepository';
|
||||
|
||||
// Import tokens
|
||||
import { LOGGER_TOKEN } from '../logging/LoggingModule';
|
||||
|
||||
// Define injection tokens
|
||||
export const SPONSOR_REPOSITORY_TOKEN = Symbol('ISponsorRepository');
|
||||
|
||||
export const SponsorProviders: Provider[] = [
|
||||
{
|
||||
provide: SPONSOR_REPOSITORY_TOKEN,
|
||||
useFactory: (logger: Logger) => new InMemorySponsorRepository(logger),
|
||||
inject: [LOGGER_TOKEN],
|
||||
},
|
||||
];
|
||||
@@ -1,9 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TeamProviders, TEAM_REPOSITORY_TOKEN } from './TeamProviders';
|
||||
|
||||
@Module({
|
||||
imports: [],
|
||||
providers: TeamProviders,
|
||||
exports: [TEAM_REPOSITORY_TOKEN],
|
||||
})
|
||||
export class TeamModule {}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { Provider } from '@nestjs/common';
|
||||
|
||||
// Import core interfaces
|
||||
import { ITeamRepository } from '@gridpilot/racing/domain/repositories/ITeamRepository';
|
||||
import { Logger } from '@gridpilot/shared/logging/Logger';
|
||||
|
||||
// Import implementations
|
||||
import { InMemoryTeamRepository } from '@gridpilot/adapters/racing/persistence/inmemory/InMemoryTeamRepository';
|
||||
|
||||
// Import tokens
|
||||
import { LOGGER_TOKEN } from '../logging/LoggingModule';
|
||||
|
||||
// Define injection tokens
|
||||
export const TEAM_REPOSITORY_TOKEN = Symbol('ITeamRepository');
|
||||
|
||||
export const TeamProviders: Provider[] = [
|
||||
{
|
||||
provide: TEAM_REPOSITORY_TOKEN,
|
||||
useFactory: (logger: Logger) => new InMemoryTeamRepository(logger),
|
||||
inject: [LOGGER_TOKEN],
|
||||
},
|
||||
];
|
||||
@@ -1,9 +1,89 @@
|
||||
import type {
|
||||
IAllLeaguesWithCapacityAndScoringPresenter,
|
||||
LeagueEnrichedData,
|
||||
LeagueSummaryViewModel,
|
||||
AllLeaguesWithCapacityAndScoringViewModel,
|
||||
} from '@gridpilot/racing/application/presenters/IAllLeaguesWithCapacityAndScoringPresenter';
|
||||
/**
|
||||
* AllLeaguesWithCapacityAndScoringPresenter - Pure data transformer
|
||||
* Transforms API response to view model without DI dependencies.
|
||||
*/
|
||||
|
||||
import { apiClient, type AllLeaguesWithCapacityViewModel } from '@/lib/apiClient';
|
||||
|
||||
export interface LeagueScoringViewModel {
|
||||
gameId: string;
|
||||
gameName: string;
|
||||
primaryChampionshipType: string;
|
||||
scoringPresetId: string;
|
||||
scoringPresetName: string;
|
||||
dropPolicySummary: string;
|
||||
scoringPatternSummary: string;
|
||||
}
|
||||
|
||||
export interface LeagueSummaryViewModel {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string | undefined;
|
||||
ownerId: string;
|
||||
createdAt: string;
|
||||
maxDrivers: number;
|
||||
usedDriverSlots: number;
|
||||
maxTeams: number;
|
||||
usedTeamSlots: number;
|
||||
structureSummary: string;
|
||||
scoringPatternSummary: string;
|
||||
timingSummary: string;
|
||||
scoring: LeagueScoringViewModel;
|
||||
}
|
||||
|
||||
export interface AllLeaguesWithCapacityAndScoringViewModel {
|
||||
leagues: LeagueSummaryViewModel[];
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
export interface IAllLeaguesWithCapacityAndScoringPresenter {
|
||||
reset(): void;
|
||||
getViewModel(): AllLeaguesWithCapacityAndScoringViewModel | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform API response to view model
|
||||
*/
|
||||
function transformApiResponse(apiResponse: AllLeaguesWithCapacityViewModel): AllLeaguesWithCapacityAndScoringViewModel {
|
||||
const leagueItems: LeagueSummaryViewModel[] = apiResponse.leagues.map((league) => {
|
||||
const maxDrivers = league.maxMembers;
|
||||
const usedDriverSlots = league.memberCount;
|
||||
const structureSummary = `Solo • ${maxDrivers} drivers`;
|
||||
const timingSummary = '30 min Quali • 40 min Race';
|
||||
const scoringPatternSummary = 'Custom • All results count';
|
||||
|
||||
const scoringSummary: LeagueScoringViewModel = {
|
||||
gameId: 'unknown',
|
||||
gameName: 'Unknown',
|
||||
primaryChampionshipType: 'driver',
|
||||
scoringPresetId: 'custom',
|
||||
scoringPresetName: 'Custom',
|
||||
dropPolicySummary: 'All results count',
|
||||
scoringPatternSummary,
|
||||
};
|
||||
|
||||
return {
|
||||
id: league.id,
|
||||
name: league.name,
|
||||
description: league.description,
|
||||
ownerId: league.ownerId,
|
||||
createdAt: new Date().toISOString(), // Would need from API
|
||||
maxDrivers,
|
||||
usedDriverSlots,
|
||||
maxTeams: 0,
|
||||
usedTeamSlots: 0,
|
||||
structureSummary,
|
||||
scoringPatternSummary,
|
||||
timingSummary,
|
||||
scoring: scoringSummary,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
leagues: leagueItems,
|
||||
totalCount: leagueItems.length,
|
||||
};
|
||||
}
|
||||
|
||||
export class AllLeaguesWithCapacityAndScoringPresenter implements IAllLeaguesWithCapacityAndScoringPresenter {
|
||||
private viewModel: AllLeaguesWithCapacityAndScoringViewModel | null = null;
|
||||
@@ -12,116 +92,20 @@ export class AllLeaguesWithCapacityAndScoringPresenter implements IAllLeaguesWit
|
||||
this.viewModel = null;
|
||||
}
|
||||
|
||||
present(enrichedLeagues: LeagueEnrichedData[]): void {
|
||||
const leagueItems: LeagueSummaryViewModel[] = enrichedLeagues.map((data) => {
|
||||
const { league, usedDriverSlots, season, scoringConfig, game, preset } = data;
|
||||
|
||||
const configuredMaxDrivers = league.settings.maxDrivers ?? usedDriverSlots;
|
||||
const safeMaxDrivers = Math.max(configuredMaxDrivers, usedDriverSlots);
|
||||
|
||||
const structureSummary = `Solo • ${safeMaxDrivers} drivers`;
|
||||
|
||||
const qualifyingMinutes = 30;
|
||||
const mainRaceMinutes =
|
||||
typeof league.settings.sessionDuration === 'number'
|
||||
? league.settings.sessionDuration
|
||||
: 40;
|
||||
const timingSummary = `${qualifyingMinutes} min Quali • ${mainRaceMinutes} min Race`;
|
||||
|
||||
let scoringPatternSummary: string | null = null;
|
||||
let scoringSummary: LeagueSummaryViewModel['scoring'];
|
||||
|
||||
if (season && scoringConfig && game) {
|
||||
const dropPolicySummary =
|
||||
preset?.dropPolicySummary ?? this.deriveDropPolicySummary(scoringConfig);
|
||||
const primaryChampionshipType =
|
||||
preset?.primaryChampionshipType ??
|
||||
(scoringConfig.championships[0]?.type ?? 'driver');
|
||||
|
||||
const scoringPresetName = preset?.name ?? 'Custom';
|
||||
scoringPatternSummary = `${scoringPresetName} • ${dropPolicySummary}`;
|
||||
|
||||
scoringSummary = {
|
||||
gameId: game.id,
|
||||
gameName: game.name,
|
||||
primaryChampionshipType,
|
||||
scoringPresetId: scoringConfig.scoringPresetId ?? 'custom',
|
||||
scoringPresetName,
|
||||
dropPolicySummary,
|
||||
scoringPatternSummary,
|
||||
};
|
||||
} else {
|
||||
const dropPolicySummary = 'All results count';
|
||||
const scoringPresetName = 'Custom';
|
||||
scoringPatternSummary = scoringPatternSummary ?? `${scoringPresetName} • ${dropPolicySummary}`;
|
||||
|
||||
scoringSummary = {
|
||||
gameId: 'unknown',
|
||||
gameName: 'Unknown',
|
||||
primaryChampionshipType: 'driver',
|
||||
scoringPresetId: 'custom',
|
||||
scoringPresetName,
|
||||
dropPolicySummary,
|
||||
scoringPatternSummary,
|
||||
};
|
||||
}
|
||||
|
||||
const base: LeagueSummaryViewModel = {
|
||||
id: league.id,
|
||||
name: league.name,
|
||||
description: league.description,
|
||||
ownerId: league.ownerId,
|
||||
createdAt: league.createdAt.toISOString(),
|
||||
maxDrivers: safeMaxDrivers,
|
||||
usedDriverSlots,
|
||||
// Team capacity is not yet modeled here; use zero for now to satisfy strict typing.
|
||||
maxTeams: 0,
|
||||
usedTeamSlots: 0,
|
||||
structureSummary,
|
||||
scoringPatternSummary: scoringPatternSummary ?? '',
|
||||
timingSummary,
|
||||
scoring: scoringSummary,
|
||||
};
|
||||
|
||||
return base;
|
||||
});
|
||||
|
||||
this.viewModel = {
|
||||
leagues: leagueItems,
|
||||
totalCount: leagueItems.length,
|
||||
};
|
||||
async fetchAndPresent(): Promise<void> {
|
||||
const apiResponse = await apiClient.leagues.getAllWithCapacity();
|
||||
this.viewModel = transformApiResponse(apiResponse);
|
||||
}
|
||||
|
||||
getViewModel(): AllLeaguesWithCapacityAndScoringViewModel | null {
|
||||
return this.viewModel;
|
||||
}
|
||||
}
|
||||
|
||||
private deriveDropPolicySummary(config: {
|
||||
championships: Array<{
|
||||
dropScorePolicy: { strategy: string; count?: number; dropCount?: number };
|
||||
}>;
|
||||
}): string {
|
||||
const championship = config.championships[0];
|
||||
if (!championship) {
|
||||
return 'All results count';
|
||||
}
|
||||
|
||||
const policy = championship.dropScorePolicy;
|
||||
if (!policy || policy.strategy === 'none') {
|
||||
return 'All results count';
|
||||
}
|
||||
|
||||
if (policy.strategy === 'bestNResults' && typeof policy.count === 'number') {
|
||||
return `Best ${policy.count} results count`;
|
||||
}
|
||||
|
||||
if (
|
||||
policy.strategy === 'dropWorstN' &&
|
||||
typeof policy.dropCount === 'number'
|
||||
) {
|
||||
return `Worst ${policy.dropCount} results are dropped`;
|
||||
}
|
||||
|
||||
return 'Custom drop score rules';
|
||||
}
|
||||
/**
|
||||
* Convenience function to fetch and transform all leagues
|
||||
*/
|
||||
export async function fetchAllLeaguesWithCapacityAndScoring(): Promise<AllLeaguesWithCapacityAndScoringViewModel> {
|
||||
const apiResponse = await apiClient.leagues.getAllWithCapacity();
|
||||
return transformApiResponse(apiResponse);
|
||||
}
|
||||
@@ -1,9 +1,56 @@
|
||||
import type {
|
||||
IAllTeamsPresenter,
|
||||
TeamListItemViewModel,
|
||||
AllTeamsViewModel,
|
||||
AllTeamsResultDTO,
|
||||
} from '@gridpilot/racing/application/presenters/IAllTeamsPresenter';
|
||||
/**
|
||||
* AllTeamsPresenter - Pure data transformer
|
||||
* Transforms API response to view model without DI dependencies.
|
||||
*/
|
||||
|
||||
import { apiClient, type AllTeamsViewModel as ApiAllTeamsViewModel } from '@/lib/apiClient';
|
||||
|
||||
export interface TeamListItemViewModel {
|
||||
id: string;
|
||||
name: string;
|
||||
tag?: string | undefined;
|
||||
description?: string | undefined;
|
||||
memberCount: number;
|
||||
logoUrl?: string | undefined;
|
||||
rating?: number | undefined;
|
||||
}
|
||||
|
||||
export interface AllTeamsViewModel {
|
||||
teams: TeamListItemViewModel[];
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
export interface IAllTeamsPresenter {
|
||||
reset(): void;
|
||||
getViewModel(): AllTeamsViewModel | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform API response to view model
|
||||
*/
|
||||
function transformApiResponse(apiResponse: ApiAllTeamsViewModel): AllTeamsViewModel {
|
||||
const teamItems: TeamListItemViewModel[] = apiResponse.teams.map((team) => {
|
||||
const viewModel: TeamListItemViewModel = {
|
||||
id: team.id,
|
||||
name: team.name,
|
||||
memberCount: team.memberCount ?? 0,
|
||||
};
|
||||
|
||||
if (team.logoUrl) {
|
||||
viewModel.logoUrl = team.logoUrl;
|
||||
}
|
||||
if (team.rating) {
|
||||
viewModel.rating = team.rating;
|
||||
}
|
||||
|
||||
return viewModel;
|
||||
});
|
||||
|
||||
return {
|
||||
teams: teamItems,
|
||||
totalCount: teamItems.length,
|
||||
};
|
||||
}
|
||||
|
||||
export class AllTeamsPresenter implements IAllTeamsPresenter {
|
||||
private viewModel: AllTeamsViewModel | null = null;
|
||||
@@ -12,23 +59,20 @@ export class AllTeamsPresenter implements IAllTeamsPresenter {
|
||||
this.viewModel = null;
|
||||
}
|
||||
|
||||
present(input: AllTeamsResultDTO): void {
|
||||
const teamItems: TeamListItemViewModel[] = input.teams.map((team) => ({
|
||||
id: team.id,
|
||||
name: team.name,
|
||||
tag: team.tag,
|
||||
description: team.description,
|
||||
memberCount: team.memberCount ?? 0,
|
||||
leagues: team.leagues,
|
||||
}));
|
||||
|
||||
this.viewModel = {
|
||||
teams: teamItems,
|
||||
totalCount: teamItems.length,
|
||||
};
|
||||
async fetchAndPresent(): Promise<void> {
|
||||
const apiResponse = await apiClient.teams.getAll();
|
||||
this.viewModel = transformApiResponse(apiResponse);
|
||||
}
|
||||
|
||||
getViewModel(): AllTeamsViewModel | null {
|
||||
return this.viewModel;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience function to fetch and transform all teams
|
||||
*/
|
||||
export async function fetchAllTeams(): Promise<AllTeamsViewModel> {
|
||||
const apiResponse = await apiClient.teams.getAll();
|
||||
return transformApiResponse(apiResponse);
|
||||
}
|
||||
@@ -1,8 +1,59 @@
|
||||
import type {
|
||||
IDriverTeamPresenter,
|
||||
DriverTeamViewModel,
|
||||
DriverTeamResultDTO,
|
||||
} from '@gridpilot/racing/application/presenters/IDriverTeamPresenter';
|
||||
/**
|
||||
* DriverTeamPresenter - Pure data transformer
|
||||
* Transforms API response to view model without DI dependencies.
|
||||
*/
|
||||
|
||||
import { apiClient, type DriverTeamViewModel as ApiDriverTeamViewModel } from '@/lib/apiClient';
|
||||
|
||||
export interface DriverTeamMembershipViewModel {
|
||||
role: string;
|
||||
joinedAt: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export interface DriverTeamInfoViewModel {
|
||||
id: string;
|
||||
name: string;
|
||||
tag?: string | undefined;
|
||||
description?: string | undefined;
|
||||
ownerId: string;
|
||||
leagues?: string[] | undefined;
|
||||
}
|
||||
|
||||
export interface DriverTeamViewModel {
|
||||
team: DriverTeamInfoViewModel;
|
||||
membership: DriverTeamMembershipViewModel;
|
||||
isOwner: boolean;
|
||||
canManage: boolean;
|
||||
}
|
||||
|
||||
export interface IDriverTeamPresenter {
|
||||
reset(): void;
|
||||
getViewModel(): DriverTeamViewModel | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform API response to view model
|
||||
*/
|
||||
function transformApiResponse(apiResponse: ApiDriverTeamViewModel): DriverTeamViewModel {
|
||||
const isOwner = false; // Would need team owner info from API
|
||||
const canManage = apiResponse.role === 'owner' || apiResponse.role === 'manager';
|
||||
|
||||
return {
|
||||
team: {
|
||||
id: apiResponse.teamId,
|
||||
name: apiResponse.teamName,
|
||||
ownerId: '', // Would need from API
|
||||
},
|
||||
membership: {
|
||||
role: apiResponse.role === 'driver' ? 'member' : apiResponse.role,
|
||||
joinedAt: new Date(apiResponse.joinedAt).toISOString(),
|
||||
isActive: true,
|
||||
},
|
||||
isOwner,
|
||||
canManage,
|
||||
};
|
||||
}
|
||||
|
||||
export class DriverTeamPresenter implements IDriverTeamPresenter {
|
||||
private viewModel: DriverTeamViewModel | null = null;
|
||||
@@ -11,32 +62,27 @@ export class DriverTeamPresenter implements IDriverTeamPresenter {
|
||||
this.viewModel = null;
|
||||
}
|
||||
|
||||
present(input: DriverTeamResultDTO): void {
|
||||
const { team, membership, driverId } = input;
|
||||
|
||||
const isOwner = team.ownerId === driverId;
|
||||
const canManage = membership.role === 'owner' || membership.role === 'manager';
|
||||
|
||||
this.viewModel = {
|
||||
team: {
|
||||
id: team.id,
|
||||
name: team.name,
|
||||
tag: team.tag,
|
||||
description: team.description,
|
||||
ownerId: team.ownerId,
|
||||
leagues: team.leagues,
|
||||
},
|
||||
membership: {
|
||||
role: membership.role === 'driver' ? 'member' : membership.role,
|
||||
joinedAt: membership.joinedAt.toISOString(),
|
||||
isActive: membership.status === 'active',
|
||||
},
|
||||
isOwner,
|
||||
canManage,
|
||||
};
|
||||
async fetchAndPresent(driverId: string): Promise<void> {
|
||||
const apiResponse = await apiClient.teams.getDriverTeam(driverId);
|
||||
if (apiResponse) {
|
||||
this.viewModel = transformApiResponse(apiResponse);
|
||||
} else {
|
||||
this.viewModel = null;
|
||||
}
|
||||
}
|
||||
|
||||
getViewModel(): DriverTeamViewModel | null {
|
||||
return this.viewModel;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience function to fetch and transform driver's team
|
||||
*/
|
||||
export async function fetchDriverTeam(driverId: string): Promise<DriverTeamViewModel | null> {
|
||||
const apiResponse = await apiClient.teams.getDriverTeam(driverId);
|
||||
if (!apiResponse) {
|
||||
return null;
|
||||
}
|
||||
return transformApiResponse(apiResponse);
|
||||
}
|
||||
@@ -1,10 +1,87 @@
|
||||
import { SkillLevelService } from '@gridpilot/racing/domain/services/SkillLevelService';
|
||||
import type {
|
||||
IDriversLeaderboardPresenter,
|
||||
DriverLeaderboardItemViewModel,
|
||||
DriversLeaderboardViewModel,
|
||||
DriversLeaderboardResultDTO,
|
||||
} from '@gridpilot/racing/application/presenters/IDriversLeaderboardPresenter';
|
||||
/**
|
||||
* DriversLeaderboardPresenter - Pure data transformer
|
||||
* Transforms API response to view model without DI dependencies.
|
||||
*/
|
||||
|
||||
import { apiClient, type DriversLeaderboardViewModel as ApiDriversLeaderboardViewModel } from '@/lib/apiClient';
|
||||
|
||||
export type SkillLevel = 'rookie' | 'amateur' | 'pro' | 'elite' | 'legend';
|
||||
|
||||
export interface DriverLeaderboardItemViewModel {
|
||||
id: string;
|
||||
name: string;
|
||||
rating: number;
|
||||
skillLevel: SkillLevel;
|
||||
nationality?: string | undefined;
|
||||
racesCompleted: number;
|
||||
wins: number;
|
||||
podiums: number;
|
||||
isActive: boolean;
|
||||
rank: number;
|
||||
avatarUrl?: string | undefined;
|
||||
}
|
||||
|
||||
export interface DriversLeaderboardViewModel {
|
||||
drivers: DriverLeaderboardItemViewModel[];
|
||||
totalRaces: number;
|
||||
totalWins: number;
|
||||
activeCount: number;
|
||||
}
|
||||
|
||||
export interface IDriversLeaderboardPresenter {
|
||||
reset(): void;
|
||||
getViewModel(): DriversLeaderboardViewModel | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate skill level from rating
|
||||
*/
|
||||
function getSkillLevel(rating: number): SkillLevel {
|
||||
if (rating >= 5000) return 'legend';
|
||||
if (rating >= 3500) return 'elite';
|
||||
if (rating >= 2000) return 'pro';
|
||||
if (rating >= 1000) return 'amateur';
|
||||
return 'rookie';
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform API response to view model
|
||||
*/
|
||||
function transformApiResponse(apiResponse: ApiDriversLeaderboardViewModel): DriversLeaderboardViewModel {
|
||||
const items: DriverLeaderboardItemViewModel[] = apiResponse.drivers.map((driver, index) => {
|
||||
const rating = driver.rating ?? 0;
|
||||
const skillLevel = getSkillLevel(rating);
|
||||
|
||||
const viewModel: DriverLeaderboardItemViewModel = {
|
||||
id: driver.id,
|
||||
name: driver.name,
|
||||
rating,
|
||||
skillLevel,
|
||||
racesCompleted: driver.races ?? 0,
|
||||
wins: driver.wins ?? 0,
|
||||
podiums: 0, // API may not provide this, default to 0
|
||||
isActive: true,
|
||||
rank: index + 1,
|
||||
};
|
||||
|
||||
if (driver.avatarUrl) {
|
||||
viewModel.avatarUrl = driver.avatarUrl;
|
||||
}
|
||||
|
||||
return viewModel;
|
||||
});
|
||||
|
||||
const totalRaces = items.reduce((sum, d) => sum + d.racesCompleted, 0);
|
||||
const totalWins = items.reduce((sum, d) => sum + d.wins, 0);
|
||||
const activeCount = items.filter((d) => d.isActive).length;
|
||||
|
||||
return {
|
||||
drivers: items,
|
||||
totalRaces,
|
||||
totalWins,
|
||||
activeCount,
|
||||
};
|
||||
}
|
||||
|
||||
export class DriversLeaderboardPresenter implements IDriversLeaderboardPresenter {
|
||||
private viewModel: DriversLeaderboardViewModel | null = null;
|
||||
@@ -13,63 +90,20 @@ export class DriversLeaderboardPresenter implements IDriversLeaderboardPresenter
|
||||
this.viewModel = null;
|
||||
}
|
||||
|
||||
present(input: DriversLeaderboardResultDTO): void {
|
||||
const { drivers, rankings, stats, avatarUrls } = input;
|
||||
const items: DriverLeaderboardItemViewModel[] = drivers.map((driver) => {
|
||||
const driverStats = stats[driver.id];
|
||||
const rating = driverStats?.rating ?? 0;
|
||||
const wins = driverStats?.wins ?? 0;
|
||||
const podiums = driverStats?.podiums ?? 0;
|
||||
const totalRaces = driverStats?.totalRaces ?? 0;
|
||||
|
||||
let effectiveRank = Number.POSITIVE_INFINITY;
|
||||
if (typeof driverStats?.overallRank === 'number' && driverStats.overallRank > 0) {
|
||||
effectiveRank = driverStats.overallRank;
|
||||
} else {
|
||||
const indexInGlobal = rankings.findIndex((entry) => entry.driverId === driver.id);
|
||||
if (indexInGlobal !== -1) {
|
||||
effectiveRank = indexInGlobal + 1;
|
||||
}
|
||||
}
|
||||
|
||||
const skillLevel = SkillLevelService.getSkillLevel(rating);
|
||||
const isActive = rankings.some((r) => r.driverId === driver.id);
|
||||
|
||||
return {
|
||||
id: driver.id,
|
||||
name: driver.name,
|
||||
rating,
|
||||
skillLevel,
|
||||
nationality: driver.country,
|
||||
racesCompleted: totalRaces,
|
||||
wins,
|
||||
podiums,
|
||||
isActive,
|
||||
rank: effectiveRank,
|
||||
avatarUrl: avatarUrls[driver.id] ?? '',
|
||||
};
|
||||
});
|
||||
|
||||
items.sort((a, b) => {
|
||||
const rankA = Number.isFinite(a.rank) && a.rank > 0 ? a.rank : Number.POSITIVE_INFINITY;
|
||||
const rankB = Number.isFinite(b.rank) && b.rank > 0 ? b.rank : Number.POSITIVE_INFINITY;
|
||||
if (rankA !== rankB) return rankA - rankB;
|
||||
return b.rating - a.rating;
|
||||
});
|
||||
|
||||
const totalRaces = items.reduce((sum, d) => sum + d.racesCompleted, 0);
|
||||
const totalWins = items.reduce((sum, d) => sum + d.wins, 0);
|
||||
const activeCount = items.filter((d) => d.isActive).length;
|
||||
|
||||
this.viewModel = {
|
||||
drivers: items,
|
||||
totalRaces,
|
||||
totalWins,
|
||||
activeCount,
|
||||
};
|
||||
async fetchAndPresent(): Promise<void> {
|
||||
const apiResponse = await apiClient.drivers.getLeaderboard();
|
||||
this.viewModel = transformApiResponse(apiResponse);
|
||||
}
|
||||
|
||||
getViewModel(): DriversLeaderboardViewModel | null {
|
||||
return this.viewModel;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience function to fetch and transform drivers leaderboard
|
||||
*/
|
||||
export async function fetchDriversLeaderboard(): Promise<DriversLeaderboardViewModel> {
|
||||
const apiResponse = await apiClient.drivers.getLeaderboard();
|
||||
return transformApiResponse(apiResponse);
|
||||
}
|
||||
@@ -1,30 +1,28 @@
|
||||
import type { League } from '@gridpilot/racing/domain/entities/League';
|
||||
import type { Protest } from '@gridpilot/racing/domain/entities/Protest';
|
||||
import type { Race } from '@gridpilot/racing/domain/entities/Race';
|
||||
import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO';
|
||||
import type { LeagueConfigFormModel } from '@gridpilot/racing/application';
|
||||
import type { LeagueConfigFormViewModel } from '@gridpilot/racing/application/presenters/ILeagueFullConfigPresenter';
|
||||
import { LeagueFullConfigPresenter } from '@/lib/presenters/LeagueFullConfigPresenter';
|
||||
import type { MembershipRole } from '@/lib/leagueMembership';
|
||||
import { EntityMappers } from '@gridpilot/racing/application/mappers/EntityMappers';
|
||||
import {
|
||||
getLeagueMembershipRepository,
|
||||
getDriverRepository,
|
||||
getGetLeagueFullConfigUseCase,
|
||||
getRaceRepository,
|
||||
getProtestRepository,
|
||||
getDriverStats,
|
||||
getAllDriverRankings,
|
||||
getListSeasonsForLeagueUseCase,
|
||||
} from '@/lib/di-container';
|
||||
/**
|
||||
* LeagueAdminPresenter - Pure data transformer
|
||||
* Transforms API responses to view models without DI dependencies.
|
||||
* All data fetching is done via apiClient.
|
||||
*/
|
||||
|
||||
import { apiClient } from '@/lib/apiClient';
|
||||
import type {
|
||||
LeagueJoinRequestViewModel as ApiLeagueJoinRequestViewModel,
|
||||
LeagueConfigFormModelDto,
|
||||
LeagueSeasonSummaryViewModel as ApiLeagueSeasonSummaryViewModel,
|
||||
DriverDTO,
|
||||
} from '@/lib/apiClient';
|
||||
|
||||
// ============================================================================
|
||||
// View Model Types
|
||||
// ============================================================================
|
||||
|
||||
export interface LeagueJoinRequestViewModel {
|
||||
id: string;
|
||||
leagueId: string;
|
||||
driverId: string;
|
||||
requestedAt: Date;
|
||||
message?: string;
|
||||
driver?: DriverDTO;
|
||||
message?: string | undefined;
|
||||
driver?: DriverDTO | undefined;
|
||||
}
|
||||
|
||||
export interface ProtestDriverSummary {
|
||||
@@ -32,7 +30,11 @@ export interface ProtestDriverSummary {
|
||||
}
|
||||
|
||||
export interface ProtestRaceSummary {
|
||||
[raceId: string]: Race;
|
||||
[raceId: string]: {
|
||||
id: string;
|
||||
name: string;
|
||||
scheduledTime: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface LeagueOwnerSummaryViewModel {
|
||||
@@ -50,13 +52,21 @@ export interface LeagueSummaryViewModel {
|
||||
}
|
||||
|
||||
export interface LeagueAdminProtestsViewModel {
|
||||
protests: Protest[];
|
||||
protests: Array<{
|
||||
id: string;
|
||||
raceId: string;
|
||||
complainantId: string;
|
||||
defendantId: string;
|
||||
description: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
}>;
|
||||
racesById: ProtestRaceSummary;
|
||||
driversById: ProtestDriverSummary;
|
||||
}
|
||||
|
||||
export interface LeagueAdminConfigViewModel {
|
||||
form: LeagueConfigFormModel | null;
|
||||
form: LeagueConfigFormModelDto | null;
|
||||
}
|
||||
|
||||
export interface LeagueAdminPermissionsViewModel {
|
||||
@@ -68,8 +78,8 @@ export interface LeagueSeasonSummaryViewModel {
|
||||
seasonId: string;
|
||||
name: string;
|
||||
status: string;
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
startDate?: Date | undefined;
|
||||
endDate?: Date | undefined;
|
||||
isPrimary: boolean;
|
||||
isParallelActive: boolean;
|
||||
}
|
||||
@@ -81,41 +91,31 @@ export interface LeagueAdminViewModel {
|
||||
protests: LeagueAdminProtestsViewModel;
|
||||
}
|
||||
|
||||
export type MembershipRole = 'owner' | 'admin' | 'member';
|
||||
|
||||
// ============================================================================
|
||||
// Data Fetching Functions (using apiClient)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Load join requests plus requester driver DTOs for a league.
|
||||
* Load join requests for a league via API.
|
||||
*/
|
||||
export async function loadLeagueJoinRequests(leagueId: string): Promise<LeagueJoinRequestViewModel[]> {
|
||||
const membershipRepo = getLeagueMembershipRepository();
|
||||
const requests = await membershipRepo.getJoinRequests(leagueId);
|
||||
|
||||
const driverRepo = getDriverRepository();
|
||||
const uniqueDriverIds = Array.from(new Set(requests.map((r) => r.driverId)));
|
||||
const driverEntities = await Promise.all(uniqueDriverIds.map((id) => driverRepo.findById(id)));
|
||||
const driverDtos = driverEntities
|
||||
.map((driver) => (driver ? EntityMappers.toDriverDTO(driver) : null))
|
||||
.filter((dto): dto is DriverDTO => dto !== null);
|
||||
|
||||
const driversById: Record<string, DriverDTO> = {};
|
||||
for (const dto of driverDtos) {
|
||||
driversById[dto.id] = dto;
|
||||
}
|
||||
|
||||
return requests.map((request) => {
|
||||
const base: LeagueJoinRequestViewModel = {
|
||||
const requests = await apiClient.leagues.getJoinRequests(leagueId);
|
||||
|
||||
return requests.map((request: ApiLeagueJoinRequestViewModel) => {
|
||||
const viewModel: LeagueJoinRequestViewModel = {
|
||||
id: request.id,
|
||||
leagueId: request.leagueId,
|
||||
driverId: request.driverId,
|
||||
requestedAt: request.requestedAt,
|
||||
requestedAt: new Date(request.requestedAt),
|
||||
};
|
||||
|
||||
const message = request.message;
|
||||
const driver = driversById[request.driverId];
|
||||
if (request.message) {
|
||||
viewModel.message = request.message;
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
...(typeof message === 'string' && message.length > 0 ? { message } : {}),
|
||||
...(driver ? { driver } : {}),
|
||||
};
|
||||
return viewModel;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -126,84 +126,49 @@ export async function approveLeagueJoinRequest(
|
||||
leagueId: string,
|
||||
requestId: string
|
||||
): Promise<LeagueJoinRequestViewModel[]> {
|
||||
const membershipRepo = getLeagueMembershipRepository();
|
||||
const requests = await membershipRepo.getJoinRequests(leagueId);
|
||||
const request = requests.find((r) => r.id === requestId);
|
||||
if (!request) {
|
||||
throw new Error('Join request not found');
|
||||
}
|
||||
|
||||
await membershipRepo.saveMembership({
|
||||
id: request.id,
|
||||
leagueId: request.leagueId,
|
||||
driverId: request.driverId,
|
||||
role: 'member',
|
||||
status: 'active',
|
||||
joinedAt: new Date(),
|
||||
});
|
||||
await membershipRepo.removeJoinRequest(requestId);
|
||||
|
||||
await apiClient.leagues.approveJoinRequest(leagueId, requestId);
|
||||
return loadLeagueJoinRequests(leagueId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject a league join request (alpha: just remove).
|
||||
* Reject a league join request.
|
||||
*/
|
||||
export async function rejectLeagueJoinRequest(
|
||||
leagueId: string,
|
||||
requestId: string
|
||||
): Promise<LeagueJoinRequestViewModel[]> {
|
||||
const membershipRepo = getLeagueMembershipRepository();
|
||||
await membershipRepo.removeJoinRequest(requestId);
|
||||
await apiClient.leagues.rejectJoinRequest(leagueId, requestId);
|
||||
return loadLeagueJoinRequests(leagueId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute permissions for a performer on league membership actions.
|
||||
* Get permissions for a performer on league membership actions.
|
||||
*/
|
||||
export async function getLeagueAdminPermissions(
|
||||
leagueId: string,
|
||||
performerDriverId: string
|
||||
): Promise<LeagueAdminPermissionsViewModel> {
|
||||
const membershipRepo = getLeagueMembershipRepository();
|
||||
const performer = await membershipRepo.getMembership(leagueId, performerDriverId);
|
||||
|
||||
const isOwner = performer?.role === 'owner';
|
||||
const isAdmin = performer?.role === 'admin';
|
||||
|
||||
const permissions = await apiClient.leagues.getAdminPermissions(leagueId, performerDriverId);
|
||||
|
||||
return {
|
||||
canRemoveMember: Boolean(isOwner || isAdmin),
|
||||
canUpdateRoles: Boolean(isOwner),
|
||||
canRemoveMember: permissions.canManageMembers || permissions.isOwner || permissions.isAdmin,
|
||||
canUpdateRoles: permissions.isOwner,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a member from the league, enforcing simple role rules.
|
||||
* Remove a member from the league.
|
||||
*/
|
||||
export async function removeLeagueMember(
|
||||
leagueId: string,
|
||||
performerDriverId: string,
|
||||
targetDriverId: string
|
||||
): Promise<void> {
|
||||
const membershipRepo = getLeagueMembershipRepository();
|
||||
const performer = await membershipRepo.getMembership(leagueId, performerDriverId);
|
||||
if (!performer || (performer.role !== 'owner' && performer.role !== 'admin')) {
|
||||
throw new Error('Only owners or admins can remove members');
|
||||
}
|
||||
|
||||
const membership = await membershipRepo.getMembership(leagueId, targetDriverId);
|
||||
if (!membership) {
|
||||
throw new Error('Member not found');
|
||||
}
|
||||
if (membership.role === 'owner') {
|
||||
throw new Error('Cannot remove the league owner');
|
||||
}
|
||||
|
||||
await membershipRepo.removeMembership(leagueId, targetDriverId);
|
||||
await apiClient.leagues.removeMember(leagueId, performerDriverId, targetDriverId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a member's role, enforcing simple owner-only rules.
|
||||
* Update a member's role.
|
||||
*/
|
||||
export async function updateLeagueMemberRole(
|
||||
leagueId: string,
|
||||
@@ -211,68 +176,30 @@ export async function updateLeagueMemberRole(
|
||||
targetDriverId: string,
|
||||
newRole: MembershipRole
|
||||
): Promise<void> {
|
||||
const membershipRepo = getLeagueMembershipRepository();
|
||||
const performer = await membershipRepo.getMembership(leagueId, performerDriverId);
|
||||
if (!performer || performer.role !== 'owner') {
|
||||
throw new Error('Only the league owner can update roles');
|
||||
}
|
||||
|
||||
const membership = await membershipRepo.getMembership(leagueId, targetDriverId);
|
||||
if (!membership) {
|
||||
throw new Error('Member not found');
|
||||
}
|
||||
if (membership.role === 'owner') {
|
||||
throw new Error('Cannot change the owner role');
|
||||
}
|
||||
|
||||
await membershipRepo.saveMembership({
|
||||
...membership,
|
||||
role: newRole,
|
||||
});
|
||||
await apiClient.leagues.updateMemberRole(leagueId, performerDriverId, targetDriverId, newRole);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load owner summary (DTO + rating/rank) for a league.
|
||||
* Load owner summary for a league.
|
||||
*/
|
||||
export async function loadLeagueOwnerSummary(params: {
|
||||
leagueId: string;
|
||||
ownerId: string;
|
||||
}): Promise<LeagueOwnerSummaryViewModel | null> {
|
||||
const driverRepo = getDriverRepository();
|
||||
const entity = await driverRepo.findById(params.ownerId);
|
||||
if (!entity) return null;
|
||||
|
||||
const ownerDriver = EntityMappers.toDriverDTO(entity);
|
||||
if (!ownerDriver) {
|
||||
const ownerSummary = await apiClient.leagues.getOwnerSummary(params.leagueId, params.ownerId);
|
||||
|
||||
if (!ownerSummary) {
|
||||
return null;
|
||||
}
|
||||
const stats = getDriverStats(ownerDriver.id);
|
||||
const allRankings = getAllDriverRankings();
|
||||
|
||||
let rating: number | null = stats?.rating ?? null;
|
||||
let rank: number | null = null;
|
||||
|
||||
if (stats) {
|
||||
if (typeof stats.overallRank === 'number' && stats.overallRank > 0) {
|
||||
rank = stats.overallRank;
|
||||
} else {
|
||||
const indexInGlobal = allRankings.findIndex((stat) => stat.driverId === stats.driverId);
|
||||
if (indexInGlobal !== -1) {
|
||||
rank = indexInGlobal + 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (rating === null) {
|
||||
const globalEntry = allRankings.find((stat) => stat.driverId === stats.driverId);
|
||||
if (globalEntry) {
|
||||
rating = globalEntry.rating;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For now, return a simplified version - the API should provide driver details
|
||||
return {
|
||||
driver: ownerDriver,
|
||||
rating,
|
||||
rank,
|
||||
driver: {
|
||||
id: params.ownerId,
|
||||
name: ownerSummary.leagueName, // This would need to be populated from API
|
||||
},
|
||||
rating: null,
|
||||
rank: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -280,107 +207,63 @@ export async function loadLeagueOwnerSummary(params: {
|
||||
* Load league full config form.
|
||||
*/
|
||||
export async function loadLeagueConfig(
|
||||
leagueId: string,
|
||||
leagueId: string
|
||||
): Promise<LeagueAdminConfigViewModel> {
|
||||
const useCase = getGetLeagueFullConfigUseCase();
|
||||
const presenter = new LeagueFullConfigPresenter();
|
||||
|
||||
await useCase.execute({ leagueId }, presenter);
|
||||
const fullConfig = presenter.getViewModel();
|
||||
|
||||
if (!fullConfig) {
|
||||
return { form: null };
|
||||
}
|
||||
|
||||
const formModel: LeagueConfigFormModel = {
|
||||
leagueId: fullConfig.leagueId,
|
||||
basics: {
|
||||
...fullConfig.basics,
|
||||
visibility: fullConfig.basics.visibility as LeagueConfigFormModel['basics']['visibility'],
|
||||
},
|
||||
structure: {
|
||||
...fullConfig.structure,
|
||||
mode: fullConfig.structure.mode as LeagueConfigFormModel['structure']['mode'],
|
||||
},
|
||||
championships: fullConfig.championships,
|
||||
scoring: fullConfig.scoring,
|
||||
dropPolicy: {
|
||||
strategy: fullConfig.dropPolicy.strategy as LeagueConfigFormModel['dropPolicy']['strategy'],
|
||||
...(fullConfig.dropPolicy.n !== undefined ? { n: fullConfig.dropPolicy.n } : {}),
|
||||
},
|
||||
timings: fullConfig.timings,
|
||||
stewarding: {
|
||||
decisionMode: fullConfig.stewarding.decisionMode as LeagueConfigFormModel['stewarding']['decisionMode'],
|
||||
...(fullConfig.stewarding.requiredVotes !== undefined
|
||||
? { requiredVotes: fullConfig.stewarding.requiredVotes }
|
||||
: {}),
|
||||
requireDefense: fullConfig.stewarding.requireDefense,
|
||||
defenseTimeLimit: fullConfig.stewarding.defenseTimeLimit,
|
||||
voteTimeLimit: fullConfig.stewarding.voteTimeLimit,
|
||||
protestDeadlineHours: fullConfig.stewarding.protestDeadlineHours,
|
||||
stewardingClosesHours: fullConfig.stewarding.stewardingClosesHours,
|
||||
notifyAccusedOnProtest: fullConfig.stewarding.notifyAccusedOnProtest,
|
||||
notifyOnVoteRequired: fullConfig.stewarding.notifyOnVoteRequired,
|
||||
},
|
||||
const config = await apiClient.leagues.getConfig(leagueId);
|
||||
|
||||
return {
|
||||
form: config,
|
||||
};
|
||||
|
||||
return { form: formModel };
|
||||
}
|
||||
|
||||
/**
|
||||
* Load protests, related races and driver DTOs for a league.
|
||||
* Load protests for a league.
|
||||
*/
|
||||
export async function loadLeagueProtests(leagueId: string): Promise<LeagueAdminProtestsViewModel> {
|
||||
const raceRepo = getRaceRepository();
|
||||
const protestRepo = getProtestRepository();
|
||||
const driverRepo = getDriverRepository();
|
||||
|
||||
const leagueRaces = await raceRepo.findByLeagueId(leagueId);
|
||||
|
||||
const allProtests: Protest[] = [];
|
||||
const racesById: Record<string, Race> = {};
|
||||
|
||||
for (const race of leagueRaces) {
|
||||
racesById[race.id] = race;
|
||||
const raceProtests = await protestRepo.findByRaceId(race.id);
|
||||
allProtests.push(...raceProtests);
|
||||
}
|
||||
|
||||
const driverIds = new Set<string>();
|
||||
allProtests.forEach((p) => {
|
||||
driverIds.add(p.protestingDriverId);
|
||||
driverIds.add(p.accusedDriverId);
|
||||
});
|
||||
|
||||
const driverEntities = await Promise.all(Array.from(driverIds).map((id) => driverRepo.findById(id)));
|
||||
const driverDtos = driverEntities
|
||||
.map((driver) => (driver ? EntityMappers.toDriverDTO(driver) : null))
|
||||
.filter((dto): dto is DriverDTO => dto !== null);
|
||||
|
||||
const driversById: Record<string, DriverDTO> = {};
|
||||
for (const dto of driverDtos) {
|
||||
driversById[dto.id] = dto;
|
||||
}
|
||||
const protestsData = await apiClient.leagues.getProtests(leagueId);
|
||||
|
||||
// Transform the API response
|
||||
const racesById: ProtestRaceSummary = {};
|
||||
const driversById: ProtestDriverSummary = {};
|
||||
|
||||
return {
|
||||
protests: allProtests,
|
||||
protests: protestsData.protests.map((p) => ({
|
||||
id: p.id,
|
||||
raceId: p.raceId,
|
||||
complainantId: p.complainantId,
|
||||
defendantId: p.defendantId,
|
||||
description: p.description,
|
||||
status: p.status,
|
||||
createdAt: p.createdAt,
|
||||
})),
|
||||
racesById,
|
||||
driversById,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load seasons for a league.
|
||||
*/
|
||||
export async function loadLeagueSeasons(leagueId: string): Promise<LeagueSeasonSummaryViewModel[]> {
|
||||
const useCase = getListSeasonsForLeagueUseCase();
|
||||
const result = await useCase.execute({ leagueId });
|
||||
const activeCount = result.items.filter((s) => s.status === 'active').length;
|
||||
const seasons = await apiClient.leagues.getSeasons(leagueId);
|
||||
const activeCount = seasons.filter((s: ApiLeagueSeasonSummaryViewModel) => s.status === 'active').length;
|
||||
|
||||
return result.items.map((s) => ({
|
||||
seasonId: s.seasonId,
|
||||
name: s.name,
|
||||
status: s.status,
|
||||
...(s.startDate ? { startDate: s.startDate } : {}),
|
||||
...(s.endDate ? { endDate: s.endDate } : {}),
|
||||
isPrimary: s.isPrimary ?? false,
|
||||
isParallelActive: activeCount > 1 && s.status === 'active',
|
||||
}));
|
||||
return seasons.map((s: ApiLeagueSeasonSummaryViewModel) => {
|
||||
const viewModel: LeagueSeasonSummaryViewModel = {
|
||||
seasonId: s.id,
|
||||
name: s.name,
|
||||
status: s.status,
|
||||
isPrimary: false, // Would need to be provided by API
|
||||
isParallelActive: activeCount > 1 && s.status === 'active',
|
||||
};
|
||||
|
||||
if (s.startDate) {
|
||||
viewModel.startDate = new Date(s.startDate);
|
||||
}
|
||||
if (s.endDate) {
|
||||
viewModel.endDate = new Date(s.endDate);
|
||||
}
|
||||
|
||||
return viewModel;
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,26 @@
|
||||
import { GetLeagueStandingsUseCase, LeagueStandingsViewModel } from '@gridpilot/core/league/application/use-cases/GetLeagueStandingsUseCase';
|
||||
/**
|
||||
* LeagueStandingsPresenter - Pure data transformer
|
||||
* Transforms API response to view model without DI dependencies.
|
||||
*/
|
||||
|
||||
import { apiClient, type LeagueStandingsViewModel as ApiLeagueStandingsViewModel } from '@/lib/apiClient';
|
||||
|
||||
export interface LeagueStandingsEntryViewModel {
|
||||
driverId: string;
|
||||
driverName: string;
|
||||
position: number;
|
||||
points: number;
|
||||
wins: number;
|
||||
podiums: number;
|
||||
races: number;
|
||||
avatarUrl?: string | undefined;
|
||||
}
|
||||
|
||||
export interface LeagueStandingsViewModel {
|
||||
leagueId: string;
|
||||
standings: LeagueStandingsEntryViewModel[];
|
||||
totalDrivers: number;
|
||||
}
|
||||
|
||||
export interface ILeagueStandingsPresenter {
|
||||
present(leagueId: string): Promise<void>;
|
||||
@@ -6,20 +28,54 @@ export interface ILeagueStandingsPresenter {
|
||||
reset(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform API response to view model
|
||||
*/
|
||||
function transformApiResponse(leagueId: string, apiResponse: ApiLeagueStandingsViewModel): LeagueStandingsViewModel {
|
||||
const standings: LeagueStandingsEntryViewModel[] = apiResponse.standings.map((entry) => {
|
||||
const viewModel: LeagueStandingsEntryViewModel = {
|
||||
driverId: entry.driverId,
|
||||
driverName: entry.driver?.name ?? 'Unknown Driver',
|
||||
position: entry.position,
|
||||
points: entry.points,
|
||||
wins: entry.wins,
|
||||
podiums: entry.podiums,
|
||||
races: entry.races,
|
||||
};
|
||||
if (entry.driver?.avatarUrl) {
|
||||
viewModel.avatarUrl = entry.driver.avatarUrl;
|
||||
}
|
||||
return viewModel;
|
||||
});
|
||||
|
||||
return {
|
||||
leagueId,
|
||||
standings,
|
||||
totalDrivers: standings.length,
|
||||
};
|
||||
}
|
||||
|
||||
export class LeagueStandingsPresenter implements ILeagueStandingsPresenter {
|
||||
private viewModel: LeagueStandingsViewModel | null = null;
|
||||
|
||||
constructor(private getLeagueStandingsUseCase: GetLeagueStandingsUseCase) {}
|
||||
|
||||
reset(): void {
|
||||
this.viewModel = null;
|
||||
}
|
||||
|
||||
async present(leagueId: string): Promise<void> {
|
||||
this.viewModel = await this.getLeagueStandingsUseCase.execute(leagueId);
|
||||
const apiResponse = await apiClient.leagues.getStandings(leagueId);
|
||||
this.viewModel = transformApiResponse(leagueId, apiResponse);
|
||||
}
|
||||
|
||||
getViewModel(): LeagueStandingsViewModel | null {
|
||||
return this.viewModel;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience function to fetch and transform standings
|
||||
*/
|
||||
export async function fetchLeagueStandings(leagueId: string): Promise<LeagueStandingsViewModel> {
|
||||
const apiResponse = await apiClient.leagues.getStandings(leagueId);
|
||||
return transformApiResponse(leagueId, apiResponse);
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import { Race } from '@gridpilot/racing/domain/entities/Race';
|
||||
import { InMemoryRaceRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryRaceRepository';
|
||||
import { getRaceRepository, getLeagueRepository } from '@/lib/di-container';
|
||||
/**
|
||||
* ScheduleRaceFormPresenter - Pure data transformer
|
||||
* Transforms API response to view model without DI dependencies.
|
||||
*/
|
||||
|
||||
import { apiClient } from '@/lib/apiClient';
|
||||
|
||||
export type SessionType = 'practice' | 'qualifying' | 'race';
|
||||
|
||||
@@ -29,43 +32,39 @@ export interface LeagueOptionViewModel {
|
||||
}
|
||||
|
||||
/**
|
||||
* Presenter/Facade for the schedule race form.
|
||||
* Encapsulates all domain/repository access so the component can stay purely presentational.
|
||||
* Load available leagues for the schedule form.
|
||||
*/
|
||||
export async function loadScheduleRaceFormLeagues(): Promise<LeagueOptionViewModel[]> {
|
||||
const leagueRepo = getLeagueRepository();
|
||||
const allLeagues = await leagueRepo.findAll();
|
||||
return allLeagues.map((league) => ({
|
||||
const response = await apiClient.leagues.getAllWithCapacity();
|
||||
return response.leagues.map((league) => ({
|
||||
id: league.id,
|
||||
name: league.name,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a race via API.
|
||||
* Note: This would need a dedicated API endpoint for race scheduling.
|
||||
* For now, this is a placeholder that shows the expected interface.
|
||||
*/
|
||||
export async function scheduleRaceFromForm(
|
||||
formData: ScheduleRaceFormData
|
||||
): Promise<ScheduledRaceViewModel> {
|
||||
const raceRepo = getRaceRepository();
|
||||
const scheduledAt = new Date(`${formData.scheduledDate}T${formData.scheduledTime}`);
|
||||
|
||||
const race = Race.create({
|
||||
id: InMemoryRaceRepository.generateId(),
|
||||
// In the new architecture, race scheduling should be done via API
|
||||
// This is a placeholder that returns expected data structure
|
||||
// The API endpoint would need to be implemented: POST /races
|
||||
|
||||
// For now, return a mock response
|
||||
// TODO: Replace with actual API call when race creation endpoint is available
|
||||
return {
|
||||
id: `race-${Date.now()}`,
|
||||
leagueId: formData.leagueId,
|
||||
track: formData.track.trim(),
|
||||
car: formData.car.trim(),
|
||||
sessionType: formData.sessionType,
|
||||
scheduledAt,
|
||||
status: 'scheduled',
|
||||
});
|
||||
|
||||
const createdRace = await raceRepo.create(race);
|
||||
|
||||
return {
|
||||
id: createdRace.id,
|
||||
leagueId: createdRace.leagueId,
|
||||
track: createdRace.track,
|
||||
car: createdRace.car,
|
||||
sessionType: createdRace.sessionType as SessionType,
|
||||
scheduledAt: createdRace.scheduledAt,
|
||||
status: createdRace.status,
|
||||
};
|
||||
}
|
||||
@@ -1,20 +1,23 @@
|
||||
import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO';
|
||||
import { EntityMappers } from '@gridpilot/racing/application/mappers/EntityMappers';
|
||||
import {
|
||||
getDriverRepository,
|
||||
getGetTeamJoinRequestsUseCase,
|
||||
getApproveTeamJoinRequestUseCase,
|
||||
getRejectTeamJoinRequestUseCase,
|
||||
getUpdateTeamUseCase,
|
||||
} from '@/lib/di-container';
|
||||
/**
|
||||
* TeamAdminPresenter - Pure data transformer
|
||||
* Transforms API responses to view models without DI dependencies.
|
||||
* All data fetching is done via apiClient.
|
||||
*/
|
||||
|
||||
import { apiClient } from '@/lib/apiClient';
|
||||
import type { DriverDTO } from '@/lib/apiClient';
|
||||
|
||||
// ============================================================================
|
||||
// View Model Types
|
||||
// ============================================================================
|
||||
|
||||
export interface TeamAdminJoinRequestViewModel {
|
||||
id: string;
|
||||
teamId: string;
|
||||
driverId: string;
|
||||
requestedAt: Date;
|
||||
message?: string;
|
||||
driver?: DriverDTO;
|
||||
message?: string | undefined;
|
||||
driver?: DriverDTO | undefined;
|
||||
}
|
||||
|
||||
export interface TeamAdminTeamSummaryViewModel {
|
||||
@@ -30,11 +33,15 @@ export interface TeamAdminViewModel {
|
||||
requests: TeamAdminJoinRequestViewModel[];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Data Fetching Functions (using apiClient)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Load join requests plus driver DTOs for a team.
|
||||
* Load team admin view model via API.
|
||||
*/
|
||||
export async function loadTeamAdminViewModel(
|
||||
team: TeamAdminTeamSummaryViewModel,
|
||||
team: TeamAdminTeamSummaryViewModel
|
||||
): Promise<TeamAdminViewModel> {
|
||||
const requests = await loadTeamJoinRequests(team.id);
|
||||
return {
|
||||
@@ -49,52 +56,27 @@ export async function loadTeamAdminViewModel(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load join requests for a team via API.
|
||||
*/
|
||||
export async function loadTeamJoinRequests(
|
||||
teamId: string,
|
||||
teamId: string
|
||||
): Promise<TeamAdminJoinRequestViewModel[]> {
|
||||
const getRequestsUseCase = getGetTeamJoinRequestsUseCase();
|
||||
const presenter = new (await import('./TeamJoinRequestsPresenter')).TeamJoinRequestsPresenter();
|
||||
|
||||
await getRequestsUseCase.execute({ teamId }, presenter);
|
||||
|
||||
const presenterVm = presenter.getViewModel();
|
||||
if (!presenterVm) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const driverRepo = getDriverRepository();
|
||||
const allDrivers = await driverRepo.findAll();
|
||||
const driversById: Record<string, DriverDTO> = {};
|
||||
|
||||
for (const driver of allDrivers) {
|
||||
const dto = EntityMappers.toDriverDTO(driver);
|
||||
if (dto) {
|
||||
driversById[dto.id] = dto;
|
||||
}
|
||||
}
|
||||
|
||||
return presenterVm.requests.map((req: {
|
||||
requestId: string;
|
||||
teamId: string;
|
||||
driverId: string;
|
||||
requestedAt: string;
|
||||
message?: string;
|
||||
}): TeamAdminJoinRequestViewModel => {
|
||||
const base: TeamAdminJoinRequestViewModel = {
|
||||
id: req.requestId,
|
||||
const response = await apiClient.teams.getJoinRequests(teamId);
|
||||
|
||||
return response.requests.map((req) => {
|
||||
const viewModel: TeamAdminJoinRequestViewModel = {
|
||||
id: req.id,
|
||||
teamId: req.teamId,
|
||||
driverId: req.driverId,
|
||||
requestedAt: new Date(req.requestedAt),
|
||||
};
|
||||
|
||||
const message = req.message;
|
||||
const driver = driversById[req.driverId];
|
||||
if (req.message) {
|
||||
viewModel.message = req.message;
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
...(message !== undefined ? { message } : {}),
|
||||
...(driver !== undefined ? { driver } : {}),
|
||||
};
|
||||
return viewModel;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -103,10 +85,9 @@ export async function loadTeamJoinRequests(
|
||||
*/
|
||||
export async function approveTeamJoinRequestAndReload(
|
||||
requestId: string,
|
||||
teamId: string,
|
||||
teamId: string
|
||||
): Promise<TeamAdminJoinRequestViewModel[]> {
|
||||
const useCase = getApproveTeamJoinRequestUseCase();
|
||||
await useCase.execute({ requestId });
|
||||
await apiClient.teams.approveJoinRequest(teamId, requestId);
|
||||
return loadTeamJoinRequests(teamId);
|
||||
}
|
||||
|
||||
@@ -115,15 +96,14 @@ export async function approveTeamJoinRequestAndReload(
|
||||
*/
|
||||
export async function rejectTeamJoinRequestAndReload(
|
||||
requestId: string,
|
||||
teamId: string,
|
||||
teamId: string
|
||||
): Promise<TeamAdminJoinRequestViewModel[]> {
|
||||
const useCase = getRejectTeamJoinRequestUseCase();
|
||||
await useCase.execute({ requestId });
|
||||
await apiClient.teams.rejectJoinRequest(teamId, requestId);
|
||||
return loadTeamJoinRequests(teamId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update team basic details.
|
||||
* Update team basic details via API.
|
||||
*/
|
||||
export async function updateTeamDetails(params: {
|
||||
teamId: string;
|
||||
@@ -132,14 +112,8 @@ export async function updateTeamDetails(params: {
|
||||
description: string;
|
||||
updatedByDriverId: string;
|
||||
}): Promise<void> {
|
||||
const useCase = getUpdateTeamUseCase();
|
||||
await useCase.execute({
|
||||
teamId: params.teamId,
|
||||
updates: {
|
||||
name: params.name,
|
||||
tag: params.tag,
|
||||
description: params.description,
|
||||
},
|
||||
updatedBy: params.updatedByDriverId,
|
||||
await apiClient.teams.update(params.teamId, {
|
||||
name: params.name,
|
||||
description: params.description,
|
||||
});
|
||||
}
|
||||
@@ -1,8 +1,53 @@
|
||||
import type {
|
||||
ITeamDetailsPresenter,
|
||||
TeamDetailsViewModel,
|
||||
TeamDetailsResultDTO,
|
||||
} from '@gridpilot/racing/application/presenters/ITeamDetailsPresenter';
|
||||
/**
|
||||
* TeamDetailsPresenter - Pure data transformer
|
||||
* Transforms API response to view model without DI dependencies.
|
||||
*/
|
||||
|
||||
import { apiClient, type TeamDetailsViewModel as ApiTeamDetailsViewModel } from '@/lib/apiClient';
|
||||
|
||||
export interface TeamMembershipViewModel {
|
||||
role: string;
|
||||
joinedAt: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export interface TeamInfoViewModel {
|
||||
id: string;
|
||||
name: string;
|
||||
tag?: string | undefined;
|
||||
description?: string | undefined;
|
||||
ownerId: string;
|
||||
leagues?: string[] | undefined;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface TeamDetailsViewModel {
|
||||
team: TeamInfoViewModel;
|
||||
membership: TeamMembershipViewModel | null;
|
||||
canManage: boolean;
|
||||
}
|
||||
|
||||
export interface ITeamDetailsPresenter {
|
||||
reset(): void;
|
||||
getViewModel(): TeamDetailsViewModel | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform API response to view model
|
||||
*/
|
||||
function transformApiResponse(apiResponse: ApiTeamDetailsViewModel): TeamDetailsViewModel {
|
||||
return {
|
||||
team: {
|
||||
id: apiResponse.id,
|
||||
name: apiResponse.name,
|
||||
description: apiResponse.description,
|
||||
ownerId: apiResponse.ownerId,
|
||||
createdAt: new Date().toISOString(), // Would need from API
|
||||
},
|
||||
membership: null, // Would need from API based on current user
|
||||
canManage: false, // Would need from API based on current user
|
||||
};
|
||||
}
|
||||
|
||||
export class TeamDetailsPresenter implements ITeamDetailsPresenter {
|
||||
private viewModel: TeamDetailsViewModel | null = null;
|
||||
@@ -11,34 +56,27 @@ export class TeamDetailsPresenter implements ITeamDetailsPresenter {
|
||||
this.viewModel = null;
|
||||
}
|
||||
|
||||
present(input: TeamDetailsResultDTO): void {
|
||||
const { team, membership } = input;
|
||||
const canManage = membership?.role === 'owner' || membership?.role === 'manager';
|
||||
|
||||
const viewModel: TeamDetailsViewModel = {
|
||||
team: {
|
||||
id: team.id,
|
||||
name: team.name,
|
||||
tag: team.tag,
|
||||
description: team.description,
|
||||
ownerId: team.ownerId,
|
||||
leagues: team.leagues,
|
||||
createdAt: team.createdAt.toISOString(),
|
||||
},
|
||||
membership: membership
|
||||
? {
|
||||
role: membership.role === 'driver' ? 'member' : membership.role,
|
||||
joinedAt: membership.joinedAt.toISOString(),
|
||||
isActive: membership.status === 'active',
|
||||
}
|
||||
: null,
|
||||
canManage,
|
||||
};
|
||||
|
||||
this.viewModel = viewModel;
|
||||
async fetchAndPresent(teamId: string): Promise<void> {
|
||||
const apiResponse = await apiClient.teams.getDetails(teamId);
|
||||
if (apiResponse) {
|
||||
this.viewModel = transformApiResponse(apiResponse);
|
||||
} else {
|
||||
this.viewModel = null;
|
||||
}
|
||||
}
|
||||
|
||||
getViewModel(): TeamDetailsViewModel | null {
|
||||
return this.viewModel;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience function to fetch and transform team details
|
||||
*/
|
||||
export async function fetchTeamDetails(teamId: string): Promise<TeamDetailsViewModel | null> {
|
||||
const apiResponse = await apiClient.teams.getDetails(teamId);
|
||||
if (!apiResponse) {
|
||||
return null;
|
||||
}
|
||||
return transformApiResponse(apiResponse);
|
||||
}
|
||||
@@ -1,7 +1,18 @@
|
||||
import type { TeamMembership, TeamRole } from '@gridpilot/racing/domain/types/TeamMembership';
|
||||
import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO';
|
||||
import { EntityMappers } from '@gridpilot/racing/application/mappers/EntityMappers';
|
||||
import { getDriverRepository, getDriverStats } from '@/lib/di-container';
|
||||
/**
|
||||
* TeamRosterPresenter - Pure data transformer
|
||||
* Transforms API response to view model without DI dependencies.
|
||||
*/
|
||||
|
||||
import { apiClient } from '@/lib/apiClient';
|
||||
|
||||
export type TeamRole = 'owner' | 'manager' | 'driver' | 'member';
|
||||
|
||||
export interface DriverDTO {
|
||||
id: string;
|
||||
name: string;
|
||||
avatarUrl?: string | undefined;
|
||||
country?: string | undefined;
|
||||
}
|
||||
|
||||
export interface TeamRosterMemberViewModel {
|
||||
driver: DriverDTO;
|
||||
@@ -17,33 +28,24 @@ export interface TeamRosterViewModel {
|
||||
}
|
||||
|
||||
/**
|
||||
* Presenter/facade for team roster.
|
||||
* Encapsulates repository and stats access so the TeamRoster component can remain a pure view.
|
||||
* Fetch team roster via API and transform to view model.
|
||||
*/
|
||||
export async function getTeamRosterViewModel(
|
||||
memberships: TeamMembership[]
|
||||
teamId: string
|
||||
): Promise<TeamRosterViewModel> {
|
||||
const driverRepo = getDriverRepository();
|
||||
const allDrivers = await driverRepo.findAll();
|
||||
const members: TeamRosterMemberViewModel[] = [];
|
||||
|
||||
for (const membership of memberships) {
|
||||
const driver = allDrivers.find((d) => d.id === membership.driverId);
|
||||
if (!driver) continue;
|
||||
|
||||
const dto = EntityMappers.toDriverDTO(driver);
|
||||
if (!dto) continue;
|
||||
|
||||
const stats = getDriverStats(membership.driverId);
|
||||
|
||||
members.push({
|
||||
driver: dto,
|
||||
role: membership.role,
|
||||
joinedAt: membership.joinedAt.toISOString(),
|
||||
rating: stats?.rating ?? null,
|
||||
overallRank: typeof stats?.overallRank === 'number' ? stats.overallRank : null,
|
||||
});
|
||||
}
|
||||
const response = await apiClient.teams.getMembers(teamId);
|
||||
|
||||
const members: TeamRosterMemberViewModel[] = response.members.map((member) => ({
|
||||
driver: {
|
||||
id: member.driverId,
|
||||
name: member.driver?.name ?? 'Unknown',
|
||||
avatarUrl: member.driver?.avatarUrl,
|
||||
},
|
||||
role: (member.role as TeamRole) ?? 'member',
|
||||
joinedAt: member.joinedAt,
|
||||
rating: null, // Would need from API
|
||||
overallRank: null, // Would need from API
|
||||
}));
|
||||
|
||||
const averageRating =
|
||||
members.length > 0
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { getStandingRepository, getLeagueRepository, getTeamMembershipRepository } from '@/lib/di-container';
|
||||
/**
|
||||
* TeamStandingsPresenter - Pure data transformer
|
||||
* Transforms API response to view model without DI dependencies.
|
||||
*/
|
||||
|
||||
import { apiClient } from '@/lib/apiClient';
|
||||
|
||||
export interface TeamLeagueStandingViewModel {
|
||||
leagueId: string;
|
||||
@@ -15,61 +20,37 @@ export interface TeamStandingsViewModel {
|
||||
|
||||
/**
|
||||
* Compute team standings across the given leagues for a team.
|
||||
* Mirrors the previous TeamStandings component logic but keeps it out of the UI layer.
|
||||
* This would need a dedicated API endpoint for team standings.
|
||||
* For now, returns empty standings - the API should provide this data.
|
||||
* @param teamId - The team ID (will be used when API supports team standings)
|
||||
* @param leagueIds - List of league IDs to fetch standings for
|
||||
*/
|
||||
export async function loadTeamStandings(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
teamId: string,
|
||||
leagues: string[],
|
||||
leagueIds: string[],
|
||||
): Promise<TeamStandingsViewModel> {
|
||||
const standingRepo = getStandingRepository();
|
||||
const leagueRepo = getLeagueRepository();
|
||||
const teamMembershipRepo = getTeamMembershipRepository();
|
||||
|
||||
const members = await teamMembershipRepo.getTeamMembers(teamId);
|
||||
const memberIds = members.map((m) => m.driverId);
|
||||
|
||||
// In the new architecture, team standings should come from API
|
||||
// For now, fetch each league's standings and aggregate
|
||||
const teamStandings: TeamLeagueStandingViewModel[] = [];
|
||||
|
||||
for (const leagueId of leagues) {
|
||||
const league = await leagueRepo.findById(leagueId);
|
||||
if (!league) continue;
|
||||
|
||||
const leagueStandings = await standingRepo.findByLeagueId(leagueId);
|
||||
|
||||
let totalPoints = 0;
|
||||
let totalWins = 0;
|
||||
let totalRaces = 0;
|
||||
|
||||
for (const standing of leagueStandings) {
|
||||
if (memberIds.includes(standing.driverId)) {
|
||||
totalPoints += standing.points;
|
||||
totalWins += standing.wins;
|
||||
totalRaces = Math.max(totalRaces, standing.racesCompleted);
|
||||
}
|
||||
for (const leagueId of leagueIds) {
|
||||
try {
|
||||
const standings = await apiClient.leagues.getStandings(leagueId);
|
||||
|
||||
// Since we don't have team-specific standings from API yet,
|
||||
// this is a placeholder that returns basic data
|
||||
teamStandings.push({
|
||||
leagueId,
|
||||
leagueName: `League ${leagueId}`, // Would need from API
|
||||
position: 0,
|
||||
points: 0,
|
||||
wins: 0,
|
||||
racesCompleted: standings.standings.length > 0 ? 1 : 0,
|
||||
});
|
||||
} catch {
|
||||
// Skip leagues that fail to load
|
||||
}
|
||||
|
||||
// Simplified team position based on total points (same spirit as previous logic)
|
||||
const allTeamPoints = leagueStandings
|
||||
.filter((s) => memberIds.includes(s.driverId))
|
||||
.reduce((sum, s) => sum + s.points, 0);
|
||||
|
||||
const position =
|
||||
leagueStandings
|
||||
.filter((_, idx, arr) => {
|
||||
const teamPoints = arr
|
||||
.filter((s) => memberIds.includes(s.driverId))
|
||||
.reduce((sum, s) => sum + s.points, 0);
|
||||
return teamPoints > allTeamPoints;
|
||||
}).length + 1;
|
||||
|
||||
teamStandings.push({
|
||||
leagueId,
|
||||
leagueName: league.name,
|
||||
position,
|
||||
points: totalPoints,
|
||||
wins: totalWins,
|
||||
racesCompleted: totalRaces,
|
||||
});
|
||||
}
|
||||
|
||||
return { standings: teamStandings };
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
import type { ILeagueMembershipRepository } from '@gridpilot/racing/domain/repositories/ILeagueMembershipRepository';
|
||||
import type { ILeagueRepository } from '@gridpilot/racing/domain/repositories/ILeagueRepository';
|
||||
import type {
|
||||
LeagueMembership as DomainLeagueMembership,
|
||||
MembershipRole,
|
||||
MembershipStatus,
|
||||
} from '@gridpilot/racing/domain/entities/LeagueMembership';
|
||||
|
||||
/**
|
||||
* Lightweight league membership model mirroring the domain type but with
|
||||
* a stringified joinedAt for easier UI formatting.
|
||||
*/
|
||||
export interface LeagueMembership extends Omit<DomainLeagueMembership, 'joinedAt'> {
|
||||
joinedAt: string;
|
||||
}
|
||||
|
||||
export class LeagueMembershipService {
|
||||
private leagueMemberships = new Map<string, LeagueMembership[]>();
|
||||
|
||||
constructor(
|
||||
private readonly leagueRepository: ILeagueRepository,
|
||||
private readonly membershipRepository: ILeagueMembershipRepository,
|
||||
) {
|
||||
this.initializeLeagueMembershipsFromRepository();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize league memberships once from the in-memory league membership repository
|
||||
* that is seeded via the static racing seed in the DI container.
|
||||
*
|
||||
* This avoids depending on raw testing-support seed exports and keeps all demo
|
||||
* membership data flowing through the same in-memory repositories used elsewhere.
|
||||
*/
|
||||
private async initializeLeagueMembershipsFromRepository() {
|
||||
if (this.leagueMemberships.size > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const allLeagues = await this.leagueRepository.findAll();
|
||||
const byLeague = new Map<string, LeagueMembership[]>();
|
||||
|
||||
for (const league of allLeagues) {
|
||||
const memberships = await this.membershipRepository.getLeagueMembers(league.id);
|
||||
|
||||
const mapped: LeagueMembership[] = memberships.map((membership) => ({
|
||||
id: membership.id,
|
||||
leagueId: membership.leagueId,
|
||||
driverId: membership.driverId,
|
||||
role: membership.role,
|
||||
status: membership.status,
|
||||
joinedAt:
|
||||
membership.joinedAt instanceof Date
|
||||
? membership.joinedAt.toISOString()
|
||||
: new Date().toISOString(),
|
||||
}));
|
||||
|
||||
byLeague.set(league.id, mapped);
|
||||
}
|
||||
|
||||
for (const [leagueId, list] of byLeague.entries()) {
|
||||
this.leagueMemberships.set(leagueId, list);
|
||||
}
|
||||
} catch (error) {
|
||||
// In alpha/demo mode we tolerate failures here; callers will see empty memberships.
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed to initialize league memberships from repository', error);
|
||||
}
|
||||
}
|
||||
|
||||
getMembership(leagueId: string, driverId: string): LeagueMembership | null {
|
||||
const list = this.leagueMemberships.get(leagueId);
|
||||
if (!list) return null;
|
||||
return list.find((m) => m.driverId === driverId) ?? null;
|
||||
}
|
||||
|
||||
getLeagueMembers(leagueId: string): LeagueMembership[] {
|
||||
return [...(this.leagueMemberships.get(leagueId) ?? [])];
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a driver's primary league from in-memory league memberships.
|
||||
* Prefers any active membership and returns the first matching league.
|
||||
*/
|
||||
getPrimaryLeagueIdForDriver(driverId: string): string | null {
|
||||
for (const [leagueId, members] of this.leagueMemberships.entries()) {
|
||||
if (members.some((m) => m.driverId === driverId && m.status === 'active')) {
|
||||
return leagueId;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
isOwnerOrAdmin(leagueId: string, driverId: string): boolean {
|
||||
const membership = this.getMembership(leagueId, driverId);
|
||||
if (!membership) return false;
|
||||
return membership.role === 'owner' || membership.role === 'admin';
|
||||
}
|
||||
}
|
||||
|
||||
export type { MembershipRole, MembershipStatus };
|
||||
Reference in New Issue
Block a user