harden business rules

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

View File

@@ -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;

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

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

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

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

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