mirror of
https://origin.cursor.com/mrdevmx/panels.git
synced 2026-10-09 22:23:18 +00:00
1461 lines
53 KiB
TypeScript
1461 lines
53 KiB
TypeScript
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 { 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<string, unknown>,
|
|
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<FormData> } },
|
|
): 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<Record<string, unknown>>(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<string, unknown> }>(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<typeof filterProjectListEnvelope>[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<string, unknown>) {
|
|
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<ProjectInput>();
|
|
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<ProjectInput>();
|
|
const db = c.get("db");
|
|
const route = routeLabel(c);
|
|
const curEnv = await callCoreFn<{ project: Record<string, unknown> }>(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<string, unknown> }>(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<BudgetItemInput>();
|
|
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<BudgetItemInput>();
|
|
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<WorkerInput & { id?: number }>();
|
|
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<typeof normalizeWorker>,
|
|
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<WorkerInput & { project_id?: number }>();
|
|
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<string, unknown> }>(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<WorkerInput>();
|
|
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<string, unknown>) },
|
|
});
|
|
});
|
|
|
|
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<Record<string, unknown>> }>(
|
|
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<Record<string, unknown>> }>(
|
|
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<string, unknown>), 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);
|
|
registerWarehouseRoutes(app);
|
|
registerCostControlRoutes(app);
|
|
registerWorkProgramRoutes(app);
|
|
registerIamRoutes(app);
|
|
|
|
const port = config.port;
|
|
|
|
function startup(name: string, p: Promise<unknown>): Promise<unknown> {
|
|
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}`);
|