panels-origin/api/mx.ts
Cursor Agent 493829d028
api: migrar todo el backend de SQLite a Postgres + Redis (fase 2-4e)
Fase 2 (driver):
- api/pg.ts: adaptador delgado sobre postgres.js (prepare/get/all/run,
  placeholders ? -> $n, withTenant con set_config para RLS), con parsers
  de tipo custom (numeric/date/timestamp(tz)/bigint) para que el resto
  del codigo heredado de SQLite (fechas/montos como string, ids como
  number) siga funcionando sin reescribir cada call-site a mano.
- api/platform_db.ts, api/iam_db.ts (nuevo), api/db.ts: pools separados
  por base/esquema (panels_platform, panels_product.iam,
  panels_product.core), owner pool para bootstrap/scripts/lookups
  administrativos que cruzan tenant a proposito.
- api/redis.ts: clientes iam/core separados (ACL panels_iam_redis /
  panels_core_redis).
- api/sessions.ts + auth.ts: sesiones ahora en Redis (cookie = id opaco,
  no HMAC autocontenido); revocacion real (logout, cambio de password).
- api/storage.ts (Fase 4c): documentos/PDFs via Contabo Object Storage
  (S3), con fallback a disco local si no hay credenciales S3 (dev).
- api/scope.ts: middleware withCoreScope/requireCoreAuth que abre la
  transaccion con app.tenant_id fijado (RLS) para cada request.
- api/cache.ts (Fase 4e): cache Redis con tenant_id obligatorio en la
  llave; aplicado a /v1/catalogs.

Fase 3 (reescritura SQL, ~80 endpoints en main.ts/companies.ts/budget.ts/
payroll.ts/payroll_http.ts/excel.ts/saas.ts/smtp.ts):
- Todo async/await, sintaxis Postgres (COALESCE, ~ regex, ON CONFLICT,
  now()/current_date, booleanos reales, RETURNING via lastInsertId()).
- IDOR cross-tenant cerrado: GET/PATCH /v1/projects/:id, /v1/workers/:id
  ya no dependen de que el handler recuerde el WHERE tenant_id -- Row
  Level Security lo hace estructuralmente (verificado con un segundo
  tenant real: 404 en vez de fuga de datos).
- API key ya no ve todos los tenants: ahora exige X-Tenant-Id explicito.

Fase 3b (tests): api/test_helpers.ts corre cada test en una transaccion
que siempre se revierte, contra el mismo baseline de Liquibase que
produccion (ya no un esquema SQLite escrito a mano). payroll_test.ts
reescrito con fixtures reales; 11/11 pasan contra Postgres.

Fase 4 (IAM/RBAC): iam.roles/permissions/role_permissions formalizados
(ver db/iam ya en fase 1); uploaded_by/created_by ahora son snapshot
desnormalizado (uploaded_by_id/name); seed() en runtime eliminado,
reemplazado por scripts/bootstrap-admin.ts (one-shot).

Fase 4d (zona horaria): nuevo endpoint /v1/configuracion (GET/PUT),
PAYROLL_TZ hardcodeado reemplazado por tenant_settings.timezone,
document_validity.ts ya no usa new Date() crudo.

Verificado end-to-end contra Postgres+Redis reales: login, sesiones,
catalogos con cache, alta de trabajador, subida/descarga de documento
cifrado, y el fix de IDOR probado con un segundo tenant real (403/404
en vez de fuga de datos).

Co-authored-by: alberto.martinez <alberto.martinez@mrdev.mx>
2026-09-02 20:47:45 +00:00

195 lines
6.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const CURP_RE =
/^[A-Z][AEIOUX][A-Z]{2}\d{2}(?:0[1-9]|1[0-2])(?:0[1-9]|[12]\d|3[01])[HMX](?:AS|BC|BS|CC|CS|CH|CL|CM|DF|DG|GT|GR|HG|JC|MC|MN|MS|NT|NL|OC|PL|QT|QR|SP|SL|SR|TC|TS|TL|VZ|YN|ZS|NE)[B-DF-HJ-NP-TV-Z]{3}[A-Z\d]\d$/;
const RFC_PF = /^[A-ZÑ&]{4}\d{6}[A-Z0-9]{3}$/;
const RFC_PM = /^[A-ZÑ&]{3}\d{6}[A-Z0-9]{3}$/;
export function normUpper(value: string | null | undefined): string {
return (value ?? "").toString().trim().toUpperCase().replace(/\s+/g, "");
}
export function normName(value: string | null | undefined): string {
return (value ?? "").toString().trim().replace(/\s+/g, " ");
}
const NAME_PARTICLES = new Set([
"de", "del", "la", "las", "los", "el", "y", "e", "da", "das", "do", "dos", "di", "du", "le", "van", "von",
]);
function capPiece(piece: string): string {
if (!piece) return piece;
return piece.replace(/(^|['’])(\p{L})/gu, (_m, sep: string, ch: string) => sep + ch.toLocaleUpperCase("es-MX"));
}
/** Nombre propio: Héctor de la Cruz. No guarda TODO EN MAYÚSCULAS. */
export function titleCase(value: string | null | undefined): string {
const raw = normName(value);
if (!raw) return "";
return raw
.toLocaleLowerCase("es-MX")
.split(" ")
.map((word, i) => {
if (!word) return word;
if (i > 0 && NAME_PARTICLES.has(word)) return word;
return word.split("-").map(capPiece).join("-");
})
.join(" ");
}
/** Oración: primera letra y después de . ? ! */
export function sentenceCase(value: string | null | undefined): string {
const raw = normName(value);
if (!raw) return "";
return raw
.toLocaleLowerCase("es-MX")
.replace(/(^|[.!?]\s+)(\p{L})/gu, (_m, sep: string, ch: string) => sep + ch.toLocaleUpperCase("es-MX"));
}
export function looksAllCaps(value: string | null | undefined): boolean {
const letters = [...(value ?? "")].filter((ch) => /\p{L}/u.test(ch)).join("");
if (letters.length < 2) return false;
const up = letters.toLocaleUpperCase("es-MX");
const low = letters.toLocaleLowerCase("es-MX");
return letters === up && letters !== low;
}
export function validateCurp(raw: string): string | null {
const curp = normUpper(raw);
if (!CURP_RE.test(curp)) return "CURP inválida (18 caracteres, formato oficial)";
return null;
}
export function validateRfc(raw: string): string | null {
const rfc = normUpper(raw).replace(/Ñ/g, "Ñ");
if (!RFC_PF.test(rfc) && !RFC_PM.test(rfc)) {
return "RFC inválido (12 o 13 caracteres, formato SAT)";
}
return null;
}
/** Dígito verificador IMSS (11 dígitos). */
export function validateNss(raw: string): string | null {
const nss = (raw ?? "").toString().replace(/\D/g, "");
if (nss.length !== 11) return "NSS inválido (11 dígitos)";
const digits = nss.split("").map(Number);
let sum = 0;
for (let i = 0; i < 10; i++) {
let n = digits[i] * (i % 2 === 1 ? 2 : 1);
if (n > 9) n -= 9;
sum += n;
}
const check = (10 - (sum % 10)) % 10;
if (check !== digits[10]) return "NSS inválido (dígito verificador)";
return null;
}
export function formatNss(raw: string): string {
return (raw ?? "").toString().replace(/\D/g, "");
}
export type WorkerInput = {
first_name?: string;
middle_name?: string | null;
last_name_p?: string;
last_name_m?: string;
curp?: string;
rfc?: string;
nss?: string;
phone?: string;
email?: string;
address?: string;
blood_type?: string | null;
hire_type?: string;
company_id?: number | null;
position?: string;
risk_code?: string;
work_type?: string;
daily_wage?: number;
needs_badge?: boolean | number;
status?: string;
};
export function validateWorkerFields(body: WorkerInput): Record<string, string> {
const errors: Record<string, string> = {};
if (!normName(body.first_name)) errors.first_name = "Nombre obligatorio";
if (!normName(body.last_name_p)) errors.last_name_p = "Apellido paterno obligatorio";
if (!normName(body.last_name_m)) errors.last_name_m = "Apellido materno obligatorio";
const curpErr = validateCurp(body.curp ?? "");
if (curpErr) errors.curp = curpErr;
const rfcErr = validateRfc(body.rfc ?? "");
if (rfcErr) errors.rfc = rfcErr;
const nssErr = validateNss(body.nss ?? "");
if (nssErr) errors.nss = nssErr;
if (!normName(body.phone)) errors.phone = "Teléfono obligatorio";
if (!normName(body.email)) errors.email = "Correo obligatorio";
if (!normName(body.address)) errors.address = "Dirección obligatoria";
// Empresa / patrón se asigna solo con el alta IMSS; no es obligatoria en el padrón.
if (!normName(body.position)) errors.position = "Cargo obligatorio";
if (!normName(body.risk_code)) errors.risk_code = "Riesgo obligatorio";
const wt = (body.work_type ?? "").toUpperCase();
if (!["N", "D"].includes(wt)) errors.work_type = "Tipo de trabajo N o D";
if (body.daily_wage === undefined || body.daily_wage === null || Number.isNaN(Number(body.daily_wage))) {
errors.daily_wage = "Jornal obligatorio";
}
return errors;
}
export function canonicalRiskCode(raw?: string) {
const c = (raw ?? "").toLowerCase().trim();
if (c === "alto") return "rojo";
if (c === "medio") return "amarillo";
if (c === "bajo") return "verde";
return c;
}
export function pipelineLabelFor(status: string): string {
const labels: Record<string, string> = {
incompleto: "Falta expediente",
listo_gafete: "Listo gafete",
impreso: "Gafete impreso",
activo: "Activo en proyecto",
baja: "Baja",
};
return labels[status] ?? status;
}
export function normalizeWorker(body: WorkerInput) {
return {
first_name: titleCase(body.first_name),
middle_name: titleCase(body.middle_name) || null,
last_name_p: titleCase(body.last_name_p),
last_name_m: titleCase(body.last_name_m),
curp: normUpper(body.curp),
rfc: normUpper(body.rfc),
nss: formatNss(body.nss ?? ""),
phone: normName(body.phone),
email: (body.email ?? "").toString().trim().toLowerCase(),
address: sentenceCase(body.address),
blood_type: normUpper(body.blood_type) || null,
hire_type: (body.hire_type ?? "").toUpperCase(),
position: titleCase(body.position),
risk_code: canonicalRiskCode(body.risk_code),
work_type: (body.work_type ?? "").toUpperCase(),
daily_wage: Number(body.daily_wage ?? 0),
needs_badge: !(body.needs_badge === false || body.needs_badge === 0),
status: body.status === "baja" ? "baja" : "activo",
};
}
export function fullName(w: {
first_name: string;
middle_name?: string | null;
last_name_p: string;
last_name_m: string;
}): string {
return [w.first_name, w.middle_name, w.last_name_p, w.last_name_m]
.filter(Boolean)
.join(" ");
}
export function frontName(w: { first_name: string; last_name_p: string }): string {
return `${w.first_name} ${w.last_name_p}`.trim();
}