diff --git a/adapters/analytics/persistence/typeorm/entities/AnalyticsSnapshotOrmEntity.ts b/adapters/analytics/persistence/typeorm/entities/AnalyticsSnapshotOrmEntity.ts new file mode 100644 index 000000000..f0c5a8c3c --- /dev/null +++ b/adapters/analytics/persistence/typeorm/entities/AnalyticsSnapshotOrmEntity.ts @@ -0,0 +1,33 @@ +import { Column, Entity, Index, PrimaryColumn } from 'typeorm'; + +import type { SnapshotEntityType, SnapshotPeriod } from '@core/analytics/domain/entities/AnalyticsSnapshot'; +import type { AnalyticsMetrics } from '@core/analytics/domain/types/AnalyticsSnapshot'; + +@Index('IDX_analytics_snapshots_entity', ['entityType', 'entityId', 'period']) +@Index('IDX_analytics_snapshots_date_range', ['startDate', 'endDate']) +@Entity({ name: 'analytics_snapshots' }) +export class AnalyticsSnapshotOrmEntity { + @PrimaryColumn({ type: 'text' }) + id!: string; + + @Column({ type: 'text' }) + entityType!: SnapshotEntityType; + + @Column({ type: 'text' }) + entityId!: string; + + @Column({ type: 'text' }) + period!: SnapshotPeriod; + + @Column({ type: 'timestamptz' }) + startDate!: Date; + + @Column({ type: 'timestamptz' }) + endDate!: Date; + + @Column({ type: 'jsonb' }) + metrics!: AnalyticsMetrics; + + @Column({ type: 'timestamptz' }) + createdAt!: Date; +} \ No newline at end of file diff --git a/adapters/analytics/persistence/typeorm/entities/EngagementEventOrmEntity.ts b/adapters/analytics/persistence/typeorm/entities/EngagementEventOrmEntity.ts new file mode 100644 index 000000000..160f7ef0a --- /dev/null +++ b/adapters/analytics/persistence/typeorm/entities/EngagementEventOrmEntity.ts @@ -0,0 +1,36 @@ +import { Column, Entity, Index, PrimaryColumn } from 'typeorm'; + +import type { EngagementAction, EngagementEntityType } from '@core/analytics/domain/entities/EngagementEvent'; + +@Index('IDX_analytics_engagement_events_entity', ['entityType', 'entityId']) +@Index('IDX_analytics_engagement_events_action', ['action']) +@Index('IDX_analytics_engagement_events_timestamp', ['timestamp']) +@Entity({ name: 'analytics_engagement_events' }) +export class EngagementEventOrmEntity { + @PrimaryColumn({ type: 'text' }) + id!: string; + + @Column({ type: 'text' }) + action!: EngagementAction; + + @Column({ type: 'text' }) + entityType!: EngagementEntityType; + + @Column({ type: 'text' }) + entityId!: string; + + @Column({ type: 'text', nullable: true }) + actorId!: string | null; + + @Column({ type: 'text' }) + actorType!: 'anonymous' | 'driver' | 'sponsor'; + + @Column({ type: 'text' }) + sessionId!: string; + + @Column({ type: 'jsonb', nullable: true }) + metadata!: Record | null; + + @Column({ type: 'timestamptz' }) + timestamp!: Date; +} \ No newline at end of file diff --git a/adapters/analytics/persistence/typeorm/entities/PageViewOrmEntity.ts b/adapters/analytics/persistence/typeorm/entities/PageViewOrmEntity.ts new file mode 100644 index 000000000..0d1a8ed81 --- /dev/null +++ b/adapters/analytics/persistence/typeorm/entities/PageViewOrmEntity.ts @@ -0,0 +1,42 @@ +import { Column, Entity, Index, PrimaryColumn } from 'typeorm'; + +import type { EntityType, VisitorType } from '@core/analytics/domain/entities/PageView'; + +@Index('IDX_analytics_page_views_entity', ['entityType', 'entityId']) +@Index('IDX_analytics_page_views_session', ['sessionId']) +@Index('IDX_analytics_page_views_timestamp', ['timestamp']) +@Entity({ name: 'analytics_page_views' }) +export class PageViewOrmEntity { + @PrimaryColumn({ type: 'text' }) + id!: string; + + @Column({ type: 'text' }) + entityType!: EntityType; + + @Column({ type: 'text' }) + entityId!: string; + + @Column({ type: 'text', nullable: true }) + visitorId!: string | null; + + @Column({ type: 'text' }) + visitorType!: VisitorType; + + @Column({ type: 'text' }) + sessionId!: string; + + @Column({ type: 'text', nullable: true }) + referrer!: string | null; + + @Column({ type: 'text', nullable: true }) + userAgent!: string | null; + + @Column({ type: 'text', nullable: true }) + country!: string | null; + + @Column({ type: 'timestamptz' }) + timestamp!: Date; + + @Column({ type: 'int', nullable: true }) + durationMs!: number | null; +} \ No newline at end of file diff --git a/adapters/analytics/persistence/typeorm/errors/TypeOrmAnalyticsSchemaError.ts b/adapters/analytics/persistence/typeorm/errors/TypeOrmAnalyticsSchemaError.ts new file mode 100644 index 000000000..e09b712ca --- /dev/null +++ b/adapters/analytics/persistence/typeorm/errors/TypeOrmAnalyticsSchemaError.ts @@ -0,0 +1,33 @@ +export type TypeOrmAnalyticsSchemaErrorReason = + | 'missing' + | 'not_string' + | 'empty_string' + | 'not_number' + | 'not_integer' + | 'not_boolean' + | 'not_date' + | 'invalid_date' + | 'invalid_enum_value' + | 'not_object' + | 'invalid_shape'; + +export class TypeOrmAnalyticsSchemaError extends Error { + readonly entityName: string; + readonly fieldName: string; + readonly reason: TypeOrmAnalyticsSchemaErrorReason | (string & {}); + + constructor(params: { + entityName: string; + fieldName: string; + reason: TypeOrmAnalyticsSchemaError['reason']; + message?: string; + }) { + const { entityName, fieldName, reason, message } = params; + super(message); + + this.name = 'TypeOrmAnalyticsSchemaError'; + this.entityName = entityName; + this.fieldName = fieldName; + this.reason = reason; + } +} \ No newline at end of file diff --git a/adapters/analytics/persistence/typeorm/mappers/AnalyticsSnapshotOrmMapper.ts b/adapters/analytics/persistence/typeorm/mappers/AnalyticsSnapshotOrmMapper.ts new file mode 100644 index 000000000..e79fd090b --- /dev/null +++ b/adapters/analytics/persistence/typeorm/mappers/AnalyticsSnapshotOrmMapper.ts @@ -0,0 +1,78 @@ +import { AnalyticsSnapshot } from '@core/analytics/domain/entities/AnalyticsSnapshot'; +import type { AnalyticsMetrics, SnapshotEntityType, SnapshotPeriod } from '@core/analytics/domain/types/AnalyticsSnapshot'; + +import { TypeOrmAnalyticsSchemaError } from '../errors/TypeOrmAnalyticsSchemaError'; +import { AnalyticsSnapshotOrmEntity } from '../entities/AnalyticsSnapshotOrmEntity'; +import { assertDate, assertEnumValue, assertNonEmptyString, assertNumber, assertRecord } from '../schema/TypeOrmAnalyticsSchemaGuards'; + +const VALID_ENTITY_TYPES: readonly SnapshotEntityType[] = ['league', 'driver', 'team', 'race', 'sponsor'] as const; +const VALID_PERIODS: readonly SnapshotPeriod[] = ['daily', 'weekly', 'monthly'] as const; + +function assertMetrics(entityName: string, fieldName: string, value: unknown): asserts value is AnalyticsMetrics { + assertRecord(entityName, fieldName, value); + + const metrics = value as Record; + + assertNumber(entityName, `${fieldName}.pageViews`, metrics.pageViews); + assertNumber(entityName, `${fieldName}.uniqueVisitors`, metrics.uniqueVisitors); + assertNumber(entityName, `${fieldName}.avgSessionDuration`, metrics.avgSessionDuration); + assertNumber(entityName, `${fieldName}.bounceRate`, metrics.bounceRate); + assertNumber(entityName, `${fieldName}.engagementScore`, metrics.engagementScore); + assertNumber(entityName, `${fieldName}.sponsorClicks`, metrics.sponsorClicks); + assertNumber(entityName, `${fieldName}.sponsorUrlClicks`, metrics.sponsorUrlClicks); + assertNumber(entityName, `${fieldName}.socialShares`, metrics.socialShares); + assertNumber(entityName, `${fieldName}.leagueJoins`, metrics.leagueJoins); + assertNumber(entityName, `${fieldName}.raceRegistrations`, metrics.raceRegistrations); + assertNumber(entityName, `${fieldName}.exposureValue`, metrics.exposureValue); +} + +export class AnalyticsSnapshotOrmMapper { + toOrmEntity(domain: AnalyticsSnapshot): AnalyticsSnapshotOrmEntity { + const entity = new AnalyticsSnapshotOrmEntity(); + entity.id = domain.id; + entity.entityType = domain.entityType; + entity.entityId = domain.entityId; + entity.period = domain.period; + entity.startDate = domain.startDate; + entity.endDate = domain.endDate; + entity.metrics = domain.metrics; + entity.createdAt = domain.createdAt; + return entity; + } + + toDomain(entity: AnalyticsSnapshotOrmEntity): AnalyticsSnapshot { + const entityName = 'AnalyticsSnapshot'; + + try { + assertNonEmptyString(entityName, 'id', entity.id); + assertEnumValue(entityName, 'entityType', entity.entityType, VALID_ENTITY_TYPES); + assertNonEmptyString(entityName, 'entityId', entity.entityId); + assertEnumValue(entityName, 'period', entity.period, VALID_PERIODS); + + assertDate(entityName, 'startDate', entity.startDate); + assertDate(entityName, 'endDate', entity.endDate); + + assertMetrics(entityName, 'metrics', entity.metrics); + + assertDate(entityName, 'createdAt', entity.createdAt); + + return AnalyticsSnapshot.create({ + id: entity.id, + entityType: entity.entityType, + entityId: entity.entityId, + period: entity.period, + startDate: entity.startDate, + endDate: entity.endDate, + metrics: entity.metrics, + createdAt: entity.createdAt, + }); + } catch (error) { + if (error instanceof TypeOrmAnalyticsSchemaError) { + throw error; + } + + const message = error instanceof Error ? error.message : 'Invalid persisted AnalyticsSnapshot'; + throw new TypeOrmAnalyticsSchemaError({ entityName, fieldName: 'unknown', reason: 'invalid_shape', message }); + } + } +} \ No newline at end of file diff --git a/adapters/analytics/persistence/typeorm/mappers/EngagementEventOrmMapper.ts b/adapters/analytics/persistence/typeorm/mappers/EngagementEventOrmMapper.ts new file mode 100644 index 000000000..ae035433a --- /dev/null +++ b/adapters/analytics/persistence/typeorm/mappers/EngagementEventOrmMapper.ts @@ -0,0 +1,115 @@ +import { EngagementEvent } from '@core/analytics/domain/entities/EngagementEvent'; +import type { EngagementAction, EngagementEntityType } from '@core/analytics/domain/types/EngagementEvent'; + +import { TypeOrmAnalyticsSchemaError } from '../errors/TypeOrmAnalyticsSchemaError'; +import { EngagementEventOrmEntity } from '../entities/EngagementEventOrmEntity'; +import { + assertDate, + assertEnumValue, + assertNonEmptyString, + assertOptionalStringOrNull, + assertRecord, +} from '../schema/TypeOrmAnalyticsSchemaGuards'; + +const VALID_ACTIONS: readonly EngagementAction[] = [ + 'click_sponsor_logo', + 'click_sponsor_url', + 'download_livery_pack', + 'join_league', + 'register_race', + 'view_standings', + 'view_schedule', + 'share_social', + 'contact_sponsor', +] as const; + +const VALID_ENTITY_TYPES: readonly EngagementEntityType[] = [ + 'league', + 'driver', + 'team', + 'race', + 'sponsor', + 'sponsorship', +] as const; + +const VALID_ACTOR_TYPES: readonly EngagementEvent['actorType'][] = ['anonymous', 'driver', 'sponsor'] as const; + +function assertMetadataShape( + entityName: string, + fieldName: string, + value: unknown, +): asserts value is Record | null | undefined { + if (value === undefined || value === null) return; + + assertRecord(entityName, fieldName, value); + + for (const [k, v] of Object.entries(value)) { + const allowed = + typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean' || v === undefined || v === null; + if (!allowed) { + throw new TypeOrmAnalyticsSchemaError({ + entityName, + fieldName, + reason: 'invalid_shape', + message: `Invalid metadata value for key "${k}"`, + }); + } + } +} + +export class EngagementEventOrmMapper { + toOrmEntity(domain: EngagementEvent): EngagementEventOrmEntity { + const entity = new EngagementEventOrmEntity(); + entity.id = domain.id; + entity.action = domain.action; + entity.entityType = domain.entityType; + entity.entityId = domain.entityId; + + entity.actorId = domain.actorId ?? null; + entity.actorType = domain.actorType; + entity.sessionId = domain.sessionId; + + entity.metadata = domain.metadata ?? null; + entity.timestamp = domain.timestamp; + + return entity; + } + + toDomain(entity: EngagementEventOrmEntity): EngagementEvent { + const entityName = 'AnalyticsEngagementEvent'; + + try { + assertNonEmptyString(entityName, 'id', entity.id); + assertEnumValue(entityName, 'action', entity.action, VALID_ACTIONS); + assertEnumValue(entityName, 'entityType', entity.entityType, VALID_ENTITY_TYPES); + assertNonEmptyString(entityName, 'entityId', entity.entityId); + + assertOptionalStringOrNull(entityName, 'actorId', entity.actorId); + assertEnumValue(entityName, 'actorType', entity.actorType, VALID_ACTOR_TYPES); + + assertNonEmptyString(entityName, 'sessionId', entity.sessionId); + assertMetadataShape(entityName, 'metadata', entity.metadata); + + assertDate(entityName, 'timestamp', entity.timestamp); + + return EngagementEvent.create({ + id: entity.id, + action: entity.action, + entityType: entity.entityType, + entityId: entity.entityId, + actorType: entity.actorType, + sessionId: entity.sessionId, + timestamp: entity.timestamp, + ...(entity.actorId !== null && entity.actorId !== undefined ? { actorId: entity.actorId } : {}), + ...(entity.metadata !== null && entity.metadata !== undefined ? { metadata: entity.metadata } : {}), + }); + } catch (error) { + if (error instanceof TypeOrmAnalyticsSchemaError) { + throw error; + } + + const message = error instanceof Error ? error.message : 'Invalid persisted AnalyticsEngagementEvent'; + throw new TypeOrmAnalyticsSchemaError({ entityName, fieldName: 'unknown', reason: 'invalid_shape', message }); + } + } +} \ No newline at end of file diff --git a/adapters/analytics/persistence/typeorm/mappers/PageViewOrmMapper.test.ts b/adapters/analytics/persistence/typeorm/mappers/PageViewOrmMapper.test.ts new file mode 100644 index 000000000..44fd2de45 --- /dev/null +++ b/adapters/analytics/persistence/typeorm/mappers/PageViewOrmMapper.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest'; + +import { PageView } from '@core/analytics/domain/entities/PageView'; + +import { TypeOrmAnalyticsSchemaError } from '../errors/TypeOrmAnalyticsSchemaError'; +import { PageViewOrmEntity } from '../entities/PageViewOrmEntity'; +import { PageViewOrmMapper } from './PageViewOrmMapper'; + +describe('PageViewOrmMapper', () => { + it('maps domain -> orm -> domain (round-trip)', () => { + const mapper = new PageViewOrmMapper(); + + const domain = PageView.create({ + id: 'pv_1', + entityType: 'league', + entityId: 'league-1', + visitorType: 'anonymous', + sessionId: 'sess-1', + timestamp: new Date('2025-01-01T00:00:00.000Z'), + visitorId: 'visitor-1', + referrer: 'https://example.com', + userAgent: 'ua', + country: 'DE', + durationMs: 1234, + }); + + const orm = mapper.toOrmEntity(domain); + expect(orm).toBeInstanceOf(PageViewOrmEntity); + expect(orm.id).toBe(domain.id); + + const rehydrated = mapper.toDomain(orm); + expect(rehydrated.id).toBe(domain.id); + expect(rehydrated.entityType).toBe(domain.entityType); + expect(rehydrated.entityId).toBe(domain.entityId); + expect(rehydrated.sessionId).toBe(domain.sessionId); + expect(rehydrated.visitorType).toBe(domain.visitorType); + expect(rehydrated.visitorId).toBe(domain.visitorId); + expect(rehydrated.referrer).toBe(domain.referrer); + expect(rehydrated.userAgent).toBe(domain.userAgent); + expect(rehydrated.country).toBe(domain.country); + expect(rehydrated.durationMs).toBe(domain.durationMs); + expect(rehydrated.timestamp.toISOString()).toBe(domain.timestamp.toISOString()); + }); + + it('throws TypeOrmAnalyticsSchemaError for invalid persisted shape', () => { + const mapper = new PageViewOrmMapper(); + + const orm = new PageViewOrmEntity(); + orm.id = ''; + orm.entityType = 'league' as never; + orm.entityId = 'league-1'; + orm.visitorId = null; + orm.visitorType = 'anonymous' as never; + orm.sessionId = 'sess-1'; + orm.referrer = null; + orm.userAgent = null; + orm.country = null; + orm.timestamp = new Date('2025-01-01T00:00:00.000Z'); + orm.durationMs = null; + + expect(() => mapper.toDomain(orm)).toThrow(TypeOrmAnalyticsSchemaError); + }); +}); \ No newline at end of file diff --git a/adapters/analytics/persistence/typeorm/mappers/PageViewOrmMapper.ts b/adapters/analytics/persistence/typeorm/mappers/PageViewOrmMapper.ts new file mode 100644 index 000000000..17a4ab916 --- /dev/null +++ b/adapters/analytics/persistence/typeorm/mappers/PageViewOrmMapper.ts @@ -0,0 +1,80 @@ +import { PageView } from '@core/analytics/domain/entities/PageView'; +import type { EntityType, VisitorType } from '@core/analytics/domain/types/PageView'; + +import { TypeOrmAnalyticsSchemaError } from '../errors/TypeOrmAnalyticsSchemaError'; +import { PageViewOrmEntity } from '../entities/PageViewOrmEntity'; +import { + assertDate, + assertEnumValue, + assertNonEmptyString, + assertOptionalIntegerOrNull, + assertOptionalStringOrNull, +} from '../schema/TypeOrmAnalyticsSchemaGuards'; + +const VALID_ENTITY_TYPES: readonly EntityType[] = ['league', 'driver', 'team', 'race', 'sponsor'] as const; +const VALID_VISITOR_TYPES: readonly VisitorType[] = ['anonymous', 'driver', 'sponsor'] as const; + +export class PageViewOrmMapper { + toOrmEntity(domain: PageView): PageViewOrmEntity { + const entity = new PageViewOrmEntity(); + entity.id = domain.id; + entity.entityType = domain.entityType; + entity.entityId = domain.entityId; + + entity.visitorId = domain.visitorId ?? null; + entity.visitorType = domain.visitorType; + entity.sessionId = domain.sessionId; + + entity.referrer = domain.referrer ?? null; + entity.userAgent = domain.userAgent ?? null; + entity.country = domain.country ?? null; + + entity.timestamp = domain.timestamp; + entity.durationMs = domain.durationMs ?? null; + + return entity; + } + + toDomain(entity: PageViewOrmEntity): PageView { + const entityName = 'AnalyticsPageView'; + + try { + assertNonEmptyString(entityName, 'id', entity.id); + + assertEnumValue(entityName, 'entityType', entity.entityType, VALID_ENTITY_TYPES); + assertNonEmptyString(entityName, 'entityId', entity.entityId); + + assertOptionalStringOrNull(entityName, 'visitorId', entity.visitorId); + assertEnumValue(entityName, 'visitorType', entity.visitorType, VALID_VISITOR_TYPES); + assertNonEmptyString(entityName, 'sessionId', entity.sessionId); + + assertOptionalStringOrNull(entityName, 'referrer', entity.referrer); + assertOptionalStringOrNull(entityName, 'userAgent', entity.userAgent); + assertOptionalStringOrNull(entityName, 'country', entity.country); + + assertDate(entityName, 'timestamp', entity.timestamp); + assertOptionalIntegerOrNull(entityName, 'durationMs', entity.durationMs); + + return PageView.create({ + id: entity.id, + entityType: entity.entityType, + entityId: entity.entityId, + visitorType: entity.visitorType, + sessionId: entity.sessionId, + timestamp: entity.timestamp, + ...(entity.visitorId !== null && entity.visitorId !== undefined ? { visitorId: entity.visitorId } : {}), + ...(entity.referrer !== null && entity.referrer !== undefined ? { referrer: entity.referrer } : {}), + ...(entity.userAgent !== null && entity.userAgent !== undefined ? { userAgent: entity.userAgent } : {}), + ...(entity.country !== null && entity.country !== undefined ? { country: entity.country } : {}), + ...(entity.durationMs !== null && entity.durationMs !== undefined ? { durationMs: entity.durationMs } : {}), + }); + } catch (error) { + if (error instanceof TypeOrmAnalyticsSchemaError) { + throw error; + } + + const message = error instanceof Error ? error.message : 'Invalid persisted AnalyticsPageView'; + throw new TypeOrmAnalyticsSchemaError({ entityName, fieldName: 'unknown', reason: 'invalid_shape', message }); + } + } +} \ No newline at end of file diff --git a/adapters/analytics/persistence/typeorm/repositories/TypeOrmAnalyticsSnapshotRepository.ts b/adapters/analytics/persistence/typeorm/repositories/TypeOrmAnalyticsSnapshotRepository.ts new file mode 100644 index 000000000..3db83acc2 --- /dev/null +++ b/adapters/analytics/persistence/typeorm/repositories/TypeOrmAnalyticsSnapshotRepository.ts @@ -0,0 +1,82 @@ +import { type Repository } from 'typeorm'; + +import type { IAnalyticsSnapshotRepository } from '@core/analytics/domain/repositories/IAnalyticsSnapshotRepository'; +import type { SnapshotEntityType, SnapshotPeriod } from '@core/analytics/domain/types/AnalyticsSnapshot'; +import { AnalyticsSnapshot } from '@core/analytics/domain/entities/AnalyticsSnapshot'; + +import { AnalyticsSnapshotOrmEntity } from '../entities/AnalyticsSnapshotOrmEntity'; +import { AnalyticsSnapshotOrmMapper } from '../mappers/AnalyticsSnapshotOrmMapper'; + +export class TypeOrmAnalyticsSnapshotRepository implements IAnalyticsSnapshotRepository { + constructor( + private readonly snapshotRepo: Repository, + private readonly mapper: AnalyticsSnapshotOrmMapper, + ) {} + + async save(snapshot: AnalyticsSnapshot): Promise { + await this.snapshotRepo.save(this.mapper.toOrmEntity(snapshot)); + } + + async findById(id: string): Promise { + const entity = await this.snapshotRepo.findOneBy({ id }); + return entity ? this.mapper.toDomain(entity) : null; + } + + async findByEntity(entityType: SnapshotEntityType, entityId: string): Promise { + const entities = await this.snapshotRepo.find({ + where: { entityType, entityId }, + order: { endDate: 'DESC' }, + }); + + return entities.map((e) => this.mapper.toDomain(e)); + } + + async findByPeriod( + entityType: SnapshotEntityType, + entityId: string, + period: SnapshotPeriod, + startDate: Date, + endDate: Date, + ): Promise { + const qb = this.snapshotRepo + .createQueryBuilder('s') + .where('s.entityType = :entityType', { entityType }) + .andWhere('s.entityId = :entityId', { entityId }) + .andWhere('s.period = :period', { period }) + .andWhere('s.startDate >= :startDate', { startDate }) + .andWhere('s.endDate <= :endDate', { endDate }) + .orderBy('s.endDate', 'DESC') + .limit(1); + + const entity = await qb.getOne(); + return entity ? this.mapper.toDomain(entity) : null; + } + + async findLatest( + entityType: SnapshotEntityType, + entityId: string, + period: SnapshotPeriod, + ): Promise { + const entity = await this.snapshotRepo.findOne({ + where: { entityType, entityId, period }, + order: { endDate: 'DESC' }, + }); + + return entity ? this.mapper.toDomain(entity) : null; + } + + async getHistoricalSnapshots( + entityType: SnapshotEntityType, + entityId: string, + period: SnapshotPeriod, + limit: number, + ): Promise { + const entities = await this.snapshotRepo.find({ + where: { entityType, entityId, period }, + order: { endDate: 'DESC' }, + ...(typeof limit === 'number' ? { take: limit } : {}), + }); + + return entities.map((e) => this.mapper.toDomain(e)); + } +} \ No newline at end of file diff --git a/adapters/analytics/persistence/typeorm/repositories/TypeOrmEngagementRepository.ts b/adapters/analytics/persistence/typeorm/repositories/TypeOrmEngagementRepository.ts new file mode 100644 index 000000000..4960ba2b7 --- /dev/null +++ b/adapters/analytics/persistence/typeorm/repositories/TypeOrmEngagementRepository.ts @@ -0,0 +1,80 @@ +import { Between, MoreThanOrEqual, type FindOptionsWhere, type Repository } from 'typeorm'; + +import type { IEngagementRepository } from '@core/analytics/domain/repositories/IEngagementRepository'; +import type { EngagementAction, EngagementEntityType } from '@core/analytics/domain/types/EngagementEvent'; +import { EngagementEvent } from '@core/analytics/domain/entities/EngagementEvent'; + +import { EngagementEventOrmEntity } from '../entities/EngagementEventOrmEntity'; +import { EngagementEventOrmMapper } from '../mappers/EngagementEventOrmMapper'; + +export class TypeOrmEngagementRepository implements IEngagementRepository { + constructor( + private readonly engagementRepo: Repository, + private readonly mapper: EngagementEventOrmMapper, + ) {} + + async save(event: EngagementEvent): Promise { + await this.engagementRepo.save(this.mapper.toOrmEntity(event)); + } + + async findById(id: string): Promise { + const entity = await this.engagementRepo.findOneBy({ id }); + return entity ? this.mapper.toDomain(entity) : null; + } + + async findByEntityId(entityType: EngagementEntityType, entityId: string): Promise { + const entities = await this.engagementRepo.find({ + where: { entityType, entityId }, + order: { timestamp: 'DESC' }, + }); + + return entities.map((e) => this.mapper.toDomain(e)); + } + + async findByAction(action: EngagementAction): Promise { + const entities = await this.engagementRepo.find({ + where: { action }, + order: { timestamp: 'DESC' }, + }); + + return entities.map((e) => this.mapper.toDomain(e)); + } + + async findByDateRange(startDate: Date, endDate: Date): Promise { + const entities = await this.engagementRepo.find({ + where: { timestamp: Between(startDate, endDate) }, + order: { timestamp: 'DESC' }, + }); + + return entities.map((e) => this.mapper.toDomain(e)); + } + + async countByAction(action: EngagementAction, entityId?: string, since?: Date): Promise { + const where: FindOptionsWhere = { action }; + + if (entityId) { + where.entityId = entityId; + } + + if (since) { + where.timestamp = MoreThanOrEqual(since); + } + + return this.engagementRepo.count({ where }); + } + + async getSponsorClicksForEntity(entityId: string, since?: Date): Promise { + const actions: EngagementAction[] = ['click_sponsor_logo', 'click_sponsor_url']; + + const qb = this.engagementRepo + .createQueryBuilder('e') + .where('e.entityId = :entityId', { entityId }) + .andWhere('e.action IN (:...actions)', { actions }); + + if (since) { + qb.andWhere('e.timestamp >= :since', { since }); + } + + return qb.getCount(); + } +} \ No newline at end of file diff --git a/adapters/analytics/persistence/typeorm/repositories/TypeOrmPageViewRepository.test.ts b/adapters/analytics/persistence/typeorm/repositories/TypeOrmPageViewRepository.test.ts new file mode 100644 index 000000000..fa45e37fb --- /dev/null +++ b/adapters/analytics/persistence/typeorm/repositories/TypeOrmPageViewRepository.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { Repository } from 'typeorm'; + +import { PageView } from '@core/analytics/domain/entities/PageView'; + +import { PageViewOrmEntity } from '../entities/PageViewOrmEntity'; +import { PageViewOrmMapper } from '../mappers/PageViewOrmMapper'; +import { TypeOrmPageViewRepository } from './TypeOrmPageViewRepository'; + +describe('TypeOrmPageViewRepository', () => { + it('saves mapped entities via injected mapper', async () => { + const orm = new PageViewOrmEntity(); + orm.id = 'pv_1'; + orm.entityType = 'league'; + orm.entityId = 'league-1'; + orm.visitorId = null; + orm.visitorType = 'anonymous'; + orm.sessionId = 'sess-1'; + orm.referrer = null; + orm.userAgent = null; + orm.country = null; + orm.timestamp = new Date('2025-01-01T00:00:00.000Z'); + orm.durationMs = null; + + const mapper: PageViewOrmMapper = { + toOrmEntity: vi.fn().mockReturnValue(orm), + toDomain: vi.fn(), + } as unknown as PageViewOrmMapper; + + const repo: Repository = { + save: vi.fn().mockResolvedValue(orm), + } as unknown as Repository; + + const sut = new TypeOrmPageViewRepository(repo, mapper); + + const domain = PageView.create({ + id: 'pv_1', + entityType: 'league', + entityId: 'league-1', + visitorType: 'anonymous', + sessionId: 'sess-1', + timestamp: new Date('2025-01-01T00:00:00.000Z'), + }); + + await sut.save(domain); + + expect(mapper.toOrmEntity).toHaveBeenCalledTimes(1); + expect(repo.save).toHaveBeenCalledWith(orm); + }); + + it('findById maps entity -> domain', async () => { + const orm = new PageViewOrmEntity(); + orm.id = 'pv_1'; + orm.entityType = 'league'; + orm.entityId = 'league-1'; + orm.visitorId = null; + orm.visitorType = 'anonymous'; + orm.sessionId = 'sess-1'; + orm.referrer = null; + orm.userAgent = null; + orm.country = null; + orm.timestamp = new Date('2025-01-01T00:00:00.000Z'); + orm.durationMs = null; + + const domain = PageView.create({ + id: 'pv_1', + entityType: 'league', + entityId: 'league-1', + visitorType: 'anonymous', + sessionId: 'sess-1', + timestamp: new Date('2025-01-01T00:00:00.000Z'), + }); + + const mapper: PageViewOrmMapper = { + toOrmEntity: vi.fn(), + toDomain: vi.fn().mockReturnValue(domain), + } as unknown as PageViewOrmMapper; + + const repo: Repository = { + findOneBy: vi.fn().mockResolvedValue(orm), + } as unknown as Repository; + + const sut = new TypeOrmPageViewRepository(repo, mapper); + + const result = await sut.findById('pv_1'); + + expect(repo.findOneBy).toHaveBeenCalledWith({ id: 'pv_1' }); + expect(mapper.toDomain).toHaveBeenCalledWith(orm); + expect(result?.id).toBe('pv_1'); + }); +}); \ No newline at end of file diff --git a/adapters/analytics/persistence/typeorm/repositories/TypeOrmPageViewRepository.ts b/adapters/analytics/persistence/typeorm/repositories/TypeOrmPageViewRepository.ts new file mode 100644 index 000000000..fe7704e2a --- /dev/null +++ b/adapters/analytics/persistence/typeorm/repositories/TypeOrmPageViewRepository.ts @@ -0,0 +1,82 @@ +import { Between, MoreThanOrEqual, type Repository } from 'typeorm'; + +import type { IPageViewRepository } from '@core/analytics/application/repositories/IPageViewRepository'; +import type { EntityType } from '@core/analytics/domain/types/PageView'; +import { PageView } from '@core/analytics/domain/entities/PageView'; + +import { PageViewOrmEntity } from '../entities/PageViewOrmEntity'; +import { PageViewOrmMapper } from '../mappers/PageViewOrmMapper'; + +type CountRow = { count: string | number } | null | undefined; + +export class TypeOrmPageViewRepository implements IPageViewRepository { + constructor( + private readonly pageViewRepo: Repository, + private readonly mapper: PageViewOrmMapper, + ) {} + + async save(pageView: PageView): Promise { + await this.pageViewRepo.save(this.mapper.toOrmEntity(pageView)); + } + + async findById(id: string): Promise { + const entity = await this.pageViewRepo.findOneBy({ id }); + return entity ? this.mapper.toDomain(entity) : null; + } + + async findByEntityId(entityType: EntityType, entityId: string): Promise { + const entities = await this.pageViewRepo.find({ + where: { entityType, entityId }, + order: { timestamp: 'DESC' }, + }); + + return entities.map((e) => this.mapper.toDomain(e)); + } + + async findByDateRange(startDate: Date, endDate: Date): Promise { + const entities = await this.pageViewRepo.find({ + where: { timestamp: Between(startDate, endDate) }, + order: { timestamp: 'DESC' }, + }); + + return entities.map((e) => this.mapper.toDomain(e)); + } + + async findBySession(sessionId: string): Promise { + const entities = await this.pageViewRepo.find({ + where: { sessionId }, + order: { timestamp: 'DESC' }, + }); + + return entities.map((e) => this.mapper.toDomain(e)); + } + + async countByEntityId(entityType: EntityType, entityId: string, since?: Date): Promise { + if (!since) { + return this.pageViewRepo.count({ where: { entityType, entityId } }); + } + + return this.pageViewRepo.count({ + where: { entityType, entityId, timestamp: MoreThanOrEqual(since) }, + }); + } + + async countUniqueVisitors(entityType: EntityType, entityId: string, since?: Date): Promise { + const qb = this.pageViewRepo + .createQueryBuilder('pv') + .select('COUNT(DISTINCT COALESCE(pv.visitorId, pv.sessionId))', 'count') + .where('pv.entityType = :entityType', { entityType }) + .andWhere('pv.entityId = :entityId', { entityId }); + + if (since) { + qb.andWhere('pv.timestamp >= :since', { since }); + } + + const row: CountRow = await qb.getRawOne(); + const raw = row?.count; + + if (typeof raw === 'number') return raw; + if (typeof raw === 'string') return Number.parseInt(raw, 10); + return 0; + } +} \ No newline at end of file diff --git a/adapters/analytics/persistence/typeorm/schema/TypeOrmAnalyticsSchemaGuards.ts b/adapters/analytics/persistence/typeorm/schema/TypeOrmAnalyticsSchemaGuards.ts new file mode 100644 index 000000000..9e5e3c0d2 --- /dev/null +++ b/adapters/analytics/persistence/typeorm/schema/TypeOrmAnalyticsSchemaGuards.ts @@ -0,0 +1,128 @@ +import { TypeOrmAnalyticsSchemaError } from '../errors/TypeOrmAnalyticsSchemaError'; + +export function assertNonEmptyString(entityName: string, fieldName: string, value: unknown): asserts value is string { + if (value === undefined || value === null) { + throw new TypeOrmAnalyticsSchemaError({ entityName, fieldName, reason: 'missing' }); + } + if (typeof value !== 'string') { + throw new TypeOrmAnalyticsSchemaError({ entityName, fieldName, reason: 'not_string' }); + } + if (value.trim().length === 0) { + throw new TypeOrmAnalyticsSchemaError({ entityName, fieldName, reason: 'empty_string' }); + } +} + +export function assertOptionalStringOrNull( + entityName: string, + fieldName: string, + value: unknown, +): asserts value is string | null | undefined { + if (value === undefined || value === null) return; + if (typeof value !== 'string') { + throw new TypeOrmAnalyticsSchemaError({ entityName, fieldName, reason: 'not_string' }); + } +} + +export function assertNumber(entityName: string, fieldName: string, value: unknown): asserts value is number { + if (value === undefined || value === null) { + throw new TypeOrmAnalyticsSchemaError({ entityName, fieldName, reason: 'missing' }); + } + if (typeof value !== 'number' || Number.isNaN(value)) { + throw new TypeOrmAnalyticsSchemaError({ entityName, fieldName, reason: 'not_number' }); + } +} + +export function assertOptionalNumberOrNull( + entityName: string, + fieldName: string, + value: unknown, +): asserts value is number | null | undefined { + if (value === undefined || value === null) return; + if (typeof value !== 'number' || Number.isNaN(value)) { + throw new TypeOrmAnalyticsSchemaError({ entityName, fieldName, reason: 'not_number' }); + } +} + +export function assertInteger(entityName: string, fieldName: string, value: unknown): asserts value is number { + if (value === undefined || value === null) { + throw new TypeOrmAnalyticsSchemaError({ entityName, fieldName, reason: 'missing' }); + } + if (typeof value !== 'number' || !Number.isInteger(value)) { + throw new TypeOrmAnalyticsSchemaError({ entityName, fieldName, reason: 'not_integer' }); + } +} + +export function assertOptionalIntegerOrNull( + entityName: string, + fieldName: string, + value: unknown, +): asserts value is number | null | undefined { + if (value === undefined || value === null) return; + if (typeof value !== 'number' || !Number.isInteger(value)) { + throw new TypeOrmAnalyticsSchemaError({ entityName, fieldName, reason: 'not_integer' }); + } +} + +export function assertBoolean(entityName: string, fieldName: string, value: unknown): asserts value is boolean { + if (value === undefined || value === null) { + throw new TypeOrmAnalyticsSchemaError({ entityName, fieldName, reason: 'missing' }); + } + if (typeof value !== 'boolean') { + throw new TypeOrmAnalyticsSchemaError({ entityName, fieldName, reason: 'not_boolean' }); + } +} + +export function assertDate(entityName: string, fieldName: string, value: unknown): asserts value is Date { + if (value === undefined || value === null) { + throw new TypeOrmAnalyticsSchemaError({ entityName, fieldName, reason: 'missing' }); + } + if (!(value instanceof Date)) { + throw new TypeOrmAnalyticsSchemaError({ entityName, fieldName, reason: 'not_date' }); + } + if (Number.isNaN(value.getTime())) { + throw new TypeOrmAnalyticsSchemaError({ entityName, fieldName, reason: 'invalid_date' }); + } +} + +export function assertEnumValue( + entityName: string, + fieldName: string, + value: unknown, + allowed: readonly TAllowed[], +): asserts value is TAllowed { + if (value === undefined || value === null) { + throw new TypeOrmAnalyticsSchemaError({ entityName, fieldName, reason: 'missing' }); + } + if (typeof value !== 'string') { + throw new TypeOrmAnalyticsSchemaError({ entityName, fieldName, reason: 'not_string' }); + } + if (!allowed.includes(value as TAllowed)) { + throw new TypeOrmAnalyticsSchemaError({ entityName, fieldName, reason: 'invalid_enum_value' }); + } +} + +export function assertRecord( + entityName: string, + fieldName: string, + value: unknown, +): asserts value is Record { + if (value === undefined || value === null) { + throw new TypeOrmAnalyticsSchemaError({ entityName, fieldName, reason: 'missing' }); + } + if (typeof value !== 'object' || Array.isArray(value)) { + throw new TypeOrmAnalyticsSchemaError({ entityName, fieldName, reason: 'not_object' }); + } +} + +export const TypeOrmAnalyticsSchemaGuards = { + assertNonEmptyString, + assertOptionalStringOrNull, + assertNumber, + assertOptionalNumberOrNull, + assertInteger, + assertOptionalIntegerOrNull, + assertBoolean, + assertDate, + assertEnumValue, + assertRecord, +}; \ No newline at end of file diff --git a/adapters/identity/persistence/typeorm/TypeOrmAuthRepository.ts b/adapters/identity/persistence/typeorm/TypeOrmAuthRepository.ts new file mode 100644 index 000000000..79077ae3d --- /dev/null +++ b/adapters/identity/persistence/typeorm/TypeOrmAuthRepository.ts @@ -0,0 +1,49 @@ +import type { DataSource } from 'typeorm'; + +import { User } from '@core/identity/domain/entities/User'; +import type { IAuthRepository } from '@core/identity/domain/repositories/IAuthRepository'; +import type { EmailAddress } from '@core/identity/domain/value-objects/EmailAddress'; + +import { UserOrmEntity } from './entities/UserOrmEntity'; +import { UserOrmMapper } from './mappers/UserOrmMapper'; + +export class TypeOrmAuthRepository implements IAuthRepository { + constructor( + private readonly dataSource: DataSource, + private readonly mapper: UserOrmMapper, + ) {} + + async findByEmail(email: EmailAddress): Promise { + const repo = this.dataSource.getRepository(UserOrmEntity); + const entity = await repo.findOne({ where: { email: email.value.toLowerCase() } }); + return entity ? this.mapper.toDomain(entity) : null; + } + + async save(user: User): Promise { + const repo = this.dataSource.getRepository(UserOrmEntity); + + const id = user.getId().value; + const email = user.getEmail(); + const passwordHash = user.getPasswordHash()?.value; + + if (!email) { + throw new Error('Cannot persist user without email'); + } + if (!passwordHash) { + throw new Error('Cannot persist user without password hash'); + } + + const existing = await repo.findOne({ where: { id } }); + + const entity = new UserOrmEntity(); + entity.id = id; + entity.email = email.toLowerCase(); + entity.displayName = user.getDisplayName(); + entity.passwordHash = passwordHash; + entity.salt = ''; + entity.primaryDriverId = user.getPrimaryDriverId() ?? null; + entity.createdAt = existing?.createdAt ?? new Date(); + + await repo.save(entity); + } +} \ No newline at end of file diff --git a/adapters/identity/persistence/typeorm/TypeOrmUserRepository.test.ts b/adapters/identity/persistence/typeorm/TypeOrmUserRepository.test.ts new file mode 100644 index 000000000..9d0629cb6 --- /dev/null +++ b/adapters/identity/persistence/typeorm/TypeOrmUserRepository.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 { TypeOrmUserRepository } from './TypeOrmUserRepository'; + +describe('TypeOrmUserRepository', () => { + it('does not construct its own mapper dependencies', () => { + const sourcePath = path.resolve(__dirname, 'TypeOrmUserRepository.ts'); + const source = fs.readFileSync(sourcePath, 'utf8'); + + expect(source).not.toMatch(/new\s+UserOrmMapper\s*\(/); + expect(source).not.toMatch(/=\s*new\s+UserOrmMapper\s*\(/); + }); + + it('requires mapper injection via constructor (no default mapper)', () => { + expect(TypeOrmUserRepository.length).toBe(2); + }); + + it('uses the injected mapper at runtime (DB-free)', async () => { + const ormRepo = { + findOne: vi.fn().mockResolvedValue({ id: 'u1' }), + }; + + const dataSource = { + getRepository: vi.fn().mockReturnValue(ormRepo), + }; + + const mapper = { + toStored: vi.fn().mockReturnValue({ id: 'stored-u1' }), + toOrmEntity: vi.fn(), + }; + + const repo = new TypeOrmUserRepository(dataSource as any, mapper as any); + + const user = await repo.findByEmail('ALICE@EXAMPLE.COM'); + + expect(dataSource.getRepository).toHaveBeenCalledTimes(1); + expect(ormRepo.findOne).toHaveBeenCalledTimes(1); + expect(mapper.toStored).toHaveBeenCalledTimes(1); + expect(user).toEqual({ id: 'stored-u1' }); + }); +}); \ No newline at end of file diff --git a/adapters/identity/persistence/typeorm/TypeOrmUserRepository.ts b/adapters/identity/persistence/typeorm/TypeOrmUserRepository.ts new file mode 100644 index 000000000..9ff3bb490 --- /dev/null +++ b/adapters/identity/persistence/typeorm/TypeOrmUserRepository.ts @@ -0,0 +1,51 @@ +import type { DataSource } from 'typeorm'; + +import type { IUserRepository, StoredUser } from '@core/identity/domain/repositories/IUserRepository'; + +import { UserOrmEntity } from './entities/UserOrmEntity'; +import { UserOrmMapper } from './mappers/UserOrmMapper'; + +export class TypeOrmUserRepository implements IUserRepository { + constructor( + private readonly dataSource: DataSource, + private readonly mapper: UserOrmMapper, + ) {} + + async findByEmail(email: string): Promise { + const repo = this.dataSource.getRepository(UserOrmEntity); + const entity = await repo.findOne({ where: { email: email.toLowerCase() } }); + return entity ? this.mapper.toStored(entity) : null; + } + + async findById(id: string): Promise { + const repo = this.dataSource.getRepository(UserOrmEntity); + const entity = await repo.findOne({ where: { id } }); + return entity ? this.mapper.toStored(entity) : null; + } + + async create(user: StoredUser): Promise { + const repo = this.dataSource.getRepository(UserOrmEntity); + const entity = this.mapper.toOrmEntity({ + ...user, + email: user.email.toLowerCase(), + }); + await repo.save(entity); + return user; + } + + async update(user: StoredUser): Promise { + const repo = this.dataSource.getRepository(UserOrmEntity); + const entity = this.mapper.toOrmEntity({ + ...user, + email: user.email.toLowerCase(), + }); + await repo.save(entity); + return user; + } + + async emailExists(email: string): Promise { + const repo = this.dataSource.getRepository(UserOrmEntity); + const count = await repo.count({ where: { email: email.toLowerCase() } }); + return count > 0; + } +} \ No newline at end of file diff --git a/adapters/identity/persistence/typeorm/entities/UserOrmEntity.ts b/adapters/identity/persistence/typeorm/entities/UserOrmEntity.ts new file mode 100644 index 000000000..3778ffb35 --- /dev/null +++ b/adapters/identity/persistence/typeorm/entities/UserOrmEntity.ts @@ -0,0 +1,26 @@ +import { Column, CreateDateColumn, Entity, Index, PrimaryColumn } from 'typeorm'; + +@Entity({ name: 'identity_users' }) +export class UserOrmEntity { + @PrimaryColumn({ type: 'uuid' }) + id!: string; + + @Index({ unique: true }) + @Column({ type: 'text' }) + email!: string; + + @Column({ type: 'text' }) + displayName!: string; + + @Column({ type: 'text' }) + passwordHash!: string; + + @Column({ type: 'text' }) + salt!: string; + + @Column({ type: 'text', nullable: true }) + primaryDriverId!: string | null; + + @CreateDateColumn({ type: 'timestamptz' }) + createdAt!: Date; +} \ No newline at end of file diff --git a/adapters/identity/persistence/typeorm/errors/TypeOrmIdentitySchemaError.ts b/adapters/identity/persistence/typeorm/errors/TypeOrmIdentitySchemaError.ts new file mode 100644 index 000000000..26939bde2 --- /dev/null +++ b/adapters/identity/persistence/typeorm/errors/TypeOrmIdentitySchemaError.ts @@ -0,0 +1,34 @@ +export type TypeOrmIdentitySchemaErrorReason = + | 'missing' + | 'not_string' + | 'empty_string' + | 'not_number' + | 'not_integer' + | 'not_boolean' + | 'not_date' + | 'invalid_date' + | 'not_iso_date' + | 'not_array' + | 'not_object' + | 'invalid_enum_value' + | 'invalid_shape'; + +export class TypeOrmIdentitySchemaError extends Error { + readonly entityName: string; + readonly fieldName: string; + readonly reason: TypeOrmIdentitySchemaErrorReason | (string & {}); + + constructor(params: { + entityName: string; + fieldName: string; + reason: TypeOrmIdentitySchemaError['reason']; + message?: string; + }) { + const message = params.message ?? `Invalid persisted ${params.entityName}.${params.fieldName}: ${params.reason}`; + super(message); + this.name = 'TypeOrmIdentitySchemaError'; + this.entityName = params.entityName; + this.fieldName = params.fieldName; + this.reason = params.reason; + } +} \ No newline at end of file diff --git a/adapters/identity/persistence/typeorm/mappers/UserOrmMapper.test.ts b/adapters/identity/persistence/typeorm/mappers/UserOrmMapper.test.ts new file mode 100644 index 000000000..566033e30 --- /dev/null +++ b/adapters/identity/persistence/typeorm/mappers/UserOrmMapper.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { User } from '@core/identity/domain/entities/User'; + +import { UserOrmEntity } from '../entities/UserOrmEntity'; +import { TypeOrmIdentitySchemaError } from '../errors/TypeOrmIdentitySchemaError'; +import { UserOrmMapper } from './UserOrmMapper'; + +describe('UserOrmMapper', () => { + it('toDomain preserves persisted identity and uses rehydrate semantics (does not call create)', () => { + const mapper = new UserOrmMapper(); + + const entity = new UserOrmEntity(); + entity.id = '00000000-0000-4000-8000-000000000001'; + entity.email = 'alice@example.com'; + entity.displayName = 'Alice'; + entity.passwordHash = 'bcrypt-hash'; + entity.salt = ''; + entity.primaryDriverId = null; + entity.createdAt = new Date('2025-01-01T00:00:00.000Z'); + + if (typeof (User as unknown as { rehydrate?: unknown }).rehydrate !== 'function') { + throw new Error('rehydrate-missing'); + } + + const rehydrateSpy = vi.spyOn(User as unknown as { rehydrate: (...args: unknown[]) => unknown }, 'rehydrate'); + const createSpy = vi.spyOn(User, 'create').mockImplementation(() => { + throw new Error('create-called'); + }); + + const domain = mapper.toDomain(entity); + + expect(domain.getId().value).toBe(entity.id); + expect(domain.getEmail()).toBe(entity.email); + + expect(createSpy).not.toHaveBeenCalled(); + expect(rehydrateSpy).toHaveBeenCalled(); + }); + + it('toDomain validates persisted shape and throws adapter-scoped base schema error type', () => { + const mapper = new UserOrmMapper(); + + const entity = new UserOrmEntity(); + entity.id = '00000000-0000-4000-8000-000000000001'; + entity.email = 123 as unknown as string; + entity.displayName = 'Alice'; + entity.passwordHash = 'bcrypt-hash'; + entity.salt = ''; + entity.primaryDriverId = null; + entity.createdAt = new Date('2025-01-01T00:00:00.000Z'); + + try { + mapper.toDomain(entity); + throw new Error('expected-to-throw'); + } catch (error) { + expect(error).toBeInstanceOf(TypeOrmIdentitySchemaError); + expect(error).toMatchObject({ + entityName: 'User', + fieldName: 'email', + reason: 'not_string', + }); + } + }); +}); \ No newline at end of file diff --git a/adapters/identity/persistence/typeorm/mappers/UserOrmMapper.ts b/adapters/identity/persistence/typeorm/mappers/UserOrmMapper.ts new file mode 100644 index 000000000..f7260edf1 --- /dev/null +++ b/adapters/identity/persistence/typeorm/mappers/UserOrmMapper.ts @@ -0,0 +1,68 @@ +import { User } from '@core/identity/domain/entities/User'; +import type { StoredUser } from '@core/identity/domain/repositories/IUserRepository'; +import { PasswordHash } from '@core/identity/domain/value-objects/PasswordHash'; + +import { UserOrmEntity } from '../entities/UserOrmEntity'; +import { TypeOrmIdentitySchemaError } from '../errors/TypeOrmIdentitySchemaError'; +import { assertDate, assertNonEmptyString, assertOptionalStringOrNull } from '../schema/TypeOrmIdentitySchemaGuards'; + +export class UserOrmMapper { + toDomain(entity: UserOrmEntity): User { + const entityName = 'User'; + + try { + assertNonEmptyString(entityName, 'id', entity.id); + assertNonEmptyString(entityName, 'email', entity.email); + assertNonEmptyString(entityName, 'displayName', entity.displayName); + assertNonEmptyString(entityName, 'passwordHash', entity.passwordHash); + assertNonEmptyString(entityName, 'salt', entity.salt); + assertOptionalStringOrNull(entityName, 'primaryDriverId', entity.primaryDriverId); + assertDate(entityName, 'createdAt', entity.createdAt); + } catch (error) { + if (error instanceof TypeOrmIdentitySchemaError) { + throw error; + } + const message = error instanceof Error ? error.message : 'Invalid persisted User'; + throw new TypeOrmIdentitySchemaError({ entityName, fieldName: 'unknown', reason: 'invalid_shape', message }); + } + + try { + const passwordHash = entity.passwordHash ? PasswordHash.fromHash(entity.passwordHash) : undefined; + + return User.rehydrate({ + id: entity.id, + email: entity.email, + displayName: entity.displayName, + ...(passwordHash ? { passwordHash } : {}), + ...(entity.primaryDriverId ? { primaryDriverId: entity.primaryDriverId } : {}), + }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Invalid persisted User'; + throw new TypeOrmIdentitySchemaError({ entityName, fieldName: 'unknown', reason: 'invalid_shape', message }); + } + } + + toOrmEntity(stored: StoredUser): UserOrmEntity { + const entity = new UserOrmEntity(); + entity.id = stored.id; + entity.email = stored.email; + entity.displayName = stored.displayName; + entity.passwordHash = stored.passwordHash; + entity.salt = stored.salt; + entity.primaryDriverId = stored.primaryDriverId ?? null; + entity.createdAt = stored.createdAt; + return entity; + } + + toStored(entity: UserOrmEntity): StoredUser { + return { + id: entity.id, + email: entity.email, + displayName: entity.displayName, + passwordHash: entity.passwordHash, + salt: entity.salt, + primaryDriverId: entity.primaryDriverId ?? undefined, + createdAt: entity.createdAt, + }; + } +} \ No newline at end of file diff --git a/adapters/identity/persistence/typeorm/schema/TypeOrmIdentitySchemaGuards.ts b/adapters/identity/persistence/typeorm/schema/TypeOrmIdentitySchemaGuards.ts new file mode 100644 index 000000000..d56bd218e --- /dev/null +++ b/adapters/identity/persistence/typeorm/schema/TypeOrmIdentitySchemaGuards.ts @@ -0,0 +1,34 @@ +import { TypeOrmIdentitySchemaError } from '../errors/TypeOrmIdentitySchemaError'; + +export function assertNonEmptyString(entityName: string, fieldName: string, value: unknown): asserts value is string { + if (typeof value !== 'string') { + throw new TypeOrmIdentitySchemaError({ entityName, fieldName, reason: 'not_string' }); + } + + if (value.trim().length === 0) { + throw new TypeOrmIdentitySchemaError({ entityName, fieldName, reason: 'empty_string' }); + } +} + +export function assertDate(entityName: string, fieldName: string, value: unknown): asserts value is Date { + if (!(value instanceof Date)) { + throw new TypeOrmIdentitySchemaError({ entityName, fieldName, reason: 'not_date' }); + } + if (Number.isNaN(value.getTime())) { + throw new TypeOrmIdentitySchemaError({ entityName, fieldName, reason: 'invalid_date' }); + } +} + +export function assertOptionalStringOrNull( + entityName: string, + fieldName: string, + value: unknown, +): asserts value is string | null | undefined { + if (value === null || value === undefined) { + return; + } + + if (typeof value !== 'string') { + throw new TypeOrmIdentitySchemaError({ entityName, fieldName, reason: 'not_string' }); + } +} \ No newline at end of file diff --git a/adapters/payments/persistence/typeorm/entities/PaymentsMemberPaymentOrmEntity.ts b/adapters/payments/persistence/typeorm/entities/PaymentsMemberPaymentOrmEntity.ts new file mode 100644 index 000000000..2b425ee70 --- /dev/null +++ b/adapters/payments/persistence/typeorm/entities/PaymentsMemberPaymentOrmEntity.ts @@ -0,0 +1,34 @@ +import { Column, Entity, Index, PrimaryColumn } from 'typeorm'; + +@Entity({ name: 'payments_member_payments' }) +export class PaymentsMemberPaymentOrmEntity { + @PrimaryColumn({ type: 'text' }) + id!: string; + + @Index() + @Column({ type: 'text' }) + feeId!: string; + + @Index() + @Column({ type: 'text' }) + driverId!: string; + + @Column({ type: 'double precision' }) + amount!: number; + + @Column({ type: 'double precision' }) + platformFee!: number; + + @Column({ type: 'double precision' }) + netAmount!: number; + + @Index() + @Column({ type: 'text' }) + status!: string; + + @Column({ type: 'timestamptz' }) + dueDate!: Date; + + @Column({ type: 'timestamptz', nullable: true }) + paidAt!: Date | null; +} diff --git a/adapters/payments/persistence/typeorm/entities/PaymentsMembershipFeeOrmEntity.ts b/adapters/payments/persistence/typeorm/entities/PaymentsMembershipFeeOrmEntity.ts new file mode 100644 index 000000000..a4ce17bb1 --- /dev/null +++ b/adapters/payments/persistence/typeorm/entities/PaymentsMembershipFeeOrmEntity.ts @@ -0,0 +1,29 @@ +import { Column, Entity, Index, PrimaryColumn } from 'typeorm'; + +@Entity({ name: 'payments_membership_fees' }) +export class PaymentsMembershipFeeOrmEntity { + @PrimaryColumn({ type: 'text' }) + id!: string; + + @Index({ unique: true }) + @Column({ type: 'text' }) + leagueId!: string; + + @Column({ type: 'text', nullable: true }) + seasonId!: string | null; + + @Column({ type: 'text' }) + type!: string; + + @Column({ type: 'double precision' }) + amount!: number; + + @Column({ type: 'boolean' }) + enabled!: boolean; + + @Column({ type: 'timestamptz' }) + createdAt!: Date; + + @Column({ type: 'timestamptz' }) + updatedAt!: Date; +} diff --git a/adapters/payments/persistence/typeorm/entities/PaymentsPaymentOrmEntity.ts b/adapters/payments/persistence/typeorm/entities/PaymentsPaymentOrmEntity.ts new file mode 100644 index 000000000..a45238935 --- /dev/null +++ b/adapters/payments/persistence/typeorm/entities/PaymentsPaymentOrmEntity.ts @@ -0,0 +1,44 @@ +import { Column, Entity, Index, PrimaryColumn } from 'typeorm'; + +@Entity({ name: 'payments_payments' }) +export class PaymentsPaymentOrmEntity { + @PrimaryColumn({ type: 'text' }) + id!: string; + + @Index() + @Column({ type: 'text' }) + leagueId!: string; + + @Index() + @Column({ type: 'text' }) + payerId!: string; + + @Index() + @Column({ type: 'text' }) + type!: string; + + @Column({ type: 'double precision' }) + amount!: number; + + @Column({ type: 'double precision' }) + platformFee!: number; + + @Column({ type: 'double precision' }) + netAmount!: number; + + @Column({ type: 'text' }) + payerType!: string; + + @Column({ type: 'text', nullable: true }) + seasonId!: string | null; + + @Index() + @Column({ type: 'text' }) + status!: string; + + @Column({ type: 'timestamptz' }) + createdAt!: Date; + + @Column({ type: 'timestamptz', nullable: true }) + completedAt!: Date | null; +} diff --git a/adapters/payments/persistence/typeorm/entities/PaymentsPrizeOrmEntity.ts b/adapters/payments/persistence/typeorm/entities/PaymentsPrizeOrmEntity.ts new file mode 100644 index 000000000..42d1ccd79 --- /dev/null +++ b/adapters/payments/persistence/typeorm/entities/PaymentsPrizeOrmEntity.ts @@ -0,0 +1,43 @@ +import { Column, Entity, Index, PrimaryColumn } from 'typeorm'; + +@Index('IDX_payments_prizes_league_season_position_unique', ['leagueId', 'seasonId', 'position'], { unique: true }) +@Entity({ name: 'payments_prizes' }) +export class PaymentsPrizeOrmEntity { + @PrimaryColumn({ type: 'text' }) + id!: string; + + @Index() + @Column({ type: 'text' }) + leagueId!: string; + + @Index() + @Column({ type: 'text' }) + seasonId!: string; + + @Column({ type: 'int' }) + position!: number; + + @Column({ type: 'text' }) + name!: string; + + @Column({ type: 'double precision' }) + amount!: number; + + @Column({ type: 'text' }) + type!: string; + + @Column({ type: 'text', nullable: true }) + description!: string | null; + + @Column({ type: 'boolean' }) + awarded!: boolean; + + @Column({ type: 'text', nullable: true }) + awardedTo!: string | null; + + @Column({ type: 'timestamptz', nullable: true }) + awardedAt!: Date | null; + + @Column({ type: 'timestamptz' }) + createdAt!: Date; +} diff --git a/adapters/payments/persistence/typeorm/entities/PaymentsTransactionOrmEntity.ts b/adapters/payments/persistence/typeorm/entities/PaymentsTransactionOrmEntity.ts new file mode 100644 index 000000000..f464375b9 --- /dev/null +++ b/adapters/payments/persistence/typeorm/entities/PaymentsTransactionOrmEntity.ts @@ -0,0 +1,29 @@ +import { Column, Entity, Index, PrimaryColumn } from 'typeorm'; + +@Entity({ name: 'payments_wallet_transactions' }) +export class PaymentsTransactionOrmEntity { + @PrimaryColumn({ type: 'text' }) + id!: string; + + @Index() + @Column({ type: 'text' }) + walletId!: string; + + @Column({ type: 'text' }) + type!: string; + + @Column({ type: 'double precision' }) + amount!: number; + + @Column({ type: 'text' }) + description!: string; + + @Column({ type: 'text', nullable: true }) + referenceId!: string | null; + + @Column({ type: 'text', nullable: true }) + referenceType!: string | null; + + @Column({ type: 'timestamptz' }) + createdAt!: Date; +} diff --git a/adapters/payments/persistence/typeorm/entities/PaymentsWalletOrmEntity.ts b/adapters/payments/persistence/typeorm/entities/PaymentsWalletOrmEntity.ts new file mode 100644 index 000000000..28c4e6b62 --- /dev/null +++ b/adapters/payments/persistence/typeorm/entities/PaymentsWalletOrmEntity.ts @@ -0,0 +1,29 @@ +import { Column, Entity, Index, PrimaryColumn } from 'typeorm'; + +@Entity({ name: 'payments_wallets' }) +export class PaymentsWalletOrmEntity { + @PrimaryColumn({ type: 'text' }) + id!: string; + + @Index({ unique: true }) + @Column({ type: 'text' }) + leagueId!: string; + + @Column({ type: 'double precision', default: 0 }) + balance!: number; + + @Column({ type: 'double precision', default: 0 }) + totalRevenue!: number; + + @Column({ type: 'double precision', default: 0 }) + totalPlatformFees!: number; + + @Column({ type: 'double precision', default: 0 }) + totalWithdrawn!: number; + + @Column({ type: 'text' }) + currency!: string; + + @Column({ type: 'timestamptz' }) + createdAt!: Date; +} diff --git a/adapters/payments/persistence/typeorm/errors/TypeOrmPaymentsSchemaError.ts b/adapters/payments/persistence/typeorm/errors/TypeOrmPaymentsSchemaError.ts new file mode 100644 index 000000000..07fbd2ddf --- /dev/null +++ b/adapters/payments/persistence/typeorm/errors/TypeOrmPaymentsSchemaError.ts @@ -0,0 +1,32 @@ +export type TypeOrmPaymentsSchemaErrorReason = + | 'missing' + | 'not_string' + | 'empty_string' + | 'not_number' + | 'not_integer' + | 'not_boolean' + | 'not_date' + | 'invalid_date' + | 'invalid_enum_value' + | 'invalid_shape'; + +export class TypeOrmPaymentsSchemaError extends Error { + readonly entityName: string; + readonly fieldName: string; + readonly reason: TypeOrmPaymentsSchemaErrorReason | (string & {}); + + constructor(params: { + entityName: string; + fieldName: string; + reason: TypeOrmPaymentsSchemaError['reason']; + message?: string; + }) { + const message = + params.message ?? `Invalid persisted ${params.entityName}.${params.fieldName}: ${params.reason}`; + super(message); + this.name = 'TypeOrmPaymentsSchemaError'; + this.entityName = params.entityName; + this.fieldName = params.fieldName; + this.reason = params.reason; + } +} \ No newline at end of file diff --git a/adapters/payments/persistence/typeorm/mappers/PaymentsMemberPaymentOrmMapper.ts b/adapters/payments/persistence/typeorm/mappers/PaymentsMemberPaymentOrmMapper.ts new file mode 100644 index 000000000..19813e74c --- /dev/null +++ b/adapters/payments/persistence/typeorm/mappers/PaymentsMemberPaymentOrmMapper.ts @@ -0,0 +1,53 @@ +import type { MemberPayment } from '@core/payments/domain/entities/MemberPayment'; +import { MemberPayment as MemberPaymentFactory, MemberPaymentStatus } from '@core/payments/domain/entities/MemberPayment'; + +import { PaymentsMemberPaymentOrmEntity } from '../entities/PaymentsMemberPaymentOrmEntity'; +import { + assertDate, + assertEnumValue, + assertNonEmptyString, + assertNumber, + assertOptionalDate, +} from '../schema/TypeOrmPaymentsSchemaGuards'; + +export class PaymentsMemberPaymentOrmMapper { + toOrmEntity(domain: MemberPayment): PaymentsMemberPaymentOrmEntity { + const entity = new PaymentsMemberPaymentOrmEntity(); + entity.id = domain.id; + entity.feeId = domain.feeId; + entity.driverId = domain.driverId; + entity.amount = domain.amount; + entity.platformFee = domain.platformFee; + entity.netAmount = domain.netAmount; + entity.status = domain.status; + entity.dueDate = domain.dueDate; + entity.paidAt = domain.paidAt ?? null; + return entity; + } + + toDomain(entity: PaymentsMemberPaymentOrmEntity): MemberPayment { + const entityName = 'PaymentsMemberPayment'; + + assertNonEmptyString(entityName, 'id', entity.id); + assertNonEmptyString(entityName, 'feeId', entity.feeId); + assertNonEmptyString(entityName, 'driverId', entity.driverId); + assertNumber(entityName, 'amount', entity.amount); + assertNumber(entityName, 'platformFee', entity.platformFee); + assertNumber(entityName, 'netAmount', entity.netAmount); + assertEnumValue(entityName, 'status', entity.status, Object.values(MemberPaymentStatus)); + assertDate(entityName, 'dueDate', entity.dueDate); + assertOptionalDate(entityName, 'paidAt', entity.paidAt); + + return MemberPaymentFactory.rehydrate({ + id: entity.id, + feeId: entity.feeId, + driverId: entity.driverId, + amount: entity.amount, + platformFee: entity.platformFee, + netAmount: entity.netAmount, + status: entity.status, + dueDate: entity.dueDate, + ...(entity.paidAt !== null && entity.paidAt !== undefined ? { paidAt: entity.paidAt } : {}), + }); + } +} diff --git a/adapters/payments/persistence/typeorm/mappers/PaymentsMembershipFeeOrmMapper.ts b/adapters/payments/persistence/typeorm/mappers/PaymentsMembershipFeeOrmMapper.ts new file mode 100644 index 000000000..ec4252ac0 --- /dev/null +++ b/adapters/payments/persistence/typeorm/mappers/PaymentsMembershipFeeOrmMapper.ts @@ -0,0 +1,51 @@ +import type { MembershipFee } from '@core/payments/domain/entities/MembershipFee'; +import { MembershipFee as MembershipFeeFactory, MembershipFeeType } from '@core/payments/domain/entities/MembershipFee'; + +import { PaymentsMembershipFeeOrmEntity } from '../entities/PaymentsMembershipFeeOrmEntity'; +import { + assertBoolean, + assertDate, + assertEnumValue, + assertNonEmptyString, + assertNumber, + assertOptionalStringOrNull, +} from '../schema/TypeOrmPaymentsSchemaGuards'; + +export class PaymentsMembershipFeeOrmMapper { + toOrmEntity(domain: MembershipFee): PaymentsMembershipFeeOrmEntity { + const entity = new PaymentsMembershipFeeOrmEntity(); + entity.id = domain.id; + entity.leagueId = domain.leagueId; + entity.seasonId = domain.seasonId ?? null; + entity.type = domain.type; + entity.amount = domain.amount; + entity.enabled = domain.enabled; + entity.createdAt = domain.createdAt; + entity.updatedAt = domain.updatedAt; + return entity; + } + + toDomain(entity: PaymentsMembershipFeeOrmEntity): MembershipFee { + const entityName = 'PaymentsMembershipFee'; + + assertNonEmptyString(entityName, 'id', entity.id); + assertNonEmptyString(entityName, 'leagueId', entity.leagueId); + assertOptionalStringOrNull(entityName, 'seasonId', entity.seasonId); + assertEnumValue(entityName, 'type', entity.type, Object.values(MembershipFeeType)); + assertNumber(entityName, 'amount', entity.amount); + assertBoolean(entityName, 'enabled', entity.enabled); + assertDate(entityName, 'createdAt', entity.createdAt); + assertDate(entityName, 'updatedAt', entity.updatedAt); + + return MembershipFeeFactory.rehydrate({ + id: entity.id, + leagueId: entity.leagueId, + type: entity.type, + amount: entity.amount, + enabled: entity.enabled, + createdAt: entity.createdAt, + updatedAt: entity.updatedAt, + ...(entity.seasonId !== null && entity.seasonId !== undefined ? { seasonId: entity.seasonId } : {}), + }); + } +} diff --git a/adapters/payments/persistence/typeorm/mappers/PaymentsPaymentOrmMapper.ts b/adapters/payments/persistence/typeorm/mappers/PaymentsPaymentOrmMapper.ts new file mode 100644 index 000000000..3fac31322 --- /dev/null +++ b/adapters/payments/persistence/typeorm/mappers/PaymentsPaymentOrmMapper.ts @@ -0,0 +1,70 @@ +import type { Payment } from '@core/payments/domain/entities/Payment'; +import { + Payment as PaymentFactory, + PaymentStatus, + PaymentType, + PayerType, +} from '@core/payments/domain/entities/Payment'; + +import { PaymentsPaymentOrmEntity } from '../entities/PaymentsPaymentOrmEntity'; +import { + assertDate, + assertEnumValue, + assertNonEmptyString, + assertNumber, + assertOptionalStringOrNull, +} from '../schema/TypeOrmPaymentsSchemaGuards'; + +export class PaymentsPaymentOrmMapper { + toOrmEntity(domain: Payment): PaymentsPaymentOrmEntity { + const entity = new PaymentsPaymentOrmEntity(); + entity.id = domain.id; + entity.type = domain.type; + entity.amount = domain.amount; + entity.platformFee = domain.platformFee; + entity.netAmount = domain.netAmount; + entity.payerId = domain.payerId; + entity.payerType = domain.payerType; + entity.leagueId = domain.leagueId; + entity.seasonId = domain.seasonId ?? null; + entity.status = domain.status; + entity.createdAt = domain.createdAt; + entity.completedAt = domain.completedAt ?? null; + return entity; + } + + toDomain(entity: PaymentsPaymentOrmEntity): Payment { + const entityName = 'PaymentsPayment'; + + assertNonEmptyString(entityName, 'id', entity.id); + assertEnumValue(entityName, 'type', entity.type, Object.values(PaymentType)); + assertNumber(entityName, 'amount', entity.amount); + assertNumber(entityName, 'platformFee', entity.platformFee); + assertNumber(entityName, 'netAmount', entity.netAmount); + assertNonEmptyString(entityName, 'payerId', entity.payerId); + assertEnumValue(entityName, 'payerType', entity.payerType, Object.values(PayerType)); + assertNonEmptyString(entityName, 'leagueId', entity.leagueId); + assertOptionalStringOrNull(entityName, 'seasonId', entity.seasonId); + assertEnumValue(entityName, 'status', entity.status, Object.values(PaymentStatus)); + assertDate(entityName, 'createdAt', entity.createdAt); + + if (entity.completedAt !== null && entity.completedAt !== undefined) { + assertDate(entityName, 'completedAt', entity.completedAt); + } + + return PaymentFactory.rehydrate({ + id: entity.id, + type: entity.type, + amount: entity.amount, + platformFee: entity.platformFee, + netAmount: entity.netAmount, + payerId: entity.payerId, + payerType: entity.payerType, + leagueId: entity.leagueId, + status: entity.status, + createdAt: entity.createdAt, + ...(entity.seasonId !== null && entity.seasonId !== undefined ? { seasonId: entity.seasonId } : {}), + ...(entity.completedAt !== null && entity.completedAt !== undefined ? { completedAt: entity.completedAt } : {}), + }); + } +} diff --git a/adapters/payments/persistence/typeorm/mappers/PaymentsPrizeOrmMapper.ts b/adapters/payments/persistence/typeorm/mappers/PaymentsPrizeOrmMapper.ts new file mode 100644 index 000000000..fdffbebb9 --- /dev/null +++ b/adapters/payments/persistence/typeorm/mappers/PaymentsPrizeOrmMapper.ts @@ -0,0 +1,65 @@ +import type { Prize } from '@core/payments/domain/entities/Prize'; +import { Prize as PrizeFactory, PrizeType } from '@core/payments/domain/entities/Prize'; + +import { PaymentsPrizeOrmEntity } from '../entities/PaymentsPrizeOrmEntity'; +import { + assertBoolean, + assertDate, + assertEnumValue, + assertInteger, + assertNonEmptyString, + assertNumber, + assertOptionalDate, + assertOptionalStringOrNull, +} from '../schema/TypeOrmPaymentsSchemaGuards'; + +export class PaymentsPrizeOrmMapper { + toOrmEntity(domain: Prize): PaymentsPrizeOrmEntity { + const entity = new PaymentsPrizeOrmEntity(); + entity.id = domain.id; + entity.leagueId = domain.leagueId; + entity.seasonId = domain.seasonId; + entity.position = domain.position; + entity.name = domain.name; + entity.amount = domain.amount; + entity.type = domain.type; + entity.description = domain.description ?? null; + entity.awarded = domain.awarded; + entity.awardedTo = domain.awardedTo ?? null; + entity.awardedAt = domain.awardedAt ?? null; + entity.createdAt = domain.createdAt; + return entity; + } + + toDomain(entity: PaymentsPrizeOrmEntity): Prize { + const entityName = 'PaymentsPrize'; + + assertNonEmptyString(entityName, 'id', entity.id); + assertNonEmptyString(entityName, 'leagueId', entity.leagueId); + assertNonEmptyString(entityName, 'seasonId', entity.seasonId); + assertInteger(entityName, 'position', entity.position); + assertNonEmptyString(entityName, 'name', entity.name); + assertNumber(entityName, 'amount', entity.amount); + assertEnumValue(entityName, 'type', entity.type, Object.values(PrizeType)); + assertOptionalStringOrNull(entityName, 'description', entity.description); + assertBoolean(entityName, 'awarded', entity.awarded); + assertOptionalStringOrNull(entityName, 'awardedTo', entity.awardedTo); + assertOptionalDate(entityName, 'awardedAt', entity.awardedAt); + assertDate(entityName, 'createdAt', entity.createdAt); + + return PrizeFactory.rehydrate({ + id: entity.id, + leagueId: entity.leagueId, + seasonId: entity.seasonId, + position: entity.position, + name: entity.name, + amount: entity.amount, + type: entity.type, + awarded: entity.awarded, + createdAt: entity.createdAt, + ...(entity.description !== null && entity.description !== undefined ? { description: entity.description } : {}), + ...(entity.awardedTo !== null && entity.awardedTo !== undefined ? { awardedTo: entity.awardedTo } : {}), + ...(entity.awardedAt !== null && entity.awardedAt !== undefined ? { awardedAt: entity.awardedAt } : {}), + }); + } +} diff --git a/adapters/payments/persistence/typeorm/mappers/PaymentsWalletOrmMapper.test.ts b/adapters/payments/persistence/typeorm/mappers/PaymentsWalletOrmMapper.test.ts new file mode 100644 index 000000000..a6d43cd92 --- /dev/null +++ b/adapters/payments/persistence/typeorm/mappers/PaymentsWalletOrmMapper.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; + +import { TransactionType } from '@core/payments/domain/entities/Wallet'; + +import { TypeOrmPaymentsSchemaError } from '../errors/TypeOrmPaymentsSchemaError'; +import { PaymentsWalletOrmEntity } from '../entities/PaymentsWalletOrmEntity'; +import { PaymentsWalletOrmMapper } from './PaymentsWalletOrmMapper'; + +describe('PaymentsWalletOrmMapper', () => { + it('maps Wallet domain <-> orm', () => { + const wallet = { + id: 'wallet-1', + leagueId: 'league-1', + balance: 10, + totalRevenue: 20, + totalPlatformFees: 1, + totalWithdrawn: 5, + currency: 'USD', + createdAt: new Date('2025-01-01T00:00:00.000Z'), + }; + + const mapper = new PaymentsWalletOrmMapper(); + const orm = mapper.toOrmEntity(wallet); + const rehydrated = mapper.toDomain(orm); + + expect(rehydrated.id).toBe('wallet-1'); + expect(rehydrated.leagueId).toBe('league-1'); + expect(rehydrated.balance).toBe(10); + expect(rehydrated.currency).toBe('USD'); + expect(rehydrated.createdAt.toISOString()).toBe('2025-01-01T00:00:00.000Z'); + }); + + it('throws schema error on invalid Wallet', () => { + const mapper = new PaymentsWalletOrmMapper(); + + const orm = new PaymentsWalletOrmEntity(); + orm.id = ''; + orm.leagueId = 'league-1'; + orm.balance = 0; + orm.totalRevenue = 0; + orm.totalPlatformFees = 0; + orm.totalWithdrawn = 0; + orm.currency = 'USD'; + orm.createdAt = new Date(); + + expect(() => mapper.toDomain(orm)).toThrow(TypeOrmPaymentsSchemaError); + }); + + it('maps Transaction domain <-> orm', () => { + const transaction = { + id: 'txn-1', + walletId: 'wallet-1', + type: TransactionType.DEPOSIT, + amount: 123, + description: 'Deposit', + createdAt: new Date('2025-01-01T00:00:00.000Z'), + }; + + const mapper = new PaymentsWalletOrmMapper(); + const orm = mapper.toOrmTransaction(transaction); + const rehydrated = mapper.toDomainTransaction(orm); + + expect(rehydrated.id).toBe('txn-1'); + expect(rehydrated.walletId).toBe('wallet-1'); + expect(rehydrated.type).toBe(TransactionType.DEPOSIT); + expect(rehydrated.amount).toBe(123); + }); +}); diff --git a/adapters/payments/persistence/typeorm/mappers/PaymentsWalletOrmMapper.ts b/adapters/payments/persistence/typeorm/mappers/PaymentsWalletOrmMapper.ts new file mode 100644 index 000000000..c527ff680 --- /dev/null +++ b/adapters/payments/persistence/typeorm/mappers/PaymentsWalletOrmMapper.ts @@ -0,0 +1,93 @@ +import type { Wallet, Transaction } from '@core/payments/domain/entities/Wallet'; +import { ReferenceType, Transaction as TransactionFactory, TransactionType, Wallet as WalletFactory } from '@core/payments/domain/entities/Wallet'; + +import { PaymentsTransactionOrmEntity } from '../entities/PaymentsTransactionOrmEntity'; +import { PaymentsWalletOrmEntity } from '../entities/PaymentsWalletOrmEntity'; +import { + assertDate, + assertEnumValue, + assertNonEmptyString, + assertNumber, + assertOptionalStringOrNull, +} from '../schema/TypeOrmPaymentsSchemaGuards'; + +export class PaymentsWalletOrmMapper { + toOrmEntity(domain: Wallet): PaymentsWalletOrmEntity { + const entity = new PaymentsWalletOrmEntity(); + entity.id = domain.id; + entity.leagueId = domain.leagueId; + entity.balance = domain.balance; + entity.totalRevenue = domain.totalRevenue; + entity.totalPlatformFees = domain.totalPlatformFees; + entity.totalWithdrawn = domain.totalWithdrawn; + entity.currency = domain.currency; + entity.createdAt = domain.createdAt; + return entity; + } + + toDomain(entity: PaymentsWalletOrmEntity): Wallet { + const entityName = 'PaymentsWallet'; + + assertNonEmptyString(entityName, 'id', entity.id); + assertNonEmptyString(entityName, 'leagueId', entity.leagueId); + assertNumber(entityName, 'balance', entity.balance); + assertNumber(entityName, 'totalRevenue', entity.totalRevenue); + assertNumber(entityName, 'totalPlatformFees', entity.totalPlatformFees); + assertNumber(entityName, 'totalWithdrawn', entity.totalWithdrawn); + assertNonEmptyString(entityName, 'currency', entity.currency); + assertDate(entityName, 'createdAt', entity.createdAt); + + return WalletFactory.rehydrate({ + id: entity.id, + leagueId: entity.leagueId, + balance: entity.balance, + totalRevenue: entity.totalRevenue, + totalPlatformFees: entity.totalPlatformFees, + totalWithdrawn: entity.totalWithdrawn, + currency: entity.currency, + createdAt: entity.createdAt, + }); + } + + toOrmTransaction(domain: Transaction): PaymentsTransactionOrmEntity { + const entity = new PaymentsTransactionOrmEntity(); + entity.id = domain.id; + entity.walletId = domain.walletId; + entity.type = domain.type; + entity.amount = domain.amount; + entity.description = domain.description; + entity.referenceId = domain.referenceId ?? null; + entity.referenceType = domain.referenceType ?? null; + entity.createdAt = domain.createdAt; + return entity; + } + + toDomainTransaction(entity: PaymentsTransactionOrmEntity): Transaction { + const entityName = 'PaymentsTransaction'; + + assertNonEmptyString(entityName, 'id', entity.id); + assertNonEmptyString(entityName, 'walletId', entity.walletId); + assertNonEmptyString(entityName, 'type', entity.type); + assertEnumValue(entityName, 'type', entity.type, Object.values(TransactionType)); + assertNumber(entityName, 'amount', entity.amount); + assertNonEmptyString(entityName, 'description', entity.description); + assertOptionalStringOrNull(entityName, 'referenceId', entity.referenceId); + assertOptionalStringOrNull(entityName, 'referenceType', entity.referenceType); + assertDate(entityName, 'createdAt', entity.createdAt); + + if (entity.referenceType !== null && entity.referenceType !== undefined) { + assertEnumValue(entityName, 'referenceType', entity.referenceType, Object.values(ReferenceType)); + } + + return TransactionFactory.rehydrate({ + id: entity.id, + walletId: entity.walletId, + type: entity.type, + amount: entity.amount, + description: entity.description, + createdAt: entity.createdAt, + ...(entity.referenceId !== null && entity.referenceId !== undefined ? { referenceId: entity.referenceId } : {}), + ...(entity.referenceType !== null && entity.referenceType !== undefined ? { referenceType: entity.referenceType as ReferenceType } : {}), + }); + } +} diff --git a/adapters/payments/persistence/typeorm/repositories/TypeOrmMembershipFeeRepository.ts b/adapters/payments/persistence/typeorm/repositories/TypeOrmMembershipFeeRepository.ts new file mode 100644 index 000000000..9742386bb --- /dev/null +++ b/adapters/payments/persistence/typeorm/repositories/TypeOrmMembershipFeeRepository.ts @@ -0,0 +1,87 @@ +import type { DataSource } from 'typeorm'; + +import type { IMemberPaymentRepository, IMembershipFeeRepository } from '@core/payments/domain/repositories/IMembershipFeeRepository'; +import type { MemberPayment } from '@core/payments/domain/entities/MemberPayment'; +import type { MembershipFee } from '@core/payments/domain/entities/MembershipFee'; + +import { PaymentsMemberPaymentOrmEntity } from '../entities/PaymentsMemberPaymentOrmEntity'; +import { PaymentsMembershipFeeOrmEntity } from '../entities/PaymentsMembershipFeeOrmEntity'; +import { PaymentsMemberPaymentOrmMapper } from '../mappers/PaymentsMemberPaymentOrmMapper'; +import { PaymentsMembershipFeeOrmMapper } from '../mappers/PaymentsMembershipFeeOrmMapper'; + +export class TypeOrmMembershipFeeRepository implements IMembershipFeeRepository { + constructor( + private readonly dataSource: DataSource, + private readonly mapper: PaymentsMembershipFeeOrmMapper, + ) {} + + async findById(id: string): Promise { + const repo = this.dataSource.getRepository(PaymentsMembershipFeeOrmEntity); + const entity = await repo.findOne({ where: { id } }); + return entity ? this.mapper.toDomain(entity) : null; + } + + async findByLeagueId(leagueId: string): Promise { + const repo = this.dataSource.getRepository(PaymentsMembershipFeeOrmEntity); + const entity = await repo.findOne({ where: { leagueId } }); + return entity ? this.mapper.toDomain(entity) : null; + } + + async create(fee: MembershipFee): Promise { + const repo = this.dataSource.getRepository(PaymentsMembershipFeeOrmEntity); + await repo.save(this.mapper.toOrmEntity(fee)); + return fee; + } + + async update(fee: MembershipFee): Promise { + const repo = this.dataSource.getRepository(PaymentsMembershipFeeOrmEntity); + await repo.save(this.mapper.toOrmEntity(fee)); + return fee; + } +} + +export class TypeOrmMemberPaymentRepository implements IMemberPaymentRepository { + constructor( + private readonly dataSource: DataSource, + private readonly mapper: PaymentsMemberPaymentOrmMapper, + ) {} + + async findById(id: string): Promise { + const repo = this.dataSource.getRepository(PaymentsMemberPaymentOrmEntity); + const entity = await repo.findOne({ where: { id } }); + return entity ? this.mapper.toDomain(entity) : null; + } + + async findByFeeIdAndDriverId(feeId: string, driverId: string): Promise { + const repo = this.dataSource.getRepository(PaymentsMemberPaymentOrmEntity); + const entity = await repo.findOne({ where: { feeId, driverId } }); + return entity ? this.mapper.toDomain(entity) : null; + } + + async findByLeagueIdAndDriverId( + leagueId: string, + driverId: string, + membershipFeeRepo: IMembershipFeeRepository, + ): Promise { + const fee = await membershipFeeRepo.findByLeagueId(leagueId); + if (!fee) { + return []; + } + + const repo = this.dataSource.getRepository(PaymentsMemberPaymentOrmEntity); + const entities = await repo.find({ where: { feeId: fee.id, driverId } }); + return entities.map((e) => this.mapper.toDomain(e)); + } + + async create(payment: MemberPayment): Promise { + const repo = this.dataSource.getRepository(PaymentsMemberPaymentOrmEntity); + await repo.save(this.mapper.toOrmEntity(payment)); + return payment; + } + + async update(payment: MemberPayment): Promise { + const repo = this.dataSource.getRepository(PaymentsMemberPaymentOrmEntity); + await repo.save(this.mapper.toOrmEntity(payment)); + return payment; + } +} diff --git a/adapters/payments/persistence/typeorm/repositories/TypeOrmPaymentRepository.test.ts b/adapters/payments/persistence/typeorm/repositories/TypeOrmPaymentRepository.test.ts new file mode 100644 index 000000000..65124f3d5 --- /dev/null +++ b/adapters/payments/persistence/typeorm/repositories/TypeOrmPaymentRepository.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; + +import type { DataSource } from 'typeorm'; + +import { TypeOrmPaymentRepository } from './TypeOrmPaymentRepository'; +import { PaymentsPaymentOrmMapper } from '../mappers/PaymentsPaymentOrmMapper'; + +describe('TypeOrmPaymentRepository', () => { + it('constructor requires injected mapper (no internal mapper instantiation)', () => { + const dataSource = {} as unknown as DataSource; + const mapper = {} as unknown as PaymentsPaymentOrmMapper; + + const repo = new TypeOrmPaymentRepository(dataSource, mapper); + + expect(repo).toBeInstanceOf(TypeOrmPaymentRepository); + expect((repo as unknown as { mapper: unknown }).mapper).toBe(mapper); + }); + + it('works with mocked TypeORM DataSource (no DB required)', async () => { + const findOne = async () => null; + + const dataSource = { + getRepository: () => ({ findOne }), + } as unknown as DataSource; + + const mapper = { + toDomain: () => { + throw new Error('should-not-be-called'); + }, + } as unknown as PaymentsPaymentOrmMapper; + + const repo = new TypeOrmPaymentRepository(dataSource, mapper); + + await expect(repo.findById('payment-1')).resolves.toBeNull(); + }); +}); diff --git a/adapters/payments/persistence/typeorm/repositories/TypeOrmPaymentRepository.ts b/adapters/payments/persistence/typeorm/repositories/TypeOrmPaymentRepository.ts new file mode 100644 index 000000000..2df127957 --- /dev/null +++ b/adapters/payments/persistence/typeorm/repositories/TypeOrmPaymentRepository.ts @@ -0,0 +1,67 @@ +import type { DataSource } from 'typeorm'; + +import type { IPaymentRepository } from '@core/payments/domain/repositories/IPaymentRepository'; +import type { Payment, PaymentType } from '@core/payments/domain/entities/Payment'; + +import { PaymentsPaymentOrmEntity } from '../entities/PaymentsPaymentOrmEntity'; +import { PaymentsPaymentOrmMapper } from '../mappers/PaymentsPaymentOrmMapper'; + +export class TypeOrmPaymentRepository implements IPaymentRepository { + constructor( + private readonly dataSource: DataSource, + private readonly mapper: PaymentsPaymentOrmMapper, + ) {} + + async findById(id: string): Promise { + const repo = this.dataSource.getRepository(PaymentsPaymentOrmEntity); + const entity = await repo.findOne({ where: { id } }); + return entity ? this.mapper.toDomain(entity) : null; + } + + async findByLeagueId(leagueId: string): Promise { + const repo = this.dataSource.getRepository(PaymentsPaymentOrmEntity); + const entities = await repo.find({ where: { leagueId } }); + return entities.map((e) => this.mapper.toDomain(e)); + } + + async findByPayerId(payerId: string): Promise { + const repo = this.dataSource.getRepository(PaymentsPaymentOrmEntity); + const entities = await repo.find({ where: { payerId } }); + return entities.map((e) => this.mapper.toDomain(e)); + } + + async findByType(type: PaymentType): Promise { + const repo = this.dataSource.getRepository(PaymentsPaymentOrmEntity); + const entities = await repo.find({ where: { type } }); + return entities.map((e) => this.mapper.toDomain(e)); + } + + async findByFilters(filters: { leagueId?: string; payerId?: string; type?: PaymentType }): Promise { + const repo = this.dataSource.getRepository(PaymentsPaymentOrmEntity); + + const where: { + leagueId?: string; + payerId?: string; + type?: PaymentType; + } = {}; + + if (filters.leagueId !== undefined) where.leagueId = filters.leagueId; + if (filters.payerId !== undefined) where.payerId = filters.payerId; + if (filters.type !== undefined) where.type = filters.type; + + const entities = await repo.find({ where }); + return entities.map((e) => this.mapper.toDomain(e)); + } + + async create(payment: Payment): Promise { + const repo = this.dataSource.getRepository(PaymentsPaymentOrmEntity); + await repo.save(this.mapper.toOrmEntity(payment)); + return payment; + } + + async update(payment: Payment): Promise { + const repo = this.dataSource.getRepository(PaymentsPaymentOrmEntity); + await repo.save(this.mapper.toOrmEntity(payment)); + return payment; + } +} diff --git a/adapters/payments/persistence/typeorm/repositories/TypeOrmPrizeRepository.ts b/adapters/payments/persistence/typeorm/repositories/TypeOrmPrizeRepository.ts new file mode 100644 index 000000000..d923ce7d1 --- /dev/null +++ b/adapters/payments/persistence/typeorm/repositories/TypeOrmPrizeRepository.ts @@ -0,0 +1,55 @@ +import type { DataSource } from 'typeorm'; + +import type { IPrizeRepository } from '@core/payments/domain/repositories/IPrizeRepository'; +import type { Prize } from '@core/payments/domain/entities/Prize'; + +import { PaymentsPrizeOrmEntity } from '../entities/PaymentsPrizeOrmEntity'; +import { PaymentsPrizeOrmMapper } from '../mappers/PaymentsPrizeOrmMapper'; + +export class TypeOrmPrizeRepository implements IPrizeRepository { + constructor( + private readonly dataSource: DataSource, + private readonly mapper: PaymentsPrizeOrmMapper, + ) {} + + async findById(id: string): Promise { + const repo = this.dataSource.getRepository(PaymentsPrizeOrmEntity); + const entity = await repo.findOne({ where: { id } }); + return entity ? this.mapper.toDomain(entity) : null; + } + + async findByLeagueId(leagueId: string): Promise { + const repo = this.dataSource.getRepository(PaymentsPrizeOrmEntity); + const entities = await repo.find({ where: { leagueId } }); + return entities.map((e) => this.mapper.toDomain(e)); + } + + async findByLeagueIdAndSeasonId(leagueId: string, seasonId: string): Promise { + const repo = this.dataSource.getRepository(PaymentsPrizeOrmEntity); + const entities = await repo.find({ where: { leagueId, seasonId } }); + return entities.map((e) => this.mapper.toDomain(e)); + } + + async findByPosition(leagueId: string, seasonId: string, position: number): Promise { + const repo = this.dataSource.getRepository(PaymentsPrizeOrmEntity); + const entity = await repo.findOne({ where: { leagueId, seasonId, position } }); + return entity ? this.mapper.toDomain(entity) : null; + } + + async create(prize: Prize): Promise { + const repo = this.dataSource.getRepository(PaymentsPrizeOrmEntity); + await repo.save(this.mapper.toOrmEntity(prize)); + return prize; + } + + async update(prize: Prize): Promise { + const repo = this.dataSource.getRepository(PaymentsPrizeOrmEntity); + await repo.save(this.mapper.toOrmEntity(prize)); + return prize; + } + + async delete(id: string): Promise { + const repo = this.dataSource.getRepository(PaymentsPrizeOrmEntity); + await repo.delete({ id }); + } +} diff --git a/adapters/payments/persistence/typeorm/repositories/TypeOrmWalletRepository.test.ts b/adapters/payments/persistence/typeorm/repositories/TypeOrmWalletRepository.test.ts new file mode 100644 index 000000000..fb2693b76 --- /dev/null +++ b/adapters/payments/persistence/typeorm/repositories/TypeOrmWalletRepository.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; + +import type { DataSource } from 'typeorm'; + +import { TypeOrmWalletRepository } from './TypeOrmWalletRepository'; +import { PaymentsWalletOrmMapper } from '../mappers/PaymentsWalletOrmMapper'; + +describe('TypeOrmWalletRepository', () => { + it('constructor requires injected mapper (no internal mapper instantiation)', () => { + const dataSource = {} as unknown as DataSource; + const mapper = {} as unknown as PaymentsWalletOrmMapper; + + const repo = new TypeOrmWalletRepository(dataSource, mapper); + + expect(repo).toBeInstanceOf(TypeOrmWalletRepository); + expect((repo as unknown as { mapper: unknown }).mapper).toBe(mapper); + }); + + it('works with mocked TypeORM DataSource (no DB required)', async () => { + const findOne = async () => null; + + const dataSource = { + getRepository: () => ({ findOne }), + } as unknown as DataSource; + + const mapper = { + toDomain: () => { + throw new Error('should-not-be-called'); + }, + } as unknown as PaymentsWalletOrmMapper; + + const repo = new TypeOrmWalletRepository(dataSource, mapper); + + await expect(repo.findById('wallet-1')).resolves.toBeNull(); + }); +}); diff --git a/adapters/payments/persistence/typeorm/repositories/TypeOrmWalletRepository.ts b/adapters/payments/persistence/typeorm/repositories/TypeOrmWalletRepository.ts new file mode 100644 index 000000000..b432c31af --- /dev/null +++ b/adapters/payments/persistence/typeorm/repositories/TypeOrmWalletRepository.ts @@ -0,0 +1,64 @@ +import type { DataSource } from 'typeorm'; + +import type { ITransactionRepository, IWalletRepository } from '@core/payments/domain/repositories/IWalletRepository'; +import type { Transaction, Wallet } from '@core/payments/domain/entities/Wallet'; + +import { PaymentsTransactionOrmEntity } from '../entities/PaymentsTransactionOrmEntity'; +import { PaymentsWalletOrmEntity } from '../entities/PaymentsWalletOrmEntity'; +import { PaymentsWalletOrmMapper } from '../mappers/PaymentsWalletOrmMapper'; + +export class TypeOrmWalletRepository implements IWalletRepository { + constructor( + private readonly dataSource: DataSource, + private readonly mapper: PaymentsWalletOrmMapper, + ) {} + + async findById(id: string): Promise { + const repo = this.dataSource.getRepository(PaymentsWalletOrmEntity); + const entity = await repo.findOne({ where: { id } }); + return entity ? this.mapper.toDomain(entity) : null; + } + + async findByLeagueId(leagueId: string): Promise { + const repo = this.dataSource.getRepository(PaymentsWalletOrmEntity); + const entity = await repo.findOne({ where: { leagueId } }); + return entity ? this.mapper.toDomain(entity) : null; + } + + async create(wallet: Wallet): Promise { + const repo = this.dataSource.getRepository(PaymentsWalletOrmEntity); + await repo.save(this.mapper.toOrmEntity(wallet)); + return wallet; + } + + async update(wallet: Wallet): Promise { + const repo = this.dataSource.getRepository(PaymentsWalletOrmEntity); + await repo.save(this.mapper.toOrmEntity(wallet)); + return wallet; + } +} + +export class TypeOrmTransactionRepository implements ITransactionRepository { + constructor( + private readonly dataSource: DataSource, + private readonly mapper: PaymentsWalletOrmMapper, + ) {} + + async findById(id: string): Promise { + const repo = this.dataSource.getRepository(PaymentsTransactionOrmEntity); + const entity = await repo.findOne({ where: { id } }); + return entity ? this.mapper.toDomainTransaction(entity) : null; + } + + async findByWalletId(walletId: string): Promise { + const repo = this.dataSource.getRepository(PaymentsTransactionOrmEntity); + const entities = await repo.find({ where: { walletId } }); + return entities.map((e) => this.mapper.toDomainTransaction(e)); + } + + async create(transaction: Transaction): Promise { + const repo = this.dataSource.getRepository(PaymentsTransactionOrmEntity); + await repo.save(this.mapper.toOrmTransaction(transaction)); + return transaction; + } +} diff --git a/adapters/payments/persistence/typeorm/schema/TypeOrmPaymentsSchemaGuards.ts b/adapters/payments/persistence/typeorm/schema/TypeOrmPaymentsSchemaGuards.ts new file mode 100644 index 000000000..422ca9e32 --- /dev/null +++ b/adapters/payments/persistence/typeorm/schema/TypeOrmPaymentsSchemaGuards.ts @@ -0,0 +1,120 @@ +import { TypeOrmPaymentsSchemaError } from '../errors/TypeOrmPaymentsSchemaError'; + +export function assertNonEmptyString( + entityName: string, + fieldName: string, + value: unknown, +): asserts value is string { + if (typeof value !== 'string') { + throw new TypeOrmPaymentsSchemaError({ entityName, fieldName, reason: 'not_string' }); + } + + if (value.trim().length === 0) { + throw new TypeOrmPaymentsSchemaError({ entityName, fieldName, reason: 'empty_string' }); + } +} + +export function assertNumber(entityName: string, fieldName: string, value: unknown): asserts value is number { + if (typeof value !== 'number' || Number.isNaN(value)) { + throw new TypeOrmPaymentsSchemaError({ entityName, fieldName, reason: 'not_number' }); + } +} + +export function assertInteger(entityName: string, fieldName: string, value: unknown): asserts value is number { + if (typeof value !== 'number' || !Number.isInteger(value)) { + throw new TypeOrmPaymentsSchemaError({ entityName, fieldName, reason: 'not_integer' }); + } +} + +export function assertBoolean(entityName: string, fieldName: string, value: unknown): asserts value is boolean { + if (typeof value !== 'boolean') { + throw new TypeOrmPaymentsSchemaError({ entityName, fieldName, reason: 'not_boolean' }); + } +} + +export function assertDate(entityName: string, fieldName: string, value: unknown): asserts value is Date { + if (!(value instanceof Date)) { + throw new TypeOrmPaymentsSchemaError({ entityName, fieldName, reason: 'not_date' }); + } + if (Number.isNaN(value.getTime())) { + throw new TypeOrmPaymentsSchemaError({ entityName, fieldName, reason: 'invalid_date' }); + } +} + +export function assertEnumValue( + entityName: string, + fieldName: string, + value: unknown, + allowed: readonly TAllowed[], +): asserts value is TAllowed { + if (typeof value !== 'string') { + throw new TypeOrmPaymentsSchemaError({ entityName, fieldName, reason: 'not_string' }); + } + + if (!allowed.includes(value as TAllowed)) { + throw new TypeOrmPaymentsSchemaError({ entityName, fieldName, reason: 'invalid_enum_value' }); + } +} + +export function assertOptionalStringOrNull( + entityName: string, + fieldName: string, + value: unknown, +): asserts value is string | null | undefined { + if (value === null || value === undefined) { + return; + } + + if (typeof value !== 'string') { + throw new TypeOrmPaymentsSchemaError({ entityName, fieldName, reason: 'not_string' }); + } +} + +export function assertOptionalDate( + entityName: string, + fieldName: string, + value: unknown, +): asserts value is Date | undefined | null { + if (value === undefined || value === null) { + return; + } + + assertDate(entityName, fieldName, value); +} + +export function assertOptionalNumber( + entityName: string, + fieldName: string, + value: unknown, +): asserts value is number | undefined | null { + if (value === undefined || value === null) { + return; + } + + assertNumber(entityName, fieldName, value); +} + +export function assertOptionalBoolean( + entityName: string, + fieldName: string, + value: unknown, +): asserts value is boolean | undefined | null { + if (value === undefined || value === null) { + return; + } + + assertBoolean(entityName, fieldName, value); +} + +export const TypeOrmPaymentsSchemaGuards = { + assertNonEmptyString, + assertNumber, + assertInteger, + assertBoolean, + assertDate, + assertEnumValue, + assertOptionalStringOrNull, + assertOptionalDate, + assertOptionalNumber, + assertOptionalBoolean, +} as const; \ No newline at end of file diff --git a/adapters/racing/persistence/typeorm/entities/DriverOrmEntity.ts b/adapters/racing/persistence/typeorm/entities/DriverOrmEntity.ts new file mode 100644 index 000000000..37a96a769 --- /dev/null +++ b/adapters/racing/persistence/typeorm/entities/DriverOrmEntity.ts @@ -0,0 +1,22 @@ +import { Column, Entity, PrimaryColumn } from 'typeorm'; + +@Entity({ name: 'racing_drivers' }) +export class DriverOrmEntity { + @PrimaryColumn({ type: 'uuid' }) + id!: string; + + @Column({ type: 'text' }) + iracingId!: string; + + @Column({ type: 'text' }) + name!: string; + + @Column({ type: 'text' }) + country!: string; + + @Column({ type: 'text', nullable: true }) + bio!: string | null; + + @Column({ type: 'timestamptz' }) + joinedAt!: Date; +} \ No newline at end of file diff --git a/adapters/racing/persistence/typeorm/entities/LeagueMembershipOrmEntity.ts b/adapters/racing/persistence/typeorm/entities/LeagueMembershipOrmEntity.ts new file mode 100644 index 000000000..1ecbf9cb7 --- /dev/null +++ b/adapters/racing/persistence/typeorm/entities/LeagueMembershipOrmEntity.ts @@ -0,0 +1,22 @@ +import { Column, Entity, PrimaryColumn } from 'typeorm'; + +@Entity({ name: 'racing_league_memberships' }) +export class LeagueMembershipOrmEntity { + @PrimaryColumn({ type: 'text' }) + id!: string; + + @Column({ type: 'uuid' }) + leagueId!: string; + + @Column({ type: 'uuid' }) + driverId!: string; + + @Column({ type: 'text' }) + role!: string; + + @Column({ type: 'text' }) + status!: string; + + @Column({ type: 'timestamptz' }) + joinedAt!: Date; +} \ No newline at end of file diff --git a/adapters/racing/persistence/typeorm/entities/MissingRacingOrmEntities.ts b/adapters/racing/persistence/typeorm/entities/MissingRacingOrmEntities.ts new file mode 100644 index 000000000..e01a2f51d --- /dev/null +++ b/adapters/racing/persistence/typeorm/entities/MissingRacingOrmEntities.ts @@ -0,0 +1,272 @@ +import { Column, CreateDateColumn, Entity, PrimaryColumn } from 'typeorm'; + +@Entity({ name: 'racing_penalties' }) +export class PenaltyOrmEntity { + @PrimaryColumn({ type: 'uuid' }) + id!: string; + + @Column({ type: 'uuid' }) + leagueId!: string; + + @Column({ type: 'text' }) + raceId!: string; + + @Column({ type: 'uuid' }) + driverId!: string; + + @Column({ type: 'text' }) + type!: string; + + @Column({ type: 'int', nullable: true }) + value!: number | null; + + @Column({ type: 'text' }) + reason!: string; + + @Column({ type: 'text', nullable: true }) + protestId!: string | null; + + @Column({ type: 'uuid' }) + issuedBy!: string; + + @Column({ type: 'text' }) + status!: string; + + @Column({ type: 'timestamptz' }) + issuedAt!: Date; + + @Column({ type: 'timestamptz', nullable: true }) + appliedAt!: Date | null; + + @Column({ type: 'text', nullable: true }) + notes!: string | null; +} + +@Entity({ name: 'racing_protests' }) +export class ProtestOrmEntity { + @PrimaryColumn({ type: 'uuid' }) + id!: string; + + @Column({ type: 'text' }) + raceId!: string; + + @Column({ type: 'uuid' }) + protestingDriverId!: string; + + @Column({ type: 'uuid' }) + accusedDriverId!: string; + + @Column({ type: 'jsonb' }) + incident!: unknown; + + @Column({ type: 'text', nullable: true }) + comment!: string | null; + + @Column({ type: 'text', nullable: true }) + proofVideoUrl!: string | null; + + @Column({ type: 'text' }) + status!: string; + + @Column({ type: 'uuid', nullable: true }) + reviewedBy!: string | null; + + @Column({ type: 'text', nullable: true }) + decisionNotes!: string | null; + + @Column({ type: 'timestamptz' }) + filedAt!: Date; + + @Column({ type: 'timestamptz', nullable: true }) + reviewedAt!: Date | null; + + @Column({ type: 'jsonb', nullable: true }) + defense!: unknown | null; + + @Column({ type: 'timestamptz', nullable: true }) + defenseRequestedAt!: Date | null; + + @Column({ type: 'uuid', nullable: true }) + defenseRequestedBy!: string | null; +} + +export type OrmMoney = { amount: number; currency: string }; + +@Entity({ name: 'racing_league_wallets' }) +export class LeagueWalletOrmEntity { + @PrimaryColumn({ type: 'uuid' }) + id!: string; + + @Column({ type: 'uuid' }) + leagueId!: string; + + @Column({ type: 'jsonb' }) + balance!: OrmMoney; + + @Column({ type: 'text', array: true, default: () => 'ARRAY[]::text[]' }) + transactionIds!: string[]; + + @CreateDateColumn({ type: 'timestamptz' }) + createdAt!: Date; +} + +@Entity({ name: 'racing_transactions' }) +export class TransactionOrmEntity { + @PrimaryColumn({ type: 'uuid' }) + id!: string; + + @Column({ type: 'uuid' }) + walletId!: string; + + @Column({ type: 'text' }) + type!: string; + + @Column({ type: 'jsonb' }) + amount!: OrmMoney; + + @Column({ type: 'jsonb' }) + platformFee!: OrmMoney; + + @Column({ type: 'jsonb' }) + netAmount!: OrmMoney; + + @Column({ type: 'text' }) + status!: string; + + @CreateDateColumn({ type: 'timestamptz' }) + createdAt!: Date; + + @Column({ type: 'timestamptz', nullable: true }) + completedAt!: Date | null; + + @Column({ type: 'text', nullable: true }) + description!: string | null; + + @Column({ type: 'jsonb', nullable: true }) + metadata!: Record | null; +} + +@Entity({ name: 'racing_sponsors' }) +export class SponsorOrmEntity { + @PrimaryColumn({ type: 'uuid' }) + id!: string; + + @Column({ type: 'text' }) + name!: string; + + @Column({ type: 'text' }) + contactEmail!: string; + + @Column({ type: 'text', nullable: true }) + logoUrl!: string | null; + + @Column({ type: 'text', nullable: true }) + websiteUrl!: string | null; + + @CreateDateColumn({ type: 'timestamptz' }) + createdAt!: Date; +} + +export type OrmSponsorshipPricing = unknown; + +@Entity({ name: 'racing_sponsorship_pricings' }) +export class SponsorshipPricingOrmEntity { + @PrimaryColumn({ type: 'text' }) + id!: string; + + @Column({ type: 'text' }) + entityType!: string; + + @Column({ type: 'text' }) + entityId!: string; + + @Column({ type: 'jsonb' }) + pricing!: OrmSponsorshipPricing; +} + +@Entity({ name: 'racing_sponsorship_requests' }) +export class SponsorshipRequestOrmEntity { + @PrimaryColumn({ type: 'uuid' }) + id!: string; + + @Column({ type: 'uuid' }) + sponsorId!: string; + + @Column({ type: 'text' }) + entityType!: string; + + @Column({ type: 'text' }) + entityId!: string; + + @Column({ type: 'text' }) + tier!: string; + + @Column({ type: 'jsonb' }) + offeredAmount!: OrmMoney; + + @Column({ type: 'text', nullable: true }) + message!: string | null; + + @Column({ type: 'text' }) + status!: string; + + @CreateDateColumn({ type: 'timestamptz' }) + createdAt!: Date; + + @Column({ type: 'timestamptz', nullable: true }) + respondedAt!: Date | null; + + @Column({ type: 'uuid', nullable: true }) + respondedBy!: string | null; + + @Column({ type: 'text', nullable: true }) + rejectionReason!: string | null; +} + +@Entity({ name: 'racing_season_sponsorships' }) +export class SeasonSponsorshipOrmEntity { + @PrimaryColumn({ type: 'uuid' }) + id!: string; + + @Column({ type: 'uuid' }) + seasonId!: string; + + @Column({ type: 'uuid', nullable: true }) + leagueId!: string | null; + + @Column({ type: 'uuid' }) + sponsorId!: string; + + @Column({ type: 'text' }) + tier!: string; + + @Column({ type: 'jsonb' }) + pricing!: OrmMoney; + + @Column({ type: 'text' }) + status!: string; + + @CreateDateColumn({ type: 'timestamptz' }) + createdAt!: Date; + + @Column({ type: 'timestamptz', nullable: true }) + activatedAt!: Date | null; + + @Column({ type: 'timestamptz', nullable: true }) + endedAt!: Date | null; + + @Column({ type: 'timestamptz', nullable: true }) + cancelledAt!: Date | null; + + @Column({ type: 'text', nullable: true }) + description!: string | null; +} + +@Entity({ name: 'racing_games' }) +export class GameOrmEntity { + @PrimaryColumn({ type: 'text' }) + id!: string; + + @Column({ type: 'text' }) + name!: string; +} \ No newline at end of file diff --git a/adapters/racing/persistence/typeorm/entities/RaceRegistrationOrmEntity.ts b/adapters/racing/persistence/typeorm/entities/RaceRegistrationOrmEntity.ts new file mode 100644 index 000000000..e18a4b0a0 --- /dev/null +++ b/adapters/racing/persistence/typeorm/entities/RaceRegistrationOrmEntity.ts @@ -0,0 +1,16 @@ +import { Column, Entity, PrimaryColumn } from 'typeorm'; + +@Entity({ name: 'racing_race_registrations' }) +export class RaceRegistrationOrmEntity { + @PrimaryColumn({ type: 'text' }) + id!: string; + + @Column({ type: 'text' }) + raceId!: string; + + @Column({ type: 'text' }) + driverId!: string; + + @Column({ type: 'timestamptz' }) + registeredAt!: Date; +} \ No newline at end of file diff --git a/adapters/racing/persistence/typeorm/entities/ResultOrmEntity.ts b/adapters/racing/persistence/typeorm/entities/ResultOrmEntity.ts new file mode 100644 index 000000000..8b872ee3e --- /dev/null +++ b/adapters/racing/persistence/typeorm/entities/ResultOrmEntity.ts @@ -0,0 +1,25 @@ +import { Column, Entity, PrimaryColumn } from 'typeorm'; + +@Entity({ name: 'racing_results' }) +export class ResultOrmEntity { + @PrimaryColumn({ type: 'text' }) + id!: string; + + @Column({ type: 'text' }) + raceId!: string; + + @Column({ type: 'text' }) + driverId!: string; + + @Column({ type: 'int' }) + position!: number; + + @Column({ type: 'int' }) + fastestLap!: number; + + @Column({ type: 'int' }) + incidents!: number; + + @Column({ type: 'int' }) + startPosition!: number; +} \ No newline at end of file diff --git a/adapters/racing/persistence/typeorm/entities/StandingOrmEntity.ts b/adapters/racing/persistence/typeorm/entities/StandingOrmEntity.ts new file mode 100644 index 000000000..c64dae258 --- /dev/null +++ b/adapters/racing/persistence/typeorm/entities/StandingOrmEntity.ts @@ -0,0 +1,25 @@ +import { Column, Entity, PrimaryColumn } from 'typeorm'; + +@Entity({ name: 'racing_standings' }) +export class StandingOrmEntity { + @PrimaryColumn({ type: 'text' }) + id!: string; + + @Column({ type: 'text' }) + leagueId!: string; + + @Column({ type: 'text' }) + driverId!: string; + + @Column({ type: 'int' }) + points!: number; + + @Column({ type: 'int' }) + wins!: number; + + @Column({ type: 'int' }) + position!: number; + + @Column({ type: 'int' }) + racesCompleted!: number; +} \ No newline at end of file diff --git a/adapters/racing/persistence/typeorm/entities/TeamOrmEntities.ts b/adapters/racing/persistence/typeorm/entities/TeamOrmEntities.ts new file mode 100644 index 000000000..5a120ea39 --- /dev/null +++ b/adapters/racing/persistence/typeorm/entities/TeamOrmEntities.ts @@ -0,0 +1,61 @@ +import { Column, Entity, PrimaryColumn } from 'typeorm'; + +@Entity({ name: 'racing_teams' }) +export class TeamOrmEntity { + @PrimaryColumn({ type: 'uuid' }) + id!: string; + + @Column({ type: 'text' }) + name!: string; + + @Column({ type: 'text' }) + tag!: string; + + @Column({ type: 'text' }) + description!: string; + + @Column({ type: 'uuid' }) + ownerId!: string; + + @Column({ type: 'uuid', array: true }) + leagues!: string[]; + + @Column({ type: 'timestamptz' }) + createdAt!: Date; +} + +@Entity({ name: 'racing_team_memberships' }) +export class TeamMembershipOrmEntity { + @PrimaryColumn({ type: 'uuid' }) + teamId!: string; + + @PrimaryColumn({ type: 'uuid' }) + driverId!: string; + + @Column({ type: 'text' }) + role!: string; + + @Column({ type: 'text' }) + status!: string; + + @Column({ type: 'timestamptz' }) + joinedAt!: Date; +} + +@Entity({ name: 'racing_team_join_requests' }) +export class TeamJoinRequestOrmEntity { + @PrimaryColumn({ type: 'text' }) + id!: string; + + @Column({ type: 'uuid' }) + teamId!: string; + + @Column({ type: 'uuid' }) + driverId!: string; + + @Column({ type: 'timestamptz' }) + requestedAt!: Date; + + @Column({ type: 'text', nullable: true }) + message!: string | null; +} \ No newline at end of file diff --git a/adapters/racing/persistence/typeorm/errors/InvalidDriverSchemaError.ts b/adapters/racing/persistence/typeorm/errors/InvalidDriverSchemaError.ts new file mode 100644 index 000000000..65bff89f5 --- /dev/null +++ b/adapters/racing/persistence/typeorm/errors/InvalidDriverSchemaError.ts @@ -0,0 +1,32 @@ +import { TypeOrmPersistenceSchemaError } from './TypeOrmPersistenceSchemaError'; + +type InvalidDriverSchemaErrorParams = { + fieldName: string; + reason: TypeOrmPersistenceSchemaError['reason']; + message?: string; +}; + +export class InvalidDriverSchemaError extends TypeOrmPersistenceSchemaError { + override readonly name: string = 'InvalidDriverSchemaError'; + + constructor(params: InvalidDriverSchemaErrorParams); + constructor(message: string); + constructor(paramsOrMessage: InvalidDriverSchemaErrorParams | string) { + if (typeof paramsOrMessage === 'string') { + super({ + entityName: 'Driver', + fieldName: 'unknown', + reason: 'invalid_shape', + message: paramsOrMessage, + }); + return; + } + + super({ + entityName: 'Driver', + fieldName: paramsOrMessage.fieldName, + reason: paramsOrMessage.reason, + ...(paramsOrMessage.message ? { message: paramsOrMessage.message } : {}), + }); + } +} \ No newline at end of file diff --git a/adapters/racing/persistence/typeorm/errors/InvalidLeagueMembershipSchemaError.ts b/adapters/racing/persistence/typeorm/errors/InvalidLeagueMembershipSchemaError.ts new file mode 100644 index 000000000..1f550d265 --- /dev/null +++ b/adapters/racing/persistence/typeorm/errors/InvalidLeagueMembershipSchemaError.ts @@ -0,0 +1,32 @@ +import { TypeOrmPersistenceSchemaError } from './TypeOrmPersistenceSchemaError'; + +type InvalidLeagueMembershipSchemaErrorParams = { + fieldName: string; + reason: TypeOrmPersistenceSchemaError['reason']; + message?: string; +}; + +export class InvalidLeagueMembershipSchemaError extends TypeOrmPersistenceSchemaError { + override readonly name: string = 'InvalidLeagueMembershipSchemaError'; + + constructor(params: InvalidLeagueMembershipSchemaErrorParams); + constructor(message: string); + constructor(paramsOrMessage: InvalidLeagueMembershipSchemaErrorParams | string) { + super( + typeof paramsOrMessage === 'string' + ? { + entityName: 'LeagueMembership', + fieldName: 'unknown', + reason: 'invalid_shape', + message: paramsOrMessage, + } + : { + entityName: 'LeagueMembership', + fieldName: paramsOrMessage.fieldName, + reason: paramsOrMessage.reason, + ...(paramsOrMessage.message ? { message: paramsOrMessage.message } : {}), + }, + ); + this.name = 'InvalidLeagueMembershipSchemaError'; + } +} \ No newline at end of file diff --git a/adapters/racing/persistence/typeorm/errors/InvalidLeagueScoringConfigChampionshipsSchemaError.ts b/adapters/racing/persistence/typeorm/errors/InvalidLeagueScoringConfigChampionshipsSchemaError.ts index 412393207..4ce431fe3 100644 --- a/adapters/racing/persistence/typeorm/errors/InvalidLeagueScoringConfigChampionshipsSchemaError.ts +++ b/adapters/racing/persistence/typeorm/errors/InvalidLeagueScoringConfigChampionshipsSchemaError.ts @@ -1,7 +1,32 @@ -export class InvalidLeagueScoringConfigChampionshipsSchemaError extends Error { - override readonly name = 'InvalidLeagueScoringConfigChampionshipsSchemaError'; +import { TypeOrmPersistenceSchemaError } from './TypeOrmPersistenceSchemaError'; - constructor(message = 'Invalid LeagueScoringConfig.championships persisted schema') { - super(message); +type InvalidLeagueScoringConfigChampionshipsSchemaErrorParams = { + fieldName: string; + reason: TypeOrmPersistenceSchemaError['reason']; + message?: string; +}; + +export class InvalidLeagueScoringConfigChampionshipsSchemaError extends TypeOrmPersistenceSchemaError { + override readonly name: string = 'InvalidLeagueScoringConfigChampionshipsSchemaError'; + + constructor(params: InvalidLeagueScoringConfigChampionshipsSchemaErrorParams); + constructor(message?: string); + constructor(paramsOrMessage: InvalidLeagueScoringConfigChampionshipsSchemaErrorParams | string | undefined) { + if (typeof paramsOrMessage === 'string' || paramsOrMessage === undefined) { + super({ + entityName: 'LeagueScoringConfig', + fieldName: 'championships', + reason: 'invalid_shape', + ...(paramsOrMessage ? { message: paramsOrMessage } : {}), + }); + return; + } + + super({ + entityName: 'LeagueScoringConfig', + fieldName: paramsOrMessage.fieldName, + reason: paramsOrMessage.reason, + ...(paramsOrMessage.message ? { message: paramsOrMessage.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 index 0c5b58128..d66c8d974 100644 --- a/adapters/racing/persistence/typeorm/errors/InvalidLeagueSettingsSchemaError.ts +++ b/adapters/racing/persistence/typeorm/errors/InvalidLeagueSettingsSchemaError.ts @@ -1,6 +1,32 @@ -export class InvalidLeagueSettingsSchemaError extends Error { - constructor(message: string) { - super(message); - this.name = 'InvalidLeagueSettingsSchemaError'; +import { TypeOrmPersistenceSchemaError } from './TypeOrmPersistenceSchemaError'; + +type InvalidLeagueSettingsSchemaErrorParams = { + fieldName: string; + reason: TypeOrmPersistenceSchemaError['reason']; + message?: string; +}; + +export class InvalidLeagueSettingsSchemaError extends TypeOrmPersistenceSchemaError { + override readonly name: string = 'InvalidLeagueSettingsSchemaError'; + + constructor(params: InvalidLeagueSettingsSchemaErrorParams); + constructor(message: string); + constructor(paramsOrMessage: InvalidLeagueSettingsSchemaErrorParams | string) { + if (typeof paramsOrMessage === 'string') { + super({ + entityName: 'League', + fieldName: 'settings', + reason: 'invalid_shape', + message: paramsOrMessage, + }); + return; + } + + super({ + entityName: 'League', + fieldName: paramsOrMessage.fieldName, + reason: paramsOrMessage.reason, + ...(paramsOrMessage.message ? { message: paramsOrMessage.message } : {}), + }); } } \ No newline at end of file diff --git a/adapters/racing/persistence/typeorm/errors/InvalidRaceRegistrationSchemaError.ts b/adapters/racing/persistence/typeorm/errors/InvalidRaceRegistrationSchemaError.ts new file mode 100644 index 000000000..f5bfbcb2b --- /dev/null +++ b/adapters/racing/persistence/typeorm/errors/InvalidRaceRegistrationSchemaError.ts @@ -0,0 +1,32 @@ +import { TypeOrmPersistenceSchemaError } from './TypeOrmPersistenceSchemaError'; + +type InvalidRaceRegistrationSchemaErrorParams = { + fieldName: string; + reason: TypeOrmPersistenceSchemaError['reason']; + message?: string; +}; + +export class InvalidRaceRegistrationSchemaError extends TypeOrmPersistenceSchemaError { + override readonly name: string = 'InvalidRaceRegistrationSchemaError'; + + constructor(params: InvalidRaceRegistrationSchemaErrorParams); + constructor(message: string); + constructor(paramsOrMessage: InvalidRaceRegistrationSchemaErrorParams | string) { + super( + typeof paramsOrMessage === 'string' + ? { + entityName: 'RaceRegistration', + fieldName: 'unknown', + reason: 'invalid_shape', + message: paramsOrMessage, + } + : { + entityName: 'RaceRegistration', + fieldName: paramsOrMessage.fieldName, + reason: paramsOrMessage.reason, + ...(paramsOrMessage.message ? { message: paramsOrMessage.message } : {}), + }, + ); + this.name = 'InvalidRaceRegistrationSchemaError'; + } +} \ No newline at end of file diff --git a/adapters/racing/persistence/typeorm/errors/InvalidRaceSessionTypeSchemaError.ts b/adapters/racing/persistence/typeorm/errors/InvalidRaceSessionTypeSchemaError.ts index 899aab08c..eb481d9e2 100644 --- a/adapters/racing/persistence/typeorm/errors/InvalidRaceSessionTypeSchemaError.ts +++ b/adapters/racing/persistence/typeorm/errors/InvalidRaceSessionTypeSchemaError.ts @@ -1,6 +1,32 @@ -export class InvalidRaceSessionTypeSchemaError extends Error { - constructor(message: string) { - super(message); - this.name = 'InvalidRaceSessionTypeSchemaError'; +import { TypeOrmPersistenceSchemaError } from './TypeOrmPersistenceSchemaError'; + +type InvalidRaceSessionTypeSchemaErrorParams = { + fieldName: string; + reason: TypeOrmPersistenceSchemaError['reason']; + message?: string; +}; + +export class InvalidRaceSessionTypeSchemaError extends TypeOrmPersistenceSchemaError { + override readonly name: string = 'InvalidRaceSessionTypeSchemaError'; + + constructor(params: InvalidRaceSessionTypeSchemaErrorParams); + constructor(message: string); + constructor(paramsOrMessage: InvalidRaceSessionTypeSchemaErrorParams | string) { + if (typeof paramsOrMessage === 'string') { + super({ + entityName: 'Race', + fieldName: 'sessionType', + reason: 'invalid_shape', + message: paramsOrMessage, + }); + return; + } + + super({ + entityName: 'Race', + fieldName: paramsOrMessage.fieldName, + reason: paramsOrMessage.reason, + ...(paramsOrMessage.message ? { message: paramsOrMessage.message } : {}), + }); } } \ No newline at end of file diff --git a/adapters/racing/persistence/typeorm/errors/InvalidRaceStatusSchemaError.ts b/adapters/racing/persistence/typeorm/errors/InvalidRaceStatusSchemaError.ts index 7213b3565..a1deb684e 100644 --- a/adapters/racing/persistence/typeorm/errors/InvalidRaceStatusSchemaError.ts +++ b/adapters/racing/persistence/typeorm/errors/InvalidRaceStatusSchemaError.ts @@ -1,6 +1,32 @@ -export class InvalidRaceStatusSchemaError extends Error { - constructor(message: string) { - super(message); - this.name = 'InvalidRaceStatusSchemaError'; +import { TypeOrmPersistenceSchemaError } from './TypeOrmPersistenceSchemaError'; + +type InvalidRaceStatusSchemaErrorParams = { + fieldName: string; + reason: TypeOrmPersistenceSchemaError['reason']; + message?: string; +}; + +export class InvalidRaceStatusSchemaError extends TypeOrmPersistenceSchemaError { + override readonly name: string = 'InvalidRaceStatusSchemaError'; + + constructor(params: InvalidRaceStatusSchemaErrorParams); + constructor(message: string); + constructor(paramsOrMessage: InvalidRaceStatusSchemaErrorParams | string) { + if (typeof paramsOrMessage === 'string') { + super({ + entityName: 'Race', + fieldName: 'status', + reason: 'invalid_shape', + message: paramsOrMessage, + }); + return; + } + + super({ + entityName: 'Race', + fieldName: paramsOrMessage.fieldName, + reason: paramsOrMessage.reason, + ...(paramsOrMessage.message ? { message: paramsOrMessage.message } : {}), + }); } } \ No newline at end of file diff --git a/adapters/racing/persistence/typeorm/errors/InvalidResultSchemaError.ts b/adapters/racing/persistence/typeorm/errors/InvalidResultSchemaError.ts new file mode 100644 index 000000000..bb3d695ee --- /dev/null +++ b/adapters/racing/persistence/typeorm/errors/InvalidResultSchemaError.ts @@ -0,0 +1,31 @@ +import { TypeOrmPersistenceSchemaError } from './TypeOrmPersistenceSchemaError'; + +type InvalidResultSchemaErrorParams = { + fieldName: string; + reason: TypeOrmPersistenceSchemaError['reason']; + message?: string; +}; + +export class InvalidResultSchemaError extends TypeOrmPersistenceSchemaError { + constructor(params: InvalidResultSchemaErrorParams); + constructor(message: string); + constructor(paramsOrMessage: InvalidResultSchemaErrorParams | string) { + const params = + typeof paramsOrMessage === 'string' + ? { + entityName: 'Result', + fieldName: 'unknown', + reason: 'invalid_shape' as const, + message: paramsOrMessage, + } + : { + entityName: 'Result', + fieldName: paramsOrMessage.fieldName, + reason: paramsOrMessage.reason, + ...(paramsOrMessage.message ? { message: paramsOrMessage.message } : {}), + }; + + super(params); + this.name = 'InvalidResultSchemaError'; + } +} \ No newline at end of file diff --git a/adapters/racing/persistence/typeorm/errors/InvalidSeasonScheduleSchemaError.ts b/adapters/racing/persistence/typeorm/errors/InvalidSeasonScheduleSchemaError.ts index 7c4cba99e..f55b68f46 100644 --- a/adapters/racing/persistence/typeorm/errors/InvalidSeasonScheduleSchemaError.ts +++ b/adapters/racing/persistence/typeorm/errors/InvalidSeasonScheduleSchemaError.ts @@ -1,6 +1,32 @@ -export class InvalidSeasonScheduleSchemaError extends Error { - constructor(message: string) { - super(message); - this.name = 'InvalidSeasonScheduleSchemaError'; +import { TypeOrmPersistenceSchemaError } from './TypeOrmPersistenceSchemaError'; + +type InvalidSeasonScheduleSchemaErrorParams = { + fieldName: string; + reason: TypeOrmPersistenceSchemaError['reason']; + message?: string; +}; + +export class InvalidSeasonScheduleSchemaError extends TypeOrmPersistenceSchemaError { + override readonly name: string = 'InvalidSeasonScheduleSchemaError'; + + constructor(params: InvalidSeasonScheduleSchemaErrorParams); + constructor(message: string); + constructor(paramsOrMessage: InvalidSeasonScheduleSchemaErrorParams | string) { + if (typeof paramsOrMessage === 'string') { + super({ + entityName: 'Season', + fieldName: 'schedule', + reason: 'invalid_shape', + message: paramsOrMessage, + }); + return; + } + + super({ + entityName: 'Season', + fieldName: paramsOrMessage.fieldName, + reason: paramsOrMessage.reason, + ...(paramsOrMessage.message ? { message: paramsOrMessage.message } : {}), + }); } } \ No newline at end of file diff --git a/adapters/racing/persistence/typeorm/errors/InvalidSeasonStatusSchemaError.ts b/adapters/racing/persistence/typeorm/errors/InvalidSeasonStatusSchemaError.ts index 2c9b978f2..d8ecf21b4 100644 --- a/adapters/racing/persistence/typeorm/errors/InvalidSeasonStatusSchemaError.ts +++ b/adapters/racing/persistence/typeorm/errors/InvalidSeasonStatusSchemaError.ts @@ -1,6 +1,32 @@ -export class InvalidSeasonStatusSchemaError extends Error { - constructor(message: string) { - super(message); - this.name = 'InvalidSeasonStatusSchemaError'; +import { TypeOrmPersistenceSchemaError } from './TypeOrmPersistenceSchemaError'; + +type InvalidSeasonStatusSchemaErrorParams = { + fieldName: string; + reason: TypeOrmPersistenceSchemaError['reason']; + message?: string; +}; + +export class InvalidSeasonStatusSchemaError extends TypeOrmPersistenceSchemaError { + override readonly name: string = 'InvalidSeasonStatusSchemaError'; + + constructor(params: InvalidSeasonStatusSchemaErrorParams); + constructor(message: string); + constructor(paramsOrMessage: InvalidSeasonStatusSchemaErrorParams | string) { + if (typeof paramsOrMessage === 'string') { + super({ + entityName: 'Season', + fieldName: 'status', + reason: 'invalid_shape', + message: paramsOrMessage, + }); + return; + } + + super({ + entityName: 'Season', + fieldName: paramsOrMessage.fieldName, + reason: paramsOrMessage.reason, + ...(paramsOrMessage.message ? { message: paramsOrMessage.message } : {}), + }); } } \ No newline at end of file diff --git a/adapters/racing/persistence/typeorm/errors/InvalidStandingSchemaError.ts b/adapters/racing/persistence/typeorm/errors/InvalidStandingSchemaError.ts new file mode 100644 index 000000000..ffd4183a8 --- /dev/null +++ b/adapters/racing/persistence/typeorm/errors/InvalidStandingSchemaError.ts @@ -0,0 +1,31 @@ +import { TypeOrmPersistenceSchemaError } from './TypeOrmPersistenceSchemaError'; + +type InvalidStandingSchemaErrorParams = { + fieldName: string; + reason: TypeOrmPersistenceSchemaError['reason']; + message?: string; +}; + +export class InvalidStandingSchemaError extends TypeOrmPersistenceSchemaError { + constructor(params: InvalidStandingSchemaErrorParams); + constructor(message: string); + constructor(paramsOrMessage: InvalidStandingSchemaErrorParams | string) { + const params = + typeof paramsOrMessage === 'string' + ? { + entityName: 'Standing', + fieldName: 'unknown', + reason: 'invalid_shape' as const, + message: paramsOrMessage, + } + : { + entityName: 'Standing', + fieldName: paramsOrMessage.fieldName, + reason: paramsOrMessage.reason, + ...(paramsOrMessage.message ? { message: paramsOrMessage.message } : {}), + }; + + super(params); + this.name = 'InvalidStandingSchemaError'; + } +} \ No newline at end of file diff --git a/adapters/racing/persistence/typeorm/errors/TypeOrmPersistenceSchemaError.ts b/adapters/racing/persistence/typeorm/errors/TypeOrmPersistenceSchemaError.ts new file mode 100644 index 000000000..3a64d55e1 --- /dev/null +++ b/adapters/racing/persistence/typeorm/errors/TypeOrmPersistenceSchemaError.ts @@ -0,0 +1,34 @@ +export type TypeOrmPersistenceSchemaErrorReason = + | 'missing' + | 'not_string' + | 'empty_string' + | 'not_number' + | 'not_integer' + | 'not_boolean' + | 'not_date' + | 'invalid_date' + | 'not_iso_date' + | 'not_array' + | 'not_object' + | 'invalid_enum_value' + | 'invalid_shape'; + +export class TypeOrmPersistenceSchemaError extends Error { + readonly entityName: string; + readonly fieldName: string; + readonly reason: TypeOrmPersistenceSchemaErrorReason | (string & {}); + + constructor(params: { + entityName: string; + fieldName: string; + reason: TypeOrmPersistenceSchemaError['reason']; + message?: string; + }) { + const message = params.message ?? `Invalid persisted ${params.entityName}.${params.fieldName}: ${params.reason}`; + super(message); + this.name = 'TypeOrmPersistenceSchemaError'; + this.entityName = params.entityName; + this.fieldName = params.fieldName; + this.reason = params.reason; + } +} \ No newline at end of file diff --git a/adapters/racing/persistence/typeorm/mappers/CommerceOrmMappers.test.ts b/adapters/racing/persistence/typeorm/mappers/CommerceOrmMappers.test.ts new file mode 100644 index 000000000..cd42305ac --- /dev/null +++ b/adapters/racing/persistence/typeorm/mappers/CommerceOrmMappers.test.ts @@ -0,0 +1,310 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { Game } from '@core/racing/domain/entities/Game'; +import { LeagueWallet } from '@core/racing/domain/entities/league-wallet/LeagueWallet'; +import { Transaction } from '@core/racing/domain/entities/league-wallet/Transaction'; +import { SeasonSponsorship } from '@core/racing/domain/entities/season/SeasonSponsorship'; +import { Sponsor } from '@core/racing/domain/entities/sponsor/Sponsor'; +import { SponsorshipRequest } from '@core/racing/domain/entities/SponsorshipRequest'; +import { Money } from '@core/racing/domain/value-objects/Money'; +import { SponsorshipPricing } from '@core/racing/domain/value-objects/SponsorshipPricing'; + +import { + GameOrmEntity, + LeagueWalletOrmEntity, + SeasonSponsorshipOrmEntity, + SponsorOrmEntity, + SponsorshipPricingOrmEntity, + SponsorshipRequestOrmEntity, + TransactionOrmEntity, +} from '../entities/MissingRacingOrmEntities'; +import { TypeOrmPersistenceSchemaError } from '../errors/TypeOrmPersistenceSchemaError'; +import { MoneyOrmMapper } from './MoneyOrmMapper'; +import { + GameOrmMapper, + LeagueWalletOrmMapper, + SeasonSponsorshipOrmMapper, + SponsorOrmMapper, + SponsorshipPricingOrmMapper, + SponsorshipRequestOrmMapper, + TransactionOrmMapper, +} from './CommerceOrmMappers'; + +describe('GameOrmMapper', () => { + it('toDomain uses rehydrate semantics (does not call create)', () => { + const mapper = new GameOrmMapper(); + + const entity = new GameOrmEntity(); + entity.id = 'iracing'; + entity.name = 'iRacing'; + + const rehydrateSpy = vi.spyOn(Game, 'rehydrate'); + const createSpy = vi.spyOn(Game, 'create').mockImplementation(() => { + throw new Error('create-called'); + }); + + const domain = mapper.toDomain(entity); + + expect(domain.id.toString()).toBe('iracing'); + expect(createSpy).not.toHaveBeenCalled(); + expect(rehydrateSpy).toHaveBeenCalled(); + }); +}); + +describe('SponsorOrmMapper', () => { + it('toDomain uses rehydrate semantics (does not call create)', () => { + const mapper = new SponsorOrmMapper(); + + const entity = new SponsorOrmEntity(); + entity.id = '00000000-0000-4000-8000-000000000001'; + entity.name = 'Sponsor One'; + entity.contactEmail = 'a@example.com'; + entity.logoUrl = null; + entity.websiteUrl = null; + entity.createdAt = new Date('2025-01-01T00:00:00.000Z'); + + const rehydrateSpy = vi.spyOn(Sponsor, 'rehydrate'); + const createSpy = vi.spyOn(Sponsor, '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 createdAt is a Date', () => { + const mapper = new SponsorOrmMapper(); + + const entity = new SponsorOrmEntity(); + entity.id = '00000000-0000-4000-8000-000000000001'; + entity.name = 'Sponsor One'; + entity.contactEmail = 'a@example.com'; + entity.logoUrl = null; + entity.websiteUrl = null; + entity.createdAt = 'not-a-date' as unknown as Date; + + try { + mapper.toDomain(entity); + throw new Error('expected-to-throw'); + } catch (error) { + expect(error).toBeInstanceOf(TypeOrmPersistenceSchemaError); + expect(error).toMatchObject({ + entityName: 'Sponsor', + fieldName: 'createdAt', + reason: 'not_date', + }); + } + }); +}); + +describe('LeagueWalletOrmMapper', () => { + it('toDomain uses rehydrate semantics', () => { + const moneyMapper = new MoneyOrmMapper(); + const mapper = new LeagueWalletOrmMapper(moneyMapper); + + const entity = new LeagueWalletOrmEntity(); + entity.id = '00000000-0000-4000-8000-000000000001'; + entity.leagueId = '00000000-0000-4000-8000-000000000002'; + entity.balance = { amount: 10, currency: 'USD' }; + entity.transactionIds = []; + entity.createdAt = new Date('2025-01-01T00:00:00.000Z'); + + const rehydrateSpy = vi.spyOn(LeagueWallet, 'rehydrate'); + + const domain = mapper.toDomain(entity); + + expect(domain.id.toString()).toBe(entity.id); + expect(rehydrateSpy).toHaveBeenCalled(); + }); + + it('toDomain validates createdAt is a Date', () => { + const moneyMapper = new MoneyOrmMapper(); + const mapper = new LeagueWalletOrmMapper(moneyMapper); + + const entity = new LeagueWalletOrmEntity(); + entity.id = '00000000-0000-4000-8000-000000000001'; + entity.leagueId = '00000000-0000-4000-8000-000000000002'; + entity.balance = { amount: 10, currency: 'USD' }; + entity.transactionIds = []; + entity.createdAt = 'not-a-date' as unknown as Date; + + try { + mapper.toDomain(entity); + throw new Error('expected-to-throw'); + } catch (error) { + expect(error).toBeInstanceOf(TypeOrmPersistenceSchemaError); + expect(error).toMatchObject({ + entityName: 'LeagueWallet', + fieldName: 'createdAt', + reason: 'not_date', + }); + } + }); +}); + +describe('TransactionOrmMapper', () => { + it('toDomain uses rehydrate semantics', () => { + const moneyMapper = new MoneyOrmMapper(); + const mapper = new TransactionOrmMapper(moneyMapper); + + const entity = new TransactionOrmEntity(); + entity.id = '00000000-0000-4000-8000-000000000001'; + entity.walletId = '00000000-0000-4000-8000-000000000002'; + entity.type = 'refund'; + entity.amount = { amount: 10, currency: 'USD' }; + entity.platformFee = { amount: 1, currency: 'USD' }; + entity.netAmount = { amount: 9, currency: 'USD' }; + entity.status = 'completed'; + entity.createdAt = new Date('2025-01-01T00:00:00.000Z'); + entity.completedAt = null; + entity.description = null; + entity.metadata = null; + + const rehydrateSpy = vi.spyOn(Transaction, 'rehydrate'); + + const domain = mapper.toDomain(entity); + + expect(domain.id.toString()).toBe(entity.id); + expect(rehydrateSpy).toHaveBeenCalled(); + }); + + it('toDomain validates createdAt is a Date', () => { + const moneyMapper = new MoneyOrmMapper(); + const mapper = new TransactionOrmMapper(moneyMapper); + + const entity = new TransactionOrmEntity(); + entity.id = '00000000-0000-4000-8000-000000000001'; + entity.walletId = '00000000-0000-4000-8000-000000000002'; + entity.type = 'refund'; + entity.amount = { amount: 10, currency: 'USD' }; + entity.platformFee = { amount: 1, currency: 'USD' }; + entity.netAmount = { amount: 9, currency: 'USD' }; + entity.status = 'completed'; + entity.createdAt = 'not-a-date' as unknown as Date; + entity.completedAt = null; + entity.description = null; + entity.metadata = null; + + try { + mapper.toDomain(entity); + throw new Error('expected-to-throw'); + } catch (error) { + expect(error).toBeInstanceOf(TypeOrmPersistenceSchemaError); + expect(error).toMatchObject({ + entityName: 'Transaction', + fieldName: 'createdAt', + reason: 'not_date', + }); + } + }); +}); + +describe('SponsorshipPricingOrmMapper', () => { + it('round-trips value object via JSON', () => { + const moneyMapper = new MoneyOrmMapper(); + const mapper = new SponsorshipPricingOrmMapper(moneyMapper); + + const pricing = SponsorshipPricing.create({ + acceptingApplications: true, + mainSlot: { + tier: 'main', + price: Money.create(100, 'USD'), + benefits: ['Logo on car'], + available: true, + maxSlots: 1, + }, + }); + + const orm = mapper.toOrmEntity('team', 'team-1', pricing); + const rehydrated = mapper.toDomain(orm); + + expect(rehydrated.equals(pricing)).toBe(true); + }); + + it('toDomain validates nested currency enums', () => { + const mapper = new SponsorshipPricingOrmMapper(new MoneyOrmMapper()); + + const entity = new SponsorshipPricingOrmEntity(); + entity.id = 'team:team-1'; + entity.entityType = 'team'; + entity.entityId = 'team-1'; + entity.pricing = { + acceptingApplications: true, + mainSlot: { + tier: 'main', + price: { amount: 10, currency: 'JPY' }, + benefits: [], + available: true, + maxSlots: 1, + }, + }; + + try { + mapper.toDomain(entity); + throw new Error('expected-to-throw'); + } catch (error) { + expect(error).toBeInstanceOf(TypeOrmPersistenceSchemaError); + expect(error).toMatchObject({ + entityName: 'SponsorshipPricing', + reason: 'invalid_enum_value', + }); + } + }); +}); + +describe('SponsorshipRequestOrmMapper', () => { + it('toDomain uses rehydrate semantics', () => { + const mapper = new SponsorshipRequestOrmMapper(new MoneyOrmMapper()); + + const entity = new SponsorshipRequestOrmEntity(); + entity.id = '00000000-0000-4000-8000-000000000001'; + entity.sponsorId = '00000000-0000-4000-8000-000000000002'; + entity.entityType = 'team'; + entity.entityId = 'team-1'; + entity.tier = 'main'; + entity.offeredAmount = { amount: 10, currency: 'USD' }; + entity.message = null; + entity.status = 'pending'; + entity.createdAt = new Date('2025-01-01T00:00:00.000Z'); + entity.respondedAt = null; + entity.respondedBy = null; + entity.rejectionReason = null; + + const rehydrateSpy = vi.spyOn(SponsorshipRequest, 'rehydrate'); + + const domain = mapper.toDomain(entity); + + expect(domain.id).toBe(entity.id); + expect(rehydrateSpy).toHaveBeenCalled(); + }); +}); + +describe('SeasonSponsorshipOrmMapper', () => { + it('toDomain uses rehydrate semantics', () => { + const mapper = new SeasonSponsorshipOrmMapper(new MoneyOrmMapper()); + + const entity = new SeasonSponsorshipOrmEntity(); + entity.id = '00000000-0000-4000-8000-000000000001'; + entity.seasonId = '00000000-0000-4000-8000-000000000002'; + entity.leagueId = null; + entity.sponsorId = '00000000-0000-4000-8000-000000000003'; + entity.tier = 'main'; + entity.pricing = { amount: 100, currency: 'USD' }; + entity.status = 'pending'; + entity.createdAt = new Date('2025-01-01T00:00:00.000Z'); + entity.activatedAt = null; + entity.endedAt = null; + entity.cancelledAt = null; + entity.description = null; + + const rehydrateSpy = vi.spyOn(SeasonSponsorship, 'rehydrate'); + + const domain = mapper.toDomain(entity); + + expect(domain.id).toBe(entity.id); + expect(rehydrateSpy).toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/adapters/racing/persistence/typeorm/mappers/CommerceOrmMappers.ts b/adapters/racing/persistence/typeorm/mappers/CommerceOrmMappers.ts new file mode 100644 index 000000000..084ff4080 --- /dev/null +++ b/adapters/racing/persistence/typeorm/mappers/CommerceOrmMappers.ts @@ -0,0 +1,506 @@ +import { Game } from '@core/racing/domain/entities/Game'; +import { LeagueWallet } from '@core/racing/domain/entities/league-wallet/LeagueWallet'; +import { Transaction, type TransactionStatus, type TransactionType } from '@core/racing/domain/entities/league-wallet/Transaction'; +import { SeasonSponsorship } from '@core/racing/domain/entities/season/SeasonSponsorship'; +import { Sponsor } from '@core/racing/domain/entities/sponsor/Sponsor'; +import { SponsorshipRequest, type SponsorableEntityType, type SponsorshipRequestStatus } from '@core/racing/domain/entities/SponsorshipRequest'; +import { SponsorshipPricing } from '@core/racing/domain/value-objects/SponsorshipPricing'; + +import { + GameOrmEntity, + LeagueWalletOrmEntity, + SeasonSponsorshipOrmEntity, + SponsorOrmEntity, + SponsorshipPricingOrmEntity, + SponsorshipRequestOrmEntity, + TransactionOrmEntity, +} from '../entities/MissingRacingOrmEntities'; +import { + assertArray, + assertBoolean, + assertDate, + assertEnumValue, + assertNonEmptyString, + assertOptionalStringOrNull, + assertRecord, +} from '../schema/TypeOrmSchemaGuards'; +import { TypeOrmPersistenceSchemaError } from '../errors/TypeOrmPersistenceSchemaError'; +import { MoneyOrmMapper } from './MoneyOrmMapper'; + +const VALID_CURRENCIES = ['USD', 'EUR', 'GBP'] as const; + +const VALID_SPONSORABLE_ENTITY_TYPES = ['driver', 'team', 'race', 'season'] as const; + +const VALID_SPONSORSHIP_REQUEST_STATUSES = ['pending', 'accepted', 'rejected', 'withdrawn'] as const; + +const VALID_SPONSORSHIP_TIERS = ['main', 'secondary'] as const; + +const VALID_SPONSORSHIP_STATUSES = ['pending', 'active', 'ended', 'cancelled'] as const; + +const VALID_TRANSACTION_TYPES = [ + 'sponsorship_payment', + 'membership_payment', + 'prize_payout', + 'withdrawal', + 'refund', +] as const; + +const VALID_TRANSACTION_STATUSES = ['pending', 'completed', 'failed', 'cancelled'] as const; + +type SerializedSponsorshipSlotConfig = { + tier: 'main' | 'secondary'; + price: { amount: number; currency: (typeof VALID_CURRENCIES)[number] }; + benefits: string[]; + available: boolean; + maxSlots: number; +}; + +type SerializedSponsorshipPricing = { + mainSlot?: SerializedSponsorshipSlotConfig; + secondarySlots?: SerializedSponsorshipSlotConfig; + acceptingApplications: boolean; + customRequirements?: string; +}; + +function assertSerializedSlotConfig(value: unknown): asserts value is SerializedSponsorshipSlotConfig { + const entityName = 'SponsorshipPricing'; + const fieldName = 'pricing.slot'; + + assertRecord(entityName, fieldName, value); + + assertEnumValue(entityName, `${fieldName}.tier`, value.tier, VALID_SPONSORSHIP_TIERS); + + assertRecord(entityName, `${fieldName}.price`, value.price); + const amount = (value.price as Record).amount; + const currency = (value.price as Record).currency; + + if (typeof amount !== 'number' || Number.isNaN(amount)) { + throw new TypeOrmPersistenceSchemaError({ entityName, fieldName: `${fieldName}.price.amount`, reason: 'not_number' }); + } + if (typeof currency !== 'string') { + throw new TypeOrmPersistenceSchemaError({ entityName, fieldName: `${fieldName}.price.currency`, reason: 'not_string' }); + } + if (!(VALID_CURRENCIES as readonly string[]).includes(currency)) { + throw new TypeOrmPersistenceSchemaError({ + entityName, + fieldName: `${fieldName}.price.currency`, + reason: 'invalid_enum_value', + }); + } + + assertArray(entityName, `${fieldName}.benefits`, value.benefits); + for (const benefit of value.benefits) { + if (typeof benefit !== 'string') { + throw new TypeOrmPersistenceSchemaError({ + entityName, + fieldName: `${fieldName}.benefits`, + reason: 'not_string', + }); + } + } + + assertBoolean(entityName, `${fieldName}.available`, value.available); + + if (typeof value.maxSlots !== 'number' || !Number.isInteger(value.maxSlots)) { + throw new TypeOrmPersistenceSchemaError({ + entityName, + fieldName: `${fieldName}.maxSlots`, + reason: 'not_integer', + }); + } +} + +function assertSerializedSponsorshipPricing(value: unknown): asserts value is SerializedSponsorshipPricing { + const entityName = 'SponsorshipPricing'; + const fieldName = 'pricing'; + + assertRecord(entityName, fieldName, value); + + assertBoolean(entityName, `${fieldName}.acceptingApplications`, value.acceptingApplications); + + if (value.customRequirements !== undefined && typeof value.customRequirements !== 'string') { + throw new TypeOrmPersistenceSchemaError({ + entityName, + fieldName: `${fieldName}.customRequirements`, + reason: 'not_string', + }); + } + + if (value.mainSlot !== undefined) { + assertSerializedSlotConfig(value.mainSlot); + } + if (value.secondarySlots !== undefined) { + assertSerializedSlotConfig(value.secondarySlots); + } +} + +export class GameOrmMapper { + toOrmEntity(domain: Game): GameOrmEntity { + const entity = new GameOrmEntity(); + entity.id = domain.id.toString(); + entity.name = domain.name.toString(); + return entity; + } + + toDomain(entity: GameOrmEntity): Game { + const entityName = 'Game'; + assertNonEmptyString(entityName, 'id', entity.id); + assertNonEmptyString(entityName, 'name', entity.name); + + try { + return Game.rehydrate({ id: entity.id, name: entity.name }); + } catch { + throw new TypeOrmPersistenceSchemaError({ entityName, fieldName: '__root', reason: 'invalid_shape' }); + } + } +} + +export class SponsorOrmMapper { + toOrmEntity(domain: Sponsor): SponsorOrmEntity { + const entity = new SponsorOrmEntity(); + entity.id = domain.id.toString(); + entity.name = domain.name.toString(); + entity.contactEmail = domain.contactEmail.toString(); + entity.logoUrl = domain.logoUrl?.toString() ?? null; + entity.websiteUrl = domain.websiteUrl?.toString() ?? null; + entity.createdAt = domain.createdAt.toDate(); + return entity; + } + + toDomain(entity: SponsorOrmEntity): Sponsor { + const entityName = 'Sponsor'; + + assertNonEmptyString(entityName, 'id', entity.id); + assertNonEmptyString(entityName, 'name', entity.name); + assertNonEmptyString(entityName, 'contactEmail', entity.contactEmail); + assertOptionalStringOrNull(entityName, 'logoUrl', entity.logoUrl); + assertOptionalStringOrNull(entityName, 'websiteUrl', entity.websiteUrl); + assertDate(entityName, 'createdAt', entity.createdAt); + + try { + return Sponsor.rehydrate({ + id: entity.id, + name: entity.name, + contactEmail: entity.contactEmail, + ...(entity.logoUrl !== null && entity.logoUrl !== undefined ? { logoUrl: entity.logoUrl } : {}), + ...(entity.websiteUrl !== null && entity.websiteUrl !== undefined ? { websiteUrl: entity.websiteUrl } : {}), + createdAt: entity.createdAt, + }); + } catch { + throw new TypeOrmPersistenceSchemaError({ entityName, fieldName: '__root', reason: 'invalid_shape' }); + } + } +} + +export class LeagueWalletOrmMapper { + constructor( + private readonly moneyMapper: MoneyOrmMapper, + ) {} + + toOrmEntity(domain: LeagueWallet): LeagueWalletOrmEntity { + const entity = new LeagueWalletOrmEntity(); + entity.id = domain.id.toString(); + entity.leagueId = domain.leagueId.toString(); + entity.balance = this.moneyMapper.toOrm(domain.balance); + entity.transactionIds = domain.transactionIds.map((t) => t.toString()); + entity.createdAt = domain.createdAt; + return entity; + } + + toDomain(entity: LeagueWalletOrmEntity): LeagueWallet { + const entityName = 'LeagueWallet'; + + assertNonEmptyString(entityName, 'id', entity.id); + assertNonEmptyString(entityName, 'leagueId', entity.leagueId); + assertArray(entityName, 'transactionIds', entity.transactionIds); + for (const tid of entity.transactionIds) { + assertNonEmptyString(entityName, 'transactionIds', tid); + } + assertRecord(entityName, 'balance', entity.balance); + assertDate(entityName, 'createdAt', entity.createdAt); + + const balance = this.moneyMapper.toDomain(entityName, 'balance', entity.balance); + + try { + return LeagueWallet.rehydrate({ + id: entity.id, + leagueId: entity.leagueId, + balance, + transactionIds: entity.transactionIds, + createdAt: entity.createdAt, + }); + } catch { + throw new TypeOrmPersistenceSchemaError({ entityName, fieldName: '__root', reason: 'invalid_shape' }); + } + } +} + +export class TransactionOrmMapper { + constructor( + private readonly moneyMapper: MoneyOrmMapper, + ) {} + + toOrmEntity(domain: Transaction): TransactionOrmEntity { + const entity = new TransactionOrmEntity(); + entity.id = domain.id.toString(); + entity.walletId = domain.walletId.toString(); + entity.type = domain.type; + entity.amount = this.moneyMapper.toOrm(domain.amount); + entity.platformFee = this.moneyMapper.toOrm(domain.platformFee); + entity.netAmount = this.moneyMapper.toOrm(domain.netAmount); + entity.status = domain.status; + entity.createdAt = domain.createdAt; + entity.completedAt = domain.completedAt ?? null; + entity.description = domain.description ?? null; + entity.metadata = domain.metadata ?? null; + return entity; + } + + toDomain(entity: TransactionOrmEntity): Transaction { + const entityName = 'Transaction'; + + assertNonEmptyString(entityName, 'id', entity.id); + assertNonEmptyString(entityName, 'walletId', entity.walletId); + assertEnumValue(entityName, 'type', entity.type, VALID_TRANSACTION_TYPES); + assertEnumValue(entityName, 'status', entity.status, VALID_TRANSACTION_STATUSES); + assertRecord(entityName, 'amount', entity.amount); + assertRecord(entityName, 'platformFee', entity.platformFee); + assertRecord(entityName, 'netAmount', entity.netAmount); + assertDate(entityName, 'createdAt', entity.createdAt); + + const amount = this.moneyMapper.toDomain(entityName, 'amount', entity.amount); + const platformFee = this.moneyMapper.toDomain(entityName, 'platformFee', entity.platformFee); + const netAmount = this.moneyMapper.toDomain(entityName, 'netAmount', entity.netAmount); + + if (entity.completedAt !== null && entity.completedAt !== undefined && !(entity.completedAt instanceof Date)) { + throw new TypeOrmPersistenceSchemaError({ entityName, fieldName: 'completedAt', reason: 'not_date' }); + } + + assertOptionalStringOrNull(entityName, 'description', entity.description); + + if (entity.metadata !== null && entity.metadata !== undefined) { + assertRecord(entityName, 'metadata', entity.metadata); + } + + try { + return Transaction.rehydrate({ + id: entity.id, + walletId: entity.walletId, + type: entity.type as TransactionType, + amount, + platformFee, + netAmount, + status: entity.status as TransactionStatus, + createdAt: entity.createdAt, + ...(entity.completedAt !== null && entity.completedAt !== undefined ? { completedAt: entity.completedAt } : {}), + ...(entity.description !== null && entity.description !== undefined ? { description: entity.description } : {}), + ...(entity.metadata !== null && entity.metadata !== undefined ? { metadata: entity.metadata } : {}), + }); + } catch { + throw new TypeOrmPersistenceSchemaError({ entityName, fieldName: '__root', reason: 'invalid_shape' }); + } + } +} + +export class SponsorshipPricingOrmMapper { + constructor( + private readonly moneyMapper: MoneyOrmMapper, + ) {} + + makeId(entityType: SponsorableEntityType, entityId: string): string { + return `${entityType}:${entityId}`; + } + + toOrmEntity(entityType: SponsorableEntityType, entityId: string, pricing: SponsorshipPricing): SponsorshipPricingOrmEntity { + const entity = new SponsorshipPricingOrmEntity(); + entity.id = this.makeId(entityType, entityId); + entity.entityType = entityType; + entity.entityId = entityId; + + const serializeSlot = (slot: SponsorshipPricing['mainSlot']): SerializedSponsorshipSlotConfig | undefined => { + if (!slot) return undefined; + return { + tier: slot.tier, + price: this.moneyMapper.toOrm(slot.price), + benefits: slot.benefits, + available: slot.available, + maxSlots: slot.maxSlots, + }; + }; + + entity.pricing = { + acceptingApplications: pricing.acceptingApplications, + ...(pricing.customRequirements !== undefined ? { customRequirements: pricing.customRequirements } : {}), + ...(pricing.mainSlot !== undefined ? { mainSlot: serializeSlot(pricing.mainSlot)! } : {}), + ...(pricing.secondarySlots !== undefined ? { secondarySlots: serializeSlot(pricing.secondarySlots)! } : {}), + } satisfies SerializedSponsorshipPricing; + + return entity; + } + + toDomain(entity: SponsorshipPricingOrmEntity): SponsorshipPricing { + const entityName = 'SponsorshipPricing'; + + assertNonEmptyString(entityName, 'id', entity.id); + assertEnumValue(entityName, 'entityType', entity.entityType, VALID_SPONSORABLE_ENTITY_TYPES); + assertNonEmptyString(entityName, 'entityId', entity.entityId); + + assertSerializedSponsorshipPricing(entity.pricing); + + const parsed = entity.pricing as SerializedSponsorshipPricing; + + const parseSlot = (slot: SerializedSponsorshipSlotConfig | undefined): SponsorshipPricing['mainSlot'] => { + if (!slot) return undefined; + return { + tier: slot.tier, + price: this.moneyMapper.toDomain('SponsorshipPricing', 'pricing.slot.price', slot.price), + benefits: slot.benefits, + available: slot.available, + maxSlots: slot.maxSlots, + }; + }; + + try { + return SponsorshipPricing.create({ + acceptingApplications: parsed.acceptingApplications, + ...(parsed.customRequirements !== undefined ? { customRequirements: parsed.customRequirements } : {}), + ...(parsed.mainSlot !== undefined ? { mainSlot: parseSlot(parsed.mainSlot) } : {}), + ...(parsed.secondarySlots !== undefined ? { secondarySlots: parseSlot(parsed.secondarySlots) } : {}), + }); + } catch { + throw new TypeOrmPersistenceSchemaError({ entityName, fieldName: '__root', reason: 'invalid_shape' }); + } + } +} + +export class SponsorshipRequestOrmMapper { + constructor( + private readonly moneyMapper: MoneyOrmMapper, + ) {} + + toOrmEntity(domain: SponsorshipRequest): SponsorshipRequestOrmEntity { + const entity = new SponsorshipRequestOrmEntity(); + entity.id = domain.id; + entity.sponsorId = domain.sponsorId; + entity.entityType = domain.entityType; + entity.entityId = domain.entityId; + entity.tier = domain.tier; + entity.offeredAmount = this.moneyMapper.toOrm(domain.offeredAmount); + entity.message = domain.message ?? null; + entity.status = domain.status; + entity.createdAt = domain.createdAt; + entity.respondedAt = domain.respondedAt ?? null; + entity.respondedBy = domain.respondedBy ?? null; + entity.rejectionReason = domain.rejectionReason ?? null; + return entity; + } + + toDomain(entity: SponsorshipRequestOrmEntity): SponsorshipRequest { + const entityName = 'SponsorshipRequest'; + + assertNonEmptyString(entityName, 'id', entity.id); + assertNonEmptyString(entityName, 'sponsorId', entity.sponsorId); + assertEnumValue(entityName, 'entityType', entity.entityType, VALID_SPONSORABLE_ENTITY_TYPES); + assertNonEmptyString(entityName, 'entityId', entity.entityId); + assertEnumValue(entityName, 'tier', entity.tier, VALID_SPONSORSHIP_TIERS); + assertRecord(entityName, 'offeredAmount', entity.offeredAmount); + assertEnumValue(entityName, 'status', entity.status, VALID_SPONSORSHIP_REQUEST_STATUSES); + assertOptionalStringOrNull(entityName, 'message', entity.message); + assertOptionalStringOrNull(entityName, 'rejectionReason', entity.rejectionReason); + assertDate(entityName, 'createdAt', entity.createdAt); + + const offeredAmount = this.moneyMapper.toDomain(entityName, 'offeredAmount', entity.offeredAmount); + + if (entity.respondedAt !== null && entity.respondedAt !== undefined && !(entity.respondedAt instanceof Date)) { + throw new TypeOrmPersistenceSchemaError({ entityName, fieldName: 'respondedAt', reason: 'not_date' }); + } + if (entity.respondedBy !== null && entity.respondedBy !== undefined) { + assertNonEmptyString(entityName, 'respondedBy', entity.respondedBy); + } + + try { + return SponsorshipRequest.rehydrate({ + id: entity.id, + sponsorId: entity.sponsorId, + entityType: entity.entityType as SponsorableEntityType, + entityId: entity.entityId, + tier: entity.tier as any, + offeredAmount, + ...(entity.message !== null && entity.message !== undefined ? { message: entity.message } : {}), + status: entity.status as SponsorshipRequestStatus, + createdAt: entity.createdAt, + ...(entity.respondedAt !== null && entity.respondedAt !== undefined ? { respondedAt: entity.respondedAt } : {}), + ...(entity.respondedBy !== null && entity.respondedBy !== undefined ? { respondedBy: entity.respondedBy } : {}), + ...(entity.rejectionReason !== null && entity.rejectionReason !== undefined ? { rejectionReason: entity.rejectionReason } : {}), + }); + } catch { + throw new TypeOrmPersistenceSchemaError({ entityName, fieldName: '__root', reason: 'invalid_shape' }); + } + } +} + +export class SeasonSponsorshipOrmMapper { + constructor( + private readonly moneyMapper: MoneyOrmMapper, + ) {} + + toOrmEntity(domain: SeasonSponsorship): SeasonSponsorshipOrmEntity { + const entity = new SeasonSponsorshipOrmEntity(); + entity.id = domain.id; + entity.seasonId = domain.seasonId; + entity.leagueId = domain.leagueId ?? null; + entity.sponsorId = domain.sponsorId; + entity.tier = domain.tier; + entity.pricing = this.moneyMapper.toOrm(domain.pricing); + entity.status = domain.status; + entity.createdAt = domain.createdAt; + entity.activatedAt = domain.activatedAt ?? null; + entity.endedAt = domain.endedAt ?? null; + entity.cancelledAt = domain.cancelledAt ?? null; + entity.description = domain.description ?? null; + return entity; + } + + toDomain(entity: SeasonSponsorshipOrmEntity): SeasonSponsorship { + const entityName = 'SeasonSponsorship'; + + assertNonEmptyString(entityName, 'id', entity.id); + assertNonEmptyString(entityName, 'seasonId', entity.seasonId); + assertOptionalStringOrNull(entityName, 'leagueId', entity.leagueId); + assertNonEmptyString(entityName, 'sponsorId', entity.sponsorId); + assertEnumValue(entityName, 'tier', entity.tier, VALID_SPONSORSHIP_TIERS); + assertRecord(entityName, 'pricing', entity.pricing); + assertEnumValue(entityName, 'status', entity.status, VALID_SPONSORSHIP_STATUSES); + assertOptionalStringOrNull(entityName, 'description', entity.description); + assertDate(entityName, 'createdAt', entity.createdAt); + + const pricing = this.moneyMapper.toDomain(entityName, 'pricing', entity.pricing); + + const dateOrNull = (fieldName: string, d: Date | null) => { + if (d === null) return undefined; + if (!(d instanceof Date)) { + throw new TypeOrmPersistenceSchemaError({ entityName, fieldName, reason: 'not_date' }); + } + return d; + }; + + try { + return SeasonSponsorship.rehydrate({ + id: entity.id, + seasonId: entity.seasonId, + ...(entity.leagueId !== null && entity.leagueId !== undefined ? { leagueId: entity.leagueId } : {}), + sponsorId: entity.sponsorId, + tier: entity.tier as any, + pricing, + status: entity.status as any, + createdAt: entity.createdAt, + ...(dateOrNull('activatedAt', entity.activatedAt) ? { activatedAt: entity.activatedAt! } : {}), + ...(dateOrNull('endedAt', entity.endedAt) ? { endedAt: entity.endedAt! } : {}), + ...(dateOrNull('cancelledAt', entity.cancelledAt) ? { cancelledAt: entity.cancelledAt! } : {}), + ...(entity.description !== null && entity.description !== undefined ? { description: entity.description } : {}), + }); + } catch { + throw new TypeOrmPersistenceSchemaError({ entityName, fieldName: '__root', reason: 'invalid_shape' }); + } + } +} \ No newline at end of file diff --git a/adapters/racing/persistence/typeorm/mappers/DriverOrmMapper.test.ts b/adapters/racing/persistence/typeorm/mappers/DriverOrmMapper.test.ts new file mode 100644 index 000000000..ef66b27f2 --- /dev/null +++ b/adapters/racing/persistence/typeorm/mappers/DriverOrmMapper.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { Driver } from '@core/racing/domain/entities/Driver'; + +import { DriverOrmEntity } from '../entities/DriverOrmEntity'; +import { TypeOrmPersistenceSchemaError } from '../errors/TypeOrmPersistenceSchemaError'; +import { DriverOrmMapper } from './DriverOrmMapper'; + +describe('DriverOrmMapper', () => { + it('toDomain preserves persisted identity and uses rehydrate semantics (does not call create)', () => { + const mapper = new DriverOrmMapper(); + + const entity = new DriverOrmEntity(); + entity.id = '00000000-0000-4000-8000-000000000001'; + entity.iracingId = '12345'; + entity.name = 'Max Verstappen'; + entity.country = 'DE'; + entity.bio = 'Bio'; + entity.joinedAt = new Date('2025-01-01T00:00:00.000Z'); + + if (typeof (Driver as unknown as { rehydrate?: unknown }).rehydrate !== 'function') { + throw new Error('rehydrate-missing'); + } + + const rehydrateSpy = vi.spyOn(Driver as unknown as { rehydrate: (...args: unknown[]) => unknown }, 'rehydrate'); + const createSpy = vi.spyOn(Driver, 'create').mockImplementation(() => { + throw new Error('create-called'); + }); + + const domain = mapper.toDomain(entity); + + expect(domain.id).toBe(entity.id); + expect(domain.iracingId.toString()).toBe(entity.iracingId); + expect(createSpy).not.toHaveBeenCalled(); + expect(rehydrateSpy).toHaveBeenCalled(); + }); + + it('toDomain validates persisted shape and throws adapter-scoped base schema error type', () => { + const mapper = new DriverOrmMapper(); + + const entity = new DriverOrmEntity(); + entity.id = '00000000-0000-4000-8000-000000000001'; + entity.iracingId = 123 as unknown as string; + entity.name = 'Name'; + entity.country = 'DE'; + entity.bio = null; + entity.joinedAt = new Date('2025-01-01T00:00:00.000Z'); + + try { + mapper.toDomain(entity); + throw new Error('expected-to-throw'); + } catch (error) { + expect(error).toBeInstanceOf(TypeOrmPersistenceSchemaError); + expect(error).toMatchObject({ + entityName: 'Driver', + fieldName: 'iracingId', + reason: 'not_string', + }); + } + }); +}); \ No newline at end of file diff --git a/adapters/racing/persistence/typeorm/mappers/DriverOrmMapper.ts b/adapters/racing/persistence/typeorm/mappers/DriverOrmMapper.ts new file mode 100644 index 000000000..4fe599a1d --- /dev/null +++ b/adapters/racing/persistence/typeorm/mappers/DriverOrmMapper.ts @@ -0,0 +1,37 @@ +import { Driver } from '@core/racing/domain/entities/Driver'; + +import { DriverOrmEntity } from '../entities/DriverOrmEntity'; +import { assertDate, assertNonEmptyString, assertOptionalStringOrNull } from '../schema/TypeOrmSchemaGuards'; + +export class DriverOrmMapper { + toOrmEntity(domain: Driver): DriverOrmEntity { + const entity = new DriverOrmEntity(); + entity.id = domain.id; + entity.iracingId = domain.iracingId.toString(); + entity.name = domain.name.toString(); + entity.country = domain.country.toString(); + entity.bio = domain.bio?.toString() ?? null; + entity.joinedAt = domain.joinedAt.toDate(); + return entity; + } + + toDomain(entity: DriverOrmEntity): Driver { + const entityName = 'Driver'; + + assertNonEmptyString(entityName, 'id', entity.id); + assertNonEmptyString(entityName, 'iracingId', entity.iracingId); + assertNonEmptyString(entityName, 'name', entity.name); + assertNonEmptyString(entityName, 'country', entity.country); + assertDate(entityName, 'joinedAt', entity.joinedAt); + assertOptionalStringOrNull(entityName, 'bio', entity.bio); + + return Driver.rehydrate({ + id: entity.id, + iracingId: entity.iracingId, + name: entity.name, + country: entity.country, + ...(entity.bio !== null && entity.bio !== undefined ? { bio: entity.bio } : {}), + joinedAt: entity.joinedAt, + }); + } +} \ No newline at end of file diff --git a/adapters/racing/persistence/typeorm/mappers/LeagueMembershipOrmMapper.test.ts b/adapters/racing/persistence/typeorm/mappers/LeagueMembershipOrmMapper.test.ts new file mode 100644 index 000000000..a84c3abcc --- /dev/null +++ b/adapters/racing/persistence/typeorm/mappers/LeagueMembershipOrmMapper.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { LeagueMembership } from '@core/racing/domain/entities/LeagueMembership'; + +import { LeagueMembershipOrmEntity } from '../entities/LeagueMembershipOrmEntity'; +import { LeagueMembershipOrmMapper } from './LeagueMembershipOrmMapper'; + +describe('LeagueMembershipOrmMapper', () => { + it('toDomain preserves persisted identity and uses rehydrate semantics (does not call create)', () => { + const mapper = new LeagueMembershipOrmMapper(); + + const entity = new LeagueMembershipOrmEntity(); + entity.id = 'membership-1'; + entity.leagueId = '00000000-0000-4000-8000-000000000001'; + entity.driverId = '00000000-0000-4000-8000-000000000002'; + entity.role = 'member'; + entity.status = 'active'; + entity.joinedAt = new Date('2025-01-01T00:00:00.000Z'); + + if (typeof (LeagueMembership as unknown as { rehydrate?: unknown }).rehydrate !== 'function') { + throw new Error('rehydrate-missing'); + } + + const rehydrateSpy = vi.spyOn( + LeagueMembership as unknown as { rehydrate: (...args: unknown[]) => unknown }, + 'rehydrate', + ); + const createSpy = vi.spyOn(LeagueMembership, 'create').mockImplementation(() => { + throw new Error('create-called'); + }); + + const domain = mapper.toDomain(entity); + + expect(domain.id).toBe(entity.id); + expect(domain.leagueId.toString()).toBe(entity.leagueId); + expect(domain.driverId.toString()).toBe(entity.driverId); + expect(createSpy).not.toHaveBeenCalled(); + expect(rehydrateSpy).toHaveBeenCalled(); + }); + + it('toDomain validates persisted shape and throws adapter-scoped error type', () => { + const mapper = new LeagueMembershipOrmMapper(); + + const entity = new LeagueMembershipOrmEntity(); + entity.id = 'membership-1'; + entity.leagueId = '00000000-0000-4000-8000-000000000001'; + entity.driverId = '00000000-0000-4000-8000-000000000002'; + entity.role = 123 as unknown as string; + entity.status = 'active'; + entity.joinedAt = new Date('2025-01-01T00:00:00.000Z'); + + try { + mapper.toDomain(entity); + throw new Error('expected-to-throw'); + } catch (error) { + expect(error).toMatchObject({ name: 'InvalidLeagueMembershipSchemaError' }); + } + }); +}); \ No newline at end of file diff --git a/adapters/racing/persistence/typeorm/mappers/LeagueMembershipOrmMapper.ts b/adapters/racing/persistence/typeorm/mappers/LeagueMembershipOrmMapper.ts new file mode 100644 index 000000000..4cda7c5bd --- /dev/null +++ b/adapters/racing/persistence/typeorm/mappers/LeagueMembershipOrmMapper.ts @@ -0,0 +1,61 @@ +import { LeagueMembership } from '@core/racing/domain/entities/LeagueMembership'; +import type { MembershipRoleValue } from '@core/racing/domain/entities/MembershipRole'; +import type { MembershipStatusValue } from '@core/racing/domain/entities/MembershipStatus'; + +import { LeagueMembershipOrmEntity } from '../entities/LeagueMembershipOrmEntity'; +import { InvalidLeagueMembershipSchemaError } from '../errors/InvalidLeagueMembershipSchemaError'; + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0; +} + +const VALID_ROLES: readonly MembershipRoleValue[] = ['owner', 'admin', 'steward', 'member'] as const; + +const VALID_STATUSES: readonly MembershipStatusValue[] = ['active', 'inactive', 'pending'] as const; + +function isOneOf(value: string, allowed: readonly T[]): value is T { + return (allowed as readonly string[]).includes(value); +} + +export class LeagueMembershipOrmMapper { + toOrmEntity(domain: LeagueMembership): LeagueMembershipOrmEntity { + const entity = new LeagueMembershipOrmEntity(); + entity.id = domain.id; + entity.leagueId = domain.leagueId.toString(); + entity.driverId = domain.driverId.toString(); + entity.role = domain.role.toString(); + entity.status = domain.status.toString(); + entity.joinedAt = domain.joinedAt.toDate(); + return entity; + } + + toDomain(entity: LeagueMembershipOrmEntity): LeagueMembership { + if (!isNonEmptyString(entity.id)) { + throw new InvalidLeagueMembershipSchemaError('Invalid id'); + } + if (!isNonEmptyString(entity.leagueId)) { + throw new InvalidLeagueMembershipSchemaError('Invalid leagueId'); + } + if (!isNonEmptyString(entity.driverId)) { + throw new InvalidLeagueMembershipSchemaError('Invalid driverId'); + } + if (!isNonEmptyString(entity.role) || !isOneOf(entity.role, VALID_ROLES)) { + throw new InvalidLeagueMembershipSchemaError('Invalid role'); + } + if (!isNonEmptyString(entity.status) || !isOneOf(entity.status, VALID_STATUSES)) { + throw new InvalidLeagueMembershipSchemaError('Invalid status'); + } + if (!(entity.joinedAt instanceof Date) || Number.isNaN(entity.joinedAt.getTime())) { + throw new InvalidLeagueMembershipSchemaError('Invalid joinedAt'); + } + + return LeagueMembership.rehydrate({ + id: entity.id, + leagueId: entity.leagueId, + driverId: entity.driverId, + role: entity.role, + status: entity.status, + joinedAt: entity.joinedAt, + }); + } +} \ 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 index 4981aed3e..375d7bb5f 100644 --- a/adapters/racing/persistence/typeorm/mappers/LeagueOrmMapper.test.ts +++ b/adapters/racing/persistence/typeorm/mappers/LeagueOrmMapper.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it, vi } from 'vitest'; import { League } from '@core/racing/domain/entities/League'; import { LeagueOrmEntity } from '../entities/LeagueOrmEntity'; +import { TypeOrmPersistenceSchemaError } from '../errors/TypeOrmPersistenceSchemaError'; import { LeagueOrmMapper } from './LeagueOrmMapper'; describe('LeagueOrmMapper', () => { @@ -44,7 +45,7 @@ describe('LeagueOrmMapper', () => { expect(rehydrateSpy).toHaveBeenCalled(); }); - it('toDomain validates persisted settings schema and throws adapter-scoped error type', () => { + it('toDomain validates persisted settings schema and throws adapter-scoped base schema error type', () => { const mapper = new LeagueOrmMapper(); const entity = new LeagueOrmEntity(); @@ -63,7 +64,12 @@ describe('LeagueOrmMapper', () => { mapper.toDomain(entity); throw new Error('expected-to-throw'); } catch (error) { - expect(error).toMatchObject({ name: 'InvalidLeagueSettingsSchemaError' }); + expect(error).toBeInstanceOf(TypeOrmPersistenceSchemaError); + expect(error).toMatchObject({ + entityName: 'League', + fieldName: 'settings', + reason: 'not_object', + }); } }); }); \ No newline at end of file diff --git a/adapters/racing/persistence/typeorm/mappers/LeagueOrmMapper.ts b/adapters/racing/persistence/typeorm/mappers/LeagueOrmMapper.ts index 27aab3c9a..5cec46f30 100644 --- a/adapters/racing/persistence/typeorm/mappers/LeagueOrmMapper.ts +++ b/adapters/racing/persistence/typeorm/mappers/LeagueOrmMapper.ts @@ -1,12 +1,9 @@ import { League, type LeagueSettings } from '@core/racing/domain/entities/League'; import { LeagueOrmEntity } from '../entities/LeagueOrmEntity'; -import { InvalidLeagueSettingsSchemaError } from '../errors/InvalidLeagueSettingsSchemaError'; +import { TypeOrmPersistenceSchemaError } from '../errors/TypeOrmPersistenceSchemaError'; import type { SerializedLeagueSettings } from '../serialized/RacingTypeOrmSerialized'; - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} +import { assertEnumValue, assertNumber, assertRecord } from '../schema/TypeOrmSchemaGuards'; const VALID_POINTS_SYSTEMS = ['f1-2024', 'indycar', 'custom'] as const; @@ -21,93 +18,94 @@ const VALID_DECISION_MODES = [ '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)'); - } + const entityName = 'League'; - if (typeof value.pointsSystem !== 'string' || !isOneOf(value.pointsSystem, VALID_POINTS_SYSTEMS)) { - throw new InvalidLeagueSettingsSchemaError('Invalid settings.pointsSystem'); - } + assertRecord(entityName, 'settings', value); - if (value.sessionDuration !== undefined && typeof value.sessionDuration !== 'number') { - throw new InvalidLeagueSettingsSchemaError('Invalid settings.sessionDuration'); + const pointsSystem = value.pointsSystem; + assertEnumValue(entityName, 'settings.pointsSystem', pointsSystem, VALID_POINTS_SYSTEMS); + + if (value.sessionDuration !== undefined) { + assertNumber(entityName, 'settings.sessionDuration', value.sessionDuration); } if (value.qualifyingFormat !== undefined && typeof value.qualifyingFormat !== 'string') { - throw new InvalidLeagueSettingsSchemaError('Invalid settings.qualifyingFormat'); + throw new TypeOrmPersistenceSchemaError({ + entityName, + fieldName: 'settings.qualifyingFormat', + reason: 'not_string', + }); } - if (value.maxDrivers !== undefined && typeof value.maxDrivers !== 'number') { - throw new InvalidLeagueSettingsSchemaError('Invalid settings.maxDrivers'); + if (value.maxDrivers !== undefined) { + assertNumber(entityName, 'settings.maxDrivers', value.maxDrivers); } if (value.visibility !== undefined) { - if (typeof value.visibility !== 'string' || !isOneOf(value.visibility, VALID_VISIBILITY)) { - throw new InvalidLeagueSettingsSchemaError('Invalid settings.visibility'); - } + const visibility = value.visibility; + assertEnumValue(entityName, 'settings.visibility', visibility, VALID_VISIBILITY); } if (value.stewarding !== undefined) { - if (!isRecord(value.stewarding)) { - throw new InvalidLeagueSettingsSchemaError('Invalid settings.stewarding (expected object)'); - } + const stewarding = value.stewarding; + assertRecord(entityName, 'settings.stewarding', stewarding); - if ( - typeof value.stewarding.decisionMode !== 'string' || - !isOneOf(value.stewarding.decisionMode, VALID_DECISION_MODES) - ) { - throw new InvalidLeagueSettingsSchemaError('Invalid settings.stewarding.decisionMode'); - } + const decisionMode = stewarding.decisionMode; + assertEnumValue(entityName, 'settings.stewarding.decisionMode', decisionMode, VALID_DECISION_MODES); - if (value.stewarding.requiredVotes !== undefined && typeof value.stewarding.requiredVotes !== 'number') { - throw new InvalidLeagueSettingsSchemaError('Invalid settings.stewarding.requiredVotes'); + if (stewarding.requiredVotes !== undefined) { + assertNumber(entityName, 'settings.stewarding.requiredVotes', stewarding.requiredVotes); } - if (value.stewarding.requireDefense !== undefined && typeof value.stewarding.requireDefense !== 'boolean') { - throw new InvalidLeagueSettingsSchemaError('Invalid settings.stewarding.requireDefense'); + if (stewarding.requireDefense !== undefined && typeof stewarding.requireDefense !== 'boolean') { + throw new TypeOrmPersistenceSchemaError({ + entityName, + fieldName: 'settings.stewarding.requireDefense', + reason: 'not_boolean', + }); } - if (value.stewarding.defenseTimeLimit !== undefined && typeof value.stewarding.defenseTimeLimit !== 'number') { - throw new InvalidLeagueSettingsSchemaError('Invalid settings.stewarding.defenseTimeLimit'); + if (stewarding.defenseTimeLimit !== undefined) { + assertNumber(entityName, 'settings.stewarding.defenseTimeLimit', stewarding.defenseTimeLimit); } - if (value.stewarding.voteTimeLimit !== undefined && typeof value.stewarding.voteTimeLimit !== 'number') { - throw new InvalidLeagueSettingsSchemaError('Invalid settings.stewarding.voteTimeLimit'); + if (stewarding.voteTimeLimit !== undefined) { + assertNumber(entityName, 'settings.stewarding.voteTimeLimit', stewarding.voteTimeLimit); } - if (value.stewarding.protestDeadlineHours !== undefined && typeof value.stewarding.protestDeadlineHours !== 'number') { - throw new InvalidLeagueSettingsSchemaError('Invalid settings.stewarding.protestDeadlineHours'); + if (stewarding.protestDeadlineHours !== undefined) { + assertNumber(entityName, 'settings.stewarding.protestDeadlineHours', stewarding.protestDeadlineHours); } - if ( - value.stewarding.stewardingClosesHours !== undefined && - typeof value.stewarding.stewardingClosesHours !== 'number' - ) { - throw new InvalidLeagueSettingsSchemaError('Invalid settings.stewarding.stewardingClosesHours'); + if (stewarding.stewardingClosesHours !== undefined) { + assertNumber(entityName, 'settings.stewarding.stewardingClosesHours', stewarding.stewardingClosesHours); } - if ( - value.stewarding.notifyAccusedOnProtest !== undefined && - typeof value.stewarding.notifyAccusedOnProtest !== 'boolean' - ) { - throw new InvalidLeagueSettingsSchemaError('Invalid settings.stewarding.notifyAccusedOnProtest'); + if (stewarding.notifyAccusedOnProtest !== undefined && typeof stewarding.notifyAccusedOnProtest !== 'boolean') { + throw new TypeOrmPersistenceSchemaError({ + entityName, + fieldName: 'settings.stewarding.notifyAccusedOnProtest', + reason: 'not_boolean', + }); } - if (value.stewarding.notifyOnVoteRequired !== undefined && typeof value.stewarding.notifyOnVoteRequired !== 'boolean') { - throw new InvalidLeagueSettingsSchemaError('Invalid settings.stewarding.notifyOnVoteRequired'); + if (stewarding.notifyOnVoteRequired !== undefined && typeof stewarding.notifyOnVoteRequired !== 'boolean') { + throw new TypeOrmPersistenceSchemaError({ + entityName, + fieldName: 'settings.stewarding.notifyOnVoteRequired', + reason: 'not_boolean', + }); } } if (value.customPoints !== undefined) { - if (!isRecord(value.customPoints)) { - throw new InvalidLeagueSettingsSchemaError('Invalid settings.customPoints (expected object)'); - } + const customPoints = value.customPoints; + assertRecord(entityName, 'settings.customPoints', customPoints); - for (const [key, points] of Object.entries(value.customPoints)) { + for (const [key, points] of Object.entries(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)'); + throw new TypeOrmPersistenceSchemaError({ + entityName, + fieldName: 'settings.customPoints', + reason: 'invalid_shape', + message: 'Invalid settings.customPoints (expected numeric keys)', + }); } + assertNumber(entityName, `settings.customPoints.${key}`, points); } } } diff --git a/adapters/racing/persistence/typeorm/mappers/LeagueScoringConfigOrmMapper.test.ts b/adapters/racing/persistence/typeorm/mappers/LeagueScoringConfigOrmMapper.test.ts index 46a4eecc1..8c5ad4aca 100644 --- a/adapters/racing/persistence/typeorm/mappers/LeagueScoringConfigOrmMapper.test.ts +++ b/adapters/racing/persistence/typeorm/mappers/LeagueScoringConfigOrmMapper.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it, vi } from 'vitest'; import { LeagueScoringConfig } from '@core/racing/domain/entities/LeagueScoringConfig'; import { LeagueScoringConfigOrmEntity } from '../entities/LeagueScoringConfigOrmEntity'; +import { TypeOrmPersistenceSchemaError } from '../errors/TypeOrmPersistenceSchemaError'; import { LeagueScoringConfigOrmMapper } from './LeagueScoringConfigOrmMapper'; import { ChampionshipConfigJsonMapper, type SerializedChampionshipConfig } from './ChampionshipConfigJsonMapper'; import { PointsTableJsonMapper } from './PointsTableJsonMapper'; @@ -38,7 +39,7 @@ describe('LeagueScoringConfigOrmMapper', () => { expect(createSpy).not.toHaveBeenCalled(); }); - it('toDomain validates schema: non-array championships yields adapter-scoped error type', () => { + it('toDomain validates schema: non-array championships yields adapter-scoped base schema error type', () => { const pointsTableMapper = new PointsTableJsonMapper(); const championshipMapper = new ChampionshipConfigJsonMapper(pointsTableMapper); const mapper = new LeagueScoringConfigOrmMapper(championshipMapper); @@ -53,7 +54,12 @@ describe('LeagueScoringConfigOrmMapper', () => { mapper.toDomain(entity); throw new Error('expected-to-throw'); } catch (error) { - expect(error).toMatchObject({ name: 'InvalidLeagueScoringConfigChampionshipsSchemaError' }); + expect(error).toBeInstanceOf(TypeOrmPersistenceSchemaError); + expect(error).toMatchObject({ + entityName: 'LeagueScoringConfig', + fieldName: 'championships', + reason: 'not_array', + }); } }); }); \ No newline at end of file diff --git a/adapters/racing/persistence/typeorm/mappers/LeagueScoringConfigOrmMapper.ts b/adapters/racing/persistence/typeorm/mappers/LeagueScoringConfigOrmMapper.ts index 96cefb47d..ca8b83764 100644 --- a/adapters/racing/persistence/typeorm/mappers/LeagueScoringConfigOrmMapper.ts +++ b/adapters/racing/persistence/typeorm/mappers/LeagueScoringConfigOrmMapper.ts @@ -1,42 +1,24 @@ 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); -} +import { assertArray, assertNonEmptyString, assertRecord } from '../schema/TypeOrmSchemaGuards'; function assertSerializedChampionshipConfig(value: unknown, index: number): asserts value is SerializedChampionshipConfig { - if (!isRecord(value)) { - throw new InvalidLeagueScoringConfigChampionshipsSchemaError(`Invalid championships[${index}] (expected object)`); - } + const entityName = 'LeagueScoringConfig'; + const fieldBase = `championships[${index}]`; - if (typeof value.id !== 'string' || value.id.trim().length === 0) { - throw new InvalidLeagueScoringConfigChampionshipsSchemaError(`Invalid championships[${index}].id`); - } + assertRecord(entityName, fieldBase, value); - if (typeof value.name !== 'string' || value.name.trim().length === 0) { - throw new InvalidLeagueScoringConfigChampionshipsSchemaError(`Invalid championships[${index}].name`); - } + assertNonEmptyString(entityName, `${fieldBase}.id`, value.id); + assertNonEmptyString(entityName, `${fieldBase}.name`, value.name); + assertNonEmptyString(entityName, `${fieldBase}.type`, value.type); - if (typeof value.type !== 'string' || value.type.trim().length === 0) { - throw new InvalidLeagueScoringConfigChampionshipsSchemaError(`Invalid championships[${index}].type`); - } + assertArray(entityName, `${fieldBase}.sessionTypes`, value.sessionTypes); + assertRecord(entityName, `${fieldBase}.pointsTableBySessionType`, value.pointsTableBySessionType); - 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`); - } + assertRecord(entityName, `${fieldBase}.dropScorePolicy`, value.dropScorePolicy); + assertNonEmptyString(entityName, `${fieldBase}.dropScorePolicy.strategy`, value.dropScorePolicy.strategy); } export class LeagueScoringConfigOrmMapper { @@ -52,9 +34,9 @@ export class LeagueScoringConfigOrmMapper { } toDomain(entity: LeagueScoringConfigOrmEntity): LeagueScoringConfig { - if (!Array.isArray(entity.championships)) { - throw new InvalidLeagueScoringConfigChampionshipsSchemaError('Invalid championships (expected array)'); - } + const entityName = 'LeagueScoringConfig'; + + assertArray(entityName, 'championships', entity.championships); const championships = entity.championships.map((candidate, index) => { assertSerializedChampionshipConfig(candidate, index); diff --git a/adapters/racing/persistence/typeorm/mappers/MoneyOrmMapper.test.ts b/adapters/racing/persistence/typeorm/mappers/MoneyOrmMapper.test.ts new file mode 100644 index 000000000..22c26a709 --- /dev/null +++ b/adapters/racing/persistence/typeorm/mappers/MoneyOrmMapper.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; + +import { Money } from '@core/racing/domain/value-objects/Money'; + +import { TypeOrmPersistenceSchemaError } from '../errors/TypeOrmPersistenceSchemaError'; +import { MoneyOrmMapper } from './MoneyOrmMapper'; + +describe('MoneyOrmMapper', () => { + it('toOrm maps Money to plain object', () => { + const mapper = new MoneyOrmMapper(); + const money = Money.create(12.5, 'EUR'); + + expect(mapper.toOrm(money)).toEqual({ amount: 12.5, currency: 'EUR' }); + }); + + it('toDomain validates schema and throws adapter-scoped base schema error type', () => { + const mapper = new MoneyOrmMapper(); + + try { + mapper.toDomain('LeagueWallet', 'balance', { amount: 10, currency: 'JPY' }); + throw new Error('expected-to-throw'); + } catch (error) { + expect(error).toBeInstanceOf(TypeOrmPersistenceSchemaError); + expect(error).toMatchObject({ + entityName: 'LeagueWallet', + fieldName: 'balance.currency', + reason: 'invalid_enum_value', + }); + } + }); + + it('toDomain wraps domain validation failures into adapter-scoped schema error type', () => { + const mapper = new MoneyOrmMapper(); + + try { + mapper.toDomain('LeagueWallet', 'balance', { amount: -1, currency: 'USD' }); + throw new Error('expected-to-throw'); + } catch (error) { + expect(error).toBeInstanceOf(TypeOrmPersistenceSchemaError); + expect(error).toMatchObject({ + entityName: 'LeagueWallet', + fieldName: 'balance', + reason: 'invalid_shape', + }); + } + }); +}); \ No newline at end of file diff --git a/adapters/racing/persistence/typeorm/mappers/MoneyOrmMapper.ts b/adapters/racing/persistence/typeorm/mappers/MoneyOrmMapper.ts new file mode 100644 index 000000000..6ef4f6c0a --- /dev/null +++ b/adapters/racing/persistence/typeorm/mappers/MoneyOrmMapper.ts @@ -0,0 +1,46 @@ +import { Money, type Currency, isCurrency } from '@core/racing/domain/value-objects/Money'; + +import { TypeOrmPersistenceSchemaError } from '../errors/TypeOrmPersistenceSchemaError'; +import { assertNumber, assertRecord } from '../schema/TypeOrmSchemaGuards'; + +export type OrmMoney = { amount: number; currency: Currency }; + +export class MoneyOrmMapper { + toOrm(money: Money): OrmMoney { + return { + amount: money.amount, + currency: money.currency, + }; + } + + toDomain(entityName: string, fieldName: string, value: unknown): Money { + assertRecord(entityName, fieldName, value); + + const maybeAmount = (value as Record).amount; + const maybeCurrency = (value as Record).currency; + + assertNumber(entityName, `${fieldName}.amount`, maybeAmount); + + if (typeof maybeCurrency !== 'string') { + throw new TypeOrmPersistenceSchemaError({ + entityName, + fieldName: `${fieldName}.currency`, + reason: 'not_string', + }); + } + + if (!isCurrency(maybeCurrency)) { + throw new TypeOrmPersistenceSchemaError({ + entityName, + fieldName: `${fieldName}.currency`, + reason: 'invalid_enum_value', + }); + } + + try { + return Money.create(maybeAmount, maybeCurrency as Currency); + } catch { + throw new TypeOrmPersistenceSchemaError({ entityName, fieldName, reason: 'invalid_shape' }); + } + } +} \ 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 index 642157e26..b6d4fff30 100644 --- a/adapters/racing/persistence/typeorm/mappers/RaceOrmMapper.test.ts +++ b/adapters/racing/persistence/typeorm/mappers/RaceOrmMapper.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it, vi } from 'vitest'; import { Race } from '@core/racing/domain/entities/Race'; import { RaceOrmEntity } from '../entities/RaceOrmEntity'; +import { TypeOrmPersistenceSchemaError } from '../errors/TypeOrmPersistenceSchemaError'; import { RaceOrmMapper } from './RaceOrmMapper'; describe('RaceOrmMapper', () => { @@ -39,7 +40,7 @@ describe('RaceOrmMapper', () => { expect(rehydrateSpy).toHaveBeenCalled(); }); - it('toDomain validates persisted sessionType/status and throws adapter-scoped error type', () => { + it('toDomain validates persisted sessionType/status and throws adapter-scoped base schema error type', () => { const mapper = new RaceOrmMapper(); const entity = new RaceOrmEntity(); @@ -60,7 +61,12 @@ describe('RaceOrmMapper', () => { mapper.toDomain(entity); throw new Error('expected-to-throw'); } catch (error) { - expect(error).toMatchObject({ name: 'InvalidRaceSessionTypeSchemaError' }); + expect(error).toBeInstanceOf(TypeOrmPersistenceSchemaError); + expect(error).toMatchObject({ + entityName: 'Race', + fieldName: 'sessionType', + reason: 'not_string', + }); } }); }); \ No newline at end of file diff --git a/adapters/racing/persistence/typeorm/mappers/RaceOrmMapper.ts b/adapters/racing/persistence/typeorm/mappers/RaceOrmMapper.ts index 5e86f2d9d..36f932e37 100644 --- a/adapters/racing/persistence/typeorm/mappers/RaceOrmMapper.ts +++ b/adapters/racing/persistence/typeorm/mappers/RaceOrmMapper.ts @@ -3,10 +3,9 @@ import { RaceStatus, type RaceStatusValue } from '@core/racing/domain/value-obje 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'; +import { assertEnumValue } from '../schema/TypeOrmSchemaGuards'; -const VALID_SESSION_TYPES: SessionTypeValue[] = [ +const VALID_SESSION_TYPES = [ 'practice', 'qualifying', 'q1', @@ -15,26 +14,14 @@ const VALID_SESSION_TYPES: SessionTypeValue[] = [ 'sprint', 'main', 'timeTrial', -]; +] as const satisfies readonly SessionTypeValue[]; -const VALID_RACE_STATUSES: RaceStatusValue[] = [ +const VALID_RACE_STATUSES = [ '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'); - } -} +] as const satisfies readonly RaceStatusValue[]; export class RaceOrmMapper { toOrmEntity(domain: Race): RaceOrmEntity { @@ -55,8 +42,10 @@ export class RaceOrmMapper { } toDomain(entity: RaceOrmEntity): Race { - assertSessionTypeValue(entity.sessionType); - assertRaceStatusValue(entity.status); + const entityName = 'Race'; + + assertEnumValue(entityName, 'sessionType', entity.sessionType, VALID_SESSION_TYPES); + assertEnumValue(entityName, 'status', entity.status, VALID_RACE_STATUSES); const sessionType = new SessionType(entity.sessionType); const status = RaceStatus.create(entity.status); diff --git a/adapters/racing/persistence/typeorm/mappers/RaceRegistrationOrmMapper.test.ts b/adapters/racing/persistence/typeorm/mappers/RaceRegistrationOrmMapper.test.ts new file mode 100644 index 000000000..47f1fdd56 --- /dev/null +++ b/adapters/racing/persistence/typeorm/mappers/RaceRegistrationOrmMapper.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { RaceRegistration } from '@core/racing/domain/entities/RaceRegistration'; + +import { RaceRegistrationOrmEntity } from '../entities/RaceRegistrationOrmEntity'; +import { RaceRegistrationOrmMapper } from './RaceRegistrationOrmMapper'; + +describe('RaceRegistrationOrmMapper', () => { + it('toDomain preserves persisted identity and uses rehydrate semantics (does not call create)', () => { + const mapper = new RaceRegistrationOrmMapper(); + + const entity = new RaceRegistrationOrmEntity(); + entity.id = 'race-1:driver-1'; + entity.raceId = 'race-1'; + entity.driverId = 'driver-1'; + entity.registeredAt = new Date('2025-01-01T00:00:00.000Z'); + + if (typeof (RaceRegistration as unknown as { rehydrate?: unknown }).rehydrate !== 'function') { + throw new Error('rehydrate-missing'); + } + + const rehydrateSpy = vi.spyOn( + RaceRegistration as unknown as { rehydrate: (...args: unknown[]) => unknown }, + 'rehydrate', + ); + const createSpy = vi.spyOn(RaceRegistration, 'create').mockImplementation(() => { + throw new Error('create-called'); + }); + + const domain = mapper.toDomain(entity); + + expect(domain.id).toBe(entity.id); + expect(domain.raceId.toString()).toBe(entity.raceId); + expect(domain.driverId.toString()).toBe(entity.driverId); + expect(createSpy).not.toHaveBeenCalled(); + expect(rehydrateSpy).toHaveBeenCalled(); + }); + + it('toDomain validates persisted shape and throws adapter-scoped error type', () => { + const mapper = new RaceRegistrationOrmMapper(); + + const entity = new RaceRegistrationOrmEntity(); + entity.id = 'race-1:driver-1'; + entity.raceId = 123 as unknown as string; + entity.driverId = 'driver-1'; + entity.registeredAt = new Date('2025-01-01T00:00:00.000Z'); + + try { + mapper.toDomain(entity); + throw new Error('expected-to-throw'); + } catch (error) { + expect(error).toMatchObject({ name: 'InvalidRaceRegistrationSchemaError' }); + } + }); +}); \ No newline at end of file diff --git a/adapters/racing/persistence/typeorm/mappers/RaceRegistrationOrmMapper.ts b/adapters/racing/persistence/typeorm/mappers/RaceRegistrationOrmMapper.ts new file mode 100644 index 000000000..297041409 --- /dev/null +++ b/adapters/racing/persistence/typeorm/mappers/RaceRegistrationOrmMapper.ts @@ -0,0 +1,41 @@ +import { RaceRegistration } from '@core/racing/domain/entities/RaceRegistration'; + +import { RaceRegistrationOrmEntity } from '../entities/RaceRegistrationOrmEntity'; +import { InvalidRaceRegistrationSchemaError } from '../errors/InvalidRaceRegistrationSchemaError'; + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0; +} + +export class RaceRegistrationOrmMapper { + toOrmEntity(domain: RaceRegistration): RaceRegistrationOrmEntity { + const entity = new RaceRegistrationOrmEntity(); + entity.id = domain.id; + entity.raceId = domain.raceId.toString(); + entity.driverId = domain.driverId.toString(); + entity.registeredAt = domain.registeredAt.toDate(); + return entity; + } + + toDomain(entity: RaceRegistrationOrmEntity): RaceRegistration { + if (!isNonEmptyString(entity.id)) { + throw new InvalidRaceRegistrationSchemaError('Invalid id'); + } + if (!isNonEmptyString(entity.raceId)) { + throw new InvalidRaceRegistrationSchemaError('Invalid raceId'); + } + if (!isNonEmptyString(entity.driverId)) { + throw new InvalidRaceRegistrationSchemaError('Invalid driverId'); + } + if (!(entity.registeredAt instanceof Date) || Number.isNaN(entity.registeredAt.getTime())) { + throw new InvalidRaceRegistrationSchemaError('Invalid registeredAt'); + } + + return RaceRegistration.rehydrate({ + id: entity.id, + raceId: entity.raceId, + driverId: entity.driverId, + registeredAt: entity.registeredAt, + }); + } +} \ No newline at end of file diff --git a/adapters/racing/persistence/typeorm/mappers/ResultOrmMapper.test.ts b/adapters/racing/persistence/typeorm/mappers/ResultOrmMapper.test.ts new file mode 100644 index 000000000..e6b70e463 --- /dev/null +++ b/adapters/racing/persistence/typeorm/mappers/ResultOrmMapper.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; + +import { ResultOrmMapper } from './ResultOrmMapper'; +import { ResultOrmEntity } from '../entities/ResultOrmEntity'; +import { InvalidResultSchemaError } from '../errors/InvalidResultSchemaError'; + +describe('ResultOrmMapper', () => { + it('maps persisted schema guard failures into InvalidResultSchemaError', () => { + const mapper = new ResultOrmMapper(); + + const entity = new ResultOrmEntity(); + entity.id = ''; + entity.raceId = 'race-1'; + entity.driverId = 'driver-1'; + entity.position = 1; + entity.fastestLap = 0; + entity.incidents = 0; + entity.startPosition = 1; + + expect(() => mapper.toDomain(entity)).toThrow(InvalidResultSchemaError); + + try { + mapper.toDomain(entity); + } catch (error) { + expect(error).toBeInstanceOf(InvalidResultSchemaError); + const schemaError = error as InvalidResultSchemaError; + expect(schemaError.entityName).toBe('Result'); + expect(schemaError.fieldName).toBe('id'); + expect(schemaError.reason).toBe('empty_string'); + } + }); + + it('wraps domain rehydrate failures into InvalidResultSchemaError (no mapper-level business validation)', () => { + const mapper = new ResultOrmMapper(); + + const entity = new ResultOrmEntity(); + entity.id = 'result-1'; + entity.raceId = 'race-1'; + entity.driverId = 'driver-1'; + entity.position = 0; // schema ok (integer), domain invalid (Position) + entity.fastestLap = 0; + entity.incidents = 0; + entity.startPosition = 1; + + try { + mapper.toDomain(entity); + throw new Error('Expected mapper.toDomain to throw'); + } catch (error) { + expect(error).toBeInstanceOf(InvalidResultSchemaError); + const schemaError = error as InvalidResultSchemaError; + expect(schemaError.entityName).toBe('Result'); + expect(schemaError.reason).toBe('invalid_shape'); + } + }); +}); \ No newline at end of file diff --git a/adapters/racing/persistence/typeorm/mappers/ResultOrmMapper.ts b/adapters/racing/persistence/typeorm/mappers/ResultOrmMapper.ts new file mode 100644 index 000000000..9f1847585 --- /dev/null +++ b/adapters/racing/persistence/typeorm/mappers/ResultOrmMapper.ts @@ -0,0 +1,62 @@ +import { Result } from '@core/racing/domain/entities/result/Result'; + +import { ResultOrmEntity } from '../entities/ResultOrmEntity'; +import { InvalidResultSchemaError } from '../errors/InvalidResultSchemaError'; +import { TypeOrmPersistenceSchemaError } from '../errors/TypeOrmPersistenceSchemaError'; +import { assertInteger, assertNonEmptyString } from '../schema/TypeOrmSchemaGuards'; + +export class ResultOrmMapper { + toOrmEntity(domain: Result): ResultOrmEntity { + const entity = new ResultOrmEntity(); + entity.id = domain.id; + entity.raceId = domain.raceId.toString(); + entity.driverId = domain.driverId.toString(); + entity.position = domain.position.toNumber(); + entity.fastestLap = domain.fastestLap.toNumber(); + entity.incidents = domain.incidents.toNumber(); + entity.startPosition = domain.startPosition.toNumber(); + return entity; + } + + toDomain(entity: ResultOrmEntity): Result { + const entityName = 'Result'; + + try { + assertNonEmptyString(entityName, 'id', entity.id); + assertNonEmptyString(entityName, 'raceId', entity.raceId); + assertNonEmptyString(entityName, 'driverId', entity.driverId); + assertInteger(entityName, 'position', entity.position); + assertInteger(entityName, 'fastestLap', entity.fastestLap); + assertInteger(entityName, 'incidents', entity.incidents); + assertInteger(entityName, 'startPosition', entity.startPosition); + } catch (error) { + if (error instanceof TypeOrmPersistenceSchemaError) { + throw new InvalidResultSchemaError({ + fieldName: error.fieldName, + reason: error.reason, + message: error.message, + }); + } + throw error; + } + + try { + return Result.rehydrate({ + id: entity.id, + raceId: entity.raceId, + driverId: entity.driverId, + position: entity.position, + fastestLap: entity.fastestLap, + incidents: entity.incidents, + startPosition: entity.startPosition, + }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Invalid persisted Result'; + throw new InvalidResultSchemaError({ + fieldName: 'unknown', + reason: 'invalid_shape', + message, + }); + } + } +} \ 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 index 1922bb6e5..c9f58a8a1 100644 --- a/adapters/racing/persistence/typeorm/mappers/SeasonOrmMapper.test.ts +++ b/adapters/racing/persistence/typeorm/mappers/SeasonOrmMapper.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it, vi } from 'vitest'; import { Season } from '@core/racing/domain/entities/season/Season'; import { SeasonOrmEntity } from '../entities/SeasonOrmEntity'; +import { TypeOrmPersistenceSchemaError } from '../errors/TypeOrmPersistenceSchemaError'; import { SeasonOrmMapper } from './SeasonOrmMapper'; describe('SeasonOrmMapper', () => { @@ -43,7 +44,7 @@ describe('SeasonOrmMapper', () => { expect(rehydrateSpy).toHaveBeenCalled(); }); - it('toDomain validates persisted schedule schema and throws adapter-scoped error type', () => { + it('toDomain validates persisted schedule schema and throws adapter-scoped base schema error type', () => { const mapper = new SeasonOrmMapper(); const entity = new SeasonOrmEntity(); @@ -68,7 +69,12 @@ describe('SeasonOrmMapper', () => { mapper.toDomain(entity); throw new Error('expected-to-throw'); } catch (error) { - expect(error).toMatchObject({ name: 'InvalidSeasonScheduleSchemaError' }); + expect(error).toBeInstanceOf(TypeOrmPersistenceSchemaError); + expect(error).toMatchObject({ + entityName: 'Season', + fieldName: 'schedule', + reason: 'not_object', + }); } }); }); \ No newline at end of file diff --git a/adapters/racing/persistence/typeorm/mappers/SeasonOrmMapper.ts b/adapters/racing/persistence/typeorm/mappers/SeasonOrmMapper.ts index 31f153180..fbc0c0d80 100644 --- a/adapters/racing/persistence/typeorm/mappers/SeasonOrmMapper.ts +++ b/adapters/racing/persistence/typeorm/mappers/SeasonOrmMapper.ts @@ -3,7 +3,7 @@ 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 type { 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'; @@ -13,157 +13,181 @@ import { SeasonStewardingConfig } from '@core/racing/domain/value-objects/Season 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 { TypeOrmPersistenceSchemaError } from '../errors/TypeOrmPersistenceSchemaError'; import type { SerializedSeasonDropPolicy, SerializedSeasonEveryNWeeksRecurrence, SerializedSeasonMonthlyNthWeekdayRecurrence, - SerializedSeasonRecurrence, SerializedSeasonSchedule, SerializedSeasonScoringConfig, SerializedSeasonStewardingConfig, SerializedSeasonWeeklyRecurrence, } from '../serialized/RacingTypeOrmSerialized'; +import { + assertArray, + assertEnumValue, + assertInteger, + assertIsoDate, + assertNonEmptyString, + assertRecord, +} from '../schema/TypeOrmSchemaGuards'; -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -const VALID_SEASON_STATUSES: SeasonStatusValue[] = [ +const VALID_SEASON_STATUSES = [ 'planned', 'active', 'completed', 'archived', 'cancelled', -]; +] as const satisfies readonly SeasonStatusValue[]; -function assertSeasonStatusValue(value: unknown): asserts value is SeasonStatusValue { - if (typeof value !== 'string' || !VALID_SEASON_STATUSES.includes(value as SeasonStatusValue)) { - throw new InvalidSeasonStatusSchemaError('Invalid status'); - } -} +const VALID_RECURRENCE_KINDS = ['weekly', 'everyNWeeks', 'monthlyNthWeekday'] as const; -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 isMonthlyOrdinal(value: number): value is 1 | 2 | 3 | 4 { + return value === 1 || value === 2 || value === 3 || value === 4; } function parseSeasonSchedule(value: unknown): SeasonSchedule { - if (!isRecord(value)) { - throw new InvalidSeasonScheduleSchemaError('Invalid schedule (expected object)'); - } + const entityName = 'Season'; - 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)'); - } + assertRecord(entityName, 'schedule', value); - if (typeof value.timeOfDay !== 'string') { - throw new InvalidSeasonScheduleSchemaError('Invalid schedule.timeOfDay'); - } + const startDateCandidate = value.startDate; + assertIsoDate(entityName, 'schedule.startDate', startDateCandidate); + const startDate = new Date(startDateCandidate); - if (typeof value.timezoneId !== 'string') { - throw new InvalidSeasonScheduleSchemaError('Invalid schedule.timezoneId'); - } + const timeOfDayCandidate = value.timeOfDay; + assertNonEmptyString(entityName, 'schedule.timeOfDay', timeOfDayCandidate); + + const timezoneIdCandidate = value.timezoneId; + assertNonEmptyString(entityName, 'schedule.timezoneId', timezoneIdCandidate); const plannedRoundsCandidate = value.plannedRounds; - if (typeof plannedRoundsCandidate !== 'number' || !Number.isInteger(plannedRoundsCandidate) || plannedRoundsCandidate <= 0) { - throw new InvalidSeasonScheduleSchemaError('Invalid schedule.plannedRounds'); + assertInteger(entityName, 'schedule.plannedRounds', plannedRoundsCandidate); + if (plannedRoundsCandidate <= 0) { + throw new TypeOrmPersistenceSchemaError({ + entityName, + fieldName: 'schedule.plannedRounds', + reason: 'invalid_shape', + message: 'Invalid schedule.plannedRounds (expected positive integer)', + }); } - const plannedRounds = plannedRoundsCandidate; - assertSerializedSeasonRecurrence(value.recurrence); + const recurrenceCandidate = value.recurrence; + assertRecord(entityName, 'schedule.recurrence', recurrenceCandidate); + + const recurrenceKindCandidate = recurrenceCandidate.kind; + assertEnumValue(entityName, 'schedule.recurrence.kind', recurrenceKindCandidate, VALID_RECURRENCE_KINDS); + const recurrenceKind = recurrenceKindCandidate; let timeOfDay: RaceTimeOfDay; try { - timeOfDay = RaceTimeOfDay.fromString(value.timeOfDay); + timeOfDay = RaceTimeOfDay.fromString(timeOfDayCandidate); } catch { - throw new InvalidSeasonScheduleSchemaError('Invalid schedule.timeOfDay (expected HH:MM)'); + throw new TypeOrmPersistenceSchemaError({ + entityName, + fieldName: 'schedule.timeOfDay', + reason: 'invalid_shape', + }); } let timezone: LeagueTimezone; try { - timezone = LeagueTimezone.create(value.timezoneId); + timezone = LeagueTimezone.create(timezoneIdCandidate); } catch { - throw new InvalidSeasonScheduleSchemaError('Invalid schedule.timezoneId'); + throw new TypeOrmPersistenceSchemaError({ + entityName, + fieldName: 'schedule.timezoneId', + reason: 'invalid_shape', + }); } - let recurrence: RecurrenceStrategy; - try { - switch (value.recurrence.kind) { + const recurrenceRecord = recurrenceCandidate; + + const recurrence: RecurrenceStrategy = (() => { + switch (recurrenceKind) { case 'weekly': { - const weekdays = WeekdaySet.fromArray(value.recurrence.weekdays); - recurrence = RecurrenceStrategyFactory.weekly(weekdays); - break; + const weekdaysCandidate = recurrenceRecord.weekdays; + assertArray(entityName, 'schedule.recurrence.weekdays', weekdaysCandidate); + + const typedWeekdays: Weekday[] = []; + for (let index = 0; index < weekdaysCandidate.length; index += 1) { + const dayCandidate: unknown = weekdaysCandidate[index]; + assertEnumValue(entityName, `schedule.recurrence.weekdays[${index}]`, dayCandidate, ALL_WEEKDAYS); + typedWeekdays.push(dayCandidate); + } + + try { + return RecurrenceStrategyFactory.weekly(WeekdaySet.fromArray(typedWeekdays)); + } catch { + throw new TypeOrmPersistenceSchemaError({ + entityName, + fieldName: 'schedule.recurrence', + reason: 'invalid_shape', + }); + } } + case 'everyNWeeks': { - const weekdays = WeekdaySet.fromArray(value.recurrence.weekdays); - recurrence = RecurrenceStrategyFactory.everyNWeeks(value.recurrence.intervalWeeks, weekdays); - break; + const intervalWeeksCandidate = recurrenceRecord.intervalWeeks; + assertInteger(entityName, 'schedule.recurrence.intervalWeeks', intervalWeeksCandidate); + if (intervalWeeksCandidate <= 0) { + throw new TypeOrmPersistenceSchemaError({ + entityName, + fieldName: 'schedule.recurrence.intervalWeeks', + reason: 'invalid_shape', + message: 'Invalid schedule.recurrence.intervalWeeks (expected positive integer)', + }); + } + + const weekdaysCandidate = recurrenceRecord.weekdays; + assertArray(entityName, 'schedule.recurrence.weekdays', weekdaysCandidate); + + const typedWeekdays: Weekday[] = []; + for (let index = 0; index < weekdaysCandidate.length; index += 1) { + const dayCandidate: unknown = weekdaysCandidate[index]; + assertEnumValue(entityName, `schedule.recurrence.weekdays[${index}]`, dayCandidate, ALL_WEEKDAYS); + typedWeekdays.push(dayCandidate); + } + + try { + return RecurrenceStrategyFactory.everyNWeeks(intervalWeeksCandidate, WeekdaySet.fromArray(typedWeekdays)); + } catch { + throw new TypeOrmPersistenceSchemaError({ + entityName, + fieldName: 'schedule.recurrence', + reason: 'invalid_shape', + }); + } } + case 'monthlyNthWeekday': { - const pattern = MonthlyRecurrencePattern.create(value.recurrence.ordinal, value.recurrence.weekday); - recurrence = RecurrenceStrategyFactory.monthlyNthWeekday(pattern); - break; + const ordinalCandidate = recurrenceRecord.ordinal; + assertInteger(entityName, 'schedule.recurrence.ordinal', ordinalCandidate); + if (!isMonthlyOrdinal(ordinalCandidate)) { + throw new TypeOrmPersistenceSchemaError({ + entityName, + fieldName: 'schedule.recurrence.ordinal', + reason: 'invalid_enum_value', + }); + } + + const weekdayCandidate = recurrenceRecord.weekday; + assertEnumValue(entityName, 'schedule.recurrence.weekday', weekdayCandidate, ALL_WEEKDAYS); + + try { + const pattern = MonthlyRecurrencePattern.create(ordinalCandidate, weekdayCandidate); + return RecurrenceStrategyFactory.monthlyNthWeekday(pattern); + } catch { + throw new TypeOrmPersistenceSchemaError({ + entityName, + fieldName: 'schedule.recurrence', + reason: 'invalid_shape', + }); + } } - 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, @@ -261,36 +285,63 @@ export class SeasonOrmMapper { } toDomain(entity: SeasonOrmEntity): Season { - assertSeasonStatusValue(entity.status); + const entityName = 'Season'; - const status = SeasonStatus.create(entity.status); + const statusValue = entity.status; + assertEnumValue(entityName, 'status', statusValue, VALID_SEASON_STATUSES); + + let status: SeasonStatus; + try { + status = SeasonStatus.create(statusValue); + } catch { + throw new TypeOrmPersistenceSchemaError({ + entityName, + fieldName: 'status', + reason: 'invalid_enum_value', + }); + } const schedule = entity.schedule !== null ? parseSeasonSchedule(entity.schedule) : undefined; let scoringConfig: SeasonScoringConfig | undefined; if (entity.scoringConfig !== null) { + assertRecord(entityName, 'scoringConfig', entity.scoringConfig); try { scoringConfig = new SeasonScoringConfig(entity.scoringConfig); } catch { - throw new InvalidSeasonScheduleSchemaError('Invalid scoringConfig'); + throw new TypeOrmPersistenceSchemaError({ + entityName, + fieldName: 'scoringConfig', + reason: 'invalid_shape', + }); } } let dropPolicy: SeasonDropPolicy | undefined; if (entity.dropPolicy !== null) { + assertRecord(entityName, 'dropPolicy', entity.dropPolicy); try { dropPolicy = new SeasonDropPolicy(entity.dropPolicy); } catch { - throw new InvalidSeasonScheduleSchemaError('Invalid dropPolicy'); + throw new TypeOrmPersistenceSchemaError({ + entityName, + fieldName: 'dropPolicy', + reason: 'invalid_shape', + }); } } let stewardingConfig: SeasonStewardingConfig | undefined; if (entity.stewardingConfig !== null) { + assertRecord(entityName, 'stewardingConfig', entity.stewardingConfig); try { stewardingConfig = new SeasonStewardingConfig(entity.stewardingConfig); } catch { - throw new InvalidSeasonScheduleSchemaError('Invalid stewardingConfig'); + throw new TypeOrmPersistenceSchemaError({ + entityName, + fieldName: 'stewardingConfig', + reason: 'invalid_shape', + }); } } diff --git a/adapters/racing/persistence/typeorm/mappers/StandingOrmMapper.test.ts b/adapters/racing/persistence/typeorm/mappers/StandingOrmMapper.test.ts new file mode 100644 index 000000000..befe3fe8c --- /dev/null +++ b/adapters/racing/persistence/typeorm/mappers/StandingOrmMapper.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; + +import { StandingOrmMapper } from './StandingOrmMapper'; +import { StandingOrmEntity } from '../entities/StandingOrmEntity'; +import { InvalidStandingSchemaError } from '../errors/InvalidStandingSchemaError'; + +describe('StandingOrmMapper', () => { + it('maps persisted schema guard failures into InvalidStandingSchemaError', () => { + const mapper = new StandingOrmMapper(); + + const entity = new StandingOrmEntity(); + entity.id = 'league-1:driver-1'; + entity.leagueId = ''; + entity.driverId = 'driver-1'; + entity.points = 0; + entity.wins = 0; + entity.position = 1; + entity.racesCompleted = 0; + + expect(() => mapper.toDomain(entity)).toThrow(InvalidStandingSchemaError); + + try { + mapper.toDomain(entity); + } catch (error) { + expect(error).toBeInstanceOf(InvalidStandingSchemaError); + const schemaError = error as InvalidStandingSchemaError; + expect(schemaError.entityName).toBe('Standing'); + expect(schemaError.fieldName).toBe('leagueId'); + expect(schemaError.reason).toBe('empty_string'); + } + }); + + it('wraps domain rehydrate failures into InvalidStandingSchemaError (no mapper-level business validation)', () => { + const mapper = new StandingOrmMapper(); + + const entity = new StandingOrmEntity(); + entity.id = 'league-1:driver-1'; + entity.leagueId = 'league-1'; + entity.driverId = 'driver-1'; + entity.points = 0; + entity.wins = 0; + entity.position = 0; // schema ok (integer), domain invalid (Position) + entity.racesCompleted = 0; + + try { + mapper.toDomain(entity); + throw new Error('Expected mapper.toDomain to throw'); + } catch (error) { + expect(error).toBeInstanceOf(InvalidStandingSchemaError); + const schemaError = error as InvalidStandingSchemaError; + expect(schemaError.entityName).toBe('Standing'); + expect(schemaError.reason).toBe('invalid_shape'); + } + }); +}); \ No newline at end of file diff --git a/adapters/racing/persistence/typeorm/mappers/StandingOrmMapper.ts b/adapters/racing/persistence/typeorm/mappers/StandingOrmMapper.ts new file mode 100644 index 000000000..2ca0ed7ab --- /dev/null +++ b/adapters/racing/persistence/typeorm/mappers/StandingOrmMapper.ts @@ -0,0 +1,62 @@ +import { Standing } from '@core/racing/domain/entities/Standing'; + +import { StandingOrmEntity } from '../entities/StandingOrmEntity'; +import { InvalidStandingSchemaError } from '../errors/InvalidStandingSchemaError'; +import { TypeOrmPersistenceSchemaError } from '../errors/TypeOrmPersistenceSchemaError'; +import { assertInteger, assertNonEmptyString } from '../schema/TypeOrmSchemaGuards'; + +export class StandingOrmMapper { + toOrmEntity(domain: Standing): StandingOrmEntity { + const entity = new StandingOrmEntity(); + entity.id = domain.id; + entity.leagueId = domain.leagueId.toString(); + entity.driverId = domain.driverId.toString(); + entity.points = domain.points.toNumber(); + entity.wins = domain.wins; + entity.position = domain.position.toNumber(); + entity.racesCompleted = domain.racesCompleted; + return entity; + } + + toDomain(entity: StandingOrmEntity): Standing { + const entityName = 'Standing'; + + try { + assertNonEmptyString(entityName, 'id', entity.id); + assertNonEmptyString(entityName, 'leagueId', entity.leagueId); + assertNonEmptyString(entityName, 'driverId', entity.driverId); + assertInteger(entityName, 'points', entity.points); + assertInteger(entityName, 'wins', entity.wins); + assertInteger(entityName, 'position', entity.position); + assertInteger(entityName, 'racesCompleted', entity.racesCompleted); + } catch (error) { + if (error instanceof TypeOrmPersistenceSchemaError) { + throw new InvalidStandingSchemaError({ + fieldName: error.fieldName, + reason: error.reason, + message: error.message, + }); + } + throw error; + } + + try { + return Standing.rehydrate({ + id: entity.id, + leagueId: entity.leagueId, + driverId: entity.driverId, + points: entity.points, + wins: entity.wins, + position: entity.position, + racesCompleted: entity.racesCompleted, + }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Invalid persisted Standing'; + throw new InvalidStandingSchemaError({ + fieldName: 'unknown', + reason: 'invalid_shape', + message, + }); + } + } +} \ No newline at end of file diff --git a/adapters/racing/persistence/typeorm/mappers/StewardingOrmMappers.test.ts b/adapters/racing/persistence/typeorm/mappers/StewardingOrmMappers.test.ts new file mode 100644 index 000000000..aab1f7a1f --- /dev/null +++ b/adapters/racing/persistence/typeorm/mappers/StewardingOrmMappers.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { Penalty } from '@core/racing/domain/entities/penalty/Penalty'; +import { Protest } from '@core/racing/domain/entities/Protest'; + +import { PenaltyOrmEntity, ProtestOrmEntity } from '../entities/MissingRacingOrmEntities'; +import { TypeOrmPersistenceSchemaError } from '../errors/TypeOrmPersistenceSchemaError'; +import { PenaltyOrmMapper, ProtestOrmMapper } from './StewardingOrmMappers'; + +describe('PenaltyOrmMapper', () => { + it('toDomain uses rehydrate semantics (does not call create)', () => { + const mapper = new PenaltyOrmMapper(); + + const entity = new PenaltyOrmEntity(); + entity.id = '00000000-0000-4000-8000-000000000001'; + entity.leagueId = '00000000-0000-4000-8000-000000000002'; + entity.raceId = 'race-1'; + entity.driverId = '00000000-0000-4000-8000-000000000003'; + entity.type = 'warning'; + entity.value = null; + entity.reason = 'Unsafe rejoin'; + entity.protestId = null; + entity.issuedBy = '00000000-0000-4000-8000-000000000004'; + entity.status = 'pending'; + entity.issuedAt = new Date('2025-01-01T00:00:00.000Z'); + entity.appliedAt = null; + entity.notes = null; + + const rehydrateSpy = vi.spyOn(Penalty, 'rehydrate'); + const createSpy = vi.spyOn(Penalty, '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 enum values and throws adapter-scoped base schema error type', () => { + const mapper = new PenaltyOrmMapper(); + + const entity = new PenaltyOrmEntity(); + entity.id = '00000000-0000-4000-8000-000000000001'; + entity.leagueId = '00000000-0000-4000-8000-000000000002'; + entity.raceId = 'race-1'; + entity.driverId = '00000000-0000-4000-8000-000000000003'; + entity.type = 'not-a-penalty-type'; + entity.value = null; + entity.reason = 'Reason'; + entity.protestId = null; + entity.issuedBy = '00000000-0000-4000-8000-000000000004'; + entity.status = 'pending'; + entity.issuedAt = new Date('2025-01-01T00:00:00.000Z'); + entity.appliedAt = null; + entity.notes = null; + + try { + mapper.toDomain(entity); + throw new Error('expected-to-throw'); + } catch (error) { + expect(error).toBeInstanceOf(TypeOrmPersistenceSchemaError); + expect(error).toMatchObject({ + entityName: 'Penalty', + fieldName: 'type', + reason: 'invalid_enum_value', + }); + } + }); +}); + +describe('ProtestOrmMapper', () => { + it('toDomain uses rehydrate semantics (does not call create)', () => { + const mapper = new ProtestOrmMapper(); + + const entity = new ProtestOrmEntity(); + entity.id = '00000000-0000-4000-8000-000000000001'; + entity.raceId = 'race-1'; + entity.protestingDriverId = '00000000-0000-4000-8000-000000000002'; + entity.accusedDriverId = '00000000-0000-4000-8000-000000000003'; + entity.incident = { lap: 1, description: 'Contact', timeInRace: 12.3 }; + entity.comment = null; + entity.proofVideoUrl = null; + entity.status = 'pending'; + entity.reviewedBy = null; + entity.decisionNotes = null; + entity.filedAt = new Date('2025-01-01T00:00:00.000Z'); + entity.reviewedAt = null; + entity.defense = null; + entity.defenseRequestedAt = null; + entity.defenseRequestedBy = null; + + const rehydrateSpy = vi.spyOn(Protest, 'rehydrate'); + const createSpy = vi.spyOn(Protest, '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 defense schema and throws adapter-scoped base schema error type', () => { + const mapper = new ProtestOrmMapper(); + + const entity = new ProtestOrmEntity(); + entity.id = '00000000-0000-4000-8000-000000000001'; + entity.raceId = 'race-1'; + entity.protestingDriverId = '00000000-0000-4000-8000-000000000002'; + entity.accusedDriverId = '00000000-0000-4000-8000-000000000003'; + entity.incident = { lap: 1, description: 'Contact' }; + entity.comment = null; + entity.proofVideoUrl = null; + entity.status = 'pending'; + entity.reviewedBy = null; + entity.decisionNotes = null; + entity.filedAt = new Date('2025-01-01T00:00:00.000Z'); + entity.reviewedAt = null; + entity.defense = { statement: 'hi', submittedAt: 'not-iso' }; + entity.defenseRequestedAt = null; + entity.defenseRequestedBy = null; + + try { + mapper.toDomain(entity); + throw new Error('expected-to-throw'); + } catch (error) { + expect(error).toBeInstanceOf(TypeOrmPersistenceSchemaError); + expect(error).toMatchObject({ + entityName: 'Protest', + fieldName: 'defense.submittedAt', + reason: 'not_iso_date', + }); + } + }); +}); \ No newline at end of file diff --git a/adapters/racing/persistence/typeorm/mappers/StewardingOrmMappers.ts b/adapters/racing/persistence/typeorm/mappers/StewardingOrmMappers.ts new file mode 100644 index 000000000..8a6ed7b91 --- /dev/null +++ b/adapters/racing/persistence/typeorm/mappers/StewardingOrmMappers.ts @@ -0,0 +1,295 @@ +import { Penalty } from '@core/racing/domain/entities/penalty/Penalty'; +import { Protest } from '@core/racing/domain/entities/Protest'; + +import { TypeOrmPersistenceSchemaError } from '../errors/TypeOrmPersistenceSchemaError'; +import { PenaltyOrmEntity, ProtestOrmEntity } from '../entities/MissingRacingOrmEntities'; +import { + assertDate, + assertEnumValue, + assertNonEmptyString, + assertOptionalInteger, + assertOptionalStringOrNull, + assertRecord, +} from '../schema/TypeOrmSchemaGuards'; + +const VALID_PROTEST_STATUSES = [ + 'pending', + 'awaiting_defense', + 'under_review', + 'upheld', + 'dismissed', + 'withdrawn', +] as const; + +const VALID_PENALTY_STATUSES = ['pending', 'applied', 'appealed', 'overturned'] as const; + +const VALID_PENALTY_TYPES = [ + 'time_penalty', + 'grid_penalty', + 'points_deduction', + 'disqualification', + 'warning', + 'license_points', + 'probation', + 'fine', + 'race_ban', +] as const; + +type SerializedProtestIncident = { + lap: number; + description: string; + timeInRace?: number; +}; + +type SerializedProtestDefense = { + statement: string; + submittedAt: string; + videoUrl?: string; +}; + +function assertSerializedProtestIncident(value: unknown): asserts value is SerializedProtestIncident { + const entityName = 'Protest'; + const fieldName = 'incident'; + + assertRecord(entityName, fieldName, value); + + const lap = value.lap; + if (typeof lap !== 'number' || !Number.isFinite(lap)) { + throw new TypeOrmPersistenceSchemaError({ entityName, fieldName: `${fieldName}.lap`, reason: 'not_number' }); + } + + const description = value.description; + if (typeof description !== 'string') { + throw new TypeOrmPersistenceSchemaError({ entityName, fieldName: `${fieldName}.description`, reason: 'not_string' }); + } + if (description.trim().length === 0) { + throw new TypeOrmPersistenceSchemaError({ + entityName, + fieldName: `${fieldName}.description`, + reason: 'empty_string', + }); + } + + if (value.timeInRace !== undefined) { + const timeInRace = value.timeInRace; + if (typeof timeInRace !== 'number' || !Number.isFinite(timeInRace)) { + throw new TypeOrmPersistenceSchemaError({ + entityName, + fieldName: `${fieldName}.timeInRace`, + reason: 'not_number', + }); + } + } +} + +function assertSerializedProtestDefense(value: unknown): asserts value is SerializedProtestDefense { + const entityName = 'Protest'; + const fieldName = 'defense'; + + assertRecord(entityName, fieldName, value); + + const statement = value.statement; + if (typeof statement !== 'string') { + throw new TypeOrmPersistenceSchemaError({ entityName, fieldName: `${fieldName}.statement`, reason: 'not_string' }); + } + if (statement.trim().length === 0) { + throw new TypeOrmPersistenceSchemaError({ + entityName, + fieldName: `${fieldName}.statement`, + reason: 'empty_string', + }); + } + + const submittedAt = value.submittedAt; + if (typeof submittedAt !== 'string') { + throw new TypeOrmPersistenceSchemaError({ + entityName, + fieldName: `${fieldName}.submittedAt`, + reason: 'not_string', + }); + } + + const submittedAtDate = new Date(submittedAt); + if (Number.isNaN(submittedAtDate.getTime()) || submittedAtDate.toISOString() !== submittedAt) { + throw new TypeOrmPersistenceSchemaError({ + entityName, + fieldName: `${fieldName}.submittedAt`, + reason: 'not_iso_date', + }); + } + + if (value.videoUrl !== undefined && typeof value.videoUrl !== 'string') { + throw new TypeOrmPersistenceSchemaError({ entityName, fieldName: `${fieldName}.videoUrl`, reason: 'not_string' }); + } +} + +function serializeProtestDefense(defense: Protest['defense']): SerializedProtestDefense | undefined { + if (!defense) return undefined; + + return { + statement: defense.statement.toString(), + submittedAt: defense.submittedAt.toDate().toISOString(), + ...(defense.videoUrl !== undefined ? { videoUrl: defense.videoUrl.toString() } : {}), + }; +} + +export class PenaltyOrmMapper { + toOrmEntity(domain: Penalty): PenaltyOrmEntity { + const entity = new PenaltyOrmEntity(); + entity.id = domain.id; + entity.leagueId = domain.leagueId; + entity.raceId = domain.raceId; + entity.driverId = domain.driverId; + entity.type = domain.type; + entity.value = domain.value ?? null; + entity.reason = domain.reason; + entity.protestId = domain.protestId ?? null; + entity.issuedBy = domain.issuedBy; + entity.status = domain.status; + entity.issuedAt = domain.issuedAt; + entity.appliedAt = domain.appliedAt ?? null; + entity.notes = domain.notes ?? null; + return entity; + } + + toDomain(entity: PenaltyOrmEntity): Penalty { + const entityName = 'Penalty'; + + assertNonEmptyString(entityName, 'id', entity.id); + assertNonEmptyString(entityName, 'leagueId', entity.leagueId); + assertNonEmptyString(entityName, 'raceId', entity.raceId); + assertNonEmptyString(entityName, 'driverId', entity.driverId); + assertEnumValue(entityName, 'type', entity.type, VALID_PENALTY_TYPES); + assertOptionalInteger(entityName, 'value', entity.value); + assertNonEmptyString(entityName, 'reason', entity.reason); + assertOptionalStringOrNull(entityName, 'protestId', entity.protestId); + assertNonEmptyString(entityName, 'issuedBy', entity.issuedBy); + assertEnumValue(entityName, 'status', entity.status, VALID_PENALTY_STATUSES); + assertDate(entityName, 'issuedAt', entity.issuedAt); + assertOptionalStringOrNull(entityName, 'notes', entity.notes); + + if (entity.appliedAt !== null && entity.appliedAt !== undefined) { + assertDate(entityName, 'appliedAt', entity.appliedAt); + } + + try { + return Penalty.rehydrate({ + id: entity.id, + leagueId: entity.leagueId, + raceId: entity.raceId, + driverId: entity.driverId, + type: entity.type, + ...(entity.value !== null && entity.value !== undefined ? { value: entity.value } : {}), + reason: entity.reason, + ...(entity.protestId !== null && entity.protestId !== undefined ? { protestId: entity.protestId } : {}), + issuedBy: entity.issuedBy, + status: entity.status, + issuedAt: entity.issuedAt, + ...(entity.appliedAt !== null && entity.appliedAt !== undefined ? { appliedAt: entity.appliedAt } : {}), + ...(entity.notes !== null && entity.notes !== undefined ? { notes: entity.notes } : {}), + }); + } catch { + throw new TypeOrmPersistenceSchemaError({ entityName, fieldName: '__root', reason: 'invalid_shape' }); + } + } +} + +export class ProtestOrmMapper { + toOrmEntity(domain: Protest): ProtestOrmEntity { + const entity = new ProtestOrmEntity(); + entity.id = domain.id; + entity.raceId = domain.raceId; + entity.protestingDriverId = domain.protestingDriverId; + entity.accusedDriverId = domain.accusedDriverId; + entity.incident = { + lap: domain.incident.lap.toNumber(), + description: domain.incident.description.toString(), + ...(domain.incident.timeInRace !== undefined ? { timeInRace: domain.incident.timeInRace.toNumber() } : {}), + }; + entity.comment = domain.comment ?? null; + entity.proofVideoUrl = domain.proofVideoUrl ?? null; + entity.status = domain.status.toString(); + entity.reviewedBy = domain.reviewedBy ?? null; + entity.decisionNotes = domain.decisionNotes ?? null; + entity.filedAt = domain.filedAt; + entity.reviewedAt = domain.reviewedAt ?? null; + entity.defense = serializeProtestDefense(domain.defense) ?? null; + entity.defenseRequestedAt = domain.defenseRequestedAt ?? null; + entity.defenseRequestedBy = domain.defenseRequestedBy ?? null; + return entity; + } + + toDomain(entity: ProtestOrmEntity): Protest { + const entityName = 'Protest'; + + assertNonEmptyString(entityName, 'id', entity.id); + assertNonEmptyString(entityName, 'raceId', entity.raceId); + assertNonEmptyString(entityName, 'protestingDriverId', entity.protestingDriverId); + assertNonEmptyString(entityName, 'accusedDriverId', entity.accusedDriverId); + assertEnumValue(entityName, 'status', entity.status, VALID_PROTEST_STATUSES); + assertDate(entityName, 'filedAt', entity.filedAt); + + assertSerializedProtestIncident(entity.incident); + + assertOptionalStringOrNull(entityName, 'comment', entity.comment); + assertOptionalStringOrNull(entityName, 'proofVideoUrl', entity.proofVideoUrl); + assertOptionalStringOrNull(entityName, 'decisionNotes', entity.decisionNotes); + + if (entity.reviewedAt !== null && entity.reviewedAt !== undefined) { + assertDate(entityName, 'reviewedAt', entity.reviewedAt); + } + if (entity.defenseRequestedAt !== null && entity.defenseRequestedAt !== undefined) { + assertDate(entityName, 'defenseRequestedAt', entity.defenseRequestedAt); + } + + if (entity.reviewedBy !== null && entity.reviewedBy !== undefined) { + assertNonEmptyString(entityName, 'reviewedBy', entity.reviewedBy); + } + if (entity.defenseRequestedBy !== null && entity.defenseRequestedBy !== undefined) { + assertNonEmptyString(entityName, 'defenseRequestedBy', entity.defenseRequestedBy); + } + + let defense: { statement: string; submittedAt: Date; videoUrl?: string } | undefined; + if (entity.defense !== null && entity.defense !== undefined) { + assertSerializedProtestDefense(entity.defense); + const parsed = entity.defense as SerializedProtestDefense; + defense = { + statement: parsed.statement, + submittedAt: new Date(parsed.submittedAt), + ...(parsed.videoUrl !== undefined ? { videoUrl: parsed.videoUrl } : {}), + }; + } + + try { + return Protest.rehydrate({ + id: entity.id, + raceId: entity.raceId, + protestingDriverId: entity.protestingDriverId, + accusedDriverId: entity.accusedDriverId, + incident: { + lap: (entity.incident as SerializedProtestIncident).lap, + description: (entity.incident as SerializedProtestIncident).description, + ...((entity.incident as SerializedProtestIncident).timeInRace !== undefined + ? { timeInRace: (entity.incident as SerializedProtestIncident).timeInRace } + : {}), + }, + ...(entity.comment !== null && entity.comment !== undefined ? { comment: entity.comment } : {}), + ...(entity.proofVideoUrl !== null && entity.proofVideoUrl !== undefined ? { proofVideoUrl: entity.proofVideoUrl } : {}), + status: entity.status, + ...(entity.reviewedBy !== null && entity.reviewedBy !== undefined ? { reviewedBy: entity.reviewedBy } : {}), + ...(entity.decisionNotes !== null && entity.decisionNotes !== undefined ? { decisionNotes: entity.decisionNotes } : {}), + filedAt: entity.filedAt, + ...(entity.reviewedAt !== null && entity.reviewedAt !== undefined ? { reviewedAt: entity.reviewedAt } : {}), + ...(defense !== undefined ? { defense } : {}), + ...(entity.defenseRequestedAt !== null && entity.defenseRequestedAt !== undefined + ? { defenseRequestedAt: entity.defenseRequestedAt } + : {}), + ...(entity.defenseRequestedBy !== null && entity.defenseRequestedBy !== undefined + ? { defenseRequestedBy: entity.defenseRequestedBy } + : {}), + }); + } catch { + throw new TypeOrmPersistenceSchemaError({ entityName, fieldName: '__root', reason: 'invalid_shape' }); + } + } +} \ No newline at end of file diff --git a/adapters/racing/persistence/typeorm/mappers/TeamOrmMappers.test.ts b/adapters/racing/persistence/typeorm/mappers/TeamOrmMappers.test.ts new file mode 100644 index 000000000..d8a23bff8 --- /dev/null +++ b/adapters/racing/persistence/typeorm/mappers/TeamOrmMappers.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { Team } from '@core/racing/domain/entities/Team'; + +import { TeamMembershipOrmEntity, TeamOrmEntity } from '../entities/TeamOrmEntities'; +import { TypeOrmPersistenceSchemaError } from '../errors/TypeOrmPersistenceSchemaError'; +import { TeamMembershipOrmMapper, TeamOrmMapper } from './TeamOrmMappers'; + +describe('TeamOrmMapper', () => { + it('toDomain preserves persisted identity and uses rehydrate semantics (does not call create)', () => { + const mapper = new TeamOrmMapper(); + + const entity = new TeamOrmEntity(); + entity.id = '00000000-0000-4000-8000-000000000001'; + entity.name = 'Team One'; + entity.tag = 'T1'; + entity.description = 'Desc'; + entity.ownerId = '00000000-0000-4000-8000-000000000002'; + entity.leagues = ['00000000-0000-4000-8000-000000000003']; + entity.createdAt = new Date('2025-01-01T00:00:00.000Z'); + + const rehydrateSpy = vi.spyOn(Team, 'rehydrate'); + const createSpy = vi.spyOn(Team, 'create').mockImplementation(() => { + throw new Error('create-called'); + }); + + const domain = mapper.toDomain(entity); + + expect(domain.id).toBe(entity.id); + expect(domain.ownerId.toString()).toBe(entity.ownerId); + expect(createSpy).not.toHaveBeenCalled(); + expect(rehydrateSpy).toHaveBeenCalled(); + }); + + it('toDomain validates persisted shape and throws adapter-scoped base schema error type', () => { + const mapper = new TeamOrmMapper(); + + const entity = new TeamOrmEntity(); + entity.id = ''; + entity.name = 'Team One'; + entity.tag = 'T1'; + entity.description = 'Desc'; + entity.ownerId = '00000000-0000-4000-8000-000000000002'; + entity.leagues = []; + entity.createdAt = new Date('2025-01-01T00:00:00.000Z'); + + try { + mapper.toDomain(entity); + throw new Error('expected-to-throw'); + } catch (error) { + expect(error).toBeInstanceOf(TypeOrmPersistenceSchemaError); + expect(error).toMatchObject({ + entityName: 'Team', + fieldName: 'id', + reason: 'empty_string', + }); + } + }); +}); + +describe('TeamMembershipOrmMapper', () => { + it('toDomainMembership validates enum role/status and throws adapter-scoped base schema error type', () => { + const mapper = new TeamMembershipOrmMapper(); + + const entity = new TeamMembershipOrmEntity(); + entity.teamId = '00000000-0000-4000-8000-000000000001'; + entity.driverId = '00000000-0000-4000-8000-000000000002'; + entity.role = 'invalid'; + entity.status = 'active'; + entity.joinedAt = new Date('2025-01-01T00:00:00.000Z'); + + try { + mapper.toDomainMembership(entity); + throw new Error('expected-to-throw'); + } catch (error) { + expect(error).toBeInstanceOf(TypeOrmPersistenceSchemaError); + expect(error).toMatchObject({ + entityName: 'TeamMembership', + fieldName: 'role', + reason: 'invalid_enum_value', + }); + } + }); + + it('round-trips membership toOrmMembership <-> toDomainMembership', () => { + const mapper = new TeamMembershipOrmMapper(); + + const domain = { + teamId: '00000000-0000-4000-8000-000000000001', + driverId: '00000000-0000-4000-8000-000000000002', + role: 'driver' as const, + status: 'active' as const, + joinedAt: new Date('2025-01-01T00:00:00.000Z'), + }; + + const orm = mapper.toOrmMembership(domain); + const rehydrated = mapper.toDomainMembership(orm); + + expect(rehydrated).toEqual(domain); + }); +}); \ No newline at end of file diff --git a/adapters/racing/persistence/typeorm/mappers/TeamOrmMappers.ts b/adapters/racing/persistence/typeorm/mappers/TeamOrmMappers.ts new file mode 100644 index 000000000..6aca8048c --- /dev/null +++ b/adapters/racing/persistence/typeorm/mappers/TeamOrmMappers.ts @@ -0,0 +1,119 @@ +import { Team } from '@core/racing/domain/entities/Team'; + +import { TypeOrmPersistenceSchemaError } from '../errors/TypeOrmPersistenceSchemaError'; + +import type { TeamJoinRequest, TeamMembership } from '@core/racing/domain/types/TeamMembership'; + +import { TeamJoinRequestOrmEntity, TeamMembershipOrmEntity, TeamOrmEntity } from '../entities/TeamOrmEntities'; +import { + assertArray, + assertDate, + assertEnumValue, + assertNonEmptyString, + assertOptionalStringOrNull, +} from '../schema/TypeOrmSchemaGuards'; + +const TEAM_ROLES = ['owner', 'manager', 'driver'] as const; +const TEAM_MEMBERSHIP_STATUSES = ['active', 'pending', 'none'] as const; + +export class TeamOrmMapper { + toOrmEntity(domain: Team): TeamOrmEntity { + const entity = new TeamOrmEntity(); + entity.id = domain.id; + entity.name = domain.name.toString(); + entity.tag = domain.tag.toString(); + entity.description = domain.description.toString(); + entity.ownerId = domain.ownerId.toString(); + entity.leagues = domain.leagues.map((l) => l.toString()); + entity.createdAt = domain.createdAt.toDate(); + return entity; + } + + toDomain(entity: TeamOrmEntity): Team { + const entityName = 'Team'; + + assertNonEmptyString(entityName, 'id', entity.id); + assertNonEmptyString(entityName, 'name', entity.name); + assertNonEmptyString(entityName, 'tag', entity.tag); + assertNonEmptyString(entityName, 'description', entity.description); + assertNonEmptyString(entityName, 'ownerId', entity.ownerId); + assertDate(entityName, 'createdAt', entity.createdAt); + + assertArray(entityName, 'leagues', entity.leagues); + for (const leagueId of entity.leagues) { + assertNonEmptyString(entityName, 'leagues', leagueId); + } + + try { + return Team.rehydrate({ + id: entity.id, + name: entity.name, + tag: entity.tag, + description: entity.description, + ownerId: entity.ownerId, + leagues: entity.leagues, + createdAt: entity.createdAt, + }); + } catch { + throw new TypeOrmPersistenceSchemaError({ entityName, fieldName: '__root', reason: 'invalid_shape' }); + } + } +} + +export class TeamMembershipOrmMapper { + toOrmMembership(membership: TeamMembership): TeamMembershipOrmEntity { + const entity = new TeamMembershipOrmEntity(); + entity.teamId = membership.teamId; + entity.driverId = membership.driverId; + entity.role = membership.role; + entity.status = membership.status; + entity.joinedAt = membership.joinedAt; + return entity; + } + + toDomainMembership(entity: TeamMembershipOrmEntity): TeamMembership { + const entityName = 'TeamMembership'; + + assertNonEmptyString(entityName, 'teamId', entity.teamId); + assertNonEmptyString(entityName, 'driverId', entity.driverId); + assertEnumValue(entityName, 'role', entity.role, TEAM_ROLES); + assertEnumValue(entityName, 'status', entity.status, TEAM_MEMBERSHIP_STATUSES); + assertDate(entityName, 'joinedAt', entity.joinedAt); + + return { + teamId: entity.teamId, + driverId: entity.driverId, + role: entity.role, + status: entity.status, + joinedAt: entity.joinedAt, + }; + } + + toOrmJoinRequest(request: TeamJoinRequest): TeamJoinRequestOrmEntity { + const entity = new TeamJoinRequestOrmEntity(); + entity.id = request.id; + entity.teamId = request.teamId; + entity.driverId = request.driverId; + entity.requestedAt = request.requestedAt; + entity.message = request.message ?? null; + return entity; + } + + toDomainJoinRequest(entity: TeamJoinRequestOrmEntity): TeamJoinRequest { + const entityName = 'TeamJoinRequest'; + + assertNonEmptyString(entityName, 'id', entity.id); + assertNonEmptyString(entityName, 'teamId', entity.teamId); + assertNonEmptyString(entityName, 'driverId', entity.driverId); + assertDate(entityName, 'requestedAt', entity.requestedAt); + assertOptionalStringOrNull(entityName, 'message', entity.message); + + return { + id: entity.id, + teamId: entity.teamId, + driverId: entity.driverId, + requestedAt: entity.requestedAt, + ...(entity.message !== null && entity.message !== undefined ? { message: entity.message } : {}), + }; + } +} \ No newline at end of file diff --git a/adapters/racing/persistence/typeorm/repositories/CommerceTypeOrmRepositories.test.ts b/adapters/racing/persistence/typeorm/repositories/CommerceTypeOrmRepositories.test.ts new file mode 100644 index 000000000..401b8d20e --- /dev/null +++ b/adapters/racing/persistence/typeorm/repositories/CommerceTypeOrmRepositories.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + TypeOrmGameRepository, + TypeOrmLeagueWalletRepository, + TypeOrmSponsorRepository, + TypeOrmTransactionRepository, +} from './CommerceTypeOrmRepositories'; + +describe('TypeOrmGameRepository', () => { + it('findAll maps entities to domain using injected mapper (DB-free)', async () => { + const entities = [{ id: 'g1' }, { id: 'g2' }]; + + const repo = { + find: vi.fn().mockResolvedValue(entities), + findOne: vi.fn(), + }; + + const mapper = { + toDomain: vi.fn().mockImplementation((e: any) => ({ id: `domain-${e.id}` })), + }; + + const gameRepo = new TypeOrmGameRepository(repo as any, mapper as any); + + const games = await gameRepo.findAll(); + + expect(repo.find).toHaveBeenCalledTimes(1); + expect(mapper.toDomain).toHaveBeenCalledTimes(2); + expect(games).toEqual([{ id: 'domain-g1' }, { id: 'domain-g2' }]); + }); +}); + +describe('TypeOrmLeagueWalletRepository', () => { + it('exists returns true when count > 0 (DB-free)', async () => { + const repo = { + count: vi.fn().mockResolvedValue(1), + findOne: vi.fn(), + save: vi.fn(), + delete: vi.fn(), + }; + + const mapper = { + toDomain: vi.fn(), + toOrmEntity: vi.fn(), + }; + + const walletRepo = new TypeOrmLeagueWalletRepository(repo as any, mapper as any); + + await expect(walletRepo.exists('w1')).resolves.toBe(true); + expect(repo.count).toHaveBeenCalledWith({ where: { id: 'w1' } }); + }); +}); + +describe('TypeOrmTransactionRepository', () => { + it('findByWalletId maps entities to domain (DB-free)', async () => { + const entities = [{ id: 't1' }, { id: 't2' }]; + + const repo = { + find: vi.fn().mockResolvedValue(entities), + findOne: vi.fn(), + save: vi.fn(), + delete: vi.fn(), + count: vi.fn(), + }; + + const mapper = { + toDomain: vi.fn().mockImplementation((e: any) => ({ id: `domain-${e.id}` })), + toOrmEntity: vi.fn(), + }; + + const txRepo = new TypeOrmTransactionRepository(repo as any, mapper as any); + + const txs = await txRepo.findByWalletId('wallet-1'); + + expect(repo.find).toHaveBeenCalledWith({ where: { walletId: 'wallet-1' } }); + expect(mapper.toDomain).toHaveBeenCalledTimes(2); + expect(txs).toEqual([{ id: 'domain-t1' }, { id: 'domain-t2' }]); + }); +}); + +describe('TypeOrmSponsorRepository', () => { + it('findByEmail queries by contactEmail and maps (DB-free)', async () => { + const entity = { id: 's1' }; + + const repo = { + findOne: vi.fn().mockResolvedValue(entity), + find: vi.fn(), + save: vi.fn(), + delete: vi.fn(), + count: vi.fn(), + }; + + const mapper = { + toDomain: vi.fn().mockReturnValue({ id: 'domain-s1' }), + toOrmEntity: vi.fn(), + }; + + const sponsorRepo = new TypeOrmSponsorRepository(repo as any, mapper as any); + + const sponsor = await sponsorRepo.findByEmail('a@example.com'); + + expect(repo.findOne).toHaveBeenCalledWith({ where: { contactEmail: 'a@example.com' } }); + expect(mapper.toDomain).toHaveBeenCalledWith(entity); + expect(sponsor).toEqual({ id: 'domain-s1' }); + }); +}); \ No newline at end of file diff --git a/adapters/racing/persistence/typeorm/repositories/CommerceTypeOrmRepositories.ts b/adapters/racing/persistence/typeorm/repositories/CommerceTypeOrmRepositories.ts new file mode 100644 index 000000000..0497a96b8 --- /dev/null +++ b/adapters/racing/persistence/typeorm/repositories/CommerceTypeOrmRepositories.ts @@ -0,0 +1,320 @@ +import type { Repository } from 'typeorm'; + +import type { IGameRepository } from '@core/racing/domain/repositories/IGameRepository'; +import type { ILeagueWalletRepository } from '@core/racing/domain/repositories/ILeagueWalletRepository'; +import type { ISeasonSponsorshipRepository } from '@core/racing/domain/repositories/ISeasonSponsorshipRepository'; +import type { ISponsorRepository } from '@core/racing/domain/repositories/ISponsorRepository'; +import type { ISponsorshipPricingRepository } from '@core/racing/domain/repositories/ISponsorshipPricingRepository'; +import type { ISponsorshipRequestRepository } from '@core/racing/domain/repositories/ISponsorshipRequestRepository'; +import type { ITransactionRepository } from '@core/racing/domain/repositories/ITransactionRepository'; + +import type { Game } from '@core/racing/domain/entities/Game'; +import type { LeagueWallet } from '@core/racing/domain/entities/league-wallet/LeagueWallet'; +import type { Transaction, TransactionType } from '@core/racing/domain/entities/league-wallet/Transaction'; +import type { SeasonSponsorship, SponsorshipTier } from '@core/racing/domain/entities/season/SeasonSponsorship'; +import type { Sponsor } from '@core/racing/domain/entities/sponsor/Sponsor'; +import type { SponsorshipPricing } from '@core/racing/domain/value-objects/SponsorshipPricing'; +import type { SponsorshipRequest, SponsorableEntityType, SponsorshipRequestStatus } from '@core/racing/domain/entities/SponsorshipRequest'; + +import { + GameOrmEntity, + LeagueWalletOrmEntity, + SeasonSponsorshipOrmEntity, + SponsorOrmEntity, + SponsorshipPricingOrmEntity, + SponsorshipRequestOrmEntity, + TransactionOrmEntity, +} from '../entities/MissingRacingOrmEntities'; +import { + GameOrmMapper, + LeagueWalletOrmMapper, + SeasonSponsorshipOrmMapper, + SponsorOrmMapper, + SponsorshipPricingOrmMapper, + SponsorshipRequestOrmMapper, + TransactionOrmMapper, +} from '../mappers/CommerceOrmMappers'; + +export class TypeOrmGameRepository implements IGameRepository { + constructor( + private readonly repo: Repository, + private readonly mapper: GameOrmMapper, + ) {} + + async findById(id: string): Promise { + const entity = await this.repo.findOne({ where: { id } }); + return entity ? this.mapper.toDomain(entity) : null; + } + + async findAll(): Promise { + const entities = await this.repo.find(); + return entities.map((e) => this.mapper.toDomain(e)); + } +} + +export class TypeOrmLeagueWalletRepository implements ILeagueWalletRepository { + constructor( + private readonly repo: Repository, + private readonly mapper: LeagueWalletOrmMapper, + ) {} + + async findById(id: string): Promise { + const entity = await this.repo.findOne({ where: { id } }); + return entity ? this.mapper.toDomain(entity) : null; + } + + async findByLeagueId(leagueId: string): Promise { + const entity = await this.repo.findOne({ where: { leagueId } }); + return entity ? this.mapper.toDomain(entity) : null; + } + + async create(wallet: LeagueWallet): Promise { + await this.repo.save(this.mapper.toOrmEntity(wallet)); + return wallet; + } + + async update(wallet: LeagueWallet): Promise { + await this.repo.save(this.mapper.toOrmEntity(wallet)); + return wallet; + } + + async delete(id: string): Promise { + await this.repo.delete({ id }); + } + + async exists(id: string): Promise { + const count = await this.repo.count({ where: { id } }); + return count > 0; + } +} + +export class TypeOrmTransactionRepository implements ITransactionRepository { + constructor( + private readonly repo: Repository, + private readonly mapper: TransactionOrmMapper, + ) {} + + async findById(id: string): Promise { + const entity = await this.repo.findOne({ where: { id } }); + return entity ? this.mapper.toDomain(entity) : null; + } + + async findByWalletId(walletId: string): Promise { + const entities = await this.repo.find({ where: { walletId } }); + return entities.map((e) => this.mapper.toDomain(e)); + } + + async findByType(type: TransactionType): Promise { + const entities = await this.repo.find({ where: { type } }); + return entities.map((e) => this.mapper.toDomain(e)); + } + + async create(transaction: Transaction): Promise { + await this.repo.save(this.mapper.toOrmEntity(transaction)); + return transaction; + } + + async update(transaction: Transaction): Promise { + await this.repo.save(this.mapper.toOrmEntity(transaction)); + return transaction; + } + + async delete(id: string): Promise { + await this.repo.delete({ id }); + } + + async exists(id: string): Promise { + const count = await this.repo.count({ where: { id } }); + return count > 0; + } +} + +export class TypeOrmSponsorRepository implements ISponsorRepository { + constructor( + private readonly repo: Repository, + private readonly mapper: SponsorOrmMapper, + ) {} + + async findById(id: string): Promise { + const entity = await this.repo.findOne({ where: { id } }); + return entity ? this.mapper.toDomain(entity) : null; + } + + async findAll(): Promise { + const entities = await this.repo.find(); + return entities.map((e) => this.mapper.toDomain(e)); + } + + async findByEmail(email: string): Promise { + const entity = await this.repo.findOne({ where: { contactEmail: email } }); + return entity ? this.mapper.toDomain(entity) : null; + } + + async create(sponsor: Sponsor): Promise { + await this.repo.save(this.mapper.toOrmEntity(sponsor)); + return sponsor; + } + + async update(sponsor: Sponsor): Promise { + await this.repo.save(this.mapper.toOrmEntity(sponsor)); + return sponsor; + } + + async delete(id: string): Promise { + await this.repo.delete({ id }); + } + + async exists(id: string): Promise { + const count = await this.repo.count({ where: { id } }); + return count > 0; + } +} + +export class TypeOrmSponsorshipPricingRepository implements ISponsorshipPricingRepository { + constructor( + private readonly repo: Repository, + private readonly mapper: SponsorshipPricingOrmMapper, + ) {} + + async findByEntity(entityType: SponsorableEntityType, entityId: string): Promise { + const id = this.mapper.makeId(entityType, entityId); + const entity = await this.repo.findOne({ where: { id } }); + return entity ? this.mapper.toDomain(entity) : null; + } + + async save(entityType: SponsorableEntityType, entityId: string, pricing: SponsorshipPricing): Promise { + await this.repo.save(this.mapper.toOrmEntity(entityType, entityId, pricing)); + } + + async delete(entityType: SponsorableEntityType, entityId: string): Promise { + const id = this.mapper.makeId(entityType, entityId); + await this.repo.delete({ id }); + } + + async exists(entityType: SponsorableEntityType, entityId: string): Promise { + const id = this.mapper.makeId(entityType, entityId); + const count = await this.repo.count({ where: { id } }); + return count > 0; + } + + async findAcceptingApplications(entityType: SponsorableEntityType): Promise> { + const entities = await this.repo.find({ where: { entityType, pricing: { acceptingApplications: true } } as any }); + return entities.map((e) => ({ entityId: e.entityId, pricing: this.mapper.toDomain(e) })); + } +} + +export class TypeOrmSponsorshipRequestRepository implements ISponsorshipRequestRepository { + constructor( + private readonly repo: Repository, + private readonly mapper: SponsorshipRequestOrmMapper, + ) {} + + async findById(id: string): Promise { + const entity = await this.repo.findOne({ where: { id } }); + return entity ? this.mapper.toDomain(entity) : null; + } + + async findByEntity(entityType: SponsorableEntityType, entityId: string): Promise { + const entities = await this.repo.find({ where: { entityType, entityId } }); + return entities.map((e) => this.mapper.toDomain(e)); + } + + async findPendingByEntity(entityType: SponsorableEntityType, entityId: string): Promise { + const entities = await this.repo.find({ where: { entityType, entityId, status: 'pending' } }); + return entities.map((e) => this.mapper.toDomain(e)); + } + + async findBySponsorId(sponsorId: string): Promise { + const entities = await this.repo.find({ where: { sponsorId } }); + return entities.map((e) => this.mapper.toDomain(e)); + } + + async findByStatus(status: SponsorshipRequestStatus): Promise { + const entities = await this.repo.find({ where: { status } }); + return entities.map((e) => this.mapper.toDomain(e)); + } + + async findBySponsorIdAndStatus(sponsorId: string, status: SponsorshipRequestStatus): Promise { + const entities = await this.repo.find({ where: { sponsorId, status } }); + return entities.map((e) => this.mapper.toDomain(e)); + } + + async hasPendingRequest(sponsorId: string, entityType: SponsorableEntityType, entityId: string): Promise { + const count = await this.repo.count({ where: { sponsorId, entityType, entityId, status: 'pending' } }); + return count > 0; + } + + async countPendingByEntity(entityType: SponsorableEntityType, entityId: string): Promise { + return this.repo.count({ where: { entityType, entityId, status: 'pending' } }); + } + + async create(request: SponsorshipRequest): Promise { + await this.repo.save(this.mapper.toOrmEntity(request)); + return request; + } + + async update(request: SponsorshipRequest): Promise { + await this.repo.save(this.mapper.toOrmEntity(request)); + return request; + } + + async delete(id: string): Promise { + await this.repo.delete({ id }); + } + + async exists(id: string): Promise { + const count = await this.repo.count({ where: { id } }); + return count > 0; + } +} + +export class TypeOrmSeasonSponsorshipRepository implements ISeasonSponsorshipRepository { + constructor( + private readonly repo: Repository, + private readonly mapper: SeasonSponsorshipOrmMapper, + ) {} + + async findById(id: string): Promise { + const entity = await this.repo.findOne({ where: { id } }); + return entity ? this.mapper.toDomain(entity) : null; + } + + async findBySeasonId(seasonId: string): Promise { + const entities = await this.repo.find({ where: { seasonId } }); + return entities.map((e) => this.mapper.toDomain(e)); + } + + async findByLeagueId(leagueId: string): Promise { + const entities = await this.repo.find({ where: { leagueId } }); + return entities.map((e) => this.mapper.toDomain(e)); + } + + async findBySponsorId(sponsorId: string): Promise { + const entities = await this.repo.find({ where: { sponsorId } }); + return entities.map((e) => this.mapper.toDomain(e)); + } + + async findBySeasonAndTier(seasonId: string, tier: SponsorshipTier): Promise { + const entities = await this.repo.find({ where: { seasonId, tier } }); + return entities.map((e) => this.mapper.toDomain(e)); + } + + async create(sponsorship: SeasonSponsorship): Promise { + await this.repo.save(this.mapper.toOrmEntity(sponsorship)); + return sponsorship; + } + + async update(sponsorship: SeasonSponsorship): Promise { + await this.repo.save(this.mapper.toOrmEntity(sponsorship)); + return sponsorship; + } + + async delete(id: string): Promise { + await this.repo.delete({ id }); + } + + async exists(id: string): Promise { + const count = await this.repo.count({ where: { id } }); + return count > 0; + } +} \ No newline at end of file diff --git a/adapters/racing/persistence/typeorm/repositories/StewardingTypeOrmRepositories.test.ts b/adapters/racing/persistence/typeorm/repositories/StewardingTypeOrmRepositories.test.ts new file mode 100644 index 000000000..b1628489e --- /dev/null +++ b/adapters/racing/persistence/typeorm/repositories/StewardingTypeOrmRepositories.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { TypeOrmPenaltyRepository, TypeOrmProtestRepository } from './StewardingTypeOrmRepositories'; + +describe('TypeOrmPenaltyRepository', () => { + it('findById returns mapped domain when found (DB-free)', async () => { + const ormEntity = { id: 'p1' }; + + const repo = { + findOne: vi.fn().mockResolvedValue(ormEntity), + find: vi.fn(), + save: vi.fn(), + count: vi.fn(), + delete: vi.fn(), + }; + + const mapper = { + toDomain: vi.fn().mockReturnValue({ id: 'domain' }), + toOrmEntity: vi.fn(), + }; + + const penaltyRepo = new TypeOrmPenaltyRepository(repo as any, mapper as any); + + const result = await penaltyRepo.findById('p1'); + + expect(repo.findOne).toHaveBeenCalledWith({ where: { id: 'p1' } }); + expect(mapper.toDomain).toHaveBeenCalledWith(ormEntity); + expect(result).toEqual({ id: 'domain' }); + }); + + it('create uses mapper.toOrmEntity + repo.save', async () => { + const repo = { + save: vi.fn().mockResolvedValue(undefined), + }; + + const mapper = { + toOrmEntity: vi.fn().mockReturnValue({ id: 'p1-orm' }), + }; + + const penaltyRepo = new TypeOrmPenaltyRepository(repo as any, mapper as any); + + await penaltyRepo.create({ id: 'p1' } as any); + + expect(mapper.toOrmEntity).toHaveBeenCalled(); + expect(repo.save).toHaveBeenCalledWith({ id: 'p1-orm' }); + }); +}); + +describe('TypeOrmProtestRepository', () => { + it('findPending uses injected repo + mapper (DB-free)', async () => { + const entities = [{ id: 'r1' }, { id: 'r2' }]; + + const repo = { + find: vi.fn().mockResolvedValue(entities), + findOne: vi.fn(), + save: vi.fn(), + count: vi.fn(), + }; + + const mapper = { + toDomain: vi.fn().mockImplementation((e: any) => ({ id: `domain-${e.id}` })), + toOrmEntity: vi.fn(), + }; + + const protestRepo = new TypeOrmProtestRepository(repo as any, mapper as any); + + const results = await protestRepo.findPending(); + + expect(repo.find).toHaveBeenCalledWith({ where: { status: 'pending' } }); + expect(mapper.toDomain).toHaveBeenCalledTimes(2); + expect(results).toEqual([{ id: 'domain-r1' }, { id: 'domain-r2' }]); + }); +}); \ No newline at end of file diff --git a/adapters/racing/persistence/typeorm/repositories/StewardingTypeOrmRepositories.ts b/adapters/racing/persistence/typeorm/repositories/StewardingTypeOrmRepositories.ts new file mode 100644 index 000000000..9fa5c1071 --- /dev/null +++ b/adapters/racing/persistence/typeorm/repositories/StewardingTypeOrmRepositories.ts @@ -0,0 +1,109 @@ +import type { Repository } from 'typeorm'; + +import type { IPenaltyRepository } from '@core/racing/domain/repositories/IPenaltyRepository'; +import type { IProtestRepository } from '@core/racing/domain/repositories/IProtestRepository'; +import type { Penalty } from '@core/racing/domain/entities/penalty/Penalty'; +import type { Protest } from '@core/racing/domain/entities/Protest'; + +import { PenaltyOrmEntity, ProtestOrmEntity } from '../entities/MissingRacingOrmEntities'; +import { PenaltyOrmMapper, ProtestOrmMapper } from '../mappers/StewardingOrmMappers'; + +export class TypeOrmPenaltyRepository implements IPenaltyRepository { + constructor( + private readonly repo: Repository, + private readonly mapper: PenaltyOrmMapper, + ) {} + + async findById(id: string): Promise { + const entity = await this.repo.findOne({ where: { id } }); + return entity ? this.mapper.toDomain(entity) : null; + } + + async findByRaceId(raceId: string): Promise { + const entities = await this.repo.find({ where: { raceId } }); + return entities.map((e) => this.mapper.toDomain(e)); + } + + async findByDriverId(driverId: string): Promise { + const entities = await this.repo.find({ where: { driverId } }); + return entities.map((e) => this.mapper.toDomain(e)); + } + + async findByProtestId(protestId: string): Promise { + const entities = await this.repo.find({ where: { protestId } }); + return entities.map((e) => this.mapper.toDomain(e)); + } + + async findPending(): Promise { + const entities = await this.repo.find({ where: { status: 'pending' } }); + return entities.map((e) => this.mapper.toDomain(e)); + } + + async findIssuedBy(stewardId: string): Promise { + const entities = await this.repo.find({ where: { issuedBy: stewardId } }); + return entities.map((e) => this.mapper.toDomain(e)); + } + + async create(penalty: Penalty): Promise { + await this.repo.save(this.mapper.toOrmEntity(penalty)); + } + + async update(penalty: Penalty): Promise { + await this.repo.save(this.mapper.toOrmEntity(penalty)); + } + + async exists(id: string): Promise { + const count = await this.repo.count({ where: { id } }); + return count > 0; + } +} + +export class TypeOrmProtestRepository implements IProtestRepository { + constructor( + private readonly repo: Repository, + private readonly mapper: ProtestOrmMapper, + ) {} + + async findById(id: string): Promise { + const entity = await this.repo.findOne({ where: { id } }); + return entity ? this.mapper.toDomain(entity) : null; + } + + async findByRaceId(raceId: string): Promise { + const entities = await this.repo.find({ where: { raceId } }); + return entities.map((e) => this.mapper.toDomain(e)); + } + + async findByProtestingDriverId(driverId: string): Promise { + const entities = await this.repo.find({ where: { protestingDriverId: driverId } }); + return entities.map((e) => this.mapper.toDomain(e)); + } + + async findByAccusedDriverId(driverId: string): Promise { + const entities = await this.repo.find({ where: { accusedDriverId: driverId } }); + return entities.map((e) => this.mapper.toDomain(e)); + } + + async findPending(): Promise { + const entities = await this.repo.find({ where: { status: 'pending' } }); + return entities.map((e) => this.mapper.toDomain(e)); + } + + async findUnderReviewBy(stewardId: string): Promise { + const entities = await this.repo.find({ where: { reviewedBy: stewardId, status: 'under_review' } }); + return entities.map((e) => this.mapper.toDomain(e)); + } + + async create(protest: Protest): Promise { + await this.repo.save(this.mapper.toOrmEntity(protest)); + } + + async update(protest: Protest): Promise { + await this.repo.save(this.mapper.toOrmEntity(protest)); + } + + async exists(id: string): Promise { + const count = await this.repo.count({ where: { id } }); + return count > 0; + } +} \ No newline at end of file diff --git a/adapters/racing/persistence/typeorm/repositories/TeamTypeOrmRepositories.test.ts b/adapters/racing/persistence/typeorm/repositories/TeamTypeOrmRepositories.test.ts new file mode 100644 index 000000000..172d85320 --- /dev/null +++ b/adapters/racing/persistence/typeorm/repositories/TeamTypeOrmRepositories.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { TypeOrmTeamMembershipRepository, TypeOrmTeamRepository } from './TeamTypeOrmRepositories'; + +describe('TypeOrmTeamRepository', () => { + it('uses injected repo + mapper (DB-free)', async () => { + const ormEntity = { id: 'team-1' }; + + const repo = { + findOne: vi.fn().mockResolvedValue(ormEntity), + find: vi.fn(), + save: vi.fn(), + delete: vi.fn(), + count: vi.fn(), + createQueryBuilder: vi.fn(), + }; + + const mapper = { + toDomain: vi.fn().mockReturnValue({ id: 'domain-team' }), + toOrmEntity: vi.fn().mockReturnValue({ id: 'team-1-orm' }), + }; + + const teamRepo = new TypeOrmTeamRepository(repo as any, mapper as any); + + const team = await teamRepo.findById('team-1'); + + expect(repo.findOne).toHaveBeenCalledWith({ where: { id: 'team-1' } }); + expect(mapper.toDomain).toHaveBeenCalledWith(ormEntity); + expect(team).toEqual({ id: 'domain-team' }); + }); + + it('create uses mapper.toOrmEntity + repo.save', async () => { + const repo = { + save: vi.fn().mockResolvedValue(undefined), + }; + + const mapper = { + toOrmEntity: vi.fn().mockReturnValue({ id: 'team-1-orm' }), + }; + + const teamRepo = new TypeOrmTeamRepository(repo as any, mapper as any); + + const domainTeam = { id: 'team-1' }; + + await expect(teamRepo.create(domainTeam as any)).resolves.toBe(domainTeam); + + expect(mapper.toOrmEntity).toHaveBeenCalledWith(domainTeam); + expect(repo.save).toHaveBeenCalledWith({ id: 'team-1-orm' }); + }); +}); + +describe('TypeOrmTeamMembershipRepository', () => { + it('getMembership returns null when TypeORM returns null (DB-free)', async () => { + const membershipRepo = { + findOne: vi.fn().mockResolvedValue(null), + find: vi.fn(), + save: vi.fn(), + delete: vi.fn(), + count: vi.fn(), + }; + + const joinRequestRepo = { + find: vi.fn(), + save: vi.fn(), + delete: vi.fn(), + }; + + const mapper = { + toDomainMembership: vi.fn(), + toOrmMembership: vi.fn(), + toDomainJoinRequest: vi.fn(), + toOrmJoinRequest: vi.fn(), + }; + + const repo = new TypeOrmTeamMembershipRepository( + membershipRepo as any, + joinRequestRepo as any, + mapper as any, + ); + + await expect(repo.getMembership('team-1', 'driver-1')).resolves.toBeNull(); + expect(membershipRepo.findOne).toHaveBeenCalledWith({ where: { teamId: 'team-1', driverId: 'driver-1' } }); + expect(mapper.toDomainMembership).not.toHaveBeenCalled(); + }); + + it('saveMembership uses mapper.toOrmMembership + repo.save', async () => { + const membershipRepo = { + save: vi.fn().mockResolvedValue(undefined), + }; + + const joinRequestRepo = { + find: vi.fn(), + save: vi.fn(), + delete: vi.fn(), + }; + + const mapper = { + toOrmMembership: vi.fn().mockReturnValue({ teamId: 'team-1', driverId: 'driver-1' }), + }; + + const repo = new TypeOrmTeamMembershipRepository( + membershipRepo as any, + joinRequestRepo as any, + mapper as any, + ); + + const membership = { + teamId: 'team-1', + driverId: 'driver-1', + role: 'driver', + status: 'active', + joinedAt: new Date('2025-01-01T00:00:00.000Z'), + }; + + await expect(repo.saveMembership(membership as any)).resolves.toBe(membership); + + expect(mapper.toOrmMembership).toHaveBeenCalledWith(membership); + expect(membershipRepo.save).toHaveBeenCalledWith({ teamId: 'team-1', driverId: 'driver-1' }); + }); +}); \ No newline at end of file diff --git a/adapters/racing/persistence/typeorm/repositories/TeamTypeOrmRepositories.ts b/adapters/racing/persistence/typeorm/repositories/TeamTypeOrmRepositories.ts new file mode 100644 index 000000000..ad066330f --- /dev/null +++ b/adapters/racing/persistence/typeorm/repositories/TeamTypeOrmRepositories.ts @@ -0,0 +1,104 @@ +import type { Repository } from 'typeorm'; + +import type { ITeamRepository } from '@core/racing/domain/repositories/ITeamRepository'; +import type { ITeamMembershipRepository } from '@core/racing/domain/repositories/ITeamMembershipRepository'; +import type { Team } from '@core/racing/domain/entities/Team'; +import type { TeamJoinRequest, TeamMembership } from '@core/racing/domain/types/TeamMembership'; + +import { TeamJoinRequestOrmEntity, TeamMembershipOrmEntity, TeamOrmEntity } from '../entities/TeamOrmEntities'; +import { TeamMembershipOrmMapper, TeamOrmMapper } from '../mappers/TeamOrmMappers'; + +export class TypeOrmTeamRepository implements ITeamRepository { + constructor( + private readonly repo: Repository, + private readonly mapper: TeamOrmMapper, + ) {} + + async findById(id: string): Promise { + const entity = await this.repo.findOne({ where: { id } }); + return entity ? this.mapper.toDomain(entity) : null; + } + + async findAll(): Promise { + const entities = await this.repo.find(); + return entities.map((e) => this.mapper.toDomain(e)); + } + + async findByLeagueId(leagueId: string): Promise { + const entities = await this.repo + .createQueryBuilder('team') + .where(':leagueId = ANY(team.leagues)', { leagueId }) + .getMany(); + + return entities.map((e) => this.mapper.toDomain(e)); + } + + async create(team: Team): Promise { + await this.repo.save(this.mapper.toOrmEntity(team)); + return team; + } + + async update(team: Team): Promise { + await this.repo.save(this.mapper.toOrmEntity(team)); + return team; + } + + async delete(id: string): Promise { + await this.repo.delete({ id }); + } + + async exists(id: string): Promise { + const count = await this.repo.count({ where: { id } }); + return count > 0; + } +} + +export class TypeOrmTeamMembershipRepository implements ITeamMembershipRepository { + constructor( + private readonly membershipRepo: Repository, + private readonly joinRequestRepo: Repository, + private readonly mapper: TeamMembershipOrmMapper, + ) {} + + async getMembership(teamId: string, driverId: string): Promise { + const entity = await this.membershipRepo.findOne({ where: { teamId, driverId } }); + return entity ? this.mapper.toDomainMembership(entity) : null; + } + + async getActiveMembershipForDriver(driverId: string): Promise { + const entity = await this.membershipRepo.findOne({ where: { driverId, status: 'active' } }); + return entity ? this.mapper.toDomainMembership(entity) : null; + } + + async getTeamMembers(teamId: string): Promise { + const entities = await this.membershipRepo.find({ where: { teamId, status: 'active' } }); + return entities.map((e) => this.mapper.toDomainMembership(e)); + } + + async saveMembership(membership: TeamMembership): Promise { + await this.membershipRepo.save(this.mapper.toOrmMembership(membership)); + return membership; + } + + async removeMembership(teamId: string, driverId: string): Promise { + await this.membershipRepo.delete({ teamId, driverId }); + } + + async countByTeamId(teamId: string): Promise { + return this.membershipRepo.count({ where: { teamId, status: 'active' } }); + } + + async getJoinRequests(teamId: string): Promise { + const entities = await this.joinRequestRepo.find({ where: { teamId } }); + return entities.map((e) => this.mapper.toDomainJoinRequest(e)); + } + + async saveJoinRequest(request: TeamJoinRequest): Promise { + await this.joinRequestRepo.save(this.mapper.toOrmJoinRequest(request)); + return request; + } + + async removeJoinRequest(requestId: string): Promise { + await this.joinRequestRepo.delete({ id: requestId }); + } +} \ No newline at end of file diff --git a/adapters/racing/persistence/typeorm/repositories/TypeOrmDriverRepository.test.ts b/adapters/racing/persistence/typeorm/repositories/TypeOrmDriverRepository.test.ts new file mode 100644 index 000000000..30d529c93 --- /dev/null +++ b/adapters/racing/persistence/typeorm/repositories/TypeOrmDriverRepository.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; + +import type { DataSource } from 'typeorm'; + +import { TypeOrmDriverRepository } from './TypeOrmDriverRepository'; +import { DriverOrmMapper } from '../mappers/DriverOrmMapper'; + +describe('TypeOrmDriverRepository', () => { + it('constructor requires injected mapper (no internal mapper instantiation)', () => { + const dataSource = {} as unknown as DataSource; + const mapper = {} as unknown as DriverOrmMapper; + + const repo = new TypeOrmDriverRepository(dataSource, mapper); + + expect(repo).toBeInstanceOf(TypeOrmDriverRepository); + expect((repo as unknown as { mapper: unknown }).mapper).toBe(mapper); + }); + + it('works with mocked TypeORM DataSource (no DB required)', async () => { + const findOne = async () => null; + + const dataSource = { + getRepository: () => ({ findOne }), + } as unknown as DataSource; + + const mapper = { + toDomain: () => { + throw new Error('should-not-be-called'); + }, + } as unknown as DriverOrmMapper; + + const repo = new TypeOrmDriverRepository(dataSource, mapper); + + await expect(repo.findById('driver-1')).resolves.toBeNull(); + }); +}); \ No newline at end of file diff --git a/adapters/racing/persistence/typeorm/repositories/TypeOrmDriverRepository.ts b/adapters/racing/persistence/typeorm/repositories/TypeOrmDriverRepository.ts new file mode 100644 index 000000000..cb8391a5c --- /dev/null +++ b/adapters/racing/persistence/typeorm/repositories/TypeOrmDriverRepository.ts @@ -0,0 +1,61 @@ +import type { DataSource } from 'typeorm'; + +import type { IDriverRepository } from '@core/racing/domain/repositories/IDriverRepository'; +import type { Driver } from '@core/racing/domain/entities/Driver'; + +import { DriverOrmEntity } from '../entities/DriverOrmEntity'; +import { DriverOrmMapper } from '../mappers/DriverOrmMapper'; + +export class TypeOrmDriverRepository implements IDriverRepository { + constructor( + private readonly dataSource: DataSource, + private readonly mapper: DriverOrmMapper, + ) {} + + async findById(id: string): Promise { + const repo = this.dataSource.getRepository(DriverOrmEntity); + const entity = await repo.findOne({ where: { id } }); + return entity ? this.mapper.toDomain(entity) : null; + } + + async findByIRacingId(iracingId: string): Promise { + const repo = this.dataSource.getRepository(DriverOrmEntity); + const entity = await repo.findOne({ where: { iracingId } }); + return entity ? this.mapper.toDomain(entity) : null; + } + + async findAll(): Promise { + const repo = this.dataSource.getRepository(DriverOrmEntity); + const entities = await repo.find(); + return entities.map((e) => this.mapper.toDomain(e)); + } + + async create(driver: Driver): Promise { + const repo = this.dataSource.getRepository(DriverOrmEntity); + await repo.save(this.mapper.toOrmEntity(driver)); + return driver; + } + + async update(driver: Driver): Promise { + const repo = this.dataSource.getRepository(DriverOrmEntity); + await repo.save(this.mapper.toOrmEntity(driver)); + return driver; + } + + async delete(id: string): Promise { + const repo = this.dataSource.getRepository(DriverOrmEntity); + await repo.delete({ id }); + } + + async exists(id: string): Promise { + const repo = this.dataSource.getRepository(DriverOrmEntity); + const count = await repo.count({ where: { id } }); + return count > 0; + } + + async existsByIRacingId(iracingId: string): Promise { + const repo = this.dataSource.getRepository(DriverOrmEntity); + const count = await repo.count({ where: { iracingId } }); + return count > 0; + } +} \ No newline at end of file diff --git a/adapters/racing/persistence/typeorm/repositories/TypeOrmLeagueMembershipRepository.test.ts b/adapters/racing/persistence/typeorm/repositories/TypeOrmLeagueMembershipRepository.test.ts new file mode 100644 index 000000000..58df5a8d8 --- /dev/null +++ b/adapters/racing/persistence/typeorm/repositories/TypeOrmLeagueMembershipRepository.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; + +import type { DataSource } from 'typeorm'; + +import { TypeOrmLeagueMembershipRepository } from './TypeOrmLeagueMembershipRepository'; +import { LeagueMembershipOrmMapper } from '../mappers/LeagueMembershipOrmMapper'; + +describe('TypeOrmLeagueMembershipRepository', () => { + it('constructor requires injected mapper (no internal mapper instantiation)', () => { + const dataSource = {} as unknown as DataSource; + const mapper = {} as unknown as LeagueMembershipOrmMapper; + + const repo = new TypeOrmLeagueMembershipRepository(dataSource, mapper); + + expect(repo).toBeInstanceOf(TypeOrmLeagueMembershipRepository); + expect((repo as unknown as { mapper: unknown }).mapper).toBe(mapper); + }); + + it('works with mocked TypeORM DataSource (no DB required)', async () => { + const getMembershipFindOne = async () => null; + + const dataSource = { + getRepository: () => ({ findOne: getMembershipFindOne }), + } as unknown as DataSource; + + const mapper = { + toDomain: () => { + throw new Error('should-not-be-called'); + }, + } as unknown as LeagueMembershipOrmMapper; + + const repo = new TypeOrmLeagueMembershipRepository(dataSource, mapper); + + await expect(repo.getMembership('league-1', 'driver-1')).resolves.toBeNull(); + }); +}); \ No newline at end of file diff --git a/adapters/racing/persistence/typeorm/repositories/TypeOrmLeagueMembershipRepository.ts b/adapters/racing/persistence/typeorm/repositories/TypeOrmLeagueMembershipRepository.ts new file mode 100644 index 000000000..488a03dda --- /dev/null +++ b/adapters/racing/persistence/typeorm/repositories/TypeOrmLeagueMembershipRepository.ts @@ -0,0 +1,76 @@ +import type { DataSource } from 'typeorm'; + +import type { ILeagueMembershipRepository } from '@core/racing/domain/repositories/ILeagueMembershipRepository'; +import type { LeagueMembership } from '@core/racing/domain/entities/LeagueMembership'; +import { JoinRequest } from '@core/racing/domain/entities/JoinRequest'; + +import { LeagueMembershipOrmEntity } from '../entities/LeagueMembershipOrmEntity'; +import { LeagueMembershipOrmMapper } from '../mappers/LeagueMembershipOrmMapper'; + +export class TypeOrmLeagueMembershipRepository implements ILeagueMembershipRepository { + constructor( + private readonly dataSource: DataSource, + private readonly mapper: LeagueMembershipOrmMapper, + ) {} + + async getMembership(leagueId: string, driverId: string): Promise { + const repo = this.dataSource.getRepository(LeagueMembershipOrmEntity); + const id = `${leagueId}:${driverId}`; + const entity = await repo.findOne({ where: { id } }); + if (!entity) return null; + if (entity.status !== 'active' && entity.status !== 'inactive') return null; + return this.mapper.toDomain(entity); + } + + async getLeagueMembers(leagueId: string): Promise { + const repo = this.dataSource.getRepository(LeagueMembershipOrmEntity); + const entities = await repo.find({ where: { leagueId, status: 'active' } }); + return entities.map((e) => this.mapper.toDomain(e)); + } + + async getJoinRequests(leagueId: string): Promise { + const repo = this.dataSource.getRepository(LeagueMembershipOrmEntity); + const entities = await repo.find({ where: { leagueId, status: 'pending' } }); + + return entities.map((e) => + JoinRequest.rehydrate({ + id: e.id, + leagueId: e.leagueId, + driverId: e.driverId, + requestedAt: e.joinedAt, + }), + ); + } + + async saveMembership(membership: LeagueMembership): Promise { + const repo = this.dataSource.getRepository(LeagueMembershipOrmEntity); + await repo.save(this.mapper.toOrmEntity(membership)); + return membership; + } + + async removeMembership(leagueId: string, driverId: string): Promise { + const repo = this.dataSource.getRepository(LeagueMembershipOrmEntity); + const id = `${leagueId}:${driverId}`; + await repo.delete({ id }); + } + + async saveJoinRequest(request: JoinRequest): Promise { + const repo = this.dataSource.getRepository(LeagueMembershipOrmEntity); + + const entity = new LeagueMembershipOrmEntity(); + entity.id = request.id; + entity.leagueId = request.leagueId.toString(); + entity.driverId = request.driverId.toString(); + entity.role = 'member'; + entity.status = 'pending'; + entity.joinedAt = request.requestedAt.toDate(); + + await repo.save(entity); + return request; + } + + async removeJoinRequest(requestId: string): Promise { + const repo = this.dataSource.getRepository(LeagueMembershipOrmEntity); + await repo.delete({ id: requestId }); + } +} \ No newline at end of file diff --git a/adapters/racing/persistence/typeorm/repositories/TypeOrmRaceRegistrationRepository.test.ts b/adapters/racing/persistence/typeorm/repositories/TypeOrmRaceRegistrationRepository.test.ts new file mode 100644 index 000000000..9444e18d1 --- /dev/null +++ b/adapters/racing/persistence/typeorm/repositories/TypeOrmRaceRegistrationRepository.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; + +import type { DataSource } from 'typeorm'; + +import { TypeOrmRaceRegistrationRepository } from './TypeOrmRaceRegistrationRepository'; +import { RaceRegistrationOrmMapper } from '../mappers/RaceRegistrationOrmMapper'; + +describe('TypeOrmRaceRegistrationRepository', () => { + it('constructor requires injected mapper (no internal mapper instantiation)', () => { + const dataSource = {} as unknown as DataSource; + const mapper = {} as unknown as RaceRegistrationOrmMapper; + + const repo = new TypeOrmRaceRegistrationRepository(dataSource, mapper); + + expect(repo).toBeInstanceOf(TypeOrmRaceRegistrationRepository); + expect((repo as unknown as { mapper: unknown }).mapper).toBe(mapper); + }); + + it('works with mocked TypeORM DataSource (no DB required)', async () => { + const count = async () => 0; + + const dataSource = { + getRepository: () => ({ count }), + } as unknown as DataSource; + + const mapper = { + toDomain: () => { + throw new Error('should-not-be-called'); + }, + } as unknown as RaceRegistrationOrmMapper; + + const repo = new TypeOrmRaceRegistrationRepository(dataSource, mapper); + + await expect(repo.getRegistrationCount('race-1')).resolves.toBe(0); + }); +}); \ No newline at end of file diff --git a/adapters/racing/persistence/typeorm/repositories/TypeOrmRaceRegistrationRepository.ts b/adapters/racing/persistence/typeorm/repositories/TypeOrmRaceRegistrationRepository.ts new file mode 100644 index 000000000..92dcee5a2 --- /dev/null +++ b/adapters/racing/persistence/typeorm/repositories/TypeOrmRaceRegistrationRepository.ts @@ -0,0 +1,58 @@ +import type { DataSource } from 'typeorm'; + +import type { IRaceRegistrationRepository } from '@core/racing/domain/repositories/IRaceRegistrationRepository'; +import type { RaceRegistration } from '@core/racing/domain/entities/RaceRegistration'; + +import { RaceRegistrationOrmEntity } from '../entities/RaceRegistrationOrmEntity'; +import { RaceRegistrationOrmMapper } from '../mappers/RaceRegistrationOrmMapper'; + +export class TypeOrmRaceRegistrationRepository implements IRaceRegistrationRepository { + constructor( + private readonly dataSource: DataSource, + private readonly mapper: RaceRegistrationOrmMapper, + ) {} + + async isRegistered(raceId: string, driverId: string): Promise { + const repo = this.dataSource.getRepository(RaceRegistrationOrmEntity); + const count = await repo.count({ where: { raceId, driverId } }); + return count > 0; + } + + async getRegisteredDrivers(raceId: string): Promise { + const repo = this.dataSource.getRepository(RaceRegistrationOrmEntity); + const entities = await repo.find({ where: { raceId } }); + return entities.map((e) => e.driverId); + } + + async findByRaceId(raceId: string): Promise { + const repo = this.dataSource.getRepository(RaceRegistrationOrmEntity); + const entities = await repo.find({ where: { raceId } }); + return entities.map((e) => this.mapper.toDomain(e)); + } + + async getRegistrationCount(raceId: string): Promise { + const repo = this.dataSource.getRepository(RaceRegistrationOrmEntity); + return repo.count({ where: { raceId } }); + } + + async register(registration: RaceRegistration): Promise { + const repo = this.dataSource.getRepository(RaceRegistrationOrmEntity); + await repo.save(this.mapper.toOrmEntity(registration)); + } + + async withdraw(raceId: string, driverId: string): Promise { + const repo = this.dataSource.getRepository(RaceRegistrationOrmEntity); + await repo.delete({ raceId, driverId }); + } + + async getDriverRegistrations(driverId: string): Promise { + const repo = this.dataSource.getRepository(RaceRegistrationOrmEntity); + const entities = await repo.find({ where: { driverId } }); + return entities.map((e) => e.raceId); + } + + async clearRaceRegistrations(raceId: string): Promise { + const repo = this.dataSource.getRepository(RaceRegistrationOrmEntity); + await repo.delete({ raceId }); + } +} \ No newline at end of file diff --git a/adapters/racing/persistence/typeorm/repositories/TypeOrmResultRepository.test.ts b/adapters/racing/persistence/typeorm/repositories/TypeOrmResultRepository.test.ts new file mode 100644 index 000000000..38106aff3 --- /dev/null +++ b/adapters/racing/persistence/typeorm/repositories/TypeOrmResultRepository.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { DataSource } from 'typeorm'; + +import { TypeOrmResultRepository } from './TypeOrmResultRepository'; +import { ResultOrmMapper } from '../mappers/ResultOrmMapper'; + +describe('TypeOrmResultRepository', () => { + it('requires an injected mapper (does not construct one internally)', () => { + const dataSource = {} as DataSource; + const mapper = new ResultOrmMapper(); + + expect(() => new TypeOrmResultRepository(dataSource, mapper)).not.toThrow(); + }); + + it('uses TypeORM DataSource.getRepository (DB-free mocked repository)', async () => { + const mapper = new ResultOrmMapper(); + + const repoMock = { + findOne: vi.fn(async () => null), + }; + + const dataSource = { + getRepository: vi.fn(() => repoMock), + } as unknown as DataSource; + + const repo = new TypeOrmResultRepository(dataSource, mapper); + + await repo.findById('result-1'); + + expect(dataSource.getRepository).toHaveBeenCalled(); + expect(repoMock.findOne).toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/adapters/racing/persistence/typeorm/repositories/TypeOrmResultRepository.ts b/adapters/racing/persistence/typeorm/repositories/TypeOrmResultRepository.ts new file mode 100644 index 000000000..925e81ad4 --- /dev/null +++ b/adapters/racing/persistence/typeorm/repositories/TypeOrmResultRepository.ts @@ -0,0 +1,94 @@ +import type { DataSource } from 'typeorm'; +import { In } from 'typeorm'; + +import type { IResultRepository } from '@core/racing/domain/repositories/IResultRepository'; +import type { Result } from '@core/racing/domain/entities/result/Result'; + +import { RaceOrmEntity } from '../entities/RaceOrmEntity'; +import { ResultOrmEntity } from '../entities/ResultOrmEntity'; +import { ResultOrmMapper } from '../mappers/ResultOrmMapper'; + +export class TypeOrmResultRepository implements IResultRepository { + constructor( + private readonly dataSource: DataSource, + private readonly mapper: ResultOrmMapper, + ) {} + + async findById(id: string): Promise { + const repo = this.dataSource.getRepository(ResultOrmEntity); + const entity = await repo.findOne({ where: { id } }); + return entity ? this.mapper.toDomain(entity) : null; + } + + async findAll(): Promise { + const repo = this.dataSource.getRepository(ResultOrmEntity); + const entities = await repo.find(); + return entities.map((e) => this.mapper.toDomain(e)); + } + + async findByRaceId(raceId: string): Promise { + const repo = this.dataSource.getRepository(ResultOrmEntity); + const entities = await repo.find({ where: { raceId }, order: { position: 'ASC' } }); + return entities.map((e) => this.mapper.toDomain(e)); + } + + async findByDriverId(driverId: string): Promise { + const repo = this.dataSource.getRepository(ResultOrmEntity); + const entities = await repo.find({ where: { driverId } }); + return entities.map((e) => this.mapper.toDomain(e)); + } + + async findByDriverIdAndLeagueId(driverId: string, leagueId: string): Promise { + const raceRepo = this.dataSource.getRepository(RaceOrmEntity); + const races = await raceRepo.find({ where: { leagueId }, select: { id: true } }); + const raceIds = races.map((r) => r.id); + + if (raceIds.length === 0) { + return []; + } + + const resultRepo = this.dataSource.getRepository(ResultOrmEntity); + const entities = await resultRepo.find({ where: { driverId, raceId: In(raceIds) } }); + return entities.map((e) => this.mapper.toDomain(e)); + } + + async create(result: Result): Promise { + const repo = this.dataSource.getRepository(ResultOrmEntity); + await repo.save(this.mapper.toOrmEntity(result)); + return result; + } + + async createMany(results: Result[]): Promise { + const repo = this.dataSource.getRepository(ResultOrmEntity); + await repo.save(results.map((r) => this.mapper.toOrmEntity(r))); + return results; + } + + async update(result: Result): Promise { + const repo = this.dataSource.getRepository(ResultOrmEntity); + await repo.save(this.mapper.toOrmEntity(result)); + return result; + } + + async delete(id: string): Promise { + const repo = this.dataSource.getRepository(ResultOrmEntity); + await repo.delete({ id }); + } + + async deleteByRaceId(raceId: string): Promise { + const repo = this.dataSource.getRepository(ResultOrmEntity); + await repo.delete({ raceId }); + } + + async exists(id: string): Promise { + const repo = this.dataSource.getRepository(ResultOrmEntity); + const count = await repo.count({ where: { id } }); + return count > 0; + } + + async existsByRaceId(raceId: string): Promise { + const repo = this.dataSource.getRepository(ResultOrmEntity); + const count = await repo.count({ where: { raceId } }); + return count > 0; + } +} \ No newline at end of file diff --git a/adapters/racing/persistence/typeorm/repositories/TypeOrmStandingRepository.test.ts b/adapters/racing/persistence/typeorm/repositories/TypeOrmStandingRepository.test.ts new file mode 100644 index 000000000..08840da45 --- /dev/null +++ b/adapters/racing/persistence/typeorm/repositories/TypeOrmStandingRepository.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { DataSource } from 'typeorm'; + +import { TypeOrmStandingRepository } from './TypeOrmStandingRepository'; +import { LeagueOrmMapper } from '../mappers/LeagueOrmMapper'; +import { ResultOrmMapper } from '../mappers/ResultOrmMapper'; +import { StandingOrmMapper } from '../mappers/StandingOrmMapper'; + +describe('TypeOrmStandingRepository', () => { + it('requires injected mappers (does not construct any internally)', () => { + const dataSource = {} as DataSource; + + const standingMapper = new StandingOrmMapper(); + const resultMapper = new ResultOrmMapper(); + const leagueMapper = new LeagueOrmMapper(); + const pointsSystems: Record> = {}; + + expect(() => new TypeOrmStandingRepository(dataSource, standingMapper, resultMapper, leagueMapper, pointsSystems)).not.toThrow(); + }); + + it('uses TypeORM DataSource.getRepository (DB-free mocked repository)', async () => { + const standingMapper = new StandingOrmMapper(); + const resultMapper = new ResultOrmMapper(); + const leagueMapper = new LeagueOrmMapper(); + const pointsSystems: Record> = {}; + + const standingsRepoMock = { + find: vi.fn(async () => []), + }; + const resultsRepoMock = { + find: vi.fn(async () => []), + }; + + const dataSource = { + getRepository: vi.fn((entity: unknown) => { + // Different repos per entity class; this mimics TypeORM behavior enough for constructor wiring tests. + if (typeof entity === 'function' && (entity as { name?: string }).name === 'StandingOrmEntity') { + return standingsRepoMock; + } + return resultsRepoMock; + }), + } as unknown as DataSource; + + const repo = new TypeOrmStandingRepository(dataSource, standingMapper, resultMapper, leagueMapper, pointsSystems); + + await repo.findByLeagueId('league-1'); + + expect(dataSource.getRepository).toHaveBeenCalled(); + expect(standingsRepoMock.find).toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/adapters/racing/persistence/typeorm/repositories/TypeOrmStandingRepository.ts b/adapters/racing/persistence/typeorm/repositories/TypeOrmStandingRepository.ts new file mode 100644 index 000000000..ae5562894 --- /dev/null +++ b/adapters/racing/persistence/typeorm/repositories/TypeOrmStandingRepository.ts @@ -0,0 +1,134 @@ +import type { DataSource } from 'typeorm'; +import { In } from 'typeorm'; + +import { Standing } from '@core/racing/domain/entities/Standing'; +import type { IStandingRepository } from '@core/racing/domain/repositories/IStandingRepository'; + +import { LeagueOrmEntity } from '../entities/LeagueOrmEntity'; +import { RaceOrmEntity } from '../entities/RaceOrmEntity'; +import { ResultOrmEntity } from '../entities/ResultOrmEntity'; +import { StandingOrmEntity } from '../entities/StandingOrmEntity'; +import { LeagueOrmMapper } from '../mappers/LeagueOrmMapper'; +import { ResultOrmMapper } from '../mappers/ResultOrmMapper'; +import { StandingOrmMapper } from '../mappers/StandingOrmMapper'; + +export class TypeOrmStandingRepository implements IStandingRepository { + constructor( + private readonly dataSource: DataSource, + private readonly standingMapper: StandingOrmMapper, + private readonly resultMapper: ResultOrmMapper, + private readonly leagueMapper: LeagueOrmMapper, + private readonly pointsSystems: Record>, + ) {} + + async findByLeagueId(leagueId: string): Promise { + const repo = this.dataSource.getRepository(StandingOrmEntity); + const entities = await repo.find({ where: { leagueId }, order: { position: 'ASC' } }); + return entities.map((e) => this.standingMapper.toDomain(e)); + } + + async findByDriverIdAndLeagueId(driverId: string, leagueId: string): Promise { + const repo = this.dataSource.getRepository(StandingOrmEntity); + const entity = await repo.findOne({ where: { leagueId, driverId } }); + return entity ? this.standingMapper.toDomain(entity) : null; + } + + async findAll(): Promise { + const repo = this.dataSource.getRepository(StandingOrmEntity); + const entities = await repo.find(); + return entities.map((e) => this.standingMapper.toDomain(e)); + } + + async save(standing: Standing): Promise { + const repo = this.dataSource.getRepository(StandingOrmEntity); + await repo.save(this.standingMapper.toOrmEntity(standing)); + return standing; + } + + async saveMany(standings: Standing[]): Promise { + const repo = this.dataSource.getRepository(StandingOrmEntity); + await repo.save(standings.map((s) => this.standingMapper.toOrmEntity(s))); + return standings; + } + + async delete(leagueId: string, driverId: string): Promise { + const repo = this.dataSource.getRepository(StandingOrmEntity); + await repo.delete({ leagueId, driverId }); + } + + async deleteByLeagueId(leagueId: string): Promise { + const repo = this.dataSource.getRepository(StandingOrmEntity); + await repo.delete({ leagueId }); + } + + async exists(leagueId: string, driverId: string): Promise { + const repo = this.dataSource.getRepository(StandingOrmEntity); + const count = await repo.count({ where: { leagueId, driverId } }); + return count > 0; + } + + async recalculate(leagueId: string): Promise { + const leagueRepo = this.dataSource.getRepository(LeagueOrmEntity); + const leagueEntity = await leagueRepo.findOne({ where: { id: leagueId } }); + + if (!leagueEntity) { + throw new Error(`League with ID ${leagueId} not found`); + } + + const league = this.leagueMapper.toDomain(leagueEntity); + + const resolvedPointsSystem = + league.settings.customPoints ?? + this.pointsSystems[league.settings.pointsSystem] ?? + this.pointsSystems['f1-2024']; + + if (!resolvedPointsSystem) { + throw new Error('No points system configured for league'); + } + + const raceRepo = this.dataSource.getRepository(RaceOrmEntity); + const races = await raceRepo.find({ where: { leagueId, status: 'completed' }, select: { id: true } }); + const raceIds = races.map((r) => r.id); + + if (raceIds.length === 0) { + await this.deleteByLeagueId(leagueId); + return []; + } + + const resultRepo = this.dataSource.getRepository(ResultOrmEntity); + const resultEntities = await resultRepo.find({ + where: { raceId: In(raceIds) }, + order: { position: 'ASC' }, + }); + + const standingsByDriver = new Map(); + + for (const entity of resultEntities) { + const result = this.resultMapper.toDomain(entity); + const driverId = result.driverId.toString(); + + const current = + standingsByDriver.get(driverId) ?? + Standing.create({ + leagueId, + driverId, + }); + + const next = current.addRaceResult(result.position.toNumber(), resolvedPointsSystem); + standingsByDriver.set(driverId, next); + } + + const sorted = Array.from(standingsByDriver.values()).sort((a, b) => { + if (b.points.toNumber() !== a.points.toNumber()) return b.points.toNumber() - a.points.toNumber(); + if (b.wins !== a.wins) return b.wins - a.wins; + return b.racesCompleted - a.racesCompleted; + }); + + const updated = sorted.map((standing, index) => standing.updatePosition(index + 1)); + + await this.deleteByLeagueId(leagueId); + await this.saveMany(updated); + + return updated; + } +} \ No newline at end of file diff --git a/adapters/racing/persistence/typeorm/schema/TypeOrmSchemaGuards.test.ts b/adapters/racing/persistence/typeorm/schema/TypeOrmSchemaGuards.test.ts new file mode 100644 index 000000000..73eed1f6f --- /dev/null +++ b/adapters/racing/persistence/typeorm/schema/TypeOrmSchemaGuards.test.ts @@ -0,0 +1,204 @@ +import { describe, expect, it } from 'vitest'; + +import { TypeOrmPersistenceSchemaError } from '../errors/TypeOrmPersistenceSchemaError'; +import { + assertArray, + assertBoolean, + assertDate, + assertEnumValue, + assertInteger, + assertIsoDate, + assertNonEmptyString, + assertNumber, + assertOptionalBoolean, + assertOptionalInteger, + assertOptionalNumber, + assertOptionalStringOrNull, + assertRecord, +} from './TypeOrmSchemaGuards'; + +describe('TypeOrmSchemaGuards', () => { + it('assertNonEmptyString accepts non-empty strings', () => { + expect(() => assertNonEmptyString('Driver', 'name', ' Max ')).not.toThrow(); + }); + + it('assertNonEmptyString throws TypeOrmPersistenceSchemaError with metadata', () => { + try { + assertNonEmptyString('Driver', 'name', ''); + throw new Error('expected-to-throw'); + } catch (error) { + expect(error).toBeInstanceOf(TypeOrmPersistenceSchemaError); + expect(error).toMatchObject({ + name: 'TypeOrmPersistenceSchemaError', + entityName: 'Driver', + fieldName: 'name', + reason: 'empty_string', + }); + } + }); + + it('assertIsoDate accepts ISO string with exact toISOString match', () => { + const value = '2025-01-01T00:00:00.000Z'; + expect(() => assertIsoDate('SeasonSchedule', 'startDate', value)).not.toThrow(); + }); + + it('assertIsoDate rejects non-ISO / non-normalized values', () => { + try { + assertIsoDate('SeasonSchedule', 'startDate', '2025-01-01'); + throw new Error('expected-to-throw'); + } catch (error) { + expect(error).toBeInstanceOf(TypeOrmPersistenceSchemaError); + expect(error).toMatchObject({ + entityName: 'SeasonSchedule', + fieldName: 'startDate', + reason: 'not_iso_date', + }); + } + }); + + it('assertDate accepts valid Date', () => { + expect(() => assertDate('Driver', 'joinedAt', new Date())).not.toThrow(); + }); + + it('assertDate rejects invalid Date objects', () => { + try { + assertDate('Driver', 'joinedAt', new Date('bad-date')); + throw new Error('expected-to-throw'); + } catch (error) { + expect(error).toBeInstanceOf(TypeOrmPersistenceSchemaError); + expect(error).toMatchObject({ + entityName: 'Driver', + fieldName: 'joinedAt', + reason: 'invalid_date', + }); + } + }); + + it('assertEnumValue accepts allowed enum strings', () => { + expect(() => assertEnumValue('Race', 'status', 'scheduled', ['scheduled', 'running'] as const)).not.toThrow(); + }); + + it('assertEnumValue rejects disallowed enum strings', () => { + try { + assertEnumValue('Race', 'status', 'completed', ['scheduled', 'running'] as const); + throw new Error('expected-to-throw'); + } catch (error) { + expect(error).toBeInstanceOf(TypeOrmPersistenceSchemaError); + expect(error).toMatchObject({ + entityName: 'Race', + fieldName: 'status', + reason: 'invalid_enum_value', + }); + } + }); + + it('assertArray accepts arrays and rejects non-arrays', () => { + expect(() => assertArray('LeagueScoringConfig', 'championships', [])).not.toThrow(); + + try { + assertArray('LeagueScoringConfig', 'championships', {}); + throw new Error('expected-to-throw'); + } catch (error) { + expect(error).toBeInstanceOf(TypeOrmPersistenceSchemaError); + expect(error).toMatchObject({ + entityName: 'LeagueScoringConfig', + fieldName: 'championships', + reason: 'not_array', + }); + } + }); + + it('assertRecord accepts plain objects and rejects arrays/null', () => { + expect(() => assertRecord('League', 'settings', { a: 1 })).not.toThrow(); + + try { + assertRecord('League', 'settings', null); + throw new Error('expected-to-throw'); + } catch (error) { + expect(error).toBeInstanceOf(TypeOrmPersistenceSchemaError); + expect(error).toMatchObject({ + entityName: 'League', + fieldName: 'settings', + reason: 'not_object', + }); + } + }); + + it('assertNumber/assertInteger/assertBoolean accept correct primitives and reject others', () => { + expect(() => assertNumber('League', 'settings.maxDrivers', 12.5)).not.toThrow(); + expect(() => assertInteger('League', 'settings.maxDrivers', 12)).not.toThrow(); + expect(() => assertBoolean('League', 'settings.requireDefense', true)).not.toThrow(); + + try { + assertNumber('League', 'settings.maxDrivers', '12'); + throw new Error('expected-to-throw'); + } catch (error) { + expect(error).toBeInstanceOf(TypeOrmPersistenceSchemaError); + expect(error).toMatchObject({ + entityName: 'League', + fieldName: 'settings.maxDrivers', + reason: 'not_number', + }); + } + + try { + assertInteger('League', 'settings.maxDrivers', 12.5); + throw new Error('expected-to-throw'); + } catch (error) { + expect(error).toBeInstanceOf(TypeOrmPersistenceSchemaError); + expect(error).toMatchObject({ + entityName: 'League', + fieldName: 'settings.maxDrivers', + reason: 'not_integer', + }); + } + + try { + assertBoolean('League', 'settings.requireDefense', 1); + throw new Error('expected-to-throw'); + } catch (error) { + expect(error).toBeInstanceOf(TypeOrmPersistenceSchemaError); + expect(error).toMatchObject({ + entityName: 'League', + fieldName: 'settings.requireDefense', + reason: 'not_boolean', + }); + } + }); + + it('assertOptionalNumber/assertOptionalInteger/assertOptionalBoolean accept null/undefined and validate values when present', () => { + expect(() => assertOptionalNumber('League', 'settings.sessionDuration', null)).not.toThrow(); + expect(() => assertOptionalInteger('League', 'settings.maxDrivers', undefined)).not.toThrow(); + expect(() => assertOptionalBoolean('League', 'settings.requireDefense', null)).not.toThrow(); + + try { + assertOptionalInteger('League', 'settings.maxDrivers', 12.5); + throw new Error('expected-to-throw'); + } catch (error) { + expect(error).toBeInstanceOf(TypeOrmPersistenceSchemaError); + expect(error).toMatchObject({ + entityName: 'League', + fieldName: 'settings.maxDrivers', + reason: 'not_integer', + }); + } + }); + + it('assertOptionalStringOrNull accepts undefined/null/string and rejects others', () => { + expect(() => assertOptionalStringOrNull('Driver', 'bio', undefined)).not.toThrow(); + expect(() => assertOptionalStringOrNull('Driver', 'bio', null)).not.toThrow(); + expect(() => assertOptionalStringOrNull('Driver', 'bio', 'Bio')).not.toThrow(); + + try { + assertOptionalStringOrNull('Driver', 'bio', 123); + throw new Error('expected-to-throw'); + } catch (error) { + expect(error).toBeInstanceOf(TypeOrmPersistenceSchemaError); + expect(error).toMatchObject({ + entityName: 'Driver', + fieldName: 'bio', + reason: 'not_string', + }); + } + }); +}); \ No newline at end of file diff --git a/adapters/racing/persistence/typeorm/schema/TypeOrmSchemaGuards.ts b/adapters/racing/persistence/typeorm/schema/TypeOrmSchemaGuards.ts new file mode 100644 index 000000000..6db72481a --- /dev/null +++ b/adapters/racing/persistence/typeorm/schema/TypeOrmSchemaGuards.ts @@ -0,0 +1,130 @@ +import { TypeOrmPersistenceSchemaError } from '../errors/TypeOrmPersistenceSchemaError'; + +export function assertNonEmptyString(entityName: string, fieldName: string, value: unknown): asserts value is string { + if (typeof value !== 'string') { + throw new TypeOrmPersistenceSchemaError({ entityName, fieldName, reason: 'not_string' }); + } + + if (value.trim().length === 0) { + throw new TypeOrmPersistenceSchemaError({ entityName, fieldName, reason: 'empty_string' }); + } +} + +export function assertIsoDate(entityName: string, fieldName: string, value: unknown): asserts value is string { + if (typeof value !== 'string') { + throw new TypeOrmPersistenceSchemaError({ entityName, fieldName, reason: 'not_string' }); + } + + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) { + throw new TypeOrmPersistenceSchemaError({ entityName, fieldName, reason: 'not_iso_date' }); + } + + if (parsed.toISOString() !== value) { + throw new TypeOrmPersistenceSchemaError({ entityName, fieldName, reason: 'not_iso_date' }); + } +} + +export function assertDate(entityName: string, fieldName: string, value: unknown): asserts value is Date { + if (!(value instanceof Date)) { + throw new TypeOrmPersistenceSchemaError({ entityName, fieldName, reason: 'not_date' }); + } + if (Number.isNaN(value.getTime())) { + throw new TypeOrmPersistenceSchemaError({ entityName, fieldName, reason: 'invalid_date' }); + } +} + +export function assertEnumValue( + entityName: string, + fieldName: string, + value: unknown, + allowed: readonly TAllowed[], +): asserts value is TAllowed { + if (typeof value !== 'string') { + throw new TypeOrmPersistenceSchemaError({ entityName, fieldName, reason: 'not_string' }); + } + + if (!allowed.includes(value as TAllowed)) { + throw new TypeOrmPersistenceSchemaError({ entityName, fieldName, reason: 'invalid_enum_value' }); + } +} + +export function assertArray(entityName: string, fieldName: string, value: unknown): asserts value is unknown[] { + if (!Array.isArray(value)) { + throw new TypeOrmPersistenceSchemaError({ entityName, fieldName, reason: 'not_array' }); + } +} + +export function assertNumber(entityName: string, fieldName: string, value: unknown): asserts value is number { + if (typeof value !== 'number' || Number.isNaN(value)) { + throw new TypeOrmPersistenceSchemaError({ entityName, fieldName, reason: 'not_number' }); + } +} + +export function assertInteger(entityName: string, fieldName: string, value: unknown): asserts value is number { + if (typeof value !== 'number' || !Number.isInteger(value)) { + throw new TypeOrmPersistenceSchemaError({ entityName, fieldName, reason: 'not_integer' }); + } +} + +export function assertBoolean(entityName: string, fieldName: string, value: unknown): asserts value is boolean { + if (typeof value !== 'boolean') { + throw new TypeOrmPersistenceSchemaError({ entityName, fieldName, reason: 'not_boolean' }); + } +} + +export function assertOptionalNumber( + entityName: string, + fieldName: string, + value: unknown, +): asserts value is number | null | undefined { + if (value === null || value === undefined) { + return; + } + + assertNumber(entityName, fieldName, value); +} + +export function assertOptionalInteger( + entityName: string, + fieldName: string, + value: unknown, +): asserts value is number | null | undefined { + if (value === null || value === undefined) { + return; + } + + assertInteger(entityName, fieldName, value); +} + +export function assertOptionalBoolean( + entityName: string, + fieldName: string, + value: unknown, +): asserts value is boolean | null | undefined { + if (value === null || value === undefined) { + return; + } + + assertBoolean(entityName, fieldName, value); +} + +export function assertRecord(entityName: string, fieldName: string, value: unknown): asserts value is Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new TypeOrmPersistenceSchemaError({ entityName, fieldName, reason: 'not_object' }); + } +} + +export function assertOptionalStringOrNull( + entityName: string, + fieldName: string, + value: unknown, +): asserts value is string | null | undefined { + if (value === null || value === undefined) { + return; + } + + if (typeof value !== 'string') { + throw new TypeOrmPersistenceSchemaError({ entityName, fieldName, reason: 'not_string' }); + } +} \ No newline at end of file diff --git a/adapters/social/persistence/typeorm/entities/FeedItemOrmEntity.ts b/adapters/social/persistence/typeorm/entities/FeedItemOrmEntity.ts new file mode 100644 index 000000000..e1cf3ec04 --- /dev/null +++ b/adapters/social/persistence/typeorm/entities/FeedItemOrmEntity.ts @@ -0,0 +1,45 @@ +import { Column, Entity, PrimaryColumn } from 'typeorm'; + +import type { FeedItemType } from '@core/social/domain/types/FeedItemType'; + +@Entity({ name: 'social_feed_items' }) +export class FeedItemOrmEntity { + @PrimaryColumn({ type: 'uuid' }) + id!: string; + + @Column({ type: 'timestamptz' }) + timestamp!: Date; + + @Column({ type: 'text' }) + type!: FeedItemType; + + @Column({ type: 'uuid', nullable: true }) + actorFriendId!: string | null; + + @Column({ type: 'uuid', nullable: true }) + actorDriverId!: string | null; + + @Column({ type: 'uuid', nullable: true }) + leagueId!: string | null; + + @Column({ type: 'uuid', nullable: true }) + raceId!: string | null; + + @Column({ type: 'uuid', nullable: true }) + teamId!: string | null; + + @Column({ type: 'int', nullable: true }) + position!: number | null; + + @Column({ type: 'text' }) + headline!: string; + + @Column({ type: 'text', nullable: true }) + body!: string | null; + + @Column({ type: 'text', nullable: true }) + ctaLabel!: string | null; + + @Column({ type: 'text', nullable: true }) + ctaHref!: string | null; +} \ No newline at end of file diff --git a/adapters/social/persistence/typeorm/entities/FriendshipOrmEntity.ts b/adapters/social/persistence/typeorm/entities/FriendshipOrmEntity.ts new file mode 100644 index 000000000..c0efdfa40 --- /dev/null +++ b/adapters/social/persistence/typeorm/entities/FriendshipOrmEntity.ts @@ -0,0 +1,13 @@ +import { Column, Entity, PrimaryColumn } from 'typeorm'; + +@Entity({ name: 'social_friendships' }) +export class FriendshipOrmEntity { + @PrimaryColumn({ type: 'uuid' }) + driverId!: string; + + @PrimaryColumn({ type: 'uuid' }) + friendId!: string; + + @Column({ type: 'timestamptz', nullable: true }) + createdAt!: Date | null; +} \ No newline at end of file diff --git a/adapters/social/persistence/typeorm/errors/TypeOrmSocialSchemaError.ts b/adapters/social/persistence/typeorm/errors/TypeOrmSocialSchemaError.ts new file mode 100644 index 000000000..c8e1e84c1 --- /dev/null +++ b/adapters/social/persistence/typeorm/errors/TypeOrmSocialSchemaError.ts @@ -0,0 +1,32 @@ +export type TypeOrmSocialSchemaErrorReason = + | 'missing' + | 'not_string' + | 'empty_string' + | 'not_number' + | 'not_integer' + | 'not_boolean' + | 'not_date' + | 'invalid_date' + | 'invalid_enum_value' + | 'invalid_shape'; + +export class TypeOrmSocialSchemaError extends Error { + readonly entityName: string; + readonly fieldName: string; + readonly reason: TypeOrmSocialSchemaErrorReason | (string & {}); + + constructor(params: { + entityName: string; + fieldName: string; + reason: TypeOrmSocialSchemaError['reason']; + message?: string; + }) { + const { entityName, fieldName, reason, message } = params; + super(message); + + this.name = 'TypeOrmSocialSchemaError'; + this.entityName = entityName; + this.fieldName = fieldName; + this.reason = reason; + } +} \ No newline at end of file diff --git a/adapters/social/persistence/typeorm/mappers/FeedItemOrmMapper.test.ts b/adapters/social/persistence/typeorm/mappers/FeedItemOrmMapper.test.ts new file mode 100644 index 000000000..2b5ba9caf --- /dev/null +++ b/adapters/social/persistence/typeorm/mappers/FeedItemOrmMapper.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest'; + +import { FeedItemOrmEntity } from '../entities/FeedItemOrmEntity'; +import { TypeOrmSocialSchemaError } from '../errors/TypeOrmSocialSchemaError'; +import { FeedItemOrmMapper } from './FeedItemOrmMapper'; + +describe('FeedItemOrmMapper', () => { + it('maps orm entity to domain FeedItem', () => { + const mapper = new FeedItemOrmMapper(); + + const orm = new FeedItemOrmEntity(); + orm.id = '00000000-0000-0000-0000-000000000001'; + orm.timestamp = new Date('2025-01-01T00:00:00.000Z'); + orm.type = 'new-result-posted'; + orm.actorFriendId = null; + orm.actorDriverId = '00000000-0000-0000-0000-0000000000aa'; + orm.leagueId = null; + orm.raceId = '00000000-0000-0000-0000-0000000000bb'; + orm.teamId = null; + orm.position = 2; + orm.headline = 'Hello'; + orm.body = 'World'; + orm.ctaLabel = null; + orm.ctaHref = null; + + const domain = mapper.toDomain(orm); + + expect(domain).toEqual({ + id: orm.id, + timestamp: orm.timestamp, + type: orm.type, + actorDriverId: orm.actorDriverId, + raceId: orm.raceId, + position: orm.position, + headline: orm.headline, + body: orm.body, + }); + }); + + it('throws TypeOrmSocialSchemaError for invalid shape', () => { + const mapper = new FeedItemOrmMapper(); + + const orm = new FeedItemOrmEntity(); + orm.id = ''; + orm.timestamp = new Date('2025-01-01T00:00:00.000Z'); + orm.type = 'new-result-posted'; + orm.actorFriendId = null; + orm.actorDriverId = null; + orm.leagueId = null; + orm.raceId = null; + orm.teamId = null; + orm.position = null; + orm.headline = 'Hello'; + orm.body = null; + orm.ctaLabel = null; + orm.ctaHref = null; + + expect(() => mapper.toDomain(orm)).toThrow(TypeOrmSocialSchemaError); + }); +}); \ No newline at end of file diff --git a/adapters/social/persistence/typeorm/mappers/FeedItemOrmMapper.ts b/adapters/social/persistence/typeorm/mappers/FeedItemOrmMapper.ts new file mode 100644 index 000000000..6874f1f3d --- /dev/null +++ b/adapters/social/persistence/typeorm/mappers/FeedItemOrmMapper.ts @@ -0,0 +1,100 @@ +import type { FeedItem } from '@core/social/domain/types/FeedItem'; +import type { FeedItemType } from '@core/social/domain/types/FeedItemType'; + +import { TypeOrmSocialSchemaError } from '../errors/TypeOrmSocialSchemaError'; +import { FeedItemOrmEntity } from '../entities/FeedItemOrmEntity'; +import { + assertDate, + assertEnumValue, + assertNonEmptyString, + assertOptionalIntegerOrNull, + assertOptionalStringOrNull, +} from '../schema/TypeOrmSocialSchemaGuards'; + +export class FeedItemOrmMapper { + toOrmEntity(domain: FeedItem): FeedItemOrmEntity { + const entity = new FeedItemOrmEntity(); + entity.id = domain.id; + entity.timestamp = domain.timestamp; + entity.type = domain.type; + + entity.actorFriendId = domain.actorFriendId ?? null; + entity.actorDriverId = domain.actorDriverId ?? null; + entity.leagueId = domain.leagueId ?? null; + entity.raceId = domain.raceId ?? null; + entity.teamId = domain.teamId ?? null; + + entity.position = domain.position ?? null; + + entity.headline = domain.headline; + entity.body = domain.body ?? null; + entity.ctaLabel = domain.ctaLabel ?? null; + entity.ctaHref = domain.ctaHref ?? null; + + return entity; + } + + toDomain(entity: FeedItemOrmEntity): FeedItem { + const entityName = 'SocialFeedItem'; + + try { + assertNonEmptyString(entityName, 'id', entity.id); + assertDate(entityName, 'timestamp', entity.timestamp); + + assertEnumValue( + entityName, + 'type', + entity.type, + [ + 'friend-joined-league', + 'friend-joined-team', + 'friend-finished-race', + 'friend-new-personal-best', + 'new-race-scheduled', + 'new-result-posted', + 'league-highlight', + ] as const, + ); + + assertOptionalStringOrNull(entityName, 'actorFriendId', entity.actorFriendId); + assertOptionalStringOrNull(entityName, 'actorDriverId', entity.actorDriverId); + assertOptionalStringOrNull(entityName, 'leagueId', entity.leagueId); + assertOptionalStringOrNull(entityName, 'raceId', entity.raceId); + assertOptionalStringOrNull(entityName, 'teamId', entity.teamId); + + assertOptionalIntegerOrNull(entityName, 'position', entity.position); + + assertNonEmptyString(entityName, 'headline', entity.headline); + assertOptionalStringOrNull(entityName, 'body', entity.body); + assertOptionalStringOrNull(entityName, 'ctaLabel', entity.ctaLabel); + assertOptionalStringOrNull(entityName, 'ctaHref', entity.ctaHref); + + return { + id: entity.id, + timestamp: entity.timestamp, + type: entity.type, + ...(entity.actorFriendId !== null && entity.actorFriendId !== undefined + ? { actorFriendId: entity.actorFriendId } + : {}), + ...(entity.actorDriverId !== null && entity.actorDriverId !== undefined + ? { actorDriverId: entity.actorDriverId } + : {}), + ...(entity.leagueId !== null && entity.leagueId !== undefined ? { leagueId: entity.leagueId } : {}), + ...(entity.raceId !== null && entity.raceId !== undefined ? { raceId: entity.raceId } : {}), + ...(entity.teamId !== null && entity.teamId !== undefined ? { teamId: entity.teamId } : {}), + ...(entity.position !== null && entity.position !== undefined ? { position: entity.position } : {}), + headline: entity.headline, + ...(entity.body !== null && entity.body !== undefined ? { body: entity.body } : {}), + ...(entity.ctaLabel !== null && entity.ctaLabel !== undefined ? { ctaLabel: entity.ctaLabel } : {}), + ...(entity.ctaHref !== null && entity.ctaHref !== undefined ? { ctaHref: entity.ctaHref } : {}), + }; + } catch (error) { + if (error instanceof TypeOrmSocialSchemaError) { + throw error; + } + + const message = error instanceof Error ? error.message : 'Invalid persisted SocialFeedItem'; + throw new TypeOrmSocialSchemaError({ entityName, fieldName: 'unknown', reason: 'invalid_shape', message }); + } + } +} \ No newline at end of file diff --git a/adapters/social/persistence/typeorm/repositories/TypeOrmFeedRepository.test.ts b/adapters/social/persistence/typeorm/repositories/TypeOrmFeedRepository.test.ts new file mode 100644 index 000000000..b11046d44 --- /dev/null +++ b/adapters/social/persistence/typeorm/repositories/TypeOrmFeedRepository.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { Repository } from 'typeorm'; + +import type { FeedItem } from '@core/social/domain/types/FeedItem'; + +import { FeedItemOrmEntity } from '../entities/FeedItemOrmEntity'; +import { FriendshipOrmEntity } from '../entities/FriendshipOrmEntity'; +import { FeedItemOrmMapper } from '../mappers/FeedItemOrmMapper'; +import { TypeOrmFeedRepository } from './TypeOrmFeedRepository'; + +describe('TypeOrmFeedRepository', () => { + it('getFeedForDriver returns mapped items for friend actor ids', async () => { + const feedRepo = { + find: vi.fn(), + } as unknown as Repository; + + const friendshipRepo = { + find: vi.fn(), + } as unknown as Repository; + + const mapper = { + toDomain: vi.fn(), + } as unknown as FeedItemOrmMapper; + + const repo = new TypeOrmFeedRepository(feedRepo, friendshipRepo, mapper); + + const friendship = new FriendshipOrmEntity(); + friendship.driverId = '00000000-0000-0000-0000-000000000001'; + friendship.friendId = '00000000-0000-0000-0000-000000000002'; + friendship.createdAt = null; + + friendshipRepo.find = vi.fn().mockResolvedValue([friendship]); + + const ormItem = new FeedItemOrmEntity(); + ormItem.id = '00000000-0000-0000-0000-0000000000aa'; + ormItem.timestamp = new Date('2025-01-01T00:00:00.000Z'); + ormItem.type = 'new-result-posted'; + ormItem.actorFriendId = null; + ormItem.actorDriverId = friendship.friendId; + ormItem.leagueId = null; + ormItem.raceId = null; + ormItem.teamId = null; + ormItem.position = null; + ormItem.headline = 'Headline'; + ormItem.body = null; + ormItem.ctaLabel = null; + ormItem.ctaHref = null; + + feedRepo.find = vi.fn().mockResolvedValue([ormItem]); + + const mapped: FeedItem = { + id: ormItem.id, + timestamp: ormItem.timestamp, + type: ormItem.type, + actorDriverId: ormItem.actorDriverId ?? undefined, + headline: ormItem.headline, + }; + + mapper.toDomain = vi.fn().mockReturnValue(mapped); + + const result = await repo.getFeedForDriver(friendship.driverId); + + expect(friendshipRepo.find).toHaveBeenCalledWith({ where: { driverId: friendship.driverId } }); + expect(feedRepo.find).toHaveBeenCalledTimes(1); + expect(mapper.toDomain).toHaveBeenCalledWith(ormItem); + expect(result).toEqual([mapped]); + }); + + it('getFeedForDriver returns [] when driver has no friends', async () => { + const feedRepo = { + find: vi.fn(), + } as unknown as Repository; + + const friendshipRepo = { + find: vi.fn().mockResolvedValue([]), + } as unknown as Repository; + + const mapper = { + toDomain: vi.fn(), + } as unknown as FeedItemOrmMapper; + + const repo = new TypeOrmFeedRepository(feedRepo, friendshipRepo, mapper); + + const result = await repo.getFeedForDriver('00000000-0000-0000-0000-000000000001'); + + expect(result).toEqual([]); + expect(feedRepo.find).not.toHaveBeenCalled(); + expect(mapper.toDomain).not.toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/adapters/social/persistence/typeorm/repositories/TypeOrmFeedRepository.ts b/adapters/social/persistence/typeorm/repositories/TypeOrmFeedRepository.ts new file mode 100644 index 000000000..d438e5420 --- /dev/null +++ b/adapters/social/persistence/typeorm/repositories/TypeOrmFeedRepository.ts @@ -0,0 +1,42 @@ +import { In, type Repository } from 'typeorm'; + +import type { IFeedRepository } from '@core/social/domain/repositories/IFeedRepository'; +import type { FeedItem } from '@core/social/domain/types/FeedItem'; + +import { FeedItemOrmEntity } from '../entities/FeedItemOrmEntity'; +import { FriendshipOrmEntity } from '../entities/FriendshipOrmEntity'; +import { FeedItemOrmMapper } from '../mappers/FeedItemOrmMapper'; + +export class TypeOrmFeedRepository implements IFeedRepository { + constructor( + private readonly feedRepo: Repository, + private readonly friendshipRepo: Repository, + private readonly mapper: FeedItemOrmMapper, + ) {} + + async getFeedForDriver(driverId: string, limit?: number): Promise { + const friendships = await this.friendshipRepo.find({ where: { driverId } }); + const friendIds = friendships.map((f) => f.friendId); + + if (friendIds.length === 0) return []; + + const entities = await this.feedRepo.find({ + where: { + actorDriverId: In(friendIds), + }, + order: { timestamp: 'DESC' }, + ...(typeof limit === 'number' ? { take: limit } : {}), + }); + + return entities.map((e) => this.mapper.toDomain(e)); + } + + async getGlobalFeed(limit?: number): Promise { + const entities = await this.feedRepo.find({ + order: { timestamp: 'DESC' }, + ...(typeof limit === 'number' ? { take: limit } : {}), + }); + + return entities.map((e) => this.mapper.toDomain(e)); + } +} \ No newline at end of file diff --git a/adapters/social/persistence/typeorm/repositories/TypeOrmSocialGraphRepository.ts b/adapters/social/persistence/typeorm/repositories/TypeOrmSocialGraphRepository.ts new file mode 100644 index 000000000..911528677 --- /dev/null +++ b/adapters/social/persistence/typeorm/repositories/TypeOrmSocialGraphRepository.ts @@ -0,0 +1,84 @@ +import { In, type Repository } from 'typeorm'; + +import type { ISocialGraphRepository } from '@core/social/domain/repositories/ISocialGraphRepository'; +import type { Driver } from '@core/racing/domain/entities/Driver'; + +import { DriverOrmEntity } from '@adapters/racing/persistence/typeorm/entities/DriverOrmEntity'; +import { DriverOrmMapper } from '@adapters/racing/persistence/typeorm/mappers/DriverOrmMapper'; + +import { FriendshipOrmEntity } from '../entities/FriendshipOrmEntity'; +import { TypeOrmSocialSchemaError } from '../errors/TypeOrmSocialSchemaError'; +import { assertNonEmptyString } from '../schema/TypeOrmSocialSchemaGuards'; + +export class TypeOrmSocialGraphRepository implements ISocialGraphRepository { + constructor( + private readonly friendshipRepo: Repository, + private readonly driverRepo: Repository, + private readonly driverMapper: DriverOrmMapper, + ) {} + + async getFriends(driverId: string): Promise { + const friendIds = await this.getFriendIds(driverId); + if (friendIds.length === 0) return []; + + const drivers = await this.driverRepo.find({ where: { id: In(friendIds) } }); + const byId = new Map(drivers.map((d) => [d.id, d])); + + return friendIds + .map((id) => byId.get(id)) + .filter((d): d is DriverOrmEntity => Boolean(d)) + .map((d) => this.driverMapper.toDomain(d)); + } + + async getFriendIds(driverId: string): Promise { + const entityName = 'SocialFriendship'; + + const friendships = await this.friendshipRepo.find({ where: { driverId } }); + + return friendships.map((f) => { + try { + assertNonEmptyString(entityName, 'driverId', f.driverId); + assertNonEmptyString(entityName, 'friendId', f.friendId); + return f.friendId; + } catch (error) { + if (error instanceof TypeOrmSocialSchemaError) throw error; + const message = error instanceof Error ? error.message : 'Invalid persisted SocialFriendship'; + throw new TypeOrmSocialSchemaError({ entityName, fieldName: 'unknown', reason: 'invalid_shape', message }); + } + }); + } + + async getSuggestedFriends(driverId: string, limit?: number): Promise { + const directFriendIds = new Set(await this.getFriendIds(driverId)); + if (directFriendIds.size === 0) return []; + + const friendships = await this.friendshipRepo.find({ + where: { driverId: In(Array.from(directFriendIds)) }, + }); + + const suggestions = new Map(); + + for (const friendship of friendships) { + const friendOfFriendId = friendship.friendId; + if (friendOfFriendId === driverId) continue; + if (directFriendIds.has(friendOfFriendId)) continue; + + suggestions.set(friendOfFriendId, (suggestions.get(friendOfFriendId) ?? 0) + 1); + } + + const rankedIds = Array.from(suggestions.entries()) + .sort((a, b) => b[1] - a[1]) + .map(([id]) => id); + + const topIds = typeof limit === 'number' ? rankedIds.slice(0, limit) : rankedIds; + if (topIds.length === 0) return []; + + const drivers = await this.driverRepo.find({ where: { id: In(topIds) } }); + const byId = new Map(drivers.map((d) => [d.id, d])); + + return topIds + .map((id) => byId.get(id)) + .filter((d): d is DriverOrmEntity => Boolean(d)) + .map((d) => this.driverMapper.toDomain(d)); + } +} \ No newline at end of file diff --git a/adapters/social/persistence/typeorm/schema/TypeOrmSocialSchemaGuards.ts b/adapters/social/persistence/typeorm/schema/TypeOrmSocialSchemaGuards.ts new file mode 100644 index 000000000..86151fb2b --- /dev/null +++ b/adapters/social/persistence/typeorm/schema/TypeOrmSocialSchemaGuards.ts @@ -0,0 +1,114 @@ +import { TypeOrmSocialSchemaError } from '../errors/TypeOrmSocialSchemaError'; + +export function assertNonEmptyString(entityName: string, fieldName: string, value: unknown): asserts value is string { + if (value === undefined || value === null) { + throw new TypeOrmSocialSchemaError({ entityName, fieldName, reason: 'missing' }); + } + if (typeof value !== 'string') { + throw new TypeOrmSocialSchemaError({ entityName, fieldName, reason: 'not_string' }); + } + if (value.trim().length === 0) { + throw new TypeOrmSocialSchemaError({ entityName, fieldName, reason: 'empty_string' }); + } +} + +export function assertOptionalStringOrNull( + entityName: string, + fieldName: string, + value: unknown, +): asserts value is string | null | undefined { + if (value === undefined || value === null) return; + if (typeof value !== 'string') { + throw new TypeOrmSocialSchemaError({ entityName, fieldName, reason: 'not_string' }); + } +} + +export function assertNumber(entityName: string, fieldName: string, value: unknown): asserts value is number { + if (value === undefined || value === null) { + throw new TypeOrmSocialSchemaError({ entityName, fieldName, reason: 'missing' }); + } + if (typeof value !== 'number' || Number.isNaN(value)) { + throw new TypeOrmSocialSchemaError({ entityName, fieldName, reason: 'not_number' }); + } +} + +export function assertOptionalNumberOrNull( + entityName: string, + fieldName: string, + value: unknown, +): asserts value is number | null | undefined { + if (value === undefined || value === null) return; + if (typeof value !== 'number' || Number.isNaN(value)) { + throw new TypeOrmSocialSchemaError({ entityName, fieldName, reason: 'not_number' }); + } +} + +export function assertInteger(entityName: string, fieldName: string, value: unknown): asserts value is number { + if (value === undefined || value === null) { + throw new TypeOrmSocialSchemaError({ entityName, fieldName, reason: 'missing' }); + } + if (typeof value !== 'number' || !Number.isInteger(value)) { + throw new TypeOrmSocialSchemaError({ entityName, fieldName, reason: 'not_integer' }); + } +} + +export function assertOptionalIntegerOrNull( + entityName: string, + fieldName: string, + value: unknown, +): asserts value is number | null | undefined { + if (value === undefined || value === null) return; + if (typeof value !== 'number' || !Number.isInteger(value)) { + throw new TypeOrmSocialSchemaError({ entityName, fieldName, reason: 'not_integer' }); + } +} + +export function assertBoolean(entityName: string, fieldName: string, value: unknown): asserts value is boolean { + if (value === undefined || value === null) { + throw new TypeOrmSocialSchemaError({ entityName, fieldName, reason: 'missing' }); + } + if (typeof value !== 'boolean') { + throw new TypeOrmSocialSchemaError({ entityName, fieldName, reason: 'not_boolean' }); + } +} + +export function assertDate(entityName: string, fieldName: string, value: unknown): asserts value is Date { + if (value === undefined || value === null) { + throw new TypeOrmSocialSchemaError({ entityName, fieldName, reason: 'missing' }); + } + if (!(value instanceof Date)) { + throw new TypeOrmSocialSchemaError({ entityName, fieldName, reason: 'not_date' }); + } + if (Number.isNaN(value.getTime())) { + throw new TypeOrmSocialSchemaError({ entityName, fieldName, reason: 'invalid_date' }); + } +} + +export function assertEnumValue( + entityName: string, + fieldName: string, + value: unknown, + allowed: readonly TAllowed[], +): asserts value is TAllowed { + if (value === undefined || value === null) { + throw new TypeOrmSocialSchemaError({ entityName, fieldName, reason: 'missing' }); + } + if (typeof value !== 'string') { + throw new TypeOrmSocialSchemaError({ entityName, fieldName, reason: 'not_string' }); + } + if (!allowed.includes(value as TAllowed)) { + throw new TypeOrmSocialSchemaError({ entityName, fieldName, reason: 'invalid_enum_value' }); + } +} + +export const TypeOrmSocialSchemaGuards = { + assertNonEmptyString, + assertOptionalStringOrNull, + assertNumber, + assertOptionalNumberOrNull, + assertInteger, + assertOptionalIntegerOrNull, + assertBoolean, + assertDate, + assertEnumValue, +}; \ No newline at end of file diff --git a/apps/api/src/domain/analytics/AnalyticsModule.test.ts b/apps/api/src/domain/analytics/AnalyticsModule.test.ts index 0cc2792fb..a0000cdc1 100644 --- a/apps/api/src/domain/analytics/AnalyticsModule.test.ts +++ b/apps/api/src/domain/analytics/AnalyticsModule.test.ts @@ -2,7 +2,6 @@ import { Test, TestingModule } from '@nestjs/testing'; import { AnalyticsModule } from './AnalyticsModule'; import { AnalyticsController } from './AnalyticsController'; import { AnalyticsService } from './AnalyticsService'; -import { ConsoleLogger } from '@adapters/logging/ConsoleLogger'; describe('AnalyticsModule', () => { let module: TestingModule; @@ -10,10 +9,7 @@ describe('AnalyticsModule', () => { beforeEach(async () => { module = await Test.createTestingModule({ imports: [AnalyticsModule], - }) - .overrideProvider('Logger_TOKEN') - .useClass(ConsoleLogger) - .compile(); + }).compile(); }); it('should compile the module', () => { diff --git a/apps/api/src/domain/analytics/AnalyticsModule.ts b/apps/api/src/domain/analytics/AnalyticsModule.ts index d1482fc71..fbda9f999 100644 --- a/apps/api/src/domain/analytics/AnalyticsModule.ts +++ b/apps/api/src/domain/analytics/AnalyticsModule.ts @@ -1,10 +1,12 @@ import { Module } from '@nestjs/common'; + +import { AnalyticsPersistenceModule } from '../../persistence/analytics/AnalyticsPersistenceModule'; import { AnalyticsController } from './AnalyticsController'; import { AnalyticsService } from './AnalyticsService'; import { AnalyticsProviders } from './AnalyticsProviders'; @Module({ - imports: [], + imports: [AnalyticsPersistenceModule], controllers: [AnalyticsController], providers: AnalyticsProviders, exports: [AnalyticsService], diff --git a/apps/api/src/domain/analytics/AnalyticsProviders.ts b/apps/api/src/domain/analytics/AnalyticsProviders.ts index 8de0b77a2..3a86324a8 100644 --- a/apps/api/src/domain/analytics/AnalyticsProviders.ts +++ b/apps/api/src/domain/analytics/AnalyticsProviders.ts @@ -6,18 +6,18 @@ import type { IPageViewRepository } from '@core/analytics/application/repositori import type { Logger, UseCaseOutputPort } from '@core/shared/application'; import { Provider } from '@nestjs/common'; -const Logger_TOKEN = 'Logger_TOKEN'; -const IPAGE_VIEW_REPO_TOKEN = 'IPageViewRepository_TOKEN'; -const IENGAGEMENT_REPO_TOKEN = 'IEngagementRepository_TOKEN'; +import { + ANALYTICS_ENGAGEMENT_REPOSITORY_TOKEN, + ANALYTICS_PAGE_VIEW_REPOSITORY_TOKEN, +} from '../../persistence/analytics/AnalyticsPersistenceTokens'; + +const LOGGER_TOKEN = 'Logger'; const RECORD_PAGE_VIEW_OUTPUT_PORT_TOKEN = 'RecordPageViewOutputPort_TOKEN'; const RECORD_ENGAGEMENT_OUTPUT_PORT_TOKEN = 'RecordEngagementOutputPort_TOKEN'; const GET_DASHBOARD_DATA_OUTPUT_PORT_TOKEN = 'GetDashboardDataOutputPort_TOKEN'; const GET_ANALYTICS_METRICS_OUTPUT_PORT_TOKEN = 'GetAnalyticsMetricsOutputPort_TOKEN'; -import { InMemoryEngagementRepository } from '@adapters/analytics/persistence/inmemory/InMemoryEngagementRepository'; -import { InMemoryPageViewRepository } from '@adapters/analytics/persistence/inmemory/InMemoryPageViewRepository'; -import { ConsoleLogger } from '@adapters/logging/ConsoleLogger'; import { GetAnalyticsMetricsUseCase } from '@core/analytics/application/use-cases/GetAnalyticsMetricsUseCase'; import { GetDashboardDataOutput, GetDashboardDataUseCase } from '@core/analytics/application/use-cases/GetDashboardDataUseCase'; import { RecordEngagementUseCase } from '@core/analytics/application/use-cases/RecordEngagementUseCase'; @@ -34,20 +34,6 @@ export const AnalyticsProviders: Provider[] = [ RecordEngagementPresenter, GetDashboardDataPresenter, GetAnalyticsMetricsPresenter, - { - provide: Logger_TOKEN, - useClass: ConsoleLogger, - }, - { - provide: IPAGE_VIEW_REPO_TOKEN, - useFactory: (logger: Logger) => new InMemoryPageViewRepository(logger), - inject: [Logger_TOKEN], - }, - { - provide: IENGAGEMENT_REPO_TOKEN, - useFactory: (logger: Logger) => new InMemoryEngagementRepository(logger), - inject: [Logger_TOKEN], - }, { provide: RECORD_PAGE_VIEW_OUTPUT_PORT_TOKEN, useExisting: RecordPageViewPresenter, @@ -68,24 +54,24 @@ export const AnalyticsProviders: Provider[] = [ provide: RecordPageViewUseCase, useFactory: (repo: IPageViewRepository, logger: Logger, output: UseCaseOutputPort) => new RecordPageViewUseCase(repo, logger, output), - inject: [IPAGE_VIEW_REPO_TOKEN, Logger_TOKEN, RECORD_PAGE_VIEW_OUTPUT_PORT_TOKEN], + inject: [ANALYTICS_PAGE_VIEW_REPOSITORY_TOKEN, LOGGER_TOKEN, RECORD_PAGE_VIEW_OUTPUT_PORT_TOKEN], }, { provide: RecordEngagementUseCase, useFactory: (repo: IEngagementRepository, logger: Logger, output: UseCaseOutputPort) => new RecordEngagementUseCase(repo, logger, output), - inject: [IENGAGEMENT_REPO_TOKEN, Logger_TOKEN, RECORD_ENGAGEMENT_OUTPUT_PORT_TOKEN], + inject: [ANALYTICS_ENGAGEMENT_REPOSITORY_TOKEN, LOGGER_TOKEN, RECORD_ENGAGEMENT_OUTPUT_PORT_TOKEN], }, { provide: GetDashboardDataUseCase, useFactory: (logger: Logger, output: UseCaseOutputPort) => new GetDashboardDataUseCase(logger, output), - inject: [Logger_TOKEN, GET_DASHBOARD_DATA_OUTPUT_PORT_TOKEN], + inject: [LOGGER_TOKEN, GET_DASHBOARD_DATA_OUTPUT_PORT_TOKEN], }, { provide: GetAnalyticsMetricsUseCase, useFactory: (logger: Logger, output: UseCaseOutputPort, repo: IPageViewRepository) => new GetAnalyticsMetricsUseCase(logger, output, repo), - inject: [Logger_TOKEN, GET_ANALYTICS_METRICS_OUTPUT_PORT_TOKEN, IPAGE_VIEW_REPO_TOKEN], + inject: [LOGGER_TOKEN, GET_ANALYTICS_METRICS_OUTPUT_PORT_TOKEN, ANALYTICS_PAGE_VIEW_REPOSITORY_TOKEN], }, ]; \ No newline at end of file diff --git a/apps/api/src/domain/auth/AuthModule.ts b/apps/api/src/domain/auth/AuthModule.ts index 7e1b42422..5f5eefd31 100644 --- a/apps/api/src/domain/auth/AuthModule.ts +++ b/apps/api/src/domain/auth/AuthModule.ts @@ -1,4 +1,5 @@ import { Module } from '@nestjs/common'; +import { IdentityPersistenceModule } from '../../persistence/identity/IdentityPersistenceModule'; import { AuthService } from './AuthService'; import { AuthController } from './AuthController'; import { AuthProviders } from './AuthProviders'; @@ -7,14 +8,9 @@ import { AuthorizationGuard } from './AuthorizationGuard'; import { AuthorizationService } from './AuthorizationService'; @Module({ + imports: [IdentityPersistenceModule], controllers: [AuthController], - providers: [ - AuthService, - ...AuthProviders, - AuthenticationGuard, - AuthorizationService, - AuthorizationGuard, - ], + providers: [AuthService, ...AuthProviders, AuthenticationGuard, AuthorizationService, AuthorizationGuard], exports: [AuthService, AuthenticationGuard, AuthorizationService, AuthorizationGuard], }) export class AuthModule {} diff --git a/apps/api/src/domain/auth/AuthProviders.ts b/apps/api/src/domain/auth/AuthProviders.ts index 7db82f74e..c851f95c1 100644 --- a/apps/api/src/domain/auth/AuthProviders.ts +++ b/apps/api/src/domain/auth/AuthProviders.ts @@ -1,30 +1,28 @@ import { Provider } from '@nestjs/common'; -// Import interfaces and concrete implementations -import { StoredUser } from '@core/identity/domain/repositories/IUserRepository'; -import type { IPasswordHashingService } from '@core/identity/domain/services/PasswordHashingService'; - -import { InMemoryAuthRepository } from '@adapters/identity/persistence/inmemory/InMemoryAuthRepository'; -import { InMemoryUserRepository } from '@adapters/identity/persistence/inmemory/InMemoryUserRepository'; -import { InMemoryPasswordHashingService } from '@adapters/identity/services/InMemoryPasswordHashingService'; -import { ConsoleLogger } from '@adapters/logging/ConsoleLogger'; import { CookieIdentitySessionAdapter } from '@adapters/identity/session/CookieIdentitySessionAdapter'; import { LoginUseCase } from '@core/identity/application/use-cases/LoginUseCase'; import { LogoutUseCase } from '@core/identity/application/use-cases/LogoutUseCase'; import { SignupUseCase } from '@core/identity/application/use-cases/SignupUseCase'; +import type { IdentitySessionPort } from '@core/identity/application/ports/IdentitySessionPort'; +import type { IAuthRepository } from '@core/identity/domain/repositories/IAuthRepository'; +import type { IPasswordHashingService } from '@core/identity/domain/services/PasswordHashingService'; +import type { LoginResult } from '@core/identity/application/use-cases/LoginUseCase'; +import type { LogoutResult } from '@core/identity/application/use-cases/LogoutUseCase'; +import type { SignupResult } from '@core/identity/application/use-cases/SignupUseCase'; +import type { Logger, UseCaseOutputPort } from '@core/shared/application'; + +import { + AUTH_REPOSITORY_TOKEN, + PASSWORD_HASHING_SERVICE_TOKEN, + USER_REPOSITORY_TOKEN, +} from '../../persistence/identity/IdentityPersistenceTokens'; + import { AuthSessionPresenter } from './presenters/AuthSessionPresenter'; import { CommandResultPresenter } from './presenters/CommandResultPresenter'; -import type { Logger, UseCaseOutputPort } from '@core/shared/application'; -import type { LoginResult } from '@core/identity/application/use-cases/LoginUseCase'; -import type { SignupResult } from '@core/identity/application/use-cases/SignupUseCase'; -import type { LogoutResult } from '@core/identity/application/use-cases/LogoutUseCase'; -import type { IAuthRepository } from '@core/identity/domain/repositories/IAuthRepository'; -import type { IdentitySessionPort } from '@core/identity/application/ports/IdentitySessionPort'; // Define the tokens for dependency injection -export const AUTH_REPOSITORY_TOKEN = 'IAuthRepository'; -export const USER_REPOSITORY_TOKEN = 'IUserRepository'; -export const PASSWORD_HASHING_SERVICE_TOKEN = 'IPasswordHashingService'; +export { AUTH_REPOSITORY_TOKEN, USER_REPOSITORY_TOKEN, PASSWORD_HASHING_SERVICE_TOKEN }; export const LOGGER_TOKEN = 'Logger'; export const IDENTITY_SESSION_PORT_TOKEN = 'IdentitySessionPort'; export const LOGIN_USE_CASE_TOKEN = 'LoginUseCase'; @@ -35,38 +33,6 @@ export const AUTH_SESSION_OUTPUT_PORT_TOKEN = 'AuthSessionOutputPort'; export const COMMAND_RESULT_OUTPUT_PORT_TOKEN = 'CommandResultOutputPort'; export const AuthProviders: Provider[] = [ - { - provide: AUTH_REPOSITORY_TOKEN, - useFactory: (userRepository: InMemoryUserRepository, passwordHashingService: IPasswordHashingService, logger: Logger) => - new InMemoryAuthRepository(userRepository, passwordHashingService, logger), - inject: [USER_REPOSITORY_TOKEN, PASSWORD_HASHING_SERVICE_TOKEN, LOGGER_TOKEN], - }, - { - provide: USER_REPOSITORY_TOKEN, - useFactory: (logger: Logger) => { - const initialUsers: StoredUser[] = [ - { - // Match seeded racing driver id so dashboard works in inmemory mode. - id: 'driver-1', - email: 'admin@gridpilot.local', - passwordHash: 'demo_salt_321nimda', // InMemoryPasswordHashingService: "admin123" reversed. - displayName: 'Admin', - salt: '', - createdAt: new Date(), - }, - ]; - return new InMemoryUserRepository(logger, initialUsers); - }, - inject: [LOGGER_TOKEN], - }, - { - provide: PASSWORD_HASHING_SERVICE_TOKEN, - useClass: InMemoryPasswordHashingService, - }, - { - provide: LOGGER_TOKEN, - useClass: ConsoleLogger, - }, { provide: IDENTITY_SESSION_PORT_TOKEN, useFactory: (logger: Logger) => new CookieIdentitySessionAdapter(logger), diff --git a/apps/api/src/domain/bootstrap/BootstrapModule.ts b/apps/api/src/domain/bootstrap/BootstrapModule.ts index 0869e5666..e5381c713 100644 --- a/apps/api/src/domain/bootstrap/BootstrapModule.ts +++ b/apps/api/src/domain/bootstrap/BootstrapModule.ts @@ -4,11 +4,11 @@ import { SeedRacingData, type RacingSeedDependencies } from '../../../../../adap import { Inject, Module, OnModuleInit } from '@nestjs/common'; import { getApiPersistence, getEnableBootstrap } from '../../env'; import { RacingPersistenceModule } from '../../persistence/racing/RacingPersistenceModule'; -import { InMemorySocialPersistenceModule } from '../../persistence/inmemory/InMemorySocialPersistenceModule'; +import { SocialPersistenceModule } from '../../persistence/social/SocialPersistenceModule'; import { BootstrapProviders, ENSURE_INITIAL_DATA_TOKEN } from './BootstrapProviders'; @Module({ - imports: [RacingPersistenceModule, InMemorySocialPersistenceModule], + imports: [RacingPersistenceModule, SocialPersistenceModule], providers: BootstrapProviders, }) export class BootstrapModule implements OnModuleInit { diff --git a/apps/api/src/domain/bootstrap/BootstrapProviders.ts b/apps/api/src/domain/bootstrap/BootstrapProviders.ts index c30a41359..c44e17103 100644 --- a/apps/api/src/domain/bootstrap/BootstrapProviders.ts +++ b/apps/api/src/domain/bootstrap/BootstrapProviders.ts @@ -1,4 +1,5 @@ import { Provider } from '@nestjs/common'; +import { SOCIAL_FEED_REPOSITORY_TOKEN, SOCIAL_GRAPH_REPOSITORY_TOKEN } from '../../persistence/social/SocialPersistenceTokens'; import { EnsureInitialData } from '../../../../../adapters/bootstrap/EnsureInitialData'; import type { RacingSeedDependencies } from '../../../../../adapters/bootstrap/SeedRacingData'; import { SignupWithEmailUseCase, type SignupWithEmailResult } from '@core/identity/application/use-cases/SignupWithEmailUseCase'; @@ -105,8 +106,8 @@ export const BootstrapProviders: Provider[] = [ 'ITeamRepository', 'ITeamMembershipRepository', 'ISponsorRepository', - 'IFeedRepository', - 'ISocialGraphRepository', + SOCIAL_FEED_REPOSITORY_TOKEN, + SOCIAL_GRAPH_REPOSITORY_TOKEN, ], }, { diff --git a/apps/api/src/domain/dashboard/DashboardModule.ts b/apps/api/src/domain/dashboard/DashboardModule.ts index 07f66ff96..f2f085b64 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 { RacingPersistenceModule } from '../../persistence/racing/RacingPersistenceModule'; -import { InMemorySocialPersistenceModule } from '../../persistence/inmemory/InMemorySocialPersistenceModule'; +import { SocialPersistenceModule } from '../../persistence/social/SocialPersistenceModule'; import { DashboardService } from './DashboardService'; import { DashboardController } from './DashboardController'; import { DashboardProviders } from './DashboardProviders'; @Module({ - imports: [RacingPersistenceModule, InMemorySocialPersistenceModule], + imports: [RacingPersistenceModule, SocialPersistenceModule], controllers: [DashboardController], providers: [DashboardService, ...DashboardProviders], exports: [DashboardService], diff --git a/apps/api/src/domain/dashboard/DashboardProviders.ts b/apps/api/src/domain/dashboard/DashboardProviders.ts index 35033686e..5d668208b 100644 --- a/apps/api/src/domain/dashboard/DashboardProviders.ts +++ b/apps/api/src/domain/dashboard/DashboardProviders.ts @@ -11,6 +11,8 @@ import { ILeagueMembershipRepository } from '@core/racing/domain/repositories/IL import { IRaceRegistrationRepository } from '@core/racing/domain/repositories/IRaceRegistrationRepository'; import { IFeedRepository } from '@core/social/domain/repositories/IFeedRepository'; import { ISocialGraphRepository } from '@core/social/domain/repositories/ISocialGraphRepository'; + +import { SOCIAL_FEED_REPOSITORY_TOKEN, SOCIAL_GRAPH_REPOSITORY_TOKEN } from '../../persistence/social/SocialPersistenceTokens'; import { ImageServicePort } from '@core/media/application/ports/ImageServicePort'; import { DashboardOverviewUseCase } from '@core/racing/application/use-cases/DashboardOverviewUseCase'; @@ -28,8 +30,6 @@ export const LEAGUE_REPOSITORY_TOKEN = 'ILeagueRepository'; export const STANDING_REPOSITORY_TOKEN = 'IStandingRepository'; export const LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN = 'ILeagueMembershipRepository'; export const RACE_REGISTRATION_REPOSITORY_TOKEN = 'IRaceRegistrationRepository'; -export const FEED_REPOSITORY_TOKEN = 'IFeedRepository'; -export const SOCIAL_GRAPH_REPOSITORY_TOKEN = 'ISocialGraphRepository'; export const IMAGE_SERVICE_TOKEN = 'IImageServicePort'; export const DASHBOARD_OVERVIEW_USE_CASE_TOKEN = 'DashboardOverviewUseCase'; export const DASHBOARD_OVERVIEW_OUTPUT_PORT_TOKEN = 'DashboardOverviewOutputPort'; @@ -86,7 +86,7 @@ export const DashboardProviders: Provider[] = [ STANDING_REPOSITORY_TOKEN, LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN, RACE_REGISTRATION_REPOSITORY_TOKEN, - FEED_REPOSITORY_TOKEN, + SOCIAL_FEED_REPOSITORY_TOKEN, SOCIAL_GRAPH_REPOSITORY_TOKEN, IMAGE_SERVICE_TOKEN, DASHBOARD_OVERVIEW_OUTPUT_PORT_TOKEN, diff --git a/apps/api/src/domain/driver/DriverModule.ts b/apps/api/src/domain/driver/DriverModule.ts index eeafd11c4..e85d9eb16 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 { RacingPersistenceModule } from '../../persistence/racing/RacingPersistenceModule'; -import { InMemorySocialPersistenceModule } from '../../persistence/inmemory/InMemorySocialPersistenceModule'; +import { SocialPersistenceModule } from '../../persistence/social/SocialPersistenceModule'; import { DriverService } from './DriverService'; import { DriverController } from './DriverController'; import { DriverProviders } from './DriverProviders'; @Module({ - imports: [RacingPersistenceModule, InMemorySocialPersistenceModule], + imports: [RacingPersistenceModule, SocialPersistenceModule], controllers: [DriverController], providers: [DriverService, ...DriverProviders], exports: [DriverService], diff --git a/apps/api/src/domain/driver/DriverTokens.ts b/apps/api/src/domain/driver/DriverTokens.ts index 59887a5ad..20e51cde9 100644 --- a/apps/api/src/domain/driver/DriverTokens.ts +++ b/apps/api/src/domain/driver/DriverTokens.ts @@ -7,8 +7,10 @@ export const IMAGE_SERVICE_PORT_TOKEN = 'IImageServicePort'; export const RACE_REGISTRATION_REPOSITORY_TOKEN = 'IRaceRegistrationRepository'; export const NOTIFICATION_PREFERENCE_REPOSITORY_TOKEN = 'INotificationPreferenceRepository'; export const TEAM_REPOSITORY_TOKEN = 'ITeamRepository'; +import { SOCIAL_GRAPH_REPOSITORY_TOKEN } from '../../persistence/social/SocialPersistenceTokens'; + export const TEAM_MEMBERSHIP_REPOSITORY_TOKEN = 'ITeamMembershipRepository'; -export const SOCIAL_GRAPH_REPOSITORY_TOKEN = 'ISocialGraphRepository'; +export { SOCIAL_GRAPH_REPOSITORY_TOKEN }; export const LOGGER_TOKEN = 'Logger'; export const GET_DRIVERS_LEADERBOARD_USE_CASE_TOKEN = 'GetDriversLeaderboardUseCase'; diff --git a/apps/api/src/domain/payments/PaymentsModule.ts b/apps/api/src/domain/payments/PaymentsModule.ts index 01b495a80..368267de9 100644 --- a/apps/api/src/domain/payments/PaymentsModule.ts +++ b/apps/api/src/domain/payments/PaymentsModule.ts @@ -1,9 +1,13 @@ import { Module } from '@nestjs/common'; + +import { PaymentsPersistenceModule } from '../../persistence/payments/PaymentsPersistenceModule'; + import { PaymentsService } from './PaymentsService'; import { PaymentsController } from './PaymentsController'; import { PaymentsProviders } from './PaymentsProviders'; @Module({ + imports: [PaymentsPersistenceModule], controllers: [PaymentsController], providers: [PaymentsService, ...PaymentsProviders], exports: [PaymentsService], diff --git a/apps/api/src/domain/payments/PaymentsProviders.ts b/apps/api/src/domain/payments/PaymentsProviders.ts index a4da790c0..be635ce02 100644 --- a/apps/api/src/domain/payments/PaymentsProviders.ts +++ b/apps/api/src/domain/payments/PaymentsProviders.ts @@ -5,7 +5,7 @@ import type { IPaymentRepository } from '@core/payments/domain/repositories/IPay import type { IMembershipFeeRepository, IMemberPaymentRepository } from '@core/payments/domain/repositories/IMembershipFeeRepository'; import type { IPrizeRepository } from '@core/payments/domain/repositories/IPrizeRepository'; import type { IWalletRepository, ITransactionRepository } from '@core/payments/domain/repositories/IWalletRepository'; -import type { Logger, UseCaseOutputPort } from '@core/shared/application'; +import type { UseCaseOutputPort } from '@core/shared/application'; // Import use cases import { GetPaymentsUseCase } from '@core/payments/application/use-cases/GetPaymentsUseCase'; @@ -22,10 +22,6 @@ import { GetWalletUseCase } from '@core/payments/application/use-cases/GetWallet import { ProcessWalletTransactionUseCase } from '@core/payments/application/use-cases/ProcessWalletTransactionUseCase'; // Import concrete in-memory implementations -import { InMemoryPaymentRepository } from '@adapters/payments/persistence/inmemory/InMemoryPaymentRepository'; -import { InMemoryMembershipFeeRepository, InMemoryMemberPaymentRepository } from '@adapters/payments/persistence/inmemory/InMemoryMembershipFeeRepository'; -import { InMemoryPrizeRepository } from '@adapters/payments/persistence/inmemory/InMemoryPrizeRepository'; -import { InMemoryWalletRepository, InMemoryTransactionRepository } from '@adapters/payments/persistence/inmemory/InMemoryWalletRepository'; import { ConsoleLogger } from '@adapters/logging/ConsoleLogger'; // Presenters @@ -150,37 +146,7 @@ export const PaymentsProviders: Provider[] = [ useClass: ConsoleLogger, }, - // Repositories (repositories are injected into use cases, NOT into services) - { - provide: PAYMENT_REPOSITORY_TOKEN, - useFactory: (logger: Logger) => new InMemoryPaymentRepository(logger), - inject: [LOGGER_TOKEN], - }, - { - provide: MEMBERSHIP_FEE_REPOSITORY_TOKEN, - useFactory: (logger: Logger) => new InMemoryMembershipFeeRepository(logger), - inject: [LOGGER_TOKEN], - }, - { - provide: MEMBER_PAYMENT_REPOSITORY_TOKEN, - useFactory: (logger: Logger) => new InMemoryMemberPaymentRepository(logger), - inject: [LOGGER_TOKEN], - }, - { - provide: PRIZE_REPOSITORY_TOKEN, - useFactory: (logger: Logger) => new InMemoryPrizeRepository(logger), - inject: [LOGGER_TOKEN], - }, - { - provide: WALLET_REPOSITORY_TOKEN, - useFactory: (logger: Logger) => new InMemoryWalletRepository(logger), - inject: [LOGGER_TOKEN], - }, - { - provide: TRANSACTION_REPOSITORY_TOKEN, - useFactory: (logger: Logger) => new InMemoryTransactionRepository(logger), - inject: [LOGGER_TOKEN], - }, + // Repositories are provided by PaymentsPersistenceModule behind tokens. // Use cases (use cases receive repositories, services receive use cases) { diff --git a/apps/api/src/domain/payments/PaymentsTokens.ts b/apps/api/src/domain/payments/PaymentsTokens.ts index 59fdbadb4..a0346b894 100644 --- a/apps/api/src/domain/payments/PaymentsTokens.ts +++ b/apps/api/src/domain/payments/PaymentsTokens.ts @@ -1,9 +1,19 @@ -export const PAYMENT_REPOSITORY_TOKEN = 'IPaymentRepository'; -export const MEMBERSHIP_FEE_REPOSITORY_TOKEN = 'IMembershipFeeRepository'; -export const MEMBER_PAYMENT_REPOSITORY_TOKEN = 'IMemberPaymentRepository'; -export const PRIZE_REPOSITORY_TOKEN = 'IPrizeRepository'; -export const WALLET_REPOSITORY_TOKEN = 'IWalletRepository'; -export const TRANSACTION_REPOSITORY_TOKEN = 'ITransactionRepository'; +import { + PAYMENTS_MEMBER_PAYMENT_REPOSITORY_TOKEN, + PAYMENTS_MEMBERSHIP_FEE_REPOSITORY_TOKEN, + PAYMENTS_PAYMENT_REPOSITORY_TOKEN, + PAYMENTS_PRIZE_REPOSITORY_TOKEN, + PAYMENTS_TRANSACTION_REPOSITORY_TOKEN, + PAYMENTS_WALLET_REPOSITORY_TOKEN, +} from '../../persistence/payments/PaymentsPersistenceTokens'; + +export const PAYMENT_REPOSITORY_TOKEN = PAYMENTS_PAYMENT_REPOSITORY_TOKEN; +export const MEMBERSHIP_FEE_REPOSITORY_TOKEN = PAYMENTS_MEMBERSHIP_FEE_REPOSITORY_TOKEN; +export const MEMBER_PAYMENT_REPOSITORY_TOKEN = PAYMENTS_MEMBER_PAYMENT_REPOSITORY_TOKEN; +export const PRIZE_REPOSITORY_TOKEN = PAYMENTS_PRIZE_REPOSITORY_TOKEN; +export const WALLET_REPOSITORY_TOKEN = PAYMENTS_WALLET_REPOSITORY_TOKEN; +export const TRANSACTION_REPOSITORY_TOKEN = PAYMENTS_TRANSACTION_REPOSITORY_TOKEN; + export const LOGGER_TOKEN = 'Logger'; export const GET_PAYMENTS_USE_CASE_TOKEN = 'GetPaymentsUseCase'; diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts index 0f1b3a560..64844d6b1 100644 --- a/apps/api/src/env.ts +++ b/apps/api/src/env.ts @@ -36,6 +36,11 @@ export function getApiPersistence(): ApiPersistence { return requireOneOf('GRIDPILOT_API_PERSISTENCE', configured, ['postgres', 'inmemory'] as const); } + // Tests should default to in-memory even when DATABASE_URL exists, unless explicitly overridden. + if (process.env.NODE_ENV === 'test') { + return 'inmemory'; + } + return process.env.DATABASE_URL ? 'postgres' : 'inmemory'; } diff --git a/apps/api/src/persistence/analytics/AnalyticsPersistenceModule.test.ts b/apps/api/src/persistence/analytics/AnalyticsPersistenceModule.test.ts new file mode 100644 index 000000000..977081400 --- /dev/null +++ b/apps/api/src/persistence/analytics/AnalyticsPersistenceModule.test.ts @@ -0,0 +1,93 @@ +import 'reflect-metadata'; + +import { Test } from '@nestjs/testing'; +import type { TestingModule } from '@nestjs/testing'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + ANALYTICS_ENGAGEMENT_REPOSITORY_TOKEN, + ANALYTICS_PAGE_VIEW_REPOSITORY_TOKEN, + ANALYTICS_SNAPSHOT_REPOSITORY_TOKEN, +} from './AnalyticsPersistenceTokens'; + +describe('AnalyticsPersistenceModule', () => { + const originalEnv = { ...process.env }; + + afterEach(() => { + process.env = originalEnv; + delete (globalThis as { __GP_TEST_DATA_SOURCE__?: unknown }).__GP_TEST_DATA_SOURCE__; + vi.restoreAllMocks(); + vi.resetModules(); + vi.unmock('@nestjs/typeorm'); + }); + + it('uses inmemory providers when GRIDPILOT_API_PERSISTENCE=inmemory', async () => { + vi.resetModules(); + + process.env.GRIDPILOT_API_PERSISTENCE = 'inmemory'; + delete process.env.DATABASE_URL; + + const { AnalyticsPersistenceModule } = await import('./AnalyticsPersistenceModule'); + const { InMemoryPageViewRepository } = await import('@adapters/analytics/persistence/inmemory/InMemoryPageViewRepository'); + const { InMemoryEngagementRepository } = await import('@adapters/analytics/persistence/inmemory/InMemoryEngagementRepository'); + const { InMemoryAnalyticsSnapshotRepository } = await import('@adapters/analytics/persistence/inmemory/InMemoryAnalyticsSnapshotRepository'); + + const module: TestingModule = await Test.createTestingModule({ + imports: [AnalyticsPersistenceModule], + }).compile(); + + expect(module.get(ANALYTICS_PAGE_VIEW_REPOSITORY_TOKEN)).toBeInstanceOf(InMemoryPageViewRepository); + expect(module.get(ANALYTICS_ENGAGEMENT_REPOSITORY_TOKEN)).toBeInstanceOf(InMemoryEngagementRepository); + expect(module.get(ANALYTICS_SNAPSHOT_REPOSITORY_TOKEN)).toBeInstanceOf(InMemoryAnalyticsSnapshotRepository); + + await module.close(); + }); + + it('uses postgres providers when GRIDPILOT_API_PERSISTENCE=postgres', async () => { + vi.resetModules(); + + process.env.GRIDPILOT_API_PERSISTENCE = 'postgres'; + delete process.env.DATABASE_URL; + + const DATA_SOURCE_TOKEN = 'TEST_DATA_SOURCE_TOKEN'; + + const fakeDataSource = { + getRepository: () => ({}), + }; + + (globalThis as { __GP_TEST_DATA_SOURCE__?: unknown }).__GP_TEST_DATA_SOURCE__ = fakeDataSource; + + vi.doMock('@nestjs/typeorm', async () => { + return { + TypeOrmModule: { + forFeature: () => ({ + module: class TypeOrmFeatureStubModule {}, + providers: [ + { + provide: DATA_SOURCE_TOKEN, + useValue: (globalThis as { __GP_TEST_DATA_SOURCE__?: unknown }).__GP_TEST_DATA_SOURCE__, + }, + ], + exports: [DATA_SOURCE_TOKEN], + }), + }, + getDataSourceToken: () => DATA_SOURCE_TOKEN, + }; + }); + + const { AnalyticsPersistenceModule } = await import('./AnalyticsPersistenceModule'); + const { TypeOrmPageViewRepository } = await import('@adapters/analytics/persistence/typeorm/repositories/TypeOrmPageViewRepository'); + const { TypeOrmEngagementRepository } = await import('@adapters/analytics/persistence/typeorm/repositories/TypeOrmEngagementRepository'); + const { TypeOrmAnalyticsSnapshotRepository } = await import('@adapters/analytics/persistence/typeorm/repositories/TypeOrmAnalyticsSnapshotRepository'); + + const module: TestingModule = await Test.createTestingModule({ + imports: [AnalyticsPersistenceModule], + }).compile(); + + expect(module.get(ANALYTICS_PAGE_VIEW_REPOSITORY_TOKEN)).toBeInstanceOf(TypeOrmPageViewRepository); + expect(module.get(ANALYTICS_ENGAGEMENT_REPOSITORY_TOKEN)).toBeInstanceOf(TypeOrmEngagementRepository); + expect(module.get(ANALYTICS_SNAPSHOT_REPOSITORY_TOKEN)).toBeInstanceOf(TypeOrmAnalyticsSnapshotRepository); + + await module.close(); + }); +}); \ No newline at end of file diff --git a/apps/api/src/persistence/analytics/AnalyticsPersistenceModule.ts b/apps/api/src/persistence/analytics/AnalyticsPersistenceModule.ts new file mode 100644 index 000000000..b4825d16a --- /dev/null +++ b/apps/api/src/persistence/analytics/AnalyticsPersistenceModule.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; + +import { getApiPersistence } from '../../env'; +import { InMemoryAnalyticsPersistenceModule } from '../inmemory/InMemoryAnalyticsPersistenceModule'; +import { PostgresAnalyticsPersistenceModule } from '../postgres/PostgresAnalyticsPersistenceModule'; + +const selectedPersistenceModule = + getApiPersistence() === 'postgres' ? PostgresAnalyticsPersistenceModule : InMemoryAnalyticsPersistenceModule; + +@Module({ + imports: [selectedPersistenceModule], + exports: [selectedPersistenceModule], +}) +export class AnalyticsPersistenceModule {} \ No newline at end of file diff --git a/apps/api/src/persistence/analytics/AnalyticsPersistenceTokens.ts b/apps/api/src/persistence/analytics/AnalyticsPersistenceTokens.ts new file mode 100644 index 000000000..652bd2a94 --- /dev/null +++ b/apps/api/src/persistence/analytics/AnalyticsPersistenceTokens.ts @@ -0,0 +1,3 @@ +export const ANALYTICS_PAGE_VIEW_REPOSITORY_TOKEN = 'ANALYTICS_IPageViewRepository'; +export const ANALYTICS_ENGAGEMENT_REPOSITORY_TOKEN = 'ANALYTICS_IEngagementRepository'; +export const ANALYTICS_SNAPSHOT_REPOSITORY_TOKEN = 'ANALYTICS_IAnalyticsSnapshotRepository'; \ No newline at end of file diff --git a/apps/api/src/persistence/identity/IdentityPersistenceModule.test.ts b/apps/api/src/persistence/identity/IdentityPersistenceModule.test.ts new file mode 100644 index 000000000..92059e2a8 --- /dev/null +++ b/apps/api/src/persistence/identity/IdentityPersistenceModule.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 { USER_REPOSITORY_TOKEN } from './IdentityPersistenceTokens'; + +describe('IdentityPersistenceModule', () => { + 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 { IdentityPersistenceModule } = await import('./IdentityPersistenceModule'); + const { InMemoryUserRepository } = await import('@adapters/identity/persistence/inmemory/InMemoryUserRepository'); + + const module: TestingModule = await Test.createTestingModule({ + imports: [IdentityPersistenceModule], + }).compile(); + + const userRepo = module.get(USER_REPOSITORY_TOKEN); + expect(userRepo).toBeInstanceOf(InMemoryUserRepository); + + 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 { IdentityPersistenceModule } = await import('./IdentityPersistenceModule'); + const { PostgresIdentityPersistenceModule } = await import('../postgres/PostgresIdentityPersistenceModule'); + + const imports = Reflect.getMetadata(MODULE_METADATA.IMPORTS, IdentityPersistenceModule) as unknown[]; + expect(imports).toContain(PostgresIdentityPersistenceModule); + }); +}); \ No newline at end of file diff --git a/apps/api/src/persistence/identity/IdentityPersistenceModule.ts b/apps/api/src/persistence/identity/IdentityPersistenceModule.ts new file mode 100644 index 000000000..eec839075 --- /dev/null +++ b/apps/api/src/persistence/identity/IdentityPersistenceModule.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; + +import { getApiPersistence } from '../../env'; +import { InMemoryIdentityPersistenceModule } from '../inmemory/InMemoryIdentityPersistenceModule'; +import { PostgresIdentityPersistenceModule } from '../postgres/PostgresIdentityPersistenceModule'; + +const selectedPersistenceModule = + getApiPersistence() === 'postgres' ? PostgresIdentityPersistenceModule : InMemoryIdentityPersistenceModule; + +@Module({ + imports: [selectedPersistenceModule], + exports: [selectedPersistenceModule], +}) +export class IdentityPersistenceModule {} \ No newline at end of file diff --git a/apps/api/src/persistence/identity/IdentityPersistenceTokens.ts b/apps/api/src/persistence/identity/IdentityPersistenceTokens.ts new file mode 100644 index 000000000..2eaac652f --- /dev/null +++ b/apps/api/src/persistence/identity/IdentityPersistenceTokens.ts @@ -0,0 +1,3 @@ +export const AUTH_REPOSITORY_TOKEN = 'IAuthRepository'; +export const USER_REPOSITORY_TOKEN = 'IUserRepository'; +export const PASSWORD_HASHING_SERVICE_TOKEN = 'IPasswordHashingService'; \ No newline at end of file diff --git a/apps/api/src/persistence/inmemory/InMemoryAnalyticsPersistenceModule.ts b/apps/api/src/persistence/inmemory/InMemoryAnalyticsPersistenceModule.ts new file mode 100644 index 000000000..1fe486816 --- /dev/null +++ b/apps/api/src/persistence/inmemory/InMemoryAnalyticsPersistenceModule.ts @@ -0,0 +1,46 @@ +import { Module } from '@nestjs/common'; + +import { LoggingModule } from '../../domain/logging/LoggingModule'; + +import type { Logger } from '@core/shared/application/Logger'; + +import type { IEngagementRepository } from '@core/analytics/domain/repositories/IEngagementRepository'; +import type { IAnalyticsSnapshotRepository } from '@core/analytics/domain/repositories/IAnalyticsSnapshotRepository'; +import type { IPageViewRepository } from '@core/analytics/application/repositories/IPageViewRepository'; + +import { InMemoryAnalyticsSnapshotRepository } from '@adapters/analytics/persistence/inmemory/InMemoryAnalyticsSnapshotRepository'; +import { InMemoryEngagementRepository } from '@adapters/analytics/persistence/inmemory/InMemoryEngagementRepository'; +import { InMemoryPageViewRepository } from '@adapters/analytics/persistence/inmemory/InMemoryPageViewRepository'; + +import { + ANALYTICS_ENGAGEMENT_REPOSITORY_TOKEN, + ANALYTICS_PAGE_VIEW_REPOSITORY_TOKEN, + ANALYTICS_SNAPSHOT_REPOSITORY_TOKEN, +} from '../analytics/AnalyticsPersistenceTokens'; + +@Module({ + imports: [LoggingModule], + providers: [ + { + provide: ANALYTICS_PAGE_VIEW_REPOSITORY_TOKEN, + useFactory: (logger: Logger): IPageViewRepository => new InMemoryPageViewRepository(logger), + inject: ['Logger'], + }, + { + provide: ANALYTICS_ENGAGEMENT_REPOSITORY_TOKEN, + useFactory: (logger: Logger): IEngagementRepository => new InMemoryEngagementRepository(logger), + inject: ['Logger'], + }, + { + provide: ANALYTICS_SNAPSHOT_REPOSITORY_TOKEN, + useFactory: (logger: Logger): IAnalyticsSnapshotRepository => new InMemoryAnalyticsSnapshotRepository(logger), + inject: ['Logger'], + }, + ], + exports: [ + ANALYTICS_PAGE_VIEW_REPOSITORY_TOKEN, + ANALYTICS_ENGAGEMENT_REPOSITORY_TOKEN, + ANALYTICS_SNAPSHOT_REPOSITORY_TOKEN, + ], +}) +export class InMemoryAnalyticsPersistenceModule {} \ No newline at end of file diff --git a/apps/api/src/persistence/inmemory/InMemoryIdentityPersistenceModule.ts b/apps/api/src/persistence/inmemory/InMemoryIdentityPersistenceModule.ts new file mode 100644 index 000000000..c60d3f426 --- /dev/null +++ b/apps/api/src/persistence/inmemory/InMemoryIdentityPersistenceModule.ts @@ -0,0 +1,49 @@ +import { Module } from '@nestjs/common'; + +import { LoggingModule } from '../../domain/logging/LoggingModule'; + +import type { Logger } from '@core/shared/application/Logger'; +import type { IPasswordHashingService } from '@core/identity/domain/services/PasswordHashingService'; +import type { StoredUser } from '@core/identity/domain/repositories/IUserRepository'; + +import { InMemoryAuthRepository } from '@adapters/identity/persistence/inmemory/InMemoryAuthRepository'; +import { InMemoryUserRepository } from '@adapters/identity/persistence/inmemory/InMemoryUserRepository'; +import { InMemoryPasswordHashingService } from '@adapters/identity/services/InMemoryPasswordHashingService'; + +import { AUTH_REPOSITORY_TOKEN, PASSWORD_HASHING_SERVICE_TOKEN, USER_REPOSITORY_TOKEN } from '../identity/IdentityPersistenceTokens'; + +@Module({ + imports: [LoggingModule], + providers: [ + { + provide: USER_REPOSITORY_TOKEN, + useFactory: (logger: Logger) => { + const initialUsers: StoredUser[] = [ + { + // Match seeded racing driver id so dashboard works in inmemory mode. + id: 'driver-1', + email: 'admin@gridpilot.local', + passwordHash: 'demo_salt_321nimda', // InMemoryPasswordHashingService: "admin123" reversed. + displayName: 'Admin', + salt: '', + createdAt: new Date(), + }, + ]; + return new InMemoryUserRepository(logger, initialUsers); + }, + inject: ['Logger'], + }, + { + provide: AUTH_REPOSITORY_TOKEN, + useFactory: (userRepository: InMemoryUserRepository, passwordHashingService: IPasswordHashingService, logger: Logger) => + new InMemoryAuthRepository(userRepository, passwordHashingService, logger), + inject: [USER_REPOSITORY_TOKEN, PASSWORD_HASHING_SERVICE_TOKEN, 'Logger'], + }, + { + provide: PASSWORD_HASHING_SERVICE_TOKEN, + useClass: InMemoryPasswordHashingService, + }, + ], + exports: [USER_REPOSITORY_TOKEN, AUTH_REPOSITORY_TOKEN, PASSWORD_HASHING_SERVICE_TOKEN], +}) +export class InMemoryIdentityPersistenceModule {} \ No newline at end of file diff --git a/apps/api/src/persistence/inmemory/InMemoryPaymentsPersistenceModule.ts b/apps/api/src/persistence/inmemory/InMemoryPaymentsPersistenceModule.ts new file mode 100644 index 000000000..d08b4de91 --- /dev/null +++ b/apps/api/src/persistence/inmemory/InMemoryPaymentsPersistenceModule.ts @@ -0,0 +1,72 @@ +import { Module } from '@nestjs/common'; + +import { LoggingModule } from '../../domain/logging/LoggingModule'; + +import type { Logger } from '@core/shared/application/Logger'; + +import type { IPaymentRepository } from '@core/payments/domain/repositories/IPaymentRepository'; +import type { + IMemberPaymentRepository, + IMembershipFeeRepository, +} from '@core/payments/domain/repositories/IMembershipFeeRepository'; +import type { IPrizeRepository } from '@core/payments/domain/repositories/IPrizeRepository'; +import type { ITransactionRepository, IWalletRepository } from '@core/payments/domain/repositories/IWalletRepository'; + +import { InMemoryMembershipFeeRepository, InMemoryMemberPaymentRepository } from '@adapters/payments/persistence/inmemory/InMemoryMembershipFeeRepository'; +import { InMemoryPaymentRepository } from '@adapters/payments/persistence/inmemory/InMemoryPaymentRepository'; +import { InMemoryPrizeRepository } from '@adapters/payments/persistence/inmemory/InMemoryPrizeRepository'; +import { InMemoryTransactionRepository, InMemoryWalletRepository } from '@adapters/payments/persistence/inmemory/InMemoryWalletRepository'; + +import { + PAYMENTS_MEMBER_PAYMENT_REPOSITORY_TOKEN, + PAYMENTS_MEMBERSHIP_FEE_REPOSITORY_TOKEN, + PAYMENTS_PAYMENT_REPOSITORY_TOKEN, + PAYMENTS_PRIZE_REPOSITORY_TOKEN, + PAYMENTS_TRANSACTION_REPOSITORY_TOKEN, + PAYMENTS_WALLET_REPOSITORY_TOKEN, +} from '../payments/PaymentsPersistenceTokens'; + +@Module({ + imports: [LoggingModule], + providers: [ + { + provide: PAYMENTS_PAYMENT_REPOSITORY_TOKEN, + useFactory: (logger: Logger): IPaymentRepository => new InMemoryPaymentRepository(logger), + inject: ['Logger'], + }, + { + provide: PAYMENTS_MEMBERSHIP_FEE_REPOSITORY_TOKEN, + useFactory: (logger: Logger): IMembershipFeeRepository => new InMemoryMembershipFeeRepository(logger), + inject: ['Logger'], + }, + { + provide: PAYMENTS_MEMBER_PAYMENT_REPOSITORY_TOKEN, + useFactory: (logger: Logger): IMemberPaymentRepository => new InMemoryMemberPaymentRepository(logger), + inject: ['Logger'], + }, + { + provide: PAYMENTS_PRIZE_REPOSITORY_TOKEN, + useFactory: (logger: Logger): IPrizeRepository => new InMemoryPrizeRepository(logger), + inject: ['Logger'], + }, + { + provide: PAYMENTS_WALLET_REPOSITORY_TOKEN, + useFactory: (logger: Logger): IWalletRepository => new InMemoryWalletRepository(logger), + inject: ['Logger'], + }, + { + provide: PAYMENTS_TRANSACTION_REPOSITORY_TOKEN, + useFactory: (logger: Logger): ITransactionRepository => new InMemoryTransactionRepository(logger), + inject: ['Logger'], + }, + ], + exports: [ + PAYMENTS_PAYMENT_REPOSITORY_TOKEN, + PAYMENTS_MEMBERSHIP_FEE_REPOSITORY_TOKEN, + PAYMENTS_MEMBER_PAYMENT_REPOSITORY_TOKEN, + PAYMENTS_PRIZE_REPOSITORY_TOKEN, + PAYMENTS_WALLET_REPOSITORY_TOKEN, + PAYMENTS_TRANSACTION_REPOSITORY_TOKEN, + ], +}) +export class InMemoryPaymentsPersistenceModule {} diff --git a/apps/api/src/persistence/inmemory/InMemorySocialPersistenceModule.ts b/apps/api/src/persistence/inmemory/InMemorySocialPersistenceModule.ts index 39056f9be..493d34879 100644 --- a/apps/api/src/persistence/inmemory/InMemorySocialPersistenceModule.ts +++ b/apps/api/src/persistence/inmemory/InMemorySocialPersistenceModule.ts @@ -12,14 +12,13 @@ import { InMemorySocialGraphRepository, } from '@adapters/social/persistence/inmemory/InMemorySocialAndFeed'; -export const FEED_REPOSITORY_TOKEN = 'IFeedRepository'; -export const SOCIAL_GRAPH_REPOSITORY_TOKEN = 'ISocialGraphRepository'; +import { SOCIAL_FEED_REPOSITORY_TOKEN, SOCIAL_GRAPH_REPOSITORY_TOKEN } from '../social/SocialPersistenceTokens'; @Module({ imports: [LoggingModule], providers: [ { - provide: FEED_REPOSITORY_TOKEN, + provide: SOCIAL_FEED_REPOSITORY_TOKEN, useFactory: (logger: Logger): IFeedRepository => new InMemoryFeedRepository(logger, { drivers: [], friendships: [], feedEvents: [] }), inject: ['Logger'], @@ -31,6 +30,6 @@ export const SOCIAL_GRAPH_REPOSITORY_TOKEN = 'ISocialGraphRepository'; inject: ['Logger'], }, ], - exports: [FEED_REPOSITORY_TOKEN, SOCIAL_GRAPH_REPOSITORY_TOKEN], + exports: [SOCIAL_FEED_REPOSITORY_TOKEN, SOCIAL_GRAPH_REPOSITORY_TOKEN], }) export class InMemorySocialPersistenceModule {} \ No newline at end of file diff --git a/apps/api/src/persistence/payments/PaymentsPersistenceModule.test.ts b/apps/api/src/persistence/payments/PaymentsPersistenceModule.test.ts new file mode 100644 index 000000000..9ea58e12c --- /dev/null +++ b/apps/api/src/persistence/payments/PaymentsPersistenceModule.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 { PAYMENTS_WALLET_REPOSITORY_TOKEN } from './PaymentsPersistenceTokens'; + +describe('PaymentsPersistenceModule', () => { + 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 { PaymentsPersistenceModule } = await import('./PaymentsPersistenceModule'); + const { InMemoryWalletRepository } = await import('@adapters/payments/persistence/inmemory/InMemoryWalletRepository'); + + const module: TestingModule = await Test.createTestingModule({ + imports: [PaymentsPersistenceModule], + }).compile(); + + const walletRepo = module.get(PAYMENTS_WALLET_REPOSITORY_TOKEN); + expect(walletRepo).toBeInstanceOf(InMemoryWalletRepository); + + 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 { PaymentsPersistenceModule } = await import('./PaymentsPersistenceModule'); + const { PostgresPaymentsPersistenceModule } = await import('../postgres/PostgresPaymentsPersistenceModule'); + + const imports = Reflect.getMetadata(MODULE_METADATA.IMPORTS, PaymentsPersistenceModule) as unknown[]; + expect(imports).toContain(PostgresPaymentsPersistenceModule); + }); +}); diff --git a/apps/api/src/persistence/payments/PaymentsPersistenceModule.ts b/apps/api/src/persistence/payments/PaymentsPersistenceModule.ts new file mode 100644 index 000000000..463b91342 --- /dev/null +++ b/apps/api/src/persistence/payments/PaymentsPersistenceModule.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; + +import { getApiPersistence } from '../../env'; +import { InMemoryPaymentsPersistenceModule } from '../inmemory/InMemoryPaymentsPersistenceModule'; +import { PostgresPaymentsPersistenceModule } from '../postgres/PostgresPaymentsPersistenceModule'; + +const selectedPersistenceModule = + getApiPersistence() === 'postgres' ? PostgresPaymentsPersistenceModule : InMemoryPaymentsPersistenceModule; + +@Module({ + imports: [selectedPersistenceModule], + exports: [selectedPersistenceModule], +}) +export class PaymentsPersistenceModule {} diff --git a/apps/api/src/persistence/payments/PaymentsPersistenceTokens.ts b/apps/api/src/persistence/payments/PaymentsPersistenceTokens.ts new file mode 100644 index 000000000..dcc4f9921 --- /dev/null +++ b/apps/api/src/persistence/payments/PaymentsPersistenceTokens.ts @@ -0,0 +1,6 @@ +export const PAYMENTS_PAYMENT_REPOSITORY_TOKEN = 'PAYMENTS_IPaymentRepository'; +export const PAYMENTS_MEMBERSHIP_FEE_REPOSITORY_TOKEN = 'PAYMENTS_IMembershipFeeRepository'; +export const PAYMENTS_MEMBER_PAYMENT_REPOSITORY_TOKEN = 'PAYMENTS_IMemberPaymentRepository'; +export const PAYMENTS_PRIZE_REPOSITORY_TOKEN = 'PAYMENTS_IPrizeRepository'; +export const PAYMENTS_WALLET_REPOSITORY_TOKEN = 'PAYMENTS_IWalletRepository'; +export const PAYMENTS_TRANSACTION_REPOSITORY_TOKEN = 'PAYMENTS_ITransactionRepository'; diff --git a/apps/api/src/persistence/postgres/PostgresAnalyticsPersistenceModule.ts b/apps/api/src/persistence/postgres/PostgresAnalyticsPersistenceModule.ts new file mode 100644 index 000000000..9df1d6865 --- /dev/null +++ b/apps/api/src/persistence/postgres/PostgresAnalyticsPersistenceModule.ts @@ -0,0 +1,56 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule, getDataSourceToken } from '@nestjs/typeorm'; +import type { DataSource } from 'typeorm'; + +import { LoggingModule } from '../../domain/logging/LoggingModule'; +import { + ANALYTICS_ENGAGEMENT_REPOSITORY_TOKEN, + ANALYTICS_PAGE_VIEW_REPOSITORY_TOKEN, + ANALYTICS_SNAPSHOT_REPOSITORY_TOKEN, +} from '../analytics/AnalyticsPersistenceTokens'; + +import { AnalyticsSnapshotOrmEntity } from '@adapters/analytics/persistence/typeorm/entities/AnalyticsSnapshotOrmEntity'; +import { EngagementEventOrmEntity } from '@adapters/analytics/persistence/typeorm/entities/EngagementEventOrmEntity'; +import { PageViewOrmEntity } from '@adapters/analytics/persistence/typeorm/entities/PageViewOrmEntity'; + +import { AnalyticsSnapshotOrmMapper } from '@adapters/analytics/persistence/typeorm/mappers/AnalyticsSnapshotOrmMapper'; +import { EngagementEventOrmMapper } from '@adapters/analytics/persistence/typeorm/mappers/EngagementEventOrmMapper'; +import { PageViewOrmMapper } from '@adapters/analytics/persistence/typeorm/mappers/PageViewOrmMapper'; + +import { TypeOrmAnalyticsSnapshotRepository } from '@adapters/analytics/persistence/typeorm/repositories/TypeOrmAnalyticsSnapshotRepository'; +import { TypeOrmEngagementRepository } from '@adapters/analytics/persistence/typeorm/repositories/TypeOrmEngagementRepository'; +import { TypeOrmPageViewRepository } from '@adapters/analytics/persistence/typeorm/repositories/TypeOrmPageViewRepository'; + +const typeOrmFeatureImports = [ + TypeOrmModule.forFeature([PageViewOrmEntity, EngagementEventOrmEntity, AnalyticsSnapshotOrmEntity]), +]; + +@Module({ + imports: [LoggingModule, ...typeOrmFeatureImports], + providers: [ + { provide: PageViewOrmMapper, useFactory: () => new PageViewOrmMapper() }, + { provide: EngagementEventOrmMapper, useFactory: () => new EngagementEventOrmMapper() }, + { provide: AnalyticsSnapshotOrmMapper, useFactory: () => new AnalyticsSnapshotOrmMapper() }, + + { + provide: ANALYTICS_PAGE_VIEW_REPOSITORY_TOKEN, + useFactory: (dataSource: DataSource, mapper: PageViewOrmMapper) => + new TypeOrmPageViewRepository(dataSource.getRepository(PageViewOrmEntity), mapper), + inject: [getDataSourceToken(), PageViewOrmMapper], + }, + { + provide: ANALYTICS_ENGAGEMENT_REPOSITORY_TOKEN, + useFactory: (dataSource: DataSource, mapper: EngagementEventOrmMapper) => + new TypeOrmEngagementRepository(dataSource.getRepository(EngagementEventOrmEntity), mapper), + inject: [getDataSourceToken(), EngagementEventOrmMapper], + }, + { + provide: ANALYTICS_SNAPSHOT_REPOSITORY_TOKEN, + useFactory: (dataSource: DataSource, mapper: AnalyticsSnapshotOrmMapper) => + new TypeOrmAnalyticsSnapshotRepository(dataSource.getRepository(AnalyticsSnapshotOrmEntity), mapper), + inject: [getDataSourceToken(), AnalyticsSnapshotOrmMapper], + }, + ], + exports: [ANALYTICS_PAGE_VIEW_REPOSITORY_TOKEN, ANALYTICS_ENGAGEMENT_REPOSITORY_TOKEN, ANALYTICS_SNAPSHOT_REPOSITORY_TOKEN], +}) +export class PostgresAnalyticsPersistenceModule {} \ No newline at end of file diff --git a/apps/api/src/persistence/postgres/PostgresIdentityPersistenceModule.ts b/apps/api/src/persistence/postgres/PostgresIdentityPersistenceModule.ts new file mode 100644 index 000000000..17cc2376e --- /dev/null +++ b/apps/api/src/persistence/postgres/PostgresIdentityPersistenceModule.ts @@ -0,0 +1,40 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule, getDataSourceToken } from '@nestjs/typeorm'; +import type { DataSource } from 'typeorm'; + +import { UserOrmEntity } from '@adapters/identity/persistence/typeorm/entities/UserOrmEntity'; +import { TypeOrmAuthRepository } from '@adapters/identity/persistence/typeorm/TypeOrmAuthRepository'; +import { TypeOrmUserRepository } from '@adapters/identity/persistence/typeorm/TypeOrmUserRepository'; +import { UserOrmMapper } from '@adapters/identity/persistence/typeorm/mappers/UserOrmMapper'; +import { InMemoryPasswordHashingService } from '@adapters/identity/services/InMemoryPasswordHashingService'; + +import { + AUTH_REPOSITORY_TOKEN, + PASSWORD_HASHING_SERVICE_TOKEN, + USER_REPOSITORY_TOKEN, +} from '../identity/IdentityPersistenceTokens'; + +const typeOrmFeatureImports = [TypeOrmModule.forFeature([UserOrmEntity])]; + +@Module({ + imports: [...typeOrmFeatureImports], + providers: [ + { provide: UserOrmMapper, useFactory: () => new UserOrmMapper() }, + { + provide: USER_REPOSITORY_TOKEN, + useFactory: (dataSource: DataSource, mapper: UserOrmMapper) => new TypeOrmUserRepository(dataSource, mapper), + inject: [getDataSourceToken(), UserOrmMapper], + }, + { + provide: AUTH_REPOSITORY_TOKEN, + useFactory: (dataSource: DataSource, mapper: UserOrmMapper) => new TypeOrmAuthRepository(dataSource, mapper), + inject: [getDataSourceToken(), UserOrmMapper], + }, + { + provide: PASSWORD_HASHING_SERVICE_TOKEN, + useClass: InMemoryPasswordHashingService, + }, + ], + exports: [USER_REPOSITORY_TOKEN, AUTH_REPOSITORY_TOKEN, PASSWORD_HASHING_SERVICE_TOKEN], +}) +export class PostgresIdentityPersistenceModule {} \ No newline at end of file diff --git a/apps/api/src/persistence/postgres/PostgresPaymentsPersistenceModule.ts b/apps/api/src/persistence/postgres/PostgresPaymentsPersistenceModule.ts new file mode 100644 index 000000000..df06f6052 --- /dev/null +++ b/apps/api/src/persistence/postgres/PostgresPaymentsPersistenceModule.ts @@ -0,0 +1,105 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule, getDataSourceToken } from '@nestjs/typeorm'; +import type { DataSource } from 'typeorm'; + +import { LoggingModule } from '../../domain/logging/LoggingModule'; + +import type { IPaymentRepository } from '@core/payments/domain/repositories/IPaymentRepository'; +import type { IMemberPaymentRepository, IMembershipFeeRepository } from '@core/payments/domain/repositories/IMembershipFeeRepository'; +import type { IPrizeRepository } from '@core/payments/domain/repositories/IPrizeRepository'; +import type { ITransactionRepository, IWalletRepository } from '@core/payments/domain/repositories/IWalletRepository'; + +import { PaymentsMemberPaymentOrmEntity } from '@adapters/payments/persistence/typeorm/entities/PaymentsMemberPaymentOrmEntity'; +import { PaymentsMembershipFeeOrmEntity } from '@adapters/payments/persistence/typeorm/entities/PaymentsMembershipFeeOrmEntity'; +import { PaymentsPaymentOrmEntity } from '@adapters/payments/persistence/typeorm/entities/PaymentsPaymentOrmEntity'; +import { PaymentsPrizeOrmEntity } from '@adapters/payments/persistence/typeorm/entities/PaymentsPrizeOrmEntity'; +import { PaymentsTransactionOrmEntity } from '@adapters/payments/persistence/typeorm/entities/PaymentsTransactionOrmEntity'; +import { PaymentsWalletOrmEntity } from '@adapters/payments/persistence/typeorm/entities/PaymentsWalletOrmEntity'; + +import { PaymentsMemberPaymentOrmMapper } from '@adapters/payments/persistence/typeorm/mappers/PaymentsMemberPaymentOrmMapper'; +import { PaymentsMembershipFeeOrmMapper } from '@adapters/payments/persistence/typeorm/mappers/PaymentsMembershipFeeOrmMapper'; +import { PaymentsPaymentOrmMapper } from '@adapters/payments/persistence/typeorm/mappers/PaymentsPaymentOrmMapper'; +import { PaymentsPrizeOrmMapper } from '@adapters/payments/persistence/typeorm/mappers/PaymentsPrizeOrmMapper'; +import { PaymentsWalletOrmMapper } from '@adapters/payments/persistence/typeorm/mappers/PaymentsWalletOrmMapper'; + +import { TypeOrmMemberPaymentRepository, TypeOrmMembershipFeeRepository } from '@adapters/payments/persistence/typeorm/repositories/TypeOrmMembershipFeeRepository'; +import { TypeOrmPaymentRepository } from '@adapters/payments/persistence/typeorm/repositories/TypeOrmPaymentRepository'; +import { TypeOrmPrizeRepository } from '@adapters/payments/persistence/typeorm/repositories/TypeOrmPrizeRepository'; +import { TypeOrmTransactionRepository, TypeOrmWalletRepository } from '@adapters/payments/persistence/typeorm/repositories/TypeOrmWalletRepository'; + +import { + PAYMENTS_MEMBER_PAYMENT_REPOSITORY_TOKEN, + PAYMENTS_MEMBERSHIP_FEE_REPOSITORY_TOKEN, + PAYMENTS_PAYMENT_REPOSITORY_TOKEN, + PAYMENTS_PRIZE_REPOSITORY_TOKEN, + PAYMENTS_TRANSACTION_REPOSITORY_TOKEN, + PAYMENTS_WALLET_REPOSITORY_TOKEN, +} from '../payments/PaymentsPersistenceTokens'; + +const typeOrmFeatureImports = [ + TypeOrmModule.forFeature([ + PaymentsWalletOrmEntity, + PaymentsTransactionOrmEntity, + PaymentsPaymentOrmEntity, + PaymentsPrizeOrmEntity, + PaymentsMembershipFeeOrmEntity, + PaymentsMemberPaymentOrmEntity, + ]), +]; + +@Module({ + imports: [LoggingModule, ...typeOrmFeatureImports], + providers: [ + { provide: PaymentsWalletOrmMapper, useFactory: () => new PaymentsWalletOrmMapper() }, + { provide: PaymentsPaymentOrmMapper, useFactory: () => new PaymentsPaymentOrmMapper() }, + { provide: PaymentsPrizeOrmMapper, useFactory: () => new PaymentsPrizeOrmMapper() }, + { provide: PaymentsMembershipFeeOrmMapper, useFactory: () => new PaymentsMembershipFeeOrmMapper() }, + { provide: PaymentsMemberPaymentOrmMapper, useFactory: () => new PaymentsMemberPaymentOrmMapper() }, + + { + provide: PAYMENTS_WALLET_REPOSITORY_TOKEN, + useFactory: (dataSource: DataSource, mapper: PaymentsWalletOrmMapper): IWalletRepository => + new TypeOrmWalletRepository(dataSource, mapper), + inject: [getDataSourceToken(), PaymentsWalletOrmMapper], + }, + { + provide: PAYMENTS_TRANSACTION_REPOSITORY_TOKEN, + useFactory: (dataSource: DataSource, mapper: PaymentsWalletOrmMapper): ITransactionRepository => + new TypeOrmTransactionRepository(dataSource, mapper), + inject: [getDataSourceToken(), PaymentsWalletOrmMapper], + }, + { + provide: PAYMENTS_PAYMENT_REPOSITORY_TOKEN, + useFactory: (dataSource: DataSource, mapper: PaymentsPaymentOrmMapper): IPaymentRepository => + new TypeOrmPaymentRepository(dataSource, mapper), + inject: [getDataSourceToken(), PaymentsPaymentOrmMapper], + }, + { + provide: PAYMENTS_PRIZE_REPOSITORY_TOKEN, + useFactory: (dataSource: DataSource, mapper: PaymentsPrizeOrmMapper): IPrizeRepository => + new TypeOrmPrizeRepository(dataSource, mapper), + inject: [getDataSourceToken(), PaymentsPrizeOrmMapper], + }, + { + provide: PAYMENTS_MEMBERSHIP_FEE_REPOSITORY_TOKEN, + useFactory: (dataSource: DataSource, mapper: PaymentsMembershipFeeOrmMapper): IMembershipFeeRepository => + new TypeOrmMembershipFeeRepository(dataSource, mapper), + inject: [getDataSourceToken(), PaymentsMembershipFeeOrmMapper], + }, + { + provide: PAYMENTS_MEMBER_PAYMENT_REPOSITORY_TOKEN, + useFactory: (dataSource: DataSource, mapper: PaymentsMemberPaymentOrmMapper): IMemberPaymentRepository => + new TypeOrmMemberPaymentRepository(dataSource, mapper), + inject: [getDataSourceToken(), PaymentsMemberPaymentOrmMapper], + }, + ], + exports: [ + PAYMENTS_WALLET_REPOSITORY_TOKEN, + PAYMENTS_TRANSACTION_REPOSITORY_TOKEN, + PAYMENTS_PAYMENT_REPOSITORY_TOKEN, + PAYMENTS_PRIZE_REPOSITORY_TOKEN, + PAYMENTS_MEMBERSHIP_FEE_REPOSITORY_TOKEN, + PAYMENTS_MEMBER_PAYMENT_REPOSITORY_TOKEN, + ], +}) +export class PostgresPaymentsPersistenceModule {} diff --git a/apps/api/src/persistence/postgres/PostgresRacingPersistenceModule.ts b/apps/api/src/persistence/postgres/PostgresRacingPersistenceModule.ts index c127e5933..8b51fdef2 100644 --- a/apps/api/src/persistence/postgres/PostgresRacingPersistenceModule.ts +++ b/apps/api/src/persistence/postgres/PostgresRacingPersistenceModule.ts @@ -1,6 +1,6 @@ import { Module } from '@nestjs/common'; -import { TypeOrmModule, getDataSourceToken } from '@nestjs/typeorm'; -import type { DataSource } from 'typeorm'; +import { TypeOrmModule, getDataSourceToken, getRepositoryToken } from '@nestjs/typeorm'; +import type { DataSource, Repository } from 'typeorm'; import { LoggingModule } from '../../domain/logging/LoggingModule'; @@ -27,112 +27,240 @@ import { TRANSACTION_REPOSITORY_TOKEN, } from '../inmemory/InMemoryRacingPersistenceModule'; +import { DriverOrmEntity } from '@adapters/racing/persistence/typeorm/entities/DriverOrmEntity'; +import { LeagueMembershipOrmEntity } from '@adapters/racing/persistence/typeorm/entities/LeagueMembershipOrmEntity'; 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 { RaceRegistrationOrmEntity } from '@adapters/racing/persistence/typeorm/entities/RaceRegistrationOrmEntity'; import { SeasonOrmEntity } from '@adapters/racing/persistence/typeorm/entities/SeasonOrmEntity'; +import { ResultOrmEntity } from '@adapters/racing/persistence/typeorm/entities/ResultOrmEntity'; +import { StandingOrmEntity } from '@adapters/racing/persistence/typeorm/entities/StandingOrmEntity'; +import { + GameOrmEntity, + LeagueWalletOrmEntity, + PenaltyOrmEntity, + ProtestOrmEntity, + SeasonSponsorshipOrmEntity, + SponsorOrmEntity, + SponsorshipPricingOrmEntity, + SponsorshipRequestOrmEntity, + TransactionOrmEntity, +} from '@adapters/racing/persistence/typeorm/entities/MissingRacingOrmEntities'; +import { + TeamJoinRequestOrmEntity, + TeamMembershipOrmEntity, + TeamOrmEntity, +} from '@adapters/racing/persistence/typeorm/entities/TeamOrmEntities'; +import { TypeOrmDriverRepository } from '@adapters/racing/persistence/typeorm/repositories/TypeOrmDriverRepository'; +import { TypeOrmLeagueMembershipRepository } from '@adapters/racing/persistence/typeorm/repositories/TypeOrmLeagueMembershipRepository'; import { TypeOrmLeagueRepository } from '@adapters/racing/persistence/typeorm/repositories/TypeOrmLeagueRepository'; import { TypeOrmLeagueScoringConfigRepository } from '@adapters/racing/persistence/typeorm/repositories/TypeOrmLeagueScoringConfigRepository'; +import { TypeOrmRaceRegistrationRepository } from '@adapters/racing/persistence/typeorm/repositories/TypeOrmRaceRegistrationRepository'; import { TypeOrmRaceRepository } from '@adapters/racing/persistence/typeorm/repositories/TypeOrmRaceRepository'; import { TypeOrmSeasonRepository } from '@adapters/racing/persistence/typeorm/repositories/TypeOrmSeasonRepository'; +import { TypeOrmResultRepository } from '@adapters/racing/persistence/typeorm/repositories/TypeOrmResultRepository'; +import { TypeOrmStandingRepository } from '@adapters/racing/persistence/typeorm/repositories/TypeOrmStandingRepository'; +import { + TypeOrmGameRepository, + TypeOrmLeagueWalletRepository, + TypeOrmSeasonSponsorshipRepository, + TypeOrmSponsorRepository, + TypeOrmSponsorshipPricingRepository, + TypeOrmSponsorshipRequestRepository, + TypeOrmTransactionRepository, +} from '@adapters/racing/persistence/typeorm/repositories/CommerceTypeOrmRepositories'; +import { TypeOrmPenaltyRepository, TypeOrmProtestRepository } from '@adapters/racing/persistence/typeorm/repositories/StewardingTypeOrmRepositories'; +import { TypeOrmTeamMembershipRepository, TypeOrmTeamRepository } from '@adapters/racing/persistence/typeorm/repositories/TeamTypeOrmRepositories'; + +import { DriverOrmMapper } from '@adapters/racing/persistence/typeorm/mappers/DriverOrmMapper'; +import { LeagueMembershipOrmMapper } from '@adapters/racing/persistence/typeorm/mappers/LeagueMembershipOrmMapper'; import { LeagueOrmMapper } from '@adapters/racing/persistence/typeorm/mappers/LeagueOrmMapper'; +import { RaceRegistrationOrmMapper } from '@adapters/racing/persistence/typeorm/mappers/RaceRegistrationOrmMapper'; import { RaceOrmMapper } from '@adapters/racing/persistence/typeorm/mappers/RaceOrmMapper'; import { SeasonOrmMapper } from '@adapters/racing/persistence/typeorm/mappers/SeasonOrmMapper'; +import { ResultOrmMapper } from '@adapters/racing/persistence/typeorm/mappers/ResultOrmMapper'; +import { StandingOrmMapper } from '@adapters/racing/persistence/typeorm/mappers/StandingOrmMapper'; 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'; +import { + GameOrmMapper, + LeagueWalletOrmMapper, + SeasonSponsorshipOrmMapper, + SponsorOrmMapper, + SponsorshipPricingOrmMapper, + SponsorshipRequestOrmMapper, + TransactionOrmMapper, +} from '@adapters/racing/persistence/typeorm/mappers/CommerceOrmMappers'; +import { MoneyOrmMapper } from '@adapters/racing/persistence/typeorm/mappers/MoneyOrmMapper'; +import { PenaltyOrmMapper, ProtestOrmMapper } from '@adapters/racing/persistence/typeorm/mappers/StewardingOrmMappers'; +import { TeamMembershipOrmMapper, TeamOrmMapper } from '@adapters/racing/persistence/typeorm/mappers/TeamOrmMappers'; -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`); - }, - }); -} +import { getPointsSystems } from '@adapters/bootstrap/PointsSystems'; + +const RACING_POINTS_SYSTEMS_TOKEN = 'RACING_POINTS_SYSTEMS_TOKEN'; const typeOrmFeatureImports = [ - TypeOrmModule.forFeature([LeagueOrmEntity, SeasonOrmEntity, RaceOrmEntity, LeagueScoringConfigOrmEntity]), + TypeOrmModule.forFeature([ + DriverOrmEntity, + LeagueMembershipOrmEntity, + LeagueOrmEntity, + SeasonOrmEntity, + RaceOrmEntity, + RaceRegistrationOrmEntity, + LeagueScoringConfigOrmEntity, + ResultOrmEntity, + StandingOrmEntity, + + TeamOrmEntity, + TeamMembershipOrmEntity, + TeamJoinRequestOrmEntity, + + PenaltyOrmEntity, + ProtestOrmEntity, + + GameOrmEntity, + LeagueWalletOrmEntity, + TransactionOrmEntity, + + SponsorOrmEntity, + SponsorshipPricingOrmEntity, + SponsorshipRequestOrmEntity, + SeasonSponsorshipOrmEntity, + ]), ]; @Module({ imports: [LoggingModule, ...typeOrmFeatureImports], providers: [ + { provide: DriverOrmMapper, useFactory: () => new DriverOrmMapper() }, + { provide: LeagueMembershipOrmMapper, useFactory: () => new LeagueMembershipOrmMapper() }, + { provide: RaceRegistrationOrmMapper, useFactory: () => new RaceRegistrationOrmMapper() }, + { provide: ResultOrmMapper, useFactory: () => new ResultOrmMapper() }, + { provide: StandingOrmMapper, useFactory: () => new StandingOrmMapper() }, + { provide: LeagueOrmMapper, useFactory: () => new LeagueOrmMapper() }, + { provide: RaceOrmMapper, useFactory: () => new RaceOrmMapper() }, + { provide: SeasonOrmMapper, useFactory: () => new SeasonOrmMapper() }, + + { provide: TeamOrmMapper, useFactory: () => new TeamOrmMapper() }, + { provide: TeamMembershipOrmMapper, useFactory: () => new TeamMembershipOrmMapper() }, + + { provide: PenaltyOrmMapper, useFactory: () => new PenaltyOrmMapper() }, + { provide: ProtestOrmMapper, useFactory: () => new ProtestOrmMapper() }, + + { provide: MoneyOrmMapper, useFactory: () => new MoneyOrmMapper() }, + { provide: GameOrmMapper, useFactory: () => new GameOrmMapper() }, + { provide: SponsorOrmMapper, useFactory: () => new SponsorOrmMapper() }, + { + provide: LeagueWalletOrmMapper, + useFactory: (moneyMapper: MoneyOrmMapper) => new LeagueWalletOrmMapper(moneyMapper), + inject: [MoneyOrmMapper], + }, + { + provide: TransactionOrmMapper, + useFactory: (moneyMapper: MoneyOrmMapper) => new TransactionOrmMapper(moneyMapper), + inject: [MoneyOrmMapper], + }, + { + provide: SponsorshipPricingOrmMapper, + useFactory: (moneyMapper: MoneyOrmMapper) => new SponsorshipPricingOrmMapper(moneyMapper), + inject: [MoneyOrmMapper], + }, + { + provide: SponsorshipRequestOrmMapper, + useFactory: (moneyMapper: MoneyOrmMapper) => new SponsorshipRequestOrmMapper(moneyMapper), + inject: [MoneyOrmMapper], + }, + { + provide: SeasonSponsorshipOrmMapper, + useFactory: (moneyMapper: MoneyOrmMapper) => new SeasonSponsorshipOrmMapper(moneyMapper), + inject: [MoneyOrmMapper], + }, + + { provide: RACING_POINTS_SYSTEMS_TOKEN, useFactory: () => getPointsSystems() }, { provide: DRIVER_REPOSITORY_TOKEN, - useFactory: () => makePlaceholder(DRIVER_REPOSITORY_TOKEN), - inject: ['Logger'], + useFactory: (dataSource: DataSource, mapper: DriverOrmMapper) => new TypeOrmDriverRepository(dataSource, mapper), + inject: [getDataSourceToken(), DriverOrmMapper], }, { provide: LEAGUE_REPOSITORY_TOKEN, - useFactory: (dataSource: DataSource) => { - const leagueMapper = new LeagueOrmMapper(); - return new TypeOrmLeagueRepository(dataSource, leagueMapper); - }, - inject: [getDataSourceToken()], + useFactory: (dataSource: DataSource, mapper: LeagueOrmMapper) => new TypeOrmLeagueRepository(dataSource, mapper), + inject: [getDataSourceToken(), LeagueOrmMapper], }, { provide: RACE_REPOSITORY_TOKEN, - useFactory: (dataSource: DataSource) => { - const raceMapper = new RaceOrmMapper(); - return new TypeOrmRaceRepository(dataSource, raceMapper); - }, - inject: [getDataSourceToken()], + useFactory: (dataSource: DataSource, mapper: RaceOrmMapper) => new TypeOrmRaceRepository(dataSource, mapper), + inject: [getDataSourceToken(), RaceOrmMapper], }, { provide: RESULT_REPOSITORY_TOKEN, - useFactory: () => makePlaceholder(RESULT_REPOSITORY_TOKEN), - inject: ['Logger'], + useFactory: (dataSource: DataSource, mapper: ResultOrmMapper) => new TypeOrmResultRepository(dataSource, mapper), + inject: [getDataSourceToken(), ResultOrmMapper], }, { provide: STANDING_REPOSITORY_TOKEN, - useFactory: () => makePlaceholder(STANDING_REPOSITORY_TOKEN), - inject: ['Logger'], + useFactory: ( + dataSource: DataSource, + standingMapper: StandingOrmMapper, + resultMapper: ResultOrmMapper, + leagueMapper: LeagueOrmMapper, + pointsSystems: Record>, + ) => new TypeOrmStandingRepository(dataSource, standingMapper, resultMapper, leagueMapper, pointsSystems), + inject: [getDataSourceToken(), StandingOrmMapper, ResultOrmMapper, LeagueOrmMapper, RACING_POINTS_SYSTEMS_TOKEN], }, { provide: LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN, - useFactory: () => makePlaceholder(LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN), - inject: ['Logger'], + useFactory: (dataSource: DataSource, mapper: LeagueMembershipOrmMapper) => + new TypeOrmLeagueMembershipRepository(dataSource, mapper), + inject: [getDataSourceToken(), LeagueMembershipOrmMapper], }, { provide: RACE_REGISTRATION_REPOSITORY_TOKEN, - useFactory: () => makePlaceholder(RACE_REGISTRATION_REPOSITORY_TOKEN), - inject: ['Logger'], + useFactory: (dataSource: DataSource, mapper: RaceRegistrationOrmMapper) => + new TypeOrmRaceRegistrationRepository(dataSource, mapper), + inject: [getDataSourceToken(), RaceRegistrationOrmMapper], }, { provide: TEAM_REPOSITORY_TOKEN, - useFactory: () => makePlaceholder(TEAM_REPOSITORY_TOKEN), - inject: ['Logger'], + useFactory: (repo: Repository, mapper: TeamOrmMapper) => new TypeOrmTeamRepository(repo, mapper), + inject: [getRepositoryToken(TeamOrmEntity), TeamOrmMapper], }, { provide: TEAM_MEMBERSHIP_REPOSITORY_TOKEN, - useFactory: () => makePlaceholder(TEAM_MEMBERSHIP_REPOSITORY_TOKEN), - inject: ['Logger'], + useFactory: ( + membershipRepo: Repository, + joinRequestRepo: Repository, + mapper: TeamMembershipOrmMapper, + ) => new TypeOrmTeamMembershipRepository(membershipRepo, joinRequestRepo, mapper), + inject: [ + getRepositoryToken(TeamMembershipOrmEntity), + getRepositoryToken(TeamJoinRequestOrmEntity), + TeamMembershipOrmMapper, + ], }, { provide: PENALTY_REPOSITORY_TOKEN, - useFactory: () => makePlaceholder(PENALTY_REPOSITORY_TOKEN), - inject: ['Logger'], + useFactory: (repo: Repository, mapper: PenaltyOrmMapper) => new TypeOrmPenaltyRepository(repo, mapper), + inject: [getRepositoryToken(PenaltyOrmEntity), PenaltyOrmMapper], }, { provide: PROTEST_REPOSITORY_TOKEN, - useFactory: () => makePlaceholder(PROTEST_REPOSITORY_TOKEN), - inject: ['Logger'], + useFactory: (repo: Repository, mapper: ProtestOrmMapper) => new TypeOrmProtestRepository(repo, mapper), + inject: [getRepositoryToken(ProtestOrmEntity), ProtestOrmMapper], }, { provide: SEASON_REPOSITORY_TOKEN, - useFactory: (dataSource: DataSource) => { - const seasonMapper = new SeasonOrmMapper(); - return new TypeOrmSeasonRepository(dataSource, seasonMapper); - }, - inject: [getDataSourceToken()], + useFactory: (dataSource: DataSource, mapper: SeasonOrmMapper) => new TypeOrmSeasonRepository(dataSource, mapper), + inject: [getDataSourceToken(), SeasonOrmMapper], }, { provide: SEASON_SPONSORSHIP_REPOSITORY_TOKEN, - useFactory: () => makePlaceholder(SEASON_SPONSORSHIP_REPOSITORY_TOKEN), - inject: ['Logger'], + useFactory: (repo: Repository, mapper: SeasonSponsorshipOrmMapper) => + new TypeOrmSeasonSponsorshipRepository(repo, mapper), + inject: [getRepositoryToken(SeasonSponsorshipOrmEntity), SeasonSponsorshipOrmMapper], }, { provide: LEAGUE_SCORING_CONFIG_REPOSITORY_TOKEN, @@ -146,33 +274,36 @@ const typeOrmFeatureImports = [ }, { provide: GAME_REPOSITORY_TOKEN, - useFactory: () => makePlaceholder(GAME_REPOSITORY_TOKEN), - inject: ['Logger'], + useFactory: (repo: Repository, mapper: GameOrmMapper) => new TypeOrmGameRepository(repo, mapper), + inject: [getRepositoryToken(GameOrmEntity), GameOrmMapper], }, { provide: LEAGUE_WALLET_REPOSITORY_TOKEN, - useFactory: () => makePlaceholder(LEAGUE_WALLET_REPOSITORY_TOKEN), - inject: ['Logger'], + useFactory: (repo: Repository, mapper: LeagueWalletOrmMapper) => + new TypeOrmLeagueWalletRepository(repo, mapper), + inject: [getRepositoryToken(LeagueWalletOrmEntity), LeagueWalletOrmMapper], }, { provide: TRANSACTION_REPOSITORY_TOKEN, - useFactory: () => makePlaceholder(TRANSACTION_REPOSITORY_TOKEN), - inject: ['Logger'], + useFactory: (repo: Repository, mapper: TransactionOrmMapper) => new TypeOrmTransactionRepository(repo, mapper), + inject: [getRepositoryToken(TransactionOrmEntity), TransactionOrmMapper], }, { provide: SPONSOR_REPOSITORY_TOKEN, - useFactory: () => makePlaceholder(SPONSOR_REPOSITORY_TOKEN), - inject: ['Logger'], + useFactory: (repo: Repository, mapper: SponsorOrmMapper) => new TypeOrmSponsorRepository(repo, mapper), + inject: [getRepositoryToken(SponsorOrmEntity), SponsorOrmMapper], }, { provide: SPONSORSHIP_PRICING_REPOSITORY_TOKEN, - useFactory: () => makePlaceholder(SPONSORSHIP_PRICING_REPOSITORY_TOKEN), - inject: ['Logger'], + useFactory: (repo: Repository, mapper: SponsorshipPricingOrmMapper) => + new TypeOrmSponsorshipPricingRepository(repo, mapper), + inject: [getRepositoryToken(SponsorshipPricingOrmEntity), SponsorshipPricingOrmMapper], }, { provide: SPONSORSHIP_REQUEST_REPOSITORY_TOKEN, - useFactory: () => makePlaceholder(SPONSORSHIP_REQUEST_REPOSITORY_TOKEN), - inject: ['Logger'], + useFactory: (repo: Repository, mapper: SponsorshipRequestOrmMapper) => + new TypeOrmSponsorshipRequestRepository(repo, mapper), + inject: [getRepositoryToken(SponsorshipRequestOrmEntity), SponsorshipRequestOrmMapper], }, ], exports: [ diff --git a/apps/api/src/persistence/postgres/PostgresSocialPersistenceModule.ts b/apps/api/src/persistence/postgres/PostgresSocialPersistenceModule.ts new file mode 100644 index 000000000..f3d2ae730 --- /dev/null +++ b/apps/api/src/persistence/postgres/PostgresSocialPersistenceModule.ts @@ -0,0 +1,50 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule, getDataSourceToken } from '@nestjs/typeorm'; +import type { DataSource } from 'typeorm'; + +import { LoggingModule } from '../../domain/logging/LoggingModule'; +import { SOCIAL_FEED_REPOSITORY_TOKEN, SOCIAL_GRAPH_REPOSITORY_TOKEN } from '../social/SocialPersistenceTokens'; + +import { DriverOrmEntity } from '@adapters/racing/persistence/typeorm/entities/DriverOrmEntity'; +import { DriverOrmMapper } from '@adapters/racing/persistence/typeorm/mappers/DriverOrmMapper'; + +import { FeedItemOrmEntity } from '@adapters/social/persistence/typeorm/entities/FeedItemOrmEntity'; +import { FriendshipOrmEntity } from '@adapters/social/persistence/typeorm/entities/FriendshipOrmEntity'; +import { FeedItemOrmMapper } from '@adapters/social/persistence/typeorm/mappers/FeedItemOrmMapper'; +import { TypeOrmFeedRepository } from '@adapters/social/persistence/typeorm/repositories/TypeOrmFeedRepository'; +import { TypeOrmSocialGraphRepository } from '@adapters/social/persistence/typeorm/repositories/TypeOrmSocialGraphRepository'; + +const typeOrmFeatureImports = [ + TypeOrmModule.forFeature([FeedItemOrmEntity, FriendshipOrmEntity, DriverOrmEntity]), +]; + +@Module({ + imports: [LoggingModule, ...typeOrmFeatureImports], + providers: [ + { provide: FeedItemOrmMapper, useFactory: () => new FeedItemOrmMapper() }, + { provide: DriverOrmMapper, useFactory: () => new DriverOrmMapper() }, + + { + provide: SOCIAL_FEED_REPOSITORY_TOKEN, + useFactory: (dataSource: DataSource, mapper: FeedItemOrmMapper) => + new TypeOrmFeedRepository( + dataSource.getRepository(FeedItemOrmEntity), + dataSource.getRepository(FriendshipOrmEntity), + mapper, + ), + inject: [getDataSourceToken(), FeedItemOrmMapper], + }, + { + provide: SOCIAL_GRAPH_REPOSITORY_TOKEN, + useFactory: (dataSource: DataSource, driverMapper: DriverOrmMapper) => + new TypeOrmSocialGraphRepository( + dataSource.getRepository(FriendshipOrmEntity), + dataSource.getRepository(DriverOrmEntity), + driverMapper, + ), + inject: [getDataSourceToken(), DriverOrmMapper], + }, + ], + exports: [SOCIAL_FEED_REPOSITORY_TOKEN, SOCIAL_GRAPH_REPOSITORY_TOKEN], +}) +export class PostgresSocialPersistenceModule {} \ No newline at end of file diff --git a/apps/api/src/persistence/postgres/typeorm/PostgresAnalyticsRepositoriesSlice.int.test.ts b/apps/api/src/persistence/postgres/typeorm/PostgresAnalyticsRepositoriesSlice.int.test.ts new file mode 100644 index 000000000..9c9d47d8b --- /dev/null +++ b/apps/api/src/persistence/postgres/typeorm/PostgresAnalyticsRepositoriesSlice.int.test.ts @@ -0,0 +1,84 @@ +import 'reflect-metadata'; + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { DataSource } from 'typeorm'; + +import { PageView } from '@core/analytics/domain/entities/PageView'; + +import { PageViewOrmEntity } from '@adapters/analytics/persistence/typeorm/entities/PageViewOrmEntity'; +import { EngagementEventOrmEntity } from '@adapters/analytics/persistence/typeorm/entities/EngagementEventOrmEntity'; +import { AnalyticsSnapshotOrmEntity } from '@adapters/analytics/persistence/typeorm/entities/AnalyticsSnapshotOrmEntity'; + +import { PageViewOrmMapper } from '@adapters/analytics/persistence/typeorm/mappers/PageViewOrmMapper'; +import { EngagementEventOrmMapper } from '@adapters/analytics/persistence/typeorm/mappers/EngagementEventOrmMapper'; +import { AnalyticsSnapshotOrmMapper } from '@adapters/analytics/persistence/typeorm/mappers/AnalyticsSnapshotOrmMapper'; + +import { TypeOrmPageViewRepository } from '@adapters/analytics/persistence/typeorm/repositories/TypeOrmPageViewRepository'; +import { TypeOrmEngagementRepository } from '@adapters/analytics/persistence/typeorm/repositories/TypeOrmEngagementRepository'; +import { TypeOrmAnalyticsSnapshotRepository } from '@adapters/analytics/persistence/typeorm/repositories/TypeOrmAnalyticsSnapshotRepository'; + +const databaseUrl = process.env.DATABASE_URL; +const describeIfDatabase = databaseUrl ? describe : describe.skip; + +describeIfDatabase('TypeORM Analytics 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: [PageViewOrmEntity, EngagementEventOrmEntity, AnalyticsSnapshotOrmEntity], + synchronize: true, + }); + + await dataSource.initialize(); + }); + + afterAll(async () => { + if (dataSource?.isInitialized) { + await dataSource.destroy(); + } + }); + + it('supports: persist page view + read it back + count unique visitors', async () => { + const pageViewOrmRepo = dataSource.getRepository(PageViewOrmEntity); + const engagementOrmRepo = dataSource.getRepository(EngagementEventOrmEntity); + const snapshotOrmRepo = dataSource.getRepository(AnalyticsSnapshotOrmEntity); + + await snapshotOrmRepo.clear(); + await engagementOrmRepo.clear(); + await pageViewOrmRepo.clear(); + + const pageViewRepo = new TypeOrmPageViewRepository(pageViewOrmRepo, new PageViewOrmMapper()); + const engagementRepo = new TypeOrmEngagementRepository(engagementOrmRepo, new EngagementEventOrmMapper()); + const snapshotRepo = new TypeOrmAnalyticsSnapshotRepository(snapshotOrmRepo, new AnalyticsSnapshotOrmMapper()); + + // Ensure all repositories at least construct and basic ops work + expect(pageViewRepo).toBeDefined(); + expect(engagementRepo).toBeDefined(); + expect(snapshotRepo).toBeDefined(); + + const pv1 = PageView.create({ + id: `pv-it-${Date.now()}`, + entityType: 'league', + entityId: 'league-it-1', + visitorType: 'anonymous', + sessionId: 'sess-it-1', + visitorId: 'visitor-it-1', + timestamp: new Date(), + durationMs: 8000, + }); + + await pageViewRepo.save(pv1); + + const loaded = await pageViewRepo.findById(pv1.id); + expect(loaded?.id).toBe(pv1.id); + + const unique = await pageViewRepo.countUniqueVisitors('league', 'league-it-1'); + expect(unique).toBe(1); + }); +}); \ No newline at end of file diff --git a/apps/api/src/persistence/postgres/typeorm/PostgresIdentityUserRepository.int.test.ts b/apps/api/src/persistence/postgres/typeorm/PostgresIdentityUserRepository.int.test.ts new file mode 100644 index 000000000..221e3caf0 --- /dev/null +++ b/apps/api/src/persistence/postgres/typeorm/PostgresIdentityUserRepository.int.test.ts @@ -0,0 +1,83 @@ +import 'reflect-metadata'; + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { DataSource } from 'typeorm'; + +import { User } from '@core/identity/domain/entities/User'; +import { EmailAddress } from '@core/identity/domain/value-objects/EmailAddress'; +import { PasswordHash } from '@core/identity/domain/value-objects/PasswordHash'; +import { UserId } from '@core/identity/domain/value-objects/UserId'; + +import { UserOrmEntity } from '@adapters/identity/persistence/typeorm/entities/UserOrmEntity'; +import { TypeOrmAuthRepository } from '@adapters/identity/persistence/typeorm/TypeOrmAuthRepository'; +import { TypeOrmUserRepository } from '@adapters/identity/persistence/typeorm/TypeOrmUserRepository'; +import { UserOrmMapper } from '@adapters/identity/persistence/typeorm/mappers/UserOrmMapper'; + +const databaseUrl = process.env.DATABASE_URL; +const describeIfDatabase = databaseUrl ? describe : describe.skip; + +describeIfDatabase('TypeORM Identity 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: [UserOrmEntity], + synchronize: true, + }); + + await dataSource.initialize(); + }); + + afterAll(async () => { + if (dataSource?.isInitialized) { + await dataSource.destroy(); + } + }); + + it('supports: persist user + find by email + update displayName', async () => { + const mapper = new UserOrmMapper(); + const userRepo = new TypeOrmUserRepository(dataSource, mapper); + const authRepo = new TypeOrmAuthRepository(dataSource, mapper); + + const email = EmailAddress.create(`it-${Date.now()}@example.com`); + const passwordHash = PasswordHash.fromHash('bcrypt-hash-for-it'); + const userId = UserId.create(); + + const user = User.create({ + id: userId, + email: email.value, + displayName: 'Integration User', + passwordHash, + }); + + await authRepo.save(user); + + const loaded = await authRepo.findByEmail(email); + expect(loaded).not.toBeNull(); + expect(loaded!.getId().value).toBe(user.getId().value); + expect(loaded!.getEmail()).toBe(email.value.toLowerCase()); + expect(loaded!.getDisplayName()).toBe('Integration User'); + + const stored = await userRepo.findByEmail(email.value.toUpperCase()); + expect(stored).not.toBeNull(); + expect(stored!.email).toBe(email.value.toLowerCase()); + + const updated = User.create({ + id: userId, + email: email.value, + displayName: 'Integration User Updated', + passwordHash, + }); + + await authRepo.save(updated); + + const loadedUpdated = await authRepo.findByEmail(email); + expect(loadedUpdated!.getDisplayName()).toBe('Integration User Updated'); + }); +}); \ No newline at end of file diff --git a/apps/api/src/persistence/postgres/typeorm/PostgresPaymentsWalletRepositorySlice.int.test.ts b/apps/api/src/persistence/postgres/typeorm/PostgresPaymentsWalletRepositorySlice.int.test.ts new file mode 100644 index 000000000..9bc171e8d --- /dev/null +++ b/apps/api/src/persistence/postgres/typeorm/PostgresPaymentsWalletRepositorySlice.int.test.ts @@ -0,0 +1,179 @@ +import 'reflect-metadata'; + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { DataSource } from 'typeorm'; + +import { MemberPaymentStatus } from '@core/payments/domain/entities/MemberPayment'; +import { MembershipFeeType } from '@core/payments/domain/entities/MembershipFee'; +import { PaymentStatus, PaymentType, PayerType } from '@core/payments/domain/entities/Payment'; +import { PrizeType } from '@core/payments/domain/entities/Prize'; +import { TransactionType } from '@core/payments/domain/entities/Wallet'; + +import { PaymentsMemberPaymentOrmEntity } from '@adapters/payments/persistence/typeorm/entities/PaymentsMemberPaymentOrmEntity'; +import { PaymentsMembershipFeeOrmEntity } from '@adapters/payments/persistence/typeorm/entities/PaymentsMembershipFeeOrmEntity'; +import { PaymentsPaymentOrmEntity } from '@adapters/payments/persistence/typeorm/entities/PaymentsPaymentOrmEntity'; +import { PaymentsPrizeOrmEntity } from '@adapters/payments/persistence/typeorm/entities/PaymentsPrizeOrmEntity'; +import { PaymentsTransactionOrmEntity } from '@adapters/payments/persistence/typeorm/entities/PaymentsTransactionOrmEntity'; +import { PaymentsWalletOrmEntity } from '@adapters/payments/persistence/typeorm/entities/PaymentsWalletOrmEntity'; + +import { PaymentsMemberPaymentOrmMapper } from '@adapters/payments/persistence/typeorm/mappers/PaymentsMemberPaymentOrmMapper'; +import { PaymentsMembershipFeeOrmMapper } from '@adapters/payments/persistence/typeorm/mappers/PaymentsMembershipFeeOrmMapper'; +import { PaymentsPaymentOrmMapper } from '@adapters/payments/persistence/typeorm/mappers/PaymentsPaymentOrmMapper'; +import { PaymentsPrizeOrmMapper } from '@adapters/payments/persistence/typeorm/mappers/PaymentsPrizeOrmMapper'; +import { PaymentsWalletOrmMapper } from '@adapters/payments/persistence/typeorm/mappers/PaymentsWalletOrmMapper'; + +import { TypeOrmMemberPaymentRepository, TypeOrmMembershipFeeRepository } from '@adapters/payments/persistence/typeorm/repositories/TypeOrmMembershipFeeRepository'; +import { TypeOrmPaymentRepository } from '@adapters/payments/persistence/typeorm/repositories/TypeOrmPaymentRepository'; +import { TypeOrmPrizeRepository } from '@adapters/payments/persistence/typeorm/repositories/TypeOrmPrizeRepository'; +import { TypeOrmTransactionRepository, TypeOrmWalletRepository } from '@adapters/payments/persistence/typeorm/repositories/TypeOrmWalletRepository'; + +const databaseUrl = process.env.DATABASE_URL; +const describeIfDatabase = databaseUrl ? describe : describe.skip; + +describeIfDatabase('TypeORM Payments 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: [ + PaymentsWalletOrmEntity, + PaymentsTransactionOrmEntity, + PaymentsPaymentOrmEntity, + PaymentsPrizeOrmEntity, + PaymentsMembershipFeeOrmEntity, + PaymentsMemberPaymentOrmEntity, + ], + synchronize: true, + }); + + await dataSource.initialize(); + }); + + afterAll(async () => { + if (dataSource?.isInitialized) { + await dataSource.destroy(); + } + }); + + it('supports: wallet+tx+payment+prize+membership fee+member payment', async () => { + const walletMapper = new PaymentsWalletOrmMapper(); + const walletRepo = new TypeOrmWalletRepository(dataSource, walletMapper); + const txRepo = new TypeOrmTransactionRepository(dataSource, walletMapper); + + const paymentRepo = new TypeOrmPaymentRepository(dataSource, new PaymentsPaymentOrmMapper()); + const prizeRepo = new TypeOrmPrizeRepository(dataSource, new PaymentsPrizeOrmMapper()); + const feeRepo = new TypeOrmMembershipFeeRepository(dataSource, new PaymentsMembershipFeeOrmMapper()); + const memberPaymentRepo = new TypeOrmMemberPaymentRepository(dataSource, new PaymentsMemberPaymentOrmMapper()); + + const now = Date.now(); + const leagueId = `league-it-${now}`; + + const wallet = { + id: `wallet-it-${now}`, + leagueId, + balance: 0, + totalRevenue: 0, + totalPlatformFees: 0, + totalWithdrawn: 0, + currency: 'USD', + createdAt: new Date(), + }; + + await walletRepo.create(wallet); + + const tx = { + id: `txn-it-${now}`, + walletId: wallet.id, + type: TransactionType.DEPOSIT, + amount: 25, + description: 'Integration deposit', + createdAt: new Date(), + }; + + await txRepo.create(tx); + + const loadedWallet = await walletRepo.findByLeagueId(wallet.leagueId); + expect(loadedWallet).not.toBeNull(); + expect(loadedWallet!.leagueId).toBe(wallet.leagueId); + + const loadedTxs = await txRepo.findByWalletId(wallet.id); + expect(loadedTxs.map(t => t.id)).toContain(tx.id); + + const payment = { + id: `payment-it-${now}`, + type: PaymentType.MEMBERSHIP_FEE, + amount: 100, + platformFee: 5, + netAmount: 95, + payerId: `driver-it-${now}`, + payerType: PayerType.DRIVER, + leagueId, + status: PaymentStatus.PENDING, + createdAt: new Date(), + }; + + await paymentRepo.create(payment); + + const loadedPayments = await paymentRepo.findByLeagueId(leagueId); + expect(loadedPayments.map(p => p.id)).toContain(payment.id); + + const prize = { + id: `prize-it-${now}`, + leagueId, + seasonId: `season-it-${now}`, + position: 1, + name: 'Winner', + amount: 250, + type: PrizeType.CASH, + awarded: false, + createdAt: new Date(), + }; + + await prizeRepo.create(prize); + + const loadedPrizes = await prizeRepo.findByLeagueIdAndSeasonId(leagueId, prize.seasonId); + expect(loadedPrizes.map(p => p.id)).toContain(prize.id); + + const fee = { + id: `fee-it-${now}`, + leagueId, + type: MembershipFeeType.SEASON, + amount: 100, + enabled: true, + createdAt: new Date(), + updatedAt: new Date(), + }; + + await feeRepo.create(fee); + + const loadedFee = await feeRepo.findByLeagueId(leagueId); + expect(loadedFee?.id).toBe(fee.id); + + const memberPayment = { + id: `member-payment-it-${now}`, + feeId: fee.id, + driverId: payment.payerId, + amount: 100, + platformFee: 5, + netAmount: 95, + status: MemberPaymentStatus.PAID, + dueDate: new Date(), + paidAt: new Date(), + }; + + await memberPaymentRepo.create(memberPayment); + + const loadedMemberPayments = await memberPaymentRepo.findByLeagueIdAndDriverId( + leagueId, + memberPayment.driverId, + feeRepo, + ); + expect(loadedMemberPayments.map(mp => mp.id)).toContain(memberPayment.id); + }); +}); diff --git a/apps/api/src/persistence/postgres/typeorm/PostgresRacingCommerceSlice.int.test.ts b/apps/api/src/persistence/postgres/typeorm/PostgresRacingCommerceSlice.int.test.ts new file mode 100644 index 000000000..844e089da --- /dev/null +++ b/apps/api/src/persistence/postgres/typeorm/PostgresRacingCommerceSlice.int.test.ts @@ -0,0 +1,193 @@ +import 'reflect-metadata'; + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { DataSource } from 'typeorm'; + +import { Game } from '@core/racing/domain/entities/Game'; +import { LeagueWallet } from '@core/racing/domain/entities/league-wallet/LeagueWallet'; +import { Transaction } from '@core/racing/domain/entities/league-wallet/Transaction'; +import { Sponsor } from '@core/racing/domain/entities/sponsor/Sponsor'; +import { SeasonSponsorship } from '@core/racing/domain/entities/season/SeasonSponsorship'; +import { SponsorshipRequest } from '@core/racing/domain/entities/SponsorshipRequest'; +import { Money } from '@core/racing/domain/value-objects/Money'; +import { SponsorshipPricing } from '@core/racing/domain/value-objects/SponsorshipPricing'; + +import { + GameOrmEntity, + LeagueWalletOrmEntity, + SeasonSponsorshipOrmEntity, + SponsorOrmEntity, + SponsorshipPricingOrmEntity, + SponsorshipRequestOrmEntity, + TransactionOrmEntity, +} from '../../../../../../adapters/racing/persistence/typeorm/entities/MissingRacingOrmEntities'; + +import { MoneyOrmMapper } from '../../../../../../adapters/racing/persistence/typeorm/mappers/MoneyOrmMapper'; +import { + GameOrmMapper, + LeagueWalletOrmMapper, + SeasonSponsorshipOrmMapper, + SponsorOrmMapper, + SponsorshipPricingOrmMapper, + SponsorshipRequestOrmMapper, + TransactionOrmMapper, +} from '../../../../../../adapters/racing/persistence/typeorm/mappers/CommerceOrmMappers'; + +import { + TypeOrmGameRepository, + TypeOrmLeagueWalletRepository, + TypeOrmSeasonSponsorshipRepository, + TypeOrmSponsorRepository, + TypeOrmSponsorshipPricingRepository, + TypeOrmSponsorshipRequestRepository, + TypeOrmTransactionRepository, +} from '../../../../../../adapters/racing/persistence/typeorm/repositories/CommerceTypeOrmRepositories'; + +const databaseUrl = process.env.DATABASE_URL; +const describeIfDatabase = databaseUrl ? describe : describe.skip; + +describeIfDatabase('TypeORM Racing repositories (commerce 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: [ + GameOrmEntity, + SponsorOrmEntity, + LeagueWalletOrmEntity, + TransactionOrmEntity, + SponsorshipPricingOrmEntity, + SponsorshipRequestOrmEntity, + SeasonSponsorshipOrmEntity, + ], + synchronize: true, + }); + + await dataSource.initialize(); + }); + + afterAll(async () => { + if (dataSource?.isInitialized) { + await dataSource.destroy(); + } + }); + + it('supports: game + sponsor + wallet + transaction + sponsorship objects', async () => { + const moneyMapper = new MoneyOrmMapper(); + + const gameRepo = new TypeOrmGameRepository( + dataSource.getRepository(GameOrmEntity), + new GameOrmMapper(), + ); + const sponsorRepo = new TypeOrmSponsorRepository( + dataSource.getRepository(SponsorOrmEntity), + new SponsorOrmMapper(), + ); + const walletRepo = new TypeOrmLeagueWalletRepository( + dataSource.getRepository(LeagueWalletOrmEntity), + new LeagueWalletOrmMapper(moneyMapper), + ); + const txRepo = new TypeOrmTransactionRepository( + dataSource.getRepository(TransactionOrmEntity), + new TransactionOrmMapper(moneyMapper), + ); + + const pricingRepo = new TypeOrmSponsorshipPricingRepository( + dataSource.getRepository(SponsorshipPricingOrmEntity), + new SponsorshipPricingOrmMapper(moneyMapper), + ); + const requestRepo = new TypeOrmSponsorshipRequestRepository( + dataSource.getRepository(SponsorshipRequestOrmEntity), + new SponsorshipRequestOrmMapper(moneyMapper), + ); + const seasonSponsorshipRepo = new TypeOrmSeasonSponsorshipRepository( + dataSource.getRepository(SeasonSponsorshipOrmEntity), + new SeasonSponsorshipOrmMapper(moneyMapper), + ); + + const game = Game.create({ id: 'iracing', name: 'iRacing' }); + await dataSource.getRepository(GameOrmEntity).save(new GameOrmMapper().toOrmEntity(game)); + const persistedGame = await gameRepo.findById('iracing'); + expect(persistedGame?.name.toString()).toBe('iRacing'); + + const sponsor = Sponsor.create({ + id: '00000000-0000-4000-8000-000000000010', + name: 'Sponsor Inc', + contactEmail: 'sponsor@example.com', + createdAt: new Date('2025-01-01T00:00:00.000Z'), + }); + await sponsorRepo.create(sponsor); + + const leagueId = '00000000-0000-4000-8000-000000000020'; + const wallet = LeagueWallet.create({ + id: '00000000-0000-4000-8000-000000000030', + leagueId, + balance: Money.create(0, 'USD'), + createdAt: new Date('2025-01-01T00:00:00.000Z'), + transactionIds: [], + }); + await walletRepo.create(wallet); + + const tx = Transaction.rehydrate({ + id: '00000000-0000-4000-8000-000000000040', + walletId: wallet.id.toString(), + type: 'refund', + amount: Money.create(10, 'USD'), + platformFee: Money.create(1, 'USD'), + netAmount: Money.create(9, 'USD'), + status: 'completed', + createdAt: new Date('2025-01-02T00:00:00.000Z'), + }); + await txRepo.create(tx); + + const walletTxs = await txRepo.findByWalletId(wallet.id.toString()); + expect(walletTxs.map((t) => t.id.toString())).toContain(tx.id.toString()); + + const pricing = SponsorshipPricing.create({ + acceptingApplications: true, + mainSlot: { + tier: 'main', + price: Money.create(100, 'USD'), + benefits: ['Logo'], + available: true, + maxSlots: 1, + }, + }); + + await pricingRepo.save('team', 'team-1', pricing); + const persistedPricing = await pricingRepo.findByEntity('team', 'team-1'); + expect(persistedPricing?.acceptingApplications).toBe(true); + + const request = SponsorshipRequest.create({ + id: '00000000-0000-4000-8000-000000000050', + sponsorId: sponsor.id.toString(), + entityType: 'team', + entityId: 'team-1', + tier: 'main', + offeredAmount: Money.create(100, 'USD'), + message: 'We would like to sponsor you', + }); + await requestRepo.create(request); + + const pending = await requestRepo.findPendingByEntity('team', 'team-1'); + expect(pending.map((r) => r.id)).toContain(request.id); + + const seasonSponsorship = SeasonSponsorship.create({ + id: '00000000-0000-4000-8000-000000000060', + seasonId: '00000000-0000-4000-8000-000000000070', + sponsorId: sponsor.id.toString(), + tier: 'main', + pricing: Money.create(100, 'USD'), + }); + await seasonSponsorshipRepo.create(seasonSponsorship); + + const seasonSponsorships = await seasonSponsorshipRepo.findBySponsorId(sponsor.id.toString()); + expect(seasonSponsorships.map((s) => s.id)).toContain(seasonSponsorship.id); + }); +}); \ No newline at end of file diff --git a/apps/api/src/persistence/postgres/typeorm/PostgresRacingTeamsStewardingSlice.int.test.ts b/apps/api/src/persistence/postgres/typeorm/PostgresRacingTeamsStewardingSlice.int.test.ts new file mode 100644 index 000000000..e3029dd2a --- /dev/null +++ b/apps/api/src/persistence/postgres/typeorm/PostgresRacingTeamsStewardingSlice.int.test.ts @@ -0,0 +1,164 @@ +import 'reflect-metadata'; + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { DataSource } from 'typeorm'; + +import { Team } from '@core/racing/domain/entities/Team'; +import { Penalty } from '@core/racing/domain/entities/penalty/Penalty'; +import { Protest } from '@core/racing/domain/entities/Protest'; + +import { + PenaltyOrmEntity, + ProtestOrmEntity, +} from '../../../../../../adapters/racing/persistence/typeorm/entities/MissingRacingOrmEntities'; +import { + TeamJoinRequestOrmEntity, + TeamMembershipOrmEntity, + TeamOrmEntity, +} from '../../../../../../adapters/racing/persistence/typeorm/entities/TeamOrmEntities'; + +import { TeamMembershipOrmMapper, TeamOrmMapper } from '../../../../../../adapters/racing/persistence/typeorm/mappers/TeamOrmMappers'; +import { PenaltyOrmMapper, ProtestOrmMapper } from '../../../../../../adapters/racing/persistence/typeorm/mappers/StewardingOrmMappers'; + +import { + TypeOrmTeamMembershipRepository, + TypeOrmTeamRepository, +} from '../../../../../../adapters/racing/persistence/typeorm/repositories/TeamTypeOrmRepositories'; +import { + TypeOrmPenaltyRepository, + TypeOrmProtestRepository, +} from '../../../../../../adapters/racing/persistence/typeorm/repositories/StewardingTypeOrmRepositories'; + +const databaseUrl = process.env.DATABASE_URL; +const describeIfDatabase = databaseUrl ? describe : describe.skip; + +describeIfDatabase('TypeORM Racing repositories (teams + stewarding 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: [ + TeamOrmEntity, + TeamMembershipOrmEntity, + TeamJoinRequestOrmEntity, + PenaltyOrmEntity, + ProtestOrmEntity, + ], + synchronize: true, + }); + + await dataSource.initialize(); + }); + + afterAll(async () => { + if (dataSource?.isInitialized) { + await dataSource.destroy(); + } + }); + + it('supports: create team + membership + join request + stewarding records', async () => { + const teamRepo = new TypeOrmTeamRepository( + dataSource.getRepository(TeamOrmEntity), + new TeamOrmMapper(), + ); + const teamMembershipRepo = new TypeOrmTeamMembershipRepository( + dataSource.getRepository(TeamMembershipOrmEntity), + dataSource.getRepository(TeamJoinRequestOrmEntity), + new TeamMembershipOrmMapper(), + ); + + const penaltyRepo = new TypeOrmPenaltyRepository( + dataSource.getRepository(PenaltyOrmEntity), + new PenaltyOrmMapper(), + ); + const protestRepo = new TypeOrmProtestRepository( + dataSource.getRepository(ProtestOrmEntity), + new ProtestOrmMapper(), + ); + + const leagueId = '00000000-0000-4000-8000-000000000010'; + + const team = Team.create({ + id: '00000000-0000-4000-8000-000000000001', + name: 'Integration Team', + tag: 'INT', + description: 'Team for integration tests', + ownerId: '00000000-0000-4000-8000-000000000002', + leagues: [leagueId], + createdAt: new Date('2025-01-01T00:00:00.000Z'), + }); + + await teamRepo.create(team); + + const membership = { + teamId: team.id, + driverId: '00000000-0000-4000-8000-000000000003', + role: 'driver' as const, + status: 'active' as const, + joinedAt: new Date('2025-01-01T00:00:00.000Z'), + }; + + await teamMembershipRepo.saveMembership(membership); + + const joinRequest = { + id: 'join-req-1', + teamId: team.id, + driverId: '00000000-0000-4000-8000-000000000004', + requestedAt: new Date('2025-01-02T00:00:00.000Z'), + message: 'Please let me in', + }; + + await teamMembershipRepo.saveJoinRequest(joinRequest); + + const byLeague = await teamRepo.findByLeagueId(leagueId); + expect(byLeague.map((t) => t.id)).toContain(team.id); + + const members = await teamMembershipRepo.getTeamMembers(team.id); + expect(members).toHaveLength(1); + expect(members[0]?.driverId).toBe(membership.driverId); + + const requests = await teamMembershipRepo.getJoinRequests(team.id); + expect(requests.map((r) => r.id)).toContain('join-req-1'); + + const raceId = '00000000-0000-4000-8000-000000000020'; + + const protest = Protest.create({ + id: '00000000-0000-4000-8000-000000000030', + raceId, + protestingDriverId: membership.driverId, + accusedDriverId: '00000000-0000-4000-8000-000000000005', + incident: { lap: 1, description: 'Contact' }, + status: 'pending', + filedAt: new Date('2025-01-03T00:00:00.000Z'), + }); + + await protestRepo.create(protest); + + const penalty = Penalty.rehydrate({ + id: '00000000-0000-4000-8000-000000000040', + leagueId, + raceId, + driverId: membership.driverId, + type: 'warning', + reason: 'Unsafe rejoin', + issuedBy: '00000000-0000-4000-8000-000000000006', + status: 'pending', + issuedAt: new Date('2025-01-04T00:00:00.000Z'), + protestId: protest.id, + }); + + await penaltyRepo.create(penalty); + + const penaltiesForRace = await penaltyRepo.findByRaceId(raceId); + expect(penaltiesForRace.map((p) => p.id)).toContain(penalty.id); + + const protestsForRace = await protestRepo.findByRaceId(raceId); + expect(protestsForRace.map((p) => p.id)).toContain(protest.id); + }); +}); \ No newline at end of file diff --git a/apps/api/src/persistence/social/PostgresSocialPersistence.integration.test.ts b/apps/api/src/persistence/social/PostgresSocialPersistence.integration.test.ts new file mode 100644 index 000000000..20f1bfba2 --- /dev/null +++ b/apps/api/src/persistence/social/PostgresSocialPersistence.integration.test.ts @@ -0,0 +1,97 @@ +import 'reflect-metadata'; + +import { getDataSourceToken } from '@nestjs/typeorm'; +import { Test } from '@nestjs/testing'; +import type { TestingModule } from '@nestjs/testing'; +import { afterEach, describe, expect, it } from 'vitest'; +import type { DataSource } from 'typeorm'; + +import { DatabaseModule } from '../../domain/database/DatabaseModule'; +import { PostgresSocialPersistenceModule } from '../postgres/PostgresSocialPersistenceModule'; +import { SOCIAL_FEED_REPOSITORY_TOKEN, SOCIAL_GRAPH_REPOSITORY_TOKEN } from './SocialPersistenceTokens'; + +import type { IFeedRepository } from '@core/social/domain/repositories/IFeedRepository'; +import type { ISocialGraphRepository } from '@core/social/domain/repositories/ISocialGraphRepository'; + +import { FeedItemOrmEntity } from '@adapters/social/persistence/typeorm/entities/FeedItemOrmEntity'; +import { FriendshipOrmEntity } from '@adapters/social/persistence/typeorm/entities/FriendshipOrmEntity'; +import { DriverOrmEntity } from '@adapters/racing/persistence/typeorm/entities/DriverOrmEntity'; + +describe('PostgresSocialPersistenceModule (integration)', () => { + const shouldRun = Boolean(process.env.DATABASE_URL); + + afterEach(() => { + delete process.env.GRIDPILOT_API_PERSISTENCE; + }); + + (shouldRun ? it : it.skip)('reads driver feed against a real database', async () => { + process.env.GRIDPILOT_API_PERSISTENCE = 'postgres'; + + const module: TestingModule = await Test.createTestingModule({ + imports: [DatabaseModule, PostgresSocialPersistenceModule], + }).compile(); + + const dataSource = module.get(getDataSourceToken()); + + const driverRepo = dataSource.getRepository(DriverOrmEntity); + const friendshipRepo = dataSource.getRepository(FriendshipOrmEntity); + const feedOrmRepo = dataSource.getRepository(FeedItemOrmEntity); + + await feedOrmRepo.clear(); + await friendshipRepo.clear(); + await driverRepo.clear(); + + const driverA = new DriverOrmEntity(); + driverA.id = '00000000-0000-0000-0000-000000000001'; + driverA.iracingId = '1'; + driverA.name = 'A'; + driverA.country = 'DE'; + driverA.bio = null; + driverA.joinedAt = new Date('2025-01-01T00:00:00.000Z'); + + const driverB = new DriverOrmEntity(); + driverB.id = '00000000-0000-0000-0000-000000000002'; + driverB.iracingId = '2'; + driverB.name = 'B'; + driverB.country = 'DE'; + driverB.bio = null; + driverB.joinedAt = new Date('2025-01-01T00:00:00.000Z'); + + await driverRepo.save([driverA, driverB]); + + const friendship = new FriendshipOrmEntity(); + friendship.driverId = driverA.id; + friendship.friendId = driverB.id; + friendship.createdAt = new Date('2025-01-01T00:00:00.000Z'); + await friendshipRepo.save(friendship); + + const item = new FeedItemOrmEntity(); + item.id = '00000000-0000-0000-0000-0000000000aa'; + item.timestamp = new Date('2025-02-01T00:00:00.000Z'); + item.type = 'new-result-posted'; + item.actorFriendId = null; + item.actorDriverId = driverB.id; + item.leagueId = null; + item.raceId = null; + item.teamId = null; + item.position = null; + item.headline = 'Hello'; + item.body = null; + item.ctaLabel = null; + item.ctaHref = null; + + await feedOrmRepo.save(item); + + const feedRepo = module.get(SOCIAL_FEED_REPOSITORY_TOKEN); + const socialGraphRepo = module.get(SOCIAL_GRAPH_REPOSITORY_TOKEN); + + const friendIds = await socialGraphRepo.getFriendIds(driverA.id); + expect(friendIds).toEqual([driverB.id]); + + const feed = await feedRepo.getFeedForDriver(driverA.id); + expect(feed).toHaveLength(1); + expect(feed[0]?.id).toBe(item.id); + + await module.close(); + }); +}); \ No newline at end of file diff --git a/apps/api/src/persistence/social/SocialPersistenceModule.test.ts b/apps/api/src/persistence/social/SocialPersistenceModule.test.ts new file mode 100644 index 000000000..7ff840898 --- /dev/null +++ b/apps/api/src/persistence/social/SocialPersistenceModule.test.ts @@ -0,0 +1,53 @@ +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 { SOCIAL_FEED_REPOSITORY_TOKEN, SOCIAL_GRAPH_REPOSITORY_TOKEN } from './SocialPersistenceTokens'; + +describe('SocialPersistenceModule', () => { + 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 { SocialPersistenceModule } = await import('./SocialPersistenceModule'); + const { InMemoryFeedRepository } = await import('@adapters/social/persistence/inmemory/InMemorySocialAndFeed'); + const { InMemorySocialGraphRepository } = await import('@adapters/social/persistence/inmemory/InMemorySocialAndFeed'); + + const module: TestingModule = await Test.createTestingModule({ + imports: [SocialPersistenceModule], + }).compile(); + + const feedRepo = module.get(SOCIAL_FEED_REPOSITORY_TOKEN); + const socialRepo = module.get(SOCIAL_GRAPH_REPOSITORY_TOKEN); + + expect(feedRepo).toBeInstanceOf(InMemoryFeedRepository); + expect(socialRepo).toBeInstanceOf(InMemorySocialGraphRepository); + + 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 { SocialPersistenceModule } = await import('./SocialPersistenceModule'); + const { PostgresSocialPersistenceModule } = await import('../postgres/PostgresSocialPersistenceModule'); + + const imports = Reflect.getMetadata(MODULE_METADATA.IMPORTS, SocialPersistenceModule) as unknown[]; + expect(imports).toContain(PostgresSocialPersistenceModule); + }); +}); \ No newline at end of file diff --git a/apps/api/src/persistence/social/SocialPersistenceModule.ts b/apps/api/src/persistence/social/SocialPersistenceModule.ts new file mode 100644 index 000000000..198ac94b9 --- /dev/null +++ b/apps/api/src/persistence/social/SocialPersistenceModule.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; + +import { getApiPersistence } from '../../env'; +import { InMemorySocialPersistenceModule } from '../inmemory/InMemorySocialPersistenceModule'; +import { PostgresSocialPersistenceModule } from '../postgres/PostgresSocialPersistenceModule'; + +const selectedPersistenceModule = + getApiPersistence() === 'postgres' ? PostgresSocialPersistenceModule : InMemorySocialPersistenceModule; + +@Module({ + imports: [selectedPersistenceModule], + exports: [selectedPersistenceModule], +}) +export class SocialPersistenceModule {} \ No newline at end of file diff --git a/apps/api/src/persistence/social/SocialPersistenceTokens.ts b/apps/api/src/persistence/social/SocialPersistenceTokens.ts new file mode 100644 index 000000000..090f7ad20 --- /dev/null +++ b/apps/api/src/persistence/social/SocialPersistenceTokens.ts @@ -0,0 +1,2 @@ +export const SOCIAL_FEED_REPOSITORY_TOKEN = 'IFeedRepository'; +export const SOCIAL_GRAPH_REPOSITORY_TOKEN = 'ISocialGraphRepository'; \ No newline at end of file diff --git a/core/identity/domain/entities/User.ts b/core/identity/domain/entities/User.ts index e52690a67..a4c10c351 100644 --- a/core/identity/domain/entities/User.ts +++ b/core/identity/domain/entities/User.ts @@ -52,6 +52,37 @@ export class User { return new User(props); } + public static rehydrate(props: { + id: string; + displayName: string; + email?: string; + passwordHash?: PasswordHash; + iracingCustomerId?: string; + primaryDriverId?: string; + avatarUrl?: string; + }): User { + const email = + props.email !== undefined + ? (() => { + const result: EmailValidationResult = validateEmail(props.email); + if (!result.success) { + throw new Error(result.error); + } + return result.email; + })() + : undefined; + + return new User({ + id: UserId.fromString(props.id), + displayName: props.displayName, + ...(email !== undefined ? { email } : {}), + ...(props.passwordHash !== undefined ? { passwordHash: props.passwordHash } : {}), + ...(props.iracingCustomerId !== undefined ? { iracingCustomerId: props.iracingCustomerId } : {}), + ...(props.primaryDriverId !== undefined ? { primaryDriverId: props.primaryDriverId } : {}), + ...(props.avatarUrl !== undefined ? { avatarUrl: props.avatarUrl } : {}), + }); + } + public static fromStored(stored: StoredUser): User { const passwordHash = stored.passwordHash ? PasswordHash.fromHash(stored.passwordHash) diff --git a/core/payments/domain/entities/MemberPayment.ts b/core/payments/domain/entities/MemberPayment.ts index 659997428..e66b4fb29 100644 --- a/core/payments/domain/entities/MemberPayment.ts +++ b/core/payments/domain/entities/MemberPayment.ts @@ -18,4 +18,10 @@ export interface MemberPayment { status: MemberPaymentStatus; dueDate: Date; paidAt?: Date; -} \ No newline at end of file +} + +export const MemberPayment = { + rehydrate(props: MemberPayment): MemberPayment { + return { ...props }; + }, +}; \ No newline at end of file diff --git a/core/payments/domain/entities/MembershipFee.ts b/core/payments/domain/entities/MembershipFee.ts index 0e54b07d0..1463f21b0 100644 --- a/core/payments/domain/entities/MembershipFee.ts +++ b/core/payments/domain/entities/MembershipFee.ts @@ -17,4 +17,10 @@ export interface MembershipFee { enabled: boolean; createdAt: Date; updatedAt: Date; -} \ No newline at end of file +} + +export const MembershipFee = { + rehydrate(props: MembershipFee): MembershipFee { + return { ...props }; + }, +}; \ No newline at end of file diff --git a/core/payments/domain/entities/Payment.ts b/core/payments/domain/entities/Payment.ts index e6ad68c05..74afff506 100644 --- a/core/payments/domain/entities/Payment.ts +++ b/core/payments/domain/entities/Payment.ts @@ -32,4 +32,10 @@ export interface Payment { status: PaymentStatus; createdAt: Date; completedAt?: Date; -} \ No newline at end of file +} + +export const Payment = { + rehydrate(props: Payment): Payment { + return { ...props }; + }, +}; \ No newline at end of file diff --git a/core/payments/domain/entities/Prize.ts b/core/payments/domain/entities/Prize.ts index 8b7ce6240..2f41974b8 100644 --- a/core/payments/domain/entities/Prize.ts +++ b/core/payments/domain/entities/Prize.ts @@ -21,4 +21,10 @@ export interface Prize { awardedTo?: string; awardedAt?: Date; createdAt: Date; -} \ No newline at end of file +} + +export const Prize = { + rehydrate(props: Prize): Prize { + return { ...props }; + }, +}; \ No newline at end of file diff --git a/core/payments/domain/entities/Wallet.ts b/core/payments/domain/entities/Wallet.ts index bf3bf7379..3b6a7e795 100644 --- a/core/payments/domain/entities/Wallet.ts +++ b/core/payments/domain/entities/Wallet.ts @@ -13,6 +13,12 @@ export interface Wallet { createdAt: Date; } +export const Wallet = { + rehydrate(props: Wallet): Wallet { + return { ...props }; + }, +}; + export enum TransactionType { DEPOSIT = 'deposit', WITHDRAWAL = 'withdrawal', @@ -34,4 +40,10 @@ export interface Transaction { referenceId?: string; referenceType?: ReferenceType; createdAt: Date; -} \ No newline at end of file +} + +export const Transaction = { + rehydrate(props: Transaction): Transaction { + return { ...props }; + }, +}; \ No newline at end of file diff --git a/core/racing/domain/entities/Driver.ts b/core/racing/domain/entities/Driver.ts index 9e6f695b3..8087fc3e3 100644 --- a/core/racing/domain/entities/Driver.ts +++ b/core/racing/domain/entities/Driver.ts @@ -62,6 +62,24 @@ export class Driver implements IEntity { }); } + static rehydrate(props: { + id: string; + iracingId: string; + name: string; + country: string; + bio?: string; + joinedAt: Date; + }): Driver { + return new Driver({ + id: props.id, + iracingId: IRacingId.create(props.iracingId), + name: DriverName.create(props.name), + country: CountryCode.create(props.country), + ...(props.bio !== undefined ? { bio: DriverBio.create(props.bio) } : {}), + joinedAt: JoinedAt.create(props.joinedAt), + }); + } + /** * Create a copy with updated properties */ diff --git a/core/racing/domain/entities/Game.ts b/core/racing/domain/entities/Game.ts index ed70d7b77..c6635bb14 100644 --- a/core/racing/domain/entities/Game.ts +++ b/core/racing/domain/entities/Game.ts @@ -20,4 +20,11 @@ export class Game implements IEntity { name, }); } + + static rehydrate(props: { id: string; name: string }): Game { + return new Game({ + id: GameId.create(props.id), + name: GameName.create(props.name), + }); + } } \ No newline at end of file diff --git a/core/racing/domain/entities/JoinRequest.ts b/core/racing/domain/entities/JoinRequest.ts index 20d49dc03..3564c96d9 100644 --- a/core/racing/domain/entities/JoinRequest.ts +++ b/core/racing/domain/entities/JoinRequest.ts @@ -49,6 +49,22 @@ export class JoinRequest implements IEntity { }); } + static rehydrate(props: { + id: string; + leagueId: string; + driverId: string; + requestedAt: Date; + message?: string; + }): JoinRequest { + return new JoinRequest({ + id: props.id, + leagueId: props.leagueId, + driverId: props.driverId, + requestedAt: props.requestedAt, + ...(props.message !== undefined && { message: props.message }), + }); + } + private static validate(props: JoinRequestProps): void { if (!props.leagueId || props.leagueId.trim().length === 0) { throw new RacingDomainValidationError('League ID is required'); diff --git a/core/racing/domain/entities/LeagueMembership.ts b/core/racing/domain/entities/LeagueMembership.ts index 25be4ddee..decb97f91 100644 --- a/core/racing/domain/entities/LeagueMembership.ts +++ b/core/racing/domain/entities/LeagueMembership.ts @@ -70,6 +70,24 @@ export class LeagueMembership implements IEntity { }); } + static rehydrate(props: { + id: string; + leagueId: string; + driverId: string; + role: MembershipRoleValue; + status: MembershipStatusValue; + joinedAt: Date; + }): LeagueMembership { + return new LeagueMembership({ + id: props.id, + leagueId: LeagueId.create(props.leagueId), + driverId: DriverId.create(props.driverId), + role: MembershipRole.create(props.role), + status: MembershipStatus.create(props.status), + joinedAt: JoinedAt.create(props.joinedAt), + }); + } + private static validate(props: LeagueMembershipProps): void { if (!props.leagueId || props.leagueId.trim().length === 0) { throw new RacingDomainValidationError('League ID is required'); diff --git a/core/racing/domain/entities/Protest.ts b/core/racing/domain/entities/Protest.ts index 4d3ad111f..16b3189de 100644 --- a/core/racing/domain/entities/Protest.ts +++ b/core/racing/domain/entities/Protest.ts @@ -116,6 +116,61 @@ export class Protest implements IEntity { return new Protest(protestProps); } + static rehydrate(props: { + id: string; + raceId: string; + protestingDriverId: string; + accusedDriverId: string; + incident: { lap: number; description: string; timeInRace?: number }; + comment?: string; + proofVideoUrl?: string; + status: string; + reviewedBy?: string; + decisionNotes?: string; + filedAt: Date; + reviewedAt?: Date; + defense?: { statement: string; videoUrl?: string; submittedAt: Date }; + defenseRequestedAt?: Date; + defenseRequestedBy?: string; + }): Protest { + const id = ProtestId.create(props.id); + const raceId = RaceId.create(props.raceId); + const protestingDriverId = DriverId.create(props.protestingDriverId); + const accusedDriverId = DriverId.create(props.accusedDriverId); + const incident = ProtestIncident.create(props.incident.lap, props.incident.description, props.incident.timeInRace); + const comment = props.comment ? ProtestComment.create(props.comment) : undefined; + const proofVideoUrl = props.proofVideoUrl ? VideoUrl.create(props.proofVideoUrl) : undefined; + const status = ProtestStatus.create(props.status); + const reviewedBy = props.reviewedBy ? StewardId.create(props.reviewedBy) : undefined; + const decisionNotes = props.decisionNotes ? DecisionNotes.create(props.decisionNotes) : undefined; + const filedAt = FiledAt.create(props.filedAt); + const reviewedAt = props.reviewedAt ? ReviewedAt.create(props.reviewedAt) : undefined; + const defense = props.defense ? ProtestDefense.create(props.defense.statement, props.defense.submittedAt, props.defense.videoUrl) : undefined; + const defenseRequestedAt = props.defenseRequestedAt ? DefenseRequestedAt.create(props.defenseRequestedAt) : undefined; + const defenseRequestedBy = props.defenseRequestedBy ? StewardId.create(props.defenseRequestedBy) : undefined; + + const protestProps: ProtestProps = { + id, + raceId, + protestingDriverId, + accusedDriverId, + incident, + status, + filedAt, + }; + + if (comment !== undefined) protestProps.comment = comment; + if (proofVideoUrl !== undefined) protestProps.proofVideoUrl = proofVideoUrl; + if (reviewedBy !== undefined) protestProps.reviewedBy = reviewedBy; + if (decisionNotes !== undefined) protestProps.decisionNotes = decisionNotes; + if (reviewedAt !== undefined) protestProps.reviewedAt = reviewedAt; + if (defense !== undefined) protestProps.defense = defense; + if (defenseRequestedAt !== undefined) protestProps.defenseRequestedAt = defenseRequestedAt; + if (defenseRequestedBy !== undefined) protestProps.defenseRequestedBy = defenseRequestedBy; + + return new Protest(protestProps); + } + get id(): string { return this.props.id.toString(); } get raceId(): string { return this.props.raceId.toString(); } get protestingDriverId(): string { return this.props.protestingDriverId.toString(); } diff --git a/core/racing/domain/entities/RaceRegistration.ts b/core/racing/domain/entities/RaceRegistration.ts index 962603abc..2d9923e54 100644 --- a/core/racing/domain/entities/RaceRegistration.ts +++ b/core/racing/domain/entities/RaceRegistration.ts @@ -55,6 +55,15 @@ export class RaceRegistration implements IEntity { }); } + static rehydrate(props: { id: string; raceId: string; driverId: string; registeredAt: Date }): RaceRegistration { + return new RaceRegistration({ + id: props.id, + raceId: RaceId.create(props.raceId), + driverId: DriverId.create(props.driverId), + registeredAt: RegisteredAt.create(props.registeredAt), + }); + } + private static validate(props: RaceRegistrationProps): void { if (!props.raceId || props.raceId.trim().length === 0) { throw new RacingDomainValidationError('Race ID is required'); diff --git a/core/racing/domain/entities/SponsorshipRequest.ts b/core/racing/domain/entities/SponsorshipRequest.ts index c4a718c94..91a7b5024 100644 --- a/core/racing/domain/entities/SponsorshipRequest.ts +++ b/core/racing/domain/entities/SponsorshipRequest.ts @@ -71,6 +71,10 @@ export class SponsorshipRequest implements IEntity { }); } + static rehydrate(props: SponsorshipRequestProps): SponsorshipRequest { + return new SponsorshipRequest(props); + } + private static validate(props: Omit): void { if (!props.id || props.id.trim().length === 0) { throw new RacingDomainValidationError('SponsorshipRequest ID is required'); diff --git a/core/racing/domain/entities/Standing.ts b/core/racing/domain/entities/Standing.ts index 22a61ba03..e392d2b2a 100644 --- a/core/racing/domain/entities/Standing.ts +++ b/core/racing/domain/entities/Standing.ts @@ -74,6 +74,30 @@ export class Standing implements IEntity { }); } + static rehydrate(props: { + id: string; + leagueId: string; + driverId: string; + points: number; + wins: number; + position: number; + racesCompleted: number; + }): Standing { + if (!props.id || props.id.trim().length === 0) { + throw new RacingDomainValidationError('Standing ID is required'); + } + + return new Standing({ + id: props.id, + leagueId: LeagueId.create(props.leagueId), + driverId: DriverId.create(props.driverId), + points: Points.create(props.points), + wins: props.wins, + position: Position.create(props.position), + racesCompleted: props.racesCompleted, + }); + } + /** * Domain validation logic */ diff --git a/core/racing/domain/entities/Team.ts b/core/racing/domain/entities/Team.ts index bbca4154a..0e3435aa5 100644 --- a/core/racing/domain/entities/Team.ts +++ b/core/racing/domain/entities/Team.ts @@ -73,6 +73,34 @@ export class Team implements IEntity { }); } + static rehydrate(props: { + id: string; + name: string; + tag: string; + description: string; + ownerId: string; + leagues: string[]; + createdAt: Date; + }): Team { + if (!props.id || props.id.trim().length === 0) { + throw new RacingDomainValidationError('Team ID is required'); + } + + if (!Array.isArray(props.leagues)) { + throw new RacingDomainValidationError('Team leagues must be an array'); + } + + return new Team({ + id: props.id, + name: TeamName.create(props.name), + tag: TeamTag.create(props.tag), + description: TeamDescription.create(props.description), + ownerId: DriverId.create(props.ownerId), + leagues: props.leagues.map(leagueId => LeagueId.create(leagueId)), + createdAt: TeamCreatedAt.create(props.createdAt), + }); + } + /** * Create a copy with updated properties. */ diff --git a/core/racing/domain/entities/league-wallet/LeagueWallet.ts b/core/racing/domain/entities/league-wallet/LeagueWallet.ts index 20f237d98..56fc54d91 100644 --- a/core/racing/domain/entities/league-wallet/LeagueWallet.ts +++ b/core/racing/domain/entities/league-wallet/LeagueWallet.ts @@ -58,6 +58,26 @@ export class LeagueWallet implements IEntity { }); } + static rehydrate(props: { + id: string; + leagueId: string; + balance: Money; + transactionIds: string[]; + createdAt: Date; + }): LeagueWallet { + const id = LeagueWalletId.create(props.id); + const leagueId = LeagueId.create(props.leagueId); + const transactionIds = props.transactionIds.map(tid => TransactionId.create(tid)); + + return new LeagueWallet({ + id, + leagueId, + balance: props.balance, + transactionIds, + createdAt: props.createdAt, + }); + } + private static validate(props: { id: string; leagueId: string; diff --git a/core/racing/domain/entities/league-wallet/Transaction.ts b/core/racing/domain/entities/league-wallet/Transaction.ts index 53a19af2e..13a7ff680 100644 --- a/core/racing/domain/entities/league-wallet/Transaction.ts +++ b/core/racing/domain/entities/league-wallet/Transaction.ts @@ -8,8 +8,8 @@ import { RacingDomainValidationError, RacingDomainInvariantError } from '../../e import type { Money } from '../../value-objects/Money'; import type { IEntity } from '@core/shared/domain'; -import type { TransactionId } from './TransactionId'; -import type { LeagueWalletId } from './LeagueWalletId'; +import { TransactionId } from './TransactionId'; +import { LeagueWalletId } from './LeagueWalletId'; export type TransactionType = | 'sponsorship_payment' @@ -79,6 +79,34 @@ export class Transaction implements IEntity { }); } + static rehydrate(props: { + id: string; + walletId: string; + type: TransactionType; + amount: Money; + platformFee: Money; + netAmount: Money; + status: TransactionStatus; + createdAt: Date; + completedAt?: Date; + description?: string; + metadata?: Record; + }): Transaction { + return new Transaction({ + id: TransactionId.create(props.id), + walletId: LeagueWalletId.create(props.walletId), + type: props.type, + amount: props.amount, + platformFee: props.platformFee, + netAmount: props.netAmount, + status: props.status, + createdAt: props.createdAt, + completedAt: props.completedAt, + description: props.description, + metadata: props.metadata, + }); + } + private static validate(props: Omit): void { if (!props.id) { throw new RacingDomainValidationError('Transaction ID is required'); diff --git a/core/racing/domain/entities/penalty/Penalty.ts b/core/racing/domain/entities/penalty/Penalty.ts index bcb1d9ef8..2242b11c2 100644 --- a/core/racing/domain/entities/penalty/Penalty.ts +++ b/core/racing/domain/entities/penalty/Penalty.ts @@ -114,6 +114,46 @@ export class Penalty implements IEntity { return new Penalty(penaltyProps); } + static rehydrate(props: { + id: string; + leagueId: string; + raceId: string; + driverId: string; + type: string; + value?: number; + reason: string; + protestId?: string; + issuedBy: string; + status: string; + issuedAt: Date; + appliedAt?: Date; + notes?: string; + }): Penalty { + const penaltyType = PenaltyType.create(props.type); + + if (penaltyTypeRequiresValue(penaltyType.toString())) { + if (props.value === undefined || props.value <= 0) { + throw new RacingDomainValidationError(`${penaltyType.toString()} requires a positive value`); + } + } + + return new Penalty({ + id: PenaltyId.create(props.id), + leagueId: LeagueId.create(props.leagueId), + raceId: RaceId.create(props.raceId), + driverId: DriverId.create(props.driverId), + type: penaltyType, + ...(props.value !== undefined ? { value: PenaltyValue.create(props.value) } : {}), + reason: PenaltyReason.create(props.reason), + ...(props.protestId !== undefined ? { protestId: ProtestId.create(props.protestId) } : {}), + issuedBy: StewardId.create(props.issuedBy), + status: PenaltyStatus.create(props.status), + issuedAt: IssuedAt.create(props.issuedAt), + ...(props.appliedAt !== undefined ? { appliedAt: AppliedAt.create(props.appliedAt) } : {}), + ...(props.notes !== undefined ? { notes: PenaltyNotes.create(props.notes) } : {}), + }); + } + get id(): string { return this.props.id.toString(); } get leagueId(): string { return this.props.leagueId.toString(); } get raceId(): string { return this.props.raceId.toString(); } diff --git a/core/racing/domain/entities/result/Result.ts b/core/racing/domain/entities/result/Result.ts index 8f6ffdb94..67122568d 100644 --- a/core/racing/domain/entities/result/Result.ts +++ b/core/racing/domain/entities/result/Result.ts @@ -72,6 +72,30 @@ export class Result implements IEntity { }); } + static rehydrate(props: { + id: string; + raceId: string; + driverId: string; + position: number; + fastestLap: number; + incidents: number; + startPosition: number; + }): Result { + if (!props.id || props.id.trim().length === 0) { + throw new RacingDomainValidationError('Result ID is required'); + } + + return new Result({ + id: props.id, + raceId: RaceId.create(props.raceId), + driverId: DriverId.create(props.driverId), + position: Position.create(props.position), + fastestLap: LapTime.create(props.fastestLap), + incidents: IncidentCount.create(props.incidents), + startPosition: Position.create(props.startPosition), + }); + } + /** * Domain validation logic */ diff --git a/core/racing/domain/entities/season/SeasonSponsorship.ts b/core/racing/domain/entities/season/SeasonSponsorship.ts index a9537b353..7446fd0c4 100644 --- a/core/racing/domain/entities/season/SeasonSponsorship.ts +++ b/core/racing/domain/entities/season/SeasonSponsorship.ts @@ -83,6 +83,10 @@ export class SeasonSponsorship implements IEntity { }); } + static rehydrate(props: SeasonSponsorshipProps): SeasonSponsorship { + return new SeasonSponsorship(props); + } + private static validate(props: Omit): void { if (!props.id || props.id.trim().length === 0) { throw new RacingDomainValidationError('SeasonSponsorship ID is required'); diff --git a/core/racing/domain/entities/sponsor/Sponsor.ts b/core/racing/domain/entities/sponsor/Sponsor.ts index d5c2ac611..32db10b91 100644 --- a/core/racing/domain/entities/sponsor/Sponsor.ts +++ b/core/racing/domain/entities/sponsor/Sponsor.ts @@ -62,6 +62,31 @@ export class Sponsor implements IEntity { }); } + static rehydrate(props: { + id: string; + name: string; + contactEmail: string; + logoUrl?: string; + websiteUrl?: string; + createdAt: Date; + }): Sponsor { + const id = SponsorId.create(props.id); + const name = SponsorName.create(props.name); + const contactEmail = SponsorEmail.create(props.contactEmail); + const logoUrl = props.logoUrl ? Url.create(props.logoUrl) : undefined; + const websiteUrl = props.websiteUrl ? Url.create(props.websiteUrl) : undefined; + const createdAt = SponsorCreatedAt.create(props.createdAt); + + return new Sponsor({ + id, + name, + contactEmail, + createdAt, + ...(logoUrl !== undefined ? { logoUrl } : {}), + ...(websiteUrl !== undefined ? { websiteUrl } : {}), + }); + } + /** * Update sponsor information */