presenter refactoring

This commit is contained in:
2025-12-20 17:06:11 +01:00
parent 92be9d2e1b
commit e9d6f90bb2
109 changed files with 4159 additions and 1283 deletions

View File

@@ -1,5 +1,4 @@
import { ConflictException, Inject, Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common';
import type { AllRacesPageViewModel } from '@core/racing/application/presenters/IGetAllRacesPresenter';
import { Inject, Injectable } from '@nestjs/common';
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';
@@ -13,17 +12,11 @@ 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 { IImageServicePort } from '@core/racing/application/ports/IImageServicePort';
import type { ILeagueRepository } from '@core/racing/domain/repositories/ILeagueRepository';
// Use cases
@@ -41,7 +34,6 @@ import { RegisterForRaceUseCase } from '@core/racing/application/use-cases/Regis
import { WithdrawFromRaceUseCase } from '@core/racing/application/use-cases/WithdrawFromRaceUseCase';
import { CancelRaceUseCase } from '@core/racing/application/use-cases/CancelRaceUseCase';
import { CompleteRaceUseCase } from '@core/racing/application/use-cases/CompleteRaceUseCase';
import { ImportRaceResultsUseCase } from '@core/racing/application/use-cases/ImportRaceResultsUseCase';
import { FileProtestUseCase } from '@core/racing/application/use-cases/FileProtestUseCase';
import { QuickPenaltyUseCase } from '@core/racing/application/use-cases/QuickPenaltyUseCase';
import { ApplyPenaltyUseCase } from '@core/racing/application/use-cases/ApplyPenaltyUseCase';
@@ -53,6 +45,14 @@ import { ReopenRaceUseCase } from '@core/racing/application/use-cases/ReopenRace
import { GetAllRacesPresenter } from './presenters/GetAllRacesPresenter';
import { GetTotalRacesPresenter } from './presenters/GetTotalRacesPresenter';
import { ImportRaceResultsApiPresenter } from './presenters/ImportRaceResultsApiPresenter';
import { RaceDetailPresenter } from './presenters/RaceDetailPresenter';
import { RacesPageDataPresenter } from './presenters/RacesPageDataPresenter';
import { AllRacesPageDataPresenter } from './presenters/AllRacesPageDataPresenter';
import { RaceResultsDetailPresenter } from './presenters/RaceResultsDetailPresenter';
import { RaceWithSOFPresenter } from './presenters/RaceWithSOFPresenter';
import { RaceProtestsPresenter } from './presenters/RaceProtestsPresenter';
import { RacePenaltiesPresenter } from './presenters/RacePenaltiesPresenter';
import { CommandResultPresenter } from './presenters/CommandResultPresenter';
// Command DTOs
import { FileProtestCommandDTO } from './dtos/FileProtestCommandDTO';
@@ -93,15 +93,21 @@ export class RaceService {
@Inject(IMAGE_SERVICE_TOKEN) private readonly imageService: IImageServicePort,
) {}
async getAllRaces(): Promise<AllRacesPageViewModel> {
async getAllRaces(): Promise<GetAllRacesPresenter> {
this.logger.debug('[RaceService] Fetching all races.');
const result = await this.getAllRacesUseCase.execute();
if (result.isErr()) {
throw new Error('Failed to get all races');
}
const presenter = new GetAllRacesPresenter();
await this.getAllRacesUseCase.execute({}, presenter);
return presenter.getViewModel()!;
await presenter.present(result.unwrap());
return presenter;
}
async getTotalRaces(): Promise<RaceStatsDTO> {
async getTotalRaces(): Promise<GetTotalRacesPresenter> {
this.logger.debug('[RaceService] Fetching total races count.');
const result = await this.getTotalRacesUseCase.execute();
if (result.isErr()) {
@@ -109,10 +115,10 @@ export class RaceService {
}
const presenter = new GetTotalRacesPresenter();
presenter.present(result.unwrap());
return presenter.getViewModel()!;
return presenter;
}
async importRaceResults(input: ImportRaceResultsDTO): Promise<ImportRaceResultsSummaryDTO> {
async importRaceResults(input: ImportRaceResultsDTO): Promise<ImportRaceResultsApiPresenter> {
this.logger.debug('Importing race results:', input);
const result = await this.importRaceResultsApiUseCase.execute({ raceId: input.raceId, resultsFileContent: input.resultsFileContent });
if (result.isErr()) {
@@ -120,10 +126,10 @@ export class RaceService {
}
const presenter = new ImportRaceResultsApiPresenter();
presenter.present(result.unwrap());
return presenter.getViewModel()!;
return presenter;
}
async getRaceDetail(params: GetRaceDetailParamsDTO): Promise<RaceDetailDTO> {
async getRaceDetail(params: GetRaceDetailParamsDTO): Promise<RaceDetailPresenter> {
this.logger.debug('[RaceService] Fetching race detail:', params);
const result = await this.getRaceDetailUseCase.execute(params);
@@ -132,79 +138,12 @@ export class RaceService {
throw new Error('Failed to get race detail');
}
const outputPort = result.value as RaceDetailOutputPort;
// 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,
};
const presenter = new RaceDetailPresenter(this.driverRatingProvider, this.imageService);
await presenter.present(result.value as RaceDetailOutputPort, params);
return presenter;
}
async getRacesPageData(): Promise<RacesPageDataDTO> {
async getRacesPageData(): Promise<RacesPageDataPresenter> {
this.logger.debug('[RaceService] Fetching races page data.');
const result = await this.getRacesPageDataUseCase.execute();
@@ -213,33 +152,12 @@ export class RaceService {
throw new Error('Failed to get races page data');
}
const outputPort = result.value as RacesPageOutputPort;
// 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,
};
const presenter = new RacesPageDataPresenter(this.leagueRepository);
await presenter.present(result.value as RacesPageOutputPort);
return presenter;
}
async getAllRacesPageData(): Promise<AllRacesPageDTO> {
async getAllRacesPageData(): Promise<AllRacesPageDataPresenter> {
this.logger.debug('[RaceService] Fetching all races page data.');
const result = await this.getAllRacesPageDataUseCase.execute();
@@ -248,10 +166,12 @@ export class RaceService {
throw new Error('Failed to get all races page data');
}
return result.value as AllRacesPageDTO;
const presenter = new AllRacesPageDataPresenter();
presenter.present(result.value);
return presenter;
}
async getRaceResultsDetail(raceId: string): Promise<RaceResultsDetailDTO> {
async getRaceResultsDetail(raceId: string): Promise<RaceResultsDetailPresenter> {
this.logger.debug('[RaceService] Fetching race results detail:', { raceId });
const result = await this.getRaceResultsDetailUseCase.execute({ raceId });
@@ -260,43 +180,12 @@ export class RaceService {
throw new Error('Failed to get race results detail');
}
const outputPort = result.value as RaceResultsDetailOutputPort;
// 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 singleResult => {
const driver = driverMap.get(singleResult.driverId.toString());
if (!driver) {
throw new Error(`Driver not found for result: ${singleResult.driverId}`);
}
const avatarResult = await this.imageService.getDriverAvatar({ driverId: driver.id });
return {
driverId: singleResult.driverId.toString(),
driverName: driver.name.toString(),
avatarUrl: avatarResult.avatarUrl,
position: singleResult.position.toNumber(),
startPosition: singleResult.startPosition.toNumber(),
incidents: singleResult.incidents.toNumber(),
fastestLap: singleResult.fastestLap.toNumber(),
positionChange: singleResult.getPositionChange(),
isPodium: singleResult.isPodium(),
isClean: singleResult.isClean(),
};
}),
);
return {
raceId: outputPort.race.id,
track: outputPort.race.track,
results: resultsDTO,
};
const presenter = new RaceResultsDetailPresenter(this.imageService);
await presenter.present(result.value as RaceResultsDetailOutputPort);
return presenter;
}
async getRaceWithSOF(raceId: string): Promise<RaceWithSOFDTO> {
async getRaceWithSOF(raceId: string): Promise<RaceWithSOFPresenter> {
this.logger.debug('[RaceService] Fetching race with SOF:', { raceId });
const result = await this.getRaceWithSOFUseCase.execute({ raceId });
@@ -305,17 +194,12 @@ export class RaceService {
throw new Error('Failed to get race with SOF');
}
const outputPort = result.value as RaceWithSOFOutputPort;
// Map to DTO
return {
id: outputPort.id,
track: outputPort.track,
strengthOfField: outputPort.strengthOfField,
};
const presenter = new RaceWithSOFPresenter();
presenter.present(result.value as RaceWithSOFOutputPort);
return presenter;
}
async getRaceProtests(raceId: string): Promise<RaceProtestsDTO> {
async getRaceProtests(raceId: string): Promise<RaceProtestsPresenter> {
this.logger.debug('[RaceService] Fetching race protests:', { raceId });
const result = await this.getRaceProtestsUseCase.execute({ raceId });
@@ -324,32 +208,12 @@ export class RaceService {
throw new Error('Failed to get race protests');
}
const outputPort = result.value as RaceProtestsOutputPort;
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,
};
const presenter = new RaceProtestsPresenter();
presenter.present(result.value as RaceProtestsOutputPort);
return presenter;
}
async getRacePenalties(raceId: string): Promise<RacePenaltiesDTO> {
async getRacePenalties(raceId: string): Promise<RacePenaltiesPresenter> {
this.logger.debug('[RaceService] Fetching race penalties:', { raceId });
const result = await this.getRacePenaltiesUseCase.execute({ raceId });
@@ -358,148 +222,175 @@ export class RaceService {
throw new Error('Failed to get race penalties');
}
const outputPort = result.value as RacePenaltiesOutputPort;
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,
};
const presenter = new RacePenaltiesPresenter();
presenter.present(result.value as RacePenaltiesOutputPort);
return presenter;
}
async registerForRace(params: RegisterForRaceParamsDTO): Promise<void> {
async registerForRace(params: RegisterForRaceParamsDTO): Promise<CommandResultPresenter> {
this.logger.debug('[RaceService] Registering for race:', params);
const result = await this.registerForRaceUseCase.execute(params);
const presenter = new CommandResultPresenter();
if (result.isErr()) {
throw new Error('Failed to register for race');
const error = result.unwrapErr();
presenter.presentFailure(error.code ?? 'FAILED_TO_REGISTER_FOR_RACE', 'Failed to register for race');
return presenter;
}
presenter.presentSuccess();
return presenter;
}
async withdrawFromRace(params: WithdrawFromRaceParamsDTO): Promise<void> {
async withdrawFromRace(params: WithdrawFromRaceParamsDTO): Promise<CommandResultPresenter> {
this.logger.debug('[RaceService] Withdrawing from race:', params);
const result = await this.withdrawFromRaceUseCase.execute(params);
const presenter = new CommandResultPresenter();
if (result.isErr()) {
throw new Error('Failed to withdraw from race');
const error = result.unwrapErr();
presenter.presentFailure(error.code ?? 'FAILED_TO_WITHDRAW_FROM_RACE', 'Failed to withdraw from race');
return presenter;
}
presenter.presentSuccess();
return presenter;
}
async cancelRace(params: RaceActionParamsDTO): Promise<void> {
async cancelRace(params: RaceActionParamsDTO): Promise<CommandResultPresenter> {
this.logger.debug('[RaceService] Cancelling race:', params);
const result = await this.cancelRaceUseCase.execute({ raceId: params.raceId });
const presenter = new CommandResultPresenter();
if (result.isErr()) {
throw new Error('Failed to cancel race');
const error = result.unwrapErr();
presenter.presentFailure(error.code ?? 'FAILED_TO_CANCEL_RACE', 'Failed to cancel race');
return presenter;
}
presenter.presentSuccess();
return presenter;
}
async completeRace(params: RaceActionParamsDTO): Promise<void> {
async completeRace(params: RaceActionParamsDTO): Promise<CommandResultPresenter> {
this.logger.debug('[RaceService] Completing race:', params);
const result = await this.completeRaceUseCase.execute({ raceId: params.raceId });
const presenter = new CommandResultPresenter();
if (result.isErr()) {
throw new Error('Failed to complete race');
const error = result.unwrapErr();
presenter.presentFailure(error.code ?? 'FAILED_TO_COMPLETE_RACE', 'Failed to complete race');
return presenter;
}
presenter.presentSuccess();
return presenter;
}
async reopenRace(params: RaceActionParamsDTO): Promise<void> {
async reopenRace(params: RaceActionParamsDTO): Promise<CommandResultPresenter> {
this.logger.debug('[RaceService] Re-opening race:', params);
const result = await this.reopenRaceUseCase.execute({ raceId: params.raceId });
const presenter = new CommandResultPresenter();
if (result.isErr()) {
const errorCode = result.unwrapErr().code;
if (errorCode === 'RACE_NOT_FOUND') {
throw new NotFoundException('Race not found');
}
if (errorCode === 'CANNOT_REOPEN_RUNNING_RACE') {
throw new ConflictException('Cannot re-open a running race');
}
if (errorCode === 'RACE_ALREADY_SCHEDULED') {
this.logger.debug('[RaceService] Race is already scheduled, treating reopen as success.');
return;
presenter.presentSuccess('Race already scheduled');
return presenter;
}
throw new InternalServerErrorException(errorCode ?? 'UNEXPECTED_ERROR');
presenter.presentFailure(errorCode ?? 'UNEXPECTED_ERROR', 'Unexpected error while reopening race');
return presenter;
}
presenter.presentSuccess();
return presenter;
}
async fileProtest(command: FileProtestCommandDTO): Promise<void> {
async fileProtest(command: FileProtestCommandDTO): Promise<CommandResultPresenter> {
this.logger.debug('[RaceService] Filing protest:', command);
const result = await this.fileProtestUseCase.execute(command);
const presenter = new CommandResultPresenter();
if (result.isErr()) {
throw new Error('Failed to file protest');
const error = result.unwrapErr();
presenter.presentFailure(error.code ?? 'FAILED_TO_FILE_PROTEST', 'Failed to file protest');
return presenter;
}
presenter.presentSuccess();
return presenter;
}
async applyQuickPenalty(command: QuickPenaltyCommandDTO): Promise<void> {
async applyQuickPenalty(command: QuickPenaltyCommandDTO): Promise<CommandResultPresenter> {
this.logger.debug('[RaceService] Applying quick penalty:', command);
const result = await this.quickPenaltyUseCase.execute(command);
const presenter = new CommandResultPresenter();
if (result.isErr()) {
throw new Error('Failed to apply quick penalty');
const error = result.unwrapErr();
presenter.presentFailure(error.code ?? 'FAILED_TO_APPLY_QUICK_PENALTY', 'Failed to apply quick penalty');
return presenter;
}
presenter.presentSuccess();
return presenter;
}
async applyPenalty(command: ApplyPenaltyCommandDTO): Promise<void> {
async applyPenalty(command: ApplyPenaltyCommandDTO): Promise<CommandResultPresenter> {
this.logger.debug('[RaceService] Applying penalty:', command);
const result = await this.applyPenaltyUseCase.execute(command);
const presenter = new CommandResultPresenter();
if (result.isErr()) {
throw new Error('Failed to apply penalty');
const error = result.unwrapErr();
presenter.presentFailure(error.code ?? 'FAILED_TO_APPLY_PENALTY', 'Failed to apply penalty');
return presenter;
}
presenter.presentSuccess();
return presenter;
}
async requestProtestDefense(command: RequestProtestDefenseCommandDTO): Promise<void> {
async requestProtestDefense(command: RequestProtestDefenseCommandDTO): Promise<CommandResultPresenter> {
this.logger.debug('[RaceService] Requesting protest defense:', command);
const result = await this.requestProtestDefenseUseCase.execute(command);
const presenter = new CommandResultPresenter();
if (result.isErr()) {
throw new Error('Failed to request protest defense');
const error = result.unwrapErr();
presenter.presentFailure(error.code ?? 'FAILED_TO_REQUEST_PROTEST_DEFENSE', 'Failed to request protest defense');
return presenter;
}
presenter.presentSuccess();
return presenter;
}
async reviewProtest(command: ReviewProtestCommandDTO): Promise<void> {
async reviewProtest(command: ReviewProtestCommandDTO): Promise<CommandResultPresenter> {
this.logger.debug('[RaceService] Reviewing protest:', command);
const result = await this.reviewProtestUseCase.execute(command);
const presenter = new CommandResultPresenter();
if (result.isErr()) {
throw new Error('Failed to review protest');
const error = result.unwrapErr();
presenter.presentFailure(error.code ?? 'FAILED_TO_REVIEW_PROTEST', 'Failed to review protest');
return presenter;
}
}
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;
presenter.presentSuccess();
return presenter;
}
}