rename to core

This commit is contained in:
2025-12-15 13:46:07 +01:00
parent aedf58643d
commit 5c22f8820c
559 changed files with 415 additions and 767 deletions

View 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];
}
}

View 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,
});
}
}

View 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,
});
}
}

View 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;
}
}

View 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,
});
}
}

View 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 }
: {}),
});
}
}

View 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;
}

View 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[];
}

View 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];
}
}

View 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');
}
}

View 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';
}
}
}

View 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';
}
}

View 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(),
});
}
}

View 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';
}
}

View 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';
}
}

View 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');
}
}
}

View 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;
}
}

View 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,
};
}
}

View 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 } : {}),
});
}
}

View 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();
}
}

View 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();
}
}

View 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);
}
}

View 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();
}
}

View 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;
}
}

View 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');
}
}
}

View 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`;
}
}

View 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';
}
}

View File

@@ -0,0 +1,34 @@
import type { IDomainError, CommonDomainErrorKind } from '@gridpilot/shared/errors';
export abstract class RacingDomainError extends Error implements IDomainError<CommonDomainErrorKind> {
readonly type = 'domain' as const;
readonly context = 'racing-domain';
abstract readonly kind: CommonDomainErrorKind;
constructor(message: string) {
super(message);
Object.setPrototypeOf(this, new.target.prototype);
}
}
export class RacingDomainValidationError
extends RacingDomainError
implements IDomainError<'validation'>
{
readonly kind = 'validation' as const;
constructor(message: string) {
super(message);
}
}
export class RacingDomainInvariantError
extends RacingDomainError
implements IDomainError<'invariant'>
{
readonly kind = 'invariant' as const;
constructor(message: string) {
super(message);
}
}

View File

@@ -0,0 +1,29 @@
import type { IDomainEvent } from '@gridpilot/shared/domain';
/**
* Domain Event: MainRaceCompleted
*
* Fired when the main race session of a race event is completed.
* This triggers immediate performance summary notifications to drivers.
*/
export interface MainRaceCompletedEventData {
raceEventId: string;
sessionId: string;
leagueId: string;
seasonId: string;
completedAt: Date;
driverIds: string[]; // Drivers who participated in the main race
}
export class MainRaceCompletedEvent implements IDomainEvent<MainRaceCompletedEventData> {
readonly eventType = 'MainRaceCompleted';
readonly aggregateId: string;
readonly eventData: MainRaceCompletedEventData;
readonly occurredAt: Date;
constructor(data: MainRaceCompletedEventData) {
this.aggregateId = data.raceEventId;
this.eventData = { ...data };
this.occurredAt = new Date();
}
}

View File

@@ -0,0 +1,29 @@
import type { IDomainEvent } from '@gridpilot/shared/domain';
/**
* Domain Event: RaceEventStewardingClosed
*
* Fired when the stewarding window closes for a race event.
* This triggers final results notifications to drivers with any penalty adjustments.
*/
export interface RaceEventStewardingClosedEventData {
raceEventId: string;
leagueId: string;
seasonId: string;
closedAt: Date;
driverIds: string[]; // Drivers who participated in the race event
hadPenaltiesApplied: boolean; // Whether any penalties were applied during stewarding
}
export class RaceEventStewardingClosedEvent implements IDomainEvent<RaceEventStewardingClosedEventData> {
readonly eventType = 'RaceEventStewardingClosed';
readonly aggregateId: string;
readonly eventData: RaceEventStewardingClosedEventData;
readonly occurredAt: Date;
constructor(data: RaceEventStewardingClosedEventData) {
this.aggregateId = data.raceEventId;
this.eventData = { ...data };
this.occurredAt = new Date();
}
}

View File

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

View File

@@ -0,0 +1,10 @@
import type { ChampionshipStanding } from '../entities/ChampionshipStanding';
export interface IChampionshipStandingRepository {
findBySeasonAndChampionship(
seasonId: string,
championshipId: string,
): Promise<ChampionshipStanding[]>;
saveAll(standings: ChampionshipStanding[]): Promise<void>;
}

View File

@@ -0,0 +1,50 @@
/**
* Application Port: IDriverRepository
*
* Repository interface for Driver entity CRUD operations.
* Defines async methods using domain entities as types.
*/
import type { Driver } from '../entities/Driver';
export interface IDriverRepository {
/**
* Find a driver by ID
*/
findById(id: string): Promise<Driver | null>;
/**
* Find a driver by iRacing ID
*/
findByIRacingId(iracingId: string): Promise<Driver | null>;
/**
* Find all drivers
*/
findAll(): Promise<Driver[]>;
/**
* Create a new driver
*/
create(driver: Driver): Promise<Driver>;
/**
* Update an existing driver
*/
update(driver: Driver): Promise<Driver>;
/**
* Delete a driver by ID
*/
delete(id: string): Promise<void>;
/**
* Check if a driver exists by ID
*/
exists(id: string): Promise<boolean>;
/**
* Check if an iRacing ID is already registered
*/
existsByIRacingId(iracingId: string): Promise<boolean>;
}

View File

@@ -0,0 +1,6 @@
import type { Game } from '../entities/Game';
export interface IGameRepository {
findById(id: string): Promise<Game | null>;
findAll(): Promise<Game[]>;
}

View File

@@ -0,0 +1,48 @@
/**
* Application Port: ILeagueMembershipRepository
*
* Repository interface for league membership and join request operations.
* This defines the persistence boundary for membership-related domain entities.
*/
import type {
LeagueMembership,
JoinRequest,
} from '../entities/LeagueMembership';
export interface ILeagueMembershipRepository {
/**
* Get membership for a driver in a league, or null if none exists.
*/
getMembership(leagueId: string, driverId: string): Promise<LeagueMembership | null>;
/**
* Get all active members for a league.
*/
getLeagueMembers(leagueId: string): Promise<LeagueMembership[]>;
/**
* Get all join requests for a league.
*/
getJoinRequests(leagueId: string): Promise<JoinRequest[]>;
/**
* Persist a membership (create or update).
*/
saveMembership(membership: LeagueMembership): Promise<LeagueMembership>;
/**
* Remove a membership for a driver in a league.
*/
removeMembership(leagueId: string, driverId: string): Promise<void>;
/**
* Persist a join request (create or update).
*/
saveJoinRequest(request: JoinRequest): Promise<JoinRequest>;
/**
* Remove a join request by its ID.
*/
removeJoinRequest(requestId: string): Promise<void>;
}

View File

@@ -0,0 +1,50 @@
/**
* Application Port: ILeagueRepository
*
* Repository interface for League entity CRUD operations.
* Defines async methods using domain entities as types.
*/
import type { League } from '../entities/League';
export interface ILeagueRepository {
/**
* Find a league by ID
*/
findById(id: string): Promise<League | null>;
/**
* Find all leagues
*/
findAll(): Promise<League[]>;
/**
* Find leagues by owner ID
*/
findByOwnerId(ownerId: string): Promise<League[]>;
/**
* Create a new league
*/
create(league: League): Promise<League>;
/**
* Update an existing league
*/
update(league: League): Promise<League>;
/**
* Delete a league by ID
*/
delete(id: string): Promise<void>;
/**
* Check if a league exists by ID
*/
exists(id: string): Promise<boolean>;
/**
* Search leagues by name
*/
searchByName(query: string): Promise<League[]>;
}

View File

@@ -0,0 +1,6 @@
import type { LeagueScoringConfig } from '../entities/LeagueScoringConfig';
export interface ILeagueScoringConfigRepository {
findBySeasonId(seasonId: string): Promise<LeagueScoringConfig | null>;
save(config: LeagueScoringConfig): Promise<LeagueScoringConfig>;
}

View File

@@ -0,0 +1,16 @@
/**
* Repository Interface: ILeagueWalletRepository
*
* Defines operations for LeagueWallet aggregate persistence
*/
import type { LeagueWallet } from '../entities/LeagueWallet';
export interface ILeagueWalletRepository {
findById(id: string): Promise<LeagueWallet | null>;
findByLeagueId(leagueId: string): Promise<LeagueWallet | null>;
create(wallet: LeagueWallet): Promise<LeagueWallet>;
update(wallet: LeagueWallet): Promise<LeagueWallet>;
delete(id: string): Promise<void>;
exists(id: string): Promise<boolean>;
}

View File

@@ -0,0 +1,28 @@
/**
* Repository Interface: ILiveryRepository
*
* Defines operations for livery-related entities
*/
import type { DriverLivery } from '../entities/DriverLivery';
import type { LiveryTemplate } from '../entities/LiveryTemplate';
export interface ILiveryRepository {
// DriverLivery operations
findDriverLiveryById(id: string): Promise<DriverLivery | null>;
findDriverLiveriesByDriverId(driverId: string): Promise<DriverLivery[]>;
findDriverLiveryByDriverAndCar(driverId: string, carId: string): Promise<DriverLivery | null>;
findDriverLiveriesByGameId(gameId: string): Promise<DriverLivery[]>;
findDriverLiveryByDriverAndGame(driverId: string, gameId: string): Promise<DriverLivery[]>;
createDriverLivery(livery: DriverLivery): Promise<DriverLivery>;
updateDriverLivery(livery: DriverLivery): Promise<DriverLivery>;
deleteDriverLivery(id: string): Promise<void>;
// LiveryTemplate operations
findTemplateById(id: string): Promise<LiveryTemplate | null>;
findTemplatesBySeasonId(seasonId: string): Promise<LiveryTemplate[]>;
findTemplateBySeasonAndCar(seasonId: string, carId: string): Promise<LiveryTemplate | null>;
createTemplate(template: LiveryTemplate): Promise<LiveryTemplate>;
updateTemplate(template: LiveryTemplate): Promise<LiveryTemplate>;
deleteTemplate(id: string): Promise<void>;
}

View File

@@ -0,0 +1,54 @@
/**
* Repository Interface: IPenaltyRepository
*
* Defines the contract for persisting and retrieving Penalty entities.
*/
import type { Penalty } from '../entities/Penalty';
export interface IPenaltyRepository {
/**
* Find a penalty by ID
*/
findById(id: string): Promise<Penalty | null>;
/**
* Find all penalties for a race
*/
findByRaceId(raceId: string): Promise<Penalty[]>;
/**
* Find all penalties for a specific driver
*/
findByDriverId(driverId: string): Promise<Penalty[]>;
/**
* Find all penalties related to a specific protest
*/
findByProtestId(protestId: string): Promise<Penalty[]>;
/**
* Find all pending penalties (not yet applied)
*/
findPending(): Promise<Penalty[]>;
/**
* Find all penalties issued by a specific steward
*/
findIssuedBy(stewardId: string): Promise<Penalty[]>;
/**
* Save a new penalty
*/
create(penalty: Penalty): Promise<void>;
/**
* Update an existing penalty
*/
update(penalty: Penalty): Promise<void>;
/**
* Check if a penalty exists
*/
exists(id: string): Promise<boolean>;
}

View File

@@ -0,0 +1,19 @@
/**
* Repository Interface: IPrizeRepository
*
* Defines operations for Prize entity persistence
*/
import type { Prize, PrizeStatus } from '../entities/Prize';
export interface IPrizeRepository {
findById(id: string): Promise<Prize | null>;
findBySeasonId(seasonId: string): Promise<Prize[]>;
findByDriverId(driverId: string): Promise<Prize[]>;
findByStatus(status: PrizeStatus): Promise<Prize[]>;
findBySeasonAndPosition(seasonId: string, position: number): Promise<Prize | null>;
create(prize: Prize): Promise<Prize>;
update(prize: Prize): Promise<Prize>;
delete(id: string): Promise<void>;
exists(id: string): Promise<boolean>;
}

View File

@@ -0,0 +1,54 @@
/**
* Repository Interface: IProtestRepository
*
* Defines the contract for persisting and retrieving Protest entities.
*/
import type { Protest } from '../entities/Protest';
export interface IProtestRepository {
/**
* Find a protest by ID
*/
findById(id: string): Promise<Protest | null>;
/**
* Find all protests for a race
*/
findByRaceId(raceId: string): Promise<Protest[]>;
/**
* Find all protests filed by a specific driver
*/
findByProtestingDriverId(driverId: string): Promise<Protest[]>;
/**
* Find all protests against a specific driver
*/
findByAccusedDriverId(driverId: string): Promise<Protest[]>;
/**
* Find all pending protests (for steward review queue)
*/
findPending(): Promise<Protest[]>;
/**
* Find all protests under review by a specific steward
*/
findUnderReviewBy(stewardId: string): Promise<Protest[]>;
/**
* Save a new protest
*/
create(protest: Protest): Promise<void>;
/**
* Update an existing protest
*/
update(protest: Protest): Promise<void>;
/**
* Check if a protest exists
*/
exists(id: string): Promise<boolean>;
}

View File

@@ -0,0 +1,14 @@
import type { RaceEvent } from '../entities/RaceEvent';
export interface IRaceEventRepository {
findById(id: string): Promise<RaceEvent | null>;
findAll(): Promise<RaceEvent[]>;
findBySeasonId(seasonId: string): Promise<RaceEvent[]>;
findByLeagueId(leagueId: string): Promise<RaceEvent[]>;
findByStatus(status: string): Promise<RaceEvent[]>;
findAwaitingStewardingClose(): Promise<RaceEvent[]>;
create(raceEvent: RaceEvent): Promise<RaceEvent>;
update(raceEvent: RaceEvent): Promise<RaceEvent>;
delete(id: string): Promise<void>;
exists(id: string): Promise<boolean>;
}

View File

@@ -0,0 +1,45 @@
/**
* Application Port: IRaceRegistrationRepository
*
* Repository interface for race registration operations.
* This defines the persistence boundary for RaceRegistration entities.
*/
import type { RaceRegistration } from '../entities/RaceRegistration';
export interface IRaceRegistrationRepository {
/**
* Check if a driver is registered for a race.
*/
isRegistered(raceId: string, driverId: string): Promise<boolean>;
/**
* Get all registered driver IDs for a race.
*/
getRegisteredDrivers(raceId: string): Promise<string[]>;
/**
* Get the number of registrations for a race.
*/
getRegistrationCount(raceId: string): Promise<number>;
/**
* Register a driver for a race.
*/
register(registration: RaceRegistration): Promise<void>;
/**
* Withdraw a driver from a race.
*/
withdraw(raceId: string, driverId: string): Promise<void>;
/**
* Get all race IDs a driver is registered for.
*/
getDriverRegistrations(driverId: string): Promise<string[]>;
/**
* Clear all registrations for a race (e.g., when race is cancelled).
*/
clearRaceRegistrations(raceId: string): Promise<void>;
}

View File

@@ -0,0 +1,65 @@
/**
* Application Port: IRaceRepository
*
* Repository interface for Race entity CRUD operations.
* Defines async methods using domain entities as types.
*/
import type { Race, RaceStatus } from '../entities/Race';
export interface IRaceRepository {
/**
* Find a race by ID
*/
findById(id: string): Promise<Race | null>;
/**
* Find all races
*/
findAll(): Promise<Race[]>;
/**
* Find races by league ID
*/
findByLeagueId(leagueId: string): Promise<Race[]>;
/**
* Find upcoming races for a league
*/
findUpcomingByLeagueId(leagueId: string): Promise<Race[]>;
/**
* Find completed races for a league
*/
findCompletedByLeagueId(leagueId: string): Promise<Race[]>;
/**
* Find races by status
*/
findByStatus(status: RaceStatus): Promise<Race[]>;
/**
* Find races scheduled within a date range
*/
findByDateRange(startDate: Date, endDate: Date): Promise<Race[]>;
/**
* Create a new race
*/
create(race: Race): Promise<Race>;
/**
* Update an existing race
*/
update(race: Race): Promise<Race>;
/**
* Delete a race by ID
*/
delete(id: string): Promise<void>;
/**
* Check if a race exists by ID
*/
exists(id: string): Promise<boolean>;
}

View File

@@ -0,0 +1,70 @@
/**
* Application Port: IResultRepository
*
* Repository interface for Result entity CRUD operations.
* Defines async methods using domain entities as types.
*/
import type { Result } from '../entities/Result';
export interface IResultRepository {
/**
* Find a result by ID
*/
findById(id: string): Promise<Result | null>;
/**
* Find all results
*/
findAll(): Promise<Result[]>;
/**
* Find results by race ID
*/
findByRaceId(raceId: string): Promise<Result[]>;
/**
* Find results by driver ID
*/
findByDriverId(driverId: string): Promise<Result[]>;
/**
* Find results by driver ID for a specific league
*/
findByDriverIdAndLeagueId(driverId: string, leagueId: string): Promise<Result[]>;
/**
* Create a new result
*/
create(result: Result): Promise<Result>;
/**
* Create multiple results
*/
createMany(results: Result[]): Promise<Result[]>;
/**
* Update an existing result
*/
update(result: Result): Promise<Result>;
/**
* Delete a result by ID
*/
delete(id: string): Promise<void>;
/**
* Delete all results for a race
*/
deleteByRaceId(raceId: string): Promise<void>;
/**
* Check if a result exists by ID
*/
exists(id: string): Promise<boolean>;
/**
* Check if results exist for a race
*/
existsByRaceId(raceId: string): Promise<boolean>;
}

View File

@@ -0,0 +1,35 @@
import type { Season } from '../entities/Season';
export interface ISeasonRepository {
findById(id: string): Promise<Season | null>;
/**
* Backward-compatible alias retained for existing callers.
* Prefer listByLeague for new usage.
*/
findByLeagueId(leagueId: string): Promise<Season[]>;
/**
* Backward-compatible alias retained for existing callers.
* Prefer add for new usage.
*/
create(season: Season): Promise<Season>;
/**
* Add a new Season aggregate.
*/
add(season: Season): Promise<void>;
/**
* Persist changes to an existing Season aggregate.
*/
update(season: Season): Promise<void>;
/**
* List all Seasons for a given League.
*/
listByLeague(leagueId: string): Promise<Season[]>;
/**
* List Seasons for a League that are currently active.
*/
listActiveByLeague(leagueId: string): Promise<Season[]>;
}

View File

@@ -0,0 +1,24 @@
/**
* Repository Interface: ISeasonSponsorshipRepository
*
* Defines operations for SeasonSponsorship aggregate persistence
*/
import type { SeasonSponsorship, SponsorshipTier } from '../entities/SeasonSponsorship';
export interface ISeasonSponsorshipRepository {
findById(id: string): Promise<SeasonSponsorship | null>;
findBySeasonId(seasonId: string): Promise<SeasonSponsorship[]>;
/**
* Convenience lookup for aggregating sponsorships at league level.
* Implementations should rely on the denormalized leagueId where present,
* falling back to joining through Seasons if needed.
*/
findByLeagueId(leagueId: string): Promise<SeasonSponsorship[]>;
findBySponsorId(sponsorId: string): Promise<SeasonSponsorship[]>;
findBySeasonAndTier(seasonId: string, tier: SponsorshipTier): Promise<SeasonSponsorship[]>;
create(sponsorship: SeasonSponsorship): Promise<SeasonSponsorship>;
update(sponsorship: SeasonSponsorship): Promise<SeasonSponsorship>;
delete(id: string): Promise<void>;
exists(id: string): Promise<boolean>;
}

View File

@@ -0,0 +1,13 @@
import type { Session } from '../entities/Session';
export interface ISessionRepository {
findById(id: string): Promise<Session | null>;
findAll(): Promise<Session[]>;
findByRaceEventId(raceEventId: string): Promise<Session[]>;
findByLeagueId(leagueId: string): Promise<Session[]>;
findByStatus(status: string): Promise<Session[]>;
create(session: Session): Promise<Session>;
update(session: Session): Promise<Session>;
delete(id: string): Promise<void>;
exists(id: string): Promise<boolean>;
}

View File

@@ -0,0 +1,17 @@
/**
* Repository Interface: ISponsorRepository
*
* Defines operations for Sponsor aggregate persistence
*/
import type { Sponsor } from '../entities/Sponsor';
export interface ISponsorRepository {
findById(id: string): Promise<Sponsor | null>;
findAll(): Promise<Sponsor[]>;
findByEmail(email: string): Promise<Sponsor | null>;
create(sponsor: Sponsor): Promise<Sponsor>;
update(sponsor: Sponsor): Promise<Sponsor>;
delete(id: string): Promise<void>;
exists(id: string): Promise<boolean>;
}

View File

@@ -0,0 +1,39 @@
/**
* Repository Interface: ISponsorshipPricingRepository
*
* Stores sponsorship pricing configuration for any sponsorable entity.
* This allows drivers, teams, races, and leagues to define their sponsorship slots.
*/
import type { SponsorshipPricing } from '../value-objects/SponsorshipPricing';
import type { SponsorableEntityType } from '../entities/SponsorshipRequest';
export interface ISponsorshipPricingRepository {
/**
* Get pricing configuration for an entity
*/
findByEntity(entityType: SponsorableEntityType, entityId: string): Promise<SponsorshipPricing | null>;
/**
* Save or update pricing configuration for an entity
*/
save(entityType: SponsorableEntityType, entityId: string, pricing: SponsorshipPricing): Promise<void>;
/**
* Delete pricing configuration for an entity
*/
delete(entityType: SponsorableEntityType, entityId: string): Promise<void>;
/**
* Check if entity has pricing configured
*/
exists(entityType: SponsorableEntityType, entityId: string): Promise<boolean>;
/**
* Find all entities accepting sponsorship applications
*/
findAcceptingApplications(entityType: SponsorableEntityType): Promise<Array<{
entityId: string;
pricing: SponsorshipPricing;
}>>;
}

View File

@@ -0,0 +1,51 @@
/**
* Repository Interface: ISponsorshipRequestRepository
*
* Defines operations for SponsorshipRequest aggregate persistence
*/
import type { SponsorshipRequest, SponsorableEntityType, SponsorshipRequestStatus } from '../entities/SponsorshipRequest';
export interface ISponsorshipRequestRepository {
findById(id: string): Promise<SponsorshipRequest | null>;
/**
* Find all requests for a specific entity (driver, team, race, or season)
*/
findByEntity(entityType: SponsorableEntityType, entityId: string): Promise<SponsorshipRequest[]>;
/**
* Find pending requests for an entity that need review
*/
findPendingByEntity(entityType: SponsorableEntityType, entityId: string): Promise<SponsorshipRequest[]>;
/**
* Find all requests made by a sponsor
*/
findBySponsorId(sponsorId: string): Promise<SponsorshipRequest[]>;
/**
* Find requests by status
*/
findByStatus(status: SponsorshipRequestStatus): Promise<SponsorshipRequest[]>;
/**
* Find requests by sponsor and status
*/
findBySponsorIdAndStatus(sponsorId: string, status: SponsorshipRequestStatus): Promise<SponsorshipRequest[]>;
/**
* Check if a sponsor already has a pending request for an entity
*/
hasPendingRequest(sponsorId: string, entityType: SponsorableEntityType, entityId: string): Promise<boolean>;
/**
* Count pending requests for an entity
*/
countPendingByEntity(entityType: SponsorableEntityType, entityId: string): Promise<number>;
create(request: SponsorshipRequest): Promise<SponsorshipRequest>;
update(request: SponsorshipRequest): Promise<SponsorshipRequest>;
delete(id: string): Promise<void>;
exists(id: string): Promise<boolean>;
}

View File

@@ -0,0 +1,55 @@
/**
* Application Port: IStandingRepository
*
* Repository interface for Standing entity operations.
* Includes methods for calculating and retrieving standings.
*/
import type { Standing } from '../entities/Standing';
export interface IStandingRepository {
/**
* Find standings by league ID (sorted by position)
*/
findByLeagueId(leagueId: string): Promise<Standing[]>;
/**
* Find standing for a specific driver in a league
*/
findByDriverIdAndLeagueId(driverId: string, leagueId: string): Promise<Standing | null>;
/**
* Find all standings
*/
findAll(): Promise<Standing[]>;
/**
* Create or update a standing
*/
save(standing: Standing): Promise<Standing>;
/**
* Create or update multiple standings
*/
saveMany(standings: Standing[]): Promise<Standing[]>;
/**
* Delete a standing
*/
delete(leagueId: string, driverId: string): Promise<void>;
/**
* Delete all standings for a league
*/
deleteByLeagueId(leagueId: string): Promise<void>;
/**
* Check if a standing exists
*/
exists(leagueId: string, driverId: string): Promise<boolean>;
/**
* Recalculate standings for a league based on race results
*/
recalculate(leagueId: string): Promise<Standing[]>;
}

View File

@@ -0,0 +1,58 @@
/**
* Application Port: ITeamMembershipRepository
*
* Repository interface for team membership and join request operations.
* This defines the persistence boundary for team membership-related entities.
*/
import type {
TeamMembership,
TeamJoinRequest,
} from '../types/TeamMembership';
export interface ITeamMembershipRepository {
/**
* Get membership for a driver in a team, or null if none exists.
*/
getMembership(teamId: string, driverId: string): Promise<TeamMembership | null>;
/**
* Get the active team membership for a driver (if any).
*/
getActiveMembershipForDriver(driverId: string): Promise<TeamMembership | null>;
/**
* Get all active members for a team.
*/
getTeamMembers(teamId: string): Promise<TeamMembership[]>;
/**
* Persist a membership (create or update).
*/
saveMembership(membership: TeamMembership): Promise<TeamMembership>;
/**
* Remove a membership for a driver in a team.
*/
removeMembership(teamId: string, driverId: string): Promise<void>;
/**
* Count active members for a team.
*/
countByTeamId(teamId: string): Promise<number>;
/**
* Get all join requests for a team.
*/
getJoinRequests(teamId: string): Promise<TeamJoinRequest[]>;
/**
* Persist a join request (create or update).
*/
saveJoinRequest(request: TeamJoinRequest): Promise<TeamJoinRequest>;
/**
* Remove a join request by its ID.
*/
removeJoinRequest(requestId: string): Promise<void>;
}

View File

@@ -0,0 +1,45 @@
/**
* Application Port: ITeamRepository
*
* Repository interface for Team aggregate operations.
* This defines the persistence boundary for Team entities.
*/
import type { Team } from '../entities/Team';
export interface ITeamRepository {
/**
* Find a team by ID.
*/
findById(id: string): Promise<Team | null>;
/**
* Find all teams.
*/
findAll(): Promise<Team[]>;
/**
* Find teams by league ID.
*/
findByLeagueId(leagueId: string): Promise<Team[]>;
/**
* Create a new team.
*/
create(team: Team): Promise<Team>;
/**
* Update an existing team.
*/
update(team: Team): Promise<Team>;
/**
* Delete a team by ID.
*/
delete(id: string): Promise<void>;
/**
* Check if a team exists by ID.
*/
exists(id: string): Promise<boolean>;
}

View File

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

View File

@@ -0,0 +1,17 @@
/**
* Repository Interface: ITransactionRepository
*
* Defines operations for Transaction entity persistence
*/
import type { Transaction, TransactionType } from '../entities/Transaction';
export interface ITransactionRepository {
findById(id: string): Promise<Transaction | null>;
findByWalletId(walletId: string): Promise<Transaction[]>;
findByType(type: TransactionType): Promise<Transaction[]>;
create(transaction: Transaction): Promise<Transaction>;
update(transaction: Transaction): Promise<Transaction>;
delete(id: string): Promise<void>;
exists(id: string): Promise<boolean>;
}

View File

@@ -0,0 +1,71 @@
import type { ChampionshipConfig } from '../types/ChampionshipConfig';
import type { ParticipantRef } from '../types/ParticipantRef';
import { ChampionshipStanding } from '../entities/ChampionshipStanding';
import type { ParticipantEventPoints } from './EventScoringService';
import { DropScoreApplier, type EventPointsEntry } from './DropScoreApplier';
export class ChampionshipAggregator {
constructor(private readonly dropScoreApplier: DropScoreApplier) {}
aggregate(params: {
seasonId: string;
championship: ChampionshipConfig;
eventPointsByEventId: Record<string, ParticipantEventPoints[]>;
}): ChampionshipStanding[] {
const { seasonId, championship, eventPointsByEventId } = params;
const perParticipantEvents = new Map<
string,
{ participant: ParticipantRef; events: EventPointsEntry[] }
>();
for (const [eventId, pointsList] of Object.entries(eventPointsByEventId)) {
for (const entry of pointsList) {
const key = entry.participant.id;
const existing = perParticipantEvents.get(key);
const eventEntry: EventPointsEntry = {
eventId,
points: entry.totalPoints,
};
if (existing) {
existing.events.push(eventEntry);
} else {
perParticipantEvents.set(key, {
participant: entry.participant,
events: [eventEntry],
});
}
}
}
const standings: ChampionshipStanding[] = [];
for (const { participant, events } of perParticipantEvents.values()) {
const dropResult = this.dropScoreApplier.apply(
championship.dropScorePolicy,
events,
);
const totalPoints = dropResult.totalPoints;
const resultsCounted = dropResult.counted.length;
const resultsDropped = dropResult.dropped.length;
standings.push(
new ChampionshipStanding({
seasonId,
championshipId: championship.id,
participant,
totalPoints,
resultsCounted,
resultsDropped,
position: 0,
}),
);
}
standings.sort((a, b) => b.totalPoints - a.totalPoints);
return standings.map((s, index) => s.withPosition(index + 1));
}
}

View File

@@ -0,0 +1,66 @@
import type { DropScorePolicy } from '../types/DropScorePolicy';
import type { IDomainCalculationService } from '@gridpilot/shared/domain';
export interface EventPointsEntry {
eventId: string;
points: number;
}
export interface DropScoreResult {
counted: EventPointsEntry[];
dropped: EventPointsEntry[];
totalPoints: number;
}
export interface DropScoreInput {
policy: DropScorePolicy;
events: EventPointsEntry[];
}
export class DropScoreApplier implements IDomainCalculationService<DropScoreInput, DropScoreResult> {
calculate(input: DropScoreInput): DropScoreResult {
return this.apply(input.policy, input.events);
}
apply(policy: DropScorePolicy, events: EventPointsEntry[]): DropScoreResult {
if (policy.strategy === 'none' || events.length === 0) {
const totalPoints = events.reduce((sum, e) => sum + e.points, 0);
return {
counted: [...events],
dropped: [],
totalPoints,
};
}
if (policy.strategy === 'bestNResults') {
const count = policy.count ?? events.length;
if (count >= events.length) {
const totalPoints = events.reduce((sum, e) => sum + e.points, 0);
return {
counted: [...events],
dropped: [],
totalPoints,
};
}
const sorted = [...events].sort((a, b) => b.points - a.points);
const counted = sorted.slice(0, count);
const dropped = sorted.slice(count);
const totalPoints = counted.reduce((sum, e) => sum + e.points, 0);
return {
counted,
dropped,
totalPoints,
};
}
// For this slice, treat unsupported strategies as 'none'
const totalPoints = events.reduce((sum, e) => sum + e.points, 0);
return {
counted: [...events],
dropped: [],
totalPoints,
};
}
}

View File

@@ -0,0 +1,147 @@
import type { ChampionshipConfig } from '../types/ChampionshipConfig';
import type { SessionType } from '../types/SessionType';
import type { ParticipantRef } from '../types/ParticipantRef';
import type { Result } from '../entities/Result';
import type { Penalty } from '../entities/Penalty';
import type { BonusRule } from '../types/BonusRule';
import type { ChampionshipType } from '../types/ChampionshipType';
import type { PointsTable } from '../value-objects/PointsTable';
import type { IDomainCalculationService } from '@gridpilot/shared/domain';
export interface ParticipantEventPoints {
participant: ParticipantRef;
basePoints: number;
bonusPoints: number;
penaltyPoints: number;
totalPoints: number;
}
export interface EventScoringInput {
seasonId: string;
championship: ChampionshipConfig;
sessionType: SessionType;
results: Result[];
penalties: Penalty[];
}
function createDriverParticipant(driverId: string): ParticipantRef {
return {
type: 'driver' as ChampionshipType,
id: driverId,
};
}
export class EventScoringService
implements IDomainCalculationService<EventScoringInput, ParticipantEventPoints[]>
{
calculate(input: EventScoringInput): ParticipantEventPoints[] {
return this.scoreSession(input);
}
scoreSession(params: EventScoringInput): ParticipantEventPoints[] {
const { championship, sessionType, results } = params;
const pointsTable = this.getPointsTableForSession(championship, sessionType);
const bonusRules = this.getBonusRulesForSession(championship, sessionType);
const baseByDriver = new Map<string, number>();
const bonusByDriver = new Map<string, number>();
const penaltyByDriver = new Map<string, number>();
for (const result of results) {
const driverId = result.driverId;
const currentBase = baseByDriver.get(driverId) ?? 0;
const added = pointsTable.getPointsForPosition(result.position);
baseByDriver.set(driverId, currentBase + added);
}
const fastestLapRule = bonusRules.find((r) => r.type === 'fastestLap');
if (fastestLapRule) {
this.applyFastestLapBonus(fastestLapRule, results, bonusByDriver);
}
const penaltyMap = this.aggregatePenalties(params.penalties);
for (const [driverId, value] of penaltyMap.entries()) {
penaltyByDriver.set(driverId, value);
}
const allDriverIds = new Set<string>();
for (const id of baseByDriver.keys()) allDriverIds.add(id);
for (const id of bonusByDriver.keys()) allDriverIds.add(id);
for (const id of penaltyByDriver.keys()) allDriverIds.add(id);
const participants: ParticipantEventPoints[] = [];
for (const driverId of allDriverIds) {
const basePoints = baseByDriver.get(driverId) ?? 0;
const bonusPoints = bonusByDriver.get(driverId) ?? 0;
const penaltyPoints = penaltyByDriver.get(driverId) ?? 0;
const totalPoints = basePoints + bonusPoints - penaltyPoints;
participants.push({
participant: createDriverParticipant(driverId),
basePoints,
bonusPoints,
penaltyPoints,
totalPoints,
});
}
return participants;
}
private getPointsTableForSession(
championship: ChampionshipConfig,
sessionType: SessionType,
): PointsTable {
return championship.pointsTableBySessionType[sessionType];
}
private getBonusRulesForSession(
championship: ChampionshipConfig,
sessionType: SessionType,
): BonusRule[] {
const all = championship.bonusRulesBySessionType ?? {};
return (all as Record<SessionType, BonusRule[]>)[sessionType] ?? [];
}
private applyFastestLapBonus(
rule: BonusRule,
results: Result[],
bonusByDriver: Map<string, number>,
): void {
if (results.length === 0) return;
const sortedByLap = [...results].sort((a, b) => a.fastestLap - b.fastestLap);
const best = sortedByLap[0];
if (!best) {
return;
}
const requiresTop = rule.requiresFinishInTopN;
if (typeof requiresTop === 'number') {
if (best.position <= 0 || best.position > requiresTop) {
return;
}
}
const current = bonusByDriver.get(best.driverId) ?? 0;
bonusByDriver.set(best.driverId, current + rule.points);
}
private aggregatePenalties(penalties: Penalty[]): Map<string, number> {
const map = new Map<string, number>();
for (const penalty of penalties) {
// Only count applied points_deduction penalties
if (penalty.status !== 'applied' || penalty.type !== 'points_deduction') {
continue;
}
const current = map.get(penalty.driverId) ?? 0;
const delta = penalty.value ?? 0;
map.set(penalty.driverId, current + delta);
}
return map;
}
}

View File

@@ -0,0 +1,13 @@
import type { IDomainService } from '@gridpilot/shared/domain';
export interface DriverStats {
rating: number;
wins: number;
podiums: number;
totalRaces: number;
overallRank: number | null;
}
export interface IDriverStatsService extends IDomainService {
getDriverStats(driverId: string): DriverStats | null;
}

View File

@@ -0,0 +1,11 @@
import type { IDomainService } from '@gridpilot/shared/domain';
export interface DriverRanking {
driverId: string;
rating: number;
overallRank: number | null;
}
export interface IRankingService extends IDomainService {
getAllDriverRankings(): DriverRanking[];
}

View File

@@ -0,0 +1,147 @@
import type { Weekday } from '../types/Weekday';
export type RecurrenceStrategy = 'weekly' | 'everyNWeeks' | 'monthlyNthWeekday';
export interface ScheduleConfig {
weekdays: Weekday[];
frequency: RecurrenceStrategy;
rounds: number;
startDate: Date;
endDate?: Date;
intervalWeeks?: number;
}
export interface ScheduleResult {
raceDates: Date[];
seasonDurationWeeks: number;
}
/**
* JavaScript getDay(): 0=Sunday, 1=Monday, 2=Tuesday, etc.
*/
const DAY_MAP: Record<Weekday, number> = {
'Sun': 0, 'Mon': 1, 'Tue': 2, 'Wed': 3, 'Thu': 4, 'Fri': 5, 'Sat': 6
};
/**
* Calculate race dates based on schedule configuration.
*
* If both startDate and endDate are provided, races are evenly distributed
* across the selected weekdays within that range.
*
* If only startDate is provided, races are scheduled according to the
* recurrence strategy (weekly or bi-weekly).
*/
export function calculateRaceDates(config: ScheduleConfig): ScheduleResult {
const { weekdays, frequency, rounds, startDate, endDate, intervalWeeks } = config;
const dates: Date[] = [];
if (weekdays.length === 0 || rounds <= 0) {
return { raceDates: [], seasonDurationWeeks: 0 };
}
// Convert weekday names to day numbers for faster lookup
const selectedDayNumbers = new Set(weekdays.map(wd => DAY_MAP[wd]));
// If we have both start and end dates, evenly distribute races
if (endDate && endDate > startDate) {
const allPossibleDays: Date[] = [];
const currentDate = new Date(startDate);
currentDate.setHours(12, 0, 0, 0);
const endDateTime = new Date(endDate);
endDateTime.setHours(12, 0, 0, 0);
while (currentDate <= endDateTime) {
const dayOfWeek = currentDate.getDay();
if (selectedDayNumbers.has(dayOfWeek)) {
allPossibleDays.push(new Date(currentDate));
}
currentDate.setDate(currentDate.getDate() + 1);
}
// Evenly distribute the rounds across available days
const totalPossible = allPossibleDays.length;
if (totalPossible >= rounds) {
const spacing = totalPossible / rounds;
for (let i = 0; i < rounds; i++) {
const index = Math.min(Math.floor(i * spacing), totalPossible - 1);
dates.push(allPossibleDays[index]!);
}
} else {
// Not enough days - use all available
dates.push(...allPossibleDays);
}
const seasonDurationWeeks = dates.length > 1
? Math.ceil((dates[dates.length - 1]!.getTime() - dates[0]!.getTime()) / (7 * 24 * 60 * 60 * 1000))
: 0;
return { raceDates: dates, seasonDurationWeeks };
}
// Schedule based on frequency (no end date)
const currentDate = new Date(startDate);
currentDate.setHours(12, 0, 0, 0);
let roundsScheduled = 0;
// Generate race dates for up to 2 years to ensure we can schedule all rounds
const maxDays = 365 * 2;
let daysChecked = 0;
const seasonStart = new Date(startDate);
seasonStart.setHours(12, 0, 0, 0);
while (roundsScheduled < rounds && daysChecked < maxDays) {
const dayOfWeek = currentDate.getDay();
const isSelectedDay = selectedDayNumbers.has(dayOfWeek);
// Calculate which week this is from the start
const daysSinceStart = Math.floor((currentDate.getTime() - seasonStart.getTime()) / (24 * 60 * 60 * 1000));
const currentWeek = Math.floor(daysSinceStart / 7);
if (isSelectedDay) {
let shouldRace = false;
if (frequency === 'weekly') {
// Weekly: race every week on selected days
shouldRace = true;
} else if (frequency === 'everyNWeeks') {
// Every N weeks: race only on matching week intervals
const interval = intervalWeeks ?? 2;
shouldRace = currentWeek % interval === 0;
} else {
// Default to weekly if frequency not set
shouldRace = true;
}
if (shouldRace) {
dates.push(new Date(currentDate));
roundsScheduled++;
}
}
currentDate.setDate(currentDate.getDate() + 1);
daysChecked++;
}
const seasonDurationWeeks = dates.length > 1
? Math.ceil((dates[dates.length - 1]!.getTime() - dates[0]!.getTime()) / (7 * 24 * 60 * 60 * 1000))
: 0;
return { raceDates: dates, seasonDurationWeeks };
}
/**
* Get the next occurrence of a specific weekday from a given date.
*/
export function getNextWeekday(fromDate: Date, weekday: Weekday): Date {
const targetDay = DAY_MAP[weekday];
const result = new Date(fromDate);
result.setHours(12, 0, 0, 0);
const currentDay = result.getDay();
const daysUntilTarget = (targetDay - currentDay + 7) % 7 || 7;
result.setDate(result.getDate() + daysUntilTarget);
return result;
}

View File

@@ -0,0 +1,185 @@
import { SeasonSchedule } from '../value-objects/SeasonSchedule';
import { ScheduledRaceSlot } from '../value-objects/ScheduledRaceSlot';
import type { RecurrenceStrategy } from '../value-objects/RecurrenceStrategy';
import { RacingDomainError, RacingDomainValidationError } from '../errors/RacingDomainError';
import { RaceTimeOfDay } from '../value-objects/RaceTimeOfDay';
import type { Weekday } from '../types/Weekday';
import { weekdayToIndex } from '../types/Weekday';
import type { IDomainCalculationService } from '@gridpilot/shared/domain';
function cloneDate(date: Date): Date {
return new Date(date.getTime());
}
function addDays(date: Date, days: number): Date {
const d = cloneDate(date);
d.setDate(d.getDate() + days);
return d;
}
function addWeeks(date: Date, weeks: number): Date {
return addDays(date, weeks * 7);
}
function addMonths(date: Date, months: number): Date {
const d = cloneDate(date);
const targetMonth = d.getMonth() + months;
d.setMonth(targetMonth);
return d;
}
function applyTimeOfDay(baseDate: Date, timeOfDay: RaceTimeOfDay): Date {
const d = new Date(
baseDate.getFullYear(),
baseDate.getMonth(),
baseDate.getDate(),
timeOfDay.hour,
timeOfDay.minute,
0,
0,
);
return d;
}
// Treat Monday as 1 ... Sunday as 7
function getCalendarWeekdayIndex(date: Date): number {
const jsDay = date.getDay(); // 0=Sun ... 6=Sat
if (jsDay === 0) {
return 7;
}
return jsDay;
}
function weekdayToCalendarOffset(anchor: Date, target: Weekday): number {
const anchorIndex = getCalendarWeekdayIndex(anchor);
const targetIndex = weekdayToIndex(target);
return targetIndex - anchorIndex;
}
function generateWeeklyOrEveryNWeeksSlots(
schedule: SeasonSchedule,
maxRounds: number,
): ScheduledRaceSlot[] {
const result: ScheduledRaceSlot[] = [];
const recurrence = schedule.recurrence;
const weekdays =
recurrence.kind === 'weekly' || recurrence.kind === 'everyNWeeks'
? recurrence.weekdays.getAll()
: [];
if (weekdays.length === 0) {
throw new RacingDomainValidationError('RecurrenceStrategy has no weekdays');
}
const intervalWeeks = recurrence.kind === 'everyNWeeks' ? recurrence.intervalWeeks : 1;
let anchorWeekStart = cloneDate(schedule.startDate);
let roundNumber = 1;
while (result.length < maxRounds) {
for (const weekday of weekdays) {
const offset = weekdayToCalendarOffset(anchorWeekStart, weekday);
const candidateDate = addDays(anchorWeekStart, offset);
if (candidateDate < schedule.startDate) {
continue;
}
const scheduledAt = applyTimeOfDay(candidateDate, schedule.timeOfDay);
result.push(
new ScheduledRaceSlot({
roundNumber,
scheduledAt,
timezone: schedule.timezone,
}),
);
roundNumber += 1;
if (result.length >= maxRounds) {
break;
}
}
anchorWeekStart = addWeeks(anchorWeekStart, intervalWeeks);
}
return result;
}
function findNthWeekdayOfMonth(base: Date, ordinal: 1 | 2 | 3 | 4, weekday: Weekday): Date {
const firstOfMonth = new Date(base.getFullYear(), base.getMonth(), 1);
const firstIndex = getCalendarWeekdayIndex(firstOfMonth);
const targetIndex = weekdayToIndex(weekday);
let offset = targetIndex - firstIndex;
if (offset < 0) {
offset += 7;
}
const dayOfMonth = 1 + offset + (ordinal - 1) * 7;
return new Date(base.getFullYear(), base.getMonth(), dayOfMonth);
}
function generateMonthlySlots(schedule: SeasonSchedule, maxRounds: number): ScheduledRaceSlot[] {
const result: ScheduledRaceSlot[] = [];
const recurrence = schedule.recurrence;
if (recurrence.kind !== 'monthlyNthWeekday') {
return result;
}
const { ordinal, weekday } = recurrence.monthlyPattern;
let currentMonthDate = new Date(
schedule.startDate.getFullYear(),
schedule.startDate.getMonth(),
1,
);
let roundNumber = 1;
while (result.length < maxRounds) {
const candidateDate = findNthWeekdayOfMonth(currentMonthDate, ordinal, weekday);
if (candidateDate >= schedule.startDate) {
const scheduledAt = applyTimeOfDay(candidateDate, schedule.timeOfDay);
result.push(
new ScheduledRaceSlot({
roundNumber,
scheduledAt,
timezone: schedule.timezone,
}),
);
roundNumber += 1;
}
currentMonthDate = addMonths(currentMonthDate, 1);
}
return result;
}
export class SeasonScheduleGenerator {
static generateSlots(schedule: SeasonSchedule): ScheduledRaceSlot[] {
return this.generateSlotsUpTo(schedule, schedule.plannedRounds);
}
static generateSlotsUpTo(schedule: SeasonSchedule, maxRounds: number): ScheduledRaceSlot[] {
if (!Number.isInteger(maxRounds) || maxRounds <= 0) {
throw new RacingDomainValidationError('maxRounds must be a positive integer');
}
const recurrence: RecurrenceStrategy = schedule.recurrence;
if (recurrence.kind === 'monthlyNthWeekday') {
return generateMonthlySlots(schedule, maxRounds);
}
return generateWeeklyOrEveryNWeeksSlots(schedule, maxRounds);
}
}
export class SeasonScheduleGeneratorService
implements IDomainCalculationService<SeasonSchedule, ScheduledRaceSlot[]>
{
calculate(schedule: SeasonSchedule): ScheduledRaceSlot[] {
return SeasonScheduleGenerator.generateSlots(schedule);
}
}

View File

@@ -0,0 +1,35 @@
import type { IDomainService } from '@gridpilot/shared/domain';
export type SkillLevel = 'beginner' | 'intermediate' | 'advanced' | 'pro';
/**
* Domain service for determining skill level based on rating.
* This encapsulates the business rule for skill tier classification.
*/
export class SkillLevelService implements IDomainService {
readonly serviceName = 'SkillLevelService';
/**
* Map driver rating to skill level band.
* Business rule: iRating thresholds determine skill tiers.
*/
static getSkillLevel(rating: number): SkillLevel {
if (rating >= 3000) return 'pro';
if (rating >= 2500) return 'advanced';
if (rating >= 1800) return 'intermediate';
return 'beginner';
}
/**
* Map average team rating to performance level.
* Business rule: Team ratings use higher thresholds than individual drivers.
*/
static getTeamPerformanceLevel(averageRating: number | null): SkillLevel {
if (averageRating === null) {
return 'beginner';
}
if (averageRating >= 4500) return 'pro';
if (averageRating >= 3000) return 'advanced';
if (averageRating >= 2000) return 'intermediate';
return 'beginner';
}
}

View File

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

View File

@@ -0,0 +1,8 @@
export type BonusRuleType = 'fastestLap' | 'polePosition' | 'mostPositionsGained';
export interface BonusRule {
id: string;
type: BonusRuleType;
points: number;
requiresFinishInTopN?: number;
}

View File

@@ -0,0 +1,21 @@
import type { ChampionshipType } from '../types/ChampionshipType';
import type { SessionType } from '../types/SessionType';
import type { PointsTable } from '../value-objects/PointsTable';
import type { BonusRule } from '../types/BonusRule';
import type { DropScorePolicy } from '../types/DropScorePolicy';
/**
* Domain Type: ChampionshipConfig
*
* Pure configuration shape for a championship's scoring model.
* This is not a value object and intentionally lives under domain/types.
*/
export interface ChampionshipConfig {
id: string;
name: string;
type: ChampionshipType;
sessionTypes: SessionType[];
pointsTableBySessionType: Record<SessionType, PointsTable>;
bonusRulesBySessionType?: Record<SessionType, BonusRule[]>;
dropScorePolicy: DropScorePolicy;
}

View File

@@ -0,0 +1 @@
export type ChampionshipType = 'driver' | 'team' | 'nations' | 'trophy';

View File

@@ -0,0 +1,13 @@
export type DropScoreStrategy = 'none' | 'bestNResults' | 'dropWorstN';
export interface DropScorePolicy {
strategy: DropScoreStrategy;
/**
* For 'bestNResults': number of best-scoring events to count.
*/
count?: number;
/**
* For 'dropWorstN': number of worst-scoring events to drop.
*/
dropCount?: number;
}

View File

@@ -0,0 +1,73 @@
/**
* Domain Types/Utilities: LeagueRoles
*
* Utility functions for working with league membership roles.
*/
import type { MembershipRole } from '../entities/LeagueMembership';
/**
* Role hierarchy (higher number = more authority)
*/
const ROLE_HIERARCHY: Record<MembershipRole, number> = {
member: 0,
steward: 1,
admin: 2,
owner: 3,
};
/**
* Check if a role is at least steward level
*/
export function isLeagueStewardOrHigherRole(role: MembershipRole): boolean {
return ROLE_HIERARCHY[role] >= ROLE_HIERARCHY.steward;
}
/**
* Check if a role is at least admin level
*/
export function isLeagueAdminOrHigherRole(role: MembershipRole): boolean {
return ROLE_HIERARCHY[role] >= ROLE_HIERARCHY.admin;
}
/**
* Check if a role is owner
*/
export function isLeagueOwnerRole(role: MembershipRole): boolean {
return role === 'owner';
}
/**
* Compare two roles
* Returns positive if role1 > role2, negative if role1 < role2, 0 if equal
*/
export function compareRoles(role1: MembershipRole, role2: MembershipRole): number {
return ROLE_HIERARCHY[role1] - ROLE_HIERARCHY[role2];
}
/**
* Get role display name
*/
export function getRoleDisplayName(role: MembershipRole): string {
const names: Record<MembershipRole, string> = {
member: 'Member',
steward: 'Steward',
admin: 'Admin',
owner: 'Owner',
};
return names[role];
}
/**
* Get all roles in order of hierarchy
*/
export function getAllRolesOrdered(): MembershipRole[] {
return ['member', 'steward', 'admin', 'owner'];
}
/**
* Get roles that can be assigned (excludes owner as it's transferred, not assigned)
*/
export function getAssignableRoles(): MembershipRole[] {
return ['member', 'steward', 'admin'];
}

View File

@@ -0,0 +1,6 @@
import type { ChampionshipType } from './ChampionshipType';
export interface ParticipantRef {
type: ChampionshipType;
id: string;
}

View File

@@ -0,0 +1,9 @@
export type SessionType =
| 'practice'
| 'qualifying'
| 'q1'
| 'q2'
| 'q3'
| 'sprint'
| 'main'
| 'timeTrial';

View File

@@ -0,0 +1,25 @@
/**
* Domain Types: TeamRole, TeamMembershipStatus, TeamMembership, TeamJoinRequest
*
* These are pure domain data shapes (no behavior) used across repositories
* and application DTOs. They are not entities or value objects.
*/
export type TeamRole = 'owner' | 'manager' | 'driver';
export type TeamMembershipStatus = 'active' | 'pending' | 'none';
export interface TeamMembership {
teamId: string;
driverId: string;
role: TeamRole;
status: TeamMembershipStatus;
joinedAt: Date;
}
export interface TeamJoinRequest {
id: string;
teamId: string;
driverId: string;
requestedAt: Date;
message?: string;
}

View File

@@ -0,0 +1,27 @@
export type Weekday = 'Mon' | 'Tue' | 'Wed' | 'Thu' | 'Fri' | 'Sat' | 'Sun';
import { RacingDomainInvariantError } from '../errors/RacingDomainError';
export const ALL_WEEKDAYS: Weekday[] = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
export function weekdayToIndex(day: Weekday): number {
switch (day) {
case 'Mon':
return 1;
case 'Tue':
return 2;
case 'Wed':
return 3;
case 'Thu':
return 4;
case 'Fri':
return 5;
case 'Sat':
return 6;
case 'Sun':
return 7;
default:
// This should be unreachable because Weekday is a closed union.
throw new RacingDomainInvariantError(`Unknown weekday: ${day}`);
}
}

View File

@@ -0,0 +1,203 @@
/**
* Domain Value Object: GameConstraints
*
* Represents game-specific constraints for leagues.
* Different sim racing games have different maximum grid sizes.
*/
import type { IValueObject } from '@gridpilot/shared/domain';
export interface GameConstraintsData {
readonly maxDrivers: number;
readonly maxTeams: number;
readonly defaultMaxDrivers: number;
readonly minDrivers: number;
readonly supportsTeams: boolean;
readonly supportsMultiClass: boolean;
}
export interface GameConstraintsProps {
gameId: string;
constraints: GameConstraintsData;
}
/**
* Game-specific constraints for popular sim racing games
*/
const GAME_CONSTRAINTS: Record<string, GameConstraintsData> & { default: GameConstraintsData } = {
iracing: {
maxDrivers: 64,
maxTeams: 32,
defaultMaxDrivers: 24,
minDrivers: 2,
supportsTeams: true,
supportsMultiClass: true,
},
acc: {
maxDrivers: 30,
maxTeams: 15,
defaultMaxDrivers: 24,
minDrivers: 2,
supportsTeams: true,
supportsMultiClass: false,
},
rf2: {
maxDrivers: 64,
maxTeams: 32,
defaultMaxDrivers: 24,
minDrivers: 2,
supportsTeams: true,
supportsMultiClass: true,
},
ams2: {
maxDrivers: 32,
maxTeams: 16,
defaultMaxDrivers: 20,
minDrivers: 2,
supportsTeams: true,
supportsMultiClass: true,
},
lmu: {
maxDrivers: 32,
maxTeams: 16,
defaultMaxDrivers: 24,
minDrivers: 2,
supportsTeams: true,
supportsMultiClass: true,
},
// Default for unknown games
default: {
maxDrivers: 32,
maxTeams: 16,
defaultMaxDrivers: 20,
minDrivers: 2,
supportsTeams: true,
supportsMultiClass: false,
},
};
function getConstraintsForId(gameId: string): GameConstraintsData {
const lower = gameId.toLowerCase();
const fromMap = GAME_CONSTRAINTS[lower];
if (fromMap) {
return fromMap;
}
return GAME_CONSTRAINTS.default;
}
export class GameConstraints implements IValueObject<GameConstraintsProps> {
readonly gameId: string;
readonly constraints: GameConstraintsData;
private constructor(gameId: string, constraints: GameConstraintsData) {
this.gameId = gameId;
this.constraints = constraints;
}
get props(): GameConstraintsProps {
return {
gameId: this.gameId,
constraints: this.constraints,
};
}
equals(other: IValueObject<GameConstraintsProps>): boolean {
return this.props.gameId === other.props.gameId;
}
/**
* Get constraints for a specific game
*/
static forGame(gameId: string): GameConstraints {
const constraints = getConstraintsForId(gameId);
const lowerId = gameId.toLowerCase();
return new GameConstraints(lowerId, constraints);
}
/**
* Get all supported game IDs
*/
static getSupportedGames(): string[] {
return Object.keys(GAME_CONSTRAINTS).filter(id => id !== 'default');
}
/**
* Maximum drivers allowed for this game
*/
get maxDrivers(): number {
return this.constraints.maxDrivers;
}
/**
* Maximum teams allowed for this game
*/
get maxTeams(): number {
return this.constraints.maxTeams;
}
/**
* Default driver count for new leagues
*/
get defaultMaxDrivers(): number {
return this.constraints.defaultMaxDrivers;
}
/**
* Minimum drivers required
*/
get minDrivers(): number {
return this.constraints.minDrivers;
}
/**
* Whether this game supports team-based leagues
*/
get supportsTeams(): boolean {
return this.constraints.supportsTeams;
}
/**
* Whether this game supports multi-class racing
*/
get supportsMultiClass(): boolean {
return this.constraints.supportsMultiClass;
}
/**
* Validate a driver count against game constraints
*/
validateDriverCount(count: number): { valid: boolean; error?: string } {
if (count < this.minDrivers) {
return {
valid: false,
error: `Minimum ${this.minDrivers} drivers required`,
};
}
if (count > this.maxDrivers) {
return {
valid: false,
error: `Maximum ${this.maxDrivers} drivers allowed for ${this.gameId.toUpperCase()}`,
};
}
return { valid: true };
}
/**
* Validate a team count against game constraints
*/
validateTeamCount(count: number): { valid: boolean; error?: string } {
if (!this.supportsTeams) {
return {
valid: false,
error: `${this.gameId.toUpperCase()} does not support team-based leagues`,
};
}
if (count > this.maxTeams) {
return {
valid: false,
error: `Maximum ${this.maxTeams} teams allowed for ${this.gameId.toUpperCase()}`,
};
}
return { valid: true };
}
}

View File

@@ -0,0 +1,100 @@
/**
* Domain Value Object: LeagueDescription
*
* Represents a valid league description with validation rules.
*/
import { RacingDomainValidationError } from '../errors/RacingDomainError';
import type { IValueObject } from '@gridpilot/shared/domain';
export interface LeagueDescriptionValidationResult {
valid: boolean;
error?: string;
}
export const LEAGUE_DESCRIPTION_CONSTRAINTS = {
minLength: 20,
maxLength: 1000,
recommendedMinLength: 50,
} as const;
export interface LeagueDescriptionProps {
value: string;
}
export class LeagueDescription implements IValueObject<LeagueDescriptionProps> {
readonly value: string;
private constructor(value: string) {
this.value = value;
}
/**
* Validate a league description without creating the value object
*/
static validate(value: string): LeagueDescriptionValidationResult {
const trimmed = value?.trim() ?? '';
if (!trimmed) {
return { valid: false, error: 'Description is required — help drivers understand your league' };
}
if (trimmed.length < LEAGUE_DESCRIPTION_CONSTRAINTS.minLength) {
return {
valid: false,
error: `Description must be at least ${LEAGUE_DESCRIPTION_CONSTRAINTS.minLength} characters — tell drivers what makes your league special`,
};
}
if (trimmed.length > LEAGUE_DESCRIPTION_CONSTRAINTS.maxLength) {
return {
valid: false,
error: `Description must be ${LEAGUE_DESCRIPTION_CONSTRAINTS.maxLength} characters or less`,
};
}
return { valid: true };
}
/**
* Check if description meets recommended length for better engagement
*/
static isRecommendedLength(value: string): boolean {
const trimmed = value?.trim() ?? '';
return trimmed.length >= LEAGUE_DESCRIPTION_CONSTRAINTS.recommendedMinLength;
}
/**
* Create a LeagueDescription from a string value
*/
static create(value: string): LeagueDescription {
const validation = this.validate(value);
if (!validation.valid) {
throw new RacingDomainValidationError(validation.error ?? 'Invalid league description');
}
return new LeagueDescription(value.trim());
}
get props(): LeagueDescriptionProps {
return { value: this.value };
}
/**
* Try to create a LeagueDescription, returning null if invalid
*/
static tryCreate(value: string): LeagueDescription | null {
const validation = this.validate(value);
if (!validation.valid) {
return null;
}
return new LeagueDescription(value.trim());
}
toString(): string {
return this.value;
}
equals(other: IValueObject<LeagueDescriptionProps>): boolean {
return this.props.value === other.props.value;
}
}

View File

@@ -0,0 +1,113 @@
/**
* Domain Value Object: LeagueName
*
* Represents a valid league name with validation rules.
*/
import { RacingDomainValidationError } from '../errors/RacingDomainError';
import type { IValueObject } from '@gridpilot/shared/domain';
export interface LeagueNameValidationResult {
valid: boolean;
error?: string;
}
export const LEAGUE_NAME_CONSTRAINTS = {
minLength: 3,
maxLength: 64,
pattern: /^[a-zA-Z0-9].*$/, // Must start with alphanumeric
forbiddenPatterns: [
/^\s/, // No leading whitespace
/\s$/, // No trailing whitespace
/\s{2,}/, // No multiple consecutive spaces
],
} as const;
export interface LeagueNameProps {
value: string;
}
export class LeagueName implements IValueObject<LeagueNameProps> {
readonly value: string;
private constructor(value: string) {
this.value = value;
}
/**
* Validate a league name without creating the value object
*/
static validate(value: string): LeagueNameValidationResult {
const trimmed = value?.trim() ?? '';
if (!trimmed) {
return { valid: false, error: 'League name is required' };
}
if (trimmed.length < LEAGUE_NAME_CONSTRAINTS.minLength) {
return {
valid: false,
error: `League name must be at least ${LEAGUE_NAME_CONSTRAINTS.minLength} characters`,
};
}
if (trimmed.length > LEAGUE_NAME_CONSTRAINTS.maxLength) {
return {
valid: false,
error: `League name must be ${LEAGUE_NAME_CONSTRAINTS.maxLength} characters or less`,
};
}
if (!LEAGUE_NAME_CONSTRAINTS.pattern.test(trimmed)) {
return {
valid: false,
error: 'League name must start with a letter or number',
};
}
for (const forbidden of LEAGUE_NAME_CONSTRAINTS.forbiddenPatterns) {
if (forbidden.test(value)) {
return {
valid: false,
error: 'League name cannot have leading/trailing spaces or multiple consecutive spaces',
};
}
}
return { valid: true };
}
/**
* Create a LeagueName from a string value
*/
static create(value: string): LeagueName {
const validation = this.validate(value);
if (!validation.valid) {
throw new RacingDomainValidationError(validation.error ?? 'Invalid league name');
}
return new LeagueName(value.trim());
}
get props(): LeagueNameProps {
return { value: this.value };
}
/**
* Try to create a LeagueName, returning null if invalid
*/
static tryCreate(value: string): LeagueName | null {
const validation = this.validate(value);
if (!validation.valid) {
return null;
}
return new LeagueName(value.trim());
}
toString(): string {
return this.value;
}
equals(other: IValueObject<LeagueNameProps>): boolean {
return this.props.value === other.props.value;
}
}

View File

@@ -0,0 +1,29 @@
import { RacingDomainValidationError } from '../errors/RacingDomainError';
import type { IValueObject } from '@gridpilot/shared/domain';
export interface LeagueTimezoneProps {
id: string;
}
export class LeagueTimezone implements IValueObject<LeagueTimezoneProps> {
private readonly id: string;
constructor(id: string) {
if (!id || id.trim().length === 0) {
throw new RacingDomainValidationError('LeagueTimezone id must be a non-empty string');
}
this.id = id;
}
getId(): string {
return this.id;
}
get props(): LeagueTimezoneProps {
return { id: this.id };
}
equals(other: IValueObject<LeagueTimezoneProps>): boolean {
return this.props.id === other.props.id;
}
}

View File

@@ -0,0 +1,140 @@
/**
* Domain Value Object: LeagueVisibility
*
* Represents the visibility and ranking status of a league.
*
* - 'ranked' (public): Competitive leagues visible to everyone, affects driver ratings.
* Requires minimum 10 players to ensure competitive integrity.
* - 'unranked' (private): Casual leagues for friends/private groups, no rating impact.
* Can have any number of players.
*/
import type { IValueObject } from '@gridpilot/shared/domain';
import { RacingDomainValidationError } from '../errors/RacingDomainError';
export type LeagueVisibilityType = 'ranked' | 'unranked';
export interface LeagueVisibilityConstraints {
readonly minDrivers: number;
readonly isPubliclyVisible: boolean;
readonly affectsRatings: boolean;
readonly requiresApproval: boolean;
}
const VISIBILITY_CONSTRAINTS: Record<LeagueVisibilityType, LeagueVisibilityConstraints> = {
ranked: {
minDrivers: 10,
isPubliclyVisible: true,
affectsRatings: true,
requiresApproval: false, // Anyone can join public leagues
},
unranked: {
minDrivers: 2,
isPubliclyVisible: false,
affectsRatings: false,
requiresApproval: true, // Private leagues require invite/approval
},
};
export interface LeagueVisibilityProps {
type: LeagueVisibilityType;
}
export class LeagueVisibility implements IValueObject<LeagueVisibilityProps> {
readonly type: LeagueVisibilityType;
readonly constraints: LeagueVisibilityConstraints;
private constructor(type: LeagueVisibilityType) {
this.type = type;
this.constraints = VISIBILITY_CONSTRAINTS[type];
}
static ranked(): LeagueVisibility {
return new LeagueVisibility('ranked');
}
static unranked(): LeagueVisibility {
return new LeagueVisibility('unranked');
}
static fromString(value: string): LeagueVisibility {
// Support both old ('public'/'private') and new ('ranked'/'unranked') terminology
if (value === 'ranked' || value === 'public') {
return LeagueVisibility.ranked();
}
if (value === 'unranked' || value === 'private') {
return LeagueVisibility.unranked();
}
throw new RacingDomainValidationError(`Invalid league visibility: ${value}`);
}
/**
* Validates that the given driver count meets the minimum requirement
* for this visibility type.
*/
validateDriverCount(driverCount: number): { valid: boolean; error?: string } {
if (driverCount < this.constraints.minDrivers) {
return {
valid: false,
error: `${this.type === 'ranked' ? 'Ranked' : 'Unranked'} leagues require at least ${this.constraints.minDrivers} drivers`,
};
}
return { valid: true };
}
/**
* Returns true if this is a ranked/public league
*/
isRanked(): boolean {
return this.type === 'ranked';
}
/**
* Returns true if this is an unranked/private league
*/
isUnranked(): boolean {
return this.type === 'unranked';
}
/**
* Human-readable label for UI display
*/
getLabel(): string {
return this.type === 'ranked' ? 'Ranked (Public)' : 'Unranked (Friends)';
}
/**
* Short description for UI tooltips
*/
getDescription(): string {
return this.type === 'ranked'
? 'Competitive league visible to everyone. Results affect driver ratings.'
: 'Private league for friends. Results do not affect ratings.';
}
/**
* Convert to string for serialization
*/
toString(): LeagueVisibilityType {
return this.type;
}
get props(): LeagueVisibilityProps {
return { type: this.type };
}
/**
* For backward compatibility with existing 'public'/'private' terminology
*/
toLegacyString(): 'public' | 'private' {
return this.type === 'ranked' ? 'public' : 'private';
}
equals(other: IValueObject<LeagueVisibilityProps>): boolean {
return this.props.type === other.props.type;
}
}
// Export constants for validation
export const MIN_RANKED_LEAGUE_DRIVERS = VISIBILITY_CONSTRAINTS.ranked.minDrivers;
export const MIN_UNRANKED_LEAGUE_DRIVERS = VISIBILITY_CONSTRAINTS.unranked.minDrivers;

View File

@@ -0,0 +1,190 @@
/**
* Value Object: LiveryDecal
* Represents a decal/logo placed on a livery
*/
import { RacingDomainValidationError } from '../errors/RacingDomainError';
import type { IValueObject } from '@gridpilot/shared/domain';
export type DecalType = 'sponsor' | 'user';
export interface LiveryDecalProps {
id: string;
imageUrl: string;
x: number;
y: number;
width: number;
height: number;
rotation: number; // Degrees, 0-360
zIndex: number;
type: DecalType;
}
export class LiveryDecal implements IValueObject<LiveryDecalProps> {
readonly id: string;
readonly imageUrl: string;
readonly x: number;
readonly y: number;
readonly width: number;
readonly height: number;
readonly rotation: number;
readonly zIndex: number;
readonly type: DecalType;
private constructor(props: LiveryDecalProps) {
this.id = props.id;
this.imageUrl = props.imageUrl;
this.x = props.x;
this.y = props.y;
this.width = props.width;
this.height = props.height;
this.rotation = props.rotation;
this.zIndex = props.zIndex;
this.type = props.type;
}
static create(props: Omit<LiveryDecalProps, 'rotation'> & { rotation?: number }): LiveryDecal {
const propsWithRotation = {
...props,
rotation: props.rotation ?? 0,
};
this.validate(propsWithRotation);
return new LiveryDecal(propsWithRotation);
}
private static validate(props: LiveryDecalProps): void {
if (!props.id || props.id.trim().length === 0) {
throw new RacingDomainValidationError('LiveryDecal ID is required');
}
if (!props.imageUrl || props.imageUrl.trim().length === 0) {
throw new RacingDomainValidationError('LiveryDecal imageUrl is required');
}
if (props.x < 0 || props.x > 1) {
throw new RacingDomainValidationError('LiveryDecal x coordinate must be between 0 and 1 (normalized)');
}
if (props.y < 0 || props.y > 1) {
throw new RacingDomainValidationError('LiveryDecal y coordinate must be between 0 and 1 (normalized)');
}
if (props.width <= 0 || props.width > 1) {
throw new RacingDomainValidationError('LiveryDecal width must be between 0 and 1 (normalized)');
}
if (props.height <= 0 || props.height > 1) {
throw new RacingDomainValidationError('LiveryDecal height must be between 0 and 1 (normalized)');
}
if (!Number.isInteger(props.zIndex) || props.zIndex < 0) {
throw new RacingDomainValidationError('LiveryDecal zIndex must be a non-negative integer');
}
if (props.rotation < 0 || props.rotation > 360) {
throw new RacingDomainValidationError('LiveryDecal rotation must be between 0 and 360 degrees');
}
if (!props.type) {
throw new RacingDomainValidationError('LiveryDecal type is required');
}
}
/**
* Move decal to new position
*/
moveTo(x: number, y: number): LiveryDecal {
return LiveryDecal.create({
...this,
x,
y,
});
}
/**
* Resize decal
*/
resize(width: number, height: number): LiveryDecal {
return LiveryDecal.create({
...this,
width,
height,
});
}
/**
* Change z-index
*/
setZIndex(zIndex: number): LiveryDecal {
return LiveryDecal.create({
...this,
zIndex,
});
}
/**
* Rotate decal
*/
rotate(rotation: number): LiveryDecal {
// Normalize rotation to 0-360 range
const normalizedRotation = ((rotation % 360) + 360) % 360;
return LiveryDecal.create({
...this,
rotation: normalizedRotation,
});
}
/**
* Get CSS transform string for rendering
*/
getCssTransform(): string {
return `rotate(${this.rotation}deg)`;
}
get props(): LiveryDecalProps {
return {
id: this.id,
imageUrl: this.imageUrl,
x: this.x,
y: this.y,
width: this.width,
height: this.height,
rotation: this.rotation,
zIndex: this.zIndex,
type: this.type,
};
}
/**
* Check if this decal overlaps with another
*/
overlapsWith(other: LiveryDecal): boolean {
const thisRight = this.x + this.width;
const thisBottom = this.y + this.height;
const otherRight = other.x + other.width;
const otherBottom = other.y + other.height;
return !(
thisRight <= other.x ||
this.x >= otherRight ||
thisBottom <= other.y ||
this.y >= otherBottom
);
}
equals(other: IValueObject<LiveryDecalProps>): boolean {
const a = this.props;
const b = other.props;
return (
a.id === b.id &&
a.imageUrl === b.imageUrl &&
a.x === b.x &&
a.y === b.y &&
a.width === b.width &&
a.height === b.height &&
a.rotation === b.rotation &&
a.zIndex === b.zIndex &&
a.type === b.type
);
}
}

View File

@@ -0,0 +1,90 @@
/**
* Value Object: MembershipFee
* Represents membership fee configuration for league drivers
*/
import { RacingDomainValidationError } from '../errors/RacingDomainError';
import type { Money } from './Money';
import type { IValueObject } from '@gridpilot/shared/domain';
export type MembershipFeeType = 'season' | 'monthly' | 'per_race';
export interface MembershipFeeProps {
type: MembershipFeeType;
amount: Money;
}
export class MembershipFee implements IValueObject<MembershipFeeProps> {
readonly type: MembershipFeeType;
readonly amount: Money;
private constructor(props: MembershipFeeProps) {
this.type = props.type;
this.amount = props.amount;
}
static create(type: MembershipFeeType, amount: Money): MembershipFee {
if (!type) {
throw new RacingDomainValidationError('MembershipFee type is required');
}
if (!amount) {
throw new RacingDomainValidationError('MembershipFee amount is required');
}
if (amount.amount < 0) {
throw new RacingDomainValidationError('MembershipFee amount cannot be negative');
}
return new MembershipFee({ type, amount });
}
/**
* Get platform fee for this membership fee
*/
getPlatformFee(): Money {
return this.amount.calculatePlatformFee();
}
/**
* Get net amount after platform fee
*/
getNetAmount(): Money {
return this.amount.calculateNetAmount();
}
get props(): MembershipFeeProps {
return {
type: this.type,
amount: this.amount,
};
}
/**
* Check if this is a recurring fee
*/
isRecurring(): boolean {
return this.type === 'monthly';
}
equals(other: IValueObject<MembershipFeeProps>): boolean {
const a = this.props;
const b = other.props;
return a.type === b.type && a.amount.equals(b.amount);
}
/**
* Get display name for fee type
*/
getDisplayName(): string {
switch (this.type) {
case 'season':
return 'Season Fee';
case 'monthly':
return 'Monthly Subscription';
case 'per_race':
return 'Per-Race Fee';
}
}
}

View File

@@ -0,0 +1,115 @@
/**
* Value Object: Money
* Represents a monetary amount with currency and platform fee calculation
*/
import { RacingDomainValidationError } from '../errors/RacingDomainError';
import type { IValueObject } from '@gridpilot/shared/domain';
export type Currency = 'USD' | 'EUR' | 'GBP';
export interface MoneyProps {
amount: number;
currency: Currency;
}
export class Money implements IValueObject<MoneyProps> {
private static readonly PLATFORM_FEE_PERCENTAGE = 0.10;
readonly amount: number;
readonly currency: Currency;
private constructor(amount: number, currency: Currency) {
this.amount = amount;
this.currency = currency;
}
static create(amount: number, currency: Currency = 'USD'): Money {
if (amount < 0) {
throw new RacingDomainValidationError('Money amount cannot be negative');
}
if (!Number.isFinite(amount)) {
throw new RacingDomainValidationError('Money amount must be a finite number');
}
return new Money(amount, currency);
}
/**
* Calculate platform fee (10%)
*/
calculatePlatformFee(): Money {
const feeAmount = this.amount * Money.PLATFORM_FEE_PERCENTAGE;
return new Money(feeAmount, this.currency);
}
/**
* Calculate net amount after platform fee
*/
calculateNetAmount(): Money {
const platformFee = this.calculatePlatformFee();
return new Money(this.amount - platformFee.amount, this.currency);
}
/**
* Add two money amounts
*/
add(other: Money): Money {
if (this.currency !== other.currency) {
throw new RacingDomainValidationError('Cannot add money with different currencies');
}
return new Money(this.amount + other.amount, this.currency);
}
/**
* Subtract two money amounts
*/
subtract(other: Money): Money {
if (this.currency !== other.currency) {
throw new RacingDomainValidationError('Cannot subtract money with different currencies');
}
const result = this.amount - other.amount;
if (result < 0) {
throw new RacingDomainValidationError('Subtraction would result in negative amount');
}
return new Money(result, this.currency);
}
/**
* Check if this money is greater than another
*/
isGreaterThan(other: Money): boolean {
if (this.currency !== other.currency) {
throw new RacingDomainValidationError('Cannot compare money with different currencies');
}
return this.amount > other.amount;
}
get props(): MoneyProps {
return {
amount: this.amount,
currency: this.currency,
};
}
/**
* Check if this money equals another
*/
equals(other: IValueObject<MoneyProps>): boolean {
const a = this.props;
const b = other.props;
return a.amount === b.amount && a.currency === b.currency;
}
/**
* Format money for display
*/
format(): string {
const formatter = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: this.currency,
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
return formatter.format(this.amount);
}
}

View File

@@ -0,0 +1,40 @@
import type { Weekday } from '../types/Weekday';
import type { IValueObject } from '@gridpilot/shared/domain';
export interface MonthlyRecurrencePatternProps {
ordinal: 1 | 2 | 3 | 4;
weekday: Weekday;
}
export class MonthlyRecurrencePattern implements IValueObject<MonthlyRecurrencePatternProps> {
readonly ordinal: 1 | 2 | 3 | 4;
readonly weekday: Weekday;
constructor(ordinal: 1 | 2 | 3 | 4, weekday: Weekday);
constructor(props: MonthlyRecurrencePatternProps);
constructor(
ordinalOrProps: 1 | 2 | 3 | 4 | MonthlyRecurrencePatternProps,
weekday?: Weekday,
) {
if (typeof ordinalOrProps === 'object') {
this.ordinal = ordinalOrProps.ordinal;
this.weekday = ordinalOrProps.weekday;
} else {
this.ordinal = ordinalOrProps;
this.weekday = weekday as Weekday;
}
}
get props(): MonthlyRecurrencePatternProps {
return {
ordinal: this.ordinal,
weekday: this.weekday,
};
}
equals(other: IValueObject<MonthlyRecurrencePatternProps>): boolean {
const a = this.props;
const b = other.props;
return a.ordinal === b.ordinal && a.weekday === b.weekday;
}
}

View File

@@ -0,0 +1,46 @@
import type { IValueObject } from '@gridpilot/shared/domain';
export interface PointsTableProps {
pointsByPosition: Map<number, number>;
}
export class PointsTable implements IValueObject<PointsTableProps> {
private readonly pointsByPosition: Map<number, number>;
constructor(pointsByPosition: Record<number, number> | Map<number, number>) {
if (pointsByPosition instanceof Map) {
this.pointsByPosition = new Map(pointsByPosition);
} else {
this.pointsByPosition = new Map(
Object.entries(pointsByPosition).map(([key, value]) => [Number(key), value]),
);
}
}
getPointsForPosition(position: number): number {
if (!Number.isInteger(position) || position < 1) {
return 0;
}
const value = this.pointsByPosition.get(position);
return typeof value === 'number' ? value : 0;
}
get props(): PointsTableProps {
return {
pointsByPosition: new Map(this.pointsByPosition),
};
}
equals(other: IValueObject<PointsTableProps>): boolean {
const a = this.props.pointsByPosition;
const b = other.props.pointsByPosition;
if (a.size !== b.size) return false;
for (const [key, value] of a.entries()) {
if (b.get(key) !== value) {
return false;
}
}
return true;
}
}

View File

@@ -0,0 +1,239 @@
import type { IValueObject } from '@gridpilot/shared/domain';
/**
* Incident types that can occur during a race
*/
export type IncidentType =
| 'track_limits' // Driver went off track and gained advantage
| 'contact' // Physical contact with another car
| 'unsafe_rejoin' // Unsafe rejoining of the track
| 'aggressive_driving' // Aggressive defensive or overtaking maneuvers
| 'false_start' // Started before green flag
| 'collision' // Major collision involving multiple cars
| 'spin' // Driver spun out
| 'mechanical' // Mechanical failure (not driver error)
| 'other'; // Other incident types
/**
* Individual incident record
*/
export interface IncidentRecord {
type: IncidentType;
lap: number;
description?: string;
penaltyPoints?: number; // Points deducted for this incident
}
/**
* Value Object: RaceIncidents
*
* Encapsulates all incidents that occurred during a driver's race.
* Provides methods to calculate total penalty points and incident severity.
*/
export class RaceIncidents implements IValueObject<IncidentRecord[]> {
private readonly incidents: IncidentRecord[];
constructor(incidents: IncidentRecord[] = []) {
this.incidents = [...incidents];
}
get props(): IncidentRecord[] {
return [...this.incidents];
}
/**
* Add a new incident
*/
addIncident(incident: IncidentRecord): RaceIncidents {
return new RaceIncidents([...this.incidents, incident]);
}
/**
* Get all incidents
*/
getAllIncidents(): IncidentRecord[] {
return [...this.incidents];
}
/**
* Get total number of incidents
*/
getTotalCount(): number {
return this.incidents.length;
}
/**
* Get total penalty points from all incidents
*/
getTotalPenaltyPoints(): number {
return this.incidents.reduce((total, incident) => total + (incident.penaltyPoints || 0), 0);
}
/**
* Get incidents by type
*/
getIncidentsByType(type: IncidentType): IncidentRecord[] {
return this.incidents.filter(incident => incident.type === type);
}
/**
* Check if driver had any incidents
*/
hasIncidents(): boolean {
return this.incidents.length > 0;
}
/**
* Check if driver had a clean race (no incidents)
*/
isClean(): boolean {
return this.incidents.length === 0;
}
/**
* Get incident severity score (0-100, higher = more severe)
*/
getSeverityScore(): number {
if (this.incidents.length === 0) return 0;
const severityWeights: Record<IncidentType, number> = {
track_limits: 10,
contact: 20,
unsafe_rejoin: 25,
aggressive_driving: 15,
false_start: 30,
collision: 40,
spin: 35,
mechanical: 5, // Lower weight as it's not driver error
other: 15,
};
const totalSeverity = this.incidents.reduce((total, incident) => {
return total + severityWeights[incident.type];
}, 0);
// Normalize to 0-100 scale (cap at 100 for very incident-heavy races)
return Math.min(100, totalSeverity);
}
/**
* Get human-readable incident summary
*/
getSummary(): string {
if (this.incidents.length === 0) {
return 'Clean race';
}
const typeCounts = this.incidents.reduce((counts, incident) => {
counts[incident.type] = (counts[incident.type] || 0) + 1;
return counts;
}, {} as Record<IncidentType, number>);
const summaryParts = Object.entries(typeCounts).map(([type, count]) => {
const typeLabel = this.getIncidentTypeLabel(type as IncidentType);
return count > 1 ? `${count}x ${typeLabel}` : typeLabel;
});
return summaryParts.join(', ');
}
/**
* Get human-readable label for incident type
*/
private getIncidentTypeLabel(type: IncidentType): string {
const labels: Record<IncidentType, string> = {
track_limits: 'Track Limits',
contact: 'Contact',
unsafe_rejoin: 'Unsafe Rejoin',
aggressive_driving: 'Aggressive Driving',
false_start: 'False Start',
collision: 'Collision',
spin: 'Spin',
mechanical: 'Mechanical',
other: 'Other',
};
return labels[type];
}
equals(other: IValueObject<IncidentRecord[]>): boolean {
const otherIncidents = other.props;
if (this.incidents.length !== otherIncidents.length) {
return false;
}
// Sort both arrays and compare
const sortedThis = [...this.incidents].sort((a, b) => a.lap - b.lap);
const sortedOther = [...otherIncidents].sort((a, b) => a.lap - b.lap);
return sortedThis.every((incident, index) => {
const otherIncident = sortedOther[index];
return incident.type === otherIncident.type &&
incident.lap === otherIncident.lap &&
incident.description === otherIncident.description &&
incident.penaltyPoints === otherIncident.penaltyPoints;
});
}
/**
* Create RaceIncidents from legacy incidents count
*/
static fromLegacyIncidentsCount(count: number): RaceIncidents {
if (count === 0) {
return new RaceIncidents();
}
// Distribute legacy incidents across different types based on probability
const incidents: IncidentRecord[] = [];
for (let i = 0; i < count; i++) {
const type = RaceIncidents.getRandomIncidentType();
incidents.push({
type,
lap: Math.floor(Math.random() * 20) + 1, // Random lap 1-20
penaltyPoints: RaceIncidents.getDefaultPenaltyPoints(type),
});
}
return new RaceIncidents(incidents);
}
/**
* Get random incident type for legacy data conversion
*/
private static getRandomIncidentType(): IncidentType {
const types: IncidentType[] = [
'track_limits', 'contact', 'unsafe_rejoin', 'aggressive_driving',
'collision', 'spin', 'other'
];
const weights = [0.4, 0.25, 0.15, 0.1, 0.05, 0.04, 0.01]; // Probability weights
const random = Math.random();
let cumulativeWeight = 0;
for (let i = 0; i < types.length; i++) {
cumulativeWeight += weights[i];
if (random <= cumulativeWeight) {
return types[i];
}
}
return 'other';
}
/**
* Get default penalty points for incident type
*/
private static getDefaultPenaltyPoints(type: IncidentType): number {
const penalties: Record<IncidentType, number> = {
track_limits: 0, // Usually just a warning
contact: 2,
unsafe_rejoin: 3,
aggressive_driving: 2,
false_start: 5,
collision: 5,
spin: 0, // Usually no penalty if no contact
mechanical: 0,
other: 2,
};
return penalties[type];
}
}

View File

@@ -0,0 +1,55 @@
import { RacingDomainValidationError } from '../errors/RacingDomainError';
import type { IValueObject } from '@gridpilot/shared/domain';
export interface RaceTimeOfDayProps {
hour: number;
minute: number;
}
export class RaceTimeOfDay implements IValueObject<RaceTimeOfDayProps> {
readonly hour: number;
readonly minute: number;
constructor(hour: number, minute: number) {
if (!Number.isInteger(hour) || hour < 0 || hour > 23) {
throw new RacingDomainValidationError(`RaceTimeOfDay hour must be between 0 and 23, got ${hour}`);
}
if (!Number.isInteger(minute) || minute < 0 || minute > 59) {
throw new RacingDomainValidationError(`RaceTimeOfDay minute must be between 0 and 59, got ${minute}`);
}
this.hour = hour;
this.minute = minute;
}
static fromString(value: string): RaceTimeOfDay {
const match = /^(\d{2}):(\d{2})$/.exec(value);
if (!match) {
throw new RacingDomainValidationError(`RaceTimeOfDay string must be in HH:MM 24h format, got "${value}"`);
}
const hour = Number(match[1]);
const minute = Number(match[2]);
return new RaceTimeOfDay(hour, minute);
}
get props(): RaceTimeOfDayProps {
return {
hour: this.hour,
minute: this.minute,
};
}
toString(): string {
const hh = this.hour.toString().padStart(2, '0');
const mm = this.minute.toString().padStart(2, '0');
return `${hh}:${mm}`;
}
equals(other: IValueObject<RaceTimeOfDayProps>): boolean {
const a = this.props;
const b = other.props;
return a.hour === b.hour && a.minute === b.minute;
}
}

View File

@@ -0,0 +1,59 @@
import { WeekdaySet } from './WeekdaySet';
import { MonthlyRecurrencePattern } from './MonthlyRecurrencePattern';
import { RacingDomainValidationError } from '../errors/RacingDomainError';
export type WeeklyRecurrenceStrategy = {
kind: 'weekly';
weekdays: WeekdaySet;
};
export type EveryNWeeksRecurrenceStrategy = {
kind: 'everyNWeeks';
weekdays: WeekdaySet;
intervalWeeks: number;
};
export type MonthlyNthWeekdayRecurrenceStrategy = {
kind: 'monthlyNthWeekday';
monthlyPattern: MonthlyRecurrencePattern;
};
export type RecurrenceStrategy =
| WeeklyRecurrenceStrategy
| EveryNWeeksRecurrenceStrategy
| MonthlyNthWeekdayRecurrenceStrategy;
export class RecurrenceStrategyFactory {
static weekly(weekdays: WeekdaySet): RecurrenceStrategy {
if (weekdays.getAll().length === 0) {
throw new RacingDomainValidationError('weekdays are required for weekly recurrence');
}
return {
kind: 'weekly',
weekdays,
};
}
static everyNWeeks(intervalWeeks: number, weekdays: WeekdaySet): RecurrenceStrategy {
if (!Number.isInteger(intervalWeeks) || intervalWeeks <= 0) {
throw new RacingDomainValidationError('intervalWeeks must be a positive integer');
}
if (weekdays.getAll().length === 0) {
throw new RacingDomainValidationError('weekdays are required for everyNWeeks recurrence');
}
return {
kind: 'everyNWeeks',
weekdays,
intervalWeeks,
};
}
static monthlyNthWeekday(pattern: MonthlyRecurrencePattern): RecurrenceStrategy {
return {
kind: 'monthlyNthWeekday',
monthlyPattern: pattern,
};
}
}

View File

@@ -0,0 +1,47 @@
import { LeagueTimezone } from './LeagueTimezone';
import { RacingDomainValidationError } from '../errors/RacingDomainError';
import type { IValueObject } from '@gridpilot/shared/domain';
export interface ScheduledRaceSlotProps {
roundNumber: number;
scheduledAt: Date;
timezone: LeagueTimezone;
}
export class ScheduledRaceSlot implements IValueObject<ScheduledRaceSlotProps> {
readonly roundNumber: number;
readonly scheduledAt: Date;
readonly timezone: LeagueTimezone;
constructor(params: { roundNumber: number; scheduledAt: Date; timezone: LeagueTimezone }) {
if (!Number.isInteger(params.roundNumber) || params.roundNumber <= 0) {
throw new RacingDomainValidationError('ScheduledRaceSlot.roundNumber must be a positive integer');
}
if (!(params.scheduledAt instanceof Date) || Number.isNaN(params.scheduledAt.getTime())) {
throw new RacingDomainValidationError('ScheduledRaceSlot.scheduledAt must be a valid Date');
}
this.roundNumber = params.roundNumber;
this.scheduledAt = params.scheduledAt;
this.timezone = params.timezone;
}
get props(): ScheduledRaceSlotProps {
return {
roundNumber: this.roundNumber,
scheduledAt: this.scheduledAt,
timezone: this.timezone,
};
}
equals(other: IValueObject<ScheduledRaceSlotProps>): boolean {
const a = this.props;
const b = other.props;
return (
a.roundNumber === b.roundNumber &&
a.scheduledAt.getTime() === b.scheduledAt.getTime() &&
a.timezone.equals(b.timezone)
);
}
}

View File

@@ -0,0 +1,59 @@
import { RacingDomainValidationError } from '../errors/RacingDomainError';
import type { IValueObject } from '@gridpilot/shared/domain';
export type SeasonDropStrategy = 'none' | 'bestNResults' | 'dropWorstN';
export interface SeasonDropPolicyProps {
strategy: SeasonDropStrategy;
/**
* Number of results to consider for strategies that require a count.
* - bestNResults: keep best N
* - dropWorstN: drop worst N
*/
n?: number;
}
export class SeasonDropPolicy implements IValueObject<SeasonDropPolicyProps> {
readonly strategy: SeasonDropStrategy;
readonly n?: number;
constructor(props: SeasonDropPolicyProps) {
if (!props.strategy) {
throw new RacingDomainValidationError(
'SeasonDropPolicy.strategy is required',
);
}
if (props.strategy === 'bestNResults' || props.strategy === 'dropWorstN') {
if (props.n === undefined || !Number.isInteger(props.n) || props.n <= 0) {
throw new RacingDomainValidationError(
'SeasonDropPolicy.n must be a positive integer when using bestNResults or dropWorstN',
);
}
}
if (props.strategy === 'none' && props.n !== undefined) {
throw new RacingDomainValidationError(
'SeasonDropPolicy.n must be undefined when strategy is none',
);
}
this.strategy = props.strategy;
if (props.n !== undefined) {
this.n = props.n;
}
}
get props(): SeasonDropPolicyProps {
return {
strategy: this.strategy,
...(this.n !== undefined ? { n: this.n } : {}),
};
}
equals(other: IValueObject<SeasonDropPolicyProps>): boolean {
const a = this.props;
const b = other.props;
return a.strategy === b.strategy && a.n === b.n;
}
}

View File

@@ -0,0 +1,68 @@
import { RaceTimeOfDay } from './RaceTimeOfDay';
import { LeagueTimezone } from './LeagueTimezone';
import type { RecurrenceStrategy } from './RecurrenceStrategy';
import { RacingDomainValidationError } from '../errors/RacingDomainError';
import type { IValueObject } from '@gridpilot/shared/domain';
export interface SeasonScheduleProps {
startDate: Date;
timeOfDay: RaceTimeOfDay;
timezone: LeagueTimezone;
recurrence: RecurrenceStrategy;
plannedRounds: number;
}
export class SeasonSchedule implements IValueObject<SeasonScheduleProps> {
readonly startDate: Date;
readonly timeOfDay: RaceTimeOfDay;
readonly timezone: LeagueTimezone;
readonly recurrence: RecurrenceStrategy;
readonly plannedRounds: number;
constructor(params: {
startDate: Date;
timeOfDay: RaceTimeOfDay;
timezone: LeagueTimezone;
recurrence: RecurrenceStrategy;
plannedRounds: number;
}) {
if (!(params.startDate instanceof Date) || Number.isNaN(params.startDate.getTime())) {
throw new RacingDomainValidationError('SeasonSchedule.startDate must be a valid Date');
}
if (!Number.isInteger(params.plannedRounds) || params.plannedRounds <= 0) {
throw new RacingDomainValidationError('SeasonSchedule.plannedRounds must be a positive integer');
}
this.startDate = new Date(
params.startDate.getFullYear(),
params.startDate.getMonth(),
params.startDate.getDate(),
);
this.timeOfDay = params.timeOfDay;
this.timezone = params.timezone;
this.recurrence = params.recurrence;
this.plannedRounds = params.plannedRounds;
}
get props(): SeasonScheduleProps {
return {
startDate: this.startDate,
timeOfDay: this.timeOfDay,
timezone: this.timezone,
recurrence: this.recurrence,
plannedRounds: this.plannedRounds,
};
}
equals(other: IValueObject<SeasonScheduleProps>): boolean {
const a = this.props;
const b = other.props;
return (
a.startDate.getTime() === b.startDate.getTime() &&
a.timeOfDay.equals(b.timeOfDay) &&
a.timezone.equals(b.timezone) &&
a.recurrence.kind === b.recurrence.kind &&
a.plannedRounds === b.plannedRounds
);
}
}

View File

@@ -0,0 +1,66 @@
import { RacingDomainValidationError } from '../errors/RacingDomainError';
import type { IValueObject } from '@gridpilot/shared/domain';
/**
* Value Object: SeasonScoringConfig
*
* Represents the scoring configuration owned by a Season.
* It is intentionally lightweight and primarily captures which
* preset (or custom mode) is applied for this Season.
*
* Detailed championship scoring rules are still modeled via
* `LeagueScoringConfig` and related types.
*/
export interface SeasonScoringConfigProps {
/**
* Identifier of the scoring preset applied to this Season.
* Examples:
* - 'sprint-main-driver'
* - 'club-default'
* - 'endurance-main-double'
* - 'custom'
*/
scoringPresetId: string;
/**
* Whether the Season uses custom scoring rather than a pure preset.
* When true, `scoringPresetId` acts as a label rather than a strict preset key.
*/
customScoringEnabled?: boolean;
}
export class SeasonScoringConfig
implements IValueObject<SeasonScoringConfigProps>
{
readonly scoringPresetId: string;
readonly customScoringEnabled: boolean;
constructor(params: SeasonScoringConfigProps) {
if (!params.scoringPresetId || params.scoringPresetId.trim().length === 0) {
throw new RacingDomainValidationError(
'SeasonScoringConfig.scoringPresetId must be a non-empty string',
);
}
this.scoringPresetId = params.scoringPresetId.trim();
this.customScoringEnabled = Boolean(params.customScoringEnabled);
}
get props(): SeasonScoringConfigProps {
return {
scoringPresetId: this.scoringPresetId,
...(this.customScoringEnabled
? { customScoringEnabled: this.customScoringEnabled }
: {}),
};
}
equals(other: IValueObject<SeasonScoringConfigProps>): boolean {
const a = this.props;
const b = other.props;
return (
a.scoringPresetId === b.scoringPresetId &&
Boolean(a.customScoringEnabled) === Boolean(b.customScoringEnabled)
);
}
}

View File

@@ -0,0 +1,142 @@
import { RacingDomainValidationError } from '../errors/RacingDomainError';
import type { IValueObject } from '@gridpilot/shared/domain';
import type { StewardingDecisionMode } from '../entities/League';
export interface SeasonStewardingConfigProps {
decisionMode: StewardingDecisionMode;
requiredVotes?: number | undefined;
requireDefense: boolean;
defenseTimeLimit: number;
voteTimeLimit: number;
protestDeadlineHours: number;
stewardingClosesHours: number;
notifyAccusedOnProtest: boolean;
notifyOnVoteRequired: boolean;
}
/**
* Value Object: SeasonStewardingConfig
*
* Encapsulates stewarding configuration owned by a Season.
* Shape intentionally mirrors LeagueStewardingFormDTO used by the wizard.
*/
export class SeasonStewardingConfig
implements IValueObject<SeasonStewardingConfigProps>
{
readonly decisionMode: StewardingDecisionMode;
readonly requiredVotes?: number;
readonly requireDefense: boolean;
readonly defenseTimeLimit: number;
readonly voteTimeLimit: number;
readonly protestDeadlineHours: number;
readonly stewardingClosesHours: number;
readonly notifyAccusedOnProtest: boolean;
readonly notifyOnVoteRequired: boolean;
constructor(props: SeasonStewardingConfigProps) {
if (!props.decisionMode) {
throw new RacingDomainValidationError(
'SeasonStewardingConfig.decisionMode is required',
);
}
if (
(props.decisionMode === 'steward_vote' ||
props.decisionMode === 'member_vote' ||
props.decisionMode === 'steward_veto' ||
props.decisionMode === 'member_veto') &&
(props.requiredVotes === undefined ||
!Number.isInteger(props.requiredVotes) ||
props.requiredVotes <= 0)
) {
throw new RacingDomainValidationError(
'SeasonStewardingConfig.requiredVotes must be a positive integer for voting/veto modes',
);
}
// For non-voting modes, requiredVotes should not be provided
if (props.decisionMode !== 'steward_vote' &&
props.decisionMode !== 'member_vote' &&
props.decisionMode !== 'steward_veto' &&
props.decisionMode !== 'member_veto' &&
props.requiredVotes !== undefined) {
throw new RacingDomainValidationError(
'SeasonStewardingConfig.requiredVotes should only be provided for voting/veto modes',
);
}
if (!Number.isInteger(props.defenseTimeLimit) || props.defenseTimeLimit < 0) {
throw new RacingDomainValidationError(
'SeasonStewardingConfig.defenseTimeLimit must be a non-negative integer (hours)',
);
}
if (!Number.isInteger(props.voteTimeLimit) || props.voteTimeLimit <= 0) {
throw new RacingDomainValidationError(
'SeasonStewardingConfig.voteTimeLimit must be a positive integer (hours)',
);
}
if (
!Number.isInteger(props.protestDeadlineHours) ||
props.protestDeadlineHours <= 0
) {
throw new RacingDomainValidationError(
'SeasonStewardingConfig.protestDeadlineHours must be a positive integer (hours)',
);
}
if (
!Number.isInteger(props.stewardingClosesHours) ||
props.stewardingClosesHours <= 0
) {
throw new RacingDomainValidationError(
'SeasonStewardingConfig.stewardingClosesHours must be a positive integer (hours)',
);
}
this.decisionMode = props.decisionMode;
if (props.requiredVotes !== undefined) {
this.requiredVotes = props.requiredVotes;
}
this.requireDefense = props.requireDefense;
this.defenseTimeLimit = props.defenseTimeLimit;
this.voteTimeLimit = props.voteTimeLimit;
this.protestDeadlineHours = props.protestDeadlineHours;
this.stewardingClosesHours = props.stewardingClosesHours;
this.notifyAccusedOnProtest = props.notifyAccusedOnProtest;
this.notifyOnVoteRequired = props.notifyOnVoteRequired;
}
get props(): SeasonStewardingConfigProps {
return {
decisionMode: this.decisionMode,
...(this.requiredVotes !== undefined
? { requiredVotes: this.requiredVotes }
: {}),
requireDefense: this.requireDefense,
defenseTimeLimit: this.defenseTimeLimit,
voteTimeLimit: this.voteTimeLimit,
protestDeadlineHours: this.protestDeadlineHours,
stewardingClosesHours: this.stewardingClosesHours,
notifyAccusedOnProtest: this.notifyAccusedOnProtest,
notifyOnVoteRequired: this.notifyOnVoteRequired,
};
}
equals(other: IValueObject<SeasonStewardingConfigProps>): boolean {
const a = this.props;
const b = other.props;
return (
a.decisionMode === b.decisionMode &&
a.requiredVotes === b.requiredVotes &&
a.requireDefense === b.requireDefense &&
a.defenseTimeLimit === b.defenseTimeLimit &&
a.voteTimeLimit === b.voteTimeLimit &&
a.protestDeadlineHours === b.protestDeadlineHours &&
a.stewardingClosesHours === b.stewardingClosesHours &&
a.notifyAccusedOnProtest === b.notifyAccusedOnProtest &&
a.notifyOnVoteRequired === b.notifyOnVoteRequired
);
}
}

View File

@@ -0,0 +1,103 @@
import { RacingDomainValidationError } from '../errors/RacingDomainError';
import type { IValueObject } from '@gridpilot/shared/domain';
/**
* Value Object: SessionType
*
* Represents the type of racing session within a race event.
* Immutable value object with domain validation.
*/
export type SessionTypeValue = 'practice' | 'qualifying' | 'q1' | 'q2' | 'q3' | 'sprint' | 'main' | 'timeTrial';
export class SessionType implements IValueObject<SessionTypeValue> {
readonly value: SessionTypeValue;
constructor(value: SessionTypeValue) {
if (!value || !this.isValidSessionType(value)) {
throw new RacingDomainValidationError(`Invalid session type: ${value}`);
}
this.value = value;
}
private isValidSessionType(value: string): value is SessionTypeValue {
const validTypes: SessionTypeValue[] = ['practice', 'qualifying', 'q1', 'q2', 'q3', 'sprint', 'main', 'timeTrial'];
return validTypes.includes(value as SessionTypeValue);
}
get props(): SessionTypeValue {
return this.value;
}
equals(other: IValueObject<SessionTypeValue>): boolean {
return this.value === other.props;
}
/**
* Check if this session type counts for championship points
*/
countsForPoints(): boolean {
return this.value === 'main' || this.value === 'sprint';
}
/**
* Check if this session type determines grid positions
*/
determinesGrid(): boolean {
return this.value === 'qualifying' || this.value.startsWith('q');
}
/**
* Get human-readable display name
*/
getDisplayName(): string {
const names: Record<SessionTypeValue, string> = {
practice: 'Practice',
qualifying: 'Qualifying',
q1: 'Q1',
q2: 'Q2',
q3: 'Q3',
sprint: 'Sprint Race',
main: 'Main Race',
timeTrial: 'Time Trial',
};
return names[this.value];
}
/**
* Get short display name for UI
*/
getShortName(): string {
const names: Record<SessionTypeValue, string> = {
practice: 'P',
qualifying: 'Q',
q1: 'Q1',
q2: 'Q2',
q3: 'Q3',
sprint: 'SPR',
main: 'RACE',
timeTrial: 'TT',
};
return names[this.value];
}
// Static factory methods for common types
static practice(): SessionType {
return new SessionType('practice');
}
static qualifying(): SessionType {
return new SessionType('qualifying');
}
static sprint(): SessionType {
return new SessionType('sprint');
}
static main(): SessionType {
return new SessionType('main');
}
static timeTrial(): SessionType {
return new SessionType('timeTrial');
}
}

View File

@@ -0,0 +1,262 @@
/**
* Value Object: SponsorshipPricing
*
* Represents the sponsorship slot configuration and pricing for any sponsorable entity.
* Used by drivers, teams, races, and leagues to define their sponsorship offerings.
*/
import { Money } from './Money';
import type { IValueObject } from '@gridpilot/shared/domain';
export interface SponsorshipSlotConfig {
tier: 'main' | 'secondary';
price: Money;
benefits: string[];
available: boolean;
maxSlots: number; // How many sponsors of this tier can exist (1 for main, 2 for secondary typically)
}
export interface SponsorshipPricingProps {
mainSlot?: SponsorshipSlotConfig | undefined;
secondarySlots?: SponsorshipSlotConfig | undefined;
acceptingApplications: boolean;
customRequirements?: string | undefined;
}
export class SponsorshipPricing implements IValueObject<SponsorshipPricingProps> {
readonly mainSlot: SponsorshipSlotConfig | undefined;
readonly secondarySlots: SponsorshipSlotConfig | undefined;
readonly acceptingApplications: boolean;
readonly customRequirements: string | undefined;
private constructor(props: SponsorshipPricingProps) {
this.mainSlot = props.mainSlot;
this.secondarySlots = props.secondarySlots;
this.acceptingApplications = props.acceptingApplications;
this.customRequirements = props.customRequirements;
}
get props(): SponsorshipPricingProps {
return {
mainSlot: this.mainSlot,
secondarySlots: this.secondarySlots,
acceptingApplications: this.acceptingApplications,
customRequirements: this.customRequirements,
};
}
equals(other: IValueObject<SponsorshipPricingProps>): boolean {
const a = this.props;
const b = other.props;
const mainEqual =
(a.mainSlot === undefined && b.mainSlot === undefined) ||
(a.mainSlot !== undefined &&
b.mainSlot !== undefined &&
a.mainSlot.tier === b.mainSlot.tier &&
a.mainSlot.price.amount === b.mainSlot.price.amount &&
a.mainSlot.price.currency === b.mainSlot.price.currency &&
a.mainSlot.available === b.mainSlot.available &&
a.mainSlot.maxSlots === b.mainSlot.maxSlots &&
a.mainSlot.benefits.length === b.mainSlot.benefits.length &&
a.mainSlot.benefits.every((val, idx) => val === b.mainSlot!.benefits[idx]));
const secondaryEqual =
(a.secondarySlots === undefined && b.secondarySlots === undefined) ||
(a.secondarySlots !== undefined &&
b.secondarySlots !== undefined &&
a.secondarySlots.tier === b.secondarySlots.tier &&
a.secondarySlots.price.amount === b.secondarySlots.price.amount &&
a.secondarySlots.price.currency === b.secondarySlots.price.currency &&
a.secondarySlots.available === b.secondarySlots.available &&
a.secondarySlots.maxSlots === b.secondarySlots.maxSlots &&
a.secondarySlots.benefits.length === b.secondarySlots.benefits.length &&
a.secondarySlots.benefits.every(
(val, idx) => val === b.secondarySlots!.benefits[idx],
));
return (
mainEqual &&
secondaryEqual &&
a.acceptingApplications === b.acceptingApplications &&
a.customRequirements === b.customRequirements
);
}
static create(props: Partial<SponsorshipPricingProps> = {}): SponsorshipPricing {
return new SponsorshipPricing({
...(props.mainSlot !== undefined ? { mainSlot: props.mainSlot } : {}),
...(props.secondarySlots !== undefined ? { secondarySlots: props.secondarySlots } : {}),
acceptingApplications: props.acceptingApplications ?? true,
...(props.customRequirements !== undefined ? { customRequirements: props.customRequirements } : {}),
});
}
/**
* Create default pricing for a driver
*/
static defaultDriver(): SponsorshipPricing {
return new SponsorshipPricing({
mainSlot: {
tier: 'main',
price: Money.create(200, 'USD'),
benefits: ['Suit logo', 'Helmet branding', 'Social mentions'],
available: true,
maxSlots: 1,
},
acceptingApplications: true,
});
}
/**
* Create default pricing for a team
*/
static defaultTeam(): SponsorshipPricing {
return new SponsorshipPricing({
mainSlot: {
tier: 'main',
price: Money.create(500, 'USD'),
benefits: ['Team name suffix', 'Car livery', 'All driver suits'],
available: true,
maxSlots: 1,
},
secondarySlots: {
tier: 'secondary',
price: Money.create(250, 'USD'),
benefits: ['Team page logo', 'Minor livery placement'],
available: true,
maxSlots: 2,
},
acceptingApplications: true,
});
}
/**
* Create default pricing for a race
*/
static defaultRace(): SponsorshipPricing {
return new SponsorshipPricing({
mainSlot: {
tier: 'main',
price: Money.create(300, 'USD'),
benefits: ['Race title sponsor', 'Stream overlay', 'Results banner'],
available: true,
maxSlots: 1,
},
acceptingApplications: true,
});
}
/**
* Create default pricing for a league/season
*/
static defaultLeague(): SponsorshipPricing {
return new SponsorshipPricing({
mainSlot: {
tier: 'main',
price: Money.create(800, 'USD'),
benefits: ['Hood placement', 'League banner', 'Prominent logo', 'League page URL'],
available: true,
maxSlots: 1,
},
secondarySlots: {
tier: 'secondary',
price: Money.create(250, 'USD'),
benefits: ['Side logo placement', 'League page listing'],
available: true,
maxSlots: 2,
},
acceptingApplications: true,
});
}
/**
* Check if a specific tier is available
*/
isSlotAvailable(tier: 'main' | 'secondary'): boolean {
if (tier === 'main') {
return !!this.mainSlot?.available;
}
return !!this.secondarySlots?.available;
}
/**
* Get price for a specific tier
*/
getPrice(tier: 'main' | 'secondary'): Money | null {
if (tier === 'main') {
return this.mainSlot?.price ?? null;
}
return this.secondarySlots?.price ?? null;
}
/**
* Get benefits for a specific tier
*/
getBenefits(tier: 'main' | 'secondary'): string[] {
if (tier === 'main') {
return this.mainSlot?.benefits ?? [];
}
return this.secondarySlots?.benefits ?? [];
}
/**
* Update main slot pricing
*/
updateMainSlot(config: Partial<SponsorshipSlotConfig>): SponsorshipPricing {
const currentMain = this.mainSlot ?? {
tier: 'main' as const,
price: Money.create(0, 'USD'),
benefits: [],
available: true,
maxSlots: 1,
};
const base = this.props;
return new SponsorshipPricing({
...base,
mainSlot: {
...currentMain,
...config,
tier: 'main',
},
});
}
/**
* Update secondary slot pricing
*/
updateSecondarySlot(config: Partial<SponsorshipSlotConfig>): SponsorshipPricing {
const currentSecondary = this.secondarySlots ?? {
tier: 'secondary' as const,
price: Money.create(0, 'USD'),
benefits: [],
available: true,
maxSlots: 2,
};
const base = this.props;
return new SponsorshipPricing({
...base,
secondarySlots: {
...currentSecondary,
...config,
tier: 'secondary',
},
});
}
/**
* Enable/disable accepting applications
*/
setAcceptingApplications(accepting: boolean): SponsorshipPricing {
const base = this.props;
return new SponsorshipPricing({
...base,
acceptingApplications: accepting,
});
}
}

View File

@@ -0,0 +1,44 @@
import type { Weekday } from '../types/Weekday';
import { weekdayToIndex } from '../types/Weekday';
import { RacingDomainValidationError } from '../errors/RacingDomainError';
import type { IValueObject } from '@gridpilot/shared/domain';
export interface WeekdaySetProps {
days: Weekday[];
}
export class WeekdaySet implements IValueObject<WeekdaySetProps> {
private readonly days: Weekday[];
static fromArray(days: Weekday[]): WeekdaySet {
return new WeekdaySet(days);
}
constructor(days: Weekday[]) {
if (!Array.isArray(days) || days.length === 0) {
throw new RacingDomainValidationError('WeekdaySet requires at least one weekday');
}
const unique = Array.from(new Set(days));
this.days = unique.sort((a, b) => weekdayToIndex(a) - weekdayToIndex(b));
}
get props(): WeekdaySetProps {
return { days: [...this.days] };
}
getAll(): Weekday[] {
return [...this.days];
}
includes(day: Weekday): boolean {
return this.days.includes(day);
}
equals(other: IValueObject<WeekdaySetProps>): boolean {
const a = this.props.days;
const b = other.props.days;
if (a.length !== b.length) return false;
return a.every((day, index) => day === b[index]);
}
}