import { existsSync } from 'node:fs'; import { resolve } from 'node:path'; import { z } from 'zod'; const emptyToUndefined = (v: unknown) => (v === '' ? undefined : v); export const envSchema = z .object({ NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), PORT: z.coerce.number().int().positive().default(3000), // Orígenes permitidos por CORS, separados por coma. Vacío = mismo origen. CORS_ORIGINS: z .string() .default('') .transform((v) => v .split(',') .map((s) => s.trim()) .filter(Boolean), ), LOG_LEVEL: z.enum(['error', 'warn', 'log', 'debug', 'verbose']).default('log'), // Base SQLite local (permisos de menú). `:memory:` para pruebas. SQLITE_PATH: z.string().min(1).default('./data/app.sqlite'), // Usuarios web (U_WEB_USER) que siempre tienen acceso total ("*"); se // garantiza al arrancar para no quedar fuera por un error de permisos. ADMIN_WEB_USERS: z .string() .default('') .transform((v) => v .split(',') .map((s) => s.trim()) .filter(Boolean), ), // Inactividad tras la cual caduca la sesión web (deslizante). SESSION_TIMEOUT_MINUTES: z.coerce.number().int().positive().default(30), // --- SAP Business One Service Layer --- SAPB1_SL_URL: z .url() .transform((v) => v.replace(/\/+$/, '')), SAPB1_SL_COMPANYDB: z.string().min(1), SAPB1_SL_USER: z.string().min(1), SAPB1_SL_PASSWORD: z.string().min(1), SAPB1_SL_TIMEOUT_MS: z.coerce.number().int().positive().default(15000), // Peticiones simultáneas permitidas hacia el Service Layer; el resto se encola. // Medido en SAP10 (12 peticiones): con 1 -> 0.6 s en total; con 2 ya tarda 7.5 s y con // 4, 22 s (el Service Layer retrasa ~7 s por cada petición paralela extra). Default: 1. SAPB1_SL_MAX_CONCURRENCY: z.coerce.number().int().positive().default(1), // CA adicional de confianza (PEM): certificado autofirmado del Service // Layer o CA de un firewall que inspecciona SSL. Opcional. SAPB1_SL_CA_FILE: z.preprocess(emptyToUndefined, z.string().optional()), // --- SAP Business One API Gateway (reportes Crystal): opcional; sin URL el módulo queda sin configurar --- APIGATEWAY_URL: z.preprocess(emptyToUndefined, z.url().optional()).transform((v) => v?.replace(/\/+$/, '')), APIGATEWAY_USER: z.preprocess(emptyToUndefined, z.string().optional()), APIGATEWAY_PASSWORD: z.preprocess(emptyToUndefined, z.string().optional()), APIGATEWAY_COMPANYDB: z.preprocess(emptyToUndefined, z.string().optional()), APIGATEWAY_DBINSTANCE: z.preprocess(emptyToUndefined, z.string().optional()), // Espera máxima (segundos) de cada llamada: generar un reporte puede tardar. APIGATEWAY_TIMEOUT: z.coerce.number().positive().default(60), // Reportes que se generan a la vez; el resto espera su turno. APIGATEWAY_MAX_CONCURRENCY: z.coerce.number().int().positive().default(2), APIGATEWAY_CA_FILE: z.preprocess(emptyToUndefined, z.string().optional()), APIGATEWAY_VERIFY_SSL: z .enum(['true', 'false']) .default('true') .transform((v) => v === 'true'), // --- Base de datos de SAP B1 (consultas de solo lectura registradas): opcional; sin motor el módulo queda sin configurar --- SAPDB_ENGINE: z.preprocess(emptyToUndefined, z.enum(['hana']).optional()), SAPDB_HOST: z.preprocess(emptyToUndefined, z.string().optional()), SAPDB_PORT: z.coerce.number().int().min(1).max(65535).default(30015), SAPDB_USER: z.preprocess(emptyToUndefined, z.string().optional()), SAPDB_PASSWORD: z.preprocess(emptyToUndefined, z.string().optional()), // Esquema de la empresa (en HANA, el nombre de la base: `SBO_TAGUMEDICA_21`). SAPDB_SCHEMA: z.preprocess(emptyToUndefined, z.string().regex(/^\w+$/, 'solo letras, dígitos y «_»').optional()), SAPDB_TLS: z .enum(['true', 'false']) .default('false') .transform((v) => v === 'true'), SAPDB_CA_FILE: z.preprocess(emptyToUndefined, z.string().optional()), SAPDB_VERIFY_SSL: z .enum(['true', 'false']) .default('true') .transform((v) => v === 'true'), // Espera máxima (segundos) de cada consulta. SAPDB_TIMEOUT: z.coerce.number().positive().default(30), // Consultas que se ejecutan a la vez; el resto espera su turno (la base de SAP no es para esto). SAPDB_MAX_CONCURRENCY: z.coerce.number().int().positive().default(2), // Tope de filas de cualquier consulta; lo que pase se recorta. SAPDB_MAX_ROWS: z.coerce.number().int().positive().default(5000), SAPB1_SL_VERIFY_SSL: z .enum(['true', 'false']) .default('true') .transform((v) => v === 'true'), }) .superRefine((env, ctx) => { if (env.SAPB1_SL_CA_FILE && !existsSync(resolve(env.SAPB1_SL_CA_FILE))) { ctx.addIssue({ code: 'custom', path: ['SAPB1_SL_CA_FILE'], message: `no existe el archivo ${resolve(env.SAPB1_SL_CA_FILE)}`, }); } if (env.APIGATEWAY_CA_FILE && !existsSync(resolve(env.APIGATEWAY_CA_FILE))) { ctx.addIssue({ code: 'custom', path: ['APIGATEWAY_CA_FILE'], message: `no existe el archivo ${resolve(env.APIGATEWAY_CA_FILE)}` }); } if (env.SAPDB_CA_FILE && !existsSync(resolve(env.SAPDB_CA_FILE))) { ctx.addIssue({ code: 'custom', path: ['SAPDB_CA_FILE'], message: `no existe el archivo ${resolve(env.SAPDB_CA_FILE)}` }); } if (env.SAPDB_ENGINE && !(env.SAPDB_HOST && env.SAPDB_USER && env.SAPDB_PASSWORD && env.SAPDB_SCHEMA)) { ctx.addIssue({ code: 'custom', path: ['SAPDB_ENGINE'], message: 'con SAPDB_ENGINE hacen falta SAPDB_HOST, SAPDB_USER, SAPDB_PASSWORD y SAPDB_SCHEMA' }); } if (env.NODE_ENV === 'production' && env.SAPDB_TLS && !env.SAPDB_VERIFY_SSL) { ctx.addIssue({ code: 'custom', path: ['SAPDB_VERIFY_SSL'], message: 'no se permite desactivar la validación SSL en producción; use SAPDB_CA_FILE' }); } if (env.APIGATEWAY_URL && !(env.APIGATEWAY_USER && env.APIGATEWAY_PASSWORD && env.APIGATEWAY_COMPANYDB)) { ctx.addIssue({ code: 'custom', path: ['APIGATEWAY_URL'], message: 'con APIGATEWAY_URL hacen falta APIGATEWAY_USER, APIGATEWAY_PASSWORD y APIGATEWAY_COMPANYDB' }); } if (env.NODE_ENV === 'production' && !env.APIGATEWAY_VERIFY_SSL) { ctx.addIssue({ code: 'custom', path: ['APIGATEWAY_VERIFY_SSL'], message: 'no se permite desactivar la validación SSL en producción; use APIGATEWAY_CA_FILE' }); } if (env.NODE_ENV === 'production' && !env.SAPB1_SL_VERIFY_SSL) { ctx.addIssue({ code: 'custom', path: ['SAPB1_SL_VERIFY_SSL'], message: 'no se permite desactivar la validación SSL en producción; use SAPB1_SL_CA_FILE', }); } }); export type Env = z.infer; export function validateEnv(raw: Record): Env { const result = envSchema.safeParse(raw); if (!result.success) { const detail = result.error.issues .map((i) => `${i.path.join('.')}: ${i.message}`) .join('; '); throw new Error(`Configuración inválida (.env): ${detail}`); } return result.data; }