mirror of
https://origin.cursor.com/mrdevmx/panels.git
synced 2026-10-09 18:03: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>
164 lines
7.4 KiB
PL/PgSQL
164 lines
7.4 KiB
PL/PgSQL
--liquibase formatted sql
|
|
-- PANELS · core · RPC documentos (metadata post-S3)
|
|
|
|
--changeset panel:core-015a-fn-project-document-store splitStatements:false
|
|
CREATE OR REPLACE FUNCTION core.fn_project_document_store(payload jsonb)
|
|
RETURNS jsonb
|
|
LANGUAGE plpgsql
|
|
SECURITY INVOKER
|
|
SET search_path = core
|
|
AS $$
|
|
DECLARE
|
|
v_project_id bigint := (payload->>'project_id')::bigint;
|
|
v_type text := btrim(payload->>'type_code');
|
|
v_doc_id bigint;
|
|
BEGIN
|
|
IF v_project_id IS NULL OR v_type IS NULL OR v_type = '' THEN
|
|
RETURN core.rpc_err('VALIDATION', 'fn_project_document_store: project_id y type_code son obligatorios',
|
|
jsonb_build_object('fn', 'fn_project_document_store'));
|
|
END IF;
|
|
IF NOT EXISTS (SELECT 1 FROM project_document_types WHERE code = v_type) THEN
|
|
RETURN core.rpc_err('VALIDATION',
|
|
format('fn_project_document_store: tipo de documento %s no válido para proyectos', v_type),
|
|
jsonb_build_object('fn', 'fn_project_document_store', 'type_code', v_type));
|
|
END IF;
|
|
UPDATE project_documents SET is_current = false WHERE project_id = v_project_id AND type_code = v_type;
|
|
INSERT INTO project_documents (
|
|
project_id, type_code, original_name, mime, size_bytes, sha256, iv, storage_name,
|
|
is_current, parse_status, uploaded_by_id, uploaded_by_name
|
|
) VALUES (
|
|
v_project_id, v_type, payload->>'original_name', payload->>'mime',
|
|
(payload->>'size_bytes')::integer, payload->>'sha256', payload->>'iv', payload->>'storage_name',
|
|
true, 'manual', NULLIF(payload->>'uploaded_by_id', '')::integer, coalesce(payload->>'uploaded_by_name', '')
|
|
) RETURNING id INTO v_doc_id;
|
|
RETURN core.rpc_ok(jsonb_build_object('id', v_doc_id),
|
|
format('Documento %s guardado para proyecto id=%s (doc id=%s)', v_type, v_project_id, v_doc_id),
|
|
jsonb_build_object('fn', 'fn_project_document_store', 'project_id', v_project_id, 'id', v_doc_id));
|
|
EXCEPTION WHEN OTHERS THEN
|
|
RETURN core.rpc_from_exception('fn_project_document_store', SQLSTATE, SQLERRM);
|
|
END;
|
|
$$;
|
|
|
|
--changeset panel:core-015b-fn-company-document-store splitStatements:false
|
|
CREATE OR REPLACE FUNCTION core.fn_company_document_store(payload jsonb)
|
|
RETURNS jsonb
|
|
LANGUAGE plpgsql
|
|
SECURITY INVOKER
|
|
SET search_path = core
|
|
AS $$
|
|
DECLARE
|
|
v_company_id bigint := (payload->>'company_id')::bigint;
|
|
v_type text := btrim(payload->>'type_code');
|
|
v_doc_id bigint;
|
|
BEGIN
|
|
IF v_company_id IS NULL OR v_type IS NULL OR v_type = '' THEN
|
|
RETURN core.rpc_err('VALIDATION', 'fn_company_document_store: company_id y type_code son obligatorios',
|
|
jsonb_build_object('fn', 'fn_company_document_store'));
|
|
END IF;
|
|
IF NOT EXISTS (SELECT 1 FROM company_document_types WHERE code = v_type) THEN
|
|
RETURN core.rpc_err('VALIDATION',
|
|
format('fn_company_document_store: tipo de documento %s no válido para empresas', v_type),
|
|
jsonb_build_object('fn', 'fn_company_document_store', 'type_code', v_type));
|
|
END IF;
|
|
UPDATE company_documents SET is_current = false WHERE company_id = v_company_id AND type_code = v_type;
|
|
INSERT INTO company_documents (
|
|
company_id, type_code, original_name, mime, size_bytes, sha256, iv, storage_name,
|
|
is_current, parse_status, uploaded_by_id, uploaded_by_name
|
|
) VALUES (
|
|
v_company_id, v_type, payload->>'original_name', payload->>'mime',
|
|
(payload->>'size_bytes')::integer, payload->>'sha256', payload->>'iv', payload->>'storage_name',
|
|
true, 'manual', NULLIF(payload->>'uploaded_by_id', '')::integer, coalesce(payload->>'uploaded_by_name', '')
|
|
) RETURNING id INTO v_doc_id;
|
|
RETURN core.rpc_ok(jsonb_build_object('id', v_doc_id),
|
|
format('Documento %s guardado para empresa id=%s (doc id=%s)', v_type, v_company_id, v_doc_id),
|
|
jsonb_build_object('fn', 'fn_company_document_store', 'company_id', v_company_id, 'id', v_doc_id));
|
|
EXCEPTION WHEN OTHERS THEN
|
|
RETURN core.rpc_from_exception('fn_company_document_store', SQLSTATE, SQLERRM);
|
|
END;
|
|
$$;
|
|
|
|
--changeset panel:core-015c-fn-document-get splitStatements:false
|
|
CREATE OR REPLACE FUNCTION core.fn_project_document_get(payload jsonb)
|
|
RETURNS jsonb
|
|
LANGUAGE plpgsql
|
|
SECURITY INVOKER
|
|
SET search_path = core
|
|
AS $$
|
|
DECLARE
|
|
v_project_id bigint := (payload->>'project_id')::bigint;
|
|
v_doc_id bigint := (payload->>'doc_id')::bigint;
|
|
v_doc jsonb;
|
|
BEGIN
|
|
SELECT to_jsonb(d) INTO v_doc FROM project_documents d
|
|
WHERE d.id = v_doc_id AND d.project_id = v_project_id;
|
|
IF v_doc IS NULL THEN
|
|
RETURN core.rpc_err('NOT_FOUND',
|
|
format('fn_project_document_get: documento id=%s no encontrado en proyecto %s', v_doc_id, v_project_id),
|
|
jsonb_build_object('fn', 'fn_project_document_get', 'doc_id', v_doc_id, 'project_id', v_project_id));
|
|
END IF;
|
|
RETURN core.rpc_ok(jsonb_build_object('document', v_doc),
|
|
format('Documento id=%s del proyecto %s obtenido', v_doc_id, v_project_id),
|
|
jsonb_build_object('fn', 'fn_project_document_get', 'doc_id', v_doc_id));
|
|
EXCEPTION WHEN OTHERS THEN
|
|
RETURN core.rpc_from_exception('fn_project_document_get', SQLSTATE, SQLERRM);
|
|
END;
|
|
$$;
|
|
|
|
CREATE OR REPLACE FUNCTION core.fn_company_document_get(payload jsonb)
|
|
RETURNS jsonb
|
|
LANGUAGE plpgsql
|
|
SECURITY INVOKER
|
|
SET search_path = core
|
|
AS $$
|
|
DECLARE
|
|
v_company_id bigint := (payload->>'company_id')::bigint;
|
|
v_doc_id bigint := (payload->>'doc_id')::bigint;
|
|
v_doc jsonb;
|
|
BEGIN
|
|
SELECT to_jsonb(d) INTO v_doc FROM company_documents d
|
|
WHERE d.id = v_doc_id AND d.company_id = v_company_id;
|
|
IF v_doc IS NULL THEN
|
|
RETURN core.rpc_err('NOT_FOUND',
|
|
format('fn_company_document_get: documento id=%s no encontrado en empresa %s', v_doc_id, v_company_id),
|
|
jsonb_build_object('fn', 'fn_company_document_get', 'doc_id', v_doc_id, 'company_id', v_company_id));
|
|
END IF;
|
|
RETURN core.rpc_ok(jsonb_build_object('document', v_doc),
|
|
format('Documento id=%s de la empresa %s obtenido', v_doc_id, v_company_id),
|
|
jsonb_build_object('fn', 'fn_company_document_get', 'doc_id', v_doc_id));
|
|
EXCEPTION WHEN OTHERS THEN
|
|
RETURN core.rpc_from_exception('fn_company_document_get', SQLSTATE, SQLERRM);
|
|
END;
|
|
$$;
|
|
|
|
CREATE OR REPLACE FUNCTION core.fn_worker_document_get(payload jsonb)
|
|
RETURNS jsonb
|
|
LANGUAGE plpgsql
|
|
SECURITY INVOKER
|
|
SET search_path = core
|
|
AS $$
|
|
DECLARE
|
|
v_worker_id bigint := (payload->>'worker_id')::bigint;
|
|
v_doc_id bigint := (payload->>'doc_id')::bigint;
|
|
v_doc jsonb;
|
|
BEGIN
|
|
SELECT to_jsonb(d) INTO v_doc FROM documents d
|
|
WHERE d.id = v_doc_id AND d.worker_id = v_worker_id;
|
|
IF v_doc IS NULL THEN
|
|
RETURN core.rpc_err('NOT_FOUND',
|
|
format('fn_worker_document_get: documento id=%s no encontrado para trabajador %s', v_doc_id, v_worker_id),
|
|
jsonb_build_object('fn', 'fn_worker_document_get', 'doc_id', v_doc_id, 'worker_id', v_worker_id));
|
|
END IF;
|
|
RETURN core.rpc_ok(jsonb_build_object('document', v_doc),
|
|
format('Documento id=%s del trabajador %s obtenido', v_doc_id, v_worker_id),
|
|
jsonb_build_object('fn', 'fn_worker_document_get', 'doc_id', v_doc_id));
|
|
EXCEPTION WHEN OTHERS THEN
|
|
RETURN core.rpc_from_exception('fn_worker_document_get', SQLSTATE, SQLERRM);
|
|
END;
|
|
$$;
|
|
|
|
--changeset panel:core-015d-fn-documents-grants endDelimiter:; splitStatements:true
|
|
GRANT EXECUTE ON FUNCTION core.fn_project_document_store(jsonb) TO panels_core_app;
|
|
GRANT EXECUTE ON FUNCTION core.fn_company_document_store(jsonb) TO panels_core_app;
|
|
GRANT EXECUTE ON FUNCTION core.fn_project_document_get(jsonb) TO panels_core_app;
|
|
GRANT EXECUTE ON FUNCTION core.fn_company_document_get(jsonb) TO panels_core_app;
|
|
GRANT EXECUTE ON FUNCTION core.fn_worker_document_get(jsonb) TO panels_core_app;
|