This commit is contained in:
2025-12-11 11:25:22 +01:00
parent 6a427eab57
commit e4c1be628d
86 changed files with 1222 additions and 736 deletions

View File

@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto';
import { StepId } from '../value-objects/StepId';
import { SessionState } from '../value-objects/SessionState';
import { HostedSessionConfig } from './HostedSessionConfig';
import { AutomationDomainError } from '../errors/AutomationDomainError';
export class AutomationSession {
private readonly _id: string;
@@ -26,13 +27,13 @@ export class AutomationSession {
static create(config: HostedSessionConfig): AutomationSession {
if (!config.sessionName || config.sessionName.trim() === '') {
throw new Error('Session name cannot be empty');
throw new AutomationDomainError('Session name cannot be empty');
}
if (!config.trackId || config.trackId.trim() === '') {
throw new Error('Track ID is required');
throw new AutomationDomainError('Track ID is required');
}
if (!config.carIds || config.carIds.length === 0) {
throw new Error('At least one car must be selected');
throw new AutomationDomainError('At least one car must be selected');
}
return new AutomationSession(
@@ -73,7 +74,7 @@ export class AutomationSession {
start(): void {
if (!this._state.isPending()) {
throw new Error('Cannot start session that is not pending');
throw new AutomationDomainError('Cannot start session that is not pending');
}
this._state = SessionState.create('IN_PROGRESS');
this._startedAt = new Date();
@@ -81,19 +82,19 @@ export class AutomationSession {
transitionToStep(targetStep: StepId): void {
if (!this._state.isInProgress()) {
throw new Error('Cannot transition steps when session is not in progress');
throw new AutomationDomainError('Cannot transition steps when session is not in progress');
}
if (this._currentStep.equals(targetStep)) {
throw new Error('Already at this step');
throw new AutomationDomainError('Already at this step');
}
if (targetStep.value < this._currentStep.value) {
throw new Error('Cannot move backward - steps must progress forward only');
throw new AutomationDomainError('Cannot move backward - steps must progress forward only');
}
if (targetStep.value !== this._currentStep.value + 1) {
throw new Error('Cannot skip steps - must transition sequentially');
throw new AutomationDomainError('Cannot skip steps - must transition sequentially');
}
this._currentStep = targetStep;
@@ -106,21 +107,21 @@ export class AutomationSession {
pause(): void {
if (!this._state.isInProgress()) {
throw new Error('Cannot pause session that is not in progress');
throw new AutomationDomainError('Cannot pause session that is not in progress');
}
this._state = SessionState.create('PAUSED');
}
resume(): void {
if (this._state.value !== 'PAUSED') {
throw new Error('Cannot resume session that is not paused');
throw new AutomationDomainError('Cannot resume session that is not paused');
}
this._state = SessionState.create('IN_PROGRESS');
}
fail(errorMessage: string): void {
if (this._state.isTerminal()) {
throw new Error('Cannot fail terminal session');
throw new AutomationDomainError('Cannot fail terminal session');
}
this._state = SessionState.create('FAILED');
this._errorMessage = errorMessage;

View File

@@ -0,0 +1,8 @@
export class AutomationDomainError extends Error {
readonly name: string = 'AutomationDomainError';
constructor(message: string) {
super(message);
Object.setPrototypeOf(this, new.target.prototype);
}
}

View File

@@ -1,4 +0,0 @@
export * from './media/DemoImageServiceAdapter';
export * from './media/DemoFaceValidationAdapter';
export * from './media/DemoAvatarGenerationAdapter';
export * from './media/InMemoryAvatarGenerationRepository';

View File

@@ -1,15 +0,0 @@
{
"name": "@gridpilot/demo-infrastructure",
"version": "0.1.0",
"type": "module",
"main": "./index.ts",
"types": "./index.ts",
"dependencies": {
"@gridpilot/media": "file:../media",
"@faker-js/faker": "^9.0.0"
},
"exports": {
".": "./index.ts",
"./media/*": "./media/*"
}
}

View File

@@ -1,13 +0,0 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "../..",
"outDir": "dist",
"declaration": true,
"declarationMap": false
},
"include": [
"../../packages/demo-infrastructure/**/*.ts",
"../../packages/media/**/*.ts"
]
}

View File

@@ -1,5 +1,5 @@
import { randomUUID } from 'crypto';
import { createStaticRacingSeed } from '../../../testing-support';
import { createStaticRacingSeed } from '@gridpilot/testing-support';
import type { IdentityProviderPort } from '../../application/ports/IdentityProviderPort';
import type { StartAuthCommandDTO } from '../../application/dto/StartAuthCommandDTO';
import type { AuthCallbackCommandDTO } from '../../application/dto/AuthCallbackCommandDTO';

View File

@@ -7,7 +7,7 @@
// Use Cases
export * from './use-cases/SendNotificationUseCase';
export * from './use-cases/MarkNotificationReadUseCase';
export * from './use-cases/GetUnreadNotificationsQuery';
export * from './use-cases/GetUnreadNotificationsUseCase';
export * from './use-cases/NotificationPreferencesUseCases';
// Ports

View File

@@ -1,5 +1,5 @@
/**
* Application Query: GetUnreadNotificationsQuery
* Application Use Case: GetUnreadNotificationsUseCase
*
* Retrieves unread notifications for a recipient.
*/
@@ -12,7 +12,7 @@ export interface UnreadNotificationsResult {
totalCount: number;
}
export class GetUnreadNotificationsQuery {
export class GetUnreadNotificationsUseCase {
constructor(
private readonly notificationRepository: INotificationRepository,
) {}
@@ -28,6 +28,6 @@ export class GetUnreadNotificationsQuery {
}
/**
* Additional notification query use cases (e.g., listing or counting notifications)
* Additional notification query/use case types (e.g., listing or counting notifications)
* can be added here in the future as needed.
*/

View File

@@ -5,6 +5,7 @@
*/
import type { INotificationRepository } from '../../domain/repositories/INotificationRepository';
import { NotificationDomainError } from '../../domain/errors/NotificationDomainError';
export interface MarkNotificationReadCommand {
notificationId: string;
@@ -20,11 +21,11 @@ export class MarkNotificationReadUseCase {
const notification = await this.notificationRepository.findById(command.notificationId);
if (!notification) {
throw new Error('Notification not found');
throw new NotificationDomainError('Notification not found');
}
if (notification.recipientId !== command.recipientId) {
throw new Error('Cannot mark another user\'s notification as read');
throw new NotificationDomainError('Cannot mark another user\'s notification as read');
}
if (!notification.isUnread()) {
@@ -70,11 +71,11 @@ export class DismissNotificationUseCase {
const notification = await this.notificationRepository.findById(command.notificationId);
if (!notification) {
throw new Error('Notification not found');
throw new NotificationDomainError('Notification not found');
}
if (notification.recipientId !== command.recipientId) {
throw new Error('Cannot dismiss another user\'s notification');
throw new NotificationDomainError('Cannot dismiss another user\'s notification');
}
if (notification.isDismissed()) {

View File

@@ -82,10 +82,10 @@ export class UpdateQuietHoursUseCase {
async execute(command: UpdateQuietHoursCommand): Promise<void> {
// Validate hours if provided
if (command.startHour !== undefined && (command.startHour < 0 || command.startHour > 23)) {
throw new Error('Start hour must be between 0 and 23');
throw new NotificationDomainError('Start hour must be between 0 and 23');
}
if (command.endHour !== undefined && (command.endHour < 0 || command.endHour > 23)) {
throw new Error('End hour must be between 0 and 23');
throw new NotificationDomainError('End hour must be between 0 and 23');
}
const preferences = await this.preferenceRepository.getOrCreateDefault(command.driverId);
@@ -110,7 +110,7 @@ export class SetDigestModeUseCase {
async execute(command: SetDigestModeCommand): Promise<void> {
if (command.frequencyHours !== undefined && command.frequencyHours < 1) {
throw new Error('Digest frequency must be at least 1 hour');
throw new NotificationDomainError('Digest frequency must be at least 1 hour');
}
const preferences = await this.preferenceRepository.getOrCreateDefault(command.driverId);

View File

@@ -5,6 +5,8 @@
* Immutable entity with factory methods and domain validation.
*/
import { NotificationDomainError } from '../errors/NotificationDomainError';
import type { NotificationType } from '../value-objects/NotificationType';
import type { NotificationChannel } from '../value-objects/NotificationChannel';
@@ -91,12 +93,12 @@ export class Notification {
createdAt?: Date;
urgency?: NotificationUrgency;
}): Notification {
if (!props.id) throw new Error('Notification ID is required');
if (!props.recipientId) throw new Error('Recipient ID is required');
if (!props.type) throw new Error('Notification type is required');
if (!props.title?.trim()) throw new Error('Notification title is required');
if (!props.body?.trim()) throw new Error('Notification body is required');
if (!props.channel) throw new Error('Notification channel is required');
if (!props.id) throw new NotificationDomainError('Notification ID is required');
if (!props.recipientId) throw new NotificationDomainError('Recipient ID is required');
if (!props.type) throw new NotificationDomainError('Notification type is required');
if (!props.title?.trim()) throw new NotificationDomainError('Notification title is required');
if (!props.body?.trim()) throw new NotificationDomainError('Notification body is required');
if (!props.channel) throw new NotificationDomainError('Notification channel is required');
// Modal notifications that require response start with action_required status
const defaultStatus = props.requiresResponse ? 'action_required' : 'unread';
@@ -196,7 +198,7 @@ export class Notification {
}
// Cannot dismiss action_required notifications without responding
if (this.props.requiresResponse && this.props.status === 'action_required') {
throw new Error('Cannot dismiss notification that requires response');
throw new NotificationDomainError('Cannot dismiss notification that requires response');
}
return new Notification({
...this.props,

View File

@@ -6,6 +6,7 @@
import type { NotificationType } from '../value-objects/NotificationType';
import type { NotificationChannel } from '../value-objects/NotificationChannel';
import { NotificationDomainError } from '../errors/NotificationDomainError';
import { DEFAULT_ENABLED_CHANNELS } from '../value-objects/NotificationChannel';
export interface ChannelPreference {
@@ -45,8 +46,8 @@ export class NotificationPreference {
private constructor(private readonly props: NotificationPreferenceProps) {}
static create(props: Omit<NotificationPreferenceProps, 'updatedAt'> & { updatedAt?: Date }): NotificationPreference {
if (!props.driverId) throw new Error('Driver ID is required');
if (!props.channels) throw new Error('Channel preferences are required');
if (!props.driverId) throw new NotificationDomainError('Driver ID is required');
if (!props.channels) throw new NotificationDomainError('Channel preferences are required');
return new NotificationPreference({
...props,

View File

@@ -0,0 +1,8 @@
export class NotificationDomainError extends Error {
readonly name: string = 'NotificationDomainError';
constructor(message: string) {
super(message);
Object.setPrototypeOf(this, new.target.prototype);
}
}

View File

@@ -7,6 +7,7 @@ import { MonthlyRecurrencePattern } from '../../domain/value-objects/MonthlyRecu
import type { RecurrenceStrategy } from '../../domain/value-objects/RecurrenceStrategy';
import { RecurrenceStrategyFactory } from '../../domain/value-objects/RecurrenceStrategy';
import { SeasonSchedule } from '../../domain/value-objects/SeasonSchedule';
import { BusinessRuleViolationError } from '../errors/RacingApplicationError';
export interface LeagueScheduleDTO {
seasonStartDate: string;
@@ -53,24 +54,24 @@ export function leagueTimingsToScheduleDTO(
export function scheduleDTOToSeasonSchedule(dto: LeagueScheduleDTO): SeasonSchedule {
if (!dto.seasonStartDate) {
throw new Error('seasonStartDate is required');
throw new RacingApplicationError('seasonStartDate is required');
}
if (!dto.raceStartTime) {
throw new Error('raceStartTime is required');
throw new RacingApplicationError('raceStartTime is required');
}
if (!dto.timezoneId) {
throw new Error('timezoneId is required');
throw new RacingApplicationError('timezoneId is required');
}
if (!dto.recurrenceStrategy) {
throw new Error('recurrenceStrategy is required');
throw new RacingApplicationError('recurrenceStrategy is required');
}
if (!Number.isInteger(dto.plannedRounds) || dto.plannedRounds <= 0) {
throw new Error('plannedRounds must be a positive integer');
throw new RacingApplicationError('plannedRounds must be a positive integer');
}
const startDate = new Date(dto.seasonStartDate);
if (Number.isNaN(startDate.getTime())) {
throw new Error(`seasonStartDate must be a valid date, got "${dto.seasonStartDate}"`);
throw new RacingApplicationError(`seasonStartDate must be a valid date, got "${dto.seasonStartDate}"`);
}
const timeOfDay = RaceTimeOfDay.fromString(dto.raceStartTime);
@@ -80,15 +81,15 @@ export function scheduleDTOToSeasonSchedule(dto: LeagueScheduleDTO): SeasonSched
if (dto.recurrenceStrategy === 'weekly') {
if (!dto.weekdays || dto.weekdays.length === 0) {
throw new Error('weekdays are required for weekly recurrence');
throw new RacingApplicationError('weekdays are required for weekly recurrence');
}
recurrence = RecurrenceStrategyFactory.weekly(new WeekdaySet(dto.weekdays));
} else if (dto.recurrenceStrategy === 'everyNWeeks') {
if (!dto.weekdays || dto.weekdays.length === 0) {
throw new Error('weekdays are required for everyNWeeks recurrence');
throw new RacingApplicationError('weekdays are required for everyNWeeks recurrence');
}
if (dto.intervalWeeks == null) {
throw new Error('intervalWeeks is required for everyNWeeks recurrence');
throw new RacingApplicationError('intervalWeeks is required for everyNWeeks recurrence');
}
recurrence = RecurrenceStrategyFactory.everyNWeeks(
dto.intervalWeeks,
@@ -96,12 +97,12 @@ export function scheduleDTOToSeasonSchedule(dto: LeagueScheduleDTO): SeasonSched
);
} else if (dto.recurrenceStrategy === 'monthlyNthWeekday') {
if (!dto.monthlyOrdinal || !dto.monthlyWeekday) {
throw new Error('monthlyOrdinal and monthlyWeekday are required for monthlyNthWeekday');
throw new RacingApplicationError('monthlyOrdinal and monthlyWeekday are required for monthlyNthWeekday');
}
const pattern = new MonthlyRecurrencePattern(dto.monthlyOrdinal, dto.monthlyWeekday);
recurrence = RecurrenceStrategyFactory.monthlyNthWeekday(pattern);
} else {
throw new Error(`Unknown recurrenceStrategy "${dto.recurrenceStrategy}"`);
throw new RacingApplicationError(`Unknown recurrenceStrategy "${dto.recurrenceStrategy}"`);
}
return new SeasonSchedule({

View File

@@ -0,0 +1,56 @@
export abstract class RacingApplicationError extends Error {
readonly context = 'racing-application';
constructor(message: string) {
super(message);
Object.setPrototypeOf(this, new.target.prototype);
}
}
export type RacingEntityType =
| 'race'
| 'league'
| 'team'
| 'season'
| 'sponsorship'
| 'sponsorshipRequest'
| 'driver'
| 'membership';
export interface EntityNotFoundDetails {
entity: RacingEntityType;
id: string;
}
export class EntityNotFoundError extends RacingApplicationError {
readonly kind = 'not_found' as const;
constructor(public readonly details: EntityNotFoundDetails) {
super(`${details.entity} not found for id: ${details.id}`);
}
}
export type PermissionDeniedReason =
| 'NOT_LEAGUE_ADMIN'
| 'NOT_LEAGUE_OWNER'
| 'NOT_TEAM_OWNER'
| 'NOT_ACTIVE_MEMBER'
| 'NOT_MEMBER'
| 'TEAM_OWNER_CANNOT_LEAVE'
| 'UNAUTHORIZED';
export class PermissionDeniedError extends RacingApplicationError {
readonly kind = 'forbidden' as const;
constructor(public readonly reason: PermissionDeniedReason, message?: string) {
super(message ?? `Permission denied: ${reason}`);
}
}
export class BusinessRuleViolationError extends RacingApplicationError {
readonly kind = 'conflict' as const;
constructor(message: string) {
super(message);
}
}

View File

@@ -11,6 +11,10 @@ import type { ISponsorshipRequestRepository } from '../../domain/repositories/IS
import type { ISponsorshipPricingRepository } from '../../domain/repositories/ISponsorshipPricingRepository';
import type { ISponsorRepository } from '../../domain/repositories/ISponsorRepository';
import { Money, type Currency } from '../../domain/value-objects/Money';
import {
EntityNotFoundError,
BusinessRuleViolationError,
} from '../errors/RacingApplicationError';
export interface ApplyForSponsorshipDTO {
sponsorId: string;
@@ -39,23 +43,23 @@ export class ApplyForSponsorshipUseCase {
// Validate sponsor exists
const sponsor = await this.sponsorRepo.findById(dto.sponsorId);
if (!sponsor) {
throw new Error('Sponsor not found');
throw new EntityNotFoundError({ entity: 'sponsor', id: dto.sponsorId });
}
// Check if entity accepts sponsorship applications
const pricing = await this.sponsorshipPricingRepo.findByEntity(dto.entityType, dto.entityId);
if (!pricing) {
throw new Error('This entity has not set up sponsorship pricing');
throw new BusinessRuleViolationError('This entity has not set up sponsorship pricing');
}
if (!pricing.acceptingApplications) {
throw new Error('This entity is not currently accepting sponsorship applications');
throw new RacingApplicationError('This entity is not currently accepting sponsorship applications');
}
// Check if the requested tier slot is available
const slotAvailable = pricing.isSlotAvailable(dto.tier);
if (!slotAvailable) {
throw new Error(`No ${dto.tier} sponsorship slots are available`);
throw new RacingApplicationError(`No ${dto.tier} sponsorship slots are available`);
}
// Check if sponsor already has a pending request for this entity
@@ -65,13 +69,13 @@ export class ApplyForSponsorshipUseCase {
dto.entityId
);
if (hasPending) {
throw new Error('You already have a pending sponsorship request for this entity');
throw new RacingApplicationError('You already have a pending sponsorship request for this entity');
}
// Validate offered amount meets minimum price
const minPrice = pricing.getPrice(dto.tier);
if (minPrice && dto.offeredAmount < minPrice.amount) {
throw new Error(`Offered amount must be at least ${minPrice.format()}`);
throw new RacingApplicationError(`Offered amount must be at least ${minPrice.format()}`);
}
// Create the sponsorship request

View File

@@ -3,6 +3,7 @@ import type { ISeasonRepository } from '../../domain/repositories/ISeasonReposit
import type { ILeagueScoringConfigRepository } from '../../domain/repositories/ILeagueScoringConfigRepository';
import type { IGameRepository } from '../../domain/repositories/IGameRepository';
import type { ILeagueFullConfigPresenter, LeagueFullConfigData } from '../presenters/ILeagueFullConfigPresenter';
import { EntityNotFoundError } from '../errors/RacingApplicationError';
/**
* Use Case for retrieving a league's full configuration.
@@ -22,7 +23,7 @@ export class GetLeagueFullConfigUseCase {
const league = await this.leagueRepository.findById(leagueId);
if (!league) {
throw new Error(`League ${leagueId} not found`);
throw new EntityNotFoundError({ entity: 'league', id: leagueId });
}
const seasons = await this.seasonRepository.findByLeagueId(leagueId);

View File

@@ -3,6 +3,10 @@ import type { ILeagueRepository } from '../../domain/repositories/ILeagueReposit
import type { IResultRepository } from '../../domain/repositories/IResultRepository';
import type { IStandingRepository } from '../../domain/repositories/IStandingRepository';
import { Result } from '../../domain/entities/Result';
import {
BusinessRuleViolationError,
EntityNotFoundError,
} from '../errors/RacingApplicationError';
import type {
IImportRaceResultsPresenter,
ImportRaceResultsSummaryViewModel,
@@ -37,17 +41,17 @@ export class ImportRaceResultsUseCase {
const race = await this.raceRepository.findById(raceId);
if (!race) {
throw new Error('Race not found');
throw new EntityNotFoundError({ entity: 'race', id: raceId });
}
const league = await this.leagueRepository.findById(race.leagueId);
if (!league) {
throw new Error('League not found');
throw new EntityNotFoundError({ entity: 'league', id: race.leagueId });
}
const existing = await this.resultRepository.existsByRaceId(raceId);
if (existing) {
throw new Error('Results already exist for this race');
throw new BusinessRuleViolationError('Results already exist for this race');
}
const entities = results.map((dto) =>

View File

@@ -7,6 +7,7 @@ import type {
MembershipStatus,
} from '@gridpilot/racing/domain/entities/LeagueMembership';
import type { JoinLeagueCommandDTO } from '../dto/JoinLeagueCommandDTO';
import { BusinessRuleViolationError } from '../errors/RacingApplicationError';
export class JoinLeagueUseCase {
constructor(private readonly membershipRepository: ILeagueMembershipRepository) {}
@@ -23,7 +24,7 @@ export class JoinLeagueUseCase {
const existing = await this.membershipRepository.getMembership(leagueId, driverId);
if (existing) {
throw new Error('Already a member or have a pending request');
throw new BusinessRuleViolationError('Already a member or have a pending request');
}
const membership: LeagueMembership = {

View File

@@ -6,6 +6,10 @@ import type {
TeamRole,
} from '../../domain/entities/Team';
import type { JoinTeamCommandDTO } from '../dto/TeamCommandAndQueryDTO';
import {
BusinessRuleViolationError,
EntityNotFoundError,
} from '../errors/RacingApplicationError';
export class JoinTeamUseCase {
constructor(
@@ -20,17 +24,17 @@ export class JoinTeamUseCase {
driverId,
);
if (existingActive) {
throw new Error('Driver already belongs to a team');
throw new BusinessRuleViolationError('Driver already belongs to a team');
}
const existingMembership = await this.membershipRepository.getMembership(teamId, driverId);
if (existingMembership) {
throw new Error('Already a member or have a pending request');
throw new BusinessRuleViolationError('Already a member or have a pending request');
}
const team = await this.teamRepository.findById(teamId);
if (!team) {
throw new Error('Team not found');
throw new EntityNotFoundError({ entity: 'team', id: teamId });
}
const membership: TeamMembership = {

View File

@@ -2,6 +2,10 @@ import type { IRaceRegistrationRepository } from '@gridpilot/racing/domain/repos
import type { ILeagueMembershipRepository } from '@gridpilot/racing/domain/repositories/ILeagueMembershipRepository';
import type { RaceRegistration } from '@gridpilot/racing/domain/entities/RaceRegistration';
import type { RegisterForRaceCommandDTO } from '../dto/RegisterForRaceCommandDTO';
import {
BusinessRuleViolationError,
PermissionDeniedError,
} from '../errors/RacingApplicationError';
export class RegisterForRaceUseCase {
constructor(
@@ -20,12 +24,12 @@ export class RegisterForRaceUseCase {
const alreadyRegistered = await this.registrationRepository.isRegistered(raceId, driverId);
if (alreadyRegistered) {
throw new Error('Already registered for this race');
throw new BusinessRuleViolationError('Already registered for this race');
}
const membership = await this.membershipRepository.getMembership(leagueId, driverId);
if (!membership || membership.status !== 'active') {
throw new Error('Must be an active league member to register for races');
throw new PermissionDeniedError('NOT_ACTIVE_MEMBER', 'Must be an active league member to register for races');
}
const registration: RaceRegistration = {

View File

@@ -7,6 +7,10 @@
import type { IProtestRepository } from '../../domain/repositories/IProtestRepository';
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
import {
EntityNotFoundError,
PermissionDeniedError,
} from '../errors/RacingApplicationError';
export interface ReviewProtestCommand {
protestId: string;
@@ -26,13 +30,13 @@ export class ReviewProtestUseCase {
// Load the protest
const protest = await this.protestRepository.findById(command.protestId);
if (!protest) {
throw new Error('Protest not found');
throw new EntityNotFoundError({ entity: 'protest', id: command.protestId });
}
// Load the race to get league ID
const race = await this.raceRepository.findById(protest.raceId);
if (!race) {
throw new Error('Race not found');
throw new EntityNotFoundError({ entity: 'race', id: protest.raceId });
}
// Validate steward has authority (owner or admin of the league)
@@ -42,7 +46,10 @@ export class ReviewProtestUseCase {
);
if (!stewardMembership || (stewardMembership.role !== 'owner' && stewardMembership.role !== 'admin')) {
throw new Error('Only league owners and admins can review protests');
throw new PermissionDeniedError(
'NOT_LEAGUE_ADMIN',
'Only league owners and admins can review protests',
);
}
// Apply the decision

View File

@@ -1,5 +1,8 @@
/**
* Domain Entity: Car
*/
import { RacingDomainValidationError } from '../errors/RacingDomainError';
*
* Represents a racing car/vehicle in the GridPilot platform.
* Immutable entity with factory methods and domain validation.
@@ -90,19 +93,19 @@ export class Car {
gameId: string;
}): void {
if (!props.id || props.id.trim().length === 0) {
throw new Error('Car ID is required');
throw new RacingDomainValidationError('Car ID is required');
}
if (!props.name || props.name.trim().length === 0) {
throw new Error('Car name is required');
throw new RacingDomainValidationError('Car name is required');
}
if (!props.manufacturer || props.manufacturer.trim().length === 0) {
throw new Error('Car manufacturer is required');
throw new RacingDomainValidationError('Car manufacturer is required');
}
if (!props.gameId || props.gameId.trim().length === 0) {
throw new Error('Game ID is required');
throw new RacingDomainValidationError('Game ID is required');
}
}

View File

@@ -1,10 +1,12 @@
/**
* Domain Entity: Driver
*
*
* Represents a driver profile in the GridPilot platform.
* Immutable entity with factory methods and domain validation.
*/
import { RacingDomainValidationError } from '../errors/RacingDomainError';
export class Driver {
readonly id: string;
readonly iracingId: string;
@@ -58,24 +60,24 @@ export class Driver {
country: string;
}): void {
if (!props.id || props.id.trim().length === 0) {
throw new Error('Driver ID is required');
throw new RacingDomainValidationError('Driver ID is required');
}
if (!props.iracingId || props.iracingId.trim().length === 0) {
throw new Error('iRacing ID is required');
throw new RacingDomainValidationError('iRacing ID is required');
}
if (!props.name || props.name.trim().length === 0) {
throw new Error('Driver name is required');
throw new RacingDomainValidationError('Driver name is required');
}
if (!props.country || props.country.trim().length === 0) {
throw new Error('Country code is required');
throw new RacingDomainValidationError('Country code is required');
}
// Validate ISO country code format (2-3 letters)
if (!/^[A-Z]{2,3}$/i.test(props.country)) {
throw new Error('Country must be a valid ISO code (2-3 letters)');
throw new RacingDomainValidationError('Country must be a valid ISO code (2-3 letters)');
}
}

View File

@@ -1,5 +1,8 @@
/**
* Domain Entity: DriverLivery
*/
import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError';
*
* Represents a driver's custom livery for a specific car.
* Includes user-placed decals and league-specific overrides.
@@ -70,23 +73,23 @@ export class DriverLivery {
private static validate(props: Omit<DriverLiveryProps, 'createdAt' | 'userDecals' | 'leagueOverrides'>): void {
if (!props.id || props.id.trim().length === 0) {
throw new Error('DriverLivery ID is required');
throw new RacingDomainValidationError('DriverLivery ID is required');
}
if (!props.driverId || props.driverId.trim().length === 0) {
throw new Error('DriverLivery driverId is required');
throw new RacingDomainValidationError('DriverLivery driverId is required');
}
if (!props.gameId || props.gameId.trim().length === 0) {
throw new Error('DriverLivery gameId is required');
throw new RacingDomainValidationError('DriverLivery gameId is required');
}
if (!props.carId || props.carId.trim().length === 0) {
throw new Error('DriverLivery carId is required');
throw new RacingDomainValidationError('DriverLivery carId is required');
}
if (!props.uploadedImageUrl || props.uploadedImageUrl.trim().length === 0) {
throw new Error('DriverLivery uploadedImageUrl is required');
throw new RacingDomainValidationError('DriverLivery uploadedImageUrl is required');
}
}
@@ -95,7 +98,7 @@ export class DriverLivery {
*/
addDecal(decal: LiveryDecal): DriverLivery {
if (decal.type !== 'user') {
throw new Error('Only user decals can be added to driver livery');
throw new RacingDomainInvariantError('Only user decals can be added to driver livery');
}
return new DriverLivery({
@@ -112,7 +115,7 @@ export class DriverLivery {
const updatedDecals = this.userDecals.filter(d => d.id !== decalId);
if (updatedDecals.length === this.userDecals.length) {
throw new Error('Decal not found in livery');
throw new RacingDomainValidationError('Decal not found in livery');
}
return new DriverLivery({
@@ -129,7 +132,7 @@ export class DriverLivery {
const index = this.userDecals.findIndex(d => d.id === decalId);
if (index === -1) {
throw new Error('Decal not found in livery');
throw new RacingDomainError('Decal not found in livery');
}
const updatedDecals = [...this.userDecals];

View File

@@ -1,3 +1,5 @@
import { RacingDomainValidationError } from '../errors/RacingDomainError';
export class Game {
readonly id: string;
readonly name: string;
@@ -9,11 +11,11 @@ export class Game {
static create(props: { id: string; name: string }): Game {
if (!props.id || props.id.trim().length === 0) {
throw new Error('Game ID is required');
throw new RacingDomainValidationError('Game ID is required');
}
if (!props.name || props.name.trim().length === 0) {
throw new Error('Game name is required');
throw new RacingDomainValidationError('Game name is required');
}
return new Game({

View File

@@ -1,10 +1,12 @@
/**
* Domain Entity: League
*
*
* Represents a league in the GridPilot platform.
* Immutable entity with factory methods and domain validation.
*/
import { RacingDomainValidationError } from '../errors/RacingDomainError';
/**
* Stewarding decision mode for protests
*/
@@ -158,23 +160,23 @@ export class League {
ownerId: string;
}): void {
if (!props.id || props.id.trim().length === 0) {
throw new Error('League ID is required');
throw new RacingDomainValidationError('League ID is required');
}
if (!props.name || props.name.trim().length === 0) {
throw new Error('League name is required');
throw new RacingDomainValidationError('League name is required');
}
if (props.name.length > 100) {
throw new Error('League name must be 100 characters or less');
throw new RacingDomainValidationError('League name must be 100 characters or less');
}
if (!props.description || props.description.trim().length === 0) {
throw new Error('League description is required');
throw new RacingDomainValidationError('League description is required');
}
if (!props.ownerId || props.ownerId.trim().length === 0) {
throw new Error('League owner ID is required');
throw new RacingDomainValidationError('League owner ID is required');
}
}

View File

@@ -1,10 +1,12 @@
/**
* Domain Entity: LeagueWallet
*
*
* Represents a league's financial wallet.
* Aggregate root for managing league finances and transactions.
*/
import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError';
import type { Money } from '../value-objects/Money';
import type { Transaction } from './Transaction';
@@ -46,15 +48,15 @@ export class LeagueWallet {
private static validate(props: Omit<LeagueWalletProps, 'createdAt' | 'transactionIds'>): void {
if (!props.id || props.id.trim().length === 0) {
throw new Error('LeagueWallet ID is required');
throw new RacingDomainValidationError('LeagueWallet ID is required');
}
if (!props.leagueId || props.leagueId.trim().length === 0) {
throw new Error('LeagueWallet leagueId is required');
throw new RacingDomainValidationError('LeagueWallet leagueId is required');
}
if (!props.balance) {
throw new Error('LeagueWallet balance is required');
throw new RacingDomainValidationError('LeagueWallet balance is required');
}
}
@@ -63,7 +65,7 @@ export class LeagueWallet {
*/
addFunds(netAmount: Money, transactionId: string): LeagueWallet {
if (this.balance.currency !== netAmount.currency) {
throw new Error('Cannot add funds with different currency');
throw new RacingDomainInvariantError('Cannot add funds with different currency');
}
const newBalance = this.balance.add(netAmount);
@@ -81,11 +83,11 @@ export class LeagueWallet {
*/
withdrawFunds(amount: Money, transactionId: string): LeagueWallet {
if (this.balance.currency !== amount.currency) {
throw new Error('Cannot withdraw funds with different currency');
throw new RacingDomainInvariantError('Cannot withdraw funds with different currency');
}
if (!this.balance.isGreaterThan(amount) && !this.balance.equals(amount)) {
throw new Error('Insufficient balance for withdrawal');
throw new RacingDomainInvariantError('Insufficient balance for withdrawal');
}
const newBalance = this.balance.subtract(amount);

View File

@@ -1,5 +1,8 @@
/**
* Domain Entity: LiveryTemplate
*/
import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError';
*
* Represents an admin-defined livery template for a specific car.
* Contains base image and sponsor decal placements.
@@ -54,23 +57,23 @@ export class LiveryTemplate {
private static validate(props: Omit<LiveryTemplateProps, 'createdAt' | 'adminDecals'>): void {
if (!props.id || props.id.trim().length === 0) {
throw new Error('LiveryTemplate ID is required');
throw new RacingDomainValidationError('LiveryTemplate ID is required');
}
if (!props.leagueId || props.leagueId.trim().length === 0) {
throw new Error('LiveryTemplate leagueId is required');
throw new RacingDomainValidationError('LiveryTemplate leagueId is required');
}
if (!props.seasonId || props.seasonId.trim().length === 0) {
throw new Error('LiveryTemplate seasonId is required');
throw new RacingDomainValidationError('LiveryTemplate seasonId is required');
}
if (!props.carId || props.carId.trim().length === 0) {
throw new Error('LiveryTemplate carId is required');
throw new RacingDomainValidationError('LiveryTemplate carId is required');
}
if (!props.baseImageUrl || props.baseImageUrl.trim().length === 0) {
throw new Error('LiveryTemplate baseImageUrl is required');
throw new RacingDomainValidationError('LiveryTemplate baseImageUrl is required');
}
}
@@ -79,7 +82,7 @@ export class LiveryTemplate {
*/
addDecal(decal: LiveryDecal): LiveryTemplate {
if (decal.type !== 'sponsor') {
throw new Error('Only sponsor decals can be added to admin template');
throw new RacingDomainInvariantError('Only sponsor decals can be added to admin template');
}
return new LiveryTemplate({
@@ -96,7 +99,7 @@ export class LiveryTemplate {
const updatedDecals = this.adminDecals.filter(d => d.id !== decalId);
if (updatedDecals.length === this.adminDecals.length) {
throw new Error('Decal not found in template');
throw new RacingDomainValidationError('Decal not found in template');
}
return new LiveryTemplate({
@@ -113,7 +116,7 @@ export class LiveryTemplate {
const index = this.adminDecals.findIndex(d => d.id === decalId);
if (index === -1) {
throw new Error('Decal not found in template');
throw new RacingDomainError('Decal not found in template');
}
const updatedDecals = [...this.adminDecals];

View File

@@ -1,10 +1,12 @@
/**
* Domain Entity: Penalty
*
*
* Represents a penalty applied to a driver for an incident during a race.
* Penalties can be applied as a result of an upheld protest or directly by stewards.
*/
import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError';
export type PenaltyType =
| 'time_penalty' // Add time to race result (e.g., +5 seconds)
| 'grid_penalty' // Grid position penalty for next race
@@ -47,17 +49,17 @@ export class Penalty {
private constructor(private readonly props: PenaltyProps) {}
static create(props: PenaltyProps): Penalty {
if (!props.id) throw new Error('Penalty ID is required');
if (!props.raceId) throw new Error('Race ID is required');
if (!props.driverId) throw new Error('Driver ID is required');
if (!props.type) throw new Error('Penalty type is required');
if (!props.reason?.trim()) throw new Error('Penalty reason is required');
if (!props.issuedBy) throw new Error('Penalty must be issued by a steward');
if (!props.id) throw new RacingDomainValidationError('Penalty ID is required');
if (!props.raceId) throw new RacingDomainValidationError('Race ID is required');
if (!props.driverId) throw new RacingDomainValidationError('Driver ID is required');
if (!props.type) throw new RacingDomainValidationError('Penalty type is required');
if (!props.reason?.trim()) throw new RacingDomainValidationError('Penalty reason is required');
if (!props.issuedBy) throw new RacingDomainValidationError('Penalty must be issued by a steward');
// Validate value based on type
if (['time_penalty', 'grid_penalty', 'points_deduction', 'license_points', 'fine', 'race_ban'].includes(props.type)) {
if (props.value === undefined || props.value <= 0) {
throw new Error(`${props.type} requires a positive value`);
throw new RacingDomainValidationError(`${props.type} requires a positive value`);
}
}
@@ -94,10 +96,10 @@ export class Penalty {
*/
markAsApplied(notes?: string): Penalty {
if (this.isApplied()) {
throw new Error('Penalty is already applied');
throw new RacingDomainInvariantError('Penalty is already applied');
}
if (this.props.status === 'overturned') {
throw new Error('Cannot apply an overturned penalty');
throw new RacingDomainInvariantError('Cannot apply an overturned penalty');
}
return new Penalty({
...this.props,
@@ -112,7 +114,7 @@ export class Penalty {
*/
overturn(reason: string): Penalty {
if (this.props.status === 'overturned') {
throw new Error('Penalty is already overturned');
throw new RacingDomainInvariantError('Penalty is already overturned');
}
return new Penalty({
...this.props,

View File

@@ -1,9 +1,11 @@
/**
* Domain Entity: Prize
*
*
* Represents a prize awarded to a driver for a specific position in a season.
*/
import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError';
import type { Money } from '../value-objects/Money';
export type PrizeStatus = 'pending' | 'awarded' | 'paid' | 'cancelled';
@@ -61,23 +63,23 @@ export class Prize {
private static validate(props: Omit<PrizeProps, 'createdAt' | 'status'>): void {
if (!props.id || props.id.trim().length === 0) {
throw new Error('Prize ID is required');
throw new RacingDomainValidationError('Prize ID is required');
}
if (!props.seasonId || props.seasonId.trim().length === 0) {
throw new Error('Prize seasonId is required');
throw new RacingDomainValidationError('Prize seasonId is required');
}
if (!Number.isInteger(props.position) || props.position < 1) {
throw new Error('Prize position must be a positive integer');
throw new RacingDomainValidationError('Prize position must be a positive integer');
}
if (!props.amount) {
throw new Error('Prize amount is required');
throw new RacingDomainValidationError('Prize amount is required');
}
if (props.amount.amount <= 0) {
throw new Error('Prize amount must be greater than zero');
throw new RacingDomainValidationError('Prize amount must be greater than zero');
}
}
@@ -86,11 +88,11 @@ export class Prize {
*/
awardTo(driverId: string): Prize {
if (!driverId || driverId.trim().length === 0) {
throw new Error('Driver ID is required to award prize');
throw new RacingDomainValidationError('Driver ID is required to award prize');
}
if (this.status !== 'pending') {
throw new Error('Only pending prizes can be awarded');
throw new RacingDomainInvariantError('Only pending prizes can be awarded');
}
return new Prize({
@@ -106,11 +108,11 @@ export class Prize {
*/
markAsPaid(): Prize {
if (this.status !== 'awarded') {
throw new Error('Only awarded prizes can be marked as paid');
throw new RacingDomainInvariantError('Only awarded prizes can be marked as paid');
}
if (!this.driverId) {
throw new Error('Prize must have a driver to be paid');
throw new RacingDomainInvariantError('Prize must have a driver to be paid');
}
return new Prize({
@@ -125,7 +127,7 @@ export class Prize {
*/
cancel(): Prize {
if (this.status === 'paid') {
throw new Error('Cannot cancel a paid prize');
throw new RacingDomainInvariantError('Cannot cancel a paid prize');
}
return new Prize({

View File

@@ -1,5 +1,8 @@
/**
* Domain Entity: Protest
*/
import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError';
*
* Represents a protest filed by a driver against another driver for an incident during a race.
*
@@ -67,13 +70,13 @@ export class Protest {
private constructor(private readonly props: ProtestProps) {}
static create(props: ProtestProps): Protest {
if (!props.id) throw new Error('Protest ID is required');
if (!props.raceId) throw new Error('Race ID is required');
if (!props.protestingDriverId) throw new Error('Protesting driver ID is required');
if (!props.accusedDriverId) throw new Error('Accused driver ID is required');
if (!props.incident) throw new Error('Incident details are required');
if (props.incident.lap < 0) throw new Error('Lap number must be non-negative');
if (!props.incident.description?.trim()) throw new Error('Incident description is required');
if (!props.id) throw new RacingDomainValidationError('Protest ID is required');
if (!props.raceId) throw new RacingDomainValidationError('Race ID is required');
if (!props.protestingDriverId) throw new RacingDomainValidationError('Protesting driver ID is required');
if (!props.accusedDriverId) throw new RacingDomainValidationError('Accused driver ID is required');
if (!props.incident) throw new RacingDomainValidationError('Incident details are required');
if (props.incident.lap < 0) throw new RacingDomainValidationError('Lap number must be non-negative');
if (!props.incident.description?.trim()) throw new RacingDomainValidationError('Incident description is required');
return new Protest({
...props,
@@ -131,7 +134,7 @@ export class Protest {
*/
requestDefense(stewardId: string): Protest {
if (!this.canRequestDefense()) {
throw new Error('Defense can only be requested for pending protests without existing defense');
throw new RacingDomainInvariantError('Defense can only be requested for pending protests without existing defense');
}
return new Protest({
...this.props,
@@ -146,10 +149,10 @@ export class Protest {
*/
submitDefense(statement: string, videoUrl?: string): Protest {
if (!this.canSubmitDefense()) {
throw new Error('Defense can only be submitted when protest is awaiting defense');
throw new RacingDomainInvariantError('Defense can only be submitted when protest is awaiting defense');
}
if (!statement?.trim()) {
throw new Error('Defense statement is required');
throw new RacingDomainValidationError('Defense statement is required');
}
return new Protest({
...this.props,
@@ -167,7 +170,7 @@ export class Protest {
*/
startReview(stewardId: string): Protest {
if (!this.isPending() && !this.isAwaitingDefense()) {
throw new Error('Only pending or awaiting-defense protests can be put under review');
throw new RacingDomainInvariantError('Only pending or awaiting-defense protests can be put under review');
}
return new Protest({
...this.props,
@@ -181,7 +184,7 @@ export class Protest {
*/
uphold(stewardId: string, decisionNotes: string): Protest {
if (!this.isPending() && !this.isUnderReview() && !this.isAwaitingDefense()) {
throw new Error('Only pending, awaiting-defense, or under-review protests can be upheld');
throw new RacingDomainInvariantError('Only pending, awaiting-defense, or under-review protests can be upheld');
}
return new Protest({
...this.props,
@@ -197,7 +200,7 @@ export class Protest {
*/
dismiss(stewardId: string, decisionNotes: string): Protest {
if (!this.isPending() && !this.isUnderReview() && !this.isAwaitingDefense()) {
throw new Error('Only pending, awaiting-defense, or under-review protests can be dismissed');
throw new RacingDomainInvariantError('Only pending, awaiting-defense, or under-review protests can be dismissed');
}
return new Protest({
...this.props,
@@ -213,7 +216,7 @@ export class Protest {
*/
withdraw(): Protest {
if (this.isResolved()) {
throw new Error('Cannot withdraw a resolved protest');
throw new RacingDomainInvariantError('Cannot withdraw a resolved protest');
}
return new Protest({
...this.props,

View File

@@ -5,6 +5,8 @@
* Immutable entity with factory methods and domain validation.
*/
import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError';
export type SessionType = 'practice' | 'qualifying' | 'race';
export type RaceStatus = 'scheduled' | 'running' | 'completed' | 'cancelled';
@@ -96,23 +98,23 @@ export class Race {
car: string;
}): void {
if (!props.id || props.id.trim().length === 0) {
throw new Error('Race ID is required');
throw new RacingDomainValidationError('Race ID is required');
}
if (!props.leagueId || props.leagueId.trim().length === 0) {
throw new Error('League ID is required');
throw new RacingDomainValidationError('League ID is required');
}
if (!props.scheduledAt || !(props.scheduledAt instanceof Date)) {
throw new Error('Valid scheduled date is required');
throw new RacingDomainValidationError('Valid scheduled date is required');
}
if (!props.track || props.track.trim().length === 0) {
throw new Error('Track is required');
throw new RacingDomainValidationError('Track is required');
}
if (!props.car || props.car.trim().length === 0) {
throw new Error('Car is required');
throw new RacingDomainValidationError('Car is required');
}
}
@@ -121,7 +123,7 @@ export class Race {
*/
start(): Race {
if (this.status !== 'scheduled') {
throw new Error('Only scheduled races can be started');
throw new RacingDomainInvariantError('Only scheduled races can be started');
}
return new Race({
@@ -135,11 +137,11 @@ export class Race {
*/
complete(): Race {
if (this.status === 'completed') {
throw new Error('Race is already completed');
throw new RacingDomainInvariantError('Race is already completed');
}
if (this.status === 'cancelled') {
throw new Error('Cannot complete a cancelled race');
throw new RacingDomainInvariantError('Cannot complete a cancelled race');
}
return new Race({
@@ -153,11 +155,11 @@ export class Race {
*/
cancel(): Race {
if (this.status === 'completed') {
throw new Error('Cannot cancel a completed race');
throw new RacingDomainInvariantError('Cannot cancel a completed race');
}
if (this.status === 'cancelled') {
throw new Error('Race is already cancelled');
throw new RacingDomainInvariantError('Race is already cancelled');
}
return new Race({

View File

@@ -1,10 +1,12 @@
/**
* Domain Entity: Result
*
*
* Represents a race result in the GridPilot platform.
* Immutable entity with factory methods and domain validation.
*/
import { RacingDomainValidationError } from '../errors/RacingDomainError';
export class Result {
readonly id: string;
readonly raceId: string;
@@ -62,31 +64,31 @@ export class Result {
startPosition: number;
}): void {
if (!props.id || props.id.trim().length === 0) {
throw new Error('Result ID is required');
throw new RacingDomainValidationError('Result ID is required');
}
if (!props.raceId || props.raceId.trim().length === 0) {
throw new Error('Race ID is required');
throw new RacingDomainValidationError('Race ID is required');
}
if (!props.driverId || props.driverId.trim().length === 0) {
throw new Error('Driver ID is required');
throw new RacingDomainValidationError('Driver ID is required');
}
if (!Number.isInteger(props.position) || props.position < 1) {
throw new Error('Position must be a positive integer');
throw new RacingDomainValidationError('Position must be a positive integer');
}
if (props.fastestLap < 0) {
throw new Error('Fastest lap cannot be negative');
throw new RacingDomainValidationError('Fastest lap cannot be negative');
}
if (!Number.isInteger(props.incidents) || props.incidents < 0) {
throw new Error('Incidents must be a non-negative integer');
throw new RacingDomainValidationError('Incidents must be a non-negative integer');
}
if (!Number.isInteger(props.startPosition) || props.startPosition < 1) {
throw new Error('Start position must be a positive integer');
throw new RacingDomainValidationError('Start position must be a positive integer');
}
}

View File

@@ -1,5 +1,7 @@
export type SeasonStatus = 'planned' | 'active' | 'completed';
import { RacingDomainValidationError } from '../errors/RacingDomainError';
export class Season {
readonly id: string;
readonly leagueId: string;
@@ -45,19 +47,19 @@ export class Season {
endDate?: Date;
}): Season {
if (!props.id || props.id.trim().length === 0) {
throw new Error('Season ID is required');
throw new RacingDomainValidationError('Season ID is required');
}
if (!props.leagueId || props.leagueId.trim().length === 0) {
throw new Error('Season leagueId is required');
throw new RacingDomainValidationError('Season leagueId is required');
}
if (!props.gameId || props.gameId.trim().length === 0) {
throw new Error('Season gameId is required');
throw new RacingDomainValidationError('Season gameId is required');
}
if (!props.name || props.name.trim().length === 0) {
throw new Error('Season name is required');
throw new RacingDomainValidationError('Season name is required');
}
const status: SeasonStatus = props.status ?? 'planned';

View File

@@ -1,10 +1,12 @@
/**
* Domain Entity: SeasonSponsorship
*
*
* Represents a sponsorship relationship between a Sponsor and a Season.
* Aggregate root for managing sponsorship slots and pricing.
*/
import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError';
import type { Money } from '../value-objects/Money';
export type SponsorshipTier = 'main' | 'secondary';
@@ -60,27 +62,27 @@ export class SeasonSponsorship {
private static validate(props: Omit<SeasonSponsorshipProps, 'createdAt' | 'status'>): void {
if (!props.id || props.id.trim().length === 0) {
throw new Error('SeasonSponsorship ID is required');
throw new RacingDomainValidationError('SeasonSponsorship ID is required');
}
if (!props.seasonId || props.seasonId.trim().length === 0) {
throw new Error('SeasonSponsorship seasonId is required');
throw new RacingDomainValidationError('SeasonSponsorship seasonId is required');
}
if (!props.sponsorId || props.sponsorId.trim().length === 0) {
throw new Error('SeasonSponsorship sponsorId is required');
throw new RacingDomainValidationError('SeasonSponsorship sponsorId is required');
}
if (!props.tier) {
throw new Error('SeasonSponsorship tier is required');
throw new RacingDomainValidationError('SeasonSponsorship tier is required');
}
if (!props.pricing) {
throw new Error('SeasonSponsorship pricing is required');
throw new RacingDomainValidationError('SeasonSponsorship pricing is required');
}
if (props.pricing.amount <= 0) {
throw new Error('SeasonSponsorship pricing must be greater than zero');
throw new RacingDomainValidationError('SeasonSponsorship pricing must be greater than zero');
}
}
@@ -89,11 +91,11 @@ export class SeasonSponsorship {
*/
activate(): SeasonSponsorship {
if (this.status === 'active') {
throw new Error('SeasonSponsorship is already active');
throw new RacingDomainInvariantError('SeasonSponsorship is already active');
}
if (this.status === 'cancelled') {
throw new Error('Cannot activate a cancelled SeasonSponsorship');
throw new RacingDomainInvariantError('Cannot activate a cancelled SeasonSponsorship');
}
return new SeasonSponsorship({
@@ -108,7 +110,7 @@ export class SeasonSponsorship {
*/
cancel(): SeasonSponsorship {
if (this.status === 'cancelled') {
throw new Error('SeasonSponsorship is already cancelled');
throw new RacingDomainInvariantError('SeasonSponsorship is already cancelled');
}
return new SeasonSponsorship({

View File

@@ -1,5 +1,8 @@
/**
* Domain Entity: Sponsor
*/
import { RacingDomainValidationError } from '../errors/RacingDomainError';
*
* Represents a sponsor that can sponsor leagues/seasons.
* Aggregate root for sponsor information.
@@ -42,32 +45,32 @@ export class Sponsor {
private static validate(props: Omit<SponsorProps, 'createdAt'>): void {
if (!props.id || props.id.trim().length === 0) {
throw new Error('Sponsor ID is required');
throw new RacingDomainValidationError('Sponsor ID is required');
}
if (!props.name || props.name.trim().length === 0) {
throw new Error('Sponsor name is required');
throw new RacingDomainValidationError('Sponsor name is required');
}
if (props.name.length > 100) {
throw new Error('Sponsor name must be 100 characters or less');
throw new RacingDomainValidationError('Sponsor name must be 100 characters or less');
}
if (!props.contactEmail || props.contactEmail.trim().length === 0) {
throw new Error('Sponsor contact email is required');
throw new RacingDomainValidationError('Sponsor contact email is required');
}
// Basic email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(props.contactEmail)) {
throw new Error('Invalid sponsor contact email format');
throw new RacingDomainValidationError('Invalid sponsor contact email format');
}
if (props.websiteUrl && props.websiteUrl.trim().length > 0) {
try {
new URL(props.websiteUrl);
} catch {
throw new Error('Invalid sponsor website URL');
throw new RacingDomainValidationError('Invalid sponsor website URL');
}
}
}

View File

@@ -5,6 +5,8 @@
* (driver, team, race, or league/season). The entity owner must approve/reject.
*/
import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError';
import type { Money } from '../value-objects/Money';
import type { SponsorshipTier } from './SeasonSponsorship';
@@ -70,31 +72,31 @@ export class SponsorshipRequest {
private static validate(props: Omit<SponsorshipRequestProps, 'createdAt' | 'status'>): void {
if (!props.id || props.id.trim().length === 0) {
throw new Error('SponsorshipRequest ID is required');
throw new RacingDomainValidationError('SponsorshipRequest ID is required');
}
if (!props.sponsorId || props.sponsorId.trim().length === 0) {
throw new Error('SponsorshipRequest sponsorId is required');
throw new RacingDomainValidationError('SponsorshipRequest sponsorId is required');
}
if (!props.entityType) {
throw new Error('SponsorshipRequest entityType is required');
throw new RacingDomainValidationError('SponsorshipRequest entityType is required');
}
if (!props.entityId || props.entityId.trim().length === 0) {
throw new Error('SponsorshipRequest entityId is required');
throw new RacingDomainValidationError('SponsorshipRequest entityId is required');
}
if (!props.tier) {
throw new Error('SponsorshipRequest tier is required');
throw new RacingDomainValidationError('SponsorshipRequest tier is required');
}
if (!props.offeredAmount) {
throw new Error('SponsorshipRequest offeredAmount is required');
throw new RacingDomainValidationError('SponsorshipRequest offeredAmount is required');
}
if (props.offeredAmount.amount <= 0) {
throw new Error('SponsorshipRequest offeredAmount must be greater than zero');
throw new RacingDomainValidationError('SponsorshipRequest offeredAmount must be greater than zero');
}
}
@@ -103,11 +105,11 @@ export class SponsorshipRequest {
*/
accept(respondedBy: string): SponsorshipRequest {
if (this.status !== 'pending') {
throw new Error(`Cannot accept a ${this.status} sponsorship request`);
throw new RacingDomainInvariantError(`Cannot accept a ${this.status} sponsorship request`);
}
if (!respondedBy || respondedBy.trim().length === 0) {
throw new Error('respondedBy is required when accepting');
throw new RacingDomainValidationError('respondedBy is required when accepting');
}
return new SponsorshipRequest({
@@ -123,11 +125,11 @@ export class SponsorshipRequest {
*/
reject(respondedBy: string, reason?: string): SponsorshipRequest {
if (this.status !== 'pending') {
throw new Error(`Cannot reject a ${this.status} sponsorship request`);
throw new RacingDomainInvariantError(`Cannot reject a ${this.status} sponsorship request`);
}
if (!respondedBy || respondedBy.trim().length === 0) {
throw new Error('respondedBy is required when rejecting');
throw new RacingDomainValidationError('respondedBy is required when rejecting');
}
return new SponsorshipRequest({
@@ -144,7 +146,7 @@ export class SponsorshipRequest {
*/
withdraw(): SponsorshipRequest {
if (this.status !== 'pending') {
throw new Error(`Cannot withdraw a ${this.status} sponsorship request`);
throw new RacingDomainInvariantError(`Cannot withdraw a ${this.status} sponsorship request`);
}
return new SponsorshipRequest({

View File

@@ -5,6 +5,8 @@
* Immutable entity with factory methods and domain validation.
*/
import { RacingDomainValidationError } from '../errors/RacingDomainError';
export class Standing {
readonly leagueId: string;
readonly driverId: string;
@@ -60,11 +62,11 @@ export class Standing {
driverId: string;
}): void {
if (!props.leagueId || props.leagueId.trim().length === 0) {
throw new Error('League ID is required');
throw new RacingDomainError('League ID is required');
}
if (!props.driverId || props.driverId.trim().length === 0) {
throw new Error('Driver ID is required');
throw new RacingDomainError('Driver ID is required');
}
}
@@ -90,7 +92,7 @@ export class Standing {
*/
updatePosition(position: number): Standing {
if (!Number.isInteger(position) || position < 1) {
throw new Error('Position must be a positive integer');
throw new RacingDomainError('Position must be a positive integer');
}
return new Standing({

View File

@@ -1,10 +1,12 @@
/**
* Domain Entity: Track
*
*
* Represents a racing track/circuit in the GridPilot platform.
* Immutable entity with factory methods and domain validation.
*/
import { RacingDomainValidationError } from '../errors/RacingDomainError';
export type TrackCategory = 'oval' | 'road' | 'street' | 'dirt';
export type TrackDifficulty = 'beginner' | 'intermediate' | 'advanced' | 'expert';
@@ -87,27 +89,27 @@ export class Track {
gameId: string;
}): void {
if (!props.id || props.id.trim().length === 0) {
throw new Error('Track ID is required');
throw new RacingDomainValidationError('Track ID is required');
}
if (!props.name || props.name.trim().length === 0) {
throw new Error('Track name is required');
throw new RacingDomainValidationError('Track name is required');
}
if (!props.country || props.country.trim().length === 0) {
throw new Error('Track country is required');
throw new RacingDomainValidationError('Track country is required');
}
if (props.lengthKm <= 0) {
throw new Error('Track length must be positive');
throw new RacingDomainValidationError('Track length must be positive');
}
if (props.turns < 0) {
throw new Error('Track turns cannot be negative');
throw new RacingDomainValidationError('Track turns cannot be negative');
}
if (!props.gameId || props.gameId.trim().length === 0) {
throw new Error('Game ID is required');
throw new RacingDomainValidationError('Game ID is required');
}
}

View File

@@ -1,9 +1,11 @@
/**
* Domain Entity: Transaction
*
*
* Represents a financial transaction in the league wallet system.
*/
import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError';
import type { Money } from '../value-objects/Money';
export type TransactionType =
@@ -76,23 +78,23 @@ export class Transaction {
private static validate(props: Omit<TransactionProps, 'createdAt' | 'status' | 'platformFee' | 'netAmount'>): void {
if (!props.id || props.id.trim().length === 0) {
throw new Error('Transaction ID is required');
throw new RacingDomainValidationError('Transaction ID is required');
}
if (!props.walletId || props.walletId.trim().length === 0) {
throw new Error('Transaction walletId is required');
throw new RacingDomainValidationError('Transaction walletId is required');
}
if (!props.type) {
throw new Error('Transaction type is required');
throw new RacingDomainValidationError('Transaction type is required');
}
if (!props.amount) {
throw new Error('Transaction amount is required');
throw new RacingDomainValidationError('Transaction amount is required');
}
if (props.amount.amount <= 0) {
throw new Error('Transaction amount must be greater than zero');
throw new RacingDomainValidationError('Transaction amount must be greater than zero');
}
}
@@ -101,11 +103,11 @@ export class Transaction {
*/
complete(): Transaction {
if (this.status === 'completed') {
throw new Error('Transaction is already completed');
throw new RacingDomainInvariantError('Transaction is already completed');
}
if (this.status === 'failed' || this.status === 'cancelled') {
throw new Error('Cannot complete a failed or cancelled transaction');
throw new RacingDomainInvariantError('Cannot complete a failed or cancelled transaction');
}
return new Transaction({
@@ -120,7 +122,7 @@ export class Transaction {
*/
fail(): Transaction {
if (this.status === 'completed') {
throw new Error('Cannot fail a completed transaction');
throw new RacingDomainInvariantError('Cannot fail a completed transaction');
}
return new Transaction({
@@ -134,7 +136,7 @@ export class Transaction {
*/
cancel(): Transaction {
if (this.status === 'completed') {
throw new Error('Cannot cancel a completed transaction');
throw new RacingDomainInvariantError('Cannot cancel a completed transaction');
}
return new Transaction({

View File

@@ -0,0 +1,24 @@
export abstract class RacingDomainError extends Error {
readonly context = 'racing-domain';
constructor(message: string) {
super(message);
Object.setPrototypeOf(this, new.target.prototype);
}
}
export class RacingDomainValidationError extends RacingDomainError {
readonly kind = 'validation' as const;
constructor(message: string) {
super(message);
}
}
export class RacingDomainInvariantError extends RacingDomainError {
readonly kind = 'invariant' as const;
constructor(message: string) {
super(message);
}
}

View File

@@ -1,6 +1,7 @@
import { SeasonSchedule } from '../value-objects/SeasonSchedule';
import { ScheduledRaceSlot } from '../value-objects/ScheduledRaceSlot';
import type { RecurrenceStrategy } from '../value-objects/RecurrenceStrategy';
import { RacingDomainValidationError } from '../errors/RacingDomainError';
import { RaceTimeOfDay } from '../value-objects/RaceTimeOfDay';
import type { Weekday } from '../value-objects/Weekday';
import { weekdayToIndex } from '../value-objects/Weekday';
@@ -66,7 +67,7 @@ function generateWeeklyOrEveryNWeeksSlots(
: [];
if (weekdays.length === 0) {
throw new Error('RecurrenceStrategy has no weekdays');
throw new RacingDomainValidationError('RecurrenceStrategy has no weekdays');
}
const intervalWeeks = recurrence.kind === 'everyNWeeks' ? recurrence.intervalWeeks : 1;
@@ -161,7 +162,7 @@ export class SeasonScheduleGenerator {
static generateSlotsUpTo(schedule: SeasonSchedule, maxRounds: number): ScheduledRaceSlot[] {
if (!Number.isInteger(maxRounds) || maxRounds <= 0) {
throw new Error('maxRounds must be a positive integer');
throw new RacingDomainError('maxRounds must be a positive integer');
}
const recurrence: RecurrenceStrategy = schedule.recurrence;

View File

@@ -1,9 +1,11 @@
/**
* Domain Value Object: LeagueDescription
*
*
* Represents a valid league description with validation rules.
*/
import { RacingDomainValidationError } from '../errors/RacingDomainError';
export interface LeagueDescriptionValidationResult {
valid: boolean;
error?: string;
@@ -63,7 +65,7 @@ export class LeagueDescription {
static create(value: string): LeagueDescription {
const validation = this.validate(value);
if (!validation.valid) {
throw new Error(validation.error);
throw new RacingDomainValidationError(validation.error ?? 'Invalid league description');
}
return new LeagueDescription(value.trim());
}

View File

@@ -1,9 +1,11 @@
/**
* Domain Value Object: LeagueName
*
*
* Represents a valid league name with validation rules.
*/
import { RacingDomainValidationError } from '../errors/RacingDomainError';
export interface LeagueNameValidationResult {
valid: boolean;
error?: string;
@@ -76,7 +78,7 @@ export class LeagueName {
static create(value: string): LeagueName {
const validation = this.validate(value);
if (!validation.valid) {
throw new Error(validation.error);
throw new RacingDomainValidationError(validation.error ?? 'Invalid league name');
}
return new LeagueName(value.trim());
}

View File

@@ -1,9 +1,11 @@
import { RacingDomainValidationError } from '../errors/RacingDomainError';
export class LeagueTimezone {
private readonly id: string;
constructor(id: string) {
if (!id || id.trim().length === 0) {
throw new Error('LeagueTimezone id must be a non-empty string');
throw new RacingDomainValidationError('LeagueTimezone id must be a non-empty string');
}
this.id = id;
}

View File

@@ -58,7 +58,7 @@ export class LeagueVisibility {
if (value === 'unranked' || value === 'private') {
return LeagueVisibility.unranked();
}
throw new Error(`Invalid league visibility: ${value}`);
throw new RacingDomainValidationError(`Invalid league visibility: ${value}`);
}
/**

View File

@@ -51,39 +51,39 @@ export class LiveryDecal {
private static validate(props: LiveryDecalProps): void {
if (!props.id || props.id.trim().length === 0) {
throw new Error('LiveryDecal ID is required');
throw new RacingDomainValidationError('LiveryDecal ID is required');
}
if (!props.imageUrl || props.imageUrl.trim().length === 0) {
throw new Error('LiveryDecal imageUrl is required');
throw new RacingDomainValidationError('LiveryDecal imageUrl is required');
}
if (props.x < 0 || props.x > 1) {
throw new Error('LiveryDecal x coordinate must be between 0 and 1 (normalized)');
throw new RacingDomainValidationError('LiveryDecal x coordinate must be between 0 and 1 (normalized)');
}
if (props.y < 0 || props.y > 1) {
throw new Error('LiveryDecal y coordinate must be between 0 and 1 (normalized)');
throw new RacingDomainValidationError('LiveryDecal y coordinate must be between 0 and 1 (normalized)');
}
if (props.width <= 0 || props.width > 1) {
throw new Error('LiveryDecal width must be between 0 and 1 (normalized)');
throw new RacingDomainValidationError('LiveryDecal width must be between 0 and 1 (normalized)');
}
if (props.height <= 0 || props.height > 1) {
throw new Error('LiveryDecal height must be between 0 and 1 (normalized)');
throw new RacingDomainValidationError('LiveryDecal height must be between 0 and 1 (normalized)');
}
if (!Number.isInteger(props.zIndex) || props.zIndex < 0) {
throw new Error('LiveryDecal zIndex must be a non-negative integer');
throw new RacingDomainValidationError('LiveryDecal zIndex must be a non-negative integer');
}
if (props.rotation < 0 || props.rotation > 360) {
throw new Error('LiveryDecal rotation must be between 0 and 360 degrees');
throw new RacingDomainValidationError('LiveryDecal rotation must be between 0 and 360 degrees');
}
if (!props.type) {
throw new Error('LiveryDecal type is required');
throw new RacingDomainValidationError('LiveryDecal type is required');
}
}

View File

@@ -3,6 +3,8 @@
* Represents membership fee configuration for league drivers
*/
import { RacingDomainValidationError } from '../errors/RacingDomainError';
import type { Money } from './Money';
export type MembershipFeeType = 'season' | 'monthly' | 'per_race';
@@ -23,15 +25,15 @@ export class MembershipFee {
static create(type: MembershipFeeType, amount: Money): MembershipFee {
if (!type) {
throw new Error('MembershipFee type is required');
throw new RacingDomainValidationError('MembershipFee type is required');
}
if (!amount) {
throw new Error('MembershipFee amount is required');
throw new RacingDomainValidationError('MembershipFee amount is required');
}
if (amount.amount < 0) {
throw new Error('MembershipFee amount cannot be negative');
throw new RacingDomainValidationError('MembershipFee amount cannot be negative');
}
return new MembershipFee({ type, amount });

View File

@@ -3,6 +3,8 @@
* Represents a monetary amount with currency and platform fee calculation
*/
import { RacingDomainValidationError } from '../errors/RacingDomainError';
export type Currency = 'USD' | 'EUR' | 'GBP';
export class Money {
@@ -18,10 +20,10 @@ export class Money {
static create(amount: number, currency: Currency = 'USD'): Money {
if (amount < 0) {
throw new Error('Money amount cannot be negative');
throw new RacingDomainValidationError('Money amount cannot be negative');
}
if (!Number.isFinite(amount)) {
throw new Error('Money amount must be a finite number');
throw new RacingDomainValidationError('Money amount must be a finite number');
}
return new Money(amount, currency);
}
@@ -47,7 +49,7 @@ export class Money {
*/
add(other: Money): Money {
if (this.currency !== other.currency) {
throw new Error('Cannot add money with different currencies');
throw new RacingDomainValidationError('Cannot add money with different currencies');
}
return new Money(this.amount + other.amount, this.currency);
}
@@ -57,11 +59,11 @@ export class Money {
*/
subtract(other: Money): Money {
if (this.currency !== other.currency) {
throw new Error('Cannot subtract money with different currencies');
throw new RacingDomainValidationError('Cannot subtract money with different currencies');
}
const result = this.amount - other.amount;
if (result < 0) {
throw new Error('Subtraction would result in negative amount');
throw new RacingDomainValidationError('Subtraction would result in negative amount');
}
return new Money(result, this.currency);
}
@@ -71,7 +73,7 @@ export class Money {
*/
isGreaterThan(other: Money): boolean {
if (this.currency !== other.currency) {
throw new Error('Cannot compare money with different currencies');
throw new RacingDomainValidationError('Cannot compare money with different currencies');
}
return this.amount > other.amount;
}

View File

@@ -1,13 +1,15 @@
import { RacingDomainValidationError } from '../errors/RacingDomainError';
export class RaceTimeOfDay {
readonly hour: number;
readonly minute: number;
constructor(hour: number, minute: number) {
if (!Number.isInteger(hour) || hour < 0 || hour > 23) {
throw new Error(`RaceTimeOfDay hour must be between 0 and 23, got ${hour}`);
throw new RacingDomainValidationError(`RaceTimeOfDay hour must be between 0 and 23, got ${hour}`);
}
if (!Number.isInteger(minute) || minute < 0 || minute > 59) {
throw new Error(`RaceTimeOfDay minute must be between 0 and 59, got ${minute}`);
throw new RacingDomainValidationError(`RaceTimeOfDay minute must be between 0 and 59, got ${minute}`);
}
this.hour = hour;
@@ -17,7 +19,7 @@ export class RaceTimeOfDay {
static fromString(value: string): RaceTimeOfDay {
const match = /^(\d{2}):(\d{2})$/.exec(value);
if (!match) {
throw new Error(`RaceTimeOfDay string must be in HH:MM 24h format, got "${value}"`);
throw new RacingDomainValidationError(`RaceTimeOfDay string must be in HH:MM 24h format, got "${value}"`);
}
const hour = Number(match[1]);

View File

@@ -1,5 +1,6 @@
import { WeekdaySet } from './WeekdaySet';
import { MonthlyRecurrencePattern } from './MonthlyRecurrencePattern';
import { RacingDomainValidationError } from '../errors/RacingDomainError';
export type RecurrenceStrategyKind = 'weekly' | 'everyNWeeks' | 'monthlyNthWeekday';
@@ -34,7 +35,9 @@ export class RecurrenceStrategyFactory {
static everyNWeeks(intervalWeeks: number, weekdays: WeekdaySet): RecurrenceStrategy {
if (!Number.isInteger(intervalWeeks) || intervalWeeks < 1 || intervalWeeks > 12) {
throw new Error('everyNWeeks intervalWeeks must be an integer between 1 and 12');
throw new RacingDomainValidationError(
'everyNWeeks intervalWeeks must be an integer between 1 and 12',
);
}
return {

View File

@@ -1,5 +1,7 @@
import { LeagueTimezone } from './LeagueTimezone';
import { RacingDomainValidationError } from '../errors/RacingDomainError';
export class ScheduledRaceSlot {
readonly roundNumber: number;
readonly scheduledAt: Date;
@@ -7,10 +9,10 @@ export class ScheduledRaceSlot {
constructor(params: { roundNumber: number; scheduledAt: Date; timezone: LeagueTimezone }) {
if (!Number.isInteger(params.roundNumber) || params.roundNumber <= 0) {
throw new Error('ScheduledRaceSlot.roundNumber must be a positive integer');
throw new RacingDomainValidationError('ScheduledRaceSlot.roundNumber must be a positive integer');
}
if (!(params.scheduledAt instanceof Date) || Number.isNaN(params.scheduledAt.getTime())) {
throw new Error('ScheduledRaceSlot.scheduledAt must be a valid Date');
throw new RacingDomainValidationError('ScheduledRaceSlot.scheduledAt must be a valid Date');
}
this.roundNumber = params.roundNumber;

View File

@@ -1,6 +1,7 @@
import { RaceTimeOfDay } from './RaceTimeOfDay';
import { LeagueTimezone } from './LeagueTimezone';
import type { RecurrenceStrategy } from './RecurrenceStrategy';
import { RacingDomainValidationError } from '../errors/RacingDomainError';
export class SeasonSchedule {
readonly startDate: Date;
@@ -17,10 +18,10 @@ export class SeasonSchedule {
plannedRounds: number;
}) {
if (!(params.startDate instanceof Date) || Number.isNaN(params.startDate.getTime())) {
throw new Error('SeasonSchedule.startDate must be a valid Date');
throw new RacingDomainValidationError('SeasonSchedule.startDate must be a valid Date');
}
if (!Number.isInteger(params.plannedRounds) || params.plannedRounds <= 0) {
throw new Error('SeasonSchedule.plannedRounds must be a positive integer');
throw new RacingDomainValidationError('SeasonSchedule.plannedRounds must be a positive integer');
}
this.startDate = new Date(

View File

@@ -1,5 +1,7 @@
export type Weekday = 'Mon' | 'Tue' | 'Wed' | 'Thu' | 'Fri' | 'Sat' | 'Sun';
import { RacingDomainInvariantError } from '../errors/RacingDomainError';
export const ALL_WEEKDAYS: Weekday[] = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
export function weekdayToIndex(day: Weekday): number {
@@ -20,6 +22,6 @@ export function weekdayToIndex(day: Weekday): number {
return 7;
default:
// This should be unreachable because Weekday is a closed union.
throw new Error(`Unknown weekday: ${day}`);
throw new RacingDomainInvariantError(`Unknown weekday: ${day}`);
}
}

View File

@@ -1,12 +1,13 @@
import type { Weekday } from './Weekday';
import { weekdayToIndex } from './Weekday';
import { RacingDomainValidationError } from '../errors/RacingDomainError';
export class WeekdaySet {
private readonly days: Weekday[];
constructor(days: Weekday[]) {
if (!Array.isArray(days) || days.length === 0) {
throw new Error('WeekdaySet requires at least one weekday');
throw new RacingDomainValidationError('WeekdaySet requires at least one weekday');
}
const unique = Array.from(new Set(days));

View File

@@ -0,0 +1,17 @@
import type { FeedItemType } from '../../domain/value-objects/FeedItemType';
export interface FeedItemDTO {
id: string;
timestamp: string;
type: FeedItemType;
actorFriendId?: string;
actorDriverId?: string;
leagueId?: string;
raceId?: string;
teamId?: string;
position?: number;
headline: string;
body?: string;
ctaLabel?: string;
ctaHref?: string;
}

View File

@@ -0,0 +1,16 @@
export { GetCurrentUserSocialUseCase } from './use-cases/GetCurrentUserSocialUseCase';
export type { GetCurrentUserSocialParams } from './use-cases/GetCurrentUserSocialUseCase';
export { GetUserFeedUseCase } from './use-cases/GetUserFeedUseCase';
export type { GetUserFeedParams } from './use-cases/GetUserFeedUseCase';
export type { CurrentUserSocialDTO } from './dto/CurrentUserSocialDTO';
export type { FriendDTO } from './dto/FriendDTO';
export type { FeedItemDTO } from './dto/FeedItemDTO';
export type {
CurrentUserSocialViewModel,
ICurrentUserSocialPresenter,
UserFeedViewModel,
IUserFeedPresenter,
} from './presenters/ISocialPresenters';

View File

@@ -0,0 +1,20 @@
import type { CurrentUserSocialDTO } from '../dto/CurrentUserSocialDTO';
import type { FriendDTO } from '../dto/FriendDTO';
import type { FeedItemDTO } from '../dto/FeedItemDTO';
export interface CurrentUserSocialViewModel {
currentUser: CurrentUserSocialDTO;
friends: FriendDTO[];
}
export interface ICurrentUserSocialPresenter {
present(viewModel: CurrentUserSocialViewModel): void;
}
export interface UserFeedViewModel {
items: FeedItemDTO[];
}
export interface IUserFeedPresenter {
present(viewModel: UserFeedViewModel): void;
}

View File

@@ -0,0 +1,56 @@
import type { ISocialGraphRepository } from '../../domain/repositories/ISocialGraphRepository';
import type { CurrentUserSocialDTO } from '../dto/CurrentUserSocialDTO';
import type { FriendDTO } from '../dto/FriendDTO';
import type {
CurrentUserSocialViewModel,
ICurrentUserSocialPresenter,
} from '../presenters/ISocialPresenters';
export interface GetCurrentUserSocialParams {
driverId: string;
}
/**
* Application-level use case to retrieve the current user's social context.
*
* Keeps orchestration in the social bounded context while delegating
* data access to domain repositories and presenting via a presenter.
*/
export class GetCurrentUserSocialUseCase {
constructor(
private readonly socialGraphRepository: ISocialGraphRepository,
public readonly presenter: ICurrentUserSocialPresenter,
) {}
async execute(params: GetCurrentUserSocialParams): Promise<void> {
const { driverId } = params;
const friendsDomain = await this.socialGraphRepository.getFriends(driverId);
const friends: FriendDTO[] = friendsDomain.map((friend) => ({
driverId: friend.id,
displayName: friend.name,
avatarUrl: '',
isOnline: false,
lastSeen: new Date(),
primaryLeagueId: undefined,
primaryTeamId: undefined,
}));
const currentUser: CurrentUserSocialDTO = {
driverId,
displayName: '',
avatarUrl: '',
countryCode: '',
primaryTeamId: undefined,
primaryLeagueId: undefined,
};
const viewModel: CurrentUserSocialViewModel = {
currentUser,
friends,
};
this.presenter.present(viewModel);
}
}

View File

@@ -0,0 +1,52 @@
import type { IFeedRepository } from '../../domain/repositories/IFeedRepository';
import type { FeedItemDTO } from '../dto/FeedItemDTO';
import type { FeedItem } from '../../domain/entities/FeedItem';
import type {
IUserFeedPresenter,
UserFeedViewModel,
} from '../presenters/ISocialPresenters';
export interface GetUserFeedParams {
driverId: string;
limit?: number;
}
export class GetUserFeedUseCase {
constructor(
private readonly feedRepository: IFeedRepository,
public readonly presenter: IUserFeedPresenter,
) {}
async execute(params: GetUserFeedParams): Promise<void> {
const { driverId, limit } = params;
const items = await this.feedRepository.getFeedForDriver(driverId, limit);
const dtoItems = items.map(mapFeedItemToDTO);
const viewModel: UserFeedViewModel = {
items: dtoItems,
};
this.presenter.present(viewModel);
}
}
function mapFeedItemToDTO(item: FeedItem): FeedItemDTO {
return {
id: item.id,
timestamp:
item.timestamp instanceof Date
? item.timestamp.toISOString()
: new Date(item.timestamp).toISOString(),
type: item.type,
actorFriendId: item.actorFriendId,
actorDriverId: item.actorDriverId,
leagueId: item.leagueId,
raceId: item.raceId,
teamId: item.teamId,
position: item.position,
headline: item.headline,
body: item.body,
ctaLabel: item.ctaLabel,
ctaHref: item.ctaHref,
};
}

View File

@@ -0,0 +1,8 @@
export class SocialDomainError extends Error {
readonly name: string = 'SocialDomainError';
constructor(message: string) {
super(message);
Object.setPrototypeOf(this, new.target.prototype);
}
}

View File

@@ -1,5 +1,9 @@
export * from './src/faker/faker';
export * from './src/images/images';
export * from './src/media/DemoAvatarGenerationAdapter';
export * from './src/media/DemoFaceValidationAdapter';
export * from './src/media/DemoImageServiceAdapter';
export * from './src/media/InMemoryAvatarGenerationRepository';
export * from './src/racing/RacingSeedCore';
export * from './src/racing/RacingSponsorshipSeed';
export * from './src/racing/RacingFeedSeed';

View File

@@ -1,18 +1,18 @@
import type {
AvatarGenerationPort,
AvatarGenerationOptions,
AvatarGenerationResult
import type {
AvatarGenerationPort,
AvatarGenerationOptions,
AvatarGenerationResult,
} from '@gridpilot/media';
/**
* Demo implementation of AvatarGenerationPort.
*
*
* In production, this would use a real AI image generation API like:
* - OpenAI DALL-E
* - Midjourney API
* - Stable Diffusion
* - RunwayML
*
*
* For demo purposes, this returns placeholder avatar images.
*/
export class DemoAvatarGenerationAdapter implements AvatarGenerationPort {
@@ -81,10 +81,10 @@ export class DemoAvatarGenerationAdapter implements AvatarGenerationPort {
// For demo, return placeholder URLs based on suit color
// In production, these would be actual AI-generated images
const colorAvatars = this.placeholderAvatars[options.suitColor] || this.placeholderAvatars.blue;
const colorAvatars = this.placeholderAvatars[options.suitColor] ?? this.placeholderAvatars.blue;
// Generate unique URLs with a hash to simulate different generations
const hash = this.generateHash(options.facePhotoUrl + Date.now());
const hash = this.generateHash((options.facePhotoUrl ?? '') + Date.now());
const avatars = colorAvatars.slice(0, options.count).map((baseUrl, index) => {
// In demo mode, use dicebear or similar for generating varied avatars
const seed = `${hash}-${options.suitColor}-${index}`;
@@ -101,15 +101,15 @@ export class DemoAvatarGenerationAdapter implements AvatarGenerationPort {
}
private delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
return new Promise((resolve) => setTimeout(resolve, ms));
}
private generateHash(input: string): string {
let hash = 0;
for (let i = 0; i < input.length; i++) {
for (let i = 0; i < input.length; i += 1) {
const char = input.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash;
hash = (hash << 5) - hash + char;
hash |= 0;
}
return Math.abs(hash).toString(36);
}

View File

@@ -2,13 +2,13 @@ import type { FaceValidationPort, FaceValidationResult } from '@gridpilot/media'
/**
* Demo implementation of FaceValidationPort.
*
*
* In production, this would use a real face detection API like:
* - AWS Rekognition
* - Google Cloud Vision
* - Azure Face API
* - OpenCV / face-api.js
*
*
* For demo purposes, this always returns a valid face if the image data is provided.
*/
export class DemoFaceValidationAdapter implements FaceValidationPort {
@@ -18,7 +18,7 @@ export class DemoFaceValidationAdapter implements FaceValidationPort {
// Check if we have any image data
const dataString = typeof imageData === 'string' ? imageData : imageData.toString();
if (!dataString || dataString.length < 100) {
return {
isValid: false,
@@ -30,8 +30,8 @@ export class DemoFaceValidationAdapter implements FaceValidationPort {
}
// Check for valid base64 image data or data URL
const isValidImage =
dataString.startsWith('data:image/') ||
const isValidImage =
dataString.startsWith('data:image/') ||
dataString.startsWith('/9j/') || // JPEG magic bytes in base64
dataString.startsWith('iVBOR') || // PNG magic bytes in base64
dataString.length > 1000; // Assume long strings are valid image data
@@ -57,6 +57,6 @@ export class DemoFaceValidationAdapter implements FaceValidationPort {
}
private delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
return new Promise((resolve) => setTimeout(resolve, ms));
}
}

View File

@@ -5,8 +5,7 @@ const FEMALE_DEFAULT_AVATAR = '/images/avatars/female-default-avatar.jpeg';
export class DemoImageServiceAdapter implements ImageServicePort {
getDriverAvatar(driverId: string): string {
const numericSuffixMatch = driverId.match(/(\d+)$/
);
const numericSuffixMatch = driverId.match(/(\d+)$/);
if (numericSuffixMatch) {
const numericSuffix = Number.parseInt(numericSuffixMatch[1], 10);
return numericSuffix % 2 === 0 ? FEMALE_DEFAULT_AVATAR : MALE_DEFAULT_AVATAR;
@@ -34,7 +33,7 @@ export class DemoImageServiceAdapter implements ImageServicePort {
function stableHash(value: string): number {
let hash = 0;
for (let i = 0; i < value.length; i++) {
for (let i = 0; i < value.length; i += 1) {
hash = (hash * 31 + value.charCodeAt(i)) | 0;
}
return Math.abs(hash);

View File

@@ -1,14 +1,14 @@
import type {
IAvatarGenerationRepository
import type {
IAvatarGenerationRepository,
} from '@gridpilot/media';
import {
AvatarGenerationRequest,
type AvatarGenerationRequestProps
import {
AvatarGenerationRequest,
type AvatarGenerationRequestProps,
} from '@gridpilot/media';
/**
* In-memory implementation of IAvatarGenerationRepository.
*
*
* For demo/development purposes. In production, this would use a database.
*/
export class InMemoryAvatarGenerationRepository implements IAvatarGenerationRepository {