panels-origin/api/document_validity.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

227 lines
9.8 KiB
TypeScript

/** Vigencia documental y contexto de alta IMSS. */
export type ValidityMode = "none" | "freshness" | "expiry";
export type ValidityStatus = "ok" | "missing" | "expired" | "stale_for_alta" | "missing_dates";
export type ImssStatus = "sin_alta" | "alta" | "baja_imss";
export type DocTypePolicy = {
code: string;
label: string;
required: boolean;
validity_mode: ValidityMode;
freshness_days: number | null;
requires_issued_at: boolean;
requires_expires_at: boolean;
};
export type CurrentDocDates = {
type_code: string;
issued_at: string | null;
expires_at: string | null;
uploaded_at: string;
imss_alta_at?: string | null;
};
export type WorkerImssContext = {
imss_status: ImssStatus;
last_rehire_at: string | null;
/** Fecha de la alta IMSS vigente (documento o worker.imss_alta_at). */
current_alta_at: string | null;
};
function parseDay(value: string | null | undefined): Date | null {
if (!value) return null;
const raw = String(value).trim().slice(0, 10);
if (!/^\d{4}-\d{2}-\d{2}$/.test(raw)) return null;
const d = new Date(`${raw}T12:00:00`);
return Number.isNaN(d.getTime()) ? null : d;
}
function daysBetween(from: Date, to: Date): number {
return Math.floor((to.getTime() - from.getTime()) / 86_400_000);
}
/** True cuando el expediente debe exigir frescor (alta o reingreso pendiente de alta). */
export function freshnessRequired(ctx: WorkerImssContext): boolean {
if (ctx.imss_status !== "alta") return true;
if (!ctx.last_rehire_at) return false;
const rehire = parseDay(ctx.last_rehire_at);
const alta = parseDay(ctx.current_alta_at);
if (!rehire) return false;
if (!alta) return true;
return alta < rehire;
}
/**
* `today` es un string ISO (YYYY-MM-DD) en la zona horaria del tenant
* (Fase 4d) -- ya no `new Date()` crudo (hora del servidor/UTC). El
* caller (api/db.ts#checklistItemsFor) lo resuelve con
* tenantTimezone()/todayInTimezone() antes de llegar aquí.
*/
export function evaluateDocumentValidity(
policy: DocTypePolicy,
doc: CurrentDocDates | null | undefined,
ctx: WorkerImssContext,
today: string = new Date().toISOString().slice(0, 10),
): {
present: boolean;
valid: boolean;
validity_status: ValidityStatus;
issued_at: string | null;
expires_at: string | null;
} {
if (!doc) {
return {
present: false,
valid: false,
validity_status: "missing",
issued_at: null,
expires_at: null,
};
}
const issuedAt = doc.issued_at;
const expiresAt = doc.expires_at;
const needFresh = freshnessRequired(ctx);
if (policy.validity_mode === "expiry") {
if (policy.requires_expires_at && !expiresAt) {
return {
present: true,
valid: false,
validity_status: "missing_dates",
issued_at: issuedAt,
expires_at: expiresAt,
};
}
const exp = parseDay(expiresAt);
const todayDate = parseDay(today);
if (exp && todayDate && exp < todayDate) {
return {
present: true,
valid: false,
validity_status: "expired",
issued_at: issuedAt,
expires_at: expiresAt,
};
}
return {
present: true,
valid: true,
validity_status: "ok",
issued_at: issuedAt,
expires_at: expiresAt,
};
}
if (policy.validity_mode === "freshness") {
if (policy.requires_issued_at && !issuedAt) {
return {
present: true,
valid: false,
validity_status: "missing_dates",
issued_at: issuedAt,
expires_at: expiresAt,
};
}
if (needFresh && issuedAt) {
const issued = parseDay(issuedAt);
const days = policy.freshness_days ?? 90;
const todayForFreshness = parseDay(today) ?? new Date();
if (issued && daysBetween(issued, todayForFreshness) > days) {
return {
present: true,
valid: false,
validity_status: "stale_for_alta",
issued_at: issuedAt,
expires_at: expiresAt,
};
}
}
return {
present: true,
valid: true,
validity_status: "ok",
issued_at: issuedAt,
expires_at: expiresAt,
};
}
return {
present: true,
valid: true,
validity_status: "ok",
issued_at: issuedAt,
expires_at: expiresAt,
};
}
export type CatalogDocType = {
code: string;
label: string;
required: number;
category: string;
validity_mode: ValidityMode;
freshness_days: number | null;
requires_issued_at: number;
requires_expires_at: number;
};
/** Núcleo = required 1. El resto es catálogo: se sube si se tiene, no cuenta como faltante. */
export const DOCUMENT_TYPE_SEED: CatalogDocType[] = [
{ code: "foto", label: "Foto", required: 1, category: "identidad", validity_mode: "none", freshness_days: null, requires_issued_at: 0, requires_expires_at: 0 },
{ code: "ine", label: "INE", required: 1, category: "identidad", validity_mode: "expiry", freshness_days: null, requires_issued_at: 0, requires_expires_at: 1 },
{ code: "curp", label: "Constancia CURP", required: 1, category: "identidad", validity_mode: "freshness", freshness_days: 90, requires_issued_at: 1, requires_expires_at: 0 },
{ code: "nss", label: "Constancia NSS", required: 1, category: "identidad", validity_mode: "freshness", freshness_days: 90, requires_issued_at: 1, requires_expires_at: 0 },
{ code: "rfc", label: "Constancia RFC", required: 1, category: "identidad", validity_mode: "freshness", freshness_days: 90, requires_issued_at: 1, requires_expires_at: 0 },
{ code: "domicilio", label: "Comprobante de domicilio", required: 1, category: "identidad", validity_mode: "freshness", freshness_days: 90, requires_issued_at: 1, requires_expires_at: 0 },
{ code: "alta_imss", label: "Alta IMSS", required: 0, category: "imss", validity_mode: "none", freshness_days: null, requires_issued_at: 0, requires_expires_at: 0 },
{ code: "baja_imss", label: "Baja IMSS", required: 0, category: "imss", validity_mode: "none", freshness_days: null, requires_issued_at: 0, requires_expires_at: 0 },
{ code: "contrato", label: "Contrato laboral", required: 0, category: "laboral", validity_mode: "none", freshness_days: null, requires_issued_at: 0, requires_expires_at: 0 },
{ code: "aviso_privacidad", label: "Aviso de privacidad", required: 0, category: "laboral", validity_mode: "none", freshness_days: null, requires_issued_at: 0, requires_expires_at: 0 },
{ code: "dc3_nom031", label: "DC-3 NOM-031 (construcción)", required: 0, category: "sst", validity_mode: "expiry", freshness_days: null, requires_issued_at: 0, requires_expires_at: 0 },
{ code: "epp", label: "Acuse de entrega de EPP", required: 0, category: "sst", validity_mode: "none", freshness_days: null, requires_issued_at: 0, requires_expires_at: 0 },
{ code: "medico", label: "Certificado médico", required: 0, category: "sst", validity_mode: "expiry", freshness_days: null, requires_issued_at: 0, requires_expires_at: 0 },
{ code: "dc3_altura", label: "DC-3 trabajo en alturas", required: 0, category: "oficio", validity_mode: "expiry", freshness_days: null, requires_issued_at: 0, requires_expires_at: 0 },
{ code: "dc3_soldadura", label: "DC-3 soldadura", required: 0, category: "oficio", validity_mode: "expiry", freshness_days: null, requires_issued_at: 0, requires_expires_at: 0 },
{ code: "dc3_montacargas", label: "DC-3 montacargas", required: 0, category: "oficio", validity_mode: "expiry", freshness_days: null, requires_issued_at: 0, requires_expires_at: 0 },
{ code: "licencia", label: "Licencia de conducir", required: 0, category: "oficio", validity_mode: "expiry", freshness_days: null, requires_issued_at: 0, requires_expires_at: 0 },
{ code: "cedula", label: "Cédula profesional", required: 0, category: "oficio", validity_mode: "none", freshness_days: null, requires_issued_at: 0, requires_expires_at: 0 },
{ code: "otro", label: "Otro", required: 0, category: "otro", validity_mode: "none", freshness_days: null, requires_issued_at: 0, requires_expires_at: 0 },
];
export const PROJECT_DOCUMENT_TYPE_SEED: Array<{
code: string;
label: string;
required: number;
category: string;
}> = [
{ code: "contrato", label: "Contrato", required: 1, category: "contrato" },
{ code: "presupuesto", label: "Presupuesto", required: 1, category: "contrato" },
{ code: "planos", label: "Planos", required: 0, category: "tecnico" },
{ code: "licencia_construccion", label: "Licencia de construcción", required: 0, category: "permisos" },
{ code: "uso_suelo", label: "Uso de suelo", required: 0, category: "permisos" },
{ code: "proteccion_civil", label: "Anuencia de Protección Civil", required: 0, category: "permisos" },
{ code: "mia", label: "MIA / resolutivo ambiental", required: 0, category: "ambiental" },
{ code: "siroc_acuse", label: "Acuse SIROC", required: 0, category: "imss" },
{ code: "programa_sst", label: "Programa de seguridad", required: 0, category: "sst" },
{ code: "seguro_obra", label: "Seguro de obra", required: 0, category: "contrato" },
{ code: "fianza", label: "Fianza", required: 0, category: "contrato" },
{ code: "otro", label: "Otro", required: 0, category: "otro" },
];
export const COMPANY_DOCUMENT_TYPE_SEED: Array<{
code: string;
label: string;
required: number;
category: string;
}> = [
{ code: "repse_federal", label: "REPSE federal (STPS)", required: 0, category: "repse" },
{ code: "repse_estatal", label: "REPSE estatal (SATQ)", required: 0, category: "repse" },
{ code: "opinion_sat", label: "Opinión de cumplimiento SAT (32-D)", required: 0, category: "fiscal" },
{ code: "opinion_imss", label: "Opinión de cumplimiento IMSS", required: 0, category: "fiscal" },
{ code: "opinion_infonavit", label: "Opinión de cumplimiento INFONAVIT", required: 0, category: "fiscal" },
{ code: "acta_constitutiva", label: "Acta constitutiva", required: 0, category: "legal" },
{ code: "poder_notarial", label: "Poder notarial", required: 0, category: "legal" },
{ code: "otro", label: "Otro", required: 0, category: "otro" },
];