This commit is contained in:
2025-12-08 23:52:36 +01:00
parent 2d0860d66c
commit 35f988f885
46 changed files with 4624 additions and 1041 deletions

View File

@@ -3,7 +3,12 @@ export type RaceDTO = {
leagueId: string;
scheduledAt: string;
track: string;
trackId?: string;
car: string;
carId?: string;
sessionType: 'practice' | 'qualifying' | 'race';
status: 'scheduled' | 'completed' | 'cancelled';
status: 'scheduled' | 'running' | 'completed' | 'cancelled';
strengthOfField?: number;
registeredCount?: number;
maxParticipants?: number;
};

View File

@@ -24,6 +24,11 @@ export * from './use-cases/RecalculateChampionshipStandingsUseCase';
export * from './use-cases/CreateLeagueWithSeasonAndScoringUseCase';
export * from './use-cases/GetLeagueFullConfigQuery';
export * from './use-cases/PreviewLeagueScheduleQuery';
export * from './use-cases/GetRaceWithSOFQuery';
export * from './use-cases/GetLeagueStatsQuery';
// Export ports
export * from './ports/DriverRatingProvider';
// Re-export domain types for legacy callers (type-only)
export type {

View File

@@ -76,9 +76,14 @@ export class EntityMappers {
leagueId: race.leagueId,
scheduledAt: race.scheduledAt.toISOString(),
track: race.track,
trackId: race.trackId,
car: race.car,
carId: race.carId,
sessionType: race.sessionType,
status: race.status,
strengthOfField: race.strengthOfField,
registeredCount: race.registeredCount,
maxParticipants: race.maxParticipants,
};
}
@@ -88,9 +93,14 @@ export class EntityMappers {
leagueId: race.leagueId,
scheduledAt: race.scheduledAt.toISOString(),
track: race.track,
trackId: race.trackId,
car: race.car,
carId: race.carId,
sessionType: race.sessionType,
status: race.status,
strengthOfField: race.strengthOfField,
registeredCount: race.registeredCount,
maxParticipants: race.maxParticipants,
}));
}

View File

@@ -0,0 +1,20 @@
/**
* Application Port: DriverRatingProvider
*
* Port for looking up driver ratings.
* Implemented by infrastructure adapters that connect to rating systems.
*/
export interface DriverRatingProvider {
/**
* Get the rating for a single driver
* Returns null if driver has no rating
*/
getRating(driverId: string): number | null;
/**
* Get ratings for multiple drivers
* Returns a map of driverId -> rating
*/
getRatings(driverIds: string[]): Map<string, number>;
}

View File

@@ -114,7 +114,7 @@ export class GetLeagueScoringConfigQuery {
for (const [sessionType, table] of Object.entries(tables)) {
for (let pos = 1; pos <= maxPositions; pos++) {
const points = table.getPoints(pos);
const points = table.getPointsForPosition(pos);
if (points && points !== 0) {
preview.push({
sessionType,

View File

@@ -0,0 +1,99 @@
/**
* Application Query: GetLeagueStatsQuery
*
* Returns league statistics including average SOF across completed races.
*/
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { IResultRepository } from '../../domain/repositories/IResultRepository';
import type { DriverRatingProvider } from '../ports/DriverRatingProvider';
import {
AverageStrengthOfFieldCalculator,
type StrengthOfFieldCalculator,
} from '../../domain/services/StrengthOfFieldCalculator';
export interface GetLeagueStatsQueryParams {
leagueId: string;
}
export interface LeagueStatsDTO {
leagueId: string;
totalRaces: number;
completedRaces: number;
scheduledRaces: number;
averageSOF: number | null;
highestSOF: number | null;
lowestSOF: number | null;
}
export class GetLeagueStatsQuery {
private readonly sofCalculator: StrengthOfFieldCalculator;
constructor(
private readonly leagueRepository: ILeagueRepository,
private readonly raceRepository: IRaceRepository,
private readonly resultRepository: IResultRepository,
private readonly driverRatingProvider: DriverRatingProvider,
sofCalculator?: StrengthOfFieldCalculator,
) {
this.sofCalculator = sofCalculator ?? new AverageStrengthOfFieldCalculator();
}
async execute(params: GetLeagueStatsQueryParams): Promise<LeagueStatsDTO | null> {
const { leagueId } = params;
const league = await this.leagueRepository.findById(leagueId);
if (!league) {
return null;
}
const races = await this.raceRepository.findByLeagueId(leagueId);
const completedRaces = races.filter(r => r.status === 'completed');
const scheduledRaces = races.filter(r => r.status === 'scheduled');
// Calculate SOF for each completed race
const sofValues: number[] = [];
for (const race of completedRaces) {
// Use stored SOF if available
if (race.strengthOfField) {
sofValues.push(race.strengthOfField);
continue;
}
// Otherwise calculate from results
const results = await this.resultRepository.findByRaceId(race.id);
if (results.length === 0) continue;
const driverIds = results.map(r => r.driverId);
const ratings = this.driverRatingProvider.getRatings(driverIds);
const driverRatings = driverIds
.filter(id => ratings.has(id))
.map(id => ({ driverId: id, rating: ratings.get(id)! }));
const sof = this.sofCalculator.calculate(driverRatings);
if (sof !== null) {
sofValues.push(sof);
}
}
// Calculate aggregate stats
const averageSOF = sofValues.length > 0
? Math.round(sofValues.reduce((a, b) => a + b, 0) / sofValues.length)
: null;
const highestSOF = sofValues.length > 0 ? Math.max(...sofValues) : null;
const lowestSOF = sofValues.length > 0 ? Math.min(...sofValues) : null;
return {
leagueId,
totalRaces: races.length,
completedRaces: completedRaces.length,
scheduledRaces: scheduledRaces.length,
averageSOF,
highestSOF,
lowestSOF,
};
}
}

View File

@@ -0,0 +1,88 @@
/**
* Application Query: GetRaceWithSOFQuery
*
* Returns race details enriched with calculated Strength of Field (SOF).
* SOF is calculated from participant ratings if not already stored on the race.
*/
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { IRaceRegistrationRepository } from '../../domain/repositories/IRaceRegistrationRepository';
import type { IResultRepository } from '../../domain/repositories/IResultRepository';
import type { DriverRatingProvider } from '../ports/DriverRatingProvider';
import {
AverageStrengthOfFieldCalculator,
type StrengthOfFieldCalculator,
} from '../../domain/services/StrengthOfFieldCalculator';
import type { RaceDTO } from '../dto/RaceDTO';
export interface GetRaceWithSOFQueryParams {
raceId: string;
}
export interface RaceWithSOFDTO extends Omit<RaceDTO, 'strengthOfField'> {
strengthOfField: number | null;
participantCount: number;
}
export class GetRaceWithSOFQuery {
private readonly sofCalculator: StrengthOfFieldCalculator;
constructor(
private readonly raceRepository: IRaceRepository,
private readonly registrationRepository: IRaceRegistrationRepository,
private readonly resultRepository: IResultRepository,
private readonly driverRatingProvider: DriverRatingProvider,
sofCalculator?: StrengthOfFieldCalculator,
) {
this.sofCalculator = sofCalculator ?? new AverageStrengthOfFieldCalculator();
}
async execute(params: GetRaceWithSOFQueryParams): Promise<RaceWithSOFDTO | null> {
const { raceId } = params;
const race = await this.raceRepository.findById(raceId);
if (!race) {
return null;
}
// Get participant IDs based on race status
let participantIds: string[] = [];
if (race.status === 'completed') {
// For completed races, use results
const results = await this.resultRepository.findByRaceId(raceId);
participantIds = results.map(r => r.driverId);
} else {
// For upcoming/running races, use registrations
participantIds = await this.registrationRepository.getRegisteredDrivers(raceId);
}
// Use stored SOF if available, otherwise calculate
let strengthOfField = race.strengthOfField ?? null;
if (strengthOfField === null && participantIds.length > 0) {
const ratings = this.driverRatingProvider.getRatings(participantIds);
const driverRatings = participantIds
.filter(id => ratings.has(id))
.map(id => ({ driverId: id, rating: ratings.get(id)! }));
strengthOfField = this.sofCalculator.calculate(driverRatings);
}
return {
id: race.id,
leagueId: race.leagueId,
scheduledAt: race.scheduledAt.toISOString(),
track: race.track,
trackId: race.trackId,
car: race.car,
carId: race.carId,
sessionType: race.sessionType,
status: race.status,
strengthOfField,
registeredCount: race.registeredCount ?? participantIds.length,
maxParticipants: race.maxParticipants,
participantCount: participantIds.length,
};
}
}

View File

@@ -0,0 +1,130 @@
/**
* Domain Entity: Car
*
* Represents a racing car/vehicle in the GridPilot platform.
* Immutable entity with factory methods and domain validation.
*/
export type CarClass = 'formula' | 'gt' | 'prototype' | 'touring' | 'sports' | 'oval' | 'dirt';
export type CarLicense = 'R' | 'D' | 'C' | 'B' | 'A' | 'Pro';
export class Car {
readonly id: string;
readonly name: string;
readonly shortName: string;
readonly manufacturer: string;
readonly carClass: CarClass;
readonly license: CarLicense;
readonly year: number;
readonly horsepower?: number;
readonly weight?: number;
readonly imageUrl?: string;
readonly gameId: string;
private constructor(props: {
id: string;
name: string;
shortName: string;
manufacturer: string;
carClass: CarClass;
license: CarLicense;
year: number;
horsepower?: number;
weight?: number;
imageUrl?: string;
gameId: string;
}) {
this.id = props.id;
this.name = props.name;
this.shortName = props.shortName;
this.manufacturer = props.manufacturer;
this.carClass = props.carClass;
this.license = props.license;
this.year = props.year;
this.horsepower = props.horsepower;
this.weight = props.weight;
this.imageUrl = props.imageUrl;
this.gameId = props.gameId;
}
/**
* Factory method to create a new Car entity
*/
static create(props: {
id: string;
name: string;
shortName?: string;
manufacturer: string;
carClass?: CarClass;
license?: CarLicense;
year?: number;
horsepower?: number;
weight?: number;
imageUrl?: string;
gameId: string;
}): Car {
this.validate(props);
return new Car({
id: props.id,
name: props.name,
shortName: props.shortName ?? props.name.slice(0, 10),
manufacturer: props.manufacturer,
carClass: props.carClass ?? 'gt',
license: props.license ?? 'D',
year: props.year ?? new Date().getFullYear(),
horsepower: props.horsepower,
weight: props.weight,
imageUrl: props.imageUrl,
gameId: props.gameId,
});
}
/**
* Domain validation logic
*/
private static validate(props: {
id: string;
name: string;
manufacturer: string;
gameId: string;
}): void {
if (!props.id || props.id.trim().length === 0) {
throw new Error('Car ID is required');
}
if (!props.name || props.name.trim().length === 0) {
throw new Error('Car name is required');
}
if (!props.manufacturer || props.manufacturer.trim().length === 0) {
throw new Error('Car manufacturer is required');
}
if (!props.gameId || props.gameId.trim().length === 0) {
throw new Error('Game ID is required');
}
}
/**
* Get formatted car display name
*/
getDisplayName(): string {
return `${this.manufacturer} ${this.name}`;
}
/**
* Get license badge color
*/
getLicenseColor(): string {
const colors: Record<CarLicense, string> = {
'R': '#FF6B6B',
'D': '#FFB347',
'C': '#FFD700',
'B': '#7FFF00',
'A': '#00BFFF',
'Pro': '#9370DB',
};
return colors[this.license];
}
}

View File

@@ -0,0 +1,146 @@
/**
* Domain Entity: Protest
*
* Represents a protest filed by a driver against another driver for an incident during a race.
*/
export type ProtestStatus = 'pending' | 'under_review' | 'upheld' | 'dismissed' | 'withdrawn';
export interface ProtestIncident {
/** Lap number where the incident occurred */
lap: number;
/** Time in the race (seconds from start, or timestamp) */
timeInRace?: number;
/** Brief description of the incident */
description: string;
}
export interface ProtestProps {
id: string;
raceId: string;
/** The driver filing the protest */
protestingDriverId: string;
/** The driver being protested against */
accusedDriverId: string;
/** Details of the incident */
incident: ProtestIncident;
/** Optional comment/statement from the protesting driver */
comment?: string;
/** URL to proof video clip */
proofVideoUrl?: string;
/** Current status of the protest */
status: ProtestStatus;
/** ID of the steward/admin who reviewed (if any) */
reviewedBy?: string;
/** Decision notes from the steward */
decisionNotes?: string;
/** Timestamp when the protest was filed */
filedAt: Date;
/** Timestamp when the protest was reviewed */
reviewedAt?: Date;
}
export class Protest {
private constructor(private readonly props: ProtestProps) {}
static create(props: ProtestProps): Protest {
if (!props.id) throw new Error('Protest ID is required');
if (!props.raceId) throw new Error('Race ID is required');
if (!props.protestingDriverId) throw new Error('Protesting driver ID is required');
if (!props.accusedDriverId) throw new Error('Accused driver ID is required');
if (!props.incident) throw new Error('Incident details are required');
if (props.incident.lap < 0) throw new Error('Lap number must be non-negative');
if (!props.incident.description?.trim()) throw new Error('Incident description is required');
return new Protest({
...props,
status: props.status || 'pending',
filedAt: props.filedAt || new Date(),
});
}
get id(): string { return this.props.id; }
get raceId(): string { return this.props.raceId; }
get protestingDriverId(): string { return this.props.protestingDriverId; }
get accusedDriverId(): string { return this.props.accusedDriverId; }
get incident(): ProtestIncident { return { ...this.props.incident }; }
get comment(): string | undefined { return this.props.comment; }
get proofVideoUrl(): string | undefined { return this.props.proofVideoUrl; }
get status(): ProtestStatus { return this.props.status; }
get reviewedBy(): string | undefined { return this.props.reviewedBy; }
get decisionNotes(): string | undefined { return this.props.decisionNotes; }
get filedAt(): Date { return this.props.filedAt; }
get reviewedAt(): Date | undefined { return this.props.reviewedAt; }
isPending(): boolean {
return this.props.status === 'pending';
}
isUnderReview(): boolean {
return this.props.status === 'under_review';
}
isResolved(): boolean {
return ['upheld', 'dismissed', 'withdrawn'].includes(this.props.status);
}
/**
* Start reviewing the protest
*/
startReview(stewardId: string): Protest {
if (!this.isPending()) {
throw new Error('Only pending protests can be put under review');
}
return new Protest({
...this.props,
status: 'under_review',
reviewedBy: stewardId,
});
}
/**
* Uphold the protest (finding the accused guilty)
*/
uphold(stewardId: string, decisionNotes: string): Protest {
if (!this.isPending() && !this.isUnderReview()) {
throw new Error('Only pending or under-review protests can be upheld');
}
return new Protest({
...this.props,
status: 'upheld',
reviewedBy: stewardId,
decisionNotes,
reviewedAt: new Date(),
});
}
/**
* Dismiss the protest (finding no fault)
*/
dismiss(stewardId: string, decisionNotes: string): Protest {
if (!this.isPending() && !this.isUnderReview()) {
throw new Error('Only pending or under-review protests can be dismissed');
}
return new Protest({
...this.props,
status: 'dismissed',
reviewedBy: stewardId,
decisionNotes,
reviewedAt: new Date(),
});
}
/**
* Withdraw the protest (by the protesting driver)
*/
withdraw(): Protest {
if (this.isResolved()) {
throw new Error('Cannot withdraw a resolved protest');
}
return new Protest({
...this.props,
status: 'withdrawn',
reviewedAt: new Date(),
});
}
}

View File

@@ -1,38 +1,53 @@
/**
* Domain Entity: Race
*
*
* Represents a race/session in the GridPilot platform.
* Immutable entity with factory methods and domain validation.
*/
export type SessionType = 'practice' | 'qualifying' | 'race';
export type RaceStatus = 'scheduled' | 'completed' | 'cancelled';
export type RaceStatus = 'scheduled' | 'running' | 'completed' | 'cancelled';
export class Race {
readonly id: string;
readonly leagueId: string;
readonly scheduledAt: Date;
readonly track: string;
readonly trackId?: string;
readonly car: string;
readonly carId?: string;
readonly sessionType: SessionType;
readonly status: RaceStatus;
readonly strengthOfField?: number;
readonly registeredCount?: number;
readonly maxParticipants?: number;
private constructor(props: {
id: string;
leagueId: string;
scheduledAt: Date;
track: string;
trackId?: string;
car: string;
carId?: string;
sessionType: SessionType;
status: RaceStatus;
strengthOfField?: number;
registeredCount?: number;
maxParticipants?: number;
}) {
this.id = props.id;
this.leagueId = props.leagueId;
this.scheduledAt = props.scheduledAt;
this.track = props.track;
this.trackId = props.trackId;
this.car = props.car;
this.carId = props.carId;
this.sessionType = props.sessionType;
this.status = props.status;
this.strengthOfField = props.strengthOfField;
this.registeredCount = props.registeredCount;
this.maxParticipants = props.maxParticipants;
}
/**
@@ -43,9 +58,14 @@ export class Race {
leagueId: string;
scheduledAt: Date;
track: string;
trackId?: string;
car: string;
carId?: string;
sessionType?: SessionType;
status?: RaceStatus;
strengthOfField?: number;
registeredCount?: number;
maxParticipants?: number;
}): Race {
this.validate(props);
@@ -54,9 +74,14 @@ export class Race {
leagueId: props.leagueId,
scheduledAt: props.scheduledAt,
track: props.track,
trackId: props.trackId,
car: props.car,
carId: props.carId,
sessionType: props.sessionType ?? 'race',
status: props.status ?? 'scheduled',
strengthOfField: props.strengthOfField,
registeredCount: props.registeredCount,
maxParticipants: props.maxParticipants,
});
}
@@ -91,6 +116,20 @@ export class Race {
}
}
/**
* Start the race (move from scheduled to running)
*/
start(): Race {
if (this.status !== 'scheduled') {
throw new Error('Only scheduled races can be started');
}
return new Race({
...this,
status: 'running',
});
}
/**
* Mark race as completed
*/
@@ -127,6 +166,17 @@ export class Race {
});
}
/**
* Update SOF and participant count
*/
updateField(strengthOfField: number, registeredCount: number): Race {
return new Race({
...this,
strengthOfField,
registeredCount,
});
}
/**
* Check if race is in the past
*/
@@ -140,4 +190,11 @@ export class Race {
isUpcoming(): boolean {
return this.status === 'scheduled' && !this.isPast();
}
/**
* Check if race is live/running
*/
isLive(): boolean {
return this.status === 'running';
}
}

View File

@@ -0,0 +1,120 @@
/**
* Domain Entity: Track
*
* Represents a racing track/circuit in the GridPilot platform.
* Immutable entity with factory methods and domain validation.
*/
export type TrackCategory = 'oval' | 'road' | 'street' | 'dirt';
export type TrackDifficulty = 'beginner' | 'intermediate' | 'advanced' | 'expert';
export class Track {
readonly id: string;
readonly name: string;
readonly shortName: string;
readonly country: string;
readonly category: TrackCategory;
readonly difficulty: TrackDifficulty;
readonly lengthKm: number;
readonly turns: number;
readonly imageUrl?: string;
readonly gameId: string;
private constructor(props: {
id: string;
name: string;
shortName: string;
country: string;
category: TrackCategory;
difficulty: TrackDifficulty;
lengthKm: number;
turns: number;
imageUrl?: string;
gameId: string;
}) {
this.id = props.id;
this.name = props.name;
this.shortName = props.shortName;
this.country = props.country;
this.category = props.category;
this.difficulty = props.difficulty;
this.lengthKm = props.lengthKm;
this.turns = props.turns;
this.imageUrl = props.imageUrl;
this.gameId = props.gameId;
}
/**
* Factory method to create a new Track entity
*/
static create(props: {
id: string;
name: string;
shortName?: string;
country: string;
category?: TrackCategory;
difficulty?: TrackDifficulty;
lengthKm: number;
turns: number;
imageUrl?: string;
gameId: string;
}): Track {
this.validate(props);
return new Track({
id: props.id,
name: props.name,
shortName: props.shortName ?? props.name.slice(0, 3).toUpperCase(),
country: props.country,
category: props.category ?? 'road',
difficulty: props.difficulty ?? 'intermediate',
lengthKm: props.lengthKm,
turns: props.turns,
imageUrl: props.imageUrl,
gameId: props.gameId,
});
}
/**
* Domain validation logic
*/
private static validate(props: {
id: string;
name: string;
country: string;
lengthKm: number;
turns: number;
gameId: string;
}): void {
if (!props.id || props.id.trim().length === 0) {
throw new Error('Track ID is required');
}
if (!props.name || props.name.trim().length === 0) {
throw new Error('Track name is required');
}
if (!props.country || props.country.trim().length === 0) {
throw new Error('Track country is required');
}
if (props.lengthKm <= 0) {
throw new Error('Track length must be positive');
}
if (props.turns < 0) {
throw new Error('Track turns cannot be negative');
}
if (!props.gameId || props.gameId.trim().length === 0) {
throw new Error('Game ID is required');
}
}
/**
* Get formatted length string
*/
getFormattedLength(): string {
return `${this.lengthKm.toFixed(2)} km`;
}
}

View File

@@ -0,0 +1,65 @@
/**
* Application Port: ICarRepository
*
* Repository interface for Car entity CRUD operations.
* Defines async methods using domain entities as types.
*/
import type { Car, CarClass, CarLicense } from '../entities/Car';
export interface ICarRepository {
/**
* Find a car by ID
*/
findById(id: string): Promise<Car | null>;
/**
* Find all cars
*/
findAll(): Promise<Car[]>;
/**
* Find cars by game ID
*/
findByGameId(gameId: string): Promise<Car[]>;
/**
* Find cars by class
*/
findByClass(carClass: CarClass): Promise<Car[]>;
/**
* Find cars by license level
*/
findByLicense(license: CarLicense): Promise<Car[]>;
/**
* Find cars by manufacturer
*/
findByManufacturer(manufacturer: string): Promise<Car[]>;
/**
* Search cars by name
*/
searchByName(query: string): Promise<Car[]>;
/**
* Create a new car
*/
create(car: Car): Promise<Car>;
/**
* Update an existing car
*/
update(car: Car): Promise<Car>;
/**
* Delete a car by ID
*/
delete(id: string): Promise<void>;
/**
* Check if a car exists by ID
*/
exists(id: string): Promise<boolean>;
}

View File

@@ -0,0 +1,60 @@
/**
* Application Port: ITrackRepository
*
* Repository interface for Track entity CRUD operations.
* Defines async methods using domain entities as types.
*/
import type { Track, TrackCategory } from '../entities/Track';
export interface ITrackRepository {
/**
* Find a track by ID
*/
findById(id: string): Promise<Track | null>;
/**
* Find all tracks
*/
findAll(): Promise<Track[]>;
/**
* Find tracks by game ID
*/
findByGameId(gameId: string): Promise<Track[]>;
/**
* Find tracks by category
*/
findByCategory(category: TrackCategory): Promise<Track[]>;
/**
* Find tracks by country
*/
findByCountry(country: string): Promise<Track[]>;
/**
* Search tracks by name
*/
searchByName(query: string): Promise<Track[]>;
/**
* Create a new track
*/
create(track: Track): Promise<Track>;
/**
* Update an existing track
*/
update(track: Track): Promise<Track>;
/**
* Delete a track by ID
*/
delete(id: string): Promise<void>;
/**
* Check if a track exists by ID
*/
exists(id: string): Promise<boolean>;
}

View File

@@ -0,0 +1,39 @@
/**
* Domain Service: StrengthOfFieldCalculator
*
* Calculates the Strength of Field (SOF) for a race based on participant ratings.
* SOF is the average rating of all participants in a race.
*/
export interface DriverRating {
driverId: string;
rating: number;
}
export interface StrengthOfFieldCalculator {
/**
* Calculate SOF from a list of driver ratings
* Returns null if no valid ratings are provided
*/
calculate(driverRatings: DriverRating[]): number | null;
}
/**
* Default implementation using simple average
*/
export class AverageStrengthOfFieldCalculator implements StrengthOfFieldCalculator {
calculate(driverRatings: DriverRating[]): number | null {
if (driverRatings.length === 0) {
return null;
}
const validRatings = driverRatings.filter(dr => dr.rating > 0);
if (validRatings.length === 0) {
return null;
}
const sum = validRatings.reduce((acc, dr) => acc + dr.rating, 0);
return Math.round(sum / validRatings.length);
}
}

View File

@@ -6,6 +6,8 @@ export * from './domain/entities/Standing';
export * from './domain/entities/LeagueMembership';
export * from './domain/entities/RaceRegistration';
export * from './domain/entities/Team';
export * from './domain/entities/Track';
export * from './domain/entities/Car';
export * from './domain/repositories/IDriverRepository';
export * from './domain/repositories/ILeagueRepository';
@@ -16,6 +18,10 @@ export * from './domain/repositories/ILeagueMembershipRepository';
export * from './domain/repositories/IRaceRegistrationRepository';
export * from './domain/repositories/ITeamRepository';
export * from './domain/repositories/ITeamMembershipRepository';
export * from './domain/repositories/ITrackRepository';
export * from './domain/repositories/ICarRepository';
export * from './domain/services/StrengthOfFieldCalculator';
export * from './application/mappers/EntityMappers';
export * from './application/dto/DriverDTO';

View File

@@ -0,0 +1,105 @@
/**
* Infrastructure Adapter: InMemoryCarRepository
*
* In-memory implementation of ICarRepository.
* Stores data in Map structure with UUID generation.
*/
import { v4 as uuidv4 } from 'uuid';
import { Car, CarClass, CarLicense } from '@gridpilot/racing/domain/entities/Car';
import type { ICarRepository } from '@gridpilot/racing/domain/repositories/ICarRepository';
export class InMemoryCarRepository implements ICarRepository {
private cars: Map<string, Car>;
constructor(seedData?: Car[]) {
this.cars = new Map();
if (seedData) {
seedData.forEach(car => {
this.cars.set(car.id, car);
});
}
}
async findById(id: string): Promise<Car | null> {
return this.cars.get(id) ?? null;
}
async findAll(): Promise<Car[]> {
return Array.from(this.cars.values());
}
async findByGameId(gameId: string): Promise<Car[]> {
return Array.from(this.cars.values())
.filter(car => car.gameId === gameId)
.sort((a, b) => a.name.localeCompare(b.name));
}
async findByClass(carClass: CarClass): Promise<Car[]> {
return Array.from(this.cars.values())
.filter(car => car.carClass === carClass)
.sort((a, b) => a.name.localeCompare(b.name));
}
async findByLicense(license: CarLicense): Promise<Car[]> {
return Array.from(this.cars.values())
.filter(car => car.license === license)
.sort((a, b) => a.name.localeCompare(b.name));
}
async findByManufacturer(manufacturer: string): Promise<Car[]> {
const lowerManufacturer = manufacturer.toLowerCase();
return Array.from(this.cars.values())
.filter(car => car.manufacturer.toLowerCase() === lowerManufacturer)
.sort((a, b) => a.name.localeCompare(b.name));
}
async searchByName(query: string): Promise<Car[]> {
const lowerQuery = query.toLowerCase();
return Array.from(this.cars.values())
.filter(car =>
car.name.toLowerCase().includes(lowerQuery) ||
car.shortName.toLowerCase().includes(lowerQuery) ||
car.manufacturer.toLowerCase().includes(lowerQuery)
)
.sort((a, b) => a.name.localeCompare(b.name));
}
async create(car: Car): Promise<Car> {
if (await this.exists(car.id)) {
throw new Error(`Car with ID ${car.id} already exists`);
}
this.cars.set(car.id, car);
return car;
}
async update(car: Car): Promise<Car> {
if (!await this.exists(car.id)) {
throw new Error(`Car with ID ${car.id} not found`);
}
this.cars.set(car.id, car);
return car;
}
async delete(id: string): Promise<void> {
if (!await this.exists(id)) {
throw new Error(`Car with ID ${id} not found`);
}
this.cars.delete(id);
}
async exists(id: string): Promise<boolean> {
return this.cars.has(id);
}
/**
* Utility method to generate a new UUID
*/
static generateId(): string {
return uuidv4();
}
}

View File

@@ -0,0 +1,97 @@
/**
* Infrastructure Adapter: InMemoryTrackRepository
*
* In-memory implementation of ITrackRepository.
* Stores data in Map structure with UUID generation.
*/
import { v4 as uuidv4 } from 'uuid';
import { Track, TrackCategory } from '@gridpilot/racing/domain/entities/Track';
import type { ITrackRepository } from '@gridpilot/racing/domain/repositories/ITrackRepository';
export class InMemoryTrackRepository implements ITrackRepository {
private tracks: Map<string, Track>;
constructor(seedData?: Track[]) {
this.tracks = new Map();
if (seedData) {
seedData.forEach(track => {
this.tracks.set(track.id, track);
});
}
}
async findById(id: string): Promise<Track | null> {
return this.tracks.get(id) ?? null;
}
async findAll(): Promise<Track[]> {
return Array.from(this.tracks.values());
}
async findByGameId(gameId: string): Promise<Track[]> {
return Array.from(this.tracks.values())
.filter(track => track.gameId === gameId)
.sort((a, b) => a.name.localeCompare(b.name));
}
async findByCategory(category: TrackCategory): Promise<Track[]> {
return Array.from(this.tracks.values())
.filter(track => track.category === category)
.sort((a, b) => a.name.localeCompare(b.name));
}
async findByCountry(country: string): Promise<Track[]> {
return Array.from(this.tracks.values())
.filter(track => track.country.toLowerCase() === country.toLowerCase())
.sort((a, b) => a.name.localeCompare(b.name));
}
async searchByName(query: string): Promise<Track[]> {
const lowerQuery = query.toLowerCase();
return Array.from(this.tracks.values())
.filter(track =>
track.name.toLowerCase().includes(lowerQuery) ||
track.shortName.toLowerCase().includes(lowerQuery)
)
.sort((a, b) => a.name.localeCompare(b.name));
}
async create(track: Track): Promise<Track> {
if (await this.exists(track.id)) {
throw new Error(`Track with ID ${track.id} already exists`);
}
this.tracks.set(track.id, track);
return track;
}
async update(track: Track): Promise<Track> {
if (!await this.exists(track.id)) {
throw new Error(`Track with ID ${track.id} not found`);
}
this.tracks.set(track.id, track);
return track;
}
async delete(id: string): Promise<void> {
if (!await this.exists(id)) {
throw new Error(`Track with ID ${id} not found`);
}
this.tracks.delete(id);
}
async exists(id: string): Promise<boolean> {
return this.tracks.has(id);
}
/**
* Utility method to generate a new UUID
*/
static generateId(): string {
return uuidv4();
}
}