diff --git a/adapters/racing/persistence/inmemory/InMemoryLeagueRepository.ts b/adapters/racing/persistence/inmemory/InMemoryLeagueRepository.ts index 768dcecc3..cf6729abe 100644 --- a/adapters/racing/persistence/inmemory/InMemoryLeagueRepository.ts +++ b/adapters/racing/persistence/inmemory/InMemoryLeagueRepository.ts @@ -67,6 +67,10 @@ export class InMemoryLeagueRepository implements ILeagueRepository { } } + async countAll(): Promise { + return this.leagues.size; + } + async create(league: League): Promise { this.logger.debug(`Attempting to create league: ${league.id}.`); try { diff --git a/adapters/racing/persistence/typeorm/entities/LeagueOrmEntity.ts b/adapters/racing/persistence/typeorm/entities/LeagueOrmEntity.ts new file mode 100644 index 000000000..01ee010a7 --- /dev/null +++ b/adapters/racing/persistence/typeorm/entities/LeagueOrmEntity.ts @@ -0,0 +1,36 @@ +import { Column, CreateDateColumn, Entity, PrimaryColumn } from 'typeorm'; + +import type { SerializedLeagueSettings } from '../serialized/RacingTypeOrmSerialized'; + +@Entity({ name: 'racing_leagues' }) +export class LeagueOrmEntity { + @PrimaryColumn({ type: 'uuid' }) + id!: string; + + @Column({ type: 'text' }) + name!: string; + + @Column({ type: 'text' }) + description!: string; + + @Column({ type: 'text' }) + ownerId!: string; + + @Column({ type: 'jsonb' }) + settings!: SerializedLeagueSettings; + + @CreateDateColumn({ type: 'timestamptz' }) + createdAt!: Date; + + @Column({ type: 'int', default: 0 }) + participantCount!: number; + + @Column({ type: 'text', nullable: true }) + discordUrl!: string | null; + + @Column({ type: 'text', nullable: true }) + youtubeUrl!: string | null; + + @Column({ type: 'text', nullable: true }) + websiteUrl!: string | null; +} \ No newline at end of file diff --git a/adapters/racing/persistence/typeorm/entities/LeagueScoringConfigOrmEntity.ts b/adapters/racing/persistence/typeorm/entities/LeagueScoringConfigOrmEntity.ts new file mode 100644 index 000000000..3216d61d0 --- /dev/null +++ b/adapters/racing/persistence/typeorm/entities/LeagueScoringConfigOrmEntity.ts @@ -0,0 +1,18 @@ +import { Column, Entity, PrimaryColumn } from 'typeorm'; + +import type { SerializedChampionshipConfig } from '../mappers/ChampionshipConfigJsonMapper'; + +@Entity({ name: 'racing_league_scoring_configs' }) +export class LeagueScoringConfigOrmEntity { + @PrimaryColumn({ type: 'text' }) + id!: string; + + @Column({ type: 'uuid' }) + seasonId!: string; + + @Column({ type: 'text', nullable: true }) + scoringPresetId!: string | null; + + @Column({ type: 'jsonb' }) + championships!: SerializedChampionshipConfig[]; +} \ No newline at end of file diff --git a/adapters/racing/persistence/typeorm/entities/RaceOrmEntity.ts b/adapters/racing/persistence/typeorm/entities/RaceOrmEntity.ts new file mode 100644 index 000000000..d908ba3e7 --- /dev/null +++ b/adapters/racing/persistence/typeorm/entities/RaceOrmEntity.ts @@ -0,0 +1,40 @@ +import { Column, Entity, PrimaryColumn } from 'typeorm'; + +@Entity({ name: 'racing_races' }) +export class RaceOrmEntity { + @PrimaryColumn({ type: 'text' }) + id!: string; + + @Column({ type: 'uuid' }) + leagueId!: string; + + @Column({ type: 'timestamptz' }) + scheduledAt!: Date; + + @Column({ type: 'text' }) + track!: string; + + @Column({ type: 'text', nullable: true }) + trackId!: string | null; + + @Column({ type: 'text' }) + car!: string; + + @Column({ type: 'text', nullable: true }) + carId!: string | null; + + @Column({ type: 'text' }) + sessionType!: string; + + @Column({ type: 'text' }) + status!: string; + + @Column({ type: 'int', nullable: true }) + strengthOfField!: number | null; + + @Column({ type: 'int', nullable: true }) + registeredCount!: number | null; + + @Column({ type: 'int', nullable: true }) + maxParticipants!: number | null; +} \ No newline at end of file diff --git a/adapters/racing/persistence/typeorm/entities/SeasonOrmEntity.ts b/adapters/racing/persistence/typeorm/entities/SeasonOrmEntity.ts new file mode 100644 index 000000000..aa33af7fa --- /dev/null +++ b/adapters/racing/persistence/typeorm/entities/SeasonOrmEntity.ts @@ -0,0 +1,59 @@ +import { Column, Entity, PrimaryColumn } from 'typeorm'; + +import type { + SerializedSeasonDropPolicy, + SerializedSeasonSchedule, + SerializedSeasonScoringConfig, + SerializedSeasonStewardingConfig, +} from '../serialized/RacingTypeOrmSerialized'; + +@Entity({ name: 'racing_seasons' }) +export class SeasonOrmEntity { + @PrimaryColumn({ type: 'uuid' }) + id!: string; + + @Column({ type: 'uuid' }) + leagueId!: string; + + @Column({ type: 'text' }) + gameId!: string; + + @Column({ type: 'text' }) + name!: string; + + @Column({ type: 'int', nullable: true }) + year!: number | null; + + @Column({ type: 'int', nullable: true }) + order!: number | null; + + @Column({ type: 'text' }) + status!: string; + + @Column({ type: 'timestamptz', nullable: true }) + startDate!: Date | null; + + @Column({ type: 'timestamptz', nullable: true }) + endDate!: Date | null; + + @Column({ type: 'jsonb', nullable: true }) + schedule!: SerializedSeasonSchedule | null; + + @Column({ type: 'boolean', default: false }) + schedulePublished!: boolean; + + @Column({ type: 'jsonb', nullable: true }) + scoringConfig!: SerializedSeasonScoringConfig | null; + + @Column({ type: 'jsonb', nullable: true }) + dropPolicy!: SerializedSeasonDropPolicy | null; + + @Column({ type: 'jsonb', nullable: true }) + stewardingConfig!: SerializedSeasonStewardingConfig | null; + + @Column({ type: 'int', nullable: true }) + maxDrivers!: number | null; + + @Column({ type: 'int', default: 0 }) + participantCount!: number; +} \ No newline at end of file diff --git a/adapters/racing/persistence/typeorm/errors/InvalidLeagueScoringConfigChampionshipsSchemaError.ts b/adapters/racing/persistence/typeorm/errors/InvalidLeagueScoringConfigChampionshipsSchemaError.ts new file mode 100644 index 000000000..412393207 --- /dev/null +++ b/adapters/racing/persistence/typeorm/errors/InvalidLeagueScoringConfigChampionshipsSchemaError.ts @@ -0,0 +1,7 @@ +export class InvalidLeagueScoringConfigChampionshipsSchemaError extends Error { + override readonly name = 'InvalidLeagueScoringConfigChampionshipsSchemaError'; + + constructor(message = 'Invalid LeagueScoringConfig.championships persisted schema') { + super(message); + } +} \ No newline at end of file diff --git a/adapters/racing/persistence/typeorm/errors/InvalidLeagueSettingsSchemaError.ts b/adapters/racing/persistence/typeorm/errors/InvalidLeagueSettingsSchemaError.ts new file mode 100644 index 000000000..0c5b58128 --- /dev/null +++ b/adapters/racing/persistence/typeorm/errors/InvalidLeagueSettingsSchemaError.ts @@ -0,0 +1,6 @@ +export class InvalidLeagueSettingsSchemaError extends Error { + constructor(message: string) { + super(message); + this.name = 'InvalidLeagueSettingsSchemaError'; + } +} \ No newline at end of file diff --git a/adapters/racing/persistence/typeorm/errors/InvalidRaceSessionTypeSchemaError.ts b/adapters/racing/persistence/typeorm/errors/InvalidRaceSessionTypeSchemaError.ts new file mode 100644 index 000000000..899aab08c --- /dev/null +++ b/adapters/racing/persistence/typeorm/errors/InvalidRaceSessionTypeSchemaError.ts @@ -0,0 +1,6 @@ +export class InvalidRaceSessionTypeSchemaError extends Error { + constructor(message: string) { + super(message); + this.name = 'InvalidRaceSessionTypeSchemaError'; + } +} \ No newline at end of file diff --git a/adapters/racing/persistence/typeorm/errors/InvalidRaceStatusSchemaError.ts b/adapters/racing/persistence/typeorm/errors/InvalidRaceStatusSchemaError.ts new file mode 100644 index 000000000..7213b3565 --- /dev/null +++ b/adapters/racing/persistence/typeorm/errors/InvalidRaceStatusSchemaError.ts @@ -0,0 +1,6 @@ +export class InvalidRaceStatusSchemaError extends Error { + constructor(message: string) { + super(message); + this.name = 'InvalidRaceStatusSchemaError'; + } +} \ No newline at end of file diff --git a/adapters/racing/persistence/typeorm/errors/InvalidSeasonScheduleSchemaError.ts b/adapters/racing/persistence/typeorm/errors/InvalidSeasonScheduleSchemaError.ts new file mode 100644 index 000000000..7c4cba99e --- /dev/null +++ b/adapters/racing/persistence/typeorm/errors/InvalidSeasonScheduleSchemaError.ts @@ -0,0 +1,6 @@ +export class InvalidSeasonScheduleSchemaError extends Error { + constructor(message: string) { + super(message); + this.name = 'InvalidSeasonScheduleSchemaError'; + } +} \ No newline at end of file diff --git a/adapters/racing/persistence/typeorm/errors/InvalidSeasonStatusSchemaError.ts b/adapters/racing/persistence/typeorm/errors/InvalidSeasonStatusSchemaError.ts new file mode 100644 index 000000000..2c9b978f2 --- /dev/null +++ b/adapters/racing/persistence/typeorm/errors/InvalidSeasonStatusSchemaError.ts @@ -0,0 +1,6 @@ +export class InvalidSeasonStatusSchemaError extends Error { + constructor(message: string) { + super(message); + this.name = 'InvalidSeasonStatusSchemaError'; + } +} \ No newline at end of file diff --git a/adapters/racing/persistence/typeorm/mappers/ChampionshipConfigJsonMapper.ts b/adapters/racing/persistence/typeorm/mappers/ChampionshipConfigJsonMapper.ts new file mode 100644 index 000000000..e512b8fed --- /dev/null +++ b/adapters/racing/persistence/typeorm/mappers/ChampionshipConfigJsonMapper.ts @@ -0,0 +1,59 @@ +import type { BonusRule } from '@core/racing/domain/types/BonusRule'; +import type { ChampionshipConfig } from '@core/racing/domain/types/ChampionshipConfig'; +import type { DropScorePolicy } from '@core/racing/domain/types/DropScorePolicy'; +import type { SessionType } from '@core/racing/domain/types/SessionType'; +import { PointsTableJsonMapper, type SerializedPointsTable } from './PointsTableJsonMapper'; + +export type SerializedChampionshipConfig = { + id: string; + name: string; + type: ChampionshipConfig['type']; + sessionTypes: SessionType[]; + pointsTableBySessionType: Record; + bonusRulesBySessionType?: Record; + dropScorePolicy: DropScorePolicy; +}; + +export class ChampionshipConfigJsonMapper { + constructor(private readonly pointsTableMapper: PointsTableJsonMapper) {} + + toJson(config: ChampionshipConfig): SerializedChampionshipConfig { + const pointsTableBySessionType = {} as Record; + + for (const [sessionType, pointsTable] of Object.entries(config.pointsTableBySessionType) as Array< + [SessionType, Parameters[0]] + >) { + pointsTableBySessionType[sessionType] = this.pointsTableMapper.toJson(pointsTable); + } + + return { + id: config.id, + name: config.name, + type: config.type, + sessionTypes: config.sessionTypes, + pointsTableBySessionType, + ...(config.bonusRulesBySessionType ? { bonusRulesBySessionType: config.bonusRulesBySessionType } : {}), + dropScorePolicy: config.dropScorePolicy, + }; + } + + fromJson(serialized: SerializedChampionshipConfig): ChampionshipConfig { + const pointsTableBySessionType = {} as ChampionshipConfig['pointsTableBySessionType']; + + for (const [sessionType, pointsTable] of Object.entries(serialized.pointsTableBySessionType) as Array< + [SessionType, SerializedPointsTable] + >) { + pointsTableBySessionType[sessionType] = this.pointsTableMapper.fromJson(pointsTable); + } + + return { + id: serialized.id, + name: serialized.name, + type: serialized.type, + sessionTypes: serialized.sessionTypes, + pointsTableBySessionType, + ...(serialized.bonusRulesBySessionType ? { bonusRulesBySessionType: serialized.bonusRulesBySessionType } : {}), + dropScorePolicy: serialized.dropScorePolicy, + }; + } +} \ No newline at end of file diff --git a/adapters/racing/persistence/typeorm/mappers/LeagueOrmMapper.test.ts b/adapters/racing/persistence/typeorm/mappers/LeagueOrmMapper.test.ts new file mode 100644 index 000000000..4981aed3e --- /dev/null +++ b/adapters/racing/persistence/typeorm/mappers/LeagueOrmMapper.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { League } from '@core/racing/domain/entities/League'; + +import { LeagueOrmEntity } from '../entities/LeagueOrmEntity'; +import { LeagueOrmMapper } from './LeagueOrmMapper'; + +describe('LeagueOrmMapper', () => { + it('toDomain preserves persisted identity and uses rehydrate semantics (does not call create)', () => { + const mapper = new LeagueOrmMapper(); + + const entity = new LeagueOrmEntity(); + entity.id = '00000000-0000-4000-8000-000000000001'; + entity.name = 'League Name'; + entity.description = 'League Description'; + entity.ownerId = '00000000-0000-4000-8000-000000000002'; + entity.settings = { + pointsSystem: 'f1-2024', + visibility: 'ranked', + maxDrivers: 32, + sessionDuration: 60, + qualifyingFormat: 'open', + stewarding: { decisionMode: 'admin_only' }, + }; + entity.createdAt = new Date('2025-01-01T00:00:00.000Z'); + entity.participantCount = 7; + entity.discordUrl = null; + entity.youtubeUrl = null; + entity.websiteUrl = null; + + if (typeof (League as any).rehydrate !== 'function') { + throw new Error('rehydrate-missing'); + } + + const rehydrateSpy = vi.spyOn(League as any, 'rehydrate'); + const createSpy = vi.spyOn(League, 'create').mockImplementation(() => { + throw new Error('create-called'); + }); + + const domain = mapper.toDomain(entity); + + expect(domain.id.toString()).toBe(entity.id); + expect(createSpy).not.toHaveBeenCalled(); + expect(rehydrateSpy).toHaveBeenCalled(); + }); + + it('toDomain validates persisted settings schema and throws adapter-scoped error type', () => { + const mapper = new LeagueOrmMapper(); + + const entity = new LeagueOrmEntity(); + entity.id = '00000000-0000-4000-8000-000000000001'; + entity.name = 'League Name'; + entity.description = 'League Description'; + entity.ownerId = '00000000-0000-4000-8000-000000000002'; + entity.settings = 123 as unknown as never; + entity.createdAt = new Date('2025-01-01T00:00:00.000Z'); + entity.participantCount = 0; + entity.discordUrl = null; + entity.youtubeUrl = null; + entity.websiteUrl = null; + + try { + mapper.toDomain(entity); + throw new Error('expected-to-throw'); + } catch (error) { + expect(error).toMatchObject({ name: 'InvalidLeagueSettingsSchemaError' }); + } + }); +}); \ No newline at end of file diff --git a/adapters/racing/persistence/typeorm/mappers/LeagueOrmMapper.ts b/adapters/racing/persistence/typeorm/mappers/LeagueOrmMapper.ts new file mode 100644 index 000000000..27aab3c9a --- /dev/null +++ b/adapters/racing/persistence/typeorm/mappers/LeagueOrmMapper.ts @@ -0,0 +1,190 @@ +import { League, type LeagueSettings } from '@core/racing/domain/entities/League'; + +import { LeagueOrmEntity } from '../entities/LeagueOrmEntity'; +import { InvalidLeagueSettingsSchemaError } from '../errors/InvalidLeagueSettingsSchemaError'; +import type { SerializedLeagueSettings } from '../serialized/RacingTypeOrmSerialized'; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +const VALID_POINTS_SYSTEMS = ['f1-2024', 'indycar', 'custom'] as const; + +const VALID_VISIBILITY = ['ranked', 'unranked'] as const; + +const VALID_DECISION_MODES = [ + 'admin_only', + 'steward_decides', + 'steward_vote', + 'member_vote', + 'steward_veto', + 'member_veto', +] as const; + +function isOneOf(value: string, allowed: readonly T[]): value is T { + return (allowed as readonly string[]).includes(value); +} + +function assertSerializedLeagueSettings(value: unknown): asserts value is SerializedLeagueSettings { + if (!isRecord(value)) { + throw new InvalidLeagueSettingsSchemaError('Invalid settings (expected object)'); + } + + if (typeof value.pointsSystem !== 'string' || !isOneOf(value.pointsSystem, VALID_POINTS_SYSTEMS)) { + throw new InvalidLeagueSettingsSchemaError('Invalid settings.pointsSystem'); + } + + if (value.sessionDuration !== undefined && typeof value.sessionDuration !== 'number') { + throw new InvalidLeagueSettingsSchemaError('Invalid settings.sessionDuration'); + } + + if (value.qualifyingFormat !== undefined && typeof value.qualifyingFormat !== 'string') { + throw new InvalidLeagueSettingsSchemaError('Invalid settings.qualifyingFormat'); + } + + if (value.maxDrivers !== undefined && typeof value.maxDrivers !== 'number') { + throw new InvalidLeagueSettingsSchemaError('Invalid settings.maxDrivers'); + } + + if (value.visibility !== undefined) { + if (typeof value.visibility !== 'string' || !isOneOf(value.visibility, VALID_VISIBILITY)) { + throw new InvalidLeagueSettingsSchemaError('Invalid settings.visibility'); + } + } + + if (value.stewarding !== undefined) { + if (!isRecord(value.stewarding)) { + throw new InvalidLeagueSettingsSchemaError('Invalid settings.stewarding (expected object)'); + } + + if ( + typeof value.stewarding.decisionMode !== 'string' || + !isOneOf(value.stewarding.decisionMode, VALID_DECISION_MODES) + ) { + throw new InvalidLeagueSettingsSchemaError('Invalid settings.stewarding.decisionMode'); + } + + if (value.stewarding.requiredVotes !== undefined && typeof value.stewarding.requiredVotes !== 'number') { + throw new InvalidLeagueSettingsSchemaError('Invalid settings.stewarding.requiredVotes'); + } + if (value.stewarding.requireDefense !== undefined && typeof value.stewarding.requireDefense !== 'boolean') { + throw new InvalidLeagueSettingsSchemaError('Invalid settings.stewarding.requireDefense'); + } + if (value.stewarding.defenseTimeLimit !== undefined && typeof value.stewarding.defenseTimeLimit !== 'number') { + throw new InvalidLeagueSettingsSchemaError('Invalid settings.stewarding.defenseTimeLimit'); + } + if (value.stewarding.voteTimeLimit !== undefined && typeof value.stewarding.voteTimeLimit !== 'number') { + throw new InvalidLeagueSettingsSchemaError('Invalid settings.stewarding.voteTimeLimit'); + } + if (value.stewarding.protestDeadlineHours !== undefined && typeof value.stewarding.protestDeadlineHours !== 'number') { + throw new InvalidLeagueSettingsSchemaError('Invalid settings.stewarding.protestDeadlineHours'); + } + if ( + value.stewarding.stewardingClosesHours !== undefined && + typeof value.stewarding.stewardingClosesHours !== 'number' + ) { + throw new InvalidLeagueSettingsSchemaError('Invalid settings.stewarding.stewardingClosesHours'); + } + if ( + value.stewarding.notifyAccusedOnProtest !== undefined && + typeof value.stewarding.notifyAccusedOnProtest !== 'boolean' + ) { + throw new InvalidLeagueSettingsSchemaError('Invalid settings.stewarding.notifyAccusedOnProtest'); + } + if (value.stewarding.notifyOnVoteRequired !== undefined && typeof value.stewarding.notifyOnVoteRequired !== 'boolean') { + throw new InvalidLeagueSettingsSchemaError('Invalid settings.stewarding.notifyOnVoteRequired'); + } + } + + if (value.customPoints !== undefined) { + if (!isRecord(value.customPoints)) { + throw new InvalidLeagueSettingsSchemaError('Invalid settings.customPoints (expected object)'); + } + + for (const [key, points] of Object.entries(value.customPoints)) { + if (!Number.isInteger(Number(key))) { + throw new InvalidLeagueSettingsSchemaError('Invalid settings.customPoints (expected numeric keys)'); + } + if (typeof points !== 'number') { + throw new InvalidLeagueSettingsSchemaError('Invalid settings.customPoints (expected number values)'); + } + } + } +} + +function parseLeagueSettings(value: unknown): LeagueSettings { + assertSerializedLeagueSettings(value); + + const customPoints: Record | undefined = value.customPoints + ? Object.fromEntries(Object.entries(value.customPoints).map(([position, points]) => [Number(position), points])) + : undefined; + + return { + pointsSystem: value.pointsSystem, + ...(value.sessionDuration !== undefined ? { sessionDuration: value.sessionDuration } : {}), + ...(value.qualifyingFormat !== undefined ? { qualifyingFormat: value.qualifyingFormat } : {}), + ...(customPoints !== undefined ? { customPoints } : {}), + ...(value.maxDrivers !== undefined ? { maxDrivers: value.maxDrivers } : {}), + ...(value.stewarding !== undefined ? { stewarding: value.stewarding } : {}), + ...(value.visibility !== undefined ? { visibility: value.visibility } : {}), + }; +} + +function serializeLeagueSettings(settings: LeagueSettings): SerializedLeagueSettings { + const customPoints = + settings.customPoints !== undefined + ? Object.fromEntries( + Object.entries(settings.customPoints).map(([position, points]) => [String(position), points]), + ) + : undefined; + + return { + pointsSystem: settings.pointsSystem, + ...(settings.sessionDuration !== undefined ? { sessionDuration: settings.sessionDuration } : {}), + ...(settings.qualifyingFormat !== undefined ? { qualifyingFormat: settings.qualifyingFormat } : {}), + ...(customPoints !== undefined ? { customPoints } : {}), + ...(settings.maxDrivers !== undefined ? { maxDrivers: settings.maxDrivers } : {}), + ...(settings.stewarding !== undefined ? { stewarding: settings.stewarding } : {}), + ...(settings.visibility !== undefined ? { visibility: settings.visibility } : {}), + }; +} + +export class LeagueOrmMapper { + toOrmEntity(domain: League): LeagueOrmEntity { + const entity = new LeagueOrmEntity(); + entity.id = domain.id.toString(); + entity.name = domain.name.toString(); + entity.description = domain.description.toString(); + entity.ownerId = domain.ownerId.toString(); + entity.settings = serializeLeagueSettings(domain.settings); + entity.createdAt = domain.createdAt.toDate(); + entity.participantCount = domain.getParticipantCount(); + entity.discordUrl = domain.socialLinks?.discordUrl ?? null; + entity.youtubeUrl = domain.socialLinks?.youtubeUrl ?? null; + entity.websiteUrl = domain.socialLinks?.websiteUrl ?? null; + return entity; + } + + toDomain(entity: LeagueOrmEntity): League { + const settings = parseLeagueSettings(entity.settings); + + return League.rehydrate({ + id: entity.id, + name: entity.name, + description: entity.description, + ownerId: entity.ownerId, + settings, + createdAt: entity.createdAt, + participantCount: entity.participantCount, + ...(entity.discordUrl || entity.youtubeUrl || entity.websiteUrl + ? { + socialLinks: { + ...(entity.discordUrl ? { discordUrl: entity.discordUrl } : {}), + ...(entity.youtubeUrl ? { youtubeUrl: entity.youtubeUrl } : {}), + ...(entity.websiteUrl ? { websiteUrl: entity.websiteUrl } : {}), + }, + } + : {}), + }); + } +} \ No newline at end of file diff --git a/adapters/racing/persistence/typeorm/mappers/LeagueScoringConfigOrmMapper.test.ts b/adapters/racing/persistence/typeorm/mappers/LeagueScoringConfigOrmMapper.test.ts new file mode 100644 index 000000000..46a4eecc1 --- /dev/null +++ b/adapters/racing/persistence/typeorm/mappers/LeagueScoringConfigOrmMapper.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { LeagueScoringConfig } from '@core/racing/domain/entities/LeagueScoringConfig'; + +import { LeagueScoringConfigOrmEntity } from '../entities/LeagueScoringConfigOrmEntity'; +import { LeagueScoringConfigOrmMapper } from './LeagueScoringConfigOrmMapper'; +import { ChampionshipConfigJsonMapper, type SerializedChampionshipConfig } from './ChampionshipConfigJsonMapper'; +import { PointsTableJsonMapper } from './PointsTableJsonMapper'; + +describe('LeagueScoringConfigOrmMapper', () => { + it('toDomain uses rehydrate semantics (does not call create)', () => { + const pointsTableMapper = new PointsTableJsonMapper(); + const championshipMapper = new ChampionshipConfigJsonMapper(pointsTableMapper); + const mapper = new LeagueScoringConfigOrmMapper(championshipMapper); + + const serializedChampionship: SerializedChampionshipConfig = { + id: 'champ-1', + name: 'Main Championship', + type: 'driver', + sessionTypes: ['main'], + pointsTableBySessionType: { + main: { pointsByPosition: { '1': 25, '2': 18 } }, + } as unknown as SerializedChampionshipConfig['pointsTableBySessionType'], + dropScorePolicy: { strategy: 'none' }, + }; + + const entity = new LeagueScoringConfigOrmEntity(); + entity.id = 'scoring-config-season-1'; + entity.seasonId = 'season-1'; + entity.scoringPresetId = null; + entity.championships = [serializedChampionship]; + + const createSpy = vi.spyOn(LeagueScoringConfig, 'create').mockImplementation(() => { + throw new Error('create-called'); + }); + + expect(() => mapper.toDomain(entity)).not.toThrow(); + expect(createSpy).not.toHaveBeenCalled(); + }); + + it('toDomain validates schema: non-array championships yields adapter-scoped error type', () => { + const pointsTableMapper = new PointsTableJsonMapper(); + const championshipMapper = new ChampionshipConfigJsonMapper(pointsTableMapper); + const mapper = new LeagueScoringConfigOrmMapper(championshipMapper); + + const entity = new LeagueScoringConfigOrmEntity(); + entity.id = 'scoring-config-season-1'; + entity.seasonId = 'season-1'; + entity.scoringPresetId = null; + entity.championships = { not: 'an-array' } as unknown as never; + + try { + mapper.toDomain(entity); + throw new Error('expected-to-throw'); + } catch (error) { + expect(error).toMatchObject({ name: 'InvalidLeagueScoringConfigChampionshipsSchemaError' }); + } + }); +}); \ No newline at end of file diff --git a/adapters/racing/persistence/typeorm/mappers/LeagueScoringConfigOrmMapper.ts b/adapters/racing/persistence/typeorm/mappers/LeagueScoringConfigOrmMapper.ts new file mode 100644 index 000000000..96cefb47d --- /dev/null +++ b/adapters/racing/persistence/typeorm/mappers/LeagueScoringConfigOrmMapper.ts @@ -0,0 +1,71 @@ +import { LeagueScoringConfig } from '@core/racing/domain/entities/LeagueScoringConfig'; +import { ChampionshipConfigJsonMapper, type SerializedChampionshipConfig } from './ChampionshipConfigJsonMapper'; +import { LeagueScoringConfigOrmEntity } from '../entities/LeagueScoringConfigOrmEntity'; +import { InvalidLeagueScoringConfigChampionshipsSchemaError } from '../errors/InvalidLeagueScoringConfigChampionshipsSchemaError'; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function assertSerializedChampionshipConfig(value: unknown, index: number): asserts value is SerializedChampionshipConfig { + if (!isRecord(value)) { + throw new InvalidLeagueScoringConfigChampionshipsSchemaError(`Invalid championships[${index}] (expected object)`); + } + + if (typeof value.id !== 'string' || value.id.trim().length === 0) { + throw new InvalidLeagueScoringConfigChampionshipsSchemaError(`Invalid championships[${index}].id`); + } + + if (typeof value.name !== 'string' || value.name.trim().length === 0) { + throw new InvalidLeagueScoringConfigChampionshipsSchemaError(`Invalid championships[${index}].name`); + } + + if (typeof value.type !== 'string' || value.type.trim().length === 0) { + throw new InvalidLeagueScoringConfigChampionshipsSchemaError(`Invalid championships[${index}].type`); + } + + if (!Array.isArray(value.sessionTypes)) { + throw new InvalidLeagueScoringConfigChampionshipsSchemaError(`Invalid championships[${index}].sessionTypes`); + } + + if (!isRecord(value.pointsTableBySessionType)) { + throw new InvalidLeagueScoringConfigChampionshipsSchemaError( + `Invalid championships[${index}].pointsTableBySessionType`, + ); + } + + if (!isRecord(value.dropScorePolicy) || typeof value.dropScorePolicy.strategy !== 'string') { + throw new InvalidLeagueScoringConfigChampionshipsSchemaError(`Invalid championships[${index}].dropScorePolicy`); + } +} + +export class LeagueScoringConfigOrmMapper { + constructor(private readonly championshipConfigMapper: ChampionshipConfigJsonMapper) {} + + toOrmEntity(domain: LeagueScoringConfig): LeagueScoringConfigOrmEntity { + const entity = new LeagueScoringConfigOrmEntity(); + entity.id = domain.id.toString(); + entity.seasonId = domain.seasonId.toString(); + entity.scoringPresetId = domain.scoringPresetId ? domain.scoringPresetId.toString() : null; + entity.championships = domain.championships.map((c) => this.championshipConfigMapper.toJson(c)); + return entity; + } + + toDomain(entity: LeagueScoringConfigOrmEntity): LeagueScoringConfig { + if (!Array.isArray(entity.championships)) { + throw new InvalidLeagueScoringConfigChampionshipsSchemaError('Invalid championships (expected array)'); + } + + const championships = entity.championships.map((candidate, index) => { + assertSerializedChampionshipConfig(candidate, index); + return this.championshipConfigMapper.fromJson(candidate); + }); + + return LeagueScoringConfig.rehydrate({ + id: entity.id, + seasonId: entity.seasonId, + ...(entity.scoringPresetId ? { scoringPresetId: entity.scoringPresetId } : {}), + championships, + }); + } +} \ No newline at end of file diff --git a/adapters/racing/persistence/typeorm/mappers/PointsTableJsonMapper.ts b/adapters/racing/persistence/typeorm/mappers/PointsTableJsonMapper.ts new file mode 100644 index 000000000..a8cb9c5c5 --- /dev/null +++ b/adapters/racing/persistence/typeorm/mappers/PointsTableJsonMapper.ts @@ -0,0 +1,23 @@ +import { PointsTable } from '@core/racing/domain/value-objects/PointsTable'; + +export type SerializedPointsTable = { + pointsByPosition: Record; +}; + +export class PointsTableJsonMapper { + toJson(pointsTable: PointsTable): SerializedPointsTable { + const pointsByPosition: Record = {}; + for (const [position, points] of pointsTable.props.pointsByPosition.entries()) { + pointsByPosition[String(position)] = points; + } + return { pointsByPosition }; + } + + fromJson(serialized: SerializedPointsTable): PointsTable { + const record: Record = {}; + for (const [position, points] of Object.entries(serialized.pointsByPosition)) { + record[Number(position)] = points; + } + return new PointsTable(record); + } +} \ No newline at end of file diff --git a/adapters/racing/persistence/typeorm/mappers/RaceOrmMapper.test.ts b/adapters/racing/persistence/typeorm/mappers/RaceOrmMapper.test.ts new file mode 100644 index 000000000..642157e26 --- /dev/null +++ b/adapters/racing/persistence/typeorm/mappers/RaceOrmMapper.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { Race } from '@core/racing/domain/entities/Race'; + +import { RaceOrmEntity } from '../entities/RaceOrmEntity'; +import { RaceOrmMapper } from './RaceOrmMapper'; + +describe('RaceOrmMapper', () => { + it('toDomain preserves persisted identity and uses rehydrate semantics (does not call create)', () => { + const mapper = new RaceOrmMapper(); + + const entity = new RaceOrmEntity(); + entity.id = 'race-1'; + entity.leagueId = '00000000-0000-4000-8000-000000000001'; + entity.scheduledAt = new Date('2025-01-01T00:00:00.000Z'); + entity.track = 'Spa'; + entity.trackId = null; + entity.car = 'Porsche'; + entity.carId = null; + entity.sessionType = 'main'; + entity.status = 'scheduled'; + entity.strengthOfField = null; + entity.registeredCount = null; + entity.maxParticipants = null; + + if (typeof (Race as any).rehydrate !== 'function') { + throw new Error('rehydrate-missing'); + } + + const rehydrateSpy = vi.spyOn(Race as any, 'rehydrate'); + const createSpy = vi.spyOn(Race, 'create').mockImplementation(() => { + throw new Error('create-called'); + }); + + const domain = mapper.toDomain(entity); + + expect(domain.id).toBe(entity.id); + expect(createSpy).not.toHaveBeenCalled(); + expect(rehydrateSpy).toHaveBeenCalled(); + }); + + it('toDomain validates persisted sessionType/status and throws adapter-scoped error type', () => { + const mapper = new RaceOrmMapper(); + + const entity = new RaceOrmEntity(); + entity.id = 'race-1'; + entity.leagueId = '00000000-0000-4000-8000-000000000001'; + entity.scheduledAt = new Date('2025-01-01T00:00:00.000Z'); + entity.track = 'Spa'; + entity.trackId = null; + entity.car = 'Porsche'; + entity.carId = null; + entity.sessionType = 123 as unknown as string; + entity.status = 'scheduled'; + entity.strengthOfField = null; + entity.registeredCount = null; + entity.maxParticipants = null; + + try { + mapper.toDomain(entity); + throw new Error('expected-to-throw'); + } catch (error) { + expect(error).toMatchObject({ name: 'InvalidRaceSessionTypeSchemaError' }); + } + }); +}); \ No newline at end of file diff --git a/adapters/racing/persistence/typeorm/mappers/RaceOrmMapper.ts b/adapters/racing/persistence/typeorm/mappers/RaceOrmMapper.ts new file mode 100644 index 000000000..5e86f2d9d --- /dev/null +++ b/adapters/racing/persistence/typeorm/mappers/RaceOrmMapper.ts @@ -0,0 +1,79 @@ +import { Race } from '@core/racing/domain/entities/Race'; +import { RaceStatus, type RaceStatusValue } from '@core/racing/domain/value-objects/RaceStatus'; +import { SessionType, type SessionTypeValue } from '@core/racing/domain/value-objects/SessionType'; + +import { RaceOrmEntity } from '../entities/RaceOrmEntity'; +import { InvalidRaceSessionTypeSchemaError } from '../errors/InvalidRaceSessionTypeSchemaError'; +import { InvalidRaceStatusSchemaError } from '../errors/InvalidRaceStatusSchemaError'; + +const VALID_SESSION_TYPES: SessionTypeValue[] = [ + 'practice', + 'qualifying', + 'q1', + 'q2', + 'q3', + 'sprint', + 'main', + 'timeTrial', +]; + +const VALID_RACE_STATUSES: RaceStatusValue[] = [ + 'scheduled', + 'running', + 'completed', + 'cancelled', +]; + +function assertSessionTypeValue(value: unknown): asserts value is SessionTypeValue { + if (typeof value !== 'string' || !VALID_SESSION_TYPES.includes(value as any)) { + throw new InvalidRaceSessionTypeSchemaError('Invalid sessionType'); + } +} + +function assertRaceStatusValue(value: unknown): asserts value is RaceStatusValue { + if (typeof value !== 'string' || !VALID_RACE_STATUSES.includes(value as any)) { + throw new InvalidRaceStatusSchemaError('Invalid status'); + } +} + +export class RaceOrmMapper { + toOrmEntity(domain: Race): RaceOrmEntity { + const entity = new RaceOrmEntity(); + entity.id = domain.id; + entity.leagueId = domain.leagueId; + entity.scheduledAt = domain.scheduledAt; + entity.track = domain.track; + entity.trackId = domain.trackId ?? null; + entity.car = domain.car; + entity.carId = domain.carId ?? null; + entity.sessionType = domain.sessionType.props; + entity.status = domain.status.toString(); + entity.strengthOfField = domain.getStrengthOfField() ?? null; + entity.registeredCount = domain.registeredCountNumber ?? null; + entity.maxParticipants = domain.getMaxParticipants() ?? null; + return entity; + } + + toDomain(entity: RaceOrmEntity): Race { + assertSessionTypeValue(entity.sessionType); + assertRaceStatusValue(entity.status); + + const sessionType = new SessionType(entity.sessionType); + const status = RaceStatus.create(entity.status); + + return Race.rehydrate({ + id: entity.id, + leagueId: entity.leagueId, + scheduledAt: entity.scheduledAt, + track: entity.track, + ...(entity.trackId !== null ? { trackId: entity.trackId } : {}), + car: entity.car, + ...(entity.carId !== null ? { carId: entity.carId } : {}), + sessionType, + status, + ...(entity.strengthOfField !== null ? { strengthOfField: entity.strengthOfField } : {}), + ...(entity.registeredCount !== null ? { registeredCount: entity.registeredCount } : {}), + ...(entity.maxParticipants !== null ? { maxParticipants: entity.maxParticipants } : {}), + }); + } +} \ No newline at end of file diff --git a/adapters/racing/persistence/typeorm/mappers/SeasonOrmMapper.test.ts b/adapters/racing/persistence/typeorm/mappers/SeasonOrmMapper.test.ts new file mode 100644 index 000000000..1922bb6e5 --- /dev/null +++ b/adapters/racing/persistence/typeorm/mappers/SeasonOrmMapper.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { Season } from '@core/racing/domain/entities/season/Season'; + +import { SeasonOrmEntity } from '../entities/SeasonOrmEntity'; +import { SeasonOrmMapper } from './SeasonOrmMapper'; + +describe('SeasonOrmMapper', () => { + it('toDomain preserves persisted identity and uses rehydrate semantics (does not call create)', () => { + const mapper = new SeasonOrmMapper(); + + const entity = new SeasonOrmEntity(); + entity.id = '00000000-0000-4000-8000-000000000001'; + entity.leagueId = '00000000-0000-4000-8000-000000000002'; + entity.gameId = 'iracing'; + entity.name = 'Season 2025'; + entity.year = 2025; + entity.order = 1; + entity.status = 'planned'; + entity.startDate = null; + entity.endDate = null; + entity.schedule = null; + entity.schedulePublished = false; + entity.scoringConfig = null; + entity.dropPolicy = null; + entity.stewardingConfig = null; + entity.maxDrivers = null; + entity.participantCount = 3; + + if (typeof (Season as any).rehydrate !== 'function') { + throw new Error('rehydrate-missing'); + } + + const rehydrateSpy = vi.spyOn(Season as any, 'rehydrate'); + const createSpy = vi.spyOn(Season, 'create').mockImplementation(() => { + throw new Error('create-called'); + }); + + const domain = mapper.toDomain(entity); + + expect(domain.id).toBe(entity.id); + expect(createSpy).not.toHaveBeenCalled(); + expect(rehydrateSpy).toHaveBeenCalled(); + }); + + it('toDomain validates persisted schedule schema and throws adapter-scoped error type', () => { + const mapper = new SeasonOrmMapper(); + + const entity = new SeasonOrmEntity(); + entity.id = '00000000-0000-4000-8000-000000000001'; + entity.leagueId = '00000000-0000-4000-8000-000000000002'; + entity.gameId = 'iracing'; + entity.name = 'Season 2025'; + entity.year = null; + entity.order = null; + entity.status = 'planned'; + entity.startDate = null; + entity.endDate = null; + entity.schedule = 123 as unknown as never; + entity.schedulePublished = false; + entity.scoringConfig = null; + entity.dropPolicy = null; + entity.stewardingConfig = null; + entity.maxDrivers = null; + entity.participantCount = 0; + + try { + mapper.toDomain(entity); + throw new Error('expected-to-throw'); + } catch (error) { + expect(error).toMatchObject({ name: 'InvalidSeasonScheduleSchemaError' }); + } + }); +}); \ No newline at end of file diff --git a/adapters/racing/persistence/typeorm/mappers/SeasonOrmMapper.ts b/adapters/racing/persistence/typeorm/mappers/SeasonOrmMapper.ts new file mode 100644 index 000000000..31f153180 --- /dev/null +++ b/adapters/racing/persistence/typeorm/mappers/SeasonOrmMapper.ts @@ -0,0 +1,316 @@ +import { Season } from '@core/racing/domain/entities/season/Season'; +import { ALL_WEEKDAYS, type Weekday } from '@core/racing/domain/types/Weekday'; +import { LeagueTimezone } from '@core/racing/domain/value-objects/LeagueTimezone'; +import { MonthlyRecurrencePattern } from '@core/racing/domain/value-objects/MonthlyRecurrencePattern'; +import { RaceTimeOfDay } from '@core/racing/domain/value-objects/RaceTimeOfDay'; +import { RecurrenceStrategy } from '@core/racing/domain/value-objects/RecurrenceStrategy'; +import { RecurrenceStrategyFactory } from '@core/racing/domain/value-objects/RecurrenceStrategyFactory'; +import { SeasonDropPolicy } from '@core/racing/domain/value-objects/SeasonDropPolicy'; +import { SeasonSchedule } from '@core/racing/domain/value-objects/SeasonSchedule'; +import { SeasonScoringConfig } from '@core/racing/domain/value-objects/SeasonScoringConfig'; +import { SeasonStatus, type SeasonStatusValue } from '@core/racing/domain/value-objects/SeasonStatus'; +import { SeasonStewardingConfig } from '@core/racing/domain/value-objects/SeasonStewardingConfig'; +import { WeekdaySet } from '@core/racing/domain/value-objects/WeekdaySet'; + +import { SeasonOrmEntity } from '../entities/SeasonOrmEntity'; +import { InvalidSeasonScheduleSchemaError } from '../errors/InvalidSeasonScheduleSchemaError'; + +import { InvalidSeasonStatusSchemaError } from '@adapters/racing/persistence/typeorm/errors/InvalidSeasonStatusSchemaError'; +import type { + SerializedSeasonDropPolicy, + SerializedSeasonEveryNWeeksRecurrence, + SerializedSeasonMonthlyNthWeekdayRecurrence, + SerializedSeasonRecurrence, + SerializedSeasonSchedule, + SerializedSeasonScoringConfig, + SerializedSeasonStewardingConfig, + SerializedSeasonWeeklyRecurrence, +} from '../serialized/RacingTypeOrmSerialized'; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +const VALID_SEASON_STATUSES: SeasonStatusValue[] = [ + 'planned', + 'active', + 'completed', + 'archived', + 'cancelled', +]; + +function assertSeasonStatusValue(value: unknown): asserts value is SeasonStatusValue { + if (typeof value !== 'string' || !VALID_SEASON_STATUSES.includes(value as SeasonStatusValue)) { + throw new InvalidSeasonStatusSchemaError('Invalid status'); + } +} + +function assertSerializedSeasonRecurrence(value: unknown): asserts value is SerializedSeasonRecurrence { + if (!isRecord(value) || typeof value.kind !== 'string') { + throw new InvalidSeasonScheduleSchemaError('Invalid schedule.recurrence'); + } + + if (value.kind === 'weekly') { + const candidate = value as SerializedSeasonWeeklyRecurrence; + if (!Array.isArray(candidate.weekdays)) { + throw new InvalidSeasonScheduleSchemaError('Invalid schedule.recurrence.weekdays'); + } + for (const day of candidate.weekdays) { + if (typeof day !== 'string' || !ALL_WEEKDAYS.includes(day as Weekday)) { + throw new InvalidSeasonScheduleSchemaError('Invalid schedule.recurrence.weekdays[]'); + } + } + return; + } + + if (value.kind === 'everyNWeeks') { + const candidate = value as SerializedSeasonEveryNWeeksRecurrence; + if (!Number.isInteger(candidate.intervalWeeks) || candidate.intervalWeeks <= 0) { + throw new InvalidSeasonScheduleSchemaError('Invalid schedule.recurrence.intervalWeeks'); + } + if (!Array.isArray(candidate.weekdays)) { + throw new InvalidSeasonScheduleSchemaError('Invalid schedule.recurrence.weekdays'); + } + for (const day of candidate.weekdays) { + if (typeof day !== 'string' || !ALL_WEEKDAYS.includes(day as Weekday)) { + throw new InvalidSeasonScheduleSchemaError('Invalid schedule.recurrence.weekdays[]'); + } + } + return; + } + + if (value.kind === 'monthlyNthWeekday') { + const candidate = value as SerializedSeasonMonthlyNthWeekdayRecurrence; + if (![1, 2, 3, 4].includes(candidate.ordinal)) { + throw new InvalidSeasonScheduleSchemaError('Invalid schedule.recurrence.ordinal'); + } + if (typeof candidate.weekday !== 'string' || !ALL_WEEKDAYS.includes(candidate.weekday as Weekday)) { + throw new InvalidSeasonScheduleSchemaError('Invalid schedule.recurrence.weekday'); + } + return; + } + + throw new InvalidSeasonScheduleSchemaError('Invalid schedule.recurrence.kind'); +} + +function parseSeasonSchedule(value: unknown): SeasonSchedule { + if (!isRecord(value)) { + throw new InvalidSeasonScheduleSchemaError('Invalid schedule (expected object)'); + } + + if (typeof value.startDate !== 'string') { + throw new InvalidSeasonScheduleSchemaError('Invalid schedule.startDate'); + } + const startDate = new Date(value.startDate); + if (Number.isNaN(startDate.getTime())) { + throw new InvalidSeasonScheduleSchemaError('Invalid schedule.startDate (invalid date)'); + } + + if (typeof value.timeOfDay !== 'string') { + throw new InvalidSeasonScheduleSchemaError('Invalid schedule.timeOfDay'); + } + + if (typeof value.timezoneId !== 'string') { + throw new InvalidSeasonScheduleSchemaError('Invalid schedule.timezoneId'); + } + + const plannedRoundsCandidate = value.plannedRounds; + if (typeof plannedRoundsCandidate !== 'number' || !Number.isInteger(plannedRoundsCandidate) || plannedRoundsCandidate <= 0) { + throw new InvalidSeasonScheduleSchemaError('Invalid schedule.plannedRounds'); + } + + const plannedRounds = plannedRoundsCandidate; + + assertSerializedSeasonRecurrence(value.recurrence); + + let timeOfDay: RaceTimeOfDay; + try { + timeOfDay = RaceTimeOfDay.fromString(value.timeOfDay); + } catch { + throw new InvalidSeasonScheduleSchemaError('Invalid schedule.timeOfDay (expected HH:MM)'); + } + + let timezone: LeagueTimezone; + try { + timezone = LeagueTimezone.create(value.timezoneId); + } catch { + throw new InvalidSeasonScheduleSchemaError('Invalid schedule.timezoneId'); + } + + let recurrence: RecurrenceStrategy; + try { + switch (value.recurrence.kind) { + case 'weekly': { + const weekdays = WeekdaySet.fromArray(value.recurrence.weekdays); + recurrence = RecurrenceStrategyFactory.weekly(weekdays); + break; + } + case 'everyNWeeks': { + const weekdays = WeekdaySet.fromArray(value.recurrence.weekdays); + recurrence = RecurrenceStrategyFactory.everyNWeeks(value.recurrence.intervalWeeks, weekdays); + break; + } + case 'monthlyNthWeekday': { + const pattern = MonthlyRecurrencePattern.create(value.recurrence.ordinal, value.recurrence.weekday); + recurrence = RecurrenceStrategyFactory.monthlyNthWeekday(pattern); + break; + } + default: + throw new InvalidSeasonScheduleSchemaError('Invalid schedule.recurrence.kind'); + } + } catch (error) { + if (error instanceof InvalidSeasonScheduleSchemaError) { + throw error; + } + throw new InvalidSeasonScheduleSchemaError('Invalid schedule.recurrence'); + } + + return new SeasonSchedule({ + startDate, + timeOfDay, + timezone, + recurrence, + plannedRounds, + }); +} + +function serializeSeasonSchedule(schedule: SeasonSchedule): SerializedSeasonSchedule { + const recurrence = (() => { + const props = schedule.recurrence.props; + switch (props.kind) { + case 'weekly': + return { + kind: 'weekly', + weekdays: props.weekdays.getAll(), + } satisfies SerializedSeasonWeeklyRecurrence; + case 'everyNWeeks': + return { + kind: 'everyNWeeks', + intervalWeeks: props.intervalWeeks, + weekdays: props.weekdays.getAll(), + } satisfies SerializedSeasonEveryNWeeksRecurrence; + case 'monthlyNthWeekday': + return { + kind: 'monthlyNthWeekday', + ordinal: props.monthlyPattern.ordinal, + weekday: props.monthlyPattern.weekday, + } satisfies SerializedSeasonMonthlyNthWeekdayRecurrence; + } + })(); + + return { + startDate: schedule.startDate.toISOString(), + timeOfDay: schedule.timeOfDay.toString(), + timezoneId: schedule.timezone.toString(), + recurrence, + plannedRounds: schedule.plannedRounds, + }; +} + +function serializeSeasonScoringConfig(config: SeasonScoringConfig): SerializedSeasonScoringConfig { + const props = config.props; + return { + scoringPresetId: props.scoringPresetId, + ...(props.customScoringEnabled ? { customScoringEnabled: props.customScoringEnabled } : {}), + }; +} + +function serializeSeasonDropPolicy(policy: SeasonDropPolicy): SerializedSeasonDropPolicy { + const props = policy.props; + return { + strategy: props.strategy, + ...(props.n !== undefined ? { n: props.n } : {}), + }; +} + +function serializeSeasonStewardingConfig(config: SeasonStewardingConfig): SerializedSeasonStewardingConfig { + const props = config.props; + return { + decisionMode: props.decisionMode, + ...(props.requiredVotes !== undefined ? { requiredVotes: props.requiredVotes } : {}), + requireDefense: props.requireDefense, + defenseTimeLimit: props.defenseTimeLimit, + voteTimeLimit: props.voteTimeLimit, + protestDeadlineHours: props.protestDeadlineHours, + stewardingClosesHours: props.stewardingClosesHours, + notifyAccusedOnProtest: props.notifyAccusedOnProtest, + notifyOnVoteRequired: props.notifyOnVoteRequired, + }; +} + +export class SeasonOrmMapper { + toOrmEntity(domain: Season): SeasonOrmEntity { + const entity = new SeasonOrmEntity(); + entity.id = domain.id; + entity.leagueId = domain.leagueId; + entity.gameId = domain.gameId; + entity.name = domain.name; + entity.year = domain.year ?? null; + entity.order = domain.order ?? null; + entity.status = domain.status.toString(); + entity.startDate = domain.startDate ?? null; + entity.endDate = domain.endDate ?? null; + entity.schedule = domain.schedule ? serializeSeasonSchedule(domain.schedule) : null; + entity.schedulePublished = domain.schedulePublished; + entity.scoringConfig = domain.scoringConfig ? serializeSeasonScoringConfig(domain.scoringConfig) : null; + entity.dropPolicy = domain.dropPolicy ? serializeSeasonDropPolicy(domain.dropPolicy) : null; + entity.stewardingConfig = domain.stewardingConfig ? serializeSeasonStewardingConfig(domain.stewardingConfig) : null; + entity.maxDrivers = domain.maxDrivers ?? null; + entity.participantCount = domain.getParticipantCount(); + return entity; + } + + toDomain(entity: SeasonOrmEntity): Season { + assertSeasonStatusValue(entity.status); + + const status = SeasonStatus.create(entity.status); + + const schedule = entity.schedule !== null ? parseSeasonSchedule(entity.schedule) : undefined; + + let scoringConfig: SeasonScoringConfig | undefined; + if (entity.scoringConfig !== null) { + try { + scoringConfig = new SeasonScoringConfig(entity.scoringConfig); + } catch { + throw new InvalidSeasonScheduleSchemaError('Invalid scoringConfig'); + } + } + + let dropPolicy: SeasonDropPolicy | undefined; + if (entity.dropPolicy !== null) { + try { + dropPolicy = new SeasonDropPolicy(entity.dropPolicy); + } catch { + throw new InvalidSeasonScheduleSchemaError('Invalid dropPolicy'); + } + } + + let stewardingConfig: SeasonStewardingConfig | undefined; + if (entity.stewardingConfig !== null) { + try { + stewardingConfig = new SeasonStewardingConfig(entity.stewardingConfig); + } catch { + throw new InvalidSeasonScheduleSchemaError('Invalid stewardingConfig'); + } + } + + return Season.rehydrate({ + id: entity.id, + leagueId: entity.leagueId, + gameId: entity.gameId, + name: entity.name, + ...(entity.year !== null ? { year: entity.year } : {}), + ...(entity.order !== null ? { order: entity.order } : {}), + status, + ...(entity.startDate !== null ? { startDate: entity.startDate } : {}), + ...(entity.endDate !== null ? { endDate: entity.endDate } : {}), + ...(schedule !== undefined ? { schedule } : {}), + schedulePublished: entity.schedulePublished, + ...(scoringConfig !== undefined ? { scoringConfig } : {}), + ...(dropPolicy !== undefined ? { dropPolicy } : {}), + ...(stewardingConfig !== undefined ? { stewardingConfig } : {}), + ...(entity.maxDrivers !== null ? { maxDrivers: entity.maxDrivers } : {}), + participantCount: entity.participantCount, + }); + } +} \ No newline at end of file diff --git a/adapters/racing/persistence/typeorm/repositories/TypeOrmLeagueRepository.test.ts b/adapters/racing/persistence/typeorm/repositories/TypeOrmLeagueRepository.test.ts new file mode 100644 index 000000000..4100b83f3 --- /dev/null +++ b/adapters/racing/persistence/typeorm/repositories/TypeOrmLeagueRepository.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it, vi } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +import { TypeOrmLeagueRepository } from './TypeOrmLeagueRepository'; + +describe('TypeOrmLeagueRepository', () => { + it('does not construct its own mapper dependencies', () => { + const sourcePath = path.resolve(__dirname, 'TypeOrmLeagueRepository.ts'); + const source = fs.readFileSync(sourcePath, 'utf8'); + + expect(source).not.toMatch(/new\s+LeagueOrmMapper\s*\(/); + expect(source).not.toMatch(/=\s*new\s+LeagueOrmMapper\s*\(/); + }); + + it('requires mapper injection via constructor (no default mapper)', () => { + expect(TypeOrmLeagueRepository.length).toBe(2); + }); + + it('uses the injected mapper at runtime (DB-free)', async () => { + const ormRepo = { + findOne: vi.fn().mockResolvedValue({ id: 'l1' }), + }; + + const dataSource = { + getRepository: vi.fn().mockReturnValue(ormRepo), + }; + + const mapper = { + toDomain: vi.fn().mockReturnValue({ id: 'domain' }), + toOrmEntity: vi.fn(), + }; + + const repo = new TypeOrmLeagueRepository(dataSource as any, mapper as any); + + const league = await repo.findById('l1'); + + expect(dataSource.getRepository).toHaveBeenCalledTimes(1); + expect(ormRepo.findOne).toHaveBeenCalledTimes(1); + expect(mapper.toDomain).toHaveBeenCalledTimes(1); + expect(league).toEqual({ id: 'domain' }); + }); +}); \ No newline at end of file diff --git a/adapters/racing/persistence/typeorm/repositories/TypeOrmLeagueRepository.ts b/adapters/racing/persistence/typeorm/repositories/TypeOrmLeagueRepository.ts new file mode 100644 index 000000000..714fc0223 --- /dev/null +++ b/adapters/racing/persistence/typeorm/repositories/TypeOrmLeagueRepository.ts @@ -0,0 +1,72 @@ +import type { DataSource } from 'typeorm'; + +import type { ILeagueRepository } from '@core/racing/domain/repositories/ILeagueRepository'; +import { League } from '@core/racing/domain/entities/League'; + +import { LeagueOrmEntity } from '../entities/LeagueOrmEntity'; +import { LeagueOrmMapper } from '../mappers/LeagueOrmMapper'; + +export class TypeOrmLeagueRepository implements ILeagueRepository { + constructor( + private readonly dataSource: DataSource, + private readonly mapper: LeagueOrmMapper, + ) {} + + async findById(id: string): Promise { + const repo = this.dataSource.getRepository(LeagueOrmEntity); + const entity = await repo.findOne({ where: { id } }); + return entity ? this.mapper.toDomain(entity) : null; + } + + async findAll(): Promise { + const repo = this.dataSource.getRepository(LeagueOrmEntity); + const entities = await repo.find(); + return entities.map((e) => this.mapper.toDomain(e)); + } + + async countAll(): Promise { + const repo = this.dataSource.getRepository(LeagueOrmEntity); + return repo.count(); + } + + async findByOwnerId(ownerId: string): Promise { + const repo = this.dataSource.getRepository(LeagueOrmEntity); + const entities = await repo.find({ where: { ownerId } }); + return entities.map((e) => this.mapper.toDomain(e)); + } + + async create(league: League): Promise { + const repo = this.dataSource.getRepository(LeagueOrmEntity); + const entity = this.mapper.toOrmEntity(league); + await repo.save(entity); + return league; + } + + async update(league: League): Promise { + const repo = this.dataSource.getRepository(LeagueOrmEntity); + const entity = this.mapper.toOrmEntity(league); + await repo.save(entity); + return league; + } + + async delete(id: string): Promise { + const repo = this.dataSource.getRepository(LeagueOrmEntity); + await repo.delete({ id }); + } + + async exists(id: string): Promise { + const repo = this.dataSource.getRepository(LeagueOrmEntity); + const count = await repo.count({ where: { id } }); + return count > 0; + } + + async searchByName(query: string): Promise { + const repo = this.dataSource.getRepository(LeagueOrmEntity); + const entities = await repo + .createQueryBuilder('league') + .where('league.name ILIKE :q', { q: `%${query}%` }) + .getMany(); + + return entities.map((e) => this.mapper.toDomain(e)); + } +} \ No newline at end of file diff --git a/adapters/racing/persistence/typeorm/repositories/TypeOrmLeagueScoringConfigRepository.test.ts b/adapters/racing/persistence/typeorm/repositories/TypeOrmLeagueScoringConfigRepository.test.ts new file mode 100644 index 000000000..ff4f4da76 --- /dev/null +++ b/adapters/racing/persistence/typeorm/repositories/TypeOrmLeagueScoringConfigRepository.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it, vi } from 'vitest'; + +// Hoisted mocks: if the repository constructor still "news" these classes internally, +// the mocked constructors will be called and this test will fail. +vi.mock('../mappers/PointsTableJsonMapper', () => ({ + PointsTableJsonMapper: vi.fn(), +})); + +vi.mock('../mappers/ChampionshipConfigJsonMapper', () => ({ + ChampionshipConfigJsonMapper: vi.fn(), +})); + +vi.mock('../mappers/LeagueScoringConfigOrmMapper', () => ({ + LeagueScoringConfigOrmMapper: vi.fn(), +})); + +describe('TypeOrmLeagueScoringConfigRepository', () => { + it('constructor must not new sub-mappers internally (mapper graph must be injected)', async () => { + const { PointsTableJsonMapper } = await import('../mappers/PointsTableJsonMapper'); + const { ChampionshipConfigJsonMapper } = await import('../mappers/ChampionshipConfigJsonMapper'); + const { LeagueScoringConfigOrmMapper } = await import('../mappers/LeagueScoringConfigOrmMapper'); + const { TypeOrmLeagueScoringConfigRepository } = await import('./TypeOrmLeagueScoringConfigRepository'); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const dataSourceStub: any = { getRepository: vi.fn() }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const injectedMapper: any = { toDomain: vi.fn(), toOrmEntity: vi.fn() }; + + // If repo is still doing `new PointsTableJsonMapper()` etc. internally, the mocked constructors would be called. + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + new (TypeOrmLeagueScoringConfigRepository as unknown as new (...args: unknown[]) => unknown)( + dataSourceStub, + injectedMapper, + ); + + expect(PointsTableJsonMapper).not.toHaveBeenCalled(); + expect(ChampionshipConfigJsonMapper).not.toHaveBeenCalled(); + expect(LeagueScoringConfigOrmMapper).not.toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/adapters/racing/persistence/typeorm/repositories/TypeOrmLeagueScoringConfigRepository.ts b/adapters/racing/persistence/typeorm/repositories/TypeOrmLeagueScoringConfigRepository.ts new file mode 100644 index 000000000..2ca1a8cc2 --- /dev/null +++ b/adapters/racing/persistence/typeorm/repositories/TypeOrmLeagueScoringConfigRepository.ts @@ -0,0 +1,26 @@ +import type { DataSource } from 'typeorm'; + +import type { ILeagueScoringConfigRepository } from '@core/racing/domain/repositories/ILeagueScoringConfigRepository'; +import { LeagueScoringConfig } from '@core/racing/domain/entities/LeagueScoringConfig'; + +import { LeagueScoringConfigOrmEntity } from '../entities/LeagueScoringConfigOrmEntity'; +import { LeagueScoringConfigOrmMapper } from '../mappers/LeagueScoringConfigOrmMapper'; + +export class TypeOrmLeagueScoringConfigRepository implements ILeagueScoringConfigRepository { + constructor( + private readonly dataSource: DataSource, + private readonly mapper: LeagueScoringConfigOrmMapper, + ) {} + + async findBySeasonId(seasonId: string): Promise { + const repo = this.dataSource.getRepository(LeagueScoringConfigOrmEntity); + const entity = await repo.findOne({ where: { seasonId } }); + return entity ? this.mapper.toDomain(entity) : null; + } + + async save(config: LeagueScoringConfig): Promise { + const repo = this.dataSource.getRepository(LeagueScoringConfigOrmEntity); + await repo.save(this.mapper.toOrmEntity(config)); + return config; + } +} \ No newline at end of file diff --git a/adapters/racing/persistence/typeorm/repositories/TypeOrmRaceRepository.test.ts b/adapters/racing/persistence/typeorm/repositories/TypeOrmRaceRepository.test.ts new file mode 100644 index 000000000..bf02baec7 --- /dev/null +++ b/adapters/racing/persistence/typeorm/repositories/TypeOrmRaceRepository.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it, vi } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +import { TypeOrmRaceRepository } from './TypeOrmRaceRepository'; + +describe('TypeOrmRaceRepository', () => { + it('does not construct its own mapper dependencies', () => { + const sourcePath = path.resolve(__dirname, 'TypeOrmRaceRepository.ts'); + const source = fs.readFileSync(sourcePath, 'utf8'); + + expect(source).not.toMatch(/new\s+RaceOrmMapper\s*\(/); + expect(source).not.toMatch(/=\s*new\s+RaceOrmMapper\s*\(/); + }); + + it('requires mapper injection via constructor (no default mapper)', () => { + expect(TypeOrmRaceRepository.length).toBe(2); + }); + + it('uses the injected mapper at runtime (DB-free)', async () => { + const ormRepo = { + findOne: vi.fn().mockResolvedValue({ id: 'r1' }), + }; + + const dataSource = { + getRepository: vi.fn().mockReturnValue(ormRepo), + }; + + const mapper = { + toDomain: vi.fn().mockReturnValue({ id: 'domain' }), + toOrmEntity: vi.fn(), + }; + + const repo = new TypeOrmRaceRepository(dataSource as any, mapper as any); + + const race = await repo.findById('r1'); + + expect(dataSource.getRepository).toHaveBeenCalledTimes(1); + expect(ormRepo.findOne).toHaveBeenCalledTimes(1); + expect(mapper.toDomain).toHaveBeenCalledTimes(1); + expect(race).toEqual({ id: 'domain' }); + }); +}); \ No newline at end of file diff --git a/adapters/racing/persistence/typeorm/repositories/TypeOrmRaceRepository.ts b/adapters/racing/persistence/typeorm/repositories/TypeOrmRaceRepository.ts new file mode 100644 index 000000000..b70dda02d --- /dev/null +++ b/adapters/racing/persistence/typeorm/repositories/TypeOrmRaceRepository.ts @@ -0,0 +1,88 @@ +import type { DataSource } from 'typeorm'; + +import type { IRaceRepository } from '@core/racing/domain/repositories/IRaceRepository'; +import { Race, type RaceStatusValue } from '@core/racing/domain/entities/Race'; + +import { RaceOrmEntity } from '../entities/RaceOrmEntity'; +import { RaceOrmMapper } from '../mappers/RaceOrmMapper'; + +export class TypeOrmRaceRepository implements IRaceRepository { + constructor( + private readonly dataSource: DataSource, + private readonly mapper: RaceOrmMapper, + ) {} + + async findById(id: string): Promise { + const repo = this.dataSource.getRepository(RaceOrmEntity); + const entity = await repo.findOne({ where: { id } }); + return entity ? this.mapper.toDomain(entity) : null; + } + + async findAll(): Promise { + const repo = this.dataSource.getRepository(RaceOrmEntity); + const entities = await repo.find(); + return entities.map((e) => this.mapper.toDomain(e)); + } + + async findByLeagueId(leagueId: string): Promise { + const repo = this.dataSource.getRepository(RaceOrmEntity); + const entities = await repo.find({ where: { leagueId } }); + return entities.map((e) => this.mapper.toDomain(e)); + } + + async findUpcomingByLeagueId(leagueId: string): Promise { + const repo = this.dataSource.getRepository(RaceOrmEntity); + const entities = await repo + .createQueryBuilder('race') + .where('race.leagueId = :leagueId', { leagueId }) + .andWhere('race.status = :status', { status: 'scheduled' }) + .andWhere('race.scheduledAt >= NOW()') + .getMany(); + return entities.map((e) => this.mapper.toDomain(e)); + } + + async findCompletedByLeagueId(leagueId: string): Promise { + const repo = this.dataSource.getRepository(RaceOrmEntity); + const entities = await repo.find({ where: { leagueId, status: 'completed' } }); + return entities.map((e) => this.mapper.toDomain(e)); + } + + async findByStatus(status: RaceStatusValue): Promise { + const repo = this.dataSource.getRepository(RaceOrmEntity); + const entities = await repo.find({ where: { status } }); + return entities.map((e) => this.mapper.toDomain(e)); + } + + async findByDateRange(startDate: Date, endDate: Date): Promise { + const repo = this.dataSource.getRepository(RaceOrmEntity); + const entities = await repo + .createQueryBuilder('race') + .where('race.scheduledAt >= :startDate', { startDate }) + .andWhere('race.scheduledAt <= :endDate', { endDate }) + .getMany(); + return entities.map((e) => this.mapper.toDomain(e)); + } + + async create(race: Race): Promise { + const repo = this.dataSource.getRepository(RaceOrmEntity); + await repo.save(this.mapper.toOrmEntity(race)); + return race; + } + + async update(race: Race): Promise { + const repo = this.dataSource.getRepository(RaceOrmEntity); + await repo.save(this.mapper.toOrmEntity(race)); + return race; + } + + async delete(id: string): Promise { + const repo = this.dataSource.getRepository(RaceOrmEntity); + await repo.delete({ id }); + } + + async exists(id: string): Promise { + const repo = this.dataSource.getRepository(RaceOrmEntity); + const count = await repo.count({ where: { id } }); + return count > 0; + } +} \ No newline at end of file diff --git a/adapters/racing/persistence/typeorm/repositories/TypeOrmSeasonRepository.test.ts b/adapters/racing/persistence/typeorm/repositories/TypeOrmSeasonRepository.test.ts new file mode 100644 index 000000000..2dfaf8859 --- /dev/null +++ b/adapters/racing/persistence/typeorm/repositories/TypeOrmSeasonRepository.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it, vi } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +import { TypeOrmSeasonRepository } from './TypeOrmSeasonRepository'; + +describe('TypeOrmSeasonRepository', () => { + it('does not construct its own mapper dependencies', () => { + const sourcePath = path.resolve(__dirname, 'TypeOrmSeasonRepository.ts'); + const source = fs.readFileSync(sourcePath, 'utf8'); + + expect(source).not.toMatch(/new\s+SeasonOrmMapper\s*\(/); + expect(source).not.toMatch(/=\s*new\s+SeasonOrmMapper\s*\(/); + }); + + it('requires mapper injection via constructor (no default mapper)', () => { + expect(TypeOrmSeasonRepository.length).toBe(2); + }); + + it('uses the injected mapper at runtime (DB-free)', async () => { + const ormRepo = { + findOne: vi.fn().mockResolvedValue({ id: 's1' }), + }; + + const dataSource = { + getRepository: vi.fn().mockReturnValue(ormRepo), + }; + + const mapper = { + toDomain: vi.fn().mockReturnValue({ id: 'domain' }), + toOrmEntity: vi.fn(), + }; + + const repo = new TypeOrmSeasonRepository(dataSource as any, mapper as any); + + const season = await repo.findById('s1'); + + expect(dataSource.getRepository).toHaveBeenCalledTimes(1); + expect(ormRepo.findOne).toHaveBeenCalledTimes(1); + expect(mapper.toDomain).toHaveBeenCalledTimes(1); + expect(season).toEqual({ id: 'domain' }); + }); +}); \ No newline at end of file diff --git a/adapters/racing/persistence/typeorm/repositories/TypeOrmSeasonRepository.ts b/adapters/racing/persistence/typeorm/repositories/TypeOrmSeasonRepository.ts new file mode 100644 index 000000000..14ac2a831 --- /dev/null +++ b/adapters/racing/persistence/typeorm/repositories/TypeOrmSeasonRepository.ts @@ -0,0 +1,52 @@ +import type { DataSource } from 'typeorm'; + +import type { ISeasonRepository } from '@core/racing/domain/repositories/ISeasonRepository'; +import { Season } from '@core/racing/domain/entities/season/Season'; + +import { SeasonOrmEntity } from '../entities/SeasonOrmEntity'; +import { SeasonOrmMapper } from '../mappers/SeasonOrmMapper'; + +export class TypeOrmSeasonRepository implements ISeasonRepository { + constructor( + private readonly dataSource: DataSource, + private readonly mapper: SeasonOrmMapper, + ) {} + + async findById(id: string): Promise { + const repo = this.dataSource.getRepository(SeasonOrmEntity); + const entity = await repo.findOne({ where: { id } }); + return entity ? this.mapper.toDomain(entity) : null; + } + + async findByLeagueId(leagueId: string): Promise { + const repo = this.dataSource.getRepository(SeasonOrmEntity); + const entities = await repo.find({ where: { leagueId } }); + return entities.map((e) => this.mapper.toDomain(e)); + } + + async create(season: Season): Promise { + const repo = this.dataSource.getRepository(SeasonOrmEntity); + await repo.save(this.mapper.toOrmEntity(season)); + return season; + } + + async add(season: Season): Promise { + const repo = this.dataSource.getRepository(SeasonOrmEntity); + await repo.save(this.mapper.toOrmEntity(season)); + } + + async update(season: Season): Promise { + const repo = this.dataSource.getRepository(SeasonOrmEntity); + await repo.save(this.mapper.toOrmEntity(season)); + } + + async listByLeague(leagueId: string): Promise { + return this.findByLeagueId(leagueId); + } + + async listActiveByLeague(leagueId: string): Promise { + const repo = this.dataSource.getRepository(SeasonOrmEntity); + const entities = await repo.find({ where: { leagueId, status: 'active' } }); + return entities.map((e) => this.mapper.toDomain(e)); + } +} \ No newline at end of file diff --git a/adapters/racing/persistence/typeorm/serialized/RacingTypeOrmSerialized.ts b/adapters/racing/persistence/typeorm/serialized/RacingTypeOrmSerialized.ts new file mode 100644 index 000000000..cfac1c5bf --- /dev/null +++ b/adapters/racing/persistence/typeorm/serialized/RacingTypeOrmSerialized.ts @@ -0,0 +1,87 @@ +import type { Weekday } from '@core/racing/domain/types/Weekday'; + +export type SerializedLeagueVisibility = 'ranked' | 'unranked'; + +export type SerializedLeagueStewardingDecisionMode = + | 'admin_only' + | 'steward_decides' + | 'steward_vote' + | 'member_vote' + | 'steward_veto' + | 'member_veto'; + +export type SerializedLeagueStewardingSettings = { + decisionMode: SerializedLeagueStewardingDecisionMode; + requiredVotes?: number; + requireDefense?: boolean; + defenseTimeLimit?: number; + voteTimeLimit?: number; + protestDeadlineHours?: number; + stewardingClosesHours?: number; + notifyAccusedOnProtest?: boolean; + notifyOnVoteRequired?: boolean; +}; + +export type SerializedLeagueCustomPoints = Record; + +export type SerializedLeagueSettings = { + pointsSystem: 'f1-2024' | 'indycar' | 'custom'; + sessionDuration?: number; + qualifyingFormat?: 'single-lap' | 'open'; + customPoints?: SerializedLeagueCustomPoints; + maxDrivers?: number; + stewarding?: SerializedLeagueStewardingSettings; + visibility?: SerializedLeagueVisibility; +}; + +export type SerializedSeasonWeeklyRecurrence = { + kind: 'weekly'; + weekdays: Weekday[]; +}; + +export type SerializedSeasonEveryNWeeksRecurrence = { + kind: 'everyNWeeks'; + intervalWeeks: number; + weekdays: Weekday[]; +}; + +export type SerializedSeasonMonthlyNthWeekdayRecurrence = { + kind: 'monthlyNthWeekday'; + ordinal: 1 | 2 | 3 | 4; + weekday: Weekday; +}; + +export type SerializedSeasonRecurrence = + | SerializedSeasonWeeklyRecurrence + | SerializedSeasonEveryNWeeksRecurrence + | SerializedSeasonMonthlyNthWeekdayRecurrence; + +export type SerializedSeasonSchedule = { + startDate: string; + timeOfDay: string; + timezoneId: string; + recurrence: SerializedSeasonRecurrence; + plannedRounds: number; +}; + +export type SerializedSeasonScoringConfig = { + scoringPresetId: string; + customScoringEnabled?: boolean; +}; + +export type SerializedSeasonDropPolicy = { + strategy: 'none' | 'bestNResults' | 'dropWorstN'; + n?: number; +}; + +export type SerializedSeasonStewardingConfig = { + decisionMode: SerializedLeagueStewardingDecisionMode; + requiredVotes?: number; + requireDefense: boolean; + defenseTimeLimit: number; + voteTimeLimit: number; + protestDeadlineHours: number; + stewardingClosesHours: number; + notifyAccusedOnProtest: boolean; + notifyOnVoteRequired: boolean; +}; \ No newline at end of file diff --git a/apps/api/src/domain/bootstrap/BootstrapModule.postgres-seed.test.ts b/apps/api/src/domain/bootstrap/BootstrapModule.postgres-seed.test.ts new file mode 100644 index 000000000..88605359d --- /dev/null +++ b/apps/api/src/domain/bootstrap/BootstrapModule.postgres-seed.test.ts @@ -0,0 +1,136 @@ +import 'reflect-metadata'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +type SetupOptions = { + persistence: 'postgres' | 'inmemory'; + nodeEnv: string | undefined; + bootstrapEnabled: boolean; + leaguesCount: number; +}; + +describe('BootstrapModule Postgres racing seed gating (unit)', () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + vi.resetModules(); + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + vi.restoreAllMocks(); + }); + + async function setup({ + persistence, + nodeEnv, + bootstrapEnabled, + leaguesCount, + }: SetupOptions): Promise<{ + seedExecute: ReturnType; + ensureExecute: ReturnType; + leagueCountAll: ReturnType; + }> { + process.env.NODE_ENV = nodeEnv; + + vi.doMock('../../env', async () => { + const actual = await vi.importActual('../../env'); + return { + ...actual, + getApiPersistence: () => persistence, + getEnableBootstrap: () => bootstrapEnabled, + }; + }); + + const seedExecute = vi.fn(async () => undefined); + vi.doMock('../../../../../adapters/bootstrap/SeedRacingData', () => { + class SeedRacingData { + execute = seedExecute; + } + return { SeedRacingData }; + }); + + const { BootstrapModule } = await import('./BootstrapModule'); + + const ensureExecute = vi.fn(async () => undefined); + + const leagueCountAll = vi.fn(async () => leaguesCount); + + const bootstrapModule = new BootstrapModule( + { execute: ensureExecute } as any, + { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() } as any, + { + leagueRepository: { countAll: leagueCountAll }, + } as any, + ); + + await bootstrapModule.onModuleInit(); + + return { seedExecute, ensureExecute, leagueCountAll }; + } + + it('seeds when inmemory + bootstrap enabled (existing behavior)', async () => { + const { seedExecute, ensureExecute, leagueCountAll } = await setup({ + persistence: 'inmemory', + nodeEnv: 'test', + bootstrapEnabled: true, + leaguesCount: 123, + }); + + expect(ensureExecute).toHaveBeenCalledTimes(1); + expect(leagueCountAll).toHaveBeenCalledTimes(0); + expect(seedExecute).toHaveBeenCalledTimes(1); + }); + + it('seeds when postgres + non-production + bootstrap enabled + empty', async () => { + const { seedExecute, ensureExecute, leagueCountAll } = await setup({ + persistence: 'postgres', + nodeEnv: 'development', + bootstrapEnabled: true, + leaguesCount: 0, + }); + + expect(ensureExecute).toHaveBeenCalledTimes(1); + expect(leagueCountAll).toHaveBeenCalledTimes(1); + expect(seedExecute).toHaveBeenCalledTimes(1); + }); + + it('does not seed when postgres + non-production + bootstrap enabled + not empty', async () => { + const { seedExecute, ensureExecute, leagueCountAll } = await setup({ + persistence: 'postgres', + nodeEnv: 'development', + bootstrapEnabled: true, + leaguesCount: 1, + }); + + expect(ensureExecute).toHaveBeenCalledTimes(1); + expect(leagueCountAll).toHaveBeenCalledTimes(1); + expect(seedExecute).toHaveBeenCalledTimes(0); + }); + + it('does not seed when postgres + production (even if empty)', async () => { + const { seedExecute, ensureExecute, leagueCountAll } = await setup({ + persistence: 'postgres', + nodeEnv: 'production', + bootstrapEnabled: true, + leaguesCount: 0, + }); + + expect(ensureExecute).toHaveBeenCalledTimes(1); + expect(leagueCountAll).toHaveBeenCalledTimes(0); + expect(seedExecute).toHaveBeenCalledTimes(0); + }); + + it('does not seed when postgres + non-production + bootstrap disabled (even if empty)', async () => { + const { seedExecute, ensureExecute, leagueCountAll } = await setup({ + persistence: 'postgres', + nodeEnv: 'development', + bootstrapEnabled: false, + leaguesCount: 0, + }); + + expect(ensureExecute).toHaveBeenCalledTimes(0); + expect(leagueCountAll).toHaveBeenCalledTimes(0); + expect(seedExecute).toHaveBeenCalledTimes(0); + }); +}); \ No newline at end of file diff --git a/apps/api/src/domain/bootstrap/BootstrapModule.ts b/apps/api/src/domain/bootstrap/BootstrapModule.ts index f9e722e8f..0869e5666 100644 --- a/apps/api/src/domain/bootstrap/BootstrapModule.ts +++ b/apps/api/src/domain/bootstrap/BootstrapModule.ts @@ -2,13 +2,13 @@ import type { Logger } from '@core/shared/application'; import type { EnsureInitialData } from '../../../../../adapters/bootstrap/EnsureInitialData'; import { SeedRacingData, type RacingSeedDependencies } from '../../../../../adapters/bootstrap/SeedRacingData'; import { Inject, Module, OnModuleInit } from '@nestjs/common'; -import { getApiPersistence } from '../../env'; -import { InMemoryRacingPersistenceModule } from '../../persistence/inmemory/InMemoryRacingPersistenceModule'; +import { getApiPersistence, getEnableBootstrap } from '../../env'; +import { RacingPersistenceModule } from '../../persistence/racing/RacingPersistenceModule'; import { InMemorySocialPersistenceModule } from '../../persistence/inmemory/InMemorySocialPersistenceModule'; import { BootstrapProviders, ENSURE_INITIAL_DATA_TOKEN } from './BootstrapProviders'; @Module({ - imports: [InMemoryRacingPersistenceModule, InMemorySocialPersistenceModule], + imports: [RacingPersistenceModule, InMemorySocialPersistenceModule], providers: BootstrapProviders, }) export class BootstrapModule implements OnModuleInit { @@ -21,9 +21,14 @@ export class BootstrapModule implements OnModuleInit { async onModuleInit() { console.log('[Bootstrap] Initializing application data...'); try { + if (!getEnableBootstrap()) { + this.logger.info('[Bootstrap] Bootstrap disabled via GRIDPILOT_API_BOOTSTRAP; skipping initialization'); + return; + } + await this.ensureInitialData.execute(); - if (this.shouldSeedRacingData()) { + if (await this.shouldSeedRacingData()) { await new SeedRacingData(this.logger, this.seedDeps).execute(); } @@ -34,7 +39,21 @@ export class BootstrapModule implements OnModuleInit { } } - private shouldSeedRacingData(): boolean { - return getApiPersistence() === 'inmemory'; + private async shouldSeedRacingData(): Promise { + const persistence = getApiPersistence(); + + if (persistence === 'inmemory') return true; + if (persistence !== 'postgres') return false; + if (process.env.NODE_ENV === 'production') return false; + + return this.isRacingDatabaseEmpty(); + } + + private async isRacingDatabaseEmpty(): Promise { + const count = await this.seedDeps.leagueRepository.countAll?.(); + if (typeof count === 'number') return count === 0; + + const leagues = await this.seedDeps.leagueRepository.findAll(); + return leagues.length === 0; } } \ No newline at end of file diff --git a/apps/api/src/domain/dashboard/DashboardModule.ts b/apps/api/src/domain/dashboard/DashboardModule.ts index 0f0500695..07f66ff96 100644 --- a/apps/api/src/domain/dashboard/DashboardModule.ts +++ b/apps/api/src/domain/dashboard/DashboardModule.ts @@ -1,12 +1,12 @@ import { Module } from '@nestjs/common'; -import { InMemoryRacingPersistenceModule } from '../../persistence/inmemory/InMemoryRacingPersistenceModule'; +import { RacingPersistenceModule } from '../../persistence/racing/RacingPersistenceModule'; import { InMemorySocialPersistenceModule } from '../../persistence/inmemory/InMemorySocialPersistenceModule'; import { DashboardService } from './DashboardService'; import { DashboardController } from './DashboardController'; import { DashboardProviders } from './DashboardProviders'; @Module({ - imports: [InMemoryRacingPersistenceModule, InMemorySocialPersistenceModule], + imports: [RacingPersistenceModule, InMemorySocialPersistenceModule], controllers: [DashboardController], providers: [DashboardService, ...DashboardProviders], exports: [DashboardService], diff --git a/apps/api/src/domain/database/DatabaseModule.ts b/apps/api/src/domain/database/DatabaseModule.ts index 66d12bd3a..00527e0a0 100644 --- a/apps/api/src/domain/database/DatabaseModule.ts +++ b/apps/api/src/domain/database/DatabaseModule.ts @@ -14,7 +14,7 @@ import { TypeOrmModule } from '@nestjs/typeorm'; password: process.env.DATABASE_PASSWORD || 'password', database: process.env.DATABASE_NAME || 'gridpilot', }), - // entities: [AnalyticsSnapshotOrmEntity, EngagementOrmEntity], + autoLoadEntities: true, synchronize: process.env.NODE_ENV !== 'production', }), ], diff --git a/apps/api/src/domain/driver/DriverModule.ts b/apps/api/src/domain/driver/DriverModule.ts index b26c49b5b..eeafd11c4 100644 --- a/apps/api/src/domain/driver/DriverModule.ts +++ b/apps/api/src/domain/driver/DriverModule.ts @@ -1,12 +1,12 @@ import { Module } from '@nestjs/common'; -import { InMemoryRacingPersistenceModule } from '../../persistence/inmemory/InMemoryRacingPersistenceModule'; +import { RacingPersistenceModule } from '../../persistence/racing/RacingPersistenceModule'; import { InMemorySocialPersistenceModule } from '../../persistence/inmemory/InMemorySocialPersistenceModule'; import { DriverService } from './DriverService'; import { DriverController } from './DriverController'; import { DriverProviders } from './DriverProviders'; @Module({ - imports: [InMemoryRacingPersistenceModule, InMemorySocialPersistenceModule], + imports: [RacingPersistenceModule, InMemorySocialPersistenceModule], controllers: [DriverController], providers: [DriverService, ...DriverProviders], exports: [DriverService], diff --git a/apps/api/src/domain/league/LeagueModule.ts b/apps/api/src/domain/league/LeagueModule.ts index 90b5a5965..f8e858a83 100644 --- a/apps/api/src/domain/league/LeagueModule.ts +++ b/apps/api/src/domain/league/LeagueModule.ts @@ -1,11 +1,11 @@ import { Module } from '@nestjs/common'; -import { InMemoryRacingPersistenceModule } from '../../persistence/inmemory/InMemoryRacingPersistenceModule'; +import { RacingPersistenceModule } from '../../persistence/racing/RacingPersistenceModule'; import { LeagueService } from './LeagueService'; import { LeagueController } from './LeagueController'; import { LeagueProviders } from './LeagueProviders'; @Module({ - imports: [InMemoryRacingPersistenceModule], + imports: [RacingPersistenceModule], controllers: [LeagueController], providers: LeagueProviders, exports: [LeagueService], diff --git a/apps/api/src/domain/protests/ProtestsModule.ts b/apps/api/src/domain/protests/ProtestsModule.ts index 401f18d71..de3e55051 100644 --- a/apps/api/src/domain/protests/ProtestsModule.ts +++ b/apps/api/src/domain/protests/ProtestsModule.ts @@ -1,11 +1,11 @@ import { Module } from '@nestjs/common'; -import { InMemoryRacingPersistenceModule } from '../../persistence/inmemory/InMemoryRacingPersistenceModule'; +import { RacingPersistenceModule } from '../../persistence/racing/RacingPersistenceModule'; import { ProtestsController } from './ProtestsController'; import { ProtestsService } from './ProtestsService'; import { ProtestsProviders } from './ProtestsProviders'; @Module({ - imports: [InMemoryRacingPersistenceModule], + imports: [RacingPersistenceModule], providers: [ProtestsService, ...ProtestsProviders], controllers: [ProtestsController], }) diff --git a/apps/api/src/domain/race/RaceModule.ts b/apps/api/src/domain/race/RaceModule.ts index e05a88cfc..e6a5e0ba2 100644 --- a/apps/api/src/domain/race/RaceModule.ts +++ b/apps/api/src/domain/race/RaceModule.ts @@ -1,11 +1,11 @@ import { Module } from '@nestjs/common'; -import { InMemoryRacingPersistenceModule } from '../../persistence/inmemory/InMemoryRacingPersistenceModule'; +import { RacingPersistenceModule } from '../../persistence/racing/RacingPersistenceModule'; import { RaceService } from './RaceService'; import { RaceController } from './RaceController'; import { RaceProviders } from './RaceProviders'; @Module({ - imports: [InMemoryRacingPersistenceModule], + imports: [RacingPersistenceModule], controllers: [RaceController], providers: [RaceService, ...RaceProviders], exports: [RaceService], diff --git a/apps/api/src/domain/sponsor/SponsorModule.ts b/apps/api/src/domain/sponsor/SponsorModule.ts index 9e27f7218..230d033af 100644 --- a/apps/api/src/domain/sponsor/SponsorModule.ts +++ b/apps/api/src/domain/sponsor/SponsorModule.ts @@ -1,5 +1,5 @@ import { Module } from '@nestjs/common'; -import { InMemoryRacingPersistenceModule } from '../../persistence/inmemory/InMemoryRacingPersistenceModule'; +import { RacingPersistenceModule } from '../../persistence/racing/RacingPersistenceModule'; import { AuthModule } from '../auth/AuthModule'; import { PolicyModule } from '../policy/PolicyModule'; import { SponsorService } from './SponsorService'; @@ -7,7 +7,7 @@ import { SponsorController } from './SponsorController'; import { SponsorProviders } from './SponsorProviders'; @Module({ - imports: [InMemoryRacingPersistenceModule, AuthModule, PolicyModule], + imports: [RacingPersistenceModule, AuthModule, PolicyModule], controllers: [SponsorController], providers: SponsorProviders, exports: [SponsorService], diff --git a/apps/api/src/domain/team/TeamModule.ts b/apps/api/src/domain/team/TeamModule.ts index 2cff98eee..0211eb503 100644 --- a/apps/api/src/domain/team/TeamModule.ts +++ b/apps/api/src/domain/team/TeamModule.ts @@ -1,11 +1,11 @@ import { Module } from '@nestjs/common'; -import { InMemoryRacingPersistenceModule } from '../../persistence/inmemory/InMemoryRacingPersistenceModule'; +import { RacingPersistenceModule } from '../../persistence/racing/RacingPersistenceModule'; import { TeamService } from './TeamService'; import { TeamController } from './TeamController'; import { TeamProviders } from './TeamProviders'; @Module({ - imports: [InMemoryRacingPersistenceModule], + imports: [RacingPersistenceModule], controllers: [TeamController], providers: [TeamService, ...TeamProviders], exports: [TeamService], diff --git a/apps/api/src/persistence/postgres/PostgresRacingPersistenceModule.ts b/apps/api/src/persistence/postgres/PostgresRacingPersistenceModule.ts new file mode 100644 index 000000000..c127e5933 --- /dev/null +++ b/apps/api/src/persistence/postgres/PostgresRacingPersistenceModule.ts @@ -0,0 +1,201 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule, getDataSourceToken } from '@nestjs/typeorm'; +import type { DataSource } from 'typeorm'; + +import { LoggingModule } from '../../domain/logging/LoggingModule'; + +import { + DRIVER_REPOSITORY_TOKEN, + GAME_REPOSITORY_TOKEN, + LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN, + LEAGUE_REPOSITORY_TOKEN, + LEAGUE_SCORING_CONFIG_REPOSITORY_TOKEN, + LEAGUE_WALLET_REPOSITORY_TOKEN, + PENALTY_REPOSITORY_TOKEN, + PROTEST_REPOSITORY_TOKEN, + RACE_REGISTRATION_REPOSITORY_TOKEN, + RACE_REPOSITORY_TOKEN, + RESULT_REPOSITORY_TOKEN, + SEASON_REPOSITORY_TOKEN, + SEASON_SPONSORSHIP_REPOSITORY_TOKEN, + SPONSOR_REPOSITORY_TOKEN, + SPONSORSHIP_PRICING_REPOSITORY_TOKEN, + SPONSORSHIP_REQUEST_REPOSITORY_TOKEN, + STANDING_REPOSITORY_TOKEN, + TEAM_MEMBERSHIP_REPOSITORY_TOKEN, + TEAM_REPOSITORY_TOKEN, + TRANSACTION_REPOSITORY_TOKEN, +} from '../inmemory/InMemoryRacingPersistenceModule'; + +import { LeagueOrmEntity } from '@adapters/racing/persistence/typeorm/entities/LeagueOrmEntity'; +import { LeagueScoringConfigOrmEntity } from '@adapters/racing/persistence/typeorm/entities/LeagueScoringConfigOrmEntity'; +import { RaceOrmEntity } from '@adapters/racing/persistence/typeorm/entities/RaceOrmEntity'; +import { SeasonOrmEntity } from '@adapters/racing/persistence/typeorm/entities/SeasonOrmEntity'; + +import { TypeOrmLeagueRepository } from '@adapters/racing/persistence/typeorm/repositories/TypeOrmLeagueRepository'; +import { TypeOrmLeagueScoringConfigRepository } from '@adapters/racing/persistence/typeorm/repositories/TypeOrmLeagueScoringConfigRepository'; +import { TypeOrmRaceRepository } from '@adapters/racing/persistence/typeorm/repositories/TypeOrmRaceRepository'; +import { TypeOrmSeasonRepository } from '@adapters/racing/persistence/typeorm/repositories/TypeOrmSeasonRepository'; +import { LeagueOrmMapper } from '@adapters/racing/persistence/typeorm/mappers/LeagueOrmMapper'; +import { RaceOrmMapper } from '@adapters/racing/persistence/typeorm/mappers/RaceOrmMapper'; +import { SeasonOrmMapper } from '@adapters/racing/persistence/typeorm/mappers/SeasonOrmMapper'; +import { PointsTableJsonMapper } from '@adapters/racing/persistence/typeorm/mappers/PointsTableJsonMapper'; +import { ChampionshipConfigJsonMapper } from '@adapters/racing/persistence/typeorm/mappers/ChampionshipConfigJsonMapper'; +import { LeagueScoringConfigOrmMapper } from '@adapters/racing/persistence/typeorm/mappers/LeagueScoringConfigOrmMapper'; + +function makePlaceholder(token: string): unknown { + return Object.freeze({ + __token: token, + __kind: 'postgres-placeholder', + __notImplemented(): never { + throw new Error(`[PostgresRacingPersistenceModule] Placeholder provider "${token}" is not implemented yet`); + }, + }); +} + +const typeOrmFeatureImports = [ + TypeOrmModule.forFeature([LeagueOrmEntity, SeasonOrmEntity, RaceOrmEntity, LeagueScoringConfigOrmEntity]), +]; + +@Module({ + imports: [LoggingModule, ...typeOrmFeatureImports], + providers: [ + { + provide: DRIVER_REPOSITORY_TOKEN, + useFactory: () => makePlaceholder(DRIVER_REPOSITORY_TOKEN), + inject: ['Logger'], + }, + { + provide: LEAGUE_REPOSITORY_TOKEN, + useFactory: (dataSource: DataSource) => { + const leagueMapper = new LeagueOrmMapper(); + return new TypeOrmLeagueRepository(dataSource, leagueMapper); + }, + inject: [getDataSourceToken()], + }, + { + provide: RACE_REPOSITORY_TOKEN, + useFactory: (dataSource: DataSource) => { + const raceMapper = new RaceOrmMapper(); + return new TypeOrmRaceRepository(dataSource, raceMapper); + }, + inject: [getDataSourceToken()], + }, + { + provide: RESULT_REPOSITORY_TOKEN, + useFactory: () => makePlaceholder(RESULT_REPOSITORY_TOKEN), + inject: ['Logger'], + }, + { + provide: STANDING_REPOSITORY_TOKEN, + useFactory: () => makePlaceholder(STANDING_REPOSITORY_TOKEN), + inject: ['Logger'], + }, + { + provide: LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN, + useFactory: () => makePlaceholder(LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN), + inject: ['Logger'], + }, + { + provide: RACE_REGISTRATION_REPOSITORY_TOKEN, + useFactory: () => makePlaceholder(RACE_REGISTRATION_REPOSITORY_TOKEN), + inject: ['Logger'], + }, + { + provide: TEAM_REPOSITORY_TOKEN, + useFactory: () => makePlaceholder(TEAM_REPOSITORY_TOKEN), + inject: ['Logger'], + }, + { + provide: TEAM_MEMBERSHIP_REPOSITORY_TOKEN, + useFactory: () => makePlaceholder(TEAM_MEMBERSHIP_REPOSITORY_TOKEN), + inject: ['Logger'], + }, + { + provide: PENALTY_REPOSITORY_TOKEN, + useFactory: () => makePlaceholder(PENALTY_REPOSITORY_TOKEN), + inject: ['Logger'], + }, + { + provide: PROTEST_REPOSITORY_TOKEN, + useFactory: () => makePlaceholder(PROTEST_REPOSITORY_TOKEN), + inject: ['Logger'], + }, + { + provide: SEASON_REPOSITORY_TOKEN, + useFactory: (dataSource: DataSource) => { + const seasonMapper = new SeasonOrmMapper(); + return new TypeOrmSeasonRepository(dataSource, seasonMapper); + }, + inject: [getDataSourceToken()], + }, + { + provide: SEASON_SPONSORSHIP_REPOSITORY_TOKEN, + useFactory: () => makePlaceholder(SEASON_SPONSORSHIP_REPOSITORY_TOKEN), + inject: ['Logger'], + }, + { + provide: LEAGUE_SCORING_CONFIG_REPOSITORY_TOKEN, + useFactory: (dataSource: DataSource) => { + const pointsTableMapper = new PointsTableJsonMapper(); + const championshipMapper = new ChampionshipConfigJsonMapper(pointsTableMapper); + const scoringConfigMapper = new LeagueScoringConfigOrmMapper(championshipMapper); + return new TypeOrmLeagueScoringConfigRepository(dataSource, scoringConfigMapper); + }, + inject: [getDataSourceToken()], + }, + { + provide: GAME_REPOSITORY_TOKEN, + useFactory: () => makePlaceholder(GAME_REPOSITORY_TOKEN), + inject: ['Logger'], + }, + { + provide: LEAGUE_WALLET_REPOSITORY_TOKEN, + useFactory: () => makePlaceholder(LEAGUE_WALLET_REPOSITORY_TOKEN), + inject: ['Logger'], + }, + { + provide: TRANSACTION_REPOSITORY_TOKEN, + useFactory: () => makePlaceholder(TRANSACTION_REPOSITORY_TOKEN), + inject: ['Logger'], + }, + { + provide: SPONSOR_REPOSITORY_TOKEN, + useFactory: () => makePlaceholder(SPONSOR_REPOSITORY_TOKEN), + inject: ['Logger'], + }, + { + provide: SPONSORSHIP_PRICING_REPOSITORY_TOKEN, + useFactory: () => makePlaceholder(SPONSORSHIP_PRICING_REPOSITORY_TOKEN), + inject: ['Logger'], + }, + { + provide: SPONSORSHIP_REQUEST_REPOSITORY_TOKEN, + useFactory: () => makePlaceholder(SPONSORSHIP_REQUEST_REPOSITORY_TOKEN), + inject: ['Logger'], + }, + ], + exports: [ + DRIVER_REPOSITORY_TOKEN, + LEAGUE_REPOSITORY_TOKEN, + RACE_REPOSITORY_TOKEN, + RESULT_REPOSITORY_TOKEN, + STANDING_REPOSITORY_TOKEN, + LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN, + RACE_REGISTRATION_REPOSITORY_TOKEN, + TEAM_REPOSITORY_TOKEN, + TEAM_MEMBERSHIP_REPOSITORY_TOKEN, + PENALTY_REPOSITORY_TOKEN, + PROTEST_REPOSITORY_TOKEN, + SEASON_REPOSITORY_TOKEN, + SEASON_SPONSORSHIP_REPOSITORY_TOKEN, + LEAGUE_SCORING_CONFIG_REPOSITORY_TOKEN, + GAME_REPOSITORY_TOKEN, + LEAGUE_WALLET_REPOSITORY_TOKEN, + TRANSACTION_REPOSITORY_TOKEN, + SPONSOR_REPOSITORY_TOKEN, + SPONSORSHIP_PRICING_REPOSITORY_TOKEN, + SPONSORSHIP_REQUEST_REPOSITORY_TOKEN, + ], +}) +export class PostgresRacingPersistenceModule {} \ No newline at end of file diff --git a/apps/api/src/persistence/postgres/typeorm/PostgresLeagueScheduleRepositorySlice.int.test.ts b/apps/api/src/persistence/postgres/typeorm/PostgresLeagueScheduleRepositorySlice.int.test.ts new file mode 100644 index 000000000..c9cd25bec --- /dev/null +++ b/apps/api/src/persistence/postgres/typeorm/PostgresLeagueScheduleRepositorySlice.int.test.ts @@ -0,0 +1,158 @@ +import 'reflect-metadata'; + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { DataSource } from 'typeorm'; + +import { League } from '@core/racing/domain/entities/League'; +import { LeagueScoringConfig } from '@core/racing/domain/entities/LeagueScoringConfig'; +import { Race } from '@core/racing/domain/entities/Race'; +import { Season } from '@core/racing/domain/entities/season/Season'; +import { PointsTable } from '@core/racing/domain/value-objects/PointsTable'; + +import type { ChampionshipConfig } from '@core/racing/domain/types/ChampionshipConfig'; +import type { SessionType } from '@core/racing/domain/types/SessionType'; + +import { LeagueOrmEntity } from '../../../../../../adapters/racing/persistence/typeorm/entities/LeagueOrmEntity'; +import { LeagueScoringConfigOrmEntity } from '../../../../../../adapters/racing/persistence/typeorm/entities/LeagueScoringConfigOrmEntity'; +import { RaceOrmEntity } from '../../../../../../adapters/racing/persistence/typeorm/entities/RaceOrmEntity'; +import { SeasonOrmEntity } from '../../../../../../adapters/racing/persistence/typeorm/entities/SeasonOrmEntity'; + +import { TypeOrmLeagueRepository } from '../../../../../../adapters/racing/persistence/typeorm/repositories/TypeOrmLeagueRepository'; +import { TypeOrmLeagueScoringConfigRepository } from '../../../../../../adapters/racing/persistence/typeorm/repositories/TypeOrmLeagueScoringConfigRepository'; +import { TypeOrmRaceRepository } from '../../../../../../adapters/racing/persistence/typeorm/repositories/TypeOrmRaceRepository'; +import { TypeOrmSeasonRepository } from '../../../../../../adapters/racing/persistence/typeorm/repositories/TypeOrmSeasonRepository'; +import { LeagueOrmMapper } from '../../../../../../adapters/racing/persistence/typeorm/mappers/LeagueOrmMapper'; +import { RaceOrmMapper } from '../../../../../../adapters/racing/persistence/typeorm/mappers/RaceOrmMapper'; +import { SeasonOrmMapper } from '../../../../../../adapters/racing/persistence/typeorm/mappers/SeasonOrmMapper'; +import { PointsTableJsonMapper } from '../../../../../../adapters/racing/persistence/typeorm/mappers/PointsTableJsonMapper'; +import { ChampionshipConfigJsonMapper } from '../../../../../../adapters/racing/persistence/typeorm/mappers/ChampionshipConfigJsonMapper'; +import { LeagueScoringConfigOrmMapper } from '../../../../../../adapters/racing/persistence/typeorm/mappers/LeagueScoringConfigOrmMapper'; + +const databaseUrl = process.env.DATABASE_URL; +const describeIfDatabase = databaseUrl ? describe : describe.skip; + +describeIfDatabase('TypeORM Racing repositories (postgres slice)', () => { + let dataSource: DataSource; + + beforeAll(async () => { + if (!databaseUrl) { + throw new Error('DATABASE_URL is required to run postgres integration tests'); + } + + dataSource = new DataSource({ + type: 'postgres', + url: databaseUrl, + entities: [LeagueOrmEntity, SeasonOrmEntity, RaceOrmEntity, LeagueScoringConfigOrmEntity], + synchronize: true, + }); + + await dataSource.initialize(); + }); + + afterAll(async () => { + if (dataSource?.isInitialized) { + await dataSource.destroy(); + } + }); + + it('supports: create league + create season + save scoring config + fetch schedule', async () => { + const leagueRepo = new TypeOrmLeagueRepository(dataSource, new LeagueOrmMapper()); + const seasonRepo = new TypeOrmSeasonRepository(dataSource, new SeasonOrmMapper()); + const raceRepo = new TypeOrmRaceRepository(dataSource, new RaceOrmMapper()); + + const pointsTableMapper = new PointsTableJsonMapper(); + const championshipMapper = new ChampionshipConfigJsonMapper(pointsTableMapper); + const scoringConfigMapper = new LeagueScoringConfigOrmMapper(championshipMapper); + const scoringRepo = new TypeOrmLeagueScoringConfigRepository(dataSource, scoringConfigMapper); + + const league = League.create({ + id: 'league-it-1', + name: 'Integration League', + description: 'For integration testing', + ownerId: 'driver-it-1', + settings: { pointsSystem: 'custom', visibility: 'unranked', maxDrivers: 32 }, + participantCount: 0, + }); + + await leagueRepo.create(league); + + const season = Season.create({ + id: 'season-it-1', + leagueId: league.id.toString(), + gameId: 'iracing', + name: 'Integration Season', + year: 2025, + order: 1, + status: 'active', + startDate: new Date('2025-01-01T00:00:00.000Z'), + endDate: new Date('2025-12-31T00:00:00.000Z'), + schedulePublished: false, + }); + + await seasonRepo.create(season); + + const pointsTableBySessionType: Record = { + practice: new PointsTable({}), + qualifying: new PointsTable({}), + q1: new PointsTable({}), + q2: new PointsTable({}), + q3: new PointsTable({}), + sprint: new PointsTable({}), + main: new PointsTable({ 1: 25 }), + timeTrial: new PointsTable({}), + }; + + const bonusRulesBySessionType = { + practice: [], + qualifying: [], + q1: [], + q2: [], + q3: [], + sprint: [], + main: [], + timeTrial: [], + }; + + const championship: ChampionshipConfig = { + id: 'champ-it-1', + name: 'Driver Championship', + type: 'driver', + sessionTypes: ['main' as SessionType], + pointsTableBySessionType, + bonusRulesBySessionType, + dropScorePolicy: { strategy: 'none' }, + }; + + const scoring = LeagueScoringConfig.create({ + id: 'lsc-it-1', + seasonId: season.id, + scoringPresetId: 'club-default', + championships: [championship], + }); + + await scoringRepo.save(scoring); + + const race = Race.create({ + id: 'race-it-1', + leagueId: league.id.toString(), + scheduledAt: new Date('2025-03-01T12:00:00.000Z'), + track: 'Spa', + car: 'GT3', + status: 'scheduled', + }); + + await raceRepo.create(race); + + const persistedLeague = await leagueRepo.findById(league.id.toString()); + expect(persistedLeague?.name.toString()).toBe('Integration League'); + + const seasons = await seasonRepo.findByLeagueId(league.id.toString()); + expect(seasons.map((s: Season) => s.id)).toContain('season-it-1'); + + const races = await raceRepo.findByLeagueId(league.id.toString()); + expect(races.map((r: Race) => r.id)).toContain('race-it-1'); + + const persistedScoring = await scoringRepo.findBySeasonId(season.id); + expect(persistedScoring?.id.toString()).toBe('lsc-it-1'); + }); +}); \ No newline at end of file diff --git a/apps/api/src/persistence/postgres/typeorm/RacingOrmMappers.test.ts b/apps/api/src/persistence/postgres/typeorm/RacingOrmMappers.test.ts new file mode 100644 index 000000000..d9ac91c98 --- /dev/null +++ b/apps/api/src/persistence/postgres/typeorm/RacingOrmMappers.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it } from 'vitest'; + +import { League } from '@core/racing/domain/entities/League'; +import { LeagueScoringConfig } from '@core/racing/domain/entities/LeagueScoringConfig'; +import { Race } from '@core/racing/domain/entities/Race'; +import { Season } from '@core/racing/domain/entities/season/Season'; +import { PointsTable } from '@core/racing/domain/value-objects/PointsTable'; +import { SessionType as RaceSessionType } from '@core/racing/domain/value-objects/SessionType'; + +import type { ChampionshipConfig } from '@core/racing/domain/types/ChampionshipConfig'; +import type { SessionType } from '@core/racing/domain/types/SessionType'; + +import { ChampionshipConfigJsonMapper } from '@adapters/racing/persistence/typeorm/mappers/ChampionshipConfigJsonMapper'; +import { LeagueOrmMapper } from '@adapters/racing/persistence/typeorm/mappers/LeagueOrmMapper'; +import { LeagueScoringConfigOrmMapper } from '@adapters/racing/persistence/typeorm/mappers/LeagueScoringConfigOrmMapper'; +import { PointsTableJsonMapper } from '@adapters/racing/persistence/typeorm/mappers/PointsTableJsonMapper'; +import { RaceOrmMapper } from '@adapters/racing/persistence/typeorm/mappers/RaceOrmMapper'; +import { SeasonOrmMapper } from '@adapters/racing/persistence/typeorm/mappers/SeasonOrmMapper'; + +describe('RacingOrmMappers', () => { + it('maps League domain <-> orm', () => { + const league = League.create({ + id: 'league-1', + name: 'My League', + description: 'A description', + ownerId: 'driver-1', + settings: { + pointsSystem: 'custom', + maxDrivers: 48, + visibility: 'unranked', + }, + socialLinks: { + discordUrl: 'https://discord.gg/example', + }, + participantCount: 12, + createdAt: new Date('2025-01-01T00:00:00.000Z'), + }); + + const mapper = new LeagueOrmMapper(); + const orm = mapper.toOrmEntity(league); + const rehydrated = mapper.toDomain(orm); + + expect(rehydrated.id.toString()).toBe('league-1'); + expect(rehydrated.name.toString()).toBe('My League'); + expect(rehydrated.description.toString()).toBe('A description'); + expect(rehydrated.ownerId.toString()).toBe('driver-1'); + expect(rehydrated.settings.maxDrivers).toBe(48); + expect(rehydrated.settings.visibility).toBe('unranked'); + expect(rehydrated.getParticipantCount()).toBe(12); + expect(rehydrated.socialLinks?.discordUrl).toBe('https://discord.gg/example'); + }); + + it('maps Season domain <-> orm', () => { + const season = Season.create({ + id: 'season-1', + leagueId: 'league-1', + gameId: 'iracing', + name: 'Season 1', + year: 2025, + order: 1, + status: 'active', + startDate: new Date('2025-01-01T00:00:00.000Z'), + endDate: new Date('2025-02-01T00:00:00.000Z'), + schedulePublished: true, + participantCount: 7, + maxDrivers: 32, + }); + + const mapper = new SeasonOrmMapper(); + const orm = mapper.toOrmEntity(season); + const rehydrated = mapper.toDomain(orm); + + expect(rehydrated.id).toBe('season-1'); + expect(rehydrated.leagueId).toBe('league-1'); + expect(rehydrated.gameId).toBe('iracing'); + expect(rehydrated.name).toBe('Season 1'); + expect(rehydrated.status.toString()).toBe('active'); + expect(rehydrated.schedulePublished).toBe(true); + expect(rehydrated.getParticipantCount()).toBe(7); + }); + + it('maps Race domain <-> orm', () => { + const race = Race.create({ + id: 'race-1', + leagueId: 'league-1', + scheduledAt: new Date('2025-01-10T12:00:00.000Z'), + track: 'Spa', + car: 'GT3', + sessionType: RaceSessionType.main(), + status: 'scheduled', + registeredCount: 3, + maxParticipants: 50, + }); + + const mapper = new RaceOrmMapper(); + const orm = mapper.toOrmEntity(race); + const rehydrated = mapper.toDomain(orm); + + expect(rehydrated.id).toBe('race-1'); + expect(rehydrated.leagueId).toBe('league-1'); + expect(rehydrated.track).toBe('Spa'); + expect(rehydrated.car).toBe('GT3'); + expect(rehydrated.sessionType.props).toBe('main'); + expect(rehydrated.status.toString()).toBe('scheduled'); + expect(rehydrated.getRegisteredCount()).toBe(3); + expect(rehydrated.getMaxParticipants()).toBe(50); + }); + + it('maps LeagueScoringConfig domain <-> orm (including PointsTable)', () => { + const pointsTableBySessionType: Record = { + practice: new PointsTable({}), + qualifying: new PointsTable({}), + q1: new PointsTable({}), + q2: new PointsTable({}), + q3: new PointsTable({}), + sprint: new PointsTable({}), + main: new PointsTable({ 1: 25, 2: 18 }), + timeTrial: new PointsTable({}), + }; + + const bonusRulesBySessionType = { + practice: [], + qualifying: [], + q1: [], + q2: [], + q3: [], + sprint: [], + main: [], + timeTrial: [], + }; + + const championship: ChampionshipConfig = { + id: 'champ-1', + name: 'Driver Championship', + type: 'driver', + sessionTypes: ['main' as SessionType], + pointsTableBySessionType, + bonusRulesBySessionType, + dropScorePolicy: { strategy: 'none' }, + }; + + const config = LeagueScoringConfig.create({ + id: 'lsc-season-1', + seasonId: 'season-1', + scoringPresetId: 'club-default', + championships: [championship], + }); + + const pointsTableMapper = new PointsTableJsonMapper(); + const championshipMapper = new ChampionshipConfigJsonMapper(pointsTableMapper); + const mapper = new LeagueScoringConfigOrmMapper(championshipMapper); + + const orm = mapper.toOrmEntity(config); + const rehydrated = mapper.toDomain(orm); + + expect(rehydrated.id.toString()).toBe('lsc-season-1'); + expect(rehydrated.seasonId.toString()).toBe('season-1'); + expect(rehydrated.scoringPresetId?.toString()).toBe('club-default'); + expect(rehydrated.championships).toHaveLength(1); + + const mainPointsTable = + rehydrated.championships[0]?.pointsTableBySessionType['main' as SessionType]; + expect(mainPointsTable).toBeDefined(); + expect(mainPointsTable!.getPointsForPosition(1)).toBe(25); + }); +}); \ No newline at end of file diff --git a/apps/api/src/persistence/racing/RacingPersistenceModule.test.ts b/apps/api/src/persistence/racing/RacingPersistenceModule.test.ts new file mode 100644 index 000000000..21d085624 --- /dev/null +++ b/apps/api/src/persistence/racing/RacingPersistenceModule.test.ts @@ -0,0 +1,49 @@ +import 'reflect-metadata'; + +import { MODULE_METADATA } from '@nestjs/common/constants'; +import { Test } from '@nestjs/testing'; +import type { TestingModule } from '@nestjs/testing'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { LEAGUE_REPOSITORY_TOKEN } from '../inmemory/InMemoryRacingPersistenceModule'; + +describe('RacingPersistenceModule', () => { + const originalEnv = { ...process.env }; + + afterEach(() => { + process.env = originalEnv; + vi.restoreAllMocks(); + }); + + it('uses inmemory providers when GRIDPILOT_API_PERSISTENCE=inmemory', async () => { + vi.resetModules(); + + process.env.GRIDPILOT_API_PERSISTENCE = 'inmemory'; + delete process.env.DATABASE_URL; + + const { RacingPersistenceModule } = await import('./RacingPersistenceModule'); + const { InMemoryLeagueRepository } = await import('@adapters/racing/persistence/inmemory/InMemoryLeagueRepository'); + + const module: TestingModule = await Test.createTestingModule({ + imports: [RacingPersistenceModule], + }).compile(); + + const leagueRepo = module.get(LEAGUE_REPOSITORY_TOKEN); + expect(leagueRepo).toBeInstanceOf(InMemoryLeagueRepository); + + await module.close(); + }); + + it('uses postgres module when GRIDPILOT_API_PERSISTENCE=postgres', async () => { + vi.resetModules(); + + process.env.GRIDPILOT_API_PERSISTENCE = 'postgres'; + delete process.env.DATABASE_URL; + + const { RacingPersistenceModule } = await import('./RacingPersistenceModule'); + const { PostgresRacingPersistenceModule } = await import('../postgres/PostgresRacingPersistenceModule'); + + const imports = Reflect.getMetadata(MODULE_METADATA.IMPORTS, RacingPersistenceModule) as unknown[]; + expect(imports).toContain(PostgresRacingPersistenceModule); + }); +}); \ No newline at end of file diff --git a/apps/api/src/persistence/racing/RacingPersistenceModule.ts b/apps/api/src/persistence/racing/RacingPersistenceModule.ts new file mode 100644 index 000000000..e27654e0b --- /dev/null +++ b/apps/api/src/persistence/racing/RacingPersistenceModule.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; + +import { getApiPersistence } from '../../env'; +import { InMemoryRacingPersistenceModule } from '../inmemory/InMemoryRacingPersistenceModule'; +import { PostgresRacingPersistenceModule } from '../postgres/PostgresRacingPersistenceModule'; + +const selectedPersistenceModule = + getApiPersistence() === 'postgres' ? PostgresRacingPersistenceModule : InMemoryRacingPersistenceModule; + +@Module({ + imports: [selectedPersistenceModule], + exports: [selectedPersistenceModule], +}) +export class RacingPersistenceModule {} \ No newline at end of file diff --git a/core/racing/domain/entities/League.ts b/core/racing/domain/entities/League.ts index 66a9a0308..4e7128969 100644 --- a/core/racing/domain/entities/League.ts +++ b/core/racing/domain/entities/League.ts @@ -252,6 +252,47 @@ export class League implements IEntity { }); } + static rehydrate(props: { + id: string; + name: string; + description: string; + ownerId: string; + settings: LeagueSettings; + createdAt: Date; + participantCount: number; + socialLinks?: { + discordUrl?: string; + youtubeUrl?: string; + websiteUrl?: string; + }; + }): League { + const id = LeagueId.create(props.id); + const name = LeagueName.create(props.name); + const description = LeagueDescription.create(props.description); + const ownerId = LeagueOwnerId.create(props.ownerId); + const createdAt = LeagueCreatedAt.create(props.createdAt); + + const visibilityType = props.settings.visibility ?? 'ranked'; + const visibility = LeagueVisibility.fromString(visibilityType); + + const participantCount = ParticipantCount.create(props.participantCount); + const socialLinks = props.socialLinks + ? LeagueSocialLinks.create(props.socialLinks) + : undefined; + + return new League({ + id, + name, + description, + ownerId, + settings: props.settings, + createdAt, + ...(socialLinks !== undefined ? { socialLinks } : {}), + participantCount, + visibility, + }); + } + /** * Validate stewarding settings configuration */ diff --git a/core/racing/domain/entities/LeagueScoringConfig.ts b/core/racing/domain/entities/LeagueScoringConfig.ts index 77631f48f..b2cca0727 100644 --- a/core/racing/domain/entities/LeagueScoringConfig.ts +++ b/core/racing/domain/entities/LeagueScoringConfig.ts @@ -18,6 +18,13 @@ export interface LeagueScoringConfigProps { championships: ChampionshipConfig[]; } +export interface LeagueScoringConfigRehydrateProps { + id: string; + seasonId: string; + scoringPresetId?: string; + championships: ChampionshipConfig[]; +} + export class LeagueScoringConfig implements IEntity { readonly id: LeagueScoringConfigId; readonly seasonId: SeasonId; @@ -53,6 +60,25 @@ export class LeagueScoringConfig implements IEntity { }); } + static rehydrate(props: LeagueScoringConfigRehydrateProps): LeagueScoringConfig { + this.validate(props); + + if (!props.id || props.id.trim().length === 0) { + throw new RacingDomainValidationError('Scoring config ID is required'); + } + + const id = LeagueScoringConfigId.create(props.id); + const seasonId = SeasonId.create(props.seasonId); + const scoringPresetId = props.scoringPresetId ? ScoringPresetId.create(props.scoringPresetId) : undefined; + + return new LeagueScoringConfig({ + id, + seasonId, + ...(scoringPresetId ? { scoringPresetId } : {}), + championships: props.championships, + }); + } + private static validate(props: LeagueScoringConfigProps): void { if (!props.seasonId || props.seasonId.trim().length === 0) { throw new RacingDomainValidationError('Season ID is required'); diff --git a/core/racing/domain/entities/Race.ts b/core/racing/domain/entities/Race.ts index dd5015165..4c7a1cdca 100644 --- a/core/racing/domain/entities/Race.ts +++ b/core/racing/domain/entities/Race.ts @@ -157,6 +157,58 @@ export class Race implements IEntity { }); } + static rehydrate(props: { + id: string; + leagueId: string; + scheduledAt: Date; + track: string; + trackId?: string; + car: string; + carId?: string; + sessionType: SessionType; + status: RaceStatus; + strengthOfField?: number; + registeredCount?: number; + maxParticipants?: number; + }): Race { + let registeredCount: ParticipantCount | undefined; + let maxParticipants: MaxParticipants | undefined; + + if (props.registeredCount !== undefined) { + registeredCount = ParticipantCount.create(props.registeredCount); + } + + if (props.maxParticipants !== undefined) { + maxParticipants = MaxParticipants.create(props.maxParticipants); + + if (registeredCount && !maxParticipants.canAccommodate(registeredCount.toNumber())) { + throw new RacingDomainValidationError( + `Registered count (${registeredCount.toNumber()}) exceeds max participants (${maxParticipants.toNumber()})`, + ); + } + } + + let strengthOfField: StrengthOfField | undefined; + if (props.strengthOfField !== undefined) { + strengthOfField = StrengthOfField.create(props.strengthOfField); + } + + return new Race({ + id: props.id, + leagueId: props.leagueId, + scheduledAt: props.scheduledAt, + track: props.track, + ...(props.trackId !== undefined ? { trackId: props.trackId } : {}), + car: props.car, + ...(props.carId !== undefined ? { carId: props.carId } : {}), + sessionType: props.sessionType, + status: props.status, + ...(strengthOfField !== undefined ? { strengthOfField } : {}), + ...(registeredCount !== undefined ? { registeredCount } : {}), + ...(maxParticipants !== undefined ? { maxParticipants } : {}), + }); + } + /** * Start the race (move from scheduled to running) */ diff --git a/core/racing/domain/entities/season/Season.ts b/core/racing/domain/entities/season/Season.ts index bb5630417..0d6cab80a 100644 --- a/core/racing/domain/entities/season/Season.ts +++ b/core/racing/domain/entities/season/Season.ts @@ -175,6 +175,55 @@ export class Season implements IEntity { }); } + static rehydrate(props: { + id: string; + leagueId: string; + gameId: string; + name: string; + year?: number; + order?: number; + status: SeasonStatus; + startDate?: Date; + endDate?: Date; + schedule?: SeasonSchedule; + schedulePublished: boolean; + scoringConfig?: SeasonScoringConfig; + dropPolicy?: SeasonDropPolicy; + stewardingConfig?: SeasonStewardingConfig; + maxDrivers?: number; + participantCount: number; + }): Season { + const participantCount = ParticipantCount.create(props.participantCount); + + if (props.maxDrivers !== undefined) { + const maxParticipants = MaxParticipants.create(props.maxDrivers); + if (!maxParticipants.canAccommodate(participantCount.toNumber())) { + throw new RacingDomainValidationError( + `Participant count (${participantCount.toNumber()}) exceeds season capacity (${maxParticipants.toNumber()})`, + ); + } + } + + return new Season({ + id: props.id, + leagueId: props.leagueId, + gameId: props.gameId, + name: props.name, + ...(props.year !== undefined ? { year: props.year } : {}), + ...(props.order !== undefined ? { order: props.order } : {}), + status: props.status, + ...(props.startDate !== undefined ? { startDate: props.startDate } : {}), + ...(props.endDate !== undefined ? { endDate: props.endDate } : {}), + ...(props.schedule !== undefined ? { schedule: props.schedule } : {}), + schedulePublished: props.schedulePublished, + ...(props.scoringConfig !== undefined ? { scoringConfig: props.scoringConfig } : {}), + ...(props.dropPolicy !== undefined ? { dropPolicy: props.dropPolicy } : {}), + ...(props.stewardingConfig !== undefined ? { stewardingConfig: props.stewardingConfig } : {}), + ...(props.maxDrivers !== undefined ? { maxDrivers: props.maxDrivers } : {}), + participantCount, + }); + } + /** * Validate stewarding configuration */ diff --git a/core/racing/domain/repositories/ILeagueRepository.ts b/core/racing/domain/repositories/ILeagueRepository.ts index 265e10fc7..f3453c468 100644 --- a/core/racing/domain/repositories/ILeagueRepository.ts +++ b/core/racing/domain/repositories/ILeagueRepository.ts @@ -18,6 +18,13 @@ export interface ILeagueRepository { */ findAll(): Promise; + /** + * Count all leagues. + * + * Optional to avoid forcing all existing test doubles to implement it. + */ + countAll?(): Promise; + /** * Find leagues by owner ID */ diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index e4444223d..6ea90b715 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -33,7 +33,7 @@ services: - .env.development environment: - NODE_ENV=development - - GRIDPILOT_API_PERSISTENCE=inmemory + - GRIDPILOT_API_PERSISTENCE=${GRIDPILOT_API_PERSISTENCE:-} ports: - "3001:3000" - "9229:9229" diff --git a/docs/SCHEMA_STRATEGY.md b/docs/SCHEMA_STRATEGY.md new file mode 100644 index 000000000..94cb914a3 --- /dev/null +++ b/docs/SCHEMA_STRATEGY.md @@ -0,0 +1,50 @@ +# Schema strategy (dev) and persistence switching +## Goal +Keep the core domain independent from persistence details, while still providing a fast dev loop. Persistence and schema behavior are configured at the application boundary (the API app), not inside the domain. + +## Persistence modes (API runtime) +The API supports two persistence modes, controlled by [`ProcessEnv.GRIDPILOT_API_PERSISTENCE`](apps/api/src/env.d.ts:7): + +- **`inmemory`**: no database required; the API runs with in-memory adapters. +- **`postgres`**: the API uses Postgres via TypeORM. + +### Default inference (dev ergonomics) +If `GRIDPILOT_API_PERSISTENCE` is **unset**, the API infers persistence from `DATABASE_URL` via [`getApiPersistence()`](apps/api/src/env.ts:33): + +- If `DATABASE_URL` is set → `postgres` +- Otherwise → `inmemory` + +This is why dev compose should not hard-code `GRIDPILOT_API_PERSISTENCE`; it should allow inference unless explicitly overridden. + +## Schema strategy in development (TypeORM `synchronize`) +When the API runs in Postgres mode, it loads [`DatabaseModule`](apps/api/src/domain/database/DatabaseModule.ts:1), which configures TypeORM with: + +- `synchronize: process.env.NODE_ENV !== 'production'` in [`TypeOrmModule.forRoot()`](apps/api/src/domain/database/DatabaseModule.ts:6) + +Practical meaning: +- **Development/test:** TypeORM `synchronize` is enabled to keep schema aligned automatically during iteration. +- **Production:** TypeORM `synchronize` is disabled. + +### Migrations (deferred) +Migrations are intentionally deferred for now: local dev relies on `synchronize` for speed, while production-grade migrations will be introduced later at the infrastructure boundary (without changing the domain). + +## Switching modes locally +### Docker dev (recommended) +In [`docker-compose.dev.yml`](docker-compose.dev.yml:1), `GRIDPILOT_API_PERSISTENCE` should be optional so the default inference works. + +To force a mode, set one of: +- `GRIDPILOT_API_PERSISTENCE=inmemory` +- `GRIDPILOT_API_PERSISTENCE=postgres` + +Example environment reference (dev): [`DATABASE_URL`](.env.development.example:20) and optional persistence override (commented) in [`.env.development.example`](.env.development.example:17). + +## Dev seeding behavior (high level) +Seeding/bootstrap runs at API startup via [`BootstrapModule`](apps/api/src/domain/bootstrap/BootstrapModule.ts:1), unless disabled with `GRIDPILOT_API_BOOTSTRAP=0` (parsed by [`getEnableBootstrap()`](apps/api/src/env.ts:49)). + +At startup: +- Always runs `EnsureInitialData.execute()` in [`BootstrapModule.onModuleInit()`](apps/api/src/domain/bootstrap/BootstrapModule.ts:21). +- Seeds racing data via `SeedRacingData` when: + - persistence is `inmemory`, or + - persistence is `postgres` AND `NODE_ENV !== 'production'` AND the racing DB appears empty (checked via the league repository) in [`BootstrapModule.shouldSeedRacingData()`](apps/api/src/domain/bootstrap/BootstrapModule.ts:42). + +This keeps dev environments usable without requiring manual seed steps, while avoiding unexpected reseeding in production. \ No newline at end of file diff --git a/plans/2025-12-28T20:17:49Z_switch-inmemory-to-postgres-typeorm.md b/plans/2025-12-28T20:17:49Z_switch-inmemory-to-postgres-typeorm.md new file mode 100644 index 000000000..22eb1b144 --- /dev/null +++ b/plans/2025-12-28T20:17:49Z_switch-inmemory-to-postgres-typeorm.md @@ -0,0 +1,125 @@ +# Plan: Switch API persistence from InMemory to Postgres (TypeORM), keep InMemory for tests + +Timestamp: 2025-12-28T20:17:49Z +Scope: **Racing bounded context first**, dev switchable between InMemory and Postgres, tests keep forcing InMemory. + +## Goals +- Make it **easy to switch** API persistence in dev via [`getApiPersistence()`](apps/api/src/env.ts:33) + [`process.env.GRIDPILOT_API_PERSISTENCE`](apps/api/src/env.d.ts:7). +- Default dev flow supports Postgres via Docker, but does **not** force it. +- Keep InMemory persistence intact and default for tests (already used in tests like [`process.env.GRIDPILOT_API_PERSISTENCE = 'inmemory'`](apps/api/src/domain/bootstrap/BootstrapSeed.http.test.ts:17)). +- Implement Postgres/TypeORM persistence for **racing repositories** currently provided by [`InMemoryRacingPersistenceModule`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:72). +- Provide **minimal idempotent seed** for Postgres so UI works (analogous to [`SeedRacingData`](apps/api/src/domain/bootstrap/BootstrapModule.ts:27)). + +## Current state (what’s wired today) +- Env toggle exists: [`getApiPersistence()`](apps/api/src/env.ts:33). +- Postgres wiring exists but incomplete: [`TypeOrmModule.forRoot()`](apps/api/src/domain/database/DatabaseModule.ts:6) with `entities` commented and `synchronize` enabled outside production ([`synchronize`](apps/api/src/domain/database/DatabaseModule.ts:18)). +- Feature modules still hard-import in-memory racing persistence: + - [`LeagueModule`](apps/api/src/domain/league/LeagueModule.ts:8) + - [`RaceModule`](apps/api/src/domain/race/RaceModule.ts:8) + - [`DashboardModule`](apps/api/src/domain/dashboard/DashboardModule.ts:2) + - [`DriverModule`](apps/api/src/domain/driver/DriverModule.ts:2) + - [`ProtestsModule`](apps/api/src/domain/protests/ProtestsModule.ts:2) + - [`TeamModule`](apps/api/src/domain/team/TeamModule.ts:2) + - [`SponsorModule`](apps/api/src/domain/sponsor/SponsorModule.ts:2) + - [`BootstrapModule`](apps/api/src/domain/bootstrap/BootstrapModule.ts:11) +- Dev compose currently forces InMemory even when `.env` provides `DATABASE_URL`: + - Forced: [`GRIDPILOT_API_PERSISTENCE=inmemory`](docker-compose.dev.yml:36) + - `.env` hints inference from `DATABASE_URL`: [`DATABASE_URL=postgres://...`](.env.development.example:20) + +## High-level approach +1. Introduce a **persistence boundary module** for racing that selects implementation based on [`getApiPersistence()`](apps/api/src/env.ts:33). +2. Implement a Postgres/TypeORM module for racing repos (same tokens as in-memory). +3. Update racing-dependent API feature modules to import the boundary module (not the in-memory module). +4. Add minimal Postgres seed (dev-only, idempotent). +5. Fix dev compose to not hard-force InMemory. +6. Verify with lint/types/tests. + +## Milestones (execution order) +### M1 — Create racing persistence boundary (switch point) +- Add [`RacingPersistenceModule`](apps/api/src/persistence/racing/RacingPersistenceModule.ts:1) + - `imports`: choose one of: + - [`InMemoryRacingPersistenceModule`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:72) + - New [`PostgresRacingPersistenceModule`](apps/api/src/persistence/postgres/PostgresRacingPersistenceModule.ts:1) + - `exports`: re-export the chosen module’s tokens, so downstream modules remain unchanged. + +Acceptance: +- No feature module directly imports [`InMemoryRacingPersistenceModule`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:72) anymore. + +### M2 — Add Postgres racing persistence module (skeleton) +- Add [`PostgresRacingPersistenceModule`](apps/api/src/persistence/postgres/PostgresRacingPersistenceModule.ts:1) + - Provides **the same tokens** defined in [`InMemoryRacingPersistenceModule`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:51): + - [`LEAGUE_REPOSITORY_TOKEN`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:52), [`RACE_REPOSITORY_TOKEN`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:53), etc. + - Uses `TypeOrmModule.forFeature([...entities])`. + - Must be compatible with DB root config in [`DatabaseModule`](apps/api/src/domain/database/DatabaseModule.ts:6). + +Acceptance: +- API can start with `GRIDPILOT_API_PERSISTENCE=postgres` and resolve racing repository providers (even if repo methods are initially stubbed during iteration). + +### M3 — Implement first working slice (League + Season + Membership + Race as needed) +- Implement ORM entities + mappers (ORM entities are not domain objects; follow clean architecture boundary from [`DOMAIN_OBJECTS.md`](docs/architecture/DOMAIN_OBJECTS.md:16)). +- Implement TypeORM repositories for the minimal feature set used by: + - [`LeagueModule`](apps/api/src/domain/league/LeagueModule.ts:7) + - [`RaceModule`](apps/api/src/domain/race/RaceModule.ts:7) + +Strategy: +- Start from the endpoints exercised by existing HTTP tests that currently force InMemory (e.g. league schedule/roster tests), but run them in InMemory first; then add a small Postgres-specific smoke test later if needed. + +Acceptance: +- Core use cases depending on racing repos function against Postgres in dev. + +### M4 — Rewire feature modules to boundary module +Replace imports of [`InMemoryRacingPersistenceModule`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:72) with [`RacingPersistenceModule`](apps/api/src/persistence/racing/RacingPersistenceModule.ts:1) in: +- [`LeagueModule`](apps/api/src/domain/league/LeagueModule.ts:1) +- [`RaceModule`](apps/api/src/domain/race/RaceModule.ts:1) +- [`DashboardModule`](apps/api/src/domain/dashboard/DashboardModule.ts:1) +- [`DriverModule`](apps/api/src/domain/driver/DriverModule.ts:1) +- [`ProtestsModule`](apps/api/src/domain/protests/ProtestsModule.ts:1) +- [`TeamModule`](apps/api/src/domain/team/TeamModule.ts:1) +- [`SponsorModule`](apps/api/src/domain/sponsor/SponsorModule.ts:1) +- plus adjust [`BootstrapModule`](apps/api/src/domain/bootstrap/BootstrapModule.ts:10) (see M5). + +Acceptance: +- Switching env var changes racing persistence without touching module imports. + +### M5 — Minimal idempotent Postgres seed (dev UX) +- Extend bootstrap so Postgres mode can seed minimal data when DB is empty. +- Current bootstrap behavior only seeds racing data for InMemory: [`shouldSeedRacingData()`](apps/api/src/domain/bootstrap/BootstrapModule.ts:37). +- Update logic to also seed for Postgres when: + - dev mode (non-prod), and + - tables empty (e.g., count leagues/drivers), and + - bootstrap enabled via [`getEnableBootstrap()`](apps/api/src/env.ts:49). + +Implementation note: +- Seed code should remain adapter-level (reuse [`SeedRacingData`](apps/api/src/domain/bootstrap/BootstrapModule.ts:27)) but use repos from the active persistence module. + +Acceptance: +- `docker compose -f docker-compose.dev.yml up` + `GRIDPILOT_API_PERSISTENCE=postgres` results in a usable UI without manual DB setup. + +### M6 — Dev compose/env ergonomics +- Remove hard-coded forcing of InMemory in dev compose: + - Change/remove [`GRIDPILOT_API_PERSISTENCE=inmemory`](docker-compose.dev.yml:36) + - Prefer `.env.development` control, consistent with `.env example` guidance ([`DATABASE_URL`](.env.development.example:20)). + +Acceptance: +- Devs can switch by editing `.env.development` or setting env override. + +### M7 — Verification gate +Run: +- `eslint` +- `tsc` +- tests + +Commands live in workspace scripts; start from package-level scripts as applicable (e.g. API tests via [`npm run test`](apps/api/package.json:10)). + +Acceptance: +- No lint errors, no TypeScript errors, green tests (with default tests still using InMemory). + +## Out of scope (this pass) +- Social persistence (stays InMemory for now). +- Full migration system (placeholder remains, e.g. [`up()`](adapters/persistence/migrations/001_initial_schema.ts:5)). +- Production-ready DB lifecycle (migrations, RLS, etc.). + +## Risks / watchouts +- Provider scoping: racing repos are exported tokens; boundary module must avoid creating competing instances. +- Entity design: ORM entities must not leak into core (enforce boundary per [`DOMAIN_OBJECTS.md`](docs/architecture/DOMAIN_OBJECTS.md:16)). +- Bootstrap: ensure Postgres seed is idempotent and doesn’t run in production (align with `NODE_ENV` usage in [`DatabaseModule`](apps/api/src/domain/database/DatabaseModule.ts:18)). \ No newline at end of file diff --git a/plans/2025-12-28T20:46:00Z_racing-postgres-typeorm-implementation-plan.md b/plans/2025-12-28T20:46:00Z_racing-postgres-typeorm-implementation-plan.md new file mode 100644 index 000000000..e47e65210 --- /dev/null +++ b/plans/2025-12-28T20:46:00Z_racing-postgres-typeorm-implementation-plan.md @@ -0,0 +1,419 @@ +# Plan: Switch Racing persistence from InMemory to Postgres (TypeORM) while keeping InMemory for tests + +Timestamp: 2025-12-28T20:46:00Z +Scope: Racing bounded context persistence only (no Social/Identity/Media/Payments persistence changes) + +This plan is intentionally implementation-ready (what files to add, what to wire, what tests to write first) while keeping scope controlled: a minimal vertical slice that makes one meaningful League/Race workflow work in dev Postgres, but does not attempt to implement every Racing repository at once. + +--- + +## 0) Context (current state, must preserve) + +- Persistence toggle is already defined at [`getApiPersistence()`](apps/api/src/env.ts:33) and typed in [`ProcessEnv`](apps/api/src/env.d.ts:3). +- DB bootstrap exists as Nest module: [`DatabaseModule`](apps/api/src/domain/database/DatabaseModule.ts:1), with non-prod schema sync enabled via [`synchronize`](apps/api/src/domain/database/DatabaseModule.ts:18). +- Racing repository tokens are currently defined in [`InMemoryRacingPersistenceModule`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:51) and are used as Nest provider tokens. +- Persistence boundary already exists and selects between in-memory and Postgres: [`RacingPersistenceModule`](apps/api/src/persistence/racing/RacingPersistenceModule.ts:1). +- Postgres wiring for Racing is currently placeholder-only: [`PostgresRacingPersistenceModule`](apps/api/src/persistence/postgres/PostgresRacingPersistenceModule.ts:51). +- Clean Architecture rules to enforce (no ORM leakage into Core): + - [`DATA_FLOW.md`](docs/architecture/DATA_FLOW.md:1) + - [`DOMAIN_OBJECTS.md`](docs/architecture/DOMAIN_OBJECTS.md:1) + - File placement rules via [`FILE_STRUCTURE.md`](docs/architecture/FILE_STRUCTURE.md:1) + +--- + +## 1) Goal and non-goals + +### Goal +Enable Racing persistence via Postgres/TypeORM for development runtime (selected via [`getApiPersistence()`](apps/api/src/env.ts:33)), while keeping default test runs using in-memory persistence (to keep CI fast and deterministic). + +### Non-goals (explicit scope control) +- No migration framework rollout, no production migration story beyond the existing non-prod `synchronize` behavior in [`DatabaseModule`](apps/api/src/domain/database/DatabaseModule.ts:18). +- No broad refactors to use cases, DTOs, controllers, or domain modeling. +- No implementation of non-Racing bounded contexts (Social/Identity/Media/etc). +- No attempt to “finish all Racing repositories” in first pass; we will do a minimal vertical slice first. + +--- + +## 2) Proposed adapter folder/file layout (TypeORM under `adapters/`) + +This follows the existing adapter organization shown in [`FILE_STRUCTURE.md`](docs/architecture/FILE_STRUCTURE.md:41) and the repo’s existing Racing adapter grouping under `adapters/racing/persistence/inmemory`. + +Create a parallel `typeorm` tree for Racing persistence: + +- `adapters/racing/persistence/typeorm/README.md` +- `adapters/racing/persistence/typeorm/entities/` + - `LeagueOrmEntity.ts` + - `SeasonOrmEntity.ts` + - `LeagueScoringConfigOrmEntity.ts` + - `RaceOrmEntity.ts` + - `LeagueMembershipOrmEntity.ts` +- `adapters/racing/persistence/typeorm/mappers/` + - `LeagueOrmMapper.ts` + - `SeasonOrmMapper.ts` + - `LeagueScoringConfigOrmMapper.ts` + - `RaceOrmMapper.ts` + - `LeagueMembershipOrmMapper.ts` +- `adapters/racing/persistence/typeorm/repositories/` + - `TypeOrmLeagueRepository.ts` + - `TypeOrmSeasonRepository.ts` + - `TypeOrmLeagueScoringConfigRepository.ts` + - `TypeOrmRaceRepository.ts` + - `TypeOrmLeagueMembershipRepository.ts` +- `adapters/racing/persistence/typeorm/testing/` (test-only helpers; never imported by prod code) + - `createTypeOrmTestDataSource.ts` + - `truncateRacingTables.ts` + +Notes: +- Do not add any `index.ts` barrel files due to the lint restriction in [`.eslintrc.json`](.eslintrc.json:36). +- All mapping logic must live in adapters (never in Core), per [`DATA_FLOW.md`](docs/architecture/DATA_FLOW.md:24). + +--- + +## 3) Minimal vertical slice (useful and controlled) + +### 3.1 “Meaningful workflow” target +Implement the smallest set that supports this end-to-end workflow in Postgres dev: + +1) Create a League (creates Season + scoring config) via the API call handled by the service method [`LeagueService.createLeague()`](apps/api/src/domain/league/LeagueService.ts:773) which uses [`CreateLeagueWithSeasonAndScoringUseCase`](core/racing/application/use-cases/CreateLeagueWithSeasonAndScoringUseCase.ts:56). +2) Fetch that League’s schedule (or races list) via [`GetLeagueScheduleUseCase`](core/racing/application/use-cases/GetLeagueScheduleUseCase.ts:33), used by [`LeagueService.getLeagueSchedule()`](apps/api/src/domain/league/LeagueService.ts:614). + +This is a practical vertical slice: it enables the admin UI to create a league and see a schedule scaffold. + +### 3.2 Repos/tokens INCLUDED in slice 1 +Implement Postgres/TypeORM repositories for the following tokens from [`InMemoryRacingPersistenceModule`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:51): + +- [`LEAGUE_REPOSITORY_TOKEN`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:52) → `TypeOrmLeagueRepository` +- [`SEASON_REPOSITORY_TOKEN`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:62) → `TypeOrmSeasonRepository` +- [`LEAGUE_SCORING_CONFIG_REPOSITORY_TOKEN`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:64) → `TypeOrmLeagueScoringConfigRepository` +- [`RACE_REPOSITORY_TOKEN`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:53) → `TypeOrmRaceRepository` +- [`LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:56) → `TypeOrmLeagueMembershipRepository` + +Rationale: +- Creation flow requires the first three via [`CreateLeagueWithSeasonAndScoringUseCase`](core/racing/application/use-cases/CreateLeagueWithSeasonAndScoringUseCase.ts:56). +- Schedule flow requires `league`, `season`, `race` repos via [`GetLeagueScheduleUseCase`](core/racing/application/use-cases/GetLeagueScheduleUseCase.ts:33). +- Capacity listing and “join league” depend on membership repo via [`GetAllLeaguesWithCapacityUseCase`](core/racing/application/use-cases/GetAllLeaguesWithCapacityUseCase.ts:25) and [`JoinLeagueUseCase`](core/racing/application/use-cases/JoinLeagueUseCase.ts:18). + +### 3.3 Repos/tokens DEFERRED (slice 2+) +Explicitly defer these tokens from [`InMemoryRacingPersistenceModule`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:51) to later phases: + +- [`DRIVER_REPOSITORY_TOKEN`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:51) +- [`RESULT_REPOSITORY_TOKEN`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:54) +- [`STANDING_REPOSITORY_TOKEN`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:55) +- [`RACE_REGISTRATION_REPOSITORY_TOKEN`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:57) +- [`TEAM_REPOSITORY_TOKEN`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:58) +- [`TEAM_MEMBERSHIP_REPOSITORY_TOKEN`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:59) +- Anything sponsorship/wallet-related in Racing persistence (tokens near [`LEAGUE_WALLET_REPOSITORY_TOKEN`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:66)) + +This keeps slice 1 focused and prevents exploding schema surface area. + +--- + +## 4) Mapping strategy (ORM entities vs domain entities) + +### 4.1 Boundary rule (non-negotiable) +Core domain entities (example [`League`](core/racing/domain/entities/League.ts:93), [`Race`](core/racing/domain/entities/Race.ts:18)) MUST NOT import or refer to ORM entities, repositories, decorators, or TypeORM types, per [`DOMAIN_OBJECTS.md`](docs/architecture/DOMAIN_OBJECTS.md:16) and [`DATA_FLOW.md`](docs/architecture/DATA_FLOW.md:24). + +### 4.2 ORM entity design principles +- ORM entities are persistence models optimized for storage/querying and can use primitives (string, number, Date) and JSON columns. +- Domain entities use rich value objects and invariants and should be built using existing factories like [`League.create()`](core/racing/domain/entities/League.ts:132) and [`Race.create()`](core/racing/domain/entities/Race.ts:81). + +### 4.3 Mapping responsibilities +Mapping lives in `adapters/racing/persistence/typeorm/mappers/*` and is responsible for: + +- `toDomain(orm)`: + - Convert primitive columns/JSON back into domain props. + - Call domain factory methods (`create`) with validated values. + - Handle optional fields and backward-compatibility defaults (e.g., `League.settings` in [`League`](core/racing/domain/entities/League.ts:72)). +- `toOrm(domain)`: + - Convert domain value objects to primitives suitable for columns. + - Define canonical serialization for nested structures (e.g., store `League.settings` as JSONB). + +### 4.4 Proposed per-entity mapping notes (slice 1) + +#### League +- Persist fields: + - `id` string (PK) + - `name` string + - `description` string + - `ownerId` string + - `createdAt` Date + - `settings` JSONB (store `LeagueSettings` from [`LeagueSettings`](core/racing/domain/entities/League.ts:72)) + - `socialLinks` JSONB nullable + - `participantCount` integer (if needed; domain tracks via internal `_participantCount` in [`League`](core/racing/domain/entities/League.ts:103)) + - `visibility` string (redundant to settings.visibility, but may be useful for querying; keep either: + - Option A: derive from settings only and do not store separate column + - Option B: store both and enforce consistency in mapper (preferred for query ergonomics) + +#### Season +- Keep Season as its own ORM entity with FK to leagueId (string). +- Use JSONB for schedule (if schedule is a complex object), and scalar columns for status, year, order, start/end. + +#### LeagueScoringConfig +- Store `seasonId` string as unique FK. +- Store scoring config payload (championships, points tables, bonus rules) as JSONB. + +#### Race +- Persist scalar fields corresponding to [`Race`](core/racing/domain/entities/Race.ts:18): + - `id` string (PK) + - `leagueId` string (indexed) + - `scheduledAt` timestamptz + - `track`, `trackId`, `car`, `carId` strings + - `sessionType` string + - `status` string (from [`RaceStatus`](core/racing/domain/entities/Race.ts:11)) + - `strengthOfField`, `registeredCount`, `maxParticipants` integers nullable +- Queries required by Core ports (examples in [`IRaceRepository`](core/racing/domain/repositories/IRaceRepository.ts:10)): + - find by leagueId + - upcoming/completed filtering (status + scheduledAt) + +#### LeagueMembership +- Persist fields corresponding to [`LeagueMembership`](core/racing/domain/entities/LeagueMembership.ts:25): + - `id` string (domain uses default `${leagueId}:${driverId}` in [`LeagueMembership.create()`](core/racing/domain/entities/LeagueMembership.ts:49)) + - `leagueId` string (indexed) + - `driverId` string (indexed) + - `role` string + - `status` string + - `joinedAt` timestamptz +- This enables membership queries required by [`ILeagueMembershipRepository`](core/racing/domain/repositories/ILeagueMembershipRepository.ts:13). + +--- + +## 5) TypeORM + Nest wiring specifics + +### 5.1 Database root config +Current `DatabaseModule` uses [`TypeOrmModule.forRoot()`](apps/api/src/domain/database/DatabaseModule.ts:6) and does not register entities. + +Plan change (minimal and controlled): +- Update [`DatabaseModule`](apps/api/src/domain/database/DatabaseModule.ts:1) to support loading Racing entities when Postgres persistence is enabled: + - Add `autoLoadEntities: true` in the `forRoot` options so entities registered via feature modules are discovered. + - Keep [`synchronize`](apps/api/src/domain/database/DatabaseModule.ts:18) behavior as-is for non-prod for now (explicitly acknowledged technical debt). + +Why: +- We want Racing persistence to be modular (entities registered only when the Postgres Racing module is imported) without a global “list every entity in the world” change. + +### 5.2 Postgres Racing module structure +Replace placeholder providers in [`PostgresRacingPersistenceModule`](apps/api/src/persistence/postgres/PostgresRacingPersistenceModule.ts:51) with real wiring: + +- `imports`: + - [`LoggingModule`](apps/api/src/persistence/postgres/PostgresRacingPersistenceModule.ts:3) (already present) + - Nest TypeORM feature registration for the slice 1 entities: + - `TypeOrmModule.forFeature([LeagueOrmEntity, SeasonOrmEntity, LeagueScoringConfigOrmEntity, RaceOrmEntity, LeagueMembershipOrmEntity])` + - Mentioned as a method name; `TypeOrmModule` itself is already in use at [`TypeOrmModule.forRoot()`](apps/api/src/domain/database/DatabaseModule.ts:6). + +- `providers`: + - Register each repository implementation class under the existing tokens, matching the in-memory token names exactly (examples in [`InMemoryRacingPersistenceModule`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:75)): + - Provide [`LEAGUE_REPOSITORY_TOKEN`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:52) → `TypeOrmLeagueRepository` + - Provide [`SEASON_REPOSITORY_TOKEN`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:62) → `TypeOrmSeasonRepository` + - Provide [`LEAGUE_SCORING_CONFIG_REPOSITORY_TOKEN`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:64) → `TypeOrmLeagueScoringConfigRepository` + - Provide [`RACE_REPOSITORY_TOKEN`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:53) → `TypeOrmRaceRepository` + - Provide [`LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:56) → `TypeOrmLeagueMembershipRepository` + +- `exports`: + - Export the same tokens so downstream modules remain unchanged, mirroring the export list pattern in [`InMemoryRacingPersistenceModule`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:182). + +### 5.3 Repository implementation style +Each `TypeOrm*Repository`: +- Implements the relevant Core repository interface, e.g. [`ILeagueRepository`](core/racing/domain/repositories/ILeagueRepository.ts:10). +- Depends only on: + - TypeORM repository/DataSource types (adapter-layer OK) + - Mappers in adapters + - Domain entities/ports (core-layer OK) +- Does not expose ORM entities outside adapters. + +### 5.4 Persistence boundary selection remains the same +Do not change selection semantics in [`RacingPersistenceModule`](apps/api/src/persistence/racing/RacingPersistenceModule.ts:7). This module already selects Postgres vs in-memory using [`getApiPersistence()`](apps/api/src/env.ts:33). + +--- + +## 6) TDD-first phased rollout (tests first, controlled scope) + +### 6.1 Testing goals +- Add confidence that TypeORM repositories satisfy Core port contracts. +- Keep default test runs fast and in-memory by default. +- Add Postgres-backed integration tests that are opt-in (run only when explicitly enabled). + +### 6.2 What tests already exist and should remain green +- Persistence module selection test: [`RacingPersistenceModule.test.ts`](apps/api/src/persistence/racing/RacingPersistenceModule.test.ts:9) (currently asserts placeholder instance in Postgres mode). This will need updating once placeholders are replaced, but the intent remains valid. +- Existing in-memory repository unit tests under `adapters/racing/persistence/inmemory/*.test.ts` (example: [`InMemoryLeagueRepository.test.ts`](adapters/racing/persistence/inmemory/InMemoryLeagueRepository.test.ts)) must remain untouched and continue to run by default. + +### 6.3 New tests to write first (TDD sequence) + +#### Phase A: Contract-style repo tests for TypeORM (integration tests, opt-in) +Create a new test suite for each TypeORM repository in: +- `adapters/racing/persistence/typeorm/repositories/*Repository.integration.test.ts` + +Test approach: +- Use a real Postgres database (not mocks) and TypeORM DataSource configured similarly to runtime config in [`DatabaseModule`](apps/api/src/domain/database/DatabaseModule.ts:6). +- Keep these tests skipped unless a flag is set, e.g. `RUN_RACING_PG_TESTS=1` (exact naming to be decided in implementation mode). +- Use a dedicated DB name or schema per run and truncate tables between tests. + +Example test cases (for slice 1): +- `TypeOrmLeagueRepository` should satisfy basic operations defined in [`ILeagueRepository`](core/racing/domain/repositories/ILeagueRepository.ts:10): + - create + findById roundtrip + - findAll returns inserted + - update roundtrip + - exists works +- `TypeOrmSeasonRepository` should satisfy [`ISeasonRepository`](core/racing/domain/repositories/ISeasonRepository.ts:3): + - create + findById + - findByLeagueId +- `TypeOrmRaceRepository` should satisfy [`IRaceRepository`](core/racing/domain/repositories/IRaceRepository.ts:10): + - create + findById + - findByLeagueId + - findUpcomingByLeagueId and findCompletedByLeagueId behavior (status + date) +- `TypeOrmLeagueMembershipRepository` should satisfy [`ILeagueMembershipRepository`](core/racing/domain/repositories/ILeagueMembershipRepository.ts:13): + - saveMembership + getMembership + - getLeagueMembers filtering (active vs pending) must match whatever domain expects (start with minimal “returns all stored” behavior, then align with use cases) +- `TypeOrmLeagueScoringConfigRepository` should satisfy [`ILeagueScoringConfigRepository`](core/racing/domain/repositories/ILeagueScoringConfigRepository.ts:3): + - save + findBySeasonId + +Why integration tests first: +- It forces us to design the ORM schema + mapping in a way that matches the Core port contracts immediately. + +#### Phase B: Update module selection test (unit test, always-on) +Update [`RacingPersistenceModule.test.ts`](apps/api/src/persistence/racing/RacingPersistenceModule.test.ts:9) to assert that in Postgres mode the provider resolves to the real TypeORM repo class (instead of the placeholder in [`PostgresRacingPersistenceModule`](apps/api/src/persistence/postgres/PostgresRacingPersistenceModule.ts:30)). + +This remains a fast unit test: it only checks Nest DI wiring, not DB behavior. + +### 6.4 What to mock vs run “for real” +- Mock nothing for repository integration tests (they should hit Postgres). +- Keep Core use case tests (if any exist) running with in-memory repos or test doubles by default. +- Do not switch existing HTTP tests to Postgres by default (many explicitly set in-memory via env in files like those discovered under `apps/api/src/domain/league/*.http.test.ts` via earlier repo search). + +### 6.5 Keeping default tests in in-memory mode +- Preserve the current default behavior where tests set [`GRIDPILOT_API_PERSISTENCE`](apps/api/src/env.d.ts:7) to `inmemory` (example in [`RacingPersistenceModule.test.ts`](apps/api/src/persistence/racing/RacingPersistenceModule.test.ts:20)). +- Ensure the Postgres integration tests are opt-in and not included in default `npm run api:test` from [`apps/api/package.json`](apps/api/package.json:10). + +--- + +## 7) Dev bootstrap/seed strategy for Postgres (minimal, idempotent, non-test) + +### 7.1 Current behavior +Bootstrap currently seeds racing data only in in-memory mode via [`shouldSeedRacingData()`](apps/api/src/domain/bootstrap/BootstrapModule.ts:37), which returns `true` only for `inmemory`. + +### 7.2 Target behavior +In dev Postgres mode, seed minimal Racing data only when the database is empty, and never during tests. + +Proposed logic change in [`BootstrapModule`](apps/api/src/domain/bootstrap/BootstrapModule.ts:10): +- Seed when ALL are true: + - `NODE_ENV !== 'production'` + - persistence is Postgres via [`getApiPersistence()`](apps/api/src/env.ts:33) + - database appears empty for Racing (fast check: `driverRepository.findAll().length === 0` as already used in [`SeedRacingData.execute()`](adapters/bootstrap/SeedRacingData.ts:55)) + - bootstrap is enabled (already toggled globally in app startup via [`getEnableBootstrap()`](apps/api/src/env.ts:49) and [`AppModule`](apps/api/src/app.module.ts:29)) + +Implementation detail: +- Keep using [`SeedRacingData`](adapters/bootstrap/SeedRacingData.ts:49) because it is already idempotent-ish (it skips when drivers exist at [`SeedRacingData.execute()`](adapters/bootstrap/SeedRacingData.ts:55)). +- Ensure Postgres-backed `driverRepository` is not required for slice 1 if we keep seed minimal; however, current seed checks drivers first, so this implies either: + - Option A (preferred for UI usefulness): include Driver repo in Postgres slice 1b (small extension) so seeding can run fully, or + - Option B (controlled scope): create a Postgres-only “minimal seed” class that checks `leagueRepository.findAll()` instead of drivers and seeds only leagues/seasons/races/memberships/scoring configs. + +To keep scope controlled and aligned with “Racing only”, choose Option B for slice 1: +- Introduce `SeedRacingDataMinimal` under `adapters/bootstrap/racing/` that seeds: + - one league + - one active season + - 0..N races in the season window + - one membership for the owner (so capacity endpoints have meaningful data) + - one scoring config for the active season +- Keep it idempotent: + - skip if `leagueRepository.findAll()` returns any leagues + - upsert behavior for scoring config by seasonId (align with [`ILeagueScoringConfigRepository.findBySeasonId()`](core/racing/domain/repositories/ILeagueScoringConfigRepository.ts:3)) + +Test contamination avoidance: +- Tests already default to in-memory persistence and can also set `GRIDPILOT_API_BOOTSTRAP` to false if needed via [`getEnableBootstrap()`](apps/api/src/env.ts:49). + +--- + +## 8) Dev ergonomics: docker-compose dev toggle change (exact change) + +### 8.1 Current issue +`docker-compose.dev.yml` forces in-memory persistence via [`GRIDPILOT_API_PERSISTENCE=inmemory`](docker-compose.dev.yml:36), which overrides the inference behavior in [`getApiPersistence()`](apps/api/src/env.ts:33). + +### 8.2 Proposed exact change (minimal) +Update [`docker-compose.dev.yml`](docker-compose.dev.yml:26) to stop hard-forcing in-memory: + +- Replace the hard-coded line at [`docker-compose.dev.yml`](docker-compose.dev.yml:36) with: + - `- GRIDPILOT_API_PERSISTENCE=${GRIDPILOT_API_PERSISTENCE:-postgres}` + +Expected dev behavior: +- Default dev stack uses Postgres persistence (because compose default becomes `postgres`). +- Developers can still run in-memory explicitly by setting `GRIDPILOT_API_PERSISTENCE=inmemory` before running the compose command. + +Alternative (if you prefer to preserve auto-detection): +- Remove the line entirely and rely on [`getApiPersistence()`](apps/api/src/env.ts:33) + `DATABASE_URL` presence in `.env.development`. + +This plan recommends the explicit compose default approach because it is more deterministic and avoids hidden coupling to `.env.development` contents. + +--- + +## 9) Phased implementation plan (step-by-step) + +### Phase 1: Prepare TypeORM adapter skeleton (no behavior change) +1) Add the folder structure described in section 2. +2) Add the ORM entity files for the slice 1 domain models with minimal columns and constraints (PKs, required columns, basic indices). +3) Add mapper stubs with round-trip intent documented. +4) Add repository class stubs that implement the Core interfaces but throw “not implemented” only for methods not used in slice 1 tests. + +Gate: +- No changes to runtime wiring yet; existing tests remain green. + +### Phase 2: Add opt-in Postgres integration tests (TDD) +1) Add TypeORM DataSource test helper. +2) Write failing integration tests for `TypeOrmLeagueRepository` and implement it until green. +3) Repeat for `Season`, `Race`, `Membership`, `ScoringConfig` repos. + +Gate: +- Integration tests pass when enabled. +- Default `npm run api:test` remains unaffected. + +### Phase 3: Wire Postgres Racing module to real repos (DI correctness) +1) Update [`PostgresRacingPersistenceModule`](apps/api/src/persistence/postgres/PostgresRacingPersistenceModule.ts:51): + - Import TypeORM feature registration (section 5.2). + - Replace placeholder providers with real repository providers for slice 1 tokens. +2) Update [`RacingPersistenceModule.test.ts`](apps/api/src/persistence/racing/RacingPersistenceModule.test.ts:9) to assert the Postgres providers resolve to the real TypeORM repo classes (instead of placeholders). + +Gate: +- Always-on module selection tests pass. + +### Phase 4: Enable dev Postgres UX (bootstrap + compose) +1) Update [`BootstrapModule`](apps/api/src/domain/bootstrap/BootstrapModule.ts:10) to seed minimal Racing data in dev Postgres mode only when empty. +2) Update [`docker-compose.dev.yml`](docker-compose.dev.yml:26) per section 8. + +Gate: +- `docker compose` dev stack can run with Postgres persistence and UI has minimal data. + +### Phase 5: Incrementally expand beyond slice 1 (future, explicitly not required to finish now) +Add Driver + Result + Standings etc only when a concrete UI/endpoint requires them and after writing the next integration tests first. + +--- + +## 10) Verification gates (exact commands and when) + +Run these at the end of each phase that changes TS code: + +- ESLint: + - `npm run lint` (script defined at [`package.json`](package.json:80)) +- TypeScript: + - `npm run typecheck:targets` (script defined at [`package.json`](package.json:120)) +- API tests: + - `npm run api:test` (script defined at [`package.json`](package.json:64)) or `npm run test --workspace=@gridpilot/api` via [`apps/api/package.json`](apps/api/package.json:10) + +For opt-in Postgres repository integration tests (added in Phase 2): +- Define a dedicated command (implementation-mode decision), but the plan expects it to be an explicit command that developers run intentionally (not part of default CI). + +--- + +## 11) Risks and mitigations + +- Risk: ORM entities leak into Core through shared types. + - Mitigation: enforce mappers in adapters only, keep interfaces as Core ports (example [`ILeagueRepository`](core/racing/domain/repositories/ILeagueRepository.ts:10)). +- Risk: Seed logic contaminates tests. + - Mitigation: preserve default in-memory persistence in tests (example env usage in [`RacingPersistenceModule.test.ts`](apps/api/src/persistence/racing/RacingPersistenceModule.test.ts:20)) and gate seeding by non-prod + emptiness checks; tests can disable bootstrap via [`getEnableBootstrap()`](apps/api/src/env.ts:49). +- Risk: TypeORM entity registration not picked up because entities not configured. + - Mitigation: enable `autoLoadEntities` in [`DatabaseModule`](apps/api/src/domain/database/DatabaseModule.ts:1) as part of implementation. + +--- + +## 12) Ready-for-approval questions (for implementation mode to resolve quickly) + +These are the only decisions that materially affect implementation detail: + +1) Prefer Postgres integration tests using a developer-managed Postgres (via `DATABASE_URL`) or a dedicated docker-compose test database? +2) For slice 1 seed, should we implement a minimal Racing-only seed (recommended) or extend slice 1 to include Driver repo so we can reuse [`SeedRacingData`](adapters/bootstrap/SeedRacingData.ts:49) unchanged? \ No newline at end of file diff --git a/plans/2025-12-28T22:37:07Z_racing-typeorm-adapter-audit-refactor-guide.md b/plans/2025-12-28T22:37:07Z_racing-typeorm-adapter-audit-refactor-guide.md new file mode 100644 index 000000000..27756b9db --- /dev/null +++ b/plans/2025-12-28T22:37:07Z_racing-typeorm-adapter-audit-refactor-guide.md @@ -0,0 +1,300 @@ +# Racing TypeORM Adapter Clean Architecture Audit + Strict Refactor Guide + +Scope focus: all persistence adapter code under [`adapters/racing/persistence/typeorm/`](adapters/racing/persistence/typeorm:1), especially mappers (incl. JSON mappers) and repositories, plus the ORM entities and adapter-scoped errors they depend on. + +This guide is intentionally strict and implementation-ready. + +--- + +## Governing constraints (authoritative) + +- **Strict inward dependencies**: [`Only dependency-inward is allowed.`](docs/architecture/DATA_FLOW.md:24) +- **Domain purity / no IO in domain objects**: [`Entities MUST NOT perform IO.`](docs/architecture/DOMAIN_OBJECTS.md:49) +- **Persisted objects must rehydrate, not create**: [`Existing entities are reconstructed via rehydrate().`](docs/architecture/DOMAIN_OBJECTS.md:57) +- **Adapters translate only (no orchestration/business logic)**: [`Adapters translate.`](docs/architecture/DATA_FLOW.md:437) +- **Persistence entity placement**: ORM entities live in adapters, not domain: [`entities/ ORM-Entities (nicht Domain!)`](docs/architecture/FILE_STRUCTURE.md:47) + +--- + +## Adapter surface inventory (audited) + +### Mappers +- [`LeagueOrmMapper`](adapters/racing/persistence/typeorm/mappers/LeagueOrmMapper.ts:30) +- [`RaceOrmMapper`](adapters/racing/persistence/typeorm/mappers/RaceOrmMapper.ts:5) +- [`SeasonOrmMapper`](adapters/racing/persistence/typeorm/mappers/SeasonOrmMapper.ts:4) +- [`LeagueScoringConfigOrmMapper`](adapters/racing/persistence/typeorm/mappers/LeagueScoringConfigOrmMapper.ts:42) +- [`ChampionshipConfigJsonMapper`](adapters/racing/persistence/typeorm/mappers/ChampionshipConfigJsonMapper.ts:17) +- [`PointsTableJsonMapper`](adapters/racing/persistence/typeorm/mappers/PointsTableJsonMapper.ts:7) + +### Repositories +- [`TypeOrmLeagueRepository`](adapters/racing/persistence/typeorm/repositories/TypeOrmLeagueRepository.ts:9) +- [`TypeOrmRaceRepository`](adapters/racing/persistence/typeorm/repositories/TypeOrmRaceRepository.ts:9) +- [`TypeOrmSeasonRepository`](adapters/racing/persistence/typeorm/repositories/TypeOrmSeasonRepository.ts:9) +- [`TypeOrmLeagueScoringConfigRepository`](adapters/racing/persistence/typeorm/repositories/TypeOrmLeagueScoringConfigRepository.ts:9) + +### ORM entities +- [`LeagueOrmEntity`](adapters/racing/persistence/typeorm/entities/LeagueOrmEntity.ts:4) +- [`RaceOrmEntity`](adapters/racing/persistence/typeorm/entities/RaceOrmEntity.ts:4) +- [`SeasonOrmEntity`](adapters/racing/persistence/typeorm/entities/SeasonOrmEntity.ts:4) +- [`LeagueScoringConfigOrmEntity`](adapters/racing/persistence/typeorm/entities/LeagueScoringConfigOrmEntity.ts:6) + +### Adapter-scoped errors +- [`InvalidLeagueScoringConfigChampionshipsSchemaError`](adapters/racing/persistence/typeorm/errors/InvalidLeagueScoringConfigChampionshipsSchemaError.ts:1) + +--- + +## Concrete violations (file + function, why, severity) + +Severity rubric: +- **Blocker**: violates non-negotiable constraints; can cause domain invariants to run on persisted state, or leaks construction/orchestration into adapters. +- **Follow-up**: does not strictly violate constraints, but is unsafe/unclear and should be corrected to align with the canonical strict pattern defined below. + +### 1) Rehydration violations (calling `create()` when loading from DB) — **Blocker** + +- [`LeagueOrmMapper.toDomain()`](adapters/racing/persistence/typeorm/mappers/LeagueOrmMapper.ts:46) + - **Why**: Uses [`League.create()`](core/racing/domain/entities/League.ts:132) for persisted reconstruction. + - **Rule violated**: persisted objects must reconstruct via [`rehydrate()`](docs/architecture/DOMAIN_OBJECTS.md:57) semantics (new vs existing). + - **Impact**: running creation-time defaulting + validation on persisted state can mutate meaning (e.g., defaults merged in [`League.create()`](core/racing/domain/entities/League.ts:132)) and can throw domain validation errors due to persistence schema drift. + +- [`RaceOrmMapper.toDomain()`](adapters/racing/persistence/typeorm/mappers/RaceOrmMapper.ts:23) + - **Why**: Uses [`Race.create()`](core/racing/domain/entities/Race.ts:81) for persisted reconstruction. + - **Rule violated**: persisted objects must reconstruct via [`rehydrate()`](docs/architecture/DOMAIN_OBJECTS.md:57). + - **Impact**: DB rows become subject to “new entity” validations; adapter loses ability to separate “invalid persisted schema” (adapter concern) from “invalid new command” (domain concern). + +- [`SeasonOrmMapper.toDomain()`](adapters/racing/persistence/typeorm/mappers/SeasonOrmMapper.ts:26) + - **Why**: Uses [`Season.create()`](core/racing/domain/entities/season/Season.ts:70) for persisted reconstruction. + - **Rule violated**: persisted objects must reconstruct via [`rehydrate()`](docs/architecture/DOMAIN_OBJECTS.md:57). + - **Impact**: same as above, plus schedule/scoring/drop/stewarding props are passed as `any`, making persisted-state validation unpredictable. + +- Positive control (already compliant): [`LeagueScoringConfigOrmMapper.toDomain()`](adapters/racing/persistence/typeorm/mappers/LeagueScoringConfigOrmMapper.ts:54) + - Uses [`LeagueScoringConfig.rehydrate()`](core/racing/domain/entities/LeagueScoringConfig.ts:63) and validates schema before converting. + - This is the baseline pattern to replicate. + +### 2) Adapter “translation only” violations (construction / orchestration inside repositories) — **Blocker** + +- [`TypeOrmLeagueRepository.constructor()`](adapters/racing/persistence/typeorm/repositories/TypeOrmLeagueRepository.ts:10) + - **Why**: Default-constructs a mapper via `new` ([`new LeagueOrmMapper()`](adapters/racing/persistence/typeorm/repositories/TypeOrmLeagueRepository.ts:12)). + - **Rule violated**: adapters must “translate only” ([`Adapters translate.`](docs/architecture/DATA_FLOW.md:437)); object graph construction belongs in the composition root (Nest module). + - **Impact**: makes DI inconsistent, harder to test, and encourages mapper graphs to be built ad-hoc in infrastructure code rather than composed centrally. + +- [`TypeOrmRaceRepository.constructor()`](adapters/racing/persistence/typeorm/repositories/TypeOrmRaceRepository.ts:10) + - **Why**: Default-constructs mapper via [`new RaceOrmMapper()`](adapters/racing/persistence/typeorm/repositories/TypeOrmRaceRepository.ts:12). + - **Rule violated**: [`Adapters translate.`](docs/architecture/DATA_FLOW.md:437) + - **Impact**: same. + +- [`TypeOrmSeasonRepository.constructor()`](adapters/racing/persistence/typeorm/repositories/TypeOrmSeasonRepository.ts:10) + - **Why**: Default-constructs mapper via [`new SeasonOrmMapper()`](adapters/racing/persistence/typeorm/repositories/TypeOrmSeasonRepository.ts:12). + - **Rule violated**: [`Adapters translate.`](docs/architecture/DATA_FLOW.md:437) + +- Positive control (already aligned): [`TypeOrmLeagueScoringConfigRepository.constructor()`](adapters/racing/persistence/typeorm/repositories/TypeOrmLeagueScoringConfigRepository.ts:10) + - Requires mapper injection (no internal construction), and is enforced by [`TypeOrmLeagueScoringConfigRepository.test.ts`](adapters/racing/persistence/typeorm/repositories/TypeOrmLeagueScoringConfigRepository.test.ts:17). + +### 3) Persistence schema typing issues that currently force `unknown`/`any` translation — **Follow-up (but required by the canonical strict pattern)** + +These aren’t explicitly spelled out in the architecture docs, but they directly undermine “adapters translate only” by making translation ambiguous and unsafe. They also block strict `rehydrate()` mapping because you can’t validate/interpret persisted JSON precisely. + +- [`LeagueOrmEntity.settings`](adapters/racing/persistence/typeorm/entities/LeagueOrmEntity.ts:17) + - Current: `Record` + - Problem: forces coercion and casts in [`LeagueOrmMapper.toOrmEntity()`](adapters/racing/persistence/typeorm/mappers/LeagueOrmMapper.ts:31) and [`LeagueOrmMapper.toDomain()`](adapters/racing/persistence/typeorm/mappers/LeagueOrmMapper.ts:46). + +- [`SeasonOrmEntity.schedule`](adapters/racing/persistence/typeorm/entities/SeasonOrmEntity.ts:32), [`SeasonOrmEntity.scoringConfig`](adapters/racing/persistence/typeorm/entities/SeasonOrmEntity.ts:38), [`SeasonOrmEntity.dropPolicy`](adapters/racing/persistence/typeorm/entities/SeasonOrmEntity.ts:41), [`SeasonOrmEntity.stewardingConfig`](adapters/racing/persistence/typeorm/entities/SeasonOrmEntity.ts:44) + - Current: `Record | null` + - Problem: propagates into pervasive `as any` in [`SeasonOrmMapper.toDomain()`](adapters/racing/persistence/typeorm/mappers/SeasonOrmMapper.ts:26). + +- [`RaceOrmMapper.toDomain()`](adapters/racing/persistence/typeorm/mappers/RaceOrmMapper.ts:23) and [`SeasonOrmMapper.toDomain()`](adapters/racing/persistence/typeorm/mappers/SeasonOrmMapper.ts:26) + - Current: `as any` casts for status/sessionType/schedule/etc. + - Problem: translation is not explicit/verified; makes persisted schema errors show up as domain behavior or runtime surprises. + +### 4) Persistence boundary error mapping gaps — **Blocker** + +- [`LeagueOrmMapper.toDomain()`](adapters/racing/persistence/typeorm/mappers/LeagueOrmMapper.ts:46), [`RaceOrmMapper.toDomain()`](adapters/racing/persistence/typeorm/mappers/RaceOrmMapper.ts:23), [`SeasonOrmMapper.toDomain()`](adapters/racing/persistence/typeorm/mappers/SeasonOrmMapper.ts:26) + - **Why**: because these call `create()` and use unsafe casts, the adapter does not reliably distinguish: + - invalid persisted schema (adapter concern) vs + - invalid incoming command/request (domain concern). + - **Rule violated (intent)**: separation of roles per [`Adapters translate.`](docs/architecture/DATA_FLOW.md:437) and persisted rehydration rule ([`rehydrate()`](docs/architecture/DOMAIN_OBJECTS.md:57)). + - **Impact**: domain errors (e.g. [`RacingDomainValidationError`](core/racing/domain/entities/League.ts:148)) can leak due to persistence drift, making debugging and recovery much harder. + +--- + +## Canonical strict pattern for this repo (target state) + +This is the “golden path” all Racing TypeORM adapters should follow. + +### A) ORM entity shape rules (including JSON columns) + +- ORM entities are allowed in adapters per [`entities/ ORM-Entities (nicht Domain!)`](docs/architecture/FILE_STRUCTURE.md:47). +- JSON columns must be **typed to a concrete serialized shape** (no `unknown`, no `Record`). + - Example target types: + - `LeagueOrmEntity.settings: SerializedLeagueSettings` + - `SeasonOrmEntity.schedule: SerializedSeasonSchedule | null` + - `SeasonOrmEntity.scoringConfig: SerializedSeasonScoringConfig | null` +- Serialized types must be: + - JSON-safe (no `Date`, `Map`, class instances) + - versionable (allow future `schemaVersion?: number`) + - strict enough to validate at runtime +- Use `null` for absent optional columns (as already done in [`RaceOrmEntity.trackId`](adapters/racing/persistence/typeorm/entities/RaceOrmEntity.ts:17)). + +### B) Mapper rules (pure translation, no side effects) + +- Mappers are pure, deterministic translators: + - `domain -> orm` and `orm -> domain` + - no IO, no logging, no Date.now(), no random ids +- **No `as any`** in mapping. If types don’t line up, fix the schema types or add a safe interpreter. +- **No `create()` on load**. `orm -> domain` must call `rehydrate()` semantics for entities ([`rehydrate()` rule](docs/architecture/DOMAIN_OBJECTS.md:57)). +- On `orm -> domain`: + - validate persisted schema and throw an **adapter-scoped persistence schema error**, not a domain validation error. + - treat invalid persisted schema as infrastructure failure (data corruption/migration mismatch). +- On `domain -> orm`: + - serialize domain objects via explicit “serialize” helpers (no `as unknown as Record<...>`). +- JSON mappers: + - must be pure + - must return serialized DTO-like shapes, not domain objects except as output of `fromJson` + - should avoid calling value-object constructors directly unless the VO explicitly treats constructor as a safe “rehydrate”; otherwise introduce `fromJson()`/`rehydrate()` for VOs. + +### C) Repository rules (IO + mapper only; composition root in Nest module) + +- Repositories implement core ports and do: + - DB IO (TypeORM queries) + - mapping via injected mappers +- Repositories must not: + - construct mappers internally (`new` in constructor defaults) + - embed business logic/orchestration (that belongs to application services / use cases per [`Use Cases decide... Adapters translate.`](docs/architecture/DATA_FLOW.md:437)) +- Construction belongs in the Nest composition root: + - e.g. [`PostgresRacingPersistenceModule`](apps/api/src/persistence/postgres/PostgresRacingPersistenceModule.ts:57) should provide mapper instances and inject them into repositories. + +### D) Error handling rules (adapter-scoped errors vs domain errors) + +- Domain errors (e.g. [`RacingDomainValidationError`](core/racing/domain/entities/League.ts:148)) are for rejecting invalid **commands/new state transitions**. +- Persistence adapters must throw adapter-scoped errors for: + - invalid persisted JSON schema + - impossible enum values/statuses stored in DB + - missing required persisted columns +- Pattern baseline: + - follow [`InvalidLeagueScoringConfigChampionshipsSchemaError`](adapters/racing/persistence/typeorm/errors/InvalidLeagueScoringConfigChampionshipsSchemaError.ts:1) (adapter-owned, descriptive name, extends `Error`). +- Repositories generally should not catch/translate DB errors unless you have a stable policy (e.g. unique violations) — keep this explicit and adapter-scoped if introduced. + +--- + +## Controlled refactor plan (2–3 slices) + +Each slice is designed to be reviewable and to keep the system runnable. + +### Slice 1 — Mappers: `rehydrate()` + typed JSON schemas (DB-free unit tests) + +**Goal** +- Make all `orm -> domain` mapping use rehydration semantics and strict, typed persisted schemas. +- Remove `as any` from mapper paths by fixing schema types and adding validators. + +**Files to touch (exact)** +- Mapper implementations: + - [`LeagueOrmMapper`](adapters/racing/persistence/typeorm/mappers/LeagueOrmMapper.ts:30) + - [`RaceOrmMapper`](adapters/racing/persistence/typeorm/mappers/RaceOrmMapper.ts:5) + - [`SeasonOrmMapper`](adapters/racing/persistence/typeorm/mappers/SeasonOrmMapper.ts:4) + - (keep as reference) [`LeagueScoringConfigOrmMapper`](adapters/racing/persistence/typeorm/mappers/LeagueScoringConfigOrmMapper.ts:42) + - JSON mappers as needed: + - [`PointsTableJsonMapper`](adapters/racing/persistence/typeorm/mappers/PointsTableJsonMapper.ts:7) + - [`ChampionshipConfigJsonMapper`](adapters/racing/persistence/typeorm/mappers/ChampionshipConfigJsonMapper.ts:17) +- ORM entities (type JSON columns to serialized types): + - [`LeagueOrmEntity`](adapters/racing/persistence/typeorm/entities/LeagueOrmEntity.ts:4) + - [`SeasonOrmEntity`](adapters/racing/persistence/typeorm/entities/SeasonOrmEntity.ts:4) +- Add adapter-scoped schema error types (new files under the existing folder): + - create new errors in [`adapters/racing/persistence/typeorm/errors/`](adapters/racing/persistence/typeorm/errors/InvalidLeagueScoringConfigChampionshipsSchemaError.ts:1) +- Core changes required to satisfy rehydration rule (yes, this reaches into core because the adapter cannot comply otherwise): + - add `static rehydrate(...)` to: + - [`League`](core/racing/domain/entities/League.ts:93) + - [`Race`](core/racing/domain/entities/Race.ts:18) + - [`Season`](core/racing/domain/entities/season/Season.ts:14) + +**Acceptance tests (DB-free)** +- Add mapper unit tests in the same pattern as [`LeagueScoringConfigOrmMapper.test.ts`](adapters/racing/persistence/typeorm/mappers/LeagueScoringConfigOrmMapper.test.ts:10): + - new: `LeagueOrmMapper.test.ts` verifies [`LeagueOrmMapper.toDomain()`](adapters/racing/persistence/typeorm/mappers/LeagueOrmMapper.ts:46) calls `rehydrate()` and does not call [`League.create()`](core/racing/domain/entities/League.ts:132). + - new: `RaceOrmMapper.test.ts` verifies [`RaceOrmMapper.toDomain()`](adapters/racing/persistence/typeorm/mappers/RaceOrmMapper.ts:23) uses `rehydrate()` and never calls [`Race.create()`](core/racing/domain/entities/Race.ts:81). + - new: `SeasonOrmMapper.test.ts` verifies [`SeasonOrmMapper.toDomain()`](adapters/racing/persistence/typeorm/mappers/SeasonOrmMapper.ts:26) uses `rehydrate()` and never calls [`Season.create()`](core/racing/domain/entities/season/Season.ts:70). +- Add schema validation tests: + - invalid JSON column shapes throw adapter error types (similar to [`InvalidLeagueScoringConfigChampionshipsSchemaError`](adapters/racing/persistence/typeorm/errors/InvalidLeagueScoringConfigChampionshipsSchemaError.ts:1)), not domain validation errors. + +**Definition of done** +- No `create()` calls in any `toDomain()` for persisted entities. +- No `as any` in mapper implementations. +- JSON columns are typed to explicit `Serialized*` types, and validated on load. + +--- + +### Slice 2 — Repository wiring cleanup (DI, no `new` inside repos) + +**Goal** +- Repositories remain IO + mapper only. +- Mapper graphs are constructed in the Nest module composition root. + +**Files to touch (exact)** +- Repositories: + - [`TypeOrmLeagueRepository`](adapters/racing/persistence/typeorm/repositories/TypeOrmLeagueRepository.ts:9) + - [`TypeOrmRaceRepository`](adapters/racing/persistence/typeorm/repositories/TypeOrmRaceRepository.ts:9) + - [`TypeOrmSeasonRepository`](adapters/racing/persistence/typeorm/repositories/TypeOrmSeasonRepository.ts:9) + - (already OK, keep consistent) [`TypeOrmLeagueScoringConfigRepository`](adapters/racing/persistence/typeorm/repositories/TypeOrmLeagueScoringConfigRepository.ts:9) +- Composition root wiring: + - [`PostgresRacingPersistenceModule`](apps/api/src/persistence/postgres/PostgresRacingPersistenceModule.ts:57) +- Integration tests that new repos directly (update constructor signatures): + - [`PostgresLeagueScheduleRepositorySlice.int.test.ts`](apps/api/src/persistence/postgres/typeorm/PostgresLeagueScheduleRepositorySlice.int.test.ts:55) +- Mapper tests that assume default constructors (update as needed): + - [`RacingOrmMappers.test.ts`](apps/api/src/persistence/postgres/typeorm/RacingOrmMappers.test.ts:20) + +**Acceptance tests** +- Add repository constructor tests mirroring the existing pattern in [`TypeOrmLeagueScoringConfigRepository.test.ts`](adapters/racing/persistence/typeorm/repositories/TypeOrmLeagueScoringConfigRepository.test.ts:17): + - new: `TypeOrmLeagueRepository.test.ts` asserts no internal `new LeagueOrmMapper()`. + - new: `TypeOrmRaceRepository.test.ts` asserts no internal `new RaceOrmMapper()`. + - new: `TypeOrmSeasonRepository.test.ts` asserts no internal `new SeasonOrmMapper()`. +- `tsc` should enforce injection by making mapper a required constructor param (strongest guard). + +**Definition of done** +- No repository has a default `new Mapper()` in constructor params. +- Nest module provides mapper instances and injects them into repositories. + +--- + +### Slice 3 (optional) — Postgres integration tests + minimal vertical verification + +**Goal** +- Verify the new strict schemas + rehydrate semantics survive real persistence roundtrips. + +**Files to touch (exact)** +- Existing integration test: + - [`PostgresLeagueScheduleRepositorySlice.int.test.ts`](apps/api/src/persistence/postgres/typeorm/PostgresLeagueScheduleRepositorySlice.int.test.ts:31) +- Potentially add one focused mapper+repo roundtrip test per aggregate if gaps remain: + - extend [`PostgresLeagueScheduleRepositorySlice.int.test.ts`](apps/api/src/persistence/postgres/typeorm/PostgresLeagueScheduleRepositorySlice.int.test.ts:55) rather than adding many files. + +**Acceptance tests** +- With `DATABASE_URL` set, integration suite passes and persists/reads: + - League with settings JSON + - Season with nullable JSON configs + - Race with status/sessionType mapping + - LeagueScoringConfig with championships JSON (already covered) + +--- + +## Notes on current wiring (for context) + +- Nest composition root is already the correct place for construction: + - [`PostgresRacingPersistenceModule`](apps/api/src/persistence/postgres/PostgresRacingPersistenceModule.ts:57) +- But it currently relies on repository default constructors for some mappers: + - e.g. provides [`TypeOrmLeagueRepository`](adapters/racing/persistence/typeorm/repositories/TypeOrmLeagueRepository.ts:9) via `new TypeOrmLeagueRepository(dataSource)` in [`PostgresRacingPersistenceModule`](apps/api/src/persistence/postgres/PostgresRacingPersistenceModule.ts:66) +- League scoring config is already composed explicitly (good reference): + - constructs `PointsTableJsonMapper -> ChampionshipConfigJsonMapper -> LeagueScoringConfigOrmMapper` in [`PostgresRacingPersistenceModule`](apps/api/src/persistence/postgres/PostgresRacingPersistenceModule.ts:126) + +--- + +## Top blockers (short list) + +- Persisted entity mapping calls `create()` instead of `rehydrate()`: + - [`LeagueOrmMapper.toDomain()`](adapters/racing/persistence/typeorm/mappers/LeagueOrmMapper.ts:46) + - [`RaceOrmMapper.toDomain()`](adapters/racing/persistence/typeorm/mappers/RaceOrmMapper.ts:23) + - [`SeasonOrmMapper.toDomain()`](adapters/racing/persistence/typeorm/mappers/SeasonOrmMapper.ts:26) +- Repositories build mappers internally (construction not confined to composition root): + - [`TypeOrmLeagueRepository.constructor()`](adapters/racing/persistence/typeorm/repositories/TypeOrmLeagueRepository.ts:10) + - [`TypeOrmRaceRepository.constructor()`](adapters/racing/persistence/typeorm/repositories/TypeOrmRaceRepository.ts:10) + - [`TypeOrmSeasonRepository.constructor()`](adapters/racing/persistence/typeorm/repositories/TypeOrmSeasonRepository.ts:10) +- Untyped JSON columns and `as any` casts prevent strict translation and reliable schema error handling: + - [`LeagueOrmEntity.settings`](adapters/racing/persistence/typeorm/entities/LeagueOrmEntity.ts:17) + - [`SeasonOrmEntity.schedule`](adapters/racing/persistence/typeorm/entities/SeasonOrmEntity.ts:32) + - [`RaceOrmMapper.toDomain()`](adapters/racing/persistence/typeorm/mappers/RaceOrmMapper.ts:23) + - [`SeasonOrmMapper.toDomain()`](adapters/racing/persistence/typeorm/mappers/SeasonOrmMapper.ts:26) \ No newline at end of file