Files
gridpilot.gg/packages/racing/domain/entities/League.ts
2025-12-11 11:25:22 +01:00

203 lines
5.4 KiB
TypeScript

/**
* Domain Entity: League
*
* Represents a league in the GridPilot platform.
* Immutable entity with factory methods and domain validation.
*/
import { RacingDomainValidationError } from '../errors/RacingDomainError';
/**
* 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<number, number>;
/**
* 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 {
readonly id: string;
readonly name: string;
readonly description: string;
readonly ownerId: string;
readonly settings: LeagueSettings;
readonly createdAt: Date;
readonly socialLinks?: LeagueSocialLinks;
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<LeagueSettings>;
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,
};
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: props.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 | undefined;
}>): 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,
socialLinks: props.socialLinks ?? this.socialLinks,
});
}
}