import postgres, { createPool, PgDb, withTenant } from "./pg.ts"; import { config } from "./config.ts"; import { evaluateDocumentValidity, freshnessRequired, type ImssStatus, type WorkerImssContext } from "./document_validity.ts"; export type Db = PgDb; /** * panels_product, esquema core: negocio (empresas, personal, obras, * expedientes, presupuesto, nómina, gafetes). Aislado de `iam` a propósito * -- este módulo no debe importar iam_db.ts. */ let corePool: postgres.Sql | null = null; let coreOwnerPool: postgres.Sql | null = null; function getCorePool(): postgres.Sql { if (!corePool) { corePool = createPool(config.databaseUrlCore, { max: 20 }); } return corePool; } function getCoreOwnerPool(): postgres.Sql { if (!coreOwnerPool) { coreOwnerPool = createPool(config.databaseUrlCoreOwner || config.databaseUrlCore, { max: 3 }); } return coreOwnerPool; } export async function pingCoreDb(): Promise { await getCorePool()`SELECT 1`; } /** El único camino "normal" para atender un request: abre una transacción * con app.tenant_id fijado (Row Level Security), y la cierra sola al * terminar `fn` (commit) o al lanzar (rollback). Ver api/pg.ts#withTenant * y las políticas en db/core/changesets/005-rls.sql. */ export async function withCoreTenant( tenantId: number | null, fn: (db: Db) => Promise, ): Promise { return await withTenant(getCorePool(), tenantId, fn); } /** Conexión SIN RLS (rol _owner, dueño de las tablas) -- reservada para * scripts (bootstrap, ETL) y para resoluciones internas puntuales que * necesitan cruzar tenants a propósito (ej. auth.ts al enriquecer el login * con el nombre de la empresa). NO usar en handlers de request normales. */ export async function getCoreDb(): Promise { return new PgDb(getCoreOwnerPool()); } export async function closeCoreDb(): Promise { await corePool?.end({ timeout: 5 }); await coreOwnerPool?.end({ timeout: 5 }); corePool = null; coreOwnerPool = null; } export async function lastInsertId(database: Db): Promise { return await database.lastInsertId(); } export const PROJECT_STATUSES = ["activo", "pausado", "concluido", "cancelado"] as const; export type ProjectStatus = (typeof PROJECT_STATUSES)[number]; export const PROJECT_STATUS_CATALOG = [ { code: "activo", label: "Activo" }, { code: "pausado", label: "Pausado" }, { code: "concluido", label: "Concluido" }, { code: "cancelado", label: "Cancelado" }, ] as const; export function isProjectStatus(value: string): value is ProjectStatus { return (PROJECT_STATUSES as readonly string[]).includes(value); } export async function nextProjectCode(database: Db): Promise { const row = await database.prepare( `SELECT COALESCE(MAX(substring(code from 5)::integer), 0) + 1 AS n FROM projects WHERE code ~ '^PRY-[0-9]{4}' OR code ~ '^OBR-[0-9]{4}'`, ).get() as { n: number }; return `PRY-${String(row.n).padStart(4, "0")}`; } export async function projectById(database: Db, id: number) { return await database.prepare("SELECT * FROM projects WHERE id = ?").get(id) as | { id: number; code: string; name: string; status: string } | undefined; } export function projectMustBe( project: { status: string } | undefined, allowed: readonly ProjectStatus[], closedMessage: string, ): { error: string; status: 400 | 404 } | null { if (!project) return { error: "Proyecto no encontrado", status: 404 }; if (!allowed.includes(project.status as ProjectStatus)) { return { error: closedMessage, status: 400 }; } return null; } export async function workerImssContext(database: Db, workerId: number): Promise { const w = await database.prepare( "SELECT imss_status, imss_alta_at, last_rehire_at FROM workers WHERE id = ?", ).get(workerId) as { imss_status: ImssStatus; imss_alta_at: string | null; last_rehire_at: string | null; } | undefined; const altaDoc = await database.prepare( `SELECT imss_alta_at, uploaded_at FROM documents WHERE worker_id = ? AND type_code = 'alta_imss' AND is_current = true LIMIT 1`, ).get(workerId) as { imss_alta_at: string | null; uploaded_at: string } | undefined; return { imss_status: (w?.imss_status as ImssStatus) || "sin_alta", last_rehire_at: w?.last_rehire_at ?? null, current_alta_at: altaDoc?.imss_alta_at || w?.imss_alta_at || altaDoc?.uploaded_at || null, }; } /** Checklist con vigencia; contexto IMSS vía imssFlagsFor / checklistItemsFor. * tenantId (Fase 4d) resuelve la zona horaria del tenant para calcular * "hoy" de forma consistente con nómina -- antes esta función usaba * `new Date()` crudo (hora del servidor/UTC) sin relación con PAYROLL_TZ. */ export async function checklistItemsFor(database: Db, workerId: number, tenantId: number | null = null) { const tz = await tenantTimezone(database, tenantId); const today = todayInTimezone(tz); const types = await database.prepare( `SELECT code, label, required, validity_mode, freshness_days, requires_issued_at, requires_expires_at, category FROM document_types`, ).all() as { code: string; label: string; required: boolean; validity_mode: string; freshness_days: number | null; requires_issued_at: boolean; requires_expires_at: boolean; category: string; }[]; const currentDocs = await database.prepare( `SELECT type_code, issued_at, expires_at, uploaded_at, imss_alta_at FROM documents WHERE worker_id = ? AND is_current = true`, ).all(workerId) as { type_code: string; issued_at: string | null; expires_at: string | null; uploaded_at: string; imss_alta_at: string | null; }[]; const byType = new Map(currentDocs.map((d) => [d.type_code, d])); const ctx = await workerImssContext(database, workerId); return { ctx, freshness_required: freshnessRequired(ctx), items: types.map((t) => { const policy = { code: t.code, label: t.label, required: !!t.required, validity_mode: (t.validity_mode || "none") as "none" | "freshness" | "expiry", freshness_days: t.freshness_days, requires_issued_at: !!t.requires_issued_at, requires_expires_at: !!t.requires_expires_at, }; const evaled = evaluateDocumentValidity(policy, byType.get(t.code), ctx, today); return { code: t.code, label: t.label, required: !!t.required, validity_mode: policy.validity_mode, freshness_days: t.freshness_days, requires_issued_at: policy.requires_issued_at, requires_expires_at: policy.requires_expires_at, category: t.category || "otro", present: evaled.present, valid: evaled.valid, validity_status: evaled.validity_status, issued_at: evaled.issued_at, expires_at: evaled.expires_at, }; }), }; } export async function imssFlagsFor(database: Db, workerId: number, tenantId: number | null = null) { const { ctx, freshness_required: needFresh, items } = await checklistItemsFor(database, workerId, tenantId); const requiredOk = items.filter((i) => i.required).every((i) => i.present && i.valid); const inProject = !!await database.prepare( "SELECT 1 FROM assignments WHERE worker_id = ? AND active = true LIMIT 1", ).get(workerId); const hasImss = ctx.imss_status === "alta"; return { imss_status: ctx.imss_status, freshness_required: needFresh, imss_ready: requiredOk && !hasImss, in_project_without_imss: inProject && !hasImss, expediente_ok: requiredOk, }; } export async function checklistFor(database: Db, workerId: number, tenantId: number | null = null) { return (await checklistItemsFor(database, workerId, tenantId)).items; } export async function refreshPipeline(database: Db, workerId: number, tenantId: number | null = null): Promise { const w = await database.prepare("SELECT status FROM workers WHERE id = ?").get(workerId) as | { status: string } | undefined; if (!w) return; if (w.status === "baja") { await database.prepare("UPDATE workers SET pipeline_status = 'baja' WHERE id = ?").run(workerId); return; } const items = await checklistFor(database, workerId, tenantId); const photo = items.find((i) => i.code === "foto"); const photoOk = photo?.present && photo?.valid; const requiredOk = items.filter((i) => i.required).every((i) => i.present && i.valid); const printed = await database.prepare( `SELECT 1 FROM badge_job_people p JOIN badge_jobs j ON j.id = p.job_id WHERE p.worker_id = ? LIMIT 1`, ).get(workerId); let pipeline = "incompleto"; if (requiredOk && photoOk) pipeline = printed ? "impreso" : "listo_gafete"; const assigned = await database.prepare( "SELECT 1 FROM assignments WHERE worker_id = ? AND active = true LIMIT 1", ).get(workerId); if (pipeline !== "incompleto" && assigned) { pipeline = printed ? "activo" : "listo_gafete"; } await database.prepare("UPDATE workers SET pipeline_status = ? WHERE id = ?").run(pipeline, workerId); } export async function projectChecklistFor(database: Db, projectId: number) { const types = await database.prepare( "SELECT code, label, required, category FROM project_document_types", ).all() as { code: string; label: string; required: boolean; category: string }[]; const current = await database.prepare( `SELECT type_code FROM project_documents WHERE project_id = ? AND is_current = true`, ).all(projectId) as { type_code: string }[]; const have = new Set(current.map((c) => c.type_code)); return types.map((t) => ({ code: t.code, label: t.label, required: !!t.required, category: t.category || "otro", present: have.has(t.code), })); } export async function companyChecklistFor(database: Db, companyId: number) { const types = await database.prepare( "SELECT code, label, required, category FROM company_document_types", ).all() as { code: string; label: string; required: boolean; category: string }[]; const current = await database.prepare( `SELECT type_code FROM company_documents WHERE company_id = ? AND is_current = true`, ).all(companyId) as { type_code: string }[]; const have = new Set(current.map((c) => c.type_code)); return types.map((t) => ({ code: t.code, label: t.label, required: !!t.required, category: t.category || "otro", present: have.has(t.code), })); } /** Zona horaria del tenant (Fase 4d) -- reemplaza el PAYROLL_TZ * hardcodeado. Con default sensato si el tenant no la configuró. */ export async function tenantTimezone(database: Db, tenantId: number | null): Promise { if (tenantId == null) return "America/Mexico_City"; const row = await database.prepare( "SELECT timezone FROM tenant_settings WHERE tenant_id = ?", ).get(tenantId) as { timezone: string } | undefined; return row?.timezone || "America/Mexico_City"; } /** "Hoy" en la zona horaria del tenant, formato ISO (YYYY-MM-DD). Usado por * nómina y por validación de vigencia de documentos -- antes eran dos * nociones de "hoy" distintas (PAYROLL_TZ vs. new Date() crudo); ahora es * una sola función parametrizada por tenant. */ export function todayInTimezone(timezone: string, now = new Date()): string { return now.toLocaleDateString("en-CA", { timeZone: timezone }); }