This commit is contained in:
2025-12-12 01:11:36 +01:00
parent ec3ddc3a5c
commit 6a88fe93ab
125 changed files with 1513 additions and 803 deletions

View File

@@ -19,9 +19,9 @@ export class Car implements IEntity<string> {
readonly carClass: CarClass;
readonly license: CarLicense;
readonly year: number;
readonly horsepower?: number;
readonly weight?: number;
readonly imageUrl?: string;
readonly horsepower: number | undefined;
readonly weight: number | undefined;
readonly imageUrl: string | undefined;
readonly gameId: string;
private constructor(props: {

View File

@@ -13,7 +13,7 @@ export class Driver implements IEntity<string> {
readonly iracingId: string;
readonly name: string;
readonly country: string;
readonly bio?: string;
readonly bio: string | undefined;
readonly joinedAt: Date;
private constructor(props: {
@@ -92,14 +92,18 @@ export class Driver implements IEntity<string> {
update(props: Partial<{
name: string;
country: string;
bio: string;
bio?: string;
}>): Driver {
const nextName = props.name ?? this.name;
const nextCountry = props.country ?? this.country;
const nextBio = props.bio ?? this.bio;
return new Driver({
id: this.id,
iracingId: this.iracingId,
name: props.name ?? this.name,
country: props.country ?? this.country,
bio: props.bio ?? this.bio,
name: nextName,
country: nextCountry,
...(nextBio !== undefined ? { bio: nextBio } : {}),
joinedAt: this.joinedAt,
});
}

View File

@@ -87,7 +87,7 @@ export class League implements IEntity<string> {
readonly ownerId: string;
readonly settings: LeagueSettings;
readonly createdAt: Date;
readonly socialLinks?: LeagueSocialLinks;
readonly socialLinks: LeagueSocialLinks | undefined;
private constructor(props: {
id: string;
@@ -140,6 +140,8 @@ export class League implements IEntity<string> {
stewarding: defaultStewardingSettings,
};
const socialLinks = props.socialLinks;
return new League({
id: props.id,
name: props.name,
@@ -147,7 +149,7 @@ export class League implements IEntity<string> {
ownerId: props.ownerId,
settings: { ...defaultSettings, ...props.settings },
createdAt: props.createdAt ?? new Date(),
socialLinks: props.socialLinks,
...(socialLinks !== undefined ? { socialLinks } : {}),
});
}
@@ -189,7 +191,7 @@ export class League implements IEntity<string> {
description: string;
ownerId: string;
settings: LeagueSettings;
socialLinks: LeagueSocialLinks | undefined;
socialLinks?: LeagueSocialLinks;
}>): League {
return new League({
id: this.id,
@@ -198,7 +200,11 @@ export class League implements IEntity<string> {
ownerId: props.ownerId ?? this.ownerId,
settings: props.settings ?? this.settings,
createdAt: this.createdAt,
socialLinks: props.socialLinks ?? this.socialLinks,
...(props.socialLinks !== undefined
? { socialLinks: props.socialLinks }
: this.socialLinks !== undefined
? { socialLinks: this.socialLinks }
: {}),
});
}
}

View File

@@ -102,12 +102,16 @@ export class Penalty implements IEntity<string> {
if (this.props.status === 'overturned') {
throw new RacingDomainInvariantError('Cannot apply an overturned penalty');
}
return new Penalty({
const base: PenaltyProps = {
...this.props,
status: 'applied',
appliedAt: new Date(),
notes,
});
};
const next: PenaltyProps =
notes !== undefined ? { ...base, notes } : base;
return Penalty.create(next);
}
/**

View File

@@ -153,14 +153,18 @@ export class Protest implements IEntity<string> {
if (!statement?.trim()) {
throw new RacingDomainValidationError('Defense statement is required');
}
const defenseBase: ProtestDefense = {
statement: statement.trim(),
submittedAt: new Date(),
};
const nextDefense: ProtestDefense =
videoUrl !== undefined ? { ...defenseBase, videoUrl } : defenseBase;
return new Protest({
...this.props,
status: 'under_review',
defense: {
statement: statement.trim(),
videoUrl,
submittedAt: new Date(),
},
defense: nextDefense,
});
}

View File

@@ -16,14 +16,14 @@ export class Race implements IEntity<string> {
readonly leagueId: string;
readonly scheduledAt: Date;
readonly track: string;
readonly trackId?: string;
readonly trackId: string | undefined;
readonly car: string;
readonly carId?: string;
readonly carId: string | undefined;
readonly sessionType: SessionType;
readonly status: RaceStatus;
readonly strengthOfField?: number;
readonly registeredCount?: number;
readonly maxParticipants?: number;
readonly strengthOfField: number | undefined;
readonly registeredCount: number | undefined;
readonly maxParticipants: number | undefined;
private constructor(props: {
id: string;
@@ -127,10 +127,34 @@ export class Race implements IEntity<string> {
throw new RacingDomainInvariantError('Only scheduled races can be started');
}
return new Race({
...this,
status: 'running',
});
const base = {
id: this.id,
leagueId: this.leagueId,
scheduledAt: this.scheduledAt,
track: this.track,
car: this.car,
sessionType: this.sessionType,
status: 'running' as RaceStatus,
};
const withTrackId =
this.trackId !== undefined ? { ...base, trackId: this.trackId } : base;
const withCarId =
this.carId !== undefined ? { ...withTrackId, carId: this.carId } : withTrackId;
const withSof =
this.strengthOfField !== undefined
? { ...withCarId, strengthOfField: this.strengthOfField }
: withCarId;
const withRegistered =
this.registeredCount !== undefined
? { ...withSof, registeredCount: this.registeredCount }
: withSof;
const props =
this.maxParticipants !== undefined
? { ...withRegistered, maxParticipants: this.maxParticipants }
: withRegistered;
return Race.create(props);
}
/**
@@ -145,10 +169,34 @@ export class Race implements IEntity<string> {
throw new RacingDomainInvariantError('Cannot complete a cancelled race');
}
return new Race({
...this,
status: 'completed',
});
const base = {
id: this.id,
leagueId: this.leagueId,
scheduledAt: this.scheduledAt,
track: this.track,
car: this.car,
sessionType: this.sessionType,
status: 'completed' as RaceStatus,
};
const withTrackId =
this.trackId !== undefined ? { ...base, trackId: this.trackId } : base;
const withCarId =
this.carId !== undefined ? { ...withTrackId, carId: this.carId } : withTrackId;
const withSof =
this.strengthOfField !== undefined
? { ...withCarId, strengthOfField: this.strengthOfField }
: withCarId;
const withRegistered =
this.registeredCount !== undefined
? { ...withSof, registeredCount: this.registeredCount }
: withSof;
const props =
this.maxParticipants !== undefined
? { ...withRegistered, maxParticipants: this.maxParticipants }
: withRegistered;
return Race.create(props);
}
/**
@@ -163,21 +211,62 @@ export class Race implements IEntity<string> {
throw new RacingDomainInvariantError('Race is already cancelled');
}
return new Race({
...this,
status: 'cancelled',
});
const base = {
id: this.id,
leagueId: this.leagueId,
scheduledAt: this.scheduledAt,
track: this.track,
car: this.car,
sessionType: this.sessionType,
status: 'cancelled' as RaceStatus,
};
const withTrackId =
this.trackId !== undefined ? { ...base, trackId: this.trackId } : base;
const withCarId =
this.carId !== undefined ? { ...withTrackId, carId: this.carId } : withTrackId;
const withSof =
this.strengthOfField !== undefined
? { ...withCarId, strengthOfField: this.strengthOfField }
: withCarId;
const withRegistered =
this.registeredCount !== undefined
? { ...withSof, registeredCount: this.registeredCount }
: withSof;
const props =
this.maxParticipants !== undefined
? { ...withRegistered, maxParticipants: this.maxParticipants }
: withRegistered;
return Race.create(props);
}
/**
* Update SOF and participant count
*/
updateField(strengthOfField: number, registeredCount: number): Race {
return new Race({
...this,
const base = {
id: this.id,
leagueId: this.leagueId,
scheduledAt: this.scheduledAt,
track: this.track,
car: this.car,
sessionType: this.sessionType,
status: this.status,
strengthOfField,
registeredCount,
});
};
const withTrackId =
this.trackId !== undefined ? { ...base, trackId: this.trackId } : base;
const withCarId =
this.carId !== undefined ? { ...withTrackId, carId: this.carId } : withTrackId;
const props =
this.maxParticipants !== undefined
? { ...withCarId, maxParticipants: this.maxParticipants }
: withCarId;
return Race.create(props);
}
/**

View File

@@ -8,11 +8,11 @@ export class Season implements IEntity<string> {
readonly leagueId: string;
readonly gameId: string;
readonly name: string;
readonly year?: number;
readonly order?: number;
readonly year: number | undefined;
readonly order: number | undefined;
readonly status: SeasonStatus;
readonly startDate?: Date;
readonly endDate?: Date;
readonly startDate: Date | undefined;
readonly endDate: Date | undefined;
private constructor(props: {
id: string;

View File

@@ -33,8 +33,8 @@ export class SeasonSponsorship implements IEntity<string> {
readonly pricing: Money;
readonly status: SponsorshipStatus;
readonly createdAt: Date;
readonly activatedAt?: Date;
readonly description?: string;
readonly activatedAt: Date | undefined;
readonly description: string | undefined;
private constructor(props: SeasonSponsorshipProps) {
this.id = props.id;
@@ -105,11 +105,23 @@ export class SeasonSponsorship implements IEntity<string> {
throw new RacingDomainInvariantError('Cannot activate a cancelled SeasonSponsorship');
}
return new SeasonSponsorship({
...this,
const base: SeasonSponsorshipProps = {
id: this.id,
seasonId: this.seasonId,
sponsorId: this.sponsorId,
tier: this.tier,
pricing: this.pricing,
status: 'active',
createdAt: this.createdAt,
activatedAt: new Date(),
});
};
const next: SeasonSponsorshipProps =
this.description !== undefined
? { ...base, description: this.description }
: base;
return new SeasonSponsorship(next);
}
/**
@@ -120,10 +132,27 @@ export class SeasonSponsorship implements IEntity<string> {
throw new RacingDomainInvariantError('SeasonSponsorship is already cancelled');
}
return new SeasonSponsorship({
...this,
const base: SeasonSponsorshipProps = {
id: this.id,
seasonId: this.seasonId,
sponsorId: this.sponsorId,
tier: this.tier,
pricing: this.pricing,
status: 'cancelled',
});
createdAt: this.createdAt,
};
const withActivated =
this.activatedAt !== undefined
? { ...base, activatedAt: this.activatedAt }
: base;
const next: SeasonSponsorshipProps =
this.description !== undefined
? { ...withActivated, description: this.description }
: withActivated;
return new SeasonSponsorship(next);
}
/**

View File

@@ -20,8 +20,8 @@ export class Sponsor implements IEntity<string> {
readonly id: string;
readonly name: string;
readonly contactEmail: string;
readonly logoUrl?: string;
readonly websiteUrl?: string;
readonly logoUrl: string | undefined;
readonly websiteUrl: string | undefined;
readonly createdAt: Date;
private constructor(props: SponsorProps) {
@@ -35,11 +35,23 @@ export class Sponsor implements IEntity<string> {
static create(props: Omit<SponsorProps, 'createdAt'> & { createdAt?: Date }): Sponsor {
this.validate(props);
return new Sponsor({
...props,
createdAt: props.createdAt ?? new Date(),
});
const { createdAt, ...rest } = props;
const base = {
id: rest.id,
name: rest.name,
contactEmail: rest.contactEmail,
createdAt: createdAt ?? new Date(),
};
const withLogo =
rest.logoUrl !== undefined ? { ...base, logoUrl: rest.logoUrl } : base;
const withWebsite =
rest.websiteUrl !== undefined
? { ...withLogo, websiteUrl: rest.websiteUrl }
: withLogo;
return new Sponsor(withWebsite);
}
private static validate(props: Omit<SponsorProps, 'createdAt'>): void {
@@ -80,18 +92,30 @@ export class Sponsor implements IEntity<string> {
update(props: Partial<{
name: string;
contactEmail: string;
logoUrl: string | undefined;
websiteUrl: string | undefined;
logoUrl?: string;
websiteUrl?: string;
}>): Sponsor {
const updated = {
const updatedBase = {
id: this.id,
name: props.name ?? this.name,
contactEmail: props.contactEmail ?? this.contactEmail,
logoUrl: props.logoUrl !== undefined ? props.logoUrl : this.logoUrl,
websiteUrl: props.websiteUrl !== undefined ? props.websiteUrl : this.websiteUrl,
createdAt: this.createdAt,
};
const withLogo =
props.logoUrl !== undefined
? { ...updatedBase, logoUrl: props.logoUrl }
: this.logoUrl !== undefined
? { ...updatedBase, logoUrl: this.logoUrl }
: updatedBase;
const updated =
props.websiteUrl !== undefined
? { ...withLogo, websiteUrl: props.websiteUrl }
: this.websiteUrl !== undefined
? { ...withLogo, websiteUrl: this.websiteUrl }
: withLogo;
Sponsor.validate(updated);
return new Sponsor(updated);
}

View File

@@ -36,12 +36,12 @@ export class SponsorshipRequest implements IEntity<string> {
readonly entityId: string;
readonly tier: SponsorshipTier;
readonly offeredAmount: Money;
readonly message?: string;
readonly message: string | undefined;
readonly status: SponsorshipRequestStatus;
readonly createdAt: Date;
readonly respondedAt?: Date;
readonly respondedBy?: string;
readonly rejectionReason?: string;
readonly respondedAt: Date | undefined;
readonly respondedBy: string | undefined;
readonly rejectionReason: string | undefined;
private constructor(props: SponsorshipRequestProps) {
this.id = props.id;
@@ -113,12 +113,28 @@ export class SponsorshipRequest implements IEntity<string> {
throw new RacingDomainValidationError('respondedBy is required when accepting');
}
return new SponsorshipRequest({
...this,
const base: SponsorshipRequestProps = {
id: this.id,
sponsorId: this.sponsorId,
entityType: this.entityType,
entityId: this.entityId,
tier: this.tier,
offeredAmount: this.offeredAmount,
status: 'accepted',
createdAt: this.createdAt,
respondedAt: new Date(),
respondedBy,
});
};
const withMessage =
this.message !== undefined ? { ...base, message: this.message } : base;
const next: SponsorshipRequestProps =
this.rejectionReason !== undefined
? { ...withMessage, rejectionReason: this.rejectionReason }
: withMessage;
return new SponsorshipRequest(next);
}
/**
@@ -133,13 +149,26 @@ export class SponsorshipRequest implements IEntity<string> {
throw new RacingDomainValidationError('respondedBy is required when rejecting');
}
return new SponsorshipRequest({
...this,
const base: SponsorshipRequestProps = {
id: this.id,
sponsorId: this.sponsorId,
entityType: this.entityType,
entityId: this.entityId,
tier: this.tier,
offeredAmount: this.offeredAmount,
status: 'rejected',
createdAt: this.createdAt,
respondedAt: new Date(),
respondedBy,
rejectionReason: reason,
});
};
const withMessage =
this.message !== undefined ? { ...base, message: this.message } : base;
const next: SponsorshipRequestProps =
reason !== undefined ? { ...withMessage, rejectionReason: reason } : withMessage;
return new SponsorshipRequest(next);
}
/**
@@ -150,11 +179,34 @@ export class SponsorshipRequest implements IEntity<string> {
throw new RacingDomainInvariantError(`Cannot withdraw a ${this.status} sponsorship request`);
}
return new SponsorshipRequest({
...this,
const base: SponsorshipRequestProps = {
id: this.id,
sponsorId: this.sponsorId,
entityType: this.entityType,
entityId: this.entityId,
tier: this.tier,
offeredAmount: this.offeredAmount,
status: 'withdrawn',
createdAt: this.createdAt,
respondedAt: new Date(),
});
};
const withRespondedBy =
this.respondedBy !== undefined
? { ...base, respondedBy: this.respondedBy }
: base;
const withMessage =
this.message !== undefined
? { ...withRespondedBy, message: this.message }
: withRespondedBy;
const next: SponsorshipRequestProps =
this.rejectionReason !== undefined
? { ...withMessage, rejectionReason: this.rejectionReason }
: withMessage;
return new SponsorshipRequest(next);
}
/**

View File

@@ -5,7 +5,7 @@
* Immutable entity with factory methods and domain validation.
*/
import { RacingDomainValidationError, RacingDomainError } from '../errors/RacingDomainError';
import { RacingDomainError, RacingDomainValidationError } from '../errors/RacingDomainError';
import type { IEntity } from '@gridpilot/shared/domain';
export class Standing implements IEntity<string> {
@@ -104,12 +104,17 @@ export class Standing implements IEntity<string> {
*/
updatePosition(position: number): Standing {
if (!Number.isInteger(position) || position < 1) {
throw new RacingDomainError('Position must be a positive integer');
throw new RacingDomainValidationError('Position must be a positive integer');
}
return new Standing({
...this,
return Standing.create({
id: this.id,
leagueId: this.leagueId,
driverId: this.driverId,
points: this.points,
wins: this.wins,
position,
racesCompleted: this.racesCompleted,
});
}

View File

@@ -20,7 +20,7 @@ export class Track implements IEntity<string> {
readonly difficulty: TrackDifficulty;
readonly lengthKm: number;
readonly turns: number;
readonly imageUrl?: string;
readonly imageUrl: string | undefined;
readonly gameId: string;
private constructor(props: {
@@ -32,7 +32,7 @@ export class Track implements IEntity<string> {
difficulty: TrackDifficulty;
lengthKm: number;
turns: number;
imageUrl?: string;
imageUrl?: string | undefined;
gameId: string;
}) {
this.id = props.id;
@@ -64,7 +64,7 @@ export class Track implements IEntity<string> {
}): Track {
this.validate(props);
return new Track({
const base = {
id: props.id,
name: props.name,
shortName: props.shortName ?? props.name.slice(0, 3).toUpperCase(),
@@ -73,9 +73,13 @@ export class Track implements IEntity<string> {
difficulty: props.difficulty ?? 'intermediate',
lengthKm: props.lengthKm,
turns: props.turns,
imageUrl: props.imageUrl,
gameId: props.gameId,
});
};
const withImage =
props.imageUrl !== undefined ? { ...base, imageUrl: props.imageUrl } : base;
return new Track(withImage);
}
/**

View File

@@ -36,6 +36,11 @@ export interface ITeamMembershipRepository {
*/
removeMembership(teamId: string, driverId: string): Promise<void>;
/**
* Count active members for a team.
*/
countByTeamId(teamId: string): Promise<number>;
/**
* Get all join requests for a team.
*/

View File

@@ -115,6 +115,10 @@ export class EventScoringService
const sortedByLap = [...results].sort((a, b) => a.fastestLap - b.fastestLap);
const best = sortedByLap[0];
if (!best) {
return;
}
const requiresTop = rule.requiresFinishInTopN;
if (typeof requiresTop === 'number') {
if (best.position <= 0 || best.position > requiresTop) {

View File

@@ -0,0 +1,13 @@
import type { IDomainService } from '@gridpilot/shared/domain';
export interface DriverStats {
rating: number;
wins: number;
podiums: number;
totalRaces: number;
overallRank: number | null;
}
export interface IDriverStatsService extends IDomainService {
getDriverStats(driverId: string): DriverStats | null;
}

View File

@@ -0,0 +1,11 @@
import type { IDomainService } from '@gridpilot/shared/domain';
export interface DriverRanking {
driverId: string;
rating: number;
overallRank: number | null;
}
export interface IRankingService extends IDomainService {
getAllDriverRankings(): DriverRanking[];
}

View File

@@ -1,7 +1,7 @@
import { SeasonSchedule } from '../value-objects/SeasonSchedule';
import { ScheduledRaceSlot } from '../value-objects/ScheduledRaceSlot';
import type { RecurrenceStrategy } from '../value-objects/RecurrenceStrategy';
import { RacingDomainValidationError, RacingDomainError } from '../errors/RacingDomainError';
import { RacingDomainError, RacingDomainValidationError } from '../errors/RacingDomainError';
import { RaceTimeOfDay } from '../value-objects/RaceTimeOfDay';
import type { Weekday } from '../types/Weekday';
import { weekdayToIndex } from '../types/Weekday';
@@ -163,7 +163,7 @@ export class SeasonScheduleGenerator {
static generateSlotsUpTo(schedule: SeasonSchedule, maxRounds: number): ScheduledRaceSlot[] {
if (!Number.isInteger(maxRounds) || maxRounds <= 0) {
throw new RacingDomainError('maxRounds must be a positive integer');
throw new RacingDomainValidationError('maxRounds must be a positive integer');
}
const recurrence: RecurrenceStrategy = schedule.recurrence;

View File

@@ -24,7 +24,7 @@ export interface GameConstraintsProps {
/**
* Game-specific constraints for popular sim racing games
*/
const GAME_CONSTRAINTS: Record<string, GameConstraintsData> = {
const GAME_CONSTRAINTS: Record<string, GameConstraintsData> & { default: GameConstraintsData } = {
iracing: {
maxDrivers: 64,
maxTeams: 32,
@@ -76,6 +76,15 @@ const GAME_CONSTRAINTS: Record<string, GameConstraintsData> = {
},
};
function getConstraintsForId(gameId: string): GameConstraintsData {
const lower = gameId.toLowerCase();
const fromMap = GAME_CONSTRAINTS[lower];
if (fromMap) {
return fromMap;
}
return GAME_CONSTRAINTS.default;
}
export class GameConstraints implements IValueObject<GameConstraintsProps> {
readonly gameId: string;
readonly constraints: GameConstraintsData;
@@ -100,8 +109,8 @@ export class GameConstraints implements IValueObject<GameConstraintsProps> {
* Get constraints for a specific game
*/
static forGame(gameId: string): GameConstraints {
const constraints = getConstraintsForId(gameId);
const lowerId = gameId.toLowerCase();
const constraints = GAME_CONSTRAINTS[lowerId] ?? GAME_CONSTRAINTS.default;
return new GameConstraints(lowerId, constraints);
}

View File

@@ -17,17 +17,17 @@ export interface SponsorshipSlotConfig {
}
export interface SponsorshipPricingProps {
mainSlot?: SponsorshipSlotConfig;
secondarySlots?: SponsorshipSlotConfig;
mainSlot?: SponsorshipSlotConfig | undefined;
secondarySlots?: SponsorshipSlotConfig | undefined;
acceptingApplications: boolean;
customRequirements?: string;
customRequirements?: string | undefined;
}
export class SponsorshipPricing implements IValueObject<SponsorshipPricingProps> {
readonly mainSlot?: SponsorshipSlotConfig;
readonly secondarySlots?: SponsorshipSlotConfig;
readonly mainSlot: SponsorshipSlotConfig | undefined;
readonly secondarySlots: SponsorshipSlotConfig | undefined;
readonly acceptingApplications: boolean;
readonly customRequirements?: string;
readonly customRequirements: string | undefined;
private constructor(props: SponsorshipPricingProps) {
this.mainSlot = props.mainSlot;
@@ -212,8 +212,10 @@ export class SponsorshipPricing implements IValueObject<SponsorshipPricingProps>
maxSlots: 1,
};
const base = this.props;
return new SponsorshipPricing({
...this,
...base,
mainSlot: {
...currentMain,
...config,
@@ -234,8 +236,10 @@ export class SponsorshipPricing implements IValueObject<SponsorshipPricingProps>
maxSlots: 2,
};
const base = this.props;
return new SponsorshipPricing({
...this,
...base,
secondarySlots: {
...currentSecondary,
...config,
@@ -248,8 +252,10 @@ export class SponsorshipPricing implements IValueObject<SponsorshipPricingProps>
* Enable/disable accepting applications
*/
setAcceptingApplications(accepting: boolean): SponsorshipPricing {
const base = this.props;
return new SponsorshipPricing({
...this,
...base,
acceptingApplications: accepting,
});
}