feat(F-171): completed feature

This commit is contained in:
chattie
2026-08-22 19:20:34 +02:00
parent 56eee88315
commit 2985d7274a
14 changed files with 113 additions and 26 deletions

View File

@@ -41,7 +41,7 @@ import { PgPaymentMethodRepository } from '../modules/pos/infrastructure/pg-paym
import { PgCashSessionRepository } from '../modules/pos/infrastructure/pg-cash-session-repository.js';
import { registerCheckoutRoutes } from '../modules/checkout/index.js';
import { registerPaymentsRoutes } from '../modules/payments/index.js';
import { registerNotificationsRoutes } from '../modules/notifications/index.js';
import { registerNotificationsRoutes, SettingsEmailProvider } from '../modules/notifications/index.js';
import { registerReportingRoutes } from '../modules/reporting/index.js';
import { registerPosRoutes } from '../modules/pos/api/pos.routes.js';
import { registerReviewsRoutes } from '../modules/reviews/index.js';
@@ -52,7 +52,6 @@ import { AuditLogger, RateLimiter, registerSecurityRoutes } from '../modules/sec
import { getLogBroadcaster } from '../infrastructure/logging/log-broadcaster.js';
import { registerAdminStatsRoutes } from '../modules/admin-stats/index.js';
import { createInMemoryTelemetry, registerMetricsRoutes } from '../modules/observability/index.js';
import { LoggingEmailProvider } from '../modules/notifications/index.js';
import { createFlagStore, type FeatureFlagProvider } from '../modules/flags/index.js';
import { AppError, errorEnvelope } from '../shared/errors.js';
import { createLogger, type Logger } from '../infrastructure/logging/logger.js';
@@ -362,7 +361,7 @@ export async function buildApp(deps: BuildAppDeps = {}): Promise<FastifyInstance
await registerNotificationsRoutes(instance, {
pool: deps.pool as pg.Pool,
authenticate: combinedAuth,
emailProvider: new LoggingEmailProvider(),
emailProvider: new SettingsEmailProvider(deps.pool as pg.Pool),
});
});

View File

@@ -5,6 +5,11 @@ export {
} from './api/notifications.routes.js';
export { NotificationsService } from './application/notifications-service.js';
export { LoggingEmailProvider } from './infrastructure/log-email-provider.js';
export {
SettingsEmailProvider,
sendTransactionalEmail,
type TransactionalEmail,
} from './infrastructure/settings-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';

View File

@@ -0,0 +1,58 @@
import nodemailer from 'nodemailer';
import type pg from 'pg';
import type { EmailMessage } from '../domain/notification.js';
import type { EmailProvider } from '../domain/ports.js';
const SMTP_KEYS = ['smtp_host', 'smtp_port', 'smtp_secure', 'smtp_user', 'smtp_pass', 'smtp_from'] as const;
export interface TransactionalEmail {
to: string;
subject: string;
text: string;
html?: string;
}
export async function sendTransactionalEmail(pool: pg.Pool, message: TransactionalEmail): Promise<void> {
const result = await pool.query<{ key: string; value: string }>(
'SELECT key, value FROM store_settings WHERE key = ANY($1::text[])',
[SMTP_KEYS],
);
const settings = Object.fromEntries(result.rows.map((row) => [row.key, row.value]));
const host = settings.smtp_host?.trim() || process.env.SMTP_HOST?.trim();
const user = settings.smtp_user?.trim() || process.env.SMTP_USER?.trim();
const password = settings.smtp_pass || process.env.SMTP_PASS;
const from = settings.smtp_from?.trim() || process.env.SMTP_FROM?.trim() || user;
if (!host || !user || !password || !from) {
throw new Error('SMTP is not configured in Ajustes → SMTP / Email');
}
const port = Number(settings.smtp_port || process.env.SMTP_PORT || '465');
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error('SMTP port is invalid in Ajustes → SMTP / Email');
}
const transporter = nodemailer.createTransport({
host,
port,
secure: (settings.smtp_secure ?? process.env.SMTP_SECURE) !== 'false' || port === 465,
auth: { user, pass: password },
});
await transporter.sendMail({ from, ...message });
}
export class SettingsEmailProvider implements EmailProvider {
constructor(private readonly pool: pg.Pool) {}
async send(message: EmailMessage): Promise<void> {
await sendTransactionalEmail(this.pool, {
to: message.recipient,
subject: message.subject,
text: message.body,
html: `<p>${escapeHtml(message.body).replace(/\n/g, '<br>')}</p>`,
});
}
}
function escapeHtml(value: string): string {
return value.replace(/[&<>"']/g, (character) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
})[character] ?? character);
}

View File

@@ -18,6 +18,7 @@ import {
type OrderServicePort,
type OrderState,
} from '../../orders/index.js';
import { sendTransactionalEmail } from '../../notifications/index.js';
export interface PaymentsRoutesDeps {
pool: pg.Pool;
@@ -124,22 +125,31 @@ export async function registerPaymentsRoutes(
const outcome = await service.handleWebhook(event);
// F-152: notify the customer when their payment succeeds (order confirmation).
// Best-effort: a delivery failure is logged and never blocks the webhook.
if (event.type === 'PaymentSucceeded' && event.orderId && outcome.kind === 'processed') {
if (event.orderId && outcome.kind === 'processed' && (event.type === 'PaymentSucceeded' || event.type === 'PaymentFailed')) {
try {
const emailRow = await deps.pool.query<{ email: string }>(
`SELECT u.email FROM orders_orders o JOIN identity_users u ON u.id = o.user_id WHERE o.id = $1`,
[event.orderId],
);
const to = emailRow.rows[0]?.email;
if (to) {
if (to && event.type === 'PaymentSucceeded') {
await sendOrderStatusEmail(deps.pool, {
to,
orderId: event.orderId,
state: 'PAID',
});
}
if (to && event.type === 'PaymentFailed') {
const shortId = event.orderId.slice(0, 8);
await sendTransactionalEmail(deps.pool, {
to,
subject: `No se pudo procesar el pago del pedido #${shortId} — Mercado de Vida`,
text: `No se pudo procesar el pago de tu pedido #${shortId}. Revisa el método de pago e inténtalo de nuevo desde tu cuenta.`,
html: `<p>No se pudo procesar el pago de tu pedido <strong>#${shortId}</strong>.</p><p>Revisa el método de pago e inténtalo de nuevo desde tu cuenta.</p>`,
});
}
} catch (error) {
request.log.warn({ err: error, orderId: event.orderId }, 'order_confirmation_email_failed');
request.log.warn({ err: error, orderId: event.orderId }, 'transactional_payment_email_failed');
}
}
return reply.send({ result: outcome.kind });