resolve todos in website

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -157,11 +157,12 @@ export default function TeamDetailPage() {
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 = [
MetricBuilders.members(memberships.length),
MetricBuilders.reach(memberships.length * 15),
MetricBuilders.races(0), // TODO: Get league count from team data
MetricBuilders.races(leagueCount),
MetricBuilders.engagement(82),
];
@@ -206,15 +207,27 @@ export default function TeamDetailPage() {
<div>
<div className="flex items-center gap-3 mb-2">
<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>
<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">
<span>{memberships.length} {memberships.length === 1 ? 'member' : 'members'}</span>
{/* TODO: Add created date when available */}
{/* TODO: Add league count when available */}
{team.createdAt && (
<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>
@@ -259,8 +272,19 @@ export default function TeamDetailPage() {
<h3 className="text-xl font-semibold text-white mb-4">Quick Stats</h3>
<div className="space-y-3">
<StatItem label="Members" value={memberships.length.toString()} color="text-primary-blue" />
<StatItem label="Leagues" value="0" color="text-green-400" /> {/* TODO: Get league count */}
<StatItem label="Founded" value="Unknown" color="text-gray-300" /> {/* TODO: Get founded date */}
{leagueCount > 0 && (
<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>
</Card>
</div>
@@ -285,7 +309,7 @@ export default function TeamDetailPage() {
)}
{activeTab === 'standings' && (
<TeamStandings teamId={teamId} leagues={[]} />
<TeamStandings teamId={teamId} leagues={team.leagues} />
)}
{activeTab === 'admin' && isAdmin && (

View File

@@ -451,9 +451,29 @@ export default function TeamsPage() {
const { teamService } = useServices();
const teams = await teamService.getAllTeams();
setRealTeams(teams);
// TODO: set groups and top teams from service or compute locally
setGroupsBySkillLevel({});
setTopTeams([]);
// Derive groups by skill level from the loaded teams
const byLevel: Record<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) {
console.error('Failed to load teams:', error);
} finally {

View File

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

View File

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

View File

@@ -2,12 +2,7 @@
import MembershipStatus from '@/components/leagues/MembershipStatus';
import Image from 'next/image';
import { useEffect, useState } from 'react';
import type { DriverDTO } from '@core/racing/application/dto/DriverDTO';
import { EntityMappers } from '@core/racing/application/mappers/EntityMappers';
// TODO EntityMapper is legacy. Must use ´useServices` hook.
// Main sponsor info for "by XYZ" display
interface MainSponsorInfo {
@@ -35,30 +30,6 @@ export default function LeagueHeader({
const imageService = getImageService();
const logoUrl = imageService.getLeagueLogo(leagueId);
const [ownerDriver, setOwnerDriver] = useState<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 (
<div className="mb-8">
{/* League header with logo - no cover image */}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -78,6 +78,12 @@ export class RaceService {
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

View File

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

View File

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

View File

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