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