resolve todos in website

This commit is contained in:
2025-12-20 12:22:48 +01:00
parent a87cf27fb9
commit 20588e1c0b
39 changed files with 1238 additions and 359 deletions

View File

@@ -1,5 +1,5 @@
import { Controller, Get, Post, Body, HttpCode, HttpStatus, Param, Query } from '@nestjs/common'; import { Body, Controller, Get, HttpCode, HttpStatus, Param, Post, Query } from '@nestjs/common';
import { ApiTags, ApiResponse, ApiOperation, ApiParam, ApiQuery } from '@nestjs/swagger'; import { ApiOperation, ApiParam, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger';
import { RaceService } from './RaceService'; import { RaceService } from './RaceService';
import { AllRacesPageDTO } from './dtos/AllRacesPageDTO'; import { AllRacesPageDTO } from './dtos/AllRacesPageDTO';
import { RaceStatsDTO } from './dtos/RaceStatsDTO'; import { RaceStatsDTO } from './dtos/RaceStatsDTO';
@@ -137,6 +137,15 @@ export class RaceController {
return this.raceService.completeRace({ raceId }); return this.raceService.completeRace({ raceId });
} }
@Post(':raceId/reopen')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Re-open race' })
@ApiParam({ name: 'raceId', description: 'Race ID' })
@ApiResponse({ status: 200, description: 'Successfully re-opened race' })
async reopenRace(@Param('raceId') raceId: string): Promise<void> {
return this.raceService.reopenRace({ raceId });
}
@Post(':raceId/import-results') @Post(':raceId/import-results')
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Import race results' }) @ApiOperation({ summary: 'Import race results' })
@@ -149,7 +158,6 @@ export class RaceController {
return this.raceService.importRaceResults({ raceId, ...body }); return this.raceService.importRaceResults({ raceId, ...body });
} }
@Post('protests/file') @Post('protests/file')
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'File a protest' }) @ApiOperation({ summary: 'File a protest' })

View File

@@ -1,19 +1,19 @@
import { Provider } from '@nestjs/common'; import type { Provider } from '@nestjs/common';
import { RaceService } from './RaceService'; import { RaceService } from './RaceService';
// Import core interfaces // Import core interfaces
import type { Logger } from '@core/shared/application/Logger'; import type { Logger } from '@core/shared/application/Logger';
import { IRaceRepository } from '@core/racing/domain/repositories/IRaceRepository'; import type { IRaceRepository } from '@core/racing/domain/repositories/IRaceRepository';
import { ILeagueRepository } from '@core/racing/domain/repositories/ILeagueRepository'; import type { ILeagueRepository } from '@core/racing/domain/repositories/ILeagueRepository';
import { IDriverRepository } from '@core/racing/domain/repositories/IDriverRepository'; import type { IDriverRepository } from '@core/racing/domain/repositories/IDriverRepository';
import { IStandingRepository } from '@core/racing/domain/repositories/IStandingRepository'; import type { IStandingRepository } from '@core/racing/domain/repositories/IStandingRepository';
import { IRaceRegistrationRepository } from '@core/racing/domain/repositories/IRaceRegistrationRepository'; import type { IRaceRegistrationRepository } from '@core/racing/domain/repositories/IRaceRegistrationRepository';
import { IResultRepository } from '@core/racing/domain/repositories/IResultRepository'; import type { IResultRepository } from '@core/racing/domain/repositories/IResultRepository';
import { ILeagueMembershipRepository } from '@core/racing/domain/repositories/ILeagueMembershipRepository'; import type { ILeagueMembershipRepository } from '@core/racing/domain/repositories/ILeagueMembershipRepository';
import { IPenaltyRepository } from '@core/racing/domain/repositories/IPenaltyRepository'; import type { IPenaltyRepository } from '@core/racing/domain/repositories/IPenaltyRepository';
import { IProtestRepository } from '@core/racing/domain/repositories/IProtestRepository'; import type { IProtestRepository } from '@core/racing/domain/repositories/IProtestRepository';
import { DriverRatingProvider } from '@core/racing/application/ports/DriverRatingProvider'; import type { 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 concrete in-memory implementations // Import concrete in-memory implementations
import { InMemoryRaceRepository } from '@adapters/racing/persistence/inmemory/InMemoryRaceRepository'; import { InMemoryRaceRepository } from '@adapters/racing/persistence/inmemory/InMemoryRaceRepository';
@@ -50,6 +50,7 @@ import { QuickPenaltyUseCase } from '@core/racing/application/use-cases/QuickPen
import { ApplyPenaltyUseCase } from '@core/racing/application/use-cases/ApplyPenaltyUseCase'; import { ApplyPenaltyUseCase } from '@core/racing/application/use-cases/ApplyPenaltyUseCase';
import { RequestProtestDefenseUseCase } from '@core/racing/application/use-cases/RequestProtestDefenseUseCase'; import { RequestProtestDefenseUseCase } from '@core/racing/application/use-cases/RequestProtestDefenseUseCase';
import { ReviewProtestUseCase } from '@core/racing/application/use-cases/ReviewProtestUseCase'; import { ReviewProtestUseCase } from '@core/racing/application/use-cases/ReviewProtestUseCase';
import { ReopenRaceUseCase } from '@core/racing/application/use-cases/ReopenRaceUseCase';
// Define injection tokens // Define injection tokens
export const RACE_REPOSITORY_TOKEN = 'IRaceRepository'; export const RACE_REPOSITORY_TOKEN = 'IRaceRepository';
@@ -129,7 +130,8 @@ export const RaceProviders: Provider[] = [
// Use cases // Use cases
{ {
provide: GetAllRacesUseCase, provide: GetAllRacesUseCase,
useFactory: (raceRepo: IRaceRepository, leagueRepo: ILeagueRepository, logger: Logger) => new GetAllRacesUseCase(raceRepo, leagueRepo, logger), useFactory: (raceRepo: IRaceRepository, leagueRepo: ILeagueRepository, logger: Logger) =>
new GetAllRacesUseCase(raceRepo, leagueRepo, logger),
inject: [RACE_REPOSITORY_TOKEN, LEAGUE_REPOSITORY_TOKEN, LOGGER_TOKEN], inject: [RACE_REPOSITORY_TOKEN, LEAGUE_REPOSITORY_TOKEN, LOGGER_TOKEN],
}, },
{ {
@@ -146,14 +148,15 @@ export const RaceProviders: Provider[] = [
raceRegRepo: IRaceRegistrationRepository, raceRegRepo: IRaceRegistrationRepository,
resultRepo: IResultRepository, resultRepo: IResultRepository,
leagueMembershipRepo: ILeagueMembershipRepository, leagueMembershipRepo: ILeagueMembershipRepository,
) => new GetRaceDetailUseCase( ) =>
raceRepo, new GetRaceDetailUseCase(
leagueRepo, raceRepo,
driverRepo, leagueRepo,
raceRegRepo, driverRepo,
resultRepo, raceRegRepo,
leagueMembershipRepo, resultRepo,
), leagueMembershipRepo,
),
inject: [ inject: [
RACE_REPOSITORY_TOKEN, RACE_REPOSITORY_TOKEN,
LEAGUE_REPOSITORY_TOKEN, LEAGUE_REPOSITORY_TOKEN,
@@ -165,12 +168,14 @@ export const RaceProviders: Provider[] = [
}, },
{ {
provide: GetRacesPageDataUseCase, provide: GetRacesPageDataUseCase,
useFactory: (raceRepo: IRaceRepository, leagueRepo: ILeagueRepository) => new GetRacesPageDataUseCase(raceRepo, leagueRepo), useFactory: (raceRepo: IRaceRepository, leagueRepo: ILeagueRepository) =>
new GetRacesPageDataUseCase(raceRepo, leagueRepo),
inject: [RACE_REPOSITORY_TOKEN, LEAGUE_REPOSITORY_TOKEN], inject: [RACE_REPOSITORY_TOKEN, LEAGUE_REPOSITORY_TOKEN],
}, },
{ {
provide: GetAllRacesPageDataUseCase, provide: GetAllRacesPageDataUseCase,
useFactory: (raceRepo: IRaceRepository, leagueRepo: ILeagueRepository) => new GetAllRacesPageDataUseCase(raceRepo, leagueRepo), useFactory: (raceRepo: IRaceRepository, leagueRepo: ILeagueRepository) =>
new GetAllRacesPageDataUseCase(raceRepo, leagueRepo),
inject: [RACE_REPOSITORY_TOKEN, LEAGUE_REPOSITORY_TOKEN], inject: [RACE_REPOSITORY_TOKEN, LEAGUE_REPOSITORY_TOKEN],
}, },
{ {
@@ -207,18 +212,23 @@ export const RaceProviders: Provider[] = [
}, },
{ {
provide: GetRaceProtestsUseCase, provide: GetRaceProtestsUseCase,
useFactory: (protestRepo: IProtestRepository, driverRepo: IDriverRepository) => new GetRaceProtestsUseCase(protestRepo, driverRepo), useFactory: (protestRepo: IProtestRepository, driverRepo: IDriverRepository) =>
new GetRaceProtestsUseCase(protestRepo, driverRepo),
inject: [PROTEST_REPOSITORY_TOKEN, DRIVER_REPOSITORY_TOKEN], inject: [PROTEST_REPOSITORY_TOKEN, DRIVER_REPOSITORY_TOKEN],
}, },
{ {
provide: GetRacePenaltiesUseCase, provide: GetRacePenaltiesUseCase,
useFactory: (penaltyRepo: IPenaltyRepository, driverRepo: IDriverRepository) => new GetRacePenaltiesUseCase(penaltyRepo, driverRepo), useFactory: (penaltyRepo: IPenaltyRepository, driverRepo: IDriverRepository) =>
new GetRacePenaltiesUseCase(penaltyRepo, driverRepo),
inject: [PENALTY_REPOSITORY_TOKEN, DRIVER_REPOSITORY_TOKEN], inject: [PENALTY_REPOSITORY_TOKEN, DRIVER_REPOSITORY_TOKEN],
}, },
{ {
provide: RegisterForRaceUseCase, provide: RegisterForRaceUseCase,
useFactory: (raceRegRepo: IRaceRegistrationRepository, leagueMembershipRepo: ILeagueMembershipRepository, logger: Logger) => useFactory: (
new RegisterForRaceUseCase(raceRegRepo, leagueMembershipRepo, logger), raceRegRepo: IRaceRegistrationRepository,
leagueMembershipRepo: ILeagueMembershipRepository,
logger: Logger,
) => new RegisterForRaceUseCase(raceRegRepo, leagueMembershipRepo, logger),
inject: [RACE_REGISTRATION_REPOSITORY_TOKEN, LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN, LOGGER_TOKEN], inject: [RACE_REGISTRATION_REPOSITORY_TOKEN, LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN, LOGGER_TOKEN],
}, },
{ {
@@ -248,6 +258,11 @@ export const RaceProviders: Provider[] = [
DRIVER_RATING_PROVIDER_TOKEN, DRIVER_RATING_PROVIDER_TOKEN,
], ],
}, },
{
provide: ReopenRaceUseCase,
useFactory: (raceRepo: IRaceRepository, logger: Logger) => new ReopenRaceUseCase(raceRepo, logger),
inject: [RACE_REPOSITORY_TOKEN, LOGGER_TOKEN],
},
{ {
provide: ImportRaceResultsApiUseCase, provide: ImportRaceResultsApiUseCase,
useFactory: ( useFactory: (
@@ -257,14 +272,7 @@ export const RaceProviders: Provider[] = [
driverRepo: IDriverRepository, driverRepo: IDriverRepository,
standingRepo: IStandingRepository, standingRepo: IStandingRepository,
logger: Logger, logger: Logger,
) => new ImportRaceResultsApiUseCase( ) => new ImportRaceResultsApiUseCase(raceRepo, leagueRepo, resultRepo, driverRepo, standingRepo, logger),
raceRepo,
leagueRepo,
resultRepo,
driverRepo,
standingRepo,
logger,
),
inject: [ inject: [
RACE_REPOSITORY_TOKEN, RACE_REPOSITORY_TOKEN,
LEAGUE_REPOSITORY_TOKEN, LEAGUE_REPOSITORY_TOKEN,
@@ -283,14 +291,7 @@ export const RaceProviders: Provider[] = [
driverRepo: IDriverRepository, driverRepo: IDriverRepository,
standingRepo: IStandingRepository, standingRepo: IStandingRepository,
logger: Logger, logger: Logger,
) => new ImportRaceResultsUseCase( ) => new ImportRaceResultsUseCase(raceRepo, leagueRepo, resultRepo, driverRepo, standingRepo, logger),
raceRepo,
leagueRepo,
resultRepo,
driverRepo,
standingRepo,
logger,
),
inject: [ inject: [
RACE_REPOSITORY_TOKEN, RACE_REPOSITORY_TOKEN,
LEAGUE_REPOSITORY_TOKEN, LEAGUE_REPOSITORY_TOKEN,
@@ -302,32 +303,61 @@ export const RaceProviders: Provider[] = [
}, },
{ {
provide: FileProtestUseCase, provide: FileProtestUseCase,
useFactory: (protestRepo: IProtestRepository, raceRepo: IRaceRepository, leagueMembershipRepo: ILeagueMembershipRepository) => useFactory: (
new FileProtestUseCase(protestRepo, raceRepo, leagueMembershipRepo), protestRepo: IProtestRepository,
raceRepo: IRaceRepository,
leagueMembershipRepo: ILeagueMembershipRepository,
) => new FileProtestUseCase(protestRepo, raceRepo, leagueMembershipRepo),
inject: [PROTEST_REPOSITORY_TOKEN, RACE_REPOSITORY_TOKEN, LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN], inject: [PROTEST_REPOSITORY_TOKEN, RACE_REPOSITORY_TOKEN, LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN],
}, },
{ {
provide: QuickPenaltyUseCase, provide: QuickPenaltyUseCase,
useFactory: (penaltyRepo: IPenaltyRepository, raceRepo: IRaceRepository, leagueMembershipRepo: ILeagueMembershipRepository, logger: Logger) => useFactory: (
new QuickPenaltyUseCase(penaltyRepo, raceRepo, leagueMembershipRepo, logger), penaltyRepo: IPenaltyRepository,
inject: [PENALTY_REPOSITORY_TOKEN, RACE_REPOSITORY_TOKEN, LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN, LOGGER_TOKEN], raceRepo: IRaceRepository,
leagueMembershipRepo: ILeagueMembershipRepository,
logger: Logger,
) => new QuickPenaltyUseCase(penaltyRepo, raceRepo, leagueMembershipRepo, logger),
inject: [
PENALTY_REPOSITORY_TOKEN,
RACE_REPOSITORY_TOKEN,
LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN,
LOGGER_TOKEN,
],
}, },
{ {
provide: ApplyPenaltyUseCase, provide: ApplyPenaltyUseCase,
useFactory: (penaltyRepo: IPenaltyRepository, protestRepo: IProtestRepository, raceRepo: IRaceRepository, leagueMembershipRepo: ILeagueMembershipRepository, logger: Logger) => useFactory: (
new ApplyPenaltyUseCase(penaltyRepo, protestRepo, raceRepo, leagueMembershipRepo, logger), penaltyRepo: IPenaltyRepository,
inject: [PENALTY_REPOSITORY_TOKEN, PROTEST_REPOSITORY_TOKEN, RACE_REPOSITORY_TOKEN, LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN, LOGGER_TOKEN], protestRepo: IProtestRepository,
raceRepo: IRaceRepository,
leagueMembershipRepo: ILeagueMembershipRepository,
logger: Logger,
) => new ApplyPenaltyUseCase(penaltyRepo, protestRepo, raceRepo, leagueMembershipRepo, logger),
inject: [
PENALTY_REPOSITORY_TOKEN,
PROTEST_REPOSITORY_TOKEN,
RACE_REPOSITORY_TOKEN,
LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN,
LOGGER_TOKEN,
],
}, },
{ {
provide: RequestProtestDefenseUseCase, provide: RequestProtestDefenseUseCase,
useFactory: (protestRepo: IProtestRepository, raceRepo: IRaceRepository, leagueMembershipRepo: ILeagueMembershipRepository) => useFactory: (
new RequestProtestDefenseUseCase(protestRepo, raceRepo, leagueMembershipRepo), protestRepo: IProtestRepository,
raceRepo: IRaceRepository,
leagueMembershipRepo: ILeagueMembershipRepository,
) => new RequestProtestDefenseUseCase(protestRepo, raceRepo, leagueMembershipRepo),
inject: [PROTEST_REPOSITORY_TOKEN, RACE_REPOSITORY_TOKEN, LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN], inject: [PROTEST_REPOSITORY_TOKEN, RACE_REPOSITORY_TOKEN, LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN],
}, },
{ {
provide: ReviewProtestUseCase, provide: ReviewProtestUseCase,
useFactory: (protestRepo: IProtestRepository, raceRepo: IRaceRepository, leagueMembershipRepo: ILeagueMembershipRepository) => useFactory: (
new ReviewProtestUseCase(protestRepo, raceRepo, leagueMembershipRepo), protestRepo: IProtestRepository,
raceRepo: IRaceRepository,
leagueMembershipRepo: ILeagueMembershipRepository,
) => new ReviewProtestUseCase(protestRepo, raceRepo, leagueMembershipRepo),
inject: [PROTEST_REPOSITORY_TOKEN, RACE_REPOSITORY_TOKEN, LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN], inject: [PROTEST_REPOSITORY_TOKEN, RACE_REPOSITORY_TOKEN, LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN],
}, },
]; ];

View File

@@ -1,4 +1,4 @@
import { Injectable, Inject } from '@nestjs/common'; import { ConflictException, Inject, Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common';
import type { AllRacesPageViewModel } from '@core/racing/application/presenters/IGetAllRacesPresenter'; import type { AllRacesPageViewModel } from '@core/racing/application/presenters/IGetAllRacesPresenter';
import type { RaceDetailOutputPort } from '@core/racing/application/ports/output/RaceDetailOutputPort'; import type { RaceDetailOutputPort } from '@core/racing/application/ports/output/RaceDetailOutputPort';
import type { RacesPageOutputPort } from '@core/racing/application/ports/output/RacesPageOutputPort'; import type { RacesPageOutputPort } from '@core/racing/application/ports/output/RacesPageOutputPort';
@@ -47,6 +47,7 @@ import { QuickPenaltyUseCase } from '@core/racing/application/use-cases/QuickPen
import { ApplyPenaltyUseCase } from '@core/racing/application/use-cases/ApplyPenaltyUseCase'; import { ApplyPenaltyUseCase } from '@core/racing/application/use-cases/ApplyPenaltyUseCase';
import { RequestProtestDefenseUseCase } from '@core/racing/application/use-cases/RequestProtestDefenseUseCase'; import { RequestProtestDefenseUseCase } from '@core/racing/application/use-cases/RequestProtestDefenseUseCase';
import { ReviewProtestUseCase } from '@core/racing/application/use-cases/ReviewProtestUseCase'; import { ReviewProtestUseCase } from '@core/racing/application/use-cases/ReviewProtestUseCase';
import { ReopenRaceUseCase } from '@core/racing/application/use-cases/ReopenRaceUseCase';
// Presenters // Presenters
import { GetAllRacesPresenter } from './presenters/GetAllRacesPresenter'; import { GetAllRacesPresenter } from './presenters/GetAllRacesPresenter';
@@ -61,7 +62,7 @@ import { RequestProtestDefenseCommandDTO } from './dtos/RequestProtestDefenseCom
import { ReviewProtestCommandDTO } from './dtos/ReviewProtestCommandDTO'; import { ReviewProtestCommandDTO } from './dtos/ReviewProtestCommandDTO';
// Tokens // Tokens
import { LOGGER_TOKEN, DRIVER_RATING_PROVIDER_TOKEN, IMAGE_SERVICE_TOKEN, LEAGUE_REPOSITORY_TOKEN } from './RaceProviders'; import { DRIVER_RATING_PROVIDER_TOKEN, IMAGE_SERVICE_TOKEN, LEAGUE_REPOSITORY_TOKEN, LOGGER_TOKEN } from './RaceProviders';
@Injectable() @Injectable()
export class RaceService { export class RaceService {
@@ -85,6 +86,7 @@ export class RaceService {
private readonly applyPenaltyUseCase: ApplyPenaltyUseCase, private readonly applyPenaltyUseCase: ApplyPenaltyUseCase,
private readonly requestProtestDefenseUseCase: RequestProtestDefenseUseCase, private readonly requestProtestDefenseUseCase: RequestProtestDefenseUseCase,
private readonly reviewProtestUseCase: ReviewProtestUseCase, private readonly reviewProtestUseCase: ReviewProtestUseCase,
private readonly reopenRaceUseCase: ReopenRaceUseCase,
@Inject(LEAGUE_REPOSITORY_TOKEN) private readonly leagueRepository: ILeagueRepository, @Inject(LEAGUE_REPOSITORY_TOKEN) private readonly leagueRepository: ILeagueRepository,
@Inject(LOGGER_TOKEN) private readonly logger: Logger, @Inject(LOGGER_TOKEN) private readonly logger: Logger,
@Inject(DRIVER_RATING_PROVIDER_TOKEN) private readonly driverRatingProvider: DriverRatingProvider, @Inject(DRIVER_RATING_PROVIDER_TOKEN) private readonly driverRatingProvider: DriverRatingProvider,
@@ -130,60 +132,68 @@ export class RaceService {
throw new Error('Failed to get race detail'); throw new Error('Failed to get race detail');
} }
const outputPort = result.value; const outputPort = result.value as RaceDetailOutputPort;
// Map to DTO // Map to DTO
const raceDTO = outputPort.race ? { const raceDTO = outputPort.race
id: outputPort.race.id, ? {
leagueId: outputPort.race.leagueId, id: outputPort.race.id,
track: outputPort.race.track, leagueId: outputPort.race.leagueId,
car: outputPort.race.car, track: outputPort.race.track,
scheduledAt: outputPort.race.scheduledAt.toISOString(), car: outputPort.race.car,
sessionType: outputPort.race.sessionType, scheduledAt: outputPort.race.scheduledAt.toISOString(),
status: outputPort.race.status, sessionType: outputPort.race.sessionType,
strengthOfField: outputPort.race.strengthOfField ?? null, status: outputPort.race.status,
registeredCount: outputPort.race.registeredCount ?? undefined, strengthOfField: outputPort.race.strengthOfField ?? null,
maxParticipants: outputPort.race.maxParticipants ?? undefined, registeredCount: outputPort.race.registeredCount ?? undefined,
} : null; maxParticipants: outputPort.race.maxParticipants ?? undefined,
}
: null;
const leagueDTO = outputPort.league ? { const leagueDTO = outputPort.league
id: outputPort.league.id.toString(), ? {
name: outputPort.league.name.toString(), id: outputPort.league.id.toString(),
description: outputPort.league.description.toString(), name: outputPort.league.name.toString(),
settings: { description: outputPort.league.description.toString(),
maxDrivers: outputPort.league.settings.maxDrivers ?? undefined, settings: {
qualifyingFormat: outputPort.league.settings.qualifyingFormat ?? undefined, maxDrivers: outputPort.league.settings.maxDrivers ?? undefined,
}, qualifyingFormat: outputPort.league.settings.qualifyingFormat ?? undefined,
} : null; },
}
: null;
const entryListDTO = await Promise.all(outputPort.drivers.map(async driver => { const entryListDTO = await Promise.all(
const ratingResult = await this.driverRatingProvider.getDriverRating({ driverId: driver.id }); outputPort.drivers.map(async driver => {
const avatarResult = await this.imageService.getDriverAvatar({ driverId: driver.id }); const ratingResult = await this.driverRatingProvider.getDriverRating({ driverId: driver.id });
return { const avatarResult = await this.imageService.getDriverAvatar({ driverId: driver.id });
id: driver.id, return {
name: driver.name.toString(), id: driver.id,
country: driver.country.toString(), name: driver.name.toString(),
avatarUrl: avatarResult.avatarUrl, country: driver.country.toString(),
rating: ratingResult.rating, avatarUrl: avatarResult.avatarUrl,
isCurrentUser: driver.id === params.driverId, rating: ratingResult.rating,
}; isCurrentUser: driver.id === params.driverId,
})); };
}),
);
const registrationDTO = { const registrationDTO = {
isUserRegistered: outputPort.isUserRegistered, isUserRegistered: outputPort.isUserRegistered,
canRegister: outputPort.canRegister, canRegister: outputPort.canRegister,
}; };
const userResultDTO = outputPort.userResult ? { const userResultDTO = outputPort.userResult
position: outputPort.userResult.position.toNumber(), ? {
startPosition: outputPort.userResult.startPosition.toNumber(), position: outputPort.userResult.position.toNumber(),
incidents: outputPort.userResult.incidents.toNumber(), startPosition: outputPort.userResult.startPosition.toNumber(),
fastestLap: outputPort.userResult.fastestLap.toNumber(), incidents: outputPort.userResult.incidents.toNumber(),
positionChange: outputPort.userResult.getPositionChange(), fastestLap: outputPort.userResult.fastestLap.toNumber(),
isPodium: outputPort.userResult.isPodium(), positionChange: outputPort.userResult.getPositionChange(),
isClean: outputPort.userResult.isClean(), isPodium: outputPort.userResult.isPodium(),
ratingChange: this.calculateRatingChange(outputPort.userResult.position.toNumber()), isClean: outputPort.userResult.isClean(),
} : null; ratingChange: this.calculateRatingChange(outputPort.userResult.position.toNumber()),
}
: null;
return { return {
race: raceDTO, race: raceDTO,
@@ -203,7 +213,7 @@ export class RaceService {
throw new Error('Failed to get races page data'); throw new Error('Failed to get races page data');
} }
const outputPort = result.value; const outputPort = result.value as RacesPageOutputPort;
// Fetch leagues for league names // Fetch leagues for league names
const allLeagues = await this.leagueRepository.findAll(); const allLeagues = await this.leagueRepository.findAll();
@@ -250,32 +260,34 @@ export class RaceService {
throw new Error('Failed to get race results detail'); throw new Error('Failed to get race results detail');
} }
const outputPort = result.value; const outputPort = result.value as RaceResultsDetailOutputPort;
// Create a map of driverId to driver for easy lookup // Create a map of driverId to driver for easy lookup
const driverMap = new Map(outputPort.drivers.map(driver => [driver.id, driver])); const driverMap = new Map(outputPort.drivers.map(driver => [driver.id, driver]));
const resultsDTO = await Promise.all(outputPort.results.map(async (result) => { const resultsDTO = await Promise.all(
const driver = driverMap.get(result.driverId.toString()); outputPort.results.map(async singleResult => {
if (!driver) { const driver = driverMap.get(singleResult.driverId.toString());
throw new Error(`Driver not found for result: ${result.driverId}`); if (!driver) {
} throw new Error(`Driver not found for result: ${singleResult.driverId}`);
}
const avatarResult = await this.imageService.getDriverAvatar({ driverId: driver.id }); const avatarResult = await this.imageService.getDriverAvatar({ driverId: driver.id });
return { return {
driverId: result.driverId.toString(), driverId: singleResult.driverId.toString(),
driverName: driver.name.toString(), driverName: driver.name.toString(),
avatarUrl: avatarResult.avatarUrl, avatarUrl: avatarResult.avatarUrl,
position: result.position.toNumber(), position: singleResult.position.toNumber(),
startPosition: result.startPosition.toNumber(), startPosition: singleResult.startPosition.toNumber(),
incidents: result.incidents.toNumber(), incidents: singleResult.incidents.toNumber(),
fastestLap: result.fastestLap.toNumber(), fastestLap: singleResult.fastestLap.toNumber(),
positionChange: result.getPositionChange(), positionChange: singleResult.getPositionChange(),
isPodium: result.isPodium(), isPodium: singleResult.isPodium(),
isClean: result.isClean(), isClean: singleResult.isClean(),
}; };
})); }),
);
return { return {
raceId: outputPort.race.id, raceId: outputPort.race.id,
@@ -293,7 +305,7 @@ export class RaceService {
throw new Error('Failed to get race with SOF'); throw new Error('Failed to get race with SOF');
} }
const outputPort = result.value; const outputPort = result.value as RaceWithSOFOutputPort;
// Map to DTO // Map to DTO
return { return {
@@ -312,7 +324,7 @@ export class RaceService {
throw new Error('Failed to get race protests'); throw new Error('Failed to get race protests');
} }
const outputPort = result.value; const outputPort = result.value as RaceProtestsOutputPort;
const protestsDTO = outputPort.protests.map(protest => ({ const protestsDTO = outputPort.protests.map(protest => ({
id: protest.id, id: protest.id,
@@ -346,7 +358,7 @@ export class RaceService {
throw new Error('Failed to get race penalties'); throw new Error('Failed to get race penalties');
} }
const outputPort = result.value; const outputPort = result.value as RacePenaltiesOutputPort;
const penaltiesDTO = outputPort.penalties.map(penalty => ({ const penaltiesDTO = outputPort.penalties.map(penalty => ({
id: penalty.id, id: penalty.id,
@@ -410,7 +422,30 @@ export class RaceService {
} }
} }
async reopenRace(params: RaceActionParamsDTO): Promise<void> {
this.logger.debug('[RaceService] Re-opening race:', params);
const result = await this.reopenRaceUseCase.execute({ raceId: params.raceId });
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;
}
throw new InternalServerErrorException(errorCode ?? 'UNEXPECTED_ERROR');
}
}
async fileProtest(command: FileProtestCommandDTO): Promise<void> { async fileProtest(command: FileProtestCommandDTO): Promise<void> {
this.logger.debug('[RaceService] Filing protest:', command); this.logger.debug('[RaceService] Filing protest:', command);

View File

@@ -2,45 +2,44 @@ import { Provider } from '@nestjs/common';
import { SponsorService } from './SponsorService'; import { SponsorService } from './SponsorService';
// Import core interfaces // Import core interfaces
import { ISponsorRepository } from '@core/racing/domain/repositories/ISponsorRepository'; import { NotificationService } from '@core/notifications/application/ports/NotificationService';
import { ISeasonSponsorshipRepository } from '@core/racing/domain/repositories/ISeasonSponsorshipRepository'; import type { IPaymentRepository } from '@core/payments/domain/repositories/IPaymentRepository';
import { ISeasonRepository } from '@core/racing/domain/repositories/ISeasonRepository'; import { IWalletRepository } from '@core/payments/domain/repositories/IWalletRepository';
import { ILeagueRepository } from '@core/racing/domain/repositories/ILeagueRepository'; import { IPaymentGateway } from '@core/racing/application/ports/IPaymentGateway';
import { ILeagueMembershipRepository } from '@core/racing/domain/repositories/ILeagueMembershipRepository'; import { ILeagueMembershipRepository } from '@core/racing/domain/repositories/ILeagueMembershipRepository';
import { ILeagueRepository } from '@core/racing/domain/repositories/ILeagueRepository';
import { ILeagueWalletRepository } from '@core/racing/domain/repositories/ILeagueWalletRepository';
import { IRaceRepository } from '@core/racing/domain/repositories/IRaceRepository'; import { IRaceRepository } from '@core/racing/domain/repositories/IRaceRepository';
import { ISeasonRepository } from '@core/racing/domain/repositories/ISeasonRepository';
import { ISeasonSponsorshipRepository } from '@core/racing/domain/repositories/ISeasonSponsorshipRepository';
import { ISponsorRepository } from '@core/racing/domain/repositories/ISponsorRepository';
import { ISponsorshipPricingRepository } from '@core/racing/domain/repositories/ISponsorshipPricingRepository'; import { ISponsorshipPricingRepository } from '@core/racing/domain/repositories/ISponsorshipPricingRepository';
import { ISponsorshipRequestRepository } from '@core/racing/domain/repositories/ISponsorshipRequestRepository'; import { ISponsorshipRequestRepository } from '@core/racing/domain/repositories/ISponsorshipRequestRepository';
import { INotificationService } from '@core/notifications/application/ports/INotificationService';
import { IPaymentGateway } from '@core/racing/application/ports/IPaymentGateway';
import { IWalletRepository } from '@core/payments/domain/repositories/IWalletRepository';
import type { IPaymentRepository } from '@core/payments/domain/repositories/IPaymentRepository';
import { ILeagueWalletRepository } from '@core/racing/domain/repositories/ILeagueWalletRepository';
import type { Logger } from '@core/shared/application'; import type { Logger } from '@core/shared/application';
// Import use cases / application services // Import use cases / application services
import { GetSponsorshipPricingUseCase } from '@core/racing/application/use-cases/GetSponsorshipPricingUseCase';
import { GetSponsorsUseCase } from '@core/racing/application/use-cases/GetSponsorsUseCase';
import { CreateSponsorUseCase } from '@core/racing/application/use-cases/CreateSponsorUseCase';
import { GetSponsorDashboardUseCase } from '@core/racing/application/use-cases/GetSponsorDashboardUseCase';
import { GetSponsorSponsorshipsUseCase } from '@core/racing/application/use-cases/GetSponsorSponsorshipsUseCase';
import { GetEntitySponsorshipPricingUseCase } from '@core/racing/application/use-cases/GetEntitySponsorshipPricingUseCase';
import { GetSponsorUseCase } from '@core/racing/application/use-cases/GetSponsorUseCase';
import { GetPendingSponsorshipRequestsUseCase } from '@core/racing/application/use-cases/GetPendingSponsorshipRequestsUseCase';
import { AcceptSponsorshipRequestUseCase } from '@core/racing/application/use-cases/AcceptSponsorshipRequestUseCase';
import { RejectSponsorshipRequestUseCase } from '@core/racing/application/use-cases/RejectSponsorshipRequestUseCase';
import { SponsorBillingService } from '@core/payments/application/services/SponsorBillingService'; import { SponsorBillingService } from '@core/payments/application/services/SponsorBillingService';
import { AcceptSponsorshipRequestUseCase } from '@core/racing/application/use-cases/AcceptSponsorshipRequestUseCase';
import { CreateSponsorUseCase } from '@core/racing/application/use-cases/CreateSponsorUseCase';
import { GetEntitySponsorshipPricingUseCase } from '@core/racing/application/use-cases/GetEntitySponsorshipPricingUseCase';
import { GetPendingSponsorshipRequestsUseCase } from '@core/racing/application/use-cases/GetPendingSponsorshipRequestsUseCase';
import { GetSponsorDashboardUseCase } from '@core/racing/application/use-cases/GetSponsorDashboardUseCase';
import { GetSponsorshipPricingUseCase } from '@core/racing/application/use-cases/GetSponsorshipPricingUseCase';
import { GetSponsorSponsorshipsUseCase } from '@core/racing/application/use-cases/GetSponsorSponsorshipsUseCase';
import { GetSponsorsUseCase } from '@core/racing/application/use-cases/GetSponsorsUseCase';
import { GetSponsorUseCase } from '@core/racing/application/use-cases/GetSponsorUseCase';
import { RejectSponsorshipRequestUseCase } from '@core/racing/application/use-cases/RejectSponsorshipRequestUseCase';
// Import concrete in-memory implementations // Import concrete in-memory implementations
import { InMemorySponsorRepository } from '@adapters/racing/persistence/inmemory/InMemorySponsorRepository'; import { ConsoleLogger } from '@adapters/logging/ConsoleLogger';
import { InMemorySeasonSponsorshipRepository } from '@adapters/racing/persistence/inmemory/InMemorySeasonSponsorshipRepository';
import { InMemorySeasonRepository } from '@adapters/racing/persistence/inmemory/InMemorySeasonRepository';
import { InMemoryLeagueRepository } from '@adapters/racing/persistence/inmemory/InMemoryLeagueRepository';
import { InMemoryLeagueMembershipRepository } from '@adapters/racing/persistence/inmemory/InMemoryLeagueMembershipRepository'; import { InMemoryLeagueMembershipRepository } from '@adapters/racing/persistence/inmemory/InMemoryLeagueMembershipRepository';
import { InMemoryLeagueRepository } from '@adapters/racing/persistence/inmemory/InMemoryLeagueRepository';
import { InMemoryRaceRepository } from '@adapters/racing/persistence/inmemory/InMemoryRaceRepository'; import { InMemoryRaceRepository } from '@adapters/racing/persistence/inmemory/InMemoryRaceRepository';
import { InMemorySeasonRepository } from '@adapters/racing/persistence/inmemory/InMemorySeasonRepository';
import { InMemorySeasonSponsorshipRepository } from '@adapters/racing/persistence/inmemory/InMemorySeasonSponsorshipRepository';
import { InMemorySponsorRepository } from '@adapters/racing/persistence/inmemory/InMemorySponsorRepository';
import { InMemorySponsorshipPricingRepository } from '@adapters/racing/persistence/inmemory/InMemorySponsorshipPricingRepository'; import { InMemorySponsorshipPricingRepository } from '@adapters/racing/persistence/inmemory/InMemorySponsorshipPricingRepository';
import { InMemorySponsorshipRequestRepository } from '@adapters/racing/persistence/inmemory/InMemorySponsorshipRequestRepository'; import { InMemorySponsorshipRequestRepository } from '@adapters/racing/persistence/inmemory/InMemorySponsorshipRequestRepository';
import { InMemoryPaymentRepository } from '@adapters/payments/persistence/inmemory/InMemoryPaymentRepository';
import { ConsoleLogger } from '@adapters/logging/ConsoleLogger';
// Define injection tokens // Define injection tokens
export const SPONSOR_REPOSITORY_TOKEN = 'ISponsorRepository'; export const SPONSOR_REPOSITORY_TOKEN = 'ISponsorRepository';
@@ -179,7 +178,7 @@ export const SponsorProviders: Provider[] = [
}, },
{ {
provide: ACCEPT_SPONSORSHIP_REQUEST_USE_CASE_TOKEN, provide: ACCEPT_SPONSORSHIP_REQUEST_USE_CASE_TOKEN,
useFactory: (sponsorshipRequestRepo: ISponsorshipRequestRepository, seasonSponsorshipRepo: ISeasonSponsorshipRepository, seasonRepo: ISeasonRepository, notificationService: INotificationService, paymentGateway: IPaymentGateway, walletRepository: IWalletRepository, leagueWalletRepository: ILeagueWalletRepository, logger: Logger) => useFactory: (sponsorshipRequestRepo: ISponsorshipRequestRepository, seasonSponsorshipRepo: ISeasonSponsorshipRepository, seasonRepo: ISeasonRepository, notificationService: NotificationService, paymentGateway: IPaymentGateway, walletRepository: IWalletRepository, leagueWalletRepository: ILeagueWalletRepository, logger: Logger) =>
new AcceptSponsorshipRequestUseCase(sponsorshipRequestRepo, seasonSponsorshipRepo, seasonRepo, notificationService, paymentGateway, walletRepository, leagueWalletRepository, logger), new AcceptSponsorshipRequestUseCase(sponsorshipRequestRepo, seasonSponsorshipRepo, seasonRepo, notificationService, paymentGateway, walletRepository, leagueWalletRepository, logger),
inject: [SPONSORSHIP_REQUEST_REPOSITORY_TOKEN, SEASON_SPONSORSHIP_REPOSITORY_TOKEN, SEASON_REPOSITORY_TOKEN, 'INotificationService', 'IPaymentGateway', 'IWalletRepository', 'ILeagueWalletRepository', LOGGER_TOKEN], inject: [SPONSORSHIP_REQUEST_REPOSITORY_TOKEN, SEASON_SPONSORSHIP_REPOSITORY_TOKEN, SEASON_REPOSITORY_TOKEN, 'INotificationService', 'IPaymentGateway', 'IWalletRepository', 'ILeagueWalletRepository', LOGGER_TOKEN],
}, },

View File

@@ -45,14 +45,14 @@ import { DriverProfileViewModel } from '@/lib/view-models/DriverProfileViewModel
type ProfileTab = 'overview' | 'stats'; type ProfileTab = 'overview' | 'stats';
interface TeamLeagueSummary {
id: string;
name: string;
}
interface Team { interface Team {
id: string; id: string;
name: string; name: string;
tag: string;
description: string;
ownerId: string;
leagues: unknown[]; // TODO: define proper type
createdAt: Date;
} }
interface SocialHandle { interface SocialHandle {
@@ -317,11 +317,6 @@ export default function DriverDetailPage() {
team: { team: {
id: team.id, id: team.id,
name: team.name, name: team.name,
tag: '', // Not available in summary
description: '', // Not available in summary
ownerId: '', // Not available in summary
leagues: [], // TODO: populate if needed
createdAt: new Date(), // TODO: add to API
} as Team, } as Team,
role: membership.role, role: membership.role,
joinedAt: new Date(membership.joinedAt), joinedAt: new Date(membership.joinedAt),

View File

@@ -180,11 +180,8 @@ export default function ProtestReviewPage() {
type: 'protest_filed', type: 'protest_filed',
timestamp: new Date(protest.submittedAt), timestamp: new Date(protest.submittedAt),
actor: protestingDriver, actor: protestingDriver,
content: protest.description, // TODO: Add incident description when available content: protest.description,
metadata: { metadata: {}
// lap: protest.incident?.lap,
// comment: protest.comment
}
} }
]; ];
@@ -242,7 +239,8 @@ export default function ProtestReviewPage() {
currentDriverId, currentDriverId,
protest.id protest.id
); );
penaltyCommand.reason = 'Protest upheld'; // TODO: Make this configurable
penaltyCommand.reason = stewardNotes || 'Protest dismissed';
await protestService.applyPenalty(penaltyCommand); await protestService.applyPenalty(penaltyCommand);
} }
@@ -406,16 +404,16 @@ export default function ProtestReviewPage() {
<Calendar className="w-4 h-4 text-gray-500" /> <Calendar className="w-4 h-4 text-gray-500" />
<span className="text-gray-300">{race.formattedDate}</span> <span className="text-gray-300">{race.formattedDate}</span>
</div> </div>
{/* TODO: Add lap info when available */} {protest.incident?.lap && (
{/* <div className="flex items-center gap-2 text-sm"> <div className="flex items-center gap-2 text-sm">
<Flag className="w-4 h-4 text-gray-500" /> <Flag className="w-4 h-4 text-gray-500" />
<span className="text-gray-300">Lap {protest.incident.lap}</span> <span className="text-gray-300">Lap {protest.incident.lap}</span>
</div> */} </div>
)}
</div> </div>
</Card> </Card>
{/* TODO: Add evidence when available */} {protest.proofVideoUrl && (
{/* {protest.proofVideoUrl && (
<Card className="p-4"> <Card className="p-4">
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Evidence</h3> <h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Evidence</h3>
<a <a
@@ -429,7 +427,7 @@ export default function ProtestReviewPage() {
<ExternalLink className="w-3 h-3" /> <ExternalLink className="w-3 h-3" />
</a> </a>
</Card> </Card>
)} */} )}
{/* Quick Stats */} {/* Quick Stats */}
<Card className="p-4"> <Card className="p-4">
@@ -479,13 +477,12 @@ export default function ProtestReviewPage() {
<div className="bg-deep-graphite rounded-lg p-4 border border-charcoal-outline"> <div className="bg-deep-graphite rounded-lg p-4 border border-charcoal-outline">
<p className="text-sm text-gray-300 mb-3">{protest.description}</p> <p className="text-sm text-gray-300 mb-3">{protest.description}</p>
{/* TODO: Add comment when available */} {protest.comment && (
{/* {protest.comment && (
<div className="mt-3 pt-3 border-t border-charcoal-outline/50"> <div className="mt-3 pt-3 border-t border-charcoal-outline/50">
<p className="text-xs text-gray-500 mb-1">Additional details:</p> <p className="text-xs text-gray-500 mb-1">Additional details:</p>
<p className="text-sm text-gray-400">{protest.comment}</p> <p className="text-sm text-gray-400">{protest.comment}</p>
</div> </div>
)} */} )}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,169 @@
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import RaceDetailPage from './page';
import { RaceDetailViewModel } from '@/lib/view-models/RaceDetailViewModel';
// Mocks for Next.js navigation
const mockPush = vi.fn();
const mockBack = vi.fn();
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: mockPush,
back: mockBack,
}),
useParams: () => ({ id: 'race-123' }),
}));
// Mock effective driver id hook
vi.mock('@/hooks/useEffectiveDriverId', () => ({
useEffectiveDriverId: () => 'driver-1',
}));
// Mock sponsor mode hook to avoid rendering heavy sponsor card
vi.mock('@/components/sponsors/SponsorInsightsCard', () => ({
__esModule: true,
default: () => <div data-testid="sponsor-insights-mock" />,
MetricBuilders: {
views: vi.fn(() => ({ label: 'Views', value: '100' })),
engagement: vi.fn(() => ({ label: 'Engagement', value: '50%' })),
reach: vi.fn(() => ({ label: 'Reach', value: '1000' })),
},
SlotTemplates: {
race: vi.fn(() => []),
},
useSponsorMode: () => false,
}));
// Mock services hook to provide raceService and leagueMembershipService
const mockGetRaceDetail = vi.fn();
const mockReopenRace = vi.fn();
const mockFetchLeagueMemberships = vi.fn();
const mockGetMembership = vi.fn();
vi.mock('@/lib/services/ServiceProvider', () => ({
useServices: () => ({
raceService: {
getRaceDetail: mockGetRaceDetail,
reopenRace: mockReopenRace,
// other methods are not used in this test
},
leagueMembershipService: {
fetchLeagueMemberships: mockFetchLeagueMemberships,
getMembership: mockGetMembership,
},
}),
}));
// Mock league membership utility to control admin vs non-admin behavior
const mockIsOwnerOrAdmin = vi.fn();
vi.mock('@/lib/utilities/LeagueMembershipUtility', () => ({
LeagueMembershipUtility: {
isOwnerOrAdmin: (...args: unknown[]) => mockIsOwnerOrAdmin(...args),
},
}));
const createViewModel = (status: string) => {
return new RaceDetailViewModel({
race: {
id: 'race-123',
track: 'Test Track',
car: 'Test Car',
scheduledAt: '2023-12-31T20:00:00Z',
status,
sessionType: 'race',
strengthOfField: null,
registeredCount: 0,
maxParticipants: 32,
} as any,
league: {
id: 'league-1',
name: 'Test League',
description: 'Test league description',
settings: {
maxDrivers: 32,
qualifyingFormat: 'open',
},
} as any,
entryList: [],
registration: {
isRegistered: false,
canRegister: false,
} as any,
userResult: null,
});
};
describe('RaceDetailPage - Re-open Race behavior', () => {
beforeEach(() => {
mockGetRaceDetail.mockReset();
mockReopenRace.mockReset();
mockFetchLeagueMemberships.mockReset();
mockGetMembership.mockReset();
mockIsOwnerOrAdmin.mockReset();
mockFetchLeagueMemberships.mockResolvedValue(undefined);
mockGetMembership.mockReturnValue(null);
});
it('shows Re-open Race button for admin when race is completed and calls reopen + reload on confirm', async () => {
mockIsOwnerOrAdmin.mockReturnValue(true);
const viewModel = createViewModel('completed');
// First call: initial load, second call: after re-open
mockGetRaceDetail.mockResolvedValue(viewModel);
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
render(<RaceDetailPage />);
const reopenButton = await screen.findByText('Re-open Race');
expect(reopenButton).toBeInTheDocument();
mockReopenRace.mockResolvedValue(undefined);
fireEvent.click(reopenButton);
await waitFor(() => {
expect(mockReopenRace).toHaveBeenCalledWith('race-123');
});
// loadRaceData should be called again after reopening
await waitFor(() => {
expect(mockGetRaceDetail).toHaveBeenCalled();
});
confirmSpy.mockRestore();
});
it('does not render Re-open Race button for non-admin viewer', async () => {
mockIsOwnerOrAdmin.mockReturnValue(false);
const viewModel = createViewModel('completed');
mockGetRaceDetail.mockResolvedValue(viewModel);
render(<RaceDetailPage />);
await waitFor(() => {
expect(mockGetRaceDetail).toHaveBeenCalled();
});
expect(screen.queryByText('Re-open Race')).toBeNull();
});
it('does not render Re-open Race button when race is not completed or cancelled even for admin', async () => {
mockIsOwnerOrAdmin.mockReturnValue(true);
const viewModel = createViewModel('scheduled');
mockGetRaceDetail.mockResolvedValue(viewModel);
render(<RaceDetailPage />);
await waitFor(() => {
expect(mockGetRaceDetail).toHaveBeenCalled();
});
expect(screen.queryByText('Re-open Race')).toBeNull();
});
});

View File

@@ -44,6 +44,7 @@ export default function RaceDetailPage() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [cancelling, setCancelling] = useState(false); const [cancelling, setCancelling] = useState(false);
const [registering, setRegistering] = useState(false); const [registering, setRegistering] = useState(false);
const [reopening, setReopening] = useState(false);
const [ratingChange, setRatingChange] = useState<number | null>(null); const [ratingChange, setRatingChange] = useState<number | null>(null);
const [animatedRatingChange, setAnimatedRatingChange] = useState(0); const [animatedRatingChange, setAnimatedRatingChange] = useState(0);
const [showProtestModal, setShowProtestModal] = useState(false); const [showProtestModal, setShowProtestModal] = useState(false);
@@ -174,6 +175,27 @@ export default function RaceDetailPage() {
} }
}; };
const handleReopenRace = async () => {
const race = viewModel?.race;
if (!race || !viewModel?.canReopenRace) return;
const confirmed = window.confirm(
'Re-open this race? This will allow re-registration and re-running. Results will be archived.',
);
if (!confirmed) return;
setReopening(true);
try {
await raceService.reopenRace(race.id);
await loadRaceData();
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to re-open race');
} finally {
setReopening(false);
}
};
const formatDate = (date: Date) => { const formatDate = (date: Date) => {
return new Date(date).toLocaleDateString('en-US', { return new Date(date).toLocaleDateString('en-US', {
weekday: 'long', weekday: 'long',
@@ -856,6 +878,19 @@ export default function RaceDetailPage() {
</> </>
)} )}
{viewModel.canReopenRace &&
LeagueMembershipUtility.isOwnerOrAdmin(viewModel.league?.id || '', currentDriverId) && (
<Button
variant="outline"
className="w-full flex items-center justify-center gap-2"
onClick={handleReopenRace}
disabled={reopening}
>
<PlayCircle className="w-4 h-4" />
{reopening ? 'Re-opening...' : 'Re-open Race'}
</Button>
)}
{race.status === 'completed' && ( {race.status === 'completed' && (
<> <>
<Button <Button
@@ -884,29 +919,22 @@ export default function RaceDetailPage() {
<Scale className="w-4 h-4" /> <Scale className="w-4 h-4" />
Stewarding Stewarding
</Button> </Button>
{LeagueMembershipUtility.isOwnerOrAdmin(viewModel.league?.id || '', currentDriverId) && (
<>
<Button
variant="outline"
className="w-full flex items-center justify-center gap-2"
onClick={async () => {
const confirmed = window.confirm(
'Re-open this race? This will allow re-registration and re-running. Results will be archived.'
);
if (!confirmed) return;
// TODO: Implement re-open race functionality
alert('Re-open race functionality not yet implemented');
}}
>
<PlayCircle className="w-4 h-4" />
Re-open Race
</Button>
</>
)}
</> </>
)} )}
{viewModel.canReopenRace &&
LeagueMembershipUtility.isOwnerOrAdmin(viewModel.league?.id || '', currentDriverId) && (
<Button
variant="outline"
className="w-full flex items-center justify-center gap-2"
onClick={handleReopenRace}
disabled={reopening}
>
<PlayCircle className="w-4 h-4" />
{reopening ? 'Re-opening...' : 'Re-open Race'}
</Button>
)}
{race.status === 'running' && LeagueMembershipUtility.isOwnerOrAdmin(viewModel.league?.id || '', currentDriverId) && ( {race.status === 'running' && LeagueMembershipUtility.isOwnerOrAdmin(viewModel.league?.id || '', currentDriverId) && (
<Button <Button
variant="primary" variant="primary"

View File

@@ -8,6 +8,7 @@ import Button from '@/components/ui/Button';
import Card from '@/components/ui/Card'; import Card from '@/components/ui/Card';
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId'; import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
import { useServices } from '@/lib/services/ServiceProvider'; import { useServices } from '@/lib/services/ServiceProvider';
import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility';
import type { RaceResultsDetailViewModel } from '@/lib/view-models'; import type { RaceResultsDetailViewModel } from '@/lib/view-models';
import { ArrowLeft, Calendar, Trophy, Users, Zap } from 'lucide-react'; import { ArrowLeft, Calendar, Trophy, Users, Zap } from 'lucide-react';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
@@ -18,7 +19,7 @@ export default function RaceResultsPage() {
const params = useParams(); const params = useParams();
const raceId = params.id as string; const raceId = params.id as string;
const currentDriverId = useEffectiveDriverId(); const currentDriverId = useEffectiveDriverId();
const { raceResultsService } = useServices(); const { raceResultsService, leagueMembershipService } = useServices();
const [raceData, setRaceData] = useState<RaceResultsDetailViewModel | null>(null); const [raceData, setRaceData] = useState<RaceResultsDetailViewModel | null>(null);
const [raceSOF, setRaceSOF] = useState<number | null>(null); const [raceSOF, setRaceSOF] = useState<number | null>(null);
@@ -56,14 +57,16 @@ export default function RaceResultsPage() {
}, [raceId]); }, [raceId]);
useEffect(() => { useEffect(() => {
if (raceData?.league?.id && currentDriverId) { const leagueId = raceData?.league?.id;
if (leagueId && currentDriverId) {
const checkAdmin = async () => { const checkAdmin = async () => {
// For now, assume admin check - this might need to be updated based on API await leagueMembershipService.fetchLeagueMemberships(leagueId);
setIsAdmin(true); // TODO: Implement proper admin check via API const membership = leagueMembershipService.getMembership(leagueId, currentDriverId);
setIsAdmin(membership ? LeagueRoleUtility.isLeagueAdminOrHigherRole(membership.role) : false);
}; };
checkAdmin(); checkAdmin();
} }
}, [raceData?.league?.id, currentDriverId]); }, [raceData?.league?.id, currentDriverId, leagueMembershipService]);
const handleImportSuccess = async (importedResults: any[]) => { const handleImportSuccess = async (importedResults: any[]) => {
setImporting(true); setImporting(true);

View File

@@ -6,6 +6,7 @@ import Card from '@/components/ui/Card';
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId'; import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
import { useServices } from '@/lib/services/ServiceProvider'; import { useServices } from '@/lib/services/ServiceProvider';
import { RaceStewardingViewModel } from '@/lib/view-models/RaceStewardingViewModel'; import { RaceStewardingViewModel } from '@/lib/view-models/RaceStewardingViewModel';
import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility';
import { import {
AlertCircle, AlertCircle,
AlertTriangle, AlertTriangle,
@@ -24,7 +25,7 @@ import { useEffect, useState } from 'react';
export default function RaceStewardingPage() { export default function RaceStewardingPage() {
const params = useParams(); const params = useParams();
const router = useRouter(); const router = useRouter();
const { raceStewardingService } = useServices(); const { raceStewardingService, leagueMembershipService } = useServices();
const raceId = params.id as string; const raceId = params.id as string;
const currentDriverId = useEffectiveDriverId(); const currentDriverId = useEffectiveDriverId();
@@ -40,9 +41,11 @@ export default function RaceStewardingPage() {
const data = await raceStewardingService.getRaceStewardingData(raceId, currentDriverId); const data = await raceStewardingService.getRaceStewardingData(raceId, currentDriverId);
setStewardingData(data); setStewardingData(data);
if (data.league) { if (data.league?.id) {
// TODO: Implement admin check via API const membership = await leagueMembershipService.getMembership(data.league.id, currentDriverId);
setIsAdmin(true); setIsAdmin(membership ? LeagueRoleUtility.isLeagueAdminOrHigherRole(membership.role) : false);
} else {
setIsAdmin(false);
} }
} catch (err) { } catch (err) {
console.error('Failed to load data:', err); console.error('Failed to load data:', err);
@@ -52,7 +55,7 @@ export default function RaceStewardingPage() {
} }
loadData(); loadData();
}, [raceId, currentDriverId, raceStewardingService]); }, [raceId, currentDriverId, raceStewardingService, leagueMembershipService]);
const pendingProtests = stewardingData?.pendingProtests ?? []; const pendingProtests = stewardingData?.pendingProtests ?? [];
const resolvedProtests = stewardingData?.resolvedProtests ?? []; const resolvedProtests = stewardingData?.resolvedProtests ?? [];

View File

@@ -157,11 +157,12 @@ export default function TeamDetailPage() {
const visibleTabs = tabs.filter(tab => tab.visible); const visibleTabs = tabs.filter(tab => tab.visible);
// Build sponsor insights for team // Build sponsor insights for team using real membership and league data
const leagueCount = team.leagues?.length ?? 0;
const teamMetrics = [ const teamMetrics = [
MetricBuilders.members(memberships.length), MetricBuilders.members(memberships.length),
MetricBuilders.reach(memberships.length * 15), MetricBuilders.reach(memberships.length * 15),
MetricBuilders.races(0), // TODO: Get league count from team data MetricBuilders.races(leagueCount),
MetricBuilders.engagement(82), MetricBuilders.engagement(82),
]; ];
@@ -206,15 +207,27 @@ export default function TeamDetailPage() {
<div> <div>
<div className="flex items-center gap-3 mb-2"> <div className="flex items-center gap-3 mb-2">
<h1 className="text-3xl font-bold text-white">{team.name}</h1> <h1 className="text-3xl font-bold text-white">{team.name}</h1>
{/* TODO: Add team tag when available */} {team.tag && (
<span className="px-2 py-0.5 rounded-full text-xs bg-charcoal-outline text-gray-300">
[{team.tag}]
</span>
)}
</div> </div>
<p className="text-gray-300 mb-4 max-w-2xl">{team.description}</p> <p className="text-gray-300 mb-4 max-w-2xl">{team.description}</p>
<div className="flex items-center gap-4 text-sm text-gray-400"> <div className="flex items-center gap-4 text-sm text-gray-400">
<span>{memberships.length} {memberships.length === 1 ? 'member' : 'members'}</span> <span>{memberships.length} {memberships.length === 1 ? 'member' : 'members'}</span>
{/* TODO: Add created date when available */} {team.createdAt && (
{/* TODO: Add league count when available */} <span>
Founded {new Date(team.createdAt).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })}
</span>
)}
{leagueCount > 0 && (
<span>
Active in {leagueCount} {leagueCount === 1 ? 'league' : 'leagues'}
</span>
)}
</div> </div>
</div> </div>
</div> </div>
@@ -259,8 +272,19 @@ export default function TeamDetailPage() {
<h3 className="text-xl font-semibold text-white mb-4">Quick Stats</h3> <h3 className="text-xl font-semibold text-white mb-4">Quick Stats</h3>
<div className="space-y-3"> <div className="space-y-3">
<StatItem label="Members" value={memberships.length.toString()} color="text-primary-blue" /> <StatItem label="Members" value={memberships.length.toString()} color="text-primary-blue" />
<StatItem label="Leagues" value="0" color="text-green-400" /> {/* TODO: Get league count */} {leagueCount > 0 && (
<StatItem label="Founded" value="Unknown" color="text-gray-300" /> {/* TODO: Get founded date */} <StatItem label="Leagues" value={leagueCount.toString()} color="text-green-400" />
)}
{team.createdAt && (
<StatItem
label="Founded"
value={new Date(team.createdAt).toLocaleDateString('en-US', {
month: 'short',
year: 'numeric',
})}
color="text-gray-300"
/>
)}
</div> </div>
</Card> </Card>
</div> </div>
@@ -285,7 +309,7 @@ export default function TeamDetailPage() {
)} )}
{activeTab === 'standings' && ( {activeTab === 'standings' && (
<TeamStandings teamId={teamId} leagues={[]} /> <TeamStandings teamId={teamId} leagues={team.leagues} />
)} )}
{activeTab === 'admin' && isAdmin && ( {activeTab === 'admin' && isAdmin && (

View File

@@ -451,9 +451,29 @@ export default function TeamsPage() {
const { teamService } = useServices(); const { teamService } = useServices();
const teams = await teamService.getAllTeams(); const teams = await teamService.getAllTeams();
setRealTeams(teams); setRealTeams(teams);
// TODO: set groups and top teams from service or compute locally
setGroupsBySkillLevel({}); // Derive groups by skill level from the loaded teams
setTopTeams([]); const byLevel: Record<SkillLevel, TeamDisplayData[]> = {
beginner: [],
intermediate: [],
advanced: [],
pro: [],
};
teams.forEach((team) => {
const level = (team.performanceLevel as SkillLevel) || 'intermediate';
if (byLevel[level]) {
byLevel[level].push(team as TeamDisplayData);
}
});
setGroupsBySkillLevel(byLevel);
// Select top teams by rating for the preview section
const sortedByRating = [...teams].sort((a, b) => {
const aRating = typeof a.rating === 'number' && Number.isFinite(a.rating) ? a.rating : 0;
const bRating = typeof b.rating === 'number' && Number.isFinite(b.rating) ? b.rating : 0;
return bRating - aRating;
});
setTopTeams(sortedByRating.slice(0, 5));
} catch (error) { } catch (error) {
console.error('Failed to load teams:', error); console.error('Failed to load teams:', error);
} finally { } finally {

View File

@@ -1,6 +1,6 @@
import Link from 'next/link'; import Link from 'next/link';
import Image from 'next/image'; import Image from 'next/image';
import type { DriverDTO } from '@core/racing/application/dto/DriverDTO'; import type { DriverDTO } from '@/lib/types/generated/DriverDTO';
export interface DriverIdentityProps { export interface DriverIdentityProps {
driver: DriverDTO; driver: DriverDTO;

View File

@@ -0,0 +1,41 @@
import React from 'react';
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import LeagueHeader from './LeagueHeader';
describe('LeagueHeader', () => {
it('renders league name, description and sponsor', () => {
render(
<LeagueHeader
leagueId="league-1"
leagueName="Test League"
description="A fun test league"
ownerId="owner-1"
ownerName="Owner Name"
mainSponsor={{
name: 'Test Sponsor',
websiteUrl: 'https://example.com',
}}
/>
);
expect(screen.getByText('Test League')).toBeInTheDocument();
expect(screen.getByText('A fun test league')).toBeInTheDocument();
expect(screen.getByText('by')).toBeInTheDocument();
expect(screen.getByText('Test Sponsor')).toBeInTheDocument();
});
it('renders without description or sponsor', () => {
render(
<LeagueHeader
leagueId="league-2"
leagueName="League Without Details"
ownerId="owner-2"
ownerName="Owner 2"
/>
);
expect(screen.getByText('League Without Details')).toBeInTheDocument();
});
});

View File

@@ -2,12 +2,7 @@
import MembershipStatus from '@/components/leagues/MembershipStatus'; import MembershipStatus from '@/components/leagues/MembershipStatus';
import Image from 'next/image'; import Image from 'next/image';
import { useEffect, useState } from 'react';
import type { DriverDTO } from '@core/racing/application/dto/DriverDTO';
import { EntityMappers } from '@core/racing/application/mappers/EntityMappers';
// TODO EntityMapper is legacy. Must use ´useServices` hook.
// Main sponsor info for "by XYZ" display // Main sponsor info for "by XYZ" display
interface MainSponsorInfo { interface MainSponsorInfo {
@@ -35,30 +30,6 @@ export default function LeagueHeader({
const imageService = getImageService(); const imageService = getImageService();
const logoUrl = imageService.getLeagueLogo(leagueId); const logoUrl = imageService.getLeagueLogo(leagueId);
const [ownerDriver, setOwnerDriver] = useState<DriverDTO | null>(null);
useEffect(() => {
let isMounted = true;
async function loadOwner() {
try {
const driverRepo = getDriverRepository();
const entity = await driverRepo.findById(ownerId);
if (!entity || !isMounted) return;
setOwnerDriver(EntityMappers.toDriverDTO(entity));
} catch (err) {
console.error('Failed to load league owner for header:', err);
}
}
loadOwner();
return () => {
isMounted = false;
};
}, [ownerId]);
return ( return (
<div className="mb-8"> <div className="mb-8">
{/* League header with logo - no cover image */} {/* League header with logo - no cover image */}

View File

@@ -0,0 +1,129 @@
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import LeagueMembers from './LeagueMembers';
import type { DriverDTO } from '@/lib/types/generated/DriverDTO';
// Stub global driver stats helper used by LeagueMembers sorting/rendering
(globalThis as any).getDriverStats = (driverId: string) => ({
driverId,
rating: driverId === 'driver-1' ? 2500 : 2000,
overallRank: driverId === 'driver-1' ? 1 : 2,
wins: driverId === 'driver-1' ? 10 : 5,
});
// Mock effective driver id so we can assert the "(You)" label
vi.mock('@/hooks/useEffectiveDriverId', () => {
return {
useEffectiveDriverId: () => 'driver-1',
};
});
// Mock services hook to inject stub leagueMembershipService and driverService
const mockFetchLeagueMemberships = vi.fn<[], Promise<any[]>>();
const mockGetLeagueMembers = vi.fn<(leagueId: string) => any[]>();
const mockFindByIds = vi.fn<(ids: string[]) => Promise<DriverDTO[]>>();
vi.mock('@/lib/services/ServiceProvider', () => {
return {
useServices: () => ({
leagueMembershipService: {
fetchLeagueMemberships: mockFetchLeagueMemberships,
getLeagueMembers: mockGetLeagueMembers,
},
driverService: {
findByIds: mockFindByIds,
},
}),
};
});
describe('LeagueMembers', () => {
beforeEach(() => {
mockFetchLeagueMemberships.mockReset();
mockGetLeagueMembers.mockReset();
mockFindByIds.mockReset();
});
it('loads memberships via services and renders driver rows', async () => {
const leagueId = 'league-1';
const memberships = [
{
id: 'm1',
leagueId,
driverId: 'driver-1',
role: 'owner',
status: 'active',
joinedAt: '2024-01-01T00:00:00.000Z',
},
{
id: 'm2',
leagueId,
driverId: 'driver-2',
role: 'member',
status: 'active',
joinedAt: '2024-01-02T00:00:00.000Z',
},
];
const drivers: DriverDTO[] = [
{
id: 'driver-1',
iracingId: 'ir-1',
name: 'Driver One',
country: 'DE',
},
{
id: 'driver-2',
iracingId: 'ir-2',
name: 'Driver Two',
country: 'US',
},
];
mockFetchLeagueMemberships.mockResolvedValue(memberships);
mockGetLeagueMembers.mockReturnValue(memberships);
mockFindByIds.mockResolvedValue(drivers);
render(<LeagueMembers leagueId={leagueId} showActions={false} />);
// Loading state first
expect(screen.getByText('Loading members...')).toBeInTheDocument();
// Wait for data to be rendered
await waitFor(() => {
expect(screen.queryByText('Loading members...')).not.toBeInTheDocument();
});
// Services should have been called with expected arguments
expect(mockFetchLeagueMemberships).toHaveBeenCalledWith(leagueId);
expect(mockGetLeagueMembers).toHaveBeenCalledWith(leagueId);
expect(mockFindByIds).toHaveBeenCalledTimes(1);
expect(mockFindByIds).toHaveBeenCalledWith(['driver-1', 'driver-2']);
// Driver rows should be rendered using DTO names
expect(screen.getByText('Driver One')).toBeInTheDocument();
expect(screen.getByText('Driver Two')).toBeInTheDocument();
// Current user marker should appear for effective driver id
expect(screen.getByText('(You)')).toBeInTheDocument();
});
it('handles empty membership list gracefully', async () => {
const leagueId = 'league-empty';
mockFetchLeagueMemberships.mockResolvedValue([]);
mockGetLeagueMembers.mockReturnValue([]);
mockFindByIds.mockResolvedValue([]);
render(<LeagueMembers leagueId={leagueId} showActions={false} />);
await waitFor(() => {
expect(screen.queryByText('Loading members...')).not.toBeInTheDocument();
});
expect(screen.getByText('No members found')).toBeInTheDocument();
});
});

View File

@@ -1,17 +1,14 @@
'use client'; 'use client';
import DriverIdentity from '@/components/drivers/DriverIdentity'; import DriverIdentity from '../drivers/DriverIdentity';
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId'; import { useEffectiveDriverId } from '../../hooks/useEffectiveDriverId';
import { import { useServices } from '../../lib/services/ServiceProvider';
getLeagueMembers, import type { LeagueMembership } from '@core/racing/domain/entities/LeagueMembership';
type LeagueMembership, import type { MembershipRole } from '@core/racing/domain/entities/MembershipRole';
type MembershipRole, import type { DriverDTO } from '@/lib/types/generated/DriverDTO';
} from '@/lib/leagueMembership';
import type { DriverDTO } from '@core/racing/application/dto/DriverDTO';
import { EntityMappers } from '@core/racing/application/mappers/EntityMappers';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
// TODO EntityMapper is legacy. Must use ´useServices` hook. // Migrated to useServices-based website services; legacy EntityMapper removed.
interface LeagueMembersProps { interface LeagueMembersProps {
leagueId: string; leagueId: string;
@@ -31,32 +28,33 @@ export default function LeagueMembers({
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [sortBy, setSortBy] = useState<'role' | 'name' | 'date' | 'rating' | 'points' | 'wins'>('rating'); const [sortBy, setSortBy] = useState<'role' | 'name' | 'date' | 'rating' | 'points' | 'wins'>('rating');
const currentDriverId = useEffectiveDriverId(); const currentDriverId = useEffectiveDriverId();
const { leagueMembershipService, driverService } = useServices();
const loadMembers = useCallback(async () => { const loadMembers = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
const membershipData = getLeagueMembers(leagueId); await leagueMembershipService.fetchLeagueMemberships(leagueId);
const membershipData = leagueMembershipService.getLeagueMembers(leagueId);
setMembers(membershipData); setMembers(membershipData);
const driverRepo = getDriverRepository(); const uniqueDriverIds = Array.from(new Set(membershipData.map((m) => m.driverId)));
const driverEntities = await Promise.all( if (uniqueDriverIds.length > 0) {
membershipData.map((m) => driverRepo.findById(m.driverId)) const driverDtos = await driverService.findByIds(uniqueDriverIds);
);
const driverDtos = driverEntities
.map((driver) => (driver ? EntityMappers.toDriverDTO(driver) : null))
.filter((dto): dto is DriverDTO => dto !== null);
const byId: Record<string, DriverDTO> = {}; const byId: Record<string, DriverDTO> = {};
for (const dto of driverDtos) { for (const dto of driverDtos) {
byId[dto.id] = dto; byId[dto.id] = dto;
}
setDriversById(byId);
} else {
setDriversById({});
} }
setDriversById(byId);
} catch (error) { } catch (error) {
console.error('Failed to load members:', error); console.error('Failed to load members:', error);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [leagueId]); }, [leagueId, leagueMembershipService, driverService]);
useEffect(() => { useEffect(() => {
loadMembers(); loadMembers();

View File

@@ -2,7 +2,7 @@
import Image from 'next/image'; import Image from 'next/image';
import Link from 'next/link'; import Link from 'next/link';
import type { DriverDTO } from '@core/racing/application/dto/DriverDTO'; import type { DriverDTO } from '@/lib/types/generated/DriverDTO';
import DriverRating from '@/components/profile/DriverRatingPill'; import DriverRating from '@/components/profile/DriverRatingPill';
export interface DriverSummaryPillProps { export interface DriverSummaryPillProps {

View File

@@ -0,0 +1,120 @@
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import UserPill from './UserPill';
import type { DriverDTO } from '@/lib/types/generated/DriverDTO';
// Mock useAuth to control session state
vi.mock('@/lib/auth/AuthContext', () => {
return {
useAuth: () => mockedAuthValue,
};
});
// Mock effective driver id hook
vi.mock('@/hooks/useEffectiveDriverId', () => {
return {
useEffectiveDriverId: () => mockedDriverId,
};
});
// Mock services hook to inject stub driverService/mediaService
const mockFindById = vi.fn<[], Promise<DriverDTO | null>>();
const mockGetDriverAvatar = vi.fn<(driverId: string) => string>();
vi.mock('@/lib/services/ServiceProvider', () => {
return {
useServices: () => ({
driverService: {
findById: mockFindById,
},
mediaService: {
getDriverAvatar: mockGetDriverAvatar,
},
}),
};
});
interface MockSessionUser {
id: string;
}
interface MockSession {
user: MockSessionUser | null;
}
let mockedAuthValue: { session: MockSession | null } = { session: null };
let mockedDriverId: string | null = null;
// Provide global stats helpers used by UserPill's rating/rank computation
// They are UI-level helpers, so a minimal stub is sufficient for these tests.
(globalThis as any).getDriverStats = (driverId: string) => ({
driverId,
rating: 2000,
overallRank: 10,
wins: 5,
});
(globalThis as any).getAllDriverRankings = () => [
{ driverId: 'driver-1', rating: 2100 },
{ driverId: 'driver-2', rating: 2000 },
];
describe('UserPill', () => {
beforeEach(() => {
mockedAuthValue = { session: null };
mockedDriverId = null;
mockFindById.mockReset();
mockGetDriverAvatar.mockReset();
});
it('renders auth links when there is no session', () => {
mockedAuthValue = { session: null };
const { container } = render(<UserPill />);
expect(screen.getByText('Sign In')).toBeInTheDocument();
expect(screen.getByText('Get Started')).toBeInTheDocument();
expect(mockFindById).not.toHaveBeenCalled();
expect(container).toMatchSnapshot();
});
it('does not load driver when there is no primary driver id', async () => {
mockedAuthValue = { session: { user: { id: 'user-1' } } };
mockedDriverId = null;
const { container } = render(<UserPill />);
await waitFor(() => {
// component should render nothing in this state
expect(container.firstChild).toBeNull();
});
expect(mockFindById).not.toHaveBeenCalled();
});
it('loads driver via driverService and uses mediaService avatar', async () => {
const driver: DriverDTO = {
id: 'driver-1',
iracingId: 'ir-123',
name: 'Test Driver',
country: 'DE',
};
mockedAuthValue = { session: { user: { id: 'user-1' } } };
mockedDriverId = driver.id;
mockFindById.mockResolvedValue(driver);
mockGetDriverAvatar.mockImplementation((driverId: string) => `/api/media/avatar/${driverId}`);
render(<UserPill />);
await waitFor(() => {
expect(screen.getByText('Test Driver')).toBeInTheDocument();
});
expect(mockFindById).toHaveBeenCalledWith('driver-1');
expect(mockGetDriverAvatar).toHaveBeenCalledWith('driver-1');
});
});

View File

@@ -8,10 +8,8 @@ import { useEffect, useMemo, useState } from 'react';
import DriverSummaryPill from '@/components/profile/DriverSummaryPill'; import DriverSummaryPill from '@/components/profile/DriverSummaryPill';
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId'; import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
import type { DriverDTO } from '@core/racing/application/dto/DriverDTO'; import type { DriverDTO } from '@/lib/types/generated/DriverDTO';
import { EntityMappers } from '@core/racing/application/mappers/EntityMappers'; import { useServices } from '@/lib/services/ServiceProvider';
// TODO EntityMapper is legacy. Must use ´useServices` hook.
// Hook to detect sponsor mode // Hook to detect sponsor mode
function useSponsorMode(): boolean { function useSponsorMode(): boolean {
@@ -84,6 +82,7 @@ function SponsorSummaryPill({
export default function UserPill() { export default function UserPill() {
const { session } = useAuth(); const { session } = useAuth();
const { driverService, mediaService } = useServices();
const [driver, setDriver] = useState<DriverDTO | null>(null); const [driver, setDriver] = useState<DriverDTO | null>(null);
const [isMenuOpen, setIsMenuOpen] = useState(false); const [isMenuOpen, setIsMenuOpen] = useState(false);
const isSponsorMode = useSponsorMode(); const isSponsorMode = useSponsorMode();
@@ -103,19 +102,18 @@ export default function UserPill() {
return; return;
} }
const repo = getDriverRepository(); const dto = await driverService.findById(primaryDriverId);
const entity = await repo.findById(primaryDriverId);
if (!cancelled) { if (!cancelled) {
setDriver(EntityMappers.toDriverDTO(entity)); setDriver(dto);
} }
} }
loadDriver(); void loadDriver();
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [primaryDriverId]); }, [primaryDriverId, driverService]);
const data = useMemo(() => { const data = useMemo(() => {
if (!session?.user || !primaryDriverId || !driver) { if (!session?.user || !primaryDriverId || !driver) {
@@ -153,7 +151,7 @@ export default function UserPill() {
} }
} }
const avatarSrc = getImageService().getDriverAvatar(primaryDriverId); const avatarSrc = mediaService.getDriverAvatar(primaryDriverId);
return { return {
driver, driver,
@@ -161,7 +159,7 @@ export default function UserPill() {
rating, rating,
rank, rank,
}; };
}, [session, driver, primaryDriverId]); }, [session, driver, primaryDriverId, mediaService]);
// Close menu when clicking outside // Close menu when clicking outside
useEffect(() => { useEffect(() => {

View File

@@ -85,4 +85,9 @@ export class RacesApiClient extends BaseApiClient {
complete(raceId: string): Promise<void> { complete(raceId: string): Promise<void> {
return this.post<void>(`/races/${raceId}/complete`, {}); return this.post<void>(`/races/${raceId}/complete`, {});
} }
/** Re-open race */
reopen(raceId: string): Promise<void> {
return this.post<void>(`/races/${raceId}/reopen`, {});
}
} }

View File

@@ -14,7 +14,12 @@ describe('RaceService', () => {
getDetail: vi.fn(), getDetail: vi.fn(),
getPageData: vi.fn(), getPageData: vi.fn(),
getTotal: vi.fn(), getTotal: vi.fn(),
} as Mocked<RacesApiClient>; register: vi.fn(),
withdraw: vi.fn(),
cancel: vi.fn(),
complete: vi.fn(),
reopen: vi.fn(),
} as unknown as Mocked<RacesApiClient>;
service = new RaceService(mockApiClient); service = new RaceService(mockApiClient);
}); });
@@ -131,4 +136,22 @@ describe('RaceService', () => {
await expect(service.getRacesTotal()).rejects.toThrow('API call failed'); await expect(service.getRacesTotal()).rejects.toThrow('API call failed');
}); });
}); });
describe('reopenRace', () => {
it('should call apiClient.reopen with raceId', async () => {
const raceId = 'race-123';
await service.reopenRace(raceId);
expect(mockApiClient.reopen).toHaveBeenCalledWith(raceId);
});
it('should propagate errors from apiClient.reopen', async () => {
const raceId = 'race-123';
const error = new Error('API call failed');
mockApiClient.reopen.mockRejectedValue(error);
await expect(service.reopenRace(raceId)).rejects.toThrow('API call failed');
});
});
}); });

View File

@@ -78,6 +78,12 @@ export class RaceService {
await this.apiClient.complete(raceId); await this.apiClient.complete(raceId);
} }
/**
* Re-open a race
*/
async reopenRace(raceId: string): Promise<void> {
await this.apiClient.reopen(raceId);
}
/** /**
* Find races by league ID * Find races by league ID

View File

@@ -14,6 +14,9 @@ export class ProtestViewModel {
status: string; status: string;
reviewedAt?: string; reviewedAt?: string;
decisionNotes?: string; decisionNotes?: string;
incident?: { lap?: number } | null;
proofVideoUrl?: string | null;
comment?: string | null;
constructor(dto: ProtestDTO) { constructor(dto: ProtestDTO) {
this.id = dto.id; this.id = dto.id;

View File

@@ -252,6 +252,36 @@ describe('RaceDetailViewModel', () => {
expect(viewModel.registrationStatusMessage).toBe('Registration not available'); expect(viewModel.registrationStatusMessage).toBe('Registration not available');
}); });
it('should expose canReopenRace for completed and cancelled statuses', () => {
const completedVm = new RaceDetailViewModel({
race: createMockRace({ status: 'completed' }),
league: createMockLeague(),
entryList: [],
registration: createMockRegistration(),
userResult: null,
});
const cancelledVm = new RaceDetailViewModel({
race: createMockRace({ status: 'cancelled' as any }),
league: createMockLeague(),
entryList: [],
registration: createMockRegistration(),
userResult: null,
});
const upcomingVm = new RaceDetailViewModel({
race: createMockRace({ status: 'upcoming' }),
league: createMockLeague(),
entryList: [],
registration: createMockRegistration(),
userResult: null,
});
expect(completedVm.canReopenRace).toBe(true);
expect(cancelledVm.canReopenRace).toBe(true);
expect(upcomingVm.canReopenRace).toBe(false);
});
it('should handle error property', () => { it('should handle error property', () => {
const viewModel = new RaceDetailViewModel({ const viewModel = new RaceDetailViewModel({
race: createMockRace(), race: createMockRace(),

View File

@@ -70,4 +70,10 @@ export class RaceDetailViewModel {
if (this.canRegister) return 'You can register for this race'; if (this.canRegister) return 'You can register for this race';
return 'Registration not available'; return 'Registration not available';
} }
/** UI-specific: Whether race can be re-opened */
get canReopenRace(): boolean {
if (!this.race) return false;
return this.race.status === 'completed' || this.race.status === 'cancelled';
}
} }

View File

@@ -5,27 +5,24 @@
*/ */
// Use Cases // Use Cases
export * from './use-cases/SendNotificationUseCase';
export * from './use-cases/MarkNotificationReadUseCase';
export * from './use-cases/GetUnreadNotificationsUseCase'; export * from './use-cases/GetUnreadNotificationsUseCase';
export * from './use-cases/MarkNotificationReadUseCase';
export * from './use-cases/NotificationPreferencesUseCases'; export * from './use-cases/NotificationPreferencesUseCases';
export * from './use-cases/SendNotificationUseCase';
// Ports // Ports
export * from './ports/INotificationGateway'; export * from './ports/NotificationGateway';
// Re-export domain types for convenience // Re-export domain types for convenience
export type { export type {
Notification, Notification, NotificationAction, NotificationData, NotificationProps,
NotificationProps, NotificationStatus, NotificationUrgency
NotificationStatus,
NotificationData,
NotificationUrgency,
NotificationAction,
} from '../domain/entities/Notification'; } from '../domain/entities/Notification';
export type { NotificationPreference, NotificationPreferenceProps, ChannelPreference, TypePreference } from '../domain/entities/NotificationPreference'; export type { ChannelPreference, NotificationPreference, NotificationPreferenceProps, TypePreference } from '../domain/entities/NotificationPreference';
export type { NotificationType, NotificationChannel } from '../domain/types/NotificationTypes'; export { ALL_CHANNELS, DEFAULT_ENABLED_CHANNELS, getChannelDisplayName, getNotificationTypePriority, getNotificationTypeTitle, isExternalChannel } from '../domain/types/NotificationTypes';
export { getNotificationTypeTitle, getNotificationTypePriority, getChannelDisplayName, isExternalChannel, DEFAULT_ENABLED_CHANNELS, ALL_CHANNELS } from '../domain/types/NotificationTypes'; export type { NotificationChannel, NotificationType } from '../domain/types/NotificationTypes';
// Re-export repository interfaces // Re-export repository interfaces
export type { INotificationPreferenceRepository } from '../domain/repositories/INotificationPreferenceRepository';
export type { INotificationRepository } from '../domain/repositories/INotificationRepository'; export type { INotificationRepository } from '../domain/repositories/INotificationRepository';
export type { INotificationPreferenceRepository } from '../domain/repositories/INotificationPreferenceRepository';

View File

@@ -19,7 +19,7 @@ export interface NotificationDeliveryResult {
attemptedAt: Date; attemptedAt: Date;
} }
export interface INotificationGateway { export interface NotificationGateway {
/** /**
* Send a notification through this gateway's channel * Send a notification through this gateway's channel
*/ */
@@ -45,21 +45,21 @@ export interface INotificationGateway {
* Registry for notification gateways * Registry for notification gateways
* Allows routing notifications to the appropriate gateway based on channel * Allows routing notifications to the appropriate gateway based on channel
*/ */
export interface INotificationGatewayRegistry { export interface NotificationGatewayRegistry {
/** /**
* Register a gateway for a channel * Register a gateway for a channel
*/ */
register(gateway: INotificationGateway): void; register(gateway: NotificationGateway): void;
/** /**
* Get gateway for a specific channel * Get gateway for a specific channel
*/ */
getGateway(channel: NotificationChannel): INotificationGateway | null; getGateway(channel: NotificationChannel): NotificationGateway | null;
/** /**
* Get all registered gateways * Get all registered gateways
*/ */
getAllGateways(): INotificationGateway[]; getAllGateways(): NotificationGateway[];
/** /**
* Send notification through appropriate gateway * Send notification through appropriate gateway

View File

@@ -1,5 +1,4 @@
import type { NotificationType } from '../../domain/types/NotificationTypes'; import type { NotificationChannel, NotificationType } from '../../domain/types/NotificationTypes';
import type { NotificationChannel } from '../../domain/types/NotificationTypes';
export interface NotificationData { export interface NotificationData {
raceEventId?: string; raceEventId?: string;
@@ -36,6 +35,6 @@ export interface SendNotificationCommand {
requiresResponse?: boolean; requiresResponse?: boolean;
} }
export interface INotificationService { export interface NotificationService {
sendNotification(command: SendNotificationCommand): Promise<void>; sendNotification(command: SendNotificationCommand): Promise<void>;
} }

View File

@@ -5,15 +5,14 @@
* based on their preferences. * based on their preferences.
*/ */
import type { AsyncUseCase, Logger } from '@core/shared/application';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import type { AsyncUseCase } from '@core/shared/application';
import type { Logger } from '@core/shared/application';
import { Notification } from '../../domain/entities/Notification';
import type { NotificationData } from '../../domain/entities/Notification'; import type { NotificationData } from '../../domain/entities/Notification';
import type { INotificationRepository } from '../../domain/repositories/INotificationRepository'; import { Notification } from '../../domain/entities/Notification';
import type { INotificationPreferenceRepository } from '../../domain/repositories/INotificationPreferenceRepository'; import type { INotificationPreferenceRepository } from '../../domain/repositories/INotificationPreferenceRepository';
import type { INotificationGatewayRegistry, NotificationDeliveryResult } from '../ports/INotificationGateway'; import type { INotificationRepository } from '../../domain/repositories/INotificationRepository';
import type { NotificationType, NotificationChannel } from '../../domain/types/NotificationTypes'; import type { NotificationChannel, NotificationType } from '../../domain/types/NotificationTypes';
import type { NotificationDeliveryResult, NotificationGatewayRegistry } from '../ports/NotificationGateway';
export interface SendNotificationCommand { export interface SendNotificationCommand {
recipientId: string; recipientId: string;
@@ -48,7 +47,7 @@ export class SendNotificationUseCase implements AsyncUseCase<SendNotificationCom
constructor( constructor(
private readonly notificationRepository: INotificationRepository, private readonly notificationRepository: INotificationRepository,
private readonly preferenceRepository: INotificationPreferenceRepository, private readonly preferenceRepository: INotificationPreferenceRepository,
private readonly gatewayRegistry: INotificationGatewayRegistry, private readonly gatewayRegistry: NotificationGatewayRegistry,
private readonly logger: Logger, private readonly logger: Logger,
) { ) {
this.logger.debug('SendNotificationUseCase initialized.'); this.logger.debug('SendNotificationUseCase initialized.');

View File

@@ -1,16 +1,16 @@
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; import type { NotificationService } from '@/notifications/application/ports/NotificationService';
import { AcceptSponsorshipRequestUseCase } from './AcceptSponsorshipRequestUseCase';
import type { ISponsorshipRequestRepository } from '../../domain/repositories/ISponsorshipRequestRepository';
import type { ISeasonSponsorshipRepository } from '../../domain/repositories/ISeasonSponsorshipRepository';
import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
import type { INotificationService } from '@core/notifications/application/ports/INotificationService';
import type { IWalletRepository } from '@core/payments/domain/repositories/IWalletRepository'; import type { IWalletRepository } from '@core/payments/domain/repositories/IWalletRepository';
import type { ILeagueWalletRepository } from '../../domain/repositories/ILeagueWalletRepository';
import type { Logger } from '@core/shared/application'; import type { Logger } from '@core/shared/application';
import { SponsorshipRequest } from '../../domain/entities/SponsorshipRequest'; import { beforeEach, describe, expect, it, Mock, vi } from 'vitest';
import { Season } from '../../domain/entities/Season';
import { LeagueWallet } from '../../domain/entities/LeagueWallet'; import { LeagueWallet } from '../../domain/entities/LeagueWallet';
import { Season } from '../../domain/entities/Season';
import { SponsorshipRequest } from '../../domain/entities/SponsorshipRequest';
import type { ILeagueWalletRepository } from '../../domain/repositories/ILeagueWalletRepository';
import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
import type { ISeasonSponsorshipRepository } from '../../domain/repositories/ISeasonSponsorshipRepository';
import type { ISponsorshipRequestRepository } from '../../domain/repositories/ISponsorshipRequestRepository';
import { Money } from '../../domain/value-objects/Money'; import { Money } from '../../domain/value-objects/Money';
import { AcceptSponsorshipRequestUseCase } from './AcceptSponsorshipRequestUseCase';
describe('AcceptSponsorshipRequestUseCase', () => { describe('AcceptSponsorshipRequestUseCase', () => {
let mockSponsorshipRequestRepo: { let mockSponsorshipRequestRepo: {
@@ -78,7 +78,7 @@ describe('AcceptSponsorshipRequestUseCase', () => {
mockSponsorshipRequestRepo as unknown as ISponsorshipRequestRepository, mockSponsorshipRequestRepo as unknown as ISponsorshipRequestRepository,
mockSeasonSponsorshipRepo as unknown as ISeasonSponsorshipRepository, mockSeasonSponsorshipRepo as unknown as ISeasonSponsorshipRepository,
mockSeasonRepo as unknown as ISeasonRepository, mockSeasonRepo as unknown as ISeasonRepository,
mockNotificationService as unknown as INotificationService, mockNotificationService as unknown as NotificationService,
processPayment, processPayment,
mockWalletRepo as unknown as IWalletRepository, mockWalletRepo as unknown as IWalletRepository,
mockLeagueWalletRepo as unknown as ILeagueWalletRepository, mockLeagueWalletRepo as unknown as ILeagueWalletRepository,

View File

@@ -5,20 +5,19 @@
* This creates an active sponsorship and notifies the sponsor. * This creates an active sponsorship and notifies the sponsor.
*/ */
import type { Logger } from '@core/shared/application'; import type { NotificationService } from '@/notifications/application/ports/NotificationService';
import type { ISponsorshipRequestRepository } from '../../domain/repositories/ISponsorshipRequestRepository';
import type { ISeasonSponsorshipRepository } from '../../domain/repositories/ISeasonSponsorshipRepository';
import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
import type { INotificationService } from '@core/notifications/application/ports/INotificationService';
import type { IWalletRepository } from '@core/payments/domain/repositories/IWalletRepository'; import type { IWalletRepository } from '@core/payments/domain/repositories/IWalletRepository';
import type { ILeagueWalletRepository } from '../../domain/repositories/ILeagueWalletRepository'; import type { AsyncUseCase, Logger } from '@core/shared/application';
import { SeasonSponsorship } from '../../domain/entities/SeasonSponsorship';
import type { AsyncUseCase } from '@core/shared/application';
import { Result } from '@core/shared/application/Result'; import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import { SeasonSponsorship } from '../../domain/entities/SeasonSponsorship';
import type { ILeagueWalletRepository } from '../../domain/repositories/ILeagueWalletRepository';
import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
import type { ISeasonSponsorshipRepository } from '../../domain/repositories/ISeasonSponsorshipRepository';
import type { ISponsorshipRequestRepository } from '../../domain/repositories/ISponsorshipRequestRepository';
import type { AcceptSponsorshipRequestDTO } from '../dto/AcceptSponsorshipRequestDTO'; import type { AcceptSponsorshipRequestDTO } from '../dto/AcceptSponsorshipRequestDTO';
import type { AcceptSponsorshipOutputPort } from '../ports/output/AcceptSponsorshipOutputPort';
import type { ProcessPaymentInputPort } from '../ports/input/ProcessPaymentInputPort'; import type { ProcessPaymentInputPort } from '../ports/input/ProcessPaymentInputPort';
import type { AcceptSponsorshipOutputPort } from '../ports/output/AcceptSponsorshipOutputPort';
import type { ProcessPaymentOutputPort } from '../ports/output/ProcessPaymentOutputPort'; import type { ProcessPaymentOutputPort } from '../ports/output/ProcessPaymentOutputPort';
export class AcceptSponsorshipRequestUseCase export class AcceptSponsorshipRequestUseCase
@@ -27,7 +26,7 @@ export class AcceptSponsorshipRequestUseCase
private readonly sponsorshipRequestRepo: ISponsorshipRequestRepository, private readonly sponsorshipRequestRepo: ISponsorshipRequestRepository,
private readonly seasonSponsorshipRepo: ISeasonSponsorshipRepository, private readonly seasonSponsorshipRepo: ISeasonSponsorshipRepository,
private readonly seasonRepository: ISeasonRepository, private readonly seasonRepository: ISeasonRepository,
private readonly notificationService: INotificationService, private readonly notificationService: NotificationService,
private readonly paymentProcessor: (input: ProcessPaymentInputPort) => Promise<ProcessPaymentOutputPort>, private readonly paymentProcessor: (input: ProcessPaymentInputPort) => Promise<ProcessPaymentOutputPort>,
private readonly walletRepository: IWalletRepository, private readonly walletRepository: IWalletRepository,
private readonly leagueWalletRepository: ILeagueWalletRepository, private readonly leagueWalletRepository: ILeagueWalletRepository,

View File

@@ -1,15 +1,15 @@
import { describe, it, expect, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import { SendFinalResultsUseCase } from './SendFinalResultsUseCase'; import type { NotificationService } from '../../../notifications/application/ports/NotificationService';
import type { INotificationService } from '../../../notifications/application/ports/INotificationService'; import type { RaceEventStewardingClosedEvent } from '../../domain/events/RaceEventStewardingClosed';
import type { IRaceEventRepository } from '../../domain/repositories/IRaceEventRepository'; import type { IRaceEventRepository } from '../../domain/repositories/IRaceEventRepository';
import type { IResultRepository } from '../../domain/repositories/IResultRepository'; import type { IResultRepository } from '../../domain/repositories/IResultRepository';
import type { RaceEventStewardingClosedEvent } from '../../domain/events/RaceEventStewardingClosed'; import { SendFinalResultsUseCase } from './SendFinalResultsUseCase';
describe('SendFinalResultsUseCase', () => { describe('SendFinalResultsUseCase', () => {
it('sends final results notifications to all participating drivers', async () => { it('sends final results notifications to all participating drivers', async () => {
const mockNotificationService = { const mockNotificationService = {
sendNotification: vi.fn(), sendNotification: vi.fn(),
} as unknown as INotificationService; } as unknown as NotificationService;
const mockRaceEvent = { const mockRaceEvent = {
id: 'race-1', id: 'race-1',
@@ -107,7 +107,7 @@ describe('SendFinalResultsUseCase', () => {
it('skips sending notifications if race event not found', async () => { it('skips sending notifications if race event not found', async () => {
const mockNotificationService = { const mockNotificationService = {
sendNotification: vi.fn(), sendNotification: vi.fn(),
} as unknown as INotificationService; } as unknown as NotificationService;
const mockRaceEventRepository = { const mockRaceEventRepository = {
findById: vi.fn().mockResolvedValue(null), findById: vi.fn().mockResolvedValue(null),
@@ -146,7 +146,7 @@ describe('SendFinalResultsUseCase', () => {
it('skips sending notifications if no main race session', async () => { it('skips sending notifications if no main race session', async () => {
const mockNotificationService = { const mockNotificationService = {
sendNotification: vi.fn(), sendNotification: vi.fn(),
} as unknown as INotificationService; } as unknown as NotificationService;
const mockRaceEvent = { const mockRaceEvent = {
id: 'race-1', id: 'race-1',

View File

@@ -1,12 +1,12 @@
import { Result } from '@core/shared/application/Result'; import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { INotificationService } from '../../../notifications/application/ports/INotificationService'; import type { NotificationService } from '../../../notifications/application/ports/NotificationService';
import type { IRaceEventRepository } from '../../domain/repositories/IRaceEventRepository';
import type { IResultRepository } from '../../domain/repositories/IResultRepository';
import type { RaceEventStewardingClosedEvent } from '../../domain/events/RaceEventStewardingClosed';
import type { NotificationType } from '../../../notifications/domain/types/NotificationTypes'; import type { NotificationType } from '../../../notifications/domain/types/NotificationTypes';
import type { RaceEvent } from '../../domain/entities/RaceEvent'; import type { RaceEvent } from '../../domain/entities/RaceEvent';
import type { Result as RaceResult } from '../../domain/entities/Result'; import type { Result as RaceResult } from '../../domain/entities/Result';
import type { RaceEventStewardingClosedEvent } from '../../domain/events/RaceEventStewardingClosed';
import type { IRaceEventRepository } from '../../domain/repositories/IRaceEventRepository';
import type { IResultRepository } from '../../domain/repositories/IResultRepository';
/** /**
* Use Case: SendFinalResultsUseCase * Use Case: SendFinalResultsUseCase
@@ -17,7 +17,7 @@ import type { Result as RaceResult } from '../../domain/entities/Result';
*/ */
export class SendFinalResultsUseCase { export class SendFinalResultsUseCase {
constructor( constructor(
private readonly notificationService: INotificationService, private readonly notificationService: NotificationService,
private readonly raceEventRepository: IRaceEventRepository, private readonly raceEventRepository: IRaceEventRepository,
private readonly resultRepository: IResultRepository, private readonly resultRepository: IResultRepository,
) {} ) {}

View File

@@ -1,15 +1,15 @@
import { describe, it, expect, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import { SendPerformanceSummaryUseCase } from './SendPerformanceSummaryUseCase'; import type { NotificationService } from '../../../notifications/application/ports/NotificationService';
import type { INotificationService } from '../../../notifications/application/ports/INotificationService'; import type { MainRaceCompletedEvent } from '../../domain/events/MainRaceCompleted';
import type { IRaceEventRepository } from '../../domain/repositories/IRaceEventRepository'; import type { IRaceEventRepository } from '../../domain/repositories/IRaceEventRepository';
import type { IResultRepository } from '../../domain/repositories/IResultRepository'; import type { IResultRepository } from '../../domain/repositories/IResultRepository';
import type { MainRaceCompletedEvent } from '../../domain/events/MainRaceCompleted'; import { SendPerformanceSummaryUseCase } from './SendPerformanceSummaryUseCase';
describe('SendPerformanceSummaryUseCase', () => { describe('SendPerformanceSummaryUseCase', () => {
it('sends performance summary notifications to all participating drivers', async () => { it('sends performance summary notifications to all participating drivers', async () => {
const mockNotificationService = { const mockNotificationService = {
sendNotification: vi.fn(), sendNotification: vi.fn(),
} as unknown as INotificationService; } as unknown as NotificationService;
const mockRaceEvent = { const mockRaceEvent = {
id: 'race-1', id: 'race-1',
@@ -106,7 +106,7 @@ describe('SendPerformanceSummaryUseCase', () => {
it('skips sending notifications if race event not found', async () => { it('skips sending notifications if race event not found', async () => {
const mockNotificationService = { const mockNotificationService = {
sendNotification: vi.fn(), sendNotification: vi.fn(),
} as unknown as INotificationService; } as unknown as NotificationService;
const mockRaceEventRepository = { const mockRaceEventRepository = {
findById: vi.fn().mockResolvedValue(null), findById: vi.fn().mockResolvedValue(null),

View File

@@ -1,12 +1,12 @@
import { Result } from '@core/shared/application/Result'; import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { INotificationService } from '../../../notifications/application/ports/INotificationService'; import type { NotificationService } from '../../../notifications/application/ports/NotificationService';
import type { IRaceEventRepository } from '../../domain/repositories/IRaceEventRepository';
import type { IResultRepository } from '../../domain/repositories/IResultRepository';
import type { MainRaceCompletedEvent } from '../../domain/events/MainRaceCompleted';
import type { NotificationType } from '../../../notifications/domain/types/NotificationTypes'; import type { NotificationType } from '../../../notifications/domain/types/NotificationTypes';
import type { RaceEvent } from '../../domain/entities/RaceEvent'; import type { RaceEvent } from '../../domain/entities/RaceEvent';
import type { Result as RaceResult } from '../../domain/entities/Result'; import type { Result as RaceResult } from '../../domain/entities/Result';
import type { MainRaceCompletedEvent } from '../../domain/events/MainRaceCompleted';
import type { IRaceEventRepository } from '../../domain/repositories/IRaceEventRepository';
import type { IResultRepository } from '../../domain/repositories/IResultRepository';
/** /**
* Use Case: SendPerformanceSummaryUseCase * Use Case: SendPerformanceSummaryUseCase
@@ -16,7 +16,7 @@ import type { Result as RaceResult } from '../../domain/entities/Result';
*/ */
export class SendPerformanceSummaryUseCase { export class SendPerformanceSummaryUseCase {
constructor( constructor(
private readonly notificationService: INotificationService, private readonly notificationService: NotificationService,
private readonly raceEventRepository: IRaceEventRepository, private readonly raceEventRepository: IRaceEventRepository,
private readonly resultRepository: IResultRepository, private readonly resultRepository: IResultRepository,
) {} ) {}

244
docs/architecture/CQRS.md Normal file
View File

@@ -0,0 +1,244 @@
CQRS Light with Clean Architecture
This document defines CQRS Light as a pragmatic, production-ready approach that integrates cleanly with Clean Architecture.
It is intentionally non-dogmatic, avoids event-sourcing overhead, and focuses on clarity, performance, and maintainability.
1. What CQRS Light Is
CQRS Light separates how the system writes data from how it reads data — without changing the core architecture.
Key properties:
• Commands and Queries are separated logically, not infrastructurally
• Same database is allowed
• No event bus required
• No eventual consistency by default
CQRS Light is an optimization, not a foundation.
2. What CQRS Light Is NOT
CQRS Light explicitly does not include:
• Event Sourcing
• Message brokers
• Projections as a hard requirement
• Separate databases
• Microservices
Those can be added later if needed.
3. Why CQRS Light Exists
Without CQRS:
• Reads are forced through domain aggregates
• Aggregates grow unnaturally large
• Reporting logic pollutes the domain
• Performance degrades due to object loading
CQRS Light solves this by allowing:
• Strict domain logic on writes
• Flexible, optimized reads
4. Core Architectural Principle
Writes protect invariants. Reads optimize information access.
Therefore:
• Commands enforce business rules
• Queries are allowed to be pragmatic and denormalized
5. Placement in Clean Architecture
CQRS Light does not introduce new layers.
It reorganizes existing ones.
core/
└── <context>/
└── application/
├── commands/ # Write side (Use Cases)
└── queries/ # Read side (Query Use Cases)
Domain remains unchanged.
6. Command Side (Write Model)
Purpose
• Modify state
• Enforce invariants
• Emit outcomes
Characteristics
• Uses Domain Entities and Value Objects
• Uses Repositories
• Uses Output Ports
• Transactional
Example Structure
core/racing/application/commands/
├── CreateLeagueUseCase.ts
├── ApplyPenaltyUseCase.ts
└── RegisterForRaceUseCase.ts
7. Query Side (Read Model)
Purpose
• Read state
• Aggregate data
• Serve UI efficiently
Characteristics
• No domain entities
• No invariants
• No side effects
• May use SQL/ORM directly
Example Structure
core/racing/application/queries/
├── GetLeagueStandingsQuery.ts
├── GetDashboardOverviewQuery.ts
└── GetDriverStatsQuery.ts
Queries are still Use Cases, just read-only ones.
8. Repositories in CQRS Light
Write Repositories
• Used by command use cases
• Work with domain entities
• Enforce consistency
core/racing/domain/repositories/
└── LeagueRepositoryPort.ts
Read Repositories
• Used by query use cases
• Return flat, optimized data
• Not domain repositories
core/racing/application/ports/
└── LeagueStandingsReadPort.ts
Implementation lives in adapters.
9. Performance Benefits (Why It Matters)
Without CQRS Light
• Aggregate loading
• N+1 queries
• Heavy object graphs
• CPU and memory overhead
With CQRS Light
• Single optimized queries
• Minimal data transfer
• Database does aggregation
• Lower memory footprint
This results in:
• Faster endpoints
• Simpler code
• Easier scaling
10. Testing Strategy
Commands
• Unit tests
• Mock repositories and ports
• Verify output port calls
Queries
• Simple unit tests
• Input → Output verification
• No mocks beyond data source
CQRS Light reduces the need for complex integration tests.
11. When CQRS Light Is a Good Fit
Use CQRS Light when:
• Read complexity is high
• Write logic must stay strict
• Dashboards or analytics exist
• Multiple clients consume the system
Avoid CQRS Light when:
• Application is CRUD-only
• Data volume is small
• Read/write patterns are identical
12. Migration Path
CQRS Light allows incremental adoption:
1. Start with classic Clean Architecture
2. Separate commands and queries logically
3. Optimize read paths as needed
4. Introduce events or projections later (optional)
No rewrites required.
13. Key Rules (Non-Negotiable)
• Commands MUST use the domain
• Queries MUST NOT modify state
• Queries MUST NOT enforce invariants
• Domain MUST NOT depend on queries
• Core remains framework-agnostic
14. Mental Model
Think of CQRS Light as:
One core truth, two access patterns.
The domain defines truth.
Queries define convenience.
15. Final Summary
CQRS Light provides:
• Cleaner domain models
• Faster reads
• Reduced complexity
• Future scalability
Without:
• Infrastructure overhead
• Event sourcing complexity
• Premature optimization
It is the safest way to gain CQRS benefits while staying true to Clean Architecture.