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

290 lines
11 KiB
TypeScript

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<void> {
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<T>(
tenantId: number | null,
fn: (db: Db) => Promise<T>,
): Promise<T> {
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<Db> {
return new PgDb(getCoreOwnerPool());
}
export async function closeCoreDb(): Promise<void> {
await corePool?.end({ timeout: 5 });
await coreOwnerPool?.end({ timeout: 5 });
corePool = null;
coreOwnerPool = null;
}
export async function lastInsertId(database: Db): Promise<number> {
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<string> {
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<WorkerImssContext> {
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<void> {
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<string> {
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 });
}