panels-origin/api/rpc.ts
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

125 lines
3.4 KiB
TypeScript

import type { Db } from "./db.ts";
import type { RpcEnvelope } from "./http_errors.ts";
import { enrichInfraError } from "./http_errors.ts";
const DEADLOCK = "40P01";
export class RpcCallError extends Error {
constructor(
readonly envelope: RpcEnvelope,
) {
super(envelope.message);
this.name = "RpcCallError";
}
}
function isEnvelope(value: unknown): value is RpcEnvelope {
if (!value || typeof value !== "object") return false;
const o = value as Record<string, unknown>;
return typeof o.ok === "boolean"
&& typeof o.code === "string"
&& typeof o.message === "string"
&& typeof o.layer === "string";
}
function postgresCode(e: unknown): string | undefined {
if (!e || typeof e !== "object") return undefined;
return (e as { code?: string }).code;
}
function postgresMessage(e: unknown): string {
if (e instanceof Error) return e.message;
return String(e);
}
async function invokeOnce<T>(
db: Db,
fn: string,
payload: Record<string, unknown>,
): Promise<RpcEnvelope<T>> {
const row = await db.prepare(`SELECT ${fn}($1::jsonb) AS result`).get(
payload,
) as { result: unknown } | undefined;
const raw = row?.result;
if (typeof raw === "string") {
try {
const parsed = JSON.parse(raw) as RpcEnvelope<T>;
if (isEnvelope(parsed)) return parsed;
} catch {
/* fall through */
}
}
if (isEnvelope(raw)) return raw as RpcEnvelope<T>;
throw new Error(`La función ${fn} no devolvió un envelope RPC válido`);
}
export async function callCoreFn<T = unknown>(
db: Db,
fn: string,
payload: Record<string, unknown> = {},
opts: { route?: string; retries?: number } = {},
): Promise<RpcEnvelope<T>> {
const route = opts.route ?? fn;
const retries = opts.retries ?? 1;
let lastErr: unknown;
for (let attempt = 0; attempt <= retries; attempt++) {
try {
const envelope = await invokeOnce<T>(db, fn, payload);
if (!envelope.ok) return envelope;
return envelope;
} catch (e) {
lastErr = e;
const code = postgresCode(e);
if (code === DEADLOCK && attempt < retries) continue;
const infra = enrichInfraError(
fn,
route,
code ?? "exception",
postgresMessage(e),
);
return {
ok: false,
code: code === DEADLOCK ? "CONFLICT" : "INTERNAL",
layer: "api",
message: infra.message,
context: { ...infra.context, sqlstate: code ?? null },
data: null,
errors: null,
};
}
}
const infra = enrichInfraError(fn, route, "exception", postgresMessage(lastErr));
return {
ok: false,
code: "INTERNAL",
layer: "api",
message: infra.message,
context: infra.context,
data: null,
errors: null,
};
}
export async function callCoreFnData<T>(
db: Db,
fn: string,
payload: Record<string, unknown> = {},
opts: { route?: string } = {},
): Promise<T> {
const envelope = await callCoreFn<T>(db, fn, payload, opts);
if (!envelope.ok) throw new RpcCallError(envelope);
return envelope.data as T;
}
export function unwrapRpc<T>(
envelope: RpcEnvelope<T>,
): { data?: T; error?: string; code?: string; status?: number } {
if (envelope.ok) return { data: envelope.data as T };
return {
error: envelope.message,
code: String(envelope.code),
status: envelope.code === "NOT_FOUND" ? 404
: envelope.code === "CONFLICT" ? 409
: 400,
};
}