feat(F-152): completed feature
This commit is contained in:
@@ -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 });
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user