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