290 lines
8.1 KiB
TypeScript
290 lines
8.1 KiB
TypeScript
/**
|
|
* 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 { Entity } from '@core/shared/domain/Entity';
|
|
import { RacingDomainInvariantError, RacingDomainValidationError } from '../errors/RacingDomainError';
|
|
import { SessionType } from '../value-objects/SessionType';
|
|
import type { Session } from './Session';
|
|
|
|
export type RaceEventStatus = 'scheduled' | 'in_progress' | 'awaiting_stewarding' | 'closed' | 'cancelled';
|
|
|
|
export class RaceEvent extends Entity<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;
|
|
}) {
|
|
super(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',
|
|
...(this.stewardingClosesAt !== undefined ? { 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',
|
|
...(this.stewardingClosesAt !== undefined ? { 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',
|
|
...(this.stewardingClosesAt !== undefined ? { 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',
|
|
...(this.stewardingClosesAt !== undefined ? { 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 ? 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';
|
|
}
|
|
|
|
equals(other: Entity<string>): boolean {
|
|
if (!(other instanceof RaceEvent)) {
|
|
return false;
|
|
}
|
|
return this.id === other.id;
|
|
}
|
|
} |