/** * Domain Entity: League * * Represents a league in the GridPilot platform. * Immutable entity with factory methods and domain validation. */ import { MediaReference } from '@core/domain/media/MediaReference'; import { Entity } from '@core/shared/domain/Entity'; import { RacingDomainInvariantError, RacingDomainValidationError } from '../errors/RacingDomainError'; import { LeagueVisibility, LeagueVisibilityType } from '../value-objects/LeagueVisibility'; import { MaxParticipants } from '../value-objects/MaxParticipants'; import { ParticipantCount } from '../value-objects/ParticipantCount'; import { SessionDuration } from '../value-objects/SessionDuration'; import { LeagueCreatedAt } from './LeagueCreatedAt'; import { LeagueDescription } from './LeagueDescription'; import { LeagueId } from './LeagueId'; import { LeagueName } from './LeagueName'; import { LeagueOwnerId } from './LeagueOwnerId'; import { LeagueSocialLinks } from './LeagueSocialLinks'; /** * Stewarding decision mode for protests */ export type StewardingDecisionMode = | 'admin_only' // Only admins can decide | 'steward_decides' // Single steward makes decision | 'steward_vote' // X stewards must vote to uphold | 'member_vote' // X members must vote to uphold | 'steward_veto' // Upheld unless X stewards vote against | 'member_veto'; // Upheld unless X members vote against export interface StewardingSettings { /** * How protest decisions are made */ decisionMode: StewardingDecisionMode; /** * Number of votes required to uphold/reject a protest * Used with steward_vote, member_vote, steward_veto, member_veto modes */ requiredVotes?: number; /** * Whether to require a defense from the accused before deciding */ requireDefense?: boolean; /** * Time limit (hours) for accused to submit defense */ defenseTimeLimit?: number; /** * Time limit (hours) for voting to complete */ voteTimeLimit?: number; /** * Time limit (hours) after race ends when protests can be filed */ protestDeadlineHours?: number; /** * Time limit (hours) after race ends when stewarding is closed (no more decisions) */ stewardingClosesHours?: number; /** * Whether to notify the accused when a protest is filed */ notifyAccusedOnProtest?: boolean; /** * Whether to notify eligible voters when a vote is required */ notifyOnVoteRequired?: boolean; } export interface LeagueSettings { pointsSystem: 'f1-2024' | 'indycar' | 'custom'; sessionDuration?: number; qualifyingFormat?: 'single-lap' | 'open'; customPoints?: Record; /** * Maximum number of drivers allowed in the league. * Used for simple capacity display on the website. */ maxDrivers?: number; /** * Stewarding settings for protest handling */ stewarding?: StewardingSettings; /** * League visibility type (ranked/unranked) * Determines participant requirements and rating impact */ visibility?: LeagueVisibilityType; } export class League extends Entity { readonly name: LeagueName; readonly description: LeagueDescription; readonly ownerId: LeagueOwnerId; readonly settings: LeagueSettings; readonly category?: string | undefined; readonly createdAt: LeagueCreatedAt; readonly socialLinks: LeagueSocialLinks | undefined; readonly logoRef: MediaReference; // Domain state for business rule enforcement private readonly _participantCount: ParticipantCount; private readonly _visibility: LeagueVisibility; private constructor(props: { id: LeagueId; name: LeagueName; description: LeagueDescription; ownerId: LeagueOwnerId; settings: LeagueSettings; category?: string | undefined; createdAt: LeagueCreatedAt; socialLinks?: LeagueSocialLinks; participantCount: ParticipantCount; visibility: LeagueVisibility; logoRef: MediaReference; }) { super(props.id); this.name = props.name; this.description = props.description; this.ownerId = props.ownerId; this.settings = props.settings; this.category = props.category; this.createdAt = props.createdAt; this.socialLinks = props.socialLinks; this._participantCount = props.participantCount; this._visibility = props.visibility; this.logoRef = props.logoRef; } /** * Factory method to create a new League entity * Enforces all business rules and invariants */ static create(props: { id: string; name: string; description: string; ownerId: string; settings?: Partial; category?: string | undefined; createdAt?: Date; socialLinks?: { discordUrl?: string; youtubeUrl?: string; websiteUrl?: string; }; participantCount?: number; logoRef?: MediaReference; }): 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, defenseTimeLimit: 48, voteTimeLimit: 72, protestDeadlineHours: 48, stewardingClosesHours: 168, // 7 days notifyAccusedOnProtest: true, notifyOnVoteRequired: true, }; // Build final settings with validation const defaultSettings: LeagueSettings = { pointsSystem: 'f1-2024', sessionDuration: 60, qualifyingFormat: 'open', 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); // Only validate minimum requirements if there are actual participants // This allows leagues to be created empty and populated later if (participantCount.toNumber() > 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({ id, name, description, ownerId, settings: finalSettings, category: props.category, createdAt, ...(socialLinks !== undefined ? { socialLinks } : {}), participantCount, visibility, logoRef: props.logoRef ?? MediaReference.createSystemDefault('logo'), }); } static rehydrate(props: { id: string; name: string; description: string; ownerId: string; settings: LeagueSettings; category?: string | undefined; createdAt: Date; participantCount: number; socialLinks?: { discordUrl?: string; youtubeUrl?: string; websiteUrl?: string; }; logoRef?: MediaReference; }): League { 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); const visibilityType = props.settings.visibility ?? 'ranked'; const visibility = LeagueVisibility.fromString(visibilityType); const participantCount = ParticipantCount.create(props.participantCount); const socialLinks = props.socialLinks ? LeagueSocialLinks.create(props.socialLinks) : undefined; return new League({ id, name, description, ownerId, settings: props.settings, category: props.category, createdAt, ...(socialLinks !== undefined ? { socialLinks } : {}), participantCount, visibility, logoRef: props.logoRef ?? MediaReference.createSystemDefault('logo'), }); } /** * 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; description: string; ownerId: string; settings: LeagueSettings; category?: string | undefined; socialLinks?: { discordUrl?: string; youtubeUrl?: string; websiteUrl?: string; }; logoRef: MediaReference; }>): League { const name = props.name ? LeagueName.create(props.name) : this.name; const description = props.description ? LeagueDescription.create(props.description) : this.description; const ownerId = props.ownerId ? LeagueOwnerId.create(props.ownerId) : this.ownerId; const socialLinks = props.socialLinks ? LeagueSocialLinks.create(props.socialLinks) : this.socialLinks; const logoRef = 'logoRef' in props ? props.logoRef! : this.logoRef; // 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: newSettings, category: props.category ?? this.category, createdAt: this.createdAt, ...(socialLinks !== undefined ? { socialLinks } : {}), participantCount: this._participantCount, visibility: this._visibility, logoRef: logoRef, }); } /** * 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, category: this.category, createdAt: this.createdAt, ...(this.socialLinks !== undefined ? { socialLinks: this.socialLinks } : {}), participantCount: newCount, visibility: this._visibility, logoRef: this.logoRef, }); } /** * 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, category: this.category, createdAt: this.createdAt, ...(this.socialLinks !== undefined ? { socialLinks: this.socialLinks } : {}), participantCount: newCount, visibility: this._visibility, logoRef: this.logoRef, }); } /** * 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(); } equals(other: Entity): boolean { if (!(other instanceof League)) { return false; } return this.id.equals(other.id); } }