mirror of
https://origin.cursor.com/mrdevmx/panels.git
synced 2026-10-09 23:03:18 +00:00
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>
195 lines
6.7 KiB
TypeScript
195 lines
6.7 KiB
TypeScript
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();
|
||
}
|