feat(F-152): completed feature
This commit is contained in:
@@ -6391,13 +6391,15 @@
|
||||
"description": "Fix customer-facing email notifications: account creation should send a welcome/verification email and confirmed orders should send the customer confirmation email.",
|
||||
"priority": "high",
|
||||
"risk": "med",
|
||||
"status": "pending",
|
||||
"status": "done",
|
||||
"created_at": "2026-08-21",
|
||||
"gates": {
|
||||
"reviewer": false,
|
||||
"security": false,
|
||||
"qa": false
|
||||
}
|
||||
"reviewer": true,
|
||||
"security": true,
|
||||
"qa": true,
|
||||
"close": true
|
||||
},
|
||||
"completed_at": "2026-08-22T05:09:03Z"
|
||||
},
|
||||
{
|
||||
"id": "F-153",
|
||||
|
||||
@@ -14,7 +14,12 @@ import type { IncomingMessage } from 'node:http';
|
||||
import type pg from 'pg';
|
||||
import { swaggerConfig } from '../shared/swagger.js';
|
||||
import { registerHealthRoutes } from '../modules/health/index.js';
|
||||
import { registerIdentityRoutes, createSessionAuthenticator } from '../modules/identity/index.js';
|
||||
import {
|
||||
createSessionAuthenticator,
|
||||
registerIdentityRoutes,
|
||||
SettingsPasswordResetMailer,
|
||||
SettingsWelcomeMailer,
|
||||
} from '../modules/identity/index.js';
|
||||
import {
|
||||
registerBackofficeRoutes,
|
||||
createBackofficeSessionAuthenticator,
|
||||
@@ -45,7 +50,6 @@ 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';
|
||||
import { SettingsPasswordResetMailer } from '../modules/identity/infrastructure/settings-password-reset-mailer.js';
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyInstance {
|
||||
@@ -185,6 +189,7 @@ export async function buildApp(deps: BuildAppDeps = {}): Promise<FastifyInstance
|
||||
pool: deps.pool as pg.Pool,
|
||||
cookieSecure: deps.cookieSecure,
|
||||
authenticate,
|
||||
welcomeMailer: new SettingsWelcomeMailer(deps.pool as pg.Pool),
|
||||
passwordReset: {
|
||||
mailer: new SettingsPasswordResetMailer(deps.pool as pg.Pool),
|
||||
audit: (entry) => {
|
||||
|
||||
@@ -26,6 +26,7 @@ import type {
|
||||
PasswordResetMailer,
|
||||
PasswordResetTokenRepository,
|
||||
ResetRateLimiter,
|
||||
WelcomeMailer,
|
||||
} from '../domain/ports.js';
|
||||
import {
|
||||
EmailAlreadyRegisteredError,
|
||||
@@ -34,10 +35,7 @@ import {
|
||||
} from '../domain/errors.js';
|
||||
import { SESSION_TTL_MS } from '../domain/session.js';
|
||||
import { PgPasswordResetTokenRepository } from '../infrastructure/pg-password-reset-token-repository.js';
|
||||
import {
|
||||
ConfirmPasswordReset,
|
||||
RequestPasswordReset,
|
||||
} from '../application/password-reset.js';
|
||||
import { ConfirmPasswordReset, RequestPasswordReset } from '../application/password-reset.js';
|
||||
import { InvalidResetTokenError } from '../domain/password-reset.js';
|
||||
import { createPasswordResetMailer } from '../infrastructure/smtp-password-reset-mailer.js';
|
||||
|
||||
@@ -62,6 +60,8 @@ export interface IdentityRoutesDeps {
|
||||
/** Audit sink (defaults to no-op). */
|
||||
audit?: (entry: { action: string; userId?: string; email?: string; ip?: string }) => void;
|
||||
};
|
||||
/** Best-effort welcome email sent on account creation (F-152). */
|
||||
welcomeMailer?: WelcomeMailer;
|
||||
}
|
||||
|
||||
const credentialsSchema = z.object({
|
||||
@@ -80,6 +80,7 @@ export async function registerIdentityRoutes(
|
||||
const rateLimiter = deps.rateLimiter ?? new InMemoryLoginRateLimiter();
|
||||
|
||||
const registerUser = new RegisterUser(users, hasher);
|
||||
const welcomeMailer = deps.welcomeMailer;
|
||||
const login = new Login({
|
||||
users,
|
||||
sessions,
|
||||
@@ -185,6 +186,15 @@ export async function registerIdentityRoutes(
|
||||
const input = parseJson(credentialsSchema, request.body);
|
||||
try {
|
||||
const user = await registerUser.execute(input);
|
||||
// F-152: best-effort welcome email. Never blocks account creation; a
|
||||
// delivery failure is logged and swallowed.
|
||||
if (welcomeMailer) {
|
||||
void welcomeMailer
|
||||
.sendWelcome({ email: user.email })
|
||||
.catch((error) =>
|
||||
request.log.warn({ err: error, userId: user.id }, 'welcome_email_failed'),
|
||||
);
|
||||
}
|
||||
return reply
|
||||
.code(201)
|
||||
.send({ id: user.id, email: user.email, role: user.role, createdAt: user.createdAt });
|
||||
@@ -238,7 +248,10 @@ export async function registerIdentityRoutes(
|
||||
app.patch('/auth/me/password', async (request, reply) => {
|
||||
const user = await deps.authenticate!(request);
|
||||
const input = parseJson(
|
||||
z.object({ currentPassword: z.string().min(1).max(128), newPassword: z.string().min(8).max(128) }),
|
||||
z.object({
|
||||
currentPassword: z.string().min(1).max(128),
|
||||
newPassword: z.string().min(8).max(128),
|
||||
}),
|
||||
request.body,
|
||||
);
|
||||
const record = await users.findByEmail(user.email);
|
||||
@@ -255,7 +268,10 @@ export async function registerIdentityRoutes(
|
||||
const usersRepo = new PgUserRepository(deps.pool);
|
||||
const rateLimiter = pr.rateLimiter ?? new InMemoryResetRateLimiter();
|
||||
const mailer = pr.mailer ?? createPasswordResetMailer();
|
||||
const publicAppUrl = (process.env.PUBLIC_APP_URL ?? 'https://mercadodevida.es').replace(/\/$/, '');
|
||||
const publicAppUrl = (process.env.PUBLIC_APP_URL ?? 'https://mercadodevida.es').replace(
|
||||
/\/$/,
|
||||
'',
|
||||
);
|
||||
const buildResetUrl =
|
||||
pr.buildResetUrl ??
|
||||
((token: string) => `${publicAppUrl}/cuenta/restablecer?token=${encodeURIComponent(token)}`);
|
||||
@@ -302,56 +318,57 @@ export async function registerIdentityRoutes(
|
||||
password: { type: 'string', minLength: 8, maxLength: 128 },
|
||||
},
|
||||
},
|
||||
response: { 200: { type: 'object', properties: { ok: { type: 'boolean' } } }, 400: errorSchema },
|
||||
response: {
|
||||
200: { type: 'object', properties: { ok: { type: 'boolean' } } },
|
||||
400: errorSchema,
|
||||
},
|
||||
};
|
||||
|
||||
app.post(
|
||||
'/auth/password-reset/request',
|
||||
{ schema: requestSchema },
|
||||
async (request, reply) => {
|
||||
if (mailer.assertReady) {
|
||||
try {
|
||||
await mailer.assertReady();
|
||||
} catch {
|
||||
throw new AppError(422, 'EMAIL_DELIVERY_NOT_CONFIGURED', 'Configura SMTP en Ajustes → SMTP / Email');
|
||||
}
|
||||
} else if (mailer.isConfigured && !mailer.isConfigured()) {
|
||||
throw new AppError(422, 'EMAIL_DELIVERY_NOT_CONFIGURED', 'Configura SMTP en Ajustes → SMTP / Email');
|
||||
}
|
||||
const input = parseJson(
|
||||
z.object({ email: z.email().max(255) }),
|
||||
request.body,
|
||||
);
|
||||
const ip = (request.headers['x-forwarded-for'] as string | undefined)?.split(',')[0]?.trim()
|
||||
?? request.ip;
|
||||
const userAgent = request.headers['user-agent'];
|
||||
await requestReset.execute({ email: input.email, ip, userAgent });
|
||||
return reply.send({ ok: true });
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/auth/password-reset/confirm',
|
||||
{ schema: confirmSchema },
|
||||
async (request, reply) => {
|
||||
const input = parseJson(
|
||||
z.object({
|
||||
token: z.string().min(16).max(128),
|
||||
password: z.string().min(8).max(128),
|
||||
}),
|
||||
request.body,
|
||||
);
|
||||
app.post('/auth/password-reset/request', { schema: requestSchema }, async (request, reply) => {
|
||||
if (mailer.assertReady) {
|
||||
try {
|
||||
await confirmReset.execute({ token: input.token, newPassword: input.password });
|
||||
return reply.send({ ok: true });
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidResetTokenError) {
|
||||
throw new AppError(400, 'INVALID_RESET_TOKEN', 'Invalid or expired reset token');
|
||||
}
|
||||
throw error;
|
||||
await mailer.assertReady();
|
||||
} catch {
|
||||
throw new AppError(
|
||||
422,
|
||||
'EMAIL_DELIVERY_NOT_CONFIGURED',
|
||||
'Configura SMTP en Ajustes → SMTP / Email',
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
} else if (mailer.isConfigured && !mailer.isConfigured()) {
|
||||
throw new AppError(
|
||||
422,
|
||||
'EMAIL_DELIVERY_NOT_CONFIGURED',
|
||||
'Configura SMTP en Ajustes → SMTP / Email',
|
||||
);
|
||||
}
|
||||
const input = parseJson(z.object({ email: z.email().max(255) }), request.body);
|
||||
const ip =
|
||||
(request.headers['x-forwarded-for'] as string | undefined)?.split(',')[0]?.trim() ??
|
||||
request.ip;
|
||||
const userAgent = request.headers['user-agent'];
|
||||
await requestReset.execute({ email: input.email, ip, userAgent });
|
||||
return reply.send({ ok: true });
|
||||
});
|
||||
|
||||
app.post('/auth/password-reset/confirm', { schema: confirmSchema }, async (request, reply) => {
|
||||
const input = parseJson(
|
||||
z.object({
|
||||
token: z.string().min(16).max(128),
|
||||
password: z.string().min(8).max(128),
|
||||
}),
|
||||
request.body,
|
||||
);
|
||||
try {
|
||||
await confirmReset.execute({ token: input.token, newPassword: input.password });
|
||||
return reply.send({ ok: true });
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidResetTokenError) {
|
||||
throw new AppError(400, 'INVALID_RESET_TOKEN', 'Invalid or expired reset token');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -55,3 +55,9 @@ export interface PasswordResetMailer {
|
||||
assertReady?: () => Promise<void>;
|
||||
sendPasswordReset(input: { email: string; resetUrl: string; locale?: string }): Promise<void>;
|
||||
}
|
||||
|
||||
/** Sends a welcome email when an account is created. Best-effort: callers must
|
||||
* swallow errors so a delivery failure never blocks registration. */
|
||||
export interface WelcomeMailer {
|
||||
sendWelcome(input: { email: string; name?: string }): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -8,3 +8,5 @@ export {
|
||||
type IdentityRoutesDeps,
|
||||
} from './api/identity.routes.js';
|
||||
export { createSessionAuthenticator } from './infrastructure/session-authenticator.js';
|
||||
export { SettingsPasswordResetMailer } from './infrastructure/settings-password-reset-mailer.js';
|
||||
export { SettingsWelcomeMailer } from './infrastructure/settings-welcome-mailer.js';
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
buildWelcomeEmail,
|
||||
SettingsWelcomeMailer,
|
||||
} from '../infrastructure/settings-welcome-mailer.js';
|
||||
|
||||
const SMTP_ROWS = [
|
||||
{ key: 'smtp_host', value: 'smtp.test' },
|
||||
{ key: 'smtp_port', value: '587' },
|
||||
{ key: 'smtp_secure', value: 'false' },
|
||||
{ key: 'smtp_user', value: 'user@test' },
|
||||
{ key: 'smtp_pass', value: 'pass' },
|
||||
{ key: 'smtp_from', value: 'no-reply@test' },
|
||||
];
|
||||
|
||||
const sentMails: Record<string, unknown>[] = [];
|
||||
vi.mock('nodemailer', () => ({
|
||||
default: {
|
||||
createTransport: () => ({
|
||||
sendMail: (mail: Record<string, unknown>) => {
|
||||
sentMails.push(mail);
|
||||
return Promise.resolve({});
|
||||
},
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
function pool(rows: typeof SMTP_ROWS | [] = SMTP_ROWS) {
|
||||
return { query: vi.fn().mockResolvedValue({ rows }) };
|
||||
}
|
||||
|
||||
describe('buildWelcomeEmail', () => {
|
||||
it('greets by name and links to mercadodevida', () => {
|
||||
const email = buildWelcomeEmail({ email: 'a@b.c', name: 'Ada' });
|
||||
expect(email.subject).toBe('¡Bienvenido a Mercado de Vida!');
|
||||
expect(email.text).toContain('Hola Ada');
|
||||
expect(email.html).toContain('<p>Hola Ada,</p>');
|
||||
expect(email.html).toContain('mercadodevida');
|
||||
});
|
||||
|
||||
it('falls back to a generic greeting when name is missing', () => {
|
||||
const email = buildWelcomeEmail({ email: 'a@b.c' });
|
||||
expect(email.text).toContain('Hola,');
|
||||
expect(email.html).toContain('<p>Hola,</p>');
|
||||
});
|
||||
|
||||
it('escapes HTML in the name for the HTML body (XSS)', () => {
|
||||
const email = buildWelcomeEmail({ email: 'a@b.c', name: '<b>Ada</b>' });
|
||||
expect(email.html).not.toContain('<b>Ada</b>');
|
||||
expect(email.html).toContain('<b>Ada</b>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SettingsWelcomeMailer', () => {
|
||||
beforeEach(() => {
|
||||
sentMails.length = 0;
|
||||
});
|
||||
|
||||
it('reads SMTP config from store_settings and sends the welcome email', async () => {
|
||||
const pgPool = pool();
|
||||
const mailer = new SettingsWelcomeMailer(pgPool as unknown as import('pg').Pool);
|
||||
await mailer.sendWelcome({ email: 'ada@example.com', name: 'Ada' });
|
||||
expect(pgPool.query).toHaveBeenCalled();
|
||||
expect(sentMails).toHaveLength(1);
|
||||
const mail = sentMails[0]!;
|
||||
expect(mail.to).toBe('ada@example.com');
|
||||
expect(mail.from).toBe('no-reply@test');
|
||||
expect(mail.subject).toBe('¡Bienvenido a Mercado de Vida!');
|
||||
});
|
||||
|
||||
it('throws when SMTP is not configured', async () => {
|
||||
const pgPool = pool([]);
|
||||
const mailer = new SettingsWelcomeMailer(pgPool as unknown as import('pg').Pool);
|
||||
await expect(mailer.sendWelcome({ email: 'ada@example.com' })).rejects.toThrow(
|
||||
'SMTP is not configured',
|
||||
);
|
||||
expect(sentMails).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import nodemailer from 'nodemailer';
|
||||
import type pg from 'pg';
|
||||
import type { WelcomeMailer } from '../domain/ports.js';
|
||||
|
||||
const SMTP_KEYS = [
|
||||
'smtp_host',
|
||||
'smtp_port',
|
||||
'smtp_secure',
|
||||
'smtp_user',
|
||||
'smtp_pass',
|
||||
'smtp_from',
|
||||
] as const;
|
||||
|
||||
interface SmtpOptions {
|
||||
host: string;
|
||||
port: number;
|
||||
secure: boolean;
|
||||
user: string;
|
||||
password: string;
|
||||
from: string;
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value.replace(
|
||||
/[&<>"']/g,
|
||||
(character) =>
|
||||
({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
})[character] ?? character,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the welcome email body. Pure function: no SMTP, easily unit-testable.
|
||||
*/
|
||||
export function buildWelcomeEmail(input: { email: string; name?: string }): {
|
||||
subject: string;
|
||||
text: string;
|
||||
html: string;
|
||||
} {
|
||||
const greetingName = input.name?.trim() ? ` ${escapeHtml(input.name.trim())}` : '';
|
||||
return {
|
||||
subject: '¡Bienvenido a Mercado de Vida!',
|
||||
text: [
|
||||
`Hola${input.name?.trim() ? ` ${input.name.trim()}` : ''},`,
|
||||
'',
|
||||
'Tu cuenta en Mercado de Vida se ha creado correctamente.',
|
||||
'',
|
||||
'Ya puedes iniciar sesión en mercadodevida y empezar a comprar nuestros productos ecológicos y de proximidad.',
|
||||
'',
|
||||
'Gracias por unirte.',
|
||||
].join('\n'),
|
||||
html: [
|
||||
`<p>Hola${greetingName},</p>`,
|
||||
'<p>Tu cuenta en <strong>Mercado de Vida</strong> se ha creado correctamente.</p>',
|
||||
'<p>Ya puedes <a href="https://mercadodevida">iniciar sesión</a> y empezar a comprar nuestros productos ecológicos y de proximidad.</p>',
|
||||
'<p>Gracias por unirte.</p>',
|
||||
].join(''),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends welcome emails through the SMTP configuration stored in store_settings
|
||||
* (Ajustes → SMTP / Email), with env-free config so admins can change it
|
||||
* without a deploy. Modeled on SettingsPasswordResetMailer. Best-effort: a
|
||||
* failure throws so the caller (identity routes) can log and continue.
|
||||
*/
|
||||
export class SettingsWelcomeMailer implements WelcomeMailer {
|
||||
constructor(private readonly pool: pg.Pool) {}
|
||||
|
||||
async sendWelcome(input: { email: string; name?: string }): Promise<void> {
|
||||
const options = await this.readSmtpOptions();
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: options.host,
|
||||
port: options.port,
|
||||
secure: options.secure,
|
||||
auth: { user: options.user, pass: options.password },
|
||||
});
|
||||
const body = buildWelcomeEmail(input);
|
||||
await transporter.sendMail({
|
||||
from: options.from,
|
||||
to: input.email,
|
||||
subject: body.subject,
|
||||
text: body.text,
|
||||
html: body.html,
|
||||
});
|
||||
}
|
||||
|
||||
private async readSmtpOptions(): Promise<SmtpOptions> {
|
||||
const result = await this.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();
|
||||
const user = settings.smtp_user?.trim();
|
||||
const password = settings.smtp_pass;
|
||||
const from = settings.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 || '465');
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
throw new Error('SMTP port is invalid in Ajustes → SMTP / Email');
|
||||
}
|
||||
return {
|
||||
host,
|
||||
port,
|
||||
secure: settings.smtp_secure !== 'false' || port === 465,
|
||||
user,
|
||||
password,
|
||||
from,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ export interface NotificationsRoutesDeps {
|
||||
const dispatchSchema = z
|
||||
.object({
|
||||
eventId: z.string().min(1).max(120),
|
||||
template: z.enum(['order_confirmation', 'payment_failed', 'order_shipped']),
|
||||
template: z.enum(['order_confirmation', 'payment_failed', 'order_shipped', 'account_created']),
|
||||
recipient: z.string().email(),
|
||||
subject: z.string().min(1).max(200),
|
||||
body: z.string().min(1).max(5000),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export type EmailTemplate = 'order_confirmation' | 'payment_failed' | 'order_shipped';
|
||||
export type EmailTemplate =
|
||||
'order_confirmation' | 'payment_failed' | 'order_shipped' | 'account_created';
|
||||
|
||||
export interface EmailMessage {
|
||||
eventId: string;
|
||||
|
||||
@@ -2,12 +2,15 @@ import type { EmailMessage, EmailTemplate } from '../domain/notification.js';
|
||||
import type { EmailProvider } from '../domain/ports.js';
|
||||
|
||||
const SUBJECTS: Record<EmailTemplate, string> = {
|
||||
account_created: '¡Bienvenido a Mercado de Vida!',
|
||||
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> = {
|
||||
account_created:
|
||||
'Tu cuenta en Mercado de Vida se ha creado. Ya puedes iniciar sesión y empezar a comprar.',
|
||||
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.',
|
||||
|
||||
@@ -8,6 +8,12 @@ export { registerOrdersRoutes, type OrdersRoutesDeps } from './api/orders.routes
|
||||
export { OrderService } from './application/order-service.js';
|
||||
export { OrderStateTransitionError, OrderNotFoundError } from './domain/errors.js';
|
||||
export { ALLOWED_TRANSITIONS, isTransitionAllowed } from './domain/order.js';
|
||||
export {
|
||||
sendOrderStatusEmail,
|
||||
buildOrderStatusEmail,
|
||||
ORDER_STATE_LABELS,
|
||||
type OrderStatusNotificationInput,
|
||||
} from './infrastructure/order-status-mailer.js';
|
||||
export type {
|
||||
OrderEvent,
|
||||
OrderEventPublisher,
|
||||
|
||||
11
project/src/modules/orders/tests/orders-index.test.ts
Normal file
11
project/src/modules/orders/tests/orders-index.test.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import * as OrdersIndex from '../index.js';
|
||||
|
||||
describe('orders barrel exports (F-152)', () => {
|
||||
it('re-exports the order-status email helpers used for confirmation emails', () => {
|
||||
expect(typeof OrdersIndex.sendOrderStatusEmail).toBe('function');
|
||||
expect(typeof OrdersIndex.buildOrderStatusEmail).toBe('function');
|
||||
expect(OrdersIndex.ORDER_STATE_LABELS.PAID).toBe('Pagado');
|
||||
expect(OrdersIndex.ORDER_STATE_LABELS.SHIPPED).toBe('Enviado');
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,7 @@ import { StripePaymentProvider } from '../infrastructure/stripe-payment-provider
|
||||
import {
|
||||
ALLOWED_TRANSITIONS,
|
||||
OrderNotFoundError,
|
||||
sendOrderStatusEmail,
|
||||
type OrderServicePort,
|
||||
type OrderState,
|
||||
} from '../../orders/index.js';
|
||||
@@ -121,6 +122,26 @@ export async function registerPaymentsRoutes(
|
||||
throw error;
|
||||
}
|
||||
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') {
|
||||
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) {
|
||||
await sendOrderStatusEmail(deps.pool, {
|
||||
to,
|
||||
orderId: event.orderId,
|
||||
state: 'PAID',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
request.log.warn({ err: error, orderId: event.orderId }, 'order_confirmation_email_failed');
|
||||
}
|
||||
}
|
||||
return reply.send({ result: outcome.kind });
|
||||
});
|
||||
|
||||
|
||||
@@ -1,9 +1,37 @@
|
||||
# Acceptance Criteria
|
||||
# F-152 — Acceptance Criteria
|
||||
|
||||
Define criterios verificables por feature.
|
||||
- Feature ID: F-152
|
||||
|
||||
Formato recomendado:
|
||||
- Feature ID:
|
||||
- Escenario:
|
||||
- Given / When / Then:
|
||||
- Evidencia esperada (test/comando):
|
||||
## Account creation → welcome email
|
||||
- Escenario: customer registers with a new email.
|
||||
- Given SMTP is configured and `POST /auth/register` is called with valid email+password
|
||||
- When registration succeeds
|
||||
- Then a welcome email (`account_created`) is sent to that email
|
||||
- And the response is still 200 with the user payload
|
||||
- And evidence: SMTP sendmail called once with recipient == input email and template account_created
|
||||
- Escenario: SMTP is not configured.
|
||||
- Given SMTP is not configured and `POST /auth/register` is called
|
||||
- When registration succeeds
|
||||
- Then the response is still 200 (registration never fails due to email)
|
||||
- And a warning is logged
|
||||
|
||||
## Order confirmation on payment success
|
||||
- Escenario: customer pays and Stripe reports PaymentSucceeded.
|
||||
- Given an order in AWAITING_PAYMENT and a valid `PaymentSucceeded` webhook
|
||||
- When the webhook is processed
|
||||
- Then the order moves to PAID
|
||||
- And a confirmation email (`state: PAID`) is sent to the order's customer email via sendOrderStatusEmail
|
||||
- And the webhook returns 2xx
|
||||
- Escenario: duplicate webhook.
|
||||
- Given the same PaymentSucceeded webhook delivered twice
|
||||
- When the second is processed
|
||||
- Then only one confirmation email is sent (idempotency from payment dedup / outcome.kind == duplicate skipped)
|
||||
- Escenario: SMTP not configured.
|
||||
- Given SMTP not configured and a PaymentSucceeded webhook arrives
|
||||
- When processed
|
||||
- Then the order still moves to PAID, webhook returns 2xx, and an email failure is logged (not thrown)
|
||||
|
||||
## Regression
|
||||
- verify.sh is green
|
||||
- No existing order admin-transition email behavior changed
|
||||
- Password-reset email flow unchanged
|
||||
|
||||
@@ -1,15 +1,32 @@
|
||||
# Product Spec
|
||||
# F-152 — Product Spec
|
||||
|
||||
## Problema
|
||||
Describe el problema de negocio.
|
||||
Los clientes no reciben confirmación por email ni al crear la cuenta ni cuando su
|
||||
pedido se confirma tras el pago:
|
||||
- `POST /auth/register` crea el usuario pero no envía email de bienvenida.
|
||||
- El webhook de Stripe (`PaymentSucceeded`) pasa el pedido a `PAID` pero no
|
||||
notifica al cliente (el `OrderEventPublisher` inyectado en payments es no-op y
|
||||
`sendOrderStatusEmail` solo se llamaba desde transiciones admin).
|
||||
|
||||
## Objetivo
|
||||
Define el resultado esperado del producto.
|
||||
Que los clientes reciban los dos emails transaccionales esenciales:
|
||||
1. **Welcome** al crear la cuenta (account_created).
|
||||
2. **Order confirmation** cuando el pago se confirma (PaymentSucceeded → PAID),
|
||||
reenviando el flujo ya existente de `sendOrderStatusEmail` (SMTP desde
|
||||
*Ajustes → SMTP / Email*).
|
||||
|
||||
## Usuarios
|
||||
- Usuario principal:
|
||||
- Usuario secundario:
|
||||
- Usuario principal: cliente que se registra / compra en la tienda.
|
||||
- Usuario secundario: operador (Ajustes SMTP) y admin (vee historial).
|
||||
|
||||
## Alcance v1
|
||||
- In scope:
|
||||
- Welcome email on `POST /auth/register` (best-effort, nunca bloquea el registro).
|
||||
- Order confirmation email on `PaymentSucceeded` webhook (best-effort, nunca
|
||||
rompe la reconciliación de pagos).
|
||||
- Reusar el SMTP configurado en `store_settings` ya usado por admin transitions.
|
||||
- Tests unitarios de cuerpo/email y de best-effort.
|
||||
- Out of scope:
|
||||
- Verificación por enlace (gating de cuenta por email) — queda como hardening.
|
||||
- Reenvío de emails ya enviados (idempotencia garantizada por el webhook).
|
||||
- Cambiar el email de transición admin existente.
|
||||
|
||||
87
spec/tech.md
87
spec/tech.md
@@ -1,37 +1,62 @@
|
||||
# Technical Spec — MercadoDeVida vNext
|
||||
# F-152 — Tech Spec
|
||||
|
||||
## Stack
|
||||
- Lenguaje: TypeScript (strict) — backend y frontend
|
||||
- Framework backend: Fastify 5 (monolito modular; elegido por tipado, simplicidad y ausencia de magia)
|
||||
- Framework frontend: Next.js + React + Tailwind (llega en F-013; SSG/ISR para SEO)
|
||||
- Runtime: Node.js >= 22
|
||||
- Base de datos: PostgreSQL (fuente de verdad) + Redis (solo caché) — desde F-002
|
||||
## Principios
|
||||
- Best-effort: un email fallido o SMTP no configurado **nunca** debe fallar el
|
||||
registro ni el webhook de pagos. Se loguea y se continúa.
|
||||
- Reusar infraestructura existente: SMTP desde `store_settings`
|
||||
(`smtp_host/port/secure/user/pass/from`), mismo patrón que
|
||||
`SettingsPasswordResetMailer` y `sendOrderStatusEmail`.
|
||||
- Boundaries: identity no importa orders/payments a nivel de dominio; el wiring
|
||||
del order-confirmation email se hace en el *route handler* de payments (posee
|
||||
`deps.pool`), reusando `sendOrderStatusEmail` exportado públicamente por
|
||||
`orders/index.ts`.
|
||||
|
||||
## Dependencias (justificación obligatoria por política de seguridad)
|
||||
## Cambios
|
||||
|
||||
| Dependencia | Versión | Justificación |
|
||||
|---|---|---|
|
||||
| fastify | ^5.2 | Servidor HTTP tipado, maintained, sin magia; única dep de runtime en F-001 |
|
||||
| typescript | ^5.7 | Tipado estricto (dev) |
|
||||
| vitest | ^3 | Tests unit/integración TS-native (dev) |
|
||||
| eslint + @eslint/js + typescript-eslint + eslint-config-prettier | ^9 / ^8 / ^10 | Linting estándar (dev) |
|
||||
| prettier | ^3 | Formato consistente (dev) |
|
||||
| pg | ^8 | Driver PostgreSQL estándar; única forma de hablar con la DB (F-002) |
|
||||
| node-pg-migrate | ^8 | Migraciones SQL up/down trackeadas en DB; elegida sobre runner propio (no reinvención) y sobre Flyway/golang-migrate (toolchains ajenos a Node) (F-002) |
|
||||
| @types/pg | ^8 | Tipos para pg (dev) (F-002) |
|
||||
| pino | ^9 | Logging JSON estructurado; estándar del ecosistema Fastify, rápido y sin magia (F-003) |
|
||||
| zod | ^4 | Validación explícita de inputs vía parseJson en handlers; sin decoradores ni magic binding (F-003) |
|
||||
| argon2 | ^1 | Hash de contraseñas Argon2id (parámetros OWASP 2024); implementación canónica, output PHC. Elegida sobre bcrypt (menos resistente a GPU/ASIC) y scrypt. Detrás del puerto PasswordHasher; fallback @node-rs/argon2 si el build nativo fallara (F-005) |
|
||||
| @fastify/cookie | ^1 | setCookie/clearCookie explícitos para la sesión HttpOnly; plugin oficial Fastify, sin reinvención de parsing/serialización (F-005) |
|
||||
### 1. Order confirmation on PaymentSucceeded (payments)
|
||||
- `orders/index.ts`: exportar `sendOrderStatusEmail` (y `ORDER_STATE_LABELS`).
|
||||
- `payments/api/payments.routes.ts`: en el handler de `/payments/webhook`, tras
|
||||
`const outcome = await service.handleWebhook(event)`, si
|
||||
`event.type === 'PaymentSucceeded' && event.orderId && outcome.kind === 'processed'`,
|
||||
fetch customer email (`identity_users.email` via `orders_orders.user_id`) y
|
||||
`sendOrderStatusEmail(deps.pool, { to, orderId: event.orderId, state: 'PAID' })`
|
||||
dentro de try/catch; log de advertencia si falla SMTP/no-config.
|
||||
|
||||
Regla: toda dependencia nueva debe agregarse a esta tabla con justificación en el ticket que la introduce.
|
||||
### 2. Welcome email on registration (identity)
|
||||
- `identity/domain/ports.ts`: nuevo puerto `WelcomeMailer` con
|
||||
`sendWelcome(input: { email: string; name?: string }): Promise<void>` (name es
|
||||
opcional: `User` no almacena nombre en el dominio actual).
|
||||
- `identity/infrastructure/settings-welcome-mailer.ts`: `SettingsWelcomeMailer(pool)`
|
||||
modelado en `SettingsPasswordResetMailer` — lee SMTP de `store_settings`, usa
|
||||
`nodemailer`, y una función pura `buildWelcomeEmail` (verificable sin SMTP).
|
||||
El subject (`¡Bienvenido a Mercado de Vida!`) coincide con la plantilla
|
||||
`account_created` de notificaciones. Lanza si SMTP no está configurado.
|
||||
- `identity/api/identity.routes.ts`: `IdentityRoutesDeps.welcomeMailer?: WelcomeMailer`;
|
||||
en el handler de `POST /auth/register`, tras `registerUser.execute` exitoso, se
|
||||
despacha el welcome email *fire-and-forget* (`void mailer.sendWelcome(...).catch(
|
||||
request.log.warn(...))`). Best-effort: un fallo SMTP se loguea (warning) y se
|
||||
traga; el registro nunca se rompe por email. `RegisterUser` se mantiene sin
|
||||
depender de email (puro orquestación de dominio).
|
||||
- `identity/index.ts`: re-exporta `SettingsWelcomeMailer` (y
|
||||
`SettingsPasswordResetMailer`) para que `build-app.ts` los importe desde el
|
||||
index en lugar de deep-importar infra (cumple R2 de boundaries).
|
||||
- `notifications/domain/notification.ts` + `notifications/api/notifications.routes.ts`:
|
||||
añadir `account_created` al union `EmailTemplate`, a los mapas SUBJECTS/BODIES
|
||||
de `LoggingEmailProvider` y al enum del schema de `POST /notifications/dispatch`.
|
||||
|
||||
## Restricciones
|
||||
- Seguridad: nunca confiar en frontend para precio/stock/descuento/estado de pago; envelopes de error sin stack traces; sin secretos en repo
|
||||
- Rendimiento: caché solo donde sea medible (F-027)
|
||||
- Compatibilidad: URLs públicas estables /productos/<slug>, /categoria/<slug>, /marca/<slug>
|
||||
## SMTP / store_settings
|
||||
Claves existentes: `smtp_host, smtp_port, smtp_secure, smtp_user, smtp_pass, smtp_from`.
|
||||
El welcome mailer reusa exactamente estas claves.
|
||||
|
||||
## Observabilidad
|
||||
- Logging: estructurado con request_id (F-003)
|
||||
- Métricas: Prometheus con métricas de negocio (F-029)
|
||||
- Alertas: fuera de alcance inicial
|
||||
## Testing
|
||||
- `settings-welcome-mailer.test.ts`: `buildWelcomeEmail` (greeting, nombre, XSS),
|
||||
`SettingsWelcomeMailer.sendWelcome` (nodemailer mockeado: sendmail called once
|
||||
con recipient == email y subject == plantilla `account_created`), y lanza cuando
|
||||
SMTP no está configurado (`SMTP is not configured`).
|
||||
- `orders/tests/orders-index.test.ts`: el barrel de `orders/index.ts` re-exporta
|
||||
`sendOrderStatusEmail`, `buildOrderStatusEmail`, `ORDER_STATE_LABELS`.
|
||||
- `order-status-mailer.test.ts` ya existe cubriendo `buildOrderStatusEmail` (PAID
|
||||
pertenece a `ORDER_STATE_LABELS`).
|
||||
- `tsc --noEmit` limpio; `prettier --check` y `eslint` limpios en los archivos tocados;
|
||||
suite completa de vitest verde (197 tests); `lint:boundaries` sin nuevas violaciones.
|
||||
- verify.sh green.
|
||||
|
||||
37
work/artifacts/F-152/architect.md
Normal file
37
work/artifacts/F-152/architect.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# F-152 — Architecture Decision Record
|
||||
|
||||
## Status
|
||||
Accepted.
|
||||
|
||||
## Context
|
||||
Two customer-facing emails are missing: (1) a welcome email on registration, and
|
||||
(2) an order-confirmation email on successful payment. The admin order-transition
|
||||
flow already sends emails via `sendOrderStatusEmail` (nodemailer + SMTP read
|
||||
from `store_settings`), but the automated paths bypass it: the payments webhook
|
||||
uses a no-op `OrderEventPublisher`, and `RegisterUser` has no mailer at all.
|
||||
|
||||
## Decision
|
||||
Reuse the existing store_settings SMTP pattern instead of introducing a new
|
||||
email provider, to keep blast radius low and stay consistent with
|
||||
`SettingsPasswordResetMailer` / `sendOrderStatusEmail`.
|
||||
|
||||
- **Order confirmation**: export `sendOrderStatusEmail` from `orders/index.ts`
|
||||
(it is already used internally by `orders.routes`) and call it from the
|
||||
payments webhook route handler on `PaymentSucceeded`. This keeps the email
|
||||
in the route handler (owns `deps.pool`), mirroring the admin-transition route.
|
||||
- **Welcome email**: add a `WelcomeMailer` port to identity with a
|
||||
`SettingsWelcomeMailer` infra adapter modeled 1:1 on
|
||||
`SettingsPasswordResetMailer`. `RegisterUser` takes an optional mailer and
|
||||
swallows failures so registration never blocks on email.
|
||||
- **EmailTemplate**: register `account_created` in the notifications domain +
|
||||
`LoggingEmailProvider` body/subject maps so the admin dispatch endpoint stays
|
||||
consistent.
|
||||
|
||||
## Consequences
|
||||
- identity module stays self-contained (models existing password-reset mailer).
|
||||
- Best-effort semantics: email/SMTP failures log a warning and never fail the
|
||||
registration or payment webhook.
|
||||
- Idempotency on the payment side comes from the existing payment-event dedup;
|
||||
we only email when outcome.kind === 'processed' for PaymentSucceeded.
|
||||
- Out of scope: email-verification gating and refactoring the admin-transition
|
||||
mailer into the notifications provider.
|
||||
116
work/artifacts/F-152/implementer.md
Normal file
116
work/artifacts/F-152/implementer.md
Normal file
@@ -0,0 +1,116 @@
|
||||
# F-152 — Implementer evidence
|
||||
|
||||
## Problema
|
||||
|
||||
Los emails transaccionales de creación de cuenta y confirmación de orden no se enviaban:
|
||||
- Al registrarse un usuario (`POST /auth/register`) no se disparaba el email de
|
||||
bienvenida (`account_created`).
|
||||
- Al confirmarse un pago (`POST /payments/webhook` → `PaymentSucceeded`) no se
|
||||
enviaba el email de confirmación de orden (estado `PAID`).
|
||||
|
||||
SMTP se lee de `store_settings` (`smtp_host/port/secure/user/pass/from`) y la
|
||||
capa de envío reusa `nodemailer` y el patrón de `sendOrderStatusEmail` /
|
||||
`LoggingEmailProvider`.
|
||||
|
||||
## Cambios
|
||||
|
||||
### Identity — welcome email
|
||||
- `project/src/modules/identity/domain/ports.ts`
|
||||
- Nuevo puerto `WelcomeMailer`: `sendWelcome(input: { email: string; name?: string }): Promise<void>`
|
||||
(`name` opcional: el dominio `User` no almacena nombre).
|
||||
- `project/src/modules/identity/infrastructure/settings-welcome-mailer.ts` (nuevo)
|
||||
- `SettingsWelcomeMailer(pool)` — lee SMTP de `store_settings`, usa `nodemailer`,
|
||||
y función pura `buildWelcomeEmail` (verificable sin SMTP). Subject
|
||||
`¡Bienvenido a Mercado de Vida!` coincide con la plantilla `account_created`.
|
||||
Lanza `SMTP is not configured` cuando falta SMTP.
|
||||
- `project/src/modules/identity/api/identity.routes.ts`
|
||||
- `IdentityRoutesDeps.welcomeMailer?: WelcomeMailer`; en el handler de
|
||||
`POST /auth/register` se despacha el email *fire-and-forget*
|
||||
(`void mailer.sendWelcome(...).catch(request.log.warn(...))`). Best-effort:
|
||||
un fallo SMTP se loguea (warning) y se traga; el registro nunca se rompe.
|
||||
- `project/src/modules/identity/index.ts`
|
||||
- Re-exporta `SettingsWelcomeMailer` (y `SettingsPasswordResetMailer`) para que
|
||||
`build-app.ts` los importe desde el index (cumple R2 de boundaries).
|
||||
- `project/src/app/build-app.ts`
|
||||
- `welcomeMailer: new SettingsWelcomeMailer(deps.pool)` inyectado en
|
||||
`IdentityRoutesDeps`.
|
||||
|
||||
### Payments — order confirmation
|
||||
- `project/src/modules/payments/api/payments.routes.ts`
|
||||
- En el handler de `POST /payments/webhook`, tras
|
||||
`const outcome = await service.handleWebhook(event)`, si
|
||||
`event.type === 'PaymentSucceeded' && event.orderId && outcome.kind === 'processed'`
|
||||
se resuelve el email del cliente (`orders_orders.user_id` →
|
||||
`identity_users.email`) y se llama `sendOrderStatusEmail(deps.pool, { to,
|
||||
orderId, state: 'PAID' })` dentro de try/catch (log de advertencia si falla
|
||||
SMTP/no-config). El gate `outcome.kind === 'processed'` asegura envío único
|
||||
frente a webhooks duplicados (PaymentsService devuelve `{ kind: 'duplicate' }`
|
||||
antes de cualquier transición de estado).
|
||||
|
||||
### Orders — barrel
|
||||
- `project/src/modules/orders/index.ts`
|
||||
- Re-exporta `sendOrderStatusEmail`, `buildOrderStatusEmail`,
|
||||
`ORDER_STATE_LABELS` y `type OrderStatusNotificationInput` para que payments
|
||||
los consuma sin deep-import (boundary clean).
|
||||
|
||||
### Notifications — consistencia de plantilla
|
||||
- `project/src/modules/notifications/domain/notification.ts`
|
||||
- Añadido `account_created` al union `EmailTemplate` y a los mapas SUBJECTS/BODIES
|
||||
de `LoggingEmailProvider`.
|
||||
- `project/src/modules/notifications/infrastructure/log-email-provider.ts`
|
||||
- Añadido `account_created` a `SUBJECTS` y `BODIES`.
|
||||
- `project/src/modules/notifications/api/notifications.routes.ts`
|
||||
- Añadido `account_created` al enum del schema de `POST /notifications/dispatch`.
|
||||
|
||||
## Tests
|
||||
|
||||
- `project/src/modules/identity/infrastructure/settings-welcome-mailer.test.ts` (nuevo, 5 tests)
|
||||
- `buildWelcomeEmail` incluye el email en el greeting y HTML-escapea el nombre
|
||||
(XSS-safe).
|
||||
- `SettingsWelcomeMailer.sendWelcome` (nodemailer mockeado): asserta que
|
||||
`sendMail` se llama una sola vez con `to == email` y
|
||||
`subject == '¡Bienvenido a Mercado de Vida!'` (plantilla `account_created`).
|
||||
- Lanza `SMTP is not configured` cuando no hay SMTP (`readSmtpOptions`).
|
||||
- `project/src/modules/orders/tests/orders-index.test.ts` (nuevo, 1 test)
|
||||
- El barrel de `orders/index.ts` re-exporta `sendOrderStatusEmail`,
|
||||
`buildOrderStatusEmail`, `ORDER_STATE_LABELS` y el tipo.
|
||||
- `order-status-mailer.test.ts` cubre `buildOrderStatusEmail` / `ORDER_STATE_LABELS`
|
||||
(`PAID` incluido) — preexistente, sin tocar.
|
||||
|
||||
## Verificación
|
||||
|
||||
```text
|
||||
tsc --noEmit (project/tsconfig.json) ✅ 0 errores
|
||||
prettier --check (archivos tocados) ✅ All matched files use Prettier code style
|
||||
eslint (archivos tocados) ✅ 0 errores
|
||||
lint:boundaries (node scripts/check-module-boundaries.mjs src) ✅ sin nuevas violaciones (queda SOLO la R1 preexistente de security.routes, ajena a F-152)
|
||||
vitest run (suite completa) ✅ 197 passed | 56 skipped (253)
|
||||
verify.sh ✅ exit 0
|
||||
git diff --check ✅
|
||||
```
|
||||
|
||||
## Decisiones
|
||||
|
||||
- **Dispatch del welcome email en el *route handler*, no en `RegisterUser`**:
|
||||
la aceptación exige "se loguea un warning" ante fallo SMTP, pero `RegisterUser`
|
||||
no posee logger. `identity.routes.ts` sí tiene `request.log`. Dispachar allí
|
||||
best-effort (`fire-and-forget` + `.catch(request.log.warn)`) satisface
|
||||
observabilidad y best-effort, siguiendo el precedente de `sendOrderStatusEmail`
|
||||
en payments. `RegisterUser` se mantiene puro (orquestación de dominio).
|
||||
- **Gate `outcome.kind === 'processed'`** garantiza idempotencia frente a webhooks
|
||||
duplicados (PaymentsService devuelve `{ kind: 'duplicate' }` antes de cualquier
|
||||
transición). Evita tabla de dedup adicional.
|
||||
- **`buildWelcomeEmail` es pura + XSS-safe**: `name` se HTML-escapea (unit test)
|
||||
para que la personalización no inyecte markup en el body.
|
||||
- **Path gotcha `./domain` vs `../domain`**: el reader tool renderizó
|
||||
`orders/index.ts` como `../domain/order.js` cuando el archivo real usa un solo
|
||||
punto `./domain/order.js` (confirmado con `od -c`/`python3 repr`). El re-export
|
||||
final se valida con typecheck (import resuelto correctamente).
|
||||
|
||||
## Estado runtime
|
||||
|
||||
No hay endpoint HTTP nuevo verificable en vivo más allá de los tests unitarios;
|
||||
la entrega se valida por: (a) welcome email enviado al email registrado según el
|
||||
test de `SettingsWelcomeMailer` (nodemailer mockeado), (b) orden transita a `PAID`
|
||||
y se envía `sendOrderStatusEmail({ state: 'PAID' })` tras `PaymentSucceeded` con
|
||||
`outcome.kind === 'processed'` (gate de dedup).
|
||||
22
work/artifacts/F-152/leader-close.json
Normal file
22
work/artifacts/F-152/leader-close.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"feature_id": "F-152",
|
||||
"agent": "leader",
|
||||
"verdict": "APPROVED",
|
||||
"summary": "F-152 closed: welcome email (account_created) on account creation y order confirmation email on PaymentSucceeded now delivered. Best-effort, idempotent (outcome.kind==='processed' gate for duplicate webhooks), XSS-safe personalization, SMTP config read from store_settings, no RBAC/RBAC surface change, no secrets in code. All gates approved; typecheck/prettier/eslint/boundaries(197 tests)/verify.sh green.",
|
||||
"checks": [
|
||||
"reviewer.json APPROVED",
|
||||
"security.json APPROVED",
|
||||
"qa.json APPROVED",
|
||||
"leader-close.json APPROVED",
|
||||
"tsc --noEmit OK (0 errors)",
|
||||
"prettier --check OK on touched files",
|
||||
"eslint OK on touched files",
|
||||
"lint:boundaries OK (no nuevas violaciones; solo R1 preexistente de security.routes)",
|
||||
"vitest run OK (197 passed)",
|
||||
"verify.sh OK (exit 0)",
|
||||
"git diff --check OK"
|
||||
],
|
||||
"commit_message": "feat(F-152): completed feature",
|
||||
"next_step": "Resumir current.md + history.md con F-152 cerrada; verificar backlog (F-152 -> done) y reanudar la feature pending siguiente por orden de current.md",
|
||||
"closed_at": "2026-08-22T05:05:18Z"
|
||||
}
|
||||
32
work/artifacts/F-152/qa.json
Normal file
32
work/artifacts/F-152/qa.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"feature_id": "F-152",
|
||||
"agent": "qa",
|
||||
"stage": "qa_gate",
|
||||
"verdict": "APPROVED",
|
||||
"summary": "F-152 verificado: welcome email se envía al email registrado (account_created) de forma best-effort con warning logueado si SMTP falla o no está configurado; el registro sigue retornando 2xx. La confirmación de orden se dispara en PaymentSucceeded con outcome.kind==='processed' (idempotente ante duplicados) y la orden pasa a PAID. Suite tsc/prettier/eslint/boundaries/vitest(197) y verify.sh verdes.",
|
||||
"evidence": [
|
||||
"SettingsWelcomeMailer.test.ts: nodemailer mockeado -> sendMail llamado una sola vez con to==email registrado y subject=='¡Bienvenido a Mercado de Vida!' (plantilla account_created)",
|
||||
"SettingsWelcomeMailer.test.ts: readSmtpOptions lanza 'SMTP is not configured' cuando falta SMTP; sendWelcome no propaga -> register handler no falla",
|
||||
"identity.routes register: welcome dispatch es fire-and-forget .catch(request.log.warn) -> warning logueado y respuesta 201 con payload de usuario",
|
||||
"payments.routes webhook: gate event.type==='PaymentSucceeded' && outcome.kind==='processed' -> sendOrderStatusEmail({state:'PAID'}); PaymentsService.test cubre transición a PAID",
|
||||
"outcome.kind==='processed' garantiza un solo email ante webhooks duplicados (PaymentsService devuelve kind:'duplicate' antes de transición)",
|
||||
"order-status-mailer.test.ts cubre buildOrderStatusEmail/PAID (preexistente, sin tocar)",
|
||||
"orders-index.test.ts verifica barrel re-exporta sendOrderStatusEmail/ORDER_STATE_LABELS/buildOrderStatusEmail",
|
||||
"Regresión: verify.sh exit 0; tsc 0 errors; prettier+eslint clean; lint:boundaries sin nuevas violaciones; 197 tests pass; git diff --check clean"
|
||||
],
|
||||
"checks": [
|
||||
{"item": "AC: welcome email enviado al email registrado", "ok": true, "evidence": "SettingsWelcomeMailer.test.ts: mail.to === 'ada@example.com' (input email), subject === account_created"},
|
||||
{"item": "AC: registro responde 2xx aunque SMTP falle/no-config", "ok": true, "evidence": "dispatch fire-and-forget .catch(request.log.warn); RegisterUser sin depender de email"},
|
||||
{"item": "AC: warning logueado ante fallo SMTP welcome", "ok": true, "evidence": ".catch(request.log.warn) en identity.routes register handler"},
|
||||
{"item": "AC: confirmación enviada en PaymentSucceeded", "ok": true, "evidence": "payments.routes: sendOrderStatusEmail({state:'PAID'}) tras outcome.kind==='processed'"},
|
||||
{"item": "AC: orden pasa a PAID", "ok": true, "evidence": "PaymentsService.handleWebhook transition + payments-service.test.ts (existente)"},
|
||||
{"item": "AC: webhook responde 2xx", "ok": true, "evidence": "reply.send sin propagar error de email; try/catch + warn"},
|
||||
{"item": "AC: webhook duplicado -> un solo email", "ok": true, "evidence": "gate outcome.kind==='processed'"},
|
||||
{"item": "AC: email falla/no-config -> orden PAID + 2xx + warn (no excepción)", "ok": true, "evidence": "try/catch en payments.routes -> request.log.warn (o console.warn)"},
|
||||
{"item": "Regresión: verify.sh/tsc/prettier/eslint/boundaries/tests", "ok": true, "evidence": "verify.sh exit 0; tsc 0; prettier+eslint clean; 197 tests pass; git diff --check clean"},
|
||||
{"item": "Regresión: password-reset + admin dispatch intactos", "ok": true, "evidence": "solo se añade account_created; no se removen plantillas ni routes"}
|
||||
],
|
||||
"issues": [],
|
||||
"notes": "No hay endpoint HTTP nuevo para validar en vivo más allá de los tests unitarios; la cobertura email está cubierta por SettingsWelcomeMailer.test.ts (nodemailer mockeado) y el gate de dedup outcome.kind. El order-confirmation wiring es difícil de unit-testear sin app full; se valida por tipo + gate lógico + transición PAID preexistente.",
|
||||
"timestamp": "2026-08-22T05:05:50Z"
|
||||
}
|
||||
30
work/artifacts/F-152/reviewer.json
Normal file
30
work/artifacts/F-152/reviewer.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"feature_id": "F-152",
|
||||
"agent": "reviewer",
|
||||
"stage": "review_gate",
|
||||
"verdict": "APPROVED",
|
||||
"summary": "F-152 implementa best-effort welcome email on account creation y order confirmation email on PaymentSucceeded, reusando SMTP/store_settings y el mailer pattern existente. El código es limpio, typecheck/prettier/eslint/boundaries/tests/verify verdes; no se edita features.json directamente; el dispatch se hace en los route handlers (observabilidad) manteniendo RegisterUser puro. Cumple acceptance.md.",
|
||||
"evidence": [
|
||||
"implementer.md presenta build evidence completa",
|
||||
"identity WelcomeMailer port + SettingsWelcomeMailer reusan settings-pattern de SettingsPasswordResetMailer",
|
||||
"welcome email dispatch in route handler (fire-and-forget) -> RegisterUser stays pure (no logger coupling)",
|
||||
"order confirmation gated on outcome.kind==='processed' -> idempotent single email on duplicate webhooks",
|
||||
"account_created added to EmailTemplate union + LoggingEmailProvider SUBJECTS/BODIES + dispatchSchema enum",
|
||||
"SettingsWelcomeMailer.test.ts: sendmail called once with to==email and subject===account_created template",
|
||||
"orders barrel re-exports sendOrderStatusEmail/ORDER_STATE_LABELS/buildOrderStatusEmail (no deep import)",
|
||||
"identity/index.ts re-exports mailers -> build-app index import (R2 boundary clean, no new violations)",
|
||||
"tsc --noEmit 0 errors; prettier+eslint clean on touched files; 197 tests pass; verify.sh green"
|
||||
],
|
||||
"checks": [
|
||||
{"item": "Spec acceptance cumplida", "ok": true, "evidence": "implementer.md mapea cada AC a evidence de test/código"},
|
||||
{"item": "Best-effort: registro/webhook nunca falla por email", "ok": true, "evidence": "welcome dispatch en .catch(request.log.warn); order confirmation en try/catch con warn"},
|
||||
{"item": "Idempotencia webhook duplicado", "ok": true, "evidence": "gate outcome.kind==='processed'; PaymentsService devuelve {kind:'duplicate'} antes de transición"},
|
||||
{"item": "XSS-safe personalization", "ok": true, "evidence": "buildWelcomeEmail HTML-escapea name (test)"},
|
||||
{"item": "Boundaries: identity no importa orders/payments a nivel dominio", "ok": true, "evidence": "identity sólo importa pg/nodemailer; payments consume orders vía barrel index; lint:boundaries sin nuevas violaciones"},
|
||||
{"item": "No edición directa de backlog/features.json", "ok": true, "evidence": "cierre vía scripts/close_feature.py"},
|
||||
{"item": "Typecheck/prettier/eslint/tests/verify", "ok": true, "evidence": "tsc 0 errors; prettier+eslint clean; 197 tests; verify.sh exit 0; git diff --check clean"},
|
||||
{"item": "README/docs actualizados si aplica", "ok": true, "evidence": "spec/tech.md actualizado al diseño final; no hay API contracts adicionales que tocar"}
|
||||
],
|
||||
"issues": [],
|
||||
"timestamp": "2026-08-22T05:04:30Z"
|
||||
}
|
||||
29
work/artifacts/F-152/security.json
Normal file
29
work/artifacts/F-152/security.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"feature_id": "F-152",
|
||||
"agent": "security",
|
||||
"stage": "security_gate",
|
||||
"verdict": "APPROVED",
|
||||
"summary": "F-152 no amplía superficie de autorización ni expone nuevos endpoints públicos. No se hardcodean credenciales SMTP (se leen de store_settings vía pool); el email de confirmación reusa el patrón existente de order-status-mailer (read-only pool lookup de identity_users.email). Los emails son best-effort y no bloquean el registro/webhook. Personalización HTML se escapa (XSS-safe). El gate outcome.kind==='processed' previene envíos duplicados ante replay de webhooks.",
|
||||
"evidence": [
|
||||
"No se añaden endpoints públicos nuevos: POST /auth/register y POST /payments/webhook ya existían",
|
||||
"No se modifica RBAC ni requireRole en ningún route; register sigue siendo público (comportamiento previsto)",
|
||||
"SMTP credentials no aparecen en código: SettingsWelcomeMailer.readSmtpOptions lee store_settings (smtp_host/port/secure/user/pass/from) vía pg pool",
|
||||
"Order confirmation email lookup es read-only pool (orders_orders.user_id -> identity_users.email), mismo patrón que sendOrderStatusEmail/order-status-mailer",
|
||||
"Best-effort: welcome .catch(request.log.warn); order confirmation en try/catch con warn -> no excepción al cliente",
|
||||
"Idempotencia webhook: outcome.kind==='processed' garantiza un solo email por PaymentSucceeded (PaymentsService devuelve kind:'duplicate' antes de transición)",
|
||||
"XSS-safe: buildWelcomeEmail HTML-escapea name (test unitario)",
|
||||
"No new data at rest; no new tokens/secrets"
|
||||
],
|
||||
"checks": [
|
||||
{"item": "Autorización / RBAC intacta", "ok": true, "evidence": "No se modifican requireRole ni middlewares de auth; register sigue público, webhook sigue sin auth (preexistente)"},
|
||||
{"item": "No credenciales SMTP en código", "ok": true, "evidence": "SettingsWelcomeMailer readSmtpOptions(pool) consulta store_settings; prettier/eslint no detectan secrets; git diff --check limpio"},
|
||||
{"item": "Lookup de email cliente read-only", "ok": true, "evidence": "payments.routes usa pool.query SELECT identity_users.email FROM orders_orders o JOIN identity_users; no writes"},
|
||||
{"item": "Best-effort no expone errores internos", "ok": true, "evidence": "welcome .catch(warn); order-conf try/catch warn; registro/webhook nunca propagan error SMTP al cliente"},
|
||||
{"item": "Idempotencia frente a replay", "ok": true, "evidence": "gate outcome.kind==='processed' + PaymentsService kind:'duplicate'"},
|
||||
{"item": "XSS en personalización", "ok": true, "evidence": "buildWelcomeEmail escapea name (test)"},
|
||||
{"item": "Data at rest / secrets", "ok": true, "evidence": "No se crean tablas ni columnas nuevas; no se añaden env vars/secretos nuevos"}
|
||||
],
|
||||
"issues": [],
|
||||
"notes": "Hardening futuro opcional: validar/descartar emails con header CRLF injection en readSmtpOptions antes de pasar a nodemailer; fuera del scope de F-152.",
|
||||
"timestamp": "2026-08-22T05:05:10Z"
|
||||
}
|
||||
@@ -1,5 +1,17 @@
|
||||
# Feature actual
|
||||
|
||||
## Sesión 2026-08-22 — F-152 cerrada (emails on account creation + order confirmation)
|
||||
|
||||
- `F-152` cerrada: welcome email (`account_created`) on `POST /auth/register` y order confirmation email on `POST /payments/webhook` (PaymentSucceeded).
|
||||
Best-effort (fire-and-forget + `request.log.warn`), idempotent (gate `outcome.kind === "processed"`),
|
||||
XSS-safe (`buildWelcomeEmail` escapea name), SMTP config de `store_settings`.
|
||||
- Gates: implementer ✅ / reviewer APPROVED ✅ / security APPROVED ✅ / qa APPROVED ✅ / leader close ✅.
|
||||
- `tsc --noEmit` 0 errores; prettier+eslint limpios en archivos tocados; `lint:boundaries` sin nuevas violaciones;
|
||||
197 tests ✅; `git diff --check` ✅; `verify.sh` exit 0.
|
||||
- Commits: `feat(F-152): completed feature` + `chore: reset runtime after F-152` (push omitido, sin remote `origin`).
|
||||
- `runtime-status.json` reseteado a idle.
|
||||
- Pendiente siguiente por orden: `F-153` (customer email missing).
|
||||
|
||||
## Sesión 2026-08-22 — F-156 cerrada (CMS dynamic rendering)
|
||||
|
||||
Backlog: **269 features, 212 done, 57 pending, 0 in_progress, 0 blocked**.
|
||||
|
||||
@@ -409,3 +409,11 @@
|
||||
- `work/current.md` actualizado: el conteo real es 203 features (la nota anterior decía 185 y se quedó desfasada al cerrarse F-118..F-135 sin actualizar `current.md`).
|
||||
- Gates: n/a (cierre de harness, no feature nueva).
|
||||
- verify.sh exit 0.
|
||||
|
||||
## 2026-08-22 — F-152 cerrada (emails on account creation + order confirmation) — DONE
|
||||
- Gates: reviewer APPROVED, security APPROVED, qa APPROVED, leader-close APPROVED, verify.sh exit 0
|
||||
- Entregable: welcome email (account_created) on account creation (POST /auth/register) y order confirmation email on PaymentSucceeded (POST /payments/webhook). Best-effort (fire-and-forget dispatch en route handler + request.log.warn), idempotent (gate outcome.kind==='processed' evita duplicados), XSS-safe (buildWelcomeEmail HTML-escapea name), SMTP config leído de store_settings (smtp_host/port/secure/user/pass/from). No cambios en RBAC ni nuevos endpoints públicos.
|
||||
- Cambios: identity WelcomeMailer port + SettingsWelcomeMailer; identity.routes register handler dispatch; IdentityRoutesDeps.welcomeMailer; identity/index.ts re-exporta mailers; build-app index import (R2 clean). payments.routes PaymentSucceeded → sendOrderStatusEmail({state:'PAID'}) con gate outcome.kind. orders/index.ts barrel re-exporta sendOrderStatusEmail/ORDER_STATE_LABELS. notifications: account_created en EmailTemplate union, LoggingEmailProvider SUBJECTS/BODIES y dispatchSchema enum.
|
||||
- Tests: SettingsWelcomeMailer.test.ts (5) + orders-index.test.ts (1); tsc --noEmit 0 errors; prettier+eslint clean en archivos tocados; lint:boundaries sin nuevas violaciones (queda solo R1 preexistente security.routes); 197 tests pass; git diff --check clean.
|
||||
- Artefactos: work/artifacts/F-152/ (architect.md, implementer.md, reviewer.json, security.json, qa.json, leader-close.json)
|
||||
- Commits: feat(F-152): completed feature + chore: reset runtime after F-152 (push omitido, sin remote origin)
|
||||
|
||||
@@ -1,11 +1,54 @@
|
||||
{
|
||||
"feature_id": null,
|
||||
"stage": "idle",
|
||||
"feature_id": "F-152",
|
||||
"stage": "close",
|
||||
"agent": "leader",
|
||||
"action": "Sin ejecución activa",
|
||||
"state": "waiting",
|
||||
"action": "Cierre F-152 via close_feature.py",
|
||||
"state": "running",
|
||||
"next_agent": "leader",
|
||||
"waiting_for": "Seleccionar una feature pending y actualizar este estado",
|
||||
"updated_at": "2026-08-22T04:21:45Z",
|
||||
"timeline": []
|
||||
"waiting_for": "leader close",
|
||||
"updated_at": "2026-08-22T05:05:12Z",
|
||||
"timeline": [
|
||||
{
|
||||
"ts": "2026-08-22T04:29:52Z",
|
||||
"agent": "architect",
|
||||
"stage": "design",
|
||||
"state": "running",
|
||||
"message": "Design F-152 customer email delivery"
|
||||
},
|
||||
{
|
||||
"ts": "2026-08-22T04:30:52Z",
|
||||
"agent": "implementer",
|
||||
"stage": "build",
|
||||
"state": "running",
|
||||
"message": "Implement welcome email + order confirmation on payment"
|
||||
},
|
||||
{
|
||||
"ts": "2026-08-22T05:03:54Z",
|
||||
"agent": "reviewer",
|
||||
"stage": "review_gate",
|
||||
"state": "running",
|
||||
"message": "F-152 build completo; advance to reviewer gate"
|
||||
},
|
||||
{
|
||||
"ts": "2026-08-22T05:04:13Z",
|
||||
"agent": "security",
|
||||
"stage": "security_gate",
|
||||
"state": "running",
|
||||
"message": "reviewer approved; advance to security gate"
|
||||
},
|
||||
{
|
||||
"ts": "2026-08-22T05:04:38Z",
|
||||
"agent": "qa",
|
||||
"stage": "qa_gate",
|
||||
"state": "running",
|
||||
"message": "security approved; advance to qa gate"
|
||||
},
|
||||
{
|
||||
"ts": "2026-08-22T05:05:12Z",
|
||||
"agent": "leader",
|
||||
"stage": "close",
|
||||
"state": "running",
|
||||
"message": "qa approved; advance to close"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user