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

211 lines
8 KiB
TypeScript

import postgres, { createPool, PgDb, withTenant } from "./pg.ts";
import { config } from "./config.ts";
import { callCoreFn, RpcCallError } from "./rpc.ts";
import { evaluateDocumentValidity, freshnessRequired, type ImssStatus, type WorkerImssContext } from "./document_validity.ts";
export type Db = PgDb;
let corePool: postgres.Sql | null = null;
let coreOwnerPool: postgres.Sql | null = null;
function getCorePool(): postgres.Sql {
if (!corePool) corePool = createPool(config.databaseUrlCore, { max: 20 });
return corePool;
}
function getCoreOwnerPool(): postgres.Sql {
if (!coreOwnerPool) {
coreOwnerPool = createPool(config.databaseUrlCoreOwner || config.databaseUrlCore, { max: 3 });
}
return coreOwnerPool;
}
export async function pingCoreDb(): Promise<void> {
await getCorePool()`SELECT 1`;
}
export async function withCoreTenant<T>(
tenantId: number | null,
fn: (db: Db) => Promise<T>,
): Promise<T> {
return await withTenant(getCorePool(), tenantId, fn);
}
export async function getCoreDb(): Promise<Db> {
return new PgDb(getCoreOwnerPool());
}
export async function closeCoreDb(): Promise<void> {
await corePool?.end({ timeout: 5 });
await coreOwnerPool?.end({ timeout: 5 });
corePool = null;
coreOwnerPool = null;
}
export async function lastInsertId(database: Db): Promise<number> {
return await database.lastInsertId();
}
export const PROJECT_STATUSES = ["activo", "pausado", "concluido", "cancelado"] as const;
export type ProjectStatus = (typeof PROJECT_STATUSES)[number];
export const PROJECT_STATUS_CATALOG = [
{ code: "activo", label: "Activo" },
{ code: "pausado", label: "Pausado" },
{ code: "concluido", label: "Concluido" },
{ code: "cancelado", label: "Cancelado" },
] as const;
export function isProjectStatus(value: string): value is ProjectStatus {
return (PROJECT_STATUSES as readonly string[]).includes(value);
}
export async function nextProjectCode(database: Db): Promise<string> {
const env = await callCoreFn<{ code: string }>(database, "core.fn_next_project_code", {});
if (!env.ok) throw new RpcCallError(env);
return String(env.data?.code ?? "PRY-0001");
}
export async function projectById(database: Db, id: number) {
const env = await callCoreFn<{ project: Record<string, unknown> }>(database, "core.fn_project_get", { id });
if (!env.ok) return undefined;
return env.data?.project as { id: number; code: string; name: string; status: string } | undefined;
}
export function projectMustBe(
project: { status: string } | undefined,
allowed: readonly ProjectStatus[],
closedMessage: string,
): { error: string; status: 400 | 404 } | null {
if (!project) return { error: "Proyecto no encontrado", status: 404 };
if (!allowed.includes(project.status as ProjectStatus)) {
return { error: closedMessage, status: 400 };
}
return null;
}
export async function workerImssContext(database: Db, workerId: number): Promise<WorkerImssContext> {
const env = await callCoreFn<{ imss_context: WorkerImssContext }>(
database, "core.fn_worker_checklist_raw", { worker_id: workerId },
);
if (env.ok && env.data?.imss_context) return env.data.imss_context;
return { imss_status: "sin_alta" as ImssStatus, last_rehire_at: null, current_alta_at: null };
}
export async function checklistItemsFor(database: Db, workerId: number, tenantId: number | null = null) {
const tz = await tenantTimezone(database, tenantId);
const today = todayInTimezone(tz);
const env = await callCoreFn<{
items?: Array<Record<string, unknown>>;
ctx?: WorkerImssContext;
imss_context?: WorkerImssContext;
freshness_required?: boolean;
document_types?: Array<Record<string, unknown>>;
current_documents?: Array<Record<string, unknown>>;
}>(database, "core.fn_worker_checklist_raw", { worker_id: workerId, tenant_id: tenantId });
if (env.ok && env.data?.items) {
return {
ctx: (env.data.ctx ?? env.data.imss_context) as WorkerImssContext,
freshness_required: Boolean(env.data.freshness_required),
items: env.data.items,
};
}
const types = env.data?.document_types ?? [];
const currentDocs = env.data?.current_documents ?? [];
const ctx = (env.data?.imss_context ?? await workerImssContext(database, workerId)) as WorkerImssContext;
const byType = new Map(currentDocs.map((d) => [String(d.type_code), d]));
const items = types.map((t) => {
const policy = {
code: String(t.code),
label: String(t.label),
required: !!t.required,
validity_mode: (String(t.validity_mode || "none")) as "none" | "freshness" | "expiry",
freshness_days: t.freshness_days as number | null,
requires_issued_at: !!t.requires_issued_at,
requires_expires_at: !!t.requires_expires_at,
};
const evaled = evaluateDocumentValidity(policy, byType.get(policy.code) as never, ctx, today);
return {
code: policy.code,
label: policy.label,
required: policy.required,
validity_mode: policy.validity_mode,
freshness_days: t.freshness_days,
requires_issued_at: policy.requires_issued_at,
requires_expires_at: policy.requires_expires_at,
category: String(t.category || "otro"),
present: evaled.present,
valid: evaled.valid,
validity_status: evaled.validity_status,
issued_at: evaled.issued_at,
expires_at: evaled.expires_at,
};
});
return { ctx, freshness_required: freshnessRequired(ctx), items };
}
export async function imssFlagsFor(database: Db, workerId: number, tenantId: number | null = null) {
const { ctx, freshness_required: needFresh, items } = await checklistItemsFor(database, workerId, tenantId);
const requiredOk = items.filter((i) => i.required).every((i) => i.present && i.valid);
const env = await callCoreFn<{ workers: Array<Record<string, unknown>> }>(
database, "core.fn_worker_list", { tenant_id: tenantId },
);
const row = env.ok ? (env.data?.workers ?? []).find((w) => Number(w.id) === workerId) : undefined;
if (row) {
return {
imss_status: String(row.imss_status ?? ctx.imss_status),
freshness_required: Boolean(row.freshness_required ?? needFresh),
imss_ready: Boolean(row.imss_ready),
in_project_without_imss: Boolean(row.in_project_without_imss),
expediente_ok: Boolean(row.expediente_ok),
};
}
const hasImss = ctx.imss_status === "alta";
return {
imss_status: ctx.imss_status,
freshness_required: needFresh,
imss_ready: requiredOk && !hasImss,
in_project_without_imss: false,
expediente_ok: requiredOk,
};
}
export async function checklistFor(database: Db, workerId: number, tenantId: number | null = null) {
return (await checklistItemsFor(database, workerId, tenantId)).items;
}
export async function refreshPipeline(database: Db, workerId: number, tenantId: number | null = null): Promise<void> {
const env = await callCoreFn(database, "core.fn_worker_refresh_pipeline", {
worker_id: workerId, tenant_id: tenantId,
});
if (!env.ok) throw new RpcCallError(env);
}
export async function projectChecklistFor(database: Db, projectId: number) {
const env = await callCoreFn<{ checklist: unknown[] }>(
database, "core.fn_project_checklist", { project_id: projectId },
);
if (!env.ok) throw new RpcCallError(env);
return env.data?.checklist ?? [];
}
export async function companyChecklistFor(database: Db, companyId: number) {
const env = await callCoreFn<{ checklist: unknown[] }>(
database, "core.fn_company_checklist", { company_id: companyId },
);
if (!env.ok) throw new RpcCallError(env);
return env.data?.checklist ?? [];
}
export async function tenantTimezone(database: Db, tenantId: number | null): Promise<string> {
if (tenantId == null) return "America/Mexico_City";
const env = await callCoreFn<{ timezone: string }>(
database, "core.fn_tenant_timezone_get", { tenant_id: tenantId },
);
if (!env.ok) return "America/Mexico_City";
return String(env.data?.timezone ?? "America/Mexico_City");
}
export function todayInTimezone(timezone: string, now = new Date()): string {
return now.toLocaleDateString("en-CA", { timeZone: timezone });
}