import { Hono } from "hono"; import { cors } from "hono/cors"; import { checklistFor, refreshPipeline, isProjectStatus, projectById, projectMustBe, PROJECT_STATUS_CATALOG, projectChecklistFor, companyChecklistFor, imssFlagsFor, pingCoreDb, tenantTimezone, getCoreDb, type Db, } from "./db.ts"; import { callCoreFn } from "./rpc.ts"; import { normalizeDocumentGetPayload } from "./document_rpc.ts"; import { respondRpc, respondApiError, onAppError, routeLabel, type RpcEnvelope, } from "./http_errors.ts"; import { config } from "./config.ts"; import { clearSession, createSessionCookie, login, changePassword, getFreshAppUser, requireAuth, requirePlatformAdmin, tenantScope, type AuthUser, } from "./auth.ts"; import { requireCoreAuth, withCoreScope } from "./scope.ts"; import { requirePermission, requireAnyPermission } from "./permissions.ts"; import { denyUnlessProjectScope, denyUnlessProjectDocCreate, denyUnlessProjectDocView, filterProjectDocumentsForUser, filterProjectListEnvelope, } from "./scope_enforcement.ts"; import { getPlatformDb, pingPlatformDb } from "./platform_db.ts"; import { createTenant, getTenantDetail, issueTenantAdminAccess, listTenants, updateTenant } from "./saas.ts"; import { smtpConfigured, testSmtp } from "./mail.ts"; import { saveSmtpSettings, smtpPublicView } from "./smtp.ts"; import { decryptBytes } from "./docs_crypto.ts"; import { importExcel, storeDocument, storeProjectDocument, storeCompanyDocument, buildImportTemplate } from "./excel.ts"; import { normalizeWorker, validateCurp, validateNss, validateRfc, validateWorkerFields, formatNss, normUpper, titleCase, sentenceCase, type WorkerInput, } from "./mx.ts"; import { generateBadgePdf, saveJobPdf, loadCurrentPhoto, badgeQrPng } from "./pdf.ts"; import { registerPayrollRoutes } from "./payroll_http.ts"; import { registerExpenseRoutes } from "./expenses_http.ts"; import { registerExpenseCatalogRoutes } from "./expense_catalogs_http.ts"; import { registerWarehouseRoutes } from "./warehouse_http.ts"; import { registerCostControlRoutes } from "./cost_control_http.ts"; import { registerWorkProgramRoutes } from "./work_program_http.ts"; import { registerIamRoutes } from "./iam_http.ts"; import { createSubcompany, listCompanies, resolveCompany, updateCompany, companyById } from "./companies.ts"; import { buildBudgetTemplate, budgetReadErrorMessage, excelFileTypeError, exportBudgetWorkbook, previewBudgetExcel, importBudgetExcel, listBudget, } from "./budget.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; type_code?: string; type?: 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, normalizeDocumentGetPayload(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 readExcelUpload( c: { req: { formData: () => Promise } }, ): Promise<{ file: File; bytes: Uint8Array } | { error: string }> { const form = await c.req.formData(); const file = form.get("file"); if (!(file instanceof File)) return { error: "Seleccione un archivo Excel (.xlsx o .xls)." }; const typeError = excelFileTypeError(file.name); if (typeError) return { error: typeError }; return { file, bytes: new Uint8Array(await file.arrayBuffer()) }; } async function scopedCompany( db: Db, id: number, tid: number | null, ) { const company = await companyById(db, id); if (!company) return undefined; if (tid != null && company.tenant_id != null && company.tenant_id !== tid) return undefined; return company; } function corsOrigins(): string[] { const defaults = [ "http://localhost:3000", "http://127.0.0.1:3000", "http://localhost:3001", "http://127.0.0.1:3001", ]; const extra = (Deno.env.get("CORS_ORIGINS") ?? "") .split(",") .map((s) => s.trim()) .filter(Boolean); return [...new Set([...defaults, ...extra])]; } app.use( "/v1/*", cors({ origin: corsOrigins(), credentials: true, allowHeaders: ["Content-Type", "X-API-Key", "X-Tenant-Id"], }), ); // Fail-fast real (Fase 7): antes con SQLite la app "siempre arrancaba"; con // Postgres/Redis, /v1/health de verdad toca las tres conexiones Postgres y // las dos de Redis, no solo responde estático. app.get("/v1/health", async (c) => { const [core, platform, redis, storage] = await Promise.all([ pingCoreDb().then(() => true).catch(() => false), pingPlatformDb().then(() => true).catch(() => false), pingRedis(), pingStorage(), ]); const storageOk = storage.ok && (storage.backend === "local" || storage.configured); const ok = core && platform && redis.iam && redis.core && storageOk; return c.json({ ok, core, platform, redis, storage }, ok ? 200 : 503); }); app.post("/v1/auth/login", async (c) => { const body = await c.req.json<{ username?: string; password?: string }>(); try { const user = await login(body.username ?? "", body.password ?? ""); if (!user) return c.json({ error: "Usuario o contraseña incorrectos" }, 401); await createSessionCookie(c, user.id, user.realm, user.tenant_id); return c.json({ user }); } catch (e: unknown) { if (e instanceof Error && (e as { code?: string }).code === "TENANT_BLOCKED") { return c.json({ error: e.message }, 403); } throw e; } }); app.post("/v1/auth/logout", async (c) => { await clearSession(c); return c.json({ ok: true }); }); app.get("/v1/auth/me", requireAuth, (c) => c.json({ user: c.get("user") })); app.post("/v1/auth/change-password", requireAuth, async (c) => { const user = c.get("user"); if (user.realm !== "app") return c.json({ error: "No aplica" }, 400); const body = await c.req.json<{ current_password?: string; new_password?: string }>(); const result = await changePassword(user.id, user.tenant_id, body.current_password ?? "", body.new_password ?? ""); if (result.error) return c.json({ error: result.error }, 400); const fresh = await getFreshAppUser(user.id, user.tenant_id); return c.json({ ok: true, user: fresh }); }); app.get("/v1/saas/tenants", requirePlatformAdmin, async (c) => { const pdb = await getPlatformDb(); return c.json({ tenants: await listTenants(pdb), smtp_configured: await smtpConfigured(pdb) }); }); app.get("/v1/saas/tenants/:id", requirePlatformAdmin, async (c) => { const id = Number(c.req.param("id")); const pdb = await getPlatformDb(); const core = await getCoreDb(); const detail = await getTenantDetail(pdb, core, id); if (!detail) return c.json({ error: "Empresa no encontrada" }, 404); return c.json({ tenant: detail, smtp_configured: await smtpConfigured(pdb) }); }); app.get("/v1/saas/smtp", requirePlatformAdmin, async (c) => { const pdb = await getPlatformDb(); return c.json({ smtp: await smtpPublicView(pdb) }); }); app.put("/v1/saas/smtp", requirePlatformAdmin, async (c) => { const body = await c.req.json(); const pdb = await getPlatformDb(); const result = await saveSmtpSettings(pdb, { ...body, keep_password: body.password === undefined || body.password === "", }); if (result.error) return c.json({ error: result.error }, 400); return c.json({ smtp: result.settings }); }); app.post("/v1/saas/smtp/test", requirePlatformAdmin, async (c) => { const body = await c.req.json<{ to?: string }>().catch(() => ({} as { to?: string })); const pdb = await getPlatformDb(); if (!await smtpConfigured(pdb)) { return c.json({ error: "Guarde y habilite SMTP antes de probar" }, 400); } const result = await testSmtp(pdb, body.to ?? ""); if (!result.sent) return c.json({ error: result.error || "No se pudo enviar" }, 400); return c.json({ ok: true, sent: true, to: body.to, message_id: result.message_id }); }); app.post("/v1/saas/tenants", requirePlatformAdmin, async (c) => { const body = await c.req.json(); const pdb = await getPlatformDb(); const core = await getCoreDb(); const result = await createTenant(pdb, core, body); if (result.error || !result.tenant) return c.json({ error: result.error }, 400); return c.json({ tenant: result.tenant, admin: result.admin, email: result.email, }, 201); }); app.patch("/v1/saas/tenants/:id", requirePlatformAdmin, async (c) => { const id = Number(c.req.param("id")); const body = await c.req.json(); const pdb = await getPlatformDb(); const core = await getCoreDb(); const result = await updateTenant(pdb, core, id, body); if (result.error) return c.json({ error: result.error }, 400); const detail = await getTenantDetail(pdb, core, id); return c.json({ tenant: detail }); }); /** Regenera contraseña temporal del admin y opcionalmente la envía por correo. */ app.post("/v1/saas/tenants/:id/send-access", requirePlatformAdmin, async (c) => { const id = Number(c.req.param("id")); const body = await c.req.json<{ user_id?: number; send_email?: boolean }>().catch(() => ({} as { user_id?: number; send_email?: boolean }) ); const pdb = await getPlatformDb(); const core = await getCoreDb(); const result = await issueTenantAdminAccess(pdb, core, id, { userId: body.user_id, send_email: body.send_email !== false, }); if (result.error) return c.json({ error: result.error }, 400); return c.json({ admin: result.admin, email: result.email, smtp_configured: await smtpConfigured(pdb), }); }); app.get("/v1/catalogs", ...requireCoreAuth, requirePermission("reports.view"), async (c) => { const db = c.get("db"); const user = c.get("user"); const tid = tenantScope(user); 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); }); // Configuración del tenant (Fase 4d): zona horaria usada por nómina y // vigencia de documentos -- reemplaza el PAYROLL_TZ hardcodeado. app.get("/v1/configuracion", ...requireCoreAuth, requirePermission("settings.view"), async (c) => { const db = c.get("db"); const tid = tenantScope(c.get("user")); const timezone = await tenantTimezone(db, tid); return c.json({ timezone }); }); app.put("/v1/configuracion", ...requireCoreAuth, requirePermission("settings.update"), async (c) => { const user = c.get("user"); const db = c.get("db"); const tid = tenantScope(user); 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 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, requirePermission("companies.view"), async (c) => { const db = c.get("db"); const tid = tenantScope(c.get("user")); return respondRpc(c, await callCoreFn( db, "core.fn_company_list", { tenant_id: tid }, { route: routeLabel(c) }, )); }); app.post("/v1/companies", ...requireCoreAuth, requirePermission("companies.create"), async (c) => { const body = await c.req.json(); const db = c.get("db"); const tid = tenantScope(c.get("user")); const result = await createSubcompany(db, body, tid); 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, requirePermission("companies.update"), async (c) => { const id = Number(c.req.param("id")); const body = await c.req.json(); const db = c.get("db"); const tid = tenantScope(c.get("user")); const result = await updateCompany(db, id, body, tid); 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, requirePermission("companies.view"), async (c) => { const id = Number(c.req.param("id")); const db = c.get("db"); 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), }, }); }); app.post("/v1/companies/:id/documents", ...requireCoreAuth, requirePermission("documents.create"), async (c) => { const id = Number(c.req.param("id")); 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); const db = c.get("db"); if (!await scopedCompany(db, id, tenantScope(c.get("user")))) return c.json({ error: "Empresa no encontrada" }, 404); const bytes = new Uint8Array(await file.arrayBuffer()); try { await storeCompanyDocument(db, id, type, file.name, file.type || "application/octet-stream", bytes, c.get("user").id); } catch (error) { return c.json({ error: error instanceof Error ? error.message : "No se pudo guardar el documento" }, 400); } return c.json({ ok: true, checklist: await companyChecklistFor(db, id) }); }); app.get("/v1/companies/:id/documents/:docId", ...requireCoreAuth, requirePermission("documents.view"), async (c) => { const companyId = Number(c.req.param("id")); const docId = Number(c.req.param("docId")); const db = c.get("db"); 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, doc_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); c.header("Content-Type", "application/octet-stream"); c.header("X-Content-Type-Options", "nosniff"); c.header("Content-Disposition", `attachment; filename="${encodeURIComponent(doc.original_name)}"`); return c.body(plain.buffer as ArrayBuffer); }); app.get("/v1/projects", ...requireCoreAuth, requirePermission("projects.view"), async (c) => { const db = c.get("db"); const status = (c.req.query("status") ?? "").trim(); const allowed = status ? status.split(",").map((s) => s.trim()).filter(isProjectStatus) : []; if (status && !allowed.length) { return respondApiError(c, "VALIDATION", `Ningún estado de proyecto válido en ${routeLabel(c)}: ${status}`, { route: routeLabel(c), status, }); } const env = await callCoreFn( db, "core.fn_project_list", { status: allowed.length ? allowed.join(",") : undefined }, { route: routeLabel(c) }, ); return respondRpc(c, await filterProjectListEnvelope(c, env as Parameters[1])); }); type ProjectInput = { name?: string; address?: string; theme_id?: string; status?: string; company_id?: number | null; contract_amount?: number | null; start_date?: string | null; end_date?: string | null; resident_name?: string | null; siroc?: string | null; payroll_tax_pct?: number | null; }; function isoDate(value: unknown): string | null { const raw = String(value ?? "").trim(); if (!raw) return null; const match = raw.match(/^(\d{4}-\d{2}-\d{2})/); return match ? match[1] : null; } function numOrNull(value: unknown): number | null { if (value === undefined || value === null || value === "") return null; const n = Number(value); return Number.isNaN(n) ? null : n; } function projectFields(body: ProjectInput, cur?: Record) { const start = isoDate(body.start_date !== undefined ? body.start_date : cur?.start_date); const end = isoDate(body.end_date !== undefined ? body.end_date : cur?.end_date); const payroll = numOrNull(body.payroll_tax_pct !== undefined ? body.payroll_tax_pct : cur?.payroll_tax_pct); const amount = numOrNull(body.contract_amount !== undefined ? body.contract_amount : cur?.contract_amount); const companyRaw = body.company_id !== undefined ? body.company_id : cur?.company_id; return { name: titleCase(String(body.name ?? cur?.name ?? "")), address: sentenceCase(String(body.address ?? cur?.address ?? "")), theme_id: String(body.theme_id ?? cur?.theme_id ?? "arctec-dos-logos-fold"), company_id: companyRaw ? Number(companyRaw) : null, contract_amount: amount, start_date: start, end_date: end, resident_name: titleCase(String(body.resident_name ?? cur?.resident_name ?? "")), siroc: normUpper(String(body.siroc ?? cur?.siroc ?? "")), payroll_tax_pct: payroll ?? 4, }; } app.post("/v1/projects", ...requireCoreAuth, requirePermission("projects.create"), async (c) => { const body = await c.req.json(); 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 respondApiError(c, "VALIDATION", `Seleccione la empresa del proyecto en ${routeLabel(c)}`, { route: routeLabel(c), }); } if (!await resolveCompany(db, { company_id: fields.company_id })) { 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 respondApiError(c, "VALIDATION", `La fecha de término no puede ser anterior al inicio en ${routeLabel(c)}`, { route: routeLabel(c), }); } const tid = tenantScope(c.get("user")); 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, requirePermission("projects.update"), async (c) => { const id = Number(c.req.param("id")); if (!await denyUnlessProjectScope(c, id)) return; const body = await c.req.json(); const db = c.get("db"); 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 respondApiError(c, "VALIDATION", `Seleccione la empresa del proyecto en ${route}`, { route, project_id: id }); } if (!await resolveCompany(db, { company_id: fields.company_id })) { 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 respondApiError(c, "VALIDATION", `La fecha de término no puede ser anterior al inicio en ${route}`, { route, project_id: id, }); } 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, requirePermission("projects.view"), async (c) => { const id = Number(c.req.param("id")); if (!await denyUnlessProjectScope(c, id)) return; const db = c.get("db"); 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); const user = c.get("user") as AuthUser; const documents = await filterProjectDocumentsForUser( user, db, (docsEnv.data?.documents ?? []) as Array<{ type_code?: string; type?: string }>, ); return respondRpc(c, { ok: true, code: "OK", layer: "db", message: `Proyecto ${project.code} cargado`, data: { project, documents, checklist: await projectChecklistFor(db, id), }, }); }); app.post("/v1/projects/:id/documents", ...requireCoreAuth, requireAnyPermission("project_docs.tecnico.create", "project_docs.contrato.create", "project_docs.permisos.create", "project_docs.ambiental.create", "project_docs.imss.create", "project_docs.sst.create", "project_docs.otro.create"), async (c) => { const id = Number(c.req.param("id")); if (!await denyUnlessProjectScope(c, id)) return; 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); const db = c.get("db"); if (!await projectById(db, id)) return c.json({ error: "Proyecto no encontrado" }, 404); if (!await denyUnlessProjectDocCreate(c, db, type)) return; const bytes = new Uint8Array(await file.arrayBuffer()); try { await storeProjectDocument(db, id, type, file.name, file.type || "application/octet-stream", bytes, c.get("user").id); } catch (error) { return c.json({ error: error instanceof Error ? error.message : "No se pudo guardar el documento" }, 400); } return c.json({ ok: true, checklist: await projectChecklistFor(db, id) }); }); app.get("/v1/projects/:id/documents/:docId", ...requireCoreAuth, requirePermission("projects.view"), async (c) => { const projectId = Number(c.req.param("id")); if (!await denyUnlessProjectScope(c, projectId)) return; const docId = Number(c.req.param("docId")); const db = c.get("db"); const route = routeLabel(c); const { doc, envelope } = await rpcDocumentForDownload( db, "core.fn_project_document_get", { project_id: projectId, doc_id: docId }, route, ); if (!doc) return respondRpc(c, envelope); const typeCode = String(doc.type_code ?? doc.type ?? ""); if (!await denyUnlessProjectDocView(c, db, typeCode)) return; const enc = await getObject(projectDocKey(projectId, doc.storage_name)); const plain = await decryptBytes(doc.iv, enc); c.header("Content-Type", "application/octet-stream"); c.header("X-Content-Type-Options", "nosniff"); c.header("Content-Disposition", `attachment; filename="${encodeURIComponent(doc.original_name)}"`); return c.body(plain.buffer as ArrayBuffer); }); app.get("/v1/projects/:id/budget", ...requireCoreAuth, requirePermission("budget.view"), async (c) => { const id = Number(c.req.param("id")); if (!await denyUnlessProjectScope(c, id)) return; const db = c.get("db"); if (!await projectById(db, id)) return c.json({ error: "Proyecto no encontrado" }, 404); return c.json(await listBudget(db, id)); }); app.get("/v1/projects/:id/budget/template", ...requireCoreAuth, requirePermission("budget.view"), (c) => { const bytes = buildBudgetTemplate(); c.header("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); c.header("Content-Disposition", 'attachment; filename="plantilla-presupuesto.xlsx"'); return c.body(bytes.slice()); }); app.get("/v1/projects/:id/budget/export", ...requireCoreAuth, requirePermission("budget.view"), async (c) => { const id = Number(c.req.param("id")); if (!await denyUnlessProjectScope(c, id)) return; const db = c.get("db"); const project = await projectById(db, id); if (!project) return c.json({ error: "Proyecto no encontrado" }, 404); const budget = await listBudget(db, id); const bytes = exportBudgetWorkbook(project.name, budget); c.header("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); c.header("Content-Disposition", `attachment; filename="presupuesto-${project.code}.xlsx"`); return c.body(bytes.slice()); }); app.post("/v1/budget/preview", ...requireCoreAuth, requirePermission("budget.create"), async (c) => { const upload = await readExcelUpload(c); if ("error" in upload) { return respondApiError(c, "VALIDATION", upload.error, { route: routeLabel(c) }); } const preview = previewBudgetExcel(upload.bytes); if (preview.errors.length && !preview.items.length) { return respondApiError(c, "VALIDATION", budgetReadErrorMessage(preview), { route: routeLabel(c), filename: upload.file.name, format: preview.format, }, preview.errors.flatMap((row) => row.messages)); } return c.json(preview); }); app.post("/v1/projects/:id/budget/preview", ...requireCoreAuth, requirePermission("budget.create"), async (c) => { const id = Number(c.req.param("id")); if (!await denyUnlessProjectScope(c, id)) return; const upload = await readExcelUpload(c); if ("error" in upload) { return respondApiError(c, "VALIDATION", upload.error, { 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 preview = previewBudgetExcel(upload.bytes); if (preview.errors.length && !preview.items.length) { return respondApiError(c, "VALIDATION", budgetReadErrorMessage(preview), { route: routeLabel(c), project_id: id, filename: upload.file.name, format: preview.format, }, preview.errors.flatMap((row) => row.messages)); } return c.json(preview); }); app.post("/v1/projects/:id/budget/import", ...requireCoreAuth, requirePermission("budget.update"), async (c) => { const id = Number(c.req.param("id")); if (!await denyUnlessProjectScope(c, id)) return; const upload = await readExcelUpload(c); if ("error" in upload) { return respondApiError(c, "VALIDATION", upload.error, { 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 report = await importBudgetExcel(db, id, upload.bytes); if (report.errors.length && !report.inserted) { return respondApiError(c, "VALIDATION", budgetReadErrorMessage(report), { route: routeLabel(c), project_id: id, filename: upload.file.name, }, report.errors.flatMap((row) => row.messages)); } return c.json(report); }); app.post("/v1/projects/:id/budget/chapters", ...requireCoreAuth, requirePermission("budget.create"), async (c) => { const id = Number(c.req.param("id")); if (!await denyUnlessProjectScope(c, id)) return; const body = await c.req.json<{ name?: string; code?: string; parent_id?: number | null }>(); 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"); 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, requirePermission("budget.update"), async (c) => { const id = Number(c.req.param("id")); if (!await denyUnlessProjectScope(c, id)) return; const cid = Number(c.req.param("cid")); const body = await c.req.json<{ name?: string; code?: string }>(); const db = c.get("db"); 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, requirePermission("budget.delete"), async (c) => { const id = Number(c.req.param("id")); if (!await denyUnlessProjectScope(c, id)) return; const cid = Number(c.req.param("cid")); const db = c.get("db"); return respondRpc(c, await callCoreFn( db, "core.fn_budget_chapter_delete", { project_id: id, id: cid }, { route: routeLabel(c) }, )); }); type BudgetItemInput = { chapter_id?: number | null; code?: string; description?: string; unit?: string; quantity?: number; unit_price?: number; }; app.post("/v1/projects/:id/budget/items", ...requireCoreAuth, requirePermission("budget.create"), async (c) => { const id = Number(c.req.param("id")); if (!await denyUnlessProjectScope(c, id)) return; const body = await c.req.json(); 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"); 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, requirePermission("budget.update"), async (c) => { const id = Number(c.req.param("id")); if (!await denyUnlessProjectScope(c, id)) return; const iid = Number(c.req.param("iid")); const body = await c.req.json(); const db = c.get("db"); 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, requirePermission("budget.delete"), async (c) => { const id = Number(c.req.param("id")); if (!await denyUnlessProjectScope(c, id)) return; const iid = Number(c.req.param("iid")); const db = c.get("db"); 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, requirePermission("workers.create"), 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 ?? "") }, { field: "rfc", value: normUpper(body.rfc), err: validateRfc(body.rfc ?? "") }, { field: "nss", value: formatNss(body.nss ?? ""), err: validateNss(body.nss ?? "") }, ]; for (const ch of checks) { if (ch.err) continue; 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}`; } } return c.json({ ok: Object.keys(errors).length === 0, errors }); }); app.get("/v1/workers", ...requireCoreAuth, requirePermission("workers.view"), async (c) => { const db = c.get("db"); const tid = tenantScope(c.get("user")); const q = (c.req.query("q") ?? "").trim(); const status = c.req.query("status"); const projectId = c.req.query("project_id"); if (projectId && !await denyUnlessProjectScope(c, Number(projectId))) return; 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, requirePermission("workers.view"), async (c) => { const id = Number(c.req.param("id")); const db = c.get("db"); const tid = tenantScope(c.get("user")); 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, ) { if (row && row.id !== excludeId) { const field = row.curp === n.curp ? "CURP" : row.rfc === n.rfc ? "RFC" : "NSS"; return { status: 409 as const, body: { error: `Este ${field} ya pertenece a ${row.first_name} ${row.last_name_p}`, worker_id: row.id, }, }; } return null; } async function findExistingWorker( db: Db, curp: string, rfc: string, nss: string, excludeId = 0, route = "worker-duplicate-check", ): Promise< { id: number; first_name: string; last_name_p: string; curp: string; rfc: string; nss: string } | undefined > { 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 ?? undefined; } app.post("/v1/workers", ...requireCoreAuth, requirePermission("workers.create"), async (c) => { const body = await c.req.json(); const errors = validateWorkerFields(body); const db = c.get("db"); 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 findExistingWorker(db, n.curp, n.rfc, n.nss, 0, route); const cf = conflict(existing, n); 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) { const code = blocked.status === 404 ? "NOT_FOUND" : "VALIDATION"; return respondApiError(c, code, blocked.error, { route, project_id: body.project_id }); } if (!await denyUnlessProjectScope(c, body.project_id)) return; } const tid = tenantScope(c.get("user")); 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, requirePermission("workers.update"), async (c) => { const id = Number(c.req.param("id")); const db = c.get("db"); const tid = tenantScope(c.get("user")); 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 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 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, requirePermission("workers.update"), 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 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 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, requirePermission("workers.update"), async (c) => { const id = Number(c.req.param("id")); 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) { return respondRpc(c, await callCoreFn( db, "core.fn_worker_unassign", { worker_id: id, project_id, tenant_id: tid }, { route }, )); } 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, requirePermission("workers.create"), async (c) => { const db = c.get("db"); const bytes = buildImportTemplate(await listCompanies(db)); c.header("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); c.header("Content-Disposition", 'attachment; filename="plantilla-padron-arctec.xlsx"'); return c.body(bytes.buffer as ArrayBuffer); }); app.post("/v1/workers/import", ...requireCoreAuth, requirePermission("workers.create"), async (c) => { const form = await c.req.formData(); const file = form.get("file"); const projectId = Number(form.get("project_id") || 0) || null; if (!(file instanceof File)) return c.json({ error: "Archivo Excel requerido" }, 400); const bytes = new Uint8Array(await file.arrayBuffer()); const db = c.get("db"); if (projectId) { const blocked = projectMustBe( await projectById(db, projectId), ["activo"], "Solo se importan altas a proyectos activos", ); if (blocked) return c.json({ error: blocked.error }, blocked.status); } const report = await importExcel(db, bytes, projectId, c.get("user").id || null); return c.json(report); }); app.post("/v1/workers/:id/documents", ...requireCoreAuth, requirePermission("documents.create"), async (c) => { const id = Number(c.req.param("id")); const form = await c.req.formData(); const type = String(form.get("type") || ""); const file = form.get("file"); 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 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 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 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; const imssCompanyId = Number(form.get("imss_company_id") || 0) || null; const imssAltaAt = String(form.get("imss_alta_at") || "").trim() || null; const imssBajaAt = String(form.get("imss_baja_at") || "").trim() || null; if (policy.requires_issued_at && !issuedAt) { 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 respondApiError(c, "VALIDATION", `Indique la fecha de vigencia / vencimiento en ${route}`, { route, worker_id: id }); } if (type === "alta_imss" && !imssCompanyId) { return respondApiError(c, "VALIDATION", `Seleccione la empresa patrón del alta IMSS en ${route}`, { route, worker_id: id }); } if (type === "alta_imss" && !imssAltaAt) { return respondApiError(c, "VALIDATION", `Indique la fecha de alta IMSS en ${route}`, { route, worker_id: id }); } if (type === "baja_imss" && !imssBajaAt) { return respondApiError(c, "VALIDATION", `Indique la fecha de baja IMSS en ${route}`, { route, worker_id: id }); } const bytes = new Uint8Array(await file.arrayBuffer()); try { await storeDocument(db, id, type, file.name, file.type || "application/octet-stream", bytes, c.get("user").id, { issued_at: issuedAt, expires_at: expiresAt, imss_company_id: imssCompanyId, imss_alta_at: imssAltaAt, imss_baja_at: imssBajaAt, }); } catch (error) { const message = error instanceof Error ? error.message : "No se pudo guardar el documento"; return respondApiError(c, "VALIDATION", message, { route, worker_id: id }); } return c.json({ ok: true, checklist: await checklistFor(db, id, tid), ...await imssFlagsFor(db, id, tid), }); }); app.get("/v1/workers/:id/documents/:docId", ...requireCoreAuth, requirePermission("documents.view"), async (c) => { const workerId = Number(c.req.param("id")); const docId = Number(c.req.param("docId")); const db = c.get("db"); const route = routeLabel(c); const { doc, envelope } = await rpcDocumentForDownload( db, "core.fn_worker_document_get", { worker_id: workerId, doc_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"); c.header("X-Content-Type-Options", "nosniff"); c.header("Content-Disposition", `attachment; filename="${encodeURIComponent(doc.original_name)}"`); return c.body(plain.buffer as ArrayBuffer); }); app.get("/v1/workers/:id/photo", ...requireCoreAuth, requirePermission("workers.view"), async (c) => { const id = Number(c.req.param("id")); const db = c.get("db"); const bytes = await loadCurrentPhoto(db, id); if (!bytes) return c.json({ error: "Sin foto" }, 404); const jpeg = bytes[0] === 0xff && bytes[1] === 0xd8; // La foto sí se sirve inline (es una imagen legítima usada en la UI para // mostrar el rostro de la persona), pero se fija el Content-Type real // detectado por firma de bytes, no el que declaró quien la subió, y se // agrega nosniff -- así el navegador no puede reinterpretarla como HTML. c.header("Content-Type", jpeg ? "image/jpeg" : "image/png"); c.header("X-Content-Type-Options", "nosniff"); c.header("Cache-Control", "private, max-age=30"); return c.body(bytes.buffer as ArrayBuffer); }); app.get("/v1/badge-qr", ...requireCoreAuth, requirePermission("documents.view"), async (c) => { const curp = (c.req.query("curp") ?? "").trim().toUpperCase(); if (curp.length < 10) return c.json({ error: "CURP requerida" }, 400); const png = await badgeQrPng(curp); c.header("Content-Type", "image/png"); c.header("Cache-Control", "private, max-age=60"); return c.body(png.buffer as ArrayBuffer); }); app.post("/v1/projects/:id/badge-jobs", ...requireCoreAuth, requirePermission("documents.create"), async (c) => { const projectId = Number(c.req.param("id")); if (!await denyUnlessProjectScope(c, projectId)) return; 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) { 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) { 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, }); } const bytes = await generateBadgePdf(db, projectId, ids); 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 refreshPipeline(db, wid, tid); } return respondRpc(c, { ...createEnv, data: { ...(createEnv.data as Record), count: ids.length }, }); }); app.get("/v1/badge-jobs", ...requireCoreAuth, requirePermission("documents.view"), async (c) => { const db = c.get("db"); return respondRpc(c, await callCoreFn(db, "core.fn_badge_job_list", {}, { route: routeLabel(c) })); }); app.get("/v1/badge-jobs/:id", ...requireCoreAuth, requirePermission("documents.view"), async (c) => { const id = Number(c.req.param("id")); const db = c.get("db"); 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, requirePermission("documents.view"), async (c) => { const id = Number(c.req.param("id")); const db = c.get("db"); 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); }); app.patch("/v1/badge-jobs/:id/people/:workerId", ...requireCoreAuth, requirePermission("documents.create"), async (c) => { const jobId = Number(c.req.param("id")); const workerId = Number(c.req.param("workerId")); const { delivered } = await c.req.json<{ delivered: boolean }>(); const db = c.get("db"); return respondRpc(c, await callCoreFn( db, "core.fn_badge_job_set_delivered", { job_id: jobId, worker_id: workerId, delivered }, { route: routeLabel(c) }, )); }); registerPayrollRoutes(app); registerExpenseRoutes(app); registerExpenseCatalogRoutes(app); registerWarehouseRoutes(app); registerCostControlRoutes(app); registerWorkProgramRoutes(app); registerIamRoutes(app); const port = config.port; function startup(name: string, p: Promise): Promise { return p.then(() => { console.log(`[startup] ok ${name}`); }).catch((e: unknown) => { const msg = e instanceof Error ? e.message : String(e); console.error(`[startup] FAIL ${name}: ${msg}`); throw e; }); } // Fail-fast (Fase 7): antes con SQLite la app "siempre arrancaba" (creaba // el archivo si no existía). Con Postgres/Redis, si alguna de las 5 // conexiones no responde al arrancar, es mejor fallar ruidosamente que // dejar que el primer request autenticado descubra el problema. await Promise.all([ startup("postgres core (DATABASE_URL_CORE)", pingCoreDb()), startup("postgres platform (DATABASE_URL_PLATFORM)", pingPlatformDb()), startup("redis iam+core", pingRedis().then((r) => { if (!r.iam || !r.core) { throw new Error( `Redis no responde (iam=${r.iam} core=${r.core}). REDIS_URL_* debe usar el hostname de Redis URL (internal), no el UUID de Postgres.`, ); } })), startup("storage S3/R2", pingStorage().then((s) => { if (!s.ok) { throw new Error(`Storage no responde: ${s.error ?? "sin detalle"}`); } if (!config.isDev && !s.configured) { throw new Error("S3_ENDPOINT/S3_BUCKET/S3_* son obligatorios fuera de desarrollo"); } })), ]); Deno.serve({ port, hostname: "0.0.0.0" }, app.fetch); console.log(`API panel-obra en http://127.0.0.1:${port}`);