inmemory to postgres
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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) },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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() },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
48
adapters/notifications/ports/NotificationServiceAdapter.ts
Normal file
48
adapters/notifications/ports/NotificationServiceAdapter.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user