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

130 lines
3.2 KiB
TypeScript

import type { Context } from "hono";
export type RpcCode =
| "OK"
| "CREATED"
| "VALIDATION"
| "UNAUTHORIZED"
| "FORBIDDEN"
| "NOT_FOUND"
| "CONFLICT"
| "INTERNAL"
| "NETWORK";
export type ResponseLayer = "db" | "api" | "front";
export type RpcEnvelope<T = unknown> = {
ok: boolean;
code: RpcCode | string;
layer: ResponseLayer;
message: string;
context?: Record<string, unknown> | null;
data?: T | null;
errors?: Record<string, string> | string[] | null;
};
export type ApiResponse<T = unknown> = RpcEnvelope<T> & { status: number };
const GENERIC_MESSAGES = new Set([
"ok", "error", "operación exitosa", "operacion exitosa", "algo salió mal",
"algo salio mal", "error interno", "error interno del servidor",
]);
export function isGenericMessage(message: string): boolean {
return GENERIC_MESSAGES.has(message.trim().toLowerCase());
}
export function mapRpcToStatus(code: string): number {
switch (code) {
case "OK":
return 200;
case "CREATED":
return 201;
case "VALIDATION":
return 400;
case "UNAUTHORIZED":
return 401;
case "FORBIDDEN":
return 403;
case "NOT_FOUND":
return 404;
case "CONFLICT":
return 409;
case "NETWORK":
return 503;
case "INTERNAL":
default:
return 500;
}
}
export function respondRpc<T>(c: Context, envelope: RpcEnvelope<T>) {
const status = mapRpcToStatus(String(envelope.code));
const body: ApiResponse<T> = { ...envelope, status };
return c.json(body, status as 200 | 201 | 400 | 401 | 403 | 404 | 409 | 500 | 503);
}
export function respondApiError(
c: Context,
code: RpcCode,
message: string,
context?: Record<string, unknown>,
errors?: Record<string, string> | string[] | null,
) {
if (isGenericMessage(message)) {
throw new Error(`API error message is too generic: ${message}`);
}
const status = mapRpcToStatus(code);
const body: ApiResponse = {
ok: false,
code,
status,
layer: "api",
message,
context: context ?? {},
data: null,
errors: errors ?? null,
};
return c.json(body, status as 400 | 401 | 403 | 404 | 409 | 500 | 503);
}
export function enrichInfraError(
fn: string,
route: string,
cause: string,
detail?: string,
): { message: string; context: Record<string, unknown> } {
const message = detail
? `Falló la llamada a ${fn} en ${route}: ${cause} — ${detail}`
: `Falló la llamada a ${fn} en ${route}: ${cause}`;
return {
message,
context: { fn, route, cause },
};
}
export function routeLabel(c: Context): string {
return `${c.req.method} ${c.req.path}`;
}
export function onAppError(err: unknown, c: Context) {
console.error(err);
if (err && typeof err === "object" && (err as RpcEnvelope).layer) {
const env = err as RpcEnvelope;
return respondRpc(c, env);
}
const message = err instanceof Error
? `Error no controlado en ${routeLabel(c)}: ${err.message}`
: `Error no controlado en ${routeLabel(c)}`;
const status = 500;
return c.json({
ok: false,
code: "INTERNAL",
status,
layer: "api",
message,
context: { route: routeLabel(c) },
data: null,
errors: null,
}, status);
}