harden business rules
This commit is contained in:
@@ -12,10 +12,15 @@ import { LeagueDescription } from './LeagueDescription';
|
||||
import { LeagueOwnerId } from './LeagueOwnerId';
|
||||
import { LeagueCreatedAt } from './LeagueCreatedAt';
|
||||
import { LeagueSocialLinks } from './LeagueSocialLinks';
|
||||
import { LeagueVisibility, LeagueVisibilityType } from '../value-objects/LeagueVisibility';
|
||||
import { ParticipantCount } from '../value-objects/ParticipantCount';
|
||||
import { MaxParticipants } from '../value-objects/MaxParticipants';
|
||||
import { SessionDuration } from '../value-objects/SessionDuration';
|
||||
import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError';
|
||||
|
||||
/**
|
||||
* Stewarding decision mode for protests
|
||||
*/
|
||||
* Stewarding decision mode for protests
|
||||
*/
|
||||
export type StewardingDecisionMode =
|
||||
| 'admin_only' // Only admins can decide
|
||||
| 'steward_decides' // Single steward makes decision
|
||||
@@ -78,6 +83,11 @@ export interface LeagueSettings {
|
||||
* Stewarding settings for protest handling
|
||||
*/
|
||||
stewarding?: StewardingSettings;
|
||||
/**
|
||||
* League visibility type (ranked/unranked)
|
||||
* Determines participant requirements and rating impact
|
||||
*/
|
||||
visibility?: LeagueVisibilityType;
|
||||
}
|
||||
|
||||
export class League implements IEntity<LeagueId> {
|
||||
@@ -88,6 +98,10 @@ export class League implements IEntity<LeagueId> {
|
||||
readonly settings: LeagueSettings;
|
||||
readonly createdAt: LeagueCreatedAt;
|
||||
readonly socialLinks: LeagueSocialLinks | undefined;
|
||||
|
||||
// Domain state for business rule enforcement
|
||||
private readonly _participantCount: ParticipantCount;
|
||||
private readonly _visibility: LeagueVisibility;
|
||||
|
||||
private constructor(props: {
|
||||
id: LeagueId;
|
||||
@@ -97,6 +111,8 @@ export class League implements IEntity<LeagueId> {
|
||||
settings: LeagueSettings;
|
||||
createdAt: LeagueCreatedAt;
|
||||
socialLinks?: LeagueSocialLinks;
|
||||
participantCount: ParticipantCount;
|
||||
visibility: LeagueVisibility;
|
||||
}) {
|
||||
this.id = props.id;
|
||||
this.name = props.name;
|
||||
@@ -105,10 +121,13 @@ export class League implements IEntity<LeagueId> {
|
||||
this.settings = props.settings;
|
||||
this.createdAt = props.createdAt;
|
||||
this.socialLinks = props.socialLinks;
|
||||
this._participantCount = props.participantCount;
|
||||
this._visibility = props.visibility;
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory method to create a new League entity
|
||||
* Enforces all business rules and invariants
|
||||
*/
|
||||
static create(props: {
|
||||
id: string;
|
||||
@@ -122,13 +141,61 @@ export class League implements IEntity<LeagueId> {
|
||||
youtubeUrl?: string;
|
||||
websiteUrl?: string;
|
||||
};
|
||||
participantCount?: number;
|
||||
}): League {
|
||||
// Validate required fields
|
||||
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.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');
|
||||
}
|
||||
|
||||
const id = LeagueId.create(props.id);
|
||||
const name = LeagueName.create(props.name);
|
||||
const description = LeagueDescription.create(props.description);
|
||||
const ownerId = LeagueOwnerId.create(props.ownerId);
|
||||
const createdAt = LeagueCreatedAt.create(props.createdAt ?? new Date());
|
||||
|
||||
// Determine visibility from settings or default to ranked
|
||||
const visibilityType = props.settings?.visibility ?? 'ranked';
|
||||
const visibility = LeagueVisibility.fromString(visibilityType);
|
||||
|
||||
// Validate maxDrivers against visibility constraints
|
||||
const maxDrivers = props.settings?.maxDrivers ?? 32;
|
||||
const maxParticipants = MaxParticipants.create(maxDrivers);
|
||||
const maxValidation = visibility.validateMaxParticipants(maxParticipants.toNumber());
|
||||
if (!maxValidation.valid) {
|
||||
throw new RacingDomainValidationError(maxValidation.error!);
|
||||
}
|
||||
|
||||
// Validate session duration if provided
|
||||
if (props.settings?.sessionDuration !== undefined) {
|
||||
try {
|
||||
SessionDuration.create(props.settings.sessionDuration);
|
||||
} catch (error) {
|
||||
if (error instanceof RacingDomainValidationError) {
|
||||
throw error;
|
||||
}
|
||||
throw new RacingDomainValidationError('Invalid session duration');
|
||||
}
|
||||
}
|
||||
|
||||
// Validate stewarding settings if provided
|
||||
if (props.settings?.stewarding) {
|
||||
League.validateStewardingSettings(props.settings.stewarding);
|
||||
}
|
||||
|
||||
// Create default stewarding settings
|
||||
const defaultStewardingSettings: StewardingSettings = {
|
||||
decisionMode: 'admin_only',
|
||||
requireDefense: false,
|
||||
@@ -140,14 +207,31 @@ export class League implements IEntity<LeagueId> {
|
||||
notifyOnVoteRequired: true,
|
||||
};
|
||||
|
||||
// Build final settings with validation
|
||||
const defaultSettings: LeagueSettings = {
|
||||
pointsSystem: 'f1-2024',
|
||||
sessionDuration: 60,
|
||||
qualifyingFormat: 'open',
|
||||
maxDrivers: 32,
|
||||
maxDrivers: maxDrivers,
|
||||
visibility: visibilityType,
|
||||
stewarding: defaultStewardingSettings,
|
||||
};
|
||||
|
||||
const finalSettings = { ...defaultSettings, ...props.settings };
|
||||
|
||||
// Validate participant count against visibility and max
|
||||
const participantCount = ParticipantCount.create(props.participantCount ?? 0);
|
||||
const participantValidation = visibility.validateDriverCount(participantCount.toNumber());
|
||||
if (!participantValidation.valid) {
|
||||
throw new RacingDomainValidationError(participantValidation.error!);
|
||||
}
|
||||
|
||||
if (!maxParticipants.canAccommodate(participantCount.toNumber())) {
|
||||
throw new RacingDomainValidationError(
|
||||
`Participant count (${participantCount.toNumber()}) exceeds league capacity (${maxParticipants.toNumber()})`
|
||||
);
|
||||
}
|
||||
|
||||
const socialLinks = props.socialLinks ? LeagueSocialLinks.create(props.socialLinks) : undefined;
|
||||
|
||||
return new League({
|
||||
@@ -155,15 +239,58 @@ export class League implements IEntity<LeagueId> {
|
||||
name,
|
||||
description,
|
||||
ownerId,
|
||||
settings: { ...defaultSettings, ...props.settings },
|
||||
settings: finalSettings,
|
||||
createdAt,
|
||||
...(socialLinks !== undefined ? { socialLinks } : {}),
|
||||
participantCount,
|
||||
visibility,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate stewarding settings configuration
|
||||
*/
|
||||
private static validateStewardingSettings(settings: StewardingSettings): void {
|
||||
if (!settings.decisionMode) {
|
||||
throw new RacingDomainValidationError('Stewarding decision mode is required');
|
||||
}
|
||||
|
||||
const votingModes = ['steward_vote', 'member_vote', 'steward_veto', 'member_veto'];
|
||||
if (votingModes.includes(settings.decisionMode)) {
|
||||
if (!settings.requiredVotes || settings.requiredVotes <= 0) {
|
||||
throw new RacingDomainValidationError(
|
||||
'Stewarding settings with voting modes require a positive requiredVotes value'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (settings.requiredVotes !== undefined && !votingModes.includes(settings.decisionMode)) {
|
||||
throw new RacingDomainValidationError(
|
||||
'requiredVotes should only be provided for voting/veto modes'
|
||||
);
|
||||
}
|
||||
|
||||
// Validate time limits
|
||||
if (settings.defenseTimeLimit !== undefined && settings.defenseTimeLimit < 0) {
|
||||
throw new RacingDomainValidationError('Defense time limit must be non-negative');
|
||||
}
|
||||
|
||||
if (settings.voteTimeLimit !== undefined && settings.voteTimeLimit <= 0) {
|
||||
throw new RacingDomainValidationError('Vote time limit must be positive');
|
||||
}
|
||||
|
||||
if (settings.protestDeadlineHours !== undefined && settings.protestDeadlineHours <= 0) {
|
||||
throw new RacingDomainValidationError('Protest deadline must be positive');
|
||||
}
|
||||
|
||||
if (settings.stewardingClosesHours !== undefined && settings.stewardingClosesHours <= 0) {
|
||||
throw new RacingDomainValidationError('Stewarding close time must be positive');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a copy with updated properties
|
||||
* Validates all business rules on update
|
||||
*/
|
||||
update(props: Partial<{
|
||||
name: string;
|
||||
@@ -181,14 +308,162 @@ export class League implements IEntity<LeagueId> {
|
||||
const ownerId = props.ownerId ? LeagueOwnerId.create(props.ownerId) : this.ownerId;
|
||||
const socialLinks = props.socialLinks ? LeagueSocialLinks.create(props.socialLinks) : this.socialLinks;
|
||||
|
||||
// If settings are being updated, validate them
|
||||
let newSettings = props.settings ?? this.settings;
|
||||
if (props.settings) {
|
||||
// Validate visibility constraints if visibility is changing
|
||||
if (props.settings.visibility && props.settings.visibility !== this.settings.visibility) {
|
||||
const newVisibility = LeagueVisibility.fromString(props.settings.visibility);
|
||||
const maxDrivers = props.settings.maxDrivers ?? this.settings.maxDrivers ?? 32;
|
||||
|
||||
const maxValidation = newVisibility.validateMaxParticipants(maxDrivers);
|
||||
if (!maxValidation.valid) {
|
||||
throw new RacingDomainValidationError(maxValidation.error!);
|
||||
}
|
||||
|
||||
const participantValidation = newVisibility.validateDriverCount(this._participantCount.toNumber());
|
||||
if (!participantValidation.valid) {
|
||||
throw new RacingDomainValidationError(participantValidation.error!);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate session duration if changing
|
||||
if (props.settings.sessionDuration !== undefined && props.settings.sessionDuration !== this.settings.sessionDuration) {
|
||||
try {
|
||||
SessionDuration.create(props.settings.sessionDuration);
|
||||
} catch (error) {
|
||||
if (error instanceof RacingDomainValidationError) {
|
||||
throw error;
|
||||
}
|
||||
throw new RacingDomainValidationError('Invalid session duration');
|
||||
}
|
||||
}
|
||||
|
||||
// Validate max drivers if changing
|
||||
if (props.settings.maxDrivers !== undefined && props.settings.maxDrivers !== this.settings.maxDrivers) {
|
||||
const visibility = LeagueVisibility.fromString(this.settings.visibility ?? 'ranked');
|
||||
const maxValidation = visibility.validateMaxParticipants(props.settings.maxDrivers);
|
||||
if (!maxValidation.valid) {
|
||||
throw new RacingDomainValidationError(maxValidation.error!);
|
||||
}
|
||||
|
||||
if (props.settings.maxDrivers < this._participantCount.toNumber()) {
|
||||
throw new RacingDomainValidationError(
|
||||
`Cannot reduce max drivers (${props.settings.maxDrivers}) below current participant count (${this._participantCount.toNumber()})`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate stewarding settings if changing
|
||||
if (props.settings.stewarding) {
|
||||
League.validateStewardingSettings(props.settings.stewarding);
|
||||
}
|
||||
|
||||
newSettings = { ...this.settings, ...props.settings };
|
||||
}
|
||||
|
||||
return new League({
|
||||
id: this.id,
|
||||
name,
|
||||
description,
|
||||
ownerId,
|
||||
settings: props.settings ?? this.settings,
|
||||
settings: newSettings,
|
||||
createdAt: this.createdAt,
|
||||
...(socialLinks !== undefined ? { socialLinks } : {}),
|
||||
participantCount: this._participantCount,
|
||||
visibility: this._visibility,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a participant to the league
|
||||
* Validates against capacity and visibility constraints
|
||||
*/
|
||||
addParticipant(): League {
|
||||
const newCount = this._participantCount.increment();
|
||||
const maxDrivers = this.settings.maxDrivers ?? 32;
|
||||
|
||||
if (newCount.toNumber() > maxDrivers) {
|
||||
throw new RacingDomainInvariantError(
|
||||
`Cannot add participant: league capacity (${maxDrivers}) would be exceeded`
|
||||
);
|
||||
}
|
||||
|
||||
const visibility = LeagueVisibility.fromString(this.settings.visibility ?? 'ranked');
|
||||
const validation = visibility.validateDriverCount(newCount.toNumber());
|
||||
if (!validation.valid) {
|
||||
throw new RacingDomainInvariantError(validation.error!);
|
||||
}
|
||||
|
||||
return new League({
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
description: this.description,
|
||||
ownerId: this.ownerId,
|
||||
settings: this.settings,
|
||||
createdAt: this.createdAt,
|
||||
...(this.socialLinks !== undefined ? { socialLinks: this.socialLinks } : {}),
|
||||
participantCount: newCount,
|
||||
visibility: this._visibility,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a participant from the league
|
||||
*/
|
||||
removeParticipant(): League {
|
||||
if (this._participantCount.isZero()) {
|
||||
throw new RacingDomainInvariantError('Cannot remove participant: league has no participants');
|
||||
}
|
||||
|
||||
const newCount = this._participantCount.decrement();
|
||||
|
||||
return new League({
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
description: this.description,
|
||||
ownerId: this.ownerId,
|
||||
settings: this.settings,
|
||||
createdAt: this.createdAt,
|
||||
...(this.socialLinks !== undefined ? { socialLinks: this.socialLinks } : {}),
|
||||
participantCount: newCount,
|
||||
visibility: this._visibility,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current participant count
|
||||
*/
|
||||
getParticipantCount(): number {
|
||||
return this._participantCount.toNumber();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get league visibility
|
||||
*/
|
||||
getVisibility(): LeagueVisibility {
|
||||
return this._visibility;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if league is full
|
||||
*/
|
||||
isFull(): boolean {
|
||||
const maxDrivers = this.settings.maxDrivers ?? 32;
|
||||
return this._participantCount.toNumber() >= maxDrivers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if league meets minimum participant requirements
|
||||
*/
|
||||
meetsMinimumRequirements(): boolean {
|
||||
return this._visibility.meetsMinimumForVisibility(this._participantCount.toNumber());
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if league can accept more participants
|
||||
*/
|
||||
canAcceptMore(): boolean {
|
||||
return !this.isFull();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,3 @@
|
||||
export type SeasonStatus =
|
||||
| 'planned'
|
||||
| 'active'
|
||||
| 'completed'
|
||||
| 'archived'
|
||||
| 'cancelled';
|
||||
|
||||
import {
|
||||
RacingDomainInvariantError,
|
||||
RacingDomainValidationError,
|
||||
@@ -14,6 +7,9 @@ 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';
|
||||
import { SeasonStatus, SeasonStatusValue } from '../../value-objects/SeasonStatus';
|
||||
import { ParticipantCount } from '../../value-objects/ParticipantCount';
|
||||
import { MaxParticipants } from '../../value-objects/MaxParticipants';
|
||||
|
||||
export class Season implements IEntity<string> {
|
||||
readonly id: string;
|
||||
@@ -31,6 +27,9 @@ export class Season implements IEntity<string> {
|
||||
readonly stewardingConfig: SeasonStewardingConfig | undefined;
|
||||
readonly maxDrivers: number | undefined;
|
||||
|
||||
// Domain state for business rule enforcement
|
||||
private readonly _participantCount: ParticipantCount;
|
||||
|
||||
private constructor(props: {
|
||||
id: string;
|
||||
leagueId: string;
|
||||
@@ -46,6 +45,7 @@ export class Season implements IEntity<string> {
|
||||
dropPolicy?: SeasonDropPolicy;
|
||||
stewardingConfig?: SeasonStewardingConfig;
|
||||
maxDrivers?: number;
|
||||
participantCount: ParticipantCount;
|
||||
}) {
|
||||
this.id = props.id;
|
||||
this.leagueId = props.leagueId;
|
||||
@@ -61,6 +61,7 @@ export class Season implements IEntity<string> {
|
||||
this.dropPolicy = props.dropPolicy;
|
||||
this.stewardingConfig = props.stewardingConfig;
|
||||
this.maxDrivers = props.maxDrivers;
|
||||
this._participantCount = props.participantCount;
|
||||
}
|
||||
|
||||
static create(props: {
|
||||
@@ -70,7 +71,7 @@ export class Season implements IEntity<string> {
|
||||
name: string;
|
||||
year?: number;
|
||||
order?: number;
|
||||
status?: SeasonStatus;
|
||||
status?: SeasonStatusValue;
|
||||
startDate?: Date;
|
||||
endDate?: Date | undefined;
|
||||
schedule?: SeasonSchedule;
|
||||
@@ -78,7 +79,9 @@ export class Season implements IEntity<string> {
|
||||
dropPolicy?: SeasonDropPolicy;
|
||||
stewardingConfig?: SeasonStewardingConfig;
|
||||
maxDrivers?: number;
|
||||
participantCount?: number;
|
||||
}): Season {
|
||||
// Validate required fields
|
||||
if (!props.id || props.id.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('Season ID is required');
|
||||
}
|
||||
@@ -95,7 +98,58 @@ export class Season implements IEntity<string> {
|
||||
throw new RacingDomainValidationError('Season name is required');
|
||||
}
|
||||
|
||||
const status: SeasonStatus = props.status ?? 'planned';
|
||||
// Validate maxDrivers if provided
|
||||
if (props.maxDrivers !== undefined) {
|
||||
if (props.maxDrivers <= 0) {
|
||||
throw new RacingDomainValidationError('Season maxDrivers must be greater than 0 when provided');
|
||||
}
|
||||
if (props.maxDrivers > 100) {
|
||||
throw new RacingDomainValidationError('Season maxDrivers cannot exceed 100');
|
||||
}
|
||||
}
|
||||
|
||||
// Validate participant count
|
||||
const participantCount = ParticipantCount.create(props.participantCount ?? 0);
|
||||
if (props.maxDrivers !== undefined) {
|
||||
const maxParticipants = MaxParticipants.create(props.maxDrivers);
|
||||
if (!maxParticipants.canAccommodate(participantCount.toNumber())) {
|
||||
throw new RacingDomainValidationError(
|
||||
`Participant count (${participantCount.toNumber()}) exceeds season capacity (${maxParticipants.toNumber()})`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate status transitions if status is provided
|
||||
const status = SeasonStatus.create(props.status ?? 'planned');
|
||||
|
||||
// Validate schedule if provided
|
||||
if (props.schedule) {
|
||||
// Ensure schedule dates align with season dates
|
||||
if (props.startDate && props.schedule.startDate < props.startDate) {
|
||||
throw new RacingDomainValidationError('Schedule start date cannot be before season start date');
|
||||
}
|
||||
}
|
||||
|
||||
// Validate scoring config if provided
|
||||
if (props.scoringConfig) {
|
||||
if (!props.scoringConfig.scoringPresetId || props.scoringConfig.scoringPresetId.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('Scoring preset ID is required');
|
||||
}
|
||||
}
|
||||
|
||||
// Validate drop policy if provided
|
||||
if (props.dropPolicy) {
|
||||
if (props.dropPolicy.strategy === 'bestNResults' || props.dropPolicy.strategy === 'dropWorstN') {
|
||||
if (!props.dropPolicy.n || props.dropPolicy.n <= 0) {
|
||||
throw new RacingDomainValidationError('Drop policy requires positive n value for this strategy');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate stewarding config if provided
|
||||
if (props.stewardingConfig) {
|
||||
Season.validateStewardingConfig(props.stewardingConfig);
|
||||
}
|
||||
|
||||
return new Season({
|
||||
id: props.id,
|
||||
@@ -108,55 +162,110 @@ export class Season implements IEntity<string> {
|
||||
...(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.scoringConfig !== undefined ? { scoringConfig: props.scoringConfig } : {}),
|
||||
...(props.dropPolicy !== undefined ? { dropPolicy: props.dropPolicy } : {}),
|
||||
...(props.stewardingConfig !== undefined
|
||||
? { stewardingConfig: props.stewardingConfig }
|
||||
: {}),
|
||||
...(props.stewardingConfig !== undefined ? { stewardingConfig: props.stewardingConfig } : {}),
|
||||
...(props.maxDrivers !== undefined ? { maxDrivers: props.maxDrivers } : {}),
|
||||
participantCount,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate stewarding configuration
|
||||
*/
|
||||
private static validateStewardingConfig(config: SeasonStewardingConfig): void {
|
||||
if (!config.decisionMode) {
|
||||
throw new RacingDomainValidationError('Stewarding decision mode is required');
|
||||
}
|
||||
|
||||
const votingModes = ['steward_vote', 'member_vote', 'steward_veto', 'member_veto'];
|
||||
if (votingModes.includes(config.decisionMode)) {
|
||||
if (!config.requiredVotes || config.requiredVotes <= 0) {
|
||||
throw new RacingDomainValidationError(
|
||||
'Stewarding settings with voting modes require a positive requiredVotes value'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (config.requiredVotes !== undefined && !votingModes.includes(config.decisionMode)) {
|
||||
throw new RacingDomainValidationError(
|
||||
'requiredVotes should only be provided for voting/veto modes'
|
||||
);
|
||||
}
|
||||
|
||||
// Validate time limits
|
||||
if (config.defenseTimeLimit !== undefined && config.defenseTimeLimit < 0) {
|
||||
throw new RacingDomainValidationError('Defense time limit must be non-negative');
|
||||
}
|
||||
|
||||
if (config.voteTimeLimit !== undefined && config.voteTimeLimit <= 0) {
|
||||
throw new RacingDomainValidationError('Vote time limit must be positive');
|
||||
}
|
||||
|
||||
if (config.protestDeadlineHours !== undefined && config.protestDeadlineHours <= 0) {
|
||||
throw new RacingDomainValidationError('Protest deadline must be positive');
|
||||
}
|
||||
|
||||
if (config.stewardingClosesHours !== undefined && config.stewardingClosesHours <= 0) {
|
||||
throw new RacingDomainValidationError('Stewarding close time must be positive');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Domain rule: Wallet withdrawals are only allowed when season is completed
|
||||
*/
|
||||
canWithdrawFromWallet(): boolean {
|
||||
return this.status === 'completed';
|
||||
return this.status.isCompleted();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if season is active
|
||||
*/
|
||||
isActive(): boolean {
|
||||
return this.status === 'active';
|
||||
return this.status.isActive();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if season is completed
|
||||
*/
|
||||
isCompleted(): boolean {
|
||||
return this.status === 'completed';
|
||||
return this.status.isCompleted();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if season is planned (not yet active)
|
||||
*/
|
||||
isPlanned(): boolean {
|
||||
return this.status === 'planned';
|
||||
return this.status.isPlanned();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if season can accept more participants
|
||||
*/
|
||||
canAcceptMore(): boolean {
|
||||
if (this.maxDrivers === undefined) return true;
|
||||
return this._participantCount.toNumber() < this.maxDrivers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current participant count
|
||||
*/
|
||||
getParticipantCount(): number {
|
||||
return this._participantCount.toNumber();
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate the season from planned state.
|
||||
*/
|
||||
activate(): Season {
|
||||
if (this.status !== 'planned') {
|
||||
throw new RacingDomainInvariantError(
|
||||
'Only planned seasons can be activated',
|
||||
);
|
||||
const transition = this.status.canTransitionTo('active');
|
||||
if (!transition.valid) {
|
||||
throw new RacingDomainInvariantError(transition.error!);
|
||||
}
|
||||
|
||||
// Ensure start date is set
|
||||
const startDate = this.startDate ?? new Date();
|
||||
|
||||
return Season.create({
|
||||
id: this.id,
|
||||
leagueId: this.leagueId,
|
||||
@@ -165,19 +274,14 @@ export class Season implements IEntity<string> {
|
||||
...(this.year !== undefined ? { year: this.year } : {}),
|
||||
...(this.order !== undefined ? { order: this.order } : {}),
|
||||
status: 'active',
|
||||
...(this.startDate !== undefined ? { startDate: this.startDate } : {
|
||||
startDate: new Date(),
|
||||
}),
|
||||
startDate,
|
||||
...(this.endDate !== undefined ? { endDate: this.endDate } : {}),
|
||||
...(this.schedule !== undefined ? { schedule: this.schedule } : {}),
|
||||
...(this.scoringConfig !== undefined
|
||||
? { scoringConfig: this.scoringConfig }
|
||||
: {}),
|
||||
...(this.scoringConfig !== undefined ? { scoringConfig: this.scoringConfig } : {}),
|
||||
...(this.dropPolicy !== undefined ? { dropPolicy: this.dropPolicy } : {}),
|
||||
...(this.stewardingConfig !== undefined
|
||||
? { stewardingConfig: this.stewardingConfig }
|
||||
: {}),
|
||||
...(this.stewardingConfig !== undefined ? { stewardingConfig: this.stewardingConfig } : {}),
|
||||
...(this.maxDrivers !== undefined ? { maxDrivers: this.maxDrivers } : {}),
|
||||
participantCount: this._participantCount.toNumber(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -185,12 +289,14 @@ export class Season implements IEntity<string> {
|
||||
* Mark the season as completed.
|
||||
*/
|
||||
complete(): Season {
|
||||
if (this.status !== 'active') {
|
||||
throw new RacingDomainInvariantError(
|
||||
'Only active seasons can be completed',
|
||||
);
|
||||
const transition = this.status.canTransitionTo('completed');
|
||||
if (!transition.valid) {
|
||||
throw new RacingDomainInvariantError(transition.error!);
|
||||
}
|
||||
|
||||
// Ensure end date is set
|
||||
const endDate = this.endDate ?? new Date();
|
||||
|
||||
return Season.create({
|
||||
id: this.id,
|
||||
leagueId: this.leagueId,
|
||||
@@ -200,18 +306,13 @@ export class Season implements IEntity<string> {
|
||||
...(this.order !== undefined ? { order: this.order } : {}),
|
||||
status: 'completed',
|
||||
...(this.startDate !== undefined ? { startDate: this.startDate } : {}),
|
||||
...(this.endDate !== undefined ? { endDate: this.endDate } : {
|
||||
endDate: new Date(),
|
||||
}),
|
||||
endDate,
|
||||
...(this.schedule !== undefined ? { schedule: this.schedule } : {}),
|
||||
...(this.scoringConfig !== undefined
|
||||
? { scoringConfig: this.scoringConfig }
|
||||
: {}),
|
||||
...(this.scoringConfig !== undefined ? { scoringConfig: this.scoringConfig } : {}),
|
||||
...(this.dropPolicy !== undefined ? { dropPolicy: this.dropPolicy } : {}),
|
||||
...(this.stewardingConfig !== undefined
|
||||
? { stewardingConfig: this.stewardingConfig }
|
||||
: {}),
|
||||
...(this.stewardingConfig !== undefined ? { stewardingConfig: this.stewardingConfig } : {}),
|
||||
...(this.maxDrivers !== undefined ? { maxDrivers: this.maxDrivers } : {}),
|
||||
participantCount: this._participantCount.toNumber(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -219,10 +320,9 @@ export class Season implements IEntity<string> {
|
||||
* Archive a completed season.
|
||||
*/
|
||||
archive(): Season {
|
||||
if (!this.isCompleted()) {
|
||||
throw new RacingDomainInvariantError(
|
||||
'Only completed seasons can be archived',
|
||||
);
|
||||
const transition = this.status.canTransitionTo('archived');
|
||||
if (!transition.valid) {
|
||||
throw new RacingDomainInvariantError(transition.error!);
|
||||
}
|
||||
|
||||
return Season.create({
|
||||
@@ -236,14 +336,11 @@ export class Season implements IEntity<string> {
|
||||
...(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.scoringConfig !== undefined ? { scoringConfig: this.scoringConfig } : {}),
|
||||
...(this.dropPolicy !== undefined ? { dropPolicy: this.dropPolicy } : {}),
|
||||
...(this.stewardingConfig !== undefined
|
||||
? { stewardingConfig: this.stewardingConfig }
|
||||
: {}),
|
||||
...(this.stewardingConfig !== undefined ? { stewardingConfig: this.stewardingConfig } : {}),
|
||||
...(this.maxDrivers !== undefined ? { maxDrivers: this.maxDrivers } : {}),
|
||||
participantCount: this._participantCount.toNumber(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -251,16 +348,19 @@ export class Season implements IEntity<string> {
|
||||
* 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',
|
||||
);
|
||||
const transition = this.status.canTransitionTo('cancelled');
|
||||
if (!transition.valid) {
|
||||
throw new RacingDomainInvariantError(transition.error!);
|
||||
}
|
||||
|
||||
if (this.status === 'cancelled') {
|
||||
// If already cancelled, return this
|
||||
if (this.status.isCancelled()) {
|
||||
return this;
|
||||
}
|
||||
|
||||
// Ensure end date is set
|
||||
const endDate = this.endDate ?? new Date();
|
||||
|
||||
return Season.create({
|
||||
id: this.id,
|
||||
leagueId: this.leagueId,
|
||||
@@ -270,18 +370,13 @@ export class Season implements IEntity<string> {
|
||||
...(this.order !== undefined ? { order: this.order } : {}),
|
||||
status: 'cancelled',
|
||||
...(this.startDate !== undefined ? { startDate: this.startDate } : {}),
|
||||
...(this.endDate !== undefined ? { endDate: this.endDate } : {
|
||||
endDate: new Date(),
|
||||
}),
|
||||
endDate,
|
||||
...(this.schedule !== undefined ? { schedule: this.schedule } : {}),
|
||||
...(this.scoringConfig !== undefined
|
||||
? { scoringConfig: this.scoringConfig }
|
||||
: {}),
|
||||
...(this.scoringConfig !== undefined ? { scoringConfig: this.scoringConfig } : {}),
|
||||
...(this.dropPolicy !== undefined ? { dropPolicy: this.dropPolicy } : {}),
|
||||
...(this.stewardingConfig !== undefined
|
||||
? { stewardingConfig: this.stewardingConfig }
|
||||
: {}),
|
||||
...(this.stewardingConfig !== undefined ? { stewardingConfig: this.stewardingConfig } : {}),
|
||||
...(this.maxDrivers !== undefined ? { maxDrivers: this.maxDrivers } : {}),
|
||||
participantCount: this._participantCount.toNumber(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -289,110 +384,9 @@ export class Season implements IEntity<string> {
|
||||
* 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',
|
||||
);
|
||||
// Validate schedule against season dates
|
||||
if (this.startDate && schedule.startDate < this.startDate) {
|
||||
throw new RacingDomainValidationError('Schedule start date cannot be before season start date');
|
||||
}
|
||||
|
||||
return Season.create({
|
||||
@@ -402,18 +396,194 @@ export class Season implements IEntity<string> {
|
||||
name: this.name,
|
||||
...(this.year !== undefined ? { year: this.year } : {}),
|
||||
...(this.order !== undefined ? { order: this.order } : {}),
|
||||
status: this.status,
|
||||
status: this.status.toString(),
|
||||
...(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 } : {}),
|
||||
participantCount: this._participantCount.toNumber(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update scoring configuration for the season.
|
||||
*/
|
||||
withScoringConfig(scoringConfig: SeasonScoringConfig): Season {
|
||||
if (!scoringConfig.scoringPresetId || scoringConfig.scoringPresetId.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('Scoring preset ID is required');
|
||||
}
|
||||
|
||||
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.toString(),
|
||||
...(this.startDate !== undefined ? { startDate: this.startDate } : {}),
|
||||
...(this.endDate !== undefined ? { endDate: this.endDate } : {}),
|
||||
...(this.schedule !== undefined ? { schedule: this.schedule } : {}),
|
||||
...(this.scoringConfig !== undefined
|
||||
? { scoringConfig: this.scoringConfig }
|
||||
: {}),
|
||||
scoringConfig,
|
||||
...(this.dropPolicy !== undefined ? { dropPolicy: this.dropPolicy } : {}),
|
||||
...(this.stewardingConfig !== undefined
|
||||
? { stewardingConfig: this.stewardingConfig }
|
||||
: {}),
|
||||
...(this.stewardingConfig !== undefined ? { stewardingConfig: this.stewardingConfig } : {}),
|
||||
...(this.maxDrivers !== undefined ? { maxDrivers: this.maxDrivers } : {}),
|
||||
participantCount: this._participantCount.toNumber(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update drop policy for the season.
|
||||
*/
|
||||
withDropPolicy(dropPolicy: SeasonDropPolicy): Season {
|
||||
// Validate drop policy
|
||||
if (dropPolicy.strategy === 'bestNResults' || dropPolicy.strategy === 'dropWorstN') {
|
||||
if (!dropPolicy.n || dropPolicy.n <= 0) {
|
||||
throw new RacingDomainValidationError('Drop policy requires positive n value for this strategy');
|
||||
}
|
||||
}
|
||||
|
||||
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.toString(),
|
||||
...(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 } : {}),
|
||||
participantCount: this._participantCount.toNumber(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update stewarding configuration for the season.
|
||||
*/
|
||||
withStewardingConfig(stewardingConfig: SeasonStewardingConfig): Season {
|
||||
Season.validateStewardingConfig(stewardingConfig);
|
||||
|
||||
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.toString(),
|
||||
...(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 } : {}),
|
||||
participantCount: this._participantCount.toNumber(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update max driver capacity for the season.
|
||||
*/
|
||||
withMaxDrivers(maxDrivers: number | undefined): Season {
|
||||
if (maxDrivers !== undefined) {
|
||||
if (maxDrivers <= 0) {
|
||||
throw new RacingDomainValidationError('Season maxDrivers must be greater than 0 when provided');
|
||||
}
|
||||
if (maxDrivers > 100) {
|
||||
throw new RacingDomainValidationError('Season maxDrivers cannot exceed 100');
|
||||
}
|
||||
if (maxDrivers < this._participantCount.toNumber()) {
|
||||
throw new RacingDomainValidationError(
|
||||
`Cannot reduce max drivers (${maxDrivers}) below current participant count (${this._participantCount.toNumber()})`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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.toString(),
|
||||
...(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 } : {}),
|
||||
participantCount: this._participantCount.toNumber(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a participant to the season
|
||||
*/
|
||||
addParticipant(): Season {
|
||||
if (!this.canAcceptMore()) {
|
||||
throw new RacingDomainInvariantError(
|
||||
`Season capacity (${this.maxDrivers}) would be exceeded`
|
||||
);
|
||||
}
|
||||
|
||||
const newCount = this._participantCount.increment();
|
||||
|
||||
return Season.create({
|
||||
id: this.id,
|
||||
leagueId: this.leagueId,
|
||||
gameId: this.gameId,
|
||||
name: this.name,
|
||||
year: this.year,
|
||||
order: this.order,
|
||||
status: this.status.toString(),
|
||||
startDate: this.startDate,
|
||||
endDate: this.endDate,
|
||||
schedule: this.schedule,
|
||||
scoringConfig: this.scoringConfig,
|
||||
dropPolicy: this.dropPolicy,
|
||||
stewardingConfig: this.stewardingConfig,
|
||||
maxDrivers: this.maxDrivers,
|
||||
participantCount: newCount.toNumber(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a participant from the season
|
||||
*/
|
||||
removeParticipant(): Season {
|
||||
if (this._participantCount.isZero()) {
|
||||
throw new RacingDomainInvariantError('Cannot remove participant: season has no participants');
|
||||
}
|
||||
|
||||
const newCount = this._participantCount.decrement();
|
||||
|
||||
return Season.create({
|
||||
id: this.id,
|
||||
leagueId: this.leagueId,
|
||||
gameId: this.gameId,
|
||||
name: this.name,
|
||||
year: this.year,
|
||||
order: this.order,
|
||||
status: this.status.toString(),
|
||||
startDate: this.startDate,
|
||||
endDate: this.endDate,
|
||||
schedule: this.schedule,
|
||||
scoringConfig: this.scoringConfig,
|
||||
dropPolicy: this.dropPolicy,
|
||||
stewardingConfig: this.stewardingConfig,
|
||||
maxDrivers: this.maxDrivers,
|
||||
participantCount: newCount.toNumber(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,10 @@
|
||||
/**
|
||||
* 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.
|
||||
* This is a hardened version that enforces strict business rules.
|
||||
*/
|
||||
|
||||
|
||||
import type { IValueObject } from '@core/shared/domain';
|
||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||
|
||||
@@ -16,6 +12,7 @@ export type LeagueVisibilityType = 'ranked' | 'unranked';
|
||||
|
||||
export interface LeagueVisibilityConstraints {
|
||||
readonly minDrivers: number;
|
||||
readonly maxDrivers: number;
|
||||
readonly isPubliclyVisible: boolean;
|
||||
readonly affectsRatings: boolean;
|
||||
readonly requiresApproval: boolean;
|
||||
@@ -24,22 +21,24 @@ export interface LeagueVisibilityConstraints {
|
||||
const VISIBILITY_CONSTRAINTS: Record<LeagueVisibilityType, LeagueVisibilityConstraints> = {
|
||||
ranked: {
|
||||
minDrivers: 10,
|
||||
maxDrivers: 100,
|
||||
isPubliclyVisible: true,
|
||||
affectsRatings: true,
|
||||
requiresApproval: false, // Anyone can join public leagues
|
||||
requiresApproval: false,
|
||||
},
|
||||
unranked: {
|
||||
minDrivers: 2,
|
||||
maxDrivers: 50,
|
||||
isPubliclyVisible: false,
|
||||
affectsRatings: false,
|
||||
requiresApproval: true, // Private leagues require invite/approval
|
||||
requiresApproval: true,
|
||||
},
|
||||
};
|
||||
|
||||
export interface LeagueVisibilityProps {
|
||||
type: LeagueVisibilityType;
|
||||
}
|
||||
|
||||
|
||||
export class LeagueVisibility implements IValueObject<LeagueVisibilityProps> {
|
||||
readonly type: LeagueVisibilityType;
|
||||
readonly constraints: LeagueVisibilityConstraints;
|
||||
@@ -58,7 +57,6 @@ export class LeagueVisibility implements IValueObject<LeagueVisibilityProps> {
|
||||
}
|
||||
|
||||
static fromString(value: string): LeagueVisibility {
|
||||
// Support both old ('public'/'private') and new ('ranked'/'unranked') terminology
|
||||
if (value === 'ranked' || value === 'public') {
|
||||
return LeagueVisibility.ranked();
|
||||
}
|
||||
@@ -70,32 +68,76 @@ export class LeagueVisibility implements IValueObject<LeagueVisibilityProps> {
|
||||
|
||||
/**
|
||||
* 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`,
|
||||
error: `${this.type === 'ranked' ? 'Ranked' : 'Unranked'} leagues require at least ${this.constraints.minDrivers} drivers`
|
||||
};
|
||||
}
|
||||
if (driverCount > this.constraints.maxDrivers) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `${this.type === 'ranked' ? 'Ranked' : 'Unranked'} leagues cannot exceed ${this.constraints.maxDrivers} drivers`
|
||||
};
|
||||
}
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if this is a ranked/public league
|
||||
* Validates that the given max participants is appropriate for this visibility
|
||||
*/
|
||||
validateMaxParticipants(maxParticipants: number): { valid: boolean; error?: string } {
|
||||
if (maxParticipants < this.constraints.minDrivers) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Max participants must be at least ${this.constraints.minDrivers} for ${this.type} leagues`
|
||||
};
|
||||
}
|
||||
if (maxParticipants > this.constraints.maxDrivers) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Max participants cannot exceed ${this.constraints.maxDrivers} for ${this.type} leagues`
|
||||
};
|
||||
}
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is a ranked/public league
|
||||
*/
|
||||
isRanked(): boolean {
|
||||
return this.type === 'ranked';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if this is an unranked/private league
|
||||
* Check if this is an unranked/private league
|
||||
*/
|
||||
isUnranked(): boolean {
|
||||
return this.type === 'unranked';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get minimum required drivers
|
||||
*/
|
||||
getMinDrivers(): number {
|
||||
return this.constraints.minDrivers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get maximum allowed drivers
|
||||
*/
|
||||
getMaxDrivers(): number {
|
||||
return this.constraints.maxDrivers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the given driver count meets minimum requirements
|
||||
*/
|
||||
meetsMinimumForVisibility(driverCount: number): boolean {
|
||||
return driverCount >= this.constraints.minDrivers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to string for serialization
|
||||
@@ -104,10 +146,6 @@ export class LeagueVisibility implements IValueObject<LeagueVisibilityProps> {
|
||||
return this.type;
|
||||
}
|
||||
|
||||
get props(): LeagueVisibilityProps {
|
||||
return { type: this.type };
|
||||
}
|
||||
|
||||
/**
|
||||
* For backward compatibility with existing 'public'/'private' terminology
|
||||
*/
|
||||
@@ -115,6 +153,10 @@ export class LeagueVisibility implements IValueObject<LeagueVisibilityProps> {
|
||||
return this.type === 'ranked' ? 'public' : 'private';
|
||||
}
|
||||
|
||||
get props(): LeagueVisibilityProps {
|
||||
return { type: this.type };
|
||||
}
|
||||
|
||||
equals(other: IValueObject<LeagueVisibilityProps>): boolean {
|
||||
return this.props.type === other.props.type;
|
||||
}
|
||||
@@ -122,4 +164,6 @@ export class LeagueVisibility implements IValueObject<LeagueVisibilityProps> {
|
||||
|
||||
// Export constants for validation
|
||||
export const MIN_RANKED_LEAGUE_DRIVERS = VISIBILITY_CONSTRAINTS.ranked.minDrivers;
|
||||
export const MIN_UNRANKED_LEAGUE_DRIVERS = VISIBILITY_CONSTRAINTS.unranked.minDrivers;
|
||||
export const MAX_RANKED_LEAGUE_DRIVERS = VISIBILITY_CONSTRAINTS.ranked.maxDrivers;
|
||||
export const MIN_UNRANKED_LEAGUE_DRIVERS = VISIBILITY_CONSTRAINTS.unranked.minDrivers;
|
||||
export const MAX_UNRANKED_LEAGUE_DRIVERS = VISIBILITY_CONSTRAINTS.unranked.maxDrivers;
|
||||
97
core/racing/domain/value-objects/MaxParticipants.ts
Normal file
97
core/racing/domain/value-objects/MaxParticipants.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Domain Value Object: MaxParticipants
|
||||
*
|
||||
* Represents the maximum number of participants allowed in a league or race.
|
||||
* Enforces reasonable limits and constraints.
|
||||
*/
|
||||
|
||||
import type { IValueObject } from '@core/shared/domain';
|
||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||
|
||||
export interface MaxParticipantsProps {
|
||||
value: number;
|
||||
}
|
||||
|
||||
export class MaxParticipants implements IValueObject<MaxParticipantsProps> {
|
||||
readonly value: number;
|
||||
|
||||
private constructor(value: number) {
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
static create(value: number): MaxParticipants {
|
||||
if (!Number.isInteger(value) || value <= 0) {
|
||||
throw new RacingDomainValidationError('Max participants must be a positive integer');
|
||||
}
|
||||
|
||||
// Enforce reasonable upper limit to prevent system abuse
|
||||
if (value > 100) {
|
||||
throw new RacingDomainValidationError('Max participants cannot exceed 100');
|
||||
}
|
||||
|
||||
return new MaxParticipants(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that max participants meets minimum requirements for ranked leagues
|
||||
*/
|
||||
validateForRankedLeague(): { valid: boolean; error?: string } {
|
||||
if (this.value < 10) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Ranked leagues must allow at least 10 participants'
|
||||
};
|
||||
}
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that max participants meets minimum requirements for unranked leagues
|
||||
*/
|
||||
validateForUnrankedLeague(): { valid: boolean; error?: string } {
|
||||
if (this.value < 2) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Unranked leagues must allow at least 2 participants'
|
||||
};
|
||||
}
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if max participants is sufficient for the given participant count
|
||||
*/
|
||||
canAccommodate(participantCount: number): boolean {
|
||||
return participantCount <= this.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if max participants is at least the given minimum
|
||||
*/
|
||||
isAtLeast(min: number): boolean {
|
||||
return this.value >= min;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if max participants is exactly the given value
|
||||
*/
|
||||
isExactly(value: number): boolean {
|
||||
return this.value === value;
|
||||
}
|
||||
|
||||
get props(): MaxParticipantsProps {
|
||||
return { value: this.value };
|
||||
}
|
||||
|
||||
equals(other: IValueObject<MaxParticipantsProps>): boolean {
|
||||
return this.value === other.props.value;
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return this.value.toString();
|
||||
}
|
||||
|
||||
toNumber(): number {
|
||||
return this.value;
|
||||
}
|
||||
}
|
||||
121
core/racing/domain/value-objects/ParticipantCount.ts
Normal file
121
core/racing/domain/value-objects/ParticipantCount.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Domain Value Object: ParticipantCount
|
||||
*
|
||||
* Represents the number of participants in a league or race.
|
||||
* Enforces constraints based on league visibility and other business rules.
|
||||
*/
|
||||
|
||||
import type { IValueObject } from '@core/shared/domain';
|
||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||
|
||||
export interface ParticipantCountProps {
|
||||
value: number;
|
||||
}
|
||||
|
||||
export class ParticipantCount implements IValueObject<ParticipantCountProps> {
|
||||
readonly value: number;
|
||||
|
||||
private constructor(value: number) {
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
static create(value: number): ParticipantCount {
|
||||
if (!Number.isInteger(value) || value < 0) {
|
||||
throw new RacingDomainValidationError('Participant count must be a non-negative integer');
|
||||
}
|
||||
return new ParticipantCount(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate against minimum requirements for ranked leagues
|
||||
*/
|
||||
validateForRankedLeague(): { valid: boolean; error?: string } {
|
||||
if (this.value < 10) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Ranked leagues require at least 10 participants'
|
||||
};
|
||||
}
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate against minimum requirements for unranked leagues
|
||||
*/
|
||||
validateForUnrankedLeague(): { valid: boolean; error?: string } {
|
||||
if (this.value < 2) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Unranked leagues require at least 2 participants'
|
||||
};
|
||||
}
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate against maximum capacity
|
||||
*/
|
||||
validateAgainstMax(maxParticipants: number): { valid: boolean; error?: string } {
|
||||
if (this.value > maxParticipants) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Participant count (${this.value}) exceeds maximum capacity (${maxParticipants})`
|
||||
};
|
||||
}
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if count meets minimum for given visibility type
|
||||
*/
|
||||
meetsMinimumForVisibility(isRanked: boolean): boolean {
|
||||
return isRanked ? this.value >= 10 : this.value >= 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment count by 1
|
||||
*/
|
||||
increment(): ParticipantCount {
|
||||
return new ParticipantCount(this.value + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrement count by 1 (if > 0)
|
||||
*/
|
||||
decrement(): ParticipantCount {
|
||||
if (this.value === 0) {
|
||||
throw new RacingDomainValidationError('Cannot decrement below zero');
|
||||
}
|
||||
return new ParticipantCount(this.value - 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if count is zero
|
||||
*/
|
||||
isZero(): boolean {
|
||||
return this.value === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if count is at least the given minimum
|
||||
*/
|
||||
isAtLeast(min: number): boolean {
|
||||
return this.value >= min;
|
||||
}
|
||||
|
||||
get props(): ParticipantCountProps {
|
||||
return { value: this.value };
|
||||
}
|
||||
|
||||
equals(other: IValueObject<ParticipantCountProps>): boolean {
|
||||
return this.value === other.props.value;
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return this.value.toString();
|
||||
}
|
||||
|
||||
toNumber(): number {
|
||||
return this.value;
|
||||
}
|
||||
}
|
||||
128
core/racing/domain/value-objects/RaceStatus.ts
Normal file
128
core/racing/domain/value-objects/RaceStatus.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Domain Value Object: RaceStatus
|
||||
*
|
||||
* Represents the status of a race with strict lifecycle rules.
|
||||
*/
|
||||
|
||||
import type { IValueObject } from '@core/shared/domain';
|
||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||
|
||||
export type RaceStatusValue = 'scheduled' | 'running' | 'completed' | 'cancelled';
|
||||
|
||||
export interface RaceStatusProps {
|
||||
value: RaceStatusValue;
|
||||
}
|
||||
|
||||
export class RaceStatus implements IValueObject<RaceStatusProps> {
|
||||
readonly value: RaceStatusValue;
|
||||
|
||||
private constructor(value: RaceStatusValue) {
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
static create(value: RaceStatusValue): RaceStatus {
|
||||
if (!value) {
|
||||
throw new RacingDomainValidationError('Race status is required');
|
||||
}
|
||||
return new RaceStatus(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if race can be started
|
||||
*/
|
||||
canStart(): boolean {
|
||||
return this.value === 'scheduled';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if race can be completed
|
||||
*/
|
||||
canComplete(): boolean {
|
||||
return this.value === 'running';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if race can be cancelled
|
||||
*/
|
||||
canCancel(): boolean {
|
||||
return this.value === 'scheduled' || this.value === 'running';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if race can be reopened
|
||||
*/
|
||||
canReopen(): boolean {
|
||||
return this.value === 'completed' || this.value === 'cancelled';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if race is in a terminal state
|
||||
*/
|
||||
isTerminal(): boolean {
|
||||
return this.value === 'completed' || this.value === 'cancelled';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if race is running
|
||||
*/
|
||||
isRunning(): boolean {
|
||||
return this.value === 'running';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if race is completed
|
||||
*/
|
||||
isCompleted(): boolean {
|
||||
return this.value === 'completed';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if race is scheduled
|
||||
*/
|
||||
isScheduled(): boolean {
|
||||
return this.value === 'scheduled';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if race is cancelled
|
||||
*/
|
||||
isCancelled(): boolean {
|
||||
return this.value === 'cancelled';
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate transition from current status to target status
|
||||
*/
|
||||
canTransitionTo(target: RaceStatusValue): { valid: boolean; error?: string } {
|
||||
const current = this.value;
|
||||
|
||||
// Define allowed transitions
|
||||
const allowedTransitions: Record<RaceStatusValue, RaceStatusValue[]> = {
|
||||
scheduled: ['running', 'cancelled'],
|
||||
running: ['completed', 'cancelled'],
|
||||
completed: [],
|
||||
cancelled: [],
|
||||
};
|
||||
|
||||
if (!allowedTransitions[current].includes(target)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Cannot transition from ${current} to ${target}`
|
||||
};
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
get props(): RaceStatusProps {
|
||||
return { value: this.value };
|
||||
}
|
||||
|
||||
equals(other: IValueObject<RaceStatusProps>): boolean {
|
||||
return this.value === other.props.value;
|
||||
}
|
||||
|
||||
toString(): RaceStatusValue {
|
||||
return this.value;
|
||||
}
|
||||
}
|
||||
136
core/racing/domain/value-objects/SeasonStatus.ts
Normal file
136
core/racing/domain/value-objects/SeasonStatus.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Domain Value Object: SeasonStatus
|
||||
*
|
||||
* Represents the status of a season with strict lifecycle rules.
|
||||
*/
|
||||
|
||||
import type { IValueObject } from '@core/shared/domain';
|
||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||
|
||||
export type SeasonStatusValue = 'planned' | 'active' | 'completed' | 'archived' | 'cancelled';
|
||||
|
||||
export interface SeasonStatusProps {
|
||||
value: SeasonStatusValue;
|
||||
}
|
||||
|
||||
export class SeasonStatus implements IValueObject<SeasonStatusProps> {
|
||||
readonly value: SeasonStatusValue;
|
||||
|
||||
private constructor(value: SeasonStatusValue) {
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
static create(value: SeasonStatusValue): SeasonStatus {
|
||||
if (!value) {
|
||||
throw new RacingDomainValidationError('Season status is required');
|
||||
}
|
||||
return new SeasonStatus(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if season can be activated
|
||||
*/
|
||||
canActivate(): boolean {
|
||||
return this.value === 'planned';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if season can be completed
|
||||
*/
|
||||
canComplete(): boolean {
|
||||
return this.value === 'active';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if season can be archived
|
||||
*/
|
||||
canArchive(): boolean {
|
||||
return this.value === 'completed';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if season can be cancelled
|
||||
*/
|
||||
canCancel(): boolean {
|
||||
return this.value === 'planned' || this.value === 'active';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if season is in a terminal state
|
||||
*/
|
||||
isTerminal(): boolean {
|
||||
return this.value === 'completed' || this.value === 'archived' || this.value === 'cancelled';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if season is active
|
||||
*/
|
||||
isActive(): boolean {
|
||||
return this.value === 'active';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if season is completed
|
||||
*/
|
||||
isCompleted(): boolean {
|
||||
return this.value === 'completed';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if season is planned
|
||||
*/
|
||||
isPlanned(): boolean {
|
||||
return this.value === 'planned';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if season is archived
|
||||
*/
|
||||
isArchived(): boolean {
|
||||
return this.value === 'archived';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if season is cancelled
|
||||
*/
|
||||
isCancelled(): boolean {
|
||||
return this.value === 'cancelled';
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate transition from current status to target status
|
||||
*/
|
||||
canTransitionTo(target: SeasonStatusValue): { valid: boolean; error?: string } {
|
||||
const current = this.value;
|
||||
|
||||
// Define allowed transitions
|
||||
const allowedTransitions: Record<SeasonStatusValue, SeasonStatusValue[]> = {
|
||||
planned: ['active', 'cancelled'],
|
||||
active: ['completed', 'cancelled'],
|
||||
completed: ['archived'],
|
||||
archived: [],
|
||||
cancelled: [],
|
||||
};
|
||||
|
||||
if (!allowedTransitions[current].includes(target)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Cannot transition from ${current} to ${target}`
|
||||
};
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
get props(): SeasonStatusProps {
|
||||
return { value: this.value };
|
||||
}
|
||||
|
||||
equals(other: IValueObject<SeasonStatusProps>): boolean {
|
||||
return this.value === other.props.value;
|
||||
}
|
||||
|
||||
toString(): SeasonStatusValue {
|
||||
return this.value;
|
||||
}
|
||||
}
|
||||
98
core/racing/domain/value-objects/SessionDuration.ts
Normal file
98
core/racing/domain/value-objects/SessionDuration.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Domain Value Object: SessionDuration
|
||||
*
|
||||
* Represents the duration of a racing session in minutes.
|
||||
* Enforces reasonable limits for different session types.
|
||||
*/
|
||||
|
||||
import type { IValueObject } from '@core/shared/domain';
|
||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||
|
||||
export interface SessionDurationProps {
|
||||
value: number;
|
||||
}
|
||||
|
||||
export class SessionDuration implements IValueObject<SessionDurationProps> {
|
||||
readonly value: number;
|
||||
|
||||
private constructor(value: number) {
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
static create(value: number): SessionDuration {
|
||||
if (!Number.isInteger(value) || value <= 0) {
|
||||
throw new RacingDomainValidationError('Session duration must be a positive integer');
|
||||
}
|
||||
|
||||
// Enforce reasonable limits
|
||||
if (value < 15) {
|
||||
throw new RacingDomainValidationError('Session duration must be at least 15 minutes');
|
||||
}
|
||||
|
||||
if (value > 240) {
|
||||
throw new RacingDomainValidationError('Session duration cannot exceed 240 minutes (4 hours)');
|
||||
}
|
||||
|
||||
return new SessionDuration(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if duration is suitable for sprint racing
|
||||
*/
|
||||
isSprint(): boolean {
|
||||
return this.value >= 15 && this.value <= 45;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if duration is suitable for standard racing
|
||||
*/
|
||||
isStandard(): boolean {
|
||||
return this.value > 45 && this.value <= 90;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if duration is suitable for endurance racing
|
||||
*/
|
||||
isEndurance(): boolean {
|
||||
return this.value > 90;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get duration classification
|
||||
*/
|
||||
getClassification(): 'sprint' | 'standard' | 'endurance' {
|
||||
if (this.isSprint()) return 'sprint';
|
||||
if (this.isStandard()) return 'standard';
|
||||
return 'endurance';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if duration is within specified range
|
||||
*/
|
||||
isWithinRange(min: number, max: number): boolean {
|
||||
return this.value >= min && this.value <= max;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get duration in hours
|
||||
*/
|
||||
inHours(): number {
|
||||
return this.value / 60;
|
||||
}
|
||||
|
||||
get props(): SessionDurationProps {
|
||||
return { value: this.value };
|
||||
}
|
||||
|
||||
equals(other: IValueObject<SessionDurationProps>): boolean {
|
||||
return this.value === other.props.value;
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return `${this.value} minutes`;
|
||||
}
|
||||
|
||||
toNumber(): number {
|
||||
return this.value;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user