refactor core presenters

This commit is contained in:
2025-12-19 19:42:19 +01:00
parent 8116fe888f
commit 94fc538f44
228 changed files with 2817 additions and 3097 deletions

View File

@@ -46,8 +46,8 @@ export class RaceController {
@Get('all/page-data')
@ApiOperation({ summary: 'Get all races page data' })
@ApiResponse({ status: 200, description: 'All races page data', type: RacesPageDataDTO })
async getAllRacesPageData(): Promise<RacesPageDataDTO> {
@ApiResponse({ status: 200, description: 'All races page data', type: AllRacesPageDTO })
async getAllRacesPageData(): Promise<AllRacesPageDTO> {
return this.raceService.getAllRacesPageData();
}

View File

@@ -146,8 +146,6 @@ export const RaceProviders: Provider[] = [
raceRegRepo: IRaceRegistrationRepository,
resultRepo: IResultRepository,
leagueMembershipRepo: ILeagueMembershipRepository,
driverRatingProvider: DriverRatingProvider,
imageService: IImageServicePort,
) => new GetRaceDetailUseCase(
raceRepo,
leagueRepo,
@@ -155,8 +153,6 @@ export const RaceProviders: Provider[] = [
raceRegRepo,
resultRepo,
leagueMembershipRepo,
driverRatingProvider,
imageService,
),
inject: [
RACE_REPOSITORY_TOKEN,
@@ -165,8 +161,6 @@ export const RaceProviders: Provider[] = [
RACE_REGISTRATION_REPOSITORY_TOKEN,
RESULT_REPOSITORY_TOKEN,
LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN,
DRIVER_RATING_PROVIDER_TOKEN,
IMAGE_SERVICE_TOKEN,
],
},
{

View File

@@ -1,12 +1,11 @@
import { Injectable, Inject } from '@nestjs/common';
import type { AllRacesPageViewModel } from '@core/racing/application/presenters/IGetAllRacesPresenter';
import type { GetTotalRacesViewModel } from '@core/racing/application/presenters/IGetTotalRacesPresenter';
import type { RaceDetailViewModel } from '@core/racing/application/presenters/IRaceDetailPresenter';
import type { RacesPageViewModel } from '@core/racing/application/presenters/IRacesPagePresenter';
import type { RaceResultsDetailViewModel } from '@core/racing/application/presenters/IRaceResultsDetailPresenter';
import type { RaceWithSOFViewModel } from '@core/racing/application/presenters/IRaceWithSOFPresenter';
import type { RaceProtestsViewModel } from '@core/racing/application/presenters/IRaceProtestsPresenter';
import type { RacePenaltiesViewModel } from '@core/racing/application/presenters/IRacePenaltiesPresenter';
import type { RaceDetailOutputPort } from '@core/racing/application/ports/output/RaceDetailOutputPort';
import type { RacesPageOutputPort } from '@core/racing/application/ports/output/RacesPageOutputPort';
import type { RaceResultsDetailOutputPort } from '@core/racing/application/ports/output/RaceResultsDetailOutputPort';
import type { RaceWithSOFOutputPort } from '@core/racing/application/ports/output/RaceWithSOFOutputPort';
import type { RaceProtestsOutputPort } from '@core/racing/application/ports/output/RaceProtestsOutputPort';
import type { RacePenaltiesOutputPort } from '@core/racing/application/ports/output/RacePenaltiesOutputPort';
// DTOs
import { GetRaceDetailParamsDTO } from './dtos/GetRaceDetailParamsDTO';
@@ -14,9 +13,18 @@ import { RegisterForRaceParamsDTO } from './dtos/RegisterForRaceParamsDTO';
import { WithdrawFromRaceParamsDTO } from './dtos/WithdrawFromRaceParamsDTO';
import { RaceActionParamsDTO } from './dtos/RaceActionParamsDTO';
import { ImportRaceResultsDTO } from './dtos/ImportRaceResultsDTO';
import { AllRacesPageDTO } from './dtos/AllRacesPageDTO';
import { RaceStatsDTO } from './dtos/RaceStatsDTO';
import { RacePenaltiesDTO } from './dtos/RacePenaltiesDTO';
import { RaceProtestsDTO } from './dtos/RaceProtestsDTO';
import { RaceResultsDetailDTO } from './dtos/RaceResultsDetailDTO';
// Core imports
import type { Logger } from '@core/shared/application/Logger';
import { Result } from '@core/shared/application/Result';
import { DriverRatingProvider } from '@core/racing/application/ports/DriverRatingProvider';
import { IImageServicePort } from '@core/racing/application/ports/IImageServicePort';
import type { ILeagueRepository } from '@core/racing/domain/repositories/ILeagueRepository';
// Use cases
import { GetAllRacesUseCase } from '@core/racing/application/use-cases/GetAllRacesUseCase';
@@ -53,7 +61,7 @@ import { RequestProtestDefenseCommandDTO } from './dtos/RequestProtestDefenseCom
import { ReviewProtestCommandDTO } from './dtos/ReviewProtestCommandDTO';
// Tokens
import { LOGGER_TOKEN } from './RaceProviders';
import { LOGGER_TOKEN, DRIVER_RATING_PROVIDER_TOKEN, IMAGE_SERVICE_TOKEN, LEAGUE_REPOSITORY_TOKEN } from './RaceProviders';
@Injectable()
export class RaceService {
@@ -77,7 +85,10 @@ export class RaceService {
private readonly applyPenaltyUseCase: ApplyPenaltyUseCase,
private readonly requestProtestDefenseUseCase: RequestProtestDefenseUseCase,
private readonly reviewProtestUseCase: ReviewProtestUseCase,
@Inject(LEAGUE_REPOSITORY_TOKEN) private readonly leagueRepository: ILeagueRepository,
@Inject(LOGGER_TOKEN) private readonly logger: Logger,
@Inject(DRIVER_RATING_PROVIDER_TOKEN) private readonly driverRatingProvider: DriverRatingProvider,
@Inject(IMAGE_SERVICE_TOKEN) private readonly imageService: IImageServicePort,
) {}
async getAllRaces(): Promise<AllRacesPageViewModel> {
@@ -88,21 +99,29 @@ export class RaceService {
return presenter.getViewModel()!;
}
async getTotalRaces(): Promise<GetTotalRacesViewModel> {
async getTotalRaces(): Promise<RaceStatsDTO> {
this.logger.debug('[RaceService] Fetching total races count.');
const result = await this.getTotalRacesUseCase.execute();
if (result.isErr()) {
throw new Error(result.unwrapErr().code);
}
const presenter = new GetTotalRacesPresenter();
await this.getTotalRacesUseCase.execute({}, presenter);
presenter.present(result.unwrap());
return presenter.getViewModel()!;
}
async importRaceResults(input: ImportRaceResultsDTO): Promise<ImportRaceResultsSummaryDTO> {
this.logger.debug('Importing race results:', input);
const result = await this.importRaceResultsApiUseCase.execute({ raceId: input.raceId, resultsFileContent: input.resultsFileContent });
if (result.isErr()) {
throw new Error(result.unwrapErr().code);
}
const presenter = new ImportRaceResultsApiPresenter();
await this.importRaceResultsApiUseCase.execute({ raceId: input.raceId, resultsFileContent: input.resultsFileContent }, presenter);
presenter.present(result.unwrap());
return presenter.getViewModel()!;
}
async getRaceDetail(params: GetRaceDetailParamsDTO): Promise<RaceDetailViewModel> {
async getRaceDetail(params: GetRaceDetailParamsDTO): Promise<RaceDetailDTO> {
this.logger.debug('[RaceService] Fetching race detail:', params);
const result = await this.getRaceDetailUseCase.execute(params);
@@ -111,10 +130,71 @@ export class RaceService {
throw new Error('Failed to get race detail');
}
return result.value;
const outputPort = result.value;
// Map to DTO
const raceDTO = outputPort.race ? {
id: outputPort.race.id,
leagueId: outputPort.race.leagueId,
track: outputPort.race.track,
car: outputPort.race.car,
scheduledAt: outputPort.race.scheduledAt.toISOString(),
sessionType: outputPort.race.sessionType,
status: outputPort.race.status,
strengthOfField: outputPort.race.strengthOfField ?? null,
registeredCount: outputPort.race.registeredCount ?? undefined,
maxParticipants: outputPort.race.maxParticipants ?? undefined,
} : null;
const leagueDTO = outputPort.league ? {
id: outputPort.league.id.toString(),
name: outputPort.league.name.toString(),
description: outputPort.league.description.toString(),
settings: {
maxDrivers: outputPort.league.settings.maxDrivers ?? undefined,
qualifyingFormat: outputPort.league.settings.qualifyingFormat ?? undefined,
},
} : null;
const entryListDTO = await Promise.all(outputPort.drivers.map(async driver => {
const ratingResult = await this.driverRatingProvider.getDriverRating({ driverId: driver.id });
const avatarResult = await this.imageService.getDriverAvatar({ driverId: driver.id });
return {
id: driver.id,
name: driver.name.toString(),
country: driver.country.toString(),
avatarUrl: avatarResult.avatarUrl,
rating: ratingResult.rating,
isCurrentUser: driver.id === params.driverId,
};
}));
const registrationDTO = {
isUserRegistered: outputPort.isUserRegistered,
canRegister: outputPort.canRegister,
};
const userResultDTO = outputPort.userResult ? {
position: outputPort.userResult.position.toNumber(),
startPosition: outputPort.userResult.startPosition.toNumber(),
incidents: outputPort.userResult.incidents.toNumber(),
fastestLap: outputPort.userResult.fastestLap.toNumber(),
positionChange: outputPort.userResult.getPositionChange(),
isPodium: outputPort.userResult.isPodium(),
isClean: outputPort.userResult.isClean(),
ratingChange: this.calculateRatingChange(outputPort.userResult.position.toNumber()),
} : null;
return {
race: raceDTO,
league: leagueDTO,
entryList: entryListDTO,
registration: registrationDTO,
userResult: userResultDTO,
};
}
async getRacesPageData(): Promise<RacesPageViewModel> {
async getRacesPageData(): Promise<RacesPageDataDTO> {
this.logger.debug('[RaceService] Fetching races page data.');
const result = await this.getRacesPageDataUseCase.execute();
@@ -123,10 +203,33 @@ export class RaceService {
throw new Error('Failed to get races page data');
}
return result.value;
const outputPort = result.value;
// Fetch leagues for league names
const allLeagues = await this.leagueRepository.findAll();
const leagueMap = new Map(allLeagues.map(l => [l.id, l.name]));
// Map to DTO
const racesDTO = outputPort.races.map(race => ({
id: race.id,
track: race.track,
car: race.car,
scheduledAt: race.scheduledAt.toISOString(),
status: race.status,
leagueId: race.leagueId,
leagueName: leagueMap.get(race.leagueId) ?? 'Unknown League',
strengthOfField: race.strengthOfField,
isUpcoming: race.scheduledAt > new Date(),
isLive: race.status === 'running',
isPast: race.scheduledAt < new Date() && race.status === 'completed',
}));
return {
races: racesDTO,
};
}
async getAllRacesPageData(): Promise<RacesPageViewModel> {
async getAllRacesPageData(): Promise<AllRacesPageDTO> {
this.logger.debug('[RaceService] Fetching all races page data.');
const result = await this.getAllRacesPageDataUseCase.execute();
@@ -135,10 +238,10 @@ export class RaceService {
throw new Error('Failed to get all races page data');
}
return result.value;
return result.value as AllRacesPageDTO;
}
async getRaceResultsDetail(raceId: string): Promise<RaceResultsDetailViewModel> {
async getRaceResultsDetail(raceId: string): Promise<RaceResultsDetailDTO> {
this.logger.debug('[RaceService] Fetching race results detail:', { raceId });
const result = await this.getRaceResultsDetailUseCase.execute({ raceId });
@@ -147,10 +250,41 @@ export class RaceService {
throw new Error('Failed to get race results detail');
}
return result.value;
const outputPort = result.value;
// Create a map of driverId to driver for easy lookup
const driverMap = new Map(outputPort.drivers.map(driver => [driver.id, driver]));
const resultsDTO = await Promise.all(outputPort.results.map(async (result) => {
const driver = driverMap.get(result.driverId.toString());
if (!driver) {
throw new Error(`Driver not found for result: ${result.driverId}`);
}
const avatarResult = await this.imageService.getDriverAvatar({ driverId: driver.id });
return {
driverId: result.driverId.toString(),
driverName: driver.name.toString(),
avatarUrl: avatarResult.avatarUrl,
position: result.position.toNumber(),
startPosition: result.startPosition.toNumber(),
incidents: result.incidents.toNumber(),
fastestLap: result.fastestLap.toNumber(),
positionChange: result.getPositionChange(),
isPodium: result.isPodium(),
isClean: result.isClean(),
};
}));
return {
raceId: outputPort.race.id,
track: outputPort.race.track,
results: resultsDTO,
};
}
async getRaceWithSOF(raceId: string): Promise<RaceWithSOFViewModel> {
async getRaceWithSOF(raceId: string): Promise<RaceWithSOFDTO> {
this.logger.debug('[RaceService] Fetching race with SOF:', { raceId });
const result = await this.getRaceWithSOFUseCase.execute({ raceId });
@@ -159,10 +293,17 @@ export class RaceService {
throw new Error('Failed to get race with SOF');
}
return result.value;
const outputPort = result.value;
// Map to DTO
return {
id: outputPort.id,
track: outputPort.track,
strengthOfField: outputPort.strengthOfField,
};
}
async getRaceProtests(raceId: string): Promise<RaceProtestsViewModel> {
async getRaceProtests(raceId: string): Promise<RaceProtestsDTO> {
this.logger.debug('[RaceService] Fetching race protests:', { raceId });
const result = await this.getRaceProtestsUseCase.execute({ raceId });
@@ -171,10 +312,32 @@ export class RaceService {
throw new Error('Failed to get race protests');
}
return result.value;
const outputPort = result.value;
const protestsDTO = outputPort.protests.map(protest => ({
id: protest.id,
protestingDriverId: protest.protestingDriverId,
accusedDriverId: protest.accusedDriverId,
incident: {
lap: protest.incident.lap,
description: protest.incident.description,
},
status: protest.status,
filedAt: protest.filedAt.toISOString(),
}));
const driverMap: Record<string, string> = {};
outputPort.drivers.forEach(driver => {
driverMap[driver.id] = driver.name.toString();
});
return {
protests: protestsDTO,
driverMap,
};
}
async getRacePenalties(raceId: string): Promise<RacePenaltiesViewModel> {
async getRacePenalties(raceId: string): Promise<RacePenaltiesDTO> {
this.logger.debug('[RaceService] Fetching race penalties:', { raceId });
const result = await this.getRacePenaltiesUseCase.execute({ raceId });
@@ -183,7 +346,28 @@ export class RaceService {
throw new Error('Failed to get race penalties');
}
return result.value;
const outputPort = result.value;
const penaltiesDTO = outputPort.penalties.map(penalty => ({
id: penalty.id,
driverId: penalty.driverId,
type: penalty.type,
value: penalty.value ?? 0,
reason: penalty.reason,
issuedBy: penalty.issuedBy,
issuedAt: penalty.issuedAt.toISOString(),
notes: penalty.notes,
}));
const driverMap: Record<string, string> = {};
outputPort.drivers.forEach(driver => {
driverMap[driver.id] = driver.name.toString();
});
return {
penalties: penaltiesDTO,
driverMap,
};
}
async registerForRace(params: RegisterForRaceParamsDTO): Promise<void> {
@@ -277,4 +461,10 @@ export class RaceService {
throw new Error('Failed to review protest');
}
}
private calculateRatingChange(position: number): number {
const baseChange = position <= 3 ? 25 : position <= 10 ? 10 : -5;
const positionBonus = Math.max(0, (20 - position) * 2);
return baseChange + positionBonus;
}
}

View File

@@ -1,10 +1,45 @@
import { ApiProperty } from '@nestjs/swagger';
import { RaceViewModel } from '@core/racing/application/presenters/IGetAllRacesPresenter';
export class AllRacesPageDTO {
@ApiProperty({ type: [RaceViewModel] })
races!: RaceViewModel[];
export type AllRacesStatus = 'scheduled' | 'running' | 'completed' | 'cancelled' | 'all';
export class AllRacesListItemDTO {
@ApiProperty()
id!: string;
@ApiProperty()
totalCount!: number;
track!: string;
@ApiProperty()
car!: string;
@ApiProperty()
scheduledAt!: string;
@ApiProperty()
status!: 'scheduled' | 'running' | 'completed' | 'cancelled';
@ApiProperty()
leagueId!: string;
@ApiProperty()
leagueName!: string;
@ApiProperty({ nullable: true })
strengthOfField!: number | null;
}
export class AllRacesFilterOptionsDTO {
@ApiProperty({ type: [{ value: String, label: String }] })
statuses!: { value: AllRacesStatus; label: string }[];
@ApiProperty({ type: [{ id: String, name: String }] })
leagues!: { id: string; name: string }[];
}
export class AllRacesPageDTO {
@ApiProperty({ type: [AllRacesListItemDTO] })
races!: AllRacesListItemDTO[];
@ApiProperty({ type: AllRacesFilterOptionsDTO })
filters!: AllRacesFilterOptionsDTO;
}

View File

@@ -1,17 +1,39 @@
import { IGetAllRacesPresenter, GetAllRacesResultDTO, AllRacesPageViewModel } from '@core/racing/application/presenters/IGetAllRacesPresenter';
import { GetAllRacesOutputPort } from '@core/racing/application/ports/output/GetAllRacesOutputPort';
import { AllRacesPageDTO } from '../dtos/AllRacesPageDTO';
export class GetAllRacesPresenter implements IGetAllRacesPresenter {
private result: AllRacesPageViewModel | null = null;
export class GetAllRacesPresenter {
private result: AllRacesPageDTO | null = null;
reset() {
this.result = null;
}
present(dto: GetAllRacesResultDTO) {
this.result = dto;
async present(output: GetAllRacesOutputPort) {
this.result = {
races: output.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,
})),
filters: {
statuses: [
{ value: 'all', label: 'All' },
{ value: 'scheduled', label: 'Scheduled' },
{ value: 'running', label: 'Running' },
{ value: 'completed', label: 'Completed' },
{ value: 'cancelled', label: 'Cancelled' },
],
leagues: [], // TODO: populate if needed
},
};
}
getViewModel(): AllRacesPageViewModel | null {
getViewModel(): AllRacesPageDTO | null {
return this.result;
}
}

View File

@@ -1,19 +1,20 @@
import { IGetTotalRacesPresenter, GetTotalRacesResultDTO, GetTotalRacesViewModel } from '@core/racing/application/presenters/IGetTotalRacesPresenter';
import { GetTotalRacesOutputPort } from '@core/racing/application/ports/output/GetTotalRacesOutputPort';
import { RaceStatsDTO } from '../dtos/RaceStatsDTO';
export class GetTotalRacesPresenter implements IGetTotalRacesPresenter {
private result: GetTotalRacesViewModel | null = null;
export class GetTotalRacesPresenter {
private result: RaceStatsDTO | null = null;
reset() {
this.result = null;
}
present(dto: GetTotalRacesResultDTO) {
present(output: GetTotalRacesOutputPort) {
this.result = {
totalRaces: dto.totalRaces,
totalRaces: output.totalRaces,
};
}
getViewModel(): GetTotalRacesViewModel | null {
getViewModel(): RaceStatsDTO | null {
return this.result;
}
}

View File

@@ -1,17 +1,24 @@
import { IImportRaceResultsApiPresenter, ImportRaceResultsApiResultDTO, ImportRaceResultsSummaryViewModel } from '@core/racing/application/presenters/IImportRaceResultsApiPresenter';
import { ImportRaceResultsApiOutputPort } from '@core/racing/application/ports/output/ImportRaceResultsApiOutputPort';
import { ImportRaceResultsSummaryDTO } from '../dtos/ImportRaceResultsSummaryDTO';
export class ImportRaceResultsApiPresenter implements IImportRaceResultsApiPresenter {
private result: ImportRaceResultsSummaryViewModel | null = null;
export class ImportRaceResultsApiPresenter {
private result: ImportRaceResultsSummaryDTO | null = null;
reset() {
this.result = null;
}
present(dto: ImportRaceResultsApiResultDTO) {
this.result = dto;
present(output: ImportRaceResultsApiOutputPort) {
this.result = {
success: output.success,
raceId: output.raceId,
driversProcessed: output.driversProcessed,
resultsRecorded: output.resultsRecorded,
errors: output.errors,
};
}
getViewModel(): ImportRaceResultsSummaryViewModel | null {
getViewModel(): ImportRaceResultsSummaryDTO | null {
return this.result;
}
}