mirror of
https://origin.cursor.com/mrdevmx/panels.git
synced 2026-10-09 12:43:18 +00:00
<!-- 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> <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> </div>
147 lines
4 KiB
PL/PgSQL
147 lines
4 KiB
PL/PgSQL
--liquibase formatted sql
|
|
-- PANELS · core · RPC infrastructure (envelope helpers)
|
|
|
|
--changeset panel:core-006a-rpc-generic-messages splitStatements:false
|
|
CREATE OR REPLACE FUNCTION core.rpc_generic_messages()
|
|
RETURNS text[]
|
|
LANGUAGE sql
|
|
IMMUTABLE
|
|
AS $$
|
|
SELECT ARRAY[
|
|
'OK', 'Error', 'ERROR', 'Operación exitosa', 'Operacion exitosa',
|
|
'Algo salió mal', 'Algo salio mal', 'Error interno', 'Error interno del servidor'
|
|
]::text[];
|
|
$$;
|
|
|
|
--changeset panel:core-006b-rpc-assert-message splitStatements:false
|
|
CREATE OR REPLACE FUNCTION core.rpc_assert_message(p_message text)
|
|
RETURNS void
|
|
LANGUAGE plpgsql
|
|
IMMUTABLE
|
|
AS $$
|
|
BEGIN
|
|
IF p_message IS NULL OR btrim(p_message) = '' THEN
|
|
RAISE EXCEPTION 'RPC message must not be empty';
|
|
END IF;
|
|
IF p_message = ANY (core.rpc_generic_messages()) THEN
|
|
RAISE EXCEPTION 'RPC message is too generic: %', p_message;
|
|
END IF;
|
|
END;
|
|
$$;
|
|
|
|
--changeset panel:core-006c-rpc-ok splitStatements:false
|
|
CREATE OR REPLACE FUNCTION core.rpc_ok(
|
|
p_data jsonb,
|
|
p_message text,
|
|
p_ctx jsonb DEFAULT '{}'::jsonb
|
|
)
|
|
RETURNS jsonb
|
|
LANGUAGE plpgsql
|
|
IMMUTABLE
|
|
AS $$
|
|
BEGIN
|
|
PERFORM core.rpc_assert_message(p_message);
|
|
RETURN jsonb_build_object(
|
|
'ok', true,
|
|
'code', 'OK',
|
|
'layer', 'db',
|
|
'message', p_message,
|
|
'context', COALESCE(p_ctx, '{}'::jsonb),
|
|
'data', p_data,
|
|
'errors', NULL
|
|
);
|
|
END;
|
|
$$;
|
|
|
|
--changeset panel:core-006d-rpc-created splitStatements:false
|
|
CREATE OR REPLACE FUNCTION core.rpc_created(
|
|
p_data jsonb,
|
|
p_message text,
|
|
p_ctx jsonb DEFAULT '{}'::jsonb
|
|
)
|
|
RETURNS jsonb
|
|
LANGUAGE plpgsql
|
|
IMMUTABLE
|
|
AS $$
|
|
BEGIN
|
|
PERFORM core.rpc_assert_message(p_message);
|
|
RETURN jsonb_build_object(
|
|
'ok', true,
|
|
'code', 'CREATED',
|
|
'layer', 'db',
|
|
'message', p_message,
|
|
'context', COALESCE(p_ctx, '{}'::jsonb),
|
|
'data', p_data,
|
|
'errors', NULL
|
|
);
|
|
END;
|
|
$$;
|
|
|
|
--changeset panel:core-006e-rpc-err splitStatements:false
|
|
CREATE OR REPLACE FUNCTION core.rpc_err(
|
|
p_code text,
|
|
p_message text,
|
|
p_ctx jsonb DEFAULT '{}'::jsonb,
|
|
p_errors jsonb DEFAULT NULL
|
|
)
|
|
RETURNS jsonb
|
|
LANGUAGE plpgsql
|
|
IMMUTABLE
|
|
AS $$
|
|
BEGIN
|
|
PERFORM core.rpc_assert_message(p_message);
|
|
RETURN jsonb_build_object(
|
|
'ok', false,
|
|
'code', p_code,
|
|
'layer', 'db',
|
|
'message', p_message,
|
|
'context', COALESCE(p_ctx, '{}'::jsonb),
|
|
'data', NULL,
|
|
'errors', p_errors
|
|
);
|
|
END;
|
|
$$;
|
|
|
|
--changeset panel:core-006f-rpc-map-exception splitStatements:false
|
|
CREATE OR REPLACE FUNCTION core.rpc_from_exception(
|
|
p_fn text,
|
|
p_sqlstate text,
|
|
p_message text,
|
|
p_detail text DEFAULT NULL
|
|
)
|
|
RETURNS jsonb
|
|
LANGUAGE plpgsql
|
|
IMMUTABLE
|
|
AS $$
|
|
DECLARE
|
|
v_code text := 'INTERNAL';
|
|
v_msg text;
|
|
v_ctx jsonb := jsonb_build_object('fn', p_fn, 'sqlstate', p_sqlstate);
|
|
BEGIN
|
|
IF p_sqlstate = '23505' THEN
|
|
v_code := 'CONFLICT';
|
|
v_msg := format('CONFLICT en %s: %s', p_fn, COALESCE(p_detail, p_message));
|
|
ELSIF p_sqlstate = '23503' THEN
|
|
v_code := 'VALIDATION';
|
|
v_msg := format('Referencia inválida en %s: %s', p_fn, COALESCE(p_detail, p_message));
|
|
ELSIF p_sqlstate = '23514' THEN
|
|
v_code := 'VALIDATION';
|
|
v_msg := format('Restricción de valor en %s: %s', p_fn, COALESCE(p_detail, p_message));
|
|
ELSIF p_sqlstate = '40P01' THEN
|
|
v_code := 'CONFLICT';
|
|
v_msg := format('Deadlock en %s: otra operación modificó los mismos registros, intente de nuevo', p_fn);
|
|
ELSE
|
|
v_code := 'INTERNAL';
|
|
v_msg := format('Error inesperado en %s (sqlstate=%s): %s', p_fn, p_sqlstate, p_message);
|
|
END IF;
|
|
RETURN core.rpc_err(v_code, v_msg, v_ctx);
|
|
END;
|
|
$$;
|
|
|
|
--changeset panel:core-006g-rpc-grants endDelimiter:; splitStatements:true
|
|
GRANT EXECUTE ON FUNCTION core.rpc_generic_messages() TO panels_core_app;
|
|
GRANT EXECUTE ON FUNCTION core.rpc_assert_message(text) TO panels_core_app;
|
|
GRANT EXECUTE ON FUNCTION core.rpc_ok(jsonb, text, jsonb) TO panels_core_app;
|
|
GRANT EXECUTE ON FUNCTION core.rpc_created(jsonb, text, jsonb) TO panels_core_app;
|
|
GRANT EXECUTE ON FUNCTION core.rpc_err(text, text, jsonb, jsonb) TO panels_core_app;
|
|
GRANT EXECUTE ON FUNCTION core.rpc_from_exception(text, text, text, text) TO panels_core_app;
|