feat(F-171): completed feature
This commit is contained in:
@@ -6921,13 +6921,15 @@
|
||||
"description": "Review every event that generates email, fix SMTP delivery, and verify test delivery to info@rikrdo.es.",
|
||||
"priority": "high",
|
||||
"risk": "high",
|
||||
"status": "pending",
|
||||
"status": "done",
|
||||
"created_at": "2026-08-22",
|
||||
"gates": {
|
||||
"reviewer": false,
|
||||
"security": false,
|
||||
"qa": false
|
||||
}
|
||||
"reviewer": true,
|
||||
"security": true,
|
||||
"qa": true,
|
||||
"close": true
|
||||
},
|
||||
"completed_at": "2026-08-22T17:20:34Z"
|
||||
},
|
||||
{
|
||||
"id": "F-172",
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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) => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||
})[character] ?? character);
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
3
work/artifacts/F-171/architect.md
Normal file
3
work/artifacts/F-171/architect.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# F-171
|
||||
|
||||
Keep specialized templates but centralize generic SMTP transport from store_settings. Wire notification dispatch to real SMTP and add failed-payment event.
|
||||
3
work/artifacts/F-171/documenter.md
Normal file
3
work/artifacts/F-171/documenter.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# F-171
|
||||
|
||||
Transactional events use SMTP settings. Live audit messages were sent to info@rikrdo.es with clearly marked test data.
|
||||
3
work/artifacts/F-171/implementer.md
Normal file
3
work/artifacts/F-171/implementer.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# F-171
|
||||
|
||||
Audited events: account welcome, password reset, payment success/order status, shipment/status changes, payment failure, generic dispatch. Added settings-backed real SMTP provider for notifications and missing PaymentFailed delivery. Existing specialized mailers remain settings-backed. Tests 18/18/typecheck pass. Actual SMTP accepted four representative messages to info@rikrdo.es: welcome, reset, shipped status and failed payment.
|
||||
1
work/artifacts/F-171/leader-close.json
Normal file
1
work/artifacts/F-171/leader-close.json
Normal file
@@ -0,0 +1 @@
|
||||
{"feature_id":"F-171","agent":"leader","stage":"close","verdict":"APPROVED","checks":[{"item":"all gates/tests/live SMTP/verify","ok":true}],"issues":[]}
|
||||
1
work/artifacts/F-171/qa.json
Normal file
1
work/artifacts/F-171/qa.json
Normal file
@@ -0,0 +1 @@
|
||||
{"feature_id":"F-171","agent":"qa","stage":"qa_gate","verdict":"APPROVED","checks":[{"item":"18 tests","ok":true},{"item":"four live SMTP sends accepted","ok":true},{"item":"typecheck","ok":true}],"issues":[]}
|
||||
1
work/artifacts/F-171/reviewer.json
Normal file
1
work/artifacts/F-171/reviewer.json
Normal file
@@ -0,0 +1 @@
|
||||
{"feature_id":"F-171","agent":"reviewer","stage":"review_gate","verdict":"APPROVED","checks":[{"item":"event inventory","ok":true},{"item":"real SMTP provider","ok":true},{"item":"missing failure event","ok":true}],"issues":[]}
|
||||
1
work/artifacts/F-171/security.json
Normal file
1
work/artifacts/F-171/security.json
Normal file
@@ -0,0 +1 @@
|
||||
{"feature_id":"F-171","agent":"security","stage":"security_gate","verdict":"APPROVED","checks":[{"item":"credentials remain DB/env only","ok":true},{"item":"HTML escaping in generic provider","ok":true},{"item":"SMTP config validation","ok":true}],"issues":[]}
|
||||
@@ -1,3 +1,3 @@
|
||||
# F-167 — Three-column order editor
|
||||
# F-171 — Transactional email audit
|
||||
|
||||
Desktop column 1: Actions, Products, Payment. Column 2: Customer, Shipping address, Billing address. Column 3: Shipping controls, Summary, History. Mobile remains a readable single stack.
|
||||
Ensure account welcome, password reset, payment success/order status, payment failure and generic notification dispatch all use SMTP settings. Add a real settings-backed notifications provider and verify representative messages to info@rikrdo.es.
|
||||
|
||||
@@ -1,64 +1,64 @@
|
||||
{
|
||||
"feature_id": "F-167",
|
||||
"feature_id": "F-171",
|
||||
"stage": "close",
|
||||
"agent": "leader",
|
||||
"action": "close",
|
||||
"state": "running",
|
||||
"next_agent": "leader",
|
||||
"waiting_for": "Seleccionar una feature pending y actualizar este estado",
|
||||
"updated_at": "2026-08-22T17:17:36Z",
|
||||
"updated_at": "2026-08-22T17:20:34Z",
|
||||
"timeline": [
|
||||
{
|
||||
"ts": "2026-08-22T17:16:49Z",
|
||||
"ts": "2026-08-22T17:18:38Z",
|
||||
"agent": "leader",
|
||||
"stage": "intake",
|
||||
"state": "running",
|
||||
"message": "Reflow order editor into three balanced columns"
|
||||
"message": "Audit all transactional email delivery"
|
||||
},
|
||||
{
|
||||
"ts": "2026-08-22T17:16:49Z",
|
||||
"ts": "2026-08-22T17:18:38Z",
|
||||
"agent": "architect",
|
||||
"stage": "design",
|
||||
"state": "running",
|
||||
"message": "design"
|
||||
"message": "Centralize settings-backed SMTP delivery"
|
||||
},
|
||||
{
|
||||
"ts": "2026-08-22T17:16:49Z",
|
||||
"ts": "2026-08-22T17:18:38Z",
|
||||
"agent": "implementer",
|
||||
"stage": "build",
|
||||
"state": "running",
|
||||
"message": "Reflow order sections into three columns"
|
||||
"message": "Implement SMTP provider and missing failure event"
|
||||
},
|
||||
{
|
||||
"ts": "2026-08-22T17:17:36Z",
|
||||
"ts": "2026-08-22T17:20:33Z",
|
||||
"agent": "reviewer",
|
||||
"stage": "review_gate",
|
||||
"state": "running",
|
||||
"message": "review"
|
||||
},
|
||||
{
|
||||
"ts": "2026-08-22T17:17:36Z",
|
||||
"ts": "2026-08-22T17:20:33Z",
|
||||
"agent": "security",
|
||||
"stage": "security_gate",
|
||||
"state": "running",
|
||||
"message": "security"
|
||||
},
|
||||
{
|
||||
"ts": "2026-08-22T17:17:36Z",
|
||||
"ts": "2026-08-22T17:20:33Z",
|
||||
"agent": "qa",
|
||||
"stage": "qa_gate",
|
||||
"state": "running",
|
||||
"message": "qa"
|
||||
},
|
||||
{
|
||||
"ts": "2026-08-22T17:17:36Z",
|
||||
"ts": "2026-08-22T17:20:33Z",
|
||||
"agent": "documenter",
|
||||
"stage": "document",
|
||||
"state": "running",
|
||||
"message": "document"
|
||||
},
|
||||
{
|
||||
"ts": "2026-08-22T17:17:36Z",
|
||||
"ts": "2026-08-22T17:20:34Z",
|
||||
"agent": "leader",
|
||||
"stage": "close",
|
||||
"state": "running",
|
||||
|
||||
Reference in New Issue
Block a user