Files
mercadodevida/project/src/modules/store-settings/api/settings.routes.ts
2026-08-25 22:08:20 +02:00

334 lines
15 KiB
TypeScript

import { readFileSync } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import type { FastifyInstance } from 'fastify';
import type { FastifySchema } from 'fastify';
import type pg from 'pg';
import { z } from 'zod';
import type { Authenticate } from '../../../shared/auth.js';
import { requireRole } from '../../../shared/auth.js';
import { parseJson } from '../../../shared/http-input.js';
import { errorSchema } from '../../../shared/swagger.js';
export interface StoreSettingsRoutesDeps {
pool: pg.Pool;
authenticate: Authenticate;
}
const updateSettingsSchema = z.object({
storeName: z.string().min(1).max(120).optional(),
storeTagline: z.string().max(200).optional(),
contactEmail: z.string().email().optional(),
contactPhone: z.string().max(40).optional(),
contactAddress: z.string().max(400).optional(),
footerText: z.string().max(400).optional(),
facebookUrl: z.string().url().optional().or(z.literal('')),
instagramUrl: z.string().url().optional().or(z.literal('')),
twitterUrl: z.string().url().optional().or(z.literal('')),
pinterestUrl: z.string().url().optional().or(z.literal('')),
aiProvider: z.string().max(80).optional(),
aiBaseUrl: z.string().url().optional().or(z.literal('')),
aiModel: z.string().max(120).optional(),
aiApiKey: z.string().max(500).optional(),
aiSeoTitlePrompt: z.string().max(2000).optional(),
aiSeoDescriptionPrompt: z.string().max(4000).optional(),
aiProductDescriptionPrompt: z.string().max(4000).optional(),
aiCategoryDescriptionPrompt: z.string().max(4000).optional(),
aiCategorySeoTitlePrompt: z.string().max(2000).optional(),
aiCategorySeoDescriptionPrompt: 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('')),
smtpReportEmail: z.string().email().optional().or(z.literal('')),
couriers: z.array(z.string().trim().min(1).max(60)).max(30).optional(),
});
/** Lista de transportistas por defecto hasta que el admin la edite. */
export const DEFAULT_COURIERS = ['Correos', 'SEUR', 'MRW', 'GLS', 'DHL', 'UPS'];
export function parseCouriers(raw: string | undefined | null): string[] {
if (!raw?.trim()) return [...DEFAULT_COURIERS];
try {
const parsed: unknown = JSON.parse(raw);
if (!Array.isArray(parsed)) return [...DEFAULT_COURIERS];
const list = parsed
.filter((item): item is string => typeof item === 'string')
.map((item) => item.trim())
.filter((item) => item.length > 0)
.slice(0, 30);
return list.length > 0 ? list : [...DEFAULT_COURIERS];
} catch {
return [...DEFAULT_COURIERS];
}
}
type ServiceVersion = {
id: string;
name: string;
version: string;
};
function readTextIfExists(filePath: string): string | null {
try {
return readFileSync(filePath, 'utf8').trim();
} catch {
return null;
}
}
function readPackageVersion(id: string, label: string, packagePath: string): ServiceVersion {
try {
const raw = readFileSync(packagePath, 'utf8');
const pkg = JSON.parse(raw) as { name?: string; version?: string };
return {
id,
name: pkg.name ?? label,
version: pkg.version ?? 'unknown',
};
} catch {
return { id, name: label, version: 'unknown' };
}
}
function collectServiceVersions(): { productVersion: string; services: ServiceVersion[] } {
const root = process.cwd();
return {
productVersion: readTextIfExists(path.join(root, 'VERSION')) ?? 'unknown',
services: [
readPackageVersion('backend', 'Backend API', path.join(root, 'package.json')),
readPackageVersion('admin', 'Admin', path.join(root, 'apps/admin/package.json')),
readPackageVersion('tpv', 'TPV', path.join(root, 'apps/pos/package.json')),
readPackageVersion('frontend', 'Frontend', path.join(root, 'frontend/package.json')),
readPackageVersion('storefront', 'Storefront SEO', path.join(root, 'storefront/package.json')),
],
};
}
const PROCESS_STARTED_AT = new Date();
const SETTING_KEYS: Record<string, string> = {
storeName: 'store_name',
storeTagline: 'store_tagline',
contactEmail: 'contact_email',
contactPhone: 'contact_phone',
contactAddress: 'contact_address',
footerText: 'footer_text',
facebookUrl: 'facebook_url',
instagramUrl: 'instagram_url',
twitterUrl: 'twitter_url',
pinterestUrl: 'pinterest_url',
aiProvider: 'ai_provider',
aiBaseUrl: 'ai_base_url',
aiModel: 'ai_model',
aiApiKey: 'ai_api_key',
aiSeoTitlePrompt: 'ai_seo_title_prompt',
aiSeoDescriptionPrompt: 'ai_seo_description_prompt',
aiProductDescriptionPrompt: 'ai_product_description_prompt',
aiCategoryDescriptionPrompt: 'ai_category_description_prompt',
aiCategorySeoTitlePrompt: 'ai_category_seo_title_prompt',
aiCategorySeoDescriptionPrompt: 'ai_category_seo_description_prompt',
smtpHost: 'smtp_host',
smtpPort: 'smtp_port',
smtpSecure: 'smtp_secure',
smtpUser: 'smtp_user',
smtpPass: 'smtp_pass',
smtpFrom: 'smtp_from',
smtpReportEmail: 'smtp_report_email',
};
export async function registerStoreSettingsRoutes(
app: FastifyInstance,
deps: StoreSettingsRoutesDeps,
): Promise<void> {
// GET /admin/about — versions and safe server diagnostics for admin UI
const getAboutSchema: FastifySchema = {
tags: ['Admin'],
summary: 'Get service versions and server info (admin)',
response: { 401: errorSchema, 403: errorSchema },
};
app.get('/admin/about', { schema: getAboutSchema }, async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const { productVersion, services } = collectServiceVersions();
const dbVersion = await deps.pool
.query<{ server_version: string }>('SHOW server_version')
.then((result) => result.rows[0]?.server_version ?? 'unknown')
.catch(() => 'unavailable');
const memory = process.memoryUsage();
return reply.send({
productVersion,
generatedAt: new Date().toISOString(),
services,
server: {
hostname: os.hostname(),
platform: os.platform(),
arch: os.arch(),
release: os.release(),
uptimeSeconds: Math.floor(os.uptime()),
processUptimeSeconds: Math.floor(process.uptime()),
processStartedAt: PROCESS_STARTED_AT.toISOString(),
nodeVersion: process.version,
pid: process.pid,
cpuCount: os.cpus().length,
totalMemoryMb: Math.round(os.totalmem() / 1024 / 1024),
freeMemoryMb: Math.round(os.freemem() / 1024 / 1024),
processMemoryMb: {
rss: Math.round(memory.rss / 1024 / 1024),
heapUsed: Math.round(memory.heapUsed / 1024 / 1024),
heapTotal: Math.round(memory.heapTotal / 1024 / 1024),
},
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
},
database: {
postgresVersion: dbVersion,
},
});
});
// GET /admin/settings — fetch all settings
const getSettingsSchema: FastifySchema = {
tags: ['Admin'],
summary: 'Get store settings (admin)',
response: { 401: errorSchema, 403: errorSchema },
};
app.get('/admin/settings', { schema: getSettingsSchema }, async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const result = await deps.pool.query<{ key: string; value: string }>(
`SELECT key, value FROM store_settings`,
);
const map: Record<string, string> = {};
for (const row of result.rows) {
map[row.key] = row.value;
}
return reply.send({
storeName: map['store_name'] ?? '',
storeTagline: map['store_tagline'] ?? '',
contactEmail: map['contact_email'] ?? '',
contactPhone: map['contact_phone'] ?? '',
contactAddress: map['contact_address'] ?? '',
footerText: map['footer_text'] ?? '',
facebookUrl: map['facebook_url'] ?? '',
instagramUrl: map['instagram_url'] ?? '',
twitterUrl: map['twitter_url'] ?? '',
pinterestUrl: map['pinterest_url'] ?? '',
aiProvider: map['ai_provider'] ?? '',
aiBaseUrl: map['ai_base_url'] ?? '',
aiModel: map['ai_model'] ?? '',
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.',
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.',
aiCategoryDescriptionPrompt: map['ai_category_description_prompt'] ?? 'Escribe una descripción breve y atractiva en español para esta categoría de una tienda de productos naturales y ecológicos: {{name}}. Devuelve solo la descripción, sin formato.',
aiCategorySeoTitlePrompt: map['ai_category_seo_title_prompt'] ?? 'Genera un título SEO breve y atractivo para esta categoría: {{name}}. Devuelve solo el título.',
aiCategorySeoDescriptionPrompt: map['ai_category_seo_description_prompt'] ?? 'Genera una meta descripción SEO en español, clara y persuasiva, para esta categoría: {{name}}. 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 ?? '',
smtpReportEmail: map['smtp_report_email'] ?? '',
couriers: parseCouriers(map['shipping_couriers']),
});
});
// PATCH /admin/settings — update one or more settings
const patchSettingsSchema: FastifySchema = {
tags: ['Admin'],
summary: 'Update store settings (admin)',
body: { type: 'object' },
response: { 401: errorSchema, 403: errorSchema },
};
app.patch('/admin/settings', { schema: patchSettingsSchema }, async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const input = parseJson(updateSettingsSchema, request.body);
const updates: string[] = [];
const values: unknown[] = [];
const identityUser = await deps.pool.query<{ id: string }>(
`SELECT id FROM identity_users WHERE id = $1`,
[user.id],
);
// Backoffice users are a separate table. Keep the audit FK valid when the
// authenticated backoffice id has no corresponding identity_users row.
const updatedBy = identityUser.rowCount ? user.id : null;
let i = 1;
for (const [field, dbKey] of Object.entries(SETTING_KEYS)) {
const val = input[field as keyof typeof input];
if (val !== undefined) {
updates.push(`key = CASE WHEN key = $${i} THEN $${i + 1} ELSE key END`);
values.push(dbKey, val);
i += 2;
}
}
if (updates.length > 0) {
// Use INSERT ... ON CONFLICT for each key
for (const [field, dbKey] of Object.entries(SETTING_KEYS)) {
const val = input[field as keyof typeof input];
if (val !== undefined) {
await deps.pool.query(
`INSERT INTO store_settings (key, value, updated_by) VALUES ($1, $2, $3)
ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW(), updated_by = $3`,
[dbKey, val, updatedBy],
);
}
}
}
if (input.couriers !== undefined) {
await deps.pool.query(
`INSERT INTO store_settings (key, value, updated_by) VALUES ('shipping_couriers', $1, $2)
ON CONFLICT (key) DO UPDATE SET value = $1, updated_at = NOW(), updated_by = $2`,
[JSON.stringify(input.couriers), updatedBy],
);
}
// Return updated settings
const result = await deps.pool.query<{ key: string; value: string }>(
`SELECT key, value FROM store_settings`,
);
const map: Record<string, string> = {};
for (const row of result.rows) {
map[row.key] = row.value;
}
return reply.send({
storeName: map['store_name'] ?? '',
storeTagline: map['store_tagline'] ?? '',
contactEmail: map['contact_email'] ?? '',
contactPhone: map['contact_phone'] ?? '',
contactAddress: map['contact_address'] ?? '',
footerText: map['footer_text'] ?? '',
facebookUrl: map['facebook_url'] ?? '',
instagramUrl: map['instagram_url'] ?? '',
twitterUrl: map['twitter_url'] ?? '',
pinterestUrl: map['pinterest_url'] ?? '',
aiProvider: map['ai_provider'] ?? '',
aiBaseUrl: map['ai_base_url'] ?? '',
aiModel: map['ai_model'] ?? '',
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}}. 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.',
aiCategoryDescriptionPrompt: map['ai_category_description_prompt'] ?? 'Escribe una descripción breve y atractiva en español para esta categoría de una tienda de productos naturales y ecológicos: {{name}}. Devuelve solo la descripción, sin formato.',
aiCategorySeoTitlePrompt: map['ai_category_seo_title_prompt'] ?? 'Genera un título SEO breve y atractivo para esta categoría: {{name}}. Devuelve solo el título.',
aiCategorySeoDescriptionPrompt: map['ai_category_seo_description_prompt'] ?? 'Genera una meta descripción SEO en español, clara y persuasiva, para esta categoría: {{name}}. 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 ?? '',
smtpReportEmail: map['smtp_report_email'] ?? '',
couriers: parseCouriers(map['shipping_couriers']),
});
});
}