539 lines
18 KiB
TypeScript
539 lines
18 KiB
TypeScript
/**
|
|
* Domain Entity: League
|
|
*
|
|
* Represents a league in the GridPilot platform.
|
|
* Immutable entity with factory methods and domain validation.
|
|
*/
|
|
|
|
import type { IEntity } from '@core/shared/domain';
|
|
import { LeagueId } from './LeagueId';
|
|
import { LeagueName } from './LeagueName';
|
|
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';
|
|
import { MediaReference } from '@core/domain/media/MediaReference';
|
|
|
|
/**
|
|
* 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<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;
|
|
/**
|
|
* League visibility type (ranked/unranked)
|
|
* Determines participant requirements and rating impact
|
|
*/
|
|
visibility?: LeagueVisibilityType;
|
|
}
|
|
|
|
export class League implements IEntity<LeagueId> {
|
|
readonly id: LeagueId;
|
|
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;
|
|
}) {
|
|
this.id = 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<LeagueSettings>;
|
|
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();
|
|
}
|
|
} |