inmemory to postgres

This commit is contained in:
2025-12-29 20:50:03 +01:00
parent 12ae6e1dad
commit 3f610c1cb6
64 changed files with 3689 additions and 63 deletions

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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 });
}
}
}

View File

@@ -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<Achievement | null> {
const repo = this.dataSource.getRepository(AchievementOrmEntity);
const entity = await repo.findOne({ where: { id } });
return entity ? this.mapper.toDomain(entity) : null;
}
async findAllAchievements(): Promise<Achievement[]> {
const repo = this.dataSource.getRepository(AchievementOrmEntity);
const entities = await repo.find();
return entities.map((e) => this.mapper.toDomain(e));
}
async findAchievementsByCategory(category: AchievementCategory): Promise<Achievement[]> {
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<Achievement> {
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<UserAchievement | null> {
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<UserAchievement[]> {
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<UserAchievement | null> {
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<boolean> {
const ua = await this.findUserAchievementByUserAndAchievement(userId, achievementId);
return ua !== null && ua.isComplete();
}
async createUserAchievement(userAchievement: UserAchievement): Promise<UserAchievement> {
const repo = this.dataSource.getRepository(UserAchievementOrmEntity);
const entity = this.mapper.toUserAchievementOrmEntity(userAchievement);
await repo.save(entity);
return userAchievement;
}
async updateUserAchievement(userAchievement: UserAchievement): Promise<UserAchievement> {
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<string, { points: number; count: number }>();
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<AchievementCategory, number>;
}> {
const userAchievementRepo = this.dataSource.getRepository(UserAchievementOrmEntity);
const achievementRepo = this.dataSource.getRepository(AchievementOrmEntity);
const userAchievements = await userAchievementRepo.find({
where: { userId, progress: 100 },
});
const byCategory: Record<AchievementCategory, number> = {
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,
};
}
}

View File

@@ -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<TAllowed extends string>(
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<string, unknown> {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
throw new TypeOrmPersistenceSchemaError({ entityName, fieldName, reason: 'not_object' });
}
}

View File

@@ -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');

View File

@@ -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<UserOrmEntity>;
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();
});
});

View File

@@ -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(

View File

@@ -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<UserOrmEntity>;
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');

View File

@@ -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(

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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<string, unknown> | null;
}

View File

@@ -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;
}
}

View File

@@ -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();
});
});

View File

@@ -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);
}
}

View File

@@ -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);
});
});

View File

@@ -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);
}
}

View File

@@ -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();
});
});

View File

@@ -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<string, unknown>;
}
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);
}
}

View File

@@ -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' });
});
});

View File

@@ -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<void> {
const repo = this.dataSource.getRepository(AvatarGenerationRequestOrmEntity);
const entity = this.mapper.toOrmEntity(request);
await repo.save(entity);
}
async findById(id: string): Promise<AvatarGenerationRequest | null> {
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<AvatarGenerationRequest[]> {
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<AvatarGenerationRequest | null> {
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<void> {
const repo = this.dataSource.getRepository(AvatarGenerationRequestOrmEntity);
await repo.delete({ id });
}
}

View File

@@ -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' });
});
});

View File

@@ -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<void> {
const repo = this.dataSource.getRepository(AvatarOrmEntity);
const entity = this.mapper.toOrmEntity(avatar);
await repo.save(entity);
}
async findById(id: string): Promise<Avatar | null> {
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<Avatar | null> {
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<Avatar[]> {
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<void> {
const repo = this.dataSource.getRepository(AvatarOrmEntity);
await repo.delete({ id });
}
}

View File

@@ -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' });
});
});

View File

@@ -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<void> {
const repo = this.dataSource.getRepository(MediaOrmEntity);
const entity = this.mapper.toOrmEntity(media);
await repo.save(entity);
}
async findById(id: string): Promise<Media | null> {
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<Media[]> {
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<void> {
const repo = this.dataSource.getRepository(MediaOrmEntity);
await repo.delete({ id });
}
}

View File

@@ -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' });
}
}

View File

@@ -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<string, unknown> | 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;
}

View File

@@ -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<string, string> };
email: { enabled: boolean; settings?: Record<string, string> };
discord: { enabled: boolean; settings?: Record<string, string> };
push: { enabled: boolean; settings?: Record<string, string> };
};
@Column({ type: 'jsonb', nullable: true })
typePreferences!: Record<string, { enabled: boolean; channels?: string[] }> | 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;
}

View File

@@ -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;
}
}

View File

@@ -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);
});
});

View File

@@ -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<string, unknown>;
}
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);
}
}

View File

@@ -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
});
});

View File

@@ -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);
}
}

View File

@@ -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
});
});

View File

@@ -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<NotificationPreference | null> {
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<void> {
const repo = this.dataSource.getRepository(NotificationPreferenceOrmEntity);
const entity = this.mapper.toOrmEntity(preference);
await repo.save(entity);
}
async delete(driverId: string): Promise<void> {
const repo = this.dataSource.getRepository(NotificationPreferenceOrmEntity);
await repo.delete({ driverId });
}
async getOrCreateDefault(driverId: string): Promise<NotificationPreference> {
const existing = await this.findByDriverId(driverId);
if (existing) {
return existing;
}
const defaultPrefs = NotificationPreference.createDefault(driverId);
await this.save(defaultPrefs);
return defaultPrefs;
}
}

View File

@@ -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) },
);
});
});

View File

@@ -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<Notification | null> {
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<Notification[]> {
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<Notification[]> {
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<Notification[]> {
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<number> {
const repo = this.dataSource.getRepository(NotificationOrmEntity);
return await repo.count({
where: { recipientId, status: 'unread' },
});
}
async create(notification: Notification): Promise<void> {
const repo = this.dataSource.getRepository(NotificationOrmEntity);
const entity = this.mapper.toOrmEntity(notification);
await repo.save(entity);
}
async update(notification: Notification): Promise<void> {
const repo = this.dataSource.getRepository(NotificationOrmEntity);
const entity = this.mapper.toOrmEntity(notification);
await repo.save(entity);
}
async delete(id: string): Promise<void> {
const repo = this.dataSource.getRepository(NotificationOrmEntity);
await repo.delete({ id });
}
async deleteAllByRecipientId(recipientId: string): Promise<void> {
const repo = this.dataSource.getRepository(NotificationOrmEntity);
await repo.delete({ recipientId });
}
async markAllAsReadByRecipientId(recipientId: string): Promise<void> {
const repo = this.dataSource.getRepository(NotificationOrmEntity);
await repo.update(
{ recipientId, status: 'unread' },
{ status: 'read', readAt: new Date() },
);
}
}

View File

@@ -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<string, unknown>;
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<string, unknown>;
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`,
});
}
}
}

View File

@@ -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<NotificationChannel, NotificationGateway> = 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<NotificationDeliveryResult> {
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(),
};
}
}
}

View File

@@ -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<any> {
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<void> {
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');
}
}
}
}

View File

@@ -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 {

View File

@@ -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),

View File

@@ -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],

View File

@@ -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<void> {}
async findById(): Promise<AvatarGenerationRequest | null> { return null; }
async findByUserId(): Promise<AvatarGenerationRequest[]> { return []; }
async findLatestByUserId(): Promise<AvatarGenerationRequest | null> { return null; }
async delete(): Promise<void> {}
}
class MockMediaRepository implements IMediaRepository {
async save(): Promise<void> {}
async findById(): Promise<Media | null> { return null; }
async findByUploadedBy(): Promise<Media[]> { return []; }
async delete(): Promise<void> {}
}
class MockAvatarRepository implements IAvatarRepository {
async save(): Promise<void> {}
async findById(): Promise<Avatar | null> { return null; }
async findActiveByDriverId(): Promise<Avatar | null> { return null; }
async findByDriverId(): Promise<Avatar[]> { return []; }
async delete(): Promise<void> {}
}
// External adapters (ports) - these remain mock implementations
class MockFaceValidationAdapter implements FaceValidationPort {
async validateFacePhoto(): Promise<FaceValidationResult> {
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,

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { NotificationsPersistenceModule } from '../../persistence/notifications/NotificationsPersistenceModule';
@Module({
imports: [NotificationsPersistenceModule],
exports: [NotificationsPersistenceModule],
})
export class NotificationsModule {}

View File

@@ -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<IAchievementRepository>(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<IAchievementRepository>(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<IAchievementRepository>(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');
});
});

View File

@@ -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 {}

View File

@@ -0,0 +1,2 @@
export const ACHIEVEMENT_REPOSITORY_TOKEN = 'IAchievementRepository';
export const USER_ACHIEVEMENT_REPOSITORY_TOKEN = 'IUserAchievementRepository';

View File

@@ -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<void> {
await this.inMemoryRepo.createAchievement(achievement);
}
async findById(id: string): Promise<Achievement | null> {
return await this.inMemoryRepo.findAchievementById(id);
}
// Delegate all other methods to the underlying in-memory repository
async findAchievementById(id: string): Promise<Achievement | null> {
return await this.inMemoryRepo.findAchievementById(id);
}
async findAllAchievements(): Promise<Achievement[]> {
return await this.inMemoryRepo.findAllAchievements();
}
async findAchievementsByCategory(category: AchievementCategory): Promise<Achievement[]> {
return await this.inMemoryRepo.findAchievementsByCategory(category);
}
async createAchievement(achievement: Achievement): Promise<Achievement> {
return await this.inMemoryRepo.createAchievement(achievement);
}
async findUserAchievementById(id: string): Promise<UserAchievement | null> {
return await this.inMemoryRepo.findUserAchievementById(id);
}
async findUserAchievementsByUserId(userId: string): Promise<UserAchievement[]> {
return await this.inMemoryRepo.findUserAchievementsByUserId(userId);
}
async findUserAchievementByUserAndAchievement(userId: string, achievementId: string): Promise<UserAchievement | null> {
return await this.inMemoryRepo.findUserAchievementByUserAndAchievement(userId, achievementId);
}
async hasUserEarnedAchievement(userId: string, achievementId: string): Promise<boolean> {
return await this.inMemoryRepo.hasUserEarnedAchievement(userId, achievementId);
}
async createUserAchievement(userAchievement: UserAchievement): Promise<UserAchievement> {
return await this.inMemoryRepo.createUserAchievement(userAchievement);
}
async updateUserAchievement(userAchievement: UserAchievement): Promise<UserAchievement> {
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<AchievementCategory, number>;
}> {
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 {}

View File

@@ -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<void> {}
async findById(): Promise<null> { return null; }
async findByUploadedBy(): Promise<[]> { return []; }
async delete(): Promise<void> {}
}
class MockAvatarRepository implements IAvatarRepository {
async save(): Promise<void> {}
async findById(): Promise<null> { return null; }
async findActiveByDriverId(): Promise<null> { return null; }
async findByDriverId(): Promise<[]> { return []; }
async delete(): Promise<void> {}
}
@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 {}

View File

@@ -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 {}

View File

@@ -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);
});
});

View File

@@ -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 {}

View File

@@ -0,0 +1,3 @@
export const AVATAR_GENERATION_REPOSITORY_TOKEN = 'IAvatarGenerationRepository';
export const MEDIA_REPOSITORY_TOKEN = 'IMediaRepository';
export const AVATAR_REPOSITORY_TOKEN = 'IAvatarRepository';

View File

@@ -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);
});
});

View File

@@ -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 {}

View File

@@ -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';

View File

@@ -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<void> {
await this.typeOrmRepo.createAchievement(achievement);
}
async findById(id: string): Promise<Achievement | null> {
return await this.typeOrmRepo.findAchievementById(id);
}
// Delegate all other methods to the underlying TypeOrm repository
async findAchievementById(id: string): Promise<Achievement | null> {
return await this.typeOrmRepo.findAchievementById(id);
}
async findAllAchievements(): Promise<Achievement[]> {
return await this.typeOrmRepo.findAllAchievements();
}
async findAchievementsByCategory(category: AchievementCategory): Promise<Achievement[]> {
return await this.typeOrmRepo.findAchievementsByCategory(category);
}
async createAchievement(achievement: Achievement): Promise<Achievement> {
return await this.typeOrmRepo.createAchievement(achievement);
}
async findUserAchievementById(id: string): Promise<UserAchievement | null> {
return await this.typeOrmRepo.findUserAchievementById(id);
}
async findUserAchievementsByUserId(userId: string): Promise<UserAchievement[]> {
return await this.typeOrmRepo.findUserAchievementsByUserId(userId);
}
async findUserAchievementByUserAndAchievement(userId: string, achievementId: string): Promise<UserAchievement | null> {
return await this.typeOrmRepo.findUserAchievementByUserAndAchievement(userId, achievementId);
}
async hasUserEarnedAchievement(userId: string, achievementId: string): Promise<boolean> {
return await this.typeOrmRepo.hasUserEarnedAchievement(userId, achievementId);
}
async createUserAchievement(userAchievement: UserAchievement): Promise<UserAchievement> {
return await this.typeOrmRepo.createUserAchievement(userAchievement);
}
async updateUserAchievement(userAchievement: UserAchievement): Promise<UserAchievement> {
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<AchievementCategory, number>;
}> {
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 {}

View File

@@ -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';

View File

@@ -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 {}

View File

@@ -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 {}

View File

@@ -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;