This commit is contained in:
David Arranz 2025-11-03 20:45:03 +01:00
parent d6c5d067cb
commit cecba33ebb
23 changed files with 418 additions and 92 deletions

2
.gitignore vendored
View File

@ -9,7 +9,7 @@ dist-ssr
.cache
server/dist
public/dist
out
apps/**/false/*
false

94
Dockerfile Normal file
View File

@ -0,0 +1,94 @@
# syntax=docker/dockerfile:1.7-labs
ARG NODE_IMAGE=node:22-alpine
ARG PNPM_VERSION=10.20.0
########################
# 0) Base común
########################
FROM ${NODE_IMAGE} AS base
ENV CI=1 \
NODE_ENV=production
RUN corepack enable && corepack prepare pnpm@${PNPM_VERSION} --activate
WORKDIR /repo
########################
# 1) Turborepo prune (reduce contexto)
########################
FROM base AS pruner
# Copiar archivos raíz necesarios
COPY turbo.json package.json pnpm-workspace.yaml pnpm-lock.yaml ./
# Copiar los directorios relevantes del monorepo
COPY packages ./packages
COPY apps ./apps
COPY modules ./modules
# Ejecuta prune para @erp/factuges-server
RUN npx --yes turbo@2.5.8 prune @erp/factuges-server --docker
########################
# 2) Instalar deps de la parte pruned (usando pnpm fetch para cache)
########################
#FROM base AS installer
# Traemos lockfile y pnpm-store desde la etapa prune
#COPY --from=pruner /repo/out/json/ ./
#COPY --from=pruner /repo/out/pnpm-lock.yaml ./pnpm-lock.yaml
# Prefetch a store global (rápido y cacheable)
#RUN pnpm fetch
#RUN pnpm install --frozen-lockfile
########################
# 3) Builder: código + link deps y build
########################
FROM base AS builder
# Copiamos todo lo “pruned” (json, lock, full)
COPY --from=pruner /repo/out/full/ ./
COPY --from=pruner /repo/out/pnpm-lock.yaml ./pnpm-lock.yaml
# Reutilizamos la store prefetch
#COPY --from=installer /root/.local/share/pnpm/store /root/.local/share/pnpm/store
# Instalamos solo lo necesario para prod de los workspaces
RUN pnpm install --frozen-lockfile --prefer-offline
# IMPORTANTE: build de paquetes buildables (utils, logger, etc.) primero
# Turborepo asegura orden con "dependsOn". Un único comando:
RUN pnpm -w turbo run build --filter=@erp/factuges-server...
########################
# 4) Runner minimal
########################
#FROM ${NODE_IMAGE} AS runner
FROM base AS runner
ENV NODE_ENV=production TZ=UTC
# Don't run production as root
RUN addgroup --system --gid 1001 expressjs
RUN adduser --system --uid 1001 expressjs
USER expressjs
#COPY --from=builder /repo .
COPY --from=builder /repo/node_modules ./node_modules
COPY --from=builder /repo/packages ./packages
COPY --from=builder /repo/package.json ./package.json
COPY --from=builder /repo/pnpm-lock.yaml ./pnpm-lock.yaml
COPY --from=builder /repo/pnpm-workspace.yaml ./pnpm-workspace.yaml
COPY --from=builder /repo/apps/server/dist ./apps/server/dist
COPY --from=builder /repo/apps/server/.env.production ./apps/server/dist/.env
COPY --from=builder /repo/apps/server/node_modules ./apps/server/node_modules
COPY --from=builder /repo/apps/server/package.json ./apps/server/package.json
# Salud del contenedor (ajusta puerto/endpoint)
#HEALTHCHECK --interval=20s --timeout=3s --retries=5 \
# CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||3002)+'/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
EXPOSE 3002
CMD ["pnpm","exec", "node", "--env-file=apps/server/dist/.env", "apps/server/dist/index.js"]

View File

@ -1,7 +1,7 @@
NODE_ENV=development
NODE_ENV=production
HOST=0.0.0.0
PORT=3002
FRONTEND_URL=http://localhost:5173
FRONTEND_URL=http://factuges.rodax-software.local
DB_DIALECT=mysql

View File

@ -1,13 +0,0 @@
# server/Dockerfile
FROM node:22.13.1
WORKDIR /usr/src/app
COPY package*.json ./
RUN npm install --production
COPY . .
EXPOSE 5000
CMD ["npm", "start"]

View File

@ -1,10 +1,7 @@
{
"name": "@erp/server",
"version": "0.0.1",
"name": "@erp/factuges-server",
"version": "0.0.3",
"private": true,
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"scripts": {
"build": "tsup src/index.ts --config tsup.config.ts",
"dev": "tsx watch src/index.ts",

View File

@ -2,13 +2,13 @@ import { DateTime } from "luxon";
import http from "node:http";
import os from "node:os";
import { z } from "zod/v4";
import { createApp } from "./app";
import { ENV } from "./config";
import { tryConnectToDatabase } from "./config/database";
import { registerHealthRoutes } from "./health";
import { listRoutes, logger } from "./lib";
import { initModules } from "./lib/modules";
import { registerModules } from "./register-modules";
import { createApp } from "./app.ts";
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";
@ -208,9 +208,23 @@ process.on("uncaughtException", async (error: Error) => {
const now = DateTime.now();
logger.info(`Time: ${now.toLocaleString(DateTime.DATETIME_FULL)} ${now.zoneName}`);
logger.info(`Launched in: ${now.diff(currentState.launchedAt).toMillis()} ms`);
logger.info(`Environment: ${currentState.environment}`);
logger.info(`Process PID: ${process.pid}`);
// Mostrar variables de entorno
logger.info(`Environment: ${currentState.environment}`);
logger.info(`HOST: ${ENV.HOST}`);
logger.info(`PORT: ${ENV.PORT}`);
logger.info(`API_BASE_PATH: ${API_BASE_PATH}`);
logger.info(`FRONTEND_URL: ${ENV.FRONTEND_URL}`);
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}`);
const database = await tryConnectToDatabase();
// Lógica de inicialización de DB, si procede:
@ -219,7 +233,7 @@ process.on("uncaughtException", async (error: Error) => {
await initModules({ app, database, baseRoutePath: API_BASE_PATH, logger });
// El servidor ya está listo para recibir tráfico
// El servidor ya está listo para recibir tráfico
isReady = true;
logger.info("✅ Server is READY (readiness=true)");
logger.info(`startup_duration_ms=${DateTime.now().diff(currentState.launchedAt).toMillis()}`);

View File

@ -2,13 +2,19 @@
"extends": "@repo/typescript-config/express.json",
"compilerOptions": {
"noEmit": true,
"allowImportingTsExtensions": true,
"lib": ["ES2022"],
"baseUrl": "./src",
"paths": {
"@/*": ["*"]
},
"outDir": "dist"
"outDir": "./dist",
"rootDir": "./src",
"removeComments": true
},
//"files": ["src/index.ts"], // Esta opción compila sólo los archivos listados (y sus dependencias importadas).
"include": ["src"],
"exclude": ["src/**/__tests__/*", "src/**/*.mock.*", "src/**/*.test.*", "node_modules", "dist"]
"exclude": ["node_modules", "dist"]
}

View File

@ -1,3 +1,5 @@
import fs from "node:fs";
import path from "node:path";
import { defineConfig } from "tsup";
/**
@ -10,23 +12,62 @@ import { defineConfig } from "tsup";
export default defineConfig({
entry: ["src/index.ts"], // punto de entrada principal
format: ["esm"], // ESM nativo (Node 18+)
target: "node18", // objetivo de compilación
sourcemap: true,
target: "node22", // objetivo de compilación
bundle: true, // genera un único bundle, evita imports rotos
sourcemap: false,
clean: true,
minify: false,
treeshake: true,
dts: false, // opcional, genera .d.ts
outDir: "dist",
platform: "node",
// Paquetes de npm a mantener como externos (no se incluyen)
external: ["express", "zod", "react", "react-dom", "date-fns"],
// Paquetes de npm a mantener como externos (no se incluyen en el "bundle")
external: [],
// Paquetes internos buildless que se deben incluir en el bundle
noExternal: [
"@erp/auth",
"@erp/core",
"@erp/customers",
"@erp/customer-invoices",
"@erp/verifactu",
"@repo/rdx-logger",
noExternal: ["@repo", "@erp"],
esbuildOptions(options) {
// Permite resolver imports sin extensión (.ts, .js, .mjs, etc.)
options.resolveExtensions = [".ts", ".js", ".mjs", ".json"];
// Corrige la extensión de salida
options.outExtension = { ".js": ".js" };
// Permite usar require en contexto ESM (Node >=18)
options.banner = {
js: `
import { createRequire } from "module";
const require = createRequire(import.meta.url);
`,
};
},
// Plugin: fuerza a que los imports relativos se traten como locales, no "external"
esbuildPlugins: [
{
name: "fix-local-imports",
setup(build) {
build.onResolve({ filter: /^\.{1,2}\// }, (args) => {
const abs = path.resolve(args.resolveDir, args.path);
// Si es un directorio, intenta resolver a index.(ts|js)
if (fs.existsSync(abs) && fs.statSync(abs).isDirectory()) {
const indexTs = path.join(abs, "index.ts");
const indexJs = path.join(abs, "index.js");
if (fs.existsSync(indexTs)) return { path: indexTs, external: false };
if (fs.existsSync(indexJs)) return { path: indexJs, external: false };
}
// Si es un fichero con extensión válida
const withExts = [".ts", ".js", ".mjs"];
for (const ext of withExts) {
if (fs.existsSync(abs + ext)) return { path: abs + ext, external: false };
}
return { path: abs, external: false };
});
},
},
],
});

View File

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

View File

@ -1,31 +0,0 @@
name: factuges
services:
mariadb:
image: mariadb:latest
container_name: mariadb
restart: always
environment:
MYSQL_ROOT_PASSWORD: rootpass
MYSQL_DATABASE: factuges_db
MYSQL_USER: factuges_usr
MYSQL_PASSWORD: factuges_pass
volumes:
- mariadb_data:/var/lib/mysql
ports:
- "3306:3306"
phpmyadmin:
image: phpmyadmin/phpmyadmin
container_name: phpmyadmin
restart: always
environment:
PMA_HOST: mariadb
MYSQL_ROOT_PASSWORD: rootpass
ports:
- "8080:80"
depends_on:
- mariadb
volumes:
mariadb_data:

View File

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

View File

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

View File

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

View File

@ -10,7 +10,10 @@ export enum INVOICE_STATUS {
SENT = "sent", // <- Proforma
APPROVED = "approved", // <- Proforma
REJECTED = "rejected", // <- Proforma
ISSUED = "issued", // <- Factura y enviada a Veri*Factu
// issued <- (si is_proforma === true) => Es una proforma (histórica)
// issued <- (si is_proforma === false) => Factura y enviada a Veri*Factu
ISSUED = "issued",
}
export class CustomerInvoiceStatus extends ValueObject<ICustomerInvoiceStatusProps> {
private static readonly ALLOWED_STATUSES = ["draft", "sent", "approved", "rejected", "issued"];

View File

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

View File

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

View File

@ -1,6 +1,6 @@
{
"name": "@erp/verifactu",
"version": "0.0.1",
"version": "0.0.3",
"private": true,
"type": "module",
"sideEffects": false,

View File

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

View File

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

View File

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

View File

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

219
scripts/build-api.sh Executable file
View File

@ -0,0 +1,219 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_VERSION="1.0.3"
# =====================================================
# FACTUGES Build Script
# -----------------------------------------------------
# Build + Export de la API y/o
# compilación de la Web (por compañía)
# =====================================================
# Uso:
# ./scripts/build-api.sh <company> [--api|web|all] [--load]
#
# Ejemplos:
# ./scripts/build-api.sh acme --api --load # solo API + carga en Docker local
# ./scripts/build-api.sh acme --web # solo web
# ./scripts/build-api.sh acme # API + web (por defecto)
#
# Funcionalidades:
# - Detecta automáticamente el nombre, versión y puerto de la API
# - Compila la web React (Vite)
# - Copia los estáticos a out/<company>/web/dist
# - Genera imagen Docker etiquetada por compañía + versión + latest
# - Guarda la imagen .tar en out/<company>/api/
# - Carga la imagen en Docker (opcional)
# =====================================================
# --- Configuración base ---
COMPANY="${1:-}"
MODE="all" # api | web | all
LOAD=false
# --- Parseo de flags ---
for arg in "${@:2}"; do
case "$arg" in
--api) MODE="api" ;;
--web) MODE="web" ;;
--all) MODE="all" ;;
--load) LOAD=true ;;
*) echo "⚠️ Argumento desconocido: $arg" ;;
esac
done
# --- Paths base ---
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(realpath "${SCRIPT_DIR}/..")"
SERVER_DIR="${PROJECT_DIR}/apps/server"
WEB_DIR="${PROJECT_DIR}/apps/web"
OUT_API_DIR="${PROJECT_DIR}/out/${COMPANY}/api"
OUT_WEB_DIR="${PROJECT_DIR}/out/${COMPANY}/web"
if [[ -z "$COMPANY" ]]; then
echo "❌ Error: debes indicar la compañía. Ejemplo: ./scripts/build-api.sh acme [--api|--web|--all] [--load]"
exit 1
fi
# --- Verificaciones mínimas ---
[[ -d "$SERVER_DIR" ]] || { echo "❌ Falta ${SERVER_DIR}"; exit 1; }
[[ -d "$WEB_DIR" ]] || { echo "❌ Falta ${WEB_DIR}"; exit 1; }
# --- Detectar nombre y versión de la API ---
IMAGE_NAME=$(node -p "require('${SERVER_DIR}/package.json').name.replace(/^@.*\\//, '')" 2>/dev/null || echo "api")
API_VERSION=$(node -p "require('${SERVER_DIR}/package.json').version" 2>/dev/null || echo "0.0.0")
# --- Detectar versión de la web ---
WEB_VERSION=$(node -p "require('${WEB_DIR}/package.json').version" 2>/dev/null || echo "0.0.0")
# --- Detectar puerto ---
PORT=""
if [[ -f "${SERVER_DIR}/.env" ]]; then
PORT=$(grep -E '^PORT=' "${SERVER_DIR}/.env" | cut -d'=' -f2 | tr -d '"')
fi
if [[ -z "$PORT" && -f "${PROJECT_DIR}/Dockerfile" ]]; then
PORT=$(grep -E '^EXPOSE ' "${PROJECT_DIR}/Dockerfile" | awk '{print $2}' | head -n1)
fi
PORT="${PORT:-3002}" # valor por defecto
# --- 3. Etiquetas e información ---
DATE=$(date +'%Y%m%d-%H%M%S')
ISO_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
USER_NAME=$(whoami)
GIT_HASH=$(git -C "$PROJECT_DIR" rev-parse --short HEAD 2>/dev/null || echo "unknown")
GIT_BRANCH=$(git -C "$PROJECT_DIR" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown")
IMAGE_TAG_V="${IMAGE_NAME}:${COMPANY}-v${API_VERSION}"
IMAGE_TAG_LATEST="${IMAGE_NAME}:${COMPANY}-latest"
mkdir -p "$OUT_API_DIR" "$OUT_WEB_DIR"
rm -rf "${OUT_API_DIR:?}/"*
rm -rf "${OUT_WEB_DIR:?}/"*
echo ""
echo ""
echo ""
echo "-------------------------------------------------------"
echo " FACTUGES Build Script v${SCRIPT_VERSION}"
echo " Construyendo entorno para compañía '${COMPANY}'"
echo " Proyecto: ${IMAGE_NAME}"
echo " Versión API: ${API_VERSION}"
echo " Versión Web: ${WEB_VERSION}"
echo " Puerto API: ${PORT}"
echo " Etiquetas: ${IMAGE_TAG_V}, ${IMAGE_TAG_LATEST}"
echo " API out: ${OUT_API_DIR}"
echo " WEB out: ${OUT_WEB_DIR}"
echo " Modo: ${MODE}"
echo " Cargar: ${LOAD}"
echo "-------------------------------------------------------"
# =====================================================
# 1⃣ WEB
# =====================================================
if [[ "$MODE" == "web" || "$MODE" == "all" ]]; then
echo "🌐 Compilando web con Vite..."
cd "${WEB_DIR}"
# Puedes pasar variables específicas por compañía
# Ejemplo: VITE_COMPANY=acme VITE_API_BASE=https://acme.localhost/api
VITE_COMPANY="${COMPANY}" pnpm build
# Carpeta versionada
VERSION_DIR="${OUT_WEB_DIR}/versions/v${WEB_VERSION}-${DATE}"
mkdir -p "${VERSION_DIR}/dist"
rm -rf "${VERSION_DIR}/dist"/*
cp -r "${WEB_DIR}/dist/"* "${VERSION_DIR}/dist/"
# Enlace simbólico 'latest' → versión recién compilada
ln -sfn "${VERSION_DIR}" "${OUT_WEB_DIR}/latest"
# Manifest JSON de la web
cat > "${VERSION_DIR}/manifest.json" <<EOF
{
"type": "web",
"company": "${COMPANY}",
"version": "${WEB_VERSION}",
"buildTime": "${ISO_DATE}",
"user": "${USER_NAME}",
"git": {
"branch": "${GIT_BRANCH}",
"commit": "${GIT_HASH}"
},
"source": "${PROJECT_DIR}/apps/web",
"dist": "${VERSION_DIR}/dist"
}
EOF
echo "✅ Web v${WEB_VERSION} compilada y copiada a ${VERSION_DIR}"
echo " 🔗 Enlace 'latest' actualizado a ${OUT_WEB_DIR}/latest"
fi
# =====================================================
# 2⃣ API
# =====================================================
if [[ "$MODE" == "api" || "$MODE" == "all" ]]; then
cd "${PROJECT_DIR}"
echo "🐳 Construyendo imagen Docker..."
docker build --no-cache --debug -t "${IMAGE_TAG_V}" -t "${IMAGE_TAG_LATEST}" \
--build-arg PORT="${PORT}" \
-f "${PROJECT_DIR}/Dockerfile" "${PROJECT_DIR}"
echo "✅ Imagen Docker construida correctamente"
TAR_FILE_V="${OUT_API_DIR}/${IMAGE_NAME}-${COMPANY}-v${API_VERSION}-${DATE}.tar"
TAR_FILE_LATEST="${OUT_API_DIR}/${IMAGE_NAME}-${COMPANY}-latest.tar"
docker save "${IMAGE_TAG_V}" "${IMAGE_TAG_LATEST}" -o "${TAR_FILE_V}"
cp -f "${TAR_FILE_V}" "${TAR_FILE_LATEST}"
echo "📦 Imagen guardada:"
echo " ${TAR_FILE_V}"
echo " ${TAR_FILE_LATEST}"
# Manifest JSON de la API
cat > "${OUT_API_DIR}/${IMAGE_NAME}-${COMPANY}-v${API_VERSION}-${DATE}-manifest.json" <<EOF
{
"type": "api",
"company": "${COMPANY}",
"image": "${IMAGE_TAG_V}",
"version": "${API_VERSION}",
"buildTime": "${ISO_DATE}",
"user": "${USER_NAME}",
"git": {
"branch": "${GIT_BRANCH}",
"commit": "${GIT_HASH}"
},
"port": "${PORT}",
"files": {
"versioned": "${TAR_FILE_V}",
"latest": "${TAR_FILE_LATEST}"
}
}
EOF
echo "📦 API manifest generado en ${OUT_API_DIR}/manifest-v${API_VERSION}-${DATE}.json"
if [[ "$LOAD" == true ]]; then
echo "📥 Cargando imagen en Docker local..."
docker load -i "${TAR_FILE_V}"
echo "✅ Imagen cargada en Docker local"
fi
fi
# =====================================================
# 3⃣ Resumen
# =====================================================
echo "-------------------------------------------------------"
echo "🎯 Resultado final para '${COMPANY}'"
[[ "$MODE" == "web" || "$MODE" == "all" ]] && echo " 🌐 Web: v${WEB_VERSION}${OUT_WEB_DIR}/versions/v${WEB_VERSION}-${DATE}"
[[ "$MODE" == "api" || "$MODE" == "all" ]] && echo " 🐳 API: v${API_VERSION}${OUT_API_DIR}"
echo "🧩 Script version: ${SCRIPT_VERSION} - FIN"
echo "-------------------------------------------------------"
echo ""
echo ""
echo ""

View File

@ -1,4 +0,0 @@
{
"extends": "@repo/typescript-config/root.json",
"include": ["apps", "modules", "packages"]
}