import { Hono } from "hono"; import { cors } from "hono/cors"; import { getDb, checklistFor, refreshPipeline, workerDir, lastInsertId, nextProjectCode, isProjectStatus, projectById, projectMustBe, PROJECT_STATUS_CATALOG, projectDir, projectChecklistFor, companyDir, companyChecklistFor, imssFlagsFor, checklistItemsFor } from "./db.ts"; import { config } from "./config.ts"; import { clearSession, createSessionCookie, login, changePassword, requireAuth, requirePlatformAdmin, tenantScope, type AuthUser } from "./auth.ts"; import { getPlatformDb } 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, findExisting, assign, buildImportTemplate } from "./excel.ts"; import { normalizeWorker, validateCurp, validateNss, validateRfc, validateWorkerFields, fullName, formatNss, normUpper, titleCase, sentenceCase, type WorkerInput, } from "./mx.ts"; import { generateBadgePdf, saveJobPdf, loadCurrentPhoto, badgeQrPng } from "./pdf.ts"; import { registerPayrollRoutes } from "./payroll_http.ts"; import { createSubcompany, listCompanies, resolveCompany, updateCompany, companyById } from "./companies.ts"; import { buildBudgetTemplate, exportBudgetWorkbook, previewBudgetExcel, importBudgetExcel, lineAmount, listBudget, } from "./budget.ts"; import { join } from "node:path"; const app = new Hono<{ Variables: { user: AuthUser } }>(); function scopedCompany( db: Awaited>, id: number, tid: number | null, ) { const company = 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"], }), ); app.get("/v1/health", (c) => c.json({ ok: true })); 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); 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", (c) => { 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, body.current_password ?? "", body.new_password ?? ""); if (result.error) return c.json({ error: result.error }, 400); const db = await getDb(); const row = db.prepare( `SELECT u.id, u.username, u.display_name, u.company_id, u.tenant_id, u.role, COALESCE(u.must_change_password, 0) AS must_change_password, c.code AS company_code, c.name AS company_name, c.kind AS company_kind FROM users u LEFT JOIN companies c ON c.id = u.company_id WHERE u.id = ?`, ).get(user.id) as Record; return c.json({ ok: true, user: { ...row, must_change_password: false, role: row.role === "tenant_admin" ? "tenant_admin" : "user", realm: "app", }, }); }); app.get("/v1/saas/tenants", requirePlatformAdmin, async (c) => { const pdb = await getPlatformDb(); return c.json({ tenants: listTenants(pdb), smtp_configured: smtpConfigured(pdb) }); }); app.get("/v1/saas/tenants/:id", requirePlatformAdmin, async (c) => { const id = Number(c.req.param("id")); const pdb = await getPlatformDb(); const db = await getDb(); const detail = getTenantDetail(pdb, db, id); if (!detail) return c.json({ error: "Empresa no encontrada" }, 404); return c.json({ tenant: detail, smtp_configured: smtpConfigured(pdb) }); }); app.get("/v1/saas/smtp", requirePlatformAdmin, async (c) => { const pdb = await getPlatformDb(); return c.json({ smtp: smtpPublicView(pdb) }); }); app.put("/v1/saas/smtp", requirePlatformAdmin, async (c) => { const body = await c.req.json(); const pdb = await getPlatformDb(); const result = 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(() => ({})); const pdb = await getPlatformDb(); if (!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 db = await getDb(); const result = await createTenant(pdb, db, 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 db = await getDb(); const result = updateTenant(pdb, db, id, body); if (result.error) return c.json({ error: result.error }, 400); const detail = getTenantDetail(pdb, db, 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(() => ({})); const pdb = await getPlatformDb(); const db = await getDb(); const result = await issueTenantAdminAccess(pdb, db, 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: smtpConfigured(pdb), }); }); app.get("/v1/catalogs", requireAuth, async (c) => { const db = await getDb(); const user = c.get("user"); const tid = tenantScope(user); return c.json({ risks: db.prepare("SELECT * FROM risk_levels").all(), themes: db.prepare("SELECT * FROM badge_themes").all(), document_types: db.prepare("SELECT * FROM document_types").all(), project_document_types: db.prepare("SELECT * FROM project_document_types").all(), company_document_types: db.prepare("SELECT * FROM company_document_types").all(), project_statuses: PROJECT_STATUS_CATALOG, companies: listCompanies(db, tid), }); }); app.get("/v1/companies", requireAuth, async (c) => { const db = await getDb(); const tid = tenantScope(c.get("user")); return c.json({ companies: listCompanies(db, tid) }); }); app.post("/v1/companies", requireAuth, async (c) => { const body = await c.req.json(); const db = await getDb(); const tid = tenantScope(c.get("user")); const result = createSubcompany(db, body, tid); if (result.error || !result.company) return c.json({ error: result.error }, 400); return c.json({ company: result.company }, 201); }); app.patch("/v1/companies/:id", requireAuth, async (c) => { const id = Number(c.req.param("id")); const body = await c.req.json(); const db = await getDb(); const result = updateCompany(db, id, body); if (result.error) return c.json({ error: result.error }, (result.status ?? 400) as 400 | 404); return c.json({ company: result.company }); }); app.get("/v1/companies/:id", requireAuth, async (c) => { const id = Number(c.req.param("id")); const db = await getDb(); const company = scopedCompany(db, id, tenantScope(c.get("user"))); if (!company) return c.json({ error: "Empresa no encontrada" }, 404); const documents = 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: companyChecklistFor(db, id), }); }); app.post("/v1/companies/:id/documents", requireAuth, 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 = await getDb(); if (!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: companyChecklistFor(db, id) }); }); app.get("/v1/companies/:id/documents/:docId", requireAuth, async (c) => { const companyId = Number(c.req.param("id")); const docId = Number(c.req.param("docId")); const db = await getDb(); if (!scopedCompany(db, companyId, tenantScope(c.get("user")))) return c.json({ error: "Empresa no encontrada" }, 404); const doc = 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 enc = await Deno.readFile(join(companyDir(companyId), doc.storage_name)); const plain = await decryptBytes(doc.iv, enc); c.header("Content-Type", doc.mime); c.header("Content-Disposition", `inline; filename="${doc.original_name}"`); return c.body(plain.buffer as ArrayBuffer); }); app.get("/v1/projects", requireAuth, async (c) => { const db = await getDb(); 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 (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: 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 = 1) AS active_count, (SELECT COUNT(*) FROM project_document_types t WHERE t.required = 1 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 = 1 )) 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), }); }); 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", requireAuth, async (c) => { const body = await c.req.json(); if (!body.name?.trim()) return c.json({ error: "Nombre de proyecto obligatorio" }, 400); const status = body.status && isProjectStatus(body.status) ? body.status : "activo"; const db = await getDb(); const fields = projectFields(body); if (!fields.company_id) return c.json({ error: "Seleccione la empresa del proyecto" }, 400); if (!resolveCompany(db, { company_id: fields.company_id })) { return c.json({ error: "Empresa no encontrada" }, 400); } 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); } const code = nextProjectCode(db); const tid = tenantScope(c.get("user")); 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 = lastInsertId(db); return c.json({ id, code, status }, 201); }); app.patch("/v1/projects/:id", requireAuth, async (c) => { const id = Number(c.req.param("id")); const body = await c.req.json(); const db = await getDb(); const cur = db.prepare("SELECT * FROM projects WHERE id = ?").get(id) as Record | undefined; if (!cur) return c.json({ error: "Proyecto no encontrado" }, 404); 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 (!resolveCompany(db, { company_id: fields.company_id })) { return c.json({ error: "Empresa no encontrada" }, 400); } 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); } 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 }); }); app.get("/v1/projects/:id", requireAuth, async (c) => { const id = Number(c.req.param("id")); const db = await getDb(); const project = 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 = 1) 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 = 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: projectChecklistFor(db, id), }); }); app.post("/v1/projects/:id/documents", requireAuth, 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 = await getDb(); if (!projectById(db, id)) return c.json({ error: "Proyecto no encontrado" }, 404); 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: projectChecklistFor(db, id) }); }); app.get("/v1/projects/:id/documents/:docId", requireAuth, async (c) => { const projectId = Number(c.req.param("id")); const docId = Number(c.req.param("docId")); const db = await getDb(); const doc = 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 enc = await Deno.readFile(join(projectDir(projectId), doc.storage_name)); const plain = await decryptBytes(doc.iv, enc); c.header("Content-Type", doc.mime); c.header("Content-Disposition", `inline; filename="${doc.original_name}"`); return c.body(plain.buffer as ArrayBuffer); }); app.get("/v1/projects/:id/budget", requireAuth, async (c) => { const id = Number(c.req.param("id")); const db = await getDb(); if (!projectById(db, id)) return c.json({ error: "Proyecto no encontrado" }, 404); return c.json(listBudget(db, id)); }); app.get("/v1/projects/:id/budget/template", requireAuth, (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", requireAuth, async (c) => { const id = Number(c.req.param("id")); const db = await getDb(); const project = projectById(db, id); if (!project) return c.json({ error: "Proyecto no encontrado" }, 404); const budget = 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", requireAuth, async (c) => { const form = await c.req.formData(); const file = form.get("file"); if (!(file instanceof File)) return c.json({ error: "Archivo Excel requerido" }, 400); const bytes = new Uint8Array(await file.arrayBuffer()); const preview = previewBudgetExcel(bytes); if (preview.errors.length && !preview.items.length) return c.json(preview, 400); return c.json(preview); }); app.post("/v1/projects/:id/budget/preview", requireAuth, async (c) => { const id = Number(c.req.param("id")); const form = await c.req.formData(); const file = form.get("file"); if (!(file instanceof File)) return c.json({ error: "Archivo Excel requerido" }, 400); const db = await getDb(); if (!projectById(db, id)) return c.json({ error: "Proyecto no encontrado" }, 404); const bytes = new Uint8Array(await file.arrayBuffer()); const preview = previewBudgetExcel(bytes); if (preview.errors.length && !preview.items.length) return c.json(preview, 400); return c.json(preview); }); app.post("/v1/projects/:id/budget/import", requireAuth, async (c) => { const id = Number(c.req.param("id")); const form = await c.req.formData(); const file = form.get("file"); if (!(file instanceof File)) return c.json({ error: "Archivo Excel requerido" }, 400); const db = await getDb(); if (!projectById(db, id)) return c.json({ error: "Proyecto no encontrado" }, 404); const bytes = new Uint8Array(await file.arrayBuffer()); const report = importBudgetExcel(db, id, bytes); if (report.errors.length && !report.inserted) return c.json(report, 400); return c.json(report); }); app.post("/v1/projects/:id/budget/chapters", requireAuth, 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); const db = await getDb(); if (!projectById(db, id)) return c.json({ error: "Proyecto no encontrado" }, 404); const sort = (db.prepare("SELECT COALESCE(MAX(sort_order), 0) + 1 AS n FROM budget_chapters WHERE project_id = ?").get(id) as { n: number }).n; 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: lastInsertId(db) }, 201); }); app.patch("/v1/projects/:id/budget/chapters/:cid", requireAuth, async (c) => { const id = Number(c.req.param("id")); const cid = Number(c.req.param("cid")); const body = await c.req.json<{ name?: string; code?: string }>(); const db = await getDb(); const cur = 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); 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 }); }); app.delete("/v1/projects/:id/budget/chapters/:cid", requireAuth, async (c) => { const id = Number(c.req.param("id")); const cid = Number(c.req.param("cid")); const db = await getDb(); const found = 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); db.prepare("UPDATE budget_items SET chapter_id = NULL WHERE project_id = ? AND chapter_id = ?").run(id, cid); db.prepare("DELETE FROM budget_chapters WHERE id = ? AND project_id = ?").run(cid, id); return c.json({ ok: true }); }); type BudgetItemInput = { chapter_id?: number | null; code?: string; description?: string; unit?: string; quantity?: number; unit_price?: number; }; app.post("/v1/projects/:id/budget/items", requireAuth, 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); const db = await getDb(); if (!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 = (db.prepare("SELECT COALESCE(MAX(sort_order), 0) + 1 AS n FROM budget_items WHERE project_id = ?").get(id) as { n: number }).n; 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: lastInsertId(db) }, 201); }); app.patch("/v1/projects/:id/budget/items/:iid", requireAuth, async (c) => { const id = Number(c.req.param("id")); const iid = Number(c.req.param("iid")); const body = await c.req.json(); const db = await getDb(); const cur = 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); 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 }); }); app.delete("/v1/projects/:id/budget/items/:iid", requireAuth, async (c) => { const id = Number(c.req.param("id")); const iid = Number(c.req.param("iid")); const db = await getDb(); const found = 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); db.prepare("DELETE FROM budget_items WHERE id = ? AND project_id = ?").run(iid, id); return c.json({ ok: true }); }); app.post("/v1/workers/validate", requireAuth, async (c) => { const body = await c.req.json(); const errors = validateWorkerFields(body); const db = await getDb(); 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 row = 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; 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", requireAuth, async (c) => { const db = await getDb(); 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"); 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 GROUP_CONCAT(p.name, ', ') FROM assignments a JOIN projects p ON p.id = a.project_id WHERE a.worker_id = w.id AND a.active = 1) AS proyectos, (SELECT GROUP_CONCAT(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 = 1) AS proyecto_pairs, (SELECT GROUP_CONCAT(p.id, ',') FROM assignments a JOIN projects p ON p.id = a.project_id WHERE a.worker_id = w.id AND a.active = 1) AS project_ids, (SELECT COUNT(*) FROM document_types t WHERE t.required = 1 AND NOT EXISTS ( SELECT 1 FROM documents d WHERE d.worker_id = w.id AND d.type_code = t.code AND d.is_current = 1 )) 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 = 1) THEN 1 ELSE 0 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 = 1)"; params.push(Number(projectId)); } if (q) { sql += " AND (w.first_name LIKE ? OR w.last_name_p LIKE ? OR w.curp LIKE ? OR w.rfc LIKE ? OR w.nss LIKE ?)"; const like = `%${q}%`; params.push(like, like, like, like, like); } sql += " ORDER BY w.last_name_p, w.first_name"; const rows = db.prepare(sql).all(...params) as Array & { id: number; status: string; pipeline_status: string; imss_status?: string; in_project?: number; }>; const workers = rows.map((row) => { const flags = imssFlagsFor(db, row.id); return { ...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, }, }); }); app.get("/v1/workers/:id", requireAuth, async (c) => { const id = Number(c.req.param("id")); const db = await getDb(); const worker = 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 = 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 = 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 = db.prepare("SELECT * FROM loans WHERE worker_id = ? ORDER BY id DESC").all(id); const { items, freshness_required } = checklistItemsFor(db, id); const flags = imssFlagsFor(db, id); const docTypes = 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, }); }); function conflict(db: Awaited>, n: ReturnType, excludeId = 0) { const row = findExisting(db, n.curp, n.rfc, n.nss); 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; } app.post("/v1/workers", requireAuth, async (c) => { const body = await c.req.json(); const errors = validateWorkerFields(body); const db = await getDb(); if (Object.keys(errors).length) return c.json({ errors }, 400); const n = normalizeWorker({ ...body, hire_type: "" }); const cf = conflict(db, n); if (cf) return c.json(cf.body, cf.status); if (body.project_id) { const blocked = projectMustBe( projectById(db, body.project_id), ["activo"], "Solo se asigna personal a proyectos activos", ); if (blocked) return c.json({ error: blocked.error }, blocked.status); } 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, tenantScope(c.get("user")), ); const id = lastInsertId(db); if (body.project_id) { assign(db, id, body.project_id); } refreshPipeline(db, id); return c.json({ id }, 201); }); app.patch("/v1/workers/:id", requireAuth, async (c) => { const id = Number(c.req.param("id")); const db = await getDb(); const cur = db.prepare("SELECT * FROM workers WHERE id = ?").get(id) as Record | undefined; if (!cur) return c.json({ error: "No encontrado" }, 404); 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 cf = conflict(db, n, id); if (cf) return c.json(cf.body, cf.status); 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=datetime('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, ); refreshPipeline(db, id); return c.json({ ok: true }); }); app.patch("/v1/workers/:id/pipeline", requireAuth, 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); const db = await getDb(); const prev = db.prepare("SELECT status FROM workers WHERE id=?").get(id) as { status: string } | undefined; if (pipeline_status === "baja") { 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") { db.prepare( "UPDATE workers SET status='activo', last_rehire_at=date('now') WHERE id=?", ).run(id); } else { db.prepare("UPDATE workers SET status='activo' WHERE id=?").run(id); } refreshPipeline(db, id); } const worker = db.prepare("SELECT status, pipeline_status, last_rehire_at, imss_status FROM workers WHERE id=?").get(id); return c.json({ ok: true, worker }); }); app.post("/v1/workers/:id/assign", requireAuth, async (c) => { const id = Number(c.req.param("id")); const { project_id, active } = await c.req.json<{ project_id: number; active?: boolean }>(); const db = await getDb(); if (active === false) { db.prepare( "UPDATE assignments SET active=0, end_date=date('now') WHERE worker_id=? AND project_id=?", ).run(id, project_id); } else { const project = 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); } assign(db, id, project_id); } refreshPipeline(db, id); return c.json({ ok: true }); }); app.get("/v1/workers/import/template", requireAuth, async (c) => { const db = await getDb(); const bytes = buildImportTemplate(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", requireAuth, 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 = await getDb(); if (projectId) { const blocked = projectMustBe( 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", requireAuth, 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 = await getDb(); const exists = db.prepare("SELECT id FROM workers WHERE id=?").get(id); if (!exists) return c.json({ error: "No encontrado" }, 404); const policy = db.prepare( `SELECT validity_mode, requires_issued_at, requires_expires_at FROM document_types WHERE code = ?`, ).get(type) as { validity_mode: string; requires_issued_at: number; requires_expires_at: number; } | undefined; if (!policy) return c.json({ error: "Tipo de documento no válido" }, 400); 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 c.json({ error: "Indique la fecha de emisión del documento" }, 400); } if (policy.requires_expires_at && !expiresAt) { return c.json({ error: "Indique la fecha de vigencia / vencimiento" }, 400); } if (type === "alta_imss" && !imssCompanyId) { return c.json({ error: "Seleccione la empresa patrón del alta IMSS" }, 400); } if (type === "alta_imss" && !imssAltaAt) { return c.json({ error: "Indique la fecha de alta IMSS" }, 400); } if (type === "baja_imss" && !imssBajaAt) { return c.json({ error: "Indique la fecha de baja IMSS" }, 400); } 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) { return c.json({ error: error instanceof Error ? error.message : "No se pudo guardar" }, 400); } return c.json({ ok: true, checklist: checklistFor(db, id), ...imssFlagsFor(db, id), }); }); app.get("/v1/workers/:id/documents/:docId", requireAuth, async (c) => { const workerId = Number(c.req.param("id")); const docId = Number(c.req.param("docId")); const db = await getDb(); const doc = 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 enc = await Deno.readFile(join(workerDir(workerId), doc.storage_name)); const plain = await decryptBytes(doc.iv, enc); c.header("Content-Type", doc.mime); c.header("Content-Disposition", `inline; filename="${doc.original_name}"`); return c.body(plain.buffer as ArrayBuffer); }); app.get("/v1/workers/:id/photo", requireAuth, async (c) => { const id = Number(c.req.param("id")); const db = await getDb(); const bytes = await loadCurrentPhoto(db, id); if (!bytes) return c.json({ error: "Sin foto" }, 404); const jpeg = bytes[0] === 0xff && bytes[1] === 0xd8; c.header("Content-Type", jpeg ? "image/jpeg" : "image/png"); c.header("Cache-Control", "private, max-age=30"); return c.body(bytes.buffer as ArrayBuffer); }); app.get("/v1/badge-qr", requireAuth, 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", requireAuth, 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 = await getDb(); const blocked = projectMustBe( projectById(db, projectId), ["activo"], "Solo se generan gafetes de proyectos activos", ); if (blocked) return c.json({ error: blocked.error }, blocked.status); let ids = body.worker_ids ?? []; if (!ids.length) { ids = (db.prepare( `SELECT w.id FROM workers w JOIN assignments a ON a.worker_id = w.id AND a.project_id = ? AND a.active = 1 WHERE w.status = 'activo' AND w.needs_badge = 1 AND EXISTS (SELECT 1 FROM documents d WHERE d.worker_id=w.id AND d.type_code='foto' AND d.is_current=1)`, ).all(projectId) as { id: number }[]).map((r) => r.id); } if (!ids.length) return c.json({ error: "No hay personal activo con foto para imprimir" }, 400); const bytes = await generateBadgePdf(db, projectId, ids); db.prepare( "INSERT INTO badge_jobs (project_id, status, created_by) VALUES (?, 'done', ?)", ).run(projectId, c.get("user").id); const jobId = lastInsertId(db); const path = await saveJobPdf(bytes, jobId); 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 (?, ?)"); for (const wid of ids) { ins.run(jobId, wid); refreshPipeline(db, wid); } return c.json({ id: jobId, count: ids.length }); }); app.get("/v1/badge-jobs", requireAuth, async (c) => { const db = await getDb(); const jobs = 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=1) 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 }); }); app.get("/v1/badge-jobs/:id", requireAuth, async (c) => { const id = Number(c.req.param("id")); const db = await getDb(); const job = db.prepare("SELECT * FROM badge_jobs WHERE id=?").get(id); if (!job) return c.json({ error: "Job no encontrado" }, 404); const people = 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 }); }); app.get("/v1/badge-jobs/:id/pdf", requireAuth, async (c) => { const id = Number(c.req.param("id")); const db = await getDb(); const job = 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 Deno.readFile(job.pdf_path); 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", requireAuth, 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 = await getDb(); db.prepare( `UPDATE badge_job_people SET delivered=?, delivered_at=CASE WHEN ? THEN datetime('now') ELSE NULL END WHERE job_id=? AND worker_id=?`, ).run(delivered ? 1 : 0, delivered ? 1 : 0, jobId, workerId); return c.json({ ok: true }); }); registerPayrollRoutes(app); const port = config.port; await getDb(); Deno.serve({ port, hostname: "0.0.0.0" }, app.fetch); console.log(`API panel-obra en http://127.0.0.1:${port}`);