resolve todos in website
This commit is contained in:
@@ -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' })
|
||||||
|
|||||||
@@ -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],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
169
apps/website/app/races/[id]/page.test.tsx
Normal file
169
apps/website/app/races/[id]/page.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 ?? [];
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
41
apps/website/components/leagues/LeagueHeader.test.tsx
Normal file
41
apps/website/components/leagues/LeagueHeader.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 */}
|
||||||
|
|||||||
129
apps/website/components/leagues/LeagueMembers.test.tsx
Normal file
129
apps/website/components/leagues/LeagueMembers.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
120
apps/website/components/profile/UserPill.test.tsx
Normal file
120
apps/website/components/profile/UserPill.test.tsx
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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`, {});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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';
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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>;
|
||||||
}
|
}
|
||||||
@@ -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.');
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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,
|
||||||
) {}
|
) {}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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
244
docs/architecture/CQRS.md
Normal 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.
|
||||||
Reference in New Issue
Block a user