This commit is contained in:
David Arranz 2026-02-12 15:46:47 +01:00
parent 3a1c7e0844
commit 71b98fb6c2
65 changed files with 696 additions and 485 deletions

2
.vscode/launch.json vendored
View File

@ -1,5 +1,5 @@
{
"version": "0.3.6",
"version": "0.4.7",
"configurations": [
{
"name": "WEB: Vite (Chrome)",

View File

@ -44,6 +44,35 @@ RUN mkdir -p /usr/share/fonts/truetype/barlow \
&& fc-cache -f -v
## Configurar locale, timezone y variables de entorno para España
# Evita prompts interactivos
ENV DEBIAN_FRONTEND=noninteractive
# Instalar paquetes necesarios
RUN apt-get update && apt-get install -y \
locales \
tzdata \
&& rm -rf /var/lib/apt/lists/*
# Generar locale español de España UTF-8
RUN sed -i '/es_ES.UTF-8/s/^# //g' /etc/locale.gen \
&& locale-gen
# Configurar variables de entorno de locale
ENV LANG=es_ES.UTF-8 \
LANGUAGE=es_ES:es \
LC_ALL=es_ES.UTF-8
# Configurar zona horaria España
ENV TZ=Europe/Madrid
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime \
&& echo $TZ > /etc/timezone
# Verificación opcional
# RUN locale && date
# Reducir tamaño de la imagen
RUN rm -rf /var/lib/apt/lists/*

View File

@ -1,3 +1,5 @@
import { Maybe, Result } from "@repo/rdx-utils";
import { Account, AccountStatus } from "@/contexts/accounts/domain/";
import {
EmailAddress,
@ -11,8 +13,8 @@ import {
type MapperParamsType,
SequelizeMapper,
} from "@/core/common/infrastructure/sequelize/sequelize-mapper";
import { Maybe, Result } from "@repo/rdx-utils";
import { AccountCreationAttributes, AccountModel } from "../sequelize/account.model";
import type { AccountCreationAttributes, AccountModel } from "../sequelize/account.model";
export interface IAccountMapper
extends ISequelizeMapper<AccountModel, AccountCreationAttributes, Account> {}

View File

@ -1,6 +1,6 @@
{
"name": "@erp/factuges-server",
"version": "0.3.6",
"version": "0.4.7",
"private": true,
"scripts": {
"build": "tsup src/index.ts --config tsup.config.ts",
@ -38,6 +38,7 @@
"@erp/customer-invoices": "workspace:*",
"@erp/customers": "workspace:*",
"@repo/rdx-logger": "workspace:*",
"@repo/rdx-utils": "workspace:*",
"bcrypt": "^5.1.1",
"cls-rtracer": "^2.6.3",
"cors": "^2.8.5",

View File

@ -3,12 +3,14 @@ import express, { type Application } from "express";
import helmet from "helmet";
import responseTime from "response-time";
import type { ConfigType } from "./config";
// ❗️ No cargamos dotenv aquí. Debe hacerse en el entrypoint o en ./config.
// dotenv.config();
import { ENV } from "./config";
import { logger } from "./lib/logger";
export function createApp(): Application {
export function createApp(config: ConfigType): Application {
const app = express();
// ───────────────────────────────────────────────────────────────────────────
@ -30,7 +32,7 @@ export function createApp(): Application {
};
const prodCors: CorsOptions = {
origin: ENV.FRONTEND_URL,
origin: config.server.frontendUrl,
credentials: true,
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
exposedHeaders: [
@ -44,10 +46,10 @@ export function createApp(): Application {
],
};
app.use(cors(ENV.NODE_ENV === "development" ? devCors : prodCors));
app.options("*", cors(ENV.NODE_ENV === "development" ? devCors : prodCors));
app.use(cors(config.flags.isDev ? devCors : prodCors));
app.options("*", cors(config.flags.isDev ? devCors : prodCors));
app.set("port", process.env.API_PORT ?? 3002);
app.set("port", config.server.port ?? 3002);
// Oculta la cabecera x-powered-by
app.disable("x-powered-by");

View File

@ -0,0 +1,12 @@
{
"acme": {
"certificateId": "acme-prod-cert-v1",
"certificateSecretName": "acme_cert",
"certificatePasswordSecretName": "acme_cert_pwd"
},
"rodax": {
"certificateId": "no se que poner aqui",
"certificateSecretName": "certificate_secret_name",
"certificatePasswordSecretName": "certificate_password_secret_name"
}
}

View File

@ -1,12 +0,0 @@
export function asNumber(value: string | undefined, fallback: number): number {
const n = Number(value);
return Number.isFinite(n) ? n : fallback;
}
export function asBoolean(value: string | undefined, fallback: boolean): boolean {
if (value === undefined) return fallback;
return ["1", "true", "yes", "on"].includes(String(value).toLowerCase());
}
export function required(name: string, value: string | undefined): string {
if (!value) throw new Error(`Missing required environment variable: ${name}`);
return value;
}

View File

@ -0,0 +1,154 @@
import type { BaseConfigType } from "@erp/core/api";
import type { ILogger } from "@repo/rdx-logger";
import dotenv from "dotenv";
import { formatZodErrors } from "./env-error";
import { EnvSchema } from "./env-schema";
import { loadCompanyCertificates } from "./load-company-certificates";
import { logConfigSummary } from "./log-config";
dotenv.config();
let _config: ConfigType | null = null;
export type ConfigType = BaseConfigType & {
env: string;
server: {
port: number;
apiBasePath: string;
frontendUrl: string;
trustProxy: number;
timezone: string;
};
database: {
dialect: "mysql" | "postgres" | "sqlite" | "mariadb" | "mssql" | "db2" | "snowflake" | "oracle";
host: string;
port: number;
name: string;
user: string;
password: string;
logging: boolean;
ssl: boolean;
syncMode: string;
};
auth: {
jwt: {
secret: string;
accessExpiration: string;
refreshExpiration: string;
};
};
paths: {
templates: string;
documents: string;
signedDocuments: string;
fastReportBin: string;
};
signingService: {
url: string;
method: string;
timeoutMs: number;
maxRetries: number;
};
certificates?: Record<string, unknown>;
flags: {
isProd: boolean;
isDev: boolean;
isTest: boolean;
};
};
export const initConfig = (logger: ILogger): ConfigType => {
if (_config) {
return _config;
}
const parsed = EnvSchema.safeParse(process.env);
if (!parsed.success) {
const errors = formatZodErrors(parsed.error);
logger.error("Invalid environment configuration", {
errorCount: errors.length,
errors,
});
throw new Error("Invalid environment configuration");
}
const env = parsed.data;
const certificates = loadCompanyCertificates(env.COMPANY_CERTIFICATES_PATH, logger);
_config = {
env: env.NODE_ENV,
server: {
port: env.PORT,
apiBasePath: env.API_BASE_PATH,
frontendUrl: env.FRONTEND_URL,
trustProxy: env.TRUST_PROXY,
timezone: env.TIMEZONE,
},
database: {
dialect: env.DB_DIALECT,
host: env.DB_HOST,
port: env.DB_PORT,
name: env.DB_NAME,
user: env.DB_USER,
password: env.DB_PASS,
logging: env.DB_LOGGING,
ssl: env.DB_SSL,
syncMode: env.DB_SYNC_MODE,
},
auth: {
jwt: {
secret: env.JWT_SECRET,
accessExpiration: env.JWT_ACCESS_EXPIRATION,
refreshExpiration: env.JWT_REFRESH_EXPIRATION,
},
},
paths: {
templates: env.TEMPLATES_PATH,
documents: env.DOCUMENTS_PATH,
signedDocuments: env.SIGNED_DOCUMENTS_PATH,
fastReportBin: env.FASTREPORT_BIN,
},
signingService: {
url: env.SIGNING_SERVICE_URL,
method: env.SIGNING_SERVICE_METHOD,
timeoutMs: env.SIGNING_SERVICE_TIMEOUT_MS,
maxRetries: env.SIGNING_SERVICE_MAX_RETRIES,
},
certificates,
flags: {
isProd: env.NODE_ENV === "production",
isDev: env.NODE_ENV === "development",
isTest: env.NODE_ENV === "test",
},
};
logConfigSummary(_config, logger);
return _config;
};
export const Config = (): ConfigType => {
if (!_config) {
throw new Error("Config not initialized. Call initConfig(logger) during bootstrap.");
}
return _config;
};

View File

@ -2,14 +2,14 @@ import { Sequelize } from "sequelize";
import { logger } from "../lib/logger";
import { ENV } from "./index";
import type { ConfigType } from "./index";
/**
* Crea la instancia de Sequelize según ENV (DATABASE_URL o parámetros sueltos),
* autentica la conexión y devuelve la instancia lista para usar.
*/
export async function tryConnectToDatabase(): Promise<Sequelize> {
const sequelize = buildSequelize();
export async function tryConnectToDatabase(config: ConfigType): Promise<Sequelize> {
const sequelize = buildSequelize(config);
try {
await sequelize.authenticate();
@ -49,12 +49,12 @@ export async function closeDatabase(sequelize: Sequelize): Promise<void> {
}
}
function buildSequelize(): Sequelize {
function buildSequelize(config: ConfigType): Sequelize {
const common = {
logging: ENV.DB_LOGGING
logging: config.database.logging
? (msg: any) => logger.debug(String(msg), { label: "sequelize" })
: false,
timezone: ENV.APP_TIMEZONE,
timezone: config.server.timezone,
pool: {
max: 10,
min: 0,
@ -62,27 +62,28 @@ function buildSequelize(): Sequelize {
acquire: 30000,
},
dialectOptions: {
timezone: ENV.APP_TIMEZONE,
timezone: config.server.timezone,
},
} as const;
if (ENV.DATABASE_URL && ENV.DATABASE_URL.trim() !== "") {
/*if (ENV.DATABASE_URL && ENV.DATABASE_URL.trim() !== "") {
// URL completa (recomendada p.ej. en Postgres/MariaDB)
return new Sequelize(ENV.DATABASE_URL, common);
}
}*/
// Parámetros sueltos (asegurar requeridos mínimos)
if (!ENV.DB_DIALECT) {
if (!config.database.dialect) {
throw new Error("DB_DIALECT is required when DATABASE_URL is not provided");
}
if (!(ENV.DB_NAME && ENV.DB_USER)) {
if (!(config.database.name && config.database.user)) {
throw new Error("DB_NAME and DB_USER are required when DATABASE_URL is not provided");
}
return new Sequelize(ENV.DB_NAME, ENV.DB_USER, ENV.DB_PASS, {
host: ENV.DB_HOST,
port: ENV.DB_PORT,
dialect: ENV.DB_DIALECT,
return new Sequelize(config.database.name, config.database.user, config.database.password, {
host: config.database.host,
port: config.database.port,
dialect: config.database.dialect,
...common,
});
}

View File

@ -0,0 +1,12 @@
import type { ZodError, ZodIssue } from "zod";
export type EnvValidationError = {
path: string;
message: string;
};
export const formatZodErrors = (error: ZodError): EnvValidationError[] =>
error.issues.map((issue: ZodIssue) => ({
path: issue.path.join("."),
message: issue.message,
}));

View File

@ -0,0 +1,57 @@
import { z } from "zod";
const NodeEnvSchema = z.enum(["development", "test", "production"]);
const DbSyncModeSchema = z.enum(["none", "alter", "force"]);
const DbDialectSchema = z.enum(["postgres", "mysql", "mariadb", "mssql", "sqlite"]);
export const CompanyCertificateSchema = z.object({
certificateId: z.string().min(1),
certificateSecretName: z.string().min(1),
certificatePasswordSecretName: z.string().min(1),
});
export const CompanyCertificatesSchema = z.record(
z.string().regex(/^[a-z0-9-]+$/),
CompanyCertificateSchema
);
export const EnvSchema = z.object({
NODE_ENV: NodeEnvSchema.default("development"),
PORT: z.coerce.number().int().positive().default(3002),
API_BASE_PATH: z.string(),
FRONTEND_URL: z.url(),
TIMEZONE: z.string().default("Europe/Madrid"),
TRUST_PROXY: z.coerce.number().int().default(0),
DB_DIALECT: DbDialectSchema.default("mysql"),
DB_HOST: z.string().min(1),
DB_PORT: z.coerce.number().int().positive().default(3306),
DB_NAME: z.string().min(1),
DB_USER: z.string().min(1),
DB_PASS: z.string().min(1),
DB_LOGGING: z.coerce.boolean().default(false),
DB_SSL: z.coerce.boolean().default(false),
DB_SYNC_MODE: DbSyncModeSchema.default("none"),
WARMUP_TIMEOUT_MS: z.coerce.number().int().positive(),
WARMUP_STRICT: z.coerce.boolean().default(false),
JWT_SECRET: z.string().min(32),
JWT_ACCESS_EXPIRATION: z.string(),
JWT_REFRESH_EXPIRATION: z.string(),
TEMPLATES_PATH: z.string(),
DOCUMENTS_PATH: z.string(),
SIGNED_DOCUMENTS_PATH: z.string(),
FASTREPORT_BIN: z.string(),
SIGNING_SERVICE_URL: z.url(),
SIGNING_SERVICE_METHOD: z.enum(["GET", "POST", "PUT"]),
SIGNING_SERVICE_TIMEOUT_MS: z.coerce.number().int().positive(),
SIGNING_SERVICE_MAX_RETRIES: z.coerce.number().int().nonnegative(),
COMPANY_CERTIFICATES_PATH: z.string().min(1),
});

View File

@ -1,96 +1 @@
import dotenv from "dotenv";
import { asBoolean, asNumber, required } from "./config-helpers";
// Carga de variables de entorno (.env). Si ya están en el entorno, no se sobreescriben.
dotenv.config();
type NodeEnv = "development" | "test" | "production";
type DbSyncMode = "none" | "alter" | "force";
type DbDialect = "postgres" | "mysql" | "mariadb" | "mssql" | "sqlite";
const NODE_ENV = (process.env.NODE_ENV as NodeEnv) ?? "development";
const isProd = NODE_ENV === "production";
const isDev = NODE_ENV === "development";
const API_PORT = asNumber(process.env.API_PORT, 3002);
// En producción exigimos FRONTEND_URL definido (según requisitos actuales).
const FRONTEND_URL = isProd
? required("FRONTEND_URL", process.env.FRONTEND_URL)
: (process.env.FRONTEND_URL ?? "http://localhost:5173");
// Base de datos (dos modos: URL o parámetros sueltos)
const DATABASE_URL = process.env.DATABASE_URL; // p.ej. postgres://user:pass@host:5432/dbname
const DB_DIALECT = (process.env.DB_DIALECT as DbDialect | undefined) ?? "mysql";
const DB_HOST = process.env.DB_HOST ?? "localhost";
const DB_PORT = asNumber(process.env.DB_PORT, 3306);
const DB_NAME = process.env.DB_NAME ?? "";
const DB_USER = process.env.DB_USER ?? "";
const DB_PASS = process.env.DB_PASS ?? "";
const DB_LOGGING = asBoolean(process.env.DB_LOGGING, false);
// Modo de sincronización usado por model-loader.ts
const DB_SYNC_MODE =
(process.env.DB_SYNC_MODE as DbSyncMode | undefined) ?? (isProd ? "none" : "alter");
// Opcional: timezone para Sequelize (según necesidades)
const APP_TIMEZONE = process.env.APP_TIMEZONE ?? "Europe/Madrid";
// FASTREPORT_BIN: ruta al ejecutable de FastReport CLI
// Si no se define, se buscará en rutas estándar según SO.
const FASTREPORT_BIN = process.env.FASTREPORT_BIN;
// Ruta raíz para plantillas (templates)
const TEMPLATES_PATH = process.env.TEMPLATES_PATH ?? "./templates";
// Documentos
const DOCUMENTS_PATH = process.env.DOCUMENTS_PATH ?? "./documents";
// Proxy (no usáis ahora, pero dejamos la variable por si se activa en el futuro)
const TRUST_PROXY = asNumber(process.env.TRUST_PROXY, 0);
const SIGNING_SERVICE_URL = process.env.SIGNING_SERVICE_URL;
const SIGNING_SERVICE_METHOD = process.env.SIGNING_SERVICE_METHOD ?? "POST";
const SIGNING_SERVICE_TIMEOUT_MS = asNumber(process.env.SIGNING_SERVICE_TIMEOUT_MS, 15_000);
const SIGNING_SERVICE_MAX_RETRIES = asNumber(process.env.SIGNING_SERVICE_MAX_RETRIES, 2);
const COMPANY_SLUG = process.env.COMPANY_SLUG ?? "acme";
const COMPANY_CERTIFICATES_JSON = process.env.COMPANY_CERTIFICATES_JSON ?? {};
export const ENV = {
NODE_ENV,
API_PORT,
FRONTEND_URL,
DATABASE_URL,
DB_DIALECT,
DB_HOST,
DB_PORT,
DB_NAME,
DB_USER,
DB_PASS,
DB_LOGGING,
DB_SYNC_MODE,
APP_TIMEZONE,
TRUST_PROXY,
TEMPLATES_PATH,
DOCUMENTS_PATH,
FASTREPORT_BIN,
SIGNING_SERVICE_URL,
SIGNING_SERVICE_METHOD,
SIGNING_SERVICE_TIMEOUT_MS,
SIGNING_SERVICE_MAX_RETRIES,
COMPANY_SLUG,
COMPANY_CERTIFICATES_JSON,
} as const;
export const Flags = {
isProd,
isDev,
isTest: NODE_ENV === "test",
} as const;
export * from "./config";

View File

@ -0,0 +1,48 @@
import fs from "node:fs";
import path from "node:path";
import type { ILogger } from "@repo/rdx-logger";
import { CompanyCertificatesSchema } from "./env-schema";
export const loadCompanyCertificates = (filePath: string, logger: ILogger) => {
try {
const absolutePath = path.resolve(filePath);
logger.info(filePath, path.dirname(filePath));
listarDirectorio(path.dirname(filePath));
const raw = fs.readFileSync(absolutePath, "utf-8");
const parsed = JSON.parse(raw);
return CompanyCertificatesSchema.parse(parsed);
} catch (error) {
logger.error("Invalid company certificates configuration", {
path: filePath,
error,
});
throw new Error("Invalid company certificates configuration");
}
};
function listarDirectorio(directorio: string): string[] {
try {
// Verificar que exista
const stats = fs.statSync(directorio);
if (!stats.isDirectory()) {
throw new Error(`La ruta proporcionada no es un directorio: ${directorio}`);
}
// Leer contenido
const archivos = fs.readdirSync(directorio);
// Retornar rutas absolutas (opcional)
const result = archivos.map((nombre) => path.join(directorio, nombre));
console.log(result);
return result;
} catch (error: any) {
throw new Error(`Error al listar el directorio: ${error.message}`);
}
}

View File

@ -0,0 +1,35 @@
import type { ILogger } from "@repo/rdx-logger";
import type { ConfigType } from "./config";
const redact = (value: string): string => {
if (value.length <= 4) return "***";
return `${value.slice(0, 2)}***${value.slice(-2)}`;
};
export const logConfigSummary = (config: ConfigType, logger: ILogger): void => {
logger.info("────────────────────────────────────────");
logger.info("Server configuration loaded");
logger.info("────────────────────────────────────────");
logger.info(`ENV : ${config.env}`);
logger.info(`API PORT : ${config.server.port}`);
logger.info(`API API_BASE_PATH : ${config.server.apiBasePath}`);
logger.info(`FRONTEND URL : ${config.server.frontendUrl}`);
logger.info(
`DB : ${config.database.dialect} ${config.database.host}:${config.database.port}/${config.database.name}`
);
logger.info(`DB SYNC MODE : ${config.database.syncMode}`);
logger.info(`JWT SECRET : ${redact(config.auth.jwt.secret)}`);
logger.info(`TEMPLATES PATH : ${config.paths.templates}`);
logger.info(`DOCUMENTS PATH : ${config.paths.documents}`);
logger.info(`SIGNING SERVICE : ${config.signingService.url}`);
logger.info(`CERTIFICATES : ${Object.keys(config.certificates).join(", ")}`);
logger.info("────────────────────────────────────────");
};

View File

@ -5,24 +5,24 @@ import { DateTime } from "luxon";
import { z } from "zod/v4";
import { createApp } from "./app.ts";
import { initConfig } from "./config";
import { tryConnectToDatabase } from "./config/database.ts";
import { ENV } from "./config/index.ts";
import { registerHealthRoutes } from "./health.ts";
import { listRoutes, logger } from "./lib/index.ts";
import { initModules } from "./lib/modules/index.ts";
import { registerModules } from "./register-modules.ts";
const API_BASE_PATH = "/api/v1";
z.config(z.locales.es());
const config = initConfig(logger);
// Guardamos información del estado del servidor
export const currentState = {
launchedAt: DateTime.now(),
appPath: process.cwd(),
hosts: "0.0.0.0",
port: ENV.API_PORT,
environment: ENV.NODE_ENV,
port: config.server.port,
environment: config.env,
connections: {} as Record<string, unknown>,
};
@ -91,11 +91,11 @@ const serverError = (error: NodeJS.ErrnoException) => {
logger.error("⛔️ Server wasn't able to start properly.", {
label: "serverError0",
error,
port: ENV.API_PORT,
port: config.database.port,
});
if (error.code === "EADDRINUSE") {
logger.error(`Port ${ENV.API_PORT} already in use`, { error, label: "serverError1" });
logger.error(`Port ${config.database.port} already in use`, { error, label: "serverError1" });
} else {
logger.error(error.message, { error, label: "serverError2" });
}
@ -117,7 +117,7 @@ const serverConnection = (conn: any) => {
// Cargar paquetes de la aplicación: customers, invoices, routes...
registerModules();
const app = createApp();
const app = createApp(config);
// Crea el servidor HTTP
const server = http.createServer(app);
@ -208,41 +208,19 @@ process.on("uncaughtException", async (error: Error) => {
logger.info(`Launched in: ${now.diff(currentState.launchedAt).toMillis()} ms`);
logger.info(`Process PID: ${process.pid}`);
// Mostrar variables de entorno
logger.info(`Environment: ${currentState.environment}`);
logger.info(`API_PORT: ${ENV.API_PORT}`);
logger.info(`API_BASE_PATH: ${API_BASE_PATH}`);
logger.info(`FRONTEND_URL: ${ENV.FRONTEND_URL}`);
logger.info(`DB_DIALECT: ${ENV.DB_DIALECT}`);
logger.info(`DB_HOST: ${ENV.DB_HOST}`);
logger.info(`DB_PORT: ${ENV.DB_PORT}`);
logger.info(`DB_NAME: ${ENV.DB_NAME}`);
logger.info(`DB_USER: ${ENV.DB_USER}`);
logger.info(`DB_LOGGING: ${ENV.DB_LOGGING}`);
logger.info(`DB_SYNC_MODE: ${ENV.DB_SYNC_MODE}`);
logger.info(`FASTREPORT_BIN: ${ENV.FASTREPORT_BIN}`);
logger.info(`TEMPLATES_PATH: ${ENV.TEMPLATES_PATH}`);
logger.info(`SIGNING_SERVICE_URL: ${ENV.SIGNING_SERVICE_URL}`);
logger.info(`SIGNING_SERVICE_METHOD: ${ENV.SIGNING_SERVICE_METHOD}`);
const database = await tryConnectToDatabase();
const database = await tryConnectToDatabase(config);
// Lógica de inicialización de DB, si procede:
// initStructure(sequelizeConn.connection);
// insertUsers();
// Rutas de salud disponibles desde el inicio del proceso
registerHealthRoutes(app, API_BASE_PATH, () => ({ ready: isReady }));
registerHealthRoutes(app, config.server.apiBasePath, () => ({ ready: isReady }));
await initModules({
env: ENV,
config,
app,
database,
baseRoutePath: API_BASE_PATH,
logger,
});
@ -284,7 +262,7 @@ process.on("uncaughtException", async (error: Error) => {
logger.info(`Server environment: ${currentState.environment}`);
logger.info("To shut down your server, press <CTRL> + C at any time");
logger.debug(JSON.stringify(listRoutes((app as any)._router, API_BASE_PATH), null, 3));
logger.debug(JSON.stringify(listRoutes((app as any)._router), null, 3));
});
} catch (error) {
// Arranque fallido → readiness sigue false

View File

@ -1,6 +1,6 @@
import expressListRoutes from "express-list-routes";
// Función para listar rutas
export function listRoutes(app, basePath = "") {
return expressListRoutes(app, { logger: false, prefix: basePath });
export function listRoutes(app) {
return expressListRoutes(app, { logger: false, prefix: "" });
}

View File

@ -1,6 +1,5 @@
import type { ModuleParams } from "@erp/core/api";
import { ENV } from "../../config";
import { logger } from "../logger";
/**
@ -119,22 +118,23 @@ export const initModels = async (params: ModuleParams) => {
}
// 3) Sincronizar base de datos (según modo)
const nodeEnv = ENV.NODE_ENV ?? process.env.NODE_ENV ?? "development";
const syncModeEnv = (ENV as any).DB_SYNC_MODE ?? process.env.DB_SYNC_MODE; // "none" | "alter" | "force"
const defaultMode = nodeEnv === "production" ? "none" : "alter";
const mode = (syncModeEnv ?? defaultMode) as "none" | "alter" | "force";
const syncModeEnv = params.database.syncMode; // "none" | "alter" | "force"
logger.info(` Database sync mode ${syncModeEnv}`, { label: "initModels" });
try {
if (mode === "none") {
if (syncModeEnv === "none") {
logger.info("✔️ Database sync skipped (mode=none)", { label: "initModels" });
} else if (mode === "alter") {
} else if (syncModeEnv === "alter") {
await database.sync({ force: false, alter: true });
logger.info("✔️ Database synchronized successfully (mode=alter).", { label: "initModels" });
} else if (mode === "force") {
} else if (syncModeEnv === "force") {
await database.sync({ force: true, alter: false });
logger.warn("⚠️ Database synchronized with FORCE (mode=force).", { label: "initModels" });
} else {
logger.warn(`⚠️ Unknown DB sync mode "${String(mode)}" → skipping`, { label: "initModels" });
logger.warn(`⚠️ Unknown DB sync mode "${String(syncModeEnv)}" → skipping`, {
label: "initModels",
});
}
} catch (err) {
const error = err as Error;

View File

@ -1,14 +0,0 @@
{
"mimeType": "application/pdf",
"filename": "issued-invoice.pdf",
"metadata": {
"documentType": "issued-invoice",
"documentId": "019c1ef1-7c3d-79ed-a12f-995cdc40252f",
"companyId": "5e4dc5b3-96b9-4968-9490-14bd032fec5f",
"companySlug": "rodax",
"format": "PDF",
"languageCode": "es",
"filename": "factura-021.pdf",
"cacheKey": "issued-invoice:5e4dc5b3-96b9-4968-9490-14bd032fec5f:021:v1"
}
}

View File

@ -1,7 +1,7 @@
{
"name": "@erp/factuges-web",
"private": true,
"version": "0.3.6",
"version": "0.4.7",
"type": "module",
"scripts": {
"dev": "vite --host --clearScreen false",

View File

@ -1,6 +1,6 @@
{
"name": "@erp/auth",
"version": "0.3.6",
"version": "0.4.7",
"private": true,
"type": "module",
"sideEffects": false,

View File

@ -1,6 +1,6 @@
{
"name": "@erp/core",
"version": "0.3.6",
"version": "0.4.7",
"private": true,
"type": "module",
"sideEffects": false,

View File

@ -6,5 +6,5 @@ export interface IDocumentMetadata {
readonly format: "PDF" | "HTML";
readonly languageCode: string;
readonly filename: string;
readonly cacheKey?: string; // opcional
readonly storageKey?: string; // opcional
}

View File

@ -1,16 +0,0 @@
import type { IDocumentMetadata } from "../application-models";
export class DocumentCacheKeyFactory {
static fromMetadata(metadata: IDocumentMetadata): string {
return;
return [
metadata.documentType,
metadata.companyId,
metadata.documentId,
metadata.format,
metadata.languageCode,
metadata.filename,
metadata.cacheKey,
].join(":");
}
}

View File

@ -1,17 +0,0 @@
import type { IDocument } from "@erp/core/api/application";
export interface IDocumentCacheStore {
/**
* Devuelve el documento firmado si existe y es válido.
* - null => no existe / no válido
* - nunca lanza por errores técnicos (best-effort)
*/
get(cacheKey: string): Promise<IDocument | null>;
/**
* Guarda un documento firmado en cache.
* - best-effort
* - errores se ignoran o se loguean
*/
set(cacheKey: string, document: IDocument): Promise<void>;
}

View File

@ -48,9 +48,9 @@ export class DocumentGenerationService<TSnapshot> {
// 2. Pre-processors (cache / short-circuit)
for (const preProcessor of this.preProcessors) {
try {
const cached = await preProcessor.tryResolve(metadata);
if (cached) {
return Result.ok(cached);
const cachedDoc = await preProcessor.tryResolve(metadata);
if (cachedDoc) {
return Result.ok(cachedDoc);
}
} catch (error) {
// best-effort: ignorar y continuar

View File

@ -0,0 +1,7 @@
import { createHash } from "node:crypto";
export class DocumentStorageKeyFactory {
static fromMetadataRecord(metadata: Record<string, unknown>): string {
return createHash("sha256").update(JSON.stringify(metadata)).digest("hex");
}
}

View File

@ -1,6 +1,24 @@
import type { IDocument } from "../application-models";
export interface IDocumentStorage {
/**
* Determina si un existe una clave
*
* - Side-effect
* - Best-effort
* - Nunca lanza (errores se gestionan internamente)
*/
existsKeyStorage(storageKey: string): Promise<Boolean>;
/**
* Recupera un documento guardado.
*
* - Side-effect
* - Best-effort
* - Nunca lanza (errores se gestionan internamente)
*/
readDocument(storageKey: string): Promise<IDocument | null>;
/**
* Persiste un documento generado.
*
@ -8,5 +26,5 @@ export interface IDocumentStorage {
* - Best-effort
* - Nunca lanza (errores se gestionan internamente)
*/
save(document: IDocument, metadata: Record<string, unknown>): Promise<void>;
saveDocument(document: IDocument, metadataRecord: Record<string, unknown>): Promise<void>;
}

View File

@ -1,5 +1,3 @@
export * from "./document-cache.interface";
export * from "./document-cache-key-factory";
export * from "./document-generation-error";
export * from "./document-generation-service";
export * from "./document-metadata-factory.interface";
@ -10,4 +8,5 @@ export * from "./document-renderer.interface";
export * from "./document-side-effect.interface";
export * from "./document-signing-service.interface";
export * from "./document-storage.interface";
export * from "./document-storage-key-factory";
export * from "./signing-context-resolver.interface";

View File

@ -0,0 +1,9 @@
export type BaseConfigType = {
env: string;
flags: {
isProd: boolean;
isDev: boolean;
isTest: boolean;
};
};

View File

@ -0,0 +1 @@
export * from "./base-config";

View File

@ -1,42 +1,37 @@
import type { ModuleParams } from "../../modules";
import {
EnvCompanySigningContextResolver,
FastReportExecutableResolver,
FastReportProcessRunner,
FastReportRenderer,
FastReportTemplateResolver,
FilesystemDocumentCacheStore,
FilesystemDocumentStorage,
RestDocumentSigningService,
} from "../documents";
import { FilesystemDocumentStorage } from "../storage";
export const buildCoreDocumentsDI = (env: NodeJS.ProcessEnv) => {
const { TEMPLATES_PATH } = env;
export const buildCoreDocumentsDI = (params: ModuleParams) => {
const {
config: { paths, signingService },
} = params;
// Renderers
const frExecutableResolver = new FastReportExecutableResolver(env.FASTREPORT_BIN);
const frExecutableResolver = new FastReportExecutableResolver(paths.fastReportBin);
const frProcessRunner = new FastReportProcessRunner();
const fastReportRenderer = new FastReportRenderer(frExecutableResolver, frProcessRunner);
const fastReportTemplateResolver = new FastReportTemplateResolver(TEMPLATES_PATH!);
const fastReportTemplateResolver = new FastReportTemplateResolver(paths.templates);
// Signing
const signingContextResolver = new EnvCompanySigningContextResolver(env);
const signingContextResolver = new EnvCompanySigningContextResolver(params);
const signingService = new RestDocumentSigningService({
signUrl: String(env.SIGNING_SERVICE_URL),
method: String(env.SIGNING_SERVICE_METHOD),
timeoutMs: env.SIGNING_SERVICE_TIMEOUT_MS
? Number.parseInt(env.SIGNING_SERVICE_TIMEOUT_MS, 10)
: 15_000,
maxRetries: env.SIGNING_SERVICE_MAX_RETRIES
? Number.parseInt(env.SIGNING_SERVICE_MAX_RETRIES, 10)
: 2,
const restSigningService = new RestDocumentSigningService({
signUrl: String(signingService.url),
method: String(signingService.method),
timeoutMs: signingService.timeoutMs,
maxRetries: signingService.timeoutMs.maxRetries,
});
// Cache para documentos firmados
const cacheStore = new FilesystemDocumentCacheStore(String(env.SIGNED_DOCUMENTS_CACHE_PATH));
// Almancenamiento para documentos firmados
const storage = new FilesystemDocumentStorage(String(env.SIGNED_DOCUMENTS_PATH));
const storage = new FilesystemDocumentStorage(String(paths.signedDocuments));
return {
documentRenderers: {
@ -44,11 +39,10 @@ export const buildCoreDocumentsDI = (env: NodeJS.ProcessEnv) => {
fastReportTemplateResolver,
},
documentSigning: {
signingService,
signingService: restSigningService,
signingContextResolver,
},
documentStorage: {
cacheStore,
storage,
},
};

View File

@ -5,6 +5,7 @@ import type {
ICompanyCertificateContext,
ISigningContextResolver,
} from "@erp/core/api/application";
import type { ModuleParams } from "@erp/core/api/modules";
import type { ICompanySigningContextRecord } from "./company-signing-context-record.interface";
@ -19,26 +20,19 @@ import type { ICompanySigningContextRecord } from "./company-signing-context-rec
export class EnvCompanySigningContextResolver implements ISigningContextResolver {
private readonly records: Record<string, ICompanySigningContextRecord>;
constructor(env: NodeJS.ProcessEnv) {
const raw = env.COMPANY_CERTIFICATES_JSON;
if (!raw) {
this.records = {};
return;
}
constructor(params: ModuleParams) {
const jsonData = params.config.certificates as Record<
string,
{
certificateId: string;
certificateSecretName: string;
certificatePasswordSecretName: string;
}
>;
try {
const parsed = JSON.parse(raw) as Record<
string,
{
certificateId: string;
certificateSecretName: string;
certificatePasswordSecretName: string;
}
>;
this.records = Object.fromEntries(
Object.entries(parsed).map(([companySlug, cfg]) => [
Object.entries(jsonData).map(([companySlug, cfg]) => [
companySlug,
{
certificateId: cfg.certificateId,

View File

@ -1,65 +0,0 @@
import { createHash } from "node:crypto";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import type { IDocument } from "@erp/core/api/application";
import type { IDocumentCacheStore } from "@erp/core/api/application/documents/services/document-cache.interface";
/**
* Cache técnica de documentos firmados basada en filesystem.
*
* - Best-effort
* - Nunca lanza
* - Cachea SOLO documentos firmados
*/
export class FilesystemDocumentCacheStore implements IDocumentCacheStore {
constructor(private readonly basePath: string) {}
async get(cacheKey: string): Promise<IDocument | null> {
try {
const dir = this.resolveDir(cacheKey);
const payload = await readFile(join(dir, "payload.bin"));
const meta = JSON.parse(await readFile(join(dir, "meta.json"), "utf-8"));
return {
payload,
mimeType: meta.mimeType,
filename: meta.filename,
};
} catch {
// cualquier fallo => cache miss
return null;
}
}
async set(cacheKey: string, document: IDocument): Promise<void> {
try {
const dir = this.resolveDir(cacheKey);
await mkdir(dir, { recursive: true });
await writeFile(join(dir, "payload.bin"), document.payload);
await writeFile(
join(dir, "meta.json"),
JSON.stringify(
{
mimeType: document.mimeType,
filename: document.filename,
},
null,
2
)
);
} catch {
// best-effort: ignorar
}
}
private resolveDir(cacheKey: string): string {
const hash = createHash("sha256").update(cacheKey).digest("hex");
return join(this.basePath, hash);
}
}

View File

@ -0,0 +1,92 @@
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
import path from "node:path";
import {
DocumentStorageKeyFactory,
type IDocument,
type IDocumentStorage,
} from "../../../application";
/**
* Persistencia best-effort de documentos basada en filesystem.
*
* - Infra pura
* - Nunca lanza
* - No afecta al flujo del caso de uso
*/
export class FilesystemDocumentStorage implements IDocumentStorage {
public constructor(private readonly basePath: string) {}
async existsKeyStorage(storageKey: string): Promise<Boolean> {
try {
const dir = this.resolveDirFromStorageKey(storageKey);
return (await stat(dir)).isDirectory();
} catch {
// Consistente con saveDocument: best-effort
return false;
}
}
async readDocument(storageKey: string) {
try {
const dir = this.resolveDirFromStorageKey(storageKey);
const payload = await readFile(path.join(dir, "document.bin"));
const metaRaw = JSON.parse(await readFile(path.join(dir, "document.meta.json"), "utf-8"));
const meta = JSON.parse(metaRaw) as {
mimeType: string;
filename: string;
metadata: Record<string, unknown>;
};
const document: IDocument = {
payload,
mimeType: meta.mimeType,
filename: meta.filename,
};
return document;
} catch {
// Consistente con saveDocument: best-effort
return null;
}
}
async saveDocument(document: IDocument, metadataRecord: Record<string, unknown>): Promise<void> {
try {
const dir = this.resolveDirFromMetadataRecord(metadataRecord);
await mkdir(dir, { recursive: true });
await writeFile(path.join(dir, "document.bin"), document.payload);
await writeFile(
path.join(dir, "document.meta.json"),
JSON.stringify(
{
mimeType: document.mimeType,
filename: document.filename,
metadata: metadataRecord,
},
null,
2
)
);
} catch {
// best-effort: ignorar errores
}
}
private resolveDirFromMetadataRecord(metadataRecord: Record<string, unknown>): string {
/**
* El storage NO decide claves semánticas.
* Se limita a generar un path técnico estable.
*/
const storageKey = DocumentStorageKeyFactory.fromMetadataRecord(metadataRecord);
return this.resolveDirFromStorageKey(storageKey);
}
private resolveDirFromStorageKey(storageKey: string): string {
return path.join(this.basePath, storageKey);
}
}

View File

@ -1 +1 @@
export * from "./filesystem-document-cache-store";
export * from "./filesystem-signed-document-storage";

View File

@ -1,3 +1,4 @@
export * from "./config";
export * from "./database";
export * from "./di";
export * from "./documents";
@ -6,4 +7,3 @@ export * from "./express";
export * from "./logger";
export * from "./mappers";
export * from "./sequelize";
export * from "./storage";

View File

@ -1,50 +0,0 @@
import { createHash } from "node:crypto";
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import type { IDocument, IDocumentStorage } from "../../application";
/**
* Persistencia best-effort de documentos basada en filesystem.
*
* - Infra pura
* - Nunca lanza
* - No afecta al flujo del caso de uso
*/
export class FilesystemDocumentStorage implements IDocumentStorage {
public constructor(private readonly basePath: string) {}
async save(document: IDocument, metadata: Record<string, unknown>): Promise<void> {
try {
const dir = this.resolveDir(metadata);
await mkdir(dir, { recursive: true });
await writeFile(path.join(dir, "document.bin"), document.payload);
await writeFile(
path.join(dir, "document.meta.json"),
JSON.stringify(
{
mimeType: document.mimeType,
filename: document.filename,
metadata,
},
null,
2
)
);
} catch {
// best-effort: ignorar errores
}
}
private resolveDir(metadata: Record<string, unknown>): string {
/**
* El storage NO decide claves semánticas.
* Se limita a generar un path técnico estable.
*/
const hash = createHash("sha256").update(JSON.stringify(metadata)).digest("hex");
return path.join(this.basePath, hash);
}
}

View File

@ -1 +0,0 @@
export * from "./filesystem-signed-document-storage";

View File

@ -1,6 +1,6 @@
{
"name": "@erp/customer-invoices",
"version": "0.3.6",
"version": "0.4.7",
"private": true,
"type": "module",
"sideEffects": false,

View File

@ -29,17 +29,23 @@ export class IssuedInvoiceDocumentMetadataFactory
format: "PDF",
languageCode: snapshot.language_code ?? "es",
filename: this.buildFilename(snapshot),
cacheKey: this.buildCacheKey(snapshot),
storageKey: this.buildCacheKey(snapshot),
};
}
private buildFilename(snapshot: IssuedInvoiceReportSnapshot): string {
// Ejemplo: factura-F2024-000123.pdf
return `factura-${snapshot.invoice_number}.pdf`;
// Ejemplo: factura-F2024-000123-FULANITO.pdf
return `factura-${snapshot.series}${snapshot.invoice_number}-${snapshot.recipient.name}.pdf`;
}
private buildCacheKey(snapshot: IssuedInvoiceReportSnapshot): string {
// Versionado explícito para invalidaciones futuras
return ["issued-invoice", snapshot.company_id, snapshot.invoice_number, "v1"].join(":");
return [
"issued-invoice",
snapshot.company_id,
snapshot.series,
snapshot.invoice_number,
"v1",
].join(":");
}
}

View File

@ -1,12 +1,12 @@
import { buildCoreDocumentsDI } from "@erp/core/api";
import { type ModuleParams, buildCoreDocumentsDI } from "@erp/core/api";
import {
IssuedInvoiceDocumentPipelineFactory,
type IssuedInvoiceDocumentPipelineFactoryDeps,
} from "../documents";
export const buildIssuedInvoiceDocumentService = (env: NodeJS.ProcessEnv) => {
const { documentRenderers, documentSigning, documentStorage } = buildCoreDocumentsDI(env);
export const buildIssuedInvoiceDocumentService = (params: ModuleParams) => {
const { documentRenderers, documentSigning, documentStorage } = buildCoreDocumentsDI(params);
const pipelineDeps: IssuedInvoiceDocumentPipelineFactoryDeps = {
fastReportRenderer: documentRenderers.fastReportRenderer,

View File

@ -25,7 +25,7 @@ export type IssuedInvoicesInternalDeps = {
};
export function buildIssuedInvoicesDependencies(params: ModuleParams): IssuedInvoicesInternalDeps {
const { database, env } = params;
const { database } = params;
// Infrastructure
const transactionManager = buildTransactionManager(database);
@ -34,7 +34,7 @@ export function buildIssuedInvoicesDependencies(params: ModuleParams): IssuedInv
// Application helpers
const finder = buildIssuedInvoiceFinder(repository);
const snapshotBuilders = buildIssuedInvoiceSnapshotBuilders();
const documentGeneratorPipeline = buildIssuedInvoiceDocumentService(env);
const documentGeneratorPipeline = buildIssuedInvoiceDocumentService(params);
// Internal use cases (factories)
return {

View File

@ -3,7 +3,6 @@ import {
DocumentPostProcessorChain,
type FastReportRenderer,
type FastReportTemplateResolver,
type IDocumentCacheStore,
type IDocumentPostProcessor,
type IDocumentSideEffect,
type IDocumentSigningService,
@ -34,7 +33,6 @@ export interface IssuedInvoiceDocumentPipelineFactoryDeps {
signingContextResolver: ISigningContextResolver;
documentSigningService: IDocumentSigningService;
documentCacheStore: IDocumentCacheStore;
documentStorage: IDocumentStorage;
}
@ -43,9 +41,7 @@ export class IssuedInvoiceDocumentPipelineFactory {
deps: IssuedInvoiceDocumentPipelineFactoryDeps
): IssuedInvoiceDocumentGeneratorService {
// 1. Pre-processors (cache firmado)
const preProcessors = [
new IssuedInvoiceSignedDocumentCachePreProcessor(deps.documentCacheStore),
];
const preProcessors = [new IssuedInvoiceSignedDocumentCachePreProcessor(deps.documentStorage)];
// 2. Renderer (FastReport)
const documentRenderer = new IssuedInvoiceDocumentRenderer(

View File

@ -28,7 +28,9 @@ export class DigitalSignaturePostProcessor implements IDocumentPostProcessor {
// 1. Resolver certificado de la empresa
const certificate = await this.certificateResolver.resolveForCompany(metadata.companySlug);
if (!certificate) {
throw new Error("[DigitalSignaturePostProcessor] Compny certificate is undefined");
throw new Error(
`[DigitalSignaturePostProcessor] Company certificate is undefined for ${metadata.companySlug}`
);
}
// 2. Firmar payload

View File

@ -1,9 +1,10 @@
import {
DocumentCacheKeyFactory,
DocumentStorageKeyFactory,
type IDocument,
type IDocumentCacheStore,
type IDocumentMetadata,
type IDocumentPreProcessor,
type IDocumentStorage,
logger,
} from "@erp/core/api";
/**
@ -15,42 +16,35 @@ import {
* - Invalida cache corrupto
*/
export class IssuedInvoiceSignedDocumentCachePreProcessor implements IDocumentPreProcessor {
constructor(private readonly cache: IDocumentCacheStore) {}
constructor(private readonly docStorage: IDocumentStorage) {}
async tryResolve(metadata: IDocumentMetadata): Promise<IDocument | null> {
if (!metadata.cacheKey) {
return null;
}
const metadataRecord = metadata as unknown as Record<string, unknown>;
try {
const cacheKey = DocumentCacheKeyFactory.fromMetadata(metadata);
const storageKey = DocumentStorageKeyFactory.fromMetadataRecord(metadataRecord);
return await this.cache.get(cacheKey);
} catch {
// best-effort: cualquier fallo se trata como cache miss
return null;
}
if (!storageKey) {
return null;
}
if (!metadata.cacheKey) {
return null;
}
const exists = await this.docStorage.existsKeyStorage(storageKey);
try {
const exists = await this.cache.exists(metadata.cacheKey);
if (!exists) {
return null;
}
const document = await this.cache.read(metadata.cacheKey);
const document = await this.docStorage.readDocument(storageKey);
if (!this.isValid(document)) {
await this.cache.invalidate(metadata.cacheKey);
logger.warn(`Storage key ${storageKey} not exists!`, {
lable: "IssuedInvoiceSignedDocumentCachePreProcessor",
});
return null;
}
return document;
} catch {
// Cache failure → ignore and continue pipeline
// best-effort: cualquier fallo se trata como cache miss
return null;
}
}
@ -59,7 +53,9 @@ export class IssuedInvoiceSignedDocumentCachePreProcessor implements IDocumentPr
* Validación mínima de integridad.
* No valida firma criptográfica.
*/
private isValid(document: IDocument): boolean {
private isValid(document: IDocument | null): boolean {
if (!document) return false;
if (!document.payload || document.payload.length === 0) {
return false;
}

View File

@ -17,11 +17,11 @@ export class PersistIssuedInvoiceDocumentSideEffect implements IDocumentSideEffe
async execute(document: IDocument, metadata: IDocumentMetadata): Promise<void> {
// Si no hay cacheKey, no se persiste
if (!metadata.cacheKey) {
if (!metadata.storageKey) {
return;
}
// Persistencia best-effort
await this.storage.save(document, metadata);
await this.storage.saveDocument(document, metadata as unknown as Record<string, unknown>);
}
}

View File

@ -1,8 +1,6 @@
import { mockUser, requireAuthenticated, requireCompanyContext } from "@erp/auth/api";
import { type ModuleParams, type RequestWithAuth, validateRequest } from "@erp/core/api";
import type { ILogger } from "@repo/rdx-logger";
import { type Application, type NextFunction, type Request, type Response, Router } from "express";
import type { Sequelize } from "sequelize";
import { type NextFunction, type Request, type Response, Router } from "express";
import {
GetIssueInvoiceByIdRequestSchema,
@ -17,12 +15,7 @@ import { ListIssuedInvoicesController } from "./controllers/list-issued-invoices
import { ReportIssuedInvoiceController } from "./controllers/report-issued-invoice.controller";
export const issuedInvoicesRouter = (params: ModuleParams, deps: IssuedInvoicesInternalDeps) => {
const { app, baseRoutePath, logger } = params as {
app: Application;
database: Sequelize;
baseRoutePath: string;
logger: ILogger;
};
const { app, config } = params;
const router: Router = Router({ mergeParams: true });
if (process.env.NODE_ENV === "development" || process.env.NODE_ENV === "production") {
@ -78,5 +71,5 @@ export const issuedInvoicesRouter = (params: ModuleParams, deps: IssuedInvoicesI
}
);
app.use(`${baseRoutePath}/issued-invoices`, router);
app.use(`${config.server.apiBasePath}/issued-invoices`, router);
};

View File

@ -1,8 +1,6 @@
import { mockUser, requireAuthenticated, requireCompanyContext } from "@erp/auth/api";
import { type ModuleParams, type RequestWithAuth, validateRequest } from "@erp/core/api";
import type { ILogger } from "@repo/rdx-logger";
import { type Application, type NextFunction, type Request, type Response, Router } from "express";
import type { Sequelize } from "sequelize";
import { type NextFunction, type Request, type Response, Router } from "express";
import {
ChangeStatusProformaByIdParamsRequestSchema,
@ -17,7 +15,7 @@ import {
UpdateProformaByIdParamsRequestSchema,
UpdateProformaByIdRequestSchema,
} from "../../../../common";
import { buildProformasDependencies } from "../../proformas-dependencies";
import { buildProformasDependencies } from "../../di/proformas.di";
import {
ChangeStatusProformaController,
@ -31,13 +29,7 @@ import {
} from "./controllers";
export const proformasRouter = (params: ModuleParams) => {
const { app, baseRoutePath, logger } = params as {
env: Record<string, any>;
app: Application;
database: Sequelize;
baseRoutePath: string;
logger: ILogger;
};
const { app, config } = params;
const deps = buildProformasDependencies(params);
@ -158,5 +150,5 @@ export const proformasRouter = (params: ModuleParams) => {
}
);
app.use(`${baseRoutePath}/proformas`, router);
app.use(`${config.server.apiBasePath}/proformas`, router);
};

File diff suppressed because one or more lines are too long

View File

@ -1,6 +1,6 @@
{
"name": "@erp/customers",
"version": "0.3.6",
"version": "0.4.7",
"private": true,
"type": "module",
"sideEffects": false,

View File

@ -1,6 +1,6 @@
{
"name": "@erp/doc-numbering",
"version": "0.3.6",
"version": "0.4.7",
"private": true,
"type": "module",
"sideEffects": false,

View File

@ -34,5 +34,5 @@
"engines": {
"node": ">=24"
},
"packageManager": "pnpm@10.20.0"
"packageManager": "pnpm@10.29.3"
}

View File

@ -1,6 +1,6 @@
{
"name": "@repo/rdx-criteria",
"version": "0.3.6",
"version": "0.4.7",
"private": true,
"type": "module",
"sideEffects": false,

View File

@ -1,6 +1,6 @@
{
"name": "@repo/rdx-ddd",
"version": "0.3.6",
"version": "0.4.7",
"private": true,
"type": "module",
"sideEffects": false,

View File

@ -1,6 +1,14 @@
import { generateUUIDv7, Result } from "@repo/rdx-utils";
import {
Result,
generateUUIDv7,
isUuidBinary,
uuidBinaryToString,
uuidStringToBinary,
} from "@repo/rdx-utils";
import { z } from "zod/v4";
import { translateZodValidationError } from "../helpers";
import { ValueObject } from "./value-object";
export class UniqueID extends ValueObject<string> {
@ -9,8 +17,10 @@ export class UniqueID extends ValueObject<string> {
return schema.safeParse(value);
}
static create(id?: string, generateOnEmpty = false): Result<UniqueID, Error> {
if (!id || id?.trim() === "") {
static create(id?: string | Buffer, generateOnEmpty = false): Result<UniqueID, Error> {
const _id = isUuidBinary(id) ? uuidBinaryToString(id) : id;
if (!_id || _id?.trim() === "") {
if (!generateOnEmpty) {
return Result.fail(new Error("ID cannot be undefined or null"));
}
@ -18,7 +28,7 @@ export class UniqueID extends ValueObject<string> {
}
// biome-ignore lint/style/noNonNullAssertion: <explanation>
const valueIsValid = UniqueID.validate(id!);
const valueIsValid = UniqueID.validate(_id!);
if (!valueIsValid.success) {
return Result.fail(
@ -44,4 +54,8 @@ export class UniqueID extends ValueObject<string> {
toPrimitive() {
return this.toString();
}
toBuffer(): Buffer {
return uuidStringToBinary(this.toString());
}
}

View File

@ -1,6 +1,6 @@
{
"name": "@repo/rdx-logger",
"version": "0.3.6",
"version": "0.4.7",
"private": true,
"type": "module",
"sideEffects": false,

View File

@ -1,4 +1,4 @@
import { ILogger } from "../types";
import type { ILogger } from "../types";
export class ConsoleLogger implements ILogger {
info(message: string, meta?: any) {

View File

@ -1,6 +1,6 @@
{
"name": "@repo/rdx-utils",
"version": "0.3.6",
"version": "0.4.7",
"private": true,
"type": "module",
"sideEffects": false,

View File

@ -1,4 +1,31 @@
import { v4 as uuidv4, v7 as uuidv7 } from "uuid";
import {
parse as uuidParse,
stringify as uuidStringify,
v4 as uuidv4,
v7 as uuidv7,
validate,
version,
} from "uuid";
export const generateUUIDv4 = (): string => uuidv4();
export const generateUUIDv7 = (): string => uuidv7();
export function uuidStringToBinary(uuid: string): Buffer {
return Buffer.from(uuidParse(uuid));
}
export function uuidBinaryToString(buffer: Buffer): string {
return uuidStringify(buffer);
}
export function isUuidValid(value: string): boolean {
return validate(value) && version(value) === 7;
}
export function isUuidBinary(value: unknown): value is Buffer {
return Buffer.isBuffer(value) && value.length === 16;
}
export function isUuidString(value: unknown): value is string {
return typeof value === "string" && isUuidValid(value);
}

View File

@ -53,6 +53,9 @@ importers:
'@repo/rdx-logger':
specifier: workspace:*
version: link:../../packages/rdx-logger
'@repo/rdx-utils':
specifier: workspace:*
version: link:../../packages/rdx-utils
bcrypt:
specifier: ^5.1.1
version: 5.1.1

View File

@ -93,7 +93,6 @@ services:
DOCUMENTS_PATH: ${DOCUMENTS_PATH}
SIGNED_DOCUMENTS_PATH: ${SIGNED_DOCUMENTS_PATH}
SIGNED_DOCUMENTS_CACHE_PATH: ${SIGNED_DOCUMENTS_CACHE_PATH}
SIGNING_SERVICE_URL: ${SIGNING_SERVICE_URL}
SIGNING_SERVICE_METHOD: ${SIGNING_SERVICE_METHOD}

View File

@ -4,6 +4,22 @@ COMPANY_SLUG=rodax
# Dominios
DOMAIN=factuges.rodax-software.local
# API
PORT=3002
API_BASE_PATH=/api/v1
FRONTEND_URL=https://factuges.rodax-software.local
TRUST_PROXY=0
TIMEZONE=Europe/Madrid
API_IMAGE=factuges-server:rodax-latest
JWT_SECRET=supersecretkeysupersecretkeysupersecretkey
JWT_ACCESS_EXPIRATION=1h
JWT_REFRESH_EXPIRATION=7d
WARMUP_TIMEOUT_MS=10000
WARMUP_STRICT=false
# MariaDB
DB_HOST=db
DB_PORT=3306
@ -13,11 +29,10 @@ DB_USER=rodax_usr
DB_PASS=supersecret
DB_ROOT_PASS=verysecret
DB_LOGGING=true
DB_SSL=false
DB_SYNC_MODE=alter
# API
API_PORT=3002
API_IMAGE=factuges-server:rodax-latest
FRONTEND_URL=factuges.rodax-software.local
# Plantillas
TEMPLATES_PATH=/shared/templates
@ -28,23 +43,16 @@ DOCUMENTS_PATH=/shared/documents
# Firma de documentos
SIGNED_DOCUMENTS_PATH=/shared/documents
SIGNED_DOCUMENTS_CACHE_PATH=/shared/cache
SIGNING_SERVICE_URL=http://signing-service:8000/documents/sign
SIGNING_SERVICE_METHOD=POST
SIGNING_SERVICE_TIMEOUT_MS=15_000
SIGNING_SERVICE_TIMEOUT_MS=15000
SIGNING_SERVICE_MAX_RETRIES=2
COMPANY_CERTIFICATES_JSON='{
"rodax": {
"certificateId": "no se que poner aqui",
"certificateSecretName": "certificate_secret_name",
"certificatePasswordSecretName": "certificate_password_secret_name"
}
}'
COMPANY_CERTIFICATES_PATH=/shared/company-certificates.json
# SYNC
ENV = development
ENV = production
LOCAL_TZ = Europe/Madrid
STATE_PATH = /app/state
SYNC_MODE = all