From 20588e1c0be365944bc483fb60286a48dda022cc Mon Sep 17 00:00:00 2001 From: Marc Mintel Date: Sat, 20 Dec 2025 12:22:48 +0100 Subject: [PATCH] resolve todos in website --- apps/api/src/domain/race/RaceController.ts | 14 +- apps/api/src/domain/race/RaceProviders.ts | 140 ++++++---- apps/api/src/domain/race/RaceService.ts | 175 ++++++++----- .../src/domain/sponsor/SponsorProviders.ts | 51 ++-- apps/website/app/drivers/[id]/page.tsx | 15 +- .../stewarding/protests/[protestId]/page.tsx | 31 +-- apps/website/app/races/[id]/page.test.tsx | 169 ++++++++++++ apps/website/app/races/[id]/page.tsx | 68 +++-- apps/website/app/races/[id]/results/page.tsx | 13 +- .../app/races/[id]/stewarding/page.tsx | 13 +- apps/website/app/teams/[id]/page.tsx | 40 ++- apps/website/app/teams/page.tsx | 26 +- .../components/drivers/DriverIdentity.tsx | 2 +- .../components/leagues/LeagueHeader.test.tsx | 41 +++ .../components/leagues/LeagueHeader.tsx | 29 --- .../components/leagues/LeagueMembers.test.tsx | 129 +++++++++ .../components/leagues/LeagueMembers.tsx | 44 ++-- .../components/profile/DriverSummaryPill.tsx | 2 +- .../components/profile/UserPill.test.tsx | 120 +++++++++ apps/website/components/profile/UserPill.tsx | 20 +- apps/website/lib/api/races/RacesApiClient.ts | 5 + .../lib/services/races/RaceService.test.ts | 25 +- .../website/lib/services/races/RaceService.ts | 6 + .../lib/view-models/ProtestViewModel.ts | 3 + .../view-models/RaceDetailViewModel.test.ts | 30 +++ .../lib/view-models/RaceDetailViewModel.ts | 6 + core/notifications/application/index.ts | 23 +- ...ationGateway.ts => NotificationGateway.ts} | 10 +- ...ationService.ts => NotificationService.ts} | 5 +- .../use-cases/SendNotificationUseCase.ts | 13 +- .../value-objects}/NotificationId.test.ts | 0 .../value-objects}/QuietHours.test.ts | 0 .../AcceptSponsorshipRequestUseCase.test.ts | 20 +- .../AcceptSponsorshipRequestUseCase.ts | 19 +- .../use-cases/SendFinalResultsUseCase.test.ts | 14 +- .../use-cases/SendFinalResultsUseCase.ts | 10 +- .../SendPerformanceSummaryUseCase.test.ts | 12 +- .../SendPerformanceSummaryUseCase.ts | 10 +- docs/architecture/CQRS.md | 244 ++++++++++++++++++ 39 files changed, 1238 insertions(+), 359 deletions(-) create mode 100644 apps/website/app/races/[id]/page.test.tsx create mode 100644 apps/website/components/leagues/LeagueHeader.test.tsx create mode 100644 apps/website/components/leagues/LeagueMembers.test.tsx create mode 100644 apps/website/components/profile/UserPill.test.tsx rename core/notifications/application/ports/{INotificationGateway.ts => NotificationGateway.ts} (85%) rename core/notifications/application/ports/{INotificationService.ts => NotificationService.ts} (81%) rename core/notifications/{ => domain/value-objects}/NotificationId.test.ts (100%) rename core/notifications/{ => domain/value-objects}/QuietHours.test.ts (100%) create mode 100644 docs/architecture/CQRS.md diff --git a/apps/api/src/domain/race/RaceController.ts b/apps/api/src/domain/race/RaceController.ts index bc7b20bd2..dc00092b1 100644 --- a/apps/api/src/domain/race/RaceController.ts +++ b/apps/api/src/domain/race/RaceController.ts @@ -1,5 +1,5 @@ -import { Controller, Get, Post, Body, HttpCode, HttpStatus, Param, Query } from '@nestjs/common'; -import { ApiTags, ApiResponse, ApiOperation, ApiParam, ApiQuery } from '@nestjs/swagger'; +import { Body, Controller, Get, HttpCode, HttpStatus, Param, Post, Query } from '@nestjs/common'; +import { ApiOperation, ApiParam, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger'; import { RaceService } from './RaceService'; import { AllRacesPageDTO } from './dtos/AllRacesPageDTO'; import { RaceStatsDTO } from './dtos/RaceStatsDTO'; @@ -137,6 +137,15 @@ export class RaceController { 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 { + return this.raceService.reopenRace({ raceId }); + } + @Post(':raceId/import-results') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: 'Import race results' }) @@ -149,7 +158,6 @@ export class RaceController { return this.raceService.importRaceResults({ raceId, ...body }); } - @Post('protests/file') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: 'File a protest' }) diff --git a/apps/api/src/domain/race/RaceProviders.ts b/apps/api/src/domain/race/RaceProviders.ts index 18f08ac4d..163a00afb 100644 --- a/apps/api/src/domain/race/RaceProviders.ts +++ b/apps/api/src/domain/race/RaceProviders.ts @@ -1,19 +1,19 @@ -import { Provider } from '@nestjs/common'; +import type { Provider } from '@nestjs/common'; import { RaceService } from './RaceService'; // Import core interfaces import type { Logger } from '@core/shared/application/Logger'; -import { IRaceRepository } from '@core/racing/domain/repositories/IRaceRepository'; -import { ILeagueRepository } from '@core/racing/domain/repositories/ILeagueRepository'; -import { IDriverRepository } from '@core/racing/domain/repositories/IDriverRepository'; -import { IStandingRepository } from '@core/racing/domain/repositories/IStandingRepository'; -import { IRaceRegistrationRepository } from '@core/racing/domain/repositories/IRaceRegistrationRepository'; -import { IResultRepository } from '@core/racing/domain/repositories/IResultRepository'; -import { ILeagueMembershipRepository } from '@core/racing/domain/repositories/ILeagueMembershipRepository'; -import { IPenaltyRepository } from '@core/racing/domain/repositories/IPenaltyRepository'; -import { IProtestRepository } from '@core/racing/domain/repositories/IProtestRepository'; -import { DriverRatingProvider } from '@core/racing/application/ports/DriverRatingProvider'; -import { IImageServicePort } from '@core/racing/application/ports/IImageServicePort'; +import type { IRaceRepository } from '@core/racing/domain/repositories/IRaceRepository'; +import type { ILeagueRepository } from '@core/racing/domain/repositories/ILeagueRepository'; +import type { IDriverRepository } from '@core/racing/domain/repositories/IDriverRepository'; +import type { IStandingRepository } from '@core/racing/domain/repositories/IStandingRepository'; +import type { IRaceRegistrationRepository } from '@core/racing/domain/repositories/IRaceRegistrationRepository'; +import type { IResultRepository } from '@core/racing/domain/repositories/IResultRepository'; +import type { ILeagueMembershipRepository } from '@core/racing/domain/repositories/ILeagueMembershipRepository'; +import type { IPenaltyRepository } from '@core/racing/domain/repositories/IPenaltyRepository'; +import type { IProtestRepository } from '@core/racing/domain/repositories/IProtestRepository'; +import type { DriverRatingProvider } from '@core/racing/application/ports/DriverRatingProvider'; +import type { IImageServicePort } from '@core/racing/application/ports/IImageServicePort'; // Import concrete in-memory implementations 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 { RequestProtestDefenseUseCase } from '@core/racing/application/use-cases/RequestProtestDefenseUseCase'; import { ReviewProtestUseCase } from '@core/racing/application/use-cases/ReviewProtestUseCase'; +import { ReopenRaceUseCase } from '@core/racing/application/use-cases/ReopenRaceUseCase'; // Define injection tokens export const RACE_REPOSITORY_TOKEN = 'IRaceRepository'; @@ -129,7 +130,8 @@ export const RaceProviders: Provider[] = [ // Use cases { 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], }, { @@ -146,14 +148,15 @@ export const RaceProviders: Provider[] = [ raceRegRepo: IRaceRegistrationRepository, resultRepo: IResultRepository, leagueMembershipRepo: ILeagueMembershipRepository, - ) => new GetRaceDetailUseCase( - raceRepo, - leagueRepo, - driverRepo, - raceRegRepo, - resultRepo, - leagueMembershipRepo, - ), + ) => + new GetRaceDetailUseCase( + raceRepo, + leagueRepo, + driverRepo, + raceRegRepo, + resultRepo, + leagueMembershipRepo, + ), inject: [ RACE_REPOSITORY_TOKEN, LEAGUE_REPOSITORY_TOKEN, @@ -165,12 +168,14 @@ export const RaceProviders: Provider[] = [ }, { 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], }, { 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], }, { @@ -207,18 +212,23 @@ export const RaceProviders: Provider[] = [ }, { 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], }, { 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], }, { provide: RegisterForRaceUseCase, - useFactory: (raceRegRepo: IRaceRegistrationRepository, leagueMembershipRepo: ILeagueMembershipRepository, logger: Logger) => - new RegisterForRaceUseCase(raceRegRepo, leagueMembershipRepo, logger), + useFactory: ( + raceRegRepo: IRaceRegistrationRepository, + leagueMembershipRepo: ILeagueMembershipRepository, + logger: Logger, + ) => new RegisterForRaceUseCase(raceRegRepo, leagueMembershipRepo, logger), inject: [RACE_REGISTRATION_REPOSITORY_TOKEN, LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN, LOGGER_TOKEN], }, { @@ -248,6 +258,11 @@ export const RaceProviders: Provider[] = [ DRIVER_RATING_PROVIDER_TOKEN, ], }, + { + provide: ReopenRaceUseCase, + useFactory: (raceRepo: IRaceRepository, logger: Logger) => new ReopenRaceUseCase(raceRepo, logger), + inject: [RACE_REPOSITORY_TOKEN, LOGGER_TOKEN], + }, { provide: ImportRaceResultsApiUseCase, useFactory: ( @@ -257,14 +272,7 @@ export const RaceProviders: Provider[] = [ driverRepo: IDriverRepository, standingRepo: IStandingRepository, logger: Logger, - ) => new ImportRaceResultsApiUseCase( - raceRepo, - leagueRepo, - resultRepo, - driverRepo, - standingRepo, - logger, - ), + ) => new ImportRaceResultsApiUseCase(raceRepo, leagueRepo, resultRepo, driverRepo, standingRepo, logger), inject: [ RACE_REPOSITORY_TOKEN, LEAGUE_REPOSITORY_TOKEN, @@ -283,14 +291,7 @@ export const RaceProviders: Provider[] = [ driverRepo: IDriverRepository, standingRepo: IStandingRepository, logger: Logger, - ) => new ImportRaceResultsUseCase( - raceRepo, - leagueRepo, - resultRepo, - driverRepo, - standingRepo, - logger, - ), + ) => new ImportRaceResultsUseCase(raceRepo, leagueRepo, resultRepo, driverRepo, standingRepo, logger), inject: [ RACE_REPOSITORY_TOKEN, LEAGUE_REPOSITORY_TOKEN, @@ -302,32 +303,61 @@ export const RaceProviders: Provider[] = [ }, { provide: FileProtestUseCase, - useFactory: (protestRepo: IProtestRepository, raceRepo: IRaceRepository, leagueMembershipRepo: ILeagueMembershipRepository) => - new FileProtestUseCase(protestRepo, raceRepo, leagueMembershipRepo), + useFactory: ( + protestRepo: IProtestRepository, + raceRepo: IRaceRepository, + leagueMembershipRepo: ILeagueMembershipRepository, + ) => new FileProtestUseCase(protestRepo, raceRepo, leagueMembershipRepo), inject: [PROTEST_REPOSITORY_TOKEN, RACE_REPOSITORY_TOKEN, LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN], }, { provide: QuickPenaltyUseCase, - useFactory: (penaltyRepo: IPenaltyRepository, 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], + useFactory: ( + penaltyRepo: IPenaltyRepository, + 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, - useFactory: (penaltyRepo: IPenaltyRepository, 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], + useFactory: ( + penaltyRepo: IPenaltyRepository, + 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, - useFactory: (protestRepo: IProtestRepository, raceRepo: IRaceRepository, leagueMembershipRepo: ILeagueMembershipRepository) => - new RequestProtestDefenseUseCase(protestRepo, raceRepo, leagueMembershipRepo), + useFactory: ( + protestRepo: IProtestRepository, + raceRepo: IRaceRepository, + leagueMembershipRepo: ILeagueMembershipRepository, + ) => new RequestProtestDefenseUseCase(protestRepo, raceRepo, leagueMembershipRepo), inject: [PROTEST_REPOSITORY_TOKEN, RACE_REPOSITORY_TOKEN, LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN], }, { provide: ReviewProtestUseCase, - useFactory: (protestRepo: IProtestRepository, raceRepo: IRaceRepository, leagueMembershipRepo: ILeagueMembershipRepository) => - new ReviewProtestUseCase(protestRepo, raceRepo, leagueMembershipRepo), + useFactory: ( + protestRepo: IProtestRepository, + raceRepo: IRaceRepository, + leagueMembershipRepo: ILeagueMembershipRepository, + ) => new ReviewProtestUseCase(protestRepo, raceRepo, leagueMembershipRepo), inject: [PROTEST_REPOSITORY_TOKEN, RACE_REPOSITORY_TOKEN, LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN], }, ]; diff --git a/apps/api/src/domain/race/RaceService.ts b/apps/api/src/domain/race/RaceService.ts index 19a110ba7..203d54f45 100644 --- a/apps/api/src/domain/race/RaceService.ts +++ b/apps/api/src/domain/race/RaceService.ts @@ -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 { RaceDetailOutputPort } from '@core/racing/application/ports/output/RaceDetailOutputPort'; 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 { RequestProtestDefenseUseCase } from '@core/racing/application/use-cases/RequestProtestDefenseUseCase'; import { ReviewProtestUseCase } from '@core/racing/application/use-cases/ReviewProtestUseCase'; +import { ReopenRaceUseCase } from '@core/racing/application/use-cases/ReopenRaceUseCase'; // Presenters import { GetAllRacesPresenter } from './presenters/GetAllRacesPresenter'; @@ -61,7 +62,7 @@ import { RequestProtestDefenseCommandDTO } from './dtos/RequestProtestDefenseCom import { ReviewProtestCommandDTO } from './dtos/ReviewProtestCommandDTO'; // 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() export class RaceService { @@ -85,6 +86,7 @@ export class RaceService { private readonly applyPenaltyUseCase: ApplyPenaltyUseCase, private readonly requestProtestDefenseUseCase: RequestProtestDefenseUseCase, private readonly reviewProtestUseCase: ReviewProtestUseCase, + private readonly reopenRaceUseCase: ReopenRaceUseCase, @Inject(LEAGUE_REPOSITORY_TOKEN) private readonly leagueRepository: ILeagueRepository, @Inject(LOGGER_TOKEN) private readonly logger: Logger, @Inject(DRIVER_RATING_PROVIDER_TOKEN) private readonly driverRatingProvider: DriverRatingProvider, @@ -130,60 +132,68 @@ export class RaceService { throw new Error('Failed to get race detail'); } - const outputPort = result.value; + const outputPort = result.value as RaceDetailOutputPort; // Map to DTO - const raceDTO = outputPort.race ? { - id: outputPort.race.id, - leagueId: outputPort.race.leagueId, - track: outputPort.race.track, - car: outputPort.race.car, - scheduledAt: outputPort.race.scheduledAt.toISOString(), - sessionType: outputPort.race.sessionType, - status: outputPort.race.status, - strengthOfField: outputPort.race.strengthOfField ?? null, - registeredCount: outputPort.race.registeredCount ?? undefined, - maxParticipants: outputPort.race.maxParticipants ?? undefined, - } : null; + const raceDTO = outputPort.race + ? { + id: outputPort.race.id, + leagueId: outputPort.race.leagueId, + track: outputPort.race.track, + car: outputPort.race.car, + scheduledAt: outputPort.race.scheduledAt.toISOString(), + sessionType: outputPort.race.sessionType, + status: outputPort.race.status, + strengthOfField: outputPort.race.strengthOfField ?? null, + registeredCount: outputPort.race.registeredCount ?? undefined, + maxParticipants: outputPort.race.maxParticipants ?? undefined, + } + : null; - const leagueDTO = outputPort.league ? { - id: outputPort.league.id.toString(), - name: outputPort.league.name.toString(), - description: outputPort.league.description.toString(), - settings: { - maxDrivers: outputPort.league.settings.maxDrivers ?? undefined, - qualifyingFormat: outputPort.league.settings.qualifyingFormat ?? undefined, - }, - } : null; + const leagueDTO = outputPort.league + ? { + id: outputPort.league.id.toString(), + name: outputPort.league.name.toString(), + description: outputPort.league.description.toString(), + settings: { + maxDrivers: outputPort.league.settings.maxDrivers ?? undefined, + qualifyingFormat: outputPort.league.settings.qualifyingFormat ?? undefined, + }, + } + : null; - const entryListDTO = await Promise.all(outputPort.drivers.map(async driver => { - const ratingResult = await this.driverRatingProvider.getDriverRating({ driverId: driver.id }); - const avatarResult = await this.imageService.getDriverAvatar({ driverId: driver.id }); - return { - id: driver.id, - name: driver.name.toString(), - country: driver.country.toString(), - avatarUrl: avatarResult.avatarUrl, - rating: ratingResult.rating, - isCurrentUser: driver.id === params.driverId, - }; - })); + const entryListDTO = await Promise.all( + outputPort.drivers.map(async driver => { + const ratingResult = await this.driverRatingProvider.getDriverRating({ driverId: driver.id }); + const avatarResult = await this.imageService.getDriverAvatar({ driverId: driver.id }); + return { + id: driver.id, + name: driver.name.toString(), + country: driver.country.toString(), + avatarUrl: avatarResult.avatarUrl, + rating: ratingResult.rating, + isCurrentUser: driver.id === params.driverId, + }; + }), + ); const registrationDTO = { isUserRegistered: outputPort.isUserRegistered, canRegister: outputPort.canRegister, }; - const userResultDTO = outputPort.userResult ? { - position: outputPort.userResult.position.toNumber(), - startPosition: outputPort.userResult.startPosition.toNumber(), - incidents: outputPort.userResult.incidents.toNumber(), - fastestLap: outputPort.userResult.fastestLap.toNumber(), - positionChange: outputPort.userResult.getPositionChange(), - isPodium: outputPort.userResult.isPodium(), - isClean: outputPort.userResult.isClean(), - ratingChange: this.calculateRatingChange(outputPort.userResult.position.toNumber()), - } : null; + const userResultDTO = outputPort.userResult + ? { + position: outputPort.userResult.position.toNumber(), + startPosition: outputPort.userResult.startPosition.toNumber(), + incidents: outputPort.userResult.incidents.toNumber(), + fastestLap: outputPort.userResult.fastestLap.toNumber(), + positionChange: outputPort.userResult.getPositionChange(), + isPodium: outputPort.userResult.isPodium(), + isClean: outputPort.userResult.isClean(), + ratingChange: this.calculateRatingChange(outputPort.userResult.position.toNumber()), + } + : null; return { race: raceDTO, @@ -203,7 +213,7 @@ export class RaceService { throw new Error('Failed to get races page data'); } - const outputPort = result.value; + const outputPort = result.value as RacesPageOutputPort; // Fetch leagues for league names const allLeagues = await this.leagueRepository.findAll(); @@ -250,32 +260,34 @@ export class RaceService { 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 const driverMap = new Map(outputPort.drivers.map(driver => [driver.id, driver])); - const resultsDTO = await Promise.all(outputPort.results.map(async (result) => { - const driver = driverMap.get(result.driverId.toString()); - if (!driver) { - throw new Error(`Driver not found for result: ${result.driverId}`); - } + const resultsDTO = await Promise.all( + outputPort.results.map(async singleResult => { + const driver = driverMap.get(singleResult.driverId.toString()); + if (!driver) { + throw new Error(`Driver not found for result: ${singleResult.driverId}`); + } - const avatarResult = await this.imageService.getDriverAvatar({ driverId: driver.id }); + const avatarResult = await this.imageService.getDriverAvatar({ driverId: driver.id }); - return { - driverId: result.driverId.toString(), - driverName: driver.name.toString(), - avatarUrl: avatarResult.avatarUrl, - position: result.position.toNumber(), - startPosition: result.startPosition.toNumber(), - incidents: result.incidents.toNumber(), - fastestLap: result.fastestLap.toNumber(), - positionChange: result.getPositionChange(), - isPodium: result.isPodium(), - isClean: result.isClean(), - }; - })); + return { + driverId: singleResult.driverId.toString(), + driverName: driver.name.toString(), + avatarUrl: avatarResult.avatarUrl, + position: singleResult.position.toNumber(), + startPosition: singleResult.startPosition.toNumber(), + incidents: singleResult.incidents.toNumber(), + fastestLap: singleResult.fastestLap.toNumber(), + positionChange: singleResult.getPositionChange(), + isPodium: singleResult.isPodium(), + isClean: singleResult.isClean(), + }; + }), + ); return { raceId: outputPort.race.id, @@ -293,7 +305,7 @@ export class RaceService { throw new Error('Failed to get race with SOF'); } - const outputPort = result.value; + const outputPort = result.value as RaceWithSOFOutputPort; // Map to DTO return { @@ -312,7 +324,7 @@ export class RaceService { throw new Error('Failed to get race protests'); } - const outputPort = result.value; + const outputPort = result.value as RaceProtestsOutputPort; const protestsDTO = outputPort.protests.map(protest => ({ id: protest.id, @@ -346,7 +358,7 @@ export class RaceService { throw new Error('Failed to get race penalties'); } - const outputPort = result.value; + const outputPort = result.value as RacePenaltiesOutputPort; const penaltiesDTO = outputPort.penalties.map(penalty => ({ id: penalty.id, @@ -410,7 +422,30 @@ export class RaceService { } } + async reopenRace(params: RaceActionParamsDTO): Promise { + 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 { this.logger.debug('[RaceService] Filing protest:', command); diff --git a/apps/api/src/domain/sponsor/SponsorProviders.ts b/apps/api/src/domain/sponsor/SponsorProviders.ts index 2ca817311..7db5c7d91 100644 --- a/apps/api/src/domain/sponsor/SponsorProviders.ts +++ b/apps/api/src/domain/sponsor/SponsorProviders.ts @@ -2,45 +2,44 @@ import { Provider } from '@nestjs/common'; import { SponsorService } from './SponsorService'; // Import core interfaces -import { ISponsorRepository } from '@core/racing/domain/repositories/ISponsorRepository'; -import { ISeasonSponsorshipRepository } from '@core/racing/domain/repositories/ISeasonSponsorshipRepository'; -import { ISeasonRepository } from '@core/racing/domain/repositories/ISeasonRepository'; -import { ILeagueRepository } from '@core/racing/domain/repositories/ILeagueRepository'; +import { NotificationService } from '@core/notifications/application/ports/NotificationService'; +import type { IPaymentRepository } from '@core/payments/domain/repositories/IPaymentRepository'; +import { IWalletRepository } from '@core/payments/domain/repositories/IWalletRepository'; +import { IPaymentGateway } from '@core/racing/application/ports/IPaymentGateway'; 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 { 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 { 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 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 { 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 { InMemorySponsorRepository } from '@adapters/racing/persistence/inmemory/InMemorySponsorRepository'; -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 { ConsoleLogger } from '@adapters/logging/ConsoleLogger'; 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 { 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 { InMemorySponsorshipRequestRepository } from '@adapters/racing/persistence/inmemory/InMemorySponsorshipRequestRepository'; -import { InMemoryPaymentRepository } from '@adapters/payments/persistence/inmemory/InMemoryPaymentRepository'; -import { ConsoleLogger } from '@adapters/logging/ConsoleLogger'; // Define injection tokens export const SPONSOR_REPOSITORY_TOKEN = 'ISponsorRepository'; @@ -179,7 +178,7 @@ export const SponsorProviders: Provider[] = [ }, { 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), inject: [SPONSORSHIP_REQUEST_REPOSITORY_TOKEN, SEASON_SPONSORSHIP_REPOSITORY_TOKEN, SEASON_REPOSITORY_TOKEN, 'INotificationService', 'IPaymentGateway', 'IWalletRepository', 'ILeagueWalletRepository', LOGGER_TOKEN], }, diff --git a/apps/website/app/drivers/[id]/page.tsx b/apps/website/app/drivers/[id]/page.tsx index 541e058ae..fabd84f4f 100644 --- a/apps/website/app/drivers/[id]/page.tsx +++ b/apps/website/app/drivers/[id]/page.tsx @@ -45,14 +45,14 @@ import { DriverProfileViewModel } from '@/lib/view-models/DriverProfileViewModel type ProfileTab = 'overview' | 'stats'; +interface TeamLeagueSummary { + id: string; + name: string; +} + interface Team { id: string; name: string; - tag: string; - description: string; - ownerId: string; - leagues: unknown[]; // TODO: define proper type - createdAt: Date; } interface SocialHandle { @@ -317,11 +317,6 @@ export default function DriverDetailPage() { team: { id: team.id, 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, role: membership.role, joinedAt: new Date(membership.joinedAt), diff --git a/apps/website/app/leagues/[id]/stewarding/protests/[protestId]/page.tsx b/apps/website/app/leagues/[id]/stewarding/protests/[protestId]/page.tsx index 2e0d22058..0f5b9defc 100644 --- a/apps/website/app/leagues/[id]/stewarding/protests/[protestId]/page.tsx +++ b/apps/website/app/leagues/[id]/stewarding/protests/[protestId]/page.tsx @@ -180,11 +180,8 @@ export default function ProtestReviewPage() { type: 'protest_filed', timestamp: new Date(protest.submittedAt), actor: protestingDriver, - content: protest.description, // TODO: Add incident description when available - metadata: { - // lap: protest.incident?.lap, - // comment: protest.comment - } + content: protest.description, + metadata: {} } ]; @@ -242,7 +239,8 @@ export default function ProtestReviewPage() { currentDriverId, protest.id ); - penaltyCommand.reason = 'Protest upheld'; // TODO: Make this configurable + + penaltyCommand.reason = stewardNotes || 'Protest dismissed'; await protestService.applyPenalty(penaltyCommand); } @@ -406,16 +404,16 @@ export default function ProtestReviewPage() { {race.formattedDate} - {/* TODO: Add lap info when available */} - {/*
- - Lap {protest.incident.lap} -
*/} + {protest.incident?.lap && ( +
+ + Lap {protest.incident.lap} +
+ )} - {/* TODO: Add evidence when available */} - {/* {protest.proofVideoUrl && ( + {protest.proofVideoUrl && (

Evidence

- )} */} + )} {/* Quick Stats */} @@ -479,13 +477,12 @@ export default function ProtestReviewPage() {

{protest.description}

- {/* TODO: Add comment when available */} - {/* {protest.comment && ( + {protest.comment && (

Additional details:

{protest.comment}

- )} */} + )}
diff --git a/apps/website/app/races/[id]/page.test.tsx b/apps/website/app/races/[id]/page.test.tsx new file mode 100644 index 000000000..2912a9f09 --- /dev/null +++ b/apps/website/app/races/[id]/page.test.tsx @@ -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: () =>
, + 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(); + + 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(); + + 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(); + + await waitFor(() => { + expect(mockGetRaceDetail).toHaveBeenCalled(); + }); + + expect(screen.queryByText('Re-open Race')).toBeNull(); + }); +}); diff --git a/apps/website/app/races/[id]/page.tsx b/apps/website/app/races/[id]/page.tsx index a898ee68c..c46fa49fc 100644 --- a/apps/website/app/races/[id]/page.tsx +++ b/apps/website/app/races/[id]/page.tsx @@ -44,6 +44,7 @@ export default function RaceDetailPage() { const [error, setError] = useState(null); const [cancelling, setCancelling] = useState(false); const [registering, setRegistering] = useState(false); + const [reopening, setReopening] = useState(false); const [ratingChange, setRatingChange] = useState(null); const [animatedRatingChange, setAnimatedRatingChange] = useState(0); 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) => { return new Date(date).toLocaleDateString('en-US', { weekday: 'long', @@ -856,6 +878,19 @@ export default function RaceDetailPage() { )} + {viewModel.canReopenRace && + LeagueMembershipUtility.isOwnerOrAdmin(viewModel.league?.id || '', currentDriverId) && ( + + )} + {race.status === 'completed' && ( <> - {LeagueMembershipUtility.isOwnerOrAdmin(viewModel.league?.id || '', currentDriverId) && ( - <> - - - )} )} + {viewModel.canReopenRace && + LeagueMembershipUtility.isOwnerOrAdmin(viewModel.league?.id || '', currentDriverId) && ( + + )} + {race.status === 'running' && LeagueMembershipUtility.isOwnerOrAdmin(viewModel.league?.id || '', currentDriverId) && (
@@ -259,8 +272,19 @@ export default function TeamDetailPage() {

Quick Stats

- {/* TODO: Get league count */} - {/* TODO: Get founded date */} + {leagueCount > 0 && ( + + )} + {team.createdAt && ( + + )}
@@ -285,7 +309,7 @@ export default function TeamDetailPage() { )} {activeTab === 'standings' && ( - + )} {activeTab === 'admin' && isAdmin && ( diff --git a/apps/website/app/teams/page.tsx b/apps/website/app/teams/page.tsx index 2527b2b4a..4cc10c4ed 100644 --- a/apps/website/app/teams/page.tsx +++ b/apps/website/app/teams/page.tsx @@ -451,9 +451,29 @@ export default function TeamsPage() { const { teamService } = useServices(); const teams = await teamService.getAllTeams(); setRealTeams(teams); - // TODO: set groups and top teams from service or compute locally - setGroupsBySkillLevel({}); - setTopTeams([]); + + // Derive groups by skill level from the loaded teams + const byLevel: Record = { + 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) { console.error('Failed to load teams:', error); } finally { diff --git a/apps/website/components/drivers/DriverIdentity.tsx b/apps/website/components/drivers/DriverIdentity.tsx index 7fa16ea82..ad19ed8e8 100644 --- a/apps/website/components/drivers/DriverIdentity.tsx +++ b/apps/website/components/drivers/DriverIdentity.tsx @@ -1,6 +1,6 @@ import Link from 'next/link'; 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 { driver: DriverDTO; diff --git a/apps/website/components/leagues/LeagueHeader.test.tsx b/apps/website/components/leagues/LeagueHeader.test.tsx new file mode 100644 index 000000000..c4b6b0e80 --- /dev/null +++ b/apps/website/components/leagues/LeagueHeader.test.tsx @@ -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( + + ); + + 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( + + ); + + expect(screen.getByText('League Without Details')).toBeInTheDocument(); + }); +}); diff --git a/apps/website/components/leagues/LeagueHeader.tsx b/apps/website/components/leagues/LeagueHeader.tsx index ee3ba7e66..0b80f4ef9 100644 --- a/apps/website/components/leagues/LeagueHeader.tsx +++ b/apps/website/components/leagues/LeagueHeader.tsx @@ -2,12 +2,7 @@ import MembershipStatus from '@/components/leagues/MembershipStatus'; 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 interface MainSponsorInfo { @@ -35,30 +30,6 @@ export default function LeagueHeader({ const imageService = getImageService(); const logoUrl = imageService.getLeagueLogo(leagueId); - const [ownerDriver, setOwnerDriver] = useState(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 (
{/* League header with logo - no cover image */} diff --git a/apps/website/components/leagues/LeagueMembers.test.tsx b/apps/website/components/leagues/LeagueMembers.test.tsx new file mode 100644 index 000000000..4efa2a7a9 --- /dev/null +++ b/apps/website/components/leagues/LeagueMembers.test.tsx @@ -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>(); +const mockGetLeagueMembers = vi.fn<(leagueId: string) => any[]>(); +const mockFindByIds = vi.fn<(ids: string[]) => Promise>(); + +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(); + + // 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(); + + await waitFor(() => { + expect(screen.queryByText('Loading members...')).not.toBeInTheDocument(); + }); + + expect(screen.getByText('No members found')).toBeInTheDocument(); + }); +}); diff --git a/apps/website/components/leagues/LeagueMembers.tsx b/apps/website/components/leagues/LeagueMembers.tsx index 7f76b8a89..997ab3516 100644 --- a/apps/website/components/leagues/LeagueMembers.tsx +++ b/apps/website/components/leagues/LeagueMembers.tsx @@ -1,17 +1,14 @@ 'use client'; -import DriverIdentity from '@/components/drivers/DriverIdentity'; -import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId'; -import { - getLeagueMembers, - type LeagueMembership, - type MembershipRole, -} from '@/lib/leagueMembership'; -import type { DriverDTO } from '@core/racing/application/dto/DriverDTO'; -import { EntityMappers } from '@core/racing/application/mappers/EntityMappers'; +import DriverIdentity from '../drivers/DriverIdentity'; +import { useEffectiveDriverId } from '../../hooks/useEffectiveDriverId'; +import { useServices } from '../../lib/services/ServiceProvider'; +import type { LeagueMembership } from '@core/racing/domain/entities/LeagueMembership'; +import type { MembershipRole } from '@core/racing/domain/entities/MembershipRole'; +import type { DriverDTO } from '@/lib/types/generated/DriverDTO'; 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 { leagueId: string; @@ -31,32 +28,33 @@ export default function LeagueMembers({ const [loading, setLoading] = useState(true); const [sortBy, setSortBy] = useState<'role' | 'name' | 'date' | 'rating' | 'points' | 'wins'>('rating'); const currentDriverId = useEffectiveDriverId(); + const { leagueMembershipService, driverService } = useServices(); const loadMembers = useCallback(async () => { setLoading(true); try { - const membershipData = getLeagueMembers(leagueId); + await leagueMembershipService.fetchLeagueMemberships(leagueId); + const membershipData = leagueMembershipService.getLeagueMembers(leagueId); setMembers(membershipData); - const driverRepo = getDriverRepository(); - const driverEntities = await Promise.all( - membershipData.map((m) => driverRepo.findById(m.driverId)) - ); - const driverDtos = driverEntities - .map((driver) => (driver ? EntityMappers.toDriverDTO(driver) : null)) - .filter((dto): dto is DriverDTO => dto !== null); + const uniqueDriverIds = Array.from(new Set(membershipData.map((m) => m.driverId))); + if (uniqueDriverIds.length > 0) { + const driverDtos = await driverService.findByIds(uniqueDriverIds); - const byId: Record = {}; - for (const dto of driverDtos) { - byId[dto.id] = dto; + const byId: Record = {}; + for (const dto of driverDtos) { + byId[dto.id] = dto; + } + setDriversById(byId); + } else { + setDriversById({}); } - setDriversById(byId); } catch (error) { console.error('Failed to load members:', error); } finally { setLoading(false); } - }, [leagueId]); + }, [leagueId, leagueMembershipService, driverService]); useEffect(() => { loadMembers(); diff --git a/apps/website/components/profile/DriverSummaryPill.tsx b/apps/website/components/profile/DriverSummaryPill.tsx index 128833b89..76e84c177 100644 --- a/apps/website/components/profile/DriverSummaryPill.tsx +++ b/apps/website/components/profile/DriverSummaryPill.tsx @@ -2,7 +2,7 @@ import Image from 'next/image'; 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'; export interface DriverSummaryPillProps { diff --git a/apps/website/components/profile/UserPill.test.tsx b/apps/website/components/profile/UserPill.test.tsx new file mode 100644 index 000000000..167197d7b --- /dev/null +++ b/apps/website/components/profile/UserPill.test.tsx @@ -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>(); +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(); + + 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(); + + 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(); + + await waitFor(() => { + expect(screen.getByText('Test Driver')).toBeInTheDocument(); + }); + + expect(mockFindById).toHaveBeenCalledWith('driver-1'); + expect(mockGetDriverAvatar).toHaveBeenCalledWith('driver-1'); + }); +}); diff --git a/apps/website/components/profile/UserPill.tsx b/apps/website/components/profile/UserPill.tsx index ad12e1dea..26e8f310c 100644 --- a/apps/website/components/profile/UserPill.tsx +++ b/apps/website/components/profile/UserPill.tsx @@ -8,10 +8,8 @@ import { useEffect, useMemo, useState } from 'react'; import DriverSummaryPill from '@/components/profile/DriverSummaryPill'; import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId'; -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. +import type { DriverDTO } from '@/lib/types/generated/DriverDTO'; +import { useServices } from '@/lib/services/ServiceProvider'; // Hook to detect sponsor mode function useSponsorMode(): boolean { @@ -84,6 +82,7 @@ function SponsorSummaryPill({ export default function UserPill() { const { session } = useAuth(); + const { driverService, mediaService } = useServices(); const [driver, setDriver] = useState(null); const [isMenuOpen, setIsMenuOpen] = useState(false); const isSponsorMode = useSponsorMode(); @@ -103,19 +102,18 @@ export default function UserPill() { return; } - const repo = getDriverRepository(); - const entity = await repo.findById(primaryDriverId); + const dto = await driverService.findById(primaryDriverId); if (!cancelled) { - setDriver(EntityMappers.toDriverDTO(entity)); + setDriver(dto); } } - loadDriver(); + void loadDriver(); return () => { cancelled = true; }; - }, [primaryDriverId]); + }, [primaryDriverId, driverService]); const data = useMemo(() => { if (!session?.user || !primaryDriverId || !driver) { @@ -153,7 +151,7 @@ export default function UserPill() { } } - const avatarSrc = getImageService().getDriverAvatar(primaryDriverId); + const avatarSrc = mediaService.getDriverAvatar(primaryDriverId); return { driver, @@ -161,7 +159,7 @@ export default function UserPill() { rating, rank, }; - }, [session, driver, primaryDriverId]); + }, [session, driver, primaryDriverId, mediaService]); // Close menu when clicking outside useEffect(() => { diff --git a/apps/website/lib/api/races/RacesApiClient.ts b/apps/website/lib/api/races/RacesApiClient.ts index a7b390652..650fba9ea 100644 --- a/apps/website/lib/api/races/RacesApiClient.ts +++ b/apps/website/lib/api/races/RacesApiClient.ts @@ -85,4 +85,9 @@ export class RacesApiClient extends BaseApiClient { complete(raceId: string): Promise { return this.post(`/races/${raceId}/complete`, {}); } + + /** Re-open race */ + reopen(raceId: string): Promise { + return this.post(`/races/${raceId}/reopen`, {}); + } } \ No newline at end of file diff --git a/apps/website/lib/services/races/RaceService.test.ts b/apps/website/lib/services/races/RaceService.test.ts index d70fd35be..5a3504f29 100644 --- a/apps/website/lib/services/races/RaceService.test.ts +++ b/apps/website/lib/services/races/RaceService.test.ts @@ -14,7 +14,12 @@ describe('RaceService', () => { getDetail: vi.fn(), getPageData: vi.fn(), getTotal: vi.fn(), - } as Mocked; + register: vi.fn(), + withdraw: vi.fn(), + cancel: vi.fn(), + complete: vi.fn(), + reopen: vi.fn(), + } as unknown as Mocked; service = new RaceService(mockApiClient); }); @@ -131,4 +136,22 @@ describe('RaceService', () => { 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'); + }); + }); }); \ No newline at end of file diff --git a/apps/website/lib/services/races/RaceService.ts b/apps/website/lib/services/races/RaceService.ts index 35b575eac..097b6a8e8 100644 --- a/apps/website/lib/services/races/RaceService.ts +++ b/apps/website/lib/services/races/RaceService.ts @@ -78,6 +78,12 @@ export class RaceService { await this.apiClient.complete(raceId); } + /** + * Re-open a race + */ + async reopenRace(raceId: string): Promise { + await this.apiClient.reopen(raceId); + } /** * Find races by league ID diff --git a/apps/website/lib/view-models/ProtestViewModel.ts b/apps/website/lib/view-models/ProtestViewModel.ts index 0bf3fe0a7..fd347e57c 100644 --- a/apps/website/lib/view-models/ProtestViewModel.ts +++ b/apps/website/lib/view-models/ProtestViewModel.ts @@ -14,6 +14,9 @@ export class ProtestViewModel { status: string; reviewedAt?: string; decisionNotes?: string; + incident?: { lap?: number } | null; + proofVideoUrl?: string | null; + comment?: string | null; constructor(dto: ProtestDTO) { this.id = dto.id; diff --git a/apps/website/lib/view-models/RaceDetailViewModel.test.ts b/apps/website/lib/view-models/RaceDetailViewModel.test.ts index fabfe0e19..7b79fa7b9 100644 --- a/apps/website/lib/view-models/RaceDetailViewModel.test.ts +++ b/apps/website/lib/view-models/RaceDetailViewModel.test.ts @@ -252,6 +252,36 @@ describe('RaceDetailViewModel', () => { 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', () => { const viewModel = new RaceDetailViewModel({ race: createMockRace(), diff --git a/apps/website/lib/view-models/RaceDetailViewModel.ts b/apps/website/lib/view-models/RaceDetailViewModel.ts index 0e925f171..bdccf8a3a 100644 --- a/apps/website/lib/view-models/RaceDetailViewModel.ts +++ b/apps/website/lib/view-models/RaceDetailViewModel.ts @@ -70,4 +70,10 @@ export class RaceDetailViewModel { if (this.canRegister) return 'You can register for this race'; 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'; + } } \ No newline at end of file diff --git a/core/notifications/application/index.ts b/core/notifications/application/index.ts index c3078f4e1..8a3704d9b 100644 --- a/core/notifications/application/index.ts +++ b/core/notifications/application/index.ts @@ -5,27 +5,24 @@ */ // Use Cases -export * from './use-cases/SendNotificationUseCase'; -export * from './use-cases/MarkNotificationReadUseCase'; export * from './use-cases/GetUnreadNotificationsUseCase'; +export * from './use-cases/MarkNotificationReadUseCase'; export * from './use-cases/NotificationPreferencesUseCases'; +export * from './use-cases/SendNotificationUseCase'; // Ports -export * from './ports/INotificationGateway'; +export * from './ports/NotificationGateway'; // Re-export domain types for convenience export type { - Notification, - NotificationProps, - NotificationStatus, - NotificationData, - NotificationUrgency, - NotificationAction, + Notification, NotificationAction, NotificationData, NotificationProps, + NotificationStatus, NotificationUrgency } from '../domain/entities/Notification'; -export type { NotificationPreference, NotificationPreferenceProps, ChannelPreference, TypePreference } from '../domain/entities/NotificationPreference'; -export type { NotificationType, NotificationChannel } from '../domain/types/NotificationTypes'; -export { getNotificationTypeTitle, getNotificationTypePriority, getChannelDisplayName, isExternalChannel, DEFAULT_ENABLED_CHANNELS, ALL_CHANNELS } from '../domain/types/NotificationTypes'; +export type { ChannelPreference, NotificationPreference, NotificationPreferenceProps, TypePreference } from '../domain/entities/NotificationPreference'; +export { ALL_CHANNELS, DEFAULT_ENABLED_CHANNELS, getChannelDisplayName, getNotificationTypePriority, getNotificationTypeTitle, isExternalChannel } from '../domain/types/NotificationTypes'; +export type { NotificationChannel, NotificationType } from '../domain/types/NotificationTypes'; // Re-export repository interfaces +export type { INotificationPreferenceRepository } from '../domain/repositories/INotificationPreferenceRepository'; export type { INotificationRepository } from '../domain/repositories/INotificationRepository'; -export type { INotificationPreferenceRepository } from '../domain/repositories/INotificationPreferenceRepository'; \ No newline at end of file + diff --git a/core/notifications/application/ports/INotificationGateway.ts b/core/notifications/application/ports/NotificationGateway.ts similarity index 85% rename from core/notifications/application/ports/INotificationGateway.ts rename to core/notifications/application/ports/NotificationGateway.ts index a35d5f895..42dc99ed8 100644 --- a/core/notifications/application/ports/INotificationGateway.ts +++ b/core/notifications/application/ports/NotificationGateway.ts @@ -19,7 +19,7 @@ export interface NotificationDeliveryResult { attemptedAt: Date; } -export interface INotificationGateway { +export interface NotificationGateway { /** * Send a notification through this gateway's channel */ @@ -45,21 +45,21 @@ export interface INotificationGateway { * Registry for notification gateways * Allows routing notifications to the appropriate gateway based on channel */ -export interface INotificationGatewayRegistry { +export interface NotificationGatewayRegistry { /** * Register a gateway for a channel */ - register(gateway: INotificationGateway): void; + register(gateway: NotificationGateway): void; /** * Get gateway for a specific channel */ - getGateway(channel: NotificationChannel): INotificationGateway | null; + getGateway(channel: NotificationChannel): NotificationGateway | null; /** * Get all registered gateways */ - getAllGateways(): INotificationGateway[]; + getAllGateways(): NotificationGateway[]; /** * Send notification through appropriate gateway diff --git a/core/notifications/application/ports/INotificationService.ts b/core/notifications/application/ports/NotificationService.ts similarity index 81% rename from core/notifications/application/ports/INotificationService.ts rename to core/notifications/application/ports/NotificationService.ts index d67184a58..8a98a712e 100644 --- a/core/notifications/application/ports/INotificationService.ts +++ b/core/notifications/application/ports/NotificationService.ts @@ -1,5 +1,4 @@ -import type { NotificationType } from '../../domain/types/NotificationTypes'; -import type { NotificationChannel } from '../../domain/types/NotificationTypes'; +import type { NotificationChannel, NotificationType } from '../../domain/types/NotificationTypes'; export interface NotificationData { raceEventId?: string; @@ -36,6 +35,6 @@ export interface SendNotificationCommand { requiresResponse?: boolean; } -export interface INotificationService { +export interface NotificationService { sendNotification(command: SendNotificationCommand): Promise; } \ No newline at end of file diff --git a/core/notifications/application/use-cases/SendNotificationUseCase.ts b/core/notifications/application/use-cases/SendNotificationUseCase.ts index 0cc6c564f..03dc096c4 100644 --- a/core/notifications/application/use-cases/SendNotificationUseCase.ts +++ b/core/notifications/application/use-cases/SendNotificationUseCase.ts @@ -5,15 +5,14 @@ * based on their preferences. */ +import type { AsyncUseCase, Logger } from '@core/shared/application'; 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 { INotificationRepository } from '../../domain/repositories/INotificationRepository'; +import { Notification } from '../../domain/entities/Notification'; import type { INotificationPreferenceRepository } from '../../domain/repositories/INotificationPreferenceRepository'; -import type { INotificationGatewayRegistry, NotificationDeliveryResult } from '../ports/INotificationGateway'; -import type { NotificationType, NotificationChannel } from '../../domain/types/NotificationTypes'; +import type { INotificationRepository } from '../../domain/repositories/INotificationRepository'; +import type { NotificationChannel, NotificationType } from '../../domain/types/NotificationTypes'; +import type { NotificationDeliveryResult, NotificationGatewayRegistry } from '../ports/NotificationGateway'; export interface SendNotificationCommand { recipientId: string; @@ -48,7 +47,7 @@ export class SendNotificationUseCase implements AsyncUseCase { let mockSponsorshipRequestRepo: { @@ -78,7 +78,7 @@ describe('AcceptSponsorshipRequestUseCase', () => { mockSponsorshipRequestRepo as unknown as ISponsorshipRequestRepository, mockSeasonSponsorshipRepo as unknown as ISeasonSponsorshipRepository, mockSeasonRepo as unknown as ISeasonRepository, - mockNotificationService as unknown as INotificationService, + mockNotificationService as unknown as NotificationService, processPayment, mockWalletRepo as unknown as IWalletRepository, mockLeagueWalletRepo as unknown as ILeagueWalletRepository, diff --git a/core/racing/application/use-cases/AcceptSponsorshipRequestUseCase.ts b/core/racing/application/use-cases/AcceptSponsorshipRequestUseCase.ts index 7a45004d5..1b335f4ab 100644 --- a/core/racing/application/use-cases/AcceptSponsorshipRequestUseCase.ts +++ b/core/racing/application/use-cases/AcceptSponsorshipRequestUseCase.ts @@ -5,20 +5,19 @@ * This creates an active sponsorship and notifies the sponsor. */ -import type { Logger } from '@core/shared/application'; -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 { NotificationService } from '@/notifications/application/ports/NotificationService'; import type { IWalletRepository } from '@core/payments/domain/repositories/IWalletRepository'; -import type { ILeagueWalletRepository } from '../../domain/repositories/ILeagueWalletRepository'; -import { SeasonSponsorship } from '../../domain/entities/SeasonSponsorship'; -import type { AsyncUseCase } from '@core/shared/application'; +import type { AsyncUseCase, Logger } from '@core/shared/application'; import { Result } from '@core/shared/application/Result'; 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 { AcceptSponsorshipOutputPort } from '../ports/output/AcceptSponsorshipOutputPort'; import type { ProcessPaymentInputPort } from '../ports/input/ProcessPaymentInputPort'; +import type { AcceptSponsorshipOutputPort } from '../ports/output/AcceptSponsorshipOutputPort'; import type { ProcessPaymentOutputPort } from '../ports/output/ProcessPaymentOutputPort'; export class AcceptSponsorshipRequestUseCase @@ -27,7 +26,7 @@ export class AcceptSponsorshipRequestUseCase private readonly sponsorshipRequestRepo: ISponsorshipRequestRepository, private readonly seasonSponsorshipRepo: ISeasonSponsorshipRepository, private readonly seasonRepository: ISeasonRepository, - private readonly notificationService: INotificationService, + private readonly notificationService: NotificationService, private readonly paymentProcessor: (input: ProcessPaymentInputPort) => Promise, private readonly walletRepository: IWalletRepository, private readonly leagueWalletRepository: ILeagueWalletRepository, diff --git a/core/racing/application/use-cases/SendFinalResultsUseCase.test.ts b/core/racing/application/use-cases/SendFinalResultsUseCase.test.ts index fb56c90f9..0caf4c31d 100644 --- a/core/racing/application/use-cases/SendFinalResultsUseCase.test.ts +++ b/core/racing/application/use-cases/SendFinalResultsUseCase.test.ts @@ -1,15 +1,15 @@ -import { describe, it, expect, vi } from 'vitest'; -import { SendFinalResultsUseCase } from './SendFinalResultsUseCase'; -import type { INotificationService } from '../../../notifications/application/ports/INotificationService'; +import { describe, expect, it, vi } from 'vitest'; +import type { NotificationService } from '../../../notifications/application/ports/NotificationService'; +import type { RaceEventStewardingClosedEvent } from '../../domain/events/RaceEventStewardingClosed'; import type { IRaceEventRepository } from '../../domain/repositories/IRaceEventRepository'; import type { IResultRepository } from '../../domain/repositories/IResultRepository'; -import type { RaceEventStewardingClosedEvent } from '../../domain/events/RaceEventStewardingClosed'; +import { SendFinalResultsUseCase } from './SendFinalResultsUseCase'; describe('SendFinalResultsUseCase', () => { it('sends final results notifications to all participating drivers', async () => { const mockNotificationService = { sendNotification: vi.fn(), - } as unknown as INotificationService; + } as unknown as NotificationService; const mockRaceEvent = { id: 'race-1', @@ -107,7 +107,7 @@ describe('SendFinalResultsUseCase', () => { it('skips sending notifications if race event not found', async () => { const mockNotificationService = { sendNotification: vi.fn(), - } as unknown as INotificationService; + } as unknown as NotificationService; const mockRaceEventRepository = { findById: vi.fn().mockResolvedValue(null), @@ -146,7 +146,7 @@ describe('SendFinalResultsUseCase', () => { it('skips sending notifications if no main race session', async () => { const mockNotificationService = { sendNotification: vi.fn(), - } as unknown as INotificationService; + } as unknown as NotificationService; const mockRaceEvent = { id: 'race-1', diff --git a/core/racing/application/use-cases/SendFinalResultsUseCase.ts b/core/racing/application/use-cases/SendFinalResultsUseCase.ts index 3567ac508..ad976407c 100644 --- a/core/racing/application/use-cases/SendFinalResultsUseCase.ts +++ b/core/racing/application/use-cases/SendFinalResultsUseCase.ts @@ -1,12 +1,12 @@ import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; -import type { INotificationService } from '../../../notifications/application/ports/INotificationService'; -import type { IRaceEventRepository } from '../../domain/repositories/IRaceEventRepository'; -import type { IResultRepository } from '../../domain/repositories/IResultRepository'; -import type { RaceEventStewardingClosedEvent } from '../../domain/events/RaceEventStewardingClosed'; +import type { NotificationService } from '../../../notifications/application/ports/NotificationService'; import type { NotificationType } from '../../../notifications/domain/types/NotificationTypes'; import type { RaceEvent } from '../../domain/entities/RaceEvent'; 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 @@ -17,7 +17,7 @@ import type { Result as RaceResult } from '../../domain/entities/Result'; */ export class SendFinalResultsUseCase { constructor( - private readonly notificationService: INotificationService, + private readonly notificationService: NotificationService, private readonly raceEventRepository: IRaceEventRepository, private readonly resultRepository: IResultRepository, ) {} diff --git a/core/racing/application/use-cases/SendPerformanceSummaryUseCase.test.ts b/core/racing/application/use-cases/SendPerformanceSummaryUseCase.test.ts index a63c6789b..c9653a9c7 100644 --- a/core/racing/application/use-cases/SendPerformanceSummaryUseCase.test.ts +++ b/core/racing/application/use-cases/SendPerformanceSummaryUseCase.test.ts @@ -1,15 +1,15 @@ -import { describe, it, expect, vi } from 'vitest'; -import { SendPerformanceSummaryUseCase } from './SendPerformanceSummaryUseCase'; -import type { INotificationService } from '../../../notifications/application/ports/INotificationService'; +import { describe, expect, it, vi } from 'vitest'; +import type { NotificationService } from '../../../notifications/application/ports/NotificationService'; +import type { MainRaceCompletedEvent } from '../../domain/events/MainRaceCompleted'; import type { IRaceEventRepository } from '../../domain/repositories/IRaceEventRepository'; import type { IResultRepository } from '../../domain/repositories/IResultRepository'; -import type { MainRaceCompletedEvent } from '../../domain/events/MainRaceCompleted'; +import { SendPerformanceSummaryUseCase } from './SendPerformanceSummaryUseCase'; describe('SendPerformanceSummaryUseCase', () => { it('sends performance summary notifications to all participating drivers', async () => { const mockNotificationService = { sendNotification: vi.fn(), - } as unknown as INotificationService; + } as unknown as NotificationService; const mockRaceEvent = { id: 'race-1', @@ -106,7 +106,7 @@ describe('SendPerformanceSummaryUseCase', () => { it('skips sending notifications if race event not found', async () => { const mockNotificationService = { sendNotification: vi.fn(), - } as unknown as INotificationService; + } as unknown as NotificationService; const mockRaceEventRepository = { findById: vi.fn().mockResolvedValue(null), diff --git a/core/racing/application/use-cases/SendPerformanceSummaryUseCase.ts b/core/racing/application/use-cases/SendPerformanceSummaryUseCase.ts index 2ebb5d4af..c36d3a6b8 100644 --- a/core/racing/application/use-cases/SendPerformanceSummaryUseCase.ts +++ b/core/racing/application/use-cases/SendPerformanceSummaryUseCase.ts @@ -1,12 +1,12 @@ import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; -import type { INotificationService } from '../../../notifications/application/ports/INotificationService'; -import type { IRaceEventRepository } from '../../domain/repositories/IRaceEventRepository'; -import type { IResultRepository } from '../../domain/repositories/IResultRepository'; -import type { MainRaceCompletedEvent } from '../../domain/events/MainRaceCompleted'; +import type { NotificationService } from '../../../notifications/application/ports/NotificationService'; import type { NotificationType } from '../../../notifications/domain/types/NotificationTypes'; import type { RaceEvent } from '../../domain/entities/RaceEvent'; 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 @@ -16,7 +16,7 @@ import type { Result as RaceResult } from '../../domain/entities/Result'; */ export class SendPerformanceSummaryUseCase { constructor( - private readonly notificationService: INotificationService, + private readonly notificationService: NotificationService, private readonly raceEventRepository: IRaceEventRepository, private readonly resultRepository: IResultRepository, ) {} diff --git a/docs/architecture/CQRS.md b/docs/architecture/CQRS.md new file mode 100644 index 000000000..a50caef5f --- /dev/null +++ b/docs/architecture/CQRS.md @@ -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/ +└── / + └── 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. \ No newline at end of file