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';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user