mirror of
https://origin.cursor.com/mrdevmx/panels.git
synced 2026-10-09 20:43:17 +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>
290 lines
11 KiB
TypeScript
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 });
|
|
}
|