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);
}
/**