feat(F-099): completed feature

This commit is contained in:
chattie
2026-08-21 07:29:07 +02:00
parent 3f1d08382f
commit 5177a851aa
41 changed files with 922 additions and 131 deletions

View File

@@ -266,7 +266,7 @@ export async function registerCatalogRoutes(
const settingsResult = await deps.pool.query<{ key: string; value: string }>(
`SELECT key, value FROM store_settings WHERE key = ANY($1::text[])`,
[['ai_base_url', 'ai_model', 'ai_api_key', 'ai_seo_title_prompt', 'ai_seo_description_prompt']],
[['ai_base_url', 'ai_model', 'ai_api_key', 'ai_seo_title_prompt', 'ai_seo_description_prompt', 'ai_product_description_prompt']],
);
const settings = Object.fromEntries(settingsResult.rows.map((row) => [row.key, row.value]));
const baseUrl = settings.ai_base_url?.trim();
@@ -283,7 +283,10 @@ export async function registerCatalogRoutes(
};
const promptFor = (template: string | undefined, fallback: string) =>
(template || fallback).replace(/\{\{(name|description|brand)\}\}/g, (_, key: string) => replacements[key] ?? '');
const patch: { seoTitle?: string; seoDescription?: string } = {};
const patch: { description?: string; seoTitle?: string; seoDescription?: string } = {};
if (!product.description?.trim()) {
patch.description = (await generateWithModel(baseUrl, model, apiKey, promptFor(settings.ai_product_description_prompt, 'Escribe una descripción comercial clara y útil en español para este producto: {{name}}. Devuelve solo la descripción.'))).slice(0, 2_000);
}
if (!product.seoTitle?.trim()) {
patch.seoTitle = (await generateWithModel(baseUrl, model, apiKey, promptFor(settings.ai_seo_title_prompt, 'Genera un título SEO breve para {{name}}. Devuelve solo el título.'))).slice(0, 200);
}

View File

@@ -39,6 +39,7 @@ import {
RequestPasswordReset,
} from '../application/password-reset.js';
import { InvalidResetTokenError } from '../domain/password-reset.js';
import { createPasswordResetMailer } from '../infrastructure/smtp-password-reset-mailer.js';
export const SESSION_COOKIE_NAME = 'mdv_session';
@@ -234,15 +235,30 @@ 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) }),
request.body,
);
const record = await users.findByEmail(user.email);
if (!record || !(await hasher.verify(record.passwordHash, input.currentPassword))) {
throw new AppError(400, 'INVALID_CURRENT_PASSWORD', 'La contraseña actual no es válida');
}
await users.updateUser(user.id, { passwordHash: await hasher.hash(input.newPassword) });
return reply.send({ ok: true });
});
if (deps.passwordReset) {
const pr = deps.passwordReset;
const tokens = pr.tokens ?? new PgPasswordResetTokenRepository(deps.pool);
const usersRepo = new PgUserRepository(deps.pool);
const rateLimiter = pr.rateLimiter ?? new InMemoryResetRateLimiter();
const mailer = pr.mailer ?? new LoggingPasswordResetMailer();
const mailer = pr.mailer ?? createPasswordResetMailer();
const publicAppUrl = (process.env.PUBLIC_APP_URL ?? 'https://mercadodevida.es').replace(/\/$/, '');
const buildResetUrl =
pr.buildResetUrl ??
((token: string) => `/cuenta/restablecer?token=${encodeURIComponent(token)}`);
((token: string) => `${publicAppUrl}/cuenta/restablecer?token=${encodeURIComponent(token)}`);
const requestReset = new RequestPasswordReset({
users: usersRepo,
@@ -293,6 +309,15 @@ export async function registerIdentityRoutes(
'/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,
@@ -350,22 +375,6 @@ export class InMemoryResetRateLimiter implements ResetRateLimiter {
}
}
/** Logs the reset email to stdout; production should replace with a real provider. */
export class LoggingPasswordResetMailer implements PasswordResetMailer {
async sendPasswordReset(input: { email: string; resetUrl: string; locale?: string }): Promise<void> {
// eslint-disable-next-line no-console
console.log(
JSON.stringify({
level: 'info',
msg: 'password_reset_email',
to: input.email,
url: input.resetUrl,
locale: input.locale ?? 'es',
}),
);
}
}
function setSessionCookie(reply: FastifyReply, token: string, secure: boolean): void {
void reply.setCookie(SESSION_COOKIE_NAME, token, {
path: '/',

View File

@@ -50,5 +50,8 @@ export interface ResetRateLimiter {
}
export interface PasswordResetMailer {
/** Optional readiness check; absent in test doubles and legacy adapters. */
isConfigured?: () => boolean;
assertReady?: () => Promise<void>;
sendPasswordReset(input: { email: string; resetUrl: string; locale?: string }): Promise<void>;
}

View File

@@ -0,0 +1,51 @@
import type pg from 'pg';
import type { PasswordResetMailer } from '../domain/ports.js';
import { SmtpPasswordResetMailer, type SmtpPasswordResetMailerOptions } from './smtp-password-reset-mailer.js';
const SMTP_KEYS = ['smtp_host', 'smtp_port', 'smtp_secure', 'smtp_user', 'smtp_pass', 'smtp_from'] as const;
/** Reads SMTP credentials from store_settings so admins can change them without a deploy. */
export class SettingsPasswordResetMailer implements PasswordResetMailer {
constructor(private readonly pool: pg.Pool) {}
isConfigured(): boolean {
return true;
}
async assertReady(): Promise<void> {
await this.readMailer();
}
async sendPasswordReset(input: { email: string; resetUrl: string; locale?: string }): Promise<void> {
const mailer = await this.readMailer();
await mailer.sendPasswordReset(input);
}
private async readMailer(): Promise<SmtpPasswordResetMailer> {
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');
}
const options: SmtpPasswordResetMailerOptions = {
host,
port,
secure: settings.smtp_secure !== 'false' || port === 465,
user,
password,
from,
};
return new SmtpPasswordResetMailer(options);
}
}

View File

@@ -0,0 +1,90 @@
import nodemailer, { type Transporter } from 'nodemailer';
import type { PasswordResetMailer } from '../domain/ports.js';
export interface SmtpPasswordResetMailerOptions {
host: string;
port: number;
secure: boolean;
user: string;
password: string;
from: string;
}
/** Sends password-reset messages through an authenticated SMTP server. */
export class SmtpPasswordResetMailer implements PasswordResetMailer {
private readonly transporter: Transporter;
constructor(private readonly options: SmtpPasswordResetMailerOptions) {
this.transporter = nodemailer.createTransport({
host: options.host,
port: options.port,
secure: options.secure,
auth: { user: options.user, pass: options.password },
});
}
isConfigured(): boolean {
return true;
}
async assertReady(): Promise<void> {
return;
}
async sendPasswordReset(input: { email: string; resetUrl: string; locale?: string }): Promise<void> {
await this.transporter.sendMail({
from: this.options.from,
to: input.email,
subject: 'Restablece tu contraseña — Mercado de Vida',
text: [
'Has solicitado restablecer tu contraseña de Mercado de Vida.',
'',
`Abre este enlace para continuar: ${input.resetUrl}`,
'',
'Si no solicitaste este cambio, puedes ignorar este correo.',
'El enlace caduca en una hora y solo puede utilizarse una vez.',
].join('\n'),
html: [
'<p>Has solicitado restablecer tu contraseña de Mercado de Vida.</p>',
`<p><a href="${escapeHtml(input.resetUrl)}">Restablecer contraseña</a></p>`,
'<p>Si no solicitaste este cambio, puedes ignorar este correo.</p>',
'<p>El enlace caduca en una hora y solo puede utilizarse una vez.</p>',
].join(''),
});
}
}
export function createPasswordResetMailer(env: NodeJS.ProcessEnv = process.env): PasswordResetMailer {
const host = env.SMTP_HOST?.trim();
const user = env.SMTP_USER?.trim();
const password = env.SMTP_PASS;
const from = env.SMTP_FROM?.trim() || user;
if (!host || !user || !password || !from) {
return {
isConfigured: () => false,
async assertReady() {
throw new Error('SMTP is not configured; set SMTP_HOST, SMTP_USER, SMTP_PASS and SMTP_FROM');
},
async sendPasswordReset() {
throw new Error('SMTP is not configured; set SMTP_HOST, SMTP_USER, SMTP_PASS and SMTP_FROM');
},
};
}
const port = Number(env.SMTP_PORT ?? '587');
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error('SMTP_PORT must be a valid TCP port');
}
const secure = env.SMTP_SECURE === 'true' || port === 465;
return new SmtpPasswordResetMailer({ host, port, secure, user, password, from });
}
function escapeHtml(value: string): string {
return value.replace(/[&<>"']/g, (character) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
})[character] ?? character);
}

View File

@@ -27,6 +27,13 @@ const updateSettingsSchema = z.object({
aiApiKey: z.string().max(500).optional(),
aiSeoTitlePrompt: z.string().max(2000).optional(),
aiSeoDescriptionPrompt: z.string().max(4000).optional(),
aiProductDescriptionPrompt: z.string().max(4000).optional(),
smtpHost: z.string().max(255).optional(),
smtpPort: z.coerce.number().int().min(1).max(65535).optional(),
smtpSecure: z.boolean().optional(),
smtpUser: z.string().max(255).optional(),
smtpPass: z.string().max(500).optional(),
smtpFrom: z.string().email().optional().or(z.literal('')),
});
const SETTING_KEYS: Record<string, string> = {
@@ -44,6 +51,13 @@ const SETTING_KEYS: Record<string, string> = {
aiApiKey: 'ai_api_key',
aiSeoTitlePrompt: 'ai_seo_title_prompt',
aiSeoDescriptionPrompt: 'ai_seo_description_prompt',
aiProductDescriptionPrompt: 'ai_product_description_prompt',
smtpHost: 'smtp_host',
smtpPort: 'smtp_port',
smtpSecure: 'smtp_secure',
smtpUser: 'smtp_user',
smtpPass: 'smtp_pass',
smtpFrom: 'smtp_from',
};
export async function registerStoreSettingsRoutes(
@@ -82,6 +96,14 @@ export async function registerStoreSettingsRoutes(
aiApiKeyConfigured: Boolean(map['ai_api_key']),
aiSeoTitlePrompt: map['ai_seo_title_prompt'] ?? 'Genera un título SEO breve y atractivo para este producto: {{name}}. Devuelve solo el título.',
aiSeoDescriptionPrompt: map['ai_seo_description_prompt'] ?? 'Genera una meta descripción SEO en español, clara y persuasiva, para este producto: {{name}}. Devuelve solo la descripción.',
aiProductDescriptionPrompt: map['ai_product_description_prompt'] ?? 'Escribe una descripción comercial clara y útil en español para este producto: {{name}}. Incluye sus beneficios y características usando solo la información disponible. Devuelve solo la descripción.',
smtpHost: map['smtp_host'] ?? process.env.SMTP_HOST ?? '',
smtpPort: map['smtp_port'] ?? process.env.SMTP_PORT ?? '465',
smtpSecure: (map['smtp_secure'] ?? process.env.SMTP_SECURE ?? 'true') !== 'false',
smtpUser: map['smtp_user'] ?? process.env.SMTP_USER ?? '',
smtpPass: '',
smtpPassConfigured: Boolean(map['smtp_pass'] || process.env.SMTP_PASS),
smtpFrom: map['smtp_from'] ?? process.env.SMTP_FROM ?? '',
});
});
@@ -150,7 +172,15 @@ export async function registerStoreSettingsRoutes(
aiApiKey: '',
aiApiKeyConfigured: Boolean(map['ai_api_key']),
aiSeoTitlePrompt: map['ai_seo_title_prompt'] ?? 'Genera un título SEO breve y atractivo para este producto: {{name}}. Devuelve solo el título.',
aiSeoDescriptionPrompt: map['ai_seo_description_prompt'] ?? 'Genera una meta descripción SEO en español, clara y persuasiva, para este producto: {{name}}. Devuelve solo la descripción.',
aiSeoDescriptionPrompt: map['ai_seo_description_prompt'] ?? 'Genera una meta descripción SEO en español, clara y persuasiva, para este producto: {{name}}. Incluye sus beneficios y características usando solo la información disponible. Devuelve solo la descripción.',
aiProductDescriptionPrompt: map['ai_product_description_prompt'] ?? 'Escribe una descripción comercial clara y útil en español para este producto: {{name}}. Incluye sus beneficios y características usando solo la información disponible. Devuelve solo la descripción.',
smtpHost: map['smtp_host'] ?? process.env.SMTP_HOST ?? '',
smtpPort: map['smtp_port'] ?? process.env.SMTP_PORT ?? '465',
smtpSecure: (map['smtp_secure'] ?? process.env.SMTP_SECURE ?? 'true') !== 'false',
smtpUser: map['smtp_user'] ?? process.env.SMTP_USER ?? '',
smtpPass: '',
smtpPassConfigured: Boolean(map['smtp_pass'] || process.env.SMTP_PASS),
smtpFrom: map['smtp_from'] ?? process.env.SMTP_FROM ?? '',
});
});
}