wip
This commit is contained in:
@@ -60,10 +60,14 @@ export class InMemoryAuthService implements AuthService {
|
||||
const provider = new IracingDemoIdentityProviderAdapter();
|
||||
const useCase = new StartAuthUseCase(provider);
|
||||
|
||||
const command: StartAuthCommandDTO = {
|
||||
provider: 'IRACING_DEMO',
|
||||
returnTo,
|
||||
};
|
||||
const command: StartAuthCommandDTO = returnTo
|
||||
? {
|
||||
provider: 'IRACING_DEMO',
|
||||
returnTo,
|
||||
}
|
||||
: {
|
||||
provider: 'IRACING_DEMO',
|
||||
};
|
||||
|
||||
return useCase.execute(command);
|
||||
}
|
||||
@@ -77,12 +81,18 @@ export class InMemoryAuthService implements AuthService {
|
||||
const sessionPort = new CookieIdentitySessionAdapter();
|
||||
const useCase = new HandleAuthCallbackUseCase(provider, sessionPort);
|
||||
|
||||
const command: AuthCallbackCommandDTO = {
|
||||
provider: 'IRACING_DEMO',
|
||||
code: params.code,
|
||||
state: params.state,
|
||||
returnTo: params.returnTo,
|
||||
};
|
||||
const command: AuthCallbackCommandDTO = params.returnTo
|
||||
? {
|
||||
provider: 'IRACING_DEMO',
|
||||
code: params.code,
|
||||
state: params.state,
|
||||
returnTo: params.returnTo,
|
||||
}
|
||||
: {
|
||||
provider: 'IRACING_DEMO',
|
||||
code: params.code,
|
||||
state: params.state,
|
||||
};
|
||||
|
||||
return useCase.execute(command);
|
||||
}
|
||||
|
||||
@@ -24,12 +24,23 @@ export function useEffectiveDriverId(): string {
|
||||
|
||||
try {
|
||||
// Lazy-load to avoid importing DI facade at module evaluation time
|
||||
const { getDriverRepository } = require('./di-container') as typeof import('./di-container');
|
||||
const { getDriverRepository } =
|
||||
require('./di-container') as typeof import('./di-container');
|
||||
const repo = getDriverRepository();
|
||||
// In-memory repository is synchronous for findAll in the demo implementation
|
||||
const allDrivers = repo.findAllSync?.() as Array<{ id: string }> | undefined;
|
||||
if (allDrivers && allDrivers.length > 0) {
|
||||
return allDrivers[0].id;
|
||||
|
||||
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
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Season } from '@gridpilot/racing/domain/entities/Season';
|
||||
import { Sponsor } from '@gridpilot/racing/domain/entities/Sponsor';
|
||||
import { SeasonSponsorship } from '@gridpilot/racing/domain/entities/SeasonSponsorship';
|
||||
import { Money } from '@gridpilot/racing/domain/value-objects/Money';
|
||||
import type { LeagueMembership, JoinRequest } from '@gridpilot/racing/domain/entities/LeagueMembership';
|
||||
import type { JoinRequest } from '@gridpilot/racing/domain/entities/LeagueMembership';
|
||||
|
||||
import type { IDriverRepository } from '@gridpilot/racing/domain/repositories/IDriverRepository';
|
||||
import type { ILeagueRepository } from '@gridpilot/racing/domain/repositories/ILeagueRepository';
|
||||
@@ -139,7 +139,6 @@ import { TransferLeagueOwnershipUseCase } from '@gridpilot/racing/application/us
|
||||
import { CancelRaceUseCase } from '@gridpilot/racing/application/use-cases/CancelRaceUseCase';
|
||||
import { ImportRaceResultsUseCase } from '@gridpilot/racing/application/use-cases/ImportRaceResultsUseCase';
|
||||
import { DriversLeaderboardPresenter } from './presenters/DriversLeaderboardPresenter';
|
||||
import { TeamsLeaderboardPresenter } from './presenters/TeamsLeaderboardPresenter';
|
||||
import { RacesPagePresenter } from './presenters/RacesPagePresenter';
|
||||
import { AllRacesPagePresenter } from './presenters/AllRacesPagePresenter';
|
||||
import { AllTeamsPresenter } from './presenters/AllTeamsPresenter';
|
||||
@@ -195,7 +194,24 @@ export function configureDIContainer(): void {
|
||||
const primaryDriverId = seedData.drivers[0]!.id;
|
||||
|
||||
// Create driver statistics from seed data
|
||||
const driverStats = createDemoDriverStats(seedData.drivers);
|
||||
type DemoDriverStatsEntry = {
|
||||
rating?: number;
|
||||
wins?: number;
|
||||
podiums?: number;
|
||||
dnfs?: number;
|
||||
totalRaces?: number;
|
||||
avgFinish?: number;
|
||||
bestFinish?: number;
|
||||
worstFinish?: number;
|
||||
overallRank?: number;
|
||||
consistency?: number;
|
||||
percentile?: number;
|
||||
driverId?: string;
|
||||
};
|
||||
|
||||
type DemoDriverStatsMap = Record<string, DemoDriverStatsEntry>;
|
||||
|
||||
const driverStats: DemoDriverStatsMap = createDemoDriverStats(seedData.drivers);
|
||||
|
||||
// Register repositories
|
||||
container.registerInstance<IDriverRepository>(
|
||||
@@ -228,7 +244,11 @@ export function configureDIContainer(): void {
|
||||
);
|
||||
|
||||
// Race registrations - seed from results for completed races, plus some upcoming races
|
||||
const seedRaceRegistrations: Array<{ raceId: string; driverId: string; registeredAt: Date }> = [];
|
||||
const seedRaceRegistrations: Array<{
|
||||
raceId: string;
|
||||
driverId: string;
|
||||
registeredAt: Date;
|
||||
}> = [];
|
||||
|
||||
// For completed races, extract driver registrations from results
|
||||
for (const result of seedData.results) {
|
||||
@@ -280,6 +300,8 @@ export function configureDIContainer(): void {
|
||||
const seededPenalties: Penalty[] = [];
|
||||
const seededProtests: Protest[] = [];
|
||||
|
||||
type ProtestProps = Parameters<(typeof Protest)['create']>[0];
|
||||
|
||||
racesForProtests.forEach(({ race, leagueIndex: leagueIdx }, raceIndex) => {
|
||||
const raceResults = seedData.results.filter(r => r.raceId === race.id);
|
||||
if (raceResults.length < 4) return;
|
||||
@@ -291,33 +313,51 @@ export function configureDIContainer(): void {
|
||||
|
||||
if (!protestingResult || !accusedResult) continue;
|
||||
|
||||
const protestStatuses: Array<'pending' | 'under_review' | 'upheld' | 'dismissed'> = ['pending', 'under_review', 'upheld', 'dismissed'];
|
||||
const status = protestStatuses[(raceIndex + i) % protestStatuses.length];
|
||||
const protestStatuses = [
|
||||
'pending',
|
||||
'under_review',
|
||||
'upheld',
|
||||
'dismissed',
|
||||
] as const;
|
||||
const status =
|
||||
protestStatuses[(raceIndex + i) % protestStatuses.length] ?? 'pending';
|
||||
|
||||
const protest = Protest.create({
|
||||
const protestProps: ProtestProps = {
|
||||
id: `protest-${race.id}-${i}`,
|
||||
raceId: race.id,
|
||||
protestingDriverId: protestingResult.driverId,
|
||||
accusedDriverId: accusedResult.driverId,
|
||||
incident: {
|
||||
lap: 5 + i * 3,
|
||||
description: i === 0
|
||||
? 'Unsafe rejoining to the track after going off, causing contact'
|
||||
: 'Aggressive defending, pushing competitor off track',
|
||||
description:
|
||||
i === 0
|
||||
? 'Unsafe rejoining to the track after going off, causing contact'
|
||||
: 'Aggressive defending, pushing competitor off track',
|
||||
},
|
||||
comment: i === 0
|
||||
? 'Driver rejoined directly into my racing line, causing contact and damaging my front wing.'
|
||||
: 'Driver moved under braking multiple times, forcing me off the circuit.',
|
||||
comment:
|
||||
i === 0
|
||||
? 'Driver rejoined directly into my racing line, causing contact and damaging my front wing.'
|
||||
: 'Driver moved under braking multiple times, forcing me off the circuit.',
|
||||
status,
|
||||
filedAt: new Date(Date.now() - (raceIndex + 1) * 24 * 60 * 60 * 1000),
|
||||
reviewedBy: status !== 'pending' ? primaryDriverId : undefined,
|
||||
decisionNotes: status === 'upheld'
|
||||
? 'After reviewing the evidence, the accused driver is found at fault. Penalty applied.'
|
||||
: status === 'dismissed'
|
||||
? 'No clear fault found. Racing incident.'
|
||||
: undefined,
|
||||
reviewedAt: status !== 'pending' ? new Date(Date.now() - raceIndex * 24 * 60 * 60 * 1000) : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
if (status !== 'pending') {
|
||||
protestProps.reviewedBy = primaryDriverId;
|
||||
protestProps.reviewedAt = new Date(
|
||||
Date.now() - raceIndex * 24 * 60 * 60 * 1000,
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'upheld') {
|
||||
protestProps.decisionNotes =
|
||||
'After reviewing the evidence, the accused driver is found at fault. Penalty applied.';
|
||||
} else if (status === 'dismissed') {
|
||||
protestProps.decisionNotes =
|
||||
'No clear fault found. Racing incident.';
|
||||
}
|
||||
|
||||
const protest = Protest.create(protestProps);
|
||||
|
||||
seededProtests.push(protest);
|
||||
|
||||
@@ -448,7 +488,15 @@ export function configureDIContainer(): void {
|
||||
);
|
||||
|
||||
// League memberships
|
||||
const seededMemberships: LeagueMembership[] = seedData.memberships.map((m) => ({
|
||||
type SeedMembership = {
|
||||
leagueId: string;
|
||||
driverId: string;
|
||||
role: 'member' | 'owner' | 'admin' | 'steward';
|
||||
status: 'active';
|
||||
joinedAt: Date;
|
||||
};
|
||||
|
||||
const seededMemberships: SeedMembership[] = seedData.memberships.map((m) => ({
|
||||
leagueId: m.leagueId,
|
||||
driverId: m.driverId,
|
||||
role: 'member',
|
||||
@@ -476,11 +524,11 @@ export function configureDIContainer(): void {
|
||||
|
||||
// Ensure primary driver owns at least one league
|
||||
const hasPrimaryOwnerMembership = seededMemberships.some(
|
||||
(m: LeagueMembership) => m.driverId === primaryDriverId && m.role === 'owner',
|
||||
(m) => m.driverId === primaryDriverId && m.role === 'owner',
|
||||
);
|
||||
if (!hasPrimaryOwnerMembership && seedData.leagues.length > 0) {
|
||||
const targetLeague =
|
||||
seedData.leagues.find((l) => l.ownerId === primaryDriverId) ?? seedData.leagues[0];
|
||||
seedData.leagues.find((l) => l.ownerId === primaryDriverId) ?? seedData.leagues[0]!;
|
||||
|
||||
const existingForPrimary = seededMemberships.find(
|
||||
(m) => m.leagueId === targetLeague.id && m.driverId === primaryDriverId,
|
||||
@@ -574,23 +622,36 @@ export function configureDIContainer(): void {
|
||||
'Heard great things about this league. Can I join?',
|
||||
'Experienced driver looking for competitive racing.',
|
||||
'My friend recommended this league. Hope to race with you!',
|
||||
];
|
||||
] as const;
|
||||
const message =
|
||||
messages[(index + leagueIndex) % messages.length] ?? messages[0];
|
||||
seededJoinRequests.push({
|
||||
id: `join-${league.id}-${driver.id}`,
|
||||
leagueId: league.id,
|
||||
driverId: driver.id,
|
||||
requestedAt: new Date(Date.now() - (index + 1 + leagueIndex) * 24 * 60 * 60 * 1000),
|
||||
message: messages[(index + leagueIndex) % messages.length],
|
||||
requestedAt: new Date(
|
||||
Date.now() - (index + 1 + leagueIndex) * 24 * 60 * 60 * 1000,
|
||||
),
|
||||
message,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
type InMemoryLeagueMembershipSeed = ConstructorParameters<
|
||||
typeof InMemoryLeagueMembershipRepository
|
||||
>[0];
|
||||
|
||||
container.registerInstance<ILeagueMembershipRepository>(
|
||||
DI_TOKENS.LeagueMembershipRepository,
|
||||
new InMemoryLeagueMembershipRepository(seededMemberships, seededJoinRequests)
|
||||
new InMemoryLeagueMembershipRepository(
|
||||
seededMemberships as InMemoryLeagueMembershipSeed,
|
||||
seededJoinRequests,
|
||||
)
|
||||
);
|
||||
|
||||
// Team repositories
|
||||
type InMemoryTeamSeed = ConstructorParameters<typeof InMemoryTeamRepository>[0];
|
||||
|
||||
container.registerInstance<ITeamRepository>(
|
||||
DI_TOKENS.TeamRepository,
|
||||
new InMemoryTeamRepository(
|
||||
@@ -602,8 +663,8 @@ export function configureDIContainer(): void {
|
||||
ownerId: seedData.drivers[0]!.id,
|
||||
leagues: [t.primaryLeagueId],
|
||||
createdAt: new Date(),
|
||||
}))
|
||||
)
|
||||
})) as InMemoryTeamSeed,
|
||||
),
|
||||
);
|
||||
|
||||
container.registerInstance<ITeamMembershipRepository>(
|
||||
@@ -644,16 +705,13 @@ export function configureDIContainer(): void {
|
||||
);
|
||||
|
||||
const sponsorRepo = new InMemorySponsorRepository();
|
||||
// Use synchronous seeding via internal method
|
||||
seededSponsors.forEach(sponsor => {
|
||||
(sponsorRepo as any).sponsors.set(sponsor.id, sponsor);
|
||||
});
|
||||
sponsorRepo.seed(seededSponsors);
|
||||
container.registerInstance<ISponsorRepository>(
|
||||
DI_TOKENS.SponsorRepository,
|
||||
sponsorRepo
|
||||
);
|
||||
|
||||
const seededSponsorships = seedData.seasonSponsorships.map(ss =>
|
||||
const seededSponsorships = seedData.seasonSponsorships.map((ss) =>
|
||||
SeasonSponsorship.create({
|
||||
id: ss.id,
|
||||
seasonId: ss.seasonId,
|
||||
@@ -661,15 +719,12 @@ export function configureDIContainer(): void {
|
||||
tier: ss.tier,
|
||||
pricing: Money.create(ss.pricingAmount, ss.pricingCurrency),
|
||||
status: ss.status,
|
||||
description: ss.description,
|
||||
})
|
||||
description: ss.description ?? '',
|
||||
}),
|
||||
);
|
||||
|
||||
const seasonSponsorshipRepo = new InMemorySeasonSponsorshipRepository();
|
||||
// Use synchronous seeding via internal method
|
||||
seededSponsorships.forEach(sponsorship => {
|
||||
(seasonSponsorshipRepo as any).sponsorships.set(sponsorship.id, sponsorship);
|
||||
});
|
||||
seasonSponsorshipRepo.seed(seededSponsorships);
|
||||
container.registerInstance<ISeasonSponsorshipRepository>(
|
||||
DI_TOKENS.SeasonSponsorshipRepository,
|
||||
seasonSponsorshipRepo
|
||||
@@ -691,9 +746,9 @@ export function configureDIContainer(): void {
|
||||
);
|
||||
|
||||
// Seed sponsorship requests from demo data
|
||||
seedData.sponsorshipRequests?.forEach(request => {
|
||||
(sponsorshipRequestRepo as any).requests.set(request.id, request);
|
||||
});
|
||||
if (seedData.sponsorshipRequests && seedData.sponsorshipRequests.length > 0) {
|
||||
sponsorshipRequestRepo.seed(seedData.sponsorshipRequests);
|
||||
}
|
||||
|
||||
// Social repositories
|
||||
container.registerInstance<IFeedRepository>(
|
||||
@@ -732,7 +787,7 @@ export function configureDIContainer(): void {
|
||||
);
|
||||
|
||||
// Register driver stats for access by utility functions
|
||||
container.registerInstance(
|
||||
container.registerInstance<DemoDriverStatsMap>(
|
||||
DI_TOKENS.DriverStats,
|
||||
driverStats
|
||||
);
|
||||
@@ -741,7 +796,8 @@ export function configureDIContainer(): void {
|
||||
const driverRatingProvider: DriverRatingProvider = {
|
||||
getRating: (driverId: string): number | null => {
|
||||
const stats = driverStats[driverId];
|
||||
return stats?.rating ?? null;
|
||||
const rating = stats?.rating;
|
||||
return typeof rating === 'number' ? rating : null;
|
||||
},
|
||||
getRatings: (driverIds: string[]): Map<string, number> => {
|
||||
const result = new Map<string, number>();
|
||||
@@ -905,7 +961,7 @@ export function configureDIContainer(): void {
|
||||
const leagueStandingsPresenter = new LeagueStandingsPresenter();
|
||||
container.registerInstance(
|
||||
DI_TOKENS.GetLeagueStandingsUseCase,
|
||||
new GetLeagueStandingsUseCase(standingRepository, leagueStandingsPresenter)
|
||||
new GetLeagueStandingsUseCase(standingRepository),
|
||||
);
|
||||
|
||||
const leagueDriverSeasonStatsPresenter = new LeagueDriverSeasonStatsPresenter();
|
||||
@@ -919,7 +975,7 @@ export function configureDIContainer(): void {
|
||||
{
|
||||
getRating: (driverId: string) => {
|
||||
const stats = driverStats[driverId];
|
||||
if (!stats) {
|
||||
if (!stats || typeof stats.rating !== 'number') {
|
||||
return { rating: null, ratingChange: null };
|
||||
}
|
||||
const baseline = 1500;
|
||||
@@ -930,8 +986,8 @@ export function configureDIContainer(): void {
|
||||
};
|
||||
},
|
||||
},
|
||||
leagueDriverSeasonStatsPresenter
|
||||
)
|
||||
leagueDriverSeasonStatsPresenter,
|
||||
),
|
||||
);
|
||||
|
||||
const allLeaguesWithCapacityPresenter = new AllLeaguesWithCapacityPresenter();
|
||||
@@ -961,7 +1017,7 @@ export function configureDIContainer(): void {
|
||||
const leagueScoringPresetsPresenter = new LeagueScoringPresetsPresenter();
|
||||
container.registerInstance(
|
||||
DI_TOKENS.ListLeagueScoringPresetsUseCase,
|
||||
new ListLeagueScoringPresetsUseCase(leagueScoringPresetProvider, leagueScoringPresetsPresenter)
|
||||
new ListLeagueScoringPresetsUseCase(leagueScoringPresetProvider)
|
||||
);
|
||||
|
||||
const leagueScoringConfigPresenter = new LeagueScoringConfigPresenter();
|
||||
@@ -977,7 +1033,6 @@ export function configureDIContainer(): void {
|
||||
)
|
||||
);
|
||||
|
||||
const leagueFullConfigPresenter = new LeagueFullConfigPresenter();
|
||||
container.registerInstance(
|
||||
DI_TOKENS.GetLeagueFullConfigUseCase,
|
||||
new GetLeagueFullConfigUseCase(
|
||||
@@ -985,14 +1040,13 @@ export function configureDIContainer(): void {
|
||||
seasonRepository,
|
||||
leagueScoringConfigRepository,
|
||||
gameRepository,
|
||||
leagueFullConfigPresenter
|
||||
)
|
||||
);
|
||||
|
||||
const leagueSchedulePreviewPresenter = new LeagueSchedulePreviewPresenter();
|
||||
container.registerInstance(
|
||||
DI_TOKENS.PreviewLeagueScheduleUseCase,
|
||||
new PreviewLeagueScheduleUseCase(undefined, leagueSchedulePreviewPresenter)
|
||||
new PreviewLeagueScheduleUseCase(undefined, leagueSchedulePreviewPresenter),
|
||||
);
|
||||
|
||||
const raceWithSOFPresenter = new RaceWithSOFPresenter();
|
||||
@@ -1031,6 +1085,8 @@ export function configureDIContainer(): void {
|
||||
new GetAllRacesPageDataUseCase(raceRepository, leagueRepository, allRacesPagePresenter)
|
||||
);
|
||||
|
||||
const imageService = container.resolve<ImageServicePort>(DI_TOKENS.ImageService);
|
||||
|
||||
const raceDetailPresenter = new RaceDetailPresenter();
|
||||
container.registerInstance(
|
||||
DI_TOKENS.GetRaceDetailUseCase,
|
||||
@@ -1075,38 +1131,38 @@ export function configureDIContainer(): void {
|
||||
// Create services for driver leaderboard query
|
||||
const rankingService = {
|
||||
getAllDriverRankings: () => {
|
||||
const stats = getDIContainer().resolve<Record<string, any>>(DI_TOKENS.DriverStats);
|
||||
return Object.entries(stats).map(([driverId, stat]) => ({
|
||||
driverId,
|
||||
rating: stat.rating,
|
||||
overallRank: stat.overallRank,
|
||||
})).sort((a, b) => b.rating - a.rating);
|
||||
}
|
||||
const stats = getDIContainer().resolve<DemoDriverStatsMap>(DI_TOKENS.DriverStats);
|
||||
return Object.entries(stats)
|
||||
.map(([driverId, stat]) => ({
|
||||
driverId,
|
||||
rating: stat.rating ?? 0,
|
||||
overallRank: stat.overallRank ?? null,
|
||||
}))
|
||||
.sort((a, b) => (b.rating ?? 0) - (a.rating ?? 0));
|
||||
},
|
||||
};
|
||||
|
||||
const driverStatsService = {
|
||||
getDriverStats: (driverId: string) => {
|
||||
const stats = getDIContainer().resolve<Record<string, any>>(DI_TOKENS.DriverStats);
|
||||
return stats[driverId] || null;
|
||||
}
|
||||
const stats = getDIContainer().resolve<DemoDriverStatsMap>(DI_TOKENS.DriverStats);
|
||||
return stats[driverId] ?? null;
|
||||
},
|
||||
};
|
||||
|
||||
const imageService = getDIContainer().resolve<ImageServicePort>(DI_TOKENS.ImageService);
|
||||
|
||||
const driversPresenter = new DriversLeaderboardPresenter();
|
||||
container.registerInstance(
|
||||
DI_TOKENS.GetDriversLeaderboardUseCase,
|
||||
new GetDriversLeaderboardUseCase(
|
||||
driverRepository,
|
||||
rankingService as any,
|
||||
driverStatsService as any,
|
||||
rankingService,
|
||||
driverStatsService,
|
||||
imageService,
|
||||
driversPresenter
|
||||
)
|
||||
);
|
||||
|
||||
const getDriverStatsAdapter = (driverId: string) => {
|
||||
const stats = getDIContainer().resolve<Record<string, any>>(DI_TOKENS.DriverStats);
|
||||
const stats = getDIContainer().resolve<DemoDriverStatsMap>(DI_TOKENS.DriverStats);
|
||||
const stat = stats[driverId];
|
||||
if (!stat) return null;
|
||||
return {
|
||||
@@ -1116,7 +1172,6 @@ export function configureDIContainer(): void {
|
||||
};
|
||||
};
|
||||
|
||||
const teamsPresenter = new TeamsLeaderboardPresenter();
|
||||
container.registerInstance(
|
||||
DI_TOKENS.GetTeamsLeaderboardUseCase,
|
||||
new GetTeamsLeaderboardUseCase(
|
||||
@@ -1124,12 +1179,11 @@ export function configureDIContainer(): void {
|
||||
teamMembershipRepository,
|
||||
driverRepository,
|
||||
getDriverStatsAdapter,
|
||||
teamsPresenter
|
||||
)
|
||||
);
|
||||
|
||||
const getDriverStatsForDashboard = (driverId: string) => {
|
||||
const stats = getDIContainer().resolve<Record<string, any>>(DI_TOKENS.DriverStats);
|
||||
const stats = getDIContainer().resolve<DemoDriverStatsMap>(DI_TOKENS.DriverStats);
|
||||
const stat = stats[driverId];
|
||||
if (!stat) return null;
|
||||
return {
|
||||
@@ -1143,7 +1197,7 @@ export function configureDIContainer(): void {
|
||||
};
|
||||
|
||||
const getDriverStatsForProfile = (driverId: string) => {
|
||||
const stats = getDIContainer().resolve<Record<string, any>>(DI_TOKENS.DriverStats);
|
||||
const stats = getDIContainer().resolve<DemoDriverStatsMap>(DI_TOKENS.DriverStats);
|
||||
const stat = stats[driverId];
|
||||
if (!stat) return null;
|
||||
return {
|
||||
@@ -1204,7 +1258,7 @@ export function configureDIContainer(): void {
|
||||
const allTeamsPresenter = new AllTeamsPresenter();
|
||||
container.registerInstance(
|
||||
DI_TOKENS.GetAllTeamsUseCase,
|
||||
new GetAllTeamsUseCase(teamRepository, teamMembershipRepository, allTeamsPresenter)
|
||||
new GetAllTeamsUseCase(teamRepository, teamMembershipRepository),
|
||||
);
|
||||
|
||||
const teamDetailsPresenter = new TeamDetailsPresenter();
|
||||
@@ -1216,13 +1270,18 @@ export function configureDIContainer(): void {
|
||||
const teamMembersPresenter = new TeamMembersPresenter();
|
||||
container.registerInstance(
|
||||
DI_TOKENS.GetTeamMembersUseCase,
|
||||
new GetTeamMembersUseCase(teamMembershipRepository, driverRepository, imageService, teamMembersPresenter)
|
||||
new GetTeamMembersUseCase(teamMembershipRepository, driverRepository, imageService, teamMembersPresenter),
|
||||
);
|
||||
|
||||
const teamJoinRequestsPresenter = new TeamJoinRequestsPresenter();
|
||||
container.registerInstance(
|
||||
DI_TOKENS.GetTeamJoinRequestsUseCase,
|
||||
new GetTeamJoinRequestsUseCase(teamMembershipRepository, driverRepository, imageService, teamJoinRequestsPresenter)
|
||||
new GetTeamJoinRequestsUseCase(
|
||||
teamMembershipRepository,
|
||||
driverRepository,
|
||||
imageService,
|
||||
teamJoinRequestsPresenter,
|
||||
),
|
||||
);
|
||||
|
||||
const driverTeamPresenter = new DriverTeamPresenter();
|
||||
@@ -1235,13 +1294,13 @@ export function configureDIContainer(): void {
|
||||
const raceProtestsPresenter = new RaceProtestsPresenter();
|
||||
container.registerInstance(
|
||||
DI_TOKENS.GetRaceProtestsUseCase,
|
||||
new GetRaceProtestsUseCase(protestRepository, driverRepository, raceProtestsPresenter)
|
||||
new GetRaceProtestsUseCase(protestRepository, driverRepository)
|
||||
);
|
||||
|
||||
const racePenaltiesPresenter = new RacePenaltiesPresenter();
|
||||
container.registerInstance(
|
||||
DI_TOKENS.GetRacePenaltiesUseCase,
|
||||
new GetRacePenaltiesUseCase(penaltyRepository, driverRepository, racePenaltiesPresenter)
|
||||
new GetRacePenaltiesUseCase(penaltyRepository, driverRepository)
|
||||
);
|
||||
|
||||
// Register queries - Notifications
|
||||
@@ -1286,13 +1345,11 @@ export function configureDIContainer(): void {
|
||||
const sponsorshipRequestRepository = container.resolve<ISponsorshipRequestRepository>(DI_TOKENS.SponsorshipRequestRepository);
|
||||
const sponsorshipPricingRepository = container.resolve<ISponsorshipPricingRepository>(DI_TOKENS.SponsorshipPricingRepository);
|
||||
|
||||
const pendingSponsorshipRequestsPresenter = new PendingSponsorshipRequestsPresenter();
|
||||
container.registerInstance(
|
||||
DI_TOKENS.GetPendingSponsorshipRequestsUseCase,
|
||||
new GetPendingSponsorshipRequestsUseCase(
|
||||
sponsorshipRequestRepository,
|
||||
sponsorRepository,
|
||||
pendingSponsorshipRequestsPresenter
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import { configureDIContainer, getDIContainer } from './di-config';
|
||||
import { DI_TOKENS } from './di-tokens';
|
||||
import { LeagueScoringPresetsPresenter } from './presenters/LeagueScoringPresetsPresenter';
|
||||
|
||||
import type { IDriverRepository } from '@gridpilot/racing/domain/repositories/IDriverRepository';
|
||||
import type { ILeagueRepository } from '@gridpilot/racing/domain/repositories/ILeagueRepository';
|
||||
@@ -97,6 +98,7 @@ import type { TransferLeagueOwnershipUseCase } from '@gridpilot/racing/applicati
|
||||
import type { DriverRatingProvider } from '@gridpilot/racing/application';
|
||||
import type { PreviewLeagueScheduleUseCase } from '@gridpilot/racing/application';
|
||||
import type { LeagueScoringPresetProvider } from '@gridpilot/racing/application/ports/LeagueScoringPresetProvider';
|
||||
import type { LeagueScoringPresetDTO } from '@gridpilot/racing/application/ports/LeagueScoringPresetProvider';
|
||||
import type { GetDashboardOverviewUseCase } from '@gridpilot/racing/application/use-cases/GetDashboardOverviewUseCase';
|
||||
import { createDemoDriverStats, getDemoLeagueRankings, type DriverStats } from '@gridpilot/testing-support';
|
||||
|
||||
@@ -613,6 +615,21 @@ export function getIsDriverRegisteredForRaceUseCase(): IsDriverRegisteredForRace
|
||||
return DIContainer.getInstance().isDriverRegisteredForRaceUseCase;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query facade for checking if a driver is registered for a race.
|
||||
*/
|
||||
export function getIsDriverRegisteredForRaceQuery(): {
|
||||
execute(input: { raceId: string; driverId: string }): Promise<boolean>;
|
||||
} {
|
||||
const useCase = DIContainer.getInstance().isDriverRegisteredForRaceUseCase;
|
||||
return {
|
||||
async execute(input: { raceId: string; driverId: string }): Promise<boolean> {
|
||||
const result = await useCase.execute(input);
|
||||
return result as unknown as boolean;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function getGetRaceRegistrationsUseCase(): GetRaceRegistrationsUseCase {
|
||||
return DIContainer.getInstance().getRaceRegistrationsUseCase;
|
||||
}
|
||||
@@ -649,6 +666,24 @@ export function getListLeagueScoringPresetsUseCase(): ListLeagueScoringPresetsUs
|
||||
return DIContainer.getInstance().listLeagueScoringPresetsUseCase;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight query facade for listing league scoring presets.
|
||||
* Returns an object with an execute() method for use in UI code.
|
||||
*/
|
||||
export function getListLeagueScoringPresetsQuery(): {
|
||||
execute(): Promise<LeagueScoringPresetDTO[]>;
|
||||
} {
|
||||
const useCase = DIContainer.getInstance().listLeagueScoringPresetsUseCase;
|
||||
return {
|
||||
async execute(): Promise<LeagueScoringPresetDTO[]> {
|
||||
const presenter = new LeagueScoringPresetsPresenter();
|
||||
await useCase.execute(undefined as void, presenter);
|
||||
const viewModel = presenter.getViewModel();
|
||||
return viewModel.presets;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function getCreateLeagueWithSeasonAndScoringUseCase(): CreateLeagueWithSeasonAndScoringUseCase {
|
||||
return DIContainer.getInstance().createLeagueWithSeasonAndScoringUseCase;
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ const leagueMemberships = new Map<string, LeagueMembership[]>();
|
||||
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,
|
||||
|
||||
@@ -53,13 +53,13 @@ export function validateLeagueWizardStep(
|
||||
|
||||
// Use LeagueName value object for validation
|
||||
const nameValidation = LeagueName.validate(form.basics.name);
|
||||
if (!nameValidation.valid) {
|
||||
if (!nameValidation.valid && nameValidation.error) {
|
||||
basicsErrors.name = nameValidation.error;
|
||||
}
|
||||
|
||||
// Use LeagueDescription value object for validation
|
||||
const descValidation = LeagueDescription.validate(form.basics.description ?? '');
|
||||
if (!descValidation.valid) {
|
||||
if (!descValidation.valid && descValidation.error) {
|
||||
basicsErrors.description = descValidation.error;
|
||||
}
|
||||
|
||||
@@ -92,8 +92,10 @@ export function validateLeagueWizardStep(
|
||||
'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) {
|
||||
const driverValidation = gameConstraints.validateDriverCount(
|
||||
form.structure.maxDrivers,
|
||||
);
|
||||
if (!driverValidation.valid && driverValidation.error) {
|
||||
structureErrors.maxDrivers = driverValidation.error;
|
||||
}
|
||||
}
|
||||
@@ -103,8 +105,10 @@ export function validateLeagueWizardStep(
|
||||
'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) {
|
||||
const teamValidation = gameConstraints.validateTeamCount(
|
||||
form.structure.maxTeams,
|
||||
);
|
||||
if (!teamValidation.valid && teamValidation.error) {
|
||||
structureErrors.maxTeams = teamValidation.error;
|
||||
}
|
||||
}
|
||||
@@ -114,8 +118,10 @@ export function validateLeagueWizardStep(
|
||||
}
|
||||
// Validate total driver count
|
||||
if (form.structure.maxDrivers) {
|
||||
const driverValidation = gameConstraints.validateDriverCount(form.structure.maxDrivers);
|
||||
if (!driverValidation.valid) {
|
||||
const driverValidation = gameConstraints.validateDriverCount(
|
||||
form.structure.maxDrivers,
|
||||
);
|
||||
if (!driverValidation.valid && driverValidation.error) {
|
||||
structureErrors.maxDrivers = driverValidation.error;
|
||||
}
|
||||
}
|
||||
@@ -197,7 +203,7 @@ export function validateAllLeagueWizardSteps(
|
||||
|
||||
export function hasWizardErrors(errors: WizardErrors): boolean {
|
||||
return Object.keys(errors).some((key) => {
|
||||
const value = (errors as any)[key];
|
||||
const value = errors[key as keyof WizardErrors];
|
||||
if (!value) return false;
|
||||
if (typeof value === 'string') return true;
|
||||
return Object.keys(value).length > 0;
|
||||
@@ -213,27 +219,31 @@ export function buildCreateLeagueCommandFromConfig(
|
||||
ownerId: string,
|
||||
): CreateLeagueWithSeasonAndScoringCommand {
|
||||
const structure = form.structure;
|
||||
let maxDrivers: number | undefined;
|
||||
let maxTeams: number | undefined;
|
||||
let maxDrivers: number;
|
||||
let maxTeams: number;
|
||||
|
||||
if (structure.mode === 'solo') {
|
||||
maxDrivers =
|
||||
typeof structure.maxDrivers === 'number' ? structure.maxDrivers : undefined;
|
||||
maxTeams = undefined;
|
||||
typeof structure.maxDrivers === 'number' && structure.maxDrivers > 0
|
||||
? structure.maxDrivers
|
||||
: 0;
|
||||
maxTeams = 0;
|
||||
} else {
|
||||
const teams =
|
||||
typeof structure.maxTeams === 'number' ? structure.maxTeams : 0;
|
||||
typeof structure.maxTeams === 'number' && structure.maxTeams > 0
|
||||
? structure.maxTeams
|
||||
: 0;
|
||||
const perTeam =
|
||||
typeof structure.driversPerTeam === 'number'
|
||||
typeof structure.driversPerTeam === 'number' && structure.driversPerTeam > 0
|
||||
? structure.driversPerTeam
|
||||
: 0;
|
||||
maxTeams = teams > 0 ? teams : undefined;
|
||||
maxDrivers = teams > 0 && perTeam > 0 ? teams * perTeam : undefined;
|
||||
maxTeams = teams;
|
||||
maxDrivers = teams > 0 && perTeam > 0 ? teams * perTeam : 0;
|
||||
}
|
||||
|
||||
return {
|
||||
name: form.basics.name.trim(),
|
||||
description: form.basics.description?.trim() || undefined,
|
||||
description: (form.basics.description ?? '').trim(),
|
||||
visibility: form.basics.visibility,
|
||||
ownerId,
|
||||
gameId: form.basics.gameId,
|
||||
@@ -243,7 +253,7 @@ export function buildCreateLeagueCommandFromConfig(
|
||||
enableTeamChampionship: form.championships.enableTeamChampionship,
|
||||
enableNationsChampionship: form.championships.enableNationsChampionship,
|
||||
enableTrophyChampionship: form.championships.enableTrophyChampionship,
|
||||
scoringPresetId: form.scoring.patternId || undefined,
|
||||
scoringPresetId: form.scoring.patternId ?? 'custom',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -263,8 +273,8 @@ export async function createLeagueFromConfig(
|
||||
if (!currentDriver) {
|
||||
const error = new Error(
|
||||
'No driver profile found. Please create a driver profile first.',
|
||||
);
|
||||
(error as any).code = 'NO_DRIVER';
|
||||
) as Error & { code?: string };
|
||||
error.code = 'NO_DRIVER';
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -283,7 +293,9 @@ export function applyScoringPresetToConfig(
|
||||
): LeagueConfigFormModel {
|
||||
const lowerPresetId = patternId.toLowerCase();
|
||||
const timings = form.timings ?? ({} as LeagueConfigFormModel['timings']);
|
||||
let updatedTimings = { ...timings };
|
||||
let updatedTimings: NonNullable<LeagueConfigFormModel['timings']> = {
|
||||
...timings,
|
||||
};
|
||||
|
||||
if (lowerPresetId.includes('sprint') || lowerPresetId.includes('double')) {
|
||||
updatedTimings = {
|
||||
@@ -299,19 +311,19 @@ export function applyScoringPresetToConfig(
|
||||
...updatedTimings,
|
||||
practiceMinutes: 30,
|
||||
qualifyingMinutes: 30,
|
||||
sprintRaceMinutes: undefined,
|
||||
mainRaceMinutes: 90,
|
||||
sessionCount: 1,
|
||||
};
|
||||
delete (updatedTimings as { sprintRaceMinutes?: number }).sprintRaceMinutes;
|
||||
} else {
|
||||
updatedTimings = {
|
||||
...updatedTimings,
|
||||
practiceMinutes: 20,
|
||||
qualifyingMinutes: 30,
|
||||
sprintRaceMinutes: undefined,
|
||||
mainRaceMinutes: 40,
|
||||
sessionCount: 1,
|
||||
};
|
||||
delete (updatedTimings as { sprintRaceMinutes?: number }).sprintRaceMinutes;
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -25,8 +25,8 @@ export class AllLeaguesWithCapacityAndScoringPresenter implements IAllLeaguesWit
|
||||
: 40;
|
||||
const timingSummary = `${qualifyingMinutes} min Quali • ${mainRaceMinutes} min Race`;
|
||||
|
||||
let scoringSummary: LeagueSummaryViewModel['scoring'] | undefined;
|
||||
let scoringPatternSummary: string | undefined;
|
||||
let scoringPatternSummary: string | null = null;
|
||||
let scoringSummary: LeagueSummaryViewModel['scoring'];
|
||||
|
||||
if (season && scoringConfig && game) {
|
||||
const dropPolicySummary =
|
||||
@@ -47,9 +47,23 @@ export class AllLeaguesWithCapacityAndScoringPresenter implements IAllLeaguesWit
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
const base: LeagueSummaryViewModel = {
|
||||
id: league.id,
|
||||
name: league.name,
|
||||
description: league.description,
|
||||
@@ -57,13 +71,16 @@ export class AllLeaguesWithCapacityAndScoringPresenter implements IAllLeaguesWit
|
||||
createdAt: league.createdAt,
|
||||
maxDrivers: safeMaxDrivers,
|
||||
usedDriverSlots,
|
||||
maxTeams: undefined,
|
||||
usedTeamSlots: undefined,
|
||||
// Team capacity is not yet modeled here; use zero for now to satisfy strict typing.
|
||||
maxTeams: 0,
|
||||
usedTeamSlots: 0,
|
||||
structureSummary,
|
||||
scoringPatternSummary,
|
||||
scoringPatternSummary: scoringPatternSummary ?? '',
|
||||
timingSummary,
|
||||
scoring: scoringSummary,
|
||||
};
|
||||
|
||||
return base;
|
||||
});
|
||||
|
||||
this.viewModel = {
|
||||
|
||||
@@ -14,13 +14,13 @@ export class AllLeaguesWithCapacityPresenter implements IAllLeaguesWithCapacityP
|
||||
): AllLeaguesWithCapacityViewModel {
|
||||
const leagueItems: LeagueWithCapacityViewModel[] = leagues.map((league) => {
|
||||
const usedSlots = memberCounts.get(league.id) ?? 0;
|
||||
|
||||
|
||||
// Ensure we never expose an impossible state like 26/24:
|
||||
// clamp maxDrivers to at least usedSlots at the application boundary.
|
||||
const configuredMax = league.settings.maxDrivers ?? usedSlots;
|
||||
const safeMaxDrivers = Math.max(configuredMax, usedSlots);
|
||||
|
||||
return {
|
||||
const base: LeagueWithCapacityViewModel = {
|
||||
id: league.id,
|
||||
name: league.name,
|
||||
description: league.description,
|
||||
@@ -30,15 +30,33 @@ export class AllLeaguesWithCapacityPresenter implements IAllLeaguesWithCapacityP
|
||||
maxDrivers: safeMaxDrivers,
|
||||
},
|
||||
createdAt: league.createdAt.toISOString(),
|
||||
socialLinks: league.socialLinks
|
||||
? {
|
||||
discordUrl: league.socialLinks.discordUrl,
|
||||
youtubeUrl: league.socialLinks.youtubeUrl,
|
||||
websiteUrl: league.socialLinks.websiteUrl,
|
||||
}
|
||||
: undefined,
|
||||
usedSlots,
|
||||
};
|
||||
|
||||
if (!league.socialLinks) {
|
||||
return base;
|
||||
}
|
||||
|
||||
const socialLinks: NonNullable<LeagueWithCapacityViewModel['socialLinks']> = {};
|
||||
|
||||
if (league.socialLinks.discordUrl) {
|
||||
socialLinks.discordUrl = league.socialLinks.discordUrl;
|
||||
}
|
||||
if (league.socialLinks.youtubeUrl) {
|
||||
socialLinks.youtubeUrl = league.socialLinks.youtubeUrl;
|
||||
}
|
||||
if (league.socialLinks.websiteUrl) {
|
||||
socialLinks.websiteUrl = league.socialLinks.websiteUrl;
|
||||
}
|
||||
|
||||
if (Object.keys(socialLinks).length === 0) {
|
||||
return base;
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
socialLinks,
|
||||
};
|
||||
});
|
||||
|
||||
this.viewModel = {
|
||||
|
||||
@@ -1,38 +1,34 @@
|
||||
import type { Team } from '@gridpilot/racing/domain/entities/Team';
|
||||
import type {
|
||||
IAllTeamsPresenter,
|
||||
TeamListItemViewModel,
|
||||
AllTeamsViewModel,
|
||||
AllTeamsResultDTO,
|
||||
} from '@gridpilot/racing/application/presenters/IAllTeamsPresenter';
|
||||
|
||||
export class AllTeamsPresenter implements IAllTeamsPresenter {
|
||||
private viewModel: AllTeamsViewModel | null = null;
|
||||
|
||||
present(teams: Array<Team & { memberCount?: number }>): AllTeamsViewModel {
|
||||
const teamItems: TeamListItemViewModel[] = teams.map((team) => ({
|
||||
reset(): void {
|
||||
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,
|
||||
specialization: team.specialization as 'endurance' | 'sprint' | 'mixed' | undefined,
|
||||
region: team.region,
|
||||
languages: team.languages,
|
||||
}));
|
||||
|
||||
this.viewModel = {
|
||||
teams: teamItems,
|
||||
totalCount: teamItems.length,
|
||||
};
|
||||
|
||||
return this.viewModel;
|
||||
}
|
||||
|
||||
getViewModel(): AllTeamsViewModel {
|
||||
if (!this.viewModel) {
|
||||
throw new Error('Presenter has not been called yet');
|
||||
}
|
||||
getViewModel(): AllTeamsViewModel | null {
|
||||
return this.viewModel;
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,19 @@
|
||||
import type { Team, TeamMembership } from '@gridpilot/racing/domain/entities/Team';
|
||||
import type {
|
||||
IDriverTeamPresenter,
|
||||
DriverTeamViewModel,
|
||||
DriverTeamResultDTO,
|
||||
} from '@gridpilot/racing/application/presenters/IDriverTeamPresenter';
|
||||
|
||||
export class DriverTeamPresenter implements IDriverTeamPresenter {
|
||||
private viewModel: DriverTeamViewModel | null = null;
|
||||
|
||||
present(
|
||||
team: Team,
|
||||
membership: TeamMembership,
|
||||
driverId: string
|
||||
): DriverTeamViewModel {
|
||||
reset(): void {
|
||||
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';
|
||||
|
||||
@@ -23,26 +25,18 @@ export class DriverTeamPresenter implements IDriverTeamPresenter {
|
||||
description: team.description,
|
||||
ownerId: team.ownerId,
|
||||
leagues: team.leagues,
|
||||
specialization: team.specialization as 'endurance' | 'sprint' | 'mixed' | undefined,
|
||||
region: team.region,
|
||||
languages: team.languages,
|
||||
},
|
||||
membership: {
|
||||
role: membership.role,
|
||||
role: membership.role === 'driver' ? 'member' : membership.role,
|
||||
joinedAt: membership.joinedAt.toISOString(),
|
||||
isActive: membership.isActive,
|
||||
isActive: membership.status === 'active',
|
||||
},
|
||||
isOwner,
|
||||
canManage,
|
||||
};
|
||||
|
||||
return this.viewModel;
|
||||
}
|
||||
|
||||
getViewModel(): DriverTeamViewModel {
|
||||
if (!this.viewModel) {
|
||||
throw new Error('Presenter has not been called yet');
|
||||
}
|
||||
getViewModel(): DriverTeamViewModel | null {
|
||||
return this.viewModel;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { IEntitySponsorshipPricingPresenter } from '@racing/application/presenters/IEntitySponsorshipPricingPresenter';
|
||||
import type { GetEntitySponsorshipPricingResultDTO } from '@racing/application/use-cases/GetEntitySponsorshipPricingUseCase';
|
||||
import type { IEntitySponsorshipPricingPresenter } from '@gridpilot/racing/application/presenters/IEntitySponsorshipPricingPresenter';
|
||||
import type { GetEntitySponsorshipPricingResultDTO } from '@gridpilot/racing/application/use-cases/GetEntitySponsorshipPricingUseCase';
|
||||
|
||||
export class EntitySponsorshipPricingPresenter implements IEntitySponsorshipPricingPresenter {
|
||||
private data: GetEntitySponsorshipPricingResultDTO | null = null;
|
||||
|
||||
@@ -3,6 +3,8 @@ 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 {
|
||||
@@ -38,6 +40,14 @@ export interface LeagueOwnerSummaryViewModel {
|
||||
rank: number | null;
|
||||
}
|
||||
|
||||
export interface LeagueSummaryViewModel {
|
||||
id: string;
|
||||
ownerId: string;
|
||||
settings: {
|
||||
pointsSystem: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface LeagueAdminProtestsViewModel {
|
||||
protests: Protest[];
|
||||
racesById: ProtestRaceSummary;
|
||||
@@ -79,14 +89,23 @@ export async function loadLeagueJoinRequests(leagueId: string): Promise<LeagueJo
|
||||
driversById[dto.id] = dto;
|
||||
}
|
||||
|
||||
return requests.map((request) => ({
|
||||
id: request.id,
|
||||
leagueId: request.leagueId,
|
||||
driverId: request.driverId,
|
||||
requestedAt: request.requestedAt,
|
||||
message: request.message,
|
||||
driver: driversById[request.driverId],
|
||||
}));
|
||||
return requests.map((request) => {
|
||||
const base: LeagueJoinRequestViewModel = {
|
||||
id: request.id,
|
||||
leagueId: request.leagueId,
|
||||
driverId: request.driverId,
|
||||
requestedAt: request.requestedAt,
|
||||
};
|
||||
|
||||
const message = request.message;
|
||||
const driver = driversById[request.driverId];
|
||||
|
||||
return {
|
||||
...base,
|
||||
...(typeof message === 'string' && message.length > 0 ? { message } : {}),
|
||||
...(driver ? { driver } : {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -104,6 +123,7 @@ export async function approveLeagueJoinRequest(
|
||||
}
|
||||
|
||||
await membershipRepo.saveMembership({
|
||||
id: request.id,
|
||||
leagueId: request.leagueId,
|
||||
driverId: request.driverId,
|
||||
role: 'member',
|
||||
@@ -203,12 +223,17 @@ export async function updateLeagueMemberRole(
|
||||
/**
|
||||
* Load owner summary (DTO + rating/rank) for a league.
|
||||
*/
|
||||
export async function loadLeagueOwnerSummary(league: League): Promise<LeagueOwnerSummaryViewModel | null> {
|
||||
export async function loadLeagueOwnerSummary(params: {
|
||||
ownerId: string;
|
||||
}): Promise<LeagueOwnerSummaryViewModel | null> {
|
||||
const driverRepo = getDriverRepository();
|
||||
const entity = await driverRepo.findById(league.ownerId);
|
||||
const entity = await driverRepo.findById(params.ownerId);
|
||||
if (!entity) return null;
|
||||
|
||||
const ownerDriver = EntityMappers.toDriverDTO(entity);
|
||||
if (!ownerDriver) {
|
||||
return null;
|
||||
}
|
||||
const stats = getDriverStats(ownerDriver.id);
|
||||
const allRankings = getAllDriverRankings();
|
||||
|
||||
@@ -243,10 +268,52 @@ export async function loadLeagueOwnerSummary(league: League): Promise<LeagueOwne
|
||||
/**
|
||||
* Load league full config form.
|
||||
*/
|
||||
export async function loadLeagueConfig(leagueId: string): Promise<LeagueAdminConfigViewModel> {
|
||||
export async function loadLeagueConfig(
|
||||
leagueId: string,
|
||||
): Promise<LeagueAdminConfigViewModel> {
|
||||
const useCase = getGetLeagueFullConfigUseCase();
|
||||
const form = await useCase.execute({ leagueId });
|
||||
return { form };
|
||||
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,
|
||||
},
|
||||
};
|
||||
|
||||
return { form: formModel };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -42,8 +42,8 @@ export class LeagueDriverSeasonStatsPresenter implements ILeagueDriverSeasonStat
|
||||
driverId: standing.driverId,
|
||||
position: standing.position,
|
||||
driverName: '',
|
||||
teamId: undefined,
|
||||
teamName: undefined,
|
||||
teamId: '',
|
||||
teamName: '',
|
||||
totalPoints: standing.points + totalPenaltyPoints + bonusPoints,
|
||||
basePoints: standing.points,
|
||||
penaltyPoints: Math.abs(totalPenaltyPoints),
|
||||
|
||||
@@ -8,7 +8,11 @@ import type {
|
||||
export class LeagueFullConfigPresenter implements ILeagueFullConfigPresenter {
|
||||
private viewModel: LeagueConfigFormViewModel | null = null;
|
||||
|
||||
present(data: LeagueFullConfigData): LeagueConfigFormViewModel {
|
||||
reset(): void {
|
||||
this.viewModel = null;
|
||||
}
|
||||
|
||||
present(data: LeagueFullConfigData): void {
|
||||
const { league, activeSeason, scoringConfig, game } = data;
|
||||
|
||||
const patternId = scoringConfig?.scoringPresetId;
|
||||
@@ -32,12 +36,8 @@ export class LeagueFullConfigPresenter implements ILeagueFullConfigPresenter {
|
||||
const roundsPlanned = 8;
|
||||
|
||||
let sessionCount = 2;
|
||||
if (
|
||||
primaryChampionship &&
|
||||
Array.isArray((primaryChampionship as any).sessionTypes) &&
|
||||
(primaryChampionship as any).sessionTypes.length > 0
|
||||
) {
|
||||
sessionCount = (primaryChampionship as any).sessionTypes.length;
|
||||
if (primaryChampionship && Array.isArray(primaryChampionship.sessionTypes)) {
|
||||
sessionCount = primaryChampionship.sessionTypes.length;
|
||||
}
|
||||
|
||||
const practiceMinutes = 20;
|
||||
@@ -54,8 +54,6 @@ export class LeagueFullConfigPresenter implements ILeagueFullConfigPresenter {
|
||||
structure: {
|
||||
mode: 'solo',
|
||||
maxDrivers: league.settings.maxDrivers ?? 32,
|
||||
maxTeams: undefined,
|
||||
driversPerTeam: undefined,
|
||||
multiClassEnabled: false,
|
||||
},
|
||||
championships: {
|
||||
@@ -65,17 +63,19 @@ export class LeagueFullConfigPresenter implements ILeagueFullConfigPresenter {
|
||||
enableTrophyChampionship: false,
|
||||
},
|
||||
scoring: {
|
||||
patternId: patternId ?? undefined,
|
||||
customScoringEnabled: !patternId,
|
||||
...(patternId ? { patternId } : {}),
|
||||
},
|
||||
dropPolicy: dropPolicyForm,
|
||||
timings: {
|
||||
practiceMinutes,
|
||||
qualifyingMinutes,
|
||||
sprintRaceMinutes,
|
||||
mainRaceMinutes,
|
||||
sessionCount,
|
||||
roundsPlanned,
|
||||
...(typeof sprintRaceMinutes === 'number'
|
||||
? { sprintRaceMinutes }
|
||||
: {}),
|
||||
},
|
||||
stewarding: {
|
||||
decisionMode: 'admin_only',
|
||||
@@ -88,11 +88,9 @@ export class LeagueFullConfigPresenter implements ILeagueFullConfigPresenter {
|
||||
notifyOnVoteRequired: true,
|
||||
},
|
||||
};
|
||||
|
||||
return this.viewModel;
|
||||
}
|
||||
|
||||
getViewModel(): LeagueConfigFormViewModel {
|
||||
getViewModel(): LeagueConfigFormViewModel | null {
|
||||
if (!this.viewModel) {
|
||||
throw new Error('Presenter has not been called yet');
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ILeagueSchedulePreviewPresenter } from '@racing/application/presenters/ILeagueSchedulePreviewPresenter';
|
||||
import type { LeagueSchedulePreviewDTO } from '@racing/application/dto/LeagueScheduleDTO';
|
||||
import type { ILeagueSchedulePreviewPresenter } from '@gridpilot/racing/application/presenters/ILeagueSchedulePreviewPresenter';
|
||||
import type { LeagueSchedulePreviewDTO } from '@gridpilot/racing/application/dto/LeagueScheduleDTO';
|
||||
|
||||
export class LeagueSchedulePreviewPresenter implements ILeagueSchedulePreviewPresenter {
|
||||
private data: LeagueSchedulePreviewDTO | null = null;
|
||||
|
||||
@@ -23,8 +23,8 @@ export class LeagueScoringConfigPresenter implements ILeagueScoringConfigPresent
|
||||
seasonId: data.seasonId,
|
||||
gameId: data.gameId,
|
||||
gameName: data.gameName,
|
||||
scoringPresetId: data.scoringPresetId,
|
||||
scoringPresetName: data.preset?.name,
|
||||
scoringPresetId: data.scoringPresetId ?? 'custom',
|
||||
scoringPresetName: data.preset?.name ?? 'Custom',
|
||||
dropPolicySummary,
|
||||
championships,
|
||||
};
|
||||
@@ -61,7 +61,7 @@ export class LeagueScoringConfigPresenter implements ILeagueScoringConfigPresent
|
||||
}
|
||||
|
||||
private buildPointsPreview(
|
||||
tables: Record<string, any>,
|
||||
tables: Record<string, { getPointsForPosition: (position: number) => number }>,
|
||||
): Array<{ sessionType: string; position: number; points: number }> {
|
||||
const preview: Array<{
|
||||
sessionType: string;
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
import type { LeagueScoringPresetDTO } from '@gridpilot/racing/application/ports/LeagueScoringPresetProvider';
|
||||
import type {
|
||||
ILeagueScoringPresetsPresenter,
|
||||
LeagueScoringPresetsViewModel,
|
||||
LeagueScoringPresetsResultDTO,
|
||||
} from '@gridpilot/racing/application/presenters/ILeagueScoringPresetsPresenter';
|
||||
|
||||
export class LeagueScoringPresetsPresenter implements ILeagueScoringPresetsPresenter {
|
||||
private viewModel: LeagueScoringPresetsViewModel | null = null;
|
||||
|
||||
present(presets: LeagueScoringPresetDTO[]): LeagueScoringPresetsViewModel {
|
||||
reset(): void {
|
||||
this.viewModel = null;
|
||||
}
|
||||
|
||||
present(dto: LeagueScoringPresetsResultDTO): void {
|
||||
const { presets } = dto;
|
||||
|
||||
this.viewModel = {
|
||||
presets,
|
||||
totalCount: presets.length,
|
||||
};
|
||||
|
||||
return this.viewModel;
|
||||
}
|
||||
|
||||
getViewModel(): LeagueScoringPresetsViewModel {
|
||||
|
||||
@@ -1,38 +1,44 @@
|
||||
import type { Standing } from '@gridpilot/racing/domain/entities/Standing';
|
||||
import type {
|
||||
ILeagueStandingsPresenter,
|
||||
StandingItemViewModel,
|
||||
LeagueStandingsResultDTO,
|
||||
LeagueStandingsViewModel,
|
||||
StandingItemViewModel,
|
||||
} from '@gridpilot/racing/application/presenters/ILeagueStandingsPresenter';
|
||||
|
||||
export class LeagueStandingsPresenter implements ILeagueStandingsPresenter {
|
||||
private viewModel: LeagueStandingsViewModel | null = null;
|
||||
|
||||
present(standings: Standing[]): LeagueStandingsViewModel {
|
||||
const standingItems: StandingItemViewModel[] = standings.map((standing) => ({
|
||||
id: standing.id,
|
||||
leagueId: standing.leagueId,
|
||||
seasonId: standing.seasonId,
|
||||
driverId: standing.driverId,
|
||||
position: standing.position,
|
||||
points: standing.points,
|
||||
wins: standing.wins,
|
||||
podiums: standing.podiums,
|
||||
racesCompleted: standing.racesCompleted,
|
||||
}));
|
||||
|
||||
this.viewModel = {
|
||||
leagueId: standings[0]?.leagueId ?? '',
|
||||
standings: standingItems,
|
||||
};
|
||||
|
||||
return this.viewModel;
|
||||
reset(): void {
|
||||
this.viewModel = null;
|
||||
}
|
||||
|
||||
getViewModel(): LeagueStandingsViewModel {
|
||||
if (!this.viewModel) {
|
||||
throw new Error('Presenter has not been called yet');
|
||||
}
|
||||
present(dto: LeagueStandingsResultDTO): void {
|
||||
const standingItems: StandingItemViewModel[] = dto.standings.map((standing) => {
|
||||
const raw = standing as unknown as {
|
||||
seasonId?: string;
|
||||
podiums?: number;
|
||||
};
|
||||
|
||||
return {
|
||||
id: standing.id,
|
||||
leagueId: standing.leagueId,
|
||||
seasonId: raw.seasonId ?? '',
|
||||
driverId: standing.driverId,
|
||||
position: standing.position,
|
||||
points: standing.points,
|
||||
wins: standing.wins,
|
||||
podiums: raw.podiums ?? 0,
|
||||
racesCompleted: standing.racesCompleted,
|
||||
};
|
||||
});
|
||||
|
||||
this.viewModel = {
|
||||
leagueId: dto.standings[0]?.leagueId ?? '',
|
||||
standings: standingItems,
|
||||
};
|
||||
}
|
||||
|
||||
getViewModel(): LeagueStandingsViewModel | null {
|
||||
return this.viewModel;
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,21 @@
|
||||
import type { IPendingSponsorshipRequestsPresenter } from '@racing/application/presenters/IPendingSponsorshipRequestsPresenter';
|
||||
import type { GetPendingSponsorshipRequestsResultDTO } from '@racing/application/use-cases/GetPendingSponsorshipRequestsUseCase';
|
||||
import type {
|
||||
IPendingSponsorshipRequestsPresenter,
|
||||
PendingSponsorshipRequestsViewModel,
|
||||
} from '@gridpilot/racing/application/presenters/IPendingSponsorshipRequestsPresenter';
|
||||
import type { GetPendingSponsorshipRequestsResultDTO } from '@gridpilot/racing/application/use-cases/GetPendingSponsorshipRequestsUseCase';
|
||||
|
||||
export class PendingSponsorshipRequestsPresenter implements IPendingSponsorshipRequestsPresenter {
|
||||
private data: GetPendingSponsorshipRequestsResultDTO | null = null;
|
||||
private viewModel: PendingSponsorshipRequestsViewModel | null = null;
|
||||
|
||||
reset(): void {
|
||||
this.viewModel = null;
|
||||
}
|
||||
|
||||
present(data: GetPendingSponsorshipRequestsResultDTO): void {
|
||||
this.data = data;
|
||||
this.viewModel = data;
|
||||
}
|
||||
|
||||
getData(): GetPendingSponsorshipRequestsResultDTO | null {
|
||||
return this.data;
|
||||
getViewModel(): PendingSponsorshipRequestsViewModel | null {
|
||||
return this.viewModel;
|
||||
}
|
||||
}
|
||||
@@ -1,60 +1,55 @@
|
||||
import type {
|
||||
IRacePenaltiesPresenter,
|
||||
RacePenaltyViewModel,
|
||||
RacePenaltiesResultDTO,
|
||||
RacePenaltiesViewModel,
|
||||
} from '@gridpilot/racing/application/presenters/IRacePenaltiesPresenter';
|
||||
import type { PenaltyType, PenaltyStatus } from '@gridpilot/racing/domain/entities/Penalty';
|
||||
|
||||
export class RacePenaltiesPresenter implements IRacePenaltiesPresenter {
|
||||
private viewModel: RacePenaltiesViewModel | null = null;
|
||||
|
||||
present(
|
||||
penalties: Array<{
|
||||
id: string;
|
||||
raceId: string;
|
||||
driverId: string;
|
||||
type: PenaltyType;
|
||||
value?: number;
|
||||
reason: string;
|
||||
protestId?: string;
|
||||
issuedBy: string;
|
||||
status: PenaltyStatus;
|
||||
issuedAt: Date;
|
||||
appliedAt?: Date;
|
||||
notes?: string;
|
||||
getDescription(): string;
|
||||
}>,
|
||||
driverMap: Map<string, string>
|
||||
): RacePenaltiesViewModel {
|
||||
const penaltyViewModels: RacePenaltyViewModel[] = penalties.map(penalty => ({
|
||||
id: penalty.id,
|
||||
raceId: penalty.raceId,
|
||||
driverId: penalty.driverId,
|
||||
driverName: driverMap.get(penalty.driverId) || 'Unknown',
|
||||
type: penalty.type,
|
||||
value: penalty.value,
|
||||
reason: penalty.reason,
|
||||
protestId: penalty.protestId,
|
||||
issuedBy: penalty.issuedBy,
|
||||
issuedByName: driverMap.get(penalty.issuedBy) || 'Unknown',
|
||||
status: penalty.status,
|
||||
description: penalty.getDescription(),
|
||||
issuedAt: penalty.issuedAt.toISOString(),
|
||||
appliedAt: penalty.appliedAt?.toISOString(),
|
||||
notes: penalty.notes,
|
||||
}));
|
||||
reset(): void {
|
||||
this.viewModel = null;
|
||||
}
|
||||
|
||||
present(dto: RacePenaltiesResultDTO): void {
|
||||
const { penalties, driverMap } = dto;
|
||||
|
||||
const penaltyViewModels: RacePenaltyViewModel[] = penalties.map((penalty) => {
|
||||
const value = typeof penalty.value === 'number' ? penalty.value : 0;
|
||||
const protestId = penalty.protestId;
|
||||
const appliedAt = penalty.appliedAt ? penalty.appliedAt.toISOString() : undefined;
|
||||
const notes = penalty.notes;
|
||||
|
||||
const base: RacePenaltyViewModel = {
|
||||
id: penalty.id,
|
||||
raceId: penalty.raceId,
|
||||
driverId: penalty.driverId,
|
||||
driverName: driverMap.get(penalty.driverId) || 'Unknown',
|
||||
type: penalty.type,
|
||||
value,
|
||||
reason: penalty.reason,
|
||||
issuedBy: penalty.issuedBy,
|
||||
issuedByName: driverMap.get(penalty.issuedBy) || 'Unknown',
|
||||
status: penalty.status,
|
||||
description: penalty.getDescription(),
|
||||
issuedAt: penalty.issuedAt.toISOString(),
|
||||
};
|
||||
|
||||
return {
|
||||
...base,
|
||||
...(protestId ? { protestId } : {}),
|
||||
...(appliedAt ? { appliedAt } : {}),
|
||||
...(typeof notes === 'string' && notes.length > 0 ? { notes } : {}),
|
||||
};
|
||||
});
|
||||
|
||||
this.viewModel = {
|
||||
penalties: penaltyViewModels,
|
||||
};
|
||||
|
||||
return this.viewModel;
|
||||
}
|
||||
|
||||
getViewModel(): RacePenaltiesViewModel {
|
||||
if (!this.viewModel) {
|
||||
throw new Error('Presenter has not been called yet');
|
||||
}
|
||||
getViewModel(): RacePenaltiesViewModel | null {
|
||||
return this.viewModel;
|
||||
}
|
||||
}
|
||||
@@ -1,59 +1,60 @@
|
||||
import type {
|
||||
IRaceProtestsPresenter,
|
||||
RaceProtestViewModel,
|
||||
RaceProtestsResultDTO,
|
||||
RaceProtestsViewModel,
|
||||
} from '@gridpilot/racing/application/presenters/IRaceProtestsPresenter';
|
||||
import type { ProtestStatus, ProtestIncident } from '@gridpilot/racing/domain/entities/Protest';
|
||||
|
||||
export class RaceProtestsPresenter implements IRaceProtestsPresenter {
|
||||
private viewModel: RaceProtestsViewModel | null = null;
|
||||
|
||||
present(
|
||||
protests: Array<{
|
||||
id: string;
|
||||
raceId: string;
|
||||
protestingDriverId: string;
|
||||
accusedDriverId: string;
|
||||
incident: ProtestIncident;
|
||||
comment?: string;
|
||||
proofVideoUrl?: string;
|
||||
status: ProtestStatus;
|
||||
reviewedBy?: string;
|
||||
decisionNotes?: string;
|
||||
filedAt: Date;
|
||||
reviewedAt?: Date;
|
||||
}>,
|
||||
driverMap: Map<string, string>
|
||||
): RaceProtestsViewModel {
|
||||
const protestViewModels: RaceProtestViewModel[] = protests.map(protest => ({
|
||||
id: protest.id,
|
||||
raceId: protest.raceId,
|
||||
protestingDriverId: protest.protestingDriverId,
|
||||
protestingDriverName: driverMap.get(protest.protestingDriverId) || 'Unknown',
|
||||
accusedDriverId: protest.accusedDriverId,
|
||||
accusedDriverName: driverMap.get(protest.accusedDriverId) || 'Unknown',
|
||||
incident: protest.incident,
|
||||
comment: protest.comment,
|
||||
proofVideoUrl: protest.proofVideoUrl,
|
||||
status: protest.status,
|
||||
reviewedBy: protest.reviewedBy,
|
||||
reviewedByName: protest.reviewedBy ? driverMap.get(protest.reviewedBy) : undefined,
|
||||
decisionNotes: protest.decisionNotes,
|
||||
filedAt: protest.filedAt.toISOString(),
|
||||
reviewedAt: protest.reviewedAt?.toISOString(),
|
||||
}));
|
||||
reset(): void {
|
||||
this.viewModel = null;
|
||||
}
|
||||
|
||||
present(dto: RaceProtestsResultDTO): void {
|
||||
const { protests, driverMap } = dto;
|
||||
|
||||
const protestViewModels: RaceProtestViewModel[] = protests.map((protest) => {
|
||||
const base: RaceProtestViewModel = {
|
||||
id: protest.id,
|
||||
raceId: protest.raceId,
|
||||
protestingDriverId: protest.protestingDriverId,
|
||||
protestingDriverName: driverMap.get(protest.protestingDriverId) || 'Unknown',
|
||||
accusedDriverId: protest.accusedDriverId,
|
||||
accusedDriverName: driverMap.get(protest.accusedDriverId) || 'Unknown',
|
||||
incident: protest.incident,
|
||||
filedAt: protest.filedAt.toISOString(),
|
||||
status: protest.status,
|
||||
};
|
||||
|
||||
const comment = protest.comment;
|
||||
const proofVideoUrl = protest.proofVideoUrl;
|
||||
const reviewedBy = protest.reviewedBy;
|
||||
const reviewedByName =
|
||||
protest.reviewedBy !== undefined
|
||||
? driverMap.get(protest.reviewedBy) ?? 'Unknown'
|
||||
: undefined;
|
||||
const decisionNotes = protest.decisionNotes;
|
||||
const reviewedAt = protest.reviewedAt?.toISOString();
|
||||
|
||||
return {
|
||||
...base,
|
||||
...(comment !== undefined ? { comment } : {}),
|
||||
...(proofVideoUrl !== undefined ? { proofVideoUrl } : {}),
|
||||
...(reviewedBy !== undefined ? { reviewedBy } : {}),
|
||||
...(reviewedByName !== undefined ? { reviewedByName } : {}),
|
||||
...(decisionNotes !== undefined ? { decisionNotes } : {}),
|
||||
...(reviewedAt !== undefined ? { reviewedAt } : {}),
|
||||
};
|
||||
});
|
||||
|
||||
this.viewModel = {
|
||||
protests: protestViewModels,
|
||||
};
|
||||
|
||||
return this.viewModel;
|
||||
}
|
||||
|
||||
getViewModel(): RaceProtestsViewModel {
|
||||
if (!this.viewModel) {
|
||||
throw new Error('Presenter has not been called yet');
|
||||
}
|
||||
getViewModel(): RaceProtestsViewModel | null {
|
||||
return this.viewModel;
|
||||
}
|
||||
}
|
||||
@@ -4,26 +4,59 @@ import type {
|
||||
RaceListItemViewModel,
|
||||
} from '@gridpilot/racing/application/presenters/IRacesPagePresenter';
|
||||
|
||||
interface RacesPageInput {
|
||||
id: string;
|
||||
track: string;
|
||||
car: string;
|
||||
scheduledAt: string | Date;
|
||||
status: string;
|
||||
leagueId: string;
|
||||
leagueName: string;
|
||||
strengthOfField: number | null;
|
||||
isUpcoming: boolean;
|
||||
isLive: boolean;
|
||||
isPast: boolean;
|
||||
}
|
||||
|
||||
export class RacesPagePresenter implements IRacesPagePresenter {
|
||||
private viewModel: RacesPageViewModel | null = null;
|
||||
|
||||
present(races: any[]): void {
|
||||
present(races: RacesPageInput[]): void {
|
||||
const now = new Date();
|
||||
const nextWeek = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
|
||||
|
||||
const raceViewModels: RaceListItemViewModel[] = races.map(race => ({
|
||||
id: race.id,
|
||||
track: race.track,
|
||||
car: race.car,
|
||||
scheduledAt: race.scheduledAt,
|
||||
status: race.status,
|
||||
leagueId: race.leagueId,
|
||||
leagueName: race.leagueName,
|
||||
strengthOfField: race.strengthOfField,
|
||||
isUpcoming: race.isUpcoming,
|
||||
isLive: race.isLive,
|
||||
isPast: race.isPast,
|
||||
}));
|
||||
const raceViewModels: RaceListItemViewModel[] = races.map((race) => {
|
||||
const scheduledAt =
|
||||
typeof race.scheduledAt === 'string'
|
||||
? race.scheduledAt
|
||||
: race.scheduledAt.toISOString();
|
||||
|
||||
const allowedStatuses: RaceListItemViewModel['status'][] = [
|
||||
'scheduled',
|
||||
'running',
|
||||
'completed',
|
||||
'cancelled',
|
||||
];
|
||||
|
||||
const status: RaceListItemViewModel['status'] =
|
||||
allowedStatuses.includes(race.status as RaceListItemViewModel['status'])
|
||||
? (race.status as RaceListItemViewModel['status'])
|
||||
: 'scheduled';
|
||||
|
||||
return {
|
||||
id: race.id,
|
||||
track: race.track,
|
||||
car: race.car,
|
||||
scheduledAt,
|
||||
status,
|
||||
leagueId: race.leagueId,
|
||||
leagueName: race.leagueName,
|
||||
strengthOfField: race.strengthOfField,
|
||||
isUpcoming: race.isUpcoming,
|
||||
isLive: race.isLive,
|
||||
isPast: race.isPast,
|
||||
};
|
||||
});
|
||||
|
||||
const stats = {
|
||||
total: raceViewModels.length,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ISponsorDashboardPresenter } from '@racing/application/presenters/ISponsorDashboardPresenter';
|
||||
import type { SponsorDashboardDTO } from '@racing/application/use-cases/GetSponsorDashboardUseCase';
|
||||
import type { ISponsorDashboardPresenter } from '@gridpilot/racing/application/presenters/ISponsorDashboardPresenter';
|
||||
import type { SponsorDashboardDTO } from '@gridpilot/racing/application/use-cases/GetSponsorDashboardUseCase';
|
||||
|
||||
export class SponsorDashboardPresenter implements ISponsorDashboardPresenter {
|
||||
private data: SponsorDashboardDTO | null = null;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ISponsorSponsorshipsPresenter } from '@racing/application/presenters/ISponsorSponsorshipsPresenter';
|
||||
import type { SponsorSponsorshipsDTO } from '@racing/application/use-cases/GetSponsorSponsorshipsUseCase';
|
||||
import type { ISponsorSponsorshipsPresenter } from '@gridpilot/racing/application/presenters/ISponsorSponsorshipsPresenter';
|
||||
import type { SponsorSponsorshipsDTO } from '@gridpilot/racing/application/use-cases/GetSponsorSponsorshipsUseCase';
|
||||
|
||||
export class SponsorSponsorshipsPresenter implements ISponsorSponsorshipsPresenter {
|
||||
private data: SponsorSponsorshipsDTO | null = null;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { Team, TeamJoinRequest } from '@gridpilot/racing';
|
||||
import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO';
|
||||
import { EntityMappers } from '@gridpilot/racing/application/mappers/EntityMappers';
|
||||
import {
|
||||
@@ -34,7 +33,9 @@ export interface TeamAdminViewModel {
|
||||
/**
|
||||
* Load join requests plus driver DTOs for a team.
|
||||
*/
|
||||
export async function loadTeamAdminViewModel(team: Team): Promise<TeamAdminViewModel> {
|
||||
export async function loadTeamAdminViewModel(
|
||||
team: TeamAdminTeamSummaryViewModel,
|
||||
): Promise<TeamAdminViewModel> {
|
||||
const requests = await loadTeamJoinRequests(team.id);
|
||||
return {
|
||||
team: {
|
||||
@@ -48,10 +49,18 @@ export async function loadTeamAdminViewModel(team: Team): Promise<TeamAdminViewM
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadTeamJoinRequests(teamId: string): Promise<TeamAdminJoinRequestViewModel[]> {
|
||||
export async function loadTeamJoinRequests(
|
||||
teamId: string,
|
||||
): Promise<TeamAdminJoinRequestViewModel[]> {
|
||||
const getRequestsUseCase = getGetTeamJoinRequestsUseCase();
|
||||
await getRequestsUseCase.execute({ teamId });
|
||||
const presenterVm = getRequestsUseCase.presenter.getViewModel();
|
||||
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();
|
||||
@@ -64,14 +73,29 @@ export async function loadTeamJoinRequests(teamId: string): Promise<TeamAdminJoi
|
||||
}
|
||||
}
|
||||
|
||||
return presenterVm.requests.map((req) => ({
|
||||
id: req.requestId,
|
||||
teamId: req.teamId,
|
||||
driverId: req.driverId,
|
||||
requestedAt: new Date(req.requestedAt),
|
||||
message: req.message,
|
||||
driver: driversById[req.driverId],
|
||||
}));
|
||||
return presenterVm.requests.map((req: {
|
||||
requestId: string;
|
||||
teamId: string;
|
||||
driverId: string;
|
||||
requestedAt: string;
|
||||
message?: string;
|
||||
}): TeamAdminJoinRequestViewModel => {
|
||||
const base: TeamAdminJoinRequestViewModel = {
|
||||
id: req.requestId,
|
||||
teamId: req.teamId,
|
||||
driverId: req.driverId,
|
||||
requestedAt: new Date(req.requestedAt),
|
||||
};
|
||||
|
||||
const message = req.message;
|
||||
const driver = driversById[req.driverId];
|
||||
|
||||
return {
|
||||
...base,
|
||||
...(message !== undefined ? { message } : {}),
|
||||
...(driver !== undefined ? { driver } : {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Team, TeamMembership } from '@gridpilot/racing/domain/entities/Team';
|
||||
import type { Team } from '@gridpilot/racing/domain/entities/Team';
|
||||
import type { TeamMembership } from '@gridpilot/racing/domain/types/TeamMembership';
|
||||
import type {
|
||||
ITeamDetailsPresenter,
|
||||
TeamDetailsViewModel,
|
||||
@@ -14,7 +15,7 @@ export class TeamDetailsPresenter implements ITeamDetailsPresenter {
|
||||
): TeamDetailsViewModel {
|
||||
const canManage = membership?.role === 'owner' || membership?.role === 'manager';
|
||||
|
||||
this.viewModel = {
|
||||
const viewModel: TeamDetailsViewModel = {
|
||||
team: {
|
||||
id: team.id,
|
||||
name: team.name,
|
||||
@@ -22,21 +23,20 @@ export class TeamDetailsPresenter implements ITeamDetailsPresenter {
|
||||
description: team.description,
|
||||
ownerId: team.ownerId,
|
||||
leagues: team.leagues,
|
||||
specialization: team.specialization as 'endurance' | 'sprint' | 'mixed' | undefined,
|
||||
region: team.region,
|
||||
languages: team.languages,
|
||||
},
|
||||
membership: membership
|
||||
? {
|
||||
role: membership.role,
|
||||
role: membership.role === 'driver' ? 'member' : membership.role,
|
||||
joinedAt: membership.joinedAt.toISOString(),
|
||||
isActive: membership.isActive,
|
||||
isActive: membership.status === 'active',
|
||||
}
|
||||
: null,
|
||||
canManage,
|
||||
};
|
||||
|
||||
return this.viewModel;
|
||||
this.viewModel = viewModel;
|
||||
|
||||
return viewModel;
|
||||
}
|
||||
|
||||
getViewModel(): TeamDetailsViewModel {
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
import type { TeamJoinRequest } from '@gridpilot/racing/domain/entities/Team';
|
||||
import type {
|
||||
ITeamJoinRequestsPresenter,
|
||||
TeamJoinRequestViewModel,
|
||||
TeamJoinRequestsViewModel,
|
||||
TeamJoinRequestsResultDTO,
|
||||
} from '@gridpilot/racing/application/presenters/ITeamJoinRequestsPresenter';
|
||||
|
||||
export class TeamJoinRequestsPresenter implements ITeamJoinRequestsPresenter {
|
||||
private viewModel: TeamJoinRequestsViewModel | null = null;
|
||||
|
||||
present(
|
||||
requests: TeamJoinRequest[],
|
||||
driverNames: Record<string, string>,
|
||||
avatarUrls: Record<string, string>
|
||||
): TeamJoinRequestsViewModel {
|
||||
const requestItems: TeamJoinRequestViewModel[] = requests.map((request) => ({
|
||||
reset(): void {
|
||||
this.viewModel = null;
|
||||
}
|
||||
|
||||
present(input: TeamJoinRequestsResultDTO): void {
|
||||
const requestItems: TeamJoinRequestViewModel[] = input.requests.map((request) => ({
|
||||
requestId: request.id,
|
||||
driverId: request.driverId,
|
||||
driverName: driverNames[request.driverId] ?? 'Unknown Driver',
|
||||
driverName: input.driverNames[request.driverId] ?? 'Unknown Driver',
|
||||
teamId: request.teamId,
|
||||
status: request.status,
|
||||
status: 'pending',
|
||||
requestedAt: request.requestedAt.toISOString(),
|
||||
avatarUrl: avatarUrls[request.driverId] ?? '',
|
||||
avatarUrl: input.avatarUrls[request.driverId] ?? '',
|
||||
}));
|
||||
|
||||
const pendingCount = requestItems.filter((r) => r.status === 'pending').length;
|
||||
@@ -30,14 +30,9 @@ export class TeamJoinRequestsPresenter implements ITeamJoinRequestsPresenter {
|
||||
pendingCount,
|
||||
totalCount: requestItems.length,
|
||||
};
|
||||
|
||||
return this.viewModel;
|
||||
}
|
||||
|
||||
getViewModel(): TeamJoinRequestsViewModel {
|
||||
if (!this.viewModel) {
|
||||
throw new Error('Presenter has not been called yet');
|
||||
}
|
||||
getViewModel(): TeamJoinRequestsViewModel | null {
|
||||
return this.viewModel;
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,25 @@
|
||||
import type { TeamMembership } from '@gridpilot/racing/domain/entities/Team';
|
||||
import type {
|
||||
ITeamMembersPresenter,
|
||||
TeamMemberViewModel,
|
||||
TeamMembersViewModel,
|
||||
TeamMembersResultDTO,
|
||||
} from '@gridpilot/racing/application/presenters/ITeamMembersPresenter';
|
||||
|
||||
export class TeamMembersPresenter implements ITeamMembersPresenter {
|
||||
private viewModel: TeamMembersViewModel | null = null;
|
||||
|
||||
present(
|
||||
memberships: TeamMembership[],
|
||||
driverNames: Record<string, string>,
|
||||
avatarUrls: Record<string, string>
|
||||
): TeamMembersViewModel {
|
||||
const members: TeamMemberViewModel[] = memberships.map((membership) => ({
|
||||
reset(): void {
|
||||
this.viewModel = null;
|
||||
}
|
||||
|
||||
present(input: TeamMembersResultDTO): void {
|
||||
const members: TeamMemberViewModel[] = input.memberships.map((membership) => ({
|
||||
driverId: membership.driverId,
|
||||
driverName: driverNames[membership.driverId] ?? 'Unknown Driver',
|
||||
role: membership.role,
|
||||
driverName: input.driverNames[membership.driverId] ?? 'Unknown Driver',
|
||||
role: membership.role === 'driver' ? 'member' : membership.role,
|
||||
joinedAt: membership.joinedAt.toISOString(),
|
||||
isActive: membership.isActive,
|
||||
avatarUrl: avatarUrls[membership.driverId] ?? '',
|
||||
isActive: membership.status === 'active',
|
||||
avatarUrl: input.avatarUrls[membership.driverId] ?? '',
|
||||
}));
|
||||
|
||||
const ownerCount = members.filter((m) => m.role === 'owner').length;
|
||||
@@ -33,14 +33,9 @@ export class TeamMembersPresenter implements ITeamMembersPresenter {
|
||||
managerCount,
|
||||
memberCount,
|
||||
};
|
||||
|
||||
return this.viewModel;
|
||||
}
|
||||
|
||||
getViewModel(): TeamMembersViewModel {
|
||||
if (!this.viewModel) {
|
||||
throw new Error('Presenter has not been called yet');
|
||||
}
|
||||
getViewModel(): TeamMembersViewModel | null {
|
||||
return this.viewModel;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { TeamMembership, TeamRole } from '@gridpilot/racing';
|
||||
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';
|
||||
|
||||
@@ -3,12 +3,36 @@ import type {
|
||||
TeamsLeaderboardViewModel,
|
||||
TeamLeaderboardItemViewModel,
|
||||
SkillLevel,
|
||||
TeamsLeaderboardResultDTO,
|
||||
} from '@gridpilot/racing/application/presenters/ITeamsLeaderboardPresenter';
|
||||
|
||||
interface TeamLeaderboardInput {
|
||||
id: string;
|
||||
name: string;
|
||||
memberCount: number;
|
||||
rating: number | null;
|
||||
totalWins: number;
|
||||
totalRaces: number;
|
||||
performanceLevel: SkillLevel;
|
||||
isRecruiting: boolean;
|
||||
createdAt: Date;
|
||||
description?: string | null;
|
||||
specialization?: string | null;
|
||||
region?: string | null;
|
||||
languages?: string[];
|
||||
}
|
||||
|
||||
export class TeamsLeaderboardPresenter implements ITeamsLeaderboardPresenter {
|
||||
private viewModel: TeamsLeaderboardViewModel | null = null;
|
||||
|
||||
present(teams: any[], recruitingCount: number): void {
|
||||
reset(): void {
|
||||
this.viewModel = null;
|
||||
}
|
||||
|
||||
present(input: TeamsLeaderboardResultDTO): void {
|
||||
const teams = (input.teams ?? []) as TeamLeaderboardInput[];
|
||||
const recruitingCount = input.recruitingCount ?? 0;
|
||||
|
||||
const transformedTeams = teams.map((team) => this.transformTeam(team));
|
||||
|
||||
const groupsBySkillLevel = transformedTeams.reduce<Record<SkillLevel, TeamLeaderboardItemViewModel[]>>(
|
||||
@@ -41,14 +65,22 @@ export class TeamsLeaderboardPresenter implements ITeamsLeaderboardPresenter {
|
||||
};
|
||||
}
|
||||
|
||||
getViewModel(): TeamsLeaderboardViewModel {
|
||||
if (!this.viewModel) {
|
||||
throw new Error('ViewModel not yet generated. Call present() first.');
|
||||
}
|
||||
getViewModel(): TeamsLeaderboardViewModel | null {
|
||||
return this.viewModel;
|
||||
}
|
||||
|
||||
private transformTeam(team: any): TeamLeaderboardItemViewModel {
|
||||
private transformTeam(team: TeamLeaderboardInput): TeamLeaderboardItemViewModel {
|
||||
let specialization: TeamLeaderboardItemViewModel['specialization'];
|
||||
if (
|
||||
team.specialization === 'endurance' ||
|
||||
team.specialization === 'sprint' ||
|
||||
team.specialization === 'mixed'
|
||||
) {
|
||||
specialization = team.specialization;
|
||||
} else {
|
||||
specialization = undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
id: team.id,
|
||||
name: team.name,
|
||||
@@ -56,13 +88,13 @@ export class TeamsLeaderboardPresenter implements ITeamsLeaderboardPresenter {
|
||||
rating: team.rating,
|
||||
totalWins: team.totalWins,
|
||||
totalRaces: team.totalRaces,
|
||||
performanceLevel: team.performanceLevel as SkillLevel,
|
||||
performanceLevel: team.performanceLevel,
|
||||
isRecruiting: team.isRecruiting,
|
||||
createdAt: team.createdAt,
|
||||
description: team.description,
|
||||
specialization: team.specialization,
|
||||
region: team.region,
|
||||
languages: team.languages,
|
||||
description: team.description ?? '',
|
||||
specialization: specialization ?? 'mixed',
|
||||
region: team.region ?? '',
|
||||
languages: team.languages ?? [],
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user