diff --git a/adapters/achievement/persistence/typeorm/entities/AchievementOrmEntity.ts b/adapters/achievement/persistence/typeorm/entities/AchievementOrmEntity.ts new file mode 100644 index 000000000..17a04b468 --- /dev/null +++ b/adapters/achievement/persistence/typeorm/entities/AchievementOrmEntity.ts @@ -0,0 +1,34 @@ +import { Column, CreateDateColumn, Entity, PrimaryColumn } from 'typeorm'; + +@Entity({ name: 'achievements' }) +export class AchievementOrmEntity { + @PrimaryColumn({ type: 'text' }) + id!: string; + + @Column({ type: 'text' }) + name!: string; + + @Column({ type: 'text' }) + description!: string; + + @Column({ type: 'text' }) + category!: string; + + @Column({ type: 'text' }) + rarity!: string; + + @Column({ type: 'text', nullable: true }) + iconUrl!: string | null; + + @Column({ type: 'int' }) + points!: number; + + @Column({ type: 'jsonb' }) + requirements!: Array<{ type: string; value: number; operator: string }>; + + @Column({ type: 'boolean' }) + isSecret!: boolean; + + @CreateDateColumn({ type: 'timestamptz' }) + createdAt!: Date; +} \ No newline at end of file diff --git a/adapters/achievement/persistence/typeorm/entities/UserAchievementOrmEntity.ts b/adapters/achievement/persistence/typeorm/entities/UserAchievementOrmEntity.ts new file mode 100644 index 000000000..1639850f1 --- /dev/null +++ b/adapters/achievement/persistence/typeorm/entities/UserAchievementOrmEntity.ts @@ -0,0 +1,23 @@ +import { Column, CreateDateColumn, Entity, Index, PrimaryColumn } from 'typeorm'; + +@Entity({ name: 'user_achievements' }) +@Index(['userId', 'achievementId'], { unique: true }) +export class UserAchievementOrmEntity { + @PrimaryColumn({ type: 'uuid' }) + id!: string; + + @Column({ type: 'uuid' }) + userId!: string; + + @Column({ type: 'text' }) + achievementId!: string; + + @Column({ type: 'int', default: 100 }) + progress!: number; + + @CreateDateColumn({ type: 'timestamptz' }) + earnedAt!: Date; + + @Column({ type: 'timestamptz', nullable: true }) + notifiedAt!: Date | null; +} \ No newline at end of file diff --git a/adapters/achievement/persistence/typeorm/errors/TypeOrmPersistenceSchemaError.ts b/adapters/achievement/persistence/typeorm/errors/TypeOrmPersistenceSchemaError.ts new file mode 100644 index 000000000..aac838001 --- /dev/null +++ b/adapters/achievement/persistence/typeorm/errors/TypeOrmPersistenceSchemaError.ts @@ -0,0 +1,21 @@ +export class TypeOrmPersistenceSchemaError extends Error { + public readonly entityName: string; + public readonly fieldName: string; + public readonly reason: string; + public readonly message: string; + + constructor(params: { + entityName: string; + fieldName: string; + reason: string; + message?: string; + }) { + const errorMessage = params.message || `Schema validation failed for ${params.entityName}.${params.fieldName}: ${params.reason}`; + super(errorMessage); + this.name = 'TypeOrmPersistenceSchemaError'; + this.entityName = params.entityName; + this.fieldName = params.fieldName; + this.reason = params.reason; + this.message = errorMessage; + } +} \ No newline at end of file diff --git a/adapters/achievement/persistence/typeorm/mappers/AchievementOrmMapper.ts b/adapters/achievement/persistence/typeorm/mappers/AchievementOrmMapper.ts new file mode 100644 index 000000000..3649d565e --- /dev/null +++ b/adapters/achievement/persistence/typeorm/mappers/AchievementOrmMapper.ts @@ -0,0 +1,130 @@ +import { Achievement, AchievementCategory, AchievementRequirement } from '@core/identity/domain/entities/Achievement'; +import { UserAchievement } from '@core/identity/domain/entities/UserAchievement'; + +import { AchievementOrmEntity } from '../entities/AchievementOrmEntity'; +import { UserAchievementOrmEntity } from '../entities/UserAchievementOrmEntity'; +import { TypeOrmPersistenceSchemaError } from '../errors/TypeOrmPersistenceSchemaError'; +import { + assertArray, + assertBoolean, + assertDate, + assertEnumValue, + assertInteger, + assertNonEmptyString, + assertOptionalStringOrNull, +} from '../schema/AchievementSchemaGuard'; + +const VALID_CATEGORIES = ['driver', 'steward', 'admin', 'community'] as const satisfies readonly AchievementCategory[]; +const VALID_RARITIES = ['common', 'uncommon', 'rare', 'epic', 'legendary'] as const; +const VALID_OPERATORS = ['>=', '>', '=', '<', '<='] as const; + +export class AchievementOrmMapper { + toOrmEntity(domain: Achievement): AchievementOrmEntity { + const entity = new AchievementOrmEntity(); + entity.id = domain.id; + entity.name = domain.name; + entity.description = domain.description; + entity.category = domain.category; + entity.rarity = domain.rarity; + entity.iconUrl = domain.iconUrl || null; + entity.points = domain.points; + entity.requirements = domain.requirements; + entity.isSecret = domain.isSecret; + entity.createdAt = domain.createdAt; + return entity; + } + + toDomain(entity: AchievementOrmEntity): Achievement { + const entityName = 'Achievement'; + + // Validate all required fields + assertNonEmptyString(entityName, 'id', entity.id); + assertNonEmptyString(entityName, 'name', entity.name); + assertNonEmptyString(entityName, 'description', entity.description); + assertEnumValue(entityName, 'category', entity.category, VALID_CATEGORIES); + assertEnumValue(entityName, 'rarity', entity.rarity, VALID_RARITIES); + assertOptionalStringOrNull(entityName, 'iconUrl', entity.iconUrl); + assertInteger(entityName, 'points', entity.points); + assertArray(entityName, 'requirements', entity.requirements); + assertBoolean(entityName, 'isSecret', entity.isSecret); + assertDate(entityName, 'createdAt', entity.createdAt); + + // Validate requirements structure + for (let i = 0; i < entity.requirements.length; i++) { + const req = entity.requirements[i]; + const reqField = `requirements[${i}]`; + + if (!req || typeof req !== 'object') { + throw new TypeOrmPersistenceSchemaError({ + entityName, + fieldName: reqField, + reason: 'invalid_requirement_object', + }); + } + + assertNonEmptyString(entityName, `${reqField}.type`, req.type); + assertInteger(entityName, `${reqField}.value`, req.value); + assertEnumValue(entityName, `${reqField}.operator`, req.operator, VALID_OPERATORS); + } + + try { + const createProps: any = { + id: entity.id, + name: entity.name, + description: entity.description, + category: entity.category as AchievementCategory, + rarity: entity.rarity as any, + points: entity.points, + requirements: entity.requirements as AchievementRequirement[], + isSecret: entity.isSecret, + createdAt: entity.createdAt, + }; + + if (entity.iconUrl !== null) { + createProps.iconUrl = entity.iconUrl; + } + + return Achievement.create(createProps); + } catch (error) { + const message = error instanceof Error ? error.message : 'Invalid persisted Achievement'; + throw new TypeOrmPersistenceSchemaError({ entityName, fieldName: 'unknown', reason: 'invalid_shape', message }); + } + } + + toUserAchievementOrmEntity(domain: UserAchievement): UserAchievementOrmEntity { + const entity = new UserAchievementOrmEntity(); + entity.id = domain.id; + entity.userId = domain.userId; + entity.achievementId = domain.achievementId; + entity.progress = domain.progress; + entity.earnedAt = domain.earnedAt; + entity.notifiedAt = domain.notifiedAt || null; + return entity; + } + + toUserAchievementDomain(entity: UserAchievementOrmEntity): UserAchievement { + const entityName = 'UserAchievement'; + + // Validate all required fields + assertNonEmptyString(entityName, 'id', entity.id); + assertNonEmptyString(entityName, 'userId', entity.userId); + assertNonEmptyString(entityName, 'achievementId', entity.achievementId); + assertInteger(entityName, 'progress', entity.progress); + assertDate(entityName, 'earnedAt', entity.earnedAt); + assertOptionalStringOrNull(entityName, 'notifiedAt', entity.notifiedAt); + + try { + return UserAchievement.create({ + id: entity.id, + userId: entity.userId, + achievementId: entity.achievementId, + progress: entity.progress, + earnedAt: entity.earnedAt, + ...(entity.notifiedAt ? { notifiedAt: entity.notifiedAt } : {}), + }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Invalid persisted UserAchievement'; + throw new TypeOrmPersistenceSchemaError({ entityName, fieldName: 'unknown', reason: 'invalid_shape', message }); + } + } +} \ No newline at end of file diff --git a/adapters/achievement/persistence/typeorm/repositories/TypeOrmAchievementRepository.ts b/adapters/achievement/persistence/typeorm/repositories/TypeOrmAchievementRepository.ts new file mode 100644 index 000000000..a46ff51ca --- /dev/null +++ b/adapters/achievement/persistence/typeorm/repositories/TypeOrmAchievementRepository.ts @@ -0,0 +1,150 @@ +import type { DataSource } from 'typeorm'; + +import type { IAchievementRepository } from '@core/identity/domain/repositories/IAchievementRepository'; +import type { AchievementCategory } from '@core/identity/domain/entities/Achievement'; +import { Achievement } from '@core/identity/domain/entities/Achievement'; +import { UserAchievement } from '@core/identity/domain/entities/UserAchievement'; + +import { AchievementOrmEntity } from '../entities/AchievementOrmEntity'; +import { UserAchievementOrmEntity } from '../entities/UserAchievementOrmEntity'; +import { AchievementOrmMapper } from '../mappers/AchievementOrmMapper'; + +export class TypeOrmAchievementRepository implements IAchievementRepository { + constructor( + private readonly dataSource: DataSource, + private readonly mapper: AchievementOrmMapper, + ) {} + + // Achievement operations + async findAchievementById(id: string): Promise { + const repo = this.dataSource.getRepository(AchievementOrmEntity); + const entity = await repo.findOne({ where: { id } }); + return entity ? this.mapper.toDomain(entity) : null; + } + + async findAllAchievements(): Promise { + const repo = this.dataSource.getRepository(AchievementOrmEntity); + const entities = await repo.find(); + return entities.map((e) => this.mapper.toDomain(e)); + } + + async findAchievementsByCategory(category: AchievementCategory): Promise { + const repo = this.dataSource.getRepository(AchievementOrmEntity); + const entities = await repo.find({ where: { category } }); + return entities.map((e) => this.mapper.toDomain(e)); + } + + async createAchievement(achievement: Achievement): Promise { + const repo = this.dataSource.getRepository(AchievementOrmEntity); + const entity = this.mapper.toOrmEntity(achievement); + await repo.save(entity); + return achievement; + } + + // UserAchievement operations + async findUserAchievementById(id: string): Promise { + const repo = this.dataSource.getRepository(UserAchievementOrmEntity); + const entity = await repo.findOne({ where: { id } }); + return entity ? this.mapper.toUserAchievementDomain(entity) : null; + } + + async findUserAchievementsByUserId(userId: string): Promise { + const repo = this.dataSource.getRepository(UserAchievementOrmEntity); + const entities = await repo.find({ where: { userId } }); + return entities.map((e) => this.mapper.toUserAchievementDomain(e)); + } + + async findUserAchievementByUserAndAchievement(userId: string, achievementId: string): Promise { + const repo = this.dataSource.getRepository(UserAchievementOrmEntity); + const entity = await repo.findOne({ where: { userId, achievementId } }); + return entity ? this.mapper.toUserAchievementDomain(entity) : null; + } + + async hasUserEarnedAchievement(userId: string, achievementId: string): Promise { + const ua = await this.findUserAchievementByUserAndAchievement(userId, achievementId); + return ua !== null && ua.isComplete(); + } + + async createUserAchievement(userAchievement: UserAchievement): Promise { + const repo = this.dataSource.getRepository(UserAchievementOrmEntity); + const entity = this.mapper.toUserAchievementOrmEntity(userAchievement); + await repo.save(entity); + return userAchievement; + } + + async updateUserAchievement(userAchievement: UserAchievement): Promise { + const repo = this.dataSource.getRepository(UserAchievementOrmEntity); + const entity = this.mapper.toUserAchievementOrmEntity(userAchievement); + await repo.save(entity); + return userAchievement; + } + + // Stats + async getAchievementLeaderboard(limit: number): Promise<{ userId: string; points: number; count: number }[]> { + const userAchievementRepo = this.dataSource.getRepository(UserAchievementOrmEntity); + const achievementRepo = this.dataSource.getRepository(AchievementOrmEntity); + + // Get all completed user achievements + const userAchievements = await userAchievementRepo.find({ + where: { progress: 100 }, + }); + + // Build stats map + const userStats = new Map(); + + for (const ua of userAchievements) { + const achievement = await achievementRepo.findOne({ where: { id: ua.achievementId } }); + if (!achievement) continue; + + const existing = userStats.get(ua.userId) || { points: 0, count: 0 }; + userStats.set(ua.userId, { + points: existing.points + achievement.points, + count: existing.count + 1, + }); + } + + // Sort and return top N + return Array.from(userStats.entries()) + .map(([userId, stats]) => ({ userId, ...stats })) + .sort((a, b) => b.points - a.points || b.count - a.count) + .slice(0, limit); + } + + async getUserAchievementStats(userId: string): Promise<{ + total: number; + points: number; + byCategory: Record; + }> { + const userAchievementRepo = this.dataSource.getRepository(UserAchievementOrmEntity); + const achievementRepo = this.dataSource.getRepository(AchievementOrmEntity); + + const userAchievements = await userAchievementRepo.find({ + where: { userId, progress: 100 }, + }); + + const byCategory: Record = { + driver: 0, + steward: 0, + admin: 0, + community: 0, + }; + + let points = 0; + + for (const ua of userAchievements) { + const achievement = await achievementRepo.findOne({ where: { id: ua.achievementId } }); + if (achievement) { + points += achievement.points; + if (achievement.category in byCategory) { + byCategory[achievement.category as AchievementCategory]++; + } + } + } + + return { + total: userAchievements.length, + points, + byCategory, + }; + } +} \ No newline at end of file diff --git a/adapters/achievement/persistence/typeorm/schema/AchievementSchemaGuard.ts b/adapters/achievement/persistence/typeorm/schema/AchievementSchemaGuard.ts new file mode 100644 index 000000000..22df12c34 --- /dev/null +++ b/adapters/achievement/persistence/typeorm/schema/AchievementSchemaGuard.ts @@ -0,0 +1,79 @@ +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 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 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' }); + } +} + +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' }); + } +} \ 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 index 566033e30..1d486ccfd 100644 --- a/adapters/identity/persistence/typeorm/mappers/UserOrmMapper.test.ts +++ b/adapters/identity/persistence/typeorm/mappers/UserOrmMapper.test.ts @@ -15,7 +15,7 @@ describe('UserOrmMapper', () => { entity.email = 'alice@example.com'; entity.displayName = 'Alice'; entity.passwordHash = 'bcrypt-hash'; - entity.salt = ''; + entity.salt = 'test-salt'; entity.primaryDriverId = null; entity.createdAt = new Date('2025-01-01T00:00:00.000Z'); @@ -45,7 +45,7 @@ describe('UserOrmMapper', () => { entity.email = 123 as unknown as string; entity.displayName = 'Alice'; entity.passwordHash = 'bcrypt-hash'; - entity.salt = ''; + entity.salt = 'test-salt'; entity.primaryDriverId = null; entity.createdAt = new Date('2025-01-01T00:00:00.000Z'); diff --git a/adapters/identity/persistence/typeorm/repositories/TypeOrmAuthRepository.test.ts b/adapters/identity/persistence/typeorm/repositories/TypeOrmAuthRepository.test.ts new file mode 100644 index 000000000..a69917e64 --- /dev/null +++ b/adapters/identity/persistence/typeorm/repositories/TypeOrmAuthRepository.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it, vi } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import type { DataSource, Repository } from 'typeorm'; + +import { TypeOrmAuthRepository } from './TypeOrmAuthRepository'; +import { UserOrmEntity } from '../entities/UserOrmEntity'; +import { UserOrmMapper } from '../mappers/UserOrmMapper'; +import { EmailAddress } from '@core/identity/domain/value-objects/EmailAddress'; +import { User } from '@core/identity/domain/entities/User'; +import { PasswordHash } from '@core/identity/domain/value-objects/PasswordHash'; +import { UserId } from '@core/identity/domain/value-objects/UserId'; + +describe('TypeOrmAuthRepository', () => { + it('does not construct its own mapper dependencies', () => { + const sourcePath = path.resolve(__dirname, 'TypeOrmAuthRepository.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(TypeOrmAuthRepository.length).toBe(2); + }); + + it('uses the injected mapper at runtime (DB-free)', async () => { + const ormRepo = { + findOne: vi.fn().mockResolvedValue({ id: 'u1', email: 'test@example.com' }), + save: vi.fn().mockResolvedValue({ id: 'u1' }), + } as unknown as Repository; + + const dataSource = { + getRepository: vi.fn().mockReturnValue(ormRepo), + } as unknown as DataSource; + + const mapper = { + toDomain: vi.fn().mockReturnValue({ + getId: () => ({ value: 'u1' }), + getEmail: () => 'test@example.com', + getDisplayName: () => 'Test User', + getPasswordHash: () => ({ value: 'hash' }), + getPrimaryDriverId: () => null, + }), + toOrmEntity: vi.fn().mockReturnValue({ id: 'u1', email: 'test@example.com' }), + } as unknown as UserOrmMapper; + + const repo = new TypeOrmAuthRepository(dataSource, mapper); + + // Test findByEmail + const email = EmailAddress.create('TEST@EXAMPLE.COM'); + const user = await repo.findByEmail(email); + + expect(dataSource.getRepository).toHaveBeenCalledTimes(1); + expect(ormRepo.findOne).toHaveBeenCalledWith({ where: { email: 'test@example.com' } }); + expect(mapper.toDomain).toHaveBeenCalledTimes(1); + expect(user).toBeDefined(); + + // Test save + const userId = UserId.create(); + const passwordHash = PasswordHash.fromHash('hash'); + const domainUser = User.create({ + id: userId, + email: 'test@example.com', + displayName: 'Test User', + passwordHash, + }); + + await repo.save(domainUser); + expect(ormRepo.save).toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/adapters/identity/persistence/typeorm/TypeOrmAuthRepository.ts b/adapters/identity/persistence/typeorm/repositories/TypeOrmAuthRepository.ts similarity index 93% rename from adapters/identity/persistence/typeorm/TypeOrmAuthRepository.ts rename to adapters/identity/persistence/typeorm/repositories/TypeOrmAuthRepository.ts index 79077ae3d..62ddf8e31 100644 --- a/adapters/identity/persistence/typeorm/TypeOrmAuthRepository.ts +++ b/adapters/identity/persistence/typeorm/repositories/TypeOrmAuthRepository.ts @@ -4,8 +4,8 @@ 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'; +import { UserOrmEntity } from '../entities/UserOrmEntity'; +import { UserOrmMapper } from '../mappers/UserOrmMapper'; export class TypeOrmAuthRepository implements IAuthRepository { constructor( diff --git a/adapters/identity/persistence/typeorm/TypeOrmUserRepository.test.ts b/adapters/identity/persistence/typeorm/repositories/TypeOrmUserRepository.test.ts similarity index 79% rename from adapters/identity/persistence/typeorm/TypeOrmUserRepository.test.ts rename to adapters/identity/persistence/typeorm/repositories/TypeOrmUserRepository.test.ts index 9d0629cb6..fbb35d0f7 100644 --- a/adapters/identity/persistence/typeorm/TypeOrmUserRepository.test.ts +++ b/adapters/identity/persistence/typeorm/repositories/TypeOrmUserRepository.test.ts @@ -1,8 +1,11 @@ import { describe, expect, it, vi } from 'vitest'; import * as fs from 'node:fs'; import * as path from 'node:path'; +import type { DataSource, Repository } from 'typeorm'; import { TypeOrmUserRepository } from './TypeOrmUserRepository'; +import { UserOrmEntity } from '../entities/UserOrmEntity'; +import { UserOrmMapper } from '../mappers/UserOrmMapper'; describe('TypeOrmUserRepository', () => { it('does not construct its own mapper dependencies', () => { @@ -20,18 +23,18 @@ describe('TypeOrmUserRepository', () => { it('uses the injected mapper at runtime (DB-free)', async () => { const ormRepo = { findOne: vi.fn().mockResolvedValue({ id: 'u1' }), - }; + } as unknown as Repository; const dataSource = { getRepository: vi.fn().mockReturnValue(ormRepo), - }; + } as unknown as DataSource; const mapper = { toStored: vi.fn().mockReturnValue({ id: 'stored-u1' }), toOrmEntity: vi.fn(), - }; + } as unknown as UserOrmMapper; - const repo = new TypeOrmUserRepository(dataSource as any, mapper as any); + const repo = new TypeOrmUserRepository(dataSource, mapper); const user = await repo.findByEmail('ALICE@EXAMPLE.COM'); diff --git a/adapters/identity/persistence/typeorm/TypeOrmUserRepository.ts b/adapters/identity/persistence/typeorm/repositories/TypeOrmUserRepository.ts similarity index 93% rename from adapters/identity/persistence/typeorm/TypeOrmUserRepository.ts rename to adapters/identity/persistence/typeorm/repositories/TypeOrmUserRepository.ts index 9ff3bb490..e0d505a82 100644 --- a/adapters/identity/persistence/typeorm/TypeOrmUserRepository.ts +++ b/adapters/identity/persistence/typeorm/repositories/TypeOrmUserRepository.ts @@ -2,8 +2,8 @@ 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'; +import { UserOrmEntity } from '../entities/UserOrmEntity'; +import { UserOrmMapper } from '../mappers/UserOrmMapper'; export class TypeOrmUserRepository implements IUserRepository { constructor( diff --git a/adapters/media/persistence/typeorm/entities/AvatarGenerationRequestOrmEntity.ts b/adapters/media/persistence/typeorm/entities/AvatarGenerationRequestOrmEntity.ts new file mode 100644 index 000000000..039bd834b --- /dev/null +++ b/adapters/media/persistence/typeorm/entities/AvatarGenerationRequestOrmEntity.ts @@ -0,0 +1,38 @@ +import { Column, CreateDateColumn, Entity, Index, PrimaryColumn, UpdateDateColumn } from 'typeorm'; + +@Entity({ name: 'avatar_generation_requests' }) +export class AvatarGenerationRequestOrmEntity { + @PrimaryColumn({ type: 'uuid' }) + id!: string; + + @Index() + @Column({ type: 'text' }) + userId!: string; + + @Column({ type: 'text' }) + facePhotoUrl!: string; + + @Column({ type: 'text' }) + suitColor!: string; + + @Column({ type: 'text' }) + style!: string; + + @Column({ type: 'text' }) + status!: string; + + @Column({ type: 'jsonb' }) + generatedAvatarUrls!: string[]; + + @Column({ type: 'integer', nullable: true }) + selectedAvatarIndex!: number | null; + + @Column({ type: 'text', nullable: true }) + errorMessage!: string | null; + + @CreateDateColumn({ type: 'timestamptz' }) + createdAt!: Date; + + @UpdateDateColumn({ type: 'timestamptz' }) + updatedAt!: Date; +} diff --git a/adapters/media/persistence/typeorm/entities/AvatarOrmEntity.ts b/adapters/media/persistence/typeorm/entities/AvatarOrmEntity.ts new file mode 100644 index 000000000..818d8ffed --- /dev/null +++ b/adapters/media/persistence/typeorm/entities/AvatarOrmEntity.ts @@ -0,0 +1,20 @@ +import { Column, CreateDateColumn, Entity, Index, PrimaryColumn } from 'typeorm'; + +@Entity({ name: 'avatars' }) +export class AvatarOrmEntity { + @PrimaryColumn({ type: 'uuid' }) + id!: string; + + @Index() + @Column({ type: 'text' }) + driverId!: string; + + @Column({ type: 'text' }) + mediaUrl!: string; + + @CreateDateColumn({ type: 'timestamptz' }) + selectedAt!: Date; + + @Column({ type: 'boolean', default: true }) + isActive!: boolean; +} diff --git a/adapters/media/persistence/typeorm/entities/MediaOrmEntity.ts b/adapters/media/persistence/typeorm/entities/MediaOrmEntity.ts new file mode 100644 index 000000000..9b855c650 --- /dev/null +++ b/adapters/media/persistence/typeorm/entities/MediaOrmEntity.ts @@ -0,0 +1,35 @@ +import { Column, CreateDateColumn, Entity, Index, PrimaryColumn } from 'typeorm'; + +@Entity({ name: 'media_files' }) +export class MediaOrmEntity { + @PrimaryColumn({ type: 'uuid' }) + id!: string; + + @Column({ type: 'text' }) + filename!: string; + + @Column({ type: 'text' }) + originalName!: string; + + @Column({ type: 'text' }) + mimeType!: string; + + @Column({ type: 'integer' }) + size!: number; + + @Column({ type: 'text' }) + url!: string; + + @Column({ type: 'text' }) + type!: string; + + @Index() + @Column({ type: 'text' }) + uploadedBy!: string; + + @CreateDateColumn({ type: 'timestamptz' }) + uploadedAt!: Date; + + @Column({ type: 'jsonb', nullable: true }) + metadata!: Record | null; +} diff --git a/adapters/media/persistence/typeorm/errors/TypeOrmMediaSchemaError.ts b/adapters/media/persistence/typeorm/errors/TypeOrmMediaSchemaError.ts new file mode 100644 index 000000000..987a91327 --- /dev/null +++ b/adapters/media/persistence/typeorm/errors/TypeOrmMediaSchemaError.ts @@ -0,0 +1,34 @@ +export type TypeOrmMediaSchemaErrorReason = + | '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 TypeOrmMediaSchemaError extends Error { + readonly entityName: string; + readonly fieldName: string; + readonly reason: TypeOrmMediaSchemaErrorReason | (string & {}); + + constructor(params: { + entityName: string; + fieldName: string; + reason: TypeOrmMediaSchemaError['reason']; + message?: string; + }) { + const message = params.message ?? `Invalid persisted ${params.entityName}.${params.fieldName}: ${params.reason}`; + super(message); + this.name = 'TypeOrmMediaSchemaError'; + this.entityName = params.entityName; + this.fieldName = params.fieldName; + this.reason = params.reason; + } +} diff --git a/adapters/media/persistence/typeorm/mappers/AvatarGenerationRequestOrmMapper.test.ts b/adapters/media/persistence/typeorm/mappers/AvatarGenerationRequestOrmMapper.test.ts new file mode 100644 index 000000000..c22fb76cd --- /dev/null +++ b/adapters/media/persistence/typeorm/mappers/AvatarGenerationRequestOrmMapper.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { AvatarGenerationRequest } from '@core/media/domain/entities/AvatarGenerationRequest'; + +import { AvatarGenerationRequestOrmEntity } from '../entities/AvatarGenerationRequestOrmEntity'; +import { TypeOrmMediaSchemaError } from '../errors/TypeOrmMediaSchemaError'; +import { AvatarGenerationRequestOrmMapper } from './AvatarGenerationRequestOrmMapper'; + +describe('AvatarGenerationRequestOrmMapper', () => { + it('toDomain preserves persisted identity and uses reconstitute semantics', () => { + const mapper = new AvatarGenerationRequestOrmMapper(); + + const entity = new AvatarGenerationRequestOrmEntity(); + entity.id = '00000000-0000-4000-8000-000000000001'; + entity.userId = 'user-123'; + entity.facePhotoUrl = 'https://cdn.example.com/faces/face-1.png'; + entity.suitColor = 'red'; + entity.style = 'realistic'; + entity.status = 'completed'; + entity.generatedAvatarUrls = ['https://cdn.example.com/avatars/av-1.png', 'https://cdn.example.com/avatars/av-2.png']; + entity.selectedAvatarIndex = 0; + entity.errorMessage = null; + entity.createdAt = new Date('2025-01-01T00:00:00.000Z'); + entity.updatedAt = new Date('2025-01-01T01:00:00.000Z'); + + const reconstituteSpy = vi.spyOn(AvatarGenerationRequest as unknown as { reconstitute: (...args: unknown[]) => unknown }, 'reconstitute'); + + const domain = mapper.toDomain(entity); + + expect(domain.id).toBe(entity.id); + expect(domain.userId).toBe(entity.userId); + expect(domain.facePhotoUrl.value).toBe(entity.facePhotoUrl); + expect(domain.suitColor).toBe(entity.suitColor); + expect(domain.status).toBe(entity.status); + expect(domain.generatedAvatarUrls).toEqual(entity.generatedAvatarUrls); + expect(domain.selectedAvatarIndex).toBe(entity.selectedAvatarIndex); + + expect(reconstituteSpy).toHaveBeenCalled(); + }); + + it('toDomain validates persisted shape', () => { + const mapper = new AvatarGenerationRequestOrmMapper(); + + const entity = new AvatarGenerationRequestOrmEntity(); + entity.id = '00000000-0000-4000-8000-000000000001'; + entity.userId = 123 as unknown as string; + entity.facePhotoUrl = 'https://cdn.example.com/faces/face-1.png'; + entity.suitColor = 'red'; + entity.style = 'realistic'; + entity.status = 'completed'; + entity.generatedAvatarUrls = []; + entity.selectedAvatarIndex = null; + entity.errorMessage = null; + entity.createdAt = new Date('2025-01-01T00:00:00.000Z'); + entity.updatedAt = new Date('2025-01-01T01:00:00.000Z'); + + try { + mapper.toDomain(entity); + throw new Error('expected-to-throw'); + } catch (error) { + expect(error).toBeInstanceOf(TypeOrmMediaSchemaError); + expect(error).toMatchObject({ + entityName: 'AvatarGenerationRequest', + fieldName: 'userId', + reason: 'not_string', + }); + } + }); + + it('toOrmEntity converts domain entity to ORM entity', () => { + const mapper = new AvatarGenerationRequestOrmMapper(); + + const domain = AvatarGenerationRequest.create({ + id: '00000000-0000-4000-8000-000000000001', + userId: 'user-123', + facePhotoUrl: 'https://cdn.example.com/faces/face-1.png', + suitColor: 'blue', + style: 'cartoon', + }); + + // Simulate completion + domain.completeWithAvatars(['https://cdn.example.com/avatars/av-1.png']); + + const entity = mapper.toOrmEntity(domain); + + expect(entity.id).toBe(domain.id); + expect(entity.userId).toBe(domain.userId); + expect(entity.facePhotoUrl).toBe(domain.facePhotoUrl.value); + expect(entity.suitColor).toBe(domain.suitColor); + expect(entity.style).toBe(domain.style); + expect(entity.status).toBe(domain.status); + expect(entity.generatedAvatarUrls).toEqual(domain.generatedAvatarUrls); + expect(entity.selectedAvatarIndex).toBeNull(); + expect(entity.errorMessage).toBeNull(); + }); + + it('toDomain handles optional fields correctly', () => { + const mapper = new AvatarGenerationRequestOrmMapper(); + + const entity = new AvatarGenerationRequestOrmEntity(); + entity.id = '00000000-0000-4000-8000-000000000001'; + entity.userId = 'user-123'; + entity.facePhotoUrl = 'https://cdn.example.com/faces/face-1.png'; + entity.suitColor = 'red'; + entity.style = 'realistic'; + entity.status = 'failed'; + entity.generatedAvatarUrls = []; + entity.selectedAvatarIndex = null; + entity.errorMessage = 'Generation failed'; + entity.createdAt = new Date('2025-01-01T00:00:00.000Z'); + entity.updatedAt = new Date('2025-01-01T01:00:00.000Z'); + + const domain = mapper.toDomain(entity); + + expect(domain.errorMessage).toBe('Generation failed'); + expect(domain.selectedAvatarIndex).toBeUndefined(); + }); +}); diff --git a/adapters/media/persistence/typeorm/mappers/AvatarGenerationRequestOrmMapper.ts b/adapters/media/persistence/typeorm/mappers/AvatarGenerationRequestOrmMapper.ts new file mode 100644 index 000000000..3f218609f --- /dev/null +++ b/adapters/media/persistence/typeorm/mappers/AvatarGenerationRequestOrmMapper.ts @@ -0,0 +1,89 @@ +import { AvatarGenerationRequest } from '@core/media/domain/entities/AvatarGenerationRequest'; +import { AvatarGenerationRequestOrmEntity } from '../entities/AvatarGenerationRequestOrmEntity'; +import { TypeOrmMediaSchemaError } from '../errors/TypeOrmMediaSchemaError'; +import { + assertNonEmptyString, + assertDate, + assertStringArray, + assertOptionalIntegerOrNull, + assertOptionalStringOrNull, + assertRacingSuitColor, + assertAvatarStyle, + assertAvatarGenerationStatus, +} from '../schema/TypeOrmMediaSchemaGuards'; + +export class AvatarGenerationRequestOrmMapper { + toDomain(entity: AvatarGenerationRequestOrmEntity): AvatarGenerationRequest { + const entityName = 'AvatarGenerationRequest'; + + try { + assertNonEmptyString(entityName, 'id', entity.id); + assertNonEmptyString(entityName, 'userId', entity.userId); + assertNonEmptyString(entityName, 'facePhotoUrl', entity.facePhotoUrl); + assertRacingSuitColor(entityName, 'suitColor', entity.suitColor); + assertAvatarStyle(entityName, 'style', entity.style); + assertAvatarGenerationStatus(entityName, 'status', entity.status); + assertStringArray(entityName, 'generatedAvatarUrls', entity.generatedAvatarUrls); + assertOptionalIntegerOrNull(entityName, 'selectedAvatarIndex', entity.selectedAvatarIndex); + assertOptionalStringOrNull(entityName, 'errorMessage', entity.errorMessage); + assertDate(entityName, 'createdAt', entity.createdAt); + assertDate(entityName, 'updatedAt', entity.updatedAt); + } catch (error) { + if (error instanceof TypeOrmMediaSchemaError) { + throw error; + } + const message = error instanceof Error ? error.message : 'Invalid persisted AvatarGenerationRequest'; + throw new TypeOrmMediaSchemaError({ entityName, fieldName: 'unknown', reason: 'invalid_shape', message }); + } + + try { + const props: any = { + id: entity.id, + userId: entity.userId, + facePhotoUrl: entity.facePhotoUrl, + suitColor: entity.suitColor, + style: entity.style, + status: entity.status, + generatedAvatarUrls: entity.generatedAvatarUrls, + createdAt: entity.createdAt, + updatedAt: entity.updatedAt, + }; + + if (entity.selectedAvatarIndex !== null && entity.selectedAvatarIndex !== undefined) { + props.selectedAvatarIndex = entity.selectedAvatarIndex; + } + + if (entity.errorMessage !== null && entity.errorMessage !== undefined) { + props.errorMessage = entity.errorMessage; + } + + return AvatarGenerationRequest.reconstitute(props); + } catch (error) { + const message = error instanceof Error ? error.message : 'Invalid persisted AvatarGenerationRequest'; + throw new TypeOrmMediaSchemaError({ entityName, fieldName: 'unknown', reason: 'invalid_shape', message }); + } + } + + toOrmEntity(request: AvatarGenerationRequest): AvatarGenerationRequestOrmEntity { + const entity = new AvatarGenerationRequestOrmEntity(); + const props = request.toProps(); + + entity.id = props.id; + entity.userId = props.userId; + entity.facePhotoUrl = props.facePhotoUrl; + entity.suitColor = props.suitColor; + entity.style = props.style; + entity.status = props.status; + entity.generatedAvatarUrls = props.generatedAvatarUrls; + entity.selectedAvatarIndex = props.selectedAvatarIndex ?? null; + entity.errorMessage = props.errorMessage ?? null; + entity.createdAt = props.createdAt; + entity.updatedAt = props.updatedAt; + + return entity; + } + + toStored(entity: AvatarGenerationRequestOrmEntity): AvatarGenerationRequest { + return this.toDomain(entity); + } +} diff --git a/adapters/media/persistence/typeorm/mappers/AvatarOrmMapper.test.ts b/adapters/media/persistence/typeorm/mappers/AvatarOrmMapper.test.ts new file mode 100644 index 000000000..447625fbf --- /dev/null +++ b/adapters/media/persistence/typeorm/mappers/AvatarOrmMapper.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { Avatar } from '@core/media/domain/entities/Avatar'; + +import { AvatarOrmEntity } from '../entities/AvatarOrmEntity'; +import { TypeOrmMediaSchemaError } from '../errors/TypeOrmMediaSchemaError'; +import { AvatarOrmMapper } from './AvatarOrmMapper'; + +describe('AvatarOrmMapper', () => { + it('toDomain preserves persisted identity and uses reconstitute semantics', () => { + const mapper = new AvatarOrmMapper(); + + const entity = new AvatarOrmEntity(); + entity.id = '00000000-0000-4000-8000-000000000001'; + entity.driverId = 'driver-123'; + entity.mediaUrl = 'https://cdn.example.com/avatars/avatar-1.png'; + entity.selectedAt = new Date('2025-01-01T00:00:00.000Z'); + entity.isActive = true; + + const reconstituteSpy = vi.spyOn(Avatar as unknown as { reconstitute: (...args: unknown[]) => unknown }, 'reconstitute'); + + const domain = mapper.toDomain(entity); + + expect(domain.id).toBe(entity.id); + expect(domain.driverId).toBe(entity.driverId); + expect(domain.mediaUrl.value).toBe(entity.mediaUrl); + expect(domain.isActive).toBe(entity.isActive); + + expect(reconstituteSpy).toHaveBeenCalled(); + }); + + it('toDomain validates persisted shape', () => { + const mapper = new AvatarOrmMapper(); + + const entity = new AvatarOrmEntity(); + entity.id = '00000000-0000-4000-8000-000000000001'; + entity.driverId = 123 as unknown as string; + entity.mediaUrl = 'https://cdn.example.com/avatars/avatar-1.png'; + entity.selectedAt = new Date('2025-01-01T00:00:00.000Z'); + entity.isActive = true; + + try { + mapper.toDomain(entity); + throw new Error('expected-to-throw'); + } catch (error) { + expect(error).toBeInstanceOf(TypeOrmMediaSchemaError); + expect(error).toMatchObject({ + entityName: 'Avatar', + fieldName: 'driverId', + reason: 'not_string', + }); + } + }); + + it('toOrmEntity converts domain entity to ORM entity', () => { + const mapper = new AvatarOrmMapper(); + + const domain = Avatar.create({ + id: '00000000-0000-4000-8000-000000000001', + driverId: 'driver-123', + mediaUrl: 'https://cdn.example.com/avatars/avatar-1.png', + }); + + const entity = mapper.toOrmEntity(domain); + + expect(entity.id).toBe(domain.id); + expect(entity.driverId).toBe(domain.driverId); + expect(entity.mediaUrl).toBe(domain.mediaUrl.value); + expect(entity.isActive).toBe(true); + }); +}); diff --git a/adapters/media/persistence/typeorm/mappers/AvatarOrmMapper.ts b/adapters/media/persistence/typeorm/mappers/AvatarOrmMapper.ts new file mode 100644 index 000000000..f1abdc523 --- /dev/null +++ b/adapters/media/persistence/typeorm/mappers/AvatarOrmMapper.ts @@ -0,0 +1,58 @@ +import { Avatar } from '@core/media/domain/entities/Avatar'; +import { AvatarOrmEntity } from '../entities/AvatarOrmEntity'; +import { TypeOrmMediaSchemaError } from '../errors/TypeOrmMediaSchemaError'; +import { + assertNonEmptyString, + assertDate, + assertBoolean, +} from '../schema/TypeOrmMediaSchemaGuards'; + +export class AvatarOrmMapper { + toDomain(entity: AvatarOrmEntity): Avatar { + const entityName = 'Avatar'; + + try { + assertNonEmptyString(entityName, 'id', entity.id); + assertNonEmptyString(entityName, 'driverId', entity.driverId); + assertNonEmptyString(entityName, 'mediaUrl', entity.mediaUrl); + assertDate(entityName, 'selectedAt', entity.selectedAt); + assertBoolean(entityName, 'isActive', entity.isActive); + } catch (error) { + if (error instanceof TypeOrmMediaSchemaError) { + throw error; + } + const message = error instanceof Error ? error.message : 'Invalid persisted Avatar'; + throw new TypeOrmMediaSchemaError({ entityName, fieldName: 'unknown', reason: 'invalid_shape', message }); + } + + try { + return Avatar.reconstitute({ + id: entity.id, + driverId: entity.driverId, + mediaUrl: entity.mediaUrl, + selectedAt: entity.selectedAt, + isActive: entity.isActive, + }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Invalid persisted Avatar'; + throw new TypeOrmMediaSchemaError({ entityName, fieldName: 'unknown', reason: 'invalid_shape', message }); + } + } + + toOrmEntity(avatar: Avatar): AvatarOrmEntity { + const entity = new AvatarOrmEntity(); + const props = avatar.toProps(); + + entity.id = props.id; + entity.driverId = props.driverId; + entity.mediaUrl = props.mediaUrl; + entity.selectedAt = props.selectedAt; + entity.isActive = props.isActive; + + return entity; + } + + toStored(entity: AvatarOrmEntity): Avatar { + return this.toDomain(entity); + } +} diff --git a/adapters/media/persistence/typeorm/mappers/MediaOrmMapper.test.ts b/adapters/media/persistence/typeorm/mappers/MediaOrmMapper.test.ts new file mode 100644 index 000000000..cef71dc7c --- /dev/null +++ b/adapters/media/persistence/typeorm/mappers/MediaOrmMapper.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { Media } from '@core/media/domain/entities/Media'; + +import { MediaOrmEntity } from '../entities/MediaOrmEntity'; +import { TypeOrmMediaSchemaError } from '../errors/TypeOrmMediaSchemaError'; +import { MediaOrmMapper } from './MediaOrmMapper'; + +describe('MediaOrmMapper', () => { + it('toDomain preserves persisted identity and uses reconstitute semantics (does not call create)', () => { + const mapper = new MediaOrmMapper(); + + const entity = new MediaOrmEntity(); + entity.id = '00000000-0000-4000-8000-000000000001'; + entity.filename = 'test-image.png'; + entity.originalName = 'original.png'; + entity.mimeType = 'image/png'; + entity.size = 12345; + entity.url = 'https://cdn.example.com/test-image.png'; + entity.type = 'image'; + entity.uploadedBy = 'user-123'; + entity.uploadedAt = new Date('2025-01-01T00:00:00.000Z'); + entity.metadata = { width: 800, height: 600 }; + + if (typeof (Media as unknown as { reconstitute?: unknown }).reconstitute !== 'function') { + throw new Error('reconstitute-missing'); + } + + const reconstituteSpy = vi.spyOn(Media as unknown as { reconstitute: (...args: unknown[]) => unknown }, 'reconstitute'); + + const domain = mapper.toDomain(entity); + + expect(domain.id).toBe(entity.id); + expect(domain.filename).toBe(entity.filename); + expect(domain.url.value).toBe(entity.url); + + expect(reconstituteSpy).toHaveBeenCalled(); + }); + + it('toDomain validates persisted shape and throws adapter-scoped base schema error type', () => { + const mapper = new MediaOrmMapper(); + + const entity = new MediaOrmEntity(); + entity.id = '00000000-0000-4000-8000-000000000001'; + entity.filename = 123 as unknown as string; + entity.originalName = 'original.png'; + entity.mimeType = 'image/png'; + entity.size = 12345; + entity.url = 'https://cdn.example.com/test-image.png'; + entity.type = 'image'; + entity.uploadedBy = 'user-123'; + entity.uploadedAt = new Date('2025-01-01T00:00:00.000Z'); + entity.metadata = null; + + try { + mapper.toDomain(entity); + throw new Error('expected-to-throw'); + } catch (error) { + expect(error).toBeInstanceOf(TypeOrmMediaSchemaError); + expect(error).toMatchObject({ + entityName: 'Media', + fieldName: 'filename', + reason: 'not_string', + }); + } + }); + + it('toOrmEntity converts domain entity to ORM entity', () => { + const mapper = new MediaOrmMapper(); + + const domain = Media.create({ + id: '00000000-0000-4000-8000-000000000001', + filename: 'test-image.png', + originalName: 'original.png', + mimeType: 'image/png', + size: 12345, + url: 'https://cdn.example.com/test-image.png', + type: 'image', + uploadedBy: 'user-123', + metadata: { width: 800, height: 600 }, + }); + + const entity = mapper.toOrmEntity(domain); + + expect(entity.id).toBe(domain.id); + expect(entity.filename).toBe(domain.filename); + expect(entity.url).toBe(domain.url.value); + expect(entity.metadata).toEqual({ width: 800, height: 600 }); + }); + + it('toDomain handles null metadata', () => { + const mapper = new MediaOrmMapper(); + + const entity = new MediaOrmEntity(); + entity.id = '00000000-0000-4000-8000-000000000001'; + entity.filename = 'test-image.png'; + entity.originalName = 'original.png'; + entity.mimeType = 'image/png'; + entity.size = 12345; + entity.url = 'https://cdn.example.com/test-image.png'; + entity.type = 'image'; + entity.uploadedBy = 'user-123'; + entity.uploadedAt = new Date('2025-01-01T00:00:00.000Z'); + entity.metadata = null; + + const domain = mapper.toDomain(entity); + + expect(domain.id).toBe(entity.id); + expect(domain.metadata).toBeUndefined(); + }); +}); diff --git a/adapters/media/persistence/typeorm/mappers/MediaOrmMapper.ts b/adapters/media/persistence/typeorm/mappers/MediaOrmMapper.ts new file mode 100644 index 000000000..f5905ffe5 --- /dev/null +++ b/adapters/media/persistence/typeorm/mappers/MediaOrmMapper.ts @@ -0,0 +1,78 @@ +import { Media } from '@core/media/domain/entities/Media'; +import { MediaOrmEntity } from '../entities/MediaOrmEntity'; +import { TypeOrmMediaSchemaError } from '../errors/TypeOrmMediaSchemaError'; +import { + assertNonEmptyString, + assertDate, + assertInteger, + assertMediaType, +} from '../schema/TypeOrmMediaSchemaGuards'; + +export class MediaOrmMapper { + toDomain(entity: MediaOrmEntity): Media { + const entityName = 'Media'; + + try { + assertNonEmptyString(entityName, 'id', entity.id); + assertNonEmptyString(entityName, 'filename', entity.filename); + assertNonEmptyString(entityName, 'originalName', entity.originalName); + assertNonEmptyString(entityName, 'mimeType', entity.mimeType); + assertInteger(entityName, 'size', entity.size); + assertNonEmptyString(entityName, 'url', entity.url); + assertMediaType(entityName, 'type', entity.type); + assertNonEmptyString(entityName, 'uploadedBy', entity.uploadedBy); + assertDate(entityName, 'uploadedAt', entity.uploadedAt); + } catch (error) { + if (error instanceof TypeOrmMediaSchemaError) { + throw error; + } + const message = error instanceof Error ? error.message : 'Invalid persisted Media'; + throw new TypeOrmMediaSchemaError({ entityName, fieldName: 'unknown', reason: 'invalid_shape', message }); + } + + try { + const domainProps: any = { + id: entity.id, + filename: entity.filename, + originalName: entity.originalName, + mimeType: entity.mimeType, + size: entity.size, + url: entity.url, + type: entity.type as 'image' | 'video' | 'document', + uploadedBy: entity.uploadedBy, + uploadedAt: entity.uploadedAt, + }; + + if (entity.metadata !== null && entity.metadata !== undefined) { + domainProps.metadata = entity.metadata as Record; + } + + return Media.reconstitute(domainProps); + } catch (error) { + const message = error instanceof Error ? error.message : 'Invalid persisted Media'; + throw new TypeOrmMediaSchemaError({ entityName, fieldName: 'unknown', reason: 'invalid_shape', message }); + } + } + + toOrmEntity(media: Media): MediaOrmEntity { + const entity = new MediaOrmEntity(); + const props = media.toProps(); + + entity.id = props.id; + entity.filename = props.filename; + entity.originalName = props.originalName; + entity.mimeType = props.mimeType; + entity.size = props.size; + entity.url = props.url; + entity.type = props.type; + entity.uploadedBy = props.uploadedBy; + entity.uploadedAt = props.uploadedAt; + entity.metadata = props.metadata ?? null; + + return entity; + } + + toStored(entity: MediaOrmEntity): Media { + return this.toDomain(entity); + } +} diff --git a/adapters/media/persistence/typeorm/repositories/TypeOrmAvatarGenerationRepository.test.ts b/adapters/media/persistence/typeorm/repositories/TypeOrmAvatarGenerationRepository.test.ts new file mode 100644 index 000000000..b10839c81 --- /dev/null +++ b/adapters/media/persistence/typeorm/repositories/TypeOrmAvatarGenerationRepository.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it, vi } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +import { TypeOrmAvatarGenerationRepository } from './TypeOrmAvatarGenerationRepository'; + +describe('TypeOrmAvatarGenerationRepository', () => { + it('does not construct its own mapper dependencies', () => { + const sourcePath = path.resolve(__dirname, 'TypeOrmAvatarGenerationRepository.ts'); + const source = fs.readFileSync(sourcePath, 'utf8'); + + expect(source).not.toMatch(/new\s+AvatarGenerationRequestOrmMapper\s*\(/); + expect(source).not.toMatch(/=\s*new\s+AvatarGenerationRequestOrmMapper\s*\(/); + }); + + it('requires mapper injection via constructor (no default mapper)', () => { + expect(TypeOrmAvatarGenerationRepository.length).toBe(2); + }); + + it('uses the injected mapper at runtime (DB-free)', async () => { + const ormRepo = { + findOne: vi.fn().mockResolvedValue({ id: 'request-1' }), + find: vi.fn().mockResolvedValue([{ id: 'request-1' }, { id: 'request-2' }]), + save: vi.fn().mockResolvedValue({ id: 'request-1' }), + delete: vi.fn().mockResolvedValue({ affected: 1 }), + }; + + const dataSource = { + getRepository: vi.fn().mockReturnValue(ormRepo), + }; + + const mapper = { + toStored: vi.fn().mockReturnValue({ id: 'stored-request-1' }), + toDomain: vi.fn().mockReturnValue({ id: 'domain-request-1' }), + toOrmEntity: vi.fn().mockReturnValue({ id: 'orm-request-1' }), + }; + + const repo = new TypeOrmAvatarGenerationRepository(dataSource as any, mapper as any); + + // Test findById + const request = await repo.findById('request-1'); + expect(dataSource.getRepository).toHaveBeenCalledTimes(1); + expect(ormRepo.findOne).toHaveBeenCalledWith({ where: { id: 'request-1' } }); + expect(mapper.toDomain).toHaveBeenCalledTimes(1); + expect(request).toEqual({ id: 'domain-request-1' }); + + // Test findByUserId + const requests = await repo.findByUserId('user-1'); + expect(ormRepo.find).toHaveBeenCalledWith({ + where: { userId: 'user-1' }, + order: { createdAt: 'DESC' } + }); + expect(mapper.toDomain).toHaveBeenCalledTimes(3); // 1 from findById + 2 from findByUserId + expect(requests).toHaveLength(2); + + // Test findLatestByUserId + await repo.findLatestByUserId('user-1'); + expect(ormRepo.findOne).toHaveBeenCalledWith({ + where: { userId: 'user-1' }, + order: { createdAt: 'DESC' } + }); + + // Test save + const domainRequest = { id: 'new-request', toProps: () => ({ id: 'new-request' }) }; + await repo.save(domainRequest as any); + expect(mapper.toOrmEntity).toHaveBeenCalledWith(domainRequest); + expect(ormRepo.save).toHaveBeenCalledWith({ id: 'orm-request-1' }); + + // Test delete + await repo.delete('request-1'); + expect(ormRepo.delete).toHaveBeenCalledWith({ id: 'request-1' }); + }); +}); diff --git a/adapters/media/persistence/typeorm/repositories/TypeOrmAvatarGenerationRepository.ts b/adapters/media/persistence/typeorm/repositories/TypeOrmAvatarGenerationRepository.ts new file mode 100644 index 000000000..f4341718c --- /dev/null +++ b/adapters/media/persistence/typeorm/repositories/TypeOrmAvatarGenerationRepository.ts @@ -0,0 +1,47 @@ +import type { DataSource } from 'typeorm'; +import type { IAvatarGenerationRepository } from '@core/media/domain/repositories/IAvatarGenerationRepository'; +import type { AvatarGenerationRequest } from '@core/media/domain/entities/AvatarGenerationRequest'; +import { AvatarGenerationRequestOrmEntity } from '../entities/AvatarGenerationRequestOrmEntity'; +import { AvatarGenerationRequestOrmMapper } from '../mappers/AvatarGenerationRequestOrmMapper'; + +export class TypeOrmAvatarGenerationRepository implements IAvatarGenerationRepository { + constructor( + private readonly dataSource: DataSource, + private readonly mapper: AvatarGenerationRequestOrmMapper, + ) {} + + async save(request: AvatarGenerationRequest): Promise { + const repo = this.dataSource.getRepository(AvatarGenerationRequestOrmEntity); + const entity = this.mapper.toOrmEntity(request); + await repo.save(entity); + } + + async findById(id: string): Promise { + const repo = this.dataSource.getRepository(AvatarGenerationRequestOrmEntity); + const entity = await repo.findOne({ where: { id } }); + return entity ? this.mapper.toDomain(entity) : null; + } + + async findByUserId(userId: string): Promise { + const repo = this.dataSource.getRepository(AvatarGenerationRequestOrmEntity); + const entities = await repo.find({ + where: { userId }, + order: { createdAt: 'DESC' } + }); + return entities.map(entity => this.mapper.toDomain(entity)); + } + + async findLatestByUserId(userId: string): Promise { + const repo = this.dataSource.getRepository(AvatarGenerationRequestOrmEntity); + const entity = await repo.findOne({ + where: { userId }, + order: { createdAt: 'DESC' } + }); + return entity ? this.mapper.toDomain(entity) : null; + } + + async delete(id: string): Promise { + const repo = this.dataSource.getRepository(AvatarGenerationRequestOrmEntity); + await repo.delete({ id }); + } +} diff --git a/adapters/media/persistence/typeorm/repositories/TypeOrmAvatarRepository.test.ts b/adapters/media/persistence/typeorm/repositories/TypeOrmAvatarRepository.test.ts new file mode 100644 index 000000000..057ef7dc2 --- /dev/null +++ b/adapters/media/persistence/typeorm/repositories/TypeOrmAvatarRepository.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it, vi } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +import { TypeOrmAvatarRepository } from './TypeOrmAvatarRepository'; + +describe('TypeOrmAvatarRepository', () => { + it('does not construct its own mapper dependencies', () => { + const sourcePath = path.resolve(__dirname, 'TypeOrmAvatarRepository.ts'); + const source = fs.readFileSync(sourcePath, 'utf8'); + + expect(source).not.toMatch(/new\s+AvatarOrmMapper\s*\(/); + expect(source).not.toMatch(/=\s*new\s+AvatarOrmMapper\s*\(/); + }); + + it('requires mapper injection via constructor (no default mapper)', () => { + expect(TypeOrmAvatarRepository.length).toBe(2); + }); + + it('uses the injected mapper at runtime (DB-free)', async () => { + const ormRepo = { + findOne: vi.fn().mockResolvedValue({ id: 'avatar-1' }), + find: vi.fn().mockResolvedValue([{ id: 'avatar-1' }, { id: 'avatar-2' }]), + save: vi.fn().mockResolvedValue({ id: 'avatar-1' }), + delete: vi.fn().mockResolvedValue({ affected: 1 }), + }; + + const dataSource = { + getRepository: vi.fn().mockReturnValue(ormRepo), + }; + + const mapper = { + toStored: vi.fn().mockReturnValue({ id: 'stored-avatar-1' }), + toDomain: vi.fn().mockReturnValue({ id: 'domain-avatar-1' }), + toOrmEntity: vi.fn().mockReturnValue({ id: 'orm-avatar-1' }), + }; + + const repo = new TypeOrmAvatarRepository(dataSource as any, mapper as any); + + // Test findById + const avatar = await repo.findById('avatar-1'); + expect(dataSource.getRepository).toHaveBeenCalledTimes(1); + expect(ormRepo.findOne).toHaveBeenCalledWith({ where: { id: 'avatar-1' } }); + expect(mapper.toDomain).toHaveBeenCalledTimes(1); + expect(avatar).toEqual({ id: 'domain-avatar-1' }); + + // Test findActiveByDriverId + await repo.findActiveByDriverId('driver-1'); + expect(ormRepo.findOne).toHaveBeenCalledWith({ + where: { driverId: 'driver-1', isActive: true }, + order: { selectedAt: 'DESC' } + }); + + // Test findByDriverId + const avatars = await repo.findByDriverId('driver-1'); + expect(ormRepo.find).toHaveBeenCalledWith({ + where: { driverId: 'driver-1' }, + order: { selectedAt: 'DESC' } + }); + expect(mapper.toDomain).toHaveBeenCalledTimes(4); // 1 from findById + 1 from findActiveByDriverId + 2 from findByDriverId + expect(avatars).toHaveLength(2); + + // Test save + const domainAvatar = { id: 'new-avatar', toProps: () => ({ id: 'new-avatar' }) }; + await repo.save(domainAvatar as any); + expect(mapper.toOrmEntity).toHaveBeenCalledWith(domainAvatar); + expect(ormRepo.save).toHaveBeenCalledWith({ id: 'orm-avatar-1' }); + + // Test delete + await repo.delete('avatar-1'); + expect(ormRepo.delete).toHaveBeenCalledWith({ id: 'avatar-1' }); + }); +}); diff --git a/adapters/media/persistence/typeorm/repositories/TypeOrmAvatarRepository.ts b/adapters/media/persistence/typeorm/repositories/TypeOrmAvatarRepository.ts new file mode 100644 index 000000000..e3751cf17 --- /dev/null +++ b/adapters/media/persistence/typeorm/repositories/TypeOrmAvatarRepository.ts @@ -0,0 +1,47 @@ +import type { DataSource } from 'typeorm'; +import type { IAvatarRepository } from '@core/media/domain/repositories/IAvatarRepository'; +import type { Avatar } from '@core/media/domain/entities/Avatar'; +import { AvatarOrmEntity } from '../entities/AvatarOrmEntity'; +import { AvatarOrmMapper } from '../mappers/AvatarOrmMapper'; + +export class TypeOrmAvatarRepository implements IAvatarRepository { + constructor( + private readonly dataSource: DataSource, + private readonly mapper: AvatarOrmMapper, + ) {} + + async save(avatar: Avatar): Promise { + const repo = this.dataSource.getRepository(AvatarOrmEntity); + const entity = this.mapper.toOrmEntity(avatar); + await repo.save(entity); + } + + async findById(id: string): Promise { + const repo = this.dataSource.getRepository(AvatarOrmEntity); + const entity = await repo.findOne({ where: { id } }); + return entity ? this.mapper.toDomain(entity) : null; + } + + async findActiveByDriverId(driverId: string): Promise { + const repo = this.dataSource.getRepository(AvatarOrmEntity); + const entity = await repo.findOne({ + where: { driverId, isActive: true }, + order: { selectedAt: 'DESC' } + }); + return entity ? this.mapper.toDomain(entity) : null; + } + + async findByDriverId(driverId: string): Promise { + const repo = this.dataSource.getRepository(AvatarOrmEntity); + const entities = await repo.find({ + where: { driverId }, + order: { selectedAt: 'DESC' } + }); + return entities.map(entity => this.mapper.toDomain(entity)); + } + + async delete(id: string): Promise { + const repo = this.dataSource.getRepository(AvatarOrmEntity); + await repo.delete({ id }); + } +} diff --git a/adapters/media/persistence/typeorm/repositories/TypeOrmMediaRepository.test.ts b/adapters/media/persistence/typeorm/repositories/TypeOrmMediaRepository.test.ts new file mode 100644 index 000000000..cc320c0ee --- /dev/null +++ b/adapters/media/persistence/typeorm/repositories/TypeOrmMediaRepository.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it, vi } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +import { TypeOrmMediaRepository } from './TypeOrmMediaRepository'; + +describe('TypeOrmMediaRepository', () => { + it('does not construct its own mapper dependencies', () => { + const sourcePath = path.resolve(__dirname, 'TypeOrmMediaRepository.ts'); + const source = fs.readFileSync(sourcePath, 'utf8'); + + expect(source).not.toMatch(/new\s+MediaOrmMapper\s*\(/); + expect(source).not.toMatch(/=\s*new\s+MediaOrmMapper\s*\(/); + }); + + it('requires mapper injection via constructor (no default mapper)', () => { + expect(TypeOrmMediaRepository.length).toBe(2); + }); + + it('uses the injected mapper at runtime (DB-free)', async () => { + const ormRepo = { + findOne: vi.fn().mockResolvedValue({ id: 'media-1' }), + find: vi.fn().mockResolvedValue([{ id: 'media-1' }, { id: 'media-2' }]), + save: vi.fn().mockResolvedValue({ id: 'media-1' }), + delete: vi.fn().mockResolvedValue({ affected: 1 }), + }; + + const dataSource = { + getRepository: vi.fn().mockReturnValue(ormRepo), + }; + + const mapper = { + toStored: vi.fn().mockReturnValue({ id: 'stored-media-1' }), + toDomain: vi.fn().mockReturnValue({ id: 'domain-media-1' }), + toOrmEntity: vi.fn().mockReturnValue({ id: 'orm-media-1' }), + }; + + const repo = new TypeOrmMediaRepository(dataSource as any, mapper as any); + + // Test findById + const media = await repo.findById('media-1'); + expect(dataSource.getRepository).toHaveBeenCalledTimes(1); + expect(ormRepo.findOne).toHaveBeenCalledWith({ where: { id: 'media-1' } }); + expect(mapper.toDomain).toHaveBeenCalledTimes(1); + expect(media).toEqual({ id: 'domain-media-1' }); + + // Test findByUploadedBy + const medias = await repo.findByUploadedBy('user-1'); + expect(ormRepo.find).toHaveBeenCalledWith({ where: { uploadedBy: 'user-1' } }); + expect(mapper.toDomain).toHaveBeenCalledTimes(3); // 1 from findById + 2 from findByUploadedBy + expect(medias).toHaveLength(2); + + // Test save + const domainMedia = { id: 'new-media', toProps: () => ({ id: 'new-media' }) }; + await repo.save(domainMedia as any); + expect(mapper.toOrmEntity).toHaveBeenCalledWith(domainMedia); + expect(ormRepo.save).toHaveBeenCalledWith({ id: 'orm-media-1' }); + + // Test delete + await repo.delete('media-1'); + expect(ormRepo.delete).toHaveBeenCalledWith({ id: 'media-1' }); + }); +}); diff --git a/adapters/media/persistence/typeorm/repositories/TypeOrmMediaRepository.ts b/adapters/media/persistence/typeorm/repositories/TypeOrmMediaRepository.ts new file mode 100644 index 000000000..b3896a1ec --- /dev/null +++ b/adapters/media/persistence/typeorm/repositories/TypeOrmMediaRepository.ts @@ -0,0 +1,35 @@ +import type { DataSource } from 'typeorm'; +import type { IMediaRepository } from '@core/media/domain/repositories/IMediaRepository'; +import type { Media } from '@core/media/domain/entities/Media'; +import { MediaOrmEntity } from '../entities/MediaOrmEntity'; +import { MediaOrmMapper } from '../mappers/MediaOrmMapper'; + +export class TypeOrmMediaRepository implements IMediaRepository { + constructor( + private readonly dataSource: DataSource, + private readonly mapper: MediaOrmMapper, + ) {} + + async save(media: Media): Promise { + const repo = this.dataSource.getRepository(MediaOrmEntity); + const entity = this.mapper.toOrmEntity(media); + await repo.save(entity); + } + + async findById(id: string): Promise { + const repo = this.dataSource.getRepository(MediaOrmEntity); + const entity = await repo.findOne({ where: { id } }); + return entity ? this.mapper.toDomain(entity) : null; + } + + async findByUploadedBy(uploadedBy: string): Promise { + const repo = this.dataSource.getRepository(MediaOrmEntity); + const entities = await repo.find({ where: { uploadedBy } }); + return entities.map(entity => this.mapper.toDomain(entity)); + } + + async delete(id: string): Promise { + const repo = this.dataSource.getRepository(MediaOrmEntity); + await repo.delete({ id }); + } +} diff --git a/adapters/media/persistence/typeorm/schema/TypeOrmMediaSchemaGuards.ts b/adapters/media/persistence/typeorm/schema/TypeOrmMediaSchemaGuards.ts new file mode 100644 index 000000000..8202032d0 --- /dev/null +++ b/adapters/media/persistence/typeorm/schema/TypeOrmMediaSchemaGuards.ts @@ -0,0 +1,155 @@ +import { TypeOrmMediaSchemaError } from '../errors/TypeOrmMediaSchemaError'; + +export function assertNonEmptyString(entityName: string, fieldName: string, value: unknown): asserts value is string { + if (typeof value !== 'string') { + throw new TypeOrmMediaSchemaError({ entityName, fieldName, reason: 'not_string' }); + } + + if (value.trim().length === 0) { + throw new TypeOrmMediaSchemaError({ entityName, fieldName, reason: 'empty_string' }); + } +} + +export function assertDate(entityName: string, fieldName: string, value: unknown): asserts value is Date { + if (!(value instanceof Date)) { + throw new TypeOrmMediaSchemaError({ entityName, fieldName, reason: 'not_date' }); + } + if (Number.isNaN(value.getTime())) { + throw new TypeOrmMediaSchemaError({ entityName, fieldName, reason: 'invalid_date' }); + } +} + +export function assertNumber(entityName: string, fieldName: string, value: unknown): asserts value is number { + if (typeof value !== 'number') { + throw new TypeOrmMediaSchemaError({ entityName, fieldName, reason: 'not_number' }); + } + if (Number.isNaN(value)) { + throw new TypeOrmMediaSchemaError({ entityName, fieldName, reason: 'not_number' }); + } +} + +export function assertInteger(entityName: string, fieldName: string, value: unknown): asserts value is number { + if (typeof value !== 'number') { + throw new TypeOrmMediaSchemaError({ entityName, fieldName, reason: 'not_number' }); + } + if (!Number.isInteger(value)) { + throw new TypeOrmMediaSchemaError({ entityName, fieldName, reason: 'not_integer' }); + } +} + +export function assertBoolean(entityName: string, fieldName: string, value: unknown): asserts value is boolean { + if (typeof value !== 'boolean') { + throw new TypeOrmMediaSchemaError({ entityName, fieldName, reason: 'not_boolean' }); + } +} + +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 TypeOrmMediaSchemaError({ entityName, fieldName, reason: 'not_string' }); + } +} + +export function assertOptionalNumberOrNull( + entityName: string, + fieldName: string, + value: unknown, +): asserts value is number | null | undefined { + if (value === null || value === undefined) { + return; + } + + if (typeof value !== 'number') { + throw new TypeOrmMediaSchemaError({ entityName, fieldName, reason: 'not_number' }); + } + if (Number.isNaN(value)) { + throw new TypeOrmMediaSchemaError({ entityName, fieldName, reason: 'not_number' }); + } +} + +export function assertStringArray( + entityName: string, + fieldName: string, + value: unknown, +): asserts value is string[] { + if (!Array.isArray(value)) { + throw new TypeOrmMediaSchemaError({ entityName, fieldName, reason: 'not_array' }); + } + if (!value.every(item => typeof item === 'string')) { + throw new TypeOrmMediaSchemaError({ entityName, fieldName, reason: 'not_array' }); + } +} + +export function assertMediaType(entityName: string, fieldName: string, value: unknown): asserts value is 'image' | 'video' | 'document' { + if (typeof value !== 'string') { + throw new TypeOrmMediaSchemaError({ entityName, fieldName, reason: 'not_string' }); + } + if (!['image', 'video', 'document'].includes(value)) { + throw new TypeOrmMediaSchemaError({ entityName, fieldName, reason: 'invalid_enum_value' }); + } +} + +export function assertRacingSuitColor( + entityName: string, + fieldName: string, + value: unknown, +): asserts value is 'red' | 'blue' | 'green' | 'yellow' | 'orange' | 'purple' | 'black' | 'white' | 'pink' | 'cyan' { + if (typeof value !== 'string') { + throw new TypeOrmMediaSchemaError({ entityName, fieldName, reason: 'not_string' }); + } + if ( +!['red', 'blue', 'green', 'yellow', 'orange', 'purple', 'black', 'white', 'pink', 'cyan'].includes(value) +) { + throw new TypeOrmMediaSchemaError({ entityName, fieldName, reason: 'invalid_enum_value' }); + } +} + +export function assertAvatarStyle( + entityName: string, + fieldName: string, + value: unknown, +): asserts value is 'realistic' | 'cartoon' | 'pixel-art' { + if (typeof value !== 'string') { + throw new TypeOrmMediaSchemaError({ entityName, fieldName, reason: 'not_string' }); + } + if (!['realistic', 'cartoon', 'pixel-art'].includes(value)) { + throw new TypeOrmMediaSchemaError({ entityName, fieldName, reason: 'invalid_enum_value' }); + } +} + +export function assertAvatarGenerationStatus( + entityName: string, + fieldName: string, + value: unknown, +): asserts value is 'pending' | 'validating' | 'generating' | 'completed' | 'failed' { + if (typeof value !== 'string') { + throw new TypeOrmMediaSchemaError({ entityName, fieldName, reason: 'not_string' }); + } + if (!['pending', 'validating', 'generating', 'completed', 'failed'].includes(value)) { + throw new TypeOrmMediaSchemaError({ entityName, fieldName, reason: 'invalid_enum_value' }); + } +} + +export function assertOptionalIntegerOrNull( + entityName: string, + fieldName: string, + value: unknown, +): asserts value is number | null | undefined { + if (value === null || value === undefined) { + return; + } + + if (typeof value !== 'number') { + throw new TypeOrmMediaSchemaError({ entityName, fieldName, reason: 'not_number' }); + } + if (!Number.isInteger(value)) { + throw new TypeOrmMediaSchemaError({ entityName, fieldName, reason: 'not_integer' }); + } +} diff --git a/adapters/notifications/persistence/typeorm/entities/NotificationOrmEntity.ts b/adapters/notifications/persistence/typeorm/entities/NotificationOrmEntity.ts new file mode 100644 index 000000000..b6e241643 --- /dev/null +++ b/adapters/notifications/persistence/typeorm/entities/NotificationOrmEntity.ts @@ -0,0 +1,53 @@ +import { Column, CreateDateColumn, Entity, Index, PrimaryColumn, UpdateDateColumn } from 'typeorm'; + +@Entity({ name: 'notifications' }) +export class NotificationOrmEntity { + @PrimaryColumn({ type: 'uuid' }) + id!: string; + + @Index() + @Column({ type: 'text' }) + recipientId!: string; + + @Column({ type: 'text' }) + type!: string; + + @Column({ type: 'text' }) + title!: string; + + @Column({ type: 'text' }) + body!: string; + + @Column({ type: 'text' }) + channel!: string; + + @Column({ type: 'text' }) + status!: string; + + @Column({ type: 'text' }) + urgency!: string; + + @Column({ type: 'jsonb', nullable: true }) + data!: Record | null; + + @Column({ type: 'text', nullable: true }) + actionUrl!: string | null; + + @Column({ type: 'jsonb', nullable: true }) + actions!: Array<{ label: string; type: string; href?: string; actionId?: string }> | null; + + @Column({ type: 'boolean', default: false }) + requiresResponse!: boolean; + + @CreateDateColumn({ type: 'timestamptz' }) + createdAt!: Date; + + @Column({ type: 'timestamptz', nullable: true }) + readAt!: Date | null; + + @Column({ type: 'timestamptz', nullable: true }) + respondedAt!: Date | null; + + @UpdateDateColumn({ type: 'timestamptz' }) + updatedAt!: Date; +} \ No newline at end of file diff --git a/adapters/notifications/persistence/typeorm/entities/NotificationPreferenceOrmEntity.ts b/adapters/notifications/persistence/typeorm/entities/NotificationPreferenceOrmEntity.ts new file mode 100644 index 000000000..f6b3d44e0 --- /dev/null +++ b/adapters/notifications/persistence/typeorm/entities/NotificationPreferenceOrmEntity.ts @@ -0,0 +1,40 @@ +import { Column, CreateDateColumn, Entity, Index, PrimaryColumn, UpdateDateColumn } from 'typeorm'; + +@Entity({ name: 'notification_preferences' }) +export class NotificationPreferenceOrmEntity { + @PrimaryColumn({ type: 'text' }) + id!: string; + + @Index() + @Column({ type: 'text' }) + driverId!: string; + + @Column({ type: 'jsonb' }) + channels!: { + in_app: { enabled: boolean; settings?: Record }; + email: { enabled: boolean; settings?: Record }; + discord: { enabled: boolean; settings?: Record }; + push: { enabled: boolean; settings?: Record }; + }; + + @Column({ type: 'jsonb', nullable: true }) + typePreferences!: Record | null; + + @Column({ type: 'boolean', default: false }) + digestMode!: boolean; + + @Column({ type: 'integer', nullable: true }) + digestFrequencyHours!: number | null; + + @Column({ type: 'integer', nullable: true }) + quietHoursStart!: number | null; + + @Column({ type: 'integer', nullable: true }) + quietHoursEnd!: number | null; + + @CreateDateColumn({ type: 'timestamptz' }) + createdAt!: Date; + + @UpdateDateColumn({ type: 'timestamptz' }) + updatedAt!: Date; +} \ No newline at end of file diff --git a/adapters/notifications/persistence/typeorm/errors/TypeOrmPersistenceSchemaError.ts b/adapters/notifications/persistence/typeorm/errors/TypeOrmPersistenceSchemaError.ts new file mode 100644 index 000000000..87534da3a --- /dev/null +++ b/adapters/notifications/persistence/typeorm/errors/TypeOrmPersistenceSchemaError.ts @@ -0,0 +1,21 @@ +export interface TypeOrmPersistenceSchemaErrorProps { + entityName: string; + fieldName: string; + reason: string; + message?: string; +} + +export class TypeOrmPersistenceSchemaError extends Error { + readonly entityName: string; + readonly fieldName: string; + readonly reason: string; + + constructor(props: TypeOrmPersistenceSchemaErrorProps) { + const message = props.message || `Invalid schema for ${props.entityName}.${props.fieldName}: ${props.reason}`; + super(message); + this.name = 'TypeOrmPersistenceSchemaError'; + this.entityName = props.entityName; + this.fieldName = props.fieldName; + this.reason = props.reason; + } +} \ No newline at end of file diff --git a/adapters/notifications/persistence/typeorm/mappers/NotificationOrmMapper.test.ts b/adapters/notifications/persistence/typeorm/mappers/NotificationOrmMapper.test.ts new file mode 100644 index 000000000..f20146965 --- /dev/null +++ b/adapters/notifications/persistence/typeorm/mappers/NotificationOrmMapper.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, it } from 'vitest'; + +import { Notification } from '@core/notifications/domain/entities/Notification'; + +import { NotificationOrmEntity } from '../entities/NotificationOrmEntity'; +import { TypeOrmPersistenceSchemaError } from '../errors/TypeOrmPersistenceSchemaError'; +import { NotificationOrmMapper } from './NotificationOrmMapper'; + +describe('NotificationOrmMapper', () => { + it('toDomain preserves persisted identity and uses reconstitute semantics', () => { + const mapper = new NotificationOrmMapper(); + + const entity = new NotificationOrmEntity(); + entity.id = '00000000-0000-4000-8000-000000000001'; + entity.recipientId = 'driver-123'; + entity.type = 'race_reminder'; + entity.title = 'Race Starting Soon'; + entity.body = 'Your race starts in 15 minutes'; + entity.channel = 'in_app'; + entity.status = 'unread'; + entity.urgency = 'silent'; + entity.data = { raceId: 'race-456' }; + entity.actionUrl = null; + entity.actions = null; + entity.requiresResponse = false; + entity.createdAt = new Date('2025-01-01T00:00:00.000Z'); + entity.readAt = null; + entity.respondedAt = null; + entity.updatedAt = new Date('2025-01-01T00:00:00.000Z'); + + const domain = mapper.toDomain(entity); + + expect(domain.id).toBe(entity.id); + expect(domain.recipientId).toBe(entity.recipientId); + expect(domain.type).toBe(entity.type); + expect(domain.title).toBe(entity.title); + expect(domain.body).toBe(entity.body); + expect(domain.channel).toBe(entity.channel); + expect(domain.status).toBe(entity.status); + expect(domain.urgency).toBe(entity.urgency); + expect(domain.data).toEqual({ raceId: 'race-456' }); + expect(domain.createdAt).toEqual(entity.createdAt); + expect(domain.readAt).toBeUndefined(); + expect(domain.respondedAt).toBeUndefined(); + }); + + it('toDomain validates persisted shape and throws adapter-scoped schema error', () => { + const mapper = new NotificationOrmMapper(); + + const entity = new NotificationOrmEntity(); + entity.id = '00000000-0000-4000-8000-000000000001'; + entity.recipientId = 123 as unknown as string; // Invalid + entity.type = 'race_reminder'; + entity.title = 'Race Starting Soon'; + entity.body = 'Your race starts in 15 minutes'; + entity.channel = 'in_app'; + entity.status = 'unread'; + entity.urgency = 'silent'; + entity.data = null; + entity.actionUrl = null; + entity.actions = null; + entity.requiresResponse = false; + entity.createdAt = new Date('2025-01-01T00:00:00.000Z'); + entity.readAt = null; + entity.respondedAt = null; + entity.updatedAt = 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: 'Notification', + fieldName: 'recipientId', + reason: 'invalid_string', + }); + } + }); + + it('toOrmEntity converts domain entity to ORM entity', () => { + const mapper = new NotificationOrmMapper(); + + const domain = Notification.create({ + id: '00000000-0000-4000-8000-000000000001', + recipientId: 'driver-123', + type: 'race_reminder', + title: 'Race Starting Soon', + body: 'Your race starts in 15 minutes', + channel: 'in_app', + data: { raceId: 'race-456' }, + urgency: 'silent', + }); + + const entity = mapper.toOrmEntity(domain); + + expect(entity.id).toBe(domain.id); + expect(entity.recipientId).toBe(domain.recipientId); + expect(entity.type).toBe(domain.type); + expect(entity.title).toBe(domain.title); + expect(entity.body).toBe(domain.body); + expect(entity.channel).toBe(domain.channel); + expect(entity.status).toBe(domain.status); + expect(entity.urgency).toBe(domain.urgency); + expect(entity.data).toEqual({ raceId: 'race-456' }); + expect(entity.requiresResponse).toBe(false); + expect(entity.createdAt).toEqual(domain.createdAt); + }); + + it('toDomain handles optional fields correctly', () => { + const mapper = new NotificationOrmMapper(); + + const entity = new NotificationOrmEntity(); + entity.id = '00000000-0000-4000-8000-000000000001'; + entity.recipientId = 'driver-123'; + entity.type = 'protest_filed'; + entity.title = 'Protest Filed'; + entity.body = 'A protest has been filed against you'; + entity.channel = 'email'; + entity.status = 'action_required'; + entity.urgency = 'modal'; + entity.data = { protestId: 'protest-789', deadline: new Date('2025-01-02T00:00:00.000Z') }; + entity.actionUrl = '/protests/protest-789'; + entity.actions = [ + { label: 'Submit Defense', type: 'primary', href: '/protests/protest-789/defense' }, + { label: 'Dismiss', type: 'secondary', actionId: 'dismiss' }, + ]; + entity.requiresResponse = true; + entity.createdAt = new Date('2025-01-01T00:00:00.000Z'); + entity.readAt = new Date('2025-01-01T01:00:00.000Z'); + entity.respondedAt = null; + entity.updatedAt = new Date('2025-01-01T01:00:00.000Z'); + + const domain = mapper.toDomain(entity); + + expect(domain.actionUrl).toBe('/protests/protest-789'); + expect(domain.actions).toHaveLength(2); + expect(domain.requiresResponse).toBe(true); + expect(domain.readAt).toEqual(new Date('2025-01-01T01:00:00.000Z')); + expect(domain.respondedAt).toBeUndefined(); + }); + + it('toDomain handles action_required status with deadline', () => { + const mapper = new NotificationOrmMapper(); + + const entity = new NotificationOrmEntity(); + entity.id = '00000000-0000-4000-8000-000000000001'; + entity.recipientId = 'driver-123'; + entity.type = 'protest_filed'; + entity.title = 'Protest Filed'; + entity.body = 'A protest has been filed against you'; + entity.channel = 'in_app'; + entity.status = 'action_required'; + entity.urgency = 'modal'; + entity.data = { protestId: 'protest-789', deadline: '2025-01-02T00:00:00.000Z' }; + entity.actionUrl = null; + entity.actions = [{ label: 'Submit Defense', type: 'primary', href: '/defense' }]; + entity.requiresResponse = true; + entity.createdAt = new Date('2025-01-01T00:00:00.000Z'); + entity.readAt = null; + entity.respondedAt = null; + entity.updatedAt = new Date('2025-01-01T00:00:00.000Z'); + + const domain = mapper.toDomain(entity); + + expect(domain.status).toBe('action_required'); + expect(domain.requiresResponse).toBe(true); + expect(domain.urgency).toBe('modal'); + expect(domain.data?.deadline).toBeInstanceOf(Date); + }); +}); \ No newline at end of file diff --git a/adapters/notifications/persistence/typeorm/mappers/NotificationOrmMapper.ts b/adapters/notifications/persistence/typeorm/mappers/NotificationOrmMapper.ts new file mode 100644 index 000000000..3a85e05d3 --- /dev/null +++ b/adapters/notifications/persistence/typeorm/mappers/NotificationOrmMapper.ts @@ -0,0 +1,113 @@ +import { Notification } from '@core/notifications/domain/entities/Notification'; +import { NotificationOrmEntity } from '../entities/NotificationOrmEntity'; +import { TypeOrmPersistenceSchemaError } from '../errors/TypeOrmPersistenceSchemaError'; +import { + assertNonEmptyString, + assertDate, + assertOptionalDate, + assertBoolean, + assertNotificationType, + assertNotificationChannel, + assertNotificationStatus, + assertNotificationUrgency, + assertOptionalStringOrNull, + assertOptionalObject, + assertNotificationActions, +} from '../schema/NotificationSchemaGuards'; + +export class NotificationOrmMapper { + toDomain(entity: NotificationOrmEntity): Notification { + const entityName = 'Notification'; + + try { + assertNonEmptyString(entityName, 'id', entity.id); + assertNonEmptyString(entityName, 'recipientId', entity.recipientId); + assertNotificationType(entityName, 'type', entity.type); + assertNonEmptyString(entityName, 'title', entity.title); + assertNonEmptyString(entityName, 'body', entity.body); + assertNotificationChannel(entityName, 'channel', entity.channel); + assertNotificationStatus(entityName, 'status', entity.status); + assertNotificationUrgency(entityName, 'urgency', entity.urgency); + assertDate(entityName, 'createdAt', entity.createdAt); + assertOptionalDate(entityName, 'readAt', entity.readAt); + assertOptionalDate(entityName, 'respondedAt', entity.respondedAt); + assertOptionalStringOrNull(entityName, 'actionUrl', entity.actionUrl); + assertOptionalObject(entityName, 'data', entity.data); + assertNotificationActions(entityName, 'actions', entity.actions); + assertBoolean(entityName, 'requiresResponse', entity.requiresResponse); + } catch (error) { + if (error instanceof TypeOrmPersistenceSchemaError) { + throw error; + } + const message = error instanceof Error ? error.message : 'Invalid persisted Notification'; + throw new TypeOrmPersistenceSchemaError({ entityName, fieldName: 'unknown', reason: 'invalid_shape', message }); + } + + try { + const domainProps: any = { + id: entity.id, + recipientId: entity.recipientId, + type: entity.type, + title: entity.title, + body: entity.body, + channel: entity.channel, + status: entity.status, + urgency: entity.urgency, + createdAt: entity.createdAt, + requiresResponse: entity.requiresResponse, + }; + + if (entity.data !== null && entity.data !== undefined) { + domainProps.data = entity.data as Record; + } + + if (entity.actionUrl !== null && entity.actionUrl !== undefined) { + domainProps.actionUrl = entity.actionUrl; + } + + if (entity.actions !== null && entity.actions !== undefined) { + domainProps.actions = entity.actions; + } + + if (entity.readAt !== null && entity.readAt !== undefined) { + domainProps.readAt = entity.readAt; + } + + if (entity.respondedAt !== null && entity.respondedAt !== undefined) { + domainProps.respondedAt = entity.respondedAt; + } + + return Notification.create(domainProps); + } catch (error) { + const message = error instanceof Error ? error.message : 'Invalid persisted Notification'; + throw new TypeOrmPersistenceSchemaError({ entityName, fieldName: 'unknown', reason: 'invalid_shape', message }); + } + } + + toOrmEntity(notification: Notification): NotificationOrmEntity { + const entity = new NotificationOrmEntity(); + const props = notification.toJSON(); + + entity.id = props.id; + entity.recipientId = props.recipientId; + entity.type = props.type; + entity.title = props.title; + entity.body = props.body; + entity.channel = props.channel; + entity.status = props.status; + entity.urgency = props.urgency; + entity.data = props.data ?? null; + entity.actionUrl = props.actionUrl ?? null; + entity.actions = props.actions ?? null; + entity.requiresResponse = props.requiresResponse ?? false; + entity.createdAt = props.createdAt; + entity.readAt = props.readAt ?? null; + entity.respondedAt = props.respondedAt ?? null; + + return entity; + } + + toStored(entity: NotificationOrmEntity): Notification { + return this.toDomain(entity); + } +} \ No newline at end of file diff --git a/adapters/notifications/persistence/typeorm/mappers/NotificationPreferenceOrmMapper.test.ts b/adapters/notifications/persistence/typeorm/mappers/NotificationPreferenceOrmMapper.test.ts new file mode 100644 index 000000000..c74a9e600 --- /dev/null +++ b/adapters/notifications/persistence/typeorm/mappers/NotificationPreferenceOrmMapper.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it } from 'vitest'; + +import { NotificationPreference } from '@core/notifications/domain/entities/NotificationPreference'; + +import { NotificationPreferenceOrmEntity } from '../entities/NotificationPreferenceOrmEntity'; +import { TypeOrmPersistenceSchemaError } from '../errors/TypeOrmPersistenceSchemaError'; +import { NotificationPreferenceOrmMapper } from './NotificationPreferenceOrmMapper'; + +describe('NotificationPreferenceOrmMapper', () => { + it('toDomain preserves persisted identity and uses reconstitute semantics', () => { + const mapper = new NotificationPreferenceOrmMapper(); + + const entity = new NotificationPreferenceOrmEntity(); + entity.id = 'driver-123'; + entity.driverId = 'driver-123'; + entity.channels = { + in_app: { enabled: true }, + email: { enabled: false }, + discord: { enabled: false }, + push: { enabled: false }, + }; + entity.typePreferences = { + race_reminder: { enabled: true, channels: ['in_app'] }, + }; + entity.digestMode = false; + entity.digestFrequencyHours = null; + entity.quietHoursStart = null; + entity.quietHoursEnd = null; + entity.createdAt = new Date('2025-01-01T00:00:00.000Z'); + entity.updatedAt = new Date('2025-01-01T00:00:00.000Z'); + + const domain = mapper.toDomain(entity); + + expect(domain.id).toBe(entity.id); + expect(domain.driverId).toBe(entity.driverId); + expect(domain.channels).toEqual(entity.channels); + expect(domain.typePreferences).toEqual(entity.typePreferences); + expect(domain.digestMode).toBe(false); + }); + + it('toDomain validates persisted shape and throws adapter-scoped schema error', () => { + const mapper = new NotificationPreferenceOrmMapper(); + + const entity = new NotificationPreferenceOrmEntity(); + entity.id = 'driver-123'; + entity.driverId = 123 as unknown as string; // Invalid + entity.channels = { + in_app: { enabled: true }, + email: { enabled: false }, + discord: { enabled: false }, + push: { enabled: false }, + }; + entity.typePreferences = null; + entity.digestMode = false; + entity.digestFrequencyHours = null; + entity.quietHoursStart = null; + entity.quietHoursEnd = null; + entity.createdAt = new Date('2025-01-01T00:00:00.000Z'); + entity.updatedAt = 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: 'NotificationPreference', + fieldName: 'driverId', + reason: 'invalid_string', + }); + } + }); + + it('toOrmEntity converts domain entity to ORM entity', () => { + const mapper = new NotificationPreferenceOrmMapper(); + + const domain = NotificationPreference.create({ + id: 'driver-123', + driverId: 'driver-123', + channels: { + in_app: { enabled: true }, + email: { enabled: false }, + discord: { enabled: false }, + push: { enabled: false }, + }, + typePreferences: { + race_reminder: { enabled: true, channels: ['in_app'] }, + }, + digestMode: false, + updatedAt: new Date('2025-01-01T00:00:00.000Z'), + }); + + const entity = mapper.toOrmEntity(domain); + + expect(entity.id).toBe(domain.id); + expect(entity.driverId).toBe(domain.driverId); + expect(entity.channels).toEqual(domain.channels); + expect(entity.typePreferences).toEqual(domain.typePreferences); + expect(entity.digestMode).toBe(false); + expect(entity.updatedAt).toEqual(domain.updatedAt); + }); + + it('toDomain handles all optional fields as null', () => { + const mapper = new NotificationPreferenceOrmMapper(); + + const entity = new NotificationPreferenceOrmEntity(); + entity.id = 'driver-123'; + entity.driverId = 'driver-123'; + entity.channels = { + in_app: { enabled: true }, + email: { enabled: true, settings: { emailAddress: 'test@example.com' } }, + discord: { enabled: false }, + push: { enabled: false }, + }; + entity.typePreferences = null; + entity.digestMode = true; + entity.digestFrequencyHours = 24; + entity.quietHoursStart = 22; + entity.quietHoursEnd = 8; + entity.createdAt = new Date('2025-01-01T00:00:00.000Z'); + entity.updatedAt = new Date('2025-01-01T00:00:00.000Z'); + + const domain = mapper.toDomain(entity); + + expect(domain.typePreferences).toBeUndefined(); + expect(domain.digestMode).toBe(true); + expect(domain.digestFrequencyHours).toBe(24); + expect(domain.quietHoursStart).toBe(22); + expect(domain.quietHoursEnd).toBe(8); + expect(domain.channels.email.settings?.emailAddress).toBe('test@example.com'); + }); + + it('toDomain handles default preferences', () => { + const mapper = new NotificationPreferenceOrmMapper(); + + const entity = new NotificationPreferenceOrmEntity(); + entity.id = 'driver-456'; + entity.driverId = 'driver-456'; + entity.channels = { + in_app: { enabled: true }, + email: { enabled: false }, + discord: { enabled: false }, + push: { enabled: false }, + }; + entity.typePreferences = null; + entity.digestMode = false; + entity.digestFrequencyHours = null; + entity.quietHoursStart = null; + entity.quietHoursEnd = null; + entity.createdAt = new Date('2025-01-01T00:00:00.000Z'); + entity.updatedAt = new Date('2025-01-01T00:00:00.000Z'); + + const domain = mapper.toDomain(entity); + + expect(domain.isChannelEnabled('in_app')).toBe(true); + expect(domain.isChannelEnabled('email')).toBe(false); + expect(domain.isTypeEnabled('race_reminder')).toBe(true); // Default to enabled + }); +}); \ No newline at end of file diff --git a/adapters/notifications/persistence/typeorm/mappers/NotificationPreferenceOrmMapper.ts b/adapters/notifications/persistence/typeorm/mappers/NotificationPreferenceOrmMapper.ts new file mode 100644 index 000000000..a1795102b --- /dev/null +++ b/adapters/notifications/persistence/typeorm/mappers/NotificationPreferenceOrmMapper.ts @@ -0,0 +1,88 @@ +import { NotificationPreference } from '@core/notifications/domain/entities/NotificationPreference'; +import { NotificationPreferenceOrmEntity } from '../entities/NotificationPreferenceOrmEntity'; +import { TypeOrmPersistenceSchemaError } from '../errors/TypeOrmPersistenceSchemaError'; +import { + assertNonEmptyString, + assertDate, + assertBoolean, + assertOptionalInteger, + assertChannelPreferences, + assertOptionalObject, +} from '../schema/NotificationSchemaGuards'; + +export class NotificationPreferenceOrmMapper { + toDomain(entity: NotificationPreferenceOrmEntity): NotificationPreference { + const entityName = 'NotificationPreference'; + + try { + assertNonEmptyString(entityName, 'id', entity.id); + assertNonEmptyString(entityName, 'driverId', entity.driverId); + assertChannelPreferences(entityName, 'channels', entity.channels); + assertOptionalObject(entityName, 'typePreferences', entity.typePreferences); + assertBoolean(entityName, 'digestMode', entity.digestMode); + assertOptionalInteger(entityName, 'digestFrequencyHours', entity.digestFrequencyHours); + assertOptionalInteger(entityName, 'quietHoursStart', entity.quietHoursStart); + assertOptionalInteger(entityName, 'quietHoursEnd', entity.quietHoursEnd); + assertDate(entityName, 'createdAt', entity.createdAt); + assertDate(entityName, 'updatedAt', entity.updatedAt); + } catch (error) { + if (error instanceof TypeOrmPersistenceSchemaError) { + throw error; + } + const message = error instanceof Error ? error.message : 'Invalid persisted NotificationPreference'; + throw new TypeOrmPersistenceSchemaError({ entityName, fieldName: 'unknown', reason: 'invalid_shape', message }); + } + + try { + const domainProps: any = { + id: entity.id, + driverId: entity.driverId, + channels: entity.channels, + digestMode: entity.digestMode, + updatedAt: entity.updatedAt, + }; + + if (entity.typePreferences !== null && entity.typePreferences !== undefined) { + domainProps.typePreferences = entity.typePreferences; + } + + if (entity.digestFrequencyHours !== null && entity.digestFrequencyHours !== undefined) { + domainProps.digestFrequencyHours = entity.digestFrequencyHours; + } + + if (entity.quietHoursStart !== null && entity.quietHoursStart !== undefined) { + domainProps.quietHoursStart = entity.quietHoursStart; + } + + if (entity.quietHoursEnd !== null && entity.quietHoursEnd !== undefined) { + domainProps.quietHoursEnd = entity.quietHoursEnd; + } + + return NotificationPreference.create(domainProps); + } catch (error) { + const message = error instanceof Error ? error.message : 'Invalid persisted NotificationPreference'; + throw new TypeOrmPersistenceSchemaError({ entityName, fieldName: 'unknown', reason: 'invalid_shape', message }); + } + } + + toOrmEntity(preference: NotificationPreference): NotificationPreferenceOrmEntity { + const entity = new NotificationPreferenceOrmEntity(); + const props = preference.toJSON(); + + entity.id = props.id; + entity.driverId = props.driverId; + entity.channels = props.channels; + entity.typePreferences = props.typePreferences ?? null; + entity.digestMode = props.digestMode ?? false; + entity.digestFrequencyHours = props.digestFrequencyHours ?? null; + entity.quietHoursStart = props.quietHoursStart ?? null; + entity.quietHoursEnd = props.quietHoursEnd ?? null; + entity.updatedAt = props.updatedAt; + + return entity; + } + + toStored(entity: NotificationPreferenceOrmEntity): NotificationPreference { + return this.toDomain(entity); + } +} \ No newline at end of file diff --git a/adapters/notifications/persistence/typeorm/repositories/TypeOrmNotificationPreferenceRepository.test.ts b/adapters/notifications/persistence/typeorm/repositories/TypeOrmNotificationPreferenceRepository.test.ts new file mode 100644 index 000000000..a80c8900e --- /dev/null +++ b/adapters/notifications/persistence/typeorm/repositories/TypeOrmNotificationPreferenceRepository.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { TypeOrmNotificationPreferenceRepository } from './TypeOrmNotificationPreferenceRepository'; + +describe('TypeOrmNotificationPreferenceRepository', () => { + it('does not construct its own mapper dependencies', () => { + // Check that the repository doesn't create its own mapper + const sourcePath = require.resolve('./TypeOrmNotificationPreferenceRepository.ts'); + const fs = require('fs'); + const source = fs.readFileSync(sourcePath, 'utf8'); + + expect(source).not.toMatch(/new\s+NotificationPreferenceOrmMapper\s*\(/); + expect(source).not.toMatch(/=\s*new\s+NotificationPreferenceOrmMapper\s*\(/); + }); + + it('requires mapper injection via constructor (no default mapper)', () => { + expect(TypeOrmNotificationPreferenceRepository.length).toBe(2); + }); + + it('uses the injected mapper at runtime (DB-free)', async () => { + const ormRepo = { + findOne: vi.fn().mockResolvedValue({ id: 'driver-123' }), + save: vi.fn().mockResolvedValue({ id: 'driver-123' }), + delete: vi.fn().mockResolvedValue({ affected: 1 }), + }; + + const dataSource = { + getRepository: vi.fn().mockReturnValue(ormRepo), + }; + + const mapper = { + toDomain: vi.fn().mockReturnValue({ id: 'domain-preference-1' }), + toOrmEntity: vi.fn().mockReturnValue({ id: 'orm-preference-1' }), + }; + + const repo = new TypeOrmNotificationPreferenceRepository(dataSource as any, mapper as any); + + // Test findByDriverId + const preference = await repo.findByDriverId('driver-123'); + expect(dataSource.getRepository).toHaveBeenCalledTimes(1); + expect(ormRepo.findOne).toHaveBeenCalledWith({ where: { driverId: 'driver-123' } }); + expect(mapper.toDomain).toHaveBeenCalledTimes(1); + expect(preference).toEqual({ id: 'domain-preference-1' }); + + // Test save + const domainPreference = { id: 'driver-123', driverId: 'driver-123', toJSON: () => ({ id: 'driver-123', driverId: 'driver-123' }) }; + await repo.save(domainPreference as any); + expect(mapper.toOrmEntity).toHaveBeenCalledWith(domainPreference); + expect(ormRepo.save).toHaveBeenCalledWith({ id: 'orm-preference-1' }); + + // Test delete + await repo.delete('driver-123'); + expect(ormRepo.delete).toHaveBeenCalledWith({ driverId: 'driver-123' }); + + // Test getOrCreateDefault - existing + ormRepo.findOne.mockResolvedValue({ id: 'existing' }); + const existing = await repo.getOrCreateDefault('driver-123'); + expect(existing).toEqual({ id: 'domain-preference-1' }); + expect(ormRepo.save).toHaveBeenCalledTimes(1); // Only from previous save test + + // Test getOrCreateDefault - new + ormRepo.findOne.mockResolvedValue(null); + + // The getOrCreateDefault should create default preferences and save them + await repo.getOrCreateDefault('driver-456'); + expect(ormRepo.findOne).toHaveBeenCalledWith({ where: { driverId: 'driver-456' } }); + expect(ormRepo.save).toHaveBeenCalled(); // Should save the new default preferences + }); +}); \ No newline at end of file diff --git a/adapters/notifications/persistence/typeorm/repositories/TypeOrmNotificationPreferenceRepository.ts b/adapters/notifications/persistence/typeorm/repositories/TypeOrmNotificationPreferenceRepository.ts new file mode 100644 index 000000000..b92724c87 --- /dev/null +++ b/adapters/notifications/persistence/typeorm/repositories/TypeOrmNotificationPreferenceRepository.ts @@ -0,0 +1,40 @@ +import type { DataSource } from 'typeorm'; +import type { INotificationPreferenceRepository } from '@core/notifications/domain/repositories/INotificationPreferenceRepository'; +import { NotificationPreference } from '@core/notifications/domain/entities/NotificationPreference'; +import { NotificationPreferenceOrmEntity } from '../entities/NotificationPreferenceOrmEntity'; +import { NotificationPreferenceOrmMapper } from '../mappers/NotificationPreferenceOrmMapper'; + +export class TypeOrmNotificationPreferenceRepository implements INotificationPreferenceRepository { + constructor( + private readonly dataSource: DataSource, + private readonly mapper: NotificationPreferenceOrmMapper, + ) {} + + async findByDriverId(driverId: string): Promise { + const repo = this.dataSource.getRepository(NotificationPreferenceOrmEntity); + const entity = await repo.findOne({ where: { driverId } }); + return entity ? this.mapper.toDomain(entity) : null; + } + + async save(preference: NotificationPreference): Promise { + const repo = this.dataSource.getRepository(NotificationPreferenceOrmEntity); + const entity = this.mapper.toOrmEntity(preference); + await repo.save(entity); + } + + async delete(driverId: string): Promise { + const repo = this.dataSource.getRepository(NotificationPreferenceOrmEntity); + await repo.delete({ driverId }); + } + + async getOrCreateDefault(driverId: string): Promise { + const existing = await this.findByDriverId(driverId); + if (existing) { + return existing; + } + + const defaultPrefs = NotificationPreference.createDefault(driverId); + await this.save(defaultPrefs); + return defaultPrefs; + } +} \ No newline at end of file diff --git a/adapters/notifications/persistence/typeorm/repositories/TypeOrmNotificationRepository.test.ts b/adapters/notifications/persistence/typeorm/repositories/TypeOrmNotificationRepository.test.ts new file mode 100644 index 000000000..79ef0f2f5 --- /dev/null +++ b/adapters/notifications/persistence/typeorm/repositories/TypeOrmNotificationRepository.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { TypeOrmNotificationRepository } from './TypeOrmNotificationRepository'; + +describe('TypeOrmNotificationRepository', () => { + it('does not construct its own mapper dependencies', () => { + // Check that the repository doesn't create its own mapper + const sourcePath = require.resolve('./TypeOrmNotificationRepository.ts'); + const fs = require('fs'); + const source = fs.readFileSync(sourcePath, 'utf8'); + + expect(source).not.toMatch(/new\s+NotificationOrmMapper\s*\(/); + expect(source).not.toMatch(/=\s*new\s+NotificationOrmMapper\s*\(/); + }); + + it('requires mapper injection via constructor (no default mapper)', () => { + expect(TypeOrmNotificationRepository.length).toBe(2); + }); + + it('uses the injected mapper at runtime (DB-free)', async () => { + const ormRepo = { + findOne: vi.fn().mockResolvedValue({ id: 'notification-1' }), + find: vi.fn().mockResolvedValue([{ id: 'notification-1' }, { id: 'notification-2' }]), + save: vi.fn().mockResolvedValue({ id: 'notification-1' }), + delete: vi.fn().mockResolvedValue({ affected: 1 }), + update: vi.fn().mockResolvedValue({ affected: 1 }), + count: vi.fn().mockResolvedValue(1), + }; + + const dataSource = { + getRepository: vi.fn().mockReturnValue(ormRepo), + }; + + const mapper = { + toDomain: vi.fn().mockReturnValue({ id: 'domain-notification-1' }), + toOrmEntity: vi.fn().mockReturnValue({ id: 'orm-notification-1' }), + }; + + const repo = new TypeOrmNotificationRepository(dataSource as any, mapper as any); + + // Test findById + const notification = await repo.findById('notification-1'); + expect(dataSource.getRepository).toHaveBeenCalledTimes(1); + expect(ormRepo.findOne).toHaveBeenCalledWith({ where: { id: 'notification-1' } }); + expect(mapper.toDomain).toHaveBeenCalledTimes(1); + expect(notification).toEqual({ id: 'domain-notification-1' }); + + // Test findByRecipientId + const notifications = await repo.findByRecipientId('driver-123'); + expect(ormRepo.find).toHaveBeenCalledWith({ where: { recipientId: 'driver-123' }, order: { createdAt: 'DESC' } }); + expect(mapper.toDomain).toHaveBeenCalledTimes(3); // 1 from findById + 2 from findByRecipientId + expect(notifications).toHaveLength(2); + + // Test findUnreadByRecipientId + await repo.findUnreadByRecipientId('driver-123'); + expect(ormRepo.find).toHaveBeenCalledWith({ where: { recipientId: 'driver-123', status: 'unread' }, order: { createdAt: 'DESC' } }); + + // Test countUnreadByRecipientId + const count = await repo.countUnreadByRecipientId('driver-123'); + expect(ormRepo.count).toHaveBeenCalledWith({ where: { recipientId: 'driver-123', status: 'unread' } }); + expect(count).toBe(1); + + // Test create + const domainNotification = { id: 'new-notification', toJSON: () => ({ id: 'new-notification' }) }; + await repo.create(domainNotification as any); + expect(mapper.toOrmEntity).toHaveBeenCalledWith(domainNotification); + expect(ormRepo.save).toHaveBeenCalledWith({ id: 'orm-notification-1' }); + + // Test update + await repo.update(domainNotification as any); + expect(mapper.toOrmEntity).toHaveBeenCalledWith(domainNotification); + expect(ormRepo.save).toHaveBeenCalledWith({ id: 'orm-notification-1' }); + + // Test delete + await repo.delete('notification-1'); + expect(ormRepo.delete).toHaveBeenCalledWith({ id: 'notification-1' }); + + // Test deleteAllByRecipientId + await repo.deleteAllByRecipientId('driver-123'); + expect(ormRepo.delete).toHaveBeenCalledWith({ recipientId: 'driver-123' }); + + // Test markAllAsReadByRecipientId + await repo.markAllAsReadByRecipientId('driver-123'); + expect(ormRepo.update).toHaveBeenCalledWith( + { recipientId: 'driver-123', status: 'unread' }, + { status: 'read', readAt: expect.any(Date) }, + ); + }); +}); \ No newline at end of file diff --git a/adapters/notifications/persistence/typeorm/repositories/TypeOrmNotificationRepository.ts b/adapters/notifications/persistence/typeorm/repositories/TypeOrmNotificationRepository.ts new file mode 100644 index 000000000..3139864cf --- /dev/null +++ b/adapters/notifications/persistence/typeorm/repositories/TypeOrmNotificationRepository.ts @@ -0,0 +1,83 @@ +import type { DataSource } from 'typeorm'; +import type { INotificationRepository } from '@core/notifications/domain/repositories/INotificationRepository'; +import type { NotificationType } from '@core/notifications/domain/types/NotificationTypes'; +import { Notification } from '@core/notifications/domain/entities/Notification'; +import { NotificationOrmEntity } from '../entities/NotificationOrmEntity'; +import { NotificationOrmMapper } from '../mappers/NotificationOrmMapper'; + +export class TypeOrmNotificationRepository implements INotificationRepository { + constructor( + private readonly dataSource: DataSource, + private readonly mapper: NotificationOrmMapper, + ) {} + + async findById(id: string): Promise { + const repo = this.dataSource.getRepository(NotificationOrmEntity); + const entity = await repo.findOne({ where: { id } }); + return entity ? this.mapper.toDomain(entity) : null; + } + + async findByRecipientId(recipientId: string): Promise { + const repo = this.dataSource.getRepository(NotificationOrmEntity); + const entities = await repo.find({ + where: { recipientId }, + order: { createdAt: 'DESC' }, + }); + return entities.map(entity => this.mapper.toDomain(entity)); + } + + async findUnreadByRecipientId(recipientId: string): Promise { + const repo = this.dataSource.getRepository(NotificationOrmEntity); + const entities = await repo.find({ + where: { recipientId, status: 'unread' }, + order: { createdAt: 'DESC' }, + }); + return entities.map(entity => this.mapper.toDomain(entity)); + } + + async findByRecipientIdAndType(recipientId: string, type: NotificationType): Promise { + const repo = this.dataSource.getRepository(NotificationOrmEntity); + const entities = await repo.find({ + where: { recipientId, type }, + order: { createdAt: 'DESC' }, + }); + return entities.map(entity => this.mapper.toDomain(entity)); + } + + async countUnreadByRecipientId(recipientId: string): Promise { + const repo = this.dataSource.getRepository(NotificationOrmEntity); + return await repo.count({ + where: { recipientId, status: 'unread' }, + }); + } + + async create(notification: Notification): Promise { + const repo = this.dataSource.getRepository(NotificationOrmEntity); + const entity = this.mapper.toOrmEntity(notification); + await repo.save(entity); + } + + async update(notification: Notification): Promise { + const repo = this.dataSource.getRepository(NotificationOrmEntity); + const entity = this.mapper.toOrmEntity(notification); + await repo.save(entity); + } + + async delete(id: string): Promise { + const repo = this.dataSource.getRepository(NotificationOrmEntity); + await repo.delete({ id }); + } + + async deleteAllByRecipientId(recipientId: string): Promise { + const repo = this.dataSource.getRepository(NotificationOrmEntity); + await repo.delete({ recipientId }); + } + + async markAllAsReadByRecipientId(recipientId: string): Promise { + const repo = this.dataSource.getRepository(NotificationOrmEntity); + await repo.update( + { recipientId, status: 'unread' }, + { status: 'read', readAt: new Date() }, + ); + } +} \ No newline at end of file diff --git a/adapters/notifications/persistence/typeorm/schema/NotificationSchemaGuards.ts b/adapters/notifications/persistence/typeorm/schema/NotificationSchemaGuards.ts new file mode 100644 index 000000000..03c4aeb92 --- /dev/null +++ b/adapters/notifications/persistence/typeorm/schema/NotificationSchemaGuards.ts @@ -0,0 +1,258 @@ +import { TypeOrmPersistenceSchemaError } from '../errors/TypeOrmPersistenceSchemaError'; + +export function assertNonEmptyString(entityName: string, fieldName: string, value: unknown): void { + if (typeof value !== 'string' || value.trim().length === 0) { + throw new TypeOrmPersistenceSchemaError({ + entityName, + fieldName, + reason: 'invalid_string', + message: `${fieldName} must be a non-empty string`, + }); + } +} + +export function assertOptionalStringOrNull(entityName: string, fieldName: string, value: unknown): void { + if (value !== null && value !== undefined && typeof value !== 'string') { + throw new TypeOrmPersistenceSchemaError({ + entityName, + fieldName, + reason: 'invalid_optional_string', + message: `${fieldName} must be a string or null`, + }); + } +} + +export function assertDate(entityName: string, fieldName: string, value: unknown): void { + if (!(value instanceof Date) || isNaN(value.getTime())) { + throw new TypeOrmPersistenceSchemaError({ + entityName, + fieldName, + reason: 'invalid_date', + message: `${fieldName} must be a valid Date`, + }); + } +} + +export function assertOptionalDate(entityName: string, fieldName: string, value: unknown): void { + if (value !== null && value !== undefined) { + assertDate(entityName, fieldName, value); + } +} + +export function assertBoolean(entityName: string, fieldName: string, value: unknown): void { + if (typeof value !== 'boolean') { + throw new TypeOrmPersistenceSchemaError({ + entityName, + fieldName, + reason: 'invalid_boolean', + message: `${fieldName} must be a boolean`, + }); + } +} + +export function assertInteger(entityName: string, fieldName: string, value: unknown): void { + if (typeof value !== 'number' || !Number.isInteger(value)) { + throw new TypeOrmPersistenceSchemaError({ + entityName, + fieldName, + reason: 'invalid_integer', + message: `${fieldName} must be an integer`, + }); + } +} + +export function assertOptionalInteger(entityName: string, fieldName: string, value: unknown): void { + if (value !== null && value !== undefined) { + assertInteger(entityName, fieldName, value); + } +} + +export function assertStringArray(entityName: string, fieldName: string, value: unknown): void { + if (!Array.isArray(value) || !value.every(item => typeof item === 'string')) { + throw new TypeOrmPersistenceSchemaError({ + entityName, + fieldName, + reason: 'invalid_string_array', + message: `${fieldName} must be an array of strings`, + }); + } +} + +export function assertOptionalStringArray(entityName: string, fieldName: string, value: unknown): void { + if (value !== null && value !== undefined) { + assertStringArray(entityName, fieldName, value); + } +} + +export function assertObject(entityName: string, fieldName: string, value: unknown): void { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new TypeOrmPersistenceSchemaError({ + entityName, + fieldName, + reason: 'invalid_object', + message: `${fieldName} must be an object`, + }); + } +} + +export function assertOptionalObject(entityName: string, fieldName: string, value: unknown): void { + if (value !== null && value !== undefined) { + assertObject(entityName, fieldName, value); + } +} + +export function assertNotificationType(entityName: string, fieldName: string, value: unknown): void { + const validTypes = [ + 'system_announcement', + 'race_reminder', + 'protest_filed', + 'protest_resolved', + 'penalty_applied', + 'performance_summary', + 'final_results', + 'sponsorship_approved', + 'friend_request', + 'message_received', + 'achievement_unlocked', + ]; + + if (typeof value !== 'string' || !validTypes.includes(value)) { + throw new TypeOrmPersistenceSchemaError({ + entityName, + fieldName, + reason: 'invalid_notification_type', + message: `${fieldName} must be one of: ${validTypes.join(', ')}`, + }); + } +} + +export function assertNotificationChannel(entityName: string, fieldName: string, value: unknown): void { + const validChannels = ['in_app', 'email', 'discord', 'push']; + + if (typeof value !== 'string' || !validChannels.includes(value)) { + throw new TypeOrmPersistenceSchemaError({ + entityName, + fieldName, + reason: 'invalid_notification_channel', + message: `${fieldName} must be one of: ${validChannels.join(', ')}`, + }); + } +} + +export function assertNotificationStatus(entityName: string, fieldName: string, value: unknown): void { + const validStatuses = ['unread', 'read', 'dismissed', 'action_required']; + + if (typeof value !== 'string' || !validStatuses.includes(value)) { + throw new TypeOrmPersistenceSchemaError({ + entityName, + fieldName, + reason: 'invalid_notification_status', + message: `${fieldName} must be one of: ${validStatuses.join(', ')}`, + }); + } +} + +export function assertNotificationUrgency(entityName: string, fieldName: string, value: unknown): void { + const validUrgencies = ['silent', 'toast', 'modal']; + + if (typeof value !== 'string' || !validUrgencies.includes(value)) { + throw new TypeOrmPersistenceSchemaError({ + entityName, + fieldName, + reason: 'invalid_notification_urgency', + message: `${fieldName} must be one of: ${validUrgencies.join(', ')}`, + }); + } +} + +export function assertNotificationActions(entityName: string, fieldName: string, value: unknown): void { + if (value === null || value === undefined) { + return; + } + + if (!Array.isArray(value)) { + throw new TypeOrmPersistenceSchemaError({ + entityName, + fieldName, + reason: 'invalid_actions_array', + message: `${fieldName} must be an array of action objects`, + }); + } + + for (let i = 0; i < value.length; i++) { + const action = value[i]; + if (typeof action !== 'object' || action === null) { + throw new TypeOrmPersistenceSchemaError({ + entityName, + fieldName: `${fieldName}[${i}]`, + reason: 'invalid_action_object', + message: `Action at index ${i} must be an object`, + }); + } + + if (typeof action.label !== 'string' || action.label.trim().length === 0) { + throw new TypeOrmPersistenceSchemaError({ + entityName, + fieldName: `${fieldName}[${i}].label`, + reason: 'invalid_action_label', + message: `Action at index ${i} must have a non-empty label`, + }); + } + + if (typeof action.type !== 'string' || !['primary', 'secondary', 'danger'].includes(action.type)) { + throw new TypeOrmPersistenceSchemaError({ + entityName, + fieldName: `${fieldName}[${i}].type`, + reason: 'invalid_action_type', + message: `Action at index ${i} must have type 'primary', 'secondary', or 'danger'`, + }); + } + } +} + +export function assertChannelPreferences(entityName: string, fieldName: string, value: unknown): void { + assertObject(entityName, fieldName, value); + + const channels = ['in_app', 'email', 'discord', 'push']; + const obj = value as Record; + + for (const channel of channels) { + if (!(channel in obj)) { + throw new TypeOrmPersistenceSchemaError({ + entityName, + fieldName: `${fieldName}.${channel}`, + reason: 'missing_channel', + message: `Channel preferences must include ${channel}`, + }); + } + + const pref = obj[channel]; + if (typeof pref !== 'object' || pref === null) { + throw new TypeOrmPersistenceSchemaError({ + entityName, + fieldName: `${fieldName}.${channel}`, + reason: 'invalid_channel_preference', + message: `Channel preference for ${channel} must be an object`, + }); + } + + const prefObj = pref as Record; + if (typeof prefObj.enabled !== 'boolean') { + throw new TypeOrmPersistenceSchemaError({ + entityName, + fieldName: `${fieldName}.${channel}.enabled`, + reason: 'invalid_enabled_flag', + message: `Channel preference for ${channel} must have an enabled boolean`, + }); + } + + if (prefObj.settings !== undefined && prefObj.settings !== null && typeof prefObj.settings !== 'object') { + throw new TypeOrmPersistenceSchemaError({ + entityName, + fieldName: `${fieldName}.${channel}.settings`, + reason: 'invalid_settings', + message: `Channel preference for ${channel} settings must be an object or null`, + }); + } + } +} \ No newline at end of file diff --git a/adapters/notifications/ports/InMemoryNotificationGatewayRegistry.ts b/adapters/notifications/ports/InMemoryNotificationGatewayRegistry.ts new file mode 100644 index 000000000..5be5003a1 --- /dev/null +++ b/adapters/notifications/ports/InMemoryNotificationGatewayRegistry.ts @@ -0,0 +1,52 @@ +import type { Notification } from '@core/notifications/domain/entities/Notification'; +import type { NotificationChannel } from '@core/notifications/domain/types/NotificationTypes'; +import type { NotificationGateway, NotificationGatewayRegistry, NotificationDeliveryResult } from '@core/notifications/application/ports/NotificationGateway'; + +export class InMemoryNotificationGatewayRegistry implements NotificationGatewayRegistry { + private gateways: Map = new Map(); + + register(gateway: NotificationGateway): void { + this.gateways.set(gateway.getChannel(), gateway); + } + + getGateway(channel: NotificationChannel): NotificationGateway | null { + return this.gateways.get(channel) || null; + } + + getAllGateways(): NotificationGateway[] { + return Array.from(this.gateways.values()); + } + + async send(notification: Notification): Promise { + const gateway = this.gateways.get(notification.channel); + + if (!gateway) { + return { + success: false, + channel: notification.channel, + error: `No gateway registered for channel ${notification.channel}`, + attemptedAt: new Date(), + }; + } + + if (!gateway.isConfigured()) { + return { + success: false, + channel: notification.channel, + error: `Gateway for ${notification.channel} is not configured`, + attemptedAt: new Date(), + }; + } + + try { + return await gateway.send(notification); + } catch (error) { + return { + success: false, + channel: notification.channel, + error: error instanceof Error ? error.message : String(error), + attemptedAt: new Date(), + }; + } + } +} \ No newline at end of file diff --git a/adapters/notifications/ports/NotificationServiceAdapter.ts b/adapters/notifications/ports/NotificationServiceAdapter.ts new file mode 100644 index 000000000..b7f8ce94a --- /dev/null +++ b/adapters/notifications/ports/NotificationServiceAdapter.ts @@ -0,0 +1,48 @@ +import type { NotificationService, SendNotificationCommand } from '@core/notifications/application/ports/NotificationService'; +import type { INotificationRepository } from '@core/notifications/domain/repositories/INotificationRepository'; +import type { INotificationPreferenceRepository } from '@core/notifications/domain/repositories/INotificationPreferenceRepository'; +import type { NotificationGatewayRegistry } from '@core/notifications/application/ports/NotificationGateway'; +import { SendNotificationUseCase } from '@core/notifications/application/use-cases/SendNotificationUseCase'; +import type { Logger } from '@core/shared/application'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; + +class NoOpOutputPort implements UseCaseOutputPort { + present(_result: any): void { + // No-op for adapter + } +} + +export class NotificationServiceAdapter implements NotificationService { + private readonly useCase: SendNotificationUseCase; + private readonly logger: Logger; + + constructor( + notificationRepository: INotificationRepository, + preferenceRepository: INotificationPreferenceRepository, + gatewayRegistry: NotificationGatewayRegistry, + logger: Logger, + ) { + this.logger = logger; + this.useCase = new SendNotificationUseCase( + notificationRepository, + preferenceRepository, + gatewayRegistry, + new NoOpOutputPort(), + logger, + ); + } + + async sendNotification(command: SendNotificationCommand): Promise { + const result = await this.useCase.execute(command); + + if (result.isErr()) { + const error = result.error; + if (error) { + this.logger.error('Failed to send notification', new Error(error.details.message)); + throw new Error(error.details.message); + } else { + throw new Error('Unknown error sending notification'); + } + } + } +} \ No newline at end of file diff --git a/apps/api/src/domain/bootstrap/BootstrapModule.ts b/apps/api/src/domain/bootstrap/BootstrapModule.ts index e5381c713..d0fca9d13 100644 --- a/apps/api/src/domain/bootstrap/BootstrapModule.ts +++ b/apps/api/src/domain/bootstrap/BootstrapModule.ts @@ -5,10 +5,12 @@ import { Inject, Module, OnModuleInit } from '@nestjs/common'; import { getApiPersistence, getEnableBootstrap } from '../../env'; import { RacingPersistenceModule } from '../../persistence/racing/RacingPersistenceModule'; import { SocialPersistenceModule } from '../../persistence/social/SocialPersistenceModule'; +import { AchievementPersistenceModule } from '../../persistence/achievement/AchievementPersistenceModule'; +import { IdentityPersistenceModule } from '../../persistence/identity/IdentityPersistenceModule'; import { BootstrapProviders, ENSURE_INITIAL_DATA_TOKEN } from './BootstrapProviders'; @Module({ - imports: [RacingPersistenceModule, SocialPersistenceModule], + imports: [RacingPersistenceModule, SocialPersistenceModule, AchievementPersistenceModule, IdentityPersistenceModule], 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 c44e17103..c36023335 100644 --- a/apps/api/src/domain/bootstrap/BootstrapProviders.ts +++ b/apps/api/src/domain/bootstrap/BootstrapProviders.ts @@ -1,5 +1,6 @@ import { Provider } from '@nestjs/common'; import { SOCIAL_FEED_REPOSITORY_TOKEN, SOCIAL_GRAPH_REPOSITORY_TOKEN } from '../../persistence/social/SocialPersistenceTokens'; +import { ACHIEVEMENT_REPOSITORY_TOKEN } from '../../persistence/achievement/AchievementPersistenceTokens'; import { EnsureInitialData } from '../../../../../adapters/bootstrap/EnsureInitialData'; import type { RacingSeedDependencies } from '../../../../../adapters/bootstrap/SeedRacingData'; import { SignupWithEmailUseCase, type SignupWithEmailResult } from '@core/identity/application/use-cases/SignupWithEmailUseCase'; @@ -12,13 +13,12 @@ import type { IUserRepository } from '@core/identity/domain/repositories/IUserRe import type { IdentitySessionPort } from '@core/identity/application/ports/IdentitySessionPort'; import type { Logger } from '@core/shared/application'; import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; -import { InMemoryUserRepository } from '../../../../../adapters/identity/persistence/inmemory/InMemoryUserRepository'; -import { InMemoryAchievementRepository } from '../../../../../adapters/persistence/inmemory/achievement/InMemoryAchievementRepository'; import { CookieIdentitySessionAdapter } from '../../../../../adapters/identity/session/CookieIdentitySessionAdapter'; +import { USER_REPOSITORY_TOKEN as IDENTITY_USER_REPOSITORY_TOKEN } from '../../persistence/identity/IdentityPersistenceTokens'; // Define tokens export const USER_REPOSITORY_TOKEN = 'IUserRepository_Bootstrap'; -export const ACHIEVEMENT_REPOSITORY_TOKEN = 'IAchievementRepository_Bootstrap'; +// ACHIEVEMENT_REPOSITORY_TOKEN is now imported from AchievementPersistenceTokens export const IDENTITY_SESSION_PORT_TOKEN = 'IdentitySessionPort_Bootstrap'; export const SIGNUP_USE_CASE_TOKEN = 'SignupWithEmailUseCase_Bootstrap'; export const CREATE_ACHIEVEMENT_USE_CASE_TOKEN = 'CreateAchievementUseCase_Bootstrap'; @@ -112,13 +112,10 @@ export const BootstrapProviders: Provider[] = [ }, { provide: USER_REPOSITORY_TOKEN, - useFactory: (logger: Logger) => new InMemoryUserRepository(logger), - inject: ['Logger'], - }, - { - provide: ACHIEVEMENT_REPOSITORY_TOKEN, - useClass: InMemoryAchievementRepository, + useFactory: (userRepository: IUserRepository) => userRepository, + inject: [IDENTITY_USER_REPOSITORY_TOKEN], }, + // Achievement repository is now provided by AchievementPersistenceModule { provide: IDENTITY_SESSION_PORT_TOKEN, useFactory: (logger: Logger) => new CookieIdentitySessionAdapter(logger), diff --git a/apps/api/src/domain/media/MediaModule.ts b/apps/api/src/domain/media/MediaModule.ts index 75d02da0f..cc5c5e218 100644 --- a/apps/api/src/domain/media/MediaModule.ts +++ b/apps/api/src/domain/media/MediaModule.ts @@ -2,8 +2,10 @@ import { Module } from '@nestjs/common'; import { MediaService } from './MediaService'; import { MediaController } from './MediaController'; import { MediaProviders } from './MediaProviders'; +import { MediaPersistenceModule } from '../../persistence/media/MediaPersistenceModule'; @Module({ + imports: [MediaPersistenceModule], controllers: [MediaController], providers: [MediaService, ...MediaProviders], exports: [MediaService], diff --git a/apps/api/src/domain/media/MediaProviders.ts b/apps/api/src/domain/media/MediaProviders.ts index 65c304f4b..50380fe6a 100644 --- a/apps/api/src/domain/media/MediaProviders.ts +++ b/apps/api/src/domain/media/MediaProviders.ts @@ -57,37 +57,11 @@ import { export * from './MediaTokens'; -import type { AvatarGenerationRequest } from '@core/media/domain/entities/AvatarGenerationRequest'; -import type { Media } from '@core/media/domain/entities/Media'; -import type { Avatar } from '@core/media/domain/entities/Avatar'; import type { FaceValidationResult } from '@core/media/application/ports/FaceValidationPort'; import type { AvatarGenerationResult } from '@core/media/application/ports/AvatarGenerationPort'; import type { UploadResult } from '@core/media/application/ports/MediaStoragePort'; -// Mock implementations -class MockAvatarGenerationRepository implements IAvatarGenerationRepository { - async save(): Promise {} - async findById(): Promise { return null; } - async findByUserId(): Promise { return []; } - async findLatestByUserId(): Promise { return null; } - async delete(): Promise {} -} - -class MockMediaRepository implements IMediaRepository { - async save(): Promise {} - async findById(): Promise { return null; } - async findByUploadedBy(): Promise { return []; } - async delete(): Promise {} -} - -class MockAvatarRepository implements IAvatarRepository { - async save(): Promise {} - async findById(): Promise { return null; } - async findActiveByDriverId(): Promise { return null; } - async findByDriverId(): Promise { return []; } - async delete(): Promise {} -} - +// External adapters (ports) - these remain mock implementations class MockFaceValidationAdapter implements FaceValidationPort { async validateFacePhoto(): Promise { return { @@ -137,18 +111,6 @@ export const MediaProviders: Provider[] = [ DeleteMediaPresenter, GetAvatarPresenter, UpdateAvatarPresenter, - { - provide: AVATAR_GENERATION_REPOSITORY_TOKEN, - useClass: MockAvatarGenerationRepository, - }, - { - provide: MEDIA_REPOSITORY_TOKEN, - useClass: MockMediaRepository, - }, - { - provide: AVATAR_REPOSITORY_TOKEN, - useClass: MockAvatarRepository, - }, { provide: FACE_VALIDATION_PORT_TOKEN, useClass: MockFaceValidationAdapter, diff --git a/apps/api/src/domain/notifications/NotificationsModule.ts b/apps/api/src/domain/notifications/NotificationsModule.ts new file mode 100644 index 000000000..ee27b47e8 --- /dev/null +++ b/apps/api/src/domain/notifications/NotificationsModule.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; + +import { NotificationsPersistenceModule } from '../../persistence/notifications/NotificationsPersistenceModule'; + +@Module({ + imports: [NotificationsPersistenceModule], + exports: [NotificationsPersistenceModule], +}) +export class NotificationsModule {} \ No newline at end of file diff --git a/apps/api/src/persistence/achievement/AchievementPersistenceModule.test.ts b/apps/api/src/persistence/achievement/AchievementPersistenceModule.test.ts new file mode 100644 index 000000000..74c4e92c6 --- /dev/null +++ b/apps/api/src/persistence/achievement/AchievementPersistenceModule.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it, beforeEach } from 'vitest'; +import { Test, TestingModule } from '@nestjs/testing'; +import { ACHIEVEMENT_REPOSITORY_TOKEN } from './AchievementPersistenceTokens'; +import type { IAchievementRepository } from '@core/identity/domain/repositories/IAchievementRepository'; + +describe('AchievementPersistenceModule', () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + // Reset environment before each test + delete process.env.GRIDPILOT_API_PERSISTENCE; + // Clear module cache to ensure fresh imports + vi.resetModules(); + }); + + afterEach(() => { + // Restore original environment + process.env = { ...originalEnv }; + }); + + it('uses inmemory providers when GRIDPILOT_API_PERSISTENCE=inmemory', async () => { + process.env.GRIDPILOT_API_PERSISTENCE = 'inmemory'; + + const { AchievementPersistenceModule } = await import('./AchievementPersistenceModule'); + + const module: TestingModule = await Test.createTestingModule({ + imports: [AchievementPersistenceModule], + }).compile(); + + const repository = module.get(ACHIEVEMENT_REPOSITORY_TOKEN); + // The adapter should provide the domain interface methods + expect(repository).toBeDefined(); + expect(typeof repository.createAchievement).toBe('function'); + expect(typeof repository.findAchievementById).toBe('function'); + expect(typeof repository.findAllAchievements).toBe('function'); + }); + + it('uses postgres providers when GRIDPILOT_API_PERSISTENCE=postgres', async () => { + const shouldRun = Boolean(process.env.DATABASE_URL); + if (!shouldRun) { + console.log('Skipping postgres test - DATABASE_URL not set'); + return; + } + + process.env.GRIDPILOT_API_PERSISTENCE = 'postgres'; + + const { AchievementPersistenceModule } = await import('./AchievementPersistenceModule'); + + const module: TestingModule = await Test.createTestingModule({ + imports: [AchievementPersistenceModule], + }).compile(); + + const repository = module.get(ACHIEVEMENT_REPOSITORY_TOKEN); + // The adapter should provide the domain interface methods + expect(repository).toBeDefined(); + expect(typeof repository.createAchievement).toBe('function'); + expect(typeof repository.findAchievementById).toBe('function'); + expect(typeof repository.findAllAchievements).toBe('function'); + }); + + it('defaults to inmemory when GRIDPILOT_API_PERSISTENCE is not set', async () => { + // Ensure env var is not set + delete process.env.GRIDPILOT_API_PERSISTENCE; + + const { AchievementPersistenceModule } = await import('./AchievementPersistenceModule'); + + const module: TestingModule = await Test.createTestingModule({ + imports: [AchievementPersistenceModule], + }).compile(); + + const repository = module.get(ACHIEVEMENT_REPOSITORY_TOKEN); + // The adapter should provide the domain interface methods + expect(repository).toBeDefined(); + expect(typeof repository.createAchievement).toBe('function'); + expect(typeof repository.findAchievementById).toBe('function'); + expect(typeof repository.findAllAchievements).toBe('function'); + }); +}); \ No newline at end of file diff --git a/apps/api/src/persistence/achievement/AchievementPersistenceModule.ts b/apps/api/src/persistence/achievement/AchievementPersistenceModule.ts new file mode 100644 index 000000000..c40531e44 --- /dev/null +++ b/apps/api/src/persistence/achievement/AchievementPersistenceModule.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { getApiPersistence } from '../../env'; +import { InMemoryAchievementPersistenceModule } from '../inmemory/InMemoryAchievementPersistenceModule'; +import { PostgresAchievementPersistenceModule } from '../postgres/PostgresAchievementPersistenceModule'; + +const selectedPersistenceModule = + getApiPersistence() === 'postgres' ? PostgresAchievementPersistenceModule : InMemoryAchievementPersistenceModule; + +@Module({ + imports: [selectedPersistenceModule], + exports: [selectedPersistenceModule], +}) +export class AchievementPersistenceModule {} \ No newline at end of file diff --git a/apps/api/src/persistence/achievement/AchievementPersistenceTokens.ts b/apps/api/src/persistence/achievement/AchievementPersistenceTokens.ts new file mode 100644 index 000000000..bd9d0ca6e --- /dev/null +++ b/apps/api/src/persistence/achievement/AchievementPersistenceTokens.ts @@ -0,0 +1,2 @@ +export const ACHIEVEMENT_REPOSITORY_TOKEN = 'IAchievementRepository'; +export const USER_ACHIEVEMENT_REPOSITORY_TOKEN = 'IUserAchievementRepository'; \ No newline at end of file diff --git a/apps/api/src/persistence/inmemory/InMemoryAchievementPersistenceModule.ts b/apps/api/src/persistence/inmemory/InMemoryAchievementPersistenceModule.ts new file mode 100644 index 000000000..c23b59fc0 --- /dev/null +++ b/apps/api/src/persistence/inmemory/InMemoryAchievementPersistenceModule.ts @@ -0,0 +1,92 @@ +import { Module } from '@nestjs/common'; + +import { LoggingModule } from '../../domain/logging/LoggingModule'; + +import type { Logger } from '@core/shared/application/Logger'; + +import { InMemoryAchievementRepository } from '@adapters/identity/persistence/inmemory/InMemoryAchievementRepository'; + +import { ACHIEVEMENT_REPOSITORY_TOKEN } from '../achievement/AchievementPersistenceTokens'; +import { Achievement, AchievementCategory } from '@core/identity/domain/entities/Achievement'; +import { UserAchievement } from '@core/identity/domain/entities/UserAchievement'; + +// Adapter to convert between domain repository interface and application use case interface +class InMemoryAchievementRepositoryAdapter { + constructor(private readonly inMemoryRepo: InMemoryAchievementRepository) {} + + // Application use case interface methods + async save(achievement: Achievement): Promise { + await this.inMemoryRepo.createAchievement(achievement); + } + + async findById(id: string): Promise { + return await this.inMemoryRepo.findAchievementById(id); + } + + // Delegate all other methods to the underlying in-memory repository + async findAchievementById(id: string): Promise { + return await this.inMemoryRepo.findAchievementById(id); + } + + async findAllAchievements(): Promise { + return await this.inMemoryRepo.findAllAchievements(); + } + + async findAchievementsByCategory(category: AchievementCategory): Promise { + return await this.inMemoryRepo.findAchievementsByCategory(category); + } + + async createAchievement(achievement: Achievement): Promise { + return await this.inMemoryRepo.createAchievement(achievement); + } + + async findUserAchievementById(id: string): Promise { + return await this.inMemoryRepo.findUserAchievementById(id); + } + + async findUserAchievementsByUserId(userId: string): Promise { + return await this.inMemoryRepo.findUserAchievementsByUserId(userId); + } + + async findUserAchievementByUserAndAchievement(userId: string, achievementId: string): Promise { + return await this.inMemoryRepo.findUserAchievementByUserAndAchievement(userId, achievementId); + } + + async hasUserEarnedAchievement(userId: string, achievementId: string): Promise { + return await this.inMemoryRepo.hasUserEarnedAchievement(userId, achievementId); + } + + async createUserAchievement(userAchievement: UserAchievement): Promise { + return await this.inMemoryRepo.createUserAchievement(userAchievement); + } + + async updateUserAchievement(userAchievement: UserAchievement): Promise { + return await this.inMemoryRepo.updateUserAchievement(userAchievement); + } + + async getAchievementLeaderboard(limit: number): Promise<{ userId: string; points: number; count: number }[]> { + return await this.inMemoryRepo.getAchievementLeaderboard(limit); + } + + async getUserAchievementStats(userId: string): Promise<{ + total: number; + points: number; + byCategory: Record; + }> { + return await this.inMemoryRepo.getUserAchievementStats(userId); + } +} + +@Module({ + imports: [LoggingModule], + providers: [ + { + provide: ACHIEVEMENT_REPOSITORY_TOKEN, + useFactory: (logger: Logger) => + new InMemoryAchievementRepositoryAdapter(new InMemoryAchievementRepository(logger)), + inject: ['Logger'], + }, + ], + exports: [ACHIEVEMENT_REPOSITORY_TOKEN], +}) +export class InMemoryAchievementPersistenceModule {} \ No newline at end of file diff --git a/apps/api/src/persistence/inmemory/InMemoryMediaPersistenceModule.ts b/apps/api/src/persistence/inmemory/InMemoryMediaPersistenceModule.ts new file mode 100644 index 000000000..ed0078acb --- /dev/null +++ b/apps/api/src/persistence/inmemory/InMemoryMediaPersistenceModule.ts @@ -0,0 +1,51 @@ +import { Module } from '@nestjs/common'; + +import { LoggingModule } from '../../domain/logging/LoggingModule'; + +import type { Logger } from '@core/shared/application/Logger'; + +import type { IAvatarGenerationRepository } from '@core/media/domain/repositories/IAvatarGenerationRepository'; +import type { IMediaRepository } from '@core/media/domain/repositories/IMediaRepository'; +import type { IAvatarRepository } from '@core/media/domain/repositories/IAvatarRepository'; + +import { InMemoryAvatarGenerationRepository } from '@adapters/media/persistence/inmemory/InMemoryAvatarGenerationRepository'; + +import { AVATAR_GENERATION_REPOSITORY_TOKEN, MEDIA_REPOSITORY_TOKEN, AVATAR_REPOSITORY_TOKEN } from '../media/MediaPersistenceTokens'; + +// Mock implementations for Media and Avatar repositories (inmemory only has AvatarGeneration) +class MockMediaRepository implements IMediaRepository { + async save(): Promise {} + async findById(): Promise { return null; } + async findByUploadedBy(): Promise<[]> { return []; } + async delete(): Promise {} +} + +class MockAvatarRepository implements IAvatarRepository { + async save(): Promise {} + async findById(): Promise { return null; } + async findActiveByDriverId(): Promise { return null; } + async findByDriverId(): Promise<[]> { return []; } + async delete(): Promise {} +} + +@Module({ + imports: [LoggingModule], + providers: [ + { + provide: AVATAR_GENERATION_REPOSITORY_TOKEN, + useFactory: (logger: Logger): IAvatarGenerationRepository => + new InMemoryAvatarGenerationRepository(logger), + inject: ['Logger'], + }, + { + provide: MEDIA_REPOSITORY_TOKEN, + useClass: MockMediaRepository, + }, + { + provide: AVATAR_REPOSITORY_TOKEN, + useClass: MockAvatarRepository, + }, + ], + exports: [AVATAR_GENERATION_REPOSITORY_TOKEN, MEDIA_REPOSITORY_TOKEN, AVATAR_REPOSITORY_TOKEN], +}) +export class InMemoryMediaPersistenceModule {} diff --git a/apps/api/src/persistence/inmemory/InMemoryNotificationsPersistenceModule.ts b/apps/api/src/persistence/inmemory/InMemoryNotificationsPersistenceModule.ts new file mode 100644 index 000000000..47e68631f --- /dev/null +++ b/apps/api/src/persistence/inmemory/InMemoryNotificationsPersistenceModule.ts @@ -0,0 +1,67 @@ +import { Module } from '@nestjs/common'; + +import { LoggingModule } from '../../domain/logging/LoggingModule'; + +import type { Logger } from '@core/shared/application/Logger'; + +import type { INotificationRepository } from '@core/notifications/domain/repositories/INotificationRepository'; +import type { INotificationPreferenceRepository } from '@core/notifications/domain/repositories/INotificationPreferenceRepository'; +import type { NotificationService } from '@core/notifications/application/ports/NotificationService'; +import type { NotificationGatewayRegistry } from '@core/notifications/application/ports/NotificationGateway'; + +import { InMemoryNotificationRepository } from '@adapters/notifications/persistence/inmemory/InMemoryNotificationRepository'; +import { InMemoryNotificationPreferenceRepository } from '@adapters/notifications/persistence/inmemory/InMemoryNotificationPreferenceRepository'; +import { NotificationServiceAdapter } from '@adapters/notifications/ports/NotificationServiceAdapter'; +import { InMemoryNotificationGatewayRegistry } from '@adapters/notifications/ports/InMemoryNotificationGatewayRegistry'; + +import { NOTIFICATION_REPOSITORY_TOKEN, NOTIFICATION_PREFERENCE_REPOSITORY_TOKEN } from '../notifications/NotificationsPersistenceTokens'; + +export const NOTIFICATION_SERVICE_TOKEN = 'INotificationService'; +export const NOTIFICATION_GATEWAY_REGISTRY_TOKEN = 'INotificationGatewayRegistry'; + +@Module({ + imports: [LoggingModule], + providers: [ + { + provide: NOTIFICATION_REPOSITORY_TOKEN, + useFactory: (logger: Logger): INotificationRepository => + new InMemoryNotificationRepository(logger), + inject: ['Logger'], + }, + { + provide: NOTIFICATION_PREFERENCE_REPOSITORY_TOKEN, + useFactory: (logger: Logger): INotificationPreferenceRepository => + new InMemoryNotificationPreferenceRepository(logger), + inject: ['Logger'], + }, + { + provide: NOTIFICATION_GATEWAY_REGISTRY_TOKEN, + useFactory: (): NotificationGatewayRegistry => + new InMemoryNotificationGatewayRegistry(), + inject: [], + }, + { + provide: NOTIFICATION_SERVICE_TOKEN, + useFactory: ( + notificationRepo: INotificationRepository, + preferenceRepo: INotificationPreferenceRepository, + gatewayRegistry: NotificationGatewayRegistry, + logger: Logger, + ): NotificationService => + new NotificationServiceAdapter(notificationRepo, preferenceRepo, gatewayRegistry, logger), + inject: [ + NOTIFICATION_REPOSITORY_TOKEN, + NOTIFICATION_PREFERENCE_REPOSITORY_TOKEN, + NOTIFICATION_GATEWAY_REGISTRY_TOKEN, + 'Logger', + ], + }, + ], + exports: [ + NOTIFICATION_REPOSITORY_TOKEN, + NOTIFICATION_PREFERENCE_REPOSITORY_TOKEN, + NOTIFICATION_SERVICE_TOKEN, + NOTIFICATION_GATEWAY_REGISTRY_TOKEN, + ], +}) +export class InMemoryNotificationsPersistenceModule {} \ No newline at end of file diff --git a/apps/api/src/persistence/media/MediaPersistenceModule.test.ts b/apps/api/src/persistence/media/MediaPersistenceModule.test.ts new file mode 100644 index 000000000..c69951f29 --- /dev/null +++ b/apps/api/src/persistence/media/MediaPersistenceModule.test.ts @@ -0,0 +1,68 @@ +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 { AVATAR_GENERATION_REPOSITORY_TOKEN, MEDIA_REPOSITORY_TOKEN, AVATAR_REPOSITORY_TOKEN } from './MediaPersistenceTokens'; + +describe('MediaPersistenceModule', () => { + 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 { MediaPersistenceModule } = await import('./MediaPersistenceModule'); + const { InMemoryAvatarGenerationRepository } = await import('@adapters/media/persistence/inmemory/InMemoryAvatarGenerationRepository'); + + const module: TestingModule = await Test.createTestingModule({ + imports: [MediaPersistenceModule], + }).compile(); + + const avatarGenRepo = module.get(AVATAR_GENERATION_REPOSITORY_TOKEN); + expect(avatarGenRepo).toBeInstanceOf(InMemoryAvatarGenerationRepository); + + // Media and Avatar repos are mocks in inmemory mode + const mediaRepo = module.get(MEDIA_REPOSITORY_TOKEN); + const avatarRepo = module.get(AVATAR_REPOSITORY_TOKEN); + expect(mediaRepo).toBeDefined(); + expect(avatarRepo).toBeDefined(); + + 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 { MediaPersistenceModule } = await import('./MediaPersistenceModule'); + const { PostgresMediaPersistenceModule } = await import('../postgres/PostgresMediaPersistenceModule'); + + const imports = Reflect.getMetadata(MODULE_METADATA.IMPORTS, MediaPersistenceModule) as unknown[]; + expect(imports).toContain(PostgresMediaPersistenceModule); + }); + + it('defaults to inmemory when GRIDPILOT_API_PERSISTENCE is not set', async () => { + vi.resetModules(); + + delete process.env.GRIDPILOT_API_PERSISTENCE; + delete process.env.DATABASE_URL; + + const { MediaPersistenceModule } = await import('./MediaPersistenceModule'); + const { InMemoryMediaPersistenceModule } = await import('../inmemory/InMemoryMediaPersistenceModule'); + + const imports = Reflect.getMetadata(MODULE_METADATA.IMPORTS, MediaPersistenceModule) as unknown[]; + expect(imports).toContain(InMemoryMediaPersistenceModule); + }); +}); diff --git a/apps/api/src/persistence/media/MediaPersistenceModule.ts b/apps/api/src/persistence/media/MediaPersistenceModule.ts new file mode 100644 index 000000000..2685ed3d2 --- /dev/null +++ b/apps/api/src/persistence/media/MediaPersistenceModule.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; + +import { getApiPersistence } from '../../env'; +import { InMemoryMediaPersistenceModule } from '../inmemory/InMemoryMediaPersistenceModule'; +import { PostgresMediaPersistenceModule } from '../postgres/PostgresMediaPersistenceModule'; + +const selectedPersistenceModule = + getApiPersistence() === 'postgres' ? PostgresMediaPersistenceModule : InMemoryMediaPersistenceModule; + +@Module({ + imports: [selectedPersistenceModule], + exports: [selectedPersistenceModule], +}) +export class MediaPersistenceModule {} diff --git a/apps/api/src/persistence/media/MediaPersistenceTokens.ts b/apps/api/src/persistence/media/MediaPersistenceTokens.ts new file mode 100644 index 000000000..d7a50c554 --- /dev/null +++ b/apps/api/src/persistence/media/MediaPersistenceTokens.ts @@ -0,0 +1,3 @@ +export const AVATAR_GENERATION_REPOSITORY_TOKEN = 'IAvatarGenerationRepository'; +export const MEDIA_REPOSITORY_TOKEN = 'IMediaRepository'; +export const AVATAR_REPOSITORY_TOKEN = 'IAvatarRepository'; diff --git a/apps/api/src/persistence/notifications/NotificationsPersistenceModule.test.ts b/apps/api/src/persistence/notifications/NotificationsPersistenceModule.test.ts new file mode 100644 index 000000000..b17dd1f80 --- /dev/null +++ b/apps/api/src/persistence/notifications/NotificationsPersistenceModule.test.ts @@ -0,0 +1,73 @@ +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 { NOTIFICATION_REPOSITORY_TOKEN, NOTIFICATION_PREFERENCE_REPOSITORY_TOKEN, NOTIFICATION_SERVICE_TOKEN, NOTIFICATION_GATEWAY_REGISTRY_TOKEN } from './NotificationsPersistenceTokens'; + +describe('NotificationsPersistenceModule', () => { + 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 { NotificationsPersistenceModule } = await import('./NotificationsPersistenceModule'); + const { InMemoryNotificationRepository } = await import('@adapters/notifications/persistence/inmemory/InMemoryNotificationRepository'); + const { InMemoryNotificationPreferenceRepository } = await import('@adapters/notifications/persistence/inmemory/InMemoryNotificationPreferenceRepository'); + const { NotificationServiceAdapter } = await import('@adapters/notifications/ports/NotificationServiceAdapter'); + + const module: TestingModule = await Test.createTestingModule({ + imports: [NotificationsPersistenceModule], + }).compile(); + + const notificationRepo = module.get(NOTIFICATION_REPOSITORY_TOKEN); + expect(notificationRepo).toBeInstanceOf(InMemoryNotificationRepository); + + const preferenceRepo = module.get(NOTIFICATION_PREFERENCE_REPOSITORY_TOKEN); + expect(preferenceRepo).toBeInstanceOf(InMemoryNotificationPreferenceRepository); + + const notificationService = module.get(NOTIFICATION_SERVICE_TOKEN); + expect(notificationService).toBeInstanceOf(NotificationServiceAdapter); + + const gatewayRegistry = module.get(NOTIFICATION_GATEWAY_REGISTRY_TOKEN); + expect(gatewayRegistry).toBeDefined(); + + 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 { NotificationsPersistenceModule } = await import('./NotificationsPersistenceModule'); + const { PostgresNotificationsPersistenceModule } = await import('../postgres/PostgresNotificationsPersistenceModule'); + + const imports = Reflect.getMetadata(MODULE_METADATA.IMPORTS, NotificationsPersistenceModule) as unknown[]; + expect(imports).toContain(PostgresNotificationsPersistenceModule); + }); + + it('defaults to inmemory when GRIDPILOT_API_PERSISTENCE is not set', async () => { + vi.resetModules(); + + delete process.env.GRIDPILOT_API_PERSISTENCE; + delete process.env.DATABASE_URL; + + const { NotificationsPersistenceModule } = await import('./NotificationsPersistenceModule'); + const { InMemoryNotificationsPersistenceModule } = await import('../inmemory/InMemoryNotificationsPersistenceModule'); + + const imports = Reflect.getMetadata(MODULE_METADATA.IMPORTS, NotificationsPersistenceModule) as unknown[]; + expect(imports).toContain(InMemoryNotificationsPersistenceModule); + }); +}); \ No newline at end of file diff --git a/apps/api/src/persistence/notifications/NotificationsPersistenceModule.ts b/apps/api/src/persistence/notifications/NotificationsPersistenceModule.ts new file mode 100644 index 000000000..7b50d2101 --- /dev/null +++ b/apps/api/src/persistence/notifications/NotificationsPersistenceModule.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; + +import { getApiPersistence } from '../../env'; +import { InMemoryNotificationsPersistenceModule } from '../inmemory/InMemoryNotificationsPersistenceModule'; +import { PostgresNotificationsPersistenceModule } from '../postgres/PostgresNotificationsPersistenceModule'; + +const selectedPersistenceModule = + getApiPersistence() === 'postgres' ? PostgresNotificationsPersistenceModule : InMemoryNotificationsPersistenceModule; + +@Module({ + imports: [selectedPersistenceModule], + exports: [selectedPersistenceModule], +}) +export class NotificationsPersistenceModule {} \ No newline at end of file diff --git a/apps/api/src/persistence/notifications/NotificationsPersistenceTokens.ts b/apps/api/src/persistence/notifications/NotificationsPersistenceTokens.ts new file mode 100644 index 000000000..de09b3d6d --- /dev/null +++ b/apps/api/src/persistence/notifications/NotificationsPersistenceTokens.ts @@ -0,0 +1,4 @@ +export const NOTIFICATION_REPOSITORY_TOKEN = 'INotificationRepository'; +export const NOTIFICATION_PREFERENCE_REPOSITORY_TOKEN = 'INotificationPreferenceRepository'; +export const NOTIFICATION_SERVICE_TOKEN = 'INotificationService'; +export const NOTIFICATION_GATEWAY_REGISTRY_TOKEN = 'INotificationGatewayRegistry'; \ No newline at end of file diff --git a/apps/api/src/persistence/postgres/PostgresAchievementPersistenceModule.ts b/apps/api/src/persistence/postgres/PostgresAchievementPersistenceModule.ts new file mode 100644 index 000000000..ba9adfc6a --- /dev/null +++ b/apps/api/src/persistence/postgres/PostgresAchievementPersistenceModule.ts @@ -0,0 +1,96 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule, getDataSourceToken } from '@nestjs/typeorm'; +import type { DataSource } from 'typeorm'; + +import { AchievementOrmEntity } from '@adapters/achievement/persistence/typeorm/entities/AchievementOrmEntity'; +import { UserAchievementOrmEntity } from '@adapters/achievement/persistence/typeorm/entities/UserAchievementOrmEntity'; +import { TypeOrmAchievementRepository } from '@adapters/achievement/persistence/typeorm/repositories/TypeOrmAchievementRepository'; +import { AchievementOrmMapper } from '@adapters/achievement/persistence/typeorm/mappers/AchievementOrmMapper'; + +import { ACHIEVEMENT_REPOSITORY_TOKEN } from '../achievement/AchievementPersistenceTokens'; +import { Achievement, AchievementCategory } from '@core/identity/domain/entities/Achievement'; +import { UserAchievement } from '@core/identity/domain/entities/UserAchievement'; + +// Adapter to convert between domain repository interface and application use case interface +class AchievementRepositoryAdapter { + constructor(private readonly typeOrmRepo: TypeOrmAchievementRepository) {} + + // Application use case interface methods + async save(achievement: Achievement): Promise { + await this.typeOrmRepo.createAchievement(achievement); + } + + async findById(id: string): Promise { + return await this.typeOrmRepo.findAchievementById(id); + } + + // Delegate all other methods to the underlying TypeOrm repository + async findAchievementById(id: string): Promise { + return await this.typeOrmRepo.findAchievementById(id); + } + + async findAllAchievements(): Promise { + return await this.typeOrmRepo.findAllAchievements(); + } + + async findAchievementsByCategory(category: AchievementCategory): Promise { + return await this.typeOrmRepo.findAchievementsByCategory(category); + } + + async createAchievement(achievement: Achievement): Promise { + return await this.typeOrmRepo.createAchievement(achievement); + } + + async findUserAchievementById(id: string): Promise { + return await this.typeOrmRepo.findUserAchievementById(id); + } + + async findUserAchievementsByUserId(userId: string): Promise { + return await this.typeOrmRepo.findUserAchievementsByUserId(userId); + } + + async findUserAchievementByUserAndAchievement(userId: string, achievementId: string): Promise { + return await this.typeOrmRepo.findUserAchievementByUserAndAchievement(userId, achievementId); + } + + async hasUserEarnedAchievement(userId: string, achievementId: string): Promise { + return await this.typeOrmRepo.hasUserEarnedAchievement(userId, achievementId); + } + + async createUserAchievement(userAchievement: UserAchievement): Promise { + return await this.typeOrmRepo.createUserAchievement(userAchievement); + } + + async updateUserAchievement(userAchievement: UserAchievement): Promise { + return await this.typeOrmRepo.updateUserAchievement(userAchievement); + } + + async getAchievementLeaderboard(limit: number): Promise<{ userId: string; points: number; count: number }[]> { + return await this.typeOrmRepo.getAchievementLeaderboard(limit); + } + + async getUserAchievementStats(userId: string): Promise<{ + total: number; + points: number; + byCategory: Record; + }> { + return await this.typeOrmRepo.getUserAchievementStats(userId); + } +} + +const typeOrmFeatureImports = [TypeOrmModule.forFeature([AchievementOrmEntity, UserAchievementOrmEntity])]; + +@Module({ + imports: [...typeOrmFeatureImports], + providers: [ + { provide: AchievementOrmMapper, useFactory: () => new AchievementOrmMapper() }, + { + provide: ACHIEVEMENT_REPOSITORY_TOKEN, + useFactory: (dataSource: DataSource, mapper: AchievementOrmMapper) => + new AchievementRepositoryAdapter(new TypeOrmAchievementRepository(dataSource, mapper)), + inject: [getDataSourceToken(), AchievementOrmMapper], + }, + ], + exports: [ACHIEVEMENT_REPOSITORY_TOKEN], +}) +export class PostgresAchievementPersistenceModule {} \ No newline at end of file diff --git a/apps/api/src/persistence/postgres/PostgresIdentityPersistenceModule.ts b/apps/api/src/persistence/postgres/PostgresIdentityPersistenceModule.ts index 17cc2376e..c9a4fd798 100644 --- a/apps/api/src/persistence/postgres/PostgresIdentityPersistenceModule.ts +++ b/apps/api/src/persistence/postgres/PostgresIdentityPersistenceModule.ts @@ -3,8 +3,8 @@ 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 { TypeOrmAuthRepository } from '@adapters/identity/persistence/typeorm/repositories/TypeOrmAuthRepository'; +import { TypeOrmUserRepository } from '@adapters/identity/persistence/typeorm/repositories/TypeOrmUserRepository'; import { UserOrmMapper } from '@adapters/identity/persistence/typeorm/mappers/UserOrmMapper'; import { InMemoryPasswordHashingService } from '@adapters/identity/services/InMemoryPasswordHashingService'; diff --git a/apps/api/src/persistence/postgres/PostgresMediaPersistenceModule.ts b/apps/api/src/persistence/postgres/PostgresMediaPersistenceModule.ts new file mode 100644 index 000000000..6173c7904 --- /dev/null +++ b/apps/api/src/persistence/postgres/PostgresMediaPersistenceModule.ts @@ -0,0 +1,56 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule, getDataSourceToken } from '@nestjs/typeorm'; +import type { DataSource } from 'typeorm'; + +import { MediaOrmEntity } from '@adapters/media/persistence/typeorm/entities/MediaOrmEntity'; +import { AvatarOrmEntity } from '@adapters/media/persistence/typeorm/entities/AvatarOrmEntity'; +import { AvatarGenerationRequestOrmEntity } from '@adapters/media/persistence/typeorm/entities/AvatarGenerationRequestOrmEntity'; + +import { TypeOrmMediaRepository } from '@adapters/media/persistence/typeorm/repositories/TypeOrmMediaRepository'; +import { TypeOrmAvatarRepository } from '@adapters/media/persistence/typeorm/repositories/TypeOrmAvatarRepository'; +import { TypeOrmAvatarGenerationRepository } from '@adapters/media/persistence/typeorm/repositories/TypeOrmAvatarGenerationRepository'; + +import { MediaOrmMapper } from '@adapters/media/persistence/typeorm/mappers/MediaOrmMapper'; +import { AvatarOrmMapper } from '@adapters/media/persistence/typeorm/mappers/AvatarOrmMapper'; +import { AvatarGenerationRequestOrmMapper } from '@adapters/media/persistence/typeorm/mappers/AvatarGenerationRequestOrmMapper'; + +import { + AVATAR_GENERATION_REPOSITORY_TOKEN, + MEDIA_REPOSITORY_TOKEN, + AVATAR_REPOSITORY_TOKEN, +} from '../media/MediaPersistenceTokens'; + +const typeOrmFeatureImports = [ + TypeOrmModule.forFeature([ + MediaOrmEntity, + AvatarOrmEntity, + AvatarGenerationRequestOrmEntity, + ]), +]; + +@Module({ + imports: [...typeOrmFeatureImports], + providers: [ + { provide: MediaOrmMapper, useFactory: () => new MediaOrmMapper() }, + { provide: AvatarOrmMapper, useFactory: () => new AvatarOrmMapper() }, + { provide: AvatarGenerationRequestOrmMapper, useFactory: () => new AvatarGenerationRequestOrmMapper() }, + { + provide: MEDIA_REPOSITORY_TOKEN, + useFactory: (dataSource: DataSource, mapper: MediaOrmMapper) => new TypeOrmMediaRepository(dataSource, mapper), + inject: [getDataSourceToken(), MediaOrmMapper], + }, + { + provide: AVATAR_REPOSITORY_TOKEN, + useFactory: (dataSource: DataSource, mapper: AvatarOrmMapper) => new TypeOrmAvatarRepository(dataSource, mapper), + inject: [getDataSourceToken(), AvatarOrmMapper], + }, + { + provide: AVATAR_GENERATION_REPOSITORY_TOKEN, + useFactory: (dataSource: DataSource, mapper: AvatarGenerationRequestOrmMapper) => + new TypeOrmAvatarGenerationRepository(dataSource, mapper), + inject: [getDataSourceToken(), AvatarGenerationRequestOrmMapper], + }, + ], + exports: [AVATAR_GENERATION_REPOSITORY_TOKEN, MEDIA_REPOSITORY_TOKEN, AVATAR_REPOSITORY_TOKEN], +}) +export class PostgresMediaPersistenceModule {} diff --git a/apps/api/src/persistence/postgres/PostgresNotificationsPersistenceModule.ts b/apps/api/src/persistence/postgres/PostgresNotificationsPersistenceModule.ts new file mode 100644 index 000000000..d098aa68a --- /dev/null +++ b/apps/api/src/persistence/postgres/PostgresNotificationsPersistenceModule.ts @@ -0,0 +1,82 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule, getDataSourceToken } from '@nestjs/typeorm'; +import type { DataSource } from 'typeorm'; + +import type { Logger } from '@core/shared/application/Logger'; +import type { NotificationService } from '@core/notifications/application/ports/NotificationService'; +import type { NotificationGatewayRegistry } from '@core/notifications/application/ports/NotificationGateway'; +import type { INotificationRepository } from '@core/notifications/domain/repositories/INotificationRepository'; +import type { INotificationPreferenceRepository } from '@core/notifications/domain/repositories/INotificationPreferenceRepository'; + +import { NotificationOrmEntity } from '@adapters/notifications/persistence/typeorm/entities/NotificationOrmEntity'; +import { NotificationPreferenceOrmEntity } from '@adapters/notifications/persistence/typeorm/entities/NotificationPreferenceOrmEntity'; + +import { TypeOrmNotificationRepository } from '@adapters/notifications/persistence/typeorm/repositories/TypeOrmNotificationRepository'; +import { TypeOrmNotificationPreferenceRepository } from '@adapters/notifications/persistence/typeorm/repositories/TypeOrmNotificationPreferenceRepository'; + +import { NotificationOrmMapper } from '@adapters/notifications/persistence/typeorm/mappers/NotificationOrmMapper'; +import { NotificationPreferenceOrmMapper } from '@adapters/notifications/persistence/typeorm/mappers/NotificationPreferenceOrmMapper'; + +import { NotificationServiceAdapter } from '@adapters/notifications/ports/NotificationServiceAdapter'; +import { InMemoryNotificationGatewayRegistry } from '@adapters/notifications/ports/InMemoryNotificationGatewayRegistry'; + +import { NOTIFICATION_REPOSITORY_TOKEN, NOTIFICATION_PREFERENCE_REPOSITORY_TOKEN } from '../notifications/NotificationsPersistenceTokens'; + +export const NOTIFICATION_SERVICE_TOKEN = 'INotificationService'; +export const NOTIFICATION_GATEWAY_REGISTRY_TOKEN = 'INotificationGatewayRegistry'; + +const typeOrmFeatureImports = [ + TypeOrmModule.forFeature([ + NotificationOrmEntity, + NotificationPreferenceOrmEntity, + ]), +]; + +@Module({ + imports: [...typeOrmFeatureImports], + providers: [ + { provide: NotificationOrmMapper, useFactory: () => new NotificationOrmMapper() }, + { provide: NotificationPreferenceOrmMapper, useFactory: () => new NotificationPreferenceOrmMapper() }, + { + provide: NOTIFICATION_REPOSITORY_TOKEN, + useFactory: (dataSource: DataSource, mapper: NotificationOrmMapper) => + new TypeOrmNotificationRepository(dataSource, mapper), + inject: [getDataSourceToken(), NotificationOrmMapper], + }, + { + provide: NOTIFICATION_PREFERENCE_REPOSITORY_TOKEN, + useFactory: (dataSource: DataSource, mapper: NotificationPreferenceOrmMapper) => + new TypeOrmNotificationPreferenceRepository(dataSource, mapper), + inject: [getDataSourceToken(), NotificationPreferenceOrmMapper], + }, + { + provide: NOTIFICATION_GATEWAY_REGISTRY_TOKEN, + useFactory: (): NotificationGatewayRegistry => + new InMemoryNotificationGatewayRegistry(), + inject: [], + }, + { + provide: NOTIFICATION_SERVICE_TOKEN, + useFactory: ( + notificationRepo: INotificationRepository, + preferenceRepo: INotificationPreferenceRepository, + gatewayRegistry: NotificationGatewayRegistry, + logger: Logger, + ): NotificationService => + new NotificationServiceAdapter(notificationRepo, preferenceRepo, gatewayRegistry, logger), + inject: [ + NOTIFICATION_REPOSITORY_TOKEN, + NOTIFICATION_PREFERENCE_REPOSITORY_TOKEN, + NOTIFICATION_GATEWAY_REGISTRY_TOKEN, + 'Logger', + ], + }, + ], + exports: [ + NOTIFICATION_REPOSITORY_TOKEN, + NOTIFICATION_PREFERENCE_REPOSITORY_TOKEN, + NOTIFICATION_SERVICE_TOKEN, + NOTIFICATION_GATEWAY_REGISTRY_TOKEN, + ], +}) +export class PostgresNotificationsPersistenceModule {} \ 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 index 221e3caf0..b4b00e137 100644 --- a/apps/api/src/persistence/postgres/typeorm/PostgresIdentityUserRepository.int.test.ts +++ b/apps/api/src/persistence/postgres/typeorm/PostgresIdentityUserRepository.int.test.ts @@ -9,8 +9,8 @@ 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 { TypeOrmAuthRepository } from '@adapters/identity/persistence/typeorm/repositories/TypeOrmAuthRepository'; +import { TypeOrmUserRepository } from '@adapters/identity/persistence/typeorm/repositories/TypeOrmUserRepository'; import { UserOrmMapper } from '@adapters/identity/persistence/typeorm/mappers/UserOrmMapper'; const databaseUrl = process.env.DATABASE_URL;