feat(F-152): completed feature

This commit is contained in:
chattie
2026-08-22 07:09:03 +02:00
parent 2e9ddf4476
commit b008df4f37
25 changed files with 782 additions and 111 deletions

View File

@@ -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;
}
});
}
}

View File

@@ -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>;
}

View File

@@ -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';

View File

@@ -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('&lt;b&gt;Ada&lt;/b&gt;');
});
});
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);
});
});

View File

@@ -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) =>
({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
})[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,
};
}
}

View File

@@ -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),

View File

@@ -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;

View File

@@ -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.',

View File

@@ -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,

View 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');
});
});

View File

@@ -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 });
});