/** * Domain Entity: League * * Represents a league in the GridPilot platform. * Immutable entity with factory methods and domain validation. */ import { RacingDomainValidationError } from '../errors/RacingDomainError'; import type { IEntity } from '@gridpilot/shared/domain'; /** * Stewarding decision mode for protests */ export type StewardingDecisionMode = | 'admin_only' // Only admins can decide | '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; } export interface LeagueSocialLinks { discordUrl?: string; youtubeUrl?: string; websiteUrl?: string; } export class League implements IEntity { readonly id: string; readonly name: string; readonly description: string; readonly ownerId: string; readonly settings: LeagueSettings; readonly createdAt: Date; readonly socialLinks: LeagueSocialLinks | undefined; private constructor(props: { id: string; name: string; description: string; ownerId: string; settings: LeagueSettings; createdAt: Date; socialLinks?: LeagueSocialLinks; }) { this.id = props.id; this.name = props.name; this.description = props.description; this.ownerId = props.ownerId; this.settings = props.settings; this.createdAt = props.createdAt; this.socialLinks = props.socialLinks; } /** * Factory method to create a new League entity */ static create(props: { id: string; name: string; description: string; ownerId: string; settings?: Partial; createdAt?: Date; socialLinks?: LeagueSocialLinks; }): League { this.validate(props); const defaultStewardingSettings: StewardingSettings = { decisionMode: 'admin_only', requireDefense: false, defenseTimeLimit: 48, voteTimeLimit: 72, protestDeadlineHours: 48, stewardingClosesHours: 168, // 7 days notifyAccusedOnProtest: true, notifyOnVoteRequired: true, }; const defaultSettings: LeagueSettings = { pointsSystem: 'f1-2024', sessionDuration: 60, qualifyingFormat: 'open', maxDrivers: 32, stewarding: defaultStewardingSettings, }; const socialLinks = props.socialLinks; return new League({ id: props.id, name: props.name, description: props.description, ownerId: props.ownerId, settings: { ...defaultSettings, ...props.settings }, createdAt: props.createdAt ?? new Date(), ...(socialLinks !== undefined ? { socialLinks } : {}), }); } /** * Domain validation logic */ private static validate(props: { id: string; name: string; description: string; ownerId: string; }): void { 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.name.length > 100) { throw new RacingDomainValidationError('League name must be 100 characters or less'); } 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'); } } /** * Create a copy with updated properties */ update(props: Partial<{ name: string; description: string; ownerId: string; settings: LeagueSettings; socialLinks?: LeagueSocialLinks; }>): League { return new League({ id: this.id, name: props.name ?? this.name, description: props.description ?? this.description, ownerId: props.ownerId ?? this.ownerId, settings: props.settings ?? this.settings, createdAt: this.createdAt, ...(props.socialLinks !== undefined ? { socialLinks: props.socialLinks } : this.socialLinks !== undefined ? { socialLinks: this.socialLinks } : {}), }); } }