feat(ADM-018): completed feature
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type pg from 'pg';
|
||||
import { z } from 'zod';
|
||||
import type { Authenticate } from '../../../shared/auth.js';
|
||||
import { requireRole } from '../../../shared/auth.js';
|
||||
import { parseJson } from '../../../shared/http-input.js';
|
||||
import { NotificationsService } from '../application/notifications-service.js';
|
||||
import { PgNotificationsRepository } from '../infrastructure/pg-notifications-repository.js';
|
||||
|
||||
export interface NotificationsRoutesDeps {
|
||||
pool: pg.Pool;
|
||||
authenticate: Authenticate;
|
||||
emailProvider: import('../domain/ports.js').EmailProvider;
|
||||
}
|
||||
|
||||
const dispatchSchema = z
|
||||
.object({
|
||||
eventId: z.string().min(1).max(120),
|
||||
template: z.enum(['order_confirmation', 'payment_failed', 'order_shipped']),
|
||||
recipient: z.string().email(),
|
||||
subject: z.string().min(1).max(200),
|
||||
body: z.string().min(1).max(5000),
|
||||
})
|
||||
.strip();
|
||||
|
||||
export async function registerNotificationsRoutes(
|
||||
app: FastifyInstance,
|
||||
deps: NotificationsRoutesDeps,
|
||||
): Promise<void> {
|
||||
const service = new NotificationsService(
|
||||
new PgNotificationsRepository(deps.pool),
|
||||
deps.emailProvider,
|
||||
);
|
||||
|
||||
app.post('/notifications/dispatch', async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const input = parseJson(dispatchSchema, request.body);
|
||||
const outcome = await service.dispatch(input);
|
||||
return reply.send({ outcome });
|
||||
});
|
||||
|
||||
app.get('/notifications/messages/:eventId', async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const params = z.object({ eventId: z.string().min(1).max(120) }).parse(request.params);
|
||||
const repo = new PgNotificationsRepository(deps.pool);
|
||||
const found = await repo.findByEventId(params.eventId);
|
||||
if (!found)
|
||||
throw new (await import('../../../shared/errors.js')).AppError(
|
||||
404,
|
||||
'NOT_FOUND',
|
||||
'Message not found',
|
||||
);
|
||||
return reply.send(found);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { NotificationsRepository, EmailProvider } from '../domain/ports.js';
|
||||
import type { EmailMessage } from '../domain/notification.js';
|
||||
|
||||
export type DispatchOutcome = 'sent' | 'duplicate';
|
||||
|
||||
export class NotificationsService {
|
||||
constructor(
|
||||
private readonly repo: NotificationsRepository,
|
||||
private readonly provider: EmailProvider,
|
||||
) {}
|
||||
|
||||
async dispatch(message: EmailMessage): Promise<DispatchOutcome> {
|
||||
const existing = await this.repo.findByEventId(message.eventId);
|
||||
if (existing) return 'duplicate';
|
||||
const result = await this.repo.record(message);
|
||||
if (!result.created) return 'duplicate';
|
||||
await this.provider.send(message);
|
||||
return 'sent';
|
||||
}
|
||||
}
|
||||
9
project/src/modules/notifications/domain/notification.ts
Normal file
9
project/src/modules/notifications/domain/notification.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export type EmailTemplate = 'order_confirmation' | 'payment_failed' | 'order_shipped';
|
||||
|
||||
export interface EmailMessage {
|
||||
eventId: string;
|
||||
template: EmailTemplate;
|
||||
recipient: string;
|
||||
subject: string;
|
||||
body: string;
|
||||
}
|
||||
12
project/src/modules/notifications/domain/ports.ts
Normal file
12
project/src/modules/notifications/domain/ports.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { EmailMessage } from './notification.js';
|
||||
|
||||
export interface EmailProvider {
|
||||
send(message: EmailMessage): Promise<void>;
|
||||
}
|
||||
|
||||
export interface NotificationsRepository {
|
||||
findByEventId(
|
||||
eventId: string,
|
||||
): Promise<{ template: string; recipient: string; status: string } | undefined>;
|
||||
record(message: EmailMessage): Promise<{ created: boolean }>;
|
||||
}
|
||||
10
project/src/modules/notifications/index.ts
Normal file
10
project/src/modules/notifications/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/** Public API of the notifications module. */
|
||||
export {
|
||||
registerNotificationsRoutes,
|
||||
type NotificationsRoutesDeps,
|
||||
} from './api/notifications.routes.js';
|
||||
export { NotificationsService } from './application/notifications-service.js';
|
||||
export { LoggingEmailProvider } from './infrastructure/log-email-provider.js';
|
||||
export { PgNotificationsRepository } from './infrastructure/pg-notifications-repository.js';
|
||||
export type { NotificationsRepository, EmailProvider } from './domain/ports.js';
|
||||
export type { EmailMessage, EmailTemplate } from './domain/notification.js';
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { EmailMessage, EmailTemplate } from '../domain/notification.js';
|
||||
import type { EmailProvider } from '../domain/ports.js';
|
||||
|
||||
const SUBJECTS: Record<EmailTemplate, string> = {
|
||||
order_confirmation: 'Tu pedido ha sido confirmado',
|
||||
payment_failed: 'Hubo un problema con tu pago',
|
||||
order_shipped: 'Tu pedido ha sido enviado',
|
||||
};
|
||||
|
||||
const BODIES: Record<EmailTemplate, string> = {
|
||||
order_confirmation: 'Hemos recibido tu pago y estamos preparando tu pedido.',
|
||||
payment_failed: 'No pudimos procesar tu pago. Por favor, intenta de nuevo.',
|
||||
order_shipped: 'Tu pedido está en camino. Recibirás los datos de seguimiento pronto.',
|
||||
};
|
||||
|
||||
/** Logging email provider for v1: writes to destination stream; swap with real adapter later. */
|
||||
export class LoggingEmailProvider implements EmailProvider {
|
||||
constructor(private readonly log: (line: string) => void = () => undefined) {}
|
||||
|
||||
async send(message: EmailMessage): Promise<void> {
|
||||
const line = JSON.stringify({
|
||||
provider: 'log',
|
||||
template: message.template,
|
||||
recipient: message.recipient,
|
||||
subject: message.subject || SUBJECTS[message.template],
|
||||
body: message.body || BODIES[message.template],
|
||||
eventId: message.eventId,
|
||||
});
|
||||
this.log(line);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type pg from 'pg';
|
||||
import type { NotificationsRepository } from '../domain/ports.js';
|
||||
import type { EmailMessage } from '../domain/notification.js';
|
||||
|
||||
interface MessageRow {
|
||||
event_id: string;
|
||||
template: string;
|
||||
recipient: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export class PgNotificationsRepository implements NotificationsRepository {
|
||||
constructor(private readonly pool: pg.Pool) {}
|
||||
|
||||
async findByEventId(
|
||||
eventId: string,
|
||||
): Promise<{ template: string; recipient: string; status: string } | undefined> {
|
||||
const result = await this.pool.query<MessageRow>(
|
||||
'SELECT event_id, template, recipient, status FROM notifications_messages WHERE event_id = $1',
|
||||
[eventId],
|
||||
);
|
||||
const row = result.rows[0];
|
||||
return row
|
||||
? { template: row.template, recipient: row.recipient, status: row.status }
|
||||
: undefined;
|
||||
}
|
||||
|
||||
async record(message: EmailMessage): Promise<{ created: boolean }> {
|
||||
const result = await this.pool.query(
|
||||
`INSERT INTO notifications_messages (event_id, template, recipient, subject, body)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (event_id) DO NOTHING
|
||||
RETURNING id`,
|
||||
[message.eventId, message.template, message.recipient, message.subject, message.body],
|
||||
);
|
||||
return { created: (result.rowCount ?? 0) > 0 };
|
||||
}
|
||||
}
|
||||
23
project/src/modules/notifications/tests/boundary.test.ts
Normal file
23
project/src/modules/notifications/tests/boundary.test.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
function sourceFiles(dir: string): string[] {
|
||||
return readdirSync(dir).flatMap((entry) => {
|
||||
const path = join(dir, entry);
|
||||
return statSync(path).isDirectory() ? sourceFiles(path) : path.endsWith('.ts') ? [path] : [];
|
||||
});
|
||||
}
|
||||
|
||||
describe('notifications swappability', () => {
|
||||
it('only infrastructure touches the email provider implementation', () => {
|
||||
const dir = new URL('..', import.meta.url);
|
||||
for (const file of sourceFiles(dir.pathname)) {
|
||||
if (!file.endsWith('.ts') || file.endsWith('boundary.test.ts')) continue;
|
||||
if (file.includes('/infrastructure/') || file.includes('/infrastructure')) continue;
|
||||
if (file.endsWith('/index.ts')) continue;
|
||||
const source = readFileSync(file, 'utf8');
|
||||
expect(source).not.toMatch(/LoggingEmailProvider|emailProvider\.send/);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { NotificationsService } from '../application/notifications-service.js';
|
||||
import type { EmailProvider, NotificationsRepository } from '../domain/ports.js';
|
||||
import type { EmailMessage } from '../domain/notification.js';
|
||||
|
||||
function repo(overrides: Partial<NotificationsRepository> = {}): NotificationsRepository {
|
||||
return {
|
||||
findByEventId: async () => undefined,
|
||||
record: async () => ({ created: true }),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function provider(sent: EmailMessage[]): EmailProvider {
|
||||
return {
|
||||
send: async (message) => {
|
||||
sent.push(message);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('NotificationsService', () => {
|
||||
it('sends the email on first event and duplicate on replay (AC1/AC2)', async () => {
|
||||
const sent: EmailMessage[] = [];
|
||||
const repository: NotificationsRepository = {
|
||||
findByEventId: async () => undefined,
|
||||
record: async () => ({ created: true }),
|
||||
};
|
||||
const service = new NotificationsService(repository, provider(sent));
|
||||
const message: EmailMessage = {
|
||||
eventId: 'evt-1',
|
||||
template: 'order_confirmation',
|
||||
recipient: 'a@example.com',
|
||||
subject: 'subject',
|
||||
body: 'body',
|
||||
};
|
||||
const first = await service.dispatch(message);
|
||||
expect(first).toBe('sent');
|
||||
// Now simulate replay: findByEventId returns a record.
|
||||
const replayService = new NotificationsService(
|
||||
{
|
||||
findByEventId: async () => ({
|
||||
template: message.template,
|
||||
recipient: message.recipient,
|
||||
status: 'sent',
|
||||
}),
|
||||
record: async () => ({ created: true }),
|
||||
},
|
||||
provider(sent),
|
||||
);
|
||||
const second = await replayService.dispatch(message);
|
||||
expect(second).toBe('duplicate');
|
||||
expect(sent).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('returns duplicate when repository record returns created=false', async () => {
|
||||
const sent: EmailMessage[] = [];
|
||||
const service = new NotificationsService(
|
||||
repo({ record: async () => ({ created: false }) }),
|
||||
provider(sent),
|
||||
);
|
||||
const outcome = await service.dispatch({
|
||||
eventId: 'evt-2',
|
||||
template: 'payment_failed',
|
||||
recipient: 'b@example.com',
|
||||
subject: 's',
|
||||
body: 'b',
|
||||
});
|
||||
expect(outcome).toBe('duplicate');
|
||||
expect(sent).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user