panels-origin/db
Alberto Martinez b9bf7044b2 refactor(core): migrate core CRUD to PostgreSQL RPC + unified HTTP errors
<!-- CURSOR_AGENT_PR_BODY_BEGIN -->
## Summary

Migrates the `core` schema business logic from inline `db.prepare()` calls in the API to PostgreSQL RPC functions (`core.fn_*`) with a unified JSON envelope for errors and HTTP status mapping.

### Database (Liquibase changesets 006–017)

- **006** — RPC infra: `rpc_ok`, `rpc_err`, `rpc_created`, `rpc_from_exception`
- **007** — Catalogs and tenant settings
- **008** — Companies CRUD
- **009** — Projects, checklists, document lists
- **010** — Workers CRUD, pipeline, checklist, assign
- **011** — Budget CRUD + `fn_budget_replace`
- **012** — Badge jobs
- **013** — Payroll (settings, attendance, loans, weeks, destajo)
- **014** — Worker import batch + document store
- **015** — Project/company/worker document metadata RPCs
- **016–017** — Fixes: `needs_badge` default on worker create; Liquibase `splitStatements:false` on function changesets

### API

- `api/rpc.ts` — `callCoreFn()`, `RpcCallError` (jsonb payload fix: pass JS object, not `JSON.stringify`)
- `api/http_errors.ts` — `mapRpcToStatus()`, `respondRpc()`, `respondApiError()`, `onAppError()`
- Refactored: `main.ts`, `companies.ts`, `db.ts`, `budget.ts`, `excel.ts`, `payroll.ts`, `payroll_http.ts`
- Front helpers: `web-panel/composables/api-response.ts`, `web-saas/composables/api-response.ts`

### Envelope contract

DB functions return `{ ok, code, layer: "db", message, context, data, errors }`. The API adds `status` (HTTP code) via `respondRpc()` / `respondApiError()`.

### Out of scope

`iam`, `platform`, `saas.ts`, auth/sessions, S3, PDF generation, Excel parsing, and bootstrap scripts still use direct SQL where appropriate.

## Test plan

- [x] `deno check main.ts` — compila sin errores de tipos
- [x] `npm run build` — web-panel y web-saas compilan
- [x] `deno test` — 25 tests unitarios (http_errors, companies, budget, mx, document_validity)
- [x] Liquibase migrations `006`–`017` aplicadas en Postgres local (`--context-filter=dev`)
- [x] API levantada localmente; `/v1/health` OK
- [x] Smoke CRUD vía `scripts/crud-smoke-test.sh`: empresas, proyectos, trabajadores, catálogos (create/get/list/patch)
- [ ] Import Excel de trabajadores (flujo multipart + S3/local storage)
- [ ] Import presupuesto desde Excel
- [ ] Flujo nómina: asistencia → cerrar semana
- [ ] CI en el remoto (sin checks reportados aún)
<!-- CURSOR_AGENT_PR_BODY_END -->

<div><a href="https://cursor.com/agents/bc-06667c14-38e8-42a8-9ed9-6b1322f12ae7?cursor_ref=pr_footer&cursor_cta=open_in_web"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-web-light.png"><img alt="Open in Web" width="114" height="28" src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a>&nbsp;<a href="https://cursor.com/background-agent?bcId=bc-06667c14-38e8-42a8-9ed9-6b1322f12ae7&cursor_ref=pr_footer&cursor_cta=open_in_cursor"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-cursor-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-cursor-light.png"><img alt="Open in Cursor" width="131" height="28" src="https://cursor.com/assets/images/open-in-cursor-dark.png"></picture></a>&nbsp;</div>
2026-09-04 02:34:43 +00:00
..
backups db/infra: ETL de datos, backups y limpieza de despliegue (fase 5-7) 2026-09-02 21:05:07 +00:00
core refactor(core): migrate core CRUD to PostgreSQL RPC + unified HTTP errors 2026-09-04 02:34:43 +00:00
iam db: migrar infraestructura de BD a Postgres (fase 0-1) 2026-09-02 20:00:49 +00:00
platform db: migrar infraestructura de BD a Postgres (fase 0-1) 2026-09-02 20:00:49 +00:00
provision api: --allow-sys para el SDK de AWS/R2 en Deno 2026-09-03 05:49:14 +00:00
bootstrap-tools.sh db: migrar infraestructura de BD a Postgres (fase 0-1) 2026-09-02 20:00:49 +00:00
README.md refactor(core): migrate core CRUD to PostgreSQL RPC + unified HTTP errors 2026-09-04 02:34:43 +00:00
RUNBOOK-corte.md db/infra: ETL de datos, backups y limpieza de despliegue (fase 5-7) 2026-09-02 21:05:07 +00:00
update.sh migrate: rechazar CONTAINER ID y fallar si el host no resuelve 2026-09-03 05:24:51 +00:00

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: 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):

# 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 ejecuta el SELECT del RPC.

Envelope de respuesta (BD)

Toda función devuelve un jsonb con esta forma:

{
  "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 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.