This commit is contained in:
2025-12-12 14:23:40 +01:00
parent 6a88fe93ab
commit 2cd3bfbb47
58 changed files with 2866 additions and 260 deletions

View File

@@ -7,6 +7,7 @@
import type { ISponsorshipRequestRepository } from '../../domain/repositories/ISponsorshipRequestRepository';
import type { ISeasonSponsorshipRepository } from '../../domain/repositories/ISeasonSponsorshipRepository';
import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
import { SeasonSponsorship } from '../../domain/entities/SeasonSponsorship';
import type { AsyncUseCase } from '@gridpilot/shared/application';
@@ -29,6 +30,7 @@ export class AcceptSponsorshipRequestUseCase
constructor(
private readonly sponsorshipRequestRepo: ISponsorshipRequestRepository,
private readonly seasonSponsorshipRepo: ISeasonSponsorshipRepository,
private readonly seasonRepository: ISeasonRepository,
) {}
async execute(dto: AcceptSponsorshipRequestDTO): Promise<AcceptSponsorshipRequestResultDTO> {
@@ -50,9 +52,15 @@ export class AcceptSponsorshipRequestUseCase
let sponsorshipId = `spons_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
if (request.entityType === 'season') {
const season = await this.seasonRepository.findById(request.entityId);
if (!season) {
throw new Error('Season not found for sponsorship request');
}
const sponsorship = SeasonSponsorship.create({
id: sponsorshipId,
seasonId: request.entityId,
seasonId: season.id,
leagueId: season.leagueId,
sponsorId: request.sponsorId,
tier: request.tier,
pricing: request.offeredAmount,

View File

@@ -0,0 +1,460 @@
import { Season } from '../../domain/entities/Season';
import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { LeagueConfigFormModel } from '../dto/LeagueConfigFormDTO';
import { SeasonSchedule } from '../../domain/value-objects/SeasonSchedule';
import { SeasonScoringConfig } from '../../domain/value-objects/SeasonScoringConfig';
import { SeasonDropPolicy } from '../../domain/value-objects/SeasonDropPolicy';
import { SeasonStewardingConfig } from '../../domain/value-objects/SeasonStewardingConfig';
import { RaceTimeOfDay } from '../../domain/value-objects/RaceTimeOfDay';
import { LeagueTimezone } from '../../domain/value-objects/LeagueTimezone';
import { RecurrenceStrategyFactory } from '../../domain/value-objects/RecurrenceStrategy';
import { WeekdaySet } from '../../domain/value-objects/WeekdaySet';
import { MonthlyRecurrencePattern } from '../../domain/value-objects/MonthlyRecurrencePattern';
import type { Weekday } from '../../domain/types/Weekday';
import { normalizeVisibility } from '../dto/LeagueConfigFormDTO';
import { LeagueVisibility } from '../../domain/value-objects/LeagueVisibility';
import { v4 as uuidv4 } from 'uuid';
/**
* DTOs and helpers shared across Season-focused use cases.
*/
export interface CreateSeasonForLeagueCommand {
leagueId: string;
name: string;
gameId: string;
sourceSeasonId?: string;
/**
* Optional high-level wizard config used to derive schedule/scoring/drop/stewarding.
* When omitted, the Season will be created with minimal metadata only.
*/
config?: LeagueConfigFormModel;
}
export interface CreateSeasonForLeagueResultDTO {
seasonId: string;
}
export interface SeasonSummaryDTO {
seasonId: string;
leagueId: string;
name: string;
status: import('../../domain/entities/Season').SeasonStatus;
startDate?: Date;
endDate?: Date;
isPrimary: boolean;
}
export interface ListSeasonsForLeagueQuery {
leagueId: string;
}
export interface ListSeasonsForLeagueResultDTO {
items: SeasonSummaryDTO[];
}
export interface GetSeasonDetailsQuery {
leagueId: string;
seasonId: string;
}
export interface SeasonDetailsDTO {
seasonId: string;
leagueId: string;
gameId: string;
name: string;
status: import('../../domain/entities/Season').SeasonStatus;
startDate?: Date;
endDate?: Date;
maxDrivers?: number;
schedule?: {
startDate: Date;
plannedRounds: number;
};
scoring?: {
scoringPresetId: string;
customScoringEnabled: boolean;
};
dropPolicy?: {
strategy: import('../../domain/value-objects/SeasonDropPolicy').SeasonDropStrategy;
n?: number;
};
stewarding?: {
decisionMode: import('../../domain/entities/League').StewardingDecisionMode;
requiredVotes?: number;
requireDefense: boolean;
defenseTimeLimit: number;
voteTimeLimit: number;
protestDeadlineHours: number;
stewardingClosesHours: number;
notifyAccusedOnProtest: boolean;
notifyOnVoteRequired: boolean;
};
}
export type SeasonLifecycleTransition =
| 'activate'
| 'complete'
| 'archive'
| 'cancel';
export interface ManageSeasonLifecycleCommand {
leagueId: string;
seasonId: string;
transition: SeasonLifecycleTransition;
}
export interface ManageSeasonLifecycleResultDTO {
seasonId: string;
status: import('../../domain/entities/Season').SeasonStatus;
startDate?: Date;
endDate?: Date;
}
/**
* CreateSeasonForLeagueUseCase
*
* Creates a new Season for an existing League, optionally cloning or deriving
* configuration from a source Season or a league config form.
*/
export class CreateSeasonForLeagueUseCase {
constructor(
private readonly leagueRepository: ILeagueRepository,
private readonly seasonRepository: ISeasonRepository,
) {}
async execute(
command: CreateSeasonForLeagueCommand,
): Promise<CreateSeasonForLeagueResultDTO> {
const league = await this.leagueRepository.findById(command.leagueId);
if (!league) {
throw new Error(`League not found: ${command.leagueId}`);
}
let baseSeasonProps: {
schedule?: SeasonSchedule;
scoringConfig?: SeasonScoringConfig;
dropPolicy?: SeasonDropPolicy;
stewardingConfig?: SeasonStewardingConfig;
maxDrivers?: number;
} = {};
if (command.sourceSeasonId) {
const source = await this.seasonRepository.findById(command.sourceSeasonId);
if (!source) {
throw new Error(`Source Season not found: ${command.sourceSeasonId}`);
}
baseSeasonProps = {
...(source.schedule !== undefined ? { schedule: source.schedule } : {}),
...(source.scoringConfig !== undefined
? { scoringConfig: source.scoringConfig }
: {}),
...(source.dropPolicy !== undefined ? { dropPolicy: source.dropPolicy } : {}),
...(source.stewardingConfig !== undefined
? { stewardingConfig: source.stewardingConfig }
: {}),
...(source.maxDrivers !== undefined ? { maxDrivers: source.maxDrivers } : {}),
};
} else if (command.config) {
baseSeasonProps = this.deriveSeasonPropsFromConfig(command.config);
}
const seasonId = uuidv4();
const season = Season.create({
id: seasonId,
leagueId: league.id,
gameId: command.gameId,
name: command.name,
year: new Date().getFullYear(),
status: 'planned',
...(baseSeasonProps?.schedule
? { schedule: baseSeasonProps.schedule }
: {}),
...(baseSeasonProps?.scoringConfig
? { scoringConfig: baseSeasonProps.scoringConfig }
: {}),
...(baseSeasonProps?.dropPolicy
? { dropPolicy: baseSeasonProps.dropPolicy }
: {}),
...(baseSeasonProps?.stewardingConfig
? { stewardingConfig: baseSeasonProps.stewardingConfig }
: {}),
...(baseSeasonProps?.maxDrivers !== undefined
? { maxDrivers: baseSeasonProps.maxDrivers }
: {}),
});
await this.seasonRepository.add(season);
return { seasonId };
}
private deriveSeasonPropsFromConfig(config: LeagueConfigFormModel): {
schedule?: SeasonSchedule;
scoringConfig?: SeasonScoringConfig;
dropPolicy?: SeasonDropPolicy;
stewardingConfig?: SeasonStewardingConfig;
maxDrivers?: number;
} {
const schedule = this.buildScheduleFromTimings(config);
const scoringConfig = new SeasonScoringConfig({
scoringPresetId: config.scoring.patternId ?? 'custom',
customScoringEnabled: config.scoring.customScoringEnabled ?? false,
});
const dropPolicy = new SeasonDropPolicy({
strategy: config.dropPolicy.strategy,
...(config.dropPolicy.n !== undefined ? { n: config.dropPolicy.n } : {}),
});
const stewardingConfig = new SeasonStewardingConfig({
decisionMode: config.stewarding.decisionMode,
...(config.stewarding.requiredVotes !== undefined
? { requiredVotes: config.stewarding.requiredVotes }
: {}),
requireDefense: config.stewarding.requireDefense,
defenseTimeLimit: config.stewarding.defenseTimeLimit,
voteTimeLimit: config.stewarding.voteTimeLimit,
protestDeadlineHours: config.stewarding.protestDeadlineHours,
stewardingClosesHours: config.stewarding.stewardingClosesHours,
notifyAccusedOnProtest: config.stewarding.notifyAccusedOnProtest,
notifyOnVoteRequired: config.stewarding.notifyOnVoteRequired,
});
const structure = config.structure;
const maxDrivers =
typeof structure.maxDrivers === 'number' && structure.maxDrivers > 0
? structure.maxDrivers
: undefined;
return {
...(schedule !== undefined ? { schedule } : {}),
scoringConfig,
dropPolicy,
stewardingConfig,
...(maxDrivers !== undefined ? { maxDrivers } : {}),
};
}
private buildScheduleFromTimings(
config: LeagueConfigFormModel,
): SeasonSchedule | undefined {
const { timings } = config;
if (!timings.seasonStartDate || !timings.raceStartTime) {
return undefined;
}
const startDate = new Date(timings.seasonStartDate);
const timeOfDay = RaceTimeOfDay.fromString(timings.raceStartTime);
const timezoneId = timings.timezoneId ?? 'UTC';
const timezone = new LeagueTimezone(timezoneId);
const plannedRounds =
typeof timings.roundsPlanned === 'number' && timings.roundsPlanned > 0
? timings.roundsPlanned
: timings.sessionCount;
const recurrence = (() => {
const weekdays: WeekdaySet =
timings.weekdays && timings.weekdays.length > 0
? WeekdaySet.fromArray(
timings.weekdays as unknown as Weekday[],
)
: WeekdaySet.fromArray(['Mon']);
switch (timings.recurrenceStrategy) {
case 'everyNWeeks':
return RecurrenceStrategyFactory.everyNWeeks(
timings.intervalWeeks ?? 2,
weekdays,
);
case 'monthlyNthWeekday': {
const pattern = new MonthlyRecurrencePattern({
ordinal: (timings.monthlyOrdinal ?? 1) as 1 | 2 | 3 | 4,
weekday: (timings.monthlyWeekday ?? 'Mon') as Weekday,
});
return RecurrenceStrategyFactory.monthlyNthWeekday(pattern);
}
case 'weekly':
default:
return RecurrenceStrategyFactory.weekly(weekdays);
}
})();
return new SeasonSchedule({
startDate,
timeOfDay,
timezone,
recurrence,
plannedRounds,
});
}
}
/**
* ListSeasonsForLeagueUseCase
*/
export class ListSeasonsForLeagueUseCase {
constructor(
private readonly leagueRepository: ILeagueRepository,
private readonly seasonRepository: ISeasonRepository,
) {}
async execute(
query: ListSeasonsForLeagueQuery,
): Promise<ListSeasonsForLeagueResultDTO> {
const league = await this.leagueRepository.findById(query.leagueId);
if (!league) {
throw new Error(`League not found: ${query.leagueId}`);
}
const seasons = await this.seasonRepository.listByLeague(league.id);
const items: SeasonSummaryDTO[] = seasons.map((s) => ({
seasonId: s.id,
leagueId: s.leagueId,
name: s.name,
status: s.status,
...(s.startDate !== undefined ? { startDate: s.startDate } : {}),
...(s.endDate !== undefined ? { endDate: s.endDate } : {}),
// League currently does not track primarySeasonId, so mark false for now.
isPrimary: false,
}));
return { items };
}
}
/**
* GetSeasonDetailsUseCase
*/
export class GetSeasonDetailsUseCase {
constructor(
private readonly leagueRepository: ILeagueRepository,
private readonly seasonRepository: ISeasonRepository,
) {}
async execute(query: GetSeasonDetailsQuery): Promise<SeasonDetailsDTO> {
const league = await this.leagueRepository.findById(query.leagueId);
if (!league) {
throw new Error(`League not found: ${query.leagueId}`);
}
const season = await this.seasonRepository.findById(query.seasonId);
if (!season || season.leagueId !== league.id) {
throw new Error(
`Season ${query.seasonId} does not belong to league ${league.id}`,
);
}
return {
seasonId: season.id,
leagueId: season.leagueId,
gameId: season.gameId,
name: season.name,
status: season.status,
...(season.startDate !== undefined ? { startDate: season.startDate } : {}),
...(season.endDate !== undefined ? { endDate: season.endDate } : {}),
...(season.maxDrivers !== undefined ? { maxDrivers: season.maxDrivers } : {}),
...(season.schedule
? {
schedule: {
startDate: season.schedule.startDate,
plannedRounds: season.schedule.plannedRounds,
},
}
: {}),
...(season.scoringConfig
? {
scoring: {
scoringPresetId: season.scoringConfig.scoringPresetId,
customScoringEnabled:
season.scoringConfig.customScoringEnabled ?? false,
},
}
: {}),
...(season.dropPolicy
? {
dropPolicy: {
strategy: season.dropPolicy.strategy,
...(season.dropPolicy.n !== undefined
? { n: season.dropPolicy.n }
: {}),
},
}
: {}),
...(season.stewardingConfig
? {
stewarding: {
decisionMode: season.stewardingConfig.decisionMode,
...(season.stewardingConfig.requiredVotes !== undefined
? { requiredVotes: season.stewardingConfig.requiredVotes }
: {}),
requireDefense: season.stewardingConfig.requireDefense,
defenseTimeLimit: season.stewardingConfig.defenseTimeLimit,
voteTimeLimit: season.stewardingConfig.voteTimeLimit,
protestDeadlineHours:
season.stewardingConfig.protestDeadlineHours,
stewardingClosesHours:
season.stewardingConfig.stewardingClosesHours,
notifyAccusedOnProtest:
season.stewardingConfig.notifyAccusedOnProtest,
notifyOnVoteRequired:
season.stewardingConfig.notifyOnVoteRequired,
},
}
: {}),
};
}
}
/**
* ManageSeasonLifecycleUseCase
*/
export class ManageSeasonLifecycleUseCase {
constructor(
private readonly leagueRepository: ILeagueRepository,
private readonly seasonRepository: ISeasonRepository,
) {}
async execute(
command: ManageSeasonLifecycleCommand,
): Promise<ManageSeasonLifecycleResultDTO> {
const league = await this.leagueRepository.findById(command.leagueId);
if (!league) {
throw new Error(`League not found: ${command.leagueId}`);
}
const season = await this.seasonRepository.findById(command.seasonId);
if (!season || season.leagueId !== league.id) {
throw new Error(
`Season ${command.seasonId} does not belong to league ${league.id}`,
);
}
let updated: Season;
switch (command.transition) {
case 'activate':
updated = season.activate();
break;
case 'complete':
updated = season.complete();
break;
case 'archive':
updated = season.archive();
break;
case 'cancel':
updated = season.cancel();
break;
default:
throw new Error(`Unsupported Season lifecycle transition`);
}
await this.seasonRepository.update(updated);
return {
seasonId: updated.id,
status: updated.status,
...(updated.startDate !== undefined ? { startDate: updated.startDate } : {}),
...(updated.endDate !== undefined ? { endDate: updated.endDate } : {}),
};
}
}

View File

@@ -26,8 +26,8 @@ export interface DriverLiveryProps {
userDecals: LiveryDecal[];
leagueOverrides: DecalOverride[];
createdAt: Date;
updatedAt?: Date;
validatedAt?: Date;
updatedAt: Date | undefined;
validatedAt: Date | undefined;
}
export class DriverLivery implements IEntity<string> {
@@ -39,8 +39,8 @@ export class DriverLivery implements IEntity<string> {
readonly userDecals: LiveryDecal[];
readonly leagueOverrides: DecalOverride[];
readonly createdAt: Date;
readonly updatedAt?: Date;
readonly validatedAt?: Date;
readonly updatedAt: Date | undefined;
readonly validatedAt: Date | undefined;
private constructor(props: DriverLiveryProps) {
this.id = props.id;
@@ -50,7 +50,7 @@ export class DriverLivery implements IEntity<string> {
this.uploadedImageUrl = props.uploadedImageUrl;
this.userDecals = props.userDecals;
this.leagueOverrides = props.leagueOverrides;
this.createdAt = props.createdAt;
this.createdAt = props.createdAt ?? new Date();
this.updatedAt = props.updatedAt;
this.validatedAt = props.validatedAt;
}
@@ -101,9 +101,16 @@ export class DriverLivery implements IEntity<string> {
}
return new DriverLivery({
...this,
id: this.id,
driverId: this.driverId,
gameId: this.gameId,
carId: this.carId,
uploadedImageUrl: this.uploadedImageUrl,
userDecals: [...this.userDecals, decal],
leagueOverrides: this.leagueOverrides,
createdAt: this.createdAt,
updatedAt: new Date(),
validatedAt: this.validatedAt,
});
}
@@ -118,9 +125,16 @@ export class DriverLivery implements IEntity<string> {
}
return new DriverLivery({
...this,
id: this.id,
driverId: this.driverId,
gameId: this.gameId,
carId: this.carId,
uploadedImageUrl: this.uploadedImageUrl,
userDecals: updatedDecals,
leagueOverrides: this.leagueOverrides,
createdAt: this.createdAt,
updatedAt: new Date(),
validatedAt: this.validatedAt,
});
}
@@ -131,16 +145,23 @@ export class DriverLivery implements IEntity<string> {
const index = this.userDecals.findIndex(d => d.id === decalId);
if (index === -1) {
throw new RacingDomainError('Decal not found in livery');
throw new RacingDomainValidationError('Decal not found in livery');
}
const updatedDecals = [...this.userDecals];
updatedDecals[index] = updatedDecal;
return new DriverLivery({
...this,
id: this.id,
driverId: this.driverId,
gameId: this.gameId,
carId: this.carId,
uploadedImageUrl: this.uploadedImageUrl,
userDecals: updatedDecals,
leagueOverrides: this.leagueOverrides,
createdAt: this.createdAt,
updatedAt: new Date(),
validatedAt: this.validatedAt,
});
}
@@ -163,9 +184,16 @@ export class DriverLivery implements IEntity<string> {
}
return new DriverLivery({
...this,
id: this.id,
driverId: this.driverId,
gameId: this.gameId,
carId: this.carId,
uploadedImageUrl: this.uploadedImageUrl,
userDecals: this.userDecals,
leagueOverrides: updatedOverrides,
createdAt: this.createdAt,
updatedAt: new Date(),
validatedAt: this.validatedAt,
});
}
@@ -178,9 +206,16 @@ export class DriverLivery implements IEntity<string> {
);
return new DriverLivery({
...this,
id: this.id,
driverId: this.driverId,
gameId: this.gameId,
carId: this.carId,
uploadedImageUrl: this.uploadedImageUrl,
userDecals: this.userDecals,
leagueOverrides: updatedOverrides,
createdAt: this.createdAt,
updatedAt: new Date(),
validatedAt: this.validatedAt,
});
}
@@ -198,7 +233,15 @@ export class DriverLivery implements IEntity<string> {
*/
markAsValidated(): DriverLivery {
return new DriverLivery({
...this,
id: this.id,
driverId: this.driverId,
gameId: this.gameId,
carId: this.carId,
uploadedImageUrl: this.uploadedImageUrl,
userDecals: this.userDecals,
leagueOverrides: this.leagueOverrides,
createdAt: this.createdAt,
updatedAt: this.updatedAt,
validatedAt: new Date(),
});
}

View File

@@ -17,7 +17,7 @@ export interface LiveryTemplateProps {
baseImageUrl: string;
adminDecals: LiveryDecal[];
createdAt: Date;
updatedAt?: Date;
updatedAt: Date | undefined;
}
export class LiveryTemplate implements IEntity<string> {
@@ -28,7 +28,7 @@ export class LiveryTemplate implements IEntity<string> {
readonly baseImageUrl: string;
readonly adminDecals: LiveryDecal[];
readonly createdAt: Date;
readonly updatedAt?: Date;
readonly updatedAt: Date | undefined;
private constructor(props: LiveryTemplateProps) {
this.id = props.id;
@@ -37,7 +37,7 @@ export class LiveryTemplate implements IEntity<string> {
this.carId = props.carId;
this.baseImageUrl = props.baseImageUrl;
this.adminDecals = props.adminDecals;
this.createdAt = props.createdAt;
this.createdAt = props.createdAt ?? new Date();
this.updatedAt = props.updatedAt;
}
@@ -113,9 +113,9 @@ export class LiveryTemplate implements IEntity<string> {
*/
updateDecal(decalId: string, updatedDecal: LiveryDecal): LiveryTemplate {
const index = this.adminDecals.findIndex(d => d.id === decalId);
if (index === -1) {
throw new RacingDomainError('Decal not found in template');
throw new RacingDomainValidationError('Decal not found in template');
}
const updatedDecals = [...this.adminDecals];

View File

@@ -19,9 +19,9 @@ export interface PrizeProps {
driverId?: string;
status: PrizeStatus;
createdAt: Date;
awardedAt?: Date;
paidAt?: Date;
description?: string;
awardedAt: Date | undefined;
paidAt: Date | undefined;
description: string | undefined;
}
export class Prize implements IEntity<string> {
@@ -29,12 +29,12 @@ export class Prize implements IEntity<string> {
readonly seasonId: string;
readonly position: number;
readonly amount: Money;
readonly driverId?: string;
readonly driverId: string | undefined;
readonly status: PrizeStatus;
readonly createdAt: Date;
readonly awardedAt?: Date;
readonly paidAt?: Date;
readonly description?: string;
readonly awardedAt: Date | undefined;
readonly paidAt: Date | undefined;
readonly description: string | undefined;
private constructor(props: PrizeProps) {
this.id = props.id;
@@ -43,7 +43,7 @@ export class Prize implements IEntity<string> {
this.amount = props.amount;
this.driverId = props.driverId;
this.status = props.status;
this.createdAt = props.createdAt;
this.createdAt = props.createdAt ?? new Date();
this.awardedAt = props.awardedAt;
this.paidAt = props.paidAt;
this.description = props.description;

View File

@@ -1,8 +1,20 @@
export type SeasonStatus = 'planned' | 'active' | 'completed';
import { RacingDomainValidationError } from '../errors/RacingDomainError';
export type SeasonStatus =
| 'planned'
| 'active'
| 'completed'
| 'archived'
| 'cancelled';
import {
RacingDomainInvariantError,
RacingDomainValidationError,
} from '../errors/RacingDomainError';
import type { IEntity } from '@gridpilot/shared/domain';
import type { SeasonSchedule } from '../value-objects/SeasonSchedule';
import type { SeasonScoringConfig } from '../value-objects/SeasonScoringConfig';
import type { SeasonDropPolicy } from '../value-objects/SeasonDropPolicy';
import type { SeasonStewardingConfig } from '../value-objects/SeasonStewardingConfig';
export class Season implements IEntity<string> {
readonly id: string;
readonly leagueId: string;
@@ -13,6 +25,11 @@ export class Season implements IEntity<string> {
readonly status: SeasonStatus;
readonly startDate: Date | undefined;
readonly endDate: Date | undefined;
readonly schedule: SeasonSchedule | undefined;
readonly scoringConfig: SeasonScoringConfig | undefined;
readonly dropPolicy: SeasonDropPolicy | undefined;
readonly stewardingConfig: SeasonStewardingConfig | undefined;
readonly maxDrivers: number | undefined;
private constructor(props: {
id: string;
@@ -24,6 +41,11 @@ export class Season implements IEntity<string> {
status: SeasonStatus;
startDate?: Date;
endDate?: Date;
schedule?: SeasonSchedule;
scoringConfig?: SeasonScoringConfig;
dropPolicy?: SeasonDropPolicy;
stewardingConfig?: SeasonStewardingConfig;
maxDrivers?: number;
}) {
this.id = props.id;
this.leagueId = props.leagueId;
@@ -34,6 +56,11 @@ export class Season implements IEntity<string> {
this.status = props.status;
this.startDate = props.startDate;
this.endDate = props.endDate;
this.schedule = props.schedule;
this.scoringConfig = props.scoringConfig;
this.dropPolicy = props.dropPolicy;
this.stewardingConfig = props.stewardingConfig;
this.maxDrivers = props.maxDrivers;
}
static create(props: {
@@ -46,6 +73,11 @@ export class Season implements IEntity<string> {
status?: SeasonStatus;
startDate?: Date;
endDate?: Date;
schedule?: SeasonSchedule;
scoringConfig?: SeasonScoringConfig;
dropPolicy?: SeasonDropPolicy;
stewardingConfig?: SeasonStewardingConfig;
maxDrivers?: number;
}): Season {
if (!props.id || props.id.trim().length === 0) {
throw new RacingDomainValidationError('Season ID is required');
@@ -75,6 +107,15 @@ export class Season implements IEntity<string> {
status,
...(props.startDate !== undefined ? { startDate: props.startDate } : {}),
...(props.endDate !== undefined ? { endDate: props.endDate } : {}),
...(props.schedule !== undefined ? { schedule: props.schedule } : {}),
...(props.scoringConfig !== undefined
? { scoringConfig: props.scoringConfig }
: {}),
...(props.dropPolicy !== undefined ? { dropPolicy: props.dropPolicy } : {}),
...(props.stewardingConfig !== undefined
? { stewardingConfig: props.stewardingConfig }
: {}),
...(props.maxDrivers !== undefined ? { maxDrivers: props.maxDrivers } : {}),
});
}
@@ -98,4 +139,281 @@ export class Season implements IEntity<string> {
isCompleted(): boolean {
return this.status === 'completed';
}
/**
* Check if season is planned (not yet active)
*/
isPlanned(): boolean {
return this.status === 'planned';
}
/**
* Activate the season from planned state.
*/
activate(): Season {
if (this.status !== 'planned') {
throw new RacingDomainInvariantError(
'Only planned seasons can be activated',
);
}
return Season.create({
id: this.id,
leagueId: this.leagueId,
gameId: this.gameId,
name: this.name,
...(this.year !== undefined ? { year: this.year } : {}),
...(this.order !== undefined ? { order: this.order } : {}),
status: 'active',
...(this.startDate !== undefined ? { startDate: this.startDate } : {
startDate: new Date(),
}),
...(this.endDate !== undefined ? { endDate: this.endDate } : {}),
...(this.schedule !== undefined ? { schedule: this.schedule } : {}),
...(this.scoringConfig !== undefined
? { scoringConfig: this.scoringConfig }
: {}),
...(this.dropPolicy !== undefined ? { dropPolicy: this.dropPolicy } : {}),
...(this.stewardingConfig !== undefined
? { stewardingConfig: this.stewardingConfig }
: {}),
...(this.maxDrivers !== undefined ? { maxDrivers: this.maxDrivers } : {}),
});
}
/**
* Mark the season as completed.
*/
complete(): Season {
if (this.status !== 'active') {
throw new RacingDomainInvariantError(
'Only active seasons can be completed',
);
}
return Season.create({
id: this.id,
leagueId: this.leagueId,
gameId: this.gameId,
name: this.name,
...(this.year !== undefined ? { year: this.year } : {}),
...(this.order !== undefined ? { order: this.order } : {}),
status: 'completed',
...(this.startDate !== undefined ? { startDate: this.startDate } : {}),
...(this.endDate !== undefined ? { endDate: this.endDate } : {
endDate: new Date(),
}),
...(this.schedule !== undefined ? { schedule: this.schedule } : {}),
...(this.scoringConfig !== undefined
? { scoringConfig: this.scoringConfig }
: {}),
...(this.dropPolicy !== undefined ? { dropPolicy: this.dropPolicy } : {}),
...(this.stewardingConfig !== undefined
? { stewardingConfig: this.stewardingConfig }
: {}),
...(this.maxDrivers !== undefined ? { maxDrivers: this.maxDrivers } : {}),
});
}
/**
* Archive a completed season.
*/
archive(): Season {
if (!this.isCompleted()) {
throw new RacingDomainInvariantError(
'Only completed seasons can be archived',
);
}
return Season.create({
id: this.id,
leagueId: this.leagueId,
gameId: this.gameId,
name: this.name,
...(this.year !== undefined ? { year: this.year } : {}),
...(this.order !== undefined ? { order: this.order } : {}),
status: 'archived',
...(this.startDate !== undefined ? { startDate: this.startDate } : {}),
...(this.endDate !== undefined ? { endDate: this.endDate } : {}),
...(this.schedule !== undefined ? { schedule: this.schedule } : {}),
...(this.scoringConfig !== undefined
? { scoringConfig: this.scoringConfig }
: {}),
...(this.dropPolicy !== undefined ? { dropPolicy: this.dropPolicy } : {}),
...(this.stewardingConfig !== undefined
? { stewardingConfig: this.stewardingConfig }
: {}),
...(this.maxDrivers !== undefined ? { maxDrivers: this.maxDrivers } : {}),
});
}
/**
* Cancel a planned or active season.
*/
cancel(): Season {
if (this.status === 'completed' || this.status === 'archived') {
throw new RacingDomainInvariantError(
'Cannot cancel a completed or archived season',
);
}
if (this.status === 'cancelled') {
return this;
}
return Season.create({
id: this.id,
leagueId: this.leagueId,
gameId: this.gameId,
name: this.name,
...(this.year !== undefined ? { year: this.year } : {}),
...(this.order !== undefined ? { order: this.order } : {}),
status: 'cancelled',
...(this.startDate !== undefined ? { startDate: this.startDate } : {}),
...(this.endDate !== undefined ? { endDate: this.endDate } : {
endDate: new Date(),
}),
...(this.schedule !== undefined ? { schedule: this.schedule } : {}),
...(this.scoringConfig !== undefined
? { scoringConfig: this.scoringConfig }
: {}),
...(this.dropPolicy !== undefined ? { dropPolicy: this.dropPolicy } : {}),
...(this.stewardingConfig !== undefined
? { stewardingConfig: this.stewardingConfig }
: {}),
...(this.maxDrivers !== undefined ? { maxDrivers: this.maxDrivers } : {}),
});
}
/**
* Update schedule while keeping other properties intact.
*/
withSchedule(schedule: SeasonSchedule): Season {
return Season.create({
id: this.id,
leagueId: this.leagueId,
gameId: this.gameId,
name: this.name,
...(this.year !== undefined ? { year: this.year } : {}),
...(this.order !== undefined ? { order: this.order } : {}),
status: this.status,
...(this.startDate !== undefined ? { startDate: this.startDate } : {}),
...(this.endDate !== undefined ? { endDate: this.endDate } : {}),
schedule,
...(this.scoringConfig !== undefined
? { scoringConfig: this.scoringConfig }
: {}),
...(this.dropPolicy !== undefined ? { dropPolicy: this.dropPolicy } : {}),
...(this.stewardingConfig !== undefined
? { stewardingConfig: this.stewardingConfig }
: {}),
...(this.maxDrivers !== undefined ? { maxDrivers: this.maxDrivers } : {}),
});
}
/**
* Update scoring configuration for the season.
*/
withScoringConfig(scoringConfig: SeasonScoringConfig): Season {
return Season.create({
id: this.id,
leagueId: this.leagueId,
gameId: this.gameId,
name: this.name,
...(this.year !== undefined ? { year: this.year } : {}),
...(this.order !== undefined ? { order: this.order } : {}),
status: this.status,
...(this.startDate !== undefined ? { startDate: this.startDate } : {}),
...(this.endDate !== undefined ? { endDate: this.endDate } : {}),
...(this.schedule !== undefined ? { schedule: this.schedule } : {}),
scoringConfig,
...(this.dropPolicy !== undefined ? { dropPolicy: this.dropPolicy } : {}),
...(this.stewardingConfig !== undefined
? { stewardingConfig: this.stewardingConfig }
: {}),
...(this.maxDrivers !== undefined ? { maxDrivers: this.maxDrivers } : {}),
});
}
/**
* Update drop policy for the season.
*/
withDropPolicy(dropPolicy: SeasonDropPolicy): Season {
return Season.create({
id: this.id,
leagueId: this.leagueId,
gameId: this.gameId,
name: this.name,
...(this.year !== undefined ? { year: this.year } : {}),
...(this.order !== undefined ? { order: this.order } : {}),
status: this.status,
...(this.startDate !== undefined ? { startDate: this.startDate } : {}),
...(this.endDate !== undefined ? { endDate: this.endDate } : {}),
...(this.schedule !== undefined ? { schedule: this.schedule } : {}),
...(this.scoringConfig !== undefined
? { scoringConfig: this.scoringConfig }
: {}),
dropPolicy,
...(this.stewardingConfig !== undefined
? { stewardingConfig: this.stewardingConfig }
: {}),
...(this.maxDrivers !== undefined ? { maxDrivers: this.maxDrivers } : {}),
});
}
/**
* Update stewarding configuration for the season.
*/
withStewardingConfig(stewardingConfig: SeasonStewardingConfig): Season {
return Season.create({
id: this.id,
leagueId: this.leagueId,
gameId: this.gameId,
name: this.name,
...(this.year !== undefined ? { year: this.year } : {}),
...(this.order !== undefined ? { order: this.order } : {}),
status: this.status,
...(this.startDate !== undefined ? { startDate: this.startDate } : {}),
...(this.endDate !== undefined ? { endDate: this.endDate } : {}),
...(this.schedule !== undefined ? { schedule: this.schedule } : {}),
...(this.scoringConfig !== undefined
? { scoringConfig: this.scoringConfig }
: {}),
...(this.dropPolicy !== undefined ? { dropPolicy: this.dropPolicy } : {}),
stewardingConfig,
...(this.maxDrivers !== undefined ? { maxDrivers: this.maxDrivers } : {}),
});
}
/**
* Update max driver capacity for the season.
*/
withMaxDrivers(maxDrivers: number | undefined): Season {
if (maxDrivers !== undefined && maxDrivers <= 0) {
throw new RacingDomainValidationError(
'Season maxDrivers must be greater than 0 when provided',
);
}
return Season.create({
id: this.id,
leagueId: this.leagueId,
gameId: this.gameId,
name: this.name,
...(this.year !== undefined ? { year: this.year } : {}),
...(this.order !== undefined ? { order: this.order } : {}),
status: this.status,
...(this.startDate !== undefined ? { startDate: this.startDate } : {}),
...(this.endDate !== undefined ? { endDate: this.endDate } : {}),
...(this.schedule !== undefined ? { schedule: this.schedule } : {}),
...(this.scoringConfig !== undefined
? { scoringConfig: this.scoringConfig }
: {}),
...(this.dropPolicy !== undefined ? { dropPolicy: this.dropPolicy } : {}),
...(this.stewardingConfig !== undefined
? { stewardingConfig: this.stewardingConfig }
: {}),
...(maxDrivers !== undefined ? { maxDrivers } : {}),
});
}
}

View File

@@ -11,40 +11,53 @@ import type { IEntity } from '@gridpilot/shared/domain';
import type { Money } from '../value-objects/Money';
export type SponsorshipTier = 'main' | 'secondary';
export type SponsorshipStatus = 'pending' | 'active' | 'cancelled';
export type SponsorshipStatus = 'pending' | 'active' | 'ended' | 'cancelled';
export interface SeasonSponsorshipProps {
id: string;
seasonId: string;
/**
* Optional denormalized leagueId for fast league-level aggregations.
* Must always match the owning Season's leagueId when present.
*/
leagueId?: string;
sponsorId: string;
tier: SponsorshipTier;
pricing: Money;
status: SponsorshipStatus;
createdAt: Date;
activatedAt?: Date;
endedAt?: Date;
cancelledAt?: Date;
description?: string;
}
export class SeasonSponsorship implements IEntity<string> {
readonly id: string;
readonly seasonId: string;
readonly leagueId: string | undefined;
readonly sponsorId: string;
readonly tier: SponsorshipTier;
readonly pricing: Money;
readonly status: SponsorshipStatus;
readonly createdAt: Date;
readonly activatedAt: Date | undefined;
readonly endedAt: Date | undefined;
readonly cancelledAt: Date | undefined;
readonly description: string | undefined;
private constructor(props: SeasonSponsorshipProps) {
this.id = props.id;
this.seasonId = props.seasonId;
this.leagueId = props.leagueId;
this.sponsorId = props.sponsorId;
this.tier = props.tier;
this.pricing = props.pricing;
this.status = props.status;
this.createdAt = props.createdAt;
this.activatedAt = props.activatedAt;
this.endedAt = props.endedAt;
this.cancelledAt = props.cancelledAt;
this.description = props.description;
}
@@ -57,12 +70,15 @@ export class SeasonSponsorship implements IEntity<string> {
return new SeasonSponsorship({
id: props.id,
seasonId: props.seasonId,
...(props.leagueId !== undefined ? { leagueId: props.leagueId } : {}),
sponsorId: props.sponsorId,
tier: props.tier,
pricing: props.pricing,
status: props.status ?? 'pending',
createdAt: props.createdAt ?? new Date(),
...(props.activatedAt !== undefined ? { activatedAt: props.activatedAt } : {}),
...(props.endedAt !== undefined ? { endedAt: props.endedAt } : {}),
...(props.cancelledAt !== undefined ? { cancelledAt: props.cancelledAt } : {}),
...(props.description !== undefined ? { description: props.description } : {}),
});
}
@@ -105,15 +121,56 @@ export class SeasonSponsorship implements IEntity<string> {
throw new RacingDomainInvariantError('Cannot activate a cancelled SeasonSponsorship');
}
if (this.status === 'ended') {
throw new RacingDomainInvariantError('Cannot activate an ended SeasonSponsorship');
}
const base: SeasonSponsorshipProps = {
id: this.id,
seasonId: this.seasonId,
...(this.leagueId !== undefined ? { leagueId: this.leagueId } : {}),
sponsorId: this.sponsorId,
tier: this.tier,
pricing: this.pricing,
status: 'active',
createdAt: this.createdAt,
activatedAt: new Date(),
...(this.endedAt !== undefined ? { endedAt: this.endedAt } : {}),
...(this.cancelledAt !== undefined ? { cancelledAt: this.cancelledAt } : {}),
};
const next: SeasonSponsorshipProps =
this.description !== undefined
? { ...base, description: this.description }
: base;
return new SeasonSponsorship(next);
}
/**
* Mark the sponsorship as ended (completed term)
*/
end(): SeasonSponsorship {
if (this.status === 'cancelled') {
throw new RacingDomainInvariantError('Cannot end a cancelled SeasonSponsorship');
}
if (this.status === 'ended') {
throw new RacingDomainInvariantError('SeasonSponsorship is already ended');
}
const base: SeasonSponsorshipProps = {
id: this.id,
seasonId: this.seasonId,
...(this.leagueId !== undefined ? { leagueId: this.leagueId } : {}),
sponsorId: this.sponsorId,
tier: this.tier,
pricing: this.pricing,
status: 'ended',
createdAt: this.createdAt,
...(this.activatedAt !== undefined ? { activatedAt: this.activatedAt } : {}),
endedAt: new Date(),
...(this.cancelledAt !== undefined ? { cancelledAt: this.cancelledAt } : {}),
};
const next: SeasonSponsorshipProps =
@@ -135,22 +192,55 @@ export class SeasonSponsorship implements IEntity<string> {
const base: SeasonSponsorshipProps = {
id: this.id,
seasonId: this.seasonId,
...(this.leagueId !== undefined ? { leagueId: this.leagueId } : {}),
sponsorId: this.sponsorId,
tier: this.tier,
pricing: this.pricing,
status: 'cancelled',
createdAt: this.createdAt,
...(this.activatedAt !== undefined ? { activatedAt: this.activatedAt } : {}),
...(this.endedAt !== undefined ? { endedAt: this.endedAt } : {}),
cancelledAt: new Date(),
};
const withActivated =
this.activatedAt !== undefined
? { ...base, activatedAt: this.activatedAt }
: base;
const next: SeasonSponsorshipProps =
this.description !== undefined
? { ...withActivated, description: this.description }
: withActivated;
? { ...base, description: this.description }
: base;
return new SeasonSponsorship(next);
}
/**
* Update pricing/terms when allowed
*/
withPricing(pricing: Money): SeasonSponsorship {
if (pricing.amount <= 0) {
throw new RacingDomainValidationError('SeasonSponsorship pricing must be greater than zero');
}
if (this.status === 'cancelled' || this.status === 'ended') {
throw new RacingDomainInvariantError('Cannot update pricing for ended or cancelled SeasonSponsorship');
}
const base: SeasonSponsorshipProps = {
id: this.id,
seasonId: this.seasonId,
...(this.leagueId !== undefined ? { leagueId: this.leagueId } : {}),
sponsorId: this.sponsorId,
tier: this.tier,
pricing,
status: this.status,
createdAt: this.createdAt,
...(this.activatedAt !== undefined ? { activatedAt: this.activatedAt } : {}),
...(this.endedAt !== undefined ? { endedAt: this.endedAt } : {}),
...(this.cancelledAt !== undefined ? { cancelledAt: this.cancelledAt } : {}),
};
const next: SeasonSponsorshipProps =
this.description !== undefined
? { ...base, description: this.description }
: base;
return new SeasonSponsorship(next);
}

View File

@@ -27,9 +27,9 @@ export interface TransactionProps {
netAmount: Money;
status: TransactionStatus;
createdAt: Date;
completedAt?: Date;
description?: string;
metadata?: Record<string, unknown>;
completedAt: Date | undefined;
description: string | undefined;
metadata: Record<string, unknown> | undefined;
}
export class Transaction implements IEntity<string> {
@@ -41,9 +41,9 @@ export class Transaction implements IEntity<string> {
readonly netAmount: Money;
readonly status: TransactionStatus;
readonly createdAt: Date;
readonly completedAt?: Date;
readonly description?: string;
readonly metadata?: Record<string, unknown>;
readonly completedAt: Date | undefined;
readonly description: string | undefined;
readonly metadata: Record<string, unknown> | undefined;
private constructor(props: TransactionProps) {
this.id = props.id;

View File

@@ -2,6 +2,34 @@ import type { Season } from '../entities/Season';
export interface ISeasonRepository {
findById(id: string): Promise<Season | null>;
/**
* Backward-compatible alias retained for existing callers.
* Prefer listByLeague for new usage.
*/
findByLeagueId(leagueId: string): Promise<Season[]>;
/**
* Backward-compatible alias retained for existing callers.
* Prefer add for new usage.
*/
create(season: Season): Promise<Season>;
/**
* Add a new Season aggregate.
*/
add(season: Season): Promise<void>;
/**
* Persist changes to an existing Season aggregate.
*/
update(season: Season): Promise<void>;
/**
* List all Seasons for a given League.
*/
listByLeague(leagueId: string): Promise<Season[]>;
/**
* List Seasons for a League that are currently active.
*/
listActiveByLeague(leagueId: string): Promise<Season[]>;
}

View File

@@ -9,6 +9,12 @@ import type { SeasonSponsorship, SponsorshipTier } from '../entities/SeasonSpons
export interface ISeasonSponsorshipRepository {
findById(id: string): Promise<SeasonSponsorship | null>;
findBySeasonId(seasonId: string): Promise<SeasonSponsorship[]>;
/**
* Convenience lookup for aggregating sponsorships at league level.
* Implementations should rely on the denormalized leagueId where present,
* falling back to joining through Seasons if needed.
*/
findByLeagueId(leagueId: string): Promise<SeasonSponsorship[]>;
findBySponsorId(sponsorId: string): Promise<SeasonSponsorship[]>;
findBySeasonAndTier(seasonId: string, tier: SponsorshipTier): Promise<SeasonSponsorship[]>;
create(sponsorship: SeasonSponsorship): Promise<SeasonSponsorship>;

View File

@@ -9,10 +9,20 @@ export interface MonthlyRecurrencePatternProps {
export class MonthlyRecurrencePattern implements IValueObject<MonthlyRecurrencePatternProps> {
readonly ordinal: 1 | 2 | 3 | 4;
readonly weekday: Weekday;
constructor(ordinal: 1 | 2 | 3 | 4, weekday: Weekday) {
this.ordinal = ordinal;
this.weekday = weekday;
constructor(ordinal: 1 | 2 | 3 | 4, weekday: Weekday);
constructor(props: MonthlyRecurrencePatternProps);
constructor(
ordinalOrProps: 1 | 2 | 3 | 4 | MonthlyRecurrencePatternProps,
weekday?: Weekday,
) {
if (typeof ordinalOrProps === 'object') {
this.ordinal = ordinalOrProps.ordinal;
this.weekday = ordinalOrProps.weekday;
} else {
this.ordinal = ordinalOrProps;
this.weekday = weekday as Weekday;
}
}
get props(): MonthlyRecurrencePatternProps {

View File

@@ -0,0 +1,59 @@
import { RacingDomainValidationError } from '../errors/RacingDomainError';
import type { IValueObject } from '@gridpilot/shared/domain';
export type SeasonDropStrategy = 'none' | 'bestNResults' | 'dropWorstN';
export interface SeasonDropPolicyProps {
strategy: SeasonDropStrategy;
/**
* Number of results to consider for strategies that require a count.
* - bestNResults: keep best N
* - dropWorstN: drop worst N
*/
n?: number;
}
export class SeasonDropPolicy implements IValueObject<SeasonDropPolicyProps> {
readonly strategy: SeasonDropStrategy;
readonly n?: number;
constructor(props: SeasonDropPolicyProps) {
if (!props.strategy) {
throw new RacingDomainValidationError(
'SeasonDropPolicy.strategy is required',
);
}
if (props.strategy === 'bestNResults' || props.strategy === 'dropWorstN') {
if (props.n === undefined || !Number.isInteger(props.n) || props.n <= 0) {
throw new RacingDomainValidationError(
'SeasonDropPolicy.n must be a positive integer when using bestNResults or dropWorstN',
);
}
}
if (props.strategy === 'none' && props.n !== undefined) {
throw new RacingDomainValidationError(
'SeasonDropPolicy.n must be undefined when strategy is none',
);
}
this.strategy = props.strategy;
if (props.n !== undefined) {
this.n = props.n;
}
}
get props(): SeasonDropPolicyProps {
return {
strategy: this.strategy,
...(this.n !== undefined ? { n: this.n } : {}),
};
}
equals(other: IValueObject<SeasonDropPolicyProps>): boolean {
const a = this.props;
const b = other.props;
return a.strategy === b.strategy && a.n === b.n;
}
}

View File

@@ -0,0 +1,66 @@
import { RacingDomainValidationError } from '../errors/RacingDomainError';
import type { IValueObject } from '@gridpilot/shared/domain';
/**
* Value Object: SeasonScoringConfig
*
* Represents the scoring configuration owned by a Season.
* It is intentionally lightweight and primarily captures which
* preset (or custom mode) is applied for this Season.
*
* Detailed championship scoring rules are still modeled via
* `LeagueScoringConfig` and related types.
*/
export interface SeasonScoringConfigProps {
/**
* Identifier of the scoring preset applied to this Season.
* Examples:
* - 'sprint-main-driver'
* - 'club-default'
* - 'endurance-main-double'
* - 'custom'
*/
scoringPresetId: string;
/**
* Whether the Season uses custom scoring rather than a pure preset.
* When true, `scoringPresetId` acts as a label rather than a strict preset key.
*/
customScoringEnabled?: boolean;
}
export class SeasonScoringConfig
implements IValueObject<SeasonScoringConfigProps>
{
readonly scoringPresetId: string;
readonly customScoringEnabled: boolean;
constructor(params: SeasonScoringConfigProps) {
if (!params.scoringPresetId || params.scoringPresetId.trim().length === 0) {
throw new RacingDomainValidationError(
'SeasonScoringConfig.scoringPresetId must be a non-empty string',
);
}
this.scoringPresetId = params.scoringPresetId.trim();
this.customScoringEnabled = Boolean(params.customScoringEnabled);
}
get props(): SeasonScoringConfigProps {
return {
scoringPresetId: this.scoringPresetId,
...(this.customScoringEnabled
? { customScoringEnabled: this.customScoringEnabled }
: {}),
};
}
equals(other: IValueObject<SeasonScoringConfigProps>): boolean {
const a = this.props;
const b = other.props;
return (
a.scoringPresetId === b.scoringPresetId &&
Boolean(a.customScoringEnabled) === Boolean(b.customScoringEnabled)
);
}
}

View File

@@ -0,0 +1,131 @@
import { RacingDomainValidationError } from '../errors/RacingDomainError';
import type { IValueObject } from '@gridpilot/shared/domain';
import type { StewardingDecisionMode } from '../entities/League';
export interface SeasonStewardingConfigProps {
decisionMode: StewardingDecisionMode;
requiredVotes?: number;
requireDefense: boolean;
defenseTimeLimit: number;
voteTimeLimit: number;
protestDeadlineHours: number;
stewardingClosesHours: number;
notifyAccusedOnProtest: boolean;
notifyOnVoteRequired: boolean;
}
/**
* Value Object: SeasonStewardingConfig
*
* Encapsulates stewarding configuration owned by a Season.
* Shape intentionally mirrors LeagueStewardingFormDTO used by the wizard.
*/
export class SeasonStewardingConfig
implements IValueObject<SeasonStewardingConfigProps>
{
readonly decisionMode: StewardingDecisionMode;
readonly requiredVotes?: number;
readonly requireDefense: boolean;
readonly defenseTimeLimit: number;
readonly voteTimeLimit: number;
readonly protestDeadlineHours: number;
readonly stewardingClosesHours: number;
readonly notifyAccusedOnProtest: boolean;
readonly notifyOnVoteRequired: boolean;
constructor(props: SeasonStewardingConfigProps) {
if (!props.decisionMode) {
throw new RacingDomainValidationError(
'SeasonStewardingConfig.decisionMode is required',
);
}
if (
(props.decisionMode === 'steward_vote' ||
props.decisionMode === 'member_vote' ||
props.decisionMode === 'steward_veto' ||
props.decisionMode === 'member_veto') &&
(props.requiredVotes === undefined ||
!Number.isInteger(props.requiredVotes) ||
props.requiredVotes <= 0)
) {
throw new RacingDomainValidationError(
'SeasonStewardingConfig.requiredVotes must be a positive integer for voting/veto modes',
);
}
if (!Number.isInteger(props.defenseTimeLimit) || props.defenseTimeLimit < 0) {
throw new RacingDomainValidationError(
'SeasonStewardingConfig.defenseTimeLimit must be a non-negative integer (hours)',
);
}
if (!Number.isInteger(props.voteTimeLimit) || props.voteTimeLimit <= 0) {
throw new RacingDomainValidationError(
'SeasonStewardingConfig.voteTimeLimit must be a positive integer (hours)',
);
}
if (
!Number.isInteger(props.protestDeadlineHours) ||
props.protestDeadlineHours <= 0
) {
throw new RacingDomainValidationError(
'SeasonStewardingConfig.protestDeadlineHours must be a positive integer (hours)',
);
}
if (
!Number.isInteger(props.stewardingClosesHours) ||
props.stewardingClosesHours <= 0
) {
throw new RacingDomainValidationError(
'SeasonStewardingConfig.stewardingClosesHours must be a positive integer (hours)',
);
}
this.decisionMode = props.decisionMode;
if (props.requiredVotes !== undefined) {
this.requiredVotes = props.requiredVotes;
}
this.requireDefense = props.requireDefense;
this.defenseTimeLimit = props.defenseTimeLimit;
this.voteTimeLimit = props.voteTimeLimit;
this.protestDeadlineHours = props.protestDeadlineHours;
this.stewardingClosesHours = props.stewardingClosesHours;
this.notifyAccusedOnProtest = props.notifyAccusedOnProtest;
this.notifyOnVoteRequired = props.notifyOnVoteRequired;
}
get props(): SeasonStewardingConfigProps {
return {
decisionMode: this.decisionMode,
...(this.requiredVotes !== undefined
? { requiredVotes: this.requiredVotes }
: {}),
requireDefense: this.requireDefense,
defenseTimeLimit: this.defenseTimeLimit,
voteTimeLimit: this.voteTimeLimit,
protestDeadlineHours: this.protestDeadlineHours,
stewardingClosesHours: this.stewardingClosesHours,
notifyAccusedOnProtest: this.notifyAccusedOnProtest,
notifyOnVoteRequired: this.notifyOnVoteRequired,
};
}
equals(other: IValueObject<SeasonStewardingConfigProps>): boolean {
const a = this.props;
const b = other.props;
return (
a.decisionMode === b.decisionMode &&
a.requiredVotes === b.requiredVotes &&
a.requireDefense === b.requireDefense &&
a.defenseTimeLimit === b.defenseTimeLimit &&
a.voteTimeLimit === b.voteTimeLimit &&
a.protestDeadlineHours === b.protestDeadlineHours &&
a.stewardingClosesHours === b.stewardingClosesHours &&
a.notifyAccusedOnProtest === b.notifyAccusedOnProtest &&
a.notifyOnVoteRequired === b.notifyOnVoteRequired
);
}
}

View File

@@ -10,6 +10,10 @@ export interface WeekdaySetProps {
export class WeekdaySet implements IValueObject<WeekdaySetProps> {
private readonly days: Weekday[];
static fromArray(days: Weekday[]): WeekdaySet {
return new WeekdaySet(days);
}
constructor(days: Weekday[]) {
if (!Array.isArray(days) || days.length === 0) {
throw new RacingDomainValidationError('WeekdaySet requires at least one weekday');

View File

@@ -282,10 +282,34 @@ export class InMemorySeasonRepository implements ISeasonRepository {
}
async create(season: Season): Promise<Season> {
// Backward-compatible alias for add()
this.seasons.push(season);
return season;
}
async add(season: Season): Promise<void> {
this.seasons.push(season);
}
async update(season: Season): Promise<void> {
const index = this.seasons.findIndex((s) => s.id === season.id);
if (index === -1) {
this.seasons.push(season);
return;
}
this.seasons[index] = season;
}
async listByLeague(leagueId: string): Promise<Season[]> {
return this.seasons.filter((s) => s.leagueId === leagueId);
}
async listActiveByLeague(leagueId: string): Promise<Season[]> {
return this.seasons.filter(
(s) => s.leagueId === leagueId && s.status === 'active',
);
}
seed(season: Season): void {
this.seasons.push(season);
}

View File

@@ -18,6 +18,10 @@ export class InMemorySeasonSponsorshipRepository implements ISeasonSponsorshipRe
return Array.from(this.sponsorships.values()).filter(s => s.seasonId === seasonId);
}
async findByLeagueId(leagueId: string): Promise<SeasonSponsorship[]> {
return Array.from(this.sponsorships.values()).filter(s => s.leagueId === leagueId);
}
async findBySponsorId(sponsorId: string): Promise<SeasonSponsorship[]> {
return Array.from(this.sponsorships.values()).filter(s => s.sponsorId === sponsorId);
}