rename to core
This commit is contained in:
133
core/racing/domain/entities/Car.ts
Normal file
133
core/racing/domain/entities/Car.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Domain Entity: Car
|
||||
*
|
||||
* Represents a racing car/vehicle in the GridPilot platform.
|
||||
* Immutable entity with factory methods and domain validation.
|
||||
*/
|
||||
|
||||
import type { IEntity } from '@gridpilot/shared/domain';
|
||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||
|
||||
export type CarClass = 'formula' | 'gt' | 'prototype' | 'touring' | 'sports' | 'oval' | 'dirt';
|
||||
export type CarLicense = 'R' | 'D' | 'C' | 'B' | 'A' | 'Pro';
|
||||
|
||||
export class Car implements IEntity<string> {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly shortName: string;
|
||||
readonly manufacturer: string;
|
||||
readonly carClass: CarClass;
|
||||
readonly license: CarLicense;
|
||||
readonly year: number;
|
||||
readonly horsepower: number | undefined;
|
||||
readonly weight: number | undefined;
|
||||
readonly imageUrl: string | undefined;
|
||||
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(),
|
||||
...(props.horsepower !== undefined ? { horsepower: props.horsepower } : {}),
|
||||
...(props.weight !== undefined ? { weight: props.weight } : {}),
|
||||
...(props.imageUrl !== undefined ? { 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 RacingDomainValidationError('Car ID is required');
|
||||
}
|
||||
|
||||
if (!props.name || props.name.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('Car name is required');
|
||||
}
|
||||
|
||||
if (!props.manufacturer || props.manufacturer.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('Car manufacturer is required');
|
||||
}
|
||||
|
||||
if (!props.gameId || props.gameId.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('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];
|
||||
}
|
||||
}
|
||||
41
core/racing/domain/entities/ChampionshipStanding.ts
Normal file
41
core/racing/domain/entities/ChampionshipStanding.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { ParticipantRef } from '../types/ParticipantRef';
|
||||
|
||||
export class ChampionshipStanding {
|
||||
readonly seasonId: string;
|
||||
readonly championshipId: string;
|
||||
readonly participant: ParticipantRef;
|
||||
readonly totalPoints: number;
|
||||
readonly resultsCounted: number;
|
||||
readonly resultsDropped: number;
|
||||
readonly position: number;
|
||||
|
||||
constructor(props: {
|
||||
seasonId: string;
|
||||
championshipId: string;
|
||||
participant: ParticipantRef;
|
||||
totalPoints: number;
|
||||
resultsCounted: number;
|
||||
resultsDropped: number;
|
||||
position: number;
|
||||
}) {
|
||||
this.seasonId = props.seasonId;
|
||||
this.championshipId = props.championshipId;
|
||||
this.participant = props.participant;
|
||||
this.totalPoints = props.totalPoints;
|
||||
this.resultsCounted = props.resultsCounted;
|
||||
this.resultsDropped = props.resultsDropped;
|
||||
this.position = props.position;
|
||||
}
|
||||
|
||||
withPosition(position: number): ChampionshipStanding {
|
||||
return new ChampionshipStanding({
|
||||
seasonId: this.seasonId,
|
||||
championshipId: this.championshipId,
|
||||
participant: this.participant,
|
||||
totalPoints: this.totalPoints,
|
||||
resultsCounted: this.resultsCounted,
|
||||
resultsDropped: this.resultsDropped,
|
||||
position,
|
||||
});
|
||||
}
|
||||
}
|
||||
110
core/racing/domain/entities/Driver.ts
Normal file
110
core/racing/domain/entities/Driver.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Domain Entity: Driver
|
||||
*
|
||||
* Represents a driver profile in the GridPilot platform.
|
||||
* Immutable entity with factory methods and domain validation.
|
||||
*/
|
||||
|
||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||
import type { IEntity } from '@gridpilot/shared/domain';
|
||||
|
||||
export class Driver implements IEntity<string> {
|
||||
readonly id: string;
|
||||
readonly iracingId: string;
|
||||
readonly name: string;
|
||||
readonly country: string;
|
||||
readonly bio: string | undefined;
|
||||
readonly joinedAt: Date;
|
||||
|
||||
private constructor(props: {
|
||||
id: string;
|
||||
iracingId: string;
|
||||
name: string;
|
||||
country: string;
|
||||
bio?: string;
|
||||
joinedAt: Date;
|
||||
}) {
|
||||
this.id = props.id;
|
||||
this.iracingId = props.iracingId;
|
||||
this.name = props.name;
|
||||
this.country = props.country;
|
||||
this.bio = props.bio;
|
||||
this.joinedAt = props.joinedAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory method to create a new Driver entity
|
||||
*/
|
||||
static create(props: {
|
||||
id: string;
|
||||
iracingId: string;
|
||||
name: string;
|
||||
country: string;
|
||||
bio?: string;
|
||||
joinedAt?: Date;
|
||||
}): Driver {
|
||||
this.validate(props);
|
||||
|
||||
return new Driver({
|
||||
id: props.id,
|
||||
iracingId: props.iracingId,
|
||||
name: props.name,
|
||||
country: props.country,
|
||||
...(props.bio !== undefined ? { bio: props.bio } : {}),
|
||||
joinedAt: props.joinedAt ?? new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Domain validation logic
|
||||
*/
|
||||
private static validate(props: {
|
||||
id: string;
|
||||
iracingId: string;
|
||||
name: string;
|
||||
country: string;
|
||||
}): void {
|
||||
if (!props.id || props.id.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('Driver ID is required');
|
||||
}
|
||||
|
||||
if (!props.iracingId || props.iracingId.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('iRacing ID is required');
|
||||
}
|
||||
|
||||
if (!props.name || props.name.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('Driver name is required');
|
||||
}
|
||||
|
||||
if (!props.country || props.country.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('Country code is required');
|
||||
}
|
||||
|
||||
// Validate ISO country code format (2-3 letters)
|
||||
if (!/^[A-Z]{2,3}$/i.test(props.country)) {
|
||||
throw new RacingDomainValidationError('Country must be a valid ISO code (2-3 letters)');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a copy with updated properties
|
||||
*/
|
||||
update(props: Partial<{
|
||||
name: string;
|
||||
country: string;
|
||||
bio?: string;
|
||||
}>): Driver {
|
||||
const nextName = props.name ?? this.name;
|
||||
const nextCountry = props.country ?? this.country;
|
||||
const nextBio = props.bio ?? this.bio;
|
||||
|
||||
return new Driver({
|
||||
id: this.id,
|
||||
iracingId: this.iracingId,
|
||||
name: nextName,
|
||||
country: nextCountry,
|
||||
...(nextBio !== undefined ? { bio: nextBio } : {}),
|
||||
joinedAt: this.joinedAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
255
core/racing/domain/entities/DriverLivery.ts
Normal file
255
core/racing/domain/entities/DriverLivery.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* Domain Entity: DriverLivery
|
||||
*
|
||||
* Represents a driver's custom livery for a specific car.
|
||||
* Includes user-placed decals and league-specific overrides.
|
||||
*/
|
||||
import { RacingDomainValidationError, RacingDomainInvariantError, RacingDomainError } from '../errors/RacingDomainError';
|
||||
import type { IEntity } from '@gridpilot/shared/domain';
|
||||
|
||||
import type { LiveryDecal } from '../value-objects/LiveryDecal';
|
||||
|
||||
export interface DecalOverride {
|
||||
leagueId: string;
|
||||
seasonId: string;
|
||||
decalId: string;
|
||||
newX: number;
|
||||
newY: number;
|
||||
}
|
||||
|
||||
export interface DriverLiveryProps {
|
||||
id: string;
|
||||
driverId: string;
|
||||
gameId: string;
|
||||
carId: string;
|
||||
uploadedImageUrl: string;
|
||||
userDecals: LiveryDecal[];
|
||||
leagueOverrides: DecalOverride[];
|
||||
createdAt: Date;
|
||||
updatedAt: Date | undefined;
|
||||
validatedAt: Date | undefined;
|
||||
}
|
||||
|
||||
export class DriverLivery implements IEntity<string> {
|
||||
readonly id: string;
|
||||
readonly driverId: string;
|
||||
readonly gameId: string;
|
||||
readonly carId: string;
|
||||
readonly uploadedImageUrl: string;
|
||||
readonly userDecals: LiveryDecal[];
|
||||
readonly leagueOverrides: DecalOverride[];
|
||||
readonly createdAt: Date;
|
||||
readonly updatedAt: Date | undefined;
|
||||
readonly validatedAt: Date | undefined;
|
||||
|
||||
private constructor(props: DriverLiveryProps) {
|
||||
this.id = props.id;
|
||||
this.driverId = props.driverId;
|
||||
this.gameId = props.gameId;
|
||||
this.carId = props.carId;
|
||||
this.uploadedImageUrl = props.uploadedImageUrl;
|
||||
this.userDecals = props.userDecals;
|
||||
this.leagueOverrides = props.leagueOverrides;
|
||||
this.createdAt = props.createdAt ?? new Date();
|
||||
this.updatedAt = props.updatedAt;
|
||||
this.validatedAt = props.validatedAt;
|
||||
}
|
||||
|
||||
static create(props: Omit<DriverLiveryProps, 'createdAt' | 'userDecals' | 'leagueOverrides'> & {
|
||||
createdAt?: Date;
|
||||
userDecals?: LiveryDecal[];
|
||||
leagueOverrides?: DecalOverride[];
|
||||
}): DriverLivery {
|
||||
this.validate(props);
|
||||
|
||||
return new DriverLivery({
|
||||
...props,
|
||||
createdAt: props.createdAt ?? new Date(),
|
||||
userDecals: props.userDecals ?? [],
|
||||
leagueOverrides: props.leagueOverrides ?? [],
|
||||
});
|
||||
}
|
||||
|
||||
private static validate(props: Omit<DriverLiveryProps, 'createdAt' | 'userDecals' | 'leagueOverrides'>): void {
|
||||
if (!props.id || props.id.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('DriverLivery ID is required');
|
||||
}
|
||||
|
||||
if (!props.driverId || props.driverId.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('DriverLivery driverId is required');
|
||||
}
|
||||
|
||||
if (!props.gameId || props.gameId.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('DriverLivery gameId is required');
|
||||
}
|
||||
|
||||
if (!props.carId || props.carId.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('DriverLivery carId is required');
|
||||
}
|
||||
|
||||
if (!props.uploadedImageUrl || props.uploadedImageUrl.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('DriverLivery uploadedImageUrl is required');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a user decal
|
||||
*/
|
||||
addDecal(decal: LiveryDecal): DriverLivery {
|
||||
if (decal.type !== 'user') {
|
||||
throw new RacingDomainInvariantError('Only user decals can be added to driver livery');
|
||||
}
|
||||
|
||||
return new DriverLivery({
|
||||
id: this.id,
|
||||
driverId: this.driverId,
|
||||
gameId: this.gameId,
|
||||
carId: this.carId,
|
||||
uploadedImageUrl: this.uploadedImageUrl,
|
||||
userDecals: [...this.userDecals, decal],
|
||||
leagueOverrides: this.leagueOverrides,
|
||||
createdAt: this.createdAt,
|
||||
updatedAt: new Date(),
|
||||
validatedAt: this.validatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a user decal
|
||||
*/
|
||||
removeDecal(decalId: string): DriverLivery {
|
||||
const updatedDecals = this.userDecals.filter(d => d.id !== decalId);
|
||||
|
||||
if (updatedDecals.length === this.userDecals.length) {
|
||||
throw new RacingDomainValidationError('Decal not found in livery');
|
||||
}
|
||||
|
||||
return new DriverLivery({
|
||||
id: this.id,
|
||||
driverId: this.driverId,
|
||||
gameId: this.gameId,
|
||||
carId: this.carId,
|
||||
uploadedImageUrl: this.uploadedImageUrl,
|
||||
userDecals: updatedDecals,
|
||||
leagueOverrides: this.leagueOverrides,
|
||||
createdAt: this.createdAt,
|
||||
updatedAt: new Date(),
|
||||
validatedAt: this.validatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a user decal
|
||||
*/
|
||||
updateDecal(decalId: string, updatedDecal: LiveryDecal): DriverLivery {
|
||||
const index = this.userDecals.findIndex(d => d.id === decalId);
|
||||
|
||||
if (index === -1) {
|
||||
throw new RacingDomainValidationError('Decal not found in livery');
|
||||
}
|
||||
|
||||
const updatedDecals = [...this.userDecals];
|
||||
updatedDecals[index] = updatedDecal;
|
||||
|
||||
return new DriverLivery({
|
||||
id: this.id,
|
||||
driverId: this.driverId,
|
||||
gameId: this.gameId,
|
||||
carId: this.carId,
|
||||
uploadedImageUrl: this.uploadedImageUrl,
|
||||
userDecals: updatedDecals,
|
||||
leagueOverrides: this.leagueOverrides,
|
||||
createdAt: this.createdAt,
|
||||
updatedAt: new Date(),
|
||||
validatedAt: this.validatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add or update a league-specific decal override
|
||||
*/
|
||||
setLeagueOverride(leagueId: string, seasonId: string, decalId: string, newX: number, newY: number): DriverLivery {
|
||||
const existingIndex = this.leagueOverrides.findIndex(
|
||||
o => o.leagueId === leagueId && o.seasonId === seasonId && o.decalId === decalId
|
||||
);
|
||||
|
||||
const override: DecalOverride = { leagueId, seasonId, decalId, newX, newY };
|
||||
|
||||
let updatedOverrides: DecalOverride[];
|
||||
if (existingIndex >= 0) {
|
||||
updatedOverrides = [...this.leagueOverrides];
|
||||
updatedOverrides[existingIndex] = override;
|
||||
} else {
|
||||
updatedOverrides = [...this.leagueOverrides, override];
|
||||
}
|
||||
|
||||
return new DriverLivery({
|
||||
id: this.id,
|
||||
driverId: this.driverId,
|
||||
gameId: this.gameId,
|
||||
carId: this.carId,
|
||||
uploadedImageUrl: this.uploadedImageUrl,
|
||||
userDecals: this.userDecals,
|
||||
leagueOverrides: updatedOverrides,
|
||||
createdAt: this.createdAt,
|
||||
updatedAt: new Date(),
|
||||
validatedAt: this.validatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a league-specific override
|
||||
*/
|
||||
removeLeagueOverride(leagueId: string, seasonId: string, decalId: string): DriverLivery {
|
||||
const updatedOverrides = this.leagueOverrides.filter(
|
||||
o => !(o.leagueId === leagueId && o.seasonId === seasonId && o.decalId === decalId)
|
||||
);
|
||||
|
||||
return new DriverLivery({
|
||||
id: this.id,
|
||||
driverId: this.driverId,
|
||||
gameId: this.gameId,
|
||||
carId: this.carId,
|
||||
uploadedImageUrl: this.uploadedImageUrl,
|
||||
userDecals: this.userDecals,
|
||||
leagueOverrides: updatedOverrides,
|
||||
createdAt: this.createdAt,
|
||||
updatedAt: new Date(),
|
||||
validatedAt: this.validatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get overrides for a specific league/season
|
||||
*/
|
||||
getOverridesFor(leagueId: string, seasonId: string): DecalOverride[] {
|
||||
return this.leagueOverrides.filter(
|
||||
o => o.leagueId === leagueId && o.seasonId === seasonId
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark livery as validated (no logos/text detected)
|
||||
*/
|
||||
markAsValidated(): DriverLivery {
|
||||
return new DriverLivery({
|
||||
id: this.id,
|
||||
driverId: this.driverId,
|
||||
gameId: this.gameId,
|
||||
carId: this.carId,
|
||||
uploadedImageUrl: this.uploadedImageUrl,
|
||||
userDecals: this.userDecals,
|
||||
leagueOverrides: this.leagueOverrides,
|
||||
createdAt: this.createdAt,
|
||||
updatedAt: this.updatedAt,
|
||||
validatedAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if livery is validated
|
||||
*/
|
||||
isValidated(): boolean {
|
||||
return this.validatedAt !== undefined;
|
||||
}
|
||||
}
|
||||
27
core/racing/domain/entities/Game.ts
Normal file
27
core/racing/domain/entities/Game.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||
import type { IEntity } from '@gridpilot/shared/domain';
|
||||
|
||||
export class Game implements IEntity<string> {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
|
||||
private constructor(props: { id: string; name: string }) {
|
||||
this.id = props.id;
|
||||
this.name = props.name;
|
||||
}
|
||||
|
||||
static create(props: { id: string; name: string }): Game {
|
||||
if (!props.id || props.id.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('Game ID is required');
|
||||
}
|
||||
|
||||
if (!props.name || props.name.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('Game name is required');
|
||||
}
|
||||
|
||||
return new Game({
|
||||
id: props.id,
|
||||
name: props.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
211
core/racing/domain/entities/League.ts
Normal file
211
core/racing/domain/entities/League.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* Domain Entity: League
|
||||
*
|
||||
* Represents a league in the GridPilot platform.
|
||||
* Immutable entity with factory methods and domain validation.
|
||||
*/
|
||||
|
||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||
import type { IEntity } from '@gridpilot/shared/domain';
|
||||
|
||||
/**
|
||||
* Stewarding decision mode for protests
|
||||
*/
|
||||
export type StewardingDecisionMode =
|
||||
| 'admin_only' // Only admins can decide
|
||||
| 'steward_decides' // Single steward makes decision
|
||||
| 'steward_vote' // X stewards must vote to uphold
|
||||
| 'member_vote' // X members must vote to uphold
|
||||
| 'steward_veto' // Upheld unless X stewards vote against
|
||||
| 'member_veto'; // Upheld unless X members vote against
|
||||
|
||||
export interface StewardingSettings {
|
||||
/**
|
||||
* How protest decisions are made
|
||||
*/
|
||||
decisionMode: StewardingDecisionMode;
|
||||
/**
|
||||
* Number of votes required to uphold/reject a protest
|
||||
* Used with steward_vote, member_vote, steward_veto, member_veto modes
|
||||
*/
|
||||
requiredVotes?: number;
|
||||
/**
|
||||
* Whether to require a defense from the accused before deciding
|
||||
*/
|
||||
requireDefense?: boolean;
|
||||
/**
|
||||
* Time limit (hours) for accused to submit defense
|
||||
*/
|
||||
defenseTimeLimit?: number;
|
||||
/**
|
||||
* Time limit (hours) for voting to complete
|
||||
*/
|
||||
voteTimeLimit?: number;
|
||||
/**
|
||||
* Time limit (hours) after race ends when protests can be filed
|
||||
*/
|
||||
protestDeadlineHours?: number;
|
||||
/**
|
||||
* Time limit (hours) after race ends when stewarding is closed (no more decisions)
|
||||
*/
|
||||
stewardingClosesHours?: number;
|
||||
/**
|
||||
* Whether to notify the accused when a protest is filed
|
||||
*/
|
||||
notifyAccusedOnProtest?: boolean;
|
||||
/**
|
||||
* Whether to notify eligible voters when a vote is required
|
||||
*/
|
||||
notifyOnVoteRequired?: boolean;
|
||||
}
|
||||
|
||||
export interface LeagueSettings {
|
||||
pointsSystem: 'f1-2024' | 'indycar' | 'custom';
|
||||
sessionDuration?: number;
|
||||
qualifyingFormat?: 'single-lap' | 'open';
|
||||
customPoints?: Record<number, number>;
|
||||
/**
|
||||
* Maximum number of drivers allowed in the league.
|
||||
* Used for simple capacity display on the website.
|
||||
*/
|
||||
maxDrivers?: number;
|
||||
/**
|
||||
* Stewarding settings for protest handling
|
||||
*/
|
||||
stewarding?: StewardingSettings;
|
||||
}
|
||||
|
||||
export interface LeagueSocialLinks {
|
||||
discordUrl?: string;
|
||||
youtubeUrl?: string;
|
||||
websiteUrl?: string;
|
||||
}
|
||||
|
||||
export class League implements IEntity<string> {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly description: string;
|
||||
readonly ownerId: string;
|
||||
readonly settings: LeagueSettings;
|
||||
readonly createdAt: Date;
|
||||
readonly socialLinks: LeagueSocialLinks | undefined;
|
||||
|
||||
private constructor(props: {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
ownerId: string;
|
||||
settings: LeagueSettings;
|
||||
createdAt: Date;
|
||||
socialLinks?: LeagueSocialLinks;
|
||||
}) {
|
||||
this.id = props.id;
|
||||
this.name = props.name;
|
||||
this.description = props.description;
|
||||
this.ownerId = props.ownerId;
|
||||
this.settings = props.settings;
|
||||
this.createdAt = props.createdAt;
|
||||
this.socialLinks = props.socialLinks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory method to create a new League entity
|
||||
*/
|
||||
static create(props: {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
ownerId: string;
|
||||
settings?: Partial<LeagueSettings>;
|
||||
createdAt?: Date;
|
||||
socialLinks?: LeagueSocialLinks;
|
||||
}): League {
|
||||
this.validate(props);
|
||||
|
||||
const defaultStewardingSettings: StewardingSettings = {
|
||||
decisionMode: 'admin_only',
|
||||
requireDefense: false,
|
||||
defenseTimeLimit: 48,
|
||||
voteTimeLimit: 72,
|
||||
protestDeadlineHours: 48,
|
||||
stewardingClosesHours: 168, // 7 days
|
||||
notifyAccusedOnProtest: true,
|
||||
notifyOnVoteRequired: true,
|
||||
};
|
||||
|
||||
const defaultSettings: LeagueSettings = {
|
||||
pointsSystem: 'f1-2024',
|
||||
sessionDuration: 60,
|
||||
qualifyingFormat: 'open',
|
||||
maxDrivers: 32,
|
||||
stewarding: defaultStewardingSettings,
|
||||
};
|
||||
|
||||
const socialLinks = props.socialLinks;
|
||||
|
||||
return new League({
|
||||
id: props.id,
|
||||
name: props.name,
|
||||
description: props.description,
|
||||
ownerId: props.ownerId,
|
||||
settings: { ...defaultSettings, ...props.settings },
|
||||
createdAt: props.createdAt ?? new Date(),
|
||||
...(socialLinks !== undefined ? { socialLinks } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Domain validation logic
|
||||
*/
|
||||
private static validate(props: {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
ownerId: string;
|
||||
}): void {
|
||||
if (!props.id || props.id.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('League ID is required');
|
||||
}
|
||||
|
||||
if (!props.name || props.name.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('League name is required');
|
||||
}
|
||||
|
||||
if (props.name.length > 100) {
|
||||
throw new RacingDomainValidationError('League name must be 100 characters or less');
|
||||
}
|
||||
|
||||
if (!props.description || props.description.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('League description is required');
|
||||
}
|
||||
|
||||
if (!props.ownerId || props.ownerId.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('League owner ID is required');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a copy with updated properties
|
||||
*/
|
||||
update(props: Partial<{
|
||||
name: string;
|
||||
description: string;
|
||||
ownerId: string;
|
||||
settings: LeagueSettings;
|
||||
socialLinks?: LeagueSocialLinks;
|
||||
}>): League {
|
||||
return new League({
|
||||
id: this.id,
|
||||
name: props.name ?? this.name,
|
||||
description: props.description ?? this.description,
|
||||
ownerId: props.ownerId ?? this.ownerId,
|
||||
settings: props.settings ?? this.settings,
|
||||
createdAt: this.createdAt,
|
||||
...(props.socialLinks !== undefined
|
||||
? { socialLinks: props.socialLinks }
|
||||
: this.socialLinks !== undefined
|
||||
? { socialLinks: this.socialLinks }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
81
core/racing/domain/entities/LeagueMembership.ts
Normal file
81
core/racing/domain/entities/LeagueMembership.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Domain Entity: LeagueMembership and JoinRequest
|
||||
*
|
||||
* Represents a driver's membership in a league and join requests.
|
||||
*/
|
||||
|
||||
import type { IEntity } from '@gridpilot/shared/domain';
|
||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||
|
||||
export type MembershipRole = 'owner' | 'admin' | 'steward' | 'member';
|
||||
export type MembershipStatus = 'active' | 'pending' | 'none';
|
||||
|
||||
export interface LeagueMembershipProps {
|
||||
id?: string;
|
||||
leagueId: string;
|
||||
driverId: string;
|
||||
role: MembershipRole;
|
||||
status?: MembershipStatus;
|
||||
joinedAt?: Date;
|
||||
}
|
||||
|
||||
export class LeagueMembership implements IEntity<string> {
|
||||
readonly id: string;
|
||||
readonly leagueId: string;
|
||||
readonly driverId: string;
|
||||
readonly role: MembershipRole;
|
||||
readonly status: MembershipStatus;
|
||||
readonly joinedAt: Date;
|
||||
|
||||
private constructor(props: Required<LeagueMembershipProps>) {
|
||||
this.id = props.id;
|
||||
this.leagueId = props.leagueId;
|
||||
this.driverId = props.driverId;
|
||||
this.role = props.role;
|
||||
this.status = props.status;
|
||||
this.joinedAt = props.joinedAt;
|
||||
}
|
||||
|
||||
static create(props: LeagueMembershipProps): LeagueMembership {
|
||||
this.validate(props);
|
||||
|
||||
const id =
|
||||
props.id && props.id.trim().length > 0
|
||||
? props.id
|
||||
: `${props.leagueId}:${props.driverId}`;
|
||||
|
||||
const status = props.status ?? 'pending';
|
||||
const joinedAt = props.joinedAt ?? new Date();
|
||||
|
||||
return new LeagueMembership({
|
||||
id,
|
||||
leagueId: props.leagueId,
|
||||
driverId: props.driverId,
|
||||
role: props.role,
|
||||
status,
|
||||
joinedAt,
|
||||
});
|
||||
}
|
||||
|
||||
private static validate(props: LeagueMembershipProps): void {
|
||||
if (!props.leagueId || props.leagueId.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('League ID is required');
|
||||
}
|
||||
|
||||
if (!props.driverId || props.driverId.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('Driver ID is required');
|
||||
}
|
||||
|
||||
if (!props.role) {
|
||||
throw new RacingDomainValidationError('Membership role is required');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface JoinRequest {
|
||||
id: string;
|
||||
leagueId: string;
|
||||
driverId: string;
|
||||
requestedAt: Date;
|
||||
message?: string;
|
||||
}
|
||||
13
core/racing/domain/entities/LeagueScoringConfig.ts
Normal file
13
core/racing/domain/entities/LeagueScoringConfig.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { ChampionshipConfig } from '../types/ChampionshipConfig';
|
||||
|
||||
export interface LeagueScoringConfig {
|
||||
id: string;
|
||||
seasonId: string;
|
||||
/**
|
||||
* Optional ID of the scoring preset this configuration was derived from.
|
||||
* Used by application-layer read models to surface preset metadata such as
|
||||
* name and drop policy summaries.
|
||||
*/
|
||||
scoringPresetId?: string;
|
||||
championships: ChampionshipConfig[];
|
||||
}
|
||||
126
core/racing/domain/entities/LeagueWallet.ts
Normal file
126
core/racing/domain/entities/LeagueWallet.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Domain Entity: LeagueWallet
|
||||
*
|
||||
* Represents a league's financial wallet.
|
||||
* Aggregate root for managing league finances and transactions.
|
||||
*/
|
||||
|
||||
import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError';
|
||||
import type { IEntity } from '@gridpilot/shared/domain';
|
||||
|
||||
import type { Money } from '../value-objects/Money';
|
||||
import type { Transaction } from './Transaction';
|
||||
|
||||
export interface LeagueWalletProps {
|
||||
id: string;
|
||||
leagueId: string;
|
||||
balance: Money;
|
||||
transactionIds: string[];
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export class LeagueWallet implements IEntity<string> {
|
||||
readonly id: string;
|
||||
readonly leagueId: string;
|
||||
readonly balance: Money;
|
||||
readonly transactionIds: string[];
|
||||
readonly createdAt: Date;
|
||||
|
||||
private constructor(props: LeagueWalletProps) {
|
||||
this.id = props.id;
|
||||
this.leagueId = props.leagueId;
|
||||
this.balance = props.balance;
|
||||
this.transactionIds = props.transactionIds;
|
||||
this.createdAt = props.createdAt;
|
||||
}
|
||||
|
||||
static create(props: Omit<LeagueWalletProps, 'createdAt' | 'transactionIds'> & {
|
||||
createdAt?: Date;
|
||||
transactionIds?: string[];
|
||||
}): LeagueWallet {
|
||||
this.validate(props);
|
||||
|
||||
return new LeagueWallet({
|
||||
...props,
|
||||
createdAt: props.createdAt ?? new Date(),
|
||||
transactionIds: props.transactionIds ?? [],
|
||||
});
|
||||
}
|
||||
|
||||
private static validate(props: Omit<LeagueWalletProps, 'createdAt' | 'transactionIds'>): void {
|
||||
if (!props.id || props.id.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('LeagueWallet ID is required');
|
||||
}
|
||||
|
||||
if (!props.leagueId || props.leagueId.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('LeagueWallet leagueId is required');
|
||||
}
|
||||
|
||||
if (!props.balance) {
|
||||
throw new RacingDomainValidationError('LeagueWallet balance is required');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add funds to wallet (from sponsorship or membership payments)
|
||||
*/
|
||||
addFunds(netAmount: Money, transactionId: string): LeagueWallet {
|
||||
if (this.balance.currency !== netAmount.currency) {
|
||||
throw new RacingDomainInvariantError('Cannot add funds with different currency');
|
||||
}
|
||||
|
||||
const newBalance = this.balance.add(netAmount);
|
||||
|
||||
return new LeagueWallet({
|
||||
...this,
|
||||
balance: newBalance,
|
||||
transactionIds: [...this.transactionIds, transactionId],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Withdraw funds from wallet
|
||||
* Domain rule: Cannot withdraw if insufficient balance
|
||||
*/
|
||||
withdrawFunds(amount: Money, transactionId: string): LeagueWallet {
|
||||
if (this.balance.currency !== amount.currency) {
|
||||
throw new RacingDomainInvariantError('Cannot withdraw funds with different currency');
|
||||
}
|
||||
|
||||
if (!this.balance.isGreaterThan(amount) && !this.balance.equals(amount)) {
|
||||
throw new RacingDomainInvariantError('Insufficient balance for withdrawal');
|
||||
}
|
||||
|
||||
const newBalance = this.balance.subtract(amount);
|
||||
|
||||
return new LeagueWallet({
|
||||
...this,
|
||||
balance: newBalance,
|
||||
transactionIds: [...this.transactionIds, transactionId],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if wallet can withdraw a specific amount
|
||||
*/
|
||||
canWithdraw(amount: Money): boolean {
|
||||
if (this.balance.currency !== amount.currency) {
|
||||
return false;
|
||||
}
|
||||
return this.balance.isGreaterThan(amount) || this.balance.equals(amount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current balance
|
||||
*/
|
||||
getBalance(): Money {
|
||||
return this.balance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all transaction IDs
|
||||
*/
|
||||
getTransactionIds(): string[] {
|
||||
return [...this.transactionIds];
|
||||
}
|
||||
}
|
||||
144
core/racing/domain/entities/LiveryTemplate.ts
Normal file
144
core/racing/domain/entities/LiveryTemplate.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Domain Entity: LiveryTemplate
|
||||
*
|
||||
* Represents an admin-defined livery template for a specific car.
|
||||
* Contains base image and sponsor decal placements.
|
||||
*/
|
||||
import { RacingDomainValidationError, RacingDomainInvariantError, RacingDomainError } from '../errors/RacingDomainError';
|
||||
import type { IEntity } from '@gridpilot/shared/domain';
|
||||
|
||||
import type { LiveryDecal } from '../value-objects/LiveryDecal';
|
||||
|
||||
export interface LiveryTemplateProps {
|
||||
id: string;
|
||||
leagueId: string;
|
||||
seasonId: string;
|
||||
carId: string;
|
||||
baseImageUrl: string;
|
||||
adminDecals: LiveryDecal[];
|
||||
createdAt: Date;
|
||||
updatedAt: Date | undefined;
|
||||
}
|
||||
|
||||
export class LiveryTemplate implements IEntity<string> {
|
||||
readonly id: string;
|
||||
readonly leagueId: string;
|
||||
readonly seasonId: string;
|
||||
readonly carId: string;
|
||||
readonly baseImageUrl: string;
|
||||
readonly adminDecals: LiveryDecal[];
|
||||
readonly createdAt: Date;
|
||||
readonly updatedAt: Date | undefined;
|
||||
|
||||
private constructor(props: LiveryTemplateProps) {
|
||||
this.id = props.id;
|
||||
this.leagueId = props.leagueId;
|
||||
this.seasonId = props.seasonId;
|
||||
this.carId = props.carId;
|
||||
this.baseImageUrl = props.baseImageUrl;
|
||||
this.adminDecals = props.adminDecals;
|
||||
this.createdAt = props.createdAt ?? new Date();
|
||||
this.updatedAt = props.updatedAt;
|
||||
}
|
||||
|
||||
static create(props: Omit<LiveryTemplateProps, 'createdAt' | 'adminDecals'> & {
|
||||
createdAt?: Date;
|
||||
adminDecals?: LiveryDecal[];
|
||||
}): LiveryTemplate {
|
||||
this.validate(props);
|
||||
|
||||
return new LiveryTemplate({
|
||||
...props,
|
||||
createdAt: props.createdAt ?? new Date(),
|
||||
adminDecals: props.adminDecals ?? [],
|
||||
});
|
||||
}
|
||||
|
||||
private static validate(props: Omit<LiveryTemplateProps, 'createdAt' | 'adminDecals'>): void {
|
||||
if (!props.id || props.id.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('LiveryTemplate ID is required');
|
||||
}
|
||||
|
||||
if (!props.leagueId || props.leagueId.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('LiveryTemplate leagueId is required');
|
||||
}
|
||||
|
||||
if (!props.seasonId || props.seasonId.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('LiveryTemplate seasonId is required');
|
||||
}
|
||||
|
||||
if (!props.carId || props.carId.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('LiveryTemplate carId is required');
|
||||
}
|
||||
|
||||
if (!props.baseImageUrl || props.baseImageUrl.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('LiveryTemplate baseImageUrl is required');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a decal to the template
|
||||
*/
|
||||
addDecal(decal: LiveryDecal): LiveryTemplate {
|
||||
if (decal.type !== 'sponsor') {
|
||||
throw new RacingDomainInvariantError('Only sponsor decals can be added to admin template');
|
||||
}
|
||||
|
||||
return new LiveryTemplate({
|
||||
...this,
|
||||
adminDecals: [...this.adminDecals, decal],
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a decal from the template
|
||||
*/
|
||||
removeDecal(decalId: string): LiveryTemplate {
|
||||
const updatedDecals = this.adminDecals.filter(d => d.id !== decalId);
|
||||
|
||||
if (updatedDecals.length === this.adminDecals.length) {
|
||||
throw new RacingDomainValidationError('Decal not found in template');
|
||||
}
|
||||
|
||||
return new LiveryTemplate({
|
||||
...this,
|
||||
adminDecals: updatedDecals,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a decal position
|
||||
*/
|
||||
updateDecal(decalId: string, updatedDecal: LiveryDecal): LiveryTemplate {
|
||||
const index = this.adminDecals.findIndex(d => d.id === decalId);
|
||||
|
||||
if (index === -1) {
|
||||
throw new RacingDomainValidationError('Decal not found in template');
|
||||
}
|
||||
|
||||
const updatedDecals = [...this.adminDecals];
|
||||
updatedDecals[index] = updatedDecal;
|
||||
|
||||
return new LiveryTemplate({
|
||||
...this,
|
||||
adminDecals: updatedDecals,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all sponsor decals
|
||||
*/
|
||||
getSponsorDecals(): LiveryDecal[] {
|
||||
return this.adminDecals.filter(d => d.type === 'sponsor');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if template has sponsor decals
|
||||
*/
|
||||
hasSponsorDecals(): boolean {
|
||||
return this.adminDecals.some(d => d.type === 'sponsor');
|
||||
}
|
||||
}
|
||||
161
core/racing/domain/entities/Penalty.ts
Normal file
161
core/racing/domain/entities/Penalty.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Domain Entity: Penalty
|
||||
*
|
||||
* Represents a penalty applied to a driver for an incident during a race.
|
||||
* Penalties can be applied as a result of an upheld protest or directly by stewards.
|
||||
*/
|
||||
|
||||
import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError';
|
||||
import type { IEntity } from '@gridpilot/shared/domain';
|
||||
|
||||
export type PenaltyType =
|
||||
| 'time_penalty' // Add time to race result (e.g., +5 seconds)
|
||||
| 'grid_penalty' // Grid position penalty for next race
|
||||
| 'points_deduction' // Deduct championship points
|
||||
| 'disqualification' // DSQ from the race
|
||||
| 'warning' // Official warning (no immediate consequence)
|
||||
| 'license_points' // Add penalty points to license (safety rating)
|
||||
| 'probation' // Conditional penalty
|
||||
| 'fine' // Monetary/points fine
|
||||
| 'race_ban'; // Multi-race suspension
|
||||
|
||||
export type PenaltyStatus = 'pending' | 'applied' | 'appealed' | 'overturned';
|
||||
|
||||
export interface PenaltyProps {
|
||||
id: string;
|
||||
leagueId: string;
|
||||
raceId: string;
|
||||
/** The driver receiving the penalty */
|
||||
driverId: string;
|
||||
/** Type of penalty */
|
||||
type: PenaltyType;
|
||||
/** Value depends on type: seconds for time_penalty, positions for grid_penalty, points for points_deduction */
|
||||
value?: number;
|
||||
/** Reason for the penalty */
|
||||
reason: string;
|
||||
/** ID of the protest that led to this penalty (if applicable) */
|
||||
protestId?: string;
|
||||
/** ID of the steward who issued the penalty */
|
||||
issuedBy: string;
|
||||
/** Current status of the penalty */
|
||||
status: PenaltyStatus;
|
||||
/** Timestamp when the penalty was issued */
|
||||
issuedAt: Date;
|
||||
/** Timestamp when the penalty was applied to results */
|
||||
appliedAt?: Date;
|
||||
/** Notes about the penalty application */
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export class Penalty implements IEntity<string> {
|
||||
private constructor(private readonly props: PenaltyProps) {}
|
||||
|
||||
static create(props: PenaltyProps): Penalty {
|
||||
if (!props.id) throw new RacingDomainValidationError('Penalty ID is required');
|
||||
if (!props.leagueId) throw new RacingDomainValidationError('League ID is required');
|
||||
if (!props.raceId) throw new RacingDomainValidationError('Race ID is required');
|
||||
if (!props.driverId) throw new RacingDomainValidationError('Driver ID is required');
|
||||
if (!props.type) throw new RacingDomainValidationError('Penalty type is required');
|
||||
if (!props.reason?.trim()) throw new RacingDomainValidationError('Penalty reason is required');
|
||||
if (!props.issuedBy) throw new RacingDomainValidationError('Penalty must be issued by a steward');
|
||||
|
||||
// Validate value based on type
|
||||
if (['time_penalty', 'grid_penalty', 'points_deduction', 'license_points', 'fine', 'race_ban'].includes(props.type)) {
|
||||
if (props.value === undefined || props.value <= 0) {
|
||||
throw new RacingDomainValidationError(`${props.type} requires a positive value`);
|
||||
}
|
||||
}
|
||||
|
||||
return new Penalty({
|
||||
...props,
|
||||
status: props.status || 'pending',
|
||||
issuedAt: props.issuedAt || new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
get id(): string { return this.props.id; }
|
||||
get leagueId(): string { return this.props.leagueId; }
|
||||
get raceId(): string { return this.props.raceId; }
|
||||
get driverId(): string { return this.props.driverId; }
|
||||
get type(): PenaltyType { return this.props.type; }
|
||||
get value(): number | undefined { return this.props.value; }
|
||||
get reason(): string { return this.props.reason; }
|
||||
get protestId(): string | undefined { return this.props.protestId; }
|
||||
get issuedBy(): string { return this.props.issuedBy; }
|
||||
get status(): PenaltyStatus { return this.props.status; }
|
||||
get issuedAt(): Date { return this.props.issuedAt; }
|
||||
get appliedAt(): Date | undefined { return this.props.appliedAt; }
|
||||
get notes(): string | undefined { return this.props.notes; }
|
||||
|
||||
isPending(): boolean {
|
||||
return this.props.status === 'pending';
|
||||
}
|
||||
|
||||
isApplied(): boolean {
|
||||
return this.props.status === 'applied';
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark penalty as applied (after recalculating results)
|
||||
*/
|
||||
markAsApplied(notes?: string): Penalty {
|
||||
if (this.isApplied()) {
|
||||
throw new RacingDomainInvariantError('Penalty is already applied');
|
||||
}
|
||||
if (this.props.status === 'overturned') {
|
||||
throw new RacingDomainInvariantError('Cannot apply an overturned penalty');
|
||||
}
|
||||
const base: PenaltyProps = {
|
||||
...this.props,
|
||||
status: 'applied',
|
||||
appliedAt: new Date(),
|
||||
};
|
||||
|
||||
const next: PenaltyProps =
|
||||
notes !== undefined ? { ...base, notes } : base;
|
||||
|
||||
return Penalty.create(next);
|
||||
}
|
||||
|
||||
/**
|
||||
* Overturn the penalty (e.g., after successful appeal)
|
||||
*/
|
||||
overturn(reason: string): Penalty {
|
||||
if (this.props.status === 'overturned') {
|
||||
throw new RacingDomainInvariantError('Penalty is already overturned');
|
||||
}
|
||||
return new Penalty({
|
||||
...this.props,
|
||||
status: 'overturned',
|
||||
notes: reason,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a human-readable description of the penalty
|
||||
*/
|
||||
getDescription(): string {
|
||||
switch (this.props.type) {
|
||||
case 'time_penalty':
|
||||
return `+${this.props.value}s time penalty`;
|
||||
case 'grid_penalty':
|
||||
return `${this.props.value} place grid penalty (next race)`;
|
||||
case 'points_deduction':
|
||||
return `${this.props.value} championship points deducted`;
|
||||
case 'disqualification':
|
||||
return 'Disqualified from race';
|
||||
case 'warning':
|
||||
return 'Official warning';
|
||||
case 'license_points':
|
||||
return `${this.props.value} license penalty points`;
|
||||
case 'probation':
|
||||
return 'Probationary period';
|
||||
case 'fine':
|
||||
return `${this.props.value} points fine`;
|
||||
case 'race_ban':
|
||||
return `${this.props.value} race suspension`;
|
||||
default:
|
||||
return 'Penalty';
|
||||
}
|
||||
}
|
||||
}
|
||||
172
core/racing/domain/entities/Prize.ts
Normal file
172
core/racing/domain/entities/Prize.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* Domain Entity: Prize
|
||||
*
|
||||
* Represents a prize awarded to a driver for a specific position in a season.
|
||||
*/
|
||||
|
||||
import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError';
|
||||
import type { IEntity } from '@gridpilot/shared/domain';
|
||||
|
||||
import type { Money } from '../value-objects/Money';
|
||||
|
||||
export type PrizeStatus = 'pending' | 'awarded' | 'paid' | 'cancelled';
|
||||
|
||||
export interface PrizeProps {
|
||||
id: string;
|
||||
seasonId: string;
|
||||
position: number;
|
||||
amount: Money;
|
||||
driverId: string;
|
||||
status: PrizeStatus;
|
||||
createdAt: Date;
|
||||
awardedAt: Date | undefined;
|
||||
paidAt: Date | undefined;
|
||||
description: string | undefined;
|
||||
}
|
||||
|
||||
export class Prize implements IEntity<string> {
|
||||
readonly id: string;
|
||||
readonly seasonId: string;
|
||||
readonly position: number;
|
||||
readonly amount: Money;
|
||||
readonly driverId: string;
|
||||
readonly status: PrizeStatus;
|
||||
readonly createdAt: Date;
|
||||
readonly awardedAt: Date | undefined;
|
||||
readonly paidAt: Date | undefined;
|
||||
readonly description: string | undefined;
|
||||
|
||||
private constructor(props: PrizeProps) {
|
||||
this.id = props.id;
|
||||
this.seasonId = props.seasonId;
|
||||
this.position = props.position;
|
||||
this.amount = props.amount;
|
||||
this.driverId = props.driverId;
|
||||
this.status = props.status;
|
||||
this.createdAt = props.createdAt ?? new Date();
|
||||
this.awardedAt = props.awardedAt;
|
||||
this.paidAt = props.paidAt;
|
||||
this.description = props.description;
|
||||
}
|
||||
|
||||
static create(props: Omit<PrizeProps, 'createdAt' | 'status' | 'driverId' | 'awardedAt' | 'paidAt' | 'description'> & {
|
||||
createdAt?: Date;
|
||||
status?: PrizeStatus;
|
||||
driverId?: string;
|
||||
awardedAt?: Date;
|
||||
paidAt?: Date;
|
||||
description?: string;
|
||||
}): Prize {
|
||||
const fullProps: Omit<PrizeProps, 'createdAt' | 'status'> = {
|
||||
...props,
|
||||
driverId: props.driverId ?? '',
|
||||
awardedAt: props.awardedAt,
|
||||
paidAt: props.paidAt,
|
||||
description: props.description,
|
||||
};
|
||||
|
||||
this.validate(fullProps);
|
||||
|
||||
return new Prize({
|
||||
...fullProps,
|
||||
createdAt: props.createdAt ?? new Date(),
|
||||
status: props.status ?? 'pending',
|
||||
});
|
||||
}
|
||||
|
||||
private static validate(props: Omit<PrizeProps, 'createdAt' | 'status'>): void {
|
||||
if (!props.id || props.id.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('Prize ID is required');
|
||||
}
|
||||
|
||||
if (!props.seasonId || props.seasonId.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('Prize seasonId is required');
|
||||
}
|
||||
|
||||
if (!Number.isInteger(props.position) || props.position < 1) {
|
||||
throw new RacingDomainValidationError('Prize position must be a positive integer');
|
||||
}
|
||||
|
||||
if (!props.amount) {
|
||||
throw new RacingDomainValidationError('Prize amount is required');
|
||||
}
|
||||
|
||||
if (props.amount.amount <= 0) {
|
||||
throw new RacingDomainValidationError('Prize amount must be greater than zero');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Award prize to a driver
|
||||
*/
|
||||
awardTo(driverId: string): Prize {
|
||||
if (!driverId || driverId.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('Driver ID is required to award prize');
|
||||
}
|
||||
|
||||
if (this.status !== 'pending') {
|
||||
throw new RacingDomainInvariantError('Only pending prizes can be awarded');
|
||||
}
|
||||
|
||||
return new Prize({
|
||||
...this,
|
||||
driverId,
|
||||
status: 'awarded',
|
||||
awardedAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark prize as paid
|
||||
*/
|
||||
markAsPaid(): Prize {
|
||||
if (this.status !== 'awarded') {
|
||||
throw new RacingDomainInvariantError('Only awarded prizes can be marked as paid');
|
||||
}
|
||||
|
||||
if (!this.driverId || this.driverId.trim() === '') {
|
||||
throw new RacingDomainInvariantError('Prize must have a driver to be paid');
|
||||
}
|
||||
|
||||
return new Prize({
|
||||
...this,
|
||||
status: 'paid',
|
||||
paidAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel prize
|
||||
*/
|
||||
cancel(): Prize {
|
||||
if (this.status === 'paid') {
|
||||
throw new RacingDomainInvariantError('Cannot cancel a paid prize');
|
||||
}
|
||||
|
||||
return new Prize({
|
||||
...this,
|
||||
status: 'cancelled',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if prize is pending
|
||||
*/
|
||||
isPending(): boolean {
|
||||
return this.status === 'pending';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if prize is awarded
|
||||
*/
|
||||
isAwarded(): boolean {
|
||||
return this.status === 'awarded';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if prize is paid
|
||||
*/
|
||||
isPaid(): boolean {
|
||||
return this.status === 'paid';
|
||||
}
|
||||
}
|
||||
230
core/racing/domain/entities/Protest.ts
Normal file
230
core/racing/domain/entities/Protest.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* Domain Entity: Protest
|
||||
*
|
||||
* Represents a protest filed by a driver against another driver for an incident during a race.
|
||||
*
|
||||
* Workflow states:
|
||||
* - pending: Initial state when protest is filed
|
||||
* - awaiting_defense: Defense has been requested from the accused driver
|
||||
* - under_review: Steward is actively reviewing the protest
|
||||
* - upheld: Protest was upheld (penalty will be applied)
|
||||
* - dismissed: Protest was dismissed (no action taken)
|
||||
* - withdrawn: Protesting driver withdrew the protest
|
||||
*/
|
||||
import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError';
|
||||
import type { IEntity } from '@gridpilot/shared/domain';
|
||||
|
||||
export type ProtestStatus = 'pending' | 'awaiting_defense' | '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 ProtestDefense {
|
||||
/** The accused driver's statement/defense */
|
||||
statement: string;
|
||||
/** URL to defense video clip (optional) */
|
||||
videoUrl?: string;
|
||||
/** Timestamp when defense was submitted */
|
||||
submittedAt: Date;
|
||||
}
|
||||
|
||||
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;
|
||||
/** Defense from the accused driver (if requested and submitted) */
|
||||
defense?: ProtestDefense;
|
||||
/** Timestamp when defense was requested */
|
||||
defenseRequestedAt?: Date;
|
||||
/** ID of the steward who requested defense */
|
||||
defenseRequestedBy?: string;
|
||||
}
|
||||
|
||||
export class Protest implements IEntity<string> {
|
||||
private constructor(private readonly props: ProtestProps) {}
|
||||
|
||||
static create(props: ProtestProps): Protest {
|
||||
if (!props.id) throw new RacingDomainValidationError('Protest ID is required');
|
||||
if (!props.raceId) throw new RacingDomainValidationError('Race ID is required');
|
||||
if (!props.protestingDriverId) throw new RacingDomainValidationError('Protesting driver ID is required');
|
||||
if (!props.accusedDriverId) throw new RacingDomainValidationError('Accused driver ID is required');
|
||||
if (!props.incident) throw new RacingDomainValidationError('Incident details are required');
|
||||
if (props.incident.lap < 0) throw new RacingDomainValidationError('Lap number must be non-negative');
|
||||
if (!props.incident.description?.trim()) throw new RacingDomainValidationError('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; }
|
||||
get defense(): ProtestDefense | undefined { return this.props.defense ? { ...this.props.defense } : undefined; }
|
||||
get defenseRequestedAt(): Date | undefined { return this.props.defenseRequestedAt; }
|
||||
get defenseRequestedBy(): string | undefined { return this.props.defenseRequestedBy; }
|
||||
|
||||
isPending(): boolean {
|
||||
return this.props.status === 'pending';
|
||||
}
|
||||
|
||||
isAwaitingDefense(): boolean {
|
||||
return this.props.status === 'awaiting_defense';
|
||||
}
|
||||
|
||||
isUnderReview(): boolean {
|
||||
return this.props.status === 'under_review';
|
||||
}
|
||||
|
||||
isResolved(): boolean {
|
||||
return ['upheld', 'dismissed', 'withdrawn'].includes(this.props.status);
|
||||
}
|
||||
|
||||
hasDefense(): boolean {
|
||||
return this.props.defense !== undefined;
|
||||
}
|
||||
|
||||
canRequestDefense(): boolean {
|
||||
return this.isPending() && !this.hasDefense() && !this.props.defenseRequestedAt;
|
||||
}
|
||||
|
||||
canSubmitDefense(): boolean {
|
||||
return this.isAwaitingDefense() && !this.hasDefense();
|
||||
}
|
||||
|
||||
/**
|
||||
* Request defense from the accused driver
|
||||
*/
|
||||
requestDefense(stewardId: string): Protest {
|
||||
if (!this.canRequestDefense()) {
|
||||
throw new RacingDomainInvariantError('Defense can only be requested for pending protests without existing defense');
|
||||
}
|
||||
return new Protest({
|
||||
...this.props,
|
||||
status: 'awaiting_defense',
|
||||
defenseRequestedAt: new Date(),
|
||||
defenseRequestedBy: stewardId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit defense from the accused driver
|
||||
*/
|
||||
submitDefense(statement: string, videoUrl?: string): Protest {
|
||||
if (!this.canSubmitDefense()) {
|
||||
throw new RacingDomainInvariantError('Defense can only be submitted when protest is awaiting defense');
|
||||
}
|
||||
if (!statement?.trim()) {
|
||||
throw new RacingDomainValidationError('Defense statement is required');
|
||||
}
|
||||
const defenseBase: ProtestDefense = {
|
||||
statement: statement.trim(),
|
||||
submittedAt: new Date(),
|
||||
};
|
||||
|
||||
const nextDefense: ProtestDefense =
|
||||
videoUrl !== undefined ? { ...defenseBase, videoUrl } : defenseBase;
|
||||
|
||||
return new Protest({
|
||||
...this.props,
|
||||
status: 'under_review',
|
||||
defense: nextDefense,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start reviewing the protest (without requiring defense)
|
||||
*/
|
||||
startReview(stewardId: string): Protest {
|
||||
if (!this.isPending() && !this.isAwaitingDefense()) {
|
||||
throw new RacingDomainInvariantError('Only pending or awaiting-defense 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() && !this.isAwaitingDefense()) {
|
||||
throw new RacingDomainInvariantError('Only pending, awaiting-defense, 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() && !this.isAwaitingDefense()) {
|
||||
throw new RacingDomainInvariantError('Only pending, awaiting-defense, 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 RacingDomainInvariantError('Cannot withdraw a resolved protest');
|
||||
}
|
||||
return new Protest({
|
||||
...this.props,
|
||||
status: 'withdrawn',
|
||||
reviewedAt: new Date(),
|
||||
});
|
||||
}
|
||||
}
|
||||
291
core/racing/domain/entities/Race.ts
Normal file
291
core/racing/domain/entities/Race.ts
Normal file
@@ -0,0 +1,291 @@
|
||||
/**
|
||||
* Domain Entity: Race
|
||||
*
|
||||
* Represents a race/session in the GridPilot platform.
|
||||
* Immutable entity with factory methods and domain validation.
|
||||
*/
|
||||
|
||||
import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError';
|
||||
import type { IEntity } from '@gridpilot/shared/domain';
|
||||
import type { SessionType } from '../value-objects/SessionType';
|
||||
export type RaceStatus = 'scheduled' | 'running' | 'completed' | 'cancelled';
|
||||
|
||||
export class Race implements IEntity<string> {
|
||||
readonly id: string;
|
||||
readonly leagueId: string;
|
||||
readonly scheduledAt: Date;
|
||||
readonly track: string;
|
||||
readonly trackId: string | undefined;
|
||||
readonly car: string;
|
||||
readonly carId: string | undefined;
|
||||
readonly sessionType: SessionType;
|
||||
readonly status: RaceStatus;
|
||||
readonly strengthOfField: number | undefined;
|
||||
readonly registeredCount: number | undefined;
|
||||
readonly maxParticipants: number | undefined;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory method to create a new Race entity
|
||||
*/
|
||||
static create(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;
|
||||
}): Race {
|
||||
this.validate(props);
|
||||
|
||||
return new Race({
|
||||
id: props.id,
|
||||
leagueId: props.leagueId,
|
||||
scheduledAt: props.scheduledAt,
|
||||
track: props.track,
|
||||
...(props.trackId !== undefined ? { trackId: props.trackId } : {}),
|
||||
car: props.car,
|
||||
...(props.carId !== undefined ? { carId: props.carId } : {}),
|
||||
sessionType: props.sessionType ?? SessionType.main(),
|
||||
status: props.status ?? 'scheduled',
|
||||
...(props.strengthOfField !== undefined ? { strengthOfField: props.strengthOfField } : {}),
|
||||
...(props.registeredCount !== undefined ? { registeredCount: props.registeredCount } : {}),
|
||||
...(props.maxParticipants !== undefined ? { maxParticipants: props.maxParticipants } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Domain validation logic
|
||||
*/
|
||||
private static validate(props: {
|
||||
id: string;
|
||||
leagueId: string;
|
||||
scheduledAt: Date;
|
||||
track: string;
|
||||
car: string;
|
||||
}): void {
|
||||
if (!props.id || props.id.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('Race ID is required');
|
||||
}
|
||||
|
||||
if (!props.leagueId || props.leagueId.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('League ID is required');
|
||||
}
|
||||
|
||||
if (!props.scheduledAt || !(props.scheduledAt instanceof Date)) {
|
||||
throw new RacingDomainValidationError('Valid scheduled date is required');
|
||||
}
|
||||
|
||||
if (!props.track || props.track.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('Track is required');
|
||||
}
|
||||
|
||||
if (!props.car || props.car.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('Car is required');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the race (move from scheduled to running)
|
||||
*/
|
||||
start(): Race {
|
||||
if (this.status !== 'scheduled') {
|
||||
throw new RacingDomainInvariantError('Only scheduled races can be started');
|
||||
}
|
||||
|
||||
const base = {
|
||||
id: this.id,
|
||||
leagueId: this.leagueId,
|
||||
scheduledAt: this.scheduledAt,
|
||||
track: this.track,
|
||||
car: this.car,
|
||||
sessionType: this.sessionType,
|
||||
status: 'running' as RaceStatus,
|
||||
};
|
||||
|
||||
const withTrackId =
|
||||
this.trackId !== undefined ? { ...base, trackId: this.trackId } : base;
|
||||
const withCarId =
|
||||
this.carId !== undefined ? { ...withTrackId, carId: this.carId } : withTrackId;
|
||||
const withSof =
|
||||
this.strengthOfField !== undefined
|
||||
? { ...withCarId, strengthOfField: this.strengthOfField }
|
||||
: withCarId;
|
||||
const withRegistered =
|
||||
this.registeredCount !== undefined
|
||||
? { ...withSof, registeredCount: this.registeredCount }
|
||||
: withSof;
|
||||
const props =
|
||||
this.maxParticipants !== undefined
|
||||
? { ...withRegistered, maxParticipants: this.maxParticipants }
|
||||
: withRegistered;
|
||||
|
||||
return Race.create(props);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark race as completed
|
||||
*/
|
||||
complete(): Race {
|
||||
if (this.status === 'completed') {
|
||||
throw new RacingDomainInvariantError('Race is already completed');
|
||||
}
|
||||
|
||||
if (this.status === 'cancelled') {
|
||||
throw new RacingDomainInvariantError('Cannot complete a cancelled race');
|
||||
}
|
||||
|
||||
const base = {
|
||||
id: this.id,
|
||||
leagueId: this.leagueId,
|
||||
scheduledAt: this.scheduledAt,
|
||||
track: this.track,
|
||||
car: this.car,
|
||||
sessionType: this.sessionType,
|
||||
status: 'completed' as RaceStatus,
|
||||
};
|
||||
|
||||
const withTrackId =
|
||||
this.trackId !== undefined ? { ...base, trackId: this.trackId } : base;
|
||||
const withCarId =
|
||||
this.carId !== undefined ? { ...withTrackId, carId: this.carId } : withTrackId;
|
||||
const withSof =
|
||||
this.strengthOfField !== undefined
|
||||
? { ...withCarId, strengthOfField: this.strengthOfField }
|
||||
: withCarId;
|
||||
const withRegistered =
|
||||
this.registeredCount !== undefined
|
||||
? { ...withSof, registeredCount: this.registeredCount }
|
||||
: withSof;
|
||||
const props =
|
||||
this.maxParticipants !== undefined
|
||||
? { ...withRegistered, maxParticipants: this.maxParticipants }
|
||||
: withRegistered;
|
||||
|
||||
return Race.create(props);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel the race
|
||||
*/
|
||||
cancel(): Race {
|
||||
if (this.status === 'completed') {
|
||||
throw new RacingDomainInvariantError('Cannot cancel a completed race');
|
||||
}
|
||||
|
||||
if (this.status === 'cancelled') {
|
||||
throw new RacingDomainInvariantError('Race is already cancelled');
|
||||
}
|
||||
|
||||
const base = {
|
||||
id: this.id,
|
||||
leagueId: this.leagueId,
|
||||
scheduledAt: this.scheduledAt,
|
||||
track: this.track,
|
||||
car: this.car,
|
||||
sessionType: this.sessionType,
|
||||
status: 'cancelled' as RaceStatus,
|
||||
};
|
||||
|
||||
const withTrackId =
|
||||
this.trackId !== undefined ? { ...base, trackId: this.trackId } : base;
|
||||
const withCarId =
|
||||
this.carId !== undefined ? { ...withTrackId, carId: this.carId } : withTrackId;
|
||||
const withSof =
|
||||
this.strengthOfField !== undefined
|
||||
? { ...withCarId, strengthOfField: this.strengthOfField }
|
||||
: withCarId;
|
||||
const withRegistered =
|
||||
this.registeredCount !== undefined
|
||||
? { ...withSof, registeredCount: this.registeredCount }
|
||||
: withSof;
|
||||
const props =
|
||||
this.maxParticipants !== undefined
|
||||
? { ...withRegistered, maxParticipants: this.maxParticipants }
|
||||
: withRegistered;
|
||||
|
||||
return Race.create(props);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update SOF and participant count
|
||||
*/
|
||||
updateField(strengthOfField: number, registeredCount: number): Race {
|
||||
const base = {
|
||||
id: this.id,
|
||||
leagueId: this.leagueId,
|
||||
scheduledAt: this.scheduledAt,
|
||||
track: this.track,
|
||||
car: this.car,
|
||||
sessionType: this.sessionType,
|
||||
status: this.status,
|
||||
strengthOfField,
|
||||
registeredCount,
|
||||
};
|
||||
|
||||
const withTrackId =
|
||||
this.trackId !== undefined ? { ...base, trackId: this.trackId } : base;
|
||||
const withCarId =
|
||||
this.carId !== undefined ? { ...withTrackId, carId: this.carId } : withTrackId;
|
||||
const props =
|
||||
this.maxParticipants !== undefined
|
||||
? { ...withCarId, maxParticipants: this.maxParticipants }
|
||||
: withCarId;
|
||||
|
||||
return Race.create(props);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if race is in the past
|
||||
*/
|
||||
isPast(): boolean {
|
||||
return this.scheduledAt < new Date();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if race is upcoming
|
||||
*/
|
||||
isUpcoming(): boolean {
|
||||
return this.status === 'scheduled' && !this.isPast();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if race is live/running
|
||||
*/
|
||||
isLive(): boolean {
|
||||
return this.status === 'running';
|
||||
}
|
||||
}
|
||||
283
core/racing/domain/entities/RaceEvent.ts
Normal file
283
core/racing/domain/entities/RaceEvent.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* Domain Entity: RaceEvent (Aggregate Root)
|
||||
*
|
||||
* Represents a race event containing multiple sessions (practice, quali, race).
|
||||
* Immutable aggregate root with factory methods and domain validation.
|
||||
*/
|
||||
|
||||
import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError';
|
||||
import type { IEntity } from '@gridpilot/shared/domain';
|
||||
import type { Session } from './Session';
|
||||
import type { SessionType } from '../value-objects/SessionType';
|
||||
|
||||
export type RaceEventStatus = 'scheduled' | 'in_progress' | 'awaiting_stewarding' | 'closed' | 'cancelled';
|
||||
|
||||
export class RaceEvent implements IEntity<string> {
|
||||
readonly id: string;
|
||||
readonly seasonId: string;
|
||||
readonly leagueId: string;
|
||||
readonly name: string;
|
||||
readonly sessions: readonly Session[];
|
||||
readonly status: RaceEventStatus;
|
||||
readonly stewardingClosesAt: Date | undefined;
|
||||
|
||||
private constructor(props: {
|
||||
id: string;
|
||||
seasonId: string;
|
||||
leagueId: string;
|
||||
name: string;
|
||||
sessions: readonly Session[];
|
||||
status: RaceEventStatus;
|
||||
stewardingClosesAt?: Date;
|
||||
}) {
|
||||
this.id = props.id;
|
||||
this.seasonId = props.seasonId;
|
||||
this.leagueId = props.leagueId;
|
||||
this.name = props.name;
|
||||
this.sessions = props.sessions;
|
||||
this.status = props.status;
|
||||
this.stewardingClosesAt = props.stewardingClosesAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory method to create a new RaceEvent entity
|
||||
*/
|
||||
static create(props: {
|
||||
id: string;
|
||||
seasonId: string;
|
||||
leagueId: string;
|
||||
name: string;
|
||||
sessions: Session[];
|
||||
status?: RaceEventStatus;
|
||||
stewardingClosesAt?: Date;
|
||||
}): RaceEvent {
|
||||
this.validate(props);
|
||||
|
||||
return new RaceEvent({
|
||||
id: props.id,
|
||||
seasonId: props.seasonId,
|
||||
leagueId: props.leagueId,
|
||||
name: props.name,
|
||||
sessions: [...props.sessions], // Create immutable copy
|
||||
status: props.status ?? 'scheduled',
|
||||
...(props.stewardingClosesAt !== undefined ? { stewardingClosesAt: props.stewardingClosesAt } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Domain validation logic
|
||||
*/
|
||||
private static validate(props: {
|
||||
id: string;
|
||||
seasonId: string;
|
||||
leagueId: string;
|
||||
name: string;
|
||||
sessions: Session[];
|
||||
}): void {
|
||||
if (!props.id || props.id.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('RaceEvent ID is required');
|
||||
}
|
||||
|
||||
if (!props.seasonId || props.seasonId.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('Season ID is required');
|
||||
}
|
||||
|
||||
if (!props.leagueId || props.leagueId.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('League ID is required');
|
||||
}
|
||||
|
||||
if (!props.name || props.name.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('RaceEvent name is required');
|
||||
}
|
||||
|
||||
if (!props.sessions || props.sessions.length === 0) {
|
||||
throw new RacingDomainValidationError('RaceEvent must have at least one session');
|
||||
}
|
||||
|
||||
// Validate all sessions belong to this race event
|
||||
const invalidSessions = props.sessions.filter(s => s.raceEventId !== props.id);
|
||||
if (invalidSessions.length > 0) {
|
||||
throw new RacingDomainValidationError('All sessions must belong to this race event');
|
||||
}
|
||||
|
||||
// Validate session types are unique
|
||||
const sessionTypes = props.sessions.map(s => s.sessionType.value);
|
||||
const uniqueTypes = new Set(sessionTypes);
|
||||
if (uniqueTypes.size !== sessionTypes.length) {
|
||||
throw new RacingDomainValidationError('Session types must be unique within a race event');
|
||||
}
|
||||
|
||||
// Validate at least one main race session exists
|
||||
const hasMainRace = props.sessions.some(s => s.sessionType.value === 'main');
|
||||
if (!hasMainRace) {
|
||||
throw new RacingDomainValidationError('RaceEvent must have at least one main race session');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the race event (move from scheduled to in_progress)
|
||||
*/
|
||||
start(): RaceEvent {
|
||||
if (this.status !== 'scheduled') {
|
||||
throw new RacingDomainInvariantError('Only scheduled race events can be started');
|
||||
}
|
||||
|
||||
return RaceEvent.create({
|
||||
id: this.id,
|
||||
seasonId: this.seasonId,
|
||||
leagueId: this.leagueId,
|
||||
name: this.name,
|
||||
sessions: this.sessions,
|
||||
status: 'in_progress',
|
||||
stewardingClosesAt: this.stewardingClosesAt,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete the main race session and move to awaiting_stewarding
|
||||
*/
|
||||
completeMainRace(): RaceEvent {
|
||||
if (this.status !== 'in_progress') {
|
||||
throw new RacingDomainInvariantError('Only in-progress race events can complete main race');
|
||||
}
|
||||
|
||||
const mainRaceSession = this.getMainRaceSession();
|
||||
if (!mainRaceSession || mainRaceSession.status !== 'completed') {
|
||||
throw new RacingDomainInvariantError('Main race session must be completed first');
|
||||
}
|
||||
|
||||
return RaceEvent.create({
|
||||
id: this.id,
|
||||
seasonId: this.seasonId,
|
||||
leagueId: this.leagueId,
|
||||
name: this.name,
|
||||
sessions: this.sessions,
|
||||
status: 'awaiting_stewarding',
|
||||
stewardingClosesAt: this.stewardingClosesAt,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Close stewarding and finalize the race event
|
||||
*/
|
||||
closeStewarding(): RaceEvent {
|
||||
if (this.status !== 'awaiting_stewarding') {
|
||||
throw new RacingDomainInvariantError('Only race events awaiting stewarding can be closed');
|
||||
}
|
||||
|
||||
return RaceEvent.create({
|
||||
id: this.id,
|
||||
seasonId: this.seasonId,
|
||||
leagueId: this.leagueId,
|
||||
name: this.name,
|
||||
sessions: this.sessions,
|
||||
status: 'closed',
|
||||
stewardingClosesAt: this.stewardingClosesAt,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel the race event
|
||||
*/
|
||||
cancel(): RaceEvent {
|
||||
if (this.status === 'closed') {
|
||||
throw new RacingDomainInvariantError('Cannot cancel a closed race event');
|
||||
}
|
||||
|
||||
if (this.status === 'cancelled') {
|
||||
return this;
|
||||
}
|
||||
|
||||
return RaceEvent.create({
|
||||
id: this.id,
|
||||
seasonId: this.seasonId,
|
||||
leagueId: this.leagueId,
|
||||
name: this.name,
|
||||
sessions: this.sessions,
|
||||
status: 'cancelled',
|
||||
stewardingClosesAt: this.stewardingClosesAt,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the main race session (the one that counts for championship points)
|
||||
*/
|
||||
getMainRaceSession(): Session | undefined {
|
||||
return this.sessions.find(s => s.sessionType.equals(SessionType.main()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all sessions of a specific type
|
||||
*/
|
||||
getSessionsByType(sessionType: SessionType): Session[] {
|
||||
return this.sessions.filter(s => s.sessionType.equals(sessionType));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all completed sessions
|
||||
*/
|
||||
getCompletedSessions(): Session[] {
|
||||
return this.sessions.filter(s => s.status === 'completed');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if all sessions are completed
|
||||
*/
|
||||
areAllSessionsCompleted(): boolean {
|
||||
return this.sessions.every(s => s.status === 'completed');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the main race is completed
|
||||
*/
|
||||
isMainRaceCompleted(): boolean {
|
||||
const mainRace = this.getMainRaceSession();
|
||||
return mainRace?.status === 'completed' ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if stewarding window has expired
|
||||
*/
|
||||
hasStewardingExpired(): boolean {
|
||||
if (!this.stewardingClosesAt) return false;
|
||||
return new Date() > this.stewardingClosesAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if race event is in the past
|
||||
*/
|
||||
isPast(): boolean {
|
||||
const latestSession = this.sessions.reduce((latest, session) =>
|
||||
session.scheduledAt > latest.scheduledAt ? session : latest
|
||||
);
|
||||
return latestSession.scheduledAt < new Date();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if race event is upcoming
|
||||
*/
|
||||
isUpcoming(): boolean {
|
||||
return this.status === 'scheduled' && !this.isPast();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if race event is currently running
|
||||
*/
|
||||
isLive(): boolean {
|
||||
return this.status === 'in_progress';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if race event is awaiting stewarding decisions
|
||||
*/
|
||||
isAwaitingStewarding(): boolean {
|
||||
return this.status === 'awaiting_stewarding';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if race event is closed (stewarding complete)
|
||||
*/
|
||||
isClosed(): boolean {
|
||||
return this.status === 'closed';
|
||||
}
|
||||
}
|
||||
57
core/racing/domain/entities/RaceRegistration.ts
Normal file
57
core/racing/domain/entities/RaceRegistration.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Domain Entity: RaceRegistration
|
||||
*
|
||||
* Represents a registration of a driver for a specific race.
|
||||
*/
|
||||
|
||||
import type { IEntity } from '@gridpilot/shared/domain';
|
||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||
|
||||
export interface RaceRegistrationProps {
|
||||
id?: string;
|
||||
raceId: string;
|
||||
driverId: string;
|
||||
registeredAt?: Date;
|
||||
}
|
||||
|
||||
export class RaceRegistration implements IEntity<string> {
|
||||
readonly id: string;
|
||||
readonly raceId: string;
|
||||
readonly driverId: string;
|
||||
readonly registeredAt: Date;
|
||||
|
||||
private constructor(props: Required<RaceRegistrationProps>) {
|
||||
this.id = props.id;
|
||||
this.raceId = props.raceId;
|
||||
this.driverId = props.driverId;
|
||||
this.registeredAt = props.registeredAt;
|
||||
}
|
||||
|
||||
static create(props: RaceRegistrationProps): RaceRegistration {
|
||||
this.validate(props);
|
||||
|
||||
const id =
|
||||
props.id && props.id.trim().length > 0
|
||||
? props.id
|
||||
: `${props.raceId}:${props.driverId}`;
|
||||
|
||||
const registeredAt = props.registeredAt ?? new Date();
|
||||
|
||||
return new RaceRegistration({
|
||||
id,
|
||||
raceId: props.raceId,
|
||||
driverId: props.driverId,
|
||||
registeredAt,
|
||||
});
|
||||
}
|
||||
|
||||
private static validate(props: RaceRegistrationProps): void {
|
||||
if (!props.raceId || props.raceId.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('Race ID is required');
|
||||
}
|
||||
|
||||
if (!props.driverId || props.driverId.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('Driver ID is required');
|
||||
}
|
||||
}
|
||||
}
|
||||
116
core/racing/domain/entities/Result.ts
Normal file
116
core/racing/domain/entities/Result.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Domain Entity: Result
|
||||
*
|
||||
* Represents a race result in the GridPilot platform.
|
||||
* Immutable entity with factory methods and domain validation.
|
||||
*/
|
||||
|
||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||
import type { IEntity } from '@gridpilot/shared/domain';
|
||||
|
||||
export class Result implements IEntity<string> {
|
||||
readonly id: string;
|
||||
readonly raceId: string;
|
||||
readonly driverId: string;
|
||||
readonly position: number;
|
||||
readonly fastestLap: number;
|
||||
readonly incidents: number;
|
||||
readonly startPosition: number;
|
||||
|
||||
private constructor(props: {
|
||||
id: string;
|
||||
raceId: string;
|
||||
driverId: string;
|
||||
position: number;
|
||||
fastestLap: number;
|
||||
incidents: number;
|
||||
startPosition: number;
|
||||
}) {
|
||||
this.id = props.id;
|
||||
this.raceId = props.raceId;
|
||||
this.driverId = props.driverId;
|
||||
this.position = props.position;
|
||||
this.fastestLap = props.fastestLap;
|
||||
this.incidents = props.incidents;
|
||||
this.startPosition = props.startPosition;
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory method to create a new Result entity
|
||||
*/
|
||||
static create(props: {
|
||||
id: string;
|
||||
raceId: string;
|
||||
driverId: string;
|
||||
position: number;
|
||||
fastestLap: number;
|
||||
incidents: number;
|
||||
startPosition: number;
|
||||
}): Result {
|
||||
this.validate(props);
|
||||
|
||||
return new Result(props);
|
||||
}
|
||||
|
||||
/**
|
||||
* Domain validation logic
|
||||
*/
|
||||
private static validate(props: {
|
||||
id: string;
|
||||
raceId: string;
|
||||
driverId: string;
|
||||
position: number;
|
||||
fastestLap: number;
|
||||
incidents: number;
|
||||
startPosition: number;
|
||||
}): void {
|
||||
if (!props.id || props.id.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('Result ID is required');
|
||||
}
|
||||
|
||||
if (!props.raceId || props.raceId.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('Race ID is required');
|
||||
}
|
||||
|
||||
if (!props.driverId || props.driverId.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('Driver ID is required');
|
||||
}
|
||||
|
||||
if (!Number.isInteger(props.position) || props.position < 1) {
|
||||
throw new RacingDomainValidationError('Position must be a positive integer');
|
||||
}
|
||||
|
||||
if (props.fastestLap < 0) {
|
||||
throw new RacingDomainValidationError('Fastest lap cannot be negative');
|
||||
}
|
||||
|
||||
if (!Number.isInteger(props.incidents) || props.incidents < 0) {
|
||||
throw new RacingDomainValidationError('Incidents must be a non-negative integer');
|
||||
}
|
||||
|
||||
if (!Number.isInteger(props.startPosition) || props.startPosition < 1) {
|
||||
throw new RacingDomainValidationError('Start position must be a positive integer');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate positions gained/lost
|
||||
*/
|
||||
getPositionChange(): number {
|
||||
return this.startPosition - this.position;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if driver finished on podium
|
||||
*/
|
||||
isPodium(): boolean {
|
||||
return this.position <= 3;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if driver had a clean race (0 incidents)
|
||||
*/
|
||||
isClean(): boolean {
|
||||
return this.incidents === 0;
|
||||
}
|
||||
}
|
||||
175
core/racing/domain/entities/ResultWithIncidents.ts
Normal file
175
core/racing/domain/entities/ResultWithIncidents.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* Enhanced Result entity with detailed incident tracking
|
||||
*/
|
||||
|
||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||
import type { IEntity } from '@gridpilot/shared/domain';
|
||||
import { RaceIncidents, type IncidentRecord } from '../value-objects/RaceIncidents';
|
||||
|
||||
export class ResultWithIncidents implements IEntity<string> {
|
||||
readonly id: string;
|
||||
readonly raceId: string;
|
||||
readonly driverId: string;
|
||||
readonly position: number;
|
||||
readonly fastestLap: number;
|
||||
readonly incidents: RaceIncidents;
|
||||
readonly startPosition: number;
|
||||
|
||||
private constructor(props: {
|
||||
id: string;
|
||||
raceId: string;
|
||||
driverId: string;
|
||||
position: number;
|
||||
fastestLap: number;
|
||||
incidents: RaceIncidents;
|
||||
startPosition: number;
|
||||
}) {
|
||||
this.id = props.id;
|
||||
this.raceId = props.raceId;
|
||||
this.driverId = props.driverId;
|
||||
this.position = props.position;
|
||||
this.fastestLap = props.fastestLap;
|
||||
this.incidents = props.incidents;
|
||||
this.startPosition = props.startPosition;
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory method to create a new Result entity
|
||||
*/
|
||||
static create(props: {
|
||||
id: string;
|
||||
raceId: string;
|
||||
driverId: string;
|
||||
position: number;
|
||||
fastestLap: number;
|
||||
incidents: RaceIncidents;
|
||||
startPosition: number;
|
||||
}): ResultWithIncidents {
|
||||
ResultWithIncidents.validate(props);
|
||||
return new ResultWithIncidents(props);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create from legacy Result data (with incidents as number)
|
||||
*/
|
||||
static fromLegacy(props: {
|
||||
id: string;
|
||||
raceId: string;
|
||||
driverId: string;
|
||||
position: number;
|
||||
fastestLap: number;
|
||||
incidents: number;
|
||||
startPosition: number;
|
||||
}): ResultWithIncidents {
|
||||
const raceIncidents = RaceIncidents.fromLegacyIncidentsCount(props.incidents);
|
||||
return ResultWithIncidents.create({
|
||||
...props,
|
||||
incidents: raceIncidents,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Domain validation logic
|
||||
*/
|
||||
private static validate(props: {
|
||||
id: string;
|
||||
raceId: string;
|
||||
driverId: string;
|
||||
position: number;
|
||||
fastestLap: number;
|
||||
incidents: RaceIncidents;
|
||||
startPosition: number;
|
||||
}): void {
|
||||
if (!props.id || props.id.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('Result ID is required');
|
||||
}
|
||||
|
||||
if (!props.raceId || props.raceId.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('Race ID is required');
|
||||
}
|
||||
|
||||
if (!props.driverId || props.driverId.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('Driver ID is required');
|
||||
}
|
||||
|
||||
if (!Number.isInteger(props.position) || props.position < 1) {
|
||||
throw new RacingDomainValidationError('Position must be a positive integer');
|
||||
}
|
||||
|
||||
if (props.fastestLap < 0) {
|
||||
throw new RacingDomainValidationError('Fastest lap cannot be negative');
|
||||
}
|
||||
|
||||
if (!Number.isInteger(props.startPosition) || props.startPosition < 1) {
|
||||
throw new RacingDomainValidationError('Start position must be a positive integer');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate positions gained/lost
|
||||
*/
|
||||
getPositionChange(): number {
|
||||
return this.startPosition - this.position;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if driver finished on podium
|
||||
*/
|
||||
isPodium(): boolean {
|
||||
return this.position <= 3;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if driver had a clean race (no incidents)
|
||||
*/
|
||||
isClean(): boolean {
|
||||
return this.incidents.isClean();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total incident count (for backward compatibility)
|
||||
*/
|
||||
getTotalIncidents(): number {
|
||||
return this.incidents.getTotalCount();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get incident severity score
|
||||
*/
|
||||
getIncidentSeverityScore(): number {
|
||||
return this.incidents.getSeverityScore();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get human-readable incident summary
|
||||
*/
|
||||
getIncidentSummary(): string {
|
||||
return this.incidents.getSummary();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an incident to this result
|
||||
*/
|
||||
addIncident(incident: IncidentRecord): ResultWithIncidents {
|
||||
const updatedIncidents = this.incidents.addIncident(incident);
|
||||
return new ResultWithIncidents({
|
||||
...this,
|
||||
incidents: updatedIncidents,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to legacy format (for backward compatibility)
|
||||
*/
|
||||
toLegacyFormat() {
|
||||
return {
|
||||
id: this.id,
|
||||
raceId: this.raceId,
|
||||
driverId: this.driverId,
|
||||
position: this.position,
|
||||
fastestLap: this.fastestLap,
|
||||
incidents: this.getTotalIncidents(),
|
||||
startPosition: this.startPosition,
|
||||
};
|
||||
}
|
||||
}
|
||||
419
core/racing/domain/entities/Season.ts
Normal file
419
core/racing/domain/entities/Season.ts
Normal file
@@ -0,0 +1,419 @@
|
||||
export type SeasonStatus =
|
||||
| 'planned'
|
||||
| 'active'
|
||||
| 'completed'
|
||||
| 'archived'
|
||||
| 'cancelled';
|
||||
|
||||
import {
|
||||
RacingDomainInvariantError,
|
||||
RacingDomainValidationError,
|
||||
} from '../errors/RacingDomainError';
|
||||
import type { IEntity } from '@gridpilot/shared/domain';
|
||||
import type { SeasonSchedule } from '../value-objects/SeasonSchedule';
|
||||
import type { SeasonScoringConfig } from '../value-objects/SeasonScoringConfig';
|
||||
import type { SeasonDropPolicy } from '../value-objects/SeasonDropPolicy';
|
||||
import type { SeasonStewardingConfig } from '../value-objects/SeasonStewardingConfig';
|
||||
|
||||
export class Season implements IEntity<string> {
|
||||
readonly id: string;
|
||||
readonly leagueId: string;
|
||||
readonly gameId: string;
|
||||
readonly name: string;
|
||||
readonly year: number | undefined;
|
||||
readonly order: number | undefined;
|
||||
readonly status: SeasonStatus;
|
||||
readonly startDate: Date | undefined;
|
||||
readonly endDate: Date | undefined;
|
||||
readonly schedule: SeasonSchedule | undefined;
|
||||
readonly scoringConfig: SeasonScoringConfig | undefined;
|
||||
readonly dropPolicy: SeasonDropPolicy | undefined;
|
||||
readonly stewardingConfig: SeasonStewardingConfig | undefined;
|
||||
readonly maxDrivers: number | undefined;
|
||||
|
||||
private constructor(props: {
|
||||
id: string;
|
||||
leagueId: string;
|
||||
gameId: string;
|
||||
name: string;
|
||||
year?: number;
|
||||
order?: number;
|
||||
status: SeasonStatus;
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
schedule?: SeasonSchedule;
|
||||
scoringConfig?: SeasonScoringConfig;
|
||||
dropPolicy?: SeasonDropPolicy;
|
||||
stewardingConfig?: SeasonStewardingConfig;
|
||||
maxDrivers?: number;
|
||||
}) {
|
||||
this.id = props.id;
|
||||
this.leagueId = props.leagueId;
|
||||
this.gameId = props.gameId;
|
||||
this.name = props.name;
|
||||
this.year = props.year;
|
||||
this.order = props.order;
|
||||
this.status = props.status;
|
||||
this.startDate = props.startDate;
|
||||
this.endDate = props.endDate;
|
||||
this.schedule = props.schedule;
|
||||
this.scoringConfig = props.scoringConfig;
|
||||
this.dropPolicy = props.dropPolicy;
|
||||
this.stewardingConfig = props.stewardingConfig;
|
||||
this.maxDrivers = props.maxDrivers;
|
||||
}
|
||||
|
||||
static create(props: {
|
||||
id: string;
|
||||
leagueId: string;
|
||||
gameId: string;
|
||||
name: string;
|
||||
year?: number;
|
||||
order?: number;
|
||||
status?: SeasonStatus;
|
||||
startDate?: Date;
|
||||
endDate?: Date | undefined;
|
||||
schedule?: SeasonSchedule;
|
||||
scoringConfig?: SeasonScoringConfig;
|
||||
dropPolicy?: SeasonDropPolicy;
|
||||
stewardingConfig?: SeasonStewardingConfig;
|
||||
maxDrivers?: number;
|
||||
}): Season {
|
||||
if (!props.id || props.id.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('Season ID is required');
|
||||
}
|
||||
|
||||
if (!props.leagueId || props.leagueId.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('Season leagueId is required');
|
||||
}
|
||||
|
||||
if (!props.gameId || props.gameId.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('Season gameId is required');
|
||||
}
|
||||
|
||||
if (!props.name || props.name.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('Season name is required');
|
||||
}
|
||||
|
||||
const status: SeasonStatus = props.status ?? 'planned';
|
||||
|
||||
return new Season({
|
||||
id: props.id,
|
||||
leagueId: props.leagueId,
|
||||
gameId: props.gameId,
|
||||
name: props.name,
|
||||
...(props.year !== undefined ? { year: props.year } : {}),
|
||||
...(props.order !== undefined ? { order: props.order } : {}),
|
||||
status,
|
||||
...(props.startDate !== undefined ? { startDate: props.startDate } : {}),
|
||||
...(props.endDate !== undefined ? { endDate: props.endDate } : {}),
|
||||
...(props.schedule !== undefined ? { schedule: props.schedule } : {}),
|
||||
...(props.scoringConfig !== undefined
|
||||
? { scoringConfig: props.scoringConfig }
|
||||
: {}),
|
||||
...(props.dropPolicy !== undefined ? { dropPolicy: props.dropPolicy } : {}),
|
||||
...(props.stewardingConfig !== undefined
|
||||
? { stewardingConfig: props.stewardingConfig }
|
||||
: {}),
|
||||
...(props.maxDrivers !== undefined ? { maxDrivers: props.maxDrivers } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Domain rule: Wallet withdrawals are only allowed when season is completed
|
||||
*/
|
||||
canWithdrawFromWallet(): boolean {
|
||||
return this.status === 'completed';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if season is active
|
||||
*/
|
||||
isActive(): boolean {
|
||||
return this.status === 'active';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if season is completed
|
||||
*/
|
||||
isCompleted(): boolean {
|
||||
return this.status === 'completed';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if season is planned (not yet active)
|
||||
*/
|
||||
isPlanned(): boolean {
|
||||
return this.status === 'planned';
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate the season from planned state.
|
||||
*/
|
||||
activate(): Season {
|
||||
if (this.status !== 'planned') {
|
||||
throw new RacingDomainInvariantError(
|
||||
'Only planned seasons can be activated',
|
||||
);
|
||||
}
|
||||
|
||||
return Season.create({
|
||||
id: this.id,
|
||||
leagueId: this.leagueId,
|
||||
gameId: this.gameId,
|
||||
name: this.name,
|
||||
...(this.year !== undefined ? { year: this.year } : {}),
|
||||
...(this.order !== undefined ? { order: this.order } : {}),
|
||||
status: 'active',
|
||||
...(this.startDate !== undefined ? { startDate: this.startDate } : {
|
||||
startDate: new Date(),
|
||||
}),
|
||||
...(this.endDate !== undefined ? { endDate: this.endDate } : {}),
|
||||
...(this.schedule !== undefined ? { schedule: this.schedule } : {}),
|
||||
...(this.scoringConfig !== undefined
|
||||
? { scoringConfig: this.scoringConfig }
|
||||
: {}),
|
||||
...(this.dropPolicy !== undefined ? { dropPolicy: this.dropPolicy } : {}),
|
||||
...(this.stewardingConfig !== undefined
|
||||
? { stewardingConfig: this.stewardingConfig }
|
||||
: {}),
|
||||
...(this.maxDrivers !== undefined ? { maxDrivers: this.maxDrivers } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the season as completed.
|
||||
*/
|
||||
complete(): Season {
|
||||
if (this.status !== 'active') {
|
||||
throw new RacingDomainInvariantError(
|
||||
'Only active seasons can be completed',
|
||||
);
|
||||
}
|
||||
|
||||
return Season.create({
|
||||
id: this.id,
|
||||
leagueId: this.leagueId,
|
||||
gameId: this.gameId,
|
||||
name: this.name,
|
||||
...(this.year !== undefined ? { year: this.year } : {}),
|
||||
...(this.order !== undefined ? { order: this.order } : {}),
|
||||
status: 'completed',
|
||||
...(this.startDate !== undefined ? { startDate: this.startDate } : {}),
|
||||
...(this.endDate !== undefined ? { endDate: this.endDate } : {
|
||||
endDate: new Date(),
|
||||
}),
|
||||
...(this.schedule !== undefined ? { schedule: this.schedule } : {}),
|
||||
...(this.scoringConfig !== undefined
|
||||
? { scoringConfig: this.scoringConfig }
|
||||
: {}),
|
||||
...(this.dropPolicy !== undefined ? { dropPolicy: this.dropPolicy } : {}),
|
||||
...(this.stewardingConfig !== undefined
|
||||
? { stewardingConfig: this.stewardingConfig }
|
||||
: {}),
|
||||
...(this.maxDrivers !== undefined ? { maxDrivers: this.maxDrivers } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive a completed season.
|
||||
*/
|
||||
archive(): Season {
|
||||
if (!this.isCompleted()) {
|
||||
throw new RacingDomainInvariantError(
|
||||
'Only completed seasons can be archived',
|
||||
);
|
||||
}
|
||||
|
||||
return Season.create({
|
||||
id: this.id,
|
||||
leagueId: this.leagueId,
|
||||
gameId: this.gameId,
|
||||
name: this.name,
|
||||
...(this.year !== undefined ? { year: this.year } : {}),
|
||||
...(this.order !== undefined ? { order: this.order } : {}),
|
||||
status: 'archived',
|
||||
...(this.startDate !== undefined ? { startDate: this.startDate } : {}),
|
||||
...(this.endDate !== undefined ? { endDate: this.endDate } : {}),
|
||||
...(this.schedule !== undefined ? { schedule: this.schedule } : {}),
|
||||
...(this.scoringConfig !== undefined
|
||||
? { scoringConfig: this.scoringConfig }
|
||||
: {}),
|
||||
...(this.dropPolicy !== undefined ? { dropPolicy: this.dropPolicy } : {}),
|
||||
...(this.stewardingConfig !== undefined
|
||||
? { stewardingConfig: this.stewardingConfig }
|
||||
: {}),
|
||||
...(this.maxDrivers !== undefined ? { maxDrivers: this.maxDrivers } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a planned or active season.
|
||||
*/
|
||||
cancel(): Season {
|
||||
if (this.status === 'completed' || this.status === 'archived') {
|
||||
throw new RacingDomainInvariantError(
|
||||
'Cannot cancel a completed or archived season',
|
||||
);
|
||||
}
|
||||
|
||||
if (this.status === 'cancelled') {
|
||||
return this;
|
||||
}
|
||||
|
||||
return Season.create({
|
||||
id: this.id,
|
||||
leagueId: this.leagueId,
|
||||
gameId: this.gameId,
|
||||
name: this.name,
|
||||
...(this.year !== undefined ? { year: this.year } : {}),
|
||||
...(this.order !== undefined ? { order: this.order } : {}),
|
||||
status: 'cancelled',
|
||||
...(this.startDate !== undefined ? { startDate: this.startDate } : {}),
|
||||
...(this.endDate !== undefined ? { endDate: this.endDate } : {
|
||||
endDate: new Date(),
|
||||
}),
|
||||
...(this.schedule !== undefined ? { schedule: this.schedule } : {}),
|
||||
...(this.scoringConfig !== undefined
|
||||
? { scoringConfig: this.scoringConfig }
|
||||
: {}),
|
||||
...(this.dropPolicy !== undefined ? { dropPolicy: this.dropPolicy } : {}),
|
||||
...(this.stewardingConfig !== undefined
|
||||
? { stewardingConfig: this.stewardingConfig }
|
||||
: {}),
|
||||
...(this.maxDrivers !== undefined ? { maxDrivers: this.maxDrivers } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update schedule while keeping other properties intact.
|
||||
*/
|
||||
withSchedule(schedule: SeasonSchedule): Season {
|
||||
return Season.create({
|
||||
id: this.id,
|
||||
leagueId: this.leagueId,
|
||||
gameId: this.gameId,
|
||||
name: this.name,
|
||||
...(this.year !== undefined ? { year: this.year } : {}),
|
||||
...(this.order !== undefined ? { order: this.order } : {}),
|
||||
status: this.status,
|
||||
...(this.startDate !== undefined ? { startDate: this.startDate } : {}),
|
||||
...(this.endDate !== undefined ? { endDate: this.endDate } : {}),
|
||||
schedule,
|
||||
...(this.scoringConfig !== undefined
|
||||
? { scoringConfig: this.scoringConfig }
|
||||
: {}),
|
||||
...(this.dropPolicy !== undefined ? { dropPolicy: this.dropPolicy } : {}),
|
||||
...(this.stewardingConfig !== undefined
|
||||
? { stewardingConfig: this.stewardingConfig }
|
||||
: {}),
|
||||
...(this.maxDrivers !== undefined ? { maxDrivers: this.maxDrivers } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update scoring configuration for the season.
|
||||
*/
|
||||
withScoringConfig(scoringConfig: SeasonScoringConfig): Season {
|
||||
return Season.create({
|
||||
id: this.id,
|
||||
leagueId: this.leagueId,
|
||||
gameId: this.gameId,
|
||||
name: this.name,
|
||||
...(this.year !== undefined ? { year: this.year } : {}),
|
||||
...(this.order !== undefined ? { order: this.order } : {}),
|
||||
status: this.status,
|
||||
...(this.startDate !== undefined ? { startDate: this.startDate } : {}),
|
||||
...(this.endDate !== undefined ? { endDate: this.endDate } : {}),
|
||||
...(this.schedule !== undefined ? { schedule: this.schedule } : {}),
|
||||
scoringConfig,
|
||||
...(this.dropPolicy !== undefined ? { dropPolicy: this.dropPolicy } : {}),
|
||||
...(this.stewardingConfig !== undefined
|
||||
? { stewardingConfig: this.stewardingConfig }
|
||||
: {}),
|
||||
...(this.maxDrivers !== undefined ? { maxDrivers: this.maxDrivers } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update drop policy for the season.
|
||||
*/
|
||||
withDropPolicy(dropPolicy: SeasonDropPolicy): Season {
|
||||
return Season.create({
|
||||
id: this.id,
|
||||
leagueId: this.leagueId,
|
||||
gameId: this.gameId,
|
||||
name: this.name,
|
||||
...(this.year !== undefined ? { year: this.year } : {}),
|
||||
...(this.order !== undefined ? { order: this.order } : {}),
|
||||
status: this.status,
|
||||
...(this.startDate !== undefined ? { startDate: this.startDate } : {}),
|
||||
...(this.endDate !== undefined ? { endDate: this.endDate } : {}),
|
||||
...(this.schedule !== undefined ? { schedule: this.schedule } : {}),
|
||||
...(this.scoringConfig !== undefined
|
||||
? { scoringConfig: this.scoringConfig }
|
||||
: {}),
|
||||
dropPolicy,
|
||||
...(this.stewardingConfig !== undefined
|
||||
? { stewardingConfig: this.stewardingConfig }
|
||||
: {}),
|
||||
...(this.maxDrivers !== undefined ? { maxDrivers: this.maxDrivers } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update stewarding configuration for the season.
|
||||
*/
|
||||
withStewardingConfig(stewardingConfig: SeasonStewardingConfig): Season {
|
||||
return Season.create({
|
||||
id: this.id,
|
||||
leagueId: this.leagueId,
|
||||
gameId: this.gameId,
|
||||
name: this.name,
|
||||
...(this.year !== undefined ? { year: this.year } : {}),
|
||||
...(this.order !== undefined ? { order: this.order } : {}),
|
||||
status: this.status,
|
||||
...(this.startDate !== undefined ? { startDate: this.startDate } : {}),
|
||||
...(this.endDate !== undefined ? { endDate: this.endDate } : {}),
|
||||
...(this.schedule !== undefined ? { schedule: this.schedule } : {}),
|
||||
...(this.scoringConfig !== undefined
|
||||
? { scoringConfig: this.scoringConfig }
|
||||
: {}),
|
||||
...(this.dropPolicy !== undefined ? { dropPolicy: this.dropPolicy } : {}),
|
||||
stewardingConfig,
|
||||
...(this.maxDrivers !== undefined ? { maxDrivers: this.maxDrivers } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update max driver capacity for the season.
|
||||
*/
|
||||
withMaxDrivers(maxDrivers: number | undefined): Season {
|
||||
if (maxDrivers !== undefined && maxDrivers <= 0) {
|
||||
throw new RacingDomainValidationError(
|
||||
'Season maxDrivers must be greater than 0 when provided',
|
||||
);
|
||||
}
|
||||
|
||||
return Season.create({
|
||||
id: this.id,
|
||||
leagueId: this.leagueId,
|
||||
gameId: this.gameId,
|
||||
name: this.name,
|
||||
...(this.year !== undefined ? { year: this.year } : {}),
|
||||
...(this.order !== undefined ? { order: this.order } : {}),
|
||||
status: this.status,
|
||||
...(this.startDate !== undefined ? { startDate: this.startDate } : {}),
|
||||
...(this.endDate !== undefined ? { endDate: this.endDate } : {}),
|
||||
...(this.schedule !== undefined ? { schedule: this.schedule } : {}),
|
||||
...(this.scoringConfig !== undefined
|
||||
? { scoringConfig: this.scoringConfig }
|
||||
: {}),
|
||||
...(this.dropPolicy !== undefined ? { dropPolicy: this.dropPolicy } : {}),
|
||||
...(this.stewardingConfig !== undefined
|
||||
? { stewardingConfig: this.stewardingConfig }
|
||||
: {}),
|
||||
...(maxDrivers !== undefined ? { maxDrivers } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
268
core/racing/domain/entities/SeasonSponsorship.ts
Normal file
268
core/racing/domain/entities/SeasonSponsorship.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
/**
|
||||
* Domain Entity: SeasonSponsorship
|
||||
*
|
||||
* Represents a sponsorship relationship between a Sponsor and a Season.
|
||||
* Aggregate root for managing sponsorship slots and pricing.
|
||||
*/
|
||||
|
||||
import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError';
|
||||
import type { IEntity } from '@gridpilot/shared/domain';
|
||||
|
||||
import type { Money } from '../value-objects/Money';
|
||||
|
||||
export type SponsorshipTier = 'main' | 'secondary';
|
||||
export type SponsorshipStatus = 'pending' | 'active' | 'ended' | 'cancelled';
|
||||
|
||||
export interface SeasonSponsorshipProps {
|
||||
id: string;
|
||||
seasonId: string;
|
||||
/**
|
||||
* Optional denormalized leagueId for fast league-level aggregations.
|
||||
* Must always match the owning Season's leagueId when present.
|
||||
*/
|
||||
leagueId?: string;
|
||||
sponsorId: string;
|
||||
tier: SponsorshipTier;
|
||||
pricing: Money;
|
||||
status: SponsorshipStatus;
|
||||
createdAt: Date;
|
||||
activatedAt?: Date;
|
||||
endedAt?: Date;
|
||||
cancelledAt?: Date;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export class SeasonSponsorship implements IEntity<string> {
|
||||
readonly id: string;
|
||||
readonly seasonId: string;
|
||||
readonly leagueId: string | undefined;
|
||||
readonly sponsorId: string;
|
||||
readonly tier: SponsorshipTier;
|
||||
readonly pricing: Money;
|
||||
readonly status: SponsorshipStatus;
|
||||
readonly createdAt: Date;
|
||||
readonly activatedAt: Date | undefined;
|
||||
readonly endedAt: Date | undefined;
|
||||
readonly cancelledAt: Date | undefined;
|
||||
readonly description: string | undefined;
|
||||
|
||||
private constructor(props: SeasonSponsorshipProps) {
|
||||
this.id = props.id;
|
||||
this.seasonId = props.seasonId;
|
||||
this.leagueId = props.leagueId;
|
||||
this.sponsorId = props.sponsorId;
|
||||
this.tier = props.tier;
|
||||
this.pricing = props.pricing;
|
||||
this.status = props.status;
|
||||
this.createdAt = props.createdAt;
|
||||
this.activatedAt = props.activatedAt;
|
||||
this.endedAt = props.endedAt;
|
||||
this.cancelledAt = props.cancelledAt;
|
||||
this.description = props.description;
|
||||
}
|
||||
|
||||
static create(props: Omit<SeasonSponsorshipProps, 'createdAt' | 'status'> & {
|
||||
createdAt?: Date;
|
||||
status?: SponsorshipStatus;
|
||||
}): SeasonSponsorship {
|
||||
this.validate(props);
|
||||
|
||||
return new SeasonSponsorship({
|
||||
id: props.id,
|
||||
seasonId: props.seasonId,
|
||||
...(props.leagueId !== undefined ? { leagueId: props.leagueId } : {}),
|
||||
sponsorId: props.sponsorId,
|
||||
tier: props.tier,
|
||||
pricing: props.pricing,
|
||||
status: props.status ?? 'pending',
|
||||
createdAt: props.createdAt ?? new Date(),
|
||||
...(props.activatedAt !== undefined ? { activatedAt: props.activatedAt } : {}),
|
||||
...(props.endedAt !== undefined ? { endedAt: props.endedAt } : {}),
|
||||
...(props.cancelledAt !== undefined ? { cancelledAt: props.cancelledAt } : {}),
|
||||
...(props.description !== undefined ? { description: props.description } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
private static validate(props: Omit<SeasonSponsorshipProps, 'createdAt' | 'status'>): void {
|
||||
if (!props.id || props.id.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('SeasonSponsorship ID is required');
|
||||
}
|
||||
|
||||
if (!props.seasonId || props.seasonId.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('SeasonSponsorship seasonId is required');
|
||||
}
|
||||
|
||||
if (!props.sponsorId || props.sponsorId.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('SeasonSponsorship sponsorId is required');
|
||||
}
|
||||
|
||||
if (!props.tier) {
|
||||
throw new RacingDomainValidationError('SeasonSponsorship tier is required');
|
||||
}
|
||||
|
||||
if (!props.pricing) {
|
||||
throw new RacingDomainValidationError('SeasonSponsorship pricing is required');
|
||||
}
|
||||
|
||||
if (props.pricing.amount <= 0) {
|
||||
throw new RacingDomainValidationError('SeasonSponsorship pricing must be greater than zero');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate the sponsorship
|
||||
*/
|
||||
activate(): SeasonSponsorship {
|
||||
if (this.status === 'active') {
|
||||
throw new RacingDomainInvariantError('SeasonSponsorship is already active');
|
||||
}
|
||||
|
||||
if (this.status === 'cancelled') {
|
||||
throw new RacingDomainInvariantError('Cannot activate a cancelled SeasonSponsorship');
|
||||
}
|
||||
|
||||
if (this.status === 'ended') {
|
||||
throw new RacingDomainInvariantError('Cannot activate an ended SeasonSponsorship');
|
||||
}
|
||||
|
||||
const base: SeasonSponsorshipProps = {
|
||||
id: this.id,
|
||||
seasonId: this.seasonId,
|
||||
...(this.leagueId !== undefined ? { leagueId: this.leagueId } : {}),
|
||||
sponsorId: this.sponsorId,
|
||||
tier: this.tier,
|
||||
pricing: this.pricing,
|
||||
status: 'active',
|
||||
createdAt: this.createdAt,
|
||||
activatedAt: new Date(),
|
||||
...(this.endedAt !== undefined ? { endedAt: this.endedAt } : {}),
|
||||
...(this.cancelledAt !== undefined ? { cancelledAt: this.cancelledAt } : {}),
|
||||
};
|
||||
|
||||
const next: SeasonSponsorshipProps =
|
||||
this.description !== undefined
|
||||
? { ...base, description: this.description }
|
||||
: base;
|
||||
|
||||
return new SeasonSponsorship(next);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the sponsorship as ended (completed term)
|
||||
*/
|
||||
end(): SeasonSponsorship {
|
||||
if (this.status === 'cancelled') {
|
||||
throw new RacingDomainInvariantError('Cannot end a cancelled SeasonSponsorship');
|
||||
}
|
||||
|
||||
if (this.status === 'ended') {
|
||||
throw new RacingDomainInvariantError('SeasonSponsorship is already ended');
|
||||
}
|
||||
|
||||
const base: SeasonSponsorshipProps = {
|
||||
id: this.id,
|
||||
seasonId: this.seasonId,
|
||||
...(this.leagueId !== undefined ? { leagueId: this.leagueId } : {}),
|
||||
sponsorId: this.sponsorId,
|
||||
tier: this.tier,
|
||||
pricing: this.pricing,
|
||||
status: 'ended',
|
||||
createdAt: this.createdAt,
|
||||
...(this.activatedAt !== undefined ? { activatedAt: this.activatedAt } : {}),
|
||||
endedAt: new Date(),
|
||||
...(this.cancelledAt !== undefined ? { cancelledAt: this.cancelledAt } : {}),
|
||||
};
|
||||
|
||||
const next: SeasonSponsorshipProps =
|
||||
this.description !== undefined
|
||||
? { ...base, description: this.description }
|
||||
: base;
|
||||
|
||||
return new SeasonSponsorship(next);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel the sponsorship
|
||||
*/
|
||||
cancel(): SeasonSponsorship {
|
||||
if (this.status === 'cancelled') {
|
||||
throw new RacingDomainInvariantError('SeasonSponsorship is already cancelled');
|
||||
}
|
||||
|
||||
const base: SeasonSponsorshipProps = {
|
||||
id: this.id,
|
||||
seasonId: this.seasonId,
|
||||
...(this.leagueId !== undefined ? { leagueId: this.leagueId } : {}),
|
||||
sponsorId: this.sponsorId,
|
||||
tier: this.tier,
|
||||
pricing: this.pricing,
|
||||
status: 'cancelled',
|
||||
createdAt: this.createdAt,
|
||||
...(this.activatedAt !== undefined ? { activatedAt: this.activatedAt } : {}),
|
||||
...(this.endedAt !== undefined ? { endedAt: this.endedAt } : {}),
|
||||
cancelledAt: new Date(),
|
||||
};
|
||||
|
||||
const next: SeasonSponsorshipProps =
|
||||
this.description !== undefined
|
||||
? { ...base, description: this.description }
|
||||
: base;
|
||||
|
||||
return new SeasonSponsorship(next);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update pricing/terms when allowed
|
||||
*/
|
||||
withPricing(pricing: Money): SeasonSponsorship {
|
||||
if (pricing.amount <= 0) {
|
||||
throw new RacingDomainValidationError('SeasonSponsorship pricing must be greater than zero');
|
||||
}
|
||||
|
||||
if (this.status === 'cancelled' || this.status === 'ended') {
|
||||
throw new RacingDomainInvariantError('Cannot update pricing for ended or cancelled SeasonSponsorship');
|
||||
}
|
||||
|
||||
const base: SeasonSponsorshipProps = {
|
||||
id: this.id,
|
||||
seasonId: this.seasonId,
|
||||
...(this.leagueId !== undefined ? { leagueId: this.leagueId } : {}),
|
||||
sponsorId: this.sponsorId,
|
||||
tier: this.tier,
|
||||
pricing,
|
||||
status: this.status,
|
||||
createdAt: this.createdAt,
|
||||
...(this.activatedAt !== undefined ? { activatedAt: this.activatedAt } : {}),
|
||||
...(this.endedAt !== undefined ? { endedAt: this.endedAt } : {}),
|
||||
...(this.cancelledAt !== undefined ? { cancelledAt: this.cancelledAt } : {}),
|
||||
};
|
||||
|
||||
const next: SeasonSponsorshipProps =
|
||||
this.description !== undefined
|
||||
? { ...base, description: this.description }
|
||||
: base;
|
||||
|
||||
return new SeasonSponsorship(next);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if sponsorship is active
|
||||
*/
|
||||
isActive(): boolean {
|
||||
return this.status === 'active';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the platform fee for this sponsorship
|
||||
*/
|
||||
getPlatformFee(): Money {
|
||||
return this.pricing.calculatePlatformFee();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the net amount after platform fee
|
||||
*/
|
||||
getNetAmount(): Money {
|
||||
return this.pricing.calculateNetAmount();
|
||||
}
|
||||
}
|
||||
311
core/racing/domain/entities/Session.ts
Normal file
311
core/racing/domain/entities/Session.ts
Normal file
@@ -0,0 +1,311 @@
|
||||
/**
|
||||
* Domain Entity: Session
|
||||
*
|
||||
* Represents a racing session within a race event.
|
||||
* Immutable entity with factory methods and domain validation.
|
||||
*/
|
||||
|
||||
import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError';
|
||||
import type { IEntity } from '@gridpilot/shared/domain';
|
||||
import type { SessionType } from '../value-objects/SessionType';
|
||||
|
||||
export type SessionStatus = 'scheduled' | 'running' | 'completed' | 'cancelled';
|
||||
|
||||
export class Session implements IEntity<string> {
|
||||
readonly id: string;
|
||||
readonly raceEventId: string;
|
||||
readonly scheduledAt: Date;
|
||||
readonly track: string;
|
||||
readonly trackId: string | undefined;
|
||||
readonly car: string;
|
||||
readonly carId: string | undefined;
|
||||
readonly sessionType: SessionType;
|
||||
readonly status: SessionStatus;
|
||||
readonly strengthOfField: number | undefined;
|
||||
readonly registeredCount: number | undefined;
|
||||
readonly maxParticipants: number | undefined;
|
||||
|
||||
private constructor(props: {
|
||||
id: string;
|
||||
raceEventId: string;
|
||||
scheduledAt: Date;
|
||||
track: string;
|
||||
trackId?: string;
|
||||
car: string;
|
||||
carId?: string;
|
||||
sessionType: SessionType;
|
||||
status: SessionStatus;
|
||||
strengthOfField?: number;
|
||||
registeredCount?: number;
|
||||
maxParticipants?: number;
|
||||
}) {
|
||||
this.id = props.id;
|
||||
this.raceEventId = props.raceEventId;
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory method to create a new Session entity
|
||||
*/
|
||||
static create(props: {
|
||||
id: string;
|
||||
raceEventId: string;
|
||||
scheduledAt: Date;
|
||||
track: string;
|
||||
trackId?: string;
|
||||
car: string;
|
||||
carId?: string;
|
||||
sessionType: SessionType;
|
||||
status?: SessionStatus;
|
||||
strengthOfField?: number;
|
||||
registeredCount?: number;
|
||||
maxParticipants?: number;
|
||||
}): Session {
|
||||
this.validate(props);
|
||||
|
||||
return new Session({
|
||||
id: props.id,
|
||||
raceEventId: props.raceEventId,
|
||||
scheduledAt: props.scheduledAt,
|
||||
track: props.track,
|
||||
...(props.trackId !== undefined ? { trackId: props.trackId } : {}),
|
||||
car: props.car,
|
||||
...(props.carId !== undefined ? { carId: props.carId } : {}),
|
||||
sessionType: props.sessionType,
|
||||
status: props.status ?? 'scheduled',
|
||||
...(props.strengthOfField !== undefined ? { strengthOfField: props.strengthOfField } : {}),
|
||||
...(props.registeredCount !== undefined ? { registeredCount: props.registeredCount } : {}),
|
||||
...(props.maxParticipants !== undefined ? { maxParticipants: props.maxParticipants } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Domain validation logic
|
||||
*/
|
||||
private static validate(props: {
|
||||
id: string;
|
||||
raceEventId: string;
|
||||
scheduledAt: Date;
|
||||
track: string;
|
||||
car: string;
|
||||
sessionType: SessionType;
|
||||
}): void {
|
||||
if (!props.id || props.id.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('Session ID is required');
|
||||
}
|
||||
|
||||
if (!props.raceEventId || props.raceEventId.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('Race Event ID is required');
|
||||
}
|
||||
|
||||
if (!props.scheduledAt || !(props.scheduledAt instanceof Date)) {
|
||||
throw new RacingDomainValidationError('Valid scheduled date is required');
|
||||
}
|
||||
|
||||
if (!props.track || props.track.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('Track is required');
|
||||
}
|
||||
|
||||
if (!props.car || props.car.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('Car is required');
|
||||
}
|
||||
|
||||
if (!props.sessionType) {
|
||||
throw new RacingDomainValidationError('Session type is required');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the session (move from scheduled to running)
|
||||
*/
|
||||
start(): Session {
|
||||
if (this.status !== 'scheduled') {
|
||||
throw new RacingDomainInvariantError('Only scheduled sessions can be started');
|
||||
}
|
||||
|
||||
const base = {
|
||||
id: this.id,
|
||||
raceEventId: this.raceEventId,
|
||||
scheduledAt: this.scheduledAt,
|
||||
track: this.track,
|
||||
car: this.car,
|
||||
sessionType: this.sessionType,
|
||||
status: 'running' as SessionStatus,
|
||||
};
|
||||
|
||||
const withTrackId =
|
||||
this.trackId !== undefined ? { ...base, trackId: this.trackId } : base;
|
||||
const withCarId =
|
||||
this.carId !== undefined ? { ...withTrackId, carId: this.carId } : withTrackId;
|
||||
const withSof =
|
||||
this.strengthOfField !== undefined
|
||||
? { ...withCarId, strengthOfField: this.strengthOfField }
|
||||
: withCarId;
|
||||
const withRegistered =
|
||||
this.registeredCount !== undefined
|
||||
? { ...withSof, registeredCount: this.registeredCount }
|
||||
: withSof;
|
||||
const props =
|
||||
this.maxParticipants !== undefined
|
||||
? { ...withRegistered, maxParticipants: this.maxParticipants }
|
||||
: withRegistered;
|
||||
|
||||
return Session.create(props);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark session as completed
|
||||
*/
|
||||
complete(): Session {
|
||||
if (this.status === 'completed') {
|
||||
throw new RacingDomainInvariantError('Session is already completed');
|
||||
}
|
||||
|
||||
if (this.status === 'cancelled') {
|
||||
throw new RacingDomainInvariantError('Cannot complete a cancelled session');
|
||||
}
|
||||
|
||||
const base = {
|
||||
id: this.id,
|
||||
raceEventId: this.raceEventId,
|
||||
scheduledAt: this.scheduledAt,
|
||||
track: this.track,
|
||||
car: this.car,
|
||||
sessionType: this.sessionType,
|
||||
status: 'completed' as SessionStatus,
|
||||
};
|
||||
|
||||
const withTrackId =
|
||||
this.trackId !== undefined ? { ...base, trackId: this.trackId } : base;
|
||||
const withCarId =
|
||||
this.carId !== undefined ? { ...withTrackId, carId: this.carId } : withTrackId;
|
||||
const withSof =
|
||||
this.strengthOfField !== undefined
|
||||
? { ...withCarId, strengthOfField: this.strengthOfField }
|
||||
: withCarId;
|
||||
const withRegistered =
|
||||
this.registeredCount !== undefined
|
||||
? { ...withSof, registeredCount: this.registeredCount }
|
||||
: withSof;
|
||||
const props =
|
||||
this.maxParticipants !== undefined
|
||||
? { ...withRegistered, maxParticipants: this.maxParticipants }
|
||||
: withRegistered;
|
||||
|
||||
return Session.create(props);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel the session
|
||||
*/
|
||||
cancel(): Session {
|
||||
if (this.status === 'completed') {
|
||||
throw new RacingDomainInvariantError('Cannot cancel a completed session');
|
||||
}
|
||||
|
||||
if (this.status === 'cancelled') {
|
||||
throw new RacingDomainInvariantError('Session is already cancelled');
|
||||
}
|
||||
|
||||
const base = {
|
||||
id: this.id,
|
||||
raceEventId: this.raceEventId,
|
||||
scheduledAt: this.scheduledAt,
|
||||
track: this.track,
|
||||
car: this.car,
|
||||
sessionType: this.sessionType,
|
||||
status: 'cancelled' as SessionStatus,
|
||||
};
|
||||
|
||||
const withTrackId =
|
||||
this.trackId !== undefined ? { ...base, trackId: this.trackId } : base;
|
||||
const withCarId =
|
||||
this.carId !== undefined ? { ...withTrackId, carId: this.carId } : withTrackId;
|
||||
const withSof =
|
||||
this.strengthOfField !== undefined
|
||||
? { ...withCarId, strengthOfField: this.strengthOfField }
|
||||
: withCarId;
|
||||
const withRegistered =
|
||||
this.registeredCount !== undefined
|
||||
? { ...withSof, registeredCount: this.registeredCount }
|
||||
: withSof;
|
||||
const props =
|
||||
this.maxParticipants !== undefined
|
||||
? { ...withRegistered, maxParticipants: this.maxParticipants }
|
||||
: withRegistered;
|
||||
|
||||
return Session.create(props);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update SOF and participant count
|
||||
*/
|
||||
updateField(strengthOfField: number, registeredCount: number): Session {
|
||||
const base = {
|
||||
id: this.id,
|
||||
raceEventId: this.raceEventId,
|
||||
scheduledAt: this.scheduledAt,
|
||||
track: this.track,
|
||||
car: this.car,
|
||||
sessionType: this.sessionType,
|
||||
status: this.status,
|
||||
strengthOfField,
|
||||
registeredCount,
|
||||
};
|
||||
|
||||
const withTrackId =
|
||||
this.trackId !== undefined ? { ...base, trackId: this.trackId } : base;
|
||||
const withCarId =
|
||||
this.carId !== undefined ? { ...withTrackId, carId: this.carId } : withTrackId;
|
||||
const props =
|
||||
this.maxParticipants !== undefined
|
||||
? { ...withCarId, maxParticipants: this.maxParticipants }
|
||||
: withCarId;
|
||||
|
||||
return Session.create(props);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if session is in the past
|
||||
*/
|
||||
isPast(): boolean {
|
||||
return this.scheduledAt < new Date();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if session is upcoming
|
||||
*/
|
||||
isUpcoming(): boolean {
|
||||
return this.status === 'scheduled' && !this.isPast();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if session is live/running
|
||||
*/
|
||||
isLive(): boolean {
|
||||
return this.status === 'running';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this session counts for championship points
|
||||
*/
|
||||
countsForPoints(): boolean {
|
||||
return this.sessionType.countsForPoints();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this session determines grid positions
|
||||
*/
|
||||
determinesGrid(): boolean {
|
||||
return this.sessionType.determinesGrid();
|
||||
}
|
||||
}
|
||||
122
core/racing/domain/entities/Sponsor.ts
Normal file
122
core/racing/domain/entities/Sponsor.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Domain Entity: Sponsor
|
||||
*
|
||||
* Represents a sponsor that can sponsor leagues/seasons.
|
||||
* Aggregate root for sponsor information.
|
||||
*/
|
||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||
import type { IEntity } from '@gridpilot/shared/domain';
|
||||
|
||||
export interface SponsorProps {
|
||||
id: string;
|
||||
name: string;
|
||||
contactEmail: string;
|
||||
logoUrl?: string;
|
||||
websiteUrl?: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export class Sponsor implements IEntity<string> {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly contactEmail: string;
|
||||
readonly logoUrl: string | undefined;
|
||||
readonly websiteUrl: string | undefined;
|
||||
readonly createdAt: Date;
|
||||
|
||||
private constructor(props: SponsorProps) {
|
||||
this.id = props.id;
|
||||
this.name = props.name;
|
||||
this.contactEmail = props.contactEmail;
|
||||
this.logoUrl = props.logoUrl;
|
||||
this.websiteUrl = props.websiteUrl;
|
||||
this.createdAt = props.createdAt;
|
||||
}
|
||||
|
||||
static create(props: Omit<SponsorProps, 'createdAt'> & { createdAt?: Date }): Sponsor {
|
||||
this.validate(props);
|
||||
|
||||
const { createdAt, ...rest } = props;
|
||||
const base = {
|
||||
id: rest.id,
|
||||
name: rest.name,
|
||||
contactEmail: rest.contactEmail,
|
||||
createdAt: createdAt ?? new Date(),
|
||||
};
|
||||
|
||||
const withLogo =
|
||||
rest.logoUrl !== undefined ? { ...base, logoUrl: rest.logoUrl } : base;
|
||||
const withWebsite =
|
||||
rest.websiteUrl !== undefined
|
||||
? { ...withLogo, websiteUrl: rest.websiteUrl }
|
||||
: withLogo;
|
||||
|
||||
return new Sponsor(withWebsite);
|
||||
}
|
||||
|
||||
private static validate(props: Omit<SponsorProps, 'createdAt'>): void {
|
||||
if (!props.id || props.id.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('Sponsor ID is required');
|
||||
}
|
||||
|
||||
if (!props.name || props.name.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('Sponsor name is required');
|
||||
}
|
||||
|
||||
if (props.name.length > 100) {
|
||||
throw new RacingDomainValidationError('Sponsor name must be 100 characters or less');
|
||||
}
|
||||
|
||||
if (!props.contactEmail || props.contactEmail.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('Sponsor contact email is required');
|
||||
}
|
||||
|
||||
// Basic email validation
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(props.contactEmail)) {
|
||||
throw new RacingDomainValidationError('Invalid sponsor contact email format');
|
||||
}
|
||||
|
||||
if (props.websiteUrl && props.websiteUrl.trim().length > 0) {
|
||||
try {
|
||||
new URL(props.websiteUrl);
|
||||
} catch {
|
||||
throw new RacingDomainValidationError('Invalid sponsor website URL');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update sponsor information
|
||||
*/
|
||||
update(props: Partial<{
|
||||
name: string;
|
||||
contactEmail: string;
|
||||
logoUrl?: string;
|
||||
websiteUrl?: string;
|
||||
}>): Sponsor {
|
||||
const updatedBase = {
|
||||
id: this.id,
|
||||
name: props.name ?? this.name,
|
||||
contactEmail: props.contactEmail ?? this.contactEmail,
|
||||
createdAt: this.createdAt,
|
||||
};
|
||||
|
||||
const withLogo =
|
||||
props.logoUrl !== undefined
|
||||
? { ...updatedBase, logoUrl: props.logoUrl }
|
||||
: this.logoUrl !== undefined
|
||||
? { ...updatedBase, logoUrl: this.logoUrl }
|
||||
: updatedBase;
|
||||
|
||||
const updated =
|
||||
props.websiteUrl !== undefined
|
||||
? { ...withLogo, websiteUrl: props.websiteUrl }
|
||||
: this.websiteUrl !== undefined
|
||||
? { ...withLogo, websiteUrl: this.websiteUrl }
|
||||
: withLogo;
|
||||
|
||||
Sponsor.validate(updated);
|
||||
return new Sponsor(updated);
|
||||
}
|
||||
}
|
||||
239
core/racing/domain/entities/SponsorshipRequest.ts
Normal file
239
core/racing/domain/entities/SponsorshipRequest.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* Domain Entity: SponsorshipRequest
|
||||
*
|
||||
* Represents a sponsorship application from a Sponsor to any sponsorable entity
|
||||
* (driver, team, race, or league/season). The entity owner must approve/reject.
|
||||
*/
|
||||
|
||||
import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError';
|
||||
import type { IEntity } from '@gridpilot/shared/domain';
|
||||
|
||||
import type { Money } from '../value-objects/Money';
|
||||
import type { SponsorshipTier } from './SeasonSponsorship';
|
||||
|
||||
export type SponsorableEntityType = 'driver' | 'team' | 'race' | 'season';
|
||||
export type SponsorshipRequestStatus = 'pending' | 'accepted' | 'rejected' | 'withdrawn';
|
||||
|
||||
export interface SponsorshipRequestProps {
|
||||
id: string;
|
||||
sponsorId: string;
|
||||
entityType: SponsorableEntityType;
|
||||
entityId: string;
|
||||
tier: SponsorshipTier;
|
||||
offeredAmount: Money;
|
||||
message?: string;
|
||||
status: SponsorshipRequestStatus;
|
||||
createdAt: Date;
|
||||
respondedAt?: Date;
|
||||
respondedBy?: string; // driverId of the person who accepted/rejected
|
||||
rejectionReason?: string;
|
||||
}
|
||||
|
||||
export class SponsorshipRequest implements IEntity<string> {
|
||||
readonly id: string;
|
||||
readonly sponsorId: string;
|
||||
readonly entityType: SponsorableEntityType;
|
||||
readonly entityId: string;
|
||||
readonly tier: SponsorshipTier;
|
||||
readonly offeredAmount: Money;
|
||||
readonly message: string | undefined;
|
||||
readonly status: SponsorshipRequestStatus;
|
||||
readonly createdAt: Date;
|
||||
readonly respondedAt: Date | undefined;
|
||||
readonly respondedBy: string | undefined;
|
||||
readonly rejectionReason: string | undefined;
|
||||
|
||||
private constructor(props: SponsorshipRequestProps) {
|
||||
this.id = props.id;
|
||||
this.sponsorId = props.sponsorId;
|
||||
this.entityType = props.entityType;
|
||||
this.entityId = props.entityId;
|
||||
this.tier = props.tier;
|
||||
this.offeredAmount = props.offeredAmount;
|
||||
this.message = props.message;
|
||||
this.status = props.status;
|
||||
this.createdAt = props.createdAt;
|
||||
this.respondedAt = props.respondedAt;
|
||||
this.respondedBy = props.respondedBy;
|
||||
this.rejectionReason = props.rejectionReason;
|
||||
}
|
||||
|
||||
static create(props: Omit<SponsorshipRequestProps, 'createdAt' | 'status'> & {
|
||||
createdAt?: Date;
|
||||
status?: SponsorshipRequestStatus;
|
||||
}): SponsorshipRequest {
|
||||
this.validate(props);
|
||||
|
||||
return new SponsorshipRequest({
|
||||
...props,
|
||||
createdAt: props.createdAt ?? new Date(),
|
||||
status: props.status ?? 'pending',
|
||||
});
|
||||
}
|
||||
|
||||
private static validate(props: Omit<SponsorshipRequestProps, 'createdAt' | 'status'>): void {
|
||||
if (!props.id || props.id.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('SponsorshipRequest ID is required');
|
||||
}
|
||||
|
||||
if (!props.sponsorId || props.sponsorId.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('SponsorshipRequest sponsorId is required');
|
||||
}
|
||||
|
||||
if (!props.entityType) {
|
||||
throw new RacingDomainValidationError('SponsorshipRequest entityType is required');
|
||||
}
|
||||
|
||||
if (!props.entityId || props.entityId.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('SponsorshipRequest entityId is required');
|
||||
}
|
||||
|
||||
if (!props.tier) {
|
||||
throw new RacingDomainValidationError('SponsorshipRequest tier is required');
|
||||
}
|
||||
|
||||
if (!props.offeredAmount) {
|
||||
throw new RacingDomainValidationError('SponsorshipRequest offeredAmount is required');
|
||||
}
|
||||
|
||||
if (props.offeredAmount.amount <= 0) {
|
||||
throw new RacingDomainValidationError('SponsorshipRequest offeredAmount must be greater than zero');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept the sponsorship request
|
||||
*/
|
||||
accept(respondedBy: string): SponsorshipRequest {
|
||||
if (this.status !== 'pending') {
|
||||
throw new RacingDomainInvariantError(`Cannot accept a ${this.status} sponsorship request`);
|
||||
}
|
||||
|
||||
if (!respondedBy || respondedBy.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('respondedBy is required when accepting');
|
||||
}
|
||||
|
||||
const base: SponsorshipRequestProps = {
|
||||
id: this.id,
|
||||
sponsorId: this.sponsorId,
|
||||
entityType: this.entityType,
|
||||
entityId: this.entityId,
|
||||
tier: this.tier,
|
||||
offeredAmount: this.offeredAmount,
|
||||
status: 'accepted',
|
||||
createdAt: this.createdAt,
|
||||
respondedAt: new Date(),
|
||||
respondedBy,
|
||||
};
|
||||
|
||||
const withMessage =
|
||||
this.message !== undefined ? { ...base, message: this.message } : base;
|
||||
|
||||
const next: SponsorshipRequestProps =
|
||||
this.rejectionReason !== undefined
|
||||
? { ...withMessage, rejectionReason: this.rejectionReason }
|
||||
: withMessage;
|
||||
|
||||
return new SponsorshipRequest(next);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject the sponsorship request
|
||||
*/
|
||||
reject(respondedBy: string, reason?: string): SponsorshipRequest {
|
||||
if (this.status !== 'pending') {
|
||||
throw new RacingDomainInvariantError(`Cannot reject a ${this.status} sponsorship request`);
|
||||
}
|
||||
|
||||
if (!respondedBy || respondedBy.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('respondedBy is required when rejecting');
|
||||
}
|
||||
|
||||
const base: SponsorshipRequestProps = {
|
||||
id: this.id,
|
||||
sponsorId: this.sponsorId,
|
||||
entityType: this.entityType,
|
||||
entityId: this.entityId,
|
||||
tier: this.tier,
|
||||
offeredAmount: this.offeredAmount,
|
||||
status: 'rejected',
|
||||
createdAt: this.createdAt,
|
||||
respondedAt: new Date(),
|
||||
respondedBy,
|
||||
};
|
||||
|
||||
const withMessage =
|
||||
this.message !== undefined ? { ...base, message: this.message } : base;
|
||||
|
||||
const next: SponsorshipRequestProps =
|
||||
reason !== undefined ? { ...withMessage, rejectionReason: reason } : withMessage;
|
||||
|
||||
return new SponsorshipRequest(next);
|
||||
}
|
||||
|
||||
/**
|
||||
* Withdraw the sponsorship request (by the sponsor)
|
||||
*/
|
||||
withdraw(): SponsorshipRequest {
|
||||
if (this.status !== 'pending') {
|
||||
throw new RacingDomainInvariantError(`Cannot withdraw a ${this.status} sponsorship request`);
|
||||
}
|
||||
|
||||
const base: SponsorshipRequestProps = {
|
||||
id: this.id,
|
||||
sponsorId: this.sponsorId,
|
||||
entityType: this.entityType,
|
||||
entityId: this.entityId,
|
||||
tier: this.tier,
|
||||
offeredAmount: this.offeredAmount,
|
||||
status: 'withdrawn',
|
||||
createdAt: this.createdAt,
|
||||
respondedAt: new Date(),
|
||||
};
|
||||
|
||||
const withRespondedBy =
|
||||
this.respondedBy !== undefined
|
||||
? { ...base, respondedBy: this.respondedBy }
|
||||
: base;
|
||||
|
||||
const withMessage =
|
||||
this.message !== undefined
|
||||
? { ...withRespondedBy, message: this.message }
|
||||
: withRespondedBy;
|
||||
|
||||
const next: SponsorshipRequestProps =
|
||||
this.rejectionReason !== undefined
|
||||
? { ...withMessage, rejectionReason: this.rejectionReason }
|
||||
: withMessage;
|
||||
|
||||
return new SponsorshipRequest(next);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if request is pending
|
||||
*/
|
||||
isPending(): boolean {
|
||||
return this.status === 'pending';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if request was accepted
|
||||
*/
|
||||
isAccepted(): boolean {
|
||||
return this.status === 'accepted';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get platform fee for this request
|
||||
*/
|
||||
getPlatformFee(): Money {
|
||||
return this.offeredAmount.calculatePlatformFee();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get net amount after platform fee
|
||||
*/
|
||||
getNetAmount(): Money {
|
||||
return this.offeredAmount.calculateNetAmount();
|
||||
}
|
||||
}
|
||||
136
core/racing/domain/entities/Standing.ts
Normal file
136
core/racing/domain/entities/Standing.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Domain Entity: Standing
|
||||
*
|
||||
* Represents a championship standing in the GridPilot platform.
|
||||
* Immutable entity with factory methods and domain validation.
|
||||
*/
|
||||
|
||||
import { RacingDomainError, RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||
import type { IEntity } from '@gridpilot/shared/domain';
|
||||
|
||||
export class Standing implements IEntity<string> {
|
||||
readonly id: string;
|
||||
readonly leagueId: string;
|
||||
readonly driverId: string;
|
||||
readonly points: number;
|
||||
readonly wins: number;
|
||||
readonly position: number;
|
||||
readonly racesCompleted: number;
|
||||
|
||||
private constructor(props: {
|
||||
id: string;
|
||||
leagueId: string;
|
||||
driverId: string;
|
||||
points: number;
|
||||
wins: number;
|
||||
position: number;
|
||||
racesCompleted: number;
|
||||
}) {
|
||||
this.id = props.id;
|
||||
this.leagueId = props.leagueId;
|
||||
this.driverId = props.driverId;
|
||||
this.points = props.points;
|
||||
this.wins = props.wins;
|
||||
this.position = props.position;
|
||||
this.racesCompleted = props.racesCompleted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory method to create a new Standing entity
|
||||
*/
|
||||
static create(props: {
|
||||
id?: string;
|
||||
leagueId: string;
|
||||
driverId: string;
|
||||
points?: number;
|
||||
wins?: number;
|
||||
position?: number;
|
||||
racesCompleted?: number;
|
||||
}): Standing {
|
||||
this.validate(props);
|
||||
|
||||
const id = props.id && props.id.trim().length > 0
|
||||
? props.id
|
||||
: `${props.leagueId}:${props.driverId}`;
|
||||
|
||||
return new Standing({
|
||||
id,
|
||||
leagueId: props.leagueId,
|
||||
driverId: props.driverId,
|
||||
points: props.points ?? 0,
|
||||
wins: props.wins ?? 0,
|
||||
position: props.position ?? 0,
|
||||
racesCompleted: props.racesCompleted ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Domain validation logic
|
||||
*/
|
||||
private static validate(props: {
|
||||
id?: string;
|
||||
leagueId: string;
|
||||
driverId: string;
|
||||
}): void {
|
||||
if (!props.leagueId || props.leagueId.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('League ID is required');
|
||||
}
|
||||
|
||||
if (!props.driverId || props.driverId.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('Driver ID is required');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add points from a race result
|
||||
*/
|
||||
addRaceResult(position: number, pointsSystem: Record<number, number>): Standing {
|
||||
const racePoints = pointsSystem[position] ?? 0;
|
||||
const isWin = position === 1;
|
||||
|
||||
return new Standing({
|
||||
id: this.id,
|
||||
leagueId: this.leagueId,
|
||||
driverId: this.driverId,
|
||||
points: this.points + racePoints,
|
||||
wins: this.wins + (isWin ? 1 : 0),
|
||||
position: this.position,
|
||||
racesCompleted: this.racesCompleted + 1,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update championship position
|
||||
*/
|
||||
updatePosition(position: number): Standing {
|
||||
if (!Number.isInteger(position) || position < 1) {
|
||||
throw new RacingDomainValidationError('Position must be a positive integer');
|
||||
}
|
||||
|
||||
return Standing.create({
|
||||
id: this.id,
|
||||
leagueId: this.leagueId,
|
||||
driverId: this.driverId,
|
||||
points: this.points,
|
||||
wins: this.wins,
|
||||
position,
|
||||
racesCompleted: this.racesCompleted,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate average points per race
|
||||
*/
|
||||
getAveragePoints(): number {
|
||||
if (this.racesCompleted === 0) return 0;
|
||||
return this.points / this.racesCompleted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate win percentage
|
||||
*/
|
||||
getWinPercentage(): number {
|
||||
if (this.racesCompleted === 0) return 0;
|
||||
return (this.wins / this.racesCompleted) * 100;
|
||||
}
|
||||
}
|
||||
128
core/racing/domain/entities/Team.ts
Normal file
128
core/racing/domain/entities/Team.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Domain Entity: Team
|
||||
*
|
||||
* Represents a racing team in the GridPilot platform.
|
||||
* Implements the shared IEntity<string> contract and encapsulates
|
||||
* basic invariants around identity and core properties.
|
||||
*/
|
||||
|
||||
import type { IEntity } from '@gridpilot/shared/domain';
|
||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||
|
||||
export class Team implements IEntity<string> {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly tag: string;
|
||||
readonly description: string;
|
||||
readonly ownerId: string;
|
||||
readonly leagues: string[];
|
||||
readonly createdAt: Date;
|
||||
|
||||
private constructor(props: {
|
||||
id: string;
|
||||
name: string;
|
||||
tag: string;
|
||||
description: string;
|
||||
ownerId: string;
|
||||
leagues: string[];
|
||||
createdAt: Date;
|
||||
}) {
|
||||
this.id = props.id;
|
||||
this.name = props.name;
|
||||
this.tag = props.tag;
|
||||
this.description = props.description;
|
||||
this.ownerId = props.ownerId;
|
||||
this.leagues = props.leagues;
|
||||
this.createdAt = props.createdAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory method to create a new Team entity.
|
||||
*/
|
||||
static create(props: {
|
||||
id: string;
|
||||
name: string;
|
||||
tag: string;
|
||||
description: string;
|
||||
ownerId: string;
|
||||
leagues: string[];
|
||||
createdAt?: Date;
|
||||
}): Team {
|
||||
this.validate(props);
|
||||
|
||||
return new Team({
|
||||
id: props.id,
|
||||
name: props.name,
|
||||
tag: props.tag,
|
||||
description: props.description,
|
||||
ownerId: props.ownerId,
|
||||
leagues: [...props.leagues],
|
||||
createdAt: props.createdAt ?? new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a copy with updated properties.
|
||||
*/
|
||||
update(props: Partial<{
|
||||
name: string;
|
||||
tag: string;
|
||||
description: string;
|
||||
ownerId: string;
|
||||
leagues: string[];
|
||||
}>): Team {
|
||||
const next: Team = new Team({
|
||||
id: this.id,
|
||||
name: props.name ?? this.name,
|
||||
tag: props.tag ?? this.tag,
|
||||
description: props.description ?? this.description,
|
||||
ownerId: props.ownerId ?? this.ownerId,
|
||||
leagues: props.leagues ? [...props.leagues] : [...this.leagues],
|
||||
createdAt: this.createdAt,
|
||||
});
|
||||
|
||||
// Re-validate updated aggregate
|
||||
Team.validate({
|
||||
id: next.id,
|
||||
name: next.name,
|
||||
tag: next.tag,
|
||||
description: next.description,
|
||||
ownerId: next.ownerId,
|
||||
leagues: next.leagues,
|
||||
});
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Domain validation logic for core invariants.
|
||||
*/
|
||||
private static validate(props: {
|
||||
id: string;
|
||||
name: string;
|
||||
tag: string;
|
||||
description: string;
|
||||
ownerId: string;
|
||||
leagues: string[];
|
||||
}): void {
|
||||
if (!props.id || props.id.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('Team ID is required');
|
||||
}
|
||||
|
||||
if (!props.name || props.name.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('Team name is required');
|
||||
}
|
||||
|
||||
if (!props.tag || props.tag.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('Team tag is required');
|
||||
}
|
||||
|
||||
if (!props.ownerId || props.ownerId.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('Team owner ID is required');
|
||||
}
|
||||
|
||||
if (!Array.isArray(props.leagues)) {
|
||||
throw new RacingDomainValidationError('Team leagues must be an array');
|
||||
}
|
||||
}
|
||||
}
|
||||
127
core/racing/domain/entities/Track.ts
Normal file
127
core/racing/domain/entities/Track.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Domain Entity: Track
|
||||
*
|
||||
* Represents a racing track/circuit in the GridPilot platform.
|
||||
* Immutable entity with factory methods and domain validation.
|
||||
*/
|
||||
|
||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||
import type { IEntity } from '@gridpilot/shared/domain';
|
||||
|
||||
export type TrackCategory = 'oval' | 'road' | 'street' | 'dirt';
|
||||
export type TrackDifficulty = 'beginner' | 'intermediate' | 'advanced' | 'expert';
|
||||
|
||||
export class Track implements IEntity<string> {
|
||||
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 | undefined;
|
||||
readonly gameId: string;
|
||||
|
||||
private constructor(props: {
|
||||
id: string;
|
||||
name: string;
|
||||
shortName: string;
|
||||
country: string;
|
||||
category: TrackCategory;
|
||||
difficulty: TrackDifficulty;
|
||||
lengthKm: number;
|
||||
turns: number;
|
||||
imageUrl?: string | undefined;
|
||||
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);
|
||||
|
||||
const base = {
|
||||
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,
|
||||
gameId: props.gameId,
|
||||
};
|
||||
|
||||
const withImage =
|
||||
props.imageUrl !== undefined ? { ...base, imageUrl: props.imageUrl } : base;
|
||||
|
||||
return new Track(withImage);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 RacingDomainValidationError('Track ID is required');
|
||||
}
|
||||
|
||||
if (!props.name || props.name.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('Track name is required');
|
||||
}
|
||||
|
||||
if (!props.country || props.country.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('Track country is required');
|
||||
}
|
||||
|
||||
if (props.lengthKm <= 0) {
|
||||
throw new RacingDomainValidationError('Track length must be positive');
|
||||
}
|
||||
|
||||
if (props.turns < 0) {
|
||||
throw new RacingDomainValidationError('Track turns cannot be negative');
|
||||
}
|
||||
|
||||
if (!props.gameId || props.gameId.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('Game ID is required');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get formatted length string
|
||||
*/
|
||||
getFormattedLength(): string {
|
||||
return `${this.lengthKm.toFixed(2)} km`;
|
||||
}
|
||||
}
|
||||
162
core/racing/domain/entities/Transaction.ts
Normal file
162
core/racing/domain/entities/Transaction.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* Domain Entity: Transaction
|
||||
*
|
||||
* Represents a financial transaction in the league wallet system.
|
||||
*/
|
||||
|
||||
import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError';
|
||||
|
||||
import type { Money } from '../value-objects/Money';
|
||||
import type { IEntity } from '@gridpilot/shared/domain';
|
||||
|
||||
export type TransactionType =
|
||||
| 'sponsorship_payment'
|
||||
| 'membership_payment'
|
||||
| 'prize_payout'
|
||||
| 'withdrawal'
|
||||
| 'refund';
|
||||
|
||||
export type TransactionStatus = 'pending' | 'completed' | 'failed' | 'cancelled';
|
||||
|
||||
export interface TransactionProps {
|
||||
id: string;
|
||||
walletId: string;
|
||||
type: TransactionType;
|
||||
amount: Money;
|
||||
platformFee: Money;
|
||||
netAmount: Money;
|
||||
status: TransactionStatus;
|
||||
createdAt: Date;
|
||||
completedAt: Date | undefined;
|
||||
description: string | undefined;
|
||||
metadata: Record<string, unknown> | undefined;
|
||||
}
|
||||
|
||||
export class Transaction implements IEntity<string> {
|
||||
readonly id: string;
|
||||
readonly walletId: string;
|
||||
readonly type: TransactionType;
|
||||
readonly amount: Money;
|
||||
readonly platformFee: Money;
|
||||
readonly netAmount: Money;
|
||||
readonly status: TransactionStatus;
|
||||
readonly createdAt: Date;
|
||||
readonly completedAt: Date | undefined;
|
||||
readonly description: string | undefined;
|
||||
readonly metadata: Record<string, unknown> | undefined;
|
||||
|
||||
private constructor(props: TransactionProps) {
|
||||
this.id = props.id;
|
||||
this.walletId = props.walletId;
|
||||
this.type = props.type;
|
||||
this.amount = props.amount;
|
||||
this.platformFee = props.platformFee;
|
||||
this.netAmount = props.netAmount;
|
||||
this.status = props.status;
|
||||
this.createdAt = props.createdAt;
|
||||
this.completedAt = props.completedAt;
|
||||
this.description = props.description;
|
||||
this.metadata = props.metadata;
|
||||
}
|
||||
|
||||
static create(props: Omit<TransactionProps, 'createdAt' | 'status' | 'platformFee' | 'netAmount'> & {
|
||||
createdAt?: Date;
|
||||
status?: TransactionStatus;
|
||||
}): Transaction {
|
||||
this.validate(props);
|
||||
|
||||
const platformFee = props.amount.calculatePlatformFee();
|
||||
const netAmount = props.amount.calculateNetAmount();
|
||||
|
||||
return new Transaction({
|
||||
...props,
|
||||
platformFee,
|
||||
netAmount,
|
||||
createdAt: props.createdAt ?? new Date(),
|
||||
status: props.status ?? 'pending',
|
||||
});
|
||||
}
|
||||
|
||||
private static validate(props: Omit<TransactionProps, 'createdAt' | 'status' | 'platformFee' | 'netAmount'>): void {
|
||||
if (!props.id || props.id.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('Transaction ID is required');
|
||||
}
|
||||
|
||||
if (!props.walletId || props.walletId.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('Transaction walletId is required');
|
||||
}
|
||||
|
||||
if (!props.type) {
|
||||
throw new RacingDomainValidationError('Transaction type is required');
|
||||
}
|
||||
|
||||
if (!props.amount) {
|
||||
throw new RacingDomainValidationError('Transaction amount is required');
|
||||
}
|
||||
|
||||
if (props.amount.amount <= 0) {
|
||||
throw new RacingDomainValidationError('Transaction amount must be greater than zero');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark transaction as completed
|
||||
*/
|
||||
complete(): Transaction {
|
||||
if (this.status === 'completed') {
|
||||
throw new RacingDomainInvariantError('Transaction is already completed');
|
||||
}
|
||||
|
||||
if (this.status === 'failed' || this.status === 'cancelled') {
|
||||
throw new RacingDomainInvariantError('Cannot complete a failed or cancelled transaction');
|
||||
}
|
||||
|
||||
return new Transaction({
|
||||
...this,
|
||||
status: 'completed',
|
||||
completedAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark transaction as failed
|
||||
*/
|
||||
fail(): Transaction {
|
||||
if (this.status === 'completed') {
|
||||
throw new RacingDomainInvariantError('Cannot fail a completed transaction');
|
||||
}
|
||||
|
||||
return new Transaction({
|
||||
...this,
|
||||
status: 'failed',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel transaction
|
||||
*/
|
||||
cancel(): Transaction {
|
||||
if (this.status === 'completed') {
|
||||
throw new RacingDomainInvariantError('Cannot cancel a completed transaction');
|
||||
}
|
||||
|
||||
return new Transaction({
|
||||
...this,
|
||||
status: 'cancelled',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if transaction is completed
|
||||
*/
|
||||
isCompleted(): boolean {
|
||||
return this.status === 'completed';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if transaction is pending
|
||||
*/
|
||||
isPending(): boolean {
|
||||
return this.status === 'pending';
|
||||
}
|
||||
}
|
||||
34
core/racing/domain/errors/RacingDomainError.ts
Normal file
34
core/racing/domain/errors/RacingDomainError.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { IDomainError, CommonDomainErrorKind } from '@gridpilot/shared/errors';
|
||||
|
||||
export abstract class RacingDomainError extends Error implements IDomainError<CommonDomainErrorKind> {
|
||||
readonly type = 'domain' as const;
|
||||
readonly context = 'racing-domain';
|
||||
abstract readonly kind: CommonDomainErrorKind;
|
||||
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
Object.setPrototypeOf(this, new.target.prototype);
|
||||
}
|
||||
}
|
||||
|
||||
export class RacingDomainValidationError
|
||||
extends RacingDomainError
|
||||
implements IDomainError<'validation'>
|
||||
{
|
||||
readonly kind = 'validation' as const;
|
||||
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export class RacingDomainInvariantError
|
||||
extends RacingDomainError
|
||||
implements IDomainError<'invariant'>
|
||||
{
|
||||
readonly kind = 'invariant' as const;
|
||||
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
29
core/racing/domain/events/MainRaceCompleted.ts
Normal file
29
core/racing/domain/events/MainRaceCompleted.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { IDomainEvent } from '@gridpilot/shared/domain';
|
||||
|
||||
/**
|
||||
* Domain Event: MainRaceCompleted
|
||||
*
|
||||
* Fired when the main race session of a race event is completed.
|
||||
* This triggers immediate performance summary notifications to drivers.
|
||||
*/
|
||||
export interface MainRaceCompletedEventData {
|
||||
raceEventId: string;
|
||||
sessionId: string;
|
||||
leagueId: string;
|
||||
seasonId: string;
|
||||
completedAt: Date;
|
||||
driverIds: string[]; // Drivers who participated in the main race
|
||||
}
|
||||
|
||||
export class MainRaceCompletedEvent implements IDomainEvent<MainRaceCompletedEventData> {
|
||||
readonly eventType = 'MainRaceCompleted';
|
||||
readonly aggregateId: string;
|
||||
readonly eventData: MainRaceCompletedEventData;
|
||||
readonly occurredAt: Date;
|
||||
|
||||
constructor(data: MainRaceCompletedEventData) {
|
||||
this.aggregateId = data.raceEventId;
|
||||
this.eventData = { ...data };
|
||||
this.occurredAt = new Date();
|
||||
}
|
||||
}
|
||||
29
core/racing/domain/events/RaceEventStewardingClosed.ts
Normal file
29
core/racing/domain/events/RaceEventStewardingClosed.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { IDomainEvent } from '@gridpilot/shared/domain';
|
||||
|
||||
/**
|
||||
* Domain Event: RaceEventStewardingClosed
|
||||
*
|
||||
* Fired when the stewarding window closes for a race event.
|
||||
* This triggers final results notifications to drivers with any penalty adjustments.
|
||||
*/
|
||||
export interface RaceEventStewardingClosedEventData {
|
||||
raceEventId: string;
|
||||
leagueId: string;
|
||||
seasonId: string;
|
||||
closedAt: Date;
|
||||
driverIds: string[]; // Drivers who participated in the race event
|
||||
hadPenaltiesApplied: boolean; // Whether any penalties were applied during stewarding
|
||||
}
|
||||
|
||||
export class RaceEventStewardingClosedEvent implements IDomainEvent<RaceEventStewardingClosedEventData> {
|
||||
readonly eventType = 'RaceEventStewardingClosed';
|
||||
readonly aggregateId: string;
|
||||
readonly eventData: RaceEventStewardingClosedEventData;
|
||||
readonly occurredAt: Date;
|
||||
|
||||
constructor(data: RaceEventStewardingClosedEventData) {
|
||||
this.aggregateId = data.raceEventId;
|
||||
this.eventData = { ...data };
|
||||
this.occurredAt = new Date();
|
||||
}
|
||||
}
|
||||
65
core/racing/domain/repositories/ICarRepository.ts
Normal file
65
core/racing/domain/repositories/ICarRepository.ts
Normal 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>;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { ChampionshipStanding } from '../entities/ChampionshipStanding';
|
||||
|
||||
export interface IChampionshipStandingRepository {
|
||||
findBySeasonAndChampionship(
|
||||
seasonId: string,
|
||||
championshipId: string,
|
||||
): Promise<ChampionshipStanding[]>;
|
||||
|
||||
saveAll(standings: ChampionshipStanding[]): Promise<void>;
|
||||
}
|
||||
50
core/racing/domain/repositories/IDriverRepository.ts
Normal file
50
core/racing/domain/repositories/IDriverRepository.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Application Port: IDriverRepository
|
||||
*
|
||||
* Repository interface for Driver entity CRUD operations.
|
||||
* Defines async methods using domain entities as types.
|
||||
*/
|
||||
|
||||
import type { Driver } from '../entities/Driver';
|
||||
|
||||
export interface IDriverRepository {
|
||||
/**
|
||||
* Find a driver by ID
|
||||
*/
|
||||
findById(id: string): Promise<Driver | null>;
|
||||
|
||||
/**
|
||||
* Find a driver by iRacing ID
|
||||
*/
|
||||
findByIRacingId(iracingId: string): Promise<Driver | null>;
|
||||
|
||||
/**
|
||||
* Find all drivers
|
||||
*/
|
||||
findAll(): Promise<Driver[]>;
|
||||
|
||||
/**
|
||||
* Create a new driver
|
||||
*/
|
||||
create(driver: Driver): Promise<Driver>;
|
||||
|
||||
/**
|
||||
* Update an existing driver
|
||||
*/
|
||||
update(driver: Driver): Promise<Driver>;
|
||||
|
||||
/**
|
||||
* Delete a driver by ID
|
||||
*/
|
||||
delete(id: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Check if a driver exists by ID
|
||||
*/
|
||||
exists(id: string): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Check if an iRacing ID is already registered
|
||||
*/
|
||||
existsByIRacingId(iracingId: string): Promise<boolean>;
|
||||
}
|
||||
6
core/racing/domain/repositories/IGameRepository.ts
Normal file
6
core/racing/domain/repositories/IGameRepository.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { Game } from '../entities/Game';
|
||||
|
||||
export interface IGameRepository {
|
||||
findById(id: string): Promise<Game | null>;
|
||||
findAll(): Promise<Game[]>;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Application Port: ILeagueMembershipRepository
|
||||
*
|
||||
* Repository interface for league membership and join request operations.
|
||||
* This defines the persistence boundary for membership-related domain entities.
|
||||
*/
|
||||
|
||||
import type {
|
||||
LeagueMembership,
|
||||
JoinRequest,
|
||||
} from '../entities/LeagueMembership';
|
||||
|
||||
export interface ILeagueMembershipRepository {
|
||||
/**
|
||||
* Get membership for a driver in a league, or null if none exists.
|
||||
*/
|
||||
getMembership(leagueId: string, driverId: string): Promise<LeagueMembership | null>;
|
||||
|
||||
/**
|
||||
* Get all active members for a league.
|
||||
*/
|
||||
getLeagueMembers(leagueId: string): Promise<LeagueMembership[]>;
|
||||
|
||||
/**
|
||||
* Get all join requests for a league.
|
||||
*/
|
||||
getJoinRequests(leagueId: string): Promise<JoinRequest[]>;
|
||||
|
||||
/**
|
||||
* Persist a membership (create or update).
|
||||
*/
|
||||
saveMembership(membership: LeagueMembership): Promise<LeagueMembership>;
|
||||
|
||||
/**
|
||||
* Remove a membership for a driver in a league.
|
||||
*/
|
||||
removeMembership(leagueId: string, driverId: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Persist a join request (create or update).
|
||||
*/
|
||||
saveJoinRequest(request: JoinRequest): Promise<JoinRequest>;
|
||||
|
||||
/**
|
||||
* Remove a join request by its ID.
|
||||
*/
|
||||
removeJoinRequest(requestId: string): Promise<void>;
|
||||
}
|
||||
50
core/racing/domain/repositories/ILeagueRepository.ts
Normal file
50
core/racing/domain/repositories/ILeagueRepository.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Application Port: ILeagueRepository
|
||||
*
|
||||
* Repository interface for League entity CRUD operations.
|
||||
* Defines async methods using domain entities as types.
|
||||
*/
|
||||
|
||||
import type { League } from '../entities/League';
|
||||
|
||||
export interface ILeagueRepository {
|
||||
/**
|
||||
* Find a league by ID
|
||||
*/
|
||||
findById(id: string): Promise<League | null>;
|
||||
|
||||
/**
|
||||
* Find all leagues
|
||||
*/
|
||||
findAll(): Promise<League[]>;
|
||||
|
||||
/**
|
||||
* Find leagues by owner ID
|
||||
*/
|
||||
findByOwnerId(ownerId: string): Promise<League[]>;
|
||||
|
||||
/**
|
||||
* Create a new league
|
||||
*/
|
||||
create(league: League): Promise<League>;
|
||||
|
||||
/**
|
||||
* Update an existing league
|
||||
*/
|
||||
update(league: League): Promise<League>;
|
||||
|
||||
/**
|
||||
* Delete a league by ID
|
||||
*/
|
||||
delete(id: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Check if a league exists by ID
|
||||
*/
|
||||
exists(id: string): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Search leagues by name
|
||||
*/
|
||||
searchByName(query: string): Promise<League[]>;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { LeagueScoringConfig } from '../entities/LeagueScoringConfig';
|
||||
|
||||
export interface ILeagueScoringConfigRepository {
|
||||
findBySeasonId(seasonId: string): Promise<LeagueScoringConfig | null>;
|
||||
save(config: LeagueScoringConfig): Promise<LeagueScoringConfig>;
|
||||
}
|
||||
16
core/racing/domain/repositories/ILeagueWalletRepository.ts
Normal file
16
core/racing/domain/repositories/ILeagueWalletRepository.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Repository Interface: ILeagueWalletRepository
|
||||
*
|
||||
* Defines operations for LeagueWallet aggregate persistence
|
||||
*/
|
||||
|
||||
import type { LeagueWallet } from '../entities/LeagueWallet';
|
||||
|
||||
export interface ILeagueWalletRepository {
|
||||
findById(id: string): Promise<LeagueWallet | null>;
|
||||
findByLeagueId(leagueId: string): Promise<LeagueWallet | null>;
|
||||
create(wallet: LeagueWallet): Promise<LeagueWallet>;
|
||||
update(wallet: LeagueWallet): Promise<LeagueWallet>;
|
||||
delete(id: string): Promise<void>;
|
||||
exists(id: string): Promise<boolean>;
|
||||
}
|
||||
28
core/racing/domain/repositories/ILiveryRepository.ts
Normal file
28
core/racing/domain/repositories/ILiveryRepository.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Repository Interface: ILiveryRepository
|
||||
*
|
||||
* Defines operations for livery-related entities
|
||||
*/
|
||||
|
||||
import type { DriverLivery } from '../entities/DriverLivery';
|
||||
import type { LiveryTemplate } from '../entities/LiveryTemplate';
|
||||
|
||||
export interface ILiveryRepository {
|
||||
// DriverLivery operations
|
||||
findDriverLiveryById(id: string): Promise<DriverLivery | null>;
|
||||
findDriverLiveriesByDriverId(driverId: string): Promise<DriverLivery[]>;
|
||||
findDriverLiveryByDriverAndCar(driverId: string, carId: string): Promise<DriverLivery | null>;
|
||||
findDriverLiveriesByGameId(gameId: string): Promise<DriverLivery[]>;
|
||||
findDriverLiveryByDriverAndGame(driverId: string, gameId: string): Promise<DriverLivery[]>;
|
||||
createDriverLivery(livery: DriverLivery): Promise<DriverLivery>;
|
||||
updateDriverLivery(livery: DriverLivery): Promise<DriverLivery>;
|
||||
deleteDriverLivery(id: string): Promise<void>;
|
||||
|
||||
// LiveryTemplate operations
|
||||
findTemplateById(id: string): Promise<LiveryTemplate | null>;
|
||||
findTemplatesBySeasonId(seasonId: string): Promise<LiveryTemplate[]>;
|
||||
findTemplateBySeasonAndCar(seasonId: string, carId: string): Promise<LiveryTemplate | null>;
|
||||
createTemplate(template: LiveryTemplate): Promise<LiveryTemplate>;
|
||||
updateTemplate(template: LiveryTemplate): Promise<LiveryTemplate>;
|
||||
deleteTemplate(id: string): Promise<void>;
|
||||
}
|
||||
54
core/racing/domain/repositories/IPenaltyRepository.ts
Normal file
54
core/racing/domain/repositories/IPenaltyRepository.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Repository Interface: IPenaltyRepository
|
||||
*
|
||||
* Defines the contract for persisting and retrieving Penalty entities.
|
||||
*/
|
||||
|
||||
import type { Penalty } from '../entities/Penalty';
|
||||
|
||||
export interface IPenaltyRepository {
|
||||
/**
|
||||
* Find a penalty by ID
|
||||
*/
|
||||
findById(id: string): Promise<Penalty | null>;
|
||||
|
||||
/**
|
||||
* Find all penalties for a race
|
||||
*/
|
||||
findByRaceId(raceId: string): Promise<Penalty[]>;
|
||||
|
||||
/**
|
||||
* Find all penalties for a specific driver
|
||||
*/
|
||||
findByDriverId(driverId: string): Promise<Penalty[]>;
|
||||
|
||||
/**
|
||||
* Find all penalties related to a specific protest
|
||||
*/
|
||||
findByProtestId(protestId: string): Promise<Penalty[]>;
|
||||
|
||||
/**
|
||||
* Find all pending penalties (not yet applied)
|
||||
*/
|
||||
findPending(): Promise<Penalty[]>;
|
||||
|
||||
/**
|
||||
* Find all penalties issued by a specific steward
|
||||
*/
|
||||
findIssuedBy(stewardId: string): Promise<Penalty[]>;
|
||||
|
||||
/**
|
||||
* Save a new penalty
|
||||
*/
|
||||
create(penalty: Penalty): Promise<void>;
|
||||
|
||||
/**
|
||||
* Update an existing penalty
|
||||
*/
|
||||
update(penalty: Penalty): Promise<void>;
|
||||
|
||||
/**
|
||||
* Check if a penalty exists
|
||||
*/
|
||||
exists(id: string): Promise<boolean>;
|
||||
}
|
||||
19
core/racing/domain/repositories/IPrizeRepository.ts
Normal file
19
core/racing/domain/repositories/IPrizeRepository.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Repository Interface: IPrizeRepository
|
||||
*
|
||||
* Defines operations for Prize entity persistence
|
||||
*/
|
||||
|
||||
import type { Prize, PrizeStatus } from '../entities/Prize';
|
||||
|
||||
export interface IPrizeRepository {
|
||||
findById(id: string): Promise<Prize | null>;
|
||||
findBySeasonId(seasonId: string): Promise<Prize[]>;
|
||||
findByDriverId(driverId: string): Promise<Prize[]>;
|
||||
findByStatus(status: PrizeStatus): Promise<Prize[]>;
|
||||
findBySeasonAndPosition(seasonId: string, position: number): Promise<Prize | null>;
|
||||
create(prize: Prize): Promise<Prize>;
|
||||
update(prize: Prize): Promise<Prize>;
|
||||
delete(id: string): Promise<void>;
|
||||
exists(id: string): Promise<boolean>;
|
||||
}
|
||||
54
core/racing/domain/repositories/IProtestRepository.ts
Normal file
54
core/racing/domain/repositories/IProtestRepository.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Repository Interface: IProtestRepository
|
||||
*
|
||||
* Defines the contract for persisting and retrieving Protest entities.
|
||||
*/
|
||||
|
||||
import type { Protest } from '../entities/Protest';
|
||||
|
||||
export interface IProtestRepository {
|
||||
/**
|
||||
* Find a protest by ID
|
||||
*/
|
||||
findById(id: string): Promise<Protest | null>;
|
||||
|
||||
/**
|
||||
* Find all protests for a race
|
||||
*/
|
||||
findByRaceId(raceId: string): Promise<Protest[]>;
|
||||
|
||||
/**
|
||||
* Find all protests filed by a specific driver
|
||||
*/
|
||||
findByProtestingDriverId(driverId: string): Promise<Protest[]>;
|
||||
|
||||
/**
|
||||
* Find all protests against a specific driver
|
||||
*/
|
||||
findByAccusedDriverId(driverId: string): Promise<Protest[]>;
|
||||
|
||||
/**
|
||||
* Find all pending protests (for steward review queue)
|
||||
*/
|
||||
findPending(): Promise<Protest[]>;
|
||||
|
||||
/**
|
||||
* Find all protests under review by a specific steward
|
||||
*/
|
||||
findUnderReviewBy(stewardId: string): Promise<Protest[]>;
|
||||
|
||||
/**
|
||||
* Save a new protest
|
||||
*/
|
||||
create(protest: Protest): Promise<void>;
|
||||
|
||||
/**
|
||||
* Update an existing protest
|
||||
*/
|
||||
update(protest: Protest): Promise<void>;
|
||||
|
||||
/**
|
||||
* Check if a protest exists
|
||||
*/
|
||||
exists(id: string): Promise<boolean>;
|
||||
}
|
||||
14
core/racing/domain/repositories/IRaceEventRepository.ts
Normal file
14
core/racing/domain/repositories/IRaceEventRepository.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { RaceEvent } from '../entities/RaceEvent';
|
||||
|
||||
export interface IRaceEventRepository {
|
||||
findById(id: string): Promise<RaceEvent | null>;
|
||||
findAll(): Promise<RaceEvent[]>;
|
||||
findBySeasonId(seasonId: string): Promise<RaceEvent[]>;
|
||||
findByLeagueId(leagueId: string): Promise<RaceEvent[]>;
|
||||
findByStatus(status: string): Promise<RaceEvent[]>;
|
||||
findAwaitingStewardingClose(): Promise<RaceEvent[]>;
|
||||
create(raceEvent: RaceEvent): Promise<RaceEvent>;
|
||||
update(raceEvent: RaceEvent): Promise<RaceEvent>;
|
||||
delete(id: string): Promise<void>;
|
||||
exists(id: string): Promise<boolean>;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Application Port: IRaceRegistrationRepository
|
||||
*
|
||||
* Repository interface for race registration operations.
|
||||
* This defines the persistence boundary for RaceRegistration entities.
|
||||
*/
|
||||
|
||||
import type { RaceRegistration } from '../entities/RaceRegistration';
|
||||
|
||||
export interface IRaceRegistrationRepository {
|
||||
/**
|
||||
* Check if a driver is registered for a race.
|
||||
*/
|
||||
isRegistered(raceId: string, driverId: string): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Get all registered driver IDs for a race.
|
||||
*/
|
||||
getRegisteredDrivers(raceId: string): Promise<string[]>;
|
||||
|
||||
/**
|
||||
* Get the number of registrations for a race.
|
||||
*/
|
||||
getRegistrationCount(raceId: string): Promise<number>;
|
||||
|
||||
/**
|
||||
* Register a driver for a race.
|
||||
*/
|
||||
register(registration: RaceRegistration): Promise<void>;
|
||||
|
||||
/**
|
||||
* Withdraw a driver from a race.
|
||||
*/
|
||||
withdraw(raceId: string, driverId: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Get all race IDs a driver is registered for.
|
||||
*/
|
||||
getDriverRegistrations(driverId: string): Promise<string[]>;
|
||||
|
||||
/**
|
||||
* Clear all registrations for a race (e.g., when race is cancelled).
|
||||
*/
|
||||
clearRaceRegistrations(raceId: string): Promise<void>;
|
||||
}
|
||||
65
core/racing/domain/repositories/IRaceRepository.ts
Normal file
65
core/racing/domain/repositories/IRaceRepository.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Application Port: IRaceRepository
|
||||
*
|
||||
* Repository interface for Race entity CRUD operations.
|
||||
* Defines async methods using domain entities as types.
|
||||
*/
|
||||
|
||||
import type { Race, RaceStatus } from '../entities/Race';
|
||||
|
||||
export interface IRaceRepository {
|
||||
/**
|
||||
* Find a race by ID
|
||||
*/
|
||||
findById(id: string): Promise<Race | null>;
|
||||
|
||||
/**
|
||||
* Find all races
|
||||
*/
|
||||
findAll(): Promise<Race[]>;
|
||||
|
||||
/**
|
||||
* Find races by league ID
|
||||
*/
|
||||
findByLeagueId(leagueId: string): Promise<Race[]>;
|
||||
|
||||
/**
|
||||
* Find upcoming races for a league
|
||||
*/
|
||||
findUpcomingByLeagueId(leagueId: string): Promise<Race[]>;
|
||||
|
||||
/**
|
||||
* Find completed races for a league
|
||||
*/
|
||||
findCompletedByLeagueId(leagueId: string): Promise<Race[]>;
|
||||
|
||||
/**
|
||||
* Find races by status
|
||||
*/
|
||||
findByStatus(status: RaceStatus): Promise<Race[]>;
|
||||
|
||||
/**
|
||||
* Find races scheduled within a date range
|
||||
*/
|
||||
findByDateRange(startDate: Date, endDate: Date): Promise<Race[]>;
|
||||
|
||||
/**
|
||||
* Create a new race
|
||||
*/
|
||||
create(race: Race): Promise<Race>;
|
||||
|
||||
/**
|
||||
* Update an existing race
|
||||
*/
|
||||
update(race: Race): Promise<Race>;
|
||||
|
||||
/**
|
||||
* Delete a race by ID
|
||||
*/
|
||||
delete(id: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Check if a race exists by ID
|
||||
*/
|
||||
exists(id: string): Promise<boolean>;
|
||||
}
|
||||
70
core/racing/domain/repositories/IResultRepository.ts
Normal file
70
core/racing/domain/repositories/IResultRepository.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Application Port: IResultRepository
|
||||
*
|
||||
* Repository interface for Result entity CRUD operations.
|
||||
* Defines async methods using domain entities as types.
|
||||
*/
|
||||
|
||||
import type { Result } from '../entities/Result';
|
||||
|
||||
export interface IResultRepository {
|
||||
/**
|
||||
* Find a result by ID
|
||||
*/
|
||||
findById(id: string): Promise<Result | null>;
|
||||
|
||||
/**
|
||||
* Find all results
|
||||
*/
|
||||
findAll(): Promise<Result[]>;
|
||||
|
||||
/**
|
||||
* Find results by race ID
|
||||
*/
|
||||
findByRaceId(raceId: string): Promise<Result[]>;
|
||||
|
||||
/**
|
||||
* Find results by driver ID
|
||||
*/
|
||||
findByDriverId(driverId: string): Promise<Result[]>;
|
||||
|
||||
/**
|
||||
* Find results by driver ID for a specific league
|
||||
*/
|
||||
findByDriverIdAndLeagueId(driverId: string, leagueId: string): Promise<Result[]>;
|
||||
|
||||
/**
|
||||
* Create a new result
|
||||
*/
|
||||
create(result: Result): Promise<Result>;
|
||||
|
||||
/**
|
||||
* Create multiple results
|
||||
*/
|
||||
createMany(results: Result[]): Promise<Result[]>;
|
||||
|
||||
/**
|
||||
* Update an existing result
|
||||
*/
|
||||
update(result: Result): Promise<Result>;
|
||||
|
||||
/**
|
||||
* Delete a result by ID
|
||||
*/
|
||||
delete(id: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Delete all results for a race
|
||||
*/
|
||||
deleteByRaceId(raceId: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Check if a result exists by ID
|
||||
*/
|
||||
exists(id: string): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Check if results exist for a race
|
||||
*/
|
||||
existsByRaceId(raceId: string): Promise<boolean>;
|
||||
}
|
||||
35
core/racing/domain/repositories/ISeasonRepository.ts
Normal file
35
core/racing/domain/repositories/ISeasonRepository.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { Season } from '../entities/Season';
|
||||
|
||||
export interface ISeasonRepository {
|
||||
findById(id: string): Promise<Season | null>;
|
||||
/**
|
||||
* Backward-compatible alias retained for existing callers.
|
||||
* Prefer listByLeague for new usage.
|
||||
*/
|
||||
findByLeagueId(leagueId: string): Promise<Season[]>;
|
||||
/**
|
||||
* Backward-compatible alias retained for existing callers.
|
||||
* Prefer add for new usage.
|
||||
*/
|
||||
create(season: Season): Promise<Season>;
|
||||
|
||||
/**
|
||||
* Add a new Season aggregate.
|
||||
*/
|
||||
add(season: Season): Promise<void>;
|
||||
|
||||
/**
|
||||
* Persist changes to an existing Season aggregate.
|
||||
*/
|
||||
update(season: Season): Promise<void>;
|
||||
|
||||
/**
|
||||
* List all Seasons for a given League.
|
||||
*/
|
||||
listByLeague(leagueId: string): Promise<Season[]>;
|
||||
|
||||
/**
|
||||
* List Seasons for a League that are currently active.
|
||||
*/
|
||||
listActiveByLeague(leagueId: string): Promise<Season[]>;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Repository Interface: ISeasonSponsorshipRepository
|
||||
*
|
||||
* Defines operations for SeasonSponsorship aggregate persistence
|
||||
*/
|
||||
|
||||
import type { SeasonSponsorship, SponsorshipTier } from '../entities/SeasonSponsorship';
|
||||
|
||||
export interface ISeasonSponsorshipRepository {
|
||||
findById(id: string): Promise<SeasonSponsorship | null>;
|
||||
findBySeasonId(seasonId: string): Promise<SeasonSponsorship[]>;
|
||||
/**
|
||||
* Convenience lookup for aggregating sponsorships at league level.
|
||||
* Implementations should rely on the denormalized leagueId where present,
|
||||
* falling back to joining through Seasons if needed.
|
||||
*/
|
||||
findByLeagueId(leagueId: string): Promise<SeasonSponsorship[]>;
|
||||
findBySponsorId(sponsorId: string): Promise<SeasonSponsorship[]>;
|
||||
findBySeasonAndTier(seasonId: string, tier: SponsorshipTier): Promise<SeasonSponsorship[]>;
|
||||
create(sponsorship: SeasonSponsorship): Promise<SeasonSponsorship>;
|
||||
update(sponsorship: SeasonSponsorship): Promise<SeasonSponsorship>;
|
||||
delete(id: string): Promise<void>;
|
||||
exists(id: string): Promise<boolean>;
|
||||
}
|
||||
13
core/racing/domain/repositories/ISessionRepository.ts
Normal file
13
core/racing/domain/repositories/ISessionRepository.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { Session } from '../entities/Session';
|
||||
|
||||
export interface ISessionRepository {
|
||||
findById(id: string): Promise<Session | null>;
|
||||
findAll(): Promise<Session[]>;
|
||||
findByRaceEventId(raceEventId: string): Promise<Session[]>;
|
||||
findByLeagueId(leagueId: string): Promise<Session[]>;
|
||||
findByStatus(status: string): Promise<Session[]>;
|
||||
create(session: Session): Promise<Session>;
|
||||
update(session: Session): Promise<Session>;
|
||||
delete(id: string): Promise<void>;
|
||||
exists(id: string): Promise<boolean>;
|
||||
}
|
||||
17
core/racing/domain/repositories/ISponsorRepository.ts
Normal file
17
core/racing/domain/repositories/ISponsorRepository.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Repository Interface: ISponsorRepository
|
||||
*
|
||||
* Defines operations for Sponsor aggregate persistence
|
||||
*/
|
||||
|
||||
import type { Sponsor } from '../entities/Sponsor';
|
||||
|
||||
export interface ISponsorRepository {
|
||||
findById(id: string): Promise<Sponsor | null>;
|
||||
findAll(): Promise<Sponsor[]>;
|
||||
findByEmail(email: string): Promise<Sponsor | null>;
|
||||
create(sponsor: Sponsor): Promise<Sponsor>;
|
||||
update(sponsor: Sponsor): Promise<Sponsor>;
|
||||
delete(id: string): Promise<void>;
|
||||
exists(id: string): Promise<boolean>;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Repository Interface: ISponsorshipPricingRepository
|
||||
*
|
||||
* Stores sponsorship pricing configuration for any sponsorable entity.
|
||||
* This allows drivers, teams, races, and leagues to define their sponsorship slots.
|
||||
*/
|
||||
|
||||
import type { SponsorshipPricing } from '../value-objects/SponsorshipPricing';
|
||||
import type { SponsorableEntityType } from '../entities/SponsorshipRequest';
|
||||
|
||||
export interface ISponsorshipPricingRepository {
|
||||
/**
|
||||
* Get pricing configuration for an entity
|
||||
*/
|
||||
findByEntity(entityType: SponsorableEntityType, entityId: string): Promise<SponsorshipPricing | null>;
|
||||
|
||||
/**
|
||||
* Save or update pricing configuration for an entity
|
||||
*/
|
||||
save(entityType: SponsorableEntityType, entityId: string, pricing: SponsorshipPricing): Promise<void>;
|
||||
|
||||
/**
|
||||
* Delete pricing configuration for an entity
|
||||
*/
|
||||
delete(entityType: SponsorableEntityType, entityId: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Check if entity has pricing configured
|
||||
*/
|
||||
exists(entityType: SponsorableEntityType, entityId: string): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Find all entities accepting sponsorship applications
|
||||
*/
|
||||
findAcceptingApplications(entityType: SponsorableEntityType): Promise<Array<{
|
||||
entityId: string;
|
||||
pricing: SponsorshipPricing;
|
||||
}>>;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Repository Interface: ISponsorshipRequestRepository
|
||||
*
|
||||
* Defines operations for SponsorshipRequest aggregate persistence
|
||||
*/
|
||||
|
||||
import type { SponsorshipRequest, SponsorableEntityType, SponsorshipRequestStatus } from '../entities/SponsorshipRequest';
|
||||
|
||||
export interface ISponsorshipRequestRepository {
|
||||
findById(id: string): Promise<SponsorshipRequest | null>;
|
||||
|
||||
/**
|
||||
* Find all requests for a specific entity (driver, team, race, or season)
|
||||
*/
|
||||
findByEntity(entityType: SponsorableEntityType, entityId: string): Promise<SponsorshipRequest[]>;
|
||||
|
||||
/**
|
||||
* Find pending requests for an entity that need review
|
||||
*/
|
||||
findPendingByEntity(entityType: SponsorableEntityType, entityId: string): Promise<SponsorshipRequest[]>;
|
||||
|
||||
/**
|
||||
* Find all requests made by a sponsor
|
||||
*/
|
||||
findBySponsorId(sponsorId: string): Promise<SponsorshipRequest[]>;
|
||||
|
||||
/**
|
||||
* Find requests by status
|
||||
*/
|
||||
findByStatus(status: SponsorshipRequestStatus): Promise<SponsorshipRequest[]>;
|
||||
|
||||
/**
|
||||
* Find requests by sponsor and status
|
||||
*/
|
||||
findBySponsorIdAndStatus(sponsorId: string, status: SponsorshipRequestStatus): Promise<SponsorshipRequest[]>;
|
||||
|
||||
/**
|
||||
* Check if a sponsor already has a pending request for an entity
|
||||
*/
|
||||
hasPendingRequest(sponsorId: string, entityType: SponsorableEntityType, entityId: string): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Count pending requests for an entity
|
||||
*/
|
||||
countPendingByEntity(entityType: SponsorableEntityType, entityId: string): Promise<number>;
|
||||
|
||||
create(request: SponsorshipRequest): Promise<SponsorshipRequest>;
|
||||
update(request: SponsorshipRequest): Promise<SponsorshipRequest>;
|
||||
delete(id: string): Promise<void>;
|
||||
exists(id: string): Promise<boolean>;
|
||||
}
|
||||
55
core/racing/domain/repositories/IStandingRepository.ts
Normal file
55
core/racing/domain/repositories/IStandingRepository.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Application Port: IStandingRepository
|
||||
*
|
||||
* Repository interface for Standing entity operations.
|
||||
* Includes methods for calculating and retrieving standings.
|
||||
*/
|
||||
|
||||
import type { Standing } from '../entities/Standing';
|
||||
|
||||
export interface IStandingRepository {
|
||||
/**
|
||||
* Find standings by league ID (sorted by position)
|
||||
*/
|
||||
findByLeagueId(leagueId: string): Promise<Standing[]>;
|
||||
|
||||
/**
|
||||
* Find standing for a specific driver in a league
|
||||
*/
|
||||
findByDriverIdAndLeagueId(driverId: string, leagueId: string): Promise<Standing | null>;
|
||||
|
||||
/**
|
||||
* Find all standings
|
||||
*/
|
||||
findAll(): Promise<Standing[]>;
|
||||
|
||||
/**
|
||||
* Create or update a standing
|
||||
*/
|
||||
save(standing: Standing): Promise<Standing>;
|
||||
|
||||
/**
|
||||
* Create or update multiple standings
|
||||
*/
|
||||
saveMany(standings: Standing[]): Promise<Standing[]>;
|
||||
|
||||
/**
|
||||
* Delete a standing
|
||||
*/
|
||||
delete(leagueId: string, driverId: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Delete all standings for a league
|
||||
*/
|
||||
deleteByLeagueId(leagueId: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Check if a standing exists
|
||||
*/
|
||||
exists(leagueId: string, driverId: string): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Recalculate standings for a league based on race results
|
||||
*/
|
||||
recalculate(leagueId: string): Promise<Standing[]>;
|
||||
}
|
||||
58
core/racing/domain/repositories/ITeamMembershipRepository.ts
Normal file
58
core/racing/domain/repositories/ITeamMembershipRepository.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Application Port: ITeamMembershipRepository
|
||||
*
|
||||
* Repository interface for team membership and join request operations.
|
||||
* This defines the persistence boundary for team membership-related entities.
|
||||
*/
|
||||
|
||||
import type {
|
||||
TeamMembership,
|
||||
TeamJoinRequest,
|
||||
} from '../types/TeamMembership';
|
||||
|
||||
export interface ITeamMembershipRepository {
|
||||
/**
|
||||
* Get membership for a driver in a team, or null if none exists.
|
||||
*/
|
||||
getMembership(teamId: string, driverId: string): Promise<TeamMembership | null>;
|
||||
|
||||
/**
|
||||
* Get the active team membership for a driver (if any).
|
||||
*/
|
||||
getActiveMembershipForDriver(driverId: string): Promise<TeamMembership | null>;
|
||||
|
||||
/**
|
||||
* Get all active members for a team.
|
||||
*/
|
||||
getTeamMembers(teamId: string): Promise<TeamMembership[]>;
|
||||
|
||||
/**
|
||||
* Persist a membership (create or update).
|
||||
*/
|
||||
saveMembership(membership: TeamMembership): Promise<TeamMembership>;
|
||||
|
||||
/**
|
||||
* Remove a membership for a driver in a team.
|
||||
*/
|
||||
removeMembership(teamId: string, driverId: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Count active members for a team.
|
||||
*/
|
||||
countByTeamId(teamId: string): Promise<number>;
|
||||
|
||||
/**
|
||||
* Get all join requests for a team.
|
||||
*/
|
||||
getJoinRequests(teamId: string): Promise<TeamJoinRequest[]>;
|
||||
|
||||
/**
|
||||
* Persist a join request (create or update).
|
||||
*/
|
||||
saveJoinRequest(request: TeamJoinRequest): Promise<TeamJoinRequest>;
|
||||
|
||||
/**
|
||||
* Remove a join request by its ID.
|
||||
*/
|
||||
removeJoinRequest(requestId: string): Promise<void>;
|
||||
}
|
||||
45
core/racing/domain/repositories/ITeamRepository.ts
Normal file
45
core/racing/domain/repositories/ITeamRepository.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Application Port: ITeamRepository
|
||||
*
|
||||
* Repository interface for Team aggregate operations.
|
||||
* This defines the persistence boundary for Team entities.
|
||||
*/
|
||||
|
||||
import type { Team } from '../entities/Team';
|
||||
|
||||
export interface ITeamRepository {
|
||||
/**
|
||||
* Find a team by ID.
|
||||
*/
|
||||
findById(id: string): Promise<Team | null>;
|
||||
|
||||
/**
|
||||
* Find all teams.
|
||||
*/
|
||||
findAll(): Promise<Team[]>;
|
||||
|
||||
/**
|
||||
* Find teams by league ID.
|
||||
*/
|
||||
findByLeagueId(leagueId: string): Promise<Team[]>;
|
||||
|
||||
/**
|
||||
* Create a new team.
|
||||
*/
|
||||
create(team: Team): Promise<Team>;
|
||||
|
||||
/**
|
||||
* Update an existing team.
|
||||
*/
|
||||
update(team: Team): Promise<Team>;
|
||||
|
||||
/**
|
||||
* Delete a team by ID.
|
||||
*/
|
||||
delete(id: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Check if a team exists by ID.
|
||||
*/
|
||||
exists(id: string): Promise<boolean>;
|
||||
}
|
||||
60
core/racing/domain/repositories/ITrackRepository.ts
Normal file
60
core/racing/domain/repositories/ITrackRepository.ts
Normal 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>;
|
||||
}
|
||||
17
core/racing/domain/repositories/ITransactionRepository.ts
Normal file
17
core/racing/domain/repositories/ITransactionRepository.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Repository Interface: ITransactionRepository
|
||||
*
|
||||
* Defines operations for Transaction entity persistence
|
||||
*/
|
||||
|
||||
import type { Transaction, TransactionType } from '../entities/Transaction';
|
||||
|
||||
export interface ITransactionRepository {
|
||||
findById(id: string): Promise<Transaction | null>;
|
||||
findByWalletId(walletId: string): Promise<Transaction[]>;
|
||||
findByType(type: TransactionType): Promise<Transaction[]>;
|
||||
create(transaction: Transaction): Promise<Transaction>;
|
||||
update(transaction: Transaction): Promise<Transaction>;
|
||||
delete(id: string): Promise<void>;
|
||||
exists(id: string): Promise<boolean>;
|
||||
}
|
||||
71
core/racing/domain/services/ChampionshipAggregator.ts
Normal file
71
core/racing/domain/services/ChampionshipAggregator.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import type { ChampionshipConfig } from '../types/ChampionshipConfig';
|
||||
import type { ParticipantRef } from '../types/ParticipantRef';
|
||||
import { ChampionshipStanding } from '../entities/ChampionshipStanding';
|
||||
import type { ParticipantEventPoints } from './EventScoringService';
|
||||
import { DropScoreApplier, type EventPointsEntry } from './DropScoreApplier';
|
||||
|
||||
export class ChampionshipAggregator {
|
||||
constructor(private readonly dropScoreApplier: DropScoreApplier) {}
|
||||
|
||||
aggregate(params: {
|
||||
seasonId: string;
|
||||
championship: ChampionshipConfig;
|
||||
eventPointsByEventId: Record<string, ParticipantEventPoints[]>;
|
||||
}): ChampionshipStanding[] {
|
||||
const { seasonId, championship, eventPointsByEventId } = params;
|
||||
|
||||
const perParticipantEvents = new Map<
|
||||
string,
|
||||
{ participant: ParticipantRef; events: EventPointsEntry[] }
|
||||
>();
|
||||
|
||||
for (const [eventId, pointsList] of Object.entries(eventPointsByEventId)) {
|
||||
for (const entry of pointsList) {
|
||||
const key = entry.participant.id;
|
||||
const existing = perParticipantEvents.get(key);
|
||||
const eventEntry: EventPointsEntry = {
|
||||
eventId,
|
||||
points: entry.totalPoints,
|
||||
};
|
||||
|
||||
if (existing) {
|
||||
existing.events.push(eventEntry);
|
||||
} else {
|
||||
perParticipantEvents.set(key, {
|
||||
participant: entry.participant,
|
||||
events: [eventEntry],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const standings: ChampionshipStanding[] = [];
|
||||
|
||||
for (const { participant, events } of perParticipantEvents.values()) {
|
||||
const dropResult = this.dropScoreApplier.apply(
|
||||
championship.dropScorePolicy,
|
||||
events,
|
||||
);
|
||||
|
||||
const totalPoints = dropResult.totalPoints;
|
||||
const resultsCounted = dropResult.counted.length;
|
||||
const resultsDropped = dropResult.dropped.length;
|
||||
|
||||
standings.push(
|
||||
new ChampionshipStanding({
|
||||
seasonId,
|
||||
championshipId: championship.id,
|
||||
participant,
|
||||
totalPoints,
|
||||
resultsCounted,
|
||||
resultsDropped,
|
||||
position: 0,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
standings.sort((a, b) => b.totalPoints - a.totalPoints);
|
||||
|
||||
return standings.map((s, index) => s.withPosition(index + 1));
|
||||
}
|
||||
}
|
||||
66
core/racing/domain/services/DropScoreApplier.ts
Normal file
66
core/racing/domain/services/DropScoreApplier.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import type { DropScorePolicy } from '../types/DropScorePolicy';
|
||||
import type { IDomainCalculationService } from '@gridpilot/shared/domain';
|
||||
|
||||
export interface EventPointsEntry {
|
||||
eventId: string;
|
||||
points: number;
|
||||
}
|
||||
|
||||
export interface DropScoreResult {
|
||||
counted: EventPointsEntry[];
|
||||
dropped: EventPointsEntry[];
|
||||
totalPoints: number;
|
||||
}
|
||||
|
||||
export interface DropScoreInput {
|
||||
policy: DropScorePolicy;
|
||||
events: EventPointsEntry[];
|
||||
}
|
||||
|
||||
export class DropScoreApplier implements IDomainCalculationService<DropScoreInput, DropScoreResult> {
|
||||
calculate(input: DropScoreInput): DropScoreResult {
|
||||
return this.apply(input.policy, input.events);
|
||||
}
|
||||
|
||||
apply(policy: DropScorePolicy, events: EventPointsEntry[]): DropScoreResult {
|
||||
if (policy.strategy === 'none' || events.length === 0) {
|
||||
const totalPoints = events.reduce((sum, e) => sum + e.points, 0);
|
||||
return {
|
||||
counted: [...events],
|
||||
dropped: [],
|
||||
totalPoints,
|
||||
};
|
||||
}
|
||||
|
||||
if (policy.strategy === 'bestNResults') {
|
||||
const count = policy.count ?? events.length;
|
||||
if (count >= events.length) {
|
||||
const totalPoints = events.reduce((sum, e) => sum + e.points, 0);
|
||||
return {
|
||||
counted: [...events],
|
||||
dropped: [],
|
||||
totalPoints,
|
||||
};
|
||||
}
|
||||
|
||||
const sorted = [...events].sort((a, b) => b.points - a.points);
|
||||
const counted = sorted.slice(0, count);
|
||||
const dropped = sorted.slice(count);
|
||||
const totalPoints = counted.reduce((sum, e) => sum + e.points, 0);
|
||||
|
||||
return {
|
||||
counted,
|
||||
dropped,
|
||||
totalPoints,
|
||||
};
|
||||
}
|
||||
|
||||
// For this slice, treat unsupported strategies as 'none'
|
||||
const totalPoints = events.reduce((sum, e) => sum + e.points, 0);
|
||||
return {
|
||||
counted: [...events],
|
||||
dropped: [],
|
||||
totalPoints,
|
||||
};
|
||||
}
|
||||
}
|
||||
147
core/racing/domain/services/EventScoringService.ts
Normal file
147
core/racing/domain/services/EventScoringService.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import type { ChampionshipConfig } from '../types/ChampionshipConfig';
|
||||
import type { SessionType } from '../types/SessionType';
|
||||
import type { ParticipantRef } from '../types/ParticipantRef';
|
||||
import type { Result } from '../entities/Result';
|
||||
import type { Penalty } from '../entities/Penalty';
|
||||
import type { BonusRule } from '../types/BonusRule';
|
||||
import type { ChampionshipType } from '../types/ChampionshipType';
|
||||
|
||||
import type { PointsTable } from '../value-objects/PointsTable';
|
||||
import type { IDomainCalculationService } from '@gridpilot/shared/domain';
|
||||
|
||||
export interface ParticipantEventPoints {
|
||||
participant: ParticipantRef;
|
||||
basePoints: number;
|
||||
bonusPoints: number;
|
||||
penaltyPoints: number;
|
||||
totalPoints: number;
|
||||
}
|
||||
|
||||
export interface EventScoringInput {
|
||||
seasonId: string;
|
||||
championship: ChampionshipConfig;
|
||||
sessionType: SessionType;
|
||||
results: Result[];
|
||||
penalties: Penalty[];
|
||||
}
|
||||
|
||||
function createDriverParticipant(driverId: string): ParticipantRef {
|
||||
return {
|
||||
type: 'driver' as ChampionshipType,
|
||||
id: driverId,
|
||||
};
|
||||
}
|
||||
|
||||
export class EventScoringService
|
||||
implements IDomainCalculationService<EventScoringInput, ParticipantEventPoints[]>
|
||||
{
|
||||
calculate(input: EventScoringInput): ParticipantEventPoints[] {
|
||||
return this.scoreSession(input);
|
||||
}
|
||||
|
||||
scoreSession(params: EventScoringInput): ParticipantEventPoints[] {
|
||||
const { championship, sessionType, results } = params;
|
||||
|
||||
const pointsTable = this.getPointsTableForSession(championship, sessionType);
|
||||
const bonusRules = this.getBonusRulesForSession(championship, sessionType);
|
||||
|
||||
const baseByDriver = new Map<string, number>();
|
||||
const bonusByDriver = new Map<string, number>();
|
||||
const penaltyByDriver = new Map<string, number>();
|
||||
|
||||
for (const result of results) {
|
||||
const driverId = result.driverId;
|
||||
const currentBase = baseByDriver.get(driverId) ?? 0;
|
||||
const added = pointsTable.getPointsForPosition(result.position);
|
||||
baseByDriver.set(driverId, currentBase + added);
|
||||
}
|
||||
|
||||
const fastestLapRule = bonusRules.find((r) => r.type === 'fastestLap');
|
||||
if (fastestLapRule) {
|
||||
this.applyFastestLapBonus(fastestLapRule, results, bonusByDriver);
|
||||
}
|
||||
|
||||
const penaltyMap = this.aggregatePenalties(params.penalties);
|
||||
for (const [driverId, value] of penaltyMap.entries()) {
|
||||
penaltyByDriver.set(driverId, value);
|
||||
}
|
||||
|
||||
const allDriverIds = new Set<string>();
|
||||
for (const id of baseByDriver.keys()) allDriverIds.add(id);
|
||||
for (const id of bonusByDriver.keys()) allDriverIds.add(id);
|
||||
for (const id of penaltyByDriver.keys()) allDriverIds.add(id);
|
||||
|
||||
const participants: ParticipantEventPoints[] = [];
|
||||
for (const driverId of allDriverIds) {
|
||||
const basePoints = baseByDriver.get(driverId) ?? 0;
|
||||
const bonusPoints = bonusByDriver.get(driverId) ?? 0;
|
||||
const penaltyPoints = penaltyByDriver.get(driverId) ?? 0;
|
||||
const totalPoints = basePoints + bonusPoints - penaltyPoints;
|
||||
|
||||
participants.push({
|
||||
participant: createDriverParticipant(driverId),
|
||||
basePoints,
|
||||
bonusPoints,
|
||||
penaltyPoints,
|
||||
totalPoints,
|
||||
});
|
||||
}
|
||||
|
||||
return participants;
|
||||
}
|
||||
|
||||
private getPointsTableForSession(
|
||||
championship: ChampionshipConfig,
|
||||
sessionType: SessionType,
|
||||
): PointsTable {
|
||||
return championship.pointsTableBySessionType[sessionType];
|
||||
}
|
||||
|
||||
private getBonusRulesForSession(
|
||||
championship: ChampionshipConfig,
|
||||
sessionType: SessionType,
|
||||
): BonusRule[] {
|
||||
const all = championship.bonusRulesBySessionType ?? {};
|
||||
return (all as Record<SessionType, BonusRule[]>)[sessionType] ?? [];
|
||||
}
|
||||
|
||||
private applyFastestLapBonus(
|
||||
rule: BonusRule,
|
||||
results: Result[],
|
||||
bonusByDriver: Map<string, number>,
|
||||
): void {
|
||||
if (results.length === 0) return;
|
||||
|
||||
const sortedByLap = [...results].sort((a, b) => a.fastestLap - b.fastestLap);
|
||||
const best = sortedByLap[0];
|
||||
|
||||
if (!best) {
|
||||
return;
|
||||
}
|
||||
|
||||
const requiresTop = rule.requiresFinishInTopN;
|
||||
if (typeof requiresTop === 'number') {
|
||||
if (best.position <= 0 || best.position > requiresTop) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const current = bonusByDriver.get(best.driverId) ?? 0;
|
||||
bonusByDriver.set(best.driverId, current + rule.points);
|
||||
}
|
||||
|
||||
private aggregatePenalties(penalties: Penalty[]): Map<string, number> {
|
||||
const map = new Map<string, number>();
|
||||
for (const penalty of penalties) {
|
||||
// Only count applied points_deduction penalties
|
||||
if (penalty.status !== 'applied' || penalty.type !== 'points_deduction') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const current = map.get(penalty.driverId) ?? 0;
|
||||
const delta = penalty.value ?? 0;
|
||||
map.set(penalty.driverId, current + delta);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
}
|
||||
13
core/racing/domain/services/IDriverStatsService.ts
Normal file
13
core/racing/domain/services/IDriverStatsService.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { IDomainService } from '@gridpilot/shared/domain';
|
||||
|
||||
export interface DriverStats {
|
||||
rating: number;
|
||||
wins: number;
|
||||
podiums: number;
|
||||
totalRaces: number;
|
||||
overallRank: number | null;
|
||||
}
|
||||
|
||||
export interface IDriverStatsService extends IDomainService {
|
||||
getDriverStats(driverId: string): DriverStats | null;
|
||||
}
|
||||
11
core/racing/domain/services/IRankingService.ts
Normal file
11
core/racing/domain/services/IRankingService.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { IDomainService } from '@gridpilot/shared/domain';
|
||||
|
||||
export interface DriverRanking {
|
||||
driverId: string;
|
||||
rating: number;
|
||||
overallRank: number | null;
|
||||
}
|
||||
|
||||
export interface IRankingService extends IDomainService {
|
||||
getAllDriverRankings(): DriverRanking[];
|
||||
}
|
||||
147
core/racing/domain/services/ScheduleCalculator.ts
Normal file
147
core/racing/domain/services/ScheduleCalculator.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import type { Weekday } from '../types/Weekday';
|
||||
|
||||
export type RecurrenceStrategy = 'weekly' | 'everyNWeeks' | 'monthlyNthWeekday';
|
||||
|
||||
export interface ScheduleConfig {
|
||||
weekdays: Weekday[];
|
||||
frequency: RecurrenceStrategy;
|
||||
rounds: number;
|
||||
startDate: Date;
|
||||
endDate?: Date;
|
||||
intervalWeeks?: number;
|
||||
}
|
||||
|
||||
export interface ScheduleResult {
|
||||
raceDates: Date[];
|
||||
seasonDurationWeeks: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* JavaScript getDay(): 0=Sunday, 1=Monday, 2=Tuesday, etc.
|
||||
*/
|
||||
const DAY_MAP: Record<Weekday, number> = {
|
||||
'Sun': 0, 'Mon': 1, 'Tue': 2, 'Wed': 3, 'Thu': 4, 'Fri': 5, 'Sat': 6
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculate race dates based on schedule configuration.
|
||||
*
|
||||
* If both startDate and endDate are provided, races are evenly distributed
|
||||
* across the selected weekdays within that range.
|
||||
*
|
||||
* If only startDate is provided, races are scheduled according to the
|
||||
* recurrence strategy (weekly or bi-weekly).
|
||||
*/
|
||||
export function calculateRaceDates(config: ScheduleConfig): ScheduleResult {
|
||||
const { weekdays, frequency, rounds, startDate, endDate, intervalWeeks } = config;
|
||||
const dates: Date[] = [];
|
||||
|
||||
if (weekdays.length === 0 || rounds <= 0) {
|
||||
return { raceDates: [], seasonDurationWeeks: 0 };
|
||||
}
|
||||
|
||||
// Convert weekday names to day numbers for faster lookup
|
||||
const selectedDayNumbers = new Set(weekdays.map(wd => DAY_MAP[wd]));
|
||||
|
||||
// If we have both start and end dates, evenly distribute races
|
||||
if (endDate && endDate > startDate) {
|
||||
const allPossibleDays: Date[] = [];
|
||||
const currentDate = new Date(startDate);
|
||||
currentDate.setHours(12, 0, 0, 0);
|
||||
|
||||
const endDateTime = new Date(endDate);
|
||||
endDateTime.setHours(12, 0, 0, 0);
|
||||
|
||||
while (currentDate <= endDateTime) {
|
||||
const dayOfWeek = currentDate.getDay();
|
||||
if (selectedDayNumbers.has(dayOfWeek)) {
|
||||
allPossibleDays.push(new Date(currentDate));
|
||||
}
|
||||
currentDate.setDate(currentDate.getDate() + 1);
|
||||
}
|
||||
|
||||
// Evenly distribute the rounds across available days
|
||||
const totalPossible = allPossibleDays.length;
|
||||
if (totalPossible >= rounds) {
|
||||
const spacing = totalPossible / rounds;
|
||||
for (let i = 0; i < rounds; i++) {
|
||||
const index = Math.min(Math.floor(i * spacing), totalPossible - 1);
|
||||
dates.push(allPossibleDays[index]!);
|
||||
}
|
||||
} else {
|
||||
// Not enough days - use all available
|
||||
dates.push(...allPossibleDays);
|
||||
}
|
||||
|
||||
const seasonDurationWeeks = dates.length > 1
|
||||
? Math.ceil((dates[dates.length - 1]!.getTime() - dates[0]!.getTime()) / (7 * 24 * 60 * 60 * 1000))
|
||||
: 0;
|
||||
|
||||
return { raceDates: dates, seasonDurationWeeks };
|
||||
}
|
||||
|
||||
// Schedule based on frequency (no end date)
|
||||
const currentDate = new Date(startDate);
|
||||
currentDate.setHours(12, 0, 0, 0);
|
||||
let roundsScheduled = 0;
|
||||
|
||||
// Generate race dates for up to 2 years to ensure we can schedule all rounds
|
||||
const maxDays = 365 * 2;
|
||||
let daysChecked = 0;
|
||||
const seasonStart = new Date(startDate);
|
||||
seasonStart.setHours(12, 0, 0, 0);
|
||||
|
||||
while (roundsScheduled < rounds && daysChecked < maxDays) {
|
||||
const dayOfWeek = currentDate.getDay();
|
||||
const isSelectedDay = selectedDayNumbers.has(dayOfWeek);
|
||||
|
||||
// Calculate which week this is from the start
|
||||
const daysSinceStart = Math.floor((currentDate.getTime() - seasonStart.getTime()) / (24 * 60 * 60 * 1000));
|
||||
const currentWeek = Math.floor(daysSinceStart / 7);
|
||||
|
||||
if (isSelectedDay) {
|
||||
let shouldRace = false;
|
||||
|
||||
if (frequency === 'weekly') {
|
||||
// Weekly: race every week on selected days
|
||||
shouldRace = true;
|
||||
} else if (frequency === 'everyNWeeks') {
|
||||
// Every N weeks: race only on matching week intervals
|
||||
const interval = intervalWeeks ?? 2;
|
||||
shouldRace = currentWeek % interval === 0;
|
||||
} else {
|
||||
// Default to weekly if frequency not set
|
||||
shouldRace = true;
|
||||
}
|
||||
|
||||
if (shouldRace) {
|
||||
dates.push(new Date(currentDate));
|
||||
roundsScheduled++;
|
||||
}
|
||||
}
|
||||
|
||||
currentDate.setDate(currentDate.getDate() + 1);
|
||||
daysChecked++;
|
||||
}
|
||||
|
||||
const seasonDurationWeeks = dates.length > 1
|
||||
? Math.ceil((dates[dates.length - 1]!.getTime() - dates[0]!.getTime()) / (7 * 24 * 60 * 60 * 1000))
|
||||
: 0;
|
||||
|
||||
return { raceDates: dates, seasonDurationWeeks };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the next occurrence of a specific weekday from a given date.
|
||||
*/
|
||||
export function getNextWeekday(fromDate: Date, weekday: Weekday): Date {
|
||||
const targetDay = DAY_MAP[weekday];
|
||||
const result = new Date(fromDate);
|
||||
result.setHours(12, 0, 0, 0);
|
||||
|
||||
const currentDay = result.getDay();
|
||||
const daysUntilTarget = (targetDay - currentDay + 7) % 7 || 7;
|
||||
|
||||
result.setDate(result.getDate() + daysUntilTarget);
|
||||
return result;
|
||||
}
|
||||
185
core/racing/domain/services/SeasonScheduleGenerator.ts
Normal file
185
core/racing/domain/services/SeasonScheduleGenerator.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import { SeasonSchedule } from '../value-objects/SeasonSchedule';
|
||||
import { ScheduledRaceSlot } from '../value-objects/ScheduledRaceSlot';
|
||||
import type { RecurrenceStrategy } from '../value-objects/RecurrenceStrategy';
|
||||
import { RacingDomainError, RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||
import { RaceTimeOfDay } from '../value-objects/RaceTimeOfDay';
|
||||
import type { Weekday } from '../types/Weekday';
|
||||
import { weekdayToIndex } from '../types/Weekday';
|
||||
import type { IDomainCalculationService } from '@gridpilot/shared/domain';
|
||||
|
||||
function cloneDate(date: Date): Date {
|
||||
return new Date(date.getTime());
|
||||
}
|
||||
|
||||
function addDays(date: Date, days: number): Date {
|
||||
const d = cloneDate(date);
|
||||
d.setDate(d.getDate() + days);
|
||||
return d;
|
||||
}
|
||||
|
||||
function addWeeks(date: Date, weeks: number): Date {
|
||||
return addDays(date, weeks * 7);
|
||||
}
|
||||
|
||||
function addMonths(date: Date, months: number): Date {
|
||||
const d = cloneDate(date);
|
||||
const targetMonth = d.getMonth() + months;
|
||||
d.setMonth(targetMonth);
|
||||
return d;
|
||||
}
|
||||
|
||||
function applyTimeOfDay(baseDate: Date, timeOfDay: RaceTimeOfDay): Date {
|
||||
const d = new Date(
|
||||
baseDate.getFullYear(),
|
||||
baseDate.getMonth(),
|
||||
baseDate.getDate(),
|
||||
timeOfDay.hour,
|
||||
timeOfDay.minute,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
return d;
|
||||
}
|
||||
|
||||
// Treat Monday as 1 ... Sunday as 7
|
||||
function getCalendarWeekdayIndex(date: Date): number {
|
||||
const jsDay = date.getDay(); // 0=Sun ... 6=Sat
|
||||
if (jsDay === 0) {
|
||||
return 7;
|
||||
}
|
||||
return jsDay;
|
||||
}
|
||||
|
||||
function weekdayToCalendarOffset(anchor: Date, target: Weekday): number {
|
||||
const anchorIndex = getCalendarWeekdayIndex(anchor);
|
||||
const targetIndex = weekdayToIndex(target);
|
||||
return targetIndex - anchorIndex;
|
||||
}
|
||||
|
||||
function generateWeeklyOrEveryNWeeksSlots(
|
||||
schedule: SeasonSchedule,
|
||||
maxRounds: number,
|
||||
): ScheduledRaceSlot[] {
|
||||
const result: ScheduledRaceSlot[] = [];
|
||||
const recurrence = schedule.recurrence;
|
||||
const weekdays =
|
||||
recurrence.kind === 'weekly' || recurrence.kind === 'everyNWeeks'
|
||||
? recurrence.weekdays.getAll()
|
||||
: [];
|
||||
|
||||
if (weekdays.length === 0) {
|
||||
throw new RacingDomainValidationError('RecurrenceStrategy has no weekdays');
|
||||
}
|
||||
|
||||
const intervalWeeks = recurrence.kind === 'everyNWeeks' ? recurrence.intervalWeeks : 1;
|
||||
|
||||
let anchorWeekStart = cloneDate(schedule.startDate);
|
||||
let roundNumber = 1;
|
||||
|
||||
while (result.length < maxRounds) {
|
||||
for (const weekday of weekdays) {
|
||||
const offset = weekdayToCalendarOffset(anchorWeekStart, weekday);
|
||||
const candidateDate = addDays(anchorWeekStart, offset);
|
||||
|
||||
if (candidateDate < schedule.startDate) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const scheduledAt = applyTimeOfDay(candidateDate, schedule.timeOfDay);
|
||||
result.push(
|
||||
new ScheduledRaceSlot({
|
||||
roundNumber,
|
||||
scheduledAt,
|
||||
timezone: schedule.timezone,
|
||||
}),
|
||||
);
|
||||
roundNumber += 1;
|
||||
|
||||
if (result.length >= maxRounds) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
anchorWeekStart = addWeeks(anchorWeekStart, intervalWeeks);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function findNthWeekdayOfMonth(base: Date, ordinal: 1 | 2 | 3 | 4, weekday: Weekday): Date {
|
||||
const firstOfMonth = new Date(base.getFullYear(), base.getMonth(), 1);
|
||||
const firstIndex = getCalendarWeekdayIndex(firstOfMonth);
|
||||
const targetIndex = weekdayToIndex(weekday);
|
||||
|
||||
let offset = targetIndex - firstIndex;
|
||||
if (offset < 0) {
|
||||
offset += 7;
|
||||
}
|
||||
|
||||
const dayOfMonth = 1 + offset + (ordinal - 1) * 7;
|
||||
return new Date(base.getFullYear(), base.getMonth(), dayOfMonth);
|
||||
}
|
||||
|
||||
function generateMonthlySlots(schedule: SeasonSchedule, maxRounds: number): ScheduledRaceSlot[] {
|
||||
const result: ScheduledRaceSlot[] = [];
|
||||
const recurrence = schedule.recurrence;
|
||||
if (recurrence.kind !== 'monthlyNthWeekday') {
|
||||
return result;
|
||||
}
|
||||
|
||||
const { ordinal, weekday } = recurrence.monthlyPattern;
|
||||
let currentMonthDate = new Date(
|
||||
schedule.startDate.getFullYear(),
|
||||
schedule.startDate.getMonth(),
|
||||
1,
|
||||
);
|
||||
let roundNumber = 1;
|
||||
|
||||
while (result.length < maxRounds) {
|
||||
const candidateDate = findNthWeekdayOfMonth(currentMonthDate, ordinal, weekday);
|
||||
|
||||
if (candidateDate >= schedule.startDate) {
|
||||
const scheduledAt = applyTimeOfDay(candidateDate, schedule.timeOfDay);
|
||||
result.push(
|
||||
new ScheduledRaceSlot({
|
||||
roundNumber,
|
||||
scheduledAt,
|
||||
timezone: schedule.timezone,
|
||||
}),
|
||||
);
|
||||
roundNumber += 1;
|
||||
}
|
||||
|
||||
currentMonthDate = addMonths(currentMonthDate, 1);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export class SeasonScheduleGenerator {
|
||||
static generateSlots(schedule: SeasonSchedule): ScheduledRaceSlot[] {
|
||||
return this.generateSlotsUpTo(schedule, schedule.plannedRounds);
|
||||
}
|
||||
|
||||
static generateSlotsUpTo(schedule: SeasonSchedule, maxRounds: number): ScheduledRaceSlot[] {
|
||||
if (!Number.isInteger(maxRounds) || maxRounds <= 0) {
|
||||
throw new RacingDomainValidationError('maxRounds must be a positive integer');
|
||||
}
|
||||
|
||||
const recurrence: RecurrenceStrategy = schedule.recurrence;
|
||||
|
||||
if (recurrence.kind === 'monthlyNthWeekday') {
|
||||
return generateMonthlySlots(schedule, maxRounds);
|
||||
}
|
||||
|
||||
return generateWeeklyOrEveryNWeeksSlots(schedule, maxRounds);
|
||||
}
|
||||
}
|
||||
|
||||
export class SeasonScheduleGeneratorService
|
||||
implements IDomainCalculationService<SeasonSchedule, ScheduledRaceSlot[]>
|
||||
{
|
||||
calculate(schedule: SeasonSchedule): ScheduledRaceSlot[] {
|
||||
return SeasonScheduleGenerator.generateSlots(schedule);
|
||||
}
|
||||
}
|
||||
35
core/racing/domain/services/SkillLevelService.ts
Normal file
35
core/racing/domain/services/SkillLevelService.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { IDomainService } from '@gridpilot/shared/domain';
|
||||
|
||||
export type SkillLevel = 'beginner' | 'intermediate' | 'advanced' | 'pro';
|
||||
|
||||
/**
|
||||
* Domain service for determining skill level based on rating.
|
||||
* This encapsulates the business rule for skill tier classification.
|
||||
*/
|
||||
export class SkillLevelService implements IDomainService {
|
||||
readonly serviceName = 'SkillLevelService';
|
||||
/**
|
||||
* Map driver rating to skill level band.
|
||||
* Business rule: iRating thresholds determine skill tiers.
|
||||
*/
|
||||
static getSkillLevel(rating: number): SkillLevel {
|
||||
if (rating >= 3000) return 'pro';
|
||||
if (rating >= 2500) return 'advanced';
|
||||
if (rating >= 1800) return 'intermediate';
|
||||
return 'beginner';
|
||||
}
|
||||
|
||||
/**
|
||||
* Map average team rating to performance level.
|
||||
* Business rule: Team ratings use higher thresholds than individual drivers.
|
||||
*/
|
||||
static getTeamPerformanceLevel(averageRating: number | null): SkillLevel {
|
||||
if (averageRating === null) {
|
||||
return 'beginner';
|
||||
}
|
||||
if (averageRating >= 4500) return 'pro';
|
||||
if (averageRating >= 3000) return 'advanced';
|
||||
if (averageRating >= 2000) return 'intermediate';
|
||||
return 'beginner';
|
||||
}
|
||||
}
|
||||
43
core/racing/domain/services/StrengthOfFieldCalculator.ts
Normal file
43
core/racing/domain/services/StrengthOfFieldCalculator.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { IDomainCalculationService } from '@gridpilot/shared/domain';
|
||||
|
||||
/**
|
||||
* 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, IDomainCalculationService<DriverRating[], number | null>
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
8
core/racing/domain/types/BonusRule.ts
Normal file
8
core/racing/domain/types/BonusRule.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export type BonusRuleType = 'fastestLap' | 'polePosition' | 'mostPositionsGained';
|
||||
|
||||
export interface BonusRule {
|
||||
id: string;
|
||||
type: BonusRuleType;
|
||||
points: number;
|
||||
requiresFinishInTopN?: number;
|
||||
}
|
||||
21
core/racing/domain/types/ChampionshipConfig.ts
Normal file
21
core/racing/domain/types/ChampionshipConfig.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { ChampionshipType } from '../types/ChampionshipType';
|
||||
import type { SessionType } from '../types/SessionType';
|
||||
import type { PointsTable } from '../value-objects/PointsTable';
|
||||
import type { BonusRule } from '../types/BonusRule';
|
||||
import type { DropScorePolicy } from '../types/DropScorePolicy';
|
||||
|
||||
/**
|
||||
* Domain Type: ChampionshipConfig
|
||||
*
|
||||
* Pure configuration shape for a championship's scoring model.
|
||||
* This is not a value object and intentionally lives under domain/types.
|
||||
*/
|
||||
export interface ChampionshipConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
type: ChampionshipType;
|
||||
sessionTypes: SessionType[];
|
||||
pointsTableBySessionType: Record<SessionType, PointsTable>;
|
||||
bonusRulesBySessionType?: Record<SessionType, BonusRule[]>;
|
||||
dropScorePolicy: DropScorePolicy;
|
||||
}
|
||||
1
core/racing/domain/types/ChampionshipType.ts
Normal file
1
core/racing/domain/types/ChampionshipType.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type ChampionshipType = 'driver' | 'team' | 'nations' | 'trophy';
|
||||
13
core/racing/domain/types/DropScorePolicy.ts
Normal file
13
core/racing/domain/types/DropScorePolicy.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export type DropScoreStrategy = 'none' | 'bestNResults' | 'dropWorstN';
|
||||
|
||||
export interface DropScorePolicy {
|
||||
strategy: DropScoreStrategy;
|
||||
/**
|
||||
* For 'bestNResults': number of best-scoring events to count.
|
||||
*/
|
||||
count?: number;
|
||||
/**
|
||||
* For 'dropWorstN': number of worst-scoring events to drop.
|
||||
*/
|
||||
dropCount?: number;
|
||||
}
|
||||
73
core/racing/domain/types/LeagueRoles.ts
Normal file
73
core/racing/domain/types/LeagueRoles.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Domain Types/Utilities: LeagueRoles
|
||||
*
|
||||
* Utility functions for working with league membership roles.
|
||||
*/
|
||||
|
||||
import type { MembershipRole } from '../entities/LeagueMembership';
|
||||
|
||||
/**
|
||||
* Role hierarchy (higher number = more authority)
|
||||
*/
|
||||
const ROLE_HIERARCHY: Record<MembershipRole, number> = {
|
||||
member: 0,
|
||||
steward: 1,
|
||||
admin: 2,
|
||||
owner: 3,
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a role is at least steward level
|
||||
*/
|
||||
export function isLeagueStewardOrHigherRole(role: MembershipRole): boolean {
|
||||
return ROLE_HIERARCHY[role] >= ROLE_HIERARCHY.steward;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a role is at least admin level
|
||||
*/
|
||||
export function isLeagueAdminOrHigherRole(role: MembershipRole): boolean {
|
||||
return ROLE_HIERARCHY[role] >= ROLE_HIERARCHY.admin;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a role is owner
|
||||
*/
|
||||
export function isLeagueOwnerRole(role: MembershipRole): boolean {
|
||||
return role === 'owner';
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two roles
|
||||
* Returns positive if role1 > role2, negative if role1 < role2, 0 if equal
|
||||
*/
|
||||
export function compareRoles(role1: MembershipRole, role2: MembershipRole): number {
|
||||
return ROLE_HIERARCHY[role1] - ROLE_HIERARCHY[role2];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get role display name
|
||||
*/
|
||||
export function getRoleDisplayName(role: MembershipRole): string {
|
||||
const names: Record<MembershipRole, string> = {
|
||||
member: 'Member',
|
||||
steward: 'Steward',
|
||||
admin: 'Admin',
|
||||
owner: 'Owner',
|
||||
};
|
||||
return names[role];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all roles in order of hierarchy
|
||||
*/
|
||||
export function getAllRolesOrdered(): MembershipRole[] {
|
||||
return ['member', 'steward', 'admin', 'owner'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get roles that can be assigned (excludes owner as it's transferred, not assigned)
|
||||
*/
|
||||
export function getAssignableRoles(): MembershipRole[] {
|
||||
return ['member', 'steward', 'admin'];
|
||||
}
|
||||
6
core/racing/domain/types/ParticipantRef.ts
Normal file
6
core/racing/domain/types/ParticipantRef.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { ChampionshipType } from './ChampionshipType';
|
||||
|
||||
export interface ParticipantRef {
|
||||
type: ChampionshipType;
|
||||
id: string;
|
||||
}
|
||||
9
core/racing/domain/types/SessionType.ts
Normal file
9
core/racing/domain/types/SessionType.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export type SessionType =
|
||||
| 'practice'
|
||||
| 'qualifying'
|
||||
| 'q1'
|
||||
| 'q2'
|
||||
| 'q3'
|
||||
| 'sprint'
|
||||
| 'main'
|
||||
| 'timeTrial';
|
||||
25
core/racing/domain/types/TeamMembership.ts
Normal file
25
core/racing/domain/types/TeamMembership.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Domain Types: TeamRole, TeamMembershipStatus, TeamMembership, TeamJoinRequest
|
||||
*
|
||||
* These are pure domain data shapes (no behavior) used across repositories
|
||||
* and application DTOs. They are not entities or value objects.
|
||||
*/
|
||||
|
||||
export type TeamRole = 'owner' | 'manager' | 'driver';
|
||||
export type TeamMembershipStatus = 'active' | 'pending' | 'none';
|
||||
|
||||
export interface TeamMembership {
|
||||
teamId: string;
|
||||
driverId: string;
|
||||
role: TeamRole;
|
||||
status: TeamMembershipStatus;
|
||||
joinedAt: Date;
|
||||
}
|
||||
|
||||
export interface TeamJoinRequest {
|
||||
id: string;
|
||||
teamId: string;
|
||||
driverId: string;
|
||||
requestedAt: Date;
|
||||
message?: string;
|
||||
}
|
||||
27
core/racing/domain/types/Weekday.ts
Normal file
27
core/racing/domain/types/Weekday.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
export type Weekday = 'Mon' | 'Tue' | 'Wed' | 'Thu' | 'Fri' | 'Sat' | 'Sun';
|
||||
|
||||
import { RacingDomainInvariantError } from '../errors/RacingDomainError';
|
||||
|
||||
export const ALL_WEEKDAYS: Weekday[] = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||
|
||||
export function weekdayToIndex(day: Weekday): number {
|
||||
switch (day) {
|
||||
case 'Mon':
|
||||
return 1;
|
||||
case 'Tue':
|
||||
return 2;
|
||||
case 'Wed':
|
||||
return 3;
|
||||
case 'Thu':
|
||||
return 4;
|
||||
case 'Fri':
|
||||
return 5;
|
||||
case 'Sat':
|
||||
return 6;
|
||||
case 'Sun':
|
||||
return 7;
|
||||
default:
|
||||
// This should be unreachable because Weekday is a closed union.
|
||||
throw new RacingDomainInvariantError(`Unknown weekday: ${day}`);
|
||||
}
|
||||
}
|
||||
203
core/racing/domain/value-objects/GameConstraints.ts
Normal file
203
core/racing/domain/value-objects/GameConstraints.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* Domain Value Object: GameConstraints
|
||||
*
|
||||
* Represents game-specific constraints for leagues.
|
||||
* Different sim racing games have different maximum grid sizes.
|
||||
*/
|
||||
|
||||
import type { IValueObject } from '@gridpilot/shared/domain';
|
||||
|
||||
export interface GameConstraintsData {
|
||||
readonly maxDrivers: number;
|
||||
readonly maxTeams: number;
|
||||
readonly defaultMaxDrivers: number;
|
||||
readonly minDrivers: number;
|
||||
readonly supportsTeams: boolean;
|
||||
readonly supportsMultiClass: boolean;
|
||||
}
|
||||
|
||||
export interface GameConstraintsProps {
|
||||
gameId: string;
|
||||
constraints: GameConstraintsData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Game-specific constraints for popular sim racing games
|
||||
*/
|
||||
const GAME_CONSTRAINTS: Record<string, GameConstraintsData> & { default: GameConstraintsData } = {
|
||||
iracing: {
|
||||
maxDrivers: 64,
|
||||
maxTeams: 32,
|
||||
defaultMaxDrivers: 24,
|
||||
minDrivers: 2,
|
||||
supportsTeams: true,
|
||||
supportsMultiClass: true,
|
||||
},
|
||||
acc: {
|
||||
maxDrivers: 30,
|
||||
maxTeams: 15,
|
||||
defaultMaxDrivers: 24,
|
||||
minDrivers: 2,
|
||||
supportsTeams: true,
|
||||
supportsMultiClass: false,
|
||||
},
|
||||
rf2: {
|
||||
maxDrivers: 64,
|
||||
maxTeams: 32,
|
||||
defaultMaxDrivers: 24,
|
||||
minDrivers: 2,
|
||||
supportsTeams: true,
|
||||
supportsMultiClass: true,
|
||||
},
|
||||
ams2: {
|
||||
maxDrivers: 32,
|
||||
maxTeams: 16,
|
||||
defaultMaxDrivers: 20,
|
||||
minDrivers: 2,
|
||||
supportsTeams: true,
|
||||
supportsMultiClass: true,
|
||||
},
|
||||
lmu: {
|
||||
maxDrivers: 32,
|
||||
maxTeams: 16,
|
||||
defaultMaxDrivers: 24,
|
||||
minDrivers: 2,
|
||||
supportsTeams: true,
|
||||
supportsMultiClass: true,
|
||||
},
|
||||
// Default for unknown games
|
||||
default: {
|
||||
maxDrivers: 32,
|
||||
maxTeams: 16,
|
||||
defaultMaxDrivers: 20,
|
||||
minDrivers: 2,
|
||||
supportsTeams: true,
|
||||
supportsMultiClass: false,
|
||||
},
|
||||
};
|
||||
|
||||
function getConstraintsForId(gameId: string): GameConstraintsData {
|
||||
const lower = gameId.toLowerCase();
|
||||
const fromMap = GAME_CONSTRAINTS[lower];
|
||||
if (fromMap) {
|
||||
return fromMap;
|
||||
}
|
||||
return GAME_CONSTRAINTS.default;
|
||||
}
|
||||
|
||||
export class GameConstraints implements IValueObject<GameConstraintsProps> {
|
||||
readonly gameId: string;
|
||||
readonly constraints: GameConstraintsData;
|
||||
|
||||
private constructor(gameId: string, constraints: GameConstraintsData) {
|
||||
this.gameId = gameId;
|
||||
this.constraints = constraints;
|
||||
}
|
||||
|
||||
get props(): GameConstraintsProps {
|
||||
return {
|
||||
gameId: this.gameId,
|
||||
constraints: this.constraints,
|
||||
};
|
||||
}
|
||||
|
||||
equals(other: IValueObject<GameConstraintsProps>): boolean {
|
||||
return this.props.gameId === other.props.gameId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get constraints for a specific game
|
||||
*/
|
||||
static forGame(gameId: string): GameConstraints {
|
||||
const constraints = getConstraintsForId(gameId);
|
||||
const lowerId = gameId.toLowerCase();
|
||||
return new GameConstraints(lowerId, constraints);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all supported game IDs
|
||||
*/
|
||||
static getSupportedGames(): string[] {
|
||||
return Object.keys(GAME_CONSTRAINTS).filter(id => id !== 'default');
|
||||
}
|
||||
|
||||
/**
|
||||
* Maximum drivers allowed for this game
|
||||
*/
|
||||
get maxDrivers(): number {
|
||||
return this.constraints.maxDrivers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maximum teams allowed for this game
|
||||
*/
|
||||
get maxTeams(): number {
|
||||
return this.constraints.maxTeams;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default driver count for new leagues
|
||||
*/
|
||||
get defaultMaxDrivers(): number {
|
||||
return this.constraints.defaultMaxDrivers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimum drivers required
|
||||
*/
|
||||
get minDrivers(): number {
|
||||
return this.constraints.minDrivers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this game supports team-based leagues
|
||||
*/
|
||||
get supportsTeams(): boolean {
|
||||
return this.constraints.supportsTeams;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this game supports multi-class racing
|
||||
*/
|
||||
get supportsMultiClass(): boolean {
|
||||
return this.constraints.supportsMultiClass;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a driver count against game constraints
|
||||
*/
|
||||
validateDriverCount(count: number): { valid: boolean; error?: string } {
|
||||
if (count < this.minDrivers) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Minimum ${this.minDrivers} drivers required`,
|
||||
};
|
||||
}
|
||||
if (count > this.maxDrivers) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Maximum ${this.maxDrivers} drivers allowed for ${this.gameId.toUpperCase()}`,
|
||||
};
|
||||
}
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a team count against game constraints
|
||||
*/
|
||||
validateTeamCount(count: number): { valid: boolean; error?: string } {
|
||||
if (!this.supportsTeams) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `${this.gameId.toUpperCase()} does not support team-based leagues`,
|
||||
};
|
||||
}
|
||||
if (count > this.maxTeams) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Maximum ${this.maxTeams} teams allowed for ${this.gameId.toUpperCase()}`,
|
||||
};
|
||||
}
|
||||
return { valid: true };
|
||||
}
|
||||
}
|
||||
100
core/racing/domain/value-objects/LeagueDescription.ts
Normal file
100
core/racing/domain/value-objects/LeagueDescription.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Domain Value Object: LeagueDescription
|
||||
*
|
||||
* Represents a valid league description with validation rules.
|
||||
*/
|
||||
|
||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||
import type { IValueObject } from '@gridpilot/shared/domain';
|
||||
|
||||
export interface LeagueDescriptionValidationResult {
|
||||
valid: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export const LEAGUE_DESCRIPTION_CONSTRAINTS = {
|
||||
minLength: 20,
|
||||
maxLength: 1000,
|
||||
recommendedMinLength: 50,
|
||||
} as const;
|
||||
|
||||
export interface LeagueDescriptionProps {
|
||||
value: string;
|
||||
}
|
||||
|
||||
export class LeagueDescription implements IValueObject<LeagueDescriptionProps> {
|
||||
readonly value: string;
|
||||
|
||||
private constructor(value: string) {
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a league description without creating the value object
|
||||
*/
|
||||
static validate(value: string): LeagueDescriptionValidationResult {
|
||||
const trimmed = value?.trim() ?? '';
|
||||
|
||||
if (!trimmed) {
|
||||
return { valid: false, error: 'Description is required — help drivers understand your league' };
|
||||
}
|
||||
|
||||
if (trimmed.length < LEAGUE_DESCRIPTION_CONSTRAINTS.minLength) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Description must be at least ${LEAGUE_DESCRIPTION_CONSTRAINTS.minLength} characters — tell drivers what makes your league special`,
|
||||
};
|
||||
}
|
||||
|
||||
if (trimmed.length > LEAGUE_DESCRIPTION_CONSTRAINTS.maxLength) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Description must be ${LEAGUE_DESCRIPTION_CONSTRAINTS.maxLength} characters or less`,
|
||||
};
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if description meets recommended length for better engagement
|
||||
*/
|
||||
static isRecommendedLength(value: string): boolean {
|
||||
const trimmed = value?.trim() ?? '';
|
||||
return trimmed.length >= LEAGUE_DESCRIPTION_CONSTRAINTS.recommendedMinLength;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a LeagueDescription from a string value
|
||||
*/
|
||||
static create(value: string): LeagueDescription {
|
||||
const validation = this.validate(value);
|
||||
if (!validation.valid) {
|
||||
throw new RacingDomainValidationError(validation.error ?? 'Invalid league description');
|
||||
}
|
||||
return new LeagueDescription(value.trim());
|
||||
}
|
||||
|
||||
get props(): LeagueDescriptionProps {
|
||||
return { value: this.value };
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to create a LeagueDescription, returning null if invalid
|
||||
*/
|
||||
static tryCreate(value: string): LeagueDescription | null {
|
||||
const validation = this.validate(value);
|
||||
if (!validation.valid) {
|
||||
return null;
|
||||
}
|
||||
return new LeagueDescription(value.trim());
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
equals(other: IValueObject<LeagueDescriptionProps>): boolean {
|
||||
return this.props.value === other.props.value;
|
||||
}
|
||||
}
|
||||
113
core/racing/domain/value-objects/LeagueName.ts
Normal file
113
core/racing/domain/value-objects/LeagueName.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Domain Value Object: LeagueName
|
||||
*
|
||||
* Represents a valid league name with validation rules.
|
||||
*/
|
||||
|
||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||
import type { IValueObject } from '@gridpilot/shared/domain';
|
||||
|
||||
export interface LeagueNameValidationResult {
|
||||
valid: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export const LEAGUE_NAME_CONSTRAINTS = {
|
||||
minLength: 3,
|
||||
maxLength: 64,
|
||||
pattern: /^[a-zA-Z0-9].*$/, // Must start with alphanumeric
|
||||
forbiddenPatterns: [
|
||||
/^\s/, // No leading whitespace
|
||||
/\s$/, // No trailing whitespace
|
||||
/\s{2,}/, // No multiple consecutive spaces
|
||||
],
|
||||
} as const;
|
||||
|
||||
export interface LeagueNameProps {
|
||||
value: string;
|
||||
}
|
||||
|
||||
export class LeagueName implements IValueObject<LeagueNameProps> {
|
||||
readonly value: string;
|
||||
|
||||
private constructor(value: string) {
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a league name without creating the value object
|
||||
*/
|
||||
static validate(value: string): LeagueNameValidationResult {
|
||||
const trimmed = value?.trim() ?? '';
|
||||
|
||||
if (!trimmed) {
|
||||
return { valid: false, error: 'League name is required' };
|
||||
}
|
||||
|
||||
if (trimmed.length < LEAGUE_NAME_CONSTRAINTS.minLength) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `League name must be at least ${LEAGUE_NAME_CONSTRAINTS.minLength} characters`,
|
||||
};
|
||||
}
|
||||
|
||||
if (trimmed.length > LEAGUE_NAME_CONSTRAINTS.maxLength) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `League name must be ${LEAGUE_NAME_CONSTRAINTS.maxLength} characters or less`,
|
||||
};
|
||||
}
|
||||
|
||||
if (!LEAGUE_NAME_CONSTRAINTS.pattern.test(trimmed)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'League name must start with a letter or number',
|
||||
};
|
||||
}
|
||||
|
||||
for (const forbidden of LEAGUE_NAME_CONSTRAINTS.forbiddenPatterns) {
|
||||
if (forbidden.test(value)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'League name cannot have leading/trailing spaces or multiple consecutive spaces',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a LeagueName from a string value
|
||||
*/
|
||||
static create(value: string): LeagueName {
|
||||
const validation = this.validate(value);
|
||||
if (!validation.valid) {
|
||||
throw new RacingDomainValidationError(validation.error ?? 'Invalid league name');
|
||||
}
|
||||
return new LeagueName(value.trim());
|
||||
}
|
||||
|
||||
get props(): LeagueNameProps {
|
||||
return { value: this.value };
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to create a LeagueName, returning null if invalid
|
||||
*/
|
||||
static tryCreate(value: string): LeagueName | null {
|
||||
const validation = this.validate(value);
|
||||
if (!validation.valid) {
|
||||
return null;
|
||||
}
|
||||
return new LeagueName(value.trim());
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
equals(other: IValueObject<LeagueNameProps>): boolean {
|
||||
return this.props.value === other.props.value;
|
||||
}
|
||||
}
|
||||
29
core/racing/domain/value-objects/LeagueTimezone.ts
Normal file
29
core/racing/domain/value-objects/LeagueTimezone.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||
import type { IValueObject } from '@gridpilot/shared/domain';
|
||||
|
||||
export interface LeagueTimezoneProps {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export class LeagueTimezone implements IValueObject<LeagueTimezoneProps> {
|
||||
private readonly id: string;
|
||||
|
||||
constructor(id: string) {
|
||||
if (!id || id.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('LeagueTimezone id must be a non-empty string');
|
||||
}
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
getId(): string {
|
||||
return this.id;
|
||||
}
|
||||
|
||||
get props(): LeagueTimezoneProps {
|
||||
return { id: this.id };
|
||||
}
|
||||
|
||||
equals(other: IValueObject<LeagueTimezoneProps>): boolean {
|
||||
return this.props.id === other.props.id;
|
||||
}
|
||||
}
|
||||
140
core/racing/domain/value-objects/LeagueVisibility.ts
Normal file
140
core/racing/domain/value-objects/LeagueVisibility.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Domain Value Object: LeagueVisibility
|
||||
*
|
||||
* Represents the visibility and ranking status of a league.
|
||||
*
|
||||
* - 'ranked' (public): Competitive leagues visible to everyone, affects driver ratings.
|
||||
* Requires minimum 10 players to ensure competitive integrity.
|
||||
* - 'unranked' (private): Casual leagues for friends/private groups, no rating impact.
|
||||
* Can have any number of players.
|
||||
*/
|
||||
|
||||
import type { IValueObject } from '@gridpilot/shared/domain';
|
||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||
|
||||
export type LeagueVisibilityType = 'ranked' | 'unranked';
|
||||
|
||||
export interface LeagueVisibilityConstraints {
|
||||
readonly minDrivers: number;
|
||||
readonly isPubliclyVisible: boolean;
|
||||
readonly affectsRatings: boolean;
|
||||
readonly requiresApproval: boolean;
|
||||
}
|
||||
|
||||
const VISIBILITY_CONSTRAINTS: Record<LeagueVisibilityType, LeagueVisibilityConstraints> = {
|
||||
ranked: {
|
||||
minDrivers: 10,
|
||||
isPubliclyVisible: true,
|
||||
affectsRatings: true,
|
||||
requiresApproval: false, // Anyone can join public leagues
|
||||
},
|
||||
unranked: {
|
||||
minDrivers: 2,
|
||||
isPubliclyVisible: false,
|
||||
affectsRatings: false,
|
||||
requiresApproval: true, // Private leagues require invite/approval
|
||||
},
|
||||
};
|
||||
|
||||
export interface LeagueVisibilityProps {
|
||||
type: LeagueVisibilityType;
|
||||
}
|
||||
|
||||
export class LeagueVisibility implements IValueObject<LeagueVisibilityProps> {
|
||||
readonly type: LeagueVisibilityType;
|
||||
readonly constraints: LeagueVisibilityConstraints;
|
||||
|
||||
private constructor(type: LeagueVisibilityType) {
|
||||
this.type = type;
|
||||
this.constraints = VISIBILITY_CONSTRAINTS[type];
|
||||
}
|
||||
|
||||
static ranked(): LeagueVisibility {
|
||||
return new LeagueVisibility('ranked');
|
||||
}
|
||||
|
||||
static unranked(): LeagueVisibility {
|
||||
return new LeagueVisibility('unranked');
|
||||
}
|
||||
|
||||
static fromString(value: string): LeagueVisibility {
|
||||
// Support both old ('public'/'private') and new ('ranked'/'unranked') terminology
|
||||
if (value === 'ranked' || value === 'public') {
|
||||
return LeagueVisibility.ranked();
|
||||
}
|
||||
if (value === 'unranked' || value === 'private') {
|
||||
return LeagueVisibility.unranked();
|
||||
}
|
||||
throw new RacingDomainValidationError(`Invalid league visibility: ${value}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that the given driver count meets the minimum requirement
|
||||
* for this visibility type.
|
||||
*/
|
||||
validateDriverCount(driverCount: number): { valid: boolean; error?: string } {
|
||||
if (driverCount < this.constraints.minDrivers) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `${this.type === 'ranked' ? 'Ranked' : 'Unranked'} leagues require at least ${this.constraints.minDrivers} drivers`,
|
||||
};
|
||||
}
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if this is a ranked/public league
|
||||
*/
|
||||
isRanked(): boolean {
|
||||
return this.type === 'ranked';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if this is an unranked/private league
|
||||
*/
|
||||
isUnranked(): boolean {
|
||||
return this.type === 'unranked';
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-readable label for UI display
|
||||
*/
|
||||
getLabel(): string {
|
||||
return this.type === 'ranked' ? 'Ranked (Public)' : 'Unranked (Friends)';
|
||||
}
|
||||
|
||||
/**
|
||||
* Short description for UI tooltips
|
||||
*/
|
||||
getDescription(): string {
|
||||
return this.type === 'ranked'
|
||||
? 'Competitive league visible to everyone. Results affect driver ratings.'
|
||||
: 'Private league for friends. Results do not affect ratings.';
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to string for serialization
|
||||
*/
|
||||
toString(): LeagueVisibilityType {
|
||||
return this.type;
|
||||
}
|
||||
|
||||
get props(): LeagueVisibilityProps {
|
||||
return { type: this.type };
|
||||
}
|
||||
|
||||
/**
|
||||
* For backward compatibility with existing 'public'/'private' terminology
|
||||
*/
|
||||
toLegacyString(): 'public' | 'private' {
|
||||
return this.type === 'ranked' ? 'public' : 'private';
|
||||
}
|
||||
|
||||
equals(other: IValueObject<LeagueVisibilityProps>): boolean {
|
||||
return this.props.type === other.props.type;
|
||||
}
|
||||
}
|
||||
|
||||
// Export constants for validation
|
||||
export const MIN_RANKED_LEAGUE_DRIVERS = VISIBILITY_CONSTRAINTS.ranked.minDrivers;
|
||||
export const MIN_UNRANKED_LEAGUE_DRIVERS = VISIBILITY_CONSTRAINTS.unranked.minDrivers;
|
||||
190
core/racing/domain/value-objects/LiveryDecal.ts
Normal file
190
core/racing/domain/value-objects/LiveryDecal.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* Value Object: LiveryDecal
|
||||
* Represents a decal/logo placed on a livery
|
||||
*/
|
||||
|
||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||
import type { IValueObject } from '@gridpilot/shared/domain';
|
||||
|
||||
export type DecalType = 'sponsor' | 'user';
|
||||
|
||||
export interface LiveryDecalProps {
|
||||
id: string;
|
||||
imageUrl: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
rotation: number; // Degrees, 0-360
|
||||
zIndex: number;
|
||||
type: DecalType;
|
||||
}
|
||||
|
||||
export class LiveryDecal implements IValueObject<LiveryDecalProps> {
|
||||
readonly id: string;
|
||||
readonly imageUrl: string;
|
||||
readonly x: number;
|
||||
readonly y: number;
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
readonly rotation: number;
|
||||
readonly zIndex: number;
|
||||
readonly type: DecalType;
|
||||
|
||||
private constructor(props: LiveryDecalProps) {
|
||||
this.id = props.id;
|
||||
this.imageUrl = props.imageUrl;
|
||||
this.x = props.x;
|
||||
this.y = props.y;
|
||||
this.width = props.width;
|
||||
this.height = props.height;
|
||||
this.rotation = props.rotation;
|
||||
this.zIndex = props.zIndex;
|
||||
this.type = props.type;
|
||||
}
|
||||
|
||||
static create(props: Omit<LiveryDecalProps, 'rotation'> & { rotation?: number }): LiveryDecal {
|
||||
const propsWithRotation = {
|
||||
...props,
|
||||
rotation: props.rotation ?? 0,
|
||||
};
|
||||
this.validate(propsWithRotation);
|
||||
return new LiveryDecal(propsWithRotation);
|
||||
}
|
||||
|
||||
private static validate(props: LiveryDecalProps): void {
|
||||
if (!props.id || props.id.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('LiveryDecal ID is required');
|
||||
}
|
||||
|
||||
if (!props.imageUrl || props.imageUrl.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('LiveryDecal imageUrl is required');
|
||||
}
|
||||
|
||||
if (props.x < 0 || props.x > 1) {
|
||||
throw new RacingDomainValidationError('LiveryDecal x coordinate must be between 0 and 1 (normalized)');
|
||||
}
|
||||
|
||||
if (props.y < 0 || props.y > 1) {
|
||||
throw new RacingDomainValidationError('LiveryDecal y coordinate must be between 0 and 1 (normalized)');
|
||||
}
|
||||
|
||||
if (props.width <= 0 || props.width > 1) {
|
||||
throw new RacingDomainValidationError('LiveryDecal width must be between 0 and 1 (normalized)');
|
||||
}
|
||||
|
||||
if (props.height <= 0 || props.height > 1) {
|
||||
throw new RacingDomainValidationError('LiveryDecal height must be between 0 and 1 (normalized)');
|
||||
}
|
||||
|
||||
if (!Number.isInteger(props.zIndex) || props.zIndex < 0) {
|
||||
throw new RacingDomainValidationError('LiveryDecal zIndex must be a non-negative integer');
|
||||
}
|
||||
|
||||
if (props.rotation < 0 || props.rotation > 360) {
|
||||
throw new RacingDomainValidationError('LiveryDecal rotation must be between 0 and 360 degrees');
|
||||
}
|
||||
|
||||
if (!props.type) {
|
||||
throw new RacingDomainValidationError('LiveryDecal type is required');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Move decal to new position
|
||||
*/
|
||||
moveTo(x: number, y: number): LiveryDecal {
|
||||
return LiveryDecal.create({
|
||||
...this,
|
||||
x,
|
||||
y,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize decal
|
||||
*/
|
||||
resize(width: number, height: number): LiveryDecal {
|
||||
return LiveryDecal.create({
|
||||
...this,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Change z-index
|
||||
*/
|
||||
setZIndex(zIndex: number): LiveryDecal {
|
||||
return LiveryDecal.create({
|
||||
...this,
|
||||
zIndex,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotate decal
|
||||
*/
|
||||
rotate(rotation: number): LiveryDecal {
|
||||
// Normalize rotation to 0-360 range
|
||||
const normalizedRotation = ((rotation % 360) + 360) % 360;
|
||||
return LiveryDecal.create({
|
||||
...this,
|
||||
rotation: normalizedRotation,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get CSS transform string for rendering
|
||||
*/
|
||||
getCssTransform(): string {
|
||||
return `rotate(${this.rotation}deg)`;
|
||||
}
|
||||
|
||||
get props(): LiveryDecalProps {
|
||||
return {
|
||||
id: this.id,
|
||||
imageUrl: this.imageUrl,
|
||||
x: this.x,
|
||||
y: this.y,
|
||||
width: this.width,
|
||||
height: this.height,
|
||||
rotation: this.rotation,
|
||||
zIndex: this.zIndex,
|
||||
type: this.type,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this decal overlaps with another
|
||||
*/
|
||||
overlapsWith(other: LiveryDecal): boolean {
|
||||
const thisRight = this.x + this.width;
|
||||
const thisBottom = this.y + this.height;
|
||||
const otherRight = other.x + other.width;
|
||||
const otherBottom = other.y + other.height;
|
||||
|
||||
return !(
|
||||
thisRight <= other.x ||
|
||||
this.x >= otherRight ||
|
||||
thisBottom <= other.y ||
|
||||
this.y >= otherBottom
|
||||
);
|
||||
}
|
||||
|
||||
equals(other: IValueObject<LiveryDecalProps>): boolean {
|
||||
const a = this.props;
|
||||
const b = other.props;
|
||||
return (
|
||||
a.id === b.id &&
|
||||
a.imageUrl === b.imageUrl &&
|
||||
a.x === b.x &&
|
||||
a.y === b.y &&
|
||||
a.width === b.width &&
|
||||
a.height === b.height &&
|
||||
a.rotation === b.rotation &&
|
||||
a.zIndex === b.zIndex &&
|
||||
a.type === b.type
|
||||
);
|
||||
}
|
||||
}
|
||||
90
core/racing/domain/value-objects/MembershipFee.ts
Normal file
90
core/racing/domain/value-objects/MembershipFee.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Value Object: MembershipFee
|
||||
* Represents membership fee configuration for league drivers
|
||||
*/
|
||||
|
||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||
|
||||
import type { Money } from './Money';
|
||||
import type { IValueObject } from '@gridpilot/shared/domain';
|
||||
|
||||
export type MembershipFeeType = 'season' | 'monthly' | 'per_race';
|
||||
|
||||
export interface MembershipFeeProps {
|
||||
type: MembershipFeeType;
|
||||
amount: Money;
|
||||
}
|
||||
|
||||
export class MembershipFee implements IValueObject<MembershipFeeProps> {
|
||||
readonly type: MembershipFeeType;
|
||||
readonly amount: Money;
|
||||
|
||||
private constructor(props: MembershipFeeProps) {
|
||||
this.type = props.type;
|
||||
this.amount = props.amount;
|
||||
}
|
||||
|
||||
static create(type: MembershipFeeType, amount: Money): MembershipFee {
|
||||
if (!type) {
|
||||
throw new RacingDomainValidationError('MembershipFee type is required');
|
||||
}
|
||||
|
||||
if (!amount) {
|
||||
throw new RacingDomainValidationError('MembershipFee amount is required');
|
||||
}
|
||||
|
||||
if (amount.amount < 0) {
|
||||
throw new RacingDomainValidationError('MembershipFee amount cannot be negative');
|
||||
}
|
||||
|
||||
return new MembershipFee({ type, amount });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get platform fee for this membership fee
|
||||
*/
|
||||
getPlatformFee(): Money {
|
||||
return this.amount.calculatePlatformFee();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get net amount after platform fee
|
||||
*/
|
||||
getNetAmount(): Money {
|
||||
return this.amount.calculateNetAmount();
|
||||
}
|
||||
|
||||
get props(): MembershipFeeProps {
|
||||
return {
|
||||
type: this.type,
|
||||
amount: this.amount,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is a recurring fee
|
||||
*/
|
||||
isRecurring(): boolean {
|
||||
return this.type === 'monthly';
|
||||
}
|
||||
|
||||
equals(other: IValueObject<MembershipFeeProps>): boolean {
|
||||
const a = this.props;
|
||||
const b = other.props;
|
||||
return a.type === b.type && a.amount.equals(b.amount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get display name for fee type
|
||||
*/
|
||||
getDisplayName(): string {
|
||||
switch (this.type) {
|
||||
case 'season':
|
||||
return 'Season Fee';
|
||||
case 'monthly':
|
||||
return 'Monthly Subscription';
|
||||
case 'per_race':
|
||||
return 'Per-Race Fee';
|
||||
}
|
||||
}
|
||||
}
|
||||
115
core/racing/domain/value-objects/Money.ts
Normal file
115
core/racing/domain/value-objects/Money.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Value Object: Money
|
||||
* Represents a monetary amount with currency and platform fee calculation
|
||||
*/
|
||||
|
||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||
import type { IValueObject } from '@gridpilot/shared/domain';
|
||||
|
||||
export type Currency = 'USD' | 'EUR' | 'GBP';
|
||||
|
||||
export interface MoneyProps {
|
||||
amount: number;
|
||||
currency: Currency;
|
||||
}
|
||||
|
||||
export class Money implements IValueObject<MoneyProps> {
|
||||
private static readonly PLATFORM_FEE_PERCENTAGE = 0.10;
|
||||
|
||||
readonly amount: number;
|
||||
readonly currency: Currency;
|
||||
|
||||
private constructor(amount: number, currency: Currency) {
|
||||
this.amount = amount;
|
||||
this.currency = currency;
|
||||
}
|
||||
|
||||
static create(amount: number, currency: Currency = 'USD'): Money {
|
||||
if (amount < 0) {
|
||||
throw new RacingDomainValidationError('Money amount cannot be negative');
|
||||
}
|
||||
if (!Number.isFinite(amount)) {
|
||||
throw new RacingDomainValidationError('Money amount must be a finite number');
|
||||
}
|
||||
return new Money(amount, currency);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate platform fee (10%)
|
||||
*/
|
||||
calculatePlatformFee(): Money {
|
||||
const feeAmount = this.amount * Money.PLATFORM_FEE_PERCENTAGE;
|
||||
return new Money(feeAmount, this.currency);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate net amount after platform fee
|
||||
*/
|
||||
calculateNetAmount(): Money {
|
||||
const platformFee = this.calculatePlatformFee();
|
||||
return new Money(this.amount - platformFee.amount, this.currency);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add two money amounts
|
||||
*/
|
||||
add(other: Money): Money {
|
||||
if (this.currency !== other.currency) {
|
||||
throw new RacingDomainValidationError('Cannot add money with different currencies');
|
||||
}
|
||||
return new Money(this.amount + other.amount, this.currency);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subtract two money amounts
|
||||
*/
|
||||
subtract(other: Money): Money {
|
||||
if (this.currency !== other.currency) {
|
||||
throw new RacingDomainValidationError('Cannot subtract money with different currencies');
|
||||
}
|
||||
const result = this.amount - other.amount;
|
||||
if (result < 0) {
|
||||
throw new RacingDomainValidationError('Subtraction would result in negative amount');
|
||||
}
|
||||
return new Money(result, this.currency);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this money is greater than another
|
||||
*/
|
||||
isGreaterThan(other: Money): boolean {
|
||||
if (this.currency !== other.currency) {
|
||||
throw new RacingDomainValidationError('Cannot compare money with different currencies');
|
||||
}
|
||||
return this.amount > other.amount;
|
||||
}
|
||||
|
||||
get props(): MoneyProps {
|
||||
return {
|
||||
amount: this.amount,
|
||||
currency: this.currency,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this money equals another
|
||||
*/
|
||||
equals(other: IValueObject<MoneyProps>): boolean {
|
||||
const a = this.props;
|
||||
const b = other.props;
|
||||
return a.amount === b.amount && a.currency === b.currency;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format money for display
|
||||
*/
|
||||
format(): string {
|
||||
const formatter = new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: this.currency,
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
return formatter.format(this.amount);
|
||||
}
|
||||
}
|
||||
40
core/racing/domain/value-objects/MonthlyRecurrencePattern.ts
Normal file
40
core/racing/domain/value-objects/MonthlyRecurrencePattern.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { Weekday } from '../types/Weekday';
|
||||
import type { IValueObject } from '@gridpilot/shared/domain';
|
||||
|
||||
export interface MonthlyRecurrencePatternProps {
|
||||
ordinal: 1 | 2 | 3 | 4;
|
||||
weekday: Weekday;
|
||||
}
|
||||
|
||||
export class MonthlyRecurrencePattern implements IValueObject<MonthlyRecurrencePatternProps> {
|
||||
readonly ordinal: 1 | 2 | 3 | 4;
|
||||
readonly weekday: Weekday;
|
||||
|
||||
constructor(ordinal: 1 | 2 | 3 | 4, weekday: Weekday);
|
||||
constructor(props: MonthlyRecurrencePatternProps);
|
||||
constructor(
|
||||
ordinalOrProps: 1 | 2 | 3 | 4 | MonthlyRecurrencePatternProps,
|
||||
weekday?: Weekday,
|
||||
) {
|
||||
if (typeof ordinalOrProps === 'object') {
|
||||
this.ordinal = ordinalOrProps.ordinal;
|
||||
this.weekday = ordinalOrProps.weekday;
|
||||
} else {
|
||||
this.ordinal = ordinalOrProps;
|
||||
this.weekday = weekday as Weekday;
|
||||
}
|
||||
}
|
||||
|
||||
get props(): MonthlyRecurrencePatternProps {
|
||||
return {
|
||||
ordinal: this.ordinal,
|
||||
weekday: this.weekday,
|
||||
};
|
||||
}
|
||||
|
||||
equals(other: IValueObject<MonthlyRecurrencePatternProps>): boolean {
|
||||
const a = this.props;
|
||||
const b = other.props;
|
||||
return a.ordinal === b.ordinal && a.weekday === b.weekday;
|
||||
}
|
||||
}
|
||||
46
core/racing/domain/value-objects/PointsTable.ts
Normal file
46
core/racing/domain/value-objects/PointsTable.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { IValueObject } from '@gridpilot/shared/domain';
|
||||
|
||||
export interface PointsTableProps {
|
||||
pointsByPosition: Map<number, number>;
|
||||
}
|
||||
|
||||
export class PointsTable implements IValueObject<PointsTableProps> {
|
||||
private readonly pointsByPosition: Map<number, number>;
|
||||
|
||||
constructor(pointsByPosition: Record<number, number> | Map<number, number>) {
|
||||
if (pointsByPosition instanceof Map) {
|
||||
this.pointsByPosition = new Map(pointsByPosition);
|
||||
} else {
|
||||
this.pointsByPosition = new Map(
|
||||
Object.entries(pointsByPosition).map(([key, value]) => [Number(key), value]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
getPointsForPosition(position: number): number {
|
||||
if (!Number.isInteger(position) || position < 1) {
|
||||
return 0;
|
||||
}
|
||||
const value = this.pointsByPosition.get(position);
|
||||
return typeof value === 'number' ? value : 0;
|
||||
}
|
||||
|
||||
get props(): PointsTableProps {
|
||||
return {
|
||||
pointsByPosition: new Map(this.pointsByPosition),
|
||||
};
|
||||
}
|
||||
|
||||
equals(other: IValueObject<PointsTableProps>): boolean {
|
||||
const a = this.props.pointsByPosition;
|
||||
const b = other.props.pointsByPosition;
|
||||
|
||||
if (a.size !== b.size) return false;
|
||||
for (const [key, value] of a.entries()) {
|
||||
if (b.get(key) !== value) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
239
core/racing/domain/value-objects/RaceIncidents.ts
Normal file
239
core/racing/domain/value-objects/RaceIncidents.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
import type { IValueObject } from '@gridpilot/shared/domain';
|
||||
|
||||
/**
|
||||
* Incident types that can occur during a race
|
||||
*/
|
||||
export type IncidentType =
|
||||
| 'track_limits' // Driver went off track and gained advantage
|
||||
| 'contact' // Physical contact with another car
|
||||
| 'unsafe_rejoin' // Unsafe rejoining of the track
|
||||
| 'aggressive_driving' // Aggressive defensive or overtaking maneuvers
|
||||
| 'false_start' // Started before green flag
|
||||
| 'collision' // Major collision involving multiple cars
|
||||
| 'spin' // Driver spun out
|
||||
| 'mechanical' // Mechanical failure (not driver error)
|
||||
| 'other'; // Other incident types
|
||||
|
||||
/**
|
||||
* Individual incident record
|
||||
*/
|
||||
export interface IncidentRecord {
|
||||
type: IncidentType;
|
||||
lap: number;
|
||||
description?: string;
|
||||
penaltyPoints?: number; // Points deducted for this incident
|
||||
}
|
||||
|
||||
/**
|
||||
* Value Object: RaceIncidents
|
||||
*
|
||||
* Encapsulates all incidents that occurred during a driver's race.
|
||||
* Provides methods to calculate total penalty points and incident severity.
|
||||
*/
|
||||
export class RaceIncidents implements IValueObject<IncidentRecord[]> {
|
||||
private readonly incidents: IncidentRecord[];
|
||||
|
||||
constructor(incidents: IncidentRecord[] = []) {
|
||||
this.incidents = [...incidents];
|
||||
}
|
||||
|
||||
get props(): IncidentRecord[] {
|
||||
return [...this.incidents];
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new incident
|
||||
*/
|
||||
addIncident(incident: IncidentRecord): RaceIncidents {
|
||||
return new RaceIncidents([...this.incidents, incident]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all incidents
|
||||
*/
|
||||
getAllIncidents(): IncidentRecord[] {
|
||||
return [...this.incidents];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total number of incidents
|
||||
*/
|
||||
getTotalCount(): number {
|
||||
return this.incidents.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total penalty points from all incidents
|
||||
*/
|
||||
getTotalPenaltyPoints(): number {
|
||||
return this.incidents.reduce((total, incident) => total + (incident.penaltyPoints || 0), 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get incidents by type
|
||||
*/
|
||||
getIncidentsByType(type: IncidentType): IncidentRecord[] {
|
||||
return this.incidents.filter(incident => incident.type === type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if driver had any incidents
|
||||
*/
|
||||
hasIncidents(): boolean {
|
||||
return this.incidents.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if driver had a clean race (no incidents)
|
||||
*/
|
||||
isClean(): boolean {
|
||||
return this.incidents.length === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get incident severity score (0-100, higher = more severe)
|
||||
*/
|
||||
getSeverityScore(): number {
|
||||
if (this.incidents.length === 0) return 0;
|
||||
|
||||
const severityWeights: Record<IncidentType, number> = {
|
||||
track_limits: 10,
|
||||
contact: 20,
|
||||
unsafe_rejoin: 25,
|
||||
aggressive_driving: 15,
|
||||
false_start: 30,
|
||||
collision: 40,
|
||||
spin: 35,
|
||||
mechanical: 5, // Lower weight as it's not driver error
|
||||
other: 15,
|
||||
};
|
||||
|
||||
const totalSeverity = this.incidents.reduce((total, incident) => {
|
||||
return total + severityWeights[incident.type];
|
||||
}, 0);
|
||||
|
||||
// Normalize to 0-100 scale (cap at 100 for very incident-heavy races)
|
||||
return Math.min(100, totalSeverity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get human-readable incident summary
|
||||
*/
|
||||
getSummary(): string {
|
||||
if (this.incidents.length === 0) {
|
||||
return 'Clean race';
|
||||
}
|
||||
|
||||
const typeCounts = this.incidents.reduce((counts, incident) => {
|
||||
counts[incident.type] = (counts[incident.type] || 0) + 1;
|
||||
return counts;
|
||||
}, {} as Record<IncidentType, number>);
|
||||
|
||||
const summaryParts = Object.entries(typeCounts).map(([type, count]) => {
|
||||
const typeLabel = this.getIncidentTypeLabel(type as IncidentType);
|
||||
return count > 1 ? `${count}x ${typeLabel}` : typeLabel;
|
||||
});
|
||||
|
||||
return summaryParts.join(', ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get human-readable label for incident type
|
||||
*/
|
||||
private getIncidentTypeLabel(type: IncidentType): string {
|
||||
const labels: Record<IncidentType, string> = {
|
||||
track_limits: 'Track Limits',
|
||||
contact: 'Contact',
|
||||
unsafe_rejoin: 'Unsafe Rejoin',
|
||||
aggressive_driving: 'Aggressive Driving',
|
||||
false_start: 'False Start',
|
||||
collision: 'Collision',
|
||||
spin: 'Spin',
|
||||
mechanical: 'Mechanical',
|
||||
other: 'Other',
|
||||
};
|
||||
return labels[type];
|
||||
}
|
||||
|
||||
equals(other: IValueObject<IncidentRecord[]>): boolean {
|
||||
const otherIncidents = other.props;
|
||||
if (this.incidents.length !== otherIncidents.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Sort both arrays and compare
|
||||
const sortedThis = [...this.incidents].sort((a, b) => a.lap - b.lap);
|
||||
const sortedOther = [...otherIncidents].sort((a, b) => a.lap - b.lap);
|
||||
|
||||
return sortedThis.every((incident, index) => {
|
||||
const otherIncident = sortedOther[index];
|
||||
return incident.type === otherIncident.type &&
|
||||
incident.lap === otherIncident.lap &&
|
||||
incident.description === otherIncident.description &&
|
||||
incident.penaltyPoints === otherIncident.penaltyPoints;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create RaceIncidents from legacy incidents count
|
||||
*/
|
||||
static fromLegacyIncidentsCount(count: number): RaceIncidents {
|
||||
if (count === 0) {
|
||||
return new RaceIncidents();
|
||||
}
|
||||
|
||||
// Distribute legacy incidents across different types based on probability
|
||||
const incidents: IncidentRecord[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const type = RaceIncidents.getRandomIncidentType();
|
||||
incidents.push({
|
||||
type,
|
||||
lap: Math.floor(Math.random() * 20) + 1, // Random lap 1-20
|
||||
penaltyPoints: RaceIncidents.getDefaultPenaltyPoints(type),
|
||||
});
|
||||
}
|
||||
|
||||
return new RaceIncidents(incidents);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get random incident type for legacy data conversion
|
||||
*/
|
||||
private static getRandomIncidentType(): IncidentType {
|
||||
const types: IncidentType[] = [
|
||||
'track_limits', 'contact', 'unsafe_rejoin', 'aggressive_driving',
|
||||
'collision', 'spin', 'other'
|
||||
];
|
||||
const weights = [0.4, 0.25, 0.15, 0.1, 0.05, 0.04, 0.01]; // Probability weights
|
||||
|
||||
const random = Math.random();
|
||||
let cumulativeWeight = 0;
|
||||
|
||||
for (let i = 0; i < types.length; i++) {
|
||||
cumulativeWeight += weights[i];
|
||||
if (random <= cumulativeWeight) {
|
||||
return types[i];
|
||||
}
|
||||
}
|
||||
|
||||
return 'other';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default penalty points for incident type
|
||||
*/
|
||||
private static getDefaultPenaltyPoints(type: IncidentType): number {
|
||||
const penalties: Record<IncidentType, number> = {
|
||||
track_limits: 0, // Usually just a warning
|
||||
contact: 2,
|
||||
unsafe_rejoin: 3,
|
||||
aggressive_driving: 2,
|
||||
false_start: 5,
|
||||
collision: 5,
|
||||
spin: 0, // Usually no penalty if no contact
|
||||
mechanical: 0,
|
||||
other: 2,
|
||||
};
|
||||
return penalties[type];
|
||||
}
|
||||
}
|
||||
55
core/racing/domain/value-objects/RaceTimeOfDay.ts
Normal file
55
core/racing/domain/value-objects/RaceTimeOfDay.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||
import type { IValueObject } from '@gridpilot/shared/domain';
|
||||
|
||||
export interface RaceTimeOfDayProps {
|
||||
hour: number;
|
||||
minute: number;
|
||||
}
|
||||
|
||||
export class RaceTimeOfDay implements IValueObject<RaceTimeOfDayProps> {
|
||||
readonly hour: number;
|
||||
readonly minute: number;
|
||||
|
||||
constructor(hour: number, minute: number) {
|
||||
if (!Number.isInteger(hour) || hour < 0 || hour > 23) {
|
||||
throw new RacingDomainValidationError(`RaceTimeOfDay hour must be between 0 and 23, got ${hour}`);
|
||||
}
|
||||
if (!Number.isInteger(minute) || minute < 0 || minute > 59) {
|
||||
throw new RacingDomainValidationError(`RaceTimeOfDay minute must be between 0 and 59, got ${minute}`);
|
||||
}
|
||||
|
||||
this.hour = hour;
|
||||
this.minute = minute;
|
||||
}
|
||||
|
||||
static fromString(value: string): RaceTimeOfDay {
|
||||
const match = /^(\d{2}):(\d{2})$/.exec(value);
|
||||
if (!match) {
|
||||
throw new RacingDomainValidationError(`RaceTimeOfDay string must be in HH:MM 24h format, got "${value}"`);
|
||||
}
|
||||
|
||||
const hour = Number(match[1]);
|
||||
const minute = Number(match[2]);
|
||||
|
||||
return new RaceTimeOfDay(hour, minute);
|
||||
}
|
||||
|
||||
get props(): RaceTimeOfDayProps {
|
||||
return {
|
||||
hour: this.hour,
|
||||
minute: this.minute,
|
||||
};
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
const hh = this.hour.toString().padStart(2, '0');
|
||||
const mm = this.minute.toString().padStart(2, '0');
|
||||
return `${hh}:${mm}`;
|
||||
}
|
||||
|
||||
equals(other: IValueObject<RaceTimeOfDayProps>): boolean {
|
||||
const a = this.props;
|
||||
const b = other.props;
|
||||
return a.hour === b.hour && a.minute === b.minute;
|
||||
}
|
||||
}
|
||||
59
core/racing/domain/value-objects/RecurrenceStrategy.ts
Normal file
59
core/racing/domain/value-objects/RecurrenceStrategy.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { WeekdaySet } from './WeekdaySet';
|
||||
import { MonthlyRecurrencePattern } from './MonthlyRecurrencePattern';
|
||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||
|
||||
export type WeeklyRecurrenceStrategy = {
|
||||
kind: 'weekly';
|
||||
weekdays: WeekdaySet;
|
||||
};
|
||||
|
||||
export type EveryNWeeksRecurrenceStrategy = {
|
||||
kind: 'everyNWeeks';
|
||||
weekdays: WeekdaySet;
|
||||
intervalWeeks: number;
|
||||
};
|
||||
|
||||
export type MonthlyNthWeekdayRecurrenceStrategy = {
|
||||
kind: 'monthlyNthWeekday';
|
||||
monthlyPattern: MonthlyRecurrencePattern;
|
||||
};
|
||||
|
||||
export type RecurrenceStrategy =
|
||||
| WeeklyRecurrenceStrategy
|
||||
| EveryNWeeksRecurrenceStrategy
|
||||
| MonthlyNthWeekdayRecurrenceStrategy;
|
||||
|
||||
export class RecurrenceStrategyFactory {
|
||||
static weekly(weekdays: WeekdaySet): RecurrenceStrategy {
|
||||
if (weekdays.getAll().length === 0) {
|
||||
throw new RacingDomainValidationError('weekdays are required for weekly recurrence');
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'weekly',
|
||||
weekdays,
|
||||
};
|
||||
}
|
||||
|
||||
static everyNWeeks(intervalWeeks: number, weekdays: WeekdaySet): RecurrenceStrategy {
|
||||
if (!Number.isInteger(intervalWeeks) || intervalWeeks <= 0) {
|
||||
throw new RacingDomainValidationError('intervalWeeks must be a positive integer');
|
||||
}
|
||||
if (weekdays.getAll().length === 0) {
|
||||
throw new RacingDomainValidationError('weekdays are required for everyNWeeks recurrence');
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'everyNWeeks',
|
||||
weekdays,
|
||||
intervalWeeks,
|
||||
};
|
||||
}
|
||||
|
||||
static monthlyNthWeekday(pattern: MonthlyRecurrencePattern): RecurrenceStrategy {
|
||||
return {
|
||||
kind: 'monthlyNthWeekday',
|
||||
monthlyPattern: pattern,
|
||||
};
|
||||
}
|
||||
}
|
||||
47
core/racing/domain/value-objects/ScheduledRaceSlot.ts
Normal file
47
core/racing/domain/value-objects/ScheduledRaceSlot.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { LeagueTimezone } from './LeagueTimezone';
|
||||
|
||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||
import type { IValueObject } from '@gridpilot/shared/domain';
|
||||
|
||||
export interface ScheduledRaceSlotProps {
|
||||
roundNumber: number;
|
||||
scheduledAt: Date;
|
||||
timezone: LeagueTimezone;
|
||||
}
|
||||
|
||||
export class ScheduledRaceSlot implements IValueObject<ScheduledRaceSlotProps> {
|
||||
readonly roundNumber: number;
|
||||
readonly scheduledAt: Date;
|
||||
readonly timezone: LeagueTimezone;
|
||||
|
||||
constructor(params: { roundNumber: number; scheduledAt: Date; timezone: LeagueTimezone }) {
|
||||
if (!Number.isInteger(params.roundNumber) || params.roundNumber <= 0) {
|
||||
throw new RacingDomainValidationError('ScheduledRaceSlot.roundNumber must be a positive integer');
|
||||
}
|
||||
if (!(params.scheduledAt instanceof Date) || Number.isNaN(params.scheduledAt.getTime())) {
|
||||
throw new RacingDomainValidationError('ScheduledRaceSlot.scheduledAt must be a valid Date');
|
||||
}
|
||||
|
||||
this.roundNumber = params.roundNumber;
|
||||
this.scheduledAt = params.scheduledAt;
|
||||
this.timezone = params.timezone;
|
||||
}
|
||||
|
||||
get props(): ScheduledRaceSlotProps {
|
||||
return {
|
||||
roundNumber: this.roundNumber,
|
||||
scheduledAt: this.scheduledAt,
|
||||
timezone: this.timezone,
|
||||
};
|
||||
}
|
||||
|
||||
equals(other: IValueObject<ScheduledRaceSlotProps>): boolean {
|
||||
const a = this.props;
|
||||
const b = other.props;
|
||||
return (
|
||||
a.roundNumber === b.roundNumber &&
|
||||
a.scheduledAt.getTime() === b.scheduledAt.getTime() &&
|
||||
a.timezone.equals(b.timezone)
|
||||
);
|
||||
}
|
||||
}
|
||||
59
core/racing/domain/value-objects/SeasonDropPolicy.ts
Normal file
59
core/racing/domain/value-objects/SeasonDropPolicy.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||
import type { IValueObject } from '@gridpilot/shared/domain';
|
||||
|
||||
export type SeasonDropStrategy = 'none' | 'bestNResults' | 'dropWorstN';
|
||||
|
||||
export interface SeasonDropPolicyProps {
|
||||
strategy: SeasonDropStrategy;
|
||||
/**
|
||||
* Number of results to consider for strategies that require a count.
|
||||
* - bestNResults: keep best N
|
||||
* - dropWorstN: drop worst N
|
||||
*/
|
||||
n?: number;
|
||||
}
|
||||
|
||||
export class SeasonDropPolicy implements IValueObject<SeasonDropPolicyProps> {
|
||||
readonly strategy: SeasonDropStrategy;
|
||||
readonly n?: number;
|
||||
|
||||
constructor(props: SeasonDropPolicyProps) {
|
||||
if (!props.strategy) {
|
||||
throw new RacingDomainValidationError(
|
||||
'SeasonDropPolicy.strategy is required',
|
||||
);
|
||||
}
|
||||
|
||||
if (props.strategy === 'bestNResults' || props.strategy === 'dropWorstN') {
|
||||
if (props.n === undefined || !Number.isInteger(props.n) || props.n <= 0) {
|
||||
throw new RacingDomainValidationError(
|
||||
'SeasonDropPolicy.n must be a positive integer when using bestNResults or dropWorstN',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (props.strategy === 'none' && props.n !== undefined) {
|
||||
throw new RacingDomainValidationError(
|
||||
'SeasonDropPolicy.n must be undefined when strategy is none',
|
||||
);
|
||||
}
|
||||
|
||||
this.strategy = props.strategy;
|
||||
if (props.n !== undefined) {
|
||||
this.n = props.n;
|
||||
}
|
||||
}
|
||||
|
||||
get props(): SeasonDropPolicyProps {
|
||||
return {
|
||||
strategy: this.strategy,
|
||||
...(this.n !== undefined ? { n: this.n } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
equals(other: IValueObject<SeasonDropPolicyProps>): boolean {
|
||||
const a = this.props;
|
||||
const b = other.props;
|
||||
return a.strategy === b.strategy && a.n === b.n;
|
||||
}
|
||||
}
|
||||
68
core/racing/domain/value-objects/SeasonSchedule.ts
Normal file
68
core/racing/domain/value-objects/SeasonSchedule.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { RaceTimeOfDay } from './RaceTimeOfDay';
|
||||
import { LeagueTimezone } from './LeagueTimezone';
|
||||
import type { RecurrenceStrategy } from './RecurrenceStrategy';
|
||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||
import type { IValueObject } from '@gridpilot/shared/domain';
|
||||
|
||||
export interface SeasonScheduleProps {
|
||||
startDate: Date;
|
||||
timeOfDay: RaceTimeOfDay;
|
||||
timezone: LeagueTimezone;
|
||||
recurrence: RecurrenceStrategy;
|
||||
plannedRounds: number;
|
||||
}
|
||||
|
||||
export class SeasonSchedule implements IValueObject<SeasonScheduleProps> {
|
||||
readonly startDate: Date;
|
||||
readonly timeOfDay: RaceTimeOfDay;
|
||||
readonly timezone: LeagueTimezone;
|
||||
readonly recurrence: RecurrenceStrategy;
|
||||
readonly plannedRounds: number;
|
||||
|
||||
constructor(params: {
|
||||
startDate: Date;
|
||||
timeOfDay: RaceTimeOfDay;
|
||||
timezone: LeagueTimezone;
|
||||
recurrence: RecurrenceStrategy;
|
||||
plannedRounds: number;
|
||||
}) {
|
||||
if (!(params.startDate instanceof Date) || Number.isNaN(params.startDate.getTime())) {
|
||||
throw new RacingDomainValidationError('SeasonSchedule.startDate must be a valid Date');
|
||||
}
|
||||
if (!Number.isInteger(params.plannedRounds) || params.plannedRounds <= 0) {
|
||||
throw new RacingDomainValidationError('SeasonSchedule.plannedRounds must be a positive integer');
|
||||
}
|
||||
|
||||
this.startDate = new Date(
|
||||
params.startDate.getFullYear(),
|
||||
params.startDate.getMonth(),
|
||||
params.startDate.getDate(),
|
||||
);
|
||||
this.timeOfDay = params.timeOfDay;
|
||||
this.timezone = params.timezone;
|
||||
this.recurrence = params.recurrence;
|
||||
this.plannedRounds = params.plannedRounds;
|
||||
}
|
||||
|
||||
get props(): SeasonScheduleProps {
|
||||
return {
|
||||
startDate: this.startDate,
|
||||
timeOfDay: this.timeOfDay,
|
||||
timezone: this.timezone,
|
||||
recurrence: this.recurrence,
|
||||
plannedRounds: this.plannedRounds,
|
||||
};
|
||||
}
|
||||
|
||||
equals(other: IValueObject<SeasonScheduleProps>): boolean {
|
||||
const a = this.props;
|
||||
const b = other.props;
|
||||
return (
|
||||
a.startDate.getTime() === b.startDate.getTime() &&
|
||||
a.timeOfDay.equals(b.timeOfDay) &&
|
||||
a.timezone.equals(b.timezone) &&
|
||||
a.recurrence.kind === b.recurrence.kind &&
|
||||
a.plannedRounds === b.plannedRounds
|
||||
);
|
||||
}
|
||||
}
|
||||
66
core/racing/domain/value-objects/SeasonScoringConfig.ts
Normal file
66
core/racing/domain/value-objects/SeasonScoringConfig.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||
import type { IValueObject } from '@gridpilot/shared/domain';
|
||||
|
||||
/**
|
||||
* Value Object: SeasonScoringConfig
|
||||
*
|
||||
* Represents the scoring configuration owned by a Season.
|
||||
* It is intentionally lightweight and primarily captures which
|
||||
* preset (or custom mode) is applied for this Season.
|
||||
*
|
||||
* Detailed championship scoring rules are still modeled via
|
||||
* `LeagueScoringConfig` and related types.
|
||||
*/
|
||||
export interface SeasonScoringConfigProps {
|
||||
/**
|
||||
* Identifier of the scoring preset applied to this Season.
|
||||
* Examples:
|
||||
* - 'sprint-main-driver'
|
||||
* - 'club-default'
|
||||
* - 'endurance-main-double'
|
||||
* - 'custom'
|
||||
*/
|
||||
scoringPresetId: string;
|
||||
|
||||
/**
|
||||
* Whether the Season uses custom scoring rather than a pure preset.
|
||||
* When true, `scoringPresetId` acts as a label rather than a strict preset key.
|
||||
*/
|
||||
customScoringEnabled?: boolean;
|
||||
}
|
||||
|
||||
export class SeasonScoringConfig
|
||||
implements IValueObject<SeasonScoringConfigProps>
|
||||
{
|
||||
readonly scoringPresetId: string;
|
||||
readonly customScoringEnabled: boolean;
|
||||
|
||||
constructor(params: SeasonScoringConfigProps) {
|
||||
if (!params.scoringPresetId || params.scoringPresetId.trim().length === 0) {
|
||||
throw new RacingDomainValidationError(
|
||||
'SeasonScoringConfig.scoringPresetId must be a non-empty string',
|
||||
);
|
||||
}
|
||||
|
||||
this.scoringPresetId = params.scoringPresetId.trim();
|
||||
this.customScoringEnabled = Boolean(params.customScoringEnabled);
|
||||
}
|
||||
|
||||
get props(): SeasonScoringConfigProps {
|
||||
return {
|
||||
scoringPresetId: this.scoringPresetId,
|
||||
...(this.customScoringEnabled
|
||||
? { customScoringEnabled: this.customScoringEnabled }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
equals(other: IValueObject<SeasonScoringConfigProps>): boolean {
|
||||
const a = this.props;
|
||||
const b = other.props;
|
||||
return (
|
||||
a.scoringPresetId === b.scoringPresetId &&
|
||||
Boolean(a.customScoringEnabled) === Boolean(b.customScoringEnabled)
|
||||
);
|
||||
}
|
||||
}
|
||||
142
core/racing/domain/value-objects/SeasonStewardingConfig.ts
Normal file
142
core/racing/domain/value-objects/SeasonStewardingConfig.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||
import type { IValueObject } from '@gridpilot/shared/domain';
|
||||
import type { StewardingDecisionMode } from '../entities/League';
|
||||
|
||||
export interface SeasonStewardingConfigProps {
|
||||
decisionMode: StewardingDecisionMode;
|
||||
requiredVotes?: number | undefined;
|
||||
requireDefense: boolean;
|
||||
defenseTimeLimit: number;
|
||||
voteTimeLimit: number;
|
||||
protestDeadlineHours: number;
|
||||
stewardingClosesHours: number;
|
||||
notifyAccusedOnProtest: boolean;
|
||||
notifyOnVoteRequired: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Value Object: SeasonStewardingConfig
|
||||
*
|
||||
* Encapsulates stewarding configuration owned by a Season.
|
||||
* Shape intentionally mirrors LeagueStewardingFormDTO used by the wizard.
|
||||
*/
|
||||
export class SeasonStewardingConfig
|
||||
implements IValueObject<SeasonStewardingConfigProps>
|
||||
{
|
||||
readonly decisionMode: StewardingDecisionMode;
|
||||
readonly requiredVotes?: number;
|
||||
readonly requireDefense: boolean;
|
||||
readonly defenseTimeLimit: number;
|
||||
readonly voteTimeLimit: number;
|
||||
readonly protestDeadlineHours: number;
|
||||
readonly stewardingClosesHours: number;
|
||||
readonly notifyAccusedOnProtest: boolean;
|
||||
readonly notifyOnVoteRequired: boolean;
|
||||
|
||||
constructor(props: SeasonStewardingConfigProps) {
|
||||
if (!props.decisionMode) {
|
||||
throw new RacingDomainValidationError(
|
||||
'SeasonStewardingConfig.decisionMode is required',
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
(props.decisionMode === 'steward_vote' ||
|
||||
props.decisionMode === 'member_vote' ||
|
||||
props.decisionMode === 'steward_veto' ||
|
||||
props.decisionMode === 'member_veto') &&
|
||||
(props.requiredVotes === undefined ||
|
||||
!Number.isInteger(props.requiredVotes) ||
|
||||
props.requiredVotes <= 0)
|
||||
) {
|
||||
throw new RacingDomainValidationError(
|
||||
'SeasonStewardingConfig.requiredVotes must be a positive integer for voting/veto modes',
|
||||
);
|
||||
}
|
||||
|
||||
// For non-voting modes, requiredVotes should not be provided
|
||||
if (props.decisionMode !== 'steward_vote' &&
|
||||
props.decisionMode !== 'member_vote' &&
|
||||
props.decisionMode !== 'steward_veto' &&
|
||||
props.decisionMode !== 'member_veto' &&
|
||||
props.requiredVotes !== undefined) {
|
||||
throw new RacingDomainValidationError(
|
||||
'SeasonStewardingConfig.requiredVotes should only be provided for voting/veto modes',
|
||||
);
|
||||
}
|
||||
|
||||
if (!Number.isInteger(props.defenseTimeLimit) || props.defenseTimeLimit < 0) {
|
||||
throw new RacingDomainValidationError(
|
||||
'SeasonStewardingConfig.defenseTimeLimit must be a non-negative integer (hours)',
|
||||
);
|
||||
}
|
||||
|
||||
if (!Number.isInteger(props.voteTimeLimit) || props.voteTimeLimit <= 0) {
|
||||
throw new RacingDomainValidationError(
|
||||
'SeasonStewardingConfig.voteTimeLimit must be a positive integer (hours)',
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
!Number.isInteger(props.protestDeadlineHours) ||
|
||||
props.protestDeadlineHours <= 0
|
||||
) {
|
||||
throw new RacingDomainValidationError(
|
||||
'SeasonStewardingConfig.protestDeadlineHours must be a positive integer (hours)',
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
!Number.isInteger(props.stewardingClosesHours) ||
|
||||
props.stewardingClosesHours <= 0
|
||||
) {
|
||||
throw new RacingDomainValidationError(
|
||||
'SeasonStewardingConfig.stewardingClosesHours must be a positive integer (hours)',
|
||||
);
|
||||
}
|
||||
|
||||
this.decisionMode = props.decisionMode;
|
||||
if (props.requiredVotes !== undefined) {
|
||||
this.requiredVotes = props.requiredVotes;
|
||||
}
|
||||
this.requireDefense = props.requireDefense;
|
||||
this.defenseTimeLimit = props.defenseTimeLimit;
|
||||
this.voteTimeLimit = props.voteTimeLimit;
|
||||
this.protestDeadlineHours = props.protestDeadlineHours;
|
||||
this.stewardingClosesHours = props.stewardingClosesHours;
|
||||
this.notifyAccusedOnProtest = props.notifyAccusedOnProtest;
|
||||
this.notifyOnVoteRequired = props.notifyOnVoteRequired;
|
||||
}
|
||||
|
||||
get props(): SeasonStewardingConfigProps {
|
||||
return {
|
||||
decisionMode: this.decisionMode,
|
||||
...(this.requiredVotes !== undefined
|
||||
? { requiredVotes: this.requiredVotes }
|
||||
: {}),
|
||||
requireDefense: this.requireDefense,
|
||||
defenseTimeLimit: this.defenseTimeLimit,
|
||||
voteTimeLimit: this.voteTimeLimit,
|
||||
protestDeadlineHours: this.protestDeadlineHours,
|
||||
stewardingClosesHours: this.stewardingClosesHours,
|
||||
notifyAccusedOnProtest: this.notifyAccusedOnProtest,
|
||||
notifyOnVoteRequired: this.notifyOnVoteRequired,
|
||||
};
|
||||
}
|
||||
|
||||
equals(other: IValueObject<SeasonStewardingConfigProps>): boolean {
|
||||
const a = this.props;
|
||||
const b = other.props;
|
||||
return (
|
||||
a.decisionMode === b.decisionMode &&
|
||||
a.requiredVotes === b.requiredVotes &&
|
||||
a.requireDefense === b.requireDefense &&
|
||||
a.defenseTimeLimit === b.defenseTimeLimit &&
|
||||
a.voteTimeLimit === b.voteTimeLimit &&
|
||||
a.protestDeadlineHours === b.protestDeadlineHours &&
|
||||
a.stewardingClosesHours === b.stewardingClosesHours &&
|
||||
a.notifyAccusedOnProtest === b.notifyAccusedOnProtest &&
|
||||
a.notifyOnVoteRequired === b.notifyOnVoteRequired
|
||||
);
|
||||
}
|
||||
}
|
||||
103
core/racing/domain/value-objects/SessionType.ts
Normal file
103
core/racing/domain/value-objects/SessionType.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||
import type { IValueObject } from '@gridpilot/shared/domain';
|
||||
|
||||
/**
|
||||
* Value Object: SessionType
|
||||
*
|
||||
* Represents the type of racing session within a race event.
|
||||
* Immutable value object with domain validation.
|
||||
*/
|
||||
export type SessionTypeValue = 'practice' | 'qualifying' | 'q1' | 'q2' | 'q3' | 'sprint' | 'main' | 'timeTrial';
|
||||
|
||||
export class SessionType implements IValueObject<SessionTypeValue> {
|
||||
readonly value: SessionTypeValue;
|
||||
|
||||
constructor(value: SessionTypeValue) {
|
||||
if (!value || !this.isValidSessionType(value)) {
|
||||
throw new RacingDomainValidationError(`Invalid session type: ${value}`);
|
||||
}
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
private isValidSessionType(value: string): value is SessionTypeValue {
|
||||
const validTypes: SessionTypeValue[] = ['practice', 'qualifying', 'q1', 'q2', 'q3', 'sprint', 'main', 'timeTrial'];
|
||||
return validTypes.includes(value as SessionTypeValue);
|
||||
}
|
||||
|
||||
get props(): SessionTypeValue {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
equals(other: IValueObject<SessionTypeValue>): boolean {
|
||||
return this.value === other.props;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this session type counts for championship points
|
||||
*/
|
||||
countsForPoints(): boolean {
|
||||
return this.value === 'main' || this.value === 'sprint';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this session type determines grid positions
|
||||
*/
|
||||
determinesGrid(): boolean {
|
||||
return this.value === 'qualifying' || this.value.startsWith('q');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get human-readable display name
|
||||
*/
|
||||
getDisplayName(): string {
|
||||
const names: Record<SessionTypeValue, string> = {
|
||||
practice: 'Practice',
|
||||
qualifying: 'Qualifying',
|
||||
q1: 'Q1',
|
||||
q2: 'Q2',
|
||||
q3: 'Q3',
|
||||
sprint: 'Sprint Race',
|
||||
main: 'Main Race',
|
||||
timeTrial: 'Time Trial',
|
||||
};
|
||||
return names[this.value];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get short display name for UI
|
||||
*/
|
||||
getShortName(): string {
|
||||
const names: Record<SessionTypeValue, string> = {
|
||||
practice: 'P',
|
||||
qualifying: 'Q',
|
||||
q1: 'Q1',
|
||||
q2: 'Q2',
|
||||
q3: 'Q3',
|
||||
sprint: 'SPR',
|
||||
main: 'RACE',
|
||||
timeTrial: 'TT',
|
||||
};
|
||||
return names[this.value];
|
||||
}
|
||||
|
||||
// Static factory methods for common types
|
||||
static practice(): SessionType {
|
||||
return new SessionType('practice');
|
||||
}
|
||||
|
||||
static qualifying(): SessionType {
|
||||
return new SessionType('qualifying');
|
||||
}
|
||||
|
||||
static sprint(): SessionType {
|
||||
return new SessionType('sprint');
|
||||
}
|
||||
|
||||
static main(): SessionType {
|
||||
return new SessionType('main');
|
||||
}
|
||||
|
||||
static timeTrial(): SessionType {
|
||||
return new SessionType('timeTrial');
|
||||
}
|
||||
}
|
||||
262
core/racing/domain/value-objects/SponsorshipPricing.ts
Normal file
262
core/racing/domain/value-objects/SponsorshipPricing.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* Value Object: SponsorshipPricing
|
||||
*
|
||||
* Represents the sponsorship slot configuration and pricing for any sponsorable entity.
|
||||
* Used by drivers, teams, races, and leagues to define their sponsorship offerings.
|
||||
*/
|
||||
|
||||
import { Money } from './Money';
|
||||
import type { IValueObject } from '@gridpilot/shared/domain';
|
||||
|
||||
export interface SponsorshipSlotConfig {
|
||||
tier: 'main' | 'secondary';
|
||||
price: Money;
|
||||
benefits: string[];
|
||||
available: boolean;
|
||||
maxSlots: number; // How many sponsors of this tier can exist (1 for main, 2 for secondary typically)
|
||||
}
|
||||
|
||||
export interface SponsorshipPricingProps {
|
||||
mainSlot?: SponsorshipSlotConfig | undefined;
|
||||
secondarySlots?: SponsorshipSlotConfig | undefined;
|
||||
acceptingApplications: boolean;
|
||||
customRequirements?: string | undefined;
|
||||
}
|
||||
|
||||
export class SponsorshipPricing implements IValueObject<SponsorshipPricingProps> {
|
||||
readonly mainSlot: SponsorshipSlotConfig | undefined;
|
||||
readonly secondarySlots: SponsorshipSlotConfig | undefined;
|
||||
readonly acceptingApplications: boolean;
|
||||
readonly customRequirements: string | undefined;
|
||||
|
||||
private constructor(props: SponsorshipPricingProps) {
|
||||
this.mainSlot = props.mainSlot;
|
||||
this.secondarySlots = props.secondarySlots;
|
||||
this.acceptingApplications = props.acceptingApplications;
|
||||
this.customRequirements = props.customRequirements;
|
||||
}
|
||||
|
||||
get props(): SponsorshipPricingProps {
|
||||
return {
|
||||
mainSlot: this.mainSlot,
|
||||
secondarySlots: this.secondarySlots,
|
||||
acceptingApplications: this.acceptingApplications,
|
||||
customRequirements: this.customRequirements,
|
||||
};
|
||||
}
|
||||
|
||||
equals(other: IValueObject<SponsorshipPricingProps>): boolean {
|
||||
const a = this.props;
|
||||
const b = other.props;
|
||||
|
||||
const mainEqual =
|
||||
(a.mainSlot === undefined && b.mainSlot === undefined) ||
|
||||
(a.mainSlot !== undefined &&
|
||||
b.mainSlot !== undefined &&
|
||||
a.mainSlot.tier === b.mainSlot.tier &&
|
||||
a.mainSlot.price.amount === b.mainSlot.price.amount &&
|
||||
a.mainSlot.price.currency === b.mainSlot.price.currency &&
|
||||
a.mainSlot.available === b.mainSlot.available &&
|
||||
a.mainSlot.maxSlots === b.mainSlot.maxSlots &&
|
||||
a.mainSlot.benefits.length === b.mainSlot.benefits.length &&
|
||||
a.mainSlot.benefits.every((val, idx) => val === b.mainSlot!.benefits[idx]));
|
||||
|
||||
const secondaryEqual =
|
||||
(a.secondarySlots === undefined && b.secondarySlots === undefined) ||
|
||||
(a.secondarySlots !== undefined &&
|
||||
b.secondarySlots !== undefined &&
|
||||
a.secondarySlots.tier === b.secondarySlots.tier &&
|
||||
a.secondarySlots.price.amount === b.secondarySlots.price.amount &&
|
||||
a.secondarySlots.price.currency === b.secondarySlots.price.currency &&
|
||||
a.secondarySlots.available === b.secondarySlots.available &&
|
||||
a.secondarySlots.maxSlots === b.secondarySlots.maxSlots &&
|
||||
a.secondarySlots.benefits.length === b.secondarySlots.benefits.length &&
|
||||
a.secondarySlots.benefits.every(
|
||||
(val, idx) => val === b.secondarySlots!.benefits[idx],
|
||||
));
|
||||
|
||||
return (
|
||||
mainEqual &&
|
||||
secondaryEqual &&
|
||||
a.acceptingApplications === b.acceptingApplications &&
|
||||
a.customRequirements === b.customRequirements
|
||||
);
|
||||
}
|
||||
|
||||
static create(props: Partial<SponsorshipPricingProps> = {}): SponsorshipPricing {
|
||||
return new SponsorshipPricing({
|
||||
...(props.mainSlot !== undefined ? { mainSlot: props.mainSlot } : {}),
|
||||
...(props.secondarySlots !== undefined ? { secondarySlots: props.secondarySlots } : {}),
|
||||
acceptingApplications: props.acceptingApplications ?? true,
|
||||
...(props.customRequirements !== undefined ? { customRequirements: props.customRequirements } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create default pricing for a driver
|
||||
*/
|
||||
static defaultDriver(): SponsorshipPricing {
|
||||
return new SponsorshipPricing({
|
||||
mainSlot: {
|
||||
tier: 'main',
|
||||
price: Money.create(200, 'USD'),
|
||||
benefits: ['Suit logo', 'Helmet branding', 'Social mentions'],
|
||||
available: true,
|
||||
maxSlots: 1,
|
||||
},
|
||||
acceptingApplications: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create default pricing for a team
|
||||
*/
|
||||
static defaultTeam(): SponsorshipPricing {
|
||||
return new SponsorshipPricing({
|
||||
mainSlot: {
|
||||
tier: 'main',
|
||||
price: Money.create(500, 'USD'),
|
||||
benefits: ['Team name suffix', 'Car livery', 'All driver suits'],
|
||||
available: true,
|
||||
maxSlots: 1,
|
||||
},
|
||||
secondarySlots: {
|
||||
tier: 'secondary',
|
||||
price: Money.create(250, 'USD'),
|
||||
benefits: ['Team page logo', 'Minor livery placement'],
|
||||
available: true,
|
||||
maxSlots: 2,
|
||||
},
|
||||
acceptingApplications: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create default pricing for a race
|
||||
*/
|
||||
static defaultRace(): SponsorshipPricing {
|
||||
return new SponsorshipPricing({
|
||||
mainSlot: {
|
||||
tier: 'main',
|
||||
price: Money.create(300, 'USD'),
|
||||
benefits: ['Race title sponsor', 'Stream overlay', 'Results banner'],
|
||||
available: true,
|
||||
maxSlots: 1,
|
||||
},
|
||||
acceptingApplications: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create default pricing for a league/season
|
||||
*/
|
||||
static defaultLeague(): SponsorshipPricing {
|
||||
return new SponsorshipPricing({
|
||||
mainSlot: {
|
||||
tier: 'main',
|
||||
price: Money.create(800, 'USD'),
|
||||
benefits: ['Hood placement', 'League banner', 'Prominent logo', 'League page URL'],
|
||||
available: true,
|
||||
maxSlots: 1,
|
||||
},
|
||||
secondarySlots: {
|
||||
tier: 'secondary',
|
||||
price: Money.create(250, 'USD'),
|
||||
benefits: ['Side logo placement', 'League page listing'],
|
||||
available: true,
|
||||
maxSlots: 2,
|
||||
},
|
||||
acceptingApplications: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a specific tier is available
|
||||
*/
|
||||
isSlotAvailable(tier: 'main' | 'secondary'): boolean {
|
||||
if (tier === 'main') {
|
||||
return !!this.mainSlot?.available;
|
||||
}
|
||||
return !!this.secondarySlots?.available;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get price for a specific tier
|
||||
*/
|
||||
getPrice(tier: 'main' | 'secondary'): Money | null {
|
||||
if (tier === 'main') {
|
||||
return this.mainSlot?.price ?? null;
|
||||
}
|
||||
return this.secondarySlots?.price ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get benefits for a specific tier
|
||||
*/
|
||||
getBenefits(tier: 'main' | 'secondary'): string[] {
|
||||
if (tier === 'main') {
|
||||
return this.mainSlot?.benefits ?? [];
|
||||
}
|
||||
return this.secondarySlots?.benefits ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Update main slot pricing
|
||||
*/
|
||||
updateMainSlot(config: Partial<SponsorshipSlotConfig>): SponsorshipPricing {
|
||||
const currentMain = this.mainSlot ?? {
|
||||
tier: 'main' as const,
|
||||
price: Money.create(0, 'USD'),
|
||||
benefits: [],
|
||||
available: true,
|
||||
maxSlots: 1,
|
||||
};
|
||||
|
||||
const base = this.props;
|
||||
|
||||
return new SponsorshipPricing({
|
||||
...base,
|
||||
mainSlot: {
|
||||
...currentMain,
|
||||
...config,
|
||||
tier: 'main',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update secondary slot pricing
|
||||
*/
|
||||
updateSecondarySlot(config: Partial<SponsorshipSlotConfig>): SponsorshipPricing {
|
||||
const currentSecondary = this.secondarySlots ?? {
|
||||
tier: 'secondary' as const,
|
||||
price: Money.create(0, 'USD'),
|
||||
benefits: [],
|
||||
available: true,
|
||||
maxSlots: 2,
|
||||
};
|
||||
|
||||
const base = this.props;
|
||||
|
||||
return new SponsorshipPricing({
|
||||
...base,
|
||||
secondarySlots: {
|
||||
...currentSecondary,
|
||||
...config,
|
||||
tier: 'secondary',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable/disable accepting applications
|
||||
*/
|
||||
setAcceptingApplications(accepting: boolean): SponsorshipPricing {
|
||||
const base = this.props;
|
||||
|
||||
return new SponsorshipPricing({
|
||||
...base,
|
||||
acceptingApplications: accepting,
|
||||
});
|
||||
}
|
||||
}
|
||||
44
core/racing/domain/value-objects/WeekdaySet.ts
Normal file
44
core/racing/domain/value-objects/WeekdaySet.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { Weekday } from '../types/Weekday';
|
||||
import { weekdayToIndex } from '../types/Weekday';
|
||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||
import type { IValueObject } from '@gridpilot/shared/domain';
|
||||
|
||||
export interface WeekdaySetProps {
|
||||
days: Weekday[];
|
||||
}
|
||||
|
||||
export class WeekdaySet implements IValueObject<WeekdaySetProps> {
|
||||
private readonly days: Weekday[];
|
||||
|
||||
static fromArray(days: Weekday[]): WeekdaySet {
|
||||
return new WeekdaySet(days);
|
||||
}
|
||||
|
||||
constructor(days: Weekday[]) {
|
||||
if (!Array.isArray(days) || days.length === 0) {
|
||||
throw new RacingDomainValidationError('WeekdaySet requires at least one weekday');
|
||||
}
|
||||
|
||||
const unique = Array.from(new Set(days));
|
||||
this.days = unique.sort((a, b) => weekdayToIndex(a) - weekdayToIndex(b));
|
||||
}
|
||||
|
||||
get props(): WeekdaySetProps {
|
||||
return { days: [...this.days] };
|
||||
}
|
||||
|
||||
getAll(): Weekday[] {
|
||||
return [...this.days];
|
||||
}
|
||||
|
||||
includes(day: Weekday): boolean {
|
||||
return this.days.includes(day);
|
||||
}
|
||||
|
||||
equals(other: IValueObject<WeekdaySetProps>): boolean {
|
||||
const a = this.props.days;
|
||||
const b = other.props.days;
|
||||
if (a.length !== b.length) return false;
|
||||
return a.every((day, index) => day === b[index]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user