# Base de datos (Postgres + Liquibase) PANELS usa **Postgres** organizado como monolito modular: dos bases de datos físicamente separadas en el mismo servidor/instancia por ambiente. | Base de datos | Esquema(s) | Changelog | Qué vive ahí | |---|---|---|---| | `panels_platform` | `public` | `db/platform/` | Control plane SaaS: `tenants`, `platform_users` (identidad de PANELS como operador), `smtp_settings`. Solo lo tocan las rutas `/v1/saas/*`. | | `panels_product` | `iam` | `db/iam/` | Identidad/roles/permisos **de cada tenant** (ej. usuarios de ARCTEC). Aislado de `core` -- sin JOIN/FK cruzado. | | `panels_product` | `core` | `db/core/` | Negocio: empresas, personal, obras, expedientes, presupuesto, nómina, gafetes. | No hay FK real entre `panels_platform` y `panels_product` (bases distintas), ni entre los esquemas `iam` y `core` (aislamiento a propósito). `tenant_id` es una referencia lógica validada en la capa de aplicación. Ver el plan de migración para el detalle de la arquitectura y las reglas del monolito modular. ## Aprovisionamiento (una vez por ambiente, antes de Liquibase) Ver [`db/provision/README.md`](provision/README.md): crea los 6 roles de Postgres (`panels_{platform,iam,core}_{owner,app}`), las 2 bases de datos, los esquemas `iam`/`core`, la extensión `citext`, y los usuarios ACL de Redis. Incluye `dev-local.sh` para reproducir todo esto en una máquina de desarrollo sin depender de Coolify, y `verify-isolation.sh` para confirmar que el aislamiento realmente se cumple. ## Aplicar migraciones En **Coolify** no se corre a mano: el servicio `migrate` de `docker-compose.yml` aplica `all --context-filter=!dev` en cada deploy, antes de levantar `api`. A mano (Java 17+ y credenciales `_owner`, nunca `_app`): ```bash # Local, después de correr db/provision/dev-local.sh: set -a && source .env.dev-local && set +a ./db/update.sh all --context-filter=dev # dev: incluye datos de demo ./db/update.sh all --context-filter='!dev' # staging/producción: solo esquema + catálogos # Un solo módulo ./db/update.sh core --context-filter='!dev' ./db/update.sh iam --context-filter='!dev' ./db/update.sh platform --context-filter='!dev' ``` **Importante**: Liquibase, si NO se le pasa `--context-filter`, corre TODOS los changesets sin importar su `context` -- incluidos los de `context="dev"`. No pasar el filtro en staging/producción NO es "modo seguro por defecto", es lo contrario: cargaría el tenant/empresas/proyecto de demostración. Por eso `--context-filter` es obligatorio siempre, nunca opcional. `npm run db:migrate` corre `./db/update.sh all`. **Las migraciones ya NO corren automáticamente al arrancar la API** -- son un paso explícito de deploy (a diferencia del `runLiquibase()` que existía con SQLite). Contexts de Liquibase: - Sin `context`: changesets de esquema y catálogos requeridos (`risk_levels`, `document_types`, etc.) -- corren en **todos** los ambientes. - `context="dev"`: datos de demostración (tenant/empresas/proyecto ARCTEC) -- **nunca** en staging/producción. Siempre pasar `--context-filter` explícito; no confiar en el default de Liquibase. ## Nuevos cambios de schema Un changeset nuevo en `db/{platform,iam,core}/changesets/`, referenciado en el `changelog-master.xml` correspondiente. Reglas: - Nunca editar un changeset ya aplicado en un ambiente compartido -- crear uno nuevo. - Usar `dbms:postgresql` en vez de `context` para lógica específica de motor. - No añadir migraciones directamente en `api/*.ts`. - Las herramientas (Liquibase + driver JDBC de Postgres) viven en `db/tools/` (gitignored); `bootstrap-tools.sh` las descarga. ## Bootstrap del primer usuario administrador El `password_hash` (PBKDF2) no lo puede generar un changeset SQL. El primer `platform_admin` y el primer `tenant_admin` de un tenant nuevo se crean con `scripts/bootstrap-admin.ts` (ver Fase 4 del plan de migración), no con lógica de seed en el arranque de la API ni con datos hardcodeados en Liquibase. ## Funciones RPC (`core.fn_*`) A partir del changeset `006-rpc-infra.sql`, el esquema `core` expone la lógica de negocio como funciones PostgreSQL invocadas desde la API con `SELECT core.fn_nombre($1::jsonb)`. La capa TypeScript **no** debe usar `db.prepare()` contra tablas de `core` en rutas de negocio; solo [`api/rpc.ts`](../api/rpc.ts) ejecuta el `SELECT` del RPC. ### Envelope de respuesta (BD) Toda función devuelve un `jsonb` con esta forma: ```json { "ok": true, "code": "OK", "layer": "db", "message": "Mensaje detallado en español", "context": { "fn": "fn_empresa_get", "id": 5 }, "data": { }, "errors": null } ``` - `code`: código de negocio (`OK`, `CREATED`, `VALIDATION`, `NOT_FOUND`, `CONFLICT`, `INTERNAL`, …). - `layer`: siempre `"db"` desde Postgres. - `message`: texto legible y específico (nunca genérico). - `context`: metadatos seguros para depuración (nombre de función, ids). - `data`: payload de éxito; `errors`: mapa de campos en validación. Helpers en `006-rpc-infra.sql`: `rpc_ok`, `rpc_err`, `rpc_created`, `rpc_from_exception`. ### Contrato API [`api/http_errors.ts`](../api/http_errors.ts) mapea `code` → HTTP y añade `status` al body. `respondRpc()` preserva `layer` y `message` de la BD; `respondApiError()` construye envelopes de capa `"api"`. ### Convenciones al añadir funciones 1. Un solo parámetro `payload jsonb`. 2. Prefijo `fn_` para funciones que devuelven envelope. 3. `SECURITY INVOKER` y `SET search_path = core`. 4. Errores de negocio con `RETURN core.rpc_err(...)`; constraints de Postgres capturados en `EXCEPTION` y traducidos a mensajes claros. 5. `GRANT EXECUTE ... TO panels_core_app` en el mismo changeset. 6. Comentario de ejemplo `SELECT core.fn_*(...)` en el SQL. Changesets RPC: `006` (infra) … `015` (documentos). Ver [`changelog-master.xml`](core/changelog-master.xml).