334 lines
15 KiB
TypeScript
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']),
|
|
});
|
|
});
|
|
}
|