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 = { 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 { // 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 = {}; 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 = {}; 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']), }); }); }