harden business rules

This commit is contained in:
2025-12-27 17:53:01 +01:00
parent 3efa978ee0
commit 0e7a01d81c
9 changed files with 1486 additions and 365 deletions

View File

@@ -4,12 +4,16 @@
* 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 '@core/shared/domain';
import { SessionType } from '../value-objects/SessionType';
export type RaceStatus = 'scheduled' | 'running' | 'completed' | 'cancelled';
import { RaceStatus, RaceStatusValue } from '../value-objects/RaceStatus';
import { ParticipantCount } from '../value-objects/ParticipantCount';
import { MaxParticipants } from '../value-objects/MaxParticipants';
export type { RaceStatus, RaceStatusValue } from '../value-objects/RaceStatus';
export class Race implements IEntity<string> {
readonly id: string;
readonly leagueId: string;
@@ -21,8 +25,8 @@ export class Race implements IEntity<string> {
readonly sessionType: SessionType;
readonly status: RaceStatus;
readonly strengthOfField: number | undefined;
readonly registeredCount: number | undefined;
readonly maxParticipants: number | undefined;
readonly registeredCount: ParticipantCount | undefined;
readonly maxParticipants: MaxParticipants | undefined;
private constructor(props: {
id: string;
@@ -35,8 +39,8 @@ export class Race implements IEntity<string> {
sessionType: SessionType;
status: RaceStatus;
strengthOfField?: number;
registeredCount?: number;
maxParticipants?: number;
registeredCount?: ParticipantCount;
maxParticipants?: MaxParticipants;
}) {
this.id = props.id;
this.leagueId = props.leagueId;
@@ -54,6 +58,7 @@ export class Race implements IEntity<string> {
/**
* Factory method to create a new Race entity
* Enforces all business rules and invariants
*/
static create(props: {
id: string;
@@ -64,39 +69,12 @@ export class Race implements IEntity<string> {
car: string;
carId?: string;
sessionType?: SessionType;
status?: RaceStatus;
status?: RaceStatusValue;
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 {
// Validate required fields
if (!props.id || props.id.trim().length === 0) {
throw new RacingDomainValidationError('Race ID is required');
}
@@ -105,7 +83,7 @@ export class Race implements IEntity<string> {
throw new RacingDomainValidationError('League ID is required');
}
if (!props.scheduledAt || !(props.scheduledAt instanceof Date)) {
if (!props.scheduledAt || !(props.scheduledAt instanceof Date) || isNaN(props.scheduledAt.getTime())) {
throw new RacingDomainValidationError('Valid scheduled date is required');
}
@@ -116,198 +94,250 @@ export class Race implements IEntity<string> {
if (!props.car || props.car.trim().length === 0) {
throw new RacingDomainValidationError('Car is required');
}
// Validate status
const status = RaceStatus.create(props.status ?? 'scheduled');
// Validate participant counts
let registeredCount: ParticipantCount | undefined;
let maxParticipants: MaxParticipants | undefined;
if (props.registeredCount !== undefined) {
registeredCount = ParticipantCount.create(props.registeredCount);
}
if (props.maxParticipants !== undefined) {
maxParticipants = MaxParticipants.create(props.maxParticipants);
// Validate that registered count doesn't exceed max
if (registeredCount && !maxParticipants.canAccommodate(registeredCount.toNumber())) {
throw new RacingDomainValidationError(
`Registered count (${registeredCount.toNumber()}) exceeds max participants (${maxParticipants.toNumber()})`
);
}
}
// Validate strength of field if provided
if (props.strengthOfField !== undefined) {
if (props.strengthOfField < 0 || props.strengthOfField > 100) {
throw new RacingDomainValidationError('Strength of field must be between 0 and 100');
}
}
// Validate scheduled time is not in the past for new races
if (status.isScheduled() && props.scheduledAt < new Date()) {
throw new RacingDomainValidationError('Scheduled time cannot be in the past');
}
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.strengthOfField !== undefined ? { strengthOfField: props.strengthOfField } : {}),
...(registeredCount !== undefined ? { registeredCount } : {}),
...(maxParticipants !== undefined ? { maxParticipants } : {}),
});
}
/**
* Start the race (move from scheduled to running)
*/
start(): Race {
if (this.status !== 'scheduled') {
throw new RacingDomainInvariantError('Only scheduled races can be started');
const transition = this.status.canTransitionTo('running');
if (!transition.valid) {
throw new RacingDomainInvariantError(transition.error!);
}
const base = {
return Race.create({
id: this.id,
leagueId: this.leagueId,
scheduledAt: this.scheduledAt,
track: this.track,
...(this.trackId !== undefined ? { trackId: this.trackId } : {}),
car: this.car,
...(this.carId !== undefined ? { carId: this.carId } : {}),
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);
status: 'running',
...(this.strengthOfField !== undefined ? { strengthOfField: this.strengthOfField } : {}),
...(this.registeredCount !== undefined ? { registeredCount: this.registeredCount.toNumber() } : {}),
...(this.maxParticipants !== undefined ? { maxParticipants: this.maxParticipants.toNumber() } : {}),
});
}
/**
* Mark race as completed
*/
complete(): Race {
if (this.status === 'completed') {
throw new RacingDomainInvariantError('Race is already completed');
const transition = this.status.canTransitionTo('completed');
if (!transition.valid) {
throw new RacingDomainInvariantError(transition.error!);
}
if (this.status === 'cancelled') {
throw new RacingDomainInvariantError('Cannot complete a cancelled race');
}
const base = {
return Race.create({
id: this.id,
leagueId: this.leagueId,
scheduledAt: this.scheduledAt,
track: this.track,
...(this.trackId !== undefined ? { trackId: this.trackId } : {}),
car: this.car,
...(this.carId !== undefined ? { carId: this.carId } : {}),
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);
status: 'completed',
...(this.strengthOfField !== undefined ? { strengthOfField: this.strengthOfField } : {}),
...(this.registeredCount !== undefined ? { registeredCount: this.registeredCount.toNumber() } : {}),
...(this.maxParticipants !== undefined ? { maxParticipants: this.maxParticipants.toNumber() } : {}),
});
}
/**
* Cancel the race
*/
cancel(): Race {
if (this.status === 'completed') {
throw new RacingDomainInvariantError('Cannot cancel a completed race');
const transition = this.status.canTransitionTo('cancelled');
if (!transition.valid) {
throw new RacingDomainInvariantError(transition.error!);
}
if (this.status === 'cancelled') {
throw new RacingDomainInvariantError('Race is already cancelled');
}
const base = {
return Race.create({
id: this.id,
leagueId: this.leagueId,
scheduledAt: this.scheduledAt,
track: this.track,
...(this.trackId !== undefined ? { trackId: this.trackId } : {}),
car: this.car,
...(this.carId !== undefined ? { carId: this.carId } : {}),
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);
status: 'cancelled',
...(this.strengthOfField !== undefined ? { strengthOfField: this.strengthOfField } : {}),
...(this.registeredCount !== undefined ? { registeredCount: this.registeredCount.toNumber() } : {}),
...(this.maxParticipants !== undefined ? { maxParticipants: this.maxParticipants.toNumber() } : {}),
});
}
/**
* Re-open a previously completed or cancelled race
*/
reopen(): Race {
if (this.status === 'scheduled') {
throw new RacingDomainInvariantError('Race is already scheduled');
const transition = this.status.canTransitionTo('scheduled');
if (!transition.valid) {
throw new RacingDomainInvariantError(transition.error!);
}
if (this.status === 'running') {
throw new RacingDomainInvariantError('Cannot re-open a running race');
}
const base = {
return Race.create({
id: this.id,
leagueId: this.leagueId,
scheduledAt: this.scheduledAt,
track: this.track,
...(this.trackId !== undefined ? { trackId: this.trackId } : {}),
car: this.car,
...(this.carId !== undefined ? { carId: this.carId } : {}),
sessionType: this.sessionType,
status: 'scheduled' 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);
status: 'scheduled',
...(this.strengthOfField !== undefined ? { strengthOfField: this.strengthOfField } : {}),
...(this.registeredCount !== undefined ? { registeredCount: this.registeredCount.toNumber() } : {}),
...(this.maxParticipants !== undefined ? { maxParticipants: this.maxParticipants.toNumber() } : {}),
});
}
/**
* Update SOF and participant count
*/
updateField(strengthOfField: number, registeredCount: number): Race {
const base = {
// Validate strength of field
if (strengthOfField < 0 || strengthOfField > 100) {
throw new RacingDomainValidationError('Strength of field must be between 0 and 100');
}
// Validate registered count against max participants
const newRegisteredCount = ParticipantCount.create(registeredCount);
if (this.maxParticipants && !this.maxParticipants.canAccommodate(newRegisteredCount.toNumber())) {
throw new RacingDomainValidationError(
`Registered count (${newRegisteredCount.toNumber()}) exceeds max participants (${this.maxParticipants.toNumber()})`
);
}
return Race.create({
id: this.id,
leagueId: this.leagueId,
scheduledAt: this.scheduledAt,
track: this.track,
...(this.trackId !== undefined ? { trackId: this.trackId } : {}),
car: this.car,
...(this.carId !== undefined ? { carId: this.carId } : {}),
sessionType: this.sessionType,
status: this.status,
status: this.status.toString(),
strengthOfField,
registeredCount,
};
registeredCount: newRegisteredCount.toNumber(),
...(this.maxParticipants !== undefined ? { maxParticipants: this.maxParticipants.toNumber() } : {}),
});
}
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;
/**
* Add a participant to the race
*/
addParticipant(): Race {
if (!this.registeredCount) {
throw new RacingDomainInvariantError('Race must have a registered count initialized');
}
return Race.create(props);
const newCount = this.registeredCount.increment();
if (this.maxParticipants && !this.maxParticipants.canAccommodate(newCount.toNumber())) {
throw new RacingDomainInvariantError(
`Cannot add participant: race capacity (${this.maxParticipants.toNumber()}) would be exceeded`
);
}
return Race.create({
id: this.id,
leagueId: this.leagueId,
scheduledAt: this.scheduledAt,
track: this.track,
...(this.trackId !== undefined ? { trackId: this.trackId } : {}),
car: this.car,
...(this.carId !== undefined ? { carId: this.carId } : {}),
sessionType: this.sessionType,
status: this.status.toString(),
...(this.strengthOfField !== undefined ? { strengthOfField: this.strengthOfField } : {}),
registeredCount: newCount.toNumber(),
...(this.maxParticipants !== undefined ? { maxParticipants: this.maxParticipants.toNumber() } : {}),
});
}
/**
* Remove a participant from the race
*/
removeParticipant(): Race {
if (!this.registeredCount) {
throw new RacingDomainInvariantError('Race must have a registered count initialized');
}
if (this.registeredCount.isZero()) {
throw new RacingDomainInvariantError('Cannot remove participant: race has no participants');
}
const newCount = this.registeredCount.decrement();
return Race.create({
id: this.id,
leagueId: this.leagueId,
scheduledAt: this.scheduledAt,
track: this.track,
...(this.trackId !== undefined ? { trackId: this.trackId } : {}),
car: this.car,
...(this.carId !== undefined ? { carId: this.carId } : {}),
sessionType: this.sessionType,
status: this.status.toString(),
...(this.strengthOfField !== undefined ? { strengthOfField: this.strengthOfField } : {}),
registeredCount: newCount.toNumber(),
...(this.maxParticipants !== undefined ? { maxParticipants: this.maxParticipants.toNumber() } : {}),
});
}
/**
@@ -321,13 +351,35 @@ export class Race implements IEntity<string> {
* Check if race is upcoming
*/
isUpcoming(): boolean {
return this.status === 'scheduled' && !this.isPast();
return this.status.isScheduled() && !this.isPast();
}
/**
* Check if race is live/running
*/
isLive(): boolean {
return this.status === 'running';
return this.status.isRunning();
}
/**
* Check if race can accept more participants
*/
canAcceptMore(): boolean {
if (!this.maxParticipants || !this.registeredCount) return false;
return this.maxParticipants.canAccommodate(this.registeredCount.toNumber() + 1);
}
/**
* Get current registered count
*/
getRegisteredCount(): number {
return this.registeredCount ? this.registeredCount.toNumber() : 0;
}
/**
* Get max participants
*/
getMaxParticipants(): number | undefined {
return this.maxParticipants ? this.maxParticipants.toNumber() : undefined;
}
}