harden business rules
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user