From f3179444bf01e7b69646c4561214f2518a888d6e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 23:58:55 +0000 Subject: [PATCH] refactor(api): migrate main.ts CRUD routes to PostgreSQL RPC Replace all db.prepare() calls in main.ts with callCoreFn and the respondRpc/respondApiError HTTP envelope helpers. Company and project routes use the RPC response pattern; worker, budget, badge, catalog, and tenant settings endpoints call their matching core.fn_* functions. Adds app.onError(onAppError) for consistent error handling. Keeps companies.ts wrappers, excel.ts document storage, and auth/PDF/S3 unchanged. Co-authored-by: alberto.martinez --- api/main.ts | 1041 ++++++++++++++++++++++++++++----------------------- 1 file changed, 565 insertions(+), 476 deletions(-) diff --git a/api/main.ts b/api/main.ts index 0ac52b5..ba5a988 100644 --- a/api/main.ts +++ b/api/main.ts @@ -3,7 +3,6 @@ import { cors } from "hono/cors"; import { checklistFor, refreshPipeline, - nextProjectCode, isProjectStatus, projectById, projectMustBe, @@ -11,13 +10,19 @@ import { projectChecklistFor, companyChecklistFor, imssFlagsFor, - checklistItemsFor, - lastInsertId, pingCoreDb, tenantTimezone, getCoreDb, type Db, } from "./db.ts"; +import { callCoreFn } from "./rpc.ts"; +import { + respondRpc, + respondApiError, + onAppError, + routeLabel, + type RpcEnvelope, +} from "./http_errors.ts"; import { config } from "./config.ts"; import { clearSession, @@ -36,14 +41,13 @@ import { createTenant, getTenantDetail, issueTenantAdminAccess, listTenants, upd import { smtpConfigured, testSmtp } from "./mail.ts"; import { saveSmtpSettings, smtpPublicView } from "./smtp.ts"; import { decryptBytes } from "./docs_crypto.ts"; -import { importExcel, storeDocument, storeProjectDocument, storeCompanyDocument, findExisting, assign, buildImportTemplate } from "./excel.ts"; +import { importExcel, storeDocument, storeProjectDocument, storeCompanyDocument, buildImportTemplate } from "./excel.ts"; import { normalizeWorker, validateCurp, validateNss, validateRfc, validateWorkerFields, - fullName, formatNss, normUpper, titleCase, @@ -58,15 +62,48 @@ import { exportBudgetWorkbook, previewBudgetExcel, importBudgetExcel, - lineAmount, listBudget, } from "./budget.ts"; -import { companyDocKey, getObject, pingStorage, projectDocKey, workerDocKey } from "./storage.ts"; +import { badgeJobPdfKey, companyDocKey, getObject, pingStorage, projectDocKey, workerDocKey } from "./storage.ts"; import { cacheCore, cacheKeyCore } from "./cache.ts"; import { pingRedis } from "./redis.ts"; const app = new Hono<{ Variables: { user: AuthUser; db: Db } }>(); +app.onError(onAppError); + +type StorageDoc = { + storage_name: string; + iv: string; + original_name: string; + mime?: string; +}; + +async function rpcDocumentForDownload( + db: Db, + fn: string, + payload: Record, + route: string, +): Promise<{ doc?: StorageDoc; envelope: RpcEnvelope }> { + const envelope = await callCoreFn<{ document: StorageDoc }>(db, fn, payload, { route }); + if (!envelope.ok) return { envelope }; + const doc = envelope.data?.document; + if (!doc?.storage_name) { + return { + envelope: { + ok: false, + code: "NOT_FOUND", + layer: "db", + message: "Documento no encontrado", + context: { fn, ...payload }, + data: null, + errors: null, + }, + }; + } + return { doc, envelope }; +} + async function scopedCompany( db: Db, id: number, @@ -237,20 +274,16 @@ app.get("/v1/catalogs", ...requireCoreAuth, async (c) => { const db = c.get("db"); const user = c.get("user"); const tid = tenantScope(user); - // Catálogos de baja escritura (Fase 4e): TTL corto, sin invalidación - // explícita. La llave incluye tenant_id porque `companies` sí es dato - // de negocio por-tenant -- risk_levels/badge_themes/document_types son - // globales, pero se cachean juntos por simplicidad de esta única - // respuesta agregada. - const payload = await cacheCore(cacheKeyCore(tid, "catalogs"), 30, async () => ({ - risks: await db.prepare("SELECT * FROM risk_levels").all(), - themes: await db.prepare("SELECT * FROM badge_themes").all(), - document_types: await db.prepare("SELECT * FROM document_types").all(), - project_document_types: await db.prepare("SELECT * FROM project_document_types").all(), - company_document_types: await db.prepare("SELECT * FROM company_document_types").all(), - project_statuses: PROJECT_STATUS_CATALOG, - companies: await listCompanies(db, tid), - })); + const route = routeLabel(c); + const payload = await cacheCore(cacheKeyCore(tid, "catalogs"), 30, async () => { + const env = await callCoreFn>(db, "core.fn_catalogs", {}, { route }); + if (!env.ok) throw new Error(env.message); + return { + ...env.data, + project_statuses: PROJECT_STATUS_CATALOG, + companies: await listCompanies(db, tid), + }; + }); return c.json(payload); }); @@ -265,25 +298,44 @@ app.get("/v1/configuracion", ...requireCoreAuth, async (c) => { app.put("/v1/configuracion", ...requireCoreAuth, async (c) => { const user = c.get("user"); - if (user.role !== "tenant_admin") return c.json({ error: "Solo administradores" }, 403); + if (user.role !== "tenant_admin") { + return respondApiError(c, "FORBIDDEN", "Solo administradores pueden cambiar la configuración del tenant", { + route: routeLabel(c), + }); + } const db = c.get("db"); const tid = tenantScope(user); - if (tid == null) return c.json({ error: "Cuenta sin tenant" }, 400); + if (tid == null) { + return respondApiError(c, "VALIDATION", "Cuenta sin tenant asociada para guardar configuración", { + route: routeLabel(c), + }); + } const body = await c.req.json<{ timezone?: string }>(); const tz = (body.timezone ?? "").trim(); const valid = new Set(Intl.supportedValuesOf("timeZone")); - if (!tz || !valid.has(tz)) return c.json({ error: "Zona horaria inválida" }, 400); - await db.prepare( - `INSERT INTO tenant_settings (tenant_id, timezone, updated_at) VALUES (?, ?, now()) - ON CONFLICT (tenant_id) DO UPDATE SET timezone = excluded.timezone, updated_at = now()`, - ).run(tid, tz); - return c.json({ timezone: tz }); + if (!tz || !valid.has(tz)) { + return respondApiError(c, "VALIDATION", `Zona horaria inválida en ${routeLabel(c)}: ${tz || "(vacío)"}`, { + route: routeLabel(c), + timezone: tz || null, + }); + } + return respondRpc(c, await callCoreFn( + db, + "core.fn_tenant_settings_upsert", + { tenant_id: tid, timezone: tz }, + { route: routeLabel(c) }, + )); }); app.get("/v1/companies", ...requireCoreAuth, async (c) => { const db = c.get("db"); const tid = tenantScope(c.get("user")); - return c.json({ companies: await listCompanies(db, tid) }); + return respondRpc(c, await callCoreFn( + db, + "core.fn_company_list", + { tenant_id: tid }, + { route: routeLabel(c) }, + )); }); app.post("/v1/companies", ...requireCoreAuth, async (c) => { @@ -291,8 +343,17 @@ app.post("/v1/companies", ...requireCoreAuth, async (c) => { const db = c.get("db"); const tid = tenantScope(c.get("user")); const result = await createSubcompany(db, body, tid); - if (result.error || !result.company) return c.json({ error: result.error }, 400); - return c.json({ company: result.company }, 201); + if (result.error || !result.company) { + const code = result.status === 404 ? "NOT_FOUND" : "VALIDATION"; + return respondApiError(c, code, result.error ?? "No se pudo crear la empresa", { route: routeLabel(c) }); + } + return respondRpc(c, { + ok: true, + code: "CREATED", + layer: "db", + message: `Empresa ${result.company.code} creada`, + data: { company: result.company }, + }); }); app.patch("/v1/companies/:id", ...requireCoreAuth, async (c) => { @@ -301,22 +362,50 @@ app.patch("/v1/companies/:id", ...requireCoreAuth, async (c) => { const db = c.get("db"); const tid = tenantScope(c.get("user")); const result = await updateCompany(db, id, body, tid); - if (result.error) return c.json({ error: result.error }, (result.status ?? 400) as 400 | 404); - return c.json({ company: result.company }); + if (result.error) { + const code = result.status === 404 ? "NOT_FOUND" : "VALIDATION"; + return respondApiError(c, code, result.error, { route: routeLabel(c), company_id: id }); + } + return respondRpc(c, { + ok: true, + code: "OK", + layer: "db", + message: `Empresa id=${id} actualizada`, + data: { company: result.company }, + }); }); app.get("/v1/companies/:id", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); const db = c.get("db"); - const company = await scopedCompany(db, id, tenantScope(c.get("user"))); - if (!company) return c.json({ error: "Empresa no encontrada" }, 404); - const documents = await db.prepare( - "SELECT id, type_code, original_name, mime, size_bytes, is_current, uploaded_at FROM company_documents WHERE company_id = ? ORDER BY uploaded_at DESC", - ).all(id); - return c.json({ - company, - documents, - checklist: await companyChecklistFor(db, id), + const tid = tenantScope(c.get("user")); + const route = routeLabel(c); + const companyEnv = await callCoreFn<{ company: Record }>(db, "core.fn_company_get", { id }, { route }); + if (!companyEnv.ok) return respondRpc(c, companyEnv); + const company = companyEnv.data?.company; + if (!company) { + return respondApiError(c, "NOT_FOUND", `Empresa id=${id} no encontrada en ${route}`, { route, company_id: id }); + } + if (tid != null && company.tenant_id != null && company.tenant_id !== tid) { + return respondApiError(c, "NOT_FOUND", `Empresa id=${id} no encontrada en ${route}`, { route, company_id: id }); + } + const docsEnv = await callCoreFn<{ documents: unknown[] }>( + db, + "core.fn_company_document_list", + { company_id: id }, + { route }, + ); + if (!docsEnv.ok) return respondRpc(c, docsEnv); + return respondRpc(c, { + ok: true, + code: "OK", + layer: "db", + message: `Empresa ${company.code} cargada`, + data: { + company, + documents: docsEnv.data?.documents ?? [], + checklist: await companyChecklistFor(db, id), + }, }); }); @@ -341,22 +430,19 @@ app.get("/v1/companies/:id/documents/:docId", ...requireCoreAuth, async (c) => { const companyId = Number(c.req.param("id")); const docId = Number(c.req.param("docId")); const db = c.get("db"); - if (!await scopedCompany(db, companyId, tenantScope(c.get("user")))) return c.json({ error: "Empresa no encontrada" }, 404); - const doc = await db.prepare( - "SELECT * FROM company_documents WHERE id=? AND company_id=?", - ).get(docId, companyId) as { - storage_name: string; - iv: string; - mime: string; - original_name: string; - } | undefined; - if (!doc) return c.json({ error: "Documento no encontrado" }, 404); + const route = routeLabel(c); + if (!await scopedCompany(db, companyId, tenantScope(c.get("user")))) { + return respondApiError(c, "NOT_FOUND", `Empresa id=${companyId} no encontrada en ${route}`, { route, company_id: companyId }); + } + const { doc, envelope } = await rpcDocumentForDownload( + db, + "core.fn_company_document_get", + { company_id: companyId, id: docId }, + route, + ); + if (!doc) return respondRpc(c, envelope); const enc = await getObject(companyDocKey(companyId, doc.storage_name)); const plain = await decryptBytes(doc.iv, enc); - // Documentos subidos por el usuario se sirven como adjunto, nunca inline: - // servir "inline" dejaría que el navegador renderice el Content-Type que - // el propio uploader eligió (ej. un .html disfrazado de "comprobante"), - // habilitando XSS almacenado dentro del origen autenticado de la app. c.header("Content-Type", "application/octet-stream"); c.header("X-Content-Type-Options", "nosniff"); c.header("Content-Disposition", `attachment; filename="${encodeURIComponent(doc.original_name)}"`); @@ -365,36 +451,20 @@ app.get("/v1/companies/:id/documents/:docId", ...requireCoreAuth, async (c) => { app.get("/v1/projects", ...requireCoreAuth, async (c) => { const db = c.get("db"); - const tid = tenantScope(c.get("user")); const status = (c.req.query("status") ?? "").trim(); const allowed = status ? status.split(",").map((s) => s.trim()).filter(isProjectStatus) : []; - const clauses: string[] = []; - const params: (string | number)[] = []; - if (tid != null) { - clauses.push("p.tenant_id = ?"); - params.push(tid); + if (status && !allowed.length) { + return respondApiError(c, "VALIDATION", `Ningún estado de proyecto válido en ${routeLabel(c)}: ${status}`, { + route: routeLabel(c), + status, + }); } - if (allowed.length) { - clauses.push(`p.status IN (${allowed.map(() => "?").join(",")})`); - params.push(...allowed); - } - const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""; - return c.json({ - projects: await db.prepare( - `SELECT p.*, c.name AS company_name, c.code AS company_code, - (SELECT COUNT(*) FROM assignments a WHERE a.project_id = p.id AND a.active = true) AS active_count, - (SELECT COUNT(*) FROM project_document_types t - WHERE t.required = true AND NOT EXISTS ( - SELECT 1 FROM project_documents d - WHERE d.project_id = p.id AND d.type_code = t.code AND d.is_current = true - )) AS missing_docs, - (SELECT COUNT(*) FROM budget_items b WHERE b.project_id = p.id) AS budget_count - FROM projects p - LEFT JOIN companies c ON c.id = p.company_id - ${where} - ORDER BY CASE p.status WHEN 'activo' THEN 0 WHEN 'pausado' THEN 1 WHEN 'concluido' THEN 2 ELSE 3 END, p.id`, - ).all(...params), - }); + return respondRpc(c, await callCoreFn( + db, + "core.fn_project_list", + { status: allowed.length ? allowed.join(",") : undefined }, + { route: routeLabel(c) }, + )); }); type ProjectInput = { @@ -446,98 +516,129 @@ function projectFields(body: ProjectInput, cur?: Record) { app.post("/v1/projects", ...requireCoreAuth, async (c) => { const body = await c.req.json(); - if (!body.name?.trim()) return c.json({ error: "Nombre de proyecto obligatorio" }, 400); + if (!body.name?.trim()) { + return respondApiError(c, "VALIDATION", `Nombre de proyecto obligatorio en ${routeLabel(c)}`, { + route: routeLabel(c), + }); + } const status = body.status && isProjectStatus(body.status) ? body.status : "activo"; const db = c.get("db"); const fields = projectFields(body); - if (!fields.company_id) return c.json({ error: "Seleccione la empresa del proyecto" }, 400); + if (!fields.company_id) { + return respondApiError(c, "VALIDATION", `Seleccione la empresa del proyecto en ${routeLabel(c)}`, { + route: routeLabel(c), + }); + } if (!await resolveCompany(db, { company_id: fields.company_id })) { - return c.json({ error: "Empresa no encontrada" }, 400); + return respondApiError(c, "VALIDATION", `Empresa id=${fields.company_id} no encontrada en ${routeLabel(c)}`, { + route: routeLabel(c), + company_id: fields.company_id, + }); } if (fields.start_date && fields.end_date && fields.end_date < fields.start_date) { - return c.json({ error: "La fecha de término no puede ser anterior al inicio" }, 400); + return respondApiError(c, "VALIDATION", `La fecha de término no puede ser anterior al inicio en ${routeLabel(c)}`, { + route: routeLabel(c), + }); } - const code = await nextProjectCode(db); const tid = tenantScope(c.get("user")); - await db.prepare( - `INSERT INTO projects ( - code, name, address, theme_id, status, company_id, contract_amount, - start_date, end_date, resident_name, siroc, payroll_tax_pct, tenant_id - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ).run( - code, - fields.name, - fields.address, - fields.theme_id, - status, - fields.company_id, - fields.contract_amount, - fields.start_date, - fields.end_date, - fields.resident_name, - fields.siroc, - fields.payroll_tax_pct, - tid, - ); - const id = await lastInsertId(db); - return c.json({ id, code, status }, 201); + return respondRpc(c, await callCoreFn( + db, + "core.fn_project_create", + { + tenant_id: tid, + name: fields.name, + address: fields.address, + theme_id: fields.theme_id, + status, + company_id: fields.company_id, + contract_amount: fields.contract_amount, + start_date: fields.start_date, + end_date: fields.end_date, + resident_name: fields.resident_name, + siroc: fields.siroc, + payroll_tax_pct: fields.payroll_tax_pct, + }, + { route: routeLabel(c) }, + )); }); app.patch("/v1/projects/:id", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); const body = await c.req.json(); const db = c.get("db"); - // Con RLS activo (db/core/changesets/005-rls.sql), esta consulta ya no - // puede traer una fila de otro tenant: la conexión solo ve app.tenant_id. - const cur = await db.prepare("SELECT * FROM projects WHERE id = ?").get(id) as Record | undefined; - if (!cur) return c.json({ error: "Proyecto no encontrado" }, 404); + const route = routeLabel(c); + const curEnv = await callCoreFn<{ project: Record }>(db, "core.fn_project_get", { id }, { route }); + if (!curEnv.ok) return respondRpc(c, curEnv); + const cur = curEnv.data?.project; + if (!cur) { + return respondApiError(c, "NOT_FOUND", `Proyecto id=${id} no encontrado en ${route}`, { route, project_id: id }); + } const status = body.status && isProjectStatus(body.status) ? body.status : String(cur.status || "activo"); const fields = projectFields(body, cur); - if (!fields.company_id) return c.json({ error: "Seleccione la empresa del proyecto" }, 400); + if (!fields.company_id) { + return respondApiError(c, "VALIDATION", `Seleccione la empresa del proyecto en ${route}`, { route, project_id: id }); + } if (!await resolveCompany(db, { company_id: fields.company_id })) { - return c.json({ error: "Empresa no encontrada" }, 400); + return respondApiError(c, "VALIDATION", `Empresa id=${fields.company_id} no encontrada en ${route}`, { + route, + project_id: id, + company_id: fields.company_id, + }); } if (fields.start_date && fields.end_date && fields.end_date < fields.start_date) { - return c.json({ error: "La fecha de término no puede ser anterior al inicio" }, 400); + return respondApiError(c, "VALIDATION", `La fecha de término no puede ser anterior al inicio en ${route}`, { + route, + project_id: id, + }); } - await db.prepare( - `UPDATE projects SET name=?, address=?, theme_id=?, status=?, company_id=?, contract_amount=?, - start_date=?, end_date=?, resident_name=?, siroc=?, payroll_tax_pct=? WHERE id=?`, - ).run( - fields.name, - fields.address, - fields.theme_id, - status, - fields.company_id, - fields.contract_amount, - fields.start_date, - fields.end_date, - fields.resident_name, - fields.siroc, - fields.payroll_tax_pct, - id, - ); - return c.json({ ok: true, id, code: cur.code, status }); + return respondRpc(c, await callCoreFn( + db, + "core.fn_project_update", + { + id, + name: fields.name, + address: fields.address, + theme_id: fields.theme_id, + status, + company_id: fields.company_id, + contract_amount: fields.contract_amount, + start_date: fields.start_date, + end_date: fields.end_date, + resident_name: fields.resident_name, + siroc: fields.siroc, + payroll_tax_pct: fields.payroll_tax_pct, + }, + { route }, + )); }); app.get("/v1/projects/:id", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); const db = c.get("db"); - const project = await db.prepare( - `SELECT p.*, c.name AS company_name, c.code AS company_code, - (SELECT COUNT(*) FROM assignments a WHERE a.project_id = p.id AND a.active = true) AS active_count - FROM projects p - LEFT JOIN companies c ON c.id = p.company_id - WHERE p.id = ?`, - ).get(id); - if (!project) return c.json({ error: "Proyecto no encontrado" }, 404); - const documents = await db.prepare( - "SELECT id, type_code, original_name, mime, size_bytes, is_current, uploaded_at FROM project_documents WHERE project_id = ? ORDER BY uploaded_at DESC", - ).all(id); - return c.json({ - project, - documents, - checklist: await projectChecklistFor(db, id), + const route = routeLabel(c); + const projectEnv = await callCoreFn<{ project: Record }>(db, "core.fn_project_get", { id }, { route }); + if (!projectEnv.ok) return respondRpc(c, projectEnv); + const project = projectEnv.data?.project; + if (!project) { + return respondApiError(c, "NOT_FOUND", `Proyecto id=${id} no encontrado en ${route}`, { route, project_id: id }); + } + const docsEnv = await callCoreFn<{ documents: unknown[] }>( + db, + "core.fn_project_document_list", + { project_id: id }, + { route }, + ); + if (!docsEnv.ok) return respondRpc(c, docsEnv); + return respondRpc(c, { + ok: true, + code: "OK", + layer: "db", + message: `Proyecto ${project.code} cargado`, + data: { + project, + documents: docsEnv.data?.documents ?? [], + checklist: await projectChecklistFor(db, id), + }, }); }); @@ -562,15 +663,14 @@ app.get("/v1/projects/:id/documents/:docId", ...requireCoreAuth, async (c) => { const projectId = Number(c.req.param("id")); const docId = Number(c.req.param("docId")); const db = c.get("db"); - const doc = await db.prepare( - "SELECT * FROM project_documents WHERE id=? AND project_id=?", - ).get(docId, projectId) as { - storage_name: string; - iv: string; - mime: string; - original_name: string; - } | undefined; - if (!doc) return c.json({ error: "Documento no encontrado" }, 404); + const route = routeLabel(c); + const { doc, envelope } = await rpcDocumentForDownload( + db, + "core.fn_project_document_get", + { project_id: projectId, id: docId }, + route, + ); + if (!doc) return respondRpc(c, envelope); const enc = await getObject(projectDocKey(projectId, doc.storage_name)); const plain = await decryptBytes(doc.iv, enc); c.header("Content-Type", "application/octet-stream"); @@ -644,18 +744,24 @@ app.post("/v1/projects/:id/budget/import", ...requireCoreAuth, async (c) => { app.post("/v1/projects/:id/budget/chapters", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); const body = await c.req.json<{ name?: string; code?: string; parent_id?: number | null }>(); - if (!body.name?.trim()) return c.json({ error: "Nombre de capítulo obligatorio" }, 400); + if (!body.name?.trim()) { + return respondApiError(c, "VALIDATION", `Nombre de capítulo obligatorio en ${routeLabel(c)}`, { + route: routeLabel(c), + project_id: id, + }); + } const db = c.get("db"); - if (!await projectById(db, id)) return c.json({ error: "Proyecto no encontrado" }, 404); - const sort = (await db.prepare("SELECT COALESCE(MAX(sort_order), 0) + 1 AS n FROM budget_chapters WHERE project_id = ?").get(id) as { n: number }).n; - await db.prepare("INSERT INTO budget_chapters (project_id, parent_id, code, name, sort_order) VALUES (?, ?, ?, ?, ?)").run( - id, - body.parent_id || null, - (body.code || "").trim(), - body.name.trim(), - sort, - ); - return c.json({ id: await lastInsertId(db) }, 201); + return respondRpc(c, await callCoreFn( + db, + "core.fn_budget_chapter_create", + { + project_id: id, + parent_id: body.parent_id || null, + code: (body.code || "").trim(), + name: body.name.trim(), + }, + { route: routeLabel(c) }, + )); }); app.patch("/v1/projects/:id/budget/chapters/:cid", ...requireCoreAuth, async (c) => { @@ -663,25 +769,29 @@ app.patch("/v1/projects/:id/budget/chapters/:cid", ...requireCoreAuth, async (c) const cid = Number(c.req.param("cid")); const body = await c.req.json<{ name?: string; code?: string }>(); const db = c.get("db"); - const cur = await db.prepare("SELECT * FROM budget_chapters WHERE id = ? AND project_id = ?").get(cid, id) as { name: string; code: string } | undefined; - if (!cur) return c.json({ error: "Capítulo no encontrado" }, 404); - await db.prepare("UPDATE budget_chapters SET name = ?, code = ? WHERE id = ?").run( - (body.name ?? cur.name).trim(), - (body.code ?? cur.code).trim(), - cid, - ); - return c.json({ ok: true }); + return respondRpc(c, await callCoreFn( + db, + "core.fn_budget_chapter_update", + { + project_id: id, + id: cid, + name: body.name, + code: body.code, + }, + { route: routeLabel(c) }, + )); }); app.delete("/v1/projects/:id/budget/chapters/:cid", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); const cid = Number(c.req.param("cid")); const db = c.get("db"); - const found = await db.prepare("SELECT id FROM budget_chapters WHERE id = ? AND project_id = ?").get(cid, id); - if (!found) return c.json({ error: "Capítulo no encontrado" }, 404); - await db.prepare("UPDATE budget_items SET chapter_id = NULL WHERE project_id = ? AND chapter_id = ?").run(id, cid); - await db.prepare("DELETE FROM budget_chapters WHERE id = ? AND project_id = ?").run(cid, id); - return c.json({ ok: true }); + return respondRpc(c, await callCoreFn( + db, + "core.fn_budget_chapter_delete", + { project_id: id, id: cid }, + { route: routeLabel(c) }, + )); }); type BudgetItemInput = { @@ -696,27 +806,27 @@ type BudgetItemInput = { app.post("/v1/projects/:id/budget/items", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); const body = await c.req.json(); - if (!body.description?.trim()) return c.json({ error: "Descripción de partida obligatoria" }, 400); + if (!body.description?.trim()) { + return respondApiError(c, "VALIDATION", `Descripción de partida obligatoria en ${routeLabel(c)}`, { + route: routeLabel(c), + project_id: id, + }); + } const db = c.get("db"); - if (!await projectById(db, id)) return c.json({ error: "Proyecto no encontrado" }, 404); - const qty = Number(body.quantity ?? 0); - const price = Number(body.unit_price ?? 0); - const sort = (await db.prepare("SELECT COALESCE(MAX(sort_order), 0) + 1 AS n FROM budget_items WHERE project_id = ?").get(id) as { n: number }).n; - await db.prepare( - `INSERT INTO budget_items (project_id, chapter_id, code, description, unit, quantity, unit_price, amount, sort_order) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ).run( - id, - body.chapter_id || null, - (body.code || "").trim().toUpperCase(), - body.description.trim(), - (body.unit || "").trim().toUpperCase(), - qty, - price, - lineAmount(qty, price), - sort, - ); - return c.json({ id: await lastInsertId(db) }, 201); + return respondRpc(c, await callCoreFn( + db, + "core.fn_budget_item_create", + { + project_id: id, + chapter_id: body.chapter_id || null, + code: (body.code || "").trim(), + description: body.description.trim(), + unit: (body.unit || "").trim(), + quantity: body.quantity ?? 0, + unit_price: body.unit_price ?? 0, + }, + { route: routeLabel(c) }, + )); }); app.patch("/v1/projects/:id/budget/items/:iid", ...requireCoreAuth, async (c) => { @@ -724,39 +834,40 @@ app.patch("/v1/projects/:id/budget/items/:iid", ...requireCoreAuth, async (c) => const iid = Number(c.req.param("iid")); const body = await c.req.json(); const db = c.get("db"); - const cur = await db.prepare("SELECT * FROM budget_items WHERE id = ? AND project_id = ?").get(iid, id) as Record | undefined; - if (!cur) return c.json({ error: "Partida no encontrada" }, 404); - const qty = body.quantity !== undefined ? Number(body.quantity) : Number(cur.quantity); - const price = body.unit_price !== undefined ? Number(body.unit_price) : Number(cur.unit_price); - await db.prepare( - `UPDATE budget_items SET chapter_id=?, code=?, description=?, unit=?, quantity=?, unit_price=?, amount=? WHERE id=?`, - ).run( - body.chapter_id !== undefined ? (body.chapter_id || null) : cur.chapter_id, - (body.code ?? String(cur.code ?? "")).trim().toUpperCase(), - (body.description ?? String(cur.description ?? "")).trim(), - (body.unit ?? String(cur.unit ?? "")).trim().toUpperCase(), - qty, - price, - lineAmount(qty, price), - iid, - ); - return c.json({ ok: true }); + return respondRpc(c, await callCoreFn( + db, + "core.fn_budget_item_update", + { + project_id: id, + id: iid, + chapter_id: body.chapter_id, + code: body.code, + description: body.description, + unit: body.unit, + quantity: body.quantity, + unit_price: body.unit_price, + }, + { route: routeLabel(c) }, + )); }); app.delete("/v1/projects/:id/budget/items/:iid", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); const iid = Number(c.req.param("iid")); const db = c.get("db"); - const found = await db.prepare("SELECT id FROM budget_items WHERE id = ? AND project_id = ?").get(iid, id); - if (!found) return c.json({ error: "Partida no encontrada" }, 404); - await db.prepare("DELETE FROM budget_items WHERE id = ? AND project_id = ?").run(iid, id); - return c.json({ ok: true }); + return respondRpc(c, await callCoreFn( + db, + "core.fn_budget_item_delete", + { project_id: id, id: iid }, + { route: routeLabel(c) }, + )); }); app.post("/v1/workers/validate", ...requireCoreAuth, async (c) => { const body = await c.req.json(); const errors = validateWorkerFields(body); const db = c.get("db"); + const route = routeLabel(c); const exclude = body.id ?? 0; const checks: { field: string; value: string; err: string | null }[] = [ { field: "curp", value: normUpper(body.curp), err: validateCurp(body.curp ?? "") }, @@ -765,9 +876,16 @@ app.post("/v1/workers/validate", ...requireCoreAuth, async (c) => { ]; for (const ch of checks) { if (ch.err) continue; - const row = await db.prepare( - `SELECT id, first_name, last_name_p FROM workers WHERE ${ch.field} = ? AND id != ?`, - ).get(ch.value, exclude) as { id: number; first_name: string; last_name_p: string } | undefined; + const env = await callCoreFn<{ worker?: { id: number; first_name: string; last_name_p: string } | null; found?: boolean }>( + db, + "core.fn_worker_find_existing", + { + [ch.field]: ch.value, + exclude_id: exclude, + }, + { route }, + ); + const row = env.ok && env.data?.found ? env.data.worker : undefined; if (row) { errors[ch.field] = `Este ${ch.field.toUpperCase()} ya pertenece a ${row.first_name} ${row.last_name_p}`; @@ -782,128 +900,36 @@ app.get("/v1/workers", ...requireCoreAuth, async (c) => { const q = (c.req.query("q") ?? "").trim(); const status = c.req.query("status"); const projectId = c.req.query("project_id"); - let sql = `SELECT w.*, r.label AS risk_label, r.color AS risk_color, r.text_color AS risk_text, - c.name AS company_name, c.kind AS company_kind, - ic.name AS imss_company_name, ic.code AS imss_company_code, ic.registro_patronal AS imss_registro_patronal, - (SELECT STRING_AGG(p.name, ', ') FROM assignments a JOIN projects p ON p.id = a.project_id - WHERE a.worker_id = w.id AND a.active = true) AS proyectos, - (SELECT STRING_AGG(p.code || '|' || replace(p.name, '|', '/'), ';;') FROM assignments a JOIN projects p ON p.id = a.project_id - WHERE a.worker_id = w.id AND a.active = true) AS proyecto_pairs, - (SELECT STRING_AGG(p.id::text, ',') FROM assignments a JOIN projects p ON p.id = a.project_id - WHERE a.worker_id = w.id AND a.active = true) AS project_ids, - (SELECT COUNT(*) FROM document_types t - WHERE t.required = true AND NOT EXISTS ( - SELECT 1 FROM documents d - WHERE d.worker_id = w.id AND d.type_code = t.code AND d.is_current = true - )) AS missing_docs, - (SELECT COALESCE(SUM(l.balance), 0) FROM loans l WHERE l.worker_id = w.id AND l.balance > 0) AS loan_balance, - CASE WHEN EXISTS (SELECT 1 FROM assignments a WHERE a.worker_id = w.id AND a.active = true) THEN true ELSE false END AS in_project - FROM workers w - JOIN risk_levels r ON r.code = w.risk_code - LEFT JOIN companies c ON c.id = w.company_id - LEFT JOIN companies ic ON ic.id = w.imss_company_id - WHERE 1=1`; - const params: (string | number)[] = []; - if (tid != null) { - sql += " AND w.tenant_id = ?"; - params.push(tid); - } - if (status) { - sql += " AND w.status = ?"; - params.push(status); - if (status === "activo") sql += " AND w.pipeline_status != 'baja'"; - } - if (projectId) { - sql += - " AND EXISTS (SELECT 1 FROM assignments a WHERE a.worker_id = w.id AND a.project_id = ? AND a.active = true)"; - params.push(Number(projectId)); - } - if (q) { - sql += - " AND (w.first_name ILIKE ? OR w.last_name_p ILIKE ? OR w.curp ILIKE ? OR w.rfc ILIKE ? OR w.nss ILIKE ?)"; - const like = `%${q}%`; - params.push(like, like, like, like, like); - } - sql += " ORDER BY w.last_name_p, w.first_name"; - const rows = await db.prepare(sql).all(...params) as Array & { - id: number; - status: string; - pipeline_status: string; - imss_status?: string; - in_project?: boolean; - }>; - const workers = []; - for (const row of rows) { - const flags = await imssFlagsFor(db, row.id, tid); - workers.push({ - ...row, - imss_status: flags.imss_status, - imss_ready: flags.imss_ready, - in_project_without_imss: flags.in_project_without_imss, - expediente_ok: flags.expediente_ok, - freshness_required: flags.freshness_required, - }); - } - const active = workers.filter((w) => w.status === "activo" && w.pipeline_status !== "baja"); - const withImss = active.filter((w) => w.imss_status === "alta").length; - const withoutImss = active.length - withImss; - const inProjectWithoutImss = active.filter((w) => w.in_project_without_imss).length; - const imssReady = active.filter((w) => w.imss_ready).length; - return c.json({ - workers, - imss_stats: { - with_imss: withImss, - without_imss: withoutImss, - in_project_without_imss: inProjectWithoutImss, - imss_ready_count: imssReady, + return respondRpc(c, await callCoreFn( + db, + "core.fn_worker_list", + { + tenant_id: tid, + q: q || undefined, + status: status || undefined, + project_id: projectId ? Number(projectId) : undefined, }, - }); + { route: routeLabel(c) }, + )); }); app.get("/v1/workers/:id", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); const db = c.get("db"); const tid = tenantScope(c.get("user")); - const worker = await db.prepare( - `SELECT w.*, r.label AS risk_label, r.color AS risk_color, r.text_color AS risk_text, - c.name AS company_name, c.kind AS company_kind, - ic.name AS imss_company_name, ic.code AS imss_company_code, ic.registro_patronal AS imss_registro_patronal - FROM workers w - JOIN risk_levels r ON r.code = w.risk_code - LEFT JOIN companies c ON c.id = w.company_id - LEFT JOIN companies ic ON ic.id = w.imss_company_id - WHERE w.id = ?`, - ).get(id); - if (!worker) return c.json({ error: "No encontrado" }, 404); - const documents = await db.prepare( - `SELECT id, type_code, original_name, mime, size_bytes, is_current, uploaded_at, - issued_at, expires_at, imss_company_id, imss_alta_at - FROM documents WHERE worker_id = ? ORDER BY uploaded_at DESC`, - ).all(id); - const assignments = await db.prepare( - `SELECT a.*, p.name AS project_name, p.code AS project_code FROM assignments a - JOIN projects p ON p.id = a.project_id WHERE a.worker_id = ? - ORDER BY a.active DESC, a.start_date DESC, a.id DESC`, - ).all(id); - const loans = await db.prepare("SELECT * FROM loans WHERE worker_id = ? ORDER BY id DESC").all(id); - const { items, freshness_required } = await checklistItemsFor(db, id, tid); - const flags = await imssFlagsFor(db, id, tid); - const docTypes = await db.prepare( - `SELECT code, label, required, validity_mode, freshness_days, requires_issued_at, requires_expires_at, category - FROM document_types ORDER BY required DESC, label`, - ).all(); - return c.json({ - worker: { ...worker, full_name: fullName(worker as never), ...flags }, - documents, - assignments, - loans, - checklist: items, - document_types: docTypes, - freshness_required, - }); + return respondRpc(c, await callCoreFn( + db, + "core.fn_worker_get", + { id, tenant_id: tid }, + { route: routeLabel(c) }, + )); }); -function conflict(row: { id: number; first_name: string; last_name_p: string; curp: string; rfc: string; nss: string } | undefined, n: ReturnType, excludeId = 0) { +function conflict( + row: { id: number; first_name: string; last_name_p: string; curp: string; rfc: string; nss: string } | undefined, + n: ReturnType, + excludeId = 0, +) { if (row && row.id !== excludeId) { const field = row.curp === n.curp ? "CURP" : row.rfc === n.rfc ? "RFC" : "NSS"; return { @@ -917,97 +943,111 @@ function conflict(row: { id: number; first_name: string; last_name_p: string; cu return null; } +async function findExistingWorker( + db: Db, + curp: string, + rfc: string, + nss: string, + excludeId = 0, + route = "worker-duplicate-check", +) { + const env = await callCoreFn<{ + worker?: { id: number; first_name: string; last_name_p: string; curp: string; rfc: string; nss: string } | null; + found?: boolean; + }>(db, "core.fn_worker_find_existing", { curp, rfc, nss, exclude_id: excludeId }, { route }); + if (!env.ok || !env.data?.found) return undefined; + return env.data.worker; +} + app.post("/v1/workers", ...requireCoreAuth, async (c) => { const body = await c.req.json(); const errors = validateWorkerFields(body); const db = c.get("db"); - if (Object.keys(errors).length) return c.json({ errors }, 400); + const route = routeLabel(c); + if (Object.keys(errors).length) { + return respondApiError(c, "VALIDATION", `Datos del trabajador inválidos en ${route}`, { route }, errors); + } const n = normalizeWorker({ ...body, hire_type: "" }); - const existing = await findExisting(db, n.curp, n.rfc, n.nss); + const existing = await findExistingWorker(db, n.curp, n.rfc, n.nss, 0, route); const cf = conflict(existing, n); - if (cf) return c.json(cf.body, cf.status); + if (cf) { + return respondApiError(c, "CONFLICT", cf.body.error, { route, worker_id: cf.body.worker_id }); + } if (body.project_id) { const blocked = projectMustBe( await projectById(db, body.project_id), ["activo"], "Solo se asigna personal a proyectos activos", ); - if (blocked) return c.json({ error: blocked.error }, blocked.status); + if (blocked) { + const code = blocked.status === 404 ? "NOT_FOUND" : "VALIDATION"; + return respondApiError(c, code, blocked.error, { route, project_id: body.project_id }); + } } const tid = tenantScope(c.get("user")); - await db.prepare( - `INSERT INTO workers - (first_name, middle_name, last_name_p, last_name_m, curp, rfc, nss, phone, email, address, - blood_type, hire_type, company_id, position, risk_code, work_type, daily_wage, needs_badge, status, tenant_id) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, - ).run( - n.first_name, n.middle_name, n.last_name_p, n.last_name_m, n.curp, n.rfc, n.nss, - n.phone, n.email, n.address, n.blood_type, "", null, n.position, n.risk_code, - n.work_type, n.daily_wage, n.needs_badge, n.status, tid, - ); - const id = await lastInsertId(db); - if (body.project_id) { - await assign(db, id, body.project_id); - } - await refreshPipeline(db, id, tid); - return c.json({ id }, 201); + return respondRpc(c, await callCoreFn( + db, + "core.fn_worker_create", + { ...body, tenant_id: tid, project_id: body.project_id ?? null }, + { route }, + )); }); app.patch("/v1/workers/:id", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); const db = c.get("db"); const tid = tenantScope(c.get("user")); - const cur = await db.prepare("SELECT * FROM workers WHERE id = ?").get(id) as Record | undefined; - if (!cur) return c.json({ error: "No encontrado" }, 404); + const route = routeLabel(c); + const curEnv = await callCoreFn<{ worker: Record }>(db, "core.fn_worker_get", { id, tenant_id: tid }, { route }); + if (!curEnv.ok) return respondRpc(c, curEnv); + const cur = curEnv.data?.worker; + if (!cur) { + return respondApiError(c, "NOT_FOUND", `Trabajador id=${id} no encontrado en ${route}`, { route, worker_id: id }); + } const body = await c.req.json(); const merged = { ...cur, ...body } as WorkerInput; const errors = validateWorkerFields(merged); - if (Object.keys(errors).length) return c.json({ errors }, 400); - // Empresa/patrón solo cambia con alta/baja IMSS; no se sobrescribe desde el formulario. - const hireType = String(cur.hire_type || ""); - const companyId = (cur.company_id as number | null) ?? null; - const n = normalizeWorker({ ...merged, hire_type: hireType }); - const existing = await findExisting(db, n.curp, n.rfc, n.nss); + if (Object.keys(errors).length) { + return respondApiError(c, "VALIDATION", `Datos del trabajador inválidos en ${route}`, { route, worker_id: id }, errors); + } + const n = normalizeWorker({ ...merged, hire_type: String(cur.hire_type || "") }); + const existing = await findExistingWorker(db, n.curp, n.rfc, n.nss, id, route); const cf = conflict(existing, n, id); - if (cf) return c.json(cf.body, cf.status); - await db.prepare( - `UPDATE workers SET - first_name=?, middle_name=?, last_name_p=?, last_name_m=?, curp=?, rfc=?, nss=?, - phone=?, email=?, address=?, blood_type=?, hire_type=?, company_id=?, position=?, risk_code=?, - work_type=?, daily_wage=?, needs_badge=?, status=?, updated_at=now() - WHERE id=?`, - ).run( - n.first_name, n.middle_name, n.last_name_p, n.last_name_m, n.curp, n.rfc, n.nss, - n.phone, n.email, n.address, n.blood_type, hireType, companyId, n.position, n.risk_code, - n.work_type, n.daily_wage, n.needs_badge, n.status, id, - ); - await refreshPipeline(db, id, tid); - return c.json({ ok: true }); + if (cf) { + return respondApiError(c, "CONFLICT", cf.body.error, { route, worker_id: cf.body.worker_id }); + } + return respondRpc(c, await callCoreFn( + db, + "core.fn_worker_update", + { id, tenant_id: tid, ...body }, + { route }, + )); }); app.patch("/v1/workers/:id/pipeline", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); const { pipeline_status } = await c.req.json<{ pipeline_status: string }>(); const allowed = ["incompleto", "listo_gafete", "impreso", "activo", "baja"]; - if (!allowed.includes(pipeline_status)) return c.json({ error: "Estado inválido" }, 400); + if (!allowed.includes(pipeline_status)) { + return respondApiError(c, "VALIDATION", `Estado de pipeline inválido en ${routeLabel(c)}: ${pipeline_status}`, { + route: routeLabel(c), + worker_id: id, + pipeline_status, + }); + } const db = c.get("db"); const tid = tenantScope(c.get("user")); - const prev = await db.prepare("SELECT status FROM workers WHERE id=?").get(id) as { status: string } | undefined; - if (pipeline_status === "baja") { - await db.prepare("UPDATE workers SET status='baja', pipeline_status='baja' WHERE id=?").run(id); - } else { - // Reactivar: si venía de baja, marca rehire para exigir docs frescos hasta nueva alta IMSS. - if (prev?.status === "baja") { - await db.prepare( - "UPDATE workers SET status='activo', last_rehire_at=current_date WHERE id=?", - ).run(id); - } else { - await db.prepare("UPDATE workers SET status='activo' WHERE id=?").run(id); - } - await refreshPipeline(db, id, tid); - } - const worker = await db.prepare("SELECT status, pipeline_status, last_rehire_at, imss_status FROM workers WHERE id=?").get(id); - return c.json({ ok: true, worker }); + const env = await callCoreFn( + db, + "core.fn_worker_set_pipeline", + { id, tenant_id: tid, pipeline_status }, + { route: routeLabel(c) }, + ); + if (!env.ok) return respondRpc(c, env); + return respondRpc(c, { + ...env, + data: { ok: true, ...(env.data as Record) }, + }); }); app.post("/v1/workers/:id/assign", ...requireCoreAuth, async (c) => { @@ -1015,20 +1055,35 @@ app.post("/v1/workers/:id/assign", ...requireCoreAuth, async (c) => { const { project_id, active } = await c.req.json<{ project_id: number; active?: boolean }>(); const db = c.get("db"); const tid = tenantScope(c.get("user")); + const route = routeLabel(c); if (active === false) { - await db.prepare( - "UPDATE assignments SET active=false, end_date=current_date WHERE worker_id=? AND project_id=?", - ).run(id, project_id); - } else { - const project = await projectById(db, project_id); - if (!project) return c.json({ error: "Proyecto no encontrado" }, 404); - if (project.status !== "activo") { - return c.json({ error: "Solo se asigna personal a proyectos activos" }, 400); - } - await assign(db, id, project_id); + return respondRpc(c, await callCoreFn( + db, + "core.fn_worker_unassign", + { worker_id: id, project_id, tenant_id: tid }, + { route }, + )); } - await refreshPipeline(db, id, tid); - return c.json({ ok: true }); + const project = await projectById(db, project_id); + if (!project) { + return respondApiError(c, "NOT_FOUND", `Proyecto id=${project_id} no encontrado en ${route}`, { + route, + project_id, + }); + } + if (project.status !== "activo") { + return respondApiError(c, "VALIDATION", `Solo se asigna personal a proyectos activos en ${route}`, { + route, + project_id, + status: project.status, + }); + } + return respondRpc(c, await callCoreFn( + db, + "core.fn_worker_assign", + { worker_id: id, project_id, tenant_id: tid }, + { route }, + )); }); app.get("/v1/workers/import/template", ...requireCoreAuth, async (c) => { @@ -1063,19 +1118,36 @@ app.post("/v1/workers/:id/documents", ...requireCoreAuth, async (c) => { const form = await c.req.formData(); const type = String(form.get("type") || ""); const file = form.get("file"); - if (!(file instanceof File) || !type) return c.json({ error: "type y file requeridos" }, 400); + if (!(file instanceof File) || !type) { + return respondApiError(c, "VALIDATION", `type y file son requeridos en ${routeLabel(c)}`, { + route: routeLabel(c), + worker_id: id, + }); + } const db = c.get("db"); - const exists = await db.prepare("SELECT id FROM workers WHERE id=?").get(id); - if (!exists) return c.json({ error: "No encontrado" }, 404); + const tid = tenantScope(c.get("user")); + const route = routeLabel(c); + const workerEnv = await callCoreFn(db, "core.fn_worker_get", { id, tenant_id: tid }, { route }); + if (!workerEnv.ok) return respondRpc(c, workerEnv); - const policy = await db.prepare( - `SELECT validity_mode, requires_issued_at, requires_expires_at FROM document_types WHERE code = ?`, - ).get(type) as { + const catEnv = await callCoreFn<{ document_types?: Array> }>( + db, + "core.fn_catalogs", + {}, + { route }, + ); + const policy = (catEnv.data?.document_types ?? []).find((t) => String(t.code) === type) as { validity_mode: string; requires_issued_at: boolean; requires_expires_at: boolean; } | undefined; - if (!policy) return c.json({ error: "Tipo de documento no válido" }, 400); + if (!policy) { + return respondApiError(c, "VALIDATION", `Tipo de documento no válido en ${route}: ${type}`, { + route, + worker_id: id, + type, + }); + } const issuedAt = String(form.get("issued_at") || "").trim() || null; const expiresAt = String(form.get("expires_at") || "").trim() || null; @@ -1084,19 +1156,19 @@ app.post("/v1/workers/:id/documents", ...requireCoreAuth, async (c) => { const imssBajaAt = String(form.get("imss_baja_at") || "").trim() || null; if (policy.requires_issued_at && !issuedAt) { - return c.json({ error: "Indique la fecha de emisión del documento" }, 400); + return respondApiError(c, "VALIDATION", `Indique la fecha de emisión del documento en ${route}`, { route, worker_id: id }); } if (policy.requires_expires_at && !expiresAt) { - return c.json({ error: "Indique la fecha de vigencia / vencimiento" }, 400); + return respondApiError(c, "VALIDATION", `Indique la fecha de vigencia / vencimiento en ${route}`, { route, worker_id: id }); } if (type === "alta_imss" && !imssCompanyId) { - return c.json({ error: "Seleccione la empresa patrón del alta IMSS" }, 400); + return respondApiError(c, "VALIDATION", `Seleccione la empresa patrón del alta IMSS en ${route}`, { route, worker_id: id }); } if (type === "alta_imss" && !imssAltaAt) { - return c.json({ error: "Indique la fecha de alta IMSS" }, 400); + return respondApiError(c, "VALIDATION", `Indique la fecha de alta IMSS en ${route}`, { route, worker_id: id }); } if (type === "baja_imss" && !imssBajaAt) { - return c.json({ error: "Indique la fecha de baja IMSS" }, 400); + return respondApiError(c, "VALIDATION", `Indique la fecha de baja IMSS en ${route}`, { route, worker_id: id }); } const bytes = new Uint8Array(await file.arrayBuffer()); @@ -1109,9 +1181,9 @@ app.post("/v1/workers/:id/documents", ...requireCoreAuth, async (c) => { imss_baja_at: imssBajaAt, }); } catch (error) { - return c.json({ error: error instanceof Error ? error.message : "No se pudo guardar" }, 400); + const message = error instanceof Error ? error.message : "No se pudo guardar el documento"; + return respondApiError(c, "VALIDATION", message, { route, worker_id: id }); } - const tid = tenantScope(c.get("user")); return c.json({ ok: true, checklist: await checklistFor(db, id, tid), @@ -1123,15 +1195,14 @@ app.get("/v1/workers/:id/documents/:docId", ...requireCoreAuth, async (c) => { const workerId = Number(c.req.param("id")); const docId = Number(c.req.param("docId")); const db = c.get("db"); - const doc = await db.prepare( - "SELECT * FROM documents WHERE id=? AND worker_id=?", - ).get(docId, workerId) as { - storage_name: string; - iv: string; - mime: string; - original_name: string; - } | undefined; - if (!doc) return c.json({ error: "Documento no encontrado" }, 404); + const route = routeLabel(c); + const { doc, envelope } = await rpcDocumentForDownload( + db, + "core.fn_worker_document_get", + { worker_id: workerId, id: docId }, + route, + ); + if (!doc) return respondRpc(c, envelope); const enc = await getObject(workerDocKey(workerId, doc.storage_name)); const plain = await decryptBytes(doc.iv, enc); c.header("Content-Type", "application/octet-stream"); @@ -1169,68 +1240,85 @@ app.post("/v1/projects/:id/badge-jobs", ...requireCoreAuth, async (c) => { const projectId = Number(c.req.param("id")); const body = await c.req.json<{ worker_ids?: number[] }>().catch(() => ({ worker_ids: [] as number[] })); const db = c.get("db"); + const user = c.get("user"); + const tid = tenantScope(user); + const route = routeLabel(c); const blocked = projectMustBe( await projectById(db, projectId), ["activo"], "Solo se generan gafetes de proyectos activos", ); - if (blocked) return c.json({ error: blocked.error }, blocked.status); + if (blocked) { + const code = blocked.status === 404 ? "NOT_FOUND" : "VALIDATION"; + return respondApiError(c, code, blocked.error, { route, project_id: projectId }); + } let ids = body.worker_ids ?? []; if (!ids.length) { - ids = (await db.prepare( - `SELECT w.id FROM workers w - JOIN assignments a ON a.worker_id = w.id AND a.project_id = ? AND a.active = true - WHERE w.status = 'activo' AND w.needs_badge = true - AND EXISTS (SELECT 1 FROM documents d WHERE d.worker_id=w.id AND d.type_code='foto' AND d.is_current=true)`, - ).all(projectId) as { id: number }[]).map((r) => r.id); + const listEnv = await callCoreFn<{ workers?: Array> }>( + db, + "core.fn_worker_list", + { tenant_id: tid, project_id: projectId, status: "activo" }, + { route }, + ); + if (!listEnv.ok) return respondRpc(c, listEnv); + ids = (listEnv.data?.workers ?? []) + .filter((w) => w.needs_badge && ["listo_gafete", "impreso", "activo"].includes(String(w.pipeline_status))) + .map((w) => Number(w.id)); + } + if (!ids.length) { + return respondApiError(c, "VALIDATION", `No hay personal activo con foto para imprimir en ${route}`, { + route, + project_id: projectId, + }); } - if (!ids.length) return c.json({ error: "No hay personal activo con foto para imprimir" }, 400); const bytes = await generateBadgePdf(db, projectId, ids); - const user = c.get("user"); - await db.prepare( - "INSERT INTO badge_jobs (project_id, status, created_by_id, created_by_name) VALUES (?, 'done', ?, ?)", - ).run(projectId, user.id, user.display_name); - const jobId = await lastInsertId(db); - const path = await saveJobPdf(bytes, jobId); - await db.prepare("UPDATE badge_jobs SET pdf_path=? WHERE id=?").run(path, jobId); - const ins = db.prepare("INSERT INTO badge_job_people (job_id, worker_id) VALUES (?, ?)"); - const tid = tenantScope(user); + const createEnv = await callCoreFn( + db, + "core.fn_badge_job_create", + { + project_id: projectId, + worker_ids: ids, + created_by_id: user.id, + created_by_name: user.display_name, + }, + { route }, + ); + if (!createEnv.ok) return respondRpc(c, createEnv); + const jobId = Number((createEnv.data as { id?: number })?.id); + await saveJobPdf(bytes, jobId); for (const wid of ids) { - await ins.run(jobId, wid); await refreshPipeline(db, wid, tid); } - return c.json({ id: jobId, count: ids.length }); + return respondRpc(c, { + ...createEnv, + data: { ...(createEnv.data as Record), count: ids.length }, + }); }); app.get("/v1/badge-jobs", ...requireCoreAuth, async (c) => { const db = c.get("db"); - const jobs = await db.prepare( - `SELECT j.*, p.name AS project_name, - (SELECT COUNT(*) FROM badge_job_people x WHERE x.job_id=j.id) AS people, - (SELECT COUNT(*) FROM badge_job_people x WHERE x.job_id=j.id AND x.delivered=true) AS delivered - FROM badge_jobs j JOIN projects p ON p.id=j.project_id ORDER BY j.id DESC`, - ).all(); - return c.json({ jobs }); + return respondRpc(c, await callCoreFn(db, "core.fn_badge_job_list", {}, { route: routeLabel(c) })); }); app.get("/v1/badge-jobs/:id", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); const db = c.get("db"); - const job = await db.prepare("SELECT * FROM badge_jobs WHERE id=?").get(id); - if (!job) return c.json({ error: "Job no encontrado" }, 404); - const people = await db.prepare( - `SELECT p.*, w.first_name, w.last_name_p, w.position FROM badge_job_people p - JOIN workers w ON w.id = p.worker_id WHERE p.job_id=?`, - ).all(id); - return c.json({ job, people }); + return respondRpc(c, await callCoreFn(db, "core.fn_badge_job_get", { job_id: id }, { route: routeLabel(c) })); }); app.get("/v1/badge-jobs/:id/pdf", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); const db = c.get("db"); - const job = await db.prepare("SELECT pdf_path FROM badge_jobs WHERE id=?").get(id) as { pdf_path: string } | undefined; - if (!job?.pdf_path) return c.json({ error: "PDF no encontrado" }, 404); - const bytes = await getObject(job.pdf_path); + const route = routeLabel(c); + const jobEnv = await callCoreFn<{ job?: { pdf_path?: string | null } }>( + db, + "core.fn_badge_job_get", + { job_id: id }, + { route }, + ); + if (!jobEnv.ok) return respondRpc(c, jobEnv); + const pdfPath = jobEnv.data?.job?.pdf_path || badgeJobPdfKey(id); + const bytes = await getObject(pdfPath); c.header("Content-Type", "application/pdf"); c.header("Content-Disposition", `attachment; filename="gafetes-${id}.pdf"`); return c.body(bytes.buffer as ArrayBuffer); @@ -1241,11 +1329,12 @@ app.patch("/v1/badge-jobs/:id/people/:workerId", ...requireCoreAuth, async (c) = const workerId = Number(c.req.param("workerId")); const { delivered } = await c.req.json<{ delivered: boolean }>(); const db = c.get("db"); - await db.prepare( - `UPDATE badge_job_people SET delivered=?, delivered_at=CASE WHEN ? THEN now() ELSE NULL END - WHERE job_id=? AND worker_id=?`, - ).run(delivered, delivered, jobId, workerId); - return c.json({ ok: true }); + return respondRpc(c, await callCoreFn( + db, + "core.fn_badge_job_set_delivered", + { job_id: jobId, worker_id: workerId, delivered }, + { route: routeLabel(c) }, + )); }); registerPayrollRoutes(app);