This commit is contained in:
2025-12-11 21:06:25 +01:00
parent c49ea2598d
commit ec3ddc3a5c
227 changed files with 3496 additions and 2083 deletions

View File

@@ -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 = {

View File

@@ -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 = {

View File

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

View File

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

View File

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

View File

@@ -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 };
}
/**

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 } : {}),
};
});
}
/**

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 ?? [],
};
}
}