From 493829d028c94ef6bd4e8d126c65d54c6cdb4a53 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 2 Sep 2026 20:47:45 +0000 Subject: [PATCH] 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 --- api/auth.ts | 164 +++++--- api/budget.ts | 81 ++-- api/cache.ts | 57 +++ api/companies.ts | 221 ++++++----- api/config.ts | 71 +++- api/crypto.ts | 42 +- api/db.ts | 339 ++++++---------- api/deno.json | 7 +- api/deno.lock | 374 +++++++++++++++--- api/docs_crypto.ts | 6 +- api/document_validity.ts | 14 +- api/document_validity_test.ts | 6 +- api/excel.ts | 113 +++--- api/iam_db.ts | 72 ++++ api/liquibase.ts | 67 ---- api/mail.ts | 8 +- api/main.ts | 662 ++++++++++++++++++-------------- api/mx.ts | 2 +- api/payroll.ts | 461 +++++++++++----------- api/payroll_http.ts | 193 +++++----- api/payroll_test.ts | 487 +++++++++++------------ api/pdf.ts | 46 +-- api/pg.ts | 138 +++++++ api/platform_db.ts | 111 ++---- api/redis.ts | 51 +++ api/saas.ts | 201 ++++++---- api/scope.ts | 26 ++ api/scripts/bootstrap-admin.ts | 103 +++++ api/seed_lista.ts | 153 ++++---- api/sessions.ts | 98 +++++ api/smtp.ts | 42 +- api/storage.ts | 100 +++++ api/test_helpers.ts | 54 +++ scripts/migrate-tenant-codes.ts | 129 ------- 34 files changed, 2761 insertions(+), 1938 deletions(-) create mode 100644 api/cache.ts create mode 100644 api/iam_db.ts delete mode 100644 api/liquibase.ts create mode 100644 api/pg.ts create mode 100644 api/redis.ts create mode 100644 api/scope.ts create mode 100644 api/scripts/bootstrap-admin.ts create mode 100644 api/sessions.ts create mode 100644 api/storage.ts create mode 100644 api/test_helpers.ts delete mode 100644 scripts/migrate-tenant-codes.ts diff --git a/api/auth.ts b/api/auth.ts index f0a79b4..443c9e1 100644 --- a/api/auth.ts +++ b/api/auth.ts @@ -1,9 +1,10 @@ import type { Context, Next } from "hono"; import { deleteCookie, getCookie, setCookie } from "hono/cookie"; -import { getDb } from "./db.ts"; +import { withIamTenant, findUserByUsernameAnyTenant } from "./iam_db.ts"; import { getPlatformDb } from "./platform_db.ts"; import { config } from "./config.ts"; -import { b64urlJson, b64urlJsonParse, hmacSign, hmacVerify, hashPassword, verifyPassword } from "./crypto.ts"; +import { createSession, getSession, revokeSession, revokeAllSessionsForUser } from "./sessions.ts"; +import { hashPassword, verifyPassword } from "./crypto.ts"; export type AuthRealm = "app" | "platform"; export type AuthRole = "platform_admin" | "tenant_admin" | "user"; @@ -22,34 +23,33 @@ export type AuthUser = { must_change_password: boolean; }; -const USER_SELECT = `u.id, u.username, u.display_name, u.company_id, u.tenant_id, u.role, - COALESCE(u.must_change_password, 0) AS must_change_password, - c.code AS company_code, c.name AS company_name, c.kind AS company_kind`; - -type SessionPayload = { uid: number; exp: number; realm?: AuthRealm }; - const COOKIE = "po_session"; -const TTL = 60 * 60 * 24 * 7; +const TTL_SECONDS = 60 * 60 * 24 * 7; -export async function createSessionCookie(c: Context, userId: number, realm: AuthRealm = "app") { - const payload: SessionPayload = { uid: userId, exp: Date.now() + TTL * 1000, realm }; - const body = b64urlJson(payload); - const sig = await hmacSign(config.sessionSecret, body); - setCookie(c, COOKIE, `${body}.${sig}`, { +export async function createSessionCookie( + c: Context, + userId: number, + realm: AuthRealm = "app", + tenantId: number | null = null, +) { + const id = await createSession(userId, realm, tenantId); + setCookie(c, COOKIE, id, { httpOnly: true, path: "/", sameSite: "Lax", secure: config.cookieSecure, - maxAge: TTL, + maxAge: TTL_SECONDS, }); } -export function clearSession(c: Context) { +export async function clearSession(c: Context) { + const id = getCookie(c, COOKIE); + if (id) await revokeSession(id); deleteCookie(c, COOKIE, { path: "/" }); } function asAppUser(row: Record): AuthUser { - const role = (row.role as string) || "user"; + const role = (row.role_code as string) || "user"; return { id: Number(row.id), username: String(row.username), @@ -82,35 +82,35 @@ function asPlatformUser(row: { id: number; username: string; display_name: strin } async function userFromCookie(c: Context): Promise { - const raw = getCookie(c, COOKIE); - if (!raw || !raw.includes(".")) return null; - const [body, sig] = raw.split("."); - if (!await hmacVerify(config.sessionSecret, body, sig)) return null; - let payload: SessionPayload; - try { - payload = b64urlJsonParse(body); - } catch { - return null; - } - if (payload.exp < Date.now()) return null; - const realm: AuthRealm = payload.realm === "platform" ? "platform" : "app"; + const id = getCookie(c, COOKIE); + if (!id) return null; + const session = await getSession(id); + if (!session) return null; - if (realm === "platform") { + if (session.realm === "platform") { const pdb = await getPlatformDb(); - const row = pdb.prepare( + const row = await pdb.prepare( `SELECT id, username, display_name FROM platform_users WHERE id = ? AND status = 'activo'`, - ).get(payload.uid) as { id: number; username: string; display_name: string } | undefined; + ).get(session.userId) as { id: number; username: string; display_name: string } | undefined; return row ? asPlatformUser(row) : null; } - const db = await getDb(); - const row = db.prepare( - `SELECT ${USER_SELECT} - FROM users u LEFT JOIN companies c ON c.id = u.company_id - WHERE u.id = ?`, - ).get(payload.uid) as Record | undefined; - return row ? asAppUser(row) : null; + return await getFreshAppUser(session.userId, session.tenantId); +} + +/** Relee un usuario app (iam) fresco de la base y lo enriquece con su + * empresa (core) -- usado tras login y tras cambiar contraseña. */ +export async function getFreshAppUser(userId: number, tenantId: number | null): Promise { + const row = await withIamTenant(tenantId, async (db) => { + return await db.prepare( + `SELECT id, username, password_hash, display_name, company_id, tenant_id, role_code, + must_change_password, email FROM users WHERE id = ?`, + ).get(userId); + }); + if (!row) return null; + const company = await enrichWithCompanyViaCore((row.company_id as number) ?? null); + return asAppUser({ ...row, ...company }); } function apiKeyOk(c: Context): boolean { @@ -124,6 +124,15 @@ function apiKeyOk(c: Context): boolean { export async function requireAuth(c: Context, next: Next) { if (apiKeyOk(c)) { + // La API key ya NO otorga visibilidad cruzada de todos los tenants + // (era un hallazgo crítico de la revisión de seguridad): ahora exige un + // tenant explícito por header, y las políticas de RLS son fail-closed + // si no se fija -- sin X-Tenant-Id válido, la API key no ve nada. + const tenantHeader = c.req.header("x-tenant-id") ?? ""; + const tenantId = Number(tenantHeader); + if (!tenantHeader || !Number.isInteger(tenantId) || tenantId <= 0) { + return c.json({ error: "X-API-Key requiere X-Tenant-Id" }, 400); + } c.set("user", { id: 0, username: "api", @@ -132,7 +141,7 @@ export async function requireAuth(c: Context, next: Next) { company_code: null, company_name: null, company_kind: null, - tenant_id: null, + tenant_id: tenantId, role: "tenant_admin", realm: "app", must_change_password: false, @@ -166,17 +175,21 @@ export async function login(username: string, password: string): Promise & { password_hash: string }) | undefined; - if (appRow && await verifyPassword(pass, appRow.password_hash)) { - const authUser = asAppUser(appRow); - const { tenantAccessBlocked } = await import("./saas.ts"); + // username es único globalmente (no por tenant) -- se resuelve con el + // pool que omite RLS (ver iam_db.ts#findUserByUsernameAnyTenant); recién + // después de esto se conoce el tenant_id para todo lo demás. + const appRow = await findUserByUsernameAnyTenant(user); + if (appRow && await verifyPassword(pass, appRow.password_hash as string)) { + const authUser = await withIamTenant( + appRow.tenant_id == null ? null : Number(appRow.tenant_id), + async (db) => { + const company = await enrichWithCompanyViaCore(Number(appRow.company_id) || null); + return asAppUser({ ...appRow, ...company }); + }, + ); const pdb = await getPlatformDb(); - const blocked = tenantAccessBlocked(pdb, authUser.tenant_id); + const { tenantAccessBlocked } = await import("./saas.ts"); + const blocked = await tenantAccessBlocked(pdb, authUser.tenant_id); if (blocked) { throw Object.assign(new Error(blocked), { code: "TENANT_BLOCKED" }); } @@ -184,7 +197,7 @@ export async function login(username: string, password: string): Promisecore; usa la conexión core con el rol de + * runtime normal (companies no está sujeta a RLS por-fila salvo por + * tenant_id, así que basta con conocer el tenant ya resuelto). */ +async function enrichWithCompanyViaCore(companyId: number | null) { + if (companyId == null) return { company_code: null, company_name: null, company_kind: null }; + const { getCoreDb } = await import("./db.ts"); + const db = await getCoreDb(); + const row = await db.prepare("SELECT code, name, kind FROM companies WHERE id = ?").get( + companyId, + ); + return { + company_code: (row?.code as string) ?? null, + company_name: (row?.name as string) ?? null, + company_kind: (row?.kind as string) ?? null, + }; +} + export async function changePassword( userId: number, + tenantId: number | null, currentPassword: string, newPassword: string, ): Promise<{ error?: string }> { const next = (newPassword ?? "").trim(); if (next.length < 8) return { error: "La nueva contraseña debe tener al menos 8 caracteres" }; - const db = await getDb(); - const row = db.prepare("SELECT password_hash FROM users WHERE id = ?").get(userId) as - | { password_hash: string } - | undefined; - if (!row) return { error: "Usuario no encontrado" }; - if (!await verifyPassword(currentPassword, row.password_hash)) { - return { error: "Contraseña actual incorrecta" }; - } - const hash = await hashPassword(next); - db.prepare("UPDATE users SET password_hash = ?, must_change_password = 0 WHERE id = ?").run(hash, userId); - return {}; + return await withIamTenant(tenantId, async (db) => { + const row = await db.prepare("SELECT password_hash FROM users WHERE id = ?").get(userId) as + | { password_hash: string } + | undefined; + if (!row) return { error: "Usuario no encontrado" }; + if (!await verifyPassword(currentPassword, row.password_hash)) { + return { error: "Contraseña actual incorrecta" }; + } + const hash = await hashPassword(next); + await db.prepare( + "UPDATE users SET password_hash = ?, must_change_password = false WHERE id = ?", + ).run(hash, userId); + // Cambiar password revoca TODAS las demás sesiones activas de este + // usuario -- antes (cookie HMAC stateless) esto era imposible; ahora + // sí, porque el estado real vive en Redis (ver sessions.ts). + await revokeAllSessionsForUser(userId, "app"); + return {}; + }); } diff --git a/api/budget.ts b/api/budget.ts index 95e3e8f..4ef4c81 100644 --- a/api/budget.ts +++ b/api/budget.ts @@ -1,9 +1,5 @@ import * as XLSX from "xlsx"; -import type { Database } from "@db/sqlite"; - -function lastId(database: Database): number { - return Number((database.prepare("SELECT last_insert_rowid() AS id").get() as { id: number }).id); -} +import type { Db } from "./db.ts"; export const BUDGET_IVA = 0.16; @@ -266,7 +262,7 @@ function looksOpus(rows: unknown[][], headerAt: number, map: ColMap) { return false; } -function parseWbsRows(rows: unknown[][], headerAt: number, map: ColMap): ParsedBudget { +function parseWbsRows(rows: unknown[][], headerAt: number, map: ColMap): Omit { const groups: ParsedGroup[] = []; const items: ParsedBudgetItem[] = []; let skipped = 0; @@ -326,7 +322,7 @@ function parseWbsRows(rows: unknown[][], headerAt: number, map: ColMap): ParsedB return { groups, items, skipped }; } -function parseOpusRows(rows: unknown[][], headerAt: number, map: ColMap): ParsedBudget { +function parseOpusRows(rows: unknown[][], headerAt: number, map: ColMap): Omit { const groups: ParsedGroup[] = []; const items: ParsedBudgetItem[] = []; let skipped = 0; @@ -428,7 +424,7 @@ function parseOpusRows(rows: unknown[][], headerAt: number, map: ColMap): Parsed return { groups, items, skipped }; } -function parseFlatRows(rows: unknown[][], headerAt: number, map: ColMap): ParsedBudget { +function parseFlatRows(rows: unknown[][], headerAt: number, map: ColMap): Omit { const groups: ParsedGroup[] = []; const items: ParsedBudgetItem[] = []; let skipped = 0; @@ -645,14 +641,12 @@ function compareWbs(a: string, b: string) { return a.localeCompare(b, undefined, { numeric: true }); } -export function listBudget(database: Database, projectId: number) { - const chapterCols = (database.prepare("PRAGMA table_info(budget_chapters)").all() as { name: string }[]).map((c) => c.name); - const wbsExpr = chapterCols.includes("wbs") ? "COALESCE(c.wbs, '') AS wbs" : "'' AS wbs"; - const chapters = database.prepare( - `SELECT c.id, c.project_id, c.parent_id, c.code, c.name, ${wbsExpr}, c.sort_order +export async function listBudget(database: Db, projectId: number) { + const chapters = await database.prepare( + `SELECT c.id, c.project_id, c.parent_id, c.code, c.name, COALESCE(c.wbs, '') AS wbs, c.sort_order FROM budget_chapters c WHERE c.project_id = ? ORDER BY c.sort_order, c.id`, ).all(projectId) as ChapterRow[]; - const rawItems = database.prepare( + const rawItems = await database.prepare( `SELECT i.*, ch.name AS chapter_name, ch.code AS chapter_code FROM budget_items i LEFT JOIN budget_chapters ch ON ch.id = i.chapter_id @@ -750,64 +744,47 @@ export function listBudget(database: Database, projectId: number) { }; } -function insertChapter( - database: Database, +async function insertChapter( + database: Db, projectId: number, parentId: number | null, code: string, name: string, wbs: string, sort: number, -) { - const cols = (database.prepare("PRAGMA table_info(budget_chapters)").all() as { name: string }[]).map((c) => c.name); - if (cols.includes("wbs")) { - database.prepare( - "INSERT INTO budget_chapters (project_id, parent_id, code, name, wbs, sort_order) VALUES (?, ?, ?, ?, ?, ?)", - ).run(projectId, parentId, code, name, wbs, sort); - } else { - database.prepare( - "INSERT INTO budget_chapters (project_id, parent_id, code, name, sort_order) VALUES (?, ?, ?, ?, ?)", - ).run(projectId, parentId, code, name, sort); - } - return lastId(database); +): Promise { + await database.prepare( + "INSERT INTO budget_chapters (project_id, parent_id, code, name, wbs, sort_order) VALUES (?, ?, ?, ?, ?, ?)", + ).run(projectId, parentId, code, name, wbs, sort); + return await database.lastInsertId(); } -export function replaceBudgetFromParsed(database: Database, projectId: number, parsed: ParsedBudget) { - database.prepare("DELETE FROM budget_items WHERE project_id = ?").run(projectId); - database.prepare("DELETE FROM budget_chapters WHERE project_id = ?").run(projectId); +export async function replaceBudgetFromParsed(database: Db, projectId: number, parsed: ParsedBudget): Promise { + await database.prepare("DELETE FROM budget_items WHERE project_id = ?").run(projectId); + await database.prepare("DELETE FROM budget_chapters WHERE project_id = ?").run(projectId); const groups = [...parsed.groups].sort((a, b) => compareWbs(a.wbs, b.wbs)); const ids = new Map(); let sort = 1; for (const group of groups) { const parentId = group.parentWbs ? ids.get(group.parentWbs) || null : null; - const id = insertChapter(database, projectId, parentId, group.code, group.name, group.wbs, sort); + const id = await insertChapter(database, projectId, parentId, group.code, group.name, group.wbs, sort); ids.set(group.wbs, id); sort++; } - const itemCols = (database.prepare("PRAGMA table_info(budget_items)").all() as { name: string }[]).map((c) => c.name); - const hasWbs = itemCols.includes("wbs"); let itemSort = 1; for (const item of parsed.items) { const chapterId = item.parentWbs ? ids.get(item.parentWbs) || null : null; const amount = Math.round((item.quantity * item.unit_price || item.amount) * 100) / 100; - if (hasWbs) { - database.prepare( - `INSERT INTO budget_items - (project_id, chapter_id, code, description, unit, quantity, unit_price, amount, sort_order, wbs) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ).run(projectId, chapterId, item.code, item.description, item.unit, item.quantity, item.unit_price, amount, itemSort, item.wbs); - } else { - database.prepare( - `INSERT INTO budget_items - (project_id, chapter_id, code, description, unit, quantity, unit_price, amount, sort_order) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ).run(projectId, chapterId, item.code, item.description, item.unit, item.quantity, item.unit_price, amount, itemSort); - } + await database.prepare( + `INSERT INTO budget_items + (project_id, chapter_id, code, description, unit, quantity, unit_price, amount, sort_order, wbs) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run(projectId, chapterId, item.code, item.description, item.unit, item.quantity, item.unit_price, amount, itemSort, item.wbs); itemSort++; } } -export function replaceBudgetFromItems(database: Database, projectId: number, parsed: ParsedBudgetItem[]) { +export async function replaceBudgetFromItems(database: Db, projectId: number, parsed: ParsedBudgetItem[]): Promise { const groups: ParsedGroup[] = []; const seen = new Set(); for (const item of parsed) { @@ -822,7 +799,7 @@ export function replaceBudgetFromItems(database: Database, projectId: number, pa name: item.chapter || "General", }); } - replaceBudgetFromParsed(database, projectId, { format: "flat", groups, items: parsed, skipped: 0 }); + await replaceBudgetFromParsed(database, projectId, { format: "flat", groups, items: parsed, skipped: 0 }); } export function lineAmount(quantity: number, unitPrice: number) { @@ -925,7 +902,7 @@ export function previewBudgetExcel(bytes: Uint8Array): BudgetPreview { }; } -export function importBudgetExcel(database: Database, projectId: number, bytes: Uint8Array): BudgetImportReport { +export async function importBudgetExcel(database: Db, projectId: number, bytes: Uint8Array): Promise { const read = readBudgetSheet(bytes); if ("error" in read) { return { replaced: false, chapters: 0, inserted: 0, skipped: 0, errors: [{ row: 0, messages: [read.error] }] }; @@ -940,7 +917,7 @@ export function importBudgetExcel(database: Database, projectId: number, bytes: errors: [{ row: 0, messages: ["No se encontraron partidas. Use la plantilla o un Excel con CLAVE, DESCRIPCION, UNIDAD, CANTIDAD y PRECIO."] }], }; } - replaceBudgetFromParsed(database, projectId, parsed); + await replaceBudgetFromParsed(database, projectId, parsed); const subtotal = Math.round(parsed.items.reduce((sum, item) => sum + Number(item.amount || 0), 0) * 100) / 100; const iva = Math.round(subtotal * BUDGET_IVA * 100) / 100; const totals = { @@ -950,7 +927,7 @@ export function importBudgetExcel(database: Database, projectId: number, bytes: item_count: parsed.items.length, }; // El monto de contrato del proyecto refleja el subtotal del presupuesto importado. - database.prepare("UPDATE projects SET contract_amount = ? WHERE id = ?").run(subtotal, projectId); + await database.prepare("UPDATE projects SET contract_amount = ? WHERE id = ?").run(subtotal, projectId); return { replaced: true, chapters: parsed.groups.length, diff --git a/api/cache.ts b/api/cache.ts new file mode 100644 index 0000000..5be7743 --- /dev/null +++ b/api/cache.ts @@ -0,0 +1,57 @@ +import { getCoreRedis, getIamRedis } from "./redis.ts"; + +/** + * Estrategia de cache con Redis (Fase 4e). + * + * Regla no negociable: toda llave que contenga datos de negocio DEBE + * incluir el tenant_id (ver cacheKeyCore). Una llave global para datos + * por-tenant reintroduce el mismo IDOR cross-tenant que Row Level Security + * (Fase 4b) se propuso cerrar -- solo que en Redis en vez de Postgres. + * + * Redis no es fuente de verdad de nada: si una llave expira o se pierde, + * la siguiente lectura recalcula desde Postgres. Por eso alcanza con TTL + * corto para datos de lectura frecuente/escritura poco frecuente + * (catálogos, agregados de listados) y no hace falta invalidación + * explícita en la mayoría de los casos. + */ + +export function cacheKeyCore(tenantId: number | null, ...parts: (string | number)[]): string { + return `core:cache:tenant:${tenantId ?? "none"}:${parts.join(":")}`; +} + +export function cacheKeyIam(tenantId: number | null, ...parts: (string | number)[]): string { + return `iam:cache:tenant:${tenantId ?? "none"}:${parts.join(":")}`; +} + +async function cached( + redis: Awaited>, + key: string, + ttlSeconds: number, + compute: () => Promise, +): Promise { + const hit = await redis.get(key).catch(() => null); + if (hit != null) { + try { + return JSON.parse(hit) as T; + } catch { + // llave corrupta/formato viejo -- recalcular sin fallar el request + } + } + const value = await compute(); + await redis.set(key, JSON.stringify(value), { EX: ttlSeconds }).catch(() => {}); + return value; +} + +export async function cacheCore(key: string, ttlSeconds: number, compute: () => Promise): Promise { + const redis = await getCoreRedis(); + return await cached(redis, key, ttlSeconds, compute); +} + +export async function cacheIam(key: string, ttlSeconds: number, compute: () => Promise): Promise { + const redis = await getIamRedis(); + return await cached(redis, key, ttlSeconds, compute); +} + +export async function invalidateCore(key: string): Promise { + await (await getCoreRedis()).del(key).catch(() => {}); +} diff --git a/api/companies.ts b/api/companies.ts index c7cda4b..dae2f91 100644 --- a/api/companies.ts +++ b/api/companies.ts @@ -1,8 +1,4 @@ -import type { Database } from "@db/sqlite"; - -function lastId(database: Database): number { - return Number((database.prepare("SELECT last_insert_rowid() AS id").get() as { id: number }).id); -} +import type { Db } from "./db.ts"; export type Company = { id: number; @@ -64,36 +60,41 @@ function normRfc(value: unknown): string { return trimText(value).toUpperCase().replace(/\s+/g, ""); } -export function listCompanies(database: Database, tenantId?: number | null): Company[] { +export async function listCompanies(database: Db, tenantId?: number | null): Promise { + // El filtro tenant_id aquí es defensa adicional/legibilidad -- el + // aislamiento real ya lo garantiza Row Level Security sobre la conexión + // acotada por withCoreScope (ver db/core/changesets/005-rls.sql). const where = tenantId != null ? "WHERE c.tenant_id = ?" : ""; const params = tenantId != null ? [tenantId] : []; - return database.prepare( + return await database.prepare( `SELECT c.*, p.code AS parent_code, p.name AS parent_name, (SELECT COUNT(*) FROM workers w WHERE w.company_id = c.id) AS worker_count FROM companies c LEFT JOIN companies p ON p.id = c.parent_id ${where} - ORDER BY CASE c.kind WHEN 'principal' THEN 0 ELSE 1 END, c.name COLLATE NOCASE`, + ORDER BY CASE c.kind WHEN 'principal' THEN 0 ELSE 1 END, LOWER(c.name)`, ).all(...params) as Company[]; } -export function companyById(database: Database, id: number): Company | undefined { - return database.prepare("SELECT * FROM companies WHERE id = ?").get(id) as Company | undefined; -} - -export function companyByCode(database: Database, code: string): Company | undefined { - return database.prepare("SELECT * FROM companies WHERE code = ?").get(normCompanyCode(code)) as +export async function companyById(database: Db, id: number): Promise { + return await database.prepare("SELECT * FROM companies WHERE id = ?").get(id) as | Company | undefined; } -export function principalCompany(database: Database, tenantId?: number | null): Company | undefined { +export async function companyByCode(database: Db, code: string): Promise { + return await database.prepare("SELECT * FROM companies WHERE code = ?").get( + normCompanyCode(code), + ) as Company | undefined; +} + +export async function principalCompany(database: Db, tenantId?: number | null): Promise { if (tenantId != null) { - return database.prepare( + return await database.prepare( "SELECT * FROM companies WHERE kind = 'principal' AND tenant_id = ? ORDER BY id LIMIT 1", ).get(tenantId) as Company | undefined; } - return database.prepare( + return await database.prepare( "SELECT * FROM companies WHERE kind = 'principal' ORDER BY id LIMIT 1", ).get() as Company | undefined; } @@ -112,26 +113,26 @@ export function companyCodeFromName(name: string): string { return slug || "EMP"; } -export function nextCompanyCode(database: Database, name: string): string { +export async function nextCompanyCode(database: Db, name: string): Promise { const base = companyCodeFromName(name); - if (!companyByCode(database, base)) return base; - const row = database.prepare( - `SELECT COALESCE(MAX(CAST(substr(code, 5) AS INTEGER)), 0) + 1 AS n - FROM companies WHERE code GLOB 'EMP-[0-9][0-9][0-9][0-9]*'`, + if (!await companyByCode(database, base)) return base; + const row = await database.prepare( + `SELECT COALESCE(MAX(substring(code from 5)::integer), 0) + 1 AS n + FROM companies WHERE code ~ '^EMP-[0-9]{4}'`, ).get() as { n: number }; return `EMP-${String(row.n).padStart(4, "0")}`; } -export function resolveCompany( - database: Database, +export async function resolveCompany( + database: Db, body: { company_id?: number | null; hire_type?: string | null }, -): Company | undefined { +): Promise { if (body.company_id) { - const byId = companyById(database, Number(body.company_id)); + const byId = await companyById(database, Number(body.company_id)); if (byId) return byId; } const code = normCompanyCode(body.hire_type); - if (code) return companyByCode(database, code); + if (code) return await companyByCode(database, code); return undefined; } @@ -142,7 +143,10 @@ export function validateCompanyCode(code: string): string | null { return null; } -export function validateCompanyProfile(input: CompanyProfileInput, opts: { requireLegal?: boolean } = {}): string | null { +export function validateCompanyProfile( + input: CompanyProfileInput, + opts: { requireLegal?: boolean } = {}, +): string | null { const rfc = normRfc(input.rfc); if (rfc && !RFC_RE.test(rfc)) { return "RFC inválido (formato mexicano de 12 o 13 caracteres)"; @@ -190,68 +194,79 @@ function profileFromInput(input: CompanyProfileInput, fallbackName = "") { return normalizeCompanyProfile(input, fallbackName); } -export function createSubcompany( - database: Database, +export async function createSubcompany( + database: Db, input: CompanyProfileInput, tenantId?: number | null, -): { company?: Company; error?: string } { +): Promise<{ company?: Company; error?: string }> { const profileErr = validateProfile(input, { requireLegal: true }); if (profileErr) return { error: profileErr }; const profile = profileFromInput(input); if (!profile.name) return { error: "Nombre de empresa obligatorio" }; - const principal = principalCompany(database, tenantId); + const principal = await principalCompany(database, tenantId); if (!principal) return { error: "No hay empresa principal" }; const parentId = input.parent_id ? Number(input.parent_id) : principal.id; - const parent = companyById(database, parentId); + const parent = await companyById(database, parentId); if (!parent) return { error: "Empresa padre no encontrada" }; if (tenantId != null && parent.tenant_id != null && parent.tenant_id !== tenantId) { return { error: "Empresa padre de otro tenant" }; } let code = normCompanyCode(input.code); - if (!code) code = nextCompanyCode(database, profile.name); + if (!code) code = await nextCompanyCode(database, profile.name); const codeErr = validateCompanyCode(code); if (codeErr) return { error: codeErr }; - if (companyByCode(database, code)) return { error: "Ya existe una empresa con ese código" }; + if (await companyByCode(database, code)) return { error: "Ya existe una empresa con ese código" }; const tid = tenantId ?? principal.tenant_id ?? null; - database.prepare( - `INSERT INTO companies ( - code, name, parent_id, kind, status, tenant_id, - registro_patronal, razon_social, nombre_comercial, rfc, regimen_fiscal, clase_riesgo, - domicilio_fiscal, codigo_postal, ciudad, estado, telefono, email, representante_legal, giro - ) VALUES (?, ?, ?, 'sub', 'activo', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ).run( - code, - profile.name, - parent.id, - tid, - profile.registro_patronal, - profile.razon_social, - profile.nombre_comercial, - profile.rfc, - profile.regimen_fiscal, - profile.clase_riesgo, - profile.domicilio_fiscal, - profile.codigo_postal, - profile.ciudad, - profile.estado, - profile.telefono, - profile.email, - profile.representante_legal, - profile.giro, - ); - return { company: companyById(database, lastId(database)) }; + try { + await database.prepare( + `INSERT INTO companies ( + code, name, parent_id, kind, status, tenant_id, + registro_patronal, razon_social, nombre_comercial, rfc, regimen_fiscal, clase_riesgo, + domicilio_fiscal, codigo_postal, ciudad, estado, telefono, email, representante_legal, giro + ) VALUES (?, ?, ?, 'sub', 'activo', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run( + code, + profile.name, + parent.id, + tid, + profile.registro_patronal, + profile.razon_social, + profile.nombre_comercial, + profile.rfc, + profile.regimen_fiscal, + profile.clase_riesgo, + profile.domicilio_fiscal, + profile.codigo_postal, + profile.ciudad, + profile.estado, + profile.telefono, + profile.email, + profile.representante_legal, + profile.giro, + ); + } catch (e) { + if (isUniqueViolation(e)) return { error: "Ya existe una empresa con ese código" }; + throw e; + } + return { company: await companyById(database, await database.lastInsertId()) }; } -export function updateCompany( - database: Database, +export async function updateCompany( + database: Db, id: number, input: CompanyProfileInput, -): { company?: Company; error?: string; status?: 400 | 404 } { - const current = companyById(database, id); - if (!current) return { error: "Empresa no encontrada", status: 404 }; + tenantId?: number | null, +): Promise<{ company?: Company; error?: string; status?: 400 | 404 }> { + const current = await companyById(database, id); + // Con RLS activo, una fila de otro tenant ya no aparece aquí (la conexión + // solo ve app.tenant_id); este chequeo explícito es defensa adicional y + // un mensaje de error más claro que un 404 "silencioso". + if (!current || (tenantId != null && current.tenant_id != null && current.tenant_id !== tenantId)) { + return { error: "Empresa no encontrada", status: 404 }; + } const profileErr = validateProfile(input); if (profileErr) return { error: profileErr, status: 400 }; @@ -285,7 +300,7 @@ export function updateCompany( code = normCompanyCode(input.code); const codeErr = validateCompanyCode(code); if (codeErr) return { error: codeErr, status: 400 }; - const clash = companyByCode(database, code); + const clash = await companyByCode(database, code); if (clash && clash.id !== id) return { error: "Ya existe una empresa con ese código", status: 400 }; } @@ -300,35 +315,45 @@ export function updateCompany( status = input.status as Company["status"]; } - database.prepare( - `UPDATE companies SET - name = ?, code = ?, status = ?, - registro_patronal = ?, razon_social = ?, nombre_comercial = ?, rfc = ?, regimen_fiscal = ?, clase_riesgo = ?, - domicilio_fiscal = ?, codigo_postal = ?, ciudad = ?, estado = ?, telefono = ?, email = ?, - representante_legal = ?, giro = ? - WHERE id = ?`, - ).run( - profile.name, - code, - status, - profile.registro_patronal, - profile.razon_social, - profile.nombre_comercial, - profile.rfc, - profile.regimen_fiscal, - profile.clase_riesgo, - profile.domicilio_fiscal, - profile.codigo_postal, - profile.ciudad, - profile.estado, - profile.telefono, - profile.email, - profile.representante_legal, - profile.giro, - id, - ); - if (code !== current.code) { - database.prepare("UPDATE workers SET hire_type = ? WHERE company_id = ?").run(code, id); + try { + await database.prepare( + `UPDATE companies SET + name = ?, code = ?, status = ?, + registro_patronal = ?, razon_social = ?, nombre_comercial = ?, rfc = ?, regimen_fiscal = ?, clase_riesgo = ?, + domicilio_fiscal = ?, codigo_postal = ?, ciudad = ?, estado = ?, telefono = ?, email = ?, + representante_legal = ?, giro = ? + WHERE id = ?`, + ).run( + profile.name, + code, + status, + profile.registro_patronal, + profile.razon_social, + profile.nombre_comercial, + profile.rfc, + profile.regimen_fiscal, + profile.clase_riesgo, + profile.domicilio_fiscal, + profile.codigo_postal, + profile.ciudad, + profile.estado, + profile.telefono, + profile.email, + profile.representante_legal, + profile.giro, + id, + ); + } catch (e) { + if (isUniqueViolation(e)) return { error: "Ya existe una empresa con ese código", status: 400 }; + throw e; } - return { company: companyById(database, id) }; + if (code !== current.code) { + await database.prepare("UPDATE workers SET hire_type = ? WHERE company_id = ?").run(code, id); + } + return { company: await companyById(database, id) }; +} + +/** Postgres error code 23505 = unique_violation. */ +function isUniqueViolation(e: unknown): boolean { + return !!e && typeof e === "object" && (e as { code?: string }).code === "23505"; } diff --git a/api/config.ts b/api/config.ts index 1f21fdd..6f57396 100644 --- a/api/config.ts +++ b/api/config.ts @@ -4,20 +4,39 @@ import { fileURLToPath } from "node:url"; const here = dirname(fileURLToPath(import.meta.url)); export const ROOT = join(here, ".."); export const DATA_DIR = join(ROOT, "data"); -export const DB_PATH = join(DATA_DIR, "app.db"); -export const PLATFORM_DB_PATH = join(DATA_DIR, "platform.db"); -export const EXPEDIENTES_DIR = join(DATA_DIR, "expedientes"); -export const PDFS_DIR = join(DATA_DIR, "pdfs"); -export const LOGOS_DIR = join(DATA_DIR, "logos"); -export const PROJECTS_DIR = join(DATA_DIR, "proyectos"); -export const COMPANIES_DIR = join(DATA_DIR, "empresas"); +// Cache local de archivos descifrados/temporales (no la fuente de verdad -- +// esa vive en Contabo Object Storage, ver api/storage.ts / Fase 4c). +export const TMP_DIR = join(DATA_DIR, "tmp"); + +function required(name: string): string { + const value = Deno.env.get(name); + if (!value) { + throw new Error( + `Falta la variable de entorno ${name}. No hay default inseguro -- ver .env.example.`, + ); + } + return value; +} + +const isDev = (Deno.env.get("DENO_ENV") ?? "development") !== "production"; + +/** SESSION_SECRET/DOCS_KEY nunca tienen default fuera de desarrollo: un + * default hardcodeado en el código es un secreto público. */ +function requiredOrDevFallback(name: string, devFallback: string): string { + const value = Deno.env.get(name); + if (value) return value; + if (isDev) return devFallback; + throw new Error(`Falta la variable de entorno ${name} (obligatoria fuera de desarrollo).`); +} export const config = { port: Number(Deno.env.get("PORT") ?? "8000"), - sessionSecret: Deno.env.get("SESSION_SECRET") ?? "dev-session-secret-change-me", + sessionSecret: requiredOrDevFallback("SESSION_SECRET", "dev-session-secret-change-me"), apiKey: Deno.env.get("API_KEY") ?? "", - docsKeyHex: Deno.env.get("DOCS_KEY") ?? + docsKeyHex: requiredOrDevFallback( + "DOCS_KEY", "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + ), /** Solo vía env. Compose exige SEED_PASSWORD; no hay default en código. */ seedPassword: Deno.env.get("SEED_PASSWORD") ?? "", vcardBase: Deno.env.get("VCARD_BASE") ?? "https://vcard.arctec.com.mx?info=", @@ -32,4 +51,38 @@ export const config = { cookieSecure: ["1", "true", "yes"].includes( (Deno.env.get("COOKIE_SECURE") ?? "").toLowerCase(), ), + + // --- Postgres: panels_platform (control plane SaaS, base separada) --- + databaseUrlPlatform: isDev + ? (Deno.env.get("DATABASE_URL_PLATFORM") ?? "") + : required("DATABASE_URL_PLATFORM"), + // --- Postgres: panels_product, esquema iam (identidad del tenant) --- + databaseUrlIam: isDev + ? (Deno.env.get("DATABASE_URL_IAM") ?? "") + : required("DATABASE_URL_IAM"), + // Credenciales _owner: SOLO para resolver el login por username (ver + // api/iam_db.ts#getAuthPool) -- el dueño de la tabla no está sujeto a + // Row Level Security, necesario porque el username es único globalmente + // y hay que ubicarlo antes de conocer su tenant_id. + databaseUrlIamOwner: Deno.env.get("DATABASE_URL_IAM_OWNER") ?? "", + // --- Postgres: panels_product, esquema core (negocio) --- + databaseUrlCore: isDev + ? (Deno.env.get("DATABASE_URL_CORE") ?? "") + : required("DATABASE_URL_CORE"), + // Credenciales _owner: solo para scripts (bootstrap/ETL) y resoluciones + // internas puntuales que cruzan tenants a propósito (ver api/db.ts#getCoreDb). + databaseUrlCoreOwner: Deno.env.get("DATABASE_URL_CORE_OWNER") ?? "", + + // --- Redis: sesiones (iam:*) y cache (core:*) --- + redisUrlIam: isDev ? (Deno.env.get("REDIS_URL_IAM") ?? "") : required("REDIS_URL_IAM"), + redisUrlCore: isDev ? (Deno.env.get("REDIS_URL_CORE") ?? "") : required("REDIS_URL_CORE"), + + // --- Contabo Object Storage (S3-compatible) -- expedientes/PDFs/logos --- + s3Endpoint: Deno.env.get("S3_ENDPOINT") ?? "", + s3Bucket: Deno.env.get("S3_BUCKET") ?? "", + s3Region: Deno.env.get("S3_REGION") ?? "us-east-1", + s3AccessKeyId: Deno.env.get("S3_ACCESS_KEY_ID") ?? "", + s3SecretAccessKey: Deno.env.get("S3_SECRET_ACCESS_KEY") ?? "", + + isDev, }; diff --git a/api/crypto.ts b/api/crypto.ts index 259dec4..ed5f743 100644 --- a/api/crypto.ts +++ b/api/crypto.ts @@ -25,7 +25,7 @@ export async function hashPassword(password: string): Promise { ["deriveBits"], ); const bits = await crypto.subtle.deriveBits( - { name: "PBKDF2", salt, iterations: 120_000, hash: "SHA-256" }, + { name: "PBKDF2", salt: salt as BufferSource, iterations: 120_000, hash: "SHA-256" }, key, 256, ); @@ -55,7 +55,7 @@ export async function verifyPassword(password: string, stored: string): Promise< ); const bits = new Uint8Array( await crypto.subtle.deriveBits( - { name: "PBKDF2", salt, iterations: 120_000, hash: "SHA-256" }, + { name: "PBKDF2", salt: salt as BufferSource, iterations: 120_000, hash: "SHA-256" }, key, 256, ), @@ -66,44 +66,8 @@ export async function verifyPassword(password: string, stored: string): Promise< return diff === 0; } -export async function hmacSign(secret: string, payload: string): Promise { - const key = await crypto.subtle.importKey( - "raw", - encoder.encode(secret), - { name: "HMAC", hash: "SHA-256" }, - false, - ["sign"], - ); - const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(payload)); - return btoa(String.fromCharCode(...new Uint8Array(sig))) - .replaceAll("+", "-") - .replaceAll("/", "_") - .replaceAll("=", ""); -} - -export async function hmacVerify(secret: string, payload: string, sig: string): Promise { - const expected = await hmacSign(secret, payload); - if (expected.length !== sig.length) return false; - let diff = 0; - for (let i = 0; i < expected.length; i++) { - diff |= expected.charCodeAt(i) ^ sig.charCodeAt(i); - } - return diff === 0; -} - -export function b64urlJson(obj: unknown): string { - const json = JSON.stringify(obj); - return btoa(json).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", ""); -} - -export function b64urlJsonParse(s: string): T { - const pad = s.replaceAll("-", "+").replaceAll("_", "/"); - const padded = pad + "=".repeat((4 - (pad.length % 4)) % 4); - return JSON.parse(atob(padded)) as T; -} - export async function sha256Hex(data: Uint8Array): Promise { - return toHex(await crypto.subtle.digest("SHA-256", data)); + return toHex(await crypto.subtle.digest("SHA-256", data as BufferSource)); } export { toHex, fromHex, encoder, decoder }; diff --git a/api/db.ts b/api/db.ts index 0794527..51cf83a 100644 --- a/api/db.ts +++ b/api/db.ts @@ -1,93 +1,64 @@ -import { Database } from "@db/sqlite"; -import { mkdir } from "node:fs/promises"; -import { join } from "node:path"; -import { DATA_DIR, DB_PATH, EXPEDIENTES_DIR, PDFS_DIR, LOGOS_DIR, PROJECTS_DIR, COMPANIES_DIR } from "./config.ts"; +import postgres, { createPool, PgDb, withTenant } from "./pg.ts"; import { config } from "./config.ts"; -import { hashPassword } from "./crypto.ts"; -import { DOCUMENT_TYPE_SEED, PROJECT_DOCUMENT_TYPE_SEED, COMPANY_DOCUMENT_TYPE_SEED, evaluateDocumentValidity, freshnessRequired, type ImssStatus, type WorkerImssContext } from "./document_validity.ts"; -import { runLiquibase } from "./liquibase.ts"; +import { evaluateDocumentValidity, freshnessRequired, type ImssStatus, type WorkerImssContext } from "./document_validity.ts"; -export type Db = Database; +export type Db = PgDb; -let db: Database | null = null; +/** + * 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. + */ -export async function getDb(): Promise { - if (db) return db; - await mkdir(DATA_DIR, { recursive: true }); - await mkdir(EXPEDIENTES_DIR, { recursive: true }); - await mkdir(PDFS_DIR, { recursive: true }); - await mkdir(LOGOS_DIR, { recursive: true }); - await mkdir(PROJECTS_DIR, { recursive: true }); - await mkdir(COMPANIES_DIR, { recursive: true }); - await runLiquibase(); - db = new Database(DB_PATH); - db.exec("PRAGMA foreign_keys = ON;"); - await seed(db); - return db; -} +let corePool: postgres.Sql | null = null; +let coreOwnerPool: postgres.Sql | null = null; -export function seedDocumentTypes(database: Database) { - const upsert = database.prepare( - `INSERT INTO document_types - (code, label, required, validity_mode, freshness_days, requires_issued_at, requires_expires_at, category) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(code) DO UPDATE SET - label = excluded.label, - required = excluded.required, - validity_mode = excluded.validity_mode, - freshness_days = excluded.freshness_days, - requires_issued_at = excluded.requires_issued_at, - requires_expires_at = excluded.requires_expires_at, - category = excluded.category`, - ); - for (const d of DOCUMENT_TYPE_SEED) { - upsert.run( - d.code, - d.label, - d.required, - d.validity_mode, - d.freshness_days, - d.requires_issued_at, - d.requires_expires_at, - d.category, - ); +function getCorePool(): postgres.Sql { + if (!corePool) { + corePool = createPool(config.databaseUrlCore, { max: 20 }); } + return corePool; } -export function seedProjectDocumentTypes(database: Database) { - const upsert = database.prepare( - `INSERT INTO project_document_types (code, label, required, category) - VALUES (?, ?, ?, ?) - ON CONFLICT(code) DO UPDATE SET - label = excluded.label, - required = excluded.required, - category = excluded.category`, - ); - for (const d of PROJECT_DOCUMENT_TYPE_SEED) { - upsert.run(d.code, d.label, d.required, d.category); +function getCoreOwnerPool(): postgres.Sql { + if (!coreOwnerPool) { + coreOwnerPool = createPool(config.databaseUrlCoreOwner || config.databaseUrlCore, { max: 3 }); } + return coreOwnerPool; } -export function seedCompanyDocumentTypes(database: Database) { - const upsert = database.prepare( - `INSERT INTO company_document_types (code, label, required, category) - VALUES (?, ?, ?, ?) - ON CONFLICT(code) DO UPDATE SET - label = excluded.label, - required = excluded.required, - category = excluded.category`, - ); - for (const d of COMPANY_DOCUMENT_TYPE_SEED) { - upsert.run(d.code, d.label, d.required, d.category); - } +export async function pingCoreDb(): Promise { + await getCorePool()`SELECT 1`; } -export function nextProjectCode(database: Database): string { - const row = database.prepare( - `SELECT COALESCE(MAX(CAST(substr(code, 5) AS INTEGER)), 0) + 1 AS n - FROM projects WHERE code GLOB 'PRY-[0-9][0-9][0-9][0-9]*' OR code GLOB 'OBR-[0-9][0-9][0-9][0-9]*'`, - ).get() as { n: number }; - return `PRY-${String(row.n).padStart(4, "0")}`; +/** 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; @@ -104,8 +75,16 @@ export function isProjectStatus(value: string): value is ProjectStatus { return (PROJECT_STATUSES as readonly string[]).includes(value); } -export function projectById(database: Database, id: number) { - return database.prepare("SELECT * FROM projects WHERE id = ?").get(id) as +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; } @@ -122,103 +101,17 @@ export function projectMustBe( return null; } -async function seed(database: Database) { - const risks = [ - ["rojo", "Rojo", "#A20000", "#FFFFFF"], - ["amarillo", "Amarillo", "#EEEE3C", "#000000"], - ["azul", "Azul", "#001485", "#FFFFFF"], - ["verde", "Verde", "#008514", "#FFFFFF"], - ["negro", "Negro", "#000000", "#FFFFFF"], - ["naranja", "Naranja", "#FF5733", "#FFFFFF"], - ]; - const insRisk = database.prepare( - "INSERT OR IGNORE INTO risk_levels (code, label, color, text_color) VALUES (?, ?, ?, ?)", - ); - for (const r of risks) insRisk.run(...r); - - database.prepare( - "INSERT OR IGNORE INTO badge_themes (id, name, layout) VALUES (?, ?, ?)", - ).run( - "arctec-dos-logos-fold", - "Arctec dos logos (doblez carta)", - "letter-landscape-4-fold", - ); - - seedDocumentTypes(database); - seedProjectDocumentTypes(database); - seedCompanyDocumentTypes(database); - - const defaultTenantId = 1; - let principal = database.prepare( - "SELECT id FROM companies WHERE kind = 'principal' AND tenant_id = ? ORDER BY id LIMIT 1", - ).get(defaultTenantId) as { id: number } | undefined; - if (!principal) { - database.prepare( - `INSERT INTO companies (code, name, parent_id, kind, tenant_id, nombre_comercial, razon_social) - VALUES ('ARCT2608', 'ARCTEC', NULL, 'principal', ?, 'ARCTEC', 'ARCTEC')`, - ).run(defaultTenantId); - principal = database.prepare( - "SELECT id FROM companies WHERE code = 'ARCT2608'", - ).get() as { id: number }; - } - for (const [code, name] of [["FISICA", "FISICA"], ["ARCOTEC", "ARCOTEC"]] as const) { - const exists = database.prepare("SELECT id FROM companies WHERE code = ?").get(code); - if (!exists) { - database.prepare( - `INSERT INTO companies (code, name, parent_id, kind, tenant_id, nombre_comercial, razon_social) - VALUES (?, ?, ?, 'sub', ?, ?, ?)`, - ).run(code, name, principal.id, defaultTenantId, name, name); - } - } - - const users = [ - ["arct2608", "Administrador"], - ]; - // Migración suave de usuarios legacy → código puro - database.prepare( - "UPDATE users SET username = 'arct2608' WHERE username IN ('papa', 'adm.arctec') AND NOT EXISTS (SELECT 1 FROM users WHERE username = 'arct2608')", - ).run(); - for (const [username, display] of users) { - const exists = database.prepare("SELECT id FROM users WHERE username = ?").get(username); - if (!exists) { - const hash = await hashPassword(config.seedPassword); - database.prepare( - `INSERT INTO users (username, password_hash, display_name, company_id, tenant_id, role) - VALUES (?, ?, ?, ?, ?, 'tenant_admin')`, - ).run(username, hash, display, principal.id, defaultTenantId); - } - } - database.prepare( - "UPDATE users SET company_id = ?, tenant_id = COALESCE(tenant_id, ?), role = COALESCE(NULLIF(role, ''), 'tenant_admin') WHERE company_id IS NULL OR tenant_id IS NULL", - ).run(principal.id, defaultTenantId); - - const proj = database.prepare("SELECT id FROM projects LIMIT 1").get(); - if (!proj) { - database.prepare( - `INSERT INTO projects (code, name, address, theme_id, company_id, tenant_id) - VALUES (?, ?, ?, ?, ?, ?)`, - ).run( - nextProjectCode(database), - "ZENDALA CANCUN", - "", - "arctec-dos-logos-fold", - principal.id, - defaultTenantId, - ); - } -} - -export function workerImssContext(database: Database, workerId: number): WorkerImssContext { - const w = database.prepare( +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 = database.prepare( + const altaDoc = await database.prepare( `SELECT imss_alta_at, uploaded_at FROM documents - WHERE worker_id = ? AND type_code = 'alta_imss' AND is_current = 1 + 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 { @@ -228,25 +121,30 @@ export function workerImssContext(database: Database, workerId: number): WorkerI }; } -/** Checklist con vigencia; contexto IMSS vía imssFlagsFor / checklistItemsFor. */ -export function checklistItemsFor(database: Database, workerId: number) { - const types = database.prepare( +/** 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: number; + required: boolean; validity_mode: string; freshness_days: number | null; - requires_issued_at: number; - requires_expires_at: number; + requires_issued_at: boolean; + requires_expires_at: boolean; category: string; }[]; - const currentDocs = database.prepare( + 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 = 1`, + FROM documents WHERE worker_id = ? AND is_current = true`, ).all(workerId) as { type_code: string; issued_at: string | null; @@ -255,7 +153,7 @@ export function checklistItemsFor(database: Database, workerId: number) { imss_alta_at: string | null; }[]; const byType = new Map(currentDocs.map((d) => [d.type_code, d])); - const ctx = workerImssContext(database, workerId); + const ctx = await workerImssContext(database, workerId); return { ctx, @@ -270,7 +168,7 @@ export function checklistItemsFor(database: Database, workerId: number) { requires_issued_at: !!t.requires_issued_at, requires_expires_at: !!t.requires_expires_at, }; - const evaled = evaluateDocumentValidity(policy, byType.get(t.code), ctx); + const evaled = evaluateDocumentValidity(policy, byType.get(t.code), ctx, today); return { code: t.code, label: t.label, @@ -290,11 +188,11 @@ export function checklistItemsFor(database: Database, workerId: number) { }; } -export function imssFlagsFor(database: Database, workerId: number) { - const { ctx, freshness_required: needFresh, items } = checklistItemsFor(database, workerId); +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 = !!database.prepare( - "SELECT 1 FROM assignments WHERE worker_id = ? AND active = 1 LIMIT 1", + 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 { @@ -306,62 +204,45 @@ export function imssFlagsFor(database: Database, workerId: number) { }; } -export function checklistFor(database: Database, workerId: number) { - return checklistItemsFor(database, workerId).items; +export async function checklistFor(database: Db, workerId: number, tenantId: number | null = null) { + return (await checklistItemsFor(database, workerId, tenantId)).items; } -export function refreshPipeline(database: Database, workerId: number) { - const w = database.prepare("SELECT status FROM workers WHERE id = ?").get(workerId) as +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") { - database.prepare("UPDATE workers SET pipeline_status = 'baja' WHERE id = ?").run(workerId); + await database.prepare("UPDATE workers SET pipeline_status = 'baja' WHERE id = ?").run(workerId); return; } - const items = checklistFor(database, workerId); + 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 = database.prepare( + 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 = database.prepare( - "SELECT 1 FROM assignments WHERE worker_id = ? AND active = 1 LIMIT 1", + 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"; } - database.prepare("UPDATE workers SET pipeline_status = ? WHERE id = ?").run(pipeline, workerId); + await database.prepare("UPDATE workers SET pipeline_status = ? WHERE id = ?").run(pipeline, workerId); } -export function lastInsertId(database: Database): number { - const row = database.prepare("SELECT last_insert_rowid() AS id").get() as { id: number }; - return Number(row.id); -} - -export function workerDir(workerId: number): string { - return join(EXPEDIENTES_DIR, String(workerId)); -} - -export function projectDir(projectId: number): string { - return join(PROJECTS_DIR, String(projectId)); -} - -export function companyDir(companyId: number): string { - return join(COMPANIES_DIR, String(companyId)); -} - -export function projectChecklistFor(database: Database, projectId: number) { - const types = database.prepare( +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: number; category: string }[]; - const current = database.prepare( - `SELECT type_code FROM project_documents WHERE project_id = ? AND is_current = 1`, + ).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) => ({ @@ -373,12 +254,12 @@ export function projectChecklistFor(database: Database, projectId: number) { })); } -export function companyChecklistFor(database: Database, companyId: number) { - const types = database.prepare( +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: number; category: string }[]; - const current = database.prepare( - `SELECT type_code FROM company_documents WHERE company_id = ? AND is_current = 1`, + ).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) => ({ @@ -389,3 +270,21 @@ export function companyChecklistFor(database: Database, companyId: number) { 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 }); +} diff --git a/api/deno.json b/api/deno.json index 6653b44..0dd5e86 100644 --- a/api/deno.json +++ b/api/deno.json @@ -2,14 +2,13 @@ "name": "panel-obra-api", "exports": "./main.ts", "tasks": { - "dev": "deno run --allow-net --allow-read --allow-write --allow-env --allow-ffi --allow-run --env-file=../.env main.ts", - "start": "deno run --allow-net --allow-read --allow-write --allow-env --allow-ffi --allow-run --env-file=../.env main.ts", + "dev": "deno run --allow-net --allow-read --allow-write --allow-env --env-file=../.env main.ts", + "start": "deno run --allow-net --allow-read --allow-write --allow-env main.ts", "migrate": "cd .. && ./db/update.sh all", "check": "deno check main.ts", - "test": "deno test --allow-read --allow-write --allow-env --allow-ffi --allow-run" + "test": "deno test --allow-net --allow-read --allow-write --allow-env" }, "imports": { - "@db/sqlite": "jsr:@db/sqlite@0.12", "hono": "jsr:@hono/hono@4", "xlsx": "npm:xlsx@0.18.5", "pdf-lib": "npm:pdf-lib@1.17.1", diff --git a/api/deno.lock b/api/deno.lock index 8c445f0..fef68da 100644 --- a/api/deno.lock +++ b/api/deno.lock @@ -1,84 +1,233 @@ { "version": "5", "specifiers": { - "jsr:@db/sqlite@0.12": "0.12.0", - "jsr:@denosaurs/plug@1": "1.1.0", "jsr:@hono/hono@4": "4.13.2", - "jsr:@std/assert@0.217": "0.217.0", "jsr:@std/assert@1": "1.0.19", - "jsr:@std/encoding@1": "1.0.11", - "jsr:@std/fmt@1": "1.0.10", - "jsr:@std/fs@1": "1.0.24", "jsr:@std/internal@^1.0.12": "1.0.14", - "jsr:@std/internal@^1.0.14": "1.0.14", - "jsr:@std/path@0.217": "0.217.0", - "jsr:@std/path@1": "1.1.6", - "jsr:@std/path@^1.1.5": "1.1.6", + "npm:@aws-sdk/client-s3@3": "3.1124.0", "npm:@types/node@*": "22.15.15", "npm:nodemailer@6.9.16": "6.9.16", "npm:pdf-lib@1.17.1": "1.17.1", + "npm:postgres@3": "3.4.9", "npm:qrcode@1.5.4": "1.5.4", + "npm:redis@4": "4.7.1", "npm:xlsx@0.18.5": "0.18.5" }, "jsr": { - "@db/sqlite@0.12.0": { - "integrity": "dd1ef7f621ad50fc1e073a1c3609c4470bd51edc0994139c5bf9851de7a6d85f", - "dependencies": [ - "jsr:@denosaurs/plug", - "jsr:@std/path@0.217" - ] - }, - "@denosaurs/plug@1.1.0": { - "integrity": "eb2f0b7546c7bca2000d8b0282c54d50d91cf6d75cb26a80df25a6de8c4bc044", - "dependencies": [ - "jsr:@std/encoding", - "jsr:@std/fmt", - "jsr:@std/fs", - "jsr:@std/path@1" - ] - }, "@hono/hono@4.13.2": { "integrity": "715d8cc1b6b5d6b9e6a7519059778d775b2d79fffdc026ac0ccdd9971e75809c" }, - "@std/assert@0.217.0": { - "integrity": "c98e279362ca6982d5285c3b89517b757c1e3477ee9f14eb2fdf80a45aaa9642" - }, "@std/assert@1.0.19": { "integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e", "dependencies": [ - "jsr:@std/internal@^1.0.12" - ] - }, - "@std/encoding@1.0.11": { - "integrity": "e7cef2f0b3153bccc17431e7c864a1de03a4b6d9647389c43e08aaa775d37385" - }, - "@std/fmt@1.0.10": { - "integrity": "90dfba288802ac6de82fb31d0917eb9e4450b9925b954d5e51fc29ac07419db5" - }, - "@std/fs@1.0.24": { - "integrity": "f3061b45b81673a2bece689da041df32d174be064c89eb6397fb5718d3fb7877", - "dependencies": [ - "jsr:@std/internal@^1.0.14", - "jsr:@std/path@^1.1.5" + "jsr:@std/internal" ] }, "@std/internal@1.0.14": { "integrity": "291516b3d4c35024d6ffbc0a9df5bf4c64116e05b50012cf846710152d2ffdf7" - }, - "@std/path@0.217.0": { - "integrity": "1217cc25534bca9a2f672d7fe7c6f356e4027df400c0e85c0ef3e4343bc67d11", - "dependencies": [ - "jsr:@std/assert@0.217" - ] - }, - "@std/path@1.1.6": { - "integrity": "c68485c2a4dfbb5ae3cc74fae4e8c4e5d874cf8a8ed12927917235c758b46cbe", - "dependencies": [ - "jsr:@std/internal@^1.0.14" - ] } }, "npm": { + "@aws-sdk/checksums@3.1000.29": { + "integrity": "sha512-Dtu0gr4dnATZAPwEYbpCsG+MpLM7OAliy2gTepEFQwl1vZ6DL3QMH2FveMa3HLvPsOdhJsPRB3KtxVhph9T75A==", + "dependencies": [ + "@aws-sdk/core", + "@aws-sdk/types", + "@smithy/core", + "@smithy/types", + "tslib@2.8.1" + ] + }, + "@aws-sdk/client-s3@3.1124.0": { + "integrity": "sha512-f20BksgVlXufcf/mijde1LAjwM/iSDdG3mGtN3yRFOUzXtlFEOjxNi1Rf2Kb+rSgoiOSpO0hUOna9Bs+J1gX2A==", + "dependencies": [ + "@aws-sdk/checksums", + "@aws-sdk/core", + "@aws-sdk/credential-provider-node", + "@aws-sdk/middleware-sdk-s3", + "@aws-sdk/signature-v4-multi-region", + "@aws-sdk/types", + "@smithy/core", + "@smithy/fetch-http-handler", + "@smithy/node-http-handler", + "@smithy/types", + "tslib@2.8.1" + ] + }, + "@aws-sdk/core@3.977.9": { + "integrity": "sha512-reqPFEQrZxDZpeGj4PFMepBeR5LGYHRqq/L0motTzgFkCRBA4rFdaVXDSLYyGHhxVz7sT2PDnPN9CluGSfgyJA==", + "dependencies": [ + "@aws-sdk/types", + "@aws-sdk/xml-builder", + "@aws/lambda-invoke-store", + "@smithy/core", + "@smithy/signature-v4", + "@smithy/types", + "bowser", + "tslib@2.8.1" + ] + }, + "@aws-sdk/credential-provider-env@3.972.70": { + "integrity": "sha512-H404B7dJl2mCrBqahDEYsanB0xhdDp6tXnXcTUnXmmpy2Q3J0Ho0bUajZ2jr/RdwzCyS59Gi8xXIFwPLGBl6Uw==", + "dependencies": [ + "@aws-sdk/core", + "@aws-sdk/types", + "@smithy/core", + "@smithy/types", + "tslib@2.8.1" + ] + }, + "@aws-sdk/credential-provider-http@3.972.72": { + "integrity": "sha512-X98zYOrVOeuosCX+6ktf29FC2N2GHPLia7qv6mzPzTc+RPAuHWCDS++Z6JK7eGYqb/v6uaW7bAXaOvDBfol+0w==", + "dependencies": [ + "@aws-sdk/core", + "@aws-sdk/types", + "@smithy/core", + "@smithy/fetch-http-handler", + "@smithy/node-http-handler", + "@smithy/types", + "tslib@2.8.1" + ] + }, + "@aws-sdk/credential-provider-ini@3.973.15": { + "integrity": "sha512-Rykg6s5ceBuynMOGWgoowO4N+27JfnqXAnVaSunZl0hOO1XodSrxGNz6sCEbnmS0lAfQZDKyb3fbr46gSuv6Sg==", + "dependencies": [ + "@aws-sdk/core", + "@aws-sdk/credential-provider-env", + "@aws-sdk/credential-provider-http", + "@aws-sdk/credential-provider-login", + "@aws-sdk/credential-provider-process", + "@aws-sdk/credential-provider-sso", + "@aws-sdk/credential-provider-web-identity", + "@aws-sdk/nested-clients", + "@aws-sdk/types", + "@smithy/core", + "@smithy/credential-provider-imds", + "@smithy/types", + "tslib@2.8.1" + ] + }, + "@aws-sdk/credential-provider-login@3.972.77": { + "integrity": "sha512-Jb59xfEISoN5mmbnA+HYqdtrSX3CgCtJoof+V5D8/TgUI56W63GEEd5Y58WijU3Ou6+WEgaLD1feVzaRXV5IDQ==", + "dependencies": [ + "@aws-sdk/core", + "@aws-sdk/nested-clients", + "@aws-sdk/types", + "@smithy/core", + "@smithy/types", + "tslib@2.8.1" + ] + }, + "@aws-sdk/credential-provider-node@3.972.82": { + "integrity": "sha512-znDkEOGXB8W3kG1LJUKP3foBZY/9qLM0eil/DxWXSp37XsdsRLQHE/d/OaCGGVgKpA6znR38h/+INk8do1FjiA==", + "dependencies": [ + "@aws-sdk/credential-provider-env", + "@aws-sdk/credential-provider-http", + "@aws-sdk/credential-provider-ini", + "@aws-sdk/credential-provider-process", + "@aws-sdk/credential-provider-sso", + "@aws-sdk/credential-provider-web-identity", + "@aws-sdk/types", + "@smithy/core", + "@smithy/credential-provider-imds", + "@smithy/types", + "tslib@2.8.1" + ] + }, + "@aws-sdk/credential-provider-process@3.972.70": { + "integrity": "sha512-2ry03fGRJr4sV3jI+ocjj5JqALnFD6ymM5KiNCDZMvq8bX2GSbE0vji4aM43TVCl2nXqqLRZaUxdq/KeWRAY4Q==", + "dependencies": [ + "@aws-sdk/core", + "@aws-sdk/types", + "@smithy/core", + "@smithy/types", + "tslib@2.8.1" + ] + }, + "@aws-sdk/credential-provider-sso@3.973.14": { + "integrity": "sha512-jkhg/8ocAAoc0RFyLMhCw+/zZh7gystQgd4F4hznNa8P4Cc501PQmxd+jGLiMHodPJ+7Zv/3znM62gZojyasmA==", + "dependencies": [ + "@aws-sdk/core", + "@aws-sdk/nested-clients", + "@aws-sdk/token-providers", + "@aws-sdk/types", + "@smithy/core", + "@smithy/types", + "tslib@2.8.1" + ] + }, + "@aws-sdk/credential-provider-web-identity@3.972.76": { + "integrity": "sha512-d3AGyVu759PGr35mEB2s22xxlNEA5rpdxtSPJthfPFJvoQ8dt357iVPECqWfUxXp1toJAvKmbtcIYVGigaGsCA==", + "dependencies": [ + "@aws-sdk/core", + "@aws-sdk/nested-clients", + "@aws-sdk/types", + "@smithy/core", + "@smithy/types", + "tslib@2.8.1" + ] + }, + "@aws-sdk/middleware-sdk-s3@3.972.75": { + "integrity": "sha512-wMIsNumRVKaNMKhvU/s9VrdEwE8S6gSzXp4RygFG5BEMnGkkXf8cjh8zf7cKJBpUDpqTWqwbz5isEgp9rH6Lng==", + "dependencies": [ + "@aws-sdk/core", + "@aws-sdk/signature-v4-multi-region", + "@aws-sdk/types", + "@smithy/core", + "@smithy/types", + "tslib@2.8.1" + ] + }, + "@aws-sdk/nested-clients@3.997.44": { + "integrity": "sha512-NhEgryjlBF9w38ZXqGymQV28IhkYa1mKhlbYnqIis57AYwWGVYfUPgg/qC2rLRqOUfblxx++irvju10kVTa8Vw==", + "dependencies": [ + "@aws-sdk/core", + "@aws-sdk/signature-v4-multi-region", + "@aws-sdk/types", + "@smithy/core", + "@smithy/fetch-http-handler", + "@smithy/node-http-handler", + "@smithy/types", + "tslib@2.8.1" + ] + }, + "@aws-sdk/signature-v4-multi-region@3.996.46": { + "integrity": "sha512-L+2xZTye/2T96f3lwCws0Zw6GG2JHZW9e8FpVgGBeeExSKyeoZ6CWRpBml/7DNiK/O26jrgPM9F+Ay8VkgzUWQ==", + "dependencies": [ + "@aws-sdk/types", + "@smithy/signature-v4", + "@smithy/types", + "tslib@2.8.1" + ] + }, + "@aws-sdk/token-providers@3.1116.0": { + "integrity": "sha512-ygIivKqh8aHzNkucOCXHyIBgBpLPfrSI0mCqXF+vLBsPTUKqj0VSqAY0GFPe7lQl4HntjOcQ+KSyS7oUV2C54Q==", + "dependencies": [ + "@aws-sdk/core", + "@aws-sdk/nested-clients", + "@aws-sdk/types", + "@smithy/core", + "@smithy/types", + "tslib@2.8.1" + ] + }, + "@aws-sdk/types@3.974.5": { + "integrity": "sha512-LkwLL2BLbC6wNNm4JaH9mbEqBMdOZCct6VAYqhdN4U1xrWM+fUJQEfbHwQgDypapOWTRtlk25akb5afM0P8CIQ==", + "dependencies": [ + "@smithy/types", + "tslib@2.8.1" + ] + }, + "@aws-sdk/xml-builder@3.972.40": { + "integrity": "sha512-wlFmCIGUlwF4zx/kncw+bmxTQh1HeSJq4mYV/V5cZUSJadDP3kXvGW8Rn21cimj/7y9ju+47oYWXi97vF7czaA==", + "dependencies": [ + "@smithy/types", + "tslib@2.8.1" + ] + }, + "@aws/lambda-invoke-store@0.3.0": { + "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==" + }, "@pdf-lib/standard-fonts@1.0.0": { "integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==", "dependencies": [ @@ -91,6 +240,89 @@ "pako" ] }, + "@redis/bloom@1.2.0_@redis+client@1.6.1": { + "integrity": "sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==", + "dependencies": [ + "@redis/client" + ] + }, + "@redis/client@1.6.1": { + "integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==", + "dependencies": [ + "cluster-key-slot", + "generic-pool", + "yallist" + ] + }, + "@redis/graph@1.1.1_@redis+client@1.6.1": { + "integrity": "sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==", + "dependencies": [ + "@redis/client" + ] + }, + "@redis/json@1.0.7_@redis+client@1.6.1": { + "integrity": "sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ==", + "dependencies": [ + "@redis/client" + ] + }, + "@redis/search@1.2.0_@redis+client@1.6.1": { + "integrity": "sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw==", + "dependencies": [ + "@redis/client" + ] + }, + "@redis/time-series@1.1.0_@redis+client@1.6.1": { + "integrity": "sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==", + "dependencies": [ + "@redis/client" + ] + }, + "@smithy/core@3.33.3": { + "integrity": "sha512-CsOeKq/9kA3y6VJHt+/+VTCtBaxJ4OTFpgrjIUhPpDIKxBci1k2bJaQASF2h/ELWrulGp+t97DZ0mevfAD8idg==", + "dependencies": [ + "@smithy/types", + "tslib@2.8.1" + ] + }, + "@smithy/credential-provider-imds@4.5.2": { + "integrity": "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==", + "dependencies": [ + "@smithy/core", + "@smithy/types", + "tslib@2.8.1" + ] + }, + "@smithy/fetch-http-handler@5.7.2": { + "integrity": "sha512-nZyWTmSpJEXl6VtWVMBJve/7x12DZu6sIX1z1a+ZMaHlQQRs9Zpu6NbTe/gmxYXVRpkjxyDYpZ5gx2IM6f/Wkw==", + "dependencies": [ + "@smithy/core", + "@smithy/types", + "tslib@2.8.1" + ] + }, + "@smithy/node-http-handler@4.12.0": { + "integrity": "sha512-0mq1pHadfyXCYCqm2cNpbjNIT+fbaUpNxewZb/YNr2L0IrEVMOb8gM/Fl4K6XvHCW3uSNDFwPl/+iKm0bx9jYg==", + "dependencies": [ + "@smithy/core", + "@smithy/types", + "tslib@2.8.1" + ] + }, + "@smithy/signature-v4@5.7.3": { + "integrity": "sha512-7ImGm+FkHRLcBaRttIAMZ6bzJZWb2cJGoYjq46F2UjycujWzrL9GEN9h4w7eQyXJYnltrUhxbbieBAIRrdqpow==", + "dependencies": [ + "@smithy/core", + "@smithy/types", + "tslib@2.8.1" + ] + }, + "@smithy/types@4.17.2": { + "integrity": "sha512-FOKpVZob9MPTn2znRzGrnsMHv7BOsKVw3XiP/cOyYLDVZ9qKp4nifIiSCuUU/fIj5Vu0UOAxCFr+qRAtG0NUkA==", + "dependencies": [ + "tslib@2.8.1" + ] + }, "@types/node@22.15.15": { "integrity": "sha512-R5muMcZob3/Jjchn5LcO8jdKwSCbzqmPB6ruBxMcf9kbxtniZHP327s6C37iOfuw8mbKK3cAQa7sEl7afLrQ8A==", "dependencies": [ @@ -109,6 +341,9 @@ "color-convert" ] }, + "bowser@2.14.1": { + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==" + }, "camelcase@5.3.1": { "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==" }, @@ -127,6 +362,9 @@ "wrap-ansi" ] }, + "cluster-key-slot@1.1.2": { + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==" + }, "codepage@1.15.0": { "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==" }, @@ -162,6 +400,9 @@ "frac@1.1.2": { "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==" }, + "generic-pool@3.9.0": { + "integrity": "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==" + }, "get-caller-file@2.0.5": { "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==" }, @@ -204,12 +445,15 @@ "@pdf-lib/standard-fonts", "@pdf-lib/upng", "pako", - "tslib" + "tslib@1.14.1" ] }, "pngjs@5.0.0": { "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==" }, + "postgres@3.4.9": { + "integrity": "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==" + }, "qrcode@1.5.4": { "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", "dependencies": [ @@ -219,6 +463,17 @@ ], "bin": true }, + "redis@4.7.1": { + "integrity": "sha512-S1bJDnqLftzHXHP8JsT5II/CtHWQrASX5K96REjWjlmWKrviSOLWmM7QnRLstAWsu1VBBV1ffV6DzCvxNP0UJQ==", + "dependencies": [ + "@redis/bloom", + "@redis/client", + "@redis/graph", + "@redis/json", + "@redis/search", + "@redis/time-series" + ] + }, "require-directory@2.1.1": { "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==" }, @@ -251,6 +506,9 @@ "tslib@1.14.1": { "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" }, + "tslib@2.8.1": { + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + }, "undici-types@6.21.0": { "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==" }, @@ -287,6 +545,9 @@ "y18n@4.0.3": { "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==" }, + "yallist@4.0.0": { + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + }, "yargs-parser@18.1.3": { "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", "dependencies": [ @@ -348,7 +609,6 @@ }, "workspace": { "dependencies": [ - "jsr:@db/sqlite@0.12", "jsr:@hono/hono@4", "npm:pdf-lib@1.17.1", "npm:qrcode@1.5.4", diff --git a/api/docs_crypto.ts b/api/docs_crypto.ts index 349549c..e4a1e28 100644 --- a/api/docs_crypto.ts +++ b/api/docs_crypto.ts @@ -6,14 +6,14 @@ async function docsKey(): Promise { if (raw.length !== 32) { throw new Error("DOCS_KEY must be 64 hex chars (32 bytes)"); } - return await crypto.subtle.importKey("raw", raw, "AES-GCM", false, ["encrypt", "decrypt"]); + return await crypto.subtle.importKey("raw", raw as BufferSource, "AES-GCM", false, ["encrypt", "decrypt"]); } export async function encryptBytes(plain: Uint8Array): Promise<{ iv: string; cipher: Uint8Array }> { const key = await docsKey(); const iv = crypto.getRandomValues(new Uint8Array(12)); const cipher = new Uint8Array( - await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, plain), + await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, plain as BufferSource), ); return { iv: toHex(iv), cipher }; } @@ -22,6 +22,6 @@ export async function decryptBytes(ivHex: string, cipher: Uint8Array): Promise days) { + const todayForFreshness = parseDay(today) ?? new Date(); + if (issued && daysBetween(issued, todayForFreshness) > days) { return { present: true, valid: false, diff --git a/api/document_validity_test.ts b/api/document_validity_test.ts index 1010730..216bcff 100644 --- a/api/document_validity_test.ts +++ b/api/document_validity_test.ts @@ -79,7 +79,7 @@ Deno.test("CURP stale when freshness required", () => { curpPolicy, { type_code: "curp", issued_at: "2025-01-01", expires_at: null, uploaded_at: "2025-01-02" }, ctx, - new Date("2026-08-20T12:00:00"), + "2026-08-20", ); assertEquals(result.validity_status, "stale_for_alta"); assertEquals(result.valid, false); @@ -95,7 +95,7 @@ Deno.test("CURP old but ok when already alta", () => { curpPolicy, { type_code: "curp", issued_at: "2025-01-01", expires_at: null, uploaded_at: "2025-01-02" }, ctx, - new Date("2026-08-20T12:00:00"), + "2026-08-20", ); assertEquals(result.validity_status, "ok"); assertEquals(result.valid, true); @@ -111,7 +111,7 @@ Deno.test("INE expired always invalid", () => { inePolicy, { type_code: "ine", issued_at: null, expires_at: "2025-12-31", uploaded_at: "2025-01-01" }, ctx, - new Date("2026-08-20T12:00:00"), + "2026-08-20", ); assertEquals(result.validity_status, "expired"); assertEquals(result.valid, false); diff --git a/api/excel.ts b/api/excel.ts index 1dbec39..a2639ed 100644 --- a/api/excel.ts +++ b/api/excel.ts @@ -1,12 +1,11 @@ import * as XLSX from "xlsx"; -import type { Database } from "@db/sqlite"; -import { mkdir } from "node:fs/promises"; -import { join } from "node:path"; +import type { Db } from "./db.ts"; import { encryptBytes } from "./docs_crypto.ts"; import { sha256Hex } from "./crypto.ts"; import { canonicalRiskCode, normalizeWorker, validateWorkerFields, formatNss, normUpper, type WorkerInput } from "./mx.ts"; -import { refreshPipeline, workerDir, lastInsertId, projectDir, companyDir } from "./db.ts"; +import { refreshPipeline, lastInsertId } from "./db.ts"; import { resolveCompany } from "./companies.ts"; +import { companyDocKey, projectDocKey, putObject, workerDocKey } from "./storage.ts"; export const IMPORT_COLUMNS = [ "NOMBRE", @@ -190,8 +189,8 @@ export function buildImportTemplate(companies: { code: string; name: string }[] return out instanceof Uint8Array ? out : new Uint8Array(out); } -function findExisting(db: Database, curp: string, rfc: string, nss: string) { - return db.prepare( +async function findExisting(db: Db, curp: string, rfc: string, nss: string) { + return await db.prepare( `SELECT id, first_name, last_name_p, curp, rfc, nss FROM workers WHERE curp = ? OR rfc = ? OR nss = ? LIMIT 1`, ).get(curp, rfc, nss) as @@ -200,7 +199,7 @@ function findExisting(db: Database, curp: string, rfc: string, nss: string) { } export async function storeDocument( - db: Database, + db: Db, workerId: number, type: string, filename: string, @@ -215,16 +214,14 @@ export async function storeDocument( imss_baja_at?: string | null; } = {}, ) { - const allowed = db.prepare("SELECT code FROM document_types WHERE code = ?").get(type); + const allowed = await db.prepare("SELECT code FROM document_types WHERE code = ?").get(type); if (!allowed) throw new Error("Tipo de documento no válido"); const { iv, cipher } = await encryptBytes(bytes); const hash = await sha256Hex(bytes); const storage = `${crypto.randomUUID()}.enc`; - const dir = workerDir(workerId); - await mkdir(dir, { recursive: true }); - await Deno.writeFile(join(dir, storage), cipher); - db.prepare("UPDATE documents SET is_current = 0 WHERE worker_id = ? AND type_code = ?").run( + await putObject(workerDocKey(workerId, storage), cipher); + await db.prepare("UPDATE documents SET is_current = false WHERE worker_id = ? AND type_code = ?").run( workerId, type, ); @@ -236,11 +233,11 @@ export async function storeDocument( const imssBajaAt = meta.imss_baja_at || (type === "baja_imss" ? today : null); const movementDate = type === "baja_imss" ? imssBajaAt : imssAltaAt; - db.prepare( + await db.prepare( `INSERT INTO documents (worker_id, type_code, original_name, mime, size_bytes, sha256, iv, storage_name, is_current, - parse_status, issued_at, expires_at, imss_company_id, imss_alta_at, uploaded_by) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, 'manual', ?, ?, ?, ?, ?)`, + parse_status, issued_at, expires_at, imss_company_id, imss_alta_at, uploaded_by_id, uploaded_by_name) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, true, 'manual', ?, ?, ?, ?, ?, '')`, ).run( workerId, type, @@ -259,16 +256,16 @@ export async function storeDocument( if (type === "alta_imss") { const companyId = imssCompanyId || - (db.prepare("SELECT company_id FROM workers WHERE id = ?").get(workerId) as { company_id: number } | undefined) + (await db.prepare("SELECT company_id FROM workers WHERE id = ?").get(workerId) as { company_id: number } | undefined) ?.company_id || null; const company = companyId - ? db.prepare("SELECT id, code FROM companies WHERE id = ?").get(companyId) as + ? await db.prepare("SELECT id, code FROM companies WHERE id = ?").get(companyId) as | { id: number; code: string } | undefined : undefined; if (!company) throw new Error("Empresa patrón no válida"); - db.prepare( + await db.prepare( `UPDATE workers SET imss_status = 'alta', imss_company_id = ?, @@ -276,29 +273,29 @@ export async function storeDocument( imss_baja_at = NULL, company_id = ?, hire_type = ?, - updated_at = datetime('now') + updated_at = now() WHERE id = ?`, ).run(company.id, imssAltaAt, company.id, company.code, workerId); } if (type === "baja_imss") { - db.prepare( + await db.prepare( `UPDATE workers SET imss_status = 'baja_imss', imss_baja_at = ?, imss_company_id = NULL, company_id = NULL, hire_type = '', - updated_at = datetime('now') + updated_at = now() WHERE id = ?`, ).run(imssBajaAt, workerId); } - refreshPipeline(db, workerId); + await refreshPipeline(db, workerId); } export async function storeProjectDocument( - db: Database, + db: Db, projectId: number, type: string, filename: string, @@ -306,27 +303,25 @@ export async function storeProjectDocument( bytes: Uint8Array, userId: number | null, ) { - const allowed = db.prepare("SELECT code FROM project_document_types WHERE code = ?").get(type); + const allowed = await db.prepare("SELECT code FROM project_document_types WHERE code = ?").get(type); if (!allowed) throw new Error("Tipo de documento no válido"); const { iv, cipher } = await encryptBytes(bytes); const hash = await sha256Hex(bytes); const storage = `${crypto.randomUUID()}.enc`; - const dir = projectDir(projectId); - await mkdir(dir, { recursive: true }); - await Deno.writeFile(join(dir, storage), cipher); - db.prepare("UPDATE project_documents SET is_current = 0 WHERE project_id = ? AND type_code = ?").run( + await putObject(projectDocKey(projectId, storage), cipher); + await db.prepare("UPDATE project_documents SET is_current = false WHERE project_id = ? AND type_code = ?").run( projectId, type, ); - db.prepare( + await db.prepare( `INSERT INTO project_documents - (project_id, type_code, original_name, mime, size_bytes, sha256, iv, storage_name, is_current, parse_status, uploaded_by) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, 'manual', ?)`, + (project_id, type_code, original_name, mime, size_bytes, sha256, iv, storage_name, is_current, parse_status, uploaded_by_id, uploaded_by_name) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, true, 'manual', ?, '')`, ).run(projectId, type, filename, mime, bytes.byteLength, hash, iv, storage, userId); } export async function storeCompanyDocument( - db: Database, + db: Db, companyId: number, type: string, filename: string, @@ -334,22 +329,20 @@ export async function storeCompanyDocument( bytes: Uint8Array, userId: number | null, ) { - const allowed = db.prepare("SELECT code FROM company_document_types WHERE code = ?").get(type); + const allowed = await db.prepare("SELECT code FROM company_document_types WHERE code = ?").get(type); if (!allowed) throw new Error("Tipo de documento no válido"); const { iv, cipher } = await encryptBytes(bytes); const hash = await sha256Hex(bytes); const storage = `${crypto.randomUUID()}.enc`; - const dir = companyDir(companyId); - await mkdir(dir, { recursive: true }); - await Deno.writeFile(join(dir, storage), cipher); - db.prepare("UPDATE company_documents SET is_current = 0 WHERE company_id = ? AND type_code = ?").run( + await putObject(companyDocKey(companyId, storage), cipher); + await db.prepare("UPDATE company_documents SET is_current = false WHERE company_id = ? AND type_code = ?").run( companyId, type, ); - db.prepare( + await db.prepare( `INSERT INTO company_documents - (company_id, type_code, original_name, mime, size_bytes, sha256, iv, storage_name, is_current, parse_status, uploaded_by) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, 'manual', ?)`, + (company_id, type_code, original_name, mime, size_bytes, sha256, iv, storage_name, is_current, parse_status, uploaded_by_id, uploaded_by_name) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, true, 'manual', ?, '')`, ).run(companyId, type, filename, mime, bytes.byteLength, hash, iv, storage, userId); } @@ -364,19 +357,19 @@ async function fetchPhoto(url: string): Promise { } } -function upsertWorker( - db: Database, +async function upsertWorker( + db: Db, input: ReturnType & { company_id: number; tenant_id?: number | null }, projectId: number | null, ) { - const existing = findExisting(db, input.curp, input.rfc, input.nss); + const existing = await findExisting(db, input.curp, input.rfc, input.nss); if (existing) { - db.prepare( + await db.prepare( `UPDATE workers SET first_name=?, middle_name=?, last_name_p=?, last_name_m=?, curp=?, rfc=?, nss=?, phone=?, email=?, address=?, blood_type=?, hire_type=?, company_id=?, tenant_id=COALESCE(?, tenant_id), position=?, risk_code=?, work_type=?, daily_wage=?, - needs_badge=?, status=?, updated_at=datetime('now') + needs_badge=?, status=?, updated_at=now() WHERE id=?`, ).run( input.first_name, @@ -401,12 +394,12 @@ function upsertWorker( input.status, existing.id, ); - if (projectId) assign(db, existing.id, projectId); - refreshPipeline(db, existing.id); + if (projectId) await assign(db, existing.id, projectId); + await refreshPipeline(db, existing.id, input.tenant_id ?? null); const matched = existing.curp === input.curp ? "CURP" : existing.rfc === input.rfc ? "RFC" : "NSS"; return { id: existing.id, action: "existed" as const, matched }; } - db.prepare( + await db.prepare( `INSERT INTO workers (first_name, middle_name, last_name_p, last_name_m, curp, rfc, nss, phone, email, address, blood_type, hire_type, company_id, position, risk_code, work_type, daily_wage, needs_badge, status, tenant_id) @@ -433,19 +426,19 @@ function upsertWorker( input.status, input.tenant_id ?? null, ); - const id = lastInsertId(db); - if (projectId) assign(db, id, projectId); - refreshPipeline(db, id); + const id = await lastInsertId(db); + if (projectId) await assign(db, id, projectId); + await refreshPipeline(db, id, input.tenant_id ?? null); return { id, action: "inserted" as const, matched: null as string | null }; } -function assign(db: Database, workerId: number, projectId: number) { - db.prepare( +async function assign(db: Db, workerId: number, projectId: number) { + await db.prepare( `INSERT INTO assignments (worker_id, project_id, active, start_date) - VALUES (?, ?, 1, date('now')) + VALUES (?, ?, true, current_date) ON CONFLICT(worker_id, project_id) DO UPDATE SET - active=1, - start_date=CASE WHEN assignments.active=0 THEN excluded.start_date ELSE assignments.start_date END, + active=true, + start_date=CASE WHEN assignments.active=false THEN excluded.start_date ELSE assignments.start_date END, end_date=NULL`, ).run(workerId, projectId); } @@ -460,14 +453,14 @@ export type ImportReport = { }; export async function importExcel( - db: Database, + db: Db, bytes: Uint8Array, projectId: number | null, userId: number | null, ): Promise { const wb = XLSX.read(bytes, { type: "array" }); const risks = new Set( - (db.prepare("SELECT code FROM risk_levels").all() as { code: string }[]).map((r) => r.code), + (await db.prepare("SELECT code FROM risk_levels").all() as { code: string }[]).map((r) => r.code), ); const report: ImportReport = { inserted: 0, @@ -509,7 +502,7 @@ export async function importExcel( const nombre = [data["NOMBRE"], data["APELLIDO PATERNO"], data["APELLIDO MATERNO"]].filter(Boolean).join(" "); const input = rowToInput(data, job.fallback); const errors = validateWorkerFields(input); - const company = resolveCompany(db, input); + const company = await resolveCompany(db, input); if (!company) errors.hire_type = `Empresa no encontrada (${data["ALTA"] || "—"})`; if (input.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(input.email)) { errors.email = "Correo inválido"; @@ -546,7 +539,7 @@ export async function importExcel( company_id: company!.id, tenant_id: company!.tenant_id ?? 1, }; - const res = upsertWorker(db, n, n.status === "activo" ? projectId : null); + const res = await upsertWorker(db, n, n.status === "activo" ? projectId : null); if (res.action === "inserted") report.inserted++; else { report.existed++; diff --git a/api/iam_db.ts b/api/iam_db.ts new file mode 100644 index 0000000..1ed16d4 --- /dev/null +++ b/api/iam_db.ts @@ -0,0 +1,72 @@ +import postgres, { createPool, PgDb, withTenant } from "./pg.ts"; +import { config } from "./config.ts"; + +/** + * panels_product, esquema iam: identidad/roles/permisos DE CADA TENANT. + * Aislado de `core` a propósito (ver reglas del monolito modular) -- este + * módulo no debe importar nada de db.ts (core) ni viceversa. + */ + +let appPool: postgres.Sql | null = null; +let authPool: postgres.Sql | null = null; + +function getAppPool(): postgres.Sql { + if (!appPool) { + appPool = createPool(config.databaseUrlIam, { max: 10 }); + } + return appPool; +} + +/** Pool con credenciales _owner: SOLO para resolver login por username (el + * username es único globalmente, no por tenant, así que hay que buscarlo + * ANTES de saber a qué tenant pertenece -- RLS con tenant_id no puede + * aplicar todavía en ese punto). El dueño de la tabla omite RLS de forma + * nativa en Postgres (a menos que se use FORCE ROW LEVEL SECURITY, que a + * propósito no se activó -- ver db/iam/changesets/002-rls.sql). No usar + * este pool para nada más que esa resolución puntual. */ +function getAuthPool(): postgres.Sql { + if (!authPool) { + authPool = createPool(config.databaseUrlIamOwner || config.databaseUrlIam, { max: 3 }); + } + return authPool; +} + +/** Health-check de arranque: falla rápido si Postgres no responde. */ +export async function pingIamDb(): Promise { + await getAppPool()`SELECT 1`; +} + +/** Busca un usuario por username en CUALQUIER tenant (paso previo al login, + * antes de conocer el tenant_id). Devuelve la fila cruda; el caller decide + * qué hacer con tenant_id/role_code. */ +export async function findUserByUsernameAnyTenant(username: string) { + const rows = await getAuthPool()` + SELECT id, username, password_hash, display_name, company_id, tenant_id, + role_code, must_change_password, email, created_at + FROM iam.users WHERE username = ${username}`; + return rows[0] as Record | undefined; +} + +/** Ejecuta `fn` con una conexión ya acotada por tenant_id via RLS (ver + * pg.ts#withTenant). Todo el resto del código de iam (fuera del login) + * debe pasar por aquí. */ +export async function withIamTenant( + tenantId: number | null, + fn: (db: PgDb) => Promise, +): Promise { + return await withTenant(getAppPool(), tenantId, fn); +} + +/** Uso puntual sin transacción/RLS explícito -- reservado para el bootstrap + * (scripts/bootstrap-admin.ts) y para lecturas del propio rol _owner que + * necesitan ver todos los tenants a propósito (soporte/administración). */ +export async function withIamOwner(fn: (db: PgDb) => Promise): Promise { + return await fn(new PgDb(getAuthPool())); +} + +export async function closeIamDb(): Promise { + await appPool?.end({ timeout: 5 }); + await authPool?.end({ timeout: 5 }); + appPool = null; + authPool = null; +} diff --git a/api/liquibase.ts b/api/liquibase.ts deleted file mode 100644 index a080270..0000000 --- a/api/liquibase.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { join } from "node:path"; -import { ROOT, DATA_DIR } from "./config.ts"; - -const LIQUIBASE = join(ROOT, "db", "tools", "liquibase", "liquibase"); -const SQLITE_JDBC = join(ROOT, "db", "tools", "sqlite-jdbc.jar"); -const BOOTSTRAP = join(ROOT, "db", "bootstrap-tools.sh"); - -async function ensureTools(): Promise { - try { - await Deno.stat(LIQUIBASE); - await Deno.stat(SQLITE_JDBC); - return true; - } catch { - const cmd = new Deno.Command("bash", { - args: [BOOTSTRAP], - stdout: "piped", - stderr: "piped", - }); - const { code, stdout, stderr } = await cmd.output(); - if (code !== 0) { - console.warn( - "[liquibase] bootstrap failed:\n", - new TextDecoder().decode(stdout), - new TextDecoder().decode(stderr), - ); - return false; - } - return true; - } -} - -async function updateOne(name: "app" | "platform"): Promise { - const dir = join(ROOT, "db", name); - const dbPath = join(DATA_DIR, `${name}.db`); - const cmd = new Deno.Command(LIQUIBASE, { - args: [ - "--defaultsFile=liquibase.properties", - `--classpath=${SQLITE_JDBC}`, - `--url=jdbc:sqlite:${dbPath}`, - "update", - ], - cwd: dir, - stdout: "piped", - stderr: "piped", - }); - const { code, stdout, stderr } = await cmd.output(); - const out = new TextDecoder().decode(stdout); - const err = new TextDecoder().decode(stderr); - if (code !== 0) { - throw new Error(`Liquibase update failed for ${name}:\n${out}\n${err}`); - } -} - -let ran = false; - -/** Apply Liquibase changelogs for app.db and platform.db (idempotent). */ -export async function runLiquibase(): Promise { - if (ran) return; - if (!await ensureTools()) { - console.warn("[liquibase] CLI missing; run ./db/update.sh"); - ran = true; - return; - } - await updateOne("app"); - await updateOne("platform"); - ran = true; -} diff --git a/api/mail.ts b/api/mail.ts index 2c15faa..b8bb4c3 100644 --- a/api/mail.ts +++ b/api/mail.ts @@ -18,8 +18,8 @@ export type MailPayload = { attachments?: MailAttachment[]; }; -export function smtpConfigured(platformDb: PlatformDb): boolean { - return smtpConfiguredFromDb(platformDb); +export async function smtpConfigured(platformDb: PlatformDb): Promise { + return await smtpConfiguredFromDb(platformDb); } /** Envía correo por SMTP. Si no hay SMTP, no falla: sent=false. */ @@ -27,11 +27,11 @@ export async function sendMail( platformDb: PlatformDb, payload: MailPayload, ): Promise<{ sent: boolean; error?: string; message_id?: string }> { - if (!smtpConfigured(platformDb)) { + if (!await smtpConfigured(platformDb)) { return { sent: false, error: "SMTP no configurado o deshabilitado" }; } - const s = resolveSmtp(platformDb); + const s = await resolveSmtp(platformDb); const from = (s.from_address ?? "").trim(); if (!from || !/(?:^|<)[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+(?:>|$)/.test(from)) { return { diff --git a/api/main.ts b/api/main.ts index 53639e6..cb401f1 100644 --- a/api/main.ts +++ b/api/main.ts @@ -1,9 +1,37 @@ import { Hono } from "hono"; import { cors } from "hono/cors"; -import { getDb, checklistFor, refreshPipeline, workerDir, lastInsertId, nextProjectCode, isProjectStatus, projectById, projectMustBe, PROJECT_STATUS_CATALOG, projectDir, projectChecklistFor, companyDir, companyChecklistFor, imssFlagsFor, checklistItemsFor } from "./db.ts"; +import { + checklistFor, + refreshPipeline, + nextProjectCode, + isProjectStatus, + projectById, + projectMustBe, + PROJECT_STATUS_CATALOG, + projectChecklistFor, + companyChecklistFor, + imssFlagsFor, + checklistItemsFor, + lastInsertId, + pingCoreDb, + tenantTimezone, + getCoreDb, + type Db, +} from "./db.ts"; import { config } from "./config.ts"; -import { clearSession, createSessionCookie, login, changePassword, requireAuth, requirePlatformAdmin, tenantScope, type AuthUser } from "./auth.ts"; -import { getPlatformDb } from "./platform_db.ts"; +import { + clearSession, + createSessionCookie, + login, + changePassword, + getFreshAppUser, + requireAuth, + requirePlatformAdmin, + tenantScope, + type AuthUser, +} from "./auth.ts"; +import { requireCoreAuth, withCoreScope } from "./scope.ts"; +import { getPlatformDb, pingPlatformDb } from "./platform_db.ts"; import { createTenant, getTenantDetail, issueTenantAdminAccess, listTenants, updateTenant } from "./saas.ts"; import { smtpConfigured, testSmtp } from "./mail.ts"; import { saveSmtpSettings, smtpPublicView } from "./smtp.ts"; @@ -33,16 +61,18 @@ import { lineAmount, listBudget, } from "./budget.ts"; -import { join } from "node:path"; +import { companyDocKey, getObject, projectDocKey, workerDocKey } from "./storage.ts"; +import { cacheCore, cacheKeyCore } from "./cache.ts"; +import { pingRedis } from "./redis.ts"; -const app = new Hono<{ Variables: { user: AuthUser } }>(); +const app = new Hono<{ Variables: { user: AuthUser; db: Db } }>(); -function scopedCompany( - db: Awaited>, +async function scopedCompany( + db: Db, id: number, tid: number | null, ) { - const company = companyById(db, id); + const company = await companyById(db, id); if (!company) return undefined; if (tid != null && company.tenant_id != null && company.tenant_id !== tid) return undefined; return company; @@ -67,18 +97,29 @@ app.use( cors({ origin: corsOrigins(), credentials: true, - allowHeaders: ["Content-Type", "X-API-Key"], + allowHeaders: ["Content-Type", "X-API-Key", "X-Tenant-Id"], }), ); -app.get("/v1/health", (c) => c.json({ ok: true })); +// Fail-fast real (Fase 7): antes con SQLite la app "siempre arrancaba"; con +// Postgres/Redis, /v1/health de verdad toca las tres conexiones Postgres y +// las dos de Redis, no solo responde estático. +app.get("/v1/health", async (c) => { + const [core, platform, redis] = await Promise.all([ + pingCoreDb().then(() => true).catch(() => false), + pingPlatformDb().then(() => true).catch(() => false), + pingRedis(), + ]); + const ok = core && platform && redis.iam && redis.core; + return c.json({ ok, core, platform, redis }, ok ? 200 : 503); +}); app.post("/v1/auth/login", async (c) => { const body = await c.req.json<{ username?: string; password?: string }>(); try { const user = await login(body.username ?? "", body.password ?? ""); if (!user) return c.json({ error: "Usuario o contraseña incorrectos" }, 401); - await createSessionCookie(c, user.id, user.realm); + await createSessionCookie(c, user.id, user.realm, user.tenant_id); return c.json({ user }); } catch (e: unknown) { if (e instanceof Error && (e as { code?: string }).code === "TENANT_BLOCKED") { @@ -88,8 +129,8 @@ app.post("/v1/auth/login", async (c) => { } }); -app.post("/v1/auth/logout", (c) => { - clearSession(c); +app.post("/v1/auth/logout", async (c) => { + await clearSession(c); return c.json({ ok: true }); }); @@ -99,49 +140,35 @@ app.post("/v1/auth/change-password", requireAuth, async (c) => { const user = c.get("user"); if (user.realm !== "app") return c.json({ error: "No aplica" }, 400); const body = await c.req.json<{ current_password?: string; new_password?: string }>(); - const result = await changePassword(user.id, body.current_password ?? "", body.new_password ?? ""); + const result = await changePassword(user.id, user.tenant_id, body.current_password ?? "", body.new_password ?? ""); if (result.error) return c.json({ error: result.error }, 400); - const db = await getDb(); - const row = db.prepare( - `SELECT u.id, u.username, u.display_name, u.company_id, u.tenant_id, u.role, - COALESCE(u.must_change_password, 0) AS must_change_password, - c.code AS company_code, c.name AS company_name, c.kind AS company_kind - FROM users u LEFT JOIN companies c ON c.id = u.company_id WHERE u.id = ?`, - ).get(user.id) as Record; - return c.json({ - ok: true, - user: { - ...row, - must_change_password: false, - role: row.role === "tenant_admin" ? "tenant_admin" : "user", - realm: "app", - }, - }); + const fresh = await getFreshAppUser(user.id, user.tenant_id); + return c.json({ ok: true, user: fresh }); }); app.get("/v1/saas/tenants", requirePlatformAdmin, async (c) => { const pdb = await getPlatformDb(); - return c.json({ tenants: listTenants(pdb), smtp_configured: smtpConfigured(pdb) }); + return c.json({ tenants: await listTenants(pdb), smtp_configured: await smtpConfigured(pdb) }); }); app.get("/v1/saas/tenants/:id", requirePlatformAdmin, async (c) => { const id = Number(c.req.param("id")); const pdb = await getPlatformDb(); - const db = await getDb(); - const detail = getTenantDetail(pdb, db, id); + const core = await getCoreDb(); + const detail = await getTenantDetail(pdb, core, id); if (!detail) return c.json({ error: "Empresa no encontrada" }, 404); - return c.json({ tenant: detail, smtp_configured: smtpConfigured(pdb) }); + return c.json({ tenant: detail, smtp_configured: await smtpConfigured(pdb) }); }); app.get("/v1/saas/smtp", requirePlatformAdmin, async (c) => { const pdb = await getPlatformDb(); - return c.json({ smtp: smtpPublicView(pdb) }); + return c.json({ smtp: await smtpPublicView(pdb) }); }); app.put("/v1/saas/smtp", requirePlatformAdmin, async (c) => { const body = await c.req.json(); const pdb = await getPlatformDb(); - const result = saveSmtpSettings(pdb, { + const result = await saveSmtpSettings(pdb, { ...body, keep_password: body.password === undefined || body.password === "", }); @@ -150,9 +177,9 @@ app.put("/v1/saas/smtp", requirePlatformAdmin, async (c) => { }); app.post("/v1/saas/smtp/test", requirePlatformAdmin, async (c) => { - const body = await c.req.json<{ to?: string }>().catch(() => ({})); + const body = await c.req.json<{ to?: string }>().catch(() => ({} as { to?: string })); const pdb = await getPlatformDb(); - if (!smtpConfigured(pdb)) { + if (!await smtpConfigured(pdb)) { return c.json({ error: "Guarde y habilite SMTP antes de probar" }, 400); } const result = await testSmtp(pdb, body.to ?? ""); @@ -163,8 +190,8 @@ app.post("/v1/saas/smtp/test", requirePlatformAdmin, async (c) => { app.post("/v1/saas/tenants", requirePlatformAdmin, async (c) => { const body = await c.req.json(); const pdb = await getPlatformDb(); - const db = await getDb(); - const result = await createTenant(pdb, db, body); + const core = await getCoreDb(); + const result = await createTenant(pdb, core, body); if (result.error || !result.tenant) return c.json({ error: result.error }, 400); return c.json({ tenant: result.tenant, @@ -177,20 +204,22 @@ app.patch("/v1/saas/tenants/:id", requirePlatformAdmin, async (c) => { const id = Number(c.req.param("id")); const body = await c.req.json(); const pdb = await getPlatformDb(); - const db = await getDb(); - const result = updateTenant(pdb, db, id, body); + const core = await getCoreDb(); + const result = await updateTenant(pdb, core, id, body); if (result.error) return c.json({ error: result.error }, 400); - const detail = getTenantDetail(pdb, db, id); + const detail = await getTenantDetail(pdb, core, id); return c.json({ tenant: detail }); }); /** Regenera contraseña temporal del admin y opcionalmente la envía por correo. */ app.post("/v1/saas/tenants/:id/send-access", requirePlatformAdmin, async (c) => { const id = Number(c.req.param("id")); - const body = await c.req.json<{ user_id?: number; send_email?: boolean }>().catch(() => ({})); + const body = await c.req.json<{ user_id?: number; send_email?: boolean }>().catch(() => + ({} as { user_id?: number; send_email?: boolean }) + ); const pdb = await getPlatformDb(); - const db = await getDb(); - const result = await issueTenantAdminAccess(pdb, db, id, { + const core = await getCoreDb(); + const result = await issueTenantAdminAccess(pdb, core, id, { userId: body.user_id, send_email: body.send_email !== false, }); @@ -198,87 +227,120 @@ app.post("/v1/saas/tenants/:id/send-access", requirePlatformAdmin, async (c) => return c.json({ admin: result.admin, email: result.email, - smtp_configured: smtpConfigured(pdb), + smtp_configured: await smtpConfigured(pdb), }); }); -app.get("/v1/catalogs", requireAuth, async (c) => { - const db = await getDb(); +app.get("/v1/catalogs", ...requireCoreAuth, async (c) => { + const db = c.get("db"); const user = c.get("user"); const tid = tenantScope(user); - return c.json({ - risks: db.prepare("SELECT * FROM risk_levels").all(), - themes: db.prepare("SELECT * FROM badge_themes").all(), - document_types: db.prepare("SELECT * FROM document_types").all(), - project_document_types: db.prepare("SELECT * FROM project_document_types").all(), - company_document_types: db.prepare("SELECT * FROM company_document_types").all(), + // Catálogos de baja escritura (Fase 4e): TTL corto, sin invalidación + // explícita. La llave incluye tenant_id porque `companies` sí es dato + // de negocio por-tenant -- risk_levels/badge_themes/document_types son + // globales, pero se cachean juntos por simplicidad de esta única + // respuesta agregada. + const payload = await cacheCore(cacheKeyCore(tid, "catalogs"), 30, async () => ({ + risks: await db.prepare("SELECT * FROM risk_levels").all(), + themes: await db.prepare("SELECT * FROM badge_themes").all(), + document_types: await db.prepare("SELECT * FROM document_types").all(), + project_document_types: await db.prepare("SELECT * FROM project_document_types").all(), + company_document_types: await db.prepare("SELECT * FROM company_document_types").all(), project_statuses: PROJECT_STATUS_CATALOG, - companies: listCompanies(db, tid), - }); + companies: await listCompanies(db, tid), + })); + return c.json(payload); }); -app.get("/v1/companies", requireAuth, async (c) => { - const db = await getDb(); +// Configuración del tenant (Fase 4d): zona horaria usada por nómina y +// vigencia de documentos -- reemplaza el PAYROLL_TZ hardcodeado. +app.get("/v1/configuracion", ...requireCoreAuth, async (c) => { + const db = c.get("db"); const tid = tenantScope(c.get("user")); - return c.json({ companies: listCompanies(db, tid) }); + const timezone = await tenantTimezone(db, tid); + return c.json({ timezone }); }); -app.post("/v1/companies", requireAuth, async (c) => { +app.put("/v1/configuracion", ...requireCoreAuth, async (c) => { + const user = c.get("user"); + if (user.role !== "tenant_admin") return c.json({ error: "Solo administradores" }, 403); + const db = c.get("db"); + const tid = tenantScope(user); + if (tid == null) return c.json({ error: "Cuenta sin tenant" }, 400); + const body = await c.req.json<{ timezone?: string }>(); + const tz = (body.timezone ?? "").trim(); + const valid = new Set(Intl.supportedValuesOf("timeZone")); + if (!tz || !valid.has(tz)) return c.json({ error: "Zona horaria inválida" }, 400); + await db.prepare( + `INSERT INTO tenant_settings (tenant_id, timezone, updated_at) VALUES (?, ?, now()) + ON CONFLICT (tenant_id) DO UPDATE SET timezone = excluded.timezone, updated_at = now()`, + ).run(tid, tz); + return c.json({ timezone: tz }); +}); + +app.get("/v1/companies", ...requireCoreAuth, async (c) => { + const db = c.get("db"); + const tid = tenantScope(c.get("user")); + return c.json({ companies: await listCompanies(db, tid) }); +}); + +app.post("/v1/companies", ...requireCoreAuth, async (c) => { const body = await c.req.json(); - const db = await getDb(); + const db = c.get("db"); const tid = tenantScope(c.get("user")); - const result = createSubcompany(db, body, tid); + const result = await createSubcompany(db, body, tid); if (result.error || !result.company) return c.json({ error: result.error }, 400); return c.json({ company: result.company }, 201); }); -app.patch("/v1/companies/:id", requireAuth, async (c) => { +app.patch("/v1/companies/:id", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); const body = await c.req.json(); - const db = await getDb(); - const result = updateCompany(db, id, body); + const db = c.get("db"); + const tid = tenantScope(c.get("user")); + const result = await updateCompany(db, id, body, tid); if (result.error) return c.json({ error: result.error }, (result.status ?? 400) as 400 | 404); return c.json({ company: result.company }); }); -app.get("/v1/companies/:id", requireAuth, async (c) => { +app.get("/v1/companies/:id", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); - const db = await getDb(); - const company = scopedCompany(db, id, tenantScope(c.get("user"))); + const db = c.get("db"); + const company = await scopedCompany(db, id, tenantScope(c.get("user"))); if (!company) return c.json({ error: "Empresa no encontrada" }, 404); - const documents = db.prepare( + const documents = await db.prepare( "SELECT id, type_code, original_name, mime, size_bytes, is_current, uploaded_at FROM company_documents WHERE company_id = ? ORDER BY uploaded_at DESC", ).all(id); return c.json({ company, documents, - checklist: companyChecklistFor(db, id), + checklist: await companyChecklistFor(db, id), }); }); -app.post("/v1/companies/:id/documents", requireAuth, async (c) => { +app.post("/v1/companies/:id/documents", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); const form = await c.req.formData(); const type = String(form.get("type") || ""); const file = form.get("file"); if (!(file instanceof File) || !type) return c.json({ error: "type y file requeridos" }, 400); - const db = await getDb(); - if (!scopedCompany(db, id, tenantScope(c.get("user")))) return c.json({ error: "Empresa no encontrada" }, 404); + const db = c.get("db"); + if (!await scopedCompany(db, id, tenantScope(c.get("user")))) return c.json({ error: "Empresa no encontrada" }, 404); const bytes = new Uint8Array(await file.arrayBuffer()); try { await storeCompanyDocument(db, id, type, file.name, file.type || "application/octet-stream", bytes, c.get("user").id); } catch (error) { return c.json({ error: error instanceof Error ? error.message : "No se pudo guardar el documento" }, 400); } - return c.json({ ok: true, checklist: companyChecklistFor(db, id) }); + return c.json({ ok: true, checklist: await companyChecklistFor(db, id) }); }); -app.get("/v1/companies/:id/documents/:docId", requireAuth, async (c) => { +app.get("/v1/companies/:id/documents/:docId", ...requireCoreAuth, async (c) => { const companyId = Number(c.req.param("id")); const docId = Number(c.req.param("docId")); - const db = await getDb(); - if (!scopedCompany(db, companyId, tenantScope(c.get("user")))) return c.json({ error: "Empresa no encontrada" }, 404); - const doc = db.prepare( + const db = c.get("db"); + if (!await scopedCompany(db, companyId, tenantScope(c.get("user")))) return c.json({ error: "Empresa no encontrada" }, 404); + const doc = await db.prepare( "SELECT * FROM company_documents WHERE id=? AND company_id=?", ).get(docId, companyId) as { storage_name: string; @@ -287,15 +349,20 @@ app.get("/v1/companies/:id/documents/:docId", requireAuth, async (c) => { original_name: string; } | undefined; if (!doc) return c.json({ error: "Documento no encontrado" }, 404); - const enc = await Deno.readFile(join(companyDir(companyId), doc.storage_name)); + const enc = await getObject(companyDocKey(companyId, doc.storage_name)); const plain = await decryptBytes(doc.iv, enc); - c.header("Content-Type", doc.mime); - c.header("Content-Disposition", `inline; filename="${doc.original_name}"`); + // Documentos subidos por el usuario se sirven como adjunto, nunca inline: + // servir "inline" dejaría que el navegador renderice el Content-Type que + // el propio uploader eligió (ej. un .html disfrazado de "comprobante"), + // habilitando XSS almacenado dentro del origen autenticado de la app. + c.header("Content-Type", "application/octet-stream"); + c.header("X-Content-Type-Options", "nosniff"); + c.header("Content-Disposition", `attachment; filename="${encodeURIComponent(doc.original_name)}"`); return c.body(plain.buffer as ArrayBuffer); }); -app.get("/v1/projects", requireAuth, async (c) => { - const db = await getDb(); +app.get("/v1/projects", ...requireCoreAuth, async (c) => { + const db = c.get("db"); const tid = tenantScope(c.get("user")); const status = (c.req.query("status") ?? "").trim(); const allowed = status ? status.split(",").map((s) => s.trim()).filter(isProjectStatus) : []; @@ -311,13 +378,13 @@ app.get("/v1/projects", requireAuth, async (c) => { } const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""; return c.json({ - projects: db.prepare( + projects: await db.prepare( `SELECT p.*, c.name AS company_name, c.code AS company_code, - (SELECT COUNT(*) FROM assignments a WHERE a.project_id = p.id AND a.active = 1) AS active_count, + (SELECT COUNT(*) FROM assignments a WHERE a.project_id = p.id AND a.active = true) AS active_count, (SELECT COUNT(*) FROM project_document_types t - WHERE t.required = 1 AND NOT EXISTS ( + WHERE t.required = true AND NOT EXISTS ( SELECT 1 FROM project_documents d - WHERE d.project_id = p.id AND d.type_code = t.code AND d.is_current = 1 + WHERE d.project_id = p.id AND d.type_code = t.code AND d.is_current = true )) AS missing_docs, (SELECT COUNT(*) FROM budget_items b WHERE b.project_id = p.id) AS budget_count FROM projects p @@ -375,22 +442,22 @@ function projectFields(body: ProjectInput, cur?: Record) { }; } -app.post("/v1/projects", requireAuth, async (c) => { +app.post("/v1/projects", ...requireCoreAuth, async (c) => { const body = await c.req.json(); if (!body.name?.trim()) return c.json({ error: "Nombre de proyecto obligatorio" }, 400); const status = body.status && isProjectStatus(body.status) ? body.status : "activo"; - const db = await getDb(); + const db = c.get("db"); const fields = projectFields(body); if (!fields.company_id) return c.json({ error: "Seleccione la empresa del proyecto" }, 400); - if (!resolveCompany(db, { company_id: fields.company_id })) { + if (!await resolveCompany(db, { company_id: fields.company_id })) { return c.json({ error: "Empresa no encontrada" }, 400); } if (fields.start_date && fields.end_date && fields.end_date < fields.start_date) { return c.json({ error: "La fecha de término no puede ser anterior al inicio" }, 400); } - const code = nextProjectCode(db); + const code = await nextProjectCode(db); const tid = tenantScope(c.get("user")); - db.prepare( + await db.prepare( `INSERT INTO projects ( code, name, address, theme_id, status, company_id, contract_amount, start_date, end_date, resident_name, siroc, payroll_tax_pct, tenant_id @@ -410,26 +477,28 @@ app.post("/v1/projects", requireAuth, async (c) => { fields.payroll_tax_pct, tid, ); - const id = lastInsertId(db); + const id = await lastInsertId(db); return c.json({ id, code, status }, 201); }); -app.patch("/v1/projects/:id", requireAuth, async (c) => { +app.patch("/v1/projects/:id", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); const body = await c.req.json(); - const db = await getDb(); - const cur = db.prepare("SELECT * FROM projects WHERE id = ?").get(id) as Record | undefined; + const db = c.get("db"); + // Con RLS activo (db/core/changesets/005-rls.sql), esta consulta ya no + // puede traer una fila de otro tenant: la conexión solo ve app.tenant_id. + const cur = await db.prepare("SELECT * FROM projects WHERE id = ?").get(id) as Record | undefined; if (!cur) return c.json({ error: "Proyecto no encontrado" }, 404); const status = body.status && isProjectStatus(body.status) ? body.status : String(cur.status || "activo"); const fields = projectFields(body, cur); if (!fields.company_id) return c.json({ error: "Seleccione la empresa del proyecto" }, 400); - if (!resolveCompany(db, { company_id: fields.company_id })) { + if (!await resolveCompany(db, { company_id: fields.company_id })) { return c.json({ error: "Empresa no encontrada" }, 400); } if (fields.start_date && fields.end_date && fields.end_date < fields.start_date) { return c.json({ error: "La fecha de término no puede ser anterior al inicio" }, 400); } - db.prepare( + await db.prepare( `UPDATE projects SET name=?, address=?, theme_id=?, status=?, company_id=?, contract_amount=?, start_date=?, end_date=?, resident_name=?, siroc=?, payroll_tax_pct=? WHERE id=?`, ).run( @@ -449,49 +518,49 @@ app.patch("/v1/projects/:id", requireAuth, async (c) => { return c.json({ ok: true, id, code: cur.code, status }); }); -app.get("/v1/projects/:id", requireAuth, async (c) => { +app.get("/v1/projects/:id", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); - const db = await getDb(); - const project = db.prepare( + const db = c.get("db"); + const project = await db.prepare( `SELECT p.*, c.name AS company_name, c.code AS company_code, - (SELECT COUNT(*) FROM assignments a WHERE a.project_id = p.id AND a.active = 1) AS active_count + (SELECT COUNT(*) FROM assignments a WHERE a.project_id = p.id AND a.active = true) AS active_count FROM projects p LEFT JOIN companies c ON c.id = p.company_id WHERE p.id = ?`, ).get(id); if (!project) return c.json({ error: "Proyecto no encontrado" }, 404); - const documents = db.prepare( + const documents = await db.prepare( "SELECT id, type_code, original_name, mime, size_bytes, is_current, uploaded_at FROM project_documents WHERE project_id = ? ORDER BY uploaded_at DESC", ).all(id); return c.json({ project, documents, - checklist: projectChecklistFor(db, id), + checklist: await projectChecklistFor(db, id), }); }); -app.post("/v1/projects/:id/documents", requireAuth, async (c) => { +app.post("/v1/projects/:id/documents", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); const form = await c.req.formData(); const type = String(form.get("type") || ""); const file = form.get("file"); if (!(file instanceof File) || !type) return c.json({ error: "type y file requeridos" }, 400); - const db = await getDb(); - if (!projectById(db, id)) return c.json({ error: "Proyecto no encontrado" }, 404); + const db = c.get("db"); + if (!await projectById(db, id)) return c.json({ error: "Proyecto no encontrado" }, 404); const bytes = new Uint8Array(await file.arrayBuffer()); try { await storeProjectDocument(db, id, type, file.name, file.type || "application/octet-stream", bytes, c.get("user").id); } catch (error) { return c.json({ error: error instanceof Error ? error.message : "No se pudo guardar el documento" }, 400); } - return c.json({ ok: true, checklist: projectChecklistFor(db, id) }); + return c.json({ ok: true, checklist: await projectChecklistFor(db, id) }); }); -app.get("/v1/projects/:id/documents/:docId", requireAuth, async (c) => { +app.get("/v1/projects/:id/documents/:docId", ...requireCoreAuth, async (c) => { const projectId = Number(c.req.param("id")); const docId = Number(c.req.param("docId")); - const db = await getDb(); - const doc = db.prepare( + const db = c.get("db"); + const doc = await db.prepare( "SELECT * FROM project_documents WHERE id=? AND project_id=?", ).get(docId, projectId) as { storage_name: string; @@ -500,40 +569,41 @@ app.get("/v1/projects/:id/documents/:docId", requireAuth, async (c) => { original_name: string; } | undefined; if (!doc) return c.json({ error: "Documento no encontrado" }, 404); - const enc = await Deno.readFile(join(projectDir(projectId), doc.storage_name)); + const enc = await getObject(projectDocKey(projectId, doc.storage_name)); const plain = await decryptBytes(doc.iv, enc); - c.header("Content-Type", doc.mime); - c.header("Content-Disposition", `inline; filename="${doc.original_name}"`); + c.header("Content-Type", "application/octet-stream"); + c.header("X-Content-Type-Options", "nosniff"); + c.header("Content-Disposition", `attachment; filename="${encodeURIComponent(doc.original_name)}"`); return c.body(plain.buffer as ArrayBuffer); }); -app.get("/v1/projects/:id/budget", requireAuth, async (c) => { +app.get("/v1/projects/:id/budget", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); - const db = await getDb(); - if (!projectById(db, id)) return c.json({ error: "Proyecto no encontrado" }, 404); - return c.json(listBudget(db, id)); + const db = c.get("db"); + if (!await projectById(db, id)) return c.json({ error: "Proyecto no encontrado" }, 404); + return c.json(await listBudget(db, id)); }); -app.get("/v1/projects/:id/budget/template", requireAuth, (c) => { +app.get("/v1/projects/:id/budget/template", ...requireCoreAuth, (c) => { const bytes = buildBudgetTemplate(); c.header("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); c.header("Content-Disposition", 'attachment; filename="plantilla-presupuesto.xlsx"'); return c.body(bytes.slice()); }); -app.get("/v1/projects/:id/budget/export", requireAuth, async (c) => { +app.get("/v1/projects/:id/budget/export", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); - const db = await getDb(); - const project = projectById(db, id); + const db = c.get("db"); + const project = await projectById(db, id); if (!project) return c.json({ error: "Proyecto no encontrado" }, 404); - const budget = listBudget(db, id); + const budget = await listBudget(db, id); const bytes = exportBudgetWorkbook(project.name, budget); c.header("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); c.header("Content-Disposition", `attachment; filename="presupuesto-${project.code}.xlsx"`); return c.body(bytes.slice()); }); -app.post("/v1/budget/preview", requireAuth, async (c) => { +app.post("/v1/budget/preview", ...requireCoreAuth, async (c) => { const form = await c.req.formData(); const file = form.get("file"); if (!(file instanceof File)) return c.json({ error: "Archivo Excel requerido" }, 400); @@ -543,57 +613,57 @@ app.post("/v1/budget/preview", requireAuth, async (c) => { return c.json(preview); }); -app.post("/v1/projects/:id/budget/preview", requireAuth, async (c) => { +app.post("/v1/projects/:id/budget/preview", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); const form = await c.req.formData(); const file = form.get("file"); if (!(file instanceof File)) return c.json({ error: "Archivo Excel requerido" }, 400); - const db = await getDb(); - if (!projectById(db, id)) return c.json({ error: "Proyecto no encontrado" }, 404); + const db = c.get("db"); + if (!await projectById(db, id)) return c.json({ error: "Proyecto no encontrado" }, 404); const bytes = new Uint8Array(await file.arrayBuffer()); const preview = previewBudgetExcel(bytes); if (preview.errors.length && !preview.items.length) return c.json(preview, 400); return c.json(preview); }); -app.post("/v1/projects/:id/budget/import", requireAuth, async (c) => { +app.post("/v1/projects/:id/budget/import", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); const form = await c.req.formData(); const file = form.get("file"); if (!(file instanceof File)) return c.json({ error: "Archivo Excel requerido" }, 400); - const db = await getDb(); - if (!projectById(db, id)) return c.json({ error: "Proyecto no encontrado" }, 404); + const db = c.get("db"); + if (!await projectById(db, id)) return c.json({ error: "Proyecto no encontrado" }, 404); const bytes = new Uint8Array(await file.arrayBuffer()); - const report = importBudgetExcel(db, id, bytes); + const report = await importBudgetExcel(db, id, bytes); if (report.errors.length && !report.inserted) return c.json(report, 400); return c.json(report); }); -app.post("/v1/projects/:id/budget/chapters", requireAuth, async (c) => { +app.post("/v1/projects/:id/budget/chapters", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); const body = await c.req.json<{ name?: string; code?: string; parent_id?: number | null }>(); if (!body.name?.trim()) return c.json({ error: "Nombre de capítulo obligatorio" }, 400); - const db = await getDb(); - if (!projectById(db, id)) return c.json({ error: "Proyecto no encontrado" }, 404); - const sort = (db.prepare("SELECT COALESCE(MAX(sort_order), 0) + 1 AS n FROM budget_chapters WHERE project_id = ?").get(id) as { n: number }).n; - db.prepare("INSERT INTO budget_chapters (project_id, parent_id, code, name, sort_order) VALUES (?, ?, ?, ?, ?)").run( + const db = c.get("db"); + if (!await projectById(db, id)) return c.json({ error: "Proyecto no encontrado" }, 404); + const sort = (await db.prepare("SELECT COALESCE(MAX(sort_order), 0) + 1 AS n FROM budget_chapters WHERE project_id = ?").get(id) as { n: number }).n; + await db.prepare("INSERT INTO budget_chapters (project_id, parent_id, code, name, sort_order) VALUES (?, ?, ?, ?, ?)").run( id, body.parent_id || null, (body.code || "").trim(), body.name.trim(), sort, ); - return c.json({ id: lastInsertId(db) }, 201); + return c.json({ id: await lastInsertId(db) }, 201); }); -app.patch("/v1/projects/:id/budget/chapters/:cid", requireAuth, async (c) => { +app.patch("/v1/projects/:id/budget/chapters/:cid", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); const cid = Number(c.req.param("cid")); const body = await c.req.json<{ name?: string; code?: string }>(); - const db = await getDb(); - const cur = db.prepare("SELECT * FROM budget_chapters WHERE id = ? AND project_id = ?").get(cid, id) as { name: string; code: string } | undefined; + const db = c.get("db"); + const cur = await db.prepare("SELECT * FROM budget_chapters WHERE id = ? AND project_id = ?").get(cid, id) as { name: string; code: string } | undefined; if (!cur) return c.json({ error: "Capítulo no encontrado" }, 404); - db.prepare("UPDATE budget_chapters SET name = ?, code = ? WHERE id = ?").run( + await db.prepare("UPDATE budget_chapters SET name = ?, code = ? WHERE id = ?").run( (body.name ?? cur.name).trim(), (body.code ?? cur.code).trim(), cid, @@ -601,14 +671,14 @@ app.patch("/v1/projects/:id/budget/chapters/:cid", requireAuth, async (c) => { return c.json({ ok: true }); }); -app.delete("/v1/projects/:id/budget/chapters/:cid", requireAuth, async (c) => { +app.delete("/v1/projects/:id/budget/chapters/:cid", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); const cid = Number(c.req.param("cid")); - const db = await getDb(); - const found = db.prepare("SELECT id FROM budget_chapters WHERE id = ? AND project_id = ?").get(cid, id); + const db = c.get("db"); + const found = await db.prepare("SELECT id FROM budget_chapters WHERE id = ? AND project_id = ?").get(cid, id); if (!found) return c.json({ error: "Capítulo no encontrado" }, 404); - db.prepare("UPDATE budget_items SET chapter_id = NULL WHERE project_id = ? AND chapter_id = ?").run(id, cid); - db.prepare("DELETE FROM budget_chapters WHERE id = ? AND project_id = ?").run(cid, id); + await db.prepare("UPDATE budget_items SET chapter_id = NULL WHERE project_id = ? AND chapter_id = ?").run(id, cid); + await db.prepare("DELETE FROM budget_chapters WHERE id = ? AND project_id = ?").run(cid, id); return c.json({ ok: true }); }); @@ -621,16 +691,16 @@ type BudgetItemInput = { unit_price?: number; }; -app.post("/v1/projects/:id/budget/items", requireAuth, async (c) => { +app.post("/v1/projects/:id/budget/items", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); const body = await c.req.json(); if (!body.description?.trim()) return c.json({ error: "Descripción de partida obligatoria" }, 400); - const db = await getDb(); - if (!projectById(db, id)) return c.json({ error: "Proyecto no encontrado" }, 404); + const db = c.get("db"); + if (!await projectById(db, id)) return c.json({ error: "Proyecto no encontrado" }, 404); const qty = Number(body.quantity ?? 0); const price = Number(body.unit_price ?? 0); - const sort = (db.prepare("SELECT COALESCE(MAX(sort_order), 0) + 1 AS n FROM budget_items WHERE project_id = ?").get(id) as { n: number }).n; - db.prepare( + const sort = (await db.prepare("SELECT COALESCE(MAX(sort_order), 0) + 1 AS n FROM budget_items WHERE project_id = ?").get(id) as { n: number }).n; + await db.prepare( `INSERT INTO budget_items (project_id, chapter_id, code, description, unit, quantity, unit_price, amount, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, ).run( @@ -644,19 +714,19 @@ app.post("/v1/projects/:id/budget/items", requireAuth, async (c) => { lineAmount(qty, price), sort, ); - return c.json({ id: lastInsertId(db) }, 201); + return c.json({ id: await lastInsertId(db) }, 201); }); -app.patch("/v1/projects/:id/budget/items/:iid", requireAuth, async (c) => { +app.patch("/v1/projects/:id/budget/items/:iid", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); const iid = Number(c.req.param("iid")); const body = await c.req.json(); - const db = await getDb(); - const cur = db.prepare("SELECT * FROM budget_items WHERE id = ? AND project_id = ?").get(iid, id) as Record | undefined; + const db = c.get("db"); + const cur = await db.prepare("SELECT * FROM budget_items WHERE id = ? AND project_id = ?").get(iid, id) as Record | undefined; if (!cur) return c.json({ error: "Partida no encontrada" }, 404); const qty = body.quantity !== undefined ? Number(body.quantity) : Number(cur.quantity); const price = body.unit_price !== undefined ? Number(body.unit_price) : Number(cur.unit_price); - db.prepare( + await db.prepare( `UPDATE budget_items SET chapter_id=?, code=?, description=?, unit=?, quantity=?, unit_price=?, amount=? WHERE id=?`, ).run( body.chapter_id !== undefined ? (body.chapter_id || null) : cur.chapter_id, @@ -671,20 +741,20 @@ app.patch("/v1/projects/:id/budget/items/:iid", requireAuth, async (c) => { return c.json({ ok: true }); }); -app.delete("/v1/projects/:id/budget/items/:iid", requireAuth, async (c) => { +app.delete("/v1/projects/:id/budget/items/:iid", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); const iid = Number(c.req.param("iid")); - const db = await getDb(); - const found = db.prepare("SELECT id FROM budget_items WHERE id = ? AND project_id = ?").get(iid, id); + const db = c.get("db"); + const found = await db.prepare("SELECT id FROM budget_items WHERE id = ? AND project_id = ?").get(iid, id); if (!found) return c.json({ error: "Partida no encontrada" }, 404); - db.prepare("DELETE FROM budget_items WHERE id = ? AND project_id = ?").run(iid, id); + await db.prepare("DELETE FROM budget_items WHERE id = ? AND project_id = ?").run(iid, id); return c.json({ ok: true }); }); -app.post("/v1/workers/validate", requireAuth, async (c) => { +app.post("/v1/workers/validate", ...requireCoreAuth, async (c) => { const body = await c.req.json(); const errors = validateWorkerFields(body); - const db = await getDb(); + const db = c.get("db"); const exclude = body.id ?? 0; const checks: { field: string; value: string; err: string | null }[] = [ { field: "curp", value: normUpper(body.curp), err: validateCurp(body.curp ?? "") }, @@ -693,7 +763,7 @@ app.post("/v1/workers/validate", requireAuth, async (c) => { ]; for (const ch of checks) { if (ch.err) continue; - const row = db.prepare( + const row = await db.prepare( `SELECT id, first_name, last_name_p FROM workers WHERE ${ch.field} = ? AND id != ?`, ).get(ch.value, exclude) as { id: number; first_name: string; last_name_p: string } | undefined; if (row) { @@ -704,8 +774,8 @@ app.post("/v1/workers/validate", requireAuth, async (c) => { return c.json({ ok: Object.keys(errors).length === 0, errors }); }); -app.get("/v1/workers", requireAuth, async (c) => { - const db = await getDb(); +app.get("/v1/workers", ...requireCoreAuth, async (c) => { + const db = c.get("db"); const tid = tenantScope(c.get("user")); const q = (c.req.query("q") ?? "").trim(); const status = c.req.query("status"); @@ -713,19 +783,19 @@ app.get("/v1/workers", requireAuth, async (c) => { let sql = `SELECT w.*, r.label AS risk_label, r.color AS risk_color, r.text_color AS risk_text, c.name AS company_name, c.kind AS company_kind, ic.name AS imss_company_name, ic.code AS imss_company_code, ic.registro_patronal AS imss_registro_patronal, - (SELECT GROUP_CONCAT(p.name, ', ') FROM assignments a JOIN projects p ON p.id = a.project_id - WHERE a.worker_id = w.id AND a.active = 1) AS proyectos, - (SELECT GROUP_CONCAT(p.code || '|' || replace(p.name, '|', '/'), ';;') FROM assignments a JOIN projects p ON p.id = a.project_id - WHERE a.worker_id = w.id AND a.active = 1) AS proyecto_pairs, - (SELECT GROUP_CONCAT(p.id, ',') FROM assignments a JOIN projects p ON p.id = a.project_id - WHERE a.worker_id = w.id AND a.active = 1) AS project_ids, + (SELECT STRING_AGG(p.name, ', ') FROM assignments a JOIN projects p ON p.id = a.project_id + WHERE a.worker_id = w.id AND a.active = true) AS proyectos, + (SELECT STRING_AGG(p.code || '|' || replace(p.name, '|', '/'), ';;') FROM assignments a JOIN projects p ON p.id = a.project_id + WHERE a.worker_id = w.id AND a.active = true) AS proyecto_pairs, + (SELECT STRING_AGG(p.id::text, ',') FROM assignments a JOIN projects p ON p.id = a.project_id + WHERE a.worker_id = w.id AND a.active = true) AS project_ids, (SELECT COUNT(*) FROM document_types t - WHERE t.required = 1 AND NOT EXISTS ( + WHERE t.required = true AND NOT EXISTS ( SELECT 1 FROM documents d - WHERE d.worker_id = w.id AND d.type_code = t.code AND d.is_current = 1 + WHERE d.worker_id = w.id AND d.type_code = t.code AND d.is_current = true )) AS missing_docs, (SELECT COALESCE(SUM(l.balance), 0) FROM loans l WHERE l.worker_id = w.id AND l.balance > 0) AS loan_balance, - CASE WHEN EXISTS (SELECT 1 FROM assignments a WHERE a.worker_id = w.id AND a.active = 1) THEN 1 ELSE 0 END AS in_project + CASE WHEN EXISTS (SELECT 1 FROM assignments a WHERE a.worker_id = w.id AND a.active = true) THEN true ELSE false END AS in_project FROM workers w JOIN risk_levels r ON r.code = w.risk_code LEFT JOIN companies c ON c.id = w.company_id @@ -743,34 +813,35 @@ app.get("/v1/workers", requireAuth, async (c) => { } if (projectId) { sql += - " AND EXISTS (SELECT 1 FROM assignments a WHERE a.worker_id = w.id AND a.project_id = ? AND a.active = 1)"; + " AND EXISTS (SELECT 1 FROM assignments a WHERE a.worker_id = w.id AND a.project_id = ? AND a.active = true)"; params.push(Number(projectId)); } if (q) { sql += - " AND (w.first_name LIKE ? OR w.last_name_p LIKE ? OR w.curp LIKE ? OR w.rfc LIKE ? OR w.nss LIKE ?)"; + " AND (w.first_name ILIKE ? OR w.last_name_p ILIKE ? OR w.curp ILIKE ? OR w.rfc ILIKE ? OR w.nss ILIKE ?)"; const like = `%${q}%`; params.push(like, like, like, like, like); } sql += " ORDER BY w.last_name_p, w.first_name"; - const rows = db.prepare(sql).all(...params) as Array & { + const rows = await db.prepare(sql).all(...params) as Array & { id: number; status: string; pipeline_status: string; imss_status?: string; - in_project?: number; + in_project?: boolean; }>; - const workers = rows.map((row) => { - const flags = imssFlagsFor(db, row.id); - return { + const workers = []; + for (const row of rows) { + const flags = await imssFlagsFor(db, row.id, tid); + workers.push({ ...row, imss_status: flags.imss_status, imss_ready: flags.imss_ready, in_project_without_imss: flags.in_project_without_imss, expediente_ok: flags.expediente_ok, freshness_required: flags.freshness_required, - }; - }); + }); + } const active = workers.filter((w) => w.status === "activo" && w.pipeline_status !== "baja"); const withImss = active.filter((w) => w.imss_status === "alta").length; const withoutImss = active.length - withImss; @@ -787,10 +858,11 @@ app.get("/v1/workers", requireAuth, async (c) => { }); }); -app.get("/v1/workers/:id", requireAuth, async (c) => { +app.get("/v1/workers/:id", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); - const db = await getDb(); - const worker = db.prepare( + const db = c.get("db"); + const tid = tenantScope(c.get("user")); + const worker = await db.prepare( `SELECT w.*, r.label AS risk_label, r.color AS risk_color, r.text_color AS risk_text, c.name AS company_name, c.kind AS company_kind, ic.name AS imss_company_name, ic.code AS imss_company_code, ic.registro_patronal AS imss_registro_patronal @@ -801,20 +873,20 @@ app.get("/v1/workers/:id", requireAuth, async (c) => { WHERE w.id = ?`, ).get(id); if (!worker) return c.json({ error: "No encontrado" }, 404); - const documents = db.prepare( + const documents = await db.prepare( `SELECT id, type_code, original_name, mime, size_bytes, is_current, uploaded_at, issued_at, expires_at, imss_company_id, imss_alta_at FROM documents WHERE worker_id = ? ORDER BY uploaded_at DESC`, ).all(id); - const assignments = db.prepare( + const assignments = await db.prepare( `SELECT a.*, p.name AS project_name, p.code AS project_code FROM assignments a JOIN projects p ON p.id = a.project_id WHERE a.worker_id = ? ORDER BY a.active DESC, a.start_date DESC, a.id DESC`, ).all(id); - const loans = db.prepare("SELECT * FROM loans WHERE worker_id = ? ORDER BY id DESC").all(id); - const { items, freshness_required } = checklistItemsFor(db, id); - const flags = imssFlagsFor(db, id); - const docTypes = db.prepare( + const loans = await db.prepare("SELECT * FROM loans WHERE worker_id = ? ORDER BY id DESC").all(id); + const { items, freshness_required } = await checklistItemsFor(db, id, tid); + const flags = await imssFlagsFor(db, id, tid); + const docTypes = await db.prepare( `SELECT code, label, required, validity_mode, freshness_days, requires_issued_at, requires_expires_at, category FROM document_types ORDER BY required DESC, label`, ).all(); @@ -829,8 +901,7 @@ app.get("/v1/workers/:id", requireAuth, async (c) => { }); }); -function conflict(db: Awaited>, n: ReturnType, excludeId = 0) { - const row = findExisting(db, n.curp, n.rfc, n.nss); +function conflict(row: { id: number; first_name: string; last_name_p: string; curp: string; rfc: string; nss: string } | undefined, n: ReturnType, excludeId = 0) { if (row && row.id !== excludeId) { const field = row.curp === n.curp ? "CURP" : row.rfc === n.rfc ? "RFC" : "NSS"; return { @@ -844,23 +915,25 @@ function conflict(db: Awaited>, n: ReturnType { +app.post("/v1/workers", ...requireCoreAuth, async (c) => { const body = await c.req.json(); const errors = validateWorkerFields(body); - const db = await getDb(); + const db = c.get("db"); if (Object.keys(errors).length) return c.json({ errors }, 400); const n = normalizeWorker({ ...body, hire_type: "" }); - const cf = conflict(db, n); + const existing = await findExisting(db, n.curp, n.rfc, n.nss); + const cf = conflict(existing, n); if (cf) return c.json(cf.body, cf.status); if (body.project_id) { const blocked = projectMustBe( - projectById(db, body.project_id), + await projectById(db, body.project_id), ["activo"], "Solo se asigna personal a proyectos activos", ); if (blocked) return c.json({ error: blocked.error }, blocked.status); } - db.prepare( + const tid = tenantScope(c.get("user")); + await db.prepare( `INSERT INTO workers (first_name, middle_name, last_name_p, last_name_m, curp, rfc, nss, phone, email, address, blood_type, hire_type, company_id, position, risk_code, work_type, daily_wage, needs_badge, status, tenant_id) @@ -868,20 +941,21 @@ app.post("/v1/workers", requireAuth, async (c) => { ).run( n.first_name, n.middle_name, n.last_name_p, n.last_name_m, n.curp, n.rfc, n.nss, n.phone, n.email, n.address, n.blood_type, "", null, n.position, n.risk_code, - n.work_type, n.daily_wage, n.needs_badge, n.status, tenantScope(c.get("user")), + n.work_type, n.daily_wage, n.needs_badge, n.status, tid, ); - const id = lastInsertId(db); + const id = await lastInsertId(db); if (body.project_id) { - assign(db, id, body.project_id); + await assign(db, id, body.project_id); } - refreshPipeline(db, id); + await refreshPipeline(db, id, tid); return c.json({ id }, 201); }); -app.patch("/v1/workers/:id", requireAuth, async (c) => { +app.patch("/v1/workers/:id", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); - const db = await getDb(); - const cur = db.prepare("SELECT * FROM workers WHERE id = ?").get(id) as Record | undefined; + const db = c.get("db"); + const tid = tenantScope(c.get("user")); + const cur = await db.prepare("SELECT * FROM workers WHERE id = ?").get(id) as Record | undefined; if (!cur) return c.json({ error: "No encontrado" }, 404); const body = await c.req.json(); const merged = { ...cur, ...body } as WorkerInput; @@ -891,85 +965,88 @@ app.patch("/v1/workers/:id", requireAuth, async (c) => { const hireType = String(cur.hire_type || ""); const companyId = (cur.company_id as number | null) ?? null; const n = normalizeWorker({ ...merged, hire_type: hireType }); - const cf = conflict(db, n, id); + const existing = await findExisting(db, n.curp, n.rfc, n.nss); + const cf = conflict(existing, n, id); if (cf) return c.json(cf.body, cf.status); - db.prepare( + await db.prepare( `UPDATE workers SET first_name=?, middle_name=?, last_name_p=?, last_name_m=?, curp=?, rfc=?, nss=?, phone=?, email=?, address=?, blood_type=?, hire_type=?, company_id=?, position=?, risk_code=?, - work_type=?, daily_wage=?, needs_badge=?, status=?, updated_at=datetime('now') + work_type=?, daily_wage=?, needs_badge=?, status=?, updated_at=now() WHERE id=?`, ).run( n.first_name, n.middle_name, n.last_name_p, n.last_name_m, n.curp, n.rfc, n.nss, n.phone, n.email, n.address, n.blood_type, hireType, companyId, n.position, n.risk_code, n.work_type, n.daily_wage, n.needs_badge, n.status, id, ); - refreshPipeline(db, id); + await refreshPipeline(db, id, tid); return c.json({ ok: true }); }); -app.patch("/v1/workers/:id/pipeline", requireAuth, async (c) => { +app.patch("/v1/workers/:id/pipeline", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); const { pipeline_status } = await c.req.json<{ pipeline_status: string }>(); const allowed = ["incompleto", "listo_gafete", "impreso", "activo", "baja"]; if (!allowed.includes(pipeline_status)) return c.json({ error: "Estado inválido" }, 400); - const db = await getDb(); - const prev = db.prepare("SELECT status FROM workers WHERE id=?").get(id) as { status: string } | undefined; + const db = c.get("db"); + const tid = tenantScope(c.get("user")); + const prev = await db.prepare("SELECT status FROM workers WHERE id=?").get(id) as { status: string } | undefined; if (pipeline_status === "baja") { - db.prepare("UPDATE workers SET status='baja', pipeline_status='baja' WHERE id=?").run(id); + await db.prepare("UPDATE workers SET status='baja', pipeline_status='baja' WHERE id=?").run(id); } else { // Reactivar: si venía de baja, marca rehire para exigir docs frescos hasta nueva alta IMSS. if (prev?.status === "baja") { - db.prepare( - "UPDATE workers SET status='activo', last_rehire_at=date('now') WHERE id=?", + await db.prepare( + "UPDATE workers SET status='activo', last_rehire_at=current_date WHERE id=?", ).run(id); } else { - db.prepare("UPDATE workers SET status='activo' WHERE id=?").run(id); + await db.prepare("UPDATE workers SET status='activo' WHERE id=?").run(id); } - refreshPipeline(db, id); + await refreshPipeline(db, id, tid); } - const worker = db.prepare("SELECT status, pipeline_status, last_rehire_at, imss_status FROM workers WHERE id=?").get(id); + const worker = await db.prepare("SELECT status, pipeline_status, last_rehire_at, imss_status FROM workers WHERE id=?").get(id); return c.json({ ok: true, worker }); }); -app.post("/v1/workers/:id/assign", requireAuth, async (c) => { +app.post("/v1/workers/:id/assign", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); const { project_id, active } = await c.req.json<{ project_id: number; active?: boolean }>(); - const db = await getDb(); + const db = c.get("db"); + const tid = tenantScope(c.get("user")); if (active === false) { - db.prepare( - "UPDATE assignments SET active=0, end_date=date('now') WHERE worker_id=? AND project_id=?", + await db.prepare( + "UPDATE assignments SET active=false, end_date=current_date WHERE worker_id=? AND project_id=?", ).run(id, project_id); } else { - const project = projectById(db, project_id); + const project = await projectById(db, project_id); if (!project) return c.json({ error: "Proyecto no encontrado" }, 404); if (project.status !== "activo") { return c.json({ error: "Solo se asigna personal a proyectos activos" }, 400); } - assign(db, id, project_id); + await assign(db, id, project_id); } - refreshPipeline(db, id); + await refreshPipeline(db, id, tid); return c.json({ ok: true }); }); -app.get("/v1/workers/import/template", requireAuth, async (c) => { - const db = await getDb(); - const bytes = buildImportTemplate(listCompanies(db)); +app.get("/v1/workers/import/template", ...requireCoreAuth, async (c) => { + const db = c.get("db"); + const bytes = buildImportTemplate(await listCompanies(db)); c.header("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); c.header("Content-Disposition", 'attachment; filename="plantilla-padron-arctec.xlsx"'); return c.body(bytes.buffer as ArrayBuffer); }); -app.post("/v1/workers/import", requireAuth, async (c) => { +app.post("/v1/workers/import", ...requireCoreAuth, async (c) => { const form = await c.req.formData(); const file = form.get("file"); const projectId = Number(form.get("project_id") || 0) || null; if (!(file instanceof File)) return c.json({ error: "Archivo Excel requerido" }, 400); const bytes = new Uint8Array(await file.arrayBuffer()); - const db = await getDb(); + const db = c.get("db"); if (projectId) { const blocked = projectMustBe( - projectById(db, projectId), + await projectById(db, projectId), ["activo"], "Solo se importan altas a proyectos activos", ); @@ -979,22 +1056,22 @@ app.post("/v1/workers/import", requireAuth, async (c) => { return c.json(report); }); -app.post("/v1/workers/:id/documents", requireAuth, async (c) => { +app.post("/v1/workers/:id/documents", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); const form = await c.req.formData(); const type = String(form.get("type") || ""); const file = form.get("file"); if (!(file instanceof File) || !type) return c.json({ error: "type y file requeridos" }, 400); - const db = await getDb(); - const exists = db.prepare("SELECT id FROM workers WHERE id=?").get(id); + const db = c.get("db"); + const exists = await db.prepare("SELECT id FROM workers WHERE id=?").get(id); if (!exists) return c.json({ error: "No encontrado" }, 404); - const policy = db.prepare( + const policy = await db.prepare( `SELECT validity_mode, requires_issued_at, requires_expires_at FROM document_types WHERE code = ?`, ).get(type) as { validity_mode: string; - requires_issued_at: number; - requires_expires_at: number; + requires_issued_at: boolean; + requires_expires_at: boolean; } | undefined; if (!policy) return c.json({ error: "Tipo de documento no válido" }, 400); @@ -1032,18 +1109,19 @@ app.post("/v1/workers/:id/documents", requireAuth, async (c) => { } catch (error) { return c.json({ error: error instanceof Error ? error.message : "No se pudo guardar" }, 400); } + const tid = tenantScope(c.get("user")); return c.json({ ok: true, - checklist: checklistFor(db, id), - ...imssFlagsFor(db, id), + checklist: await checklistFor(db, id, tid), + ...await imssFlagsFor(db, id, tid), }); }); -app.get("/v1/workers/:id/documents/:docId", requireAuth, async (c) => { +app.get("/v1/workers/:id/documents/:docId", ...requireCoreAuth, async (c) => { const workerId = Number(c.req.param("id")); const docId = Number(c.req.param("docId")); - const db = await getDb(); - const doc = db.prepare( + const db = c.get("db"); + const doc = await db.prepare( "SELECT * FROM documents WHERE id=? AND worker_id=?", ).get(docId, workerId) as { storage_name: string; @@ -1052,25 +1130,31 @@ app.get("/v1/workers/:id/documents/:docId", requireAuth, async (c) => { original_name: string; } | undefined; if (!doc) return c.json({ error: "Documento no encontrado" }, 404); - const enc = await Deno.readFile(join(workerDir(workerId), doc.storage_name)); + const enc = await getObject(workerDocKey(workerId, doc.storage_name)); const plain = await decryptBytes(doc.iv, enc); - c.header("Content-Type", doc.mime); - c.header("Content-Disposition", `inline; filename="${doc.original_name}"`); + c.header("Content-Type", "application/octet-stream"); + c.header("X-Content-Type-Options", "nosniff"); + c.header("Content-Disposition", `attachment; filename="${encodeURIComponent(doc.original_name)}"`); return c.body(plain.buffer as ArrayBuffer); }); -app.get("/v1/workers/:id/photo", requireAuth, async (c) => { +app.get("/v1/workers/:id/photo", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); - const db = await getDb(); + const db = c.get("db"); const bytes = await loadCurrentPhoto(db, id); if (!bytes) return c.json({ error: "Sin foto" }, 404); const jpeg = bytes[0] === 0xff && bytes[1] === 0xd8; + // La foto sí se sirve inline (es una imagen legítima usada en la UI para + // mostrar el rostro de la persona), pero se fija el Content-Type real + // detectado por firma de bytes, no el que declaró quien la subió, y se + // agrega nosniff -- así el navegador no puede reinterpretarla como HTML. c.header("Content-Type", jpeg ? "image/jpeg" : "image/png"); + c.header("X-Content-Type-Options", "nosniff"); c.header("Cache-Control", "private, max-age=30"); return c.body(bytes.buffer as ArrayBuffer); }); -app.get("/v1/badge-qr", requireAuth, async (c) => { +app.get("/v1/badge-qr", ...requireCoreAuth, async (c) => { const curp = (c.req.query("curp") ?? "").trim().toUpperCase(); if (curp.length < 10) return c.json({ error: "CURP requerida" }, 400); const png = await badgeQrPng(curp); @@ -1079,90 +1163,104 @@ app.get("/v1/badge-qr", requireAuth, async (c) => { return c.body(png.buffer as ArrayBuffer); }); -app.post("/v1/projects/:id/badge-jobs", requireAuth, async (c) => { +app.post("/v1/projects/:id/badge-jobs", ...requireCoreAuth, async (c) => { const projectId = Number(c.req.param("id")); const body = await c.req.json<{ worker_ids?: number[] }>().catch(() => ({ worker_ids: [] as number[] })); - const db = await getDb(); + const db = c.get("db"); const blocked = projectMustBe( - projectById(db, projectId), + await projectById(db, projectId), ["activo"], "Solo se generan gafetes de proyectos activos", ); if (blocked) return c.json({ error: blocked.error }, blocked.status); let ids = body.worker_ids ?? []; if (!ids.length) { - ids = (db.prepare( + ids = (await db.prepare( `SELECT w.id FROM workers w - JOIN assignments a ON a.worker_id = w.id AND a.project_id = ? AND a.active = 1 - WHERE w.status = 'activo' AND w.needs_badge = 1 - AND EXISTS (SELECT 1 FROM documents d WHERE d.worker_id=w.id AND d.type_code='foto' AND d.is_current=1)`, + JOIN assignments a ON a.worker_id = w.id AND a.project_id = ? AND a.active = true + WHERE w.status = 'activo' AND w.needs_badge = true + AND EXISTS (SELECT 1 FROM documents d WHERE d.worker_id=w.id AND d.type_code='foto' AND d.is_current=true)`, ).all(projectId) as { id: number }[]).map((r) => r.id); } if (!ids.length) return c.json({ error: "No hay personal activo con foto para imprimir" }, 400); const bytes = await generateBadgePdf(db, projectId, ids); - db.prepare( - "INSERT INTO badge_jobs (project_id, status, created_by) VALUES (?, 'done', ?)", - ).run(projectId, c.get("user").id); - const jobId = lastInsertId(db); + const user = c.get("user"); + await db.prepare( + "INSERT INTO badge_jobs (project_id, status, created_by_id, created_by_name) VALUES (?, 'done', ?, ?)", + ).run(projectId, user.id, user.display_name); + const jobId = await lastInsertId(db); const path = await saveJobPdf(bytes, jobId); - db.prepare("UPDATE badge_jobs SET pdf_path=? WHERE id=?").run(path, jobId); + await db.prepare("UPDATE badge_jobs SET pdf_path=? WHERE id=?").run(path, jobId); const ins = db.prepare("INSERT INTO badge_job_people (job_id, worker_id) VALUES (?, ?)"); + const tid = tenantScope(user); for (const wid of ids) { - ins.run(jobId, wid); - refreshPipeline(db, wid); + await ins.run(jobId, wid); + await refreshPipeline(db, wid, tid); } return c.json({ id: jobId, count: ids.length }); }); -app.get("/v1/badge-jobs", requireAuth, async (c) => { - const db = await getDb(); - const jobs = db.prepare( +app.get("/v1/badge-jobs", ...requireCoreAuth, async (c) => { + const db = c.get("db"); + const jobs = await db.prepare( `SELECT j.*, p.name AS project_name, (SELECT COUNT(*) FROM badge_job_people x WHERE x.job_id=j.id) AS people, - (SELECT COUNT(*) FROM badge_job_people x WHERE x.job_id=j.id AND x.delivered=1) AS delivered + (SELECT COUNT(*) FROM badge_job_people x WHERE x.job_id=j.id AND x.delivered=true) AS delivered FROM badge_jobs j JOIN projects p ON p.id=j.project_id ORDER BY j.id DESC`, ).all(); return c.json({ jobs }); }); -app.get("/v1/badge-jobs/:id", requireAuth, async (c) => { +app.get("/v1/badge-jobs/:id", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); - const db = await getDb(); - const job = db.prepare("SELECT * FROM badge_jobs WHERE id=?").get(id); + const db = c.get("db"); + const job = await db.prepare("SELECT * FROM badge_jobs WHERE id=?").get(id); if (!job) return c.json({ error: "Job no encontrado" }, 404); - const people = db.prepare( + const people = await db.prepare( `SELECT p.*, w.first_name, w.last_name_p, w.position FROM badge_job_people p JOIN workers w ON w.id = p.worker_id WHERE p.job_id=?`, ).all(id); return c.json({ job, people }); }); -app.get("/v1/badge-jobs/:id/pdf", requireAuth, async (c) => { +app.get("/v1/badge-jobs/:id/pdf", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); - const db = await getDb(); - const job = db.prepare("SELECT pdf_path FROM badge_jobs WHERE id=?").get(id) as { pdf_path: string } | undefined; + const db = c.get("db"); + const job = await db.prepare("SELECT pdf_path FROM badge_jobs WHERE id=?").get(id) as { pdf_path: string } | undefined; if (!job?.pdf_path) return c.json({ error: "PDF no encontrado" }, 404); - const bytes = await Deno.readFile(job.pdf_path); + const bytes = await getObject(job.pdf_path); c.header("Content-Type", "application/pdf"); c.header("Content-Disposition", `attachment; filename="gafetes-${id}.pdf"`); return c.body(bytes.buffer as ArrayBuffer); }); -app.patch("/v1/badge-jobs/:id/people/:workerId", requireAuth, async (c) => { +app.patch("/v1/badge-jobs/:id/people/:workerId", ...requireCoreAuth, async (c) => { const jobId = Number(c.req.param("id")); const workerId = Number(c.req.param("workerId")); const { delivered } = await c.req.json<{ delivered: boolean }>(); - const db = await getDb(); - db.prepare( - `UPDATE badge_job_people SET delivered=?, delivered_at=CASE WHEN ? THEN datetime('now') ELSE NULL END + const db = c.get("db"); + await db.prepare( + `UPDATE badge_job_people SET delivered=?, delivered_at=CASE WHEN ? THEN now() ELSE NULL END WHERE job_id=? AND worker_id=?`, - ).run(delivered ? 1 : 0, delivered ? 1 : 0, jobId, workerId); + ).run(delivered, delivered, jobId, workerId); return c.json({ ok: true }); }); registerPayrollRoutes(app); const port = config.port; -await getDb(); + +// Fail-fast (Fase 7): antes con SQLite la app "siempre arrancaba" (creaba +// el archivo si no existía). Con Postgres/Redis, si alguna de las 5 +// conexiones no responde al arrancar, es mejor fallar ruidosamente que +// dejar que el primer request autenticado descubra el problema. +await Promise.all([ + pingCoreDb(), + pingPlatformDb(), + pingRedis().then((r) => { + if (!r.iam || !r.core) throw new Error("Redis (iam/core) no responde"); + }), +]); + Deno.serve({ port, hostname: "0.0.0.0" }, app.fetch); console.log(`API panel-obra en http://127.0.0.1:${port}`); diff --git a/api/mx.ts b/api/mx.ts index 4a7c030..9b8a110 100644 --- a/api/mx.ts +++ b/api/mx.ts @@ -174,7 +174,7 @@ export function normalizeWorker(body: WorkerInput) { 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 ? 0 : 1, + needs_badge: !(body.needs_badge === false || body.needs_badge === 0), status: body.status === "baja" ? "baja" : "activo", }; } diff --git a/api/payroll.ts b/api/payroll.ts index 6023931..62f34d6 100644 --- a/api/payroll.ts +++ b/api/payroll.ts @@ -1,6 +1,6 @@ -import type { Database } from "@db/sqlite"; +import type { Db } from "./db.ts"; +import { tenantTimezone, todayInTimezone } from "./db.ts"; -export const PAYROLL_TZ = "America/Cancun"; export const DEFAULT_UNITS = [ { code: "m", label: "Metro (m)" }, { code: "m2", label: "Metro cuadrado (m²)" }, @@ -13,7 +13,7 @@ export type WeekStatus = "draft" | "assembled" | "paid"; export type PayrollSettings = { tenant_id: number; - loan_commission_enabled: number; + loan_commission_enabled: boolean; loan_commission_pct: number; loan_small_max: number; }; @@ -33,10 +33,6 @@ type LoanRow = { created_at: string; }; -function lid(db: Database): number { - return Number((db.prepare("SELECT last_insert_rowid() AS id").get() as { id: number }).id); -} - export function roundMoney(n: number): number { return Math.round((Number(n) || 0) * 100) / 100; } @@ -45,8 +41,19 @@ export function commissionAmount(delivered: number, pct: number): number { return roundMoney(delivered * (Number(pct) || 0) / 100); } -export function todayIso(now = new Date()): string { - return now.toLocaleDateString("en-CA", { timeZone: PAYROLL_TZ }); +/** "Hoy" en una zona horaria dada (Fase 4d: por tenant, ya no PAYROLL_TZ + * hardcodeado). Se mantiene aquí como alias delgado sobre + * db.ts#todayInTimezone para no duplicar la lógica. */ +export function todayIso(timezone: string, now = new Date()): string { + return todayInTimezone(timezone, now); +} + +/** Resuelve "hoy" para un tenant consultando su configuración. Punto de + * entrada único para los endpoints que antes usaban `todayIso()` sin + * argumentos (implícitamente America/Cancun para todos los tenants). */ +export async function resolveToday(db: Db, tenantId: number): Promise { + const tz = await tenantTimezone(db, tenantId); + return todayInTimezone(tz); } export function addDays(iso: string, days: number): string { @@ -87,7 +94,7 @@ export function weekAssembleDate(weekStart: string): string { return addDays(weekStart, 3); } -export function canAssembleWeek(weekStart: string, today = todayIso()): boolean { +export function canAssembleWeek(weekStart: string, today: string): boolean { return today >= weekAssembleDate(weekStart); } @@ -110,60 +117,59 @@ export function loanConditionLabel( return `${moneyLabel(payAmount)} · ${installmentN}/${loan.installments_n || 1} · ${comm}`; } -export function getSettings(db: Database, tenantId: number): PayrollSettings { - seedDefaults(db, tenantId); - const row = db.prepare("SELECT * FROM payroll_settings WHERE tenant_id=?").get(tenantId) as PayrollSettings; - return row; +export async function getSettings(db: Db, tenantId: number): Promise { + await seedDefaults(db, tenantId); + return await db.prepare("SELECT * FROM payroll_settings WHERE tenant_id=?").get(tenantId) as PayrollSettings; } -export function saveSettings( - db: Database, +export async function saveSettings( + db: Db, tenantId: number, patch: Partial, -): PayrollSettings { - const cur = getSettings(db, tenantId); +): Promise { + const cur = await getSettings(db, tenantId); const next = { loan_commission_enabled: patch.loan_commission_enabled ?? cur.loan_commission_enabled, loan_commission_pct: patch.loan_commission_pct ?? cur.loan_commission_pct, loan_small_max: patch.loan_small_max ?? cur.loan_small_max, }; - db.prepare( + await db.prepare( `UPDATE payroll_settings SET loan_commission_enabled=?, loan_commission_pct=?, loan_small_max=? WHERE tenant_id=?`, ).run( - next.loan_commission_enabled ? 1 : 0, + Boolean(next.loan_commission_enabled), Number(next.loan_commission_pct) || 0, Number(next.loan_small_max) || 0, tenantId, ); - return getSettings(db, tenantId); + return await getSettings(db, tenantId); } -export function seedDefaults(db: Database, tenantId: number) { - db.prepare( +export async function seedDefaults(db: Db, tenantId: number): Promise { + await db.prepare( `INSERT INTO payroll_settings (tenant_id, loan_commission_enabled, loan_commission_pct, loan_small_max) - VALUES (?, 1, 10, 500) + VALUES (?, true, 10, 500) ON CONFLICT(tenant_id) DO NOTHING`, ).run(tenantId); - const n = db.prepare("SELECT COUNT(*) AS n FROM destajo_units WHERE tenant_id=?").get(tenantId) as { n: number }; - if (n.n === 0) { + const n = await db.prepare("SELECT COUNT(*) AS n FROM destajo_units WHERE tenant_id=?").get(tenantId) as { n: number }; + if (Number(n.n) === 0) { const ins = db.prepare("INSERT INTO destajo_units (tenant_id, code, label) VALUES (?, ?, ?)"); - for (const u of DEFAULT_UNITS) ins.run(tenantId, u.code, u.label); + for (const u of DEFAULT_UNITS) await ins.run(tenantId, u.code, u.label); } } -export function listUnits(db: Database, tenantId: number) { - seedDefaults(db, tenantId); - return db.prepare("SELECT * FROM destajo_units WHERE tenant_id=? ORDER BY id").all(tenantId); +export async function listUnits(db: Db, tenantId: number) { + await seedDefaults(db, tenantId); + return await db.prepare("SELECT * FROM destajo_units WHERE tenant_id=? ORDER BY id").all(tenantId); } -export function addUnit(db: Database, tenantId: number, code: string, label: string) { +export async function addUnit(db: Db, tenantId: number, code: string, label: string): Promise { const c = code.trim().toLowerCase().replace(/\s+/g, ""); const l = label.trim() || c; if (!c) throw new Error("Unidad requerida"); - db.prepare("INSERT INTO destajo_units (tenant_id, code, label) VALUES (?, ?, ?)").run(tenantId, c, l); - return lid(db); + await db.prepare("INSERT INTO destajo_units (tenant_id, code, label) VALUES (?, ?, ?)").run(tenantId, c, l); + return await db.lastInsertId(); } export function firstDueForLoan(opts: { @@ -179,8 +185,8 @@ export function firstDueForLoan(opts: { return thisSaturday(opts.grantIso); } -export function createLoan( - db: Database, +export async function createLoan( + db: Db, tenantId: number, body: { worker_id: number; @@ -192,10 +198,10 @@ export function createLoan( note?: string; grantIso?: string; }, -): { id: number } { - const worker = db.prepare("SELECT id FROM workers WHERE id=?").get(body.worker_id); +): Promise<{ id: number }> { + const worker = await db.prepare("SELECT id FROM workers WHERE id=?").get(body.worker_id); if (!worker) throw Object.assign(new Error("Persona no encontrada"), { status: 404 }); - const settings = getSettings(db, tenantId); + const settings = await getSettings(db, tenantId); const delivered = roundMoney(body.delivered); if (delivered <= 0) throw Object.assign(new Error("Monto requerido"), { status: 400 }); const enabled = body.commission_enabled ?? Boolean(settings.loan_commission_enabled); @@ -212,9 +218,9 @@ export function createLoan( if (plan === "installments" && installments < 2) installments = 2; if (plan === "single") installments = 1; const weekly = roundMoney(balance / installments); - const grantIso = body.grantIso ?? todayIso(); + const grantIso = body.grantIso ?? await resolveToday(db, tenantId); const week = weekContaining(grantIso); - const current = db.prepare( + const current = await db.prepare( "SELECT id, status FROM payroll_weeks WHERE tenant_id=? AND week_start=?", ).get(tenantId, week.weekStart) as { id: number; status: string } | undefined; const assembled = current?.status === "assembled" || current?.status === "paid"; @@ -225,7 +231,7 @@ export function createLoan( grantIso, weekAssembled: assembled && weekdayMon0(grantIso) >= 4, }); - db.prepare( + await db.prepare( `INSERT INTO loans (worker_id, amount, delivered, balance, weekly_payment, note, commission_pct, commission_amount, plan, installments_n, first_due) @@ -243,21 +249,21 @@ export function createLoan( installments, firstDue, ); - const id = lid(db); - if (current && current.status !== "paid") recalcWeek(db, current.id); + const id = await db.lastInsertId(); + if (current && current.status !== "paid") await recalcWeek(db, current.id); return { id }; } -export function presentOnOtherProject( - db: Database, +export async function presentOnOtherProject( + db: Db, workerId: number, workDate: string, exceptProjectId?: number, -): { project_id: number; project_name: string } | null { - const row = db.prepare( +): Promise<{ project_id: number; project_name: string } | null> { + const row = await db.prepare( `SELECT a.project_id, p.name AS project_name FROM attendance a JOIN projects p ON p.id=a.project_id - WHERE a.worker_id=? AND a.work_date=? AND a.present=1 + WHERE a.worker_id=? AND a.work_date=? AND a.present=true AND a.project_id != ?`, ).get(workerId, workDate, exceptProjectId ?? 0) as | { project_id: number; project_name: string } @@ -265,13 +271,13 @@ export function presentOnOtherProject( return row ?? null; } -export function setAttendance( - db: Database, +export async function setAttendance( + db: Db, tenantId: number, body: { project_id: number; worker_id: number; work_date: string; present: boolean }, -): { ok: true } | { error: string; status: number; other?: { project_id: number; project_name: string } } { +): Promise<{ ok: true } | { error: string; status: number; other?: { project_id: number; project_name: string } }> { if (body.present) { - const other = presentOnOtherProject(db, body.worker_id, body.work_date, body.project_id); + const other = await presentOnOtherProject(db, body.worker_id, body.work_date, body.project_id); if (other) { return { error: `Ese día ya está marcado en ${other.project_name}`, @@ -280,45 +286,45 @@ export function setAttendance( }; } } - db.prepare( + await db.prepare( `INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (?, ?, ?, ?) ON CONFLICT(worker_id, project_id, work_date) DO UPDATE SET present=excluded.present`, - ).run(body.worker_id, body.project_id, body.work_date, body.present ? 1 : 0); + ).run(body.worker_id, body.project_id, body.work_date, body.present); const { weekStart } = weekContaining(body.work_date); - const week = db.prepare( + const week = await db.prepare( "SELECT id, status FROM payroll_weeks WHERE tenant_id=? AND week_start=?", ).get(tenantId, weekStart) as { id: number; status: string } | undefined; - if (week && week.status !== "paid") recalcWeek(db, week.id); + if (week && week.status !== "paid") await recalcWeek(db, week.id); return { ok: true }; } -export function ensureWeek(db: Database, tenantId: number, weekStart: string): number { - seedDefaults(db, tenantId); +export async function ensureWeek(db: Db, tenantId: number, weekStart: string): Promise { + await seedDefaults(db, tenantId); const { weekEnd } = weekContaining(weekStart); const start = weekContaining(weekStart).weekStart; - let week = db.prepare( + let week = await db.prepare( "SELECT id FROM payroll_weeks WHERE tenant_id=? AND week_start=?", ).get(tenantId, start) as { id: number } | undefined; if (!week) { - db.prepare( + await db.prepare( `INSERT INTO payroll_weeks (week_start, week_end, status, tenant_id) VALUES (?, ?, 'draft', ?)`, ).run(start, weekEnd, tenantId); - week = { id: lid(db) }; + week = { id: await db.lastInsertId() }; } const weekId = week.id; const destajo = destajoPeriodBounds(start); - const period = db.prepare( + const period = await db.prepare( "SELECT id FROM destajo_periods WHERE tenant_id=? AND period_end=?", ).get(tenantId, destajo.periodEnd) as { id: number } | undefined; if (!period) { - db.prepare( + await db.prepare( `INSERT INTO destajo_periods (period_start, period_end, week_id, tenant_id) VALUES (?, ?, ?, ?)`, ).run(destajo.periodStart, destajo.periodEnd, weekId, tenantId); } - const projects = db.prepare( + const projects = await db.prepare( `SELECT id FROM projects WHERE status IN ('activo', 'pausado') AND (? = 0 OR tenant_id = ? OR tenant_id IS NULL) @@ -326,32 +332,32 @@ export function ensureWeek(db: Database, tenantId: number, weekStart: string): n ).all(tenantId, tenantId) as { id: number }[]; const hasSheet = db.prepare( `SELECT id FROM payroll_sheets - WHERE week_id=? AND kind=? AND IFNULL(project_id, 0)=IFNULL(?, 0)`, + WHERE week_id=? AND kind=? AND COALESCE(project_id, 0)=COALESCE(?, 0)`, ); const insSheet = db.prepare( `INSERT INTO payroll_sheets (week_id, kind, project_id) VALUES (?, ?, ?)`, ); - const ensureSheet = (kind: string, projectId: number | null) => { - if (hasSheet.get(weekId, kind, projectId)) return; - insSheet.run(weekId, kind, projectId); + const ensureSheet = async (kind: string, projectId: number | null) => { + if (await hasSheet.get(weekId, kind, projectId)) return; + await insSheet.run(weekId, kind, projectId); }; for (const p of projects) { - ensureSheet("obra", p.id); - ensureSheet("destajo", p.id); + await ensureSheet("obra", p.id); + await ensureSheet("destajo", p.id); } - ensureSheet("admin", null); + await ensureSheet("admin", null); return weekId; } -function jornalRoster(db: Database, projectId: number, weekStart: string, weekEnd: string, weekId = 0) { - return db.prepare( +async function jornalRoster(db: Db, projectId: number, weekStart: string, weekEnd: string, weekId = 0) { + return await db.prepare( `SELECT w.id, w.first_name, w.last_name_p, w.position, w.daily_wage, w.work_type FROM workers w WHERE w.status='activo' AND w.pipeline_status != 'baja' AND ( EXISTS ( SELECT 1 FROM assignments a - WHERE a.worker_id=w.id AND a.project_id=? AND a.active=1 AND w.work_type='N' + WHERE a.worker_id=w.id AND a.project_id=? AND a.active=true AND w.work_type='N' ) OR EXISTS ( SELECT 1 FROM attendance att @@ -374,40 +380,40 @@ function jornalRoster(db: Database, projectId: number, weekStart: string, weekEn }[]; } -function sheetLine( - db: Database, +async function sheetLine( + db: Db, sheetId: number, workerId: number, destajoCutLineId: number | null, -): number { - const existing = db.prepare( +): Promise { + const existing = await db.prepare( destajoCutLineId ? "SELECT id FROM payroll_week_lines WHERE sheet_id=? AND destajo_cut_line_id=?" : "SELECT id FROM payroll_week_lines WHERE sheet_id=? AND worker_id=? AND destajo_cut_line_id IS NULL", ).get(sheetId, destajoCutLineId ?? workerId) as { id: number } | undefined; if (existing) return existing.id; - db.prepare( + await db.prepare( `INSERT INTO payroll_week_lines (sheet_id, worker_id, destajo_cut_line_id) VALUES (?, ?, ?)`, ).run(sheetId, workerId, destajoCutLineId); - return lid(db); + return await db.lastInsertId(); } -function dueLoansForSaturday(db: Database, workerId: number, saturday: string): LoanRow[] { - return db.prepare( +async function dueLoansForSaturday(db: Db, workerId: number, saturday: string): Promise { + return await db.prepare( `SELECT * FROM loans WHERE worker_id=? AND balance > 0 AND first_due IS NOT NULL AND first_due <= ? ORDER BY first_due, id`, ).all(workerId, saturday) as LoanRow[]; } -function paidInstallments(db: Database, loanId: number): number { - const row = db.prepare("SELECT COUNT(*) AS n FROM loan_payments WHERE loan_id=?").get(loanId) as { n: number }; - return row.n; +async function paidInstallments(db: Db, loanId: number): Promise { + const row = await db.prepare("SELECT COUNT(*) AS n FROM loan_payments WHERE loan_id=?").get(loanId) as { n: number }; + return Number(row.n); } -export function recalcWeek(db: Database, weekId: number) { - const week = db.prepare("SELECT * FROM payroll_weeks WHERE id=?").get(weekId) as { +export async function recalcWeek(db: Db, weekId: number): Promise { + const week = await db.prepare("SELECT * FROM payroll_weeks WHERE id=?").get(weekId) as { id: number; week_start: string; week_end: string; @@ -416,43 +422,42 @@ export function recalcWeek(db: Database, weekId: number) { tenant_id: number; } | undefined; if (!week || week.status === "paid") return; - const days = weekDays(week.week_start); const saturday = week.week_end; - const sheets = db.prepare("SELECT * FROM payroll_sheets WHERE week_id=?").all(weekId) as { + const sheets = await db.prepare("SELECT * FROM payroll_sheets WHERE week_id=?").all(weekId) as { id: number; kind: string; project_id: number | null; }[]; - db.prepare("UPDATE payroll_week_lines SET loan_id=NULL, loan_discount=0, loan_label=NULL WHERE sheet_id IN (SELECT id FROM payroll_sheets WHERE week_id=?)") + await db.prepare("UPDATE payroll_week_lines SET loan_id=NULL, loan_discount=0, loan_label=NULL WHERE sheet_id IN (SELECT id FROM payroll_sheets WHERE week_id=?)") .run(weekId); for (const sheet of sheets) { if (sheet.kind === "obra" && sheet.project_id) { - const roster = jornalRoster(db, sheet.project_id, week.week_start, week.week_end, weekId); + const roster = await jornalRoster(db, sheet.project_id, week.week_start, week.week_end, weekId); for (const w of roster) { - const lineId = sheetLine(db, sheet.id, w.id, null); - const att = db.prepare( + const lineId = await sheetLine(db, sheet.id, w.id, null); + const att = await db.prepare( `SELECT COUNT(*) AS n FROM attendance - WHERE worker_id=? AND project_id=? AND work_date BETWEEN ? AND ? AND present=1`, + WHERE worker_id=? AND project_id=? AND work_date BETWEEN ? AND ? AND present=true`, ).get(w.id, sheet.project_id, week.week_start, week.week_end) as { n: number }; - const frozen = db.prepare("SELECT daily_wage FROM payroll_week_lines WHERE id=?").get(lineId) as { daily_wage: number }; + const frozen = await db.prepare("SELECT daily_wage FROM payroll_week_lines WHERE id=?").get(lineId) as { daily_wage: number }; const wage = week.status === "assembled" && frozen.daily_wage > 0 ? frozen.daily_wage : w.daily_wage; - const gross = roundMoney(att.n * wage); - db.prepare( + const gross = roundMoney(Number(att.n) * wage); + await db.prepare( `UPDATE payroll_week_lines SET days=?, daily_wage=?, amount=?, gross=?, payable_net=? WHERE id=?`, - ).run(att.n, wage, gross, gross, gross, lineId); + ).run(Number(att.n), wage, gross, gross, gross, lineId); } } if (sheet.kind === "destajo" && sheet.project_id) { - const period = db.prepare( + const period = await db.prepare( "SELECT id FROM destajo_periods WHERE week_id=?", ).get(weekId) as { id: number } | undefined; if (!period) continue; - const cuts = db.prepare( + const cuts = await db.prepare( `SELECT c.*, j.worker_id, j.unit_price, j.unit_code, j.concepto FROM destajo_cut_lines c JOIN destajo_jobs j ON j.id=c.job_id @@ -469,7 +474,7 @@ export function recalcWeek(db: Database, weekId: number) { }[]; const keep = new Set(); for (const cut of cuts) { - const lineId = sheetLine(db, sheet.id, cut.worker_id, cut.id); + const lineId = await sheetLine(db, sheet.id, cut.worker_id, cut.id); keep.add(lineId); const payableQty = roundMoney( week.status === "draft" && cut.qty_actual === 0 && cut.qty_extra === 0 @@ -478,7 +483,7 @@ export function recalcWeek(db: Database, weekId: number) { ); const gross = roundMoney(payableQty * cut.unit_price); const requiredGross = roundMoney(cut.qty_planned * cut.unit_price); - db.prepare( + await db.prepare( `UPDATE payroll_week_lines SET destajo_cut_line_id=?, qty_planned=?, qty_actual=?, qty_extra=?, unit_price=?, unit_code=?, concepto=?, amount=?, gross=?, payable_net=? @@ -497,23 +502,23 @@ export function recalcWeek(db: Database, weekId: number) { lineId, ); } - const stale = db.prepare( + const stale = await db.prepare( "SELECT id, destajo_cut_line_id FROM payroll_week_lines WHERE sheet_id=?", ).all(sheet.id) as { id: number; destajo_cut_line_id: number | null }[]; for (const row of stale) { - if (!keep.has(row.id)) db.prepare("DELETE FROM payroll_week_lines WHERE id=?").run(row.id); + if (!keep.has(row.id)) await db.prepare("DELETE FROM payroll_week_lines WHERE id=?").run(row.id); } } if (sheet.kind === "admin") { - const lines = db.prepare("SELECT id, amount, discounts FROM payroll_week_lines WHERE sheet_id=?").all(sheet.id) as { + const lines = await db.prepare("SELECT id, amount, discounts FROM payroll_week_lines WHERE sheet_id=?").all(sheet.id) as { id: number; amount: number; discounts: number; }[]; for (const line of lines) { const gross = roundMoney(line.amount); - db.prepare("UPDATE payroll_week_lines SET gross=?, payable_net=? WHERE id=?").run( + await db.prepare("UPDATE payroll_week_lines SET gross=?, payable_net=? WHERE id=?").run( gross, roundMoney(gross - line.discounts), line.id, @@ -522,7 +527,7 @@ export function recalcWeek(db: Database, weekId: number) { } } - const allLines = db.prepare( + const allLines = await db.prepare( `SELECT l.id, l.worker_id, l.gross, l.discounts, l.payable_net FROM payroll_week_lines l JOIN payroll_sheets s ON s.id=l.sheet_id @@ -539,12 +544,12 @@ export function recalcWeek(db: Database, weekId: number) { for (const [workerId, lines] of byWorker) { const gross = roundMoney(lines.reduce((s, l) => s + l.gross - l.discounts, 0)); - const loans = dueLoansForSaturday(db, workerId, saturday); + const loans = await dueLoansForSaturday(db, workerId, saturday); let remainingNet = Math.max(0, gross); let placed = false; for (const loan of loans) { if (remainingNet <= 0) break; - const n = paidInstallments(db, loan.id) + 1; + const n = await paidInstallments(db, loan.id) + 1; const isLast = n >= (loan.installments_n || 1); const want = isLast ? loan.balance : Math.min(loan.weekly_payment || loan.balance, loan.balance); const take = roundMoney(Math.min(want, remainingNet)); @@ -553,7 +558,7 @@ export function recalcWeek(db: Database, weekId: number) { if (!placed) { const host = lines[0]; const label = loanConditionLabel(loan, take, n); - db.prepare( + await db.prepare( `UPDATE payroll_week_lines SET loan_id=?, loan_discount=?, loan_label=?, payable_net=? WHERE id=?`, ).run(loan.id, take, label, roundMoney(host.gross - host.discounts - take), host.id); placed = true; @@ -562,14 +567,14 @@ export function recalcWeek(db: Database, weekId: number) { } if (!placed) { for (const line of lines) { - db.prepare("UPDATE payroll_week_lines SET payable_net=? WHERE id=?").run( + await db.prepare("UPDATE payroll_week_lines SET payable_net=? WHERE id=?").run( roundMoney(line.gross - line.discounts), line.id, ); } } else { for (const line of lines.slice(1)) { - db.prepare("UPDATE payroll_week_lines SET payable_net=? WHERE id=?").run( + await db.prepare("UPDATE payroll_week_lines SET payable_net=? WHERE id=?").run( roundMoney(line.gross - line.discounts), line.id, ); @@ -577,37 +582,37 @@ export function recalcWeek(db: Database, weekId: number) { } } - const tot = db.prepare( + const tot = await db.prepare( `SELECT COALESCE(SUM(l.payable_net), 0) AS n FROM payroll_week_lines l JOIN payroll_sheets s ON s.id=l.sheet_id WHERE s.week_id=?`, ).get(weekId) as { n: number }; - db.prepare("UPDATE payroll_weeks SET payable_net=? WHERE id=?").run(roundMoney(tot.n), weekId); + await db.prepare("UPDATE payroll_weeks SET payable_net=? WHERE id=?").run(roundMoney(tot.n), weekId); if (week.status === "draft") { - db.prepare("UPDATE payroll_weeks SET required_net=? WHERE id=?").run(roundMoney(tot.n), weekId); - db.prepare( + await db.prepare("UPDATE payroll_weeks SET required_net=? WHERE id=?").run(roundMoney(tot.n), weekId); + await db.prepare( `UPDATE payroll_week_lines SET required_net=payable_net WHERE sheet_id IN (SELECT id FROM payroll_sheets WHERE week_id=?)`, ).run(weekId); } } -function prefillThuSat(db: Database, projectId: number, weekStart: string, workerId: number) { +async function prefillThuSat(db: Db, projectId: number, weekStart: string, workerId: number): Promise { for (const offset of [3, 4, 5]) { const day = addDays(weekStart, offset); - if (presentOnOtherProject(db, workerId, day, projectId)) continue; - const existing = db.prepare( + if (await presentOnOtherProject(db, workerId, day, projectId)) continue; + const existing = await db.prepare( "SELECT id, present FROM attendance WHERE worker_id=? AND project_id=? AND work_date=?", - ).get(workerId, projectId, day) as { id: number; present: number } | undefined; + ).get(workerId, projectId, day) as { id: number; present: boolean } | undefined; if (existing) continue; - db.prepare( - "INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (?, ?, ?, 1)", + await db.prepare( + "INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (?, ?, ?, true)", ).run(workerId, projectId, day); } } -export function assembleWeek(db: Database, weekId: number, nowIso = todayIso()) { - const week = db.prepare("SELECT * FROM payroll_weeks WHERE id=?").get(weekId) as { +export async function assembleWeek(db: Db, weekId: number, nowIso: string): Promise { + const week = await db.prepare("SELECT * FROM payroll_weeks WHERE id=?").get(weekId) as { id: number; week_start: string; status: string; @@ -621,47 +626,47 @@ export function assembleWeek(db: Database, weekId: number, nowIso = todayIso()) { status: 400 }, ); } - const sheets = db.prepare( + const sheets = await db.prepare( "SELECT * FROM payroll_sheets WHERE week_id=? AND kind='obra'", ).all(weekId) as { id: number; project_id: number | null }[]; for (const sheet of sheets) { if (!sheet.project_id) continue; - const roster = jornalRoster(db, sheet.project_id, week.week_start, addDays(week.week_start, 5), weekId); + const roster = await jornalRoster(db, sheet.project_id, week.week_start, addDays(week.week_start, 5), weekId); for (const w of roster) { - const assigned = db.prepare( - "SELECT 1 AS ok FROM assignments WHERE worker_id=? AND project_id=? AND active=1", + const assigned = await db.prepare( + "SELECT 1 AS ok FROM assignments WHERE worker_id=? AND project_id=? AND active=true", ).get(w.id, sheet.project_id) as { ok: number } | undefined; if (!assigned) continue; if (w.work_type !== "N") { - const hasLine = db.prepare( + const hasLine = await db.prepare( "SELECT id FROM payroll_week_lines WHERE sheet_id=? AND worker_id=?", ).get(sheet.id, w.id); if (!hasLine) continue; } - prefillThuSat(db, sheet.project_id, week.week_start, w.id); + await prefillThuSat(db, sheet.project_id, week.week_start, w.id); } } - const period = db.prepare("SELECT id FROM destajo_periods WHERE week_id=?").get(weekId) as { id: number } | undefined; + const period = await db.prepare("SELECT id FROM destajo_periods WHERE week_id=?").get(weekId) as { id: number } | undefined; if (period) { - db.prepare( + await db.prepare( `UPDATE destajo_cut_lines SET qty_actual = qty_planned WHERE period_id=? AND qty_actual=0 AND qty_planned>0`, ).run(period.id); } - db.prepare( - "UPDATE payroll_weeks SET status='assembled', assembled_at=datetime('now') WHERE id=?", + await db.prepare( + "UPDATE payroll_weeks SET status='assembled', assembled_at=now() WHERE id=?", ).run(weekId); - recalcWeek(db, weekId); - const payable = db.prepare("SELECT payable_net FROM payroll_weeks WHERE id=?").get(weekId) as { payable_net: number }; - db.prepare("UPDATE payroll_weeks SET required_net=? WHERE id=?").run(payable.payable_net, weekId); - db.prepare( + await recalcWeek(db, weekId); + const payable = await db.prepare("SELECT payable_net FROM payroll_weeks WHERE id=?").get(weekId) as { payable_net: number }; + await db.prepare("UPDATE payroll_weeks SET required_net=? WHERE id=?").run(payable.payable_net, weekId); + await db.prepare( `UPDATE payroll_week_lines SET required_net=payable_net WHERE sheet_id IN (SELECT id FROM payroll_sheets WHERE week_id=?)`, ).run(weekId); } -export function payWeek(db: Database, weekId: number) { - const week = db.prepare("SELECT * FROM payroll_weeks WHERE id=?").get(weekId) as { +export async function payWeek(db: Db, weekId: number, nowIso: string): Promise { + const week = await db.prepare("SELECT * FROM payroll_weeks WHERE id=?").get(weekId) as { id: number; week_end: string; status: string; @@ -669,114 +674,114 @@ export function payWeek(db: Database, weekId: number) { if (!week) throw Object.assign(new Error("Semana no encontrada"), { status: 404 }); if (week.status === "paid") throw Object.assign(new Error("La semana ya está pagada"), { status: 400 }); if (week.status !== "assembled") { - assembleWeek(db, weekId); + await assembleWeek(db, weekId, nowIso); } - recalcWeek(db, weekId); - const lines = db.prepare( + await recalcWeek(db, weekId); + const lines = await db.prepare( `SELECT l.loan_id, l.loan_discount, l.loan_label, l.worker_id FROM payroll_week_lines l JOIN payroll_sheets s ON s.id=l.sheet_id WHERE s.week_id=? AND l.loan_id IS NOT NULL AND l.loan_discount > 0`, ).all(weekId) as { loan_id: number; loan_discount: number; loan_label: string; worker_id: number }[]; for (const line of lines) { - const loan = db.prepare("SELECT * FROM loans WHERE id=?").get(line.loan_id) as LoanRow | undefined; + const loan = await db.prepare("SELECT * FROM loans WHERE id=?").get(line.loan_id) as LoanRow | undefined; if (!loan || loan.balance <= 0) continue; const take = roundMoney(Math.min(line.loan_discount, loan.balance)); if (take <= 0) continue; - const n = paidInstallments(db, loan.id) + 1; - db.prepare( + const n = await paidInstallments(db, loan.id) + 1; + await db.prepare( `INSERT INTO loan_payments (loan_id, week_id, amount, installment_n, label) VALUES (?, ?, ?, ?, ?)`, ).run(loan.id, weekId, take, n, line.loan_label); - db.prepare("UPDATE loans SET balance = ROUND(balance - ?, 2) WHERE id=?").run(take, loan.id); + await db.prepare("UPDATE loans SET balance = ROUND((balance - ?)::numeric, 2) WHERE id=?").run(take, loan.id); } - const period = db.prepare("SELECT id FROM destajo_periods WHERE week_id=?").get(weekId) as { id: number } | undefined; + const period = await db.prepare("SELECT id FROM destajo_periods WHERE week_id=?").get(weekId) as { id: number } | undefined; if (period) { - const jobs = db.prepare("SELECT id, qty_total_estimated FROM destajo_jobs").all() as { + const jobs = await db.prepare("SELECT id, qty_total_estimated FROM destajo_jobs").all() as { id: number; qty_total_estimated: number; }[]; for (const job of jobs) { - const paid = db.prepare( + const paid = await db.prepare( `SELECT COALESCE(SUM(c.qty_actual + c.qty_extra), 0) AS n FROM destajo_cut_lines c JOIN destajo_periods p ON p.id=c.period_id JOIN payroll_weeks w ON w.id=p.week_id WHERE c.job_id=? AND (w.status='paid' OR w.id=?)`, ).get(job.id, weekId) as { n: number }; - if (paid.n >= job.qty_total_estimated) { - db.prepare("UPDATE destajo_jobs SET status='done' WHERE id=?").run(job.id); + if (Number(paid.n) >= job.qty_total_estimated) { + await db.prepare("UPDATE destajo_jobs SET status='done' WHERE id=?").run(job.id); } } } - db.prepare( - "UPDATE payroll_weeks SET status='paid', paid_at=datetime('now') WHERE id=?", + await db.prepare( + "UPDATE payroll_weeks SET status='paid', paid_at=now() WHERE id=?", ).run(weekId); } -export function addJornalWorker(db: Database, weekId: number, projectId: number, workerId: number) { - const week = db.prepare("SELECT * FROM payroll_weeks WHERE id=?").get(weekId) as { status: string } | undefined; +export async function addJornalWorker(db: Db, weekId: number, projectId: number, workerId: number): Promise { + const week = await db.prepare("SELECT * FROM payroll_weeks WHERE id=?").get(weekId) as { status: string } | undefined; if (!week || week.status === "paid") throw Object.assign(new Error("No se puede editar"), { status: 400 }); - const sheet = db.prepare( + const sheet = await db.prepare( "SELECT id FROM payroll_sheets WHERE week_id=? AND kind='obra' AND project_id=?", ).get(weekId, projectId) as { id: number } | undefined; if (!sheet) throw Object.assign(new Error("Hoja de obra no encontrada"), { status: 404 }); - sheetLine(db, sheet.id, workerId, null); - recalcWeek(db, weekId); + await sheetLine(db, sheet.id, workerId, null); + await recalcWeek(db, weekId); } -export function addAdminLine( - db: Database, +export async function addAdminLine( + db: Db, weekId: number, body: { worker_id: number; amount: number; project_id?: number | null }, -) { - const week = db.prepare("SELECT * FROM payroll_weeks WHERE id=?").get(weekId) as { status: string } | undefined; +): Promise { + const week = await db.prepare("SELECT * FROM payroll_weeks WHERE id=?").get(weekId) as { status: string } | undefined; if (!week || week.status === "paid") throw Object.assign(new Error("No se puede editar"), { status: 400 }); - const sheet = db.prepare( + const sheet = await db.prepare( "SELECT id FROM payroll_sheets WHERE week_id=? AND kind='admin' AND project_id IS NULL", ).get(weekId) as { id: number } | undefined; if (!sheet) throw Object.assign(new Error("Hoja administrativa no encontrada"), { status: 404 }); - const lineId = sheetLine(db, sheet.id, body.worker_id, null); - db.prepare("UPDATE payroll_week_lines SET amount=?, project_id=?, gross=?, payable_net=? WHERE id=?").run( + const lineId = await sheetLine(db, sheet.id, body.worker_id, null); + await db.prepare("UPDATE payroll_week_lines SET amount=?, project_id=?, gross=?, payable_net=? WHERE id=?").run( roundMoney(body.amount), body.project_id ?? null, roundMoney(body.amount), roundMoney(body.amount), lineId, ); - recalcWeek(db, weekId); + await recalcWeek(db, weekId); } -export function updateAdminLine(db: Database, weekId: number, lineId: number, amount: number) { - const week = db.prepare("SELECT status FROM payroll_weeks WHERE id=?").get(weekId) as { status: string } | undefined; +export async function updateAdminLine(db: Db, weekId: number, lineId: number, amount: number): Promise { + const week = await db.prepare("SELECT status FROM payroll_weeks WHERE id=?").get(weekId) as { status: string } | undefined; if (!week || week.status === "paid") throw Object.assign(new Error("No se puede editar"), { status: 400 }); - db.prepare("UPDATE payroll_week_lines SET amount=? WHERE id=?").run(roundMoney(amount), lineId); - recalcWeek(db, weekId); + await db.prepare("UPDATE payroll_week_lines SET amount=? WHERE id=?").run(roundMoney(amount), lineId); + await recalcWeek(db, weekId); } -export function removeAdminLine(db: Database, weekId: number, lineId: number) { - const week = db.prepare("SELECT status FROM payroll_weeks WHERE id=?").get(weekId) as { status: string } | undefined; +export async function removeAdminLine(db: Db, weekId: number, lineId: number): Promise { + const week = await db.prepare("SELECT status FROM payroll_weeks WHERE id=?").get(weekId) as { status: string } | undefined; if (!week || week.status === "paid") throw Object.assign(new Error("No se puede editar"), { status: 400 }); - db.prepare("DELETE FROM payroll_week_lines WHERE id=?").run(lineId); - recalcWeek(db, weekId); + await db.prepare("DELETE FROM payroll_week_lines WHERE id=?").run(lineId); + await recalcWeek(db, weekId); } -export function jobRemainder(db: Database, jobId: number): number { - const job = db.prepare("SELECT qty_total_estimated FROM destajo_jobs WHERE id=?").get(jobId) as +export async function jobRemainder(db: Db, jobId: number): Promise { + const job = await db.prepare("SELECT qty_total_estimated FROM destajo_jobs WHERE id=?").get(jobId) as | { qty_total_estimated: number } | undefined; if (!job) return 0; - const paid = db.prepare( + const paid = await db.prepare( `SELECT COALESCE(SUM(c.qty_actual + c.qty_extra), 0) AS n FROM destajo_cut_lines c JOIN destajo_periods p ON p.id=c.period_id JOIN payroll_weeks w ON w.id=p.week_id WHERE c.job_id=? AND w.status='paid'`, ).get(jobId) as { n: number }; - return roundMoney(Math.max(0, job.qty_total_estimated - paid.n)); + return roundMoney(Math.max(0, job.qty_total_estimated - Number(paid.n))); } -export function createDestajoJob( - db: Database, +export async function createDestajoJob( + db: Db, tenantId: number, weekId: number, body: { @@ -789,12 +794,12 @@ export function createDestajoJob( qty_planned?: number; qty_extra?: number; }, -): { id: number } { - const week = db.prepare("SELECT status FROM payroll_weeks WHERE id=?").get(weekId) as { status: string } | undefined; +): Promise<{ id: number }> { + const week = await db.prepare("SELECT status FROM payroll_weeks WHERE id=?").get(weekId) as { status: string } | undefined; if (!week || week.status === "paid") throw Object.assign(new Error("No se puede editar"), { status: 400 }); const concepto = body.concepto.trim(); if (!concepto) throw Object.assign(new Error("Concepto requerido"), { status: 400 }); - db.prepare( + await db.prepare( `INSERT INTO destajo_jobs (project_id, worker_id, concepto, unit_code, qty_total_estimated, unit_price, tenant_id) VALUES (?, ?, ?, ?, ?, ?, ?)`, @@ -807,25 +812,25 @@ export function createDestajoJob( body.unit_price, tenantId, ); - const jobId = lid(db); - const period = db.prepare("SELECT id FROM destajo_periods WHERE week_id=?").get(weekId) as { id: number }; - db.prepare( + const jobId = await db.lastInsertId(); + const period = await db.prepare("SELECT id FROM destajo_periods WHERE week_id=?").get(weekId) as { id: number }; + await db.prepare( `INSERT INTO destajo_cut_lines (period_id, job_id, qty_planned, qty_actual, qty_extra) VALUES (?, ?, ?, 0, ?)`, ).run(period.id, jobId, body.qty_planned ?? 0, body.qty_extra ?? 0); - recalcWeek(db, weekId); + await recalcWeek(db, weekId); return { id: jobId }; } -export function patchDestajoCut( - db: Database, +export async function patchDestajoCut( + db: Db, weekId: number, cutId: number, patch: { qty_planned?: number; qty_actual?: number; qty_extra?: number }, -) { - const week = db.prepare("SELECT status FROM payroll_weeks WHERE id=?").get(weekId) as { status: string } | undefined; +): Promise { + const week = await db.prepare("SELECT status FROM payroll_weeks WHERE id=?").get(weekId) as { status: string } | undefined; if (!week || week.status === "paid") throw Object.assign(new Error("No se puede editar"), { status: 400 }); - const cut = db.prepare("SELECT * FROM destajo_cut_lines WHERE id=?").get(cutId) as { + const cut = await db.prepare("SELECT * FROM destajo_cut_lines WHERE id=?").get(cutId) as { qty_planned: number; qty_actual: number; qty_extra: number; @@ -834,24 +839,21 @@ export function patchDestajoCut( const planned = patch.qty_planned ?? cut.qty_planned; const actual = patch.qty_actual ?? cut.qty_actual; const extra = patch.qty_extra ?? cut.qty_extra; - if (week.status === "assembled" && patch.qty_planned != null) { - // required already frozen; planned can still be edited for display but does not rewrite required - } - db.prepare("UPDATE destajo_cut_lines SET qty_planned=?, qty_actual=?, qty_extra=? WHERE id=?").run( + await db.prepare("UPDATE destajo_cut_lines SET qty_planned=?, qty_actual=?, qty_extra=? WHERE id=?").run( planned, actual, extra, cutId, ); - recalcWeek(db, weekId); + await recalcWeek(db, weekId); } -export function ensureCutForOpenJobs(db: Database, weekId: number) { - const period = db.prepare("SELECT id, tenant_id FROM destajo_periods WHERE week_id=?").get(weekId) as +export async function ensureCutForOpenJobs(db: Db, weekId: number): Promise { + const period = await db.prepare("SELECT id, tenant_id FROM destajo_periods WHERE week_id=?").get(weekId) as | { id: number; tenant_id: number } | undefined; if (!period) return; - const jobs = db.prepare( + const jobs = await db.prepare( "SELECT id FROM destajo_jobs WHERE tenant_id=? AND status='open'", ).all(period.tenant_id) as { id: number }[]; const ins = db.prepare( @@ -859,7 +861,7 @@ export function ensureCutForOpenJobs(db: Database, weekId: number) { VALUES (?, ?, 0, 0, 0) ON CONFLICT(period_id, job_id) DO NOTHING`, ); - for (const job of jobs) ins.run(period.id, job.id); + for (const job of jobs) await ins.run(period.id, job.id); } type AttCell = { @@ -869,18 +871,18 @@ type AttCell = { other_project_name: string | null; }; -export function getWeekBundle(db: Database, tenantId: number, weekStart: string) { - const weekId = ensureWeek(db, tenantId, weekStart); - ensureCutForOpenJobs(db, weekId); - recalcWeek(db, weekId); - const week = db.prepare( +export async function getWeekBundle(db: Db, tenantId: number, weekStart: string) { + const weekId = await ensureWeek(db, tenantId, weekStart); + await ensureCutForOpenJobs(db, weekId); + await recalcWeek(db, weekId); + const week = await db.prepare( `SELECT * FROM payroll_weeks WHERE id=?`, ).get(weekId) as Record; - const period = db.prepare("SELECT * FROM destajo_periods WHERE week_id=?").get(weekId); + const period = await db.prepare("SELECT * FROM destajo_periods WHERE week_id=?").get(weekId); const start = String(week.week_start); const end = String(week.week_end); const days = weekDays(start); - const sheetsRaw = db.prepare( + const sheetsRaw = await db.prepare( `SELECT s.*, p.name AS project_name, p.code AS project_code FROM payroll_sheets s LEFT JOIN projects p ON p.id=s.project_id @@ -888,7 +890,7 @@ export function getWeekBundle(db: Database, tenantId: number, weekStart: string) ORDER BY CASE s.kind WHEN 'obra' THEN 0 WHEN 'destajo' THEN 1 ELSE 2 END, p.name`, ).all(weekId) as Record[]; - const attRows = db.prepare( + const attRows = await db.prepare( `SELECT a.worker_id, a.project_id, a.work_date, a.present, p.name AS project_name FROM attendance a JOIN projects p ON p.id=a.project_id WHERE a.work_date BETWEEN ? AND ?`, @@ -896,12 +898,13 @@ export function getWeekBundle(db: Database, tenantId: number, weekStart: string) worker_id: number; project_id: number; work_date: string; - present: number; + present: boolean; project_name: string; }[]; - const sheets = sheetsRaw.map((sheet) => { - const lines = db.prepare( + const sheets = []; + for (const sheet of sheetsRaw) { + const lines = await db.prepare( `SELECT l.*, w.first_name, w.middle_name, w.last_name_p, w.last_name_m, w.position, w.work_type FROM payroll_week_lines l JOIN workers w ON w.id=l.worker_id @@ -914,7 +917,7 @@ export function getWeekBundle(db: Database, tenantId: number, weekStart: string) a.worker_id === line.worker_id && a.project_id === sheet.project_id && a.work_date === date ); const other = attRows.find((a) => - a.worker_id === line.worker_id && a.present === 1 && a.project_id !== sheet.project_id && a.work_date === date + a.worker_id === line.worker_id && a.present === true && a.project_id !== sheet.project_id && a.work_date === date ); return { date, @@ -925,10 +928,10 @@ export function getWeekBundle(db: Database, tenantId: number, weekStart: string) }); return { ...line, cells }; }); - return { ...sheet, lines: withCells }; - }); + sheets.push({ ...sheet, lines: withCells }); + } - const jobs = db.prepare( + const jobs = await db.prepare( `SELECT j.*, w.first_name, w.middle_name, w.last_name_p, w.last_name_m, p.name AS project_name FROM destajo_jobs j JOIN workers w ON w.id=j.worker_id @@ -936,9 +939,10 @@ export function getWeekBundle(db: Database, tenantId: number, weekStart: string) WHERE j.tenant_id=? ORDER BY j.status, j.id DESC`, ).all(tenantId) as Record[]; - const jobsOut = jobs.map((job) => ({ ...job, remainder: jobRemainder(db, Number(job.id)) })); + const jobsOut = []; + for (const job of jobs) jobsOut.push({ ...job, remainder: await jobRemainder(db, Number(job.id)) }); const cuts = period - ? db.prepare( + ? await db.prepare( `SELECT c.*, j.concepto, j.unit_code, j.unit_price, j.qty_total_estimated, j.worker_id, j.project_id, j.status AS job_status, w.first_name, w.middle_name, w.last_name_p, w.last_name_m, p.name AS project_name FROM destajo_cut_lines c @@ -949,18 +953,19 @@ export function getWeekBundle(db: Database, tenantId: number, weekStart: string) ORDER BY p.name, w.last_name_p`, ).all((period as { id: number }).id) : []; - const settings = getSettings(db, tenantId); - const units = listUnits(db, tenantId); + const settings = await getSettings(db, tenantId); + const units = await listUnits(db, tenantId); const required = Number(week.required_net) || 0; const payable = Number(week.payable_net) || 0; - const recoveryRow = db.prepare( + const recoveryRow = await db.prepare( `SELECT COALESCE(SUM(l.loan_discount), 0) AS n FROM payroll_week_lines l JOIN payroll_sheets s ON s.id=l.sheet_id WHERE s.week_id=?`, ).get(weekId) as { n: number }; - const loanRecovery = roundMoney(recoveryRow.n); + const loanRecovery = roundMoney(Number(recoveryRow.n)); const faltante = roundMoney(Math.max(0, payable - required)); + const today = await resolveToday(db, tenantId); return { week, destajo_period: period, @@ -977,13 +982,13 @@ export function getWeekBundle(db: Database, tenantId: number, weekStart: string) faltante, delta: roundMoney(required - payable), }, - can_assemble: canAssembleWeek(start), + can_assemble: canAssembleWeek(start, today), assemble_from: weekAssembleDate(start), }; } -export function weekCsv(db: Database, weekId: number): string { - const lines = db.prepare( +export async function weekCsv(db: Db, weekId: number): Promise { + const lines = await db.prepare( `SELECT w.first_name, w.last_name_p, s.kind, p.name AS project_name, l.days, l.daily_wage, l.concepto, l.qty_actual, l.qty_extra, l.unit_code, l.gross, l.discounts, l.loan_discount, l.loan_label, l.payable_net, l.required_net @@ -1018,8 +1023,8 @@ export function weekCsv(db: Database, weekId: number): string { return [header, ...rows].join("\n"); } -export function listOpenWeeks(db: Database, tenantId: number) { - return db.prepare( +export async function listOpenWeeks(db: Db, tenantId: number) { + return await db.prepare( `SELECT w.*, (SELECT COALESCE(SUM(l.loan_discount), 0) FROM payroll_week_lines l diff --git a/api/payroll_http.ts b/api/payroll_http.ts index ac001f3..df72e83 100644 --- a/api/payroll_http.ts +++ b/api/payroll_http.ts @@ -1,7 +1,8 @@ -import type { Hono } from "hono"; +import type { Context, Hono } from "hono"; import type { AuthUser } from "./auth.ts"; -import { tenantScope, requireAuth } from "./auth.ts"; -import { getDb, lastInsertId, projectById, projectMustBe } from "./db.ts"; +import { tenantScope } from "./auth.ts"; +import { requireCoreAuth } from "./scope.ts"; +import { lastInsertId, projectById, projectMustBe, type Db } from "./db.ts"; import { generateLoanReceiptPdf } from "./pdf.ts"; import { fullName } from "./mx.ts"; import { @@ -19,38 +20,34 @@ import { payWeek, patchDestajoCut, removeAdminLine, + resolveToday, saveSettings, setAttendance, tenantKey, - todayIso, updateAdminLine, weekContaining, weekCsv, } from "./payroll.ts"; -type App = Hono<{ Variables: { user: AuthUser } }>; +type App = Hono<{ Variables: { user: AuthUser; db: Db } }>; function tid(c: { get: (k: "user") => AuthUser }): number { return tenantKey(tenantScope(c.get("user"))); } -async function pdfBody( - c: { header: (k: string, v: string) => void; body: (data: ArrayBuffer) => unknown }, - bytes: Uint8Array, - filename: string, -) { +function pdfBody(c: Context, bytes: Uint8Array, filename: string) { c.header("Content-Type", "application/pdf"); c.header("Content-Disposition", `attachment; filename="${filename}"`); return c.body(bytes.buffer as ArrayBuffer); } export function registerPayrollRoutes(app: App) { - app.get("/v1/attendance", requireAuth, async (c) => { + app.get("/v1/attendance", ...requireCoreAuth, async (c) => { const projectId = Number(c.req.query("project_id")); const from = c.req.query("from") ?? ""; const to = c.req.query("to") ?? ""; - const db = await getDb(); - const rows = db.prepare( + const db = c.get("db"); + const rows = await db.prepare( `SELECT a.*, w.first_name, w.last_name_p FROM attendance a JOIN workers w ON w.id=a.worker_id WHERE a.project_id=? AND a.work_date BETWEEN ? AND ? @@ -59,72 +56,72 @@ export function registerPayrollRoutes(app: App) { return c.json({ attendance: rows }); }); - app.put("/v1/attendance", requireAuth, async (c) => { + app.put("/v1/attendance", ...requireCoreAuth, async (c) => { const body = await c.req.json<{ project_id: number; worker_id: number; work_date: string; present: boolean; }>(); - const db = await getDb(); + const db = c.get("db"); const blocked = projectMustBe( - projectById(db, body.project_id), + await projectById(db, body.project_id), ["activo", "pausado"], "No se registra asistencia en un proyecto concluido o cancelado", ); if (blocked) return c.json({ error: blocked.error }, blocked.status); - const result = setAttendance(db, tid(c), body); + const result = await setAttendance(db, tid(c), body); if ("error" in result) { return c.json({ error: result.error, other: result.other }, result.status === 409 ? 409 : 400); } return c.json({ ok: true }); }); - app.get("/v1/payroll/settings", requireAuth, async (c) => { - const db = await getDb(); + app.get("/v1/payroll/settings", ...requireCoreAuth, async (c) => { + const db = c.get("db"); const tenantId = tid(c); - return c.json({ settings: getSettings(db, tenantId), units: listUnits(db, tenantId) }); + return c.json({ settings: await getSettings(db, tenantId), units: await listUnits(db, tenantId) }); }); - app.patch("/v1/payroll/settings", requireAuth, async (c) => { - const db = await getDb(); + app.patch("/v1/payroll/settings", ...requireCoreAuth, async (c) => { + const db = c.get("db"); const body = await c.req.json<{ loan_commission_enabled?: number | boolean; loan_commission_pct?: number; loan_small_max?: number; }>(); - const settings = saveSettings(db, tid(c), { + const settings = await saveSettings(db, tid(c), { loan_commission_enabled: body.loan_commission_enabled === undefined ? undefined - : body.loan_commission_enabled ? 1 : 0, + : Boolean(body.loan_commission_enabled), loan_commission_pct: body.loan_commission_pct, loan_small_max: body.loan_small_max, }); return c.json({ settings }); }); - app.post("/v1/destajo/units", requireAuth, async (c) => { + app.post("/v1/destajo/units", ...requireCoreAuth, async (c) => { const body = await c.req.json<{ code: string; label?: string }>(); - const db = await getDb(); + const db = c.get("db"); try { - const id = addUnit(db, tid(c), body.code, body.label ?? body.code); + const id = await addUnit(db, tid(c), body.code, body.label ?? body.code); return c.json({ id }, 201); } catch (e) { return c.json({ error: e instanceof Error ? e.message : "No se pudo guardar" }, 400); } }); - app.get("/v1/loans", requireAuth, async (c) => { - const db = await getDb(); + app.get("/v1/loans", ...requireCoreAuth, async (c) => { + const db = c.get("db"); const workerId = c.req.query("worker_id"); const sql = workerId ? "SELECT l.*, w.first_name, w.last_name_p FROM loans l JOIN workers w ON w.id=l.worker_id WHERE l.worker_id=? ORDER BY l.id DESC" : "SELECT l.*, w.first_name, w.last_name_p FROM loans l JOIN workers w ON w.id=l.worker_id ORDER BY l.id DESC"; - const loans = workerId ? db.prepare(sql).all(Number(workerId)) : db.prepare(sql).all(); + const loans = workerId ? await db.prepare(sql).all(Number(workerId)) : await db.prepare(sql).all(); return c.json({ loans }); }); - app.post("/v1/loans", requireAuth, async (c) => { + app.post("/v1/loans", ...requireCoreAuth, async (c) => { const body = await c.req.json<{ worker_id: number; amount?: number; @@ -136,9 +133,9 @@ export function registerPayrollRoutes(app: App) { plan?: "single" | "installments"; installments_n?: number; }>(); - const db = await getDb(); + const db = c.get("db"); try { - const created = createLoan(db, tid(c), { + const created = await createLoan(db, tid(c), { worker_id: body.worker_id, delivered: Number(body.delivered ?? body.amount ?? 0), commission_enabled: body.commission_enabled, @@ -154,10 +151,10 @@ export function registerPayrollRoutes(app: App) { } }); - app.get("/v1/loans/:id/recibo", requireAuth, async (c) => { + app.get("/v1/loans/:id/recibo", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); - const db = await getDb(); - const loan = db.prepare( + const db = c.get("db"); + const loan = await db.prepare( `SELECT l.*, w.first_name, w.middle_name, w.last_name_p, w.last_name_m FROM loans l JOIN workers w ON w.id=l.worker_id WHERE l.id=?`, ).get(id) as { @@ -202,10 +199,10 @@ export function registerPayrollRoutes(app: App) { return pdfBody(c, bytes, `prestamo-aceptacion-${id}.pdf`); }); - app.get("/v1/loan-payments/:id/recibo", requireAuth, async (c) => { + app.get("/v1/loan-payments/:id/recibo", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); - const db = await getDb(); - const pay = db.prepare( + const db = c.get("db"); + const pay = await db.prepare( `SELECT p.*, l.delivered, l.commission_pct, w.first_name, w.middle_name, w.last_name_p, w.last_name_m, wk.week_end FROM loan_payments p JOIN loans l ON l.id=p.loan_id @@ -238,23 +235,24 @@ export function registerPayrollRoutes(app: App) { return pdfBody(c, bytes, `prestamo-pago-${id}.pdf`); }); - app.get("/v1/payroll/weeks", requireAuth, async (c) => { - const db = await getDb(); + app.get("/v1/payroll/weeks", ...requireCoreAuth, async (c) => { + const db = c.get("db"); const tenantId = tid(c); - const start = c.req.query("week_start") || weekContaining(todayIso()).weekStart; - const bundle = getWeekBundle(db, tenantId, start); + const start = c.req.query("week_start") || weekContaining(await resolveToday(db, tenantId)).weekStart; + const bundle = await getWeekBundle(db, tenantId, start); return c.json(bundle); }); - app.get("/v1/payroll/weeks/open", requireAuth, async (c) => { - const db = await getDb(); - return c.json({ weeks: listOpenWeeks(db, tid(c)) }); + app.get("/v1/payroll/weeks/open", ...requireCoreAuth, async (c) => { + const db = c.get("db"); + return c.json({ weeks: await listOpenWeeks(db, tid(c)) }); }); - app.post("/v1/payroll/weeks/:id/assemble", requireAuth, async (c) => { - const db = await getDb(); + app.post("/v1/payroll/weeks/:id/assemble", ...requireCoreAuth, async (c) => { + const db = c.get("db"); try { - assembleWeek(db, Number(c.req.param("id"))); + const nowIso = await resolveToday(db, tid(c)); + await assembleWeek(db, Number(c.req.param("id")), nowIso); return c.json({ ok: true }); } catch (e) { const err = e as Error & { status?: number }; @@ -262,10 +260,11 @@ export function registerPayrollRoutes(app: App) { } }); - app.post("/v1/payroll/weeks/:id/pay", requireAuth, async (c) => { - const db = await getDb(); + app.post("/v1/payroll/weeks/:id/pay", ...requireCoreAuth, async (c) => { + const db = c.get("db"); try { - payWeek(db, Number(c.req.param("id"))); + const nowIso = await resolveToday(db, tid(c)); + await payWeek(db, Number(c.req.param("id")), nowIso); return c.json({ ok: true }); } catch (e) { const err = e as Error & { status?: number }; @@ -273,20 +272,20 @@ export function registerPayrollRoutes(app: App) { } }); - app.get("/v1/payroll/weeks/:id/csv", requireAuth, async (c) => { + app.get("/v1/payroll/weeks/:id/csv", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); - const db = await getDb(); - const csv = weekCsv(db, id); + const db = c.get("db"); + const csv = await weekCsv(db, id); c.header("Content-Type", "text/csv; charset=utf-8"); c.header("Content-Disposition", `attachment; filename="nomina-${id}.csv"`); return c.body(csv); }); - app.post("/v1/payroll/weeks/:id/jornal", requireAuth, async (c) => { + app.post("/v1/payroll/weeks/:id/jornal", ...requireCoreAuth, async (c) => { const body = await c.req.json<{ project_id: number; worker_id: number }>(); - const db = await getDb(); + const db = c.get("db"); try { - addJornalWorker(db, Number(c.req.param("id")), body.project_id, body.worker_id); + await addJornalWorker(db, Number(c.req.param("id")), body.project_id, body.worker_id); return c.json({ ok: true }); } catch (e) { const err = e as Error & { status?: number }; @@ -294,11 +293,11 @@ export function registerPayrollRoutes(app: App) { } }); - app.post("/v1/payroll/weeks/:id/admin", requireAuth, async (c) => { + app.post("/v1/payroll/weeks/:id/admin", ...requireCoreAuth, async (c) => { const body = await c.req.json<{ worker_id: number; amount: number; project_id?: number | null }>(); - const db = await getDb(); + const db = c.get("db"); try { - addAdminLine(db, Number(c.req.param("id")), body); + await addAdminLine(db, Number(c.req.param("id")), body); return c.json({ ok: true }); } catch (e) { const err = e as Error & { status?: number }; @@ -306,11 +305,11 @@ export function registerPayrollRoutes(app: App) { } }); - app.patch("/v1/payroll/weeks/:id/admin/:lineId", requireAuth, async (c) => { + app.patch("/v1/payroll/weeks/:id/admin/:lineId", ...requireCoreAuth, async (c) => { const body = await c.req.json<{ amount: number }>(); - const db = await getDb(); + const db = c.get("db"); try { - updateAdminLine(db, Number(c.req.param("id")), Number(c.req.param("lineId")), body.amount); + await updateAdminLine(db, Number(c.req.param("id")), Number(c.req.param("lineId")), body.amount); return c.json({ ok: true }); } catch (e) { const err = e as Error & { status?: number }; @@ -318,10 +317,10 @@ export function registerPayrollRoutes(app: App) { } }); - app.delete("/v1/payroll/weeks/:id/admin/:lineId", requireAuth, async (c) => { - const db = await getDb(); + app.delete("/v1/payroll/weeks/:id/admin/:lineId", ...requireCoreAuth, async (c) => { + const db = c.get("db"); try { - removeAdminLine(db, Number(c.req.param("id")), Number(c.req.param("lineId"))); + await removeAdminLine(db, Number(c.req.param("id")), Number(c.req.param("lineId"))); return c.json({ ok: true }); } catch (e) { const err = e as Error & { status?: number }; @@ -329,7 +328,7 @@ export function registerPayrollRoutes(app: App) { } }); - app.post("/v1/destajo/jobs", requireAuth, async (c) => { + app.post("/v1/destajo/jobs", ...requireCoreAuth, async (c) => { const body = await c.req.json<{ week_id: number; project_id: number; @@ -341,9 +340,9 @@ export function registerPayrollRoutes(app: App) { qty_planned?: number; qty_extra?: number; }>(); - const db = await getDb(); + const db = c.get("db"); try { - const created = createDestajoJob(db, tid(c), body.week_id, body); + const created = await createDestajoJob(db, tid(c), body.week_id, body); return c.json(created, 201); } catch (e) { const err = e as Error & { status?: number }; @@ -351,16 +350,16 @@ export function registerPayrollRoutes(app: App) { } }); - app.patch("/v1/destajo/cuts/:id", requireAuth, async (c) => { + app.patch("/v1/destajo/cuts/:id", ...requireCoreAuth, async (c) => { const body = await c.req.json<{ week_id: number; qty_planned?: number; qty_actual?: number; qty_extra?: number; }>(); - const db = await getDb(); + const db = c.get("db"); try { - patchDestajoCut(db, body.week_id, Number(c.req.param("id")), body); + await patchDestajoCut(db, body.week_id, Number(c.req.param("id")), body); return c.json({ ok: true }); } catch (e) { const err = e as Error & { status?: number }; @@ -368,27 +367,27 @@ export function registerPayrollRoutes(app: App) { } }); - app.post("/v1/payroll/periods", requireAuth, async (c) => { + app.post("/v1/payroll/periods", ...requireCoreAuth, async (c) => { const body = await c.req.json<{ project_id: number; week_start: string; week_end: string; extra_discounts?: Record; }>(); - const db = await getDb(); + const db = c.get("db"); const blocked = projectMustBe( - projectById(db, body.project_id), + await projectById(db, body.project_id), ["activo", "pausado"], "No se genera nómina de un proyecto concluido o cancelado", ); if (blocked) return c.json({ error: blocked.error }, blocked.status); - db.prepare( + await db.prepare( "INSERT INTO payroll_periods (project_id, week_start, week_end, status) VALUES (?, ?, ?, 'draft')", ).run(body.project_id, body.week_start, body.week_end); - const periodId = lastInsertId(db); - const workers = db.prepare( + const periodId = await lastInsertId(db); + const workers = await db.prepare( `SELECT w.id, w.daily_wage FROM workers w - JOIN assignments a ON a.worker_id=w.id AND a.project_id=? AND a.active=1 + JOIN assignments a ON a.worker_id=w.id AND a.project_id=? AND a.active=true WHERE w.status='activo'`, ).all(body.project_id) as { id: number; daily_wage: number }[]; const ins = db.prepare( @@ -396,47 +395,47 @@ export function registerPayrollRoutes(app: App) { VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, ); for (const w of workers) { - const att = db.prepare( + const att = await db.prepare( `SELECT COUNT(*) AS n FROM attendance - WHERE worker_id=? AND project_id=? AND work_date BETWEEN ? AND ? AND present=1`, + WHERE worker_id=? AND project_id=? AND work_date BETWEEN ? AND ? AND present=true`, ).get(w.id, body.project_id, body.week_start, body.week_end) as { n: number }; - const days = att.n; + const days = Number(att.n); const gross = days * w.daily_wage; const extra = Number(body.extra_discounts?.[w.id] ?? 0); const net = gross - extra; - ins.run(periodId, w.id, days, w.daily_wage, gross, extra, 0, net); + await ins.run(periodId, w.id, days, w.daily_wage, gross, extra, 0, net); } return c.json({ id: periodId }); }); - app.get("/v1/payroll/periods", requireAuth, async (c) => { - const db = await getDb(); + app.get("/v1/payroll/periods", ...requireCoreAuth, async (c) => { + const db = c.get("db"); const projectId = c.req.query("project_id"); const sql = projectId ? `SELECT pe.*, p.name AS project_name FROM payroll_periods pe JOIN projects p ON p.id=pe.project_id WHERE pe.project_id=? ORDER BY pe.id DESC` : `SELECT pe.*, p.name AS project_name FROM payroll_periods pe JOIN projects p ON p.id=pe.project_id ORDER BY pe.id DESC`; - const periods = projectId ? db.prepare(sql).all(Number(projectId)) : db.prepare(sql).all(); + const periods = projectId ? await db.prepare(sql).all(Number(projectId)) : await db.prepare(sql).all(); return c.json({ periods }); }); - app.get("/v1/payroll/periods/:id", requireAuth, async (c) => { + app.get("/v1/payroll/periods/:id", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); - const db = await getDb(); - const period = db.prepare( + const db = c.get("db"); + const period = await db.prepare( `SELECT pe.*, p.name AS project_name FROM payroll_periods pe JOIN projects p ON p.id=pe.project_id WHERE pe.id=?`, ).get(id); if (!period) return c.json({ error: "Periodo no encontrado" }, 404); - const lines = db.prepare( + const lines = await db.prepare( `SELECT l.*, w.first_name, w.last_name_p, w.position FROM payroll_lines l JOIN workers w ON w.id=l.worker_id WHERE l.period_id=? ORDER BY w.last_name_p`, ).all(id); return c.json({ period, lines }); }); - app.get("/v1/payroll/periods/:id/csv", requireAuth, async (c) => { + app.get("/v1/payroll/periods/:id/csv", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); - const db = await getDb(); - const lines = db.prepare( + const db = c.get("db"); + const lines = await db.prepare( `SELECT w.first_name, w.last_name_p, l.days, l.daily_wage, l.gross, l.discounts, l.loan_payment, l.net FROM payroll_lines l JOIN workers w ON w.id=l.worker_id WHERE l.period_id=?`, ).all(id) as Record[]; @@ -450,11 +449,11 @@ export function registerPayrollRoutes(app: App) { return c.body(csv); }); - app.patch("/v1/payroll/periods/:id", requireAuth, async (c) => { + app.patch("/v1/payroll/periods/:id", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); const { status } = await c.req.json<{ status: string }>(); - const db = await getDb(); - db.prepare("UPDATE payroll_periods SET status=? WHERE id=?").run(status, id); + const db = c.get("db"); + await db.prepare("UPDATE payroll_periods SET status=? WHERE id=?").run(status, id); return c.json({ ok: true }); }); } diff --git a/api/payroll_test.ts b/api/payroll_test.ts index fb0d383..981ea10 100644 --- a/api/payroll_test.ts +++ b/api/payroll_test.ts @@ -1,5 +1,6 @@ -import { assertEquals, assertExists, assertThrows } from "jsr:@std/assert@1"; -import { Database } from "@db/sqlite"; +import { assertEquals, assertExists, assertRejects } from "jsr:@std/assert@1"; +import type { PgDb } from "./pg.ts"; +import { withTestDb } from "./test_helpers.ts"; import { addDays, assembleWeek, @@ -22,92 +23,58 @@ import { weekContaining, } from "./payroll.ts"; -function mem() { - const db = new Database(":memory:"); - db.exec("PRAGMA foreign_keys = ON"); - db.exec(` - CREATE TABLE companies (id INTEGER PRIMARY KEY, name TEXT, tenant_id INTEGER); - CREATE TABLE projects ( - id INTEGER PRIMARY KEY, code TEXT, name TEXT, status TEXT DEFAULT 'activo', tenant_id INTEGER - ); - CREATE TABLE workers ( - id INTEGER PRIMARY KEY, - first_name TEXT, last_name_p TEXT, position TEXT DEFAULT 'Ayudante', - work_type TEXT DEFAULT 'N', daily_wage REAL DEFAULT 0, - status TEXT DEFAULT 'activo', pipeline_status TEXT DEFAULT 'activo', tenant_id INTEGER - ); - CREATE TABLE assignments ( - id INTEGER PRIMARY KEY, worker_id INTEGER, project_id INTEGER, active INTEGER DEFAULT 1 - ); - CREATE TABLE attendance ( - id INTEGER PRIMARY KEY, - worker_id INTEGER, project_id INTEGER, work_date TEXT, present INTEGER DEFAULT 1, - UNIQUE (worker_id, project_id, work_date) - ); - CREATE TABLE loans ( - id INTEGER PRIMARY KEY, - worker_id INTEGER, amount REAL, delivered REAL DEFAULT 0, balance REAL, - weekly_payment REAL DEFAULT 0, note TEXT, - commission_pct REAL DEFAULT 0, commission_amount REAL DEFAULT 0, - plan TEXT DEFAULT 'single', installments_n INTEGER DEFAULT 1, first_due TEXT, - created_at TEXT DEFAULT (datetime('now')) - ); - CREATE TABLE payroll_settings ( - tenant_id INTEGER PRIMARY KEY, - loan_commission_enabled INTEGER DEFAULT 1, - loan_commission_pct REAL DEFAULT 10, - loan_small_max REAL DEFAULT 500 - ); - CREATE TABLE payroll_weeks ( - id INTEGER PRIMARY KEY, - week_start TEXT, week_end TEXT, status TEXT DEFAULT 'draft', - required_net REAL DEFAULT 0, payable_net REAL DEFAULT 0, - assembled_at TEXT, paid_at TEXT, tenant_id INTEGER DEFAULT 0, - created_at TEXT DEFAULT (datetime('now')) - ); - CREATE UNIQUE INDEX idx_weeks ON payroll_weeks(tenant_id, week_start); - CREATE TABLE payroll_sheets ( - id INTEGER PRIMARY KEY, week_id INTEGER, kind TEXT, project_id INTEGER - ); - CREATE UNIQUE INDEX idx_sheets ON payroll_sheets(week_id, kind, IFNULL(project_id, 0)); - CREATE TABLE payroll_week_lines ( - id INTEGER PRIMARY KEY, - sheet_id INTEGER, worker_id INTEGER, destajo_cut_line_id INTEGER, project_id INTEGER, - days REAL DEFAULT 0, daily_wage REAL DEFAULT 0, - qty_planned REAL DEFAULT 0, qty_actual REAL DEFAULT 0, qty_extra REAL DEFAULT 0, - unit_price REAL DEFAULT 0, unit_code TEXT, concepto TEXT, - amount REAL DEFAULT 0, gross REAL DEFAULT 0, discounts REAL DEFAULT 0, - loan_id INTEGER, loan_discount REAL DEFAULT 0, loan_label TEXT, - required_net REAL DEFAULT 0, payable_net REAL DEFAULT 0 - ); - CREATE TABLE destajo_units ( - id INTEGER PRIMARY KEY, tenant_id INTEGER, code TEXT, label TEXT, UNIQUE(tenant_id, code) - ); - CREATE TABLE destajo_periods ( - id INTEGER PRIMARY KEY, period_start TEXT, period_end TEXT, week_id INTEGER, tenant_id INTEGER, - UNIQUE(tenant_id, period_end) - ); - CREATE TABLE destajo_jobs ( - id INTEGER PRIMARY KEY, project_id INTEGER, worker_id INTEGER, concepto TEXT, - unit_code TEXT, qty_total_estimated REAL, unit_price REAL, status TEXT DEFAULT 'open', - tenant_id INTEGER DEFAULT 0 - ); - CREATE TABLE destajo_cut_lines ( - id INTEGER PRIMARY KEY, period_id INTEGER, job_id INTEGER, - qty_planned REAL DEFAULT 0, qty_actual REAL DEFAULT 0, qty_extra REAL DEFAULT 0, - UNIQUE(period_id, job_id) - ); - CREATE TABLE loan_payments ( - id INTEGER PRIMARY KEY, loan_id INTEGER, week_id INTEGER, amount REAL, - installment_n INTEGER, label TEXT, created_at TEXT DEFAULT (datetime('now')) - ); - `); - db.prepare("INSERT INTO projects (id, code, name, status, tenant_id) VALUES (1, 'PRY-0001', 'Obra Norte', 'activo', 1)").run(); - db.prepare( - "INSERT INTO workers (id, first_name, last_name_p, work_type, daily_wage, tenant_id) VALUES (1, 'Juan', 'Perez', 'N', 500, 1)", - ).run(); - db.prepare("INSERT INTO assignments (worker_id, project_id, active) VALUES (1, 1, 1)").run(); - return db; +// tenant_id fijo y solo-de-tests: cada test corre en su propia transacción +// que siempre se revierte (ver test_helpers.ts), así que reusar el mismo +// id entre tests nunca colisiona -- nada de esto llega a persistir. +const TENANT_ID = 999001; + +type Fixture = { companyId: number; projectId: number; workerId: number }; + +async function seedFixture(db: PgDb, opts: { workType?: "N" | "D"; dailyWage?: number } = {}): Promise { + await db.prepare( + `INSERT INTO companies (code, name, kind, tenant_id) VALUES ('TSTCO', 'Test Co', 'principal', ?)`, + ).run(TENANT_ID); + const companyId = await db.lastInsertId(); + + await db.prepare( + `INSERT INTO projects (code, name, status, theme_id, company_id, tenant_id) + VALUES ('TST-0001', 'Obra Norte', 'activo', 'arctec-dos-logos-fold', ?, ?)`, + ).run(companyId, TENANT_ID); + const projectId = await db.lastInsertId(); + + await db.prepare( + `INSERT INTO workers + (first_name, last_name_p, last_name_m, curp, rfc, nss, phone, email, address, + hire_type, company_id, position, risk_code, work_type, daily_wage, tenant_id) + VALUES ('Juan', 'Perez', 'Lopez', 'CURPTEST0001', 'RFCTEST0001', 'NSSTEST0001', '9990000001', + 'juan@example.com', 'Sin domicilio', 'TSTCO', ?, 'Ayudante', 'rojo', ?, ?, ?)`, + ).run(companyId, opts.workType ?? "N", opts.dailyWage ?? 500, TENANT_ID); + const workerId = await db.lastInsertId(); + + await db.prepare( + `INSERT INTO assignments (worker_id, project_id, active, start_date) VALUES (?, ?, true, current_date)`, + ).run(workerId, projectId); + + return { companyId, projectId, workerId }; +} + +async function addSecondProject(db: PgDb, companyId: number): Promise { + await db.prepare( + `INSERT INTO projects (code, name, status, theme_id, company_id, tenant_id) + VALUES ('TST-0002', 'Obra Sur', 'activo', 'arctec-dos-logos-fold', ?, ?)`, + ).run(companyId, TENANT_ID); + return await db.lastInsertId(); +} + +async function addSecondWorker(db: PgDb, companyId: number): Promise { + await db.prepare( + `INSERT INTO workers + (first_name, last_name_p, last_name_m, curp, rfc, nss, phone, email, address, + hire_type, company_id, position, risk_code, work_type, daily_wage, tenant_id) + VALUES ('Luis', 'Destajo', 'Lopez', 'CURPTEST0002', 'RFCTEST0002', 'NSSTEST0002', '9990000002', + 'luis@example.com', 'Sin domicilio', 'TSTCO', ?, 'Ayudante', 'rojo', 'D', 0, ?)`, + ).run(companyId, TENANT_ID); + return await db.lastInsertId(); } Deno.test("semana lun-sáb y corte destajo jue-jue", () => { @@ -119,25 +86,28 @@ Deno.test("semana lun-sáb y corte destajo jue-jue", () => { assertEquals(d.periodStart, "2026-08-20"); }); -Deno.test("armar jueves solo a partir del jueves de esa semana", () => { +Deno.test("armar jueves solo a partir del jueves de esa semana", async () => { assertEquals(weekAssembleDate("2026-08-24"), "2026-08-27"); assertEquals(canAssembleWeek("2026-08-24", "2026-08-26"), false); assertEquals(canAssembleWeek("2026-08-24", "2026-08-27"), true); assertEquals(canAssembleWeek("2026-08-24", "2026-08-29"), true); assertEquals(canAssembleWeek("2026-08-24", "2026-08-31"), true); assertEquals(canAssembleWeek("2026-08-31", "2026-08-26"), false); - const db = mem(); - const weekId = ensureWeek(db, 1, "2026-08-24"); - assertThrows( - () => assembleWeek(db, weekId, "2026-08-26"), - Error, - "jueves", - ); - const draft = db.prepare("SELECT status FROM payroll_weeks WHERE id=?").get(weekId) as { status: string }; - assertEquals(draft.status, "draft"); - assembleWeek(db, weekId, "2026-08-28"); - const after = db.prepare("SELECT status FROM payroll_weeks WHERE id=?").get(weekId) as { status: string }; - assertEquals(after.status, "assembled"); + + await withTestDb(async (db) => { + await seedFixture(db); + const weekId = await ensureWeek(db, TENANT_ID, "2026-08-24"); + await assertRejects( + () => assembleWeek(db, weekId, "2026-08-26"), + Error, + "jueves", + ); + const draft = await db.prepare("SELECT status FROM payroll_weeks WHERE id=?").get(weekId) as { status: string }; + assertEquals(draft.status, "draft"); + await assembleWeek(db, weekId, "2026-08-28"); + const after = await db.prepare("SELECT status FROM payroll_weeks WHERE id=?").get(weekId) as { status: string }; + assertEquals(after.status, "assembled"); + }); }); Deno.test("comisión suma al saldo", () => { @@ -180,144 +150,181 @@ Deno.test("préstamo chico vence el sábado de la semana siguiente", () => { ); }); -Deno.test("armar jueves prellena jue-vie-sáb y congela requerida", () => { - const db = mem(); - db.prepare("INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (1, 1, '2026-08-24', 1)").run(); - db.prepare("INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (1, 1, '2026-08-25', 1)").run(); - const weekId = ensureWeek(db, 1, "2026-08-24"); - assembleWeek(db, weekId, "2026-08-27"); - const week = db.prepare("SELECT * FROM payroll_weeks WHERE id=?").get(weekId) as { - status: string; - required_net: number; - payable_net: number; - }; - assertEquals(week.status, "assembled"); - assertEquals(week.required_net, 2500); - assertEquals(week.payable_net, 2500); - const thu = db.prepare( - "SELECT present FROM attendance WHERE worker_id=1 AND work_date='2026-08-27'", - ).get() as { present: number }; - assertEquals(thu.present, 1); -}); - -Deno.test("falta viernes baja a pagar y no toca requerida", () => { - const db = mem(); - db.prepare("INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (1, 1, '2026-08-24', 1)").run(); - db.prepare("INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (1, 1, '2026-08-25', 1)").run(); - const weekId = ensureWeek(db, 1, "2026-08-24"); - assembleWeek(db, weekId, "2026-08-27"); - const r = setAttendance(db, 1, { project_id: 1, worker_id: 1, work_date: "2026-08-28", present: false }); - assertEquals("ok" in r && r.ok, true); - const week = db.prepare("SELECT required_net, payable_net FROM payroll_weeks WHERE id=?").get(weekId) as { - required_net: number; - payable_net: number; - }; - assertEquals(week.required_net, 2500); - assertEquals(week.payable_net, 2000); -}); - -Deno.test("préstamo no baja saldo al armar; sí al pagar", () => { - const db = mem(); - db.prepare("INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (1, 1, '2026-08-24', 1)").run(); - db.prepare("INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (1, 1, '2026-08-25', 1)").run(); - db.prepare("INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (1, 1, '2026-08-26', 1)").run(); - createLoan(db, 1, { worker_id: 1, delivered: 2000, commission_pct: 10, grantIso: "2026-08-24" }); - const weekId = ensureWeek(db, 1, "2026-08-24"); - assembleWeek(db, weekId, "2026-08-27"); - const before = db.prepare("SELECT balance FROM loans WHERE worker_id=1").get() as { balance: number }; - assertEquals(before.balance, 2200); - const week = db.prepare("SELECT required_net FROM payroll_weeks WHERE id=?").get(weekId) as { required_net: number }; - assertEquals(week.required_net, 800); - payWeek(db, weekId); - const after = db.prepare("SELECT balance FROM loans WHERE worker_id=1").get() as { balance: number }; - assertEquals(after.balance, 0); -}); - -Deno.test("tablero: recuperación de préstamos no es sobrante", () => { - const db = mem(); - db.prepare("INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (1, 1, '2026-08-24', 1)").run(); - db.prepare("INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (1, 1, '2026-08-25', 1)").run(); - const weekId = ensureWeek(db, 1, "2026-08-24"); - assembleWeek(db, weekId, "2026-08-27"); - createLoan(db, 1, { worker_id: 1, delivered: 1000, commission_pct: 20, grantIso: "2026-08-24" }); - const bundle = getWeekBundle(db, 1, "2026-08-24"); - const board = bundle.board as { - required_net: number; - payable_net: number; - loan_recovery: number; - faltante: number; - }; - assertEquals(board.required_net, 2500); - assertEquals(board.loan_recovery, 1200); - assertEquals(board.payable_net, 1300); - assertEquals(board.faltante, 0); -}); - -Deno.test("un día no se marca en dos obras", () => { - const db = mem(); - db.prepare("INSERT INTO projects (id, code, name, status, tenant_id) VALUES (2, 'PRY-0002', 'Obra Sur', 'activo', 1)").run(); - setAttendance(db, 1, { project_id: 1, worker_id: 1, work_date: "2026-08-24", present: true }); - const blocked = setAttendance(db, 1, { project_id: 2, worker_id: 1, work_date: "2026-08-24", present: true }); - assertEquals("error" in blocked, true); -}); - -Deno.test("préstamo de personal: el día en otra obra no se prellena aquí", () => { - const db = mem(); - db.prepare("INSERT INTO projects (id, code, name, status, tenant_id) VALUES (2, 'PRY-0002', 'Obra Sur', 'activo', 1)").run(); - setAttendance(db, 1, { project_id: 2, worker_id: 1, work_date: "2026-08-27", present: true }); - const weekId = ensureWeek(db, 1, "2026-08-24"); - assembleWeek(db, weekId, "2026-08-27"); - const onA = db.prepare( - "SELECT present FROM attendance WHERE worker_id=1 AND project_id=1 AND work_date='2026-08-27'", - ).get() as { present: number } | undefined; - assertEquals(onA, undefined); - const onB = db.prepare( - "SELECT present FROM attendance WHERE worker_id=1 AND project_id=2 AND work_date='2026-08-27'", - ).get() as { present: number }; - assertEquals(onB.present, 1); -}); - - -Deno.test("destajo: estimado del corte, realizado menor, remanente y extra viernes", () => { - const db = mem(); - db.prepare("DELETE FROM assignments WHERE worker_id=1").run(); - db.prepare( - "INSERT INTO workers (id, first_name, last_name_p, work_type, daily_wage, tenant_id) VALUES (2, 'Luis', 'Destajo', 'D', 0, 1)", - ).run(); - const weekId = ensureWeek(db, 1, "2026-08-24"); - const job = createDestajoJob(db, 1, weekId, { - project_id: 1, - worker_id: 2, - concepto: "Muro tablaroca", - unit_code: "m2", - qty_total_estimated: 200, - unit_price: 100, - qty_planned: 80, +Deno.test("armar jueves prellena jue-vie-sáb y congela requerida", async () => { + await withTestDb(async (db) => { + const { projectId, workerId } = await seedFixture(db); + await db.prepare( + "INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (?, ?, '2026-08-24', true)", + ).run(workerId, projectId); + await db.prepare( + "INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (?, ?, '2026-08-25', true)", + ).run(workerId, projectId); + const weekId = await ensureWeek(db, TENANT_ID, "2026-08-24"); + await assembleWeek(db, weekId, "2026-08-27"); + const week = await db.prepare("SELECT * FROM payroll_weeks WHERE id=?").get(weekId) as { + status: string; + required_net: number; + payable_net: number; + }; + assertEquals(week.status, "assembled"); + assertEquals(week.required_net, 2500); + assertEquals(week.payable_net, 2500); + const thu = await db.prepare( + "SELECT present FROM attendance WHERE worker_id=? AND work_date='2026-08-27'", + ).get(workerId) as { present: boolean }; + assertEquals(thu.present, true); + }); +}); + +Deno.test("falta viernes baja a pagar y no toca requerida", async () => { + await withTestDb(async (db) => { + const { projectId, workerId } = await seedFixture(db); + await db.prepare( + "INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (?, ?, '2026-08-24', true)", + ).run(workerId, projectId); + await db.prepare( + "INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (?, ?, '2026-08-25', true)", + ).run(workerId, projectId); + const weekId = await ensureWeek(db, TENANT_ID, "2026-08-24"); + await assembleWeek(db, weekId, "2026-08-27"); + const r = await setAttendance(db, TENANT_ID, { + project_id: projectId, + worker_id: workerId, + work_date: "2026-08-28", + present: false, + }); + assertEquals("ok" in r && r.ok, true); + const week = await db.prepare("SELECT required_net, payable_net FROM payroll_weeks WHERE id=?").get(weekId) as { + required_net: number; + payable_net: number; + }; + assertEquals(week.required_net, 2500); + assertEquals(week.payable_net, 2000); + }); +}); + +Deno.test("préstamo no baja saldo al armar; sí al pagar", async () => { + await withTestDb(async (db) => { + const { projectId, workerId } = await seedFixture(db); + for (const day of ["2026-08-24", "2026-08-25", "2026-08-26"]) { + await db.prepare( + "INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (?, ?, ?, true)", + ).run(workerId, projectId, day); + } + await createLoan(db, TENANT_ID, { worker_id: workerId, delivered: 2000, commission_pct: 10, grantIso: "2026-08-24" }); + const weekId = await ensureWeek(db, TENANT_ID, "2026-08-24"); + await assembleWeek(db, weekId, "2026-08-27"); + const before = await db.prepare("SELECT balance FROM loans WHERE worker_id=?").get(workerId) as { balance: number }; + assertEquals(before.balance, 2200); + const week = await db.prepare("SELECT required_net FROM payroll_weeks WHERE id=?").get(weekId) as { required_net: number }; + assertEquals(week.required_net, 800); + await payWeek(db, weekId, "2026-08-29"); + const after = await db.prepare("SELECT balance FROM loans WHERE worker_id=?").get(workerId) as { balance: number }; + assertEquals(after.balance, 0); + }); +}); + +Deno.test("tablero: recuperación de préstamos no es sobrante", async () => { + await withTestDb(async (db) => { + const { projectId, workerId } = await seedFixture(db); + await db.prepare( + "INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (?, ?, '2026-08-24', true)", + ).run(workerId, projectId); + await db.prepare( + "INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (?, ?, '2026-08-25', true)", + ).run(workerId, projectId); + const weekId = await ensureWeek(db, TENANT_ID, "2026-08-24"); + await assembleWeek(db, weekId, "2026-08-27"); + await createLoan(db, TENANT_ID, { worker_id: workerId, delivered: 1000, commission_pct: 20, grantIso: "2026-08-24" }); + const bundle = await getWeekBundle(db, TENANT_ID, "2026-08-24"); + const board = bundle.board as { + required_net: number; + payable_net: number; + loan_recovery: number; + faltante: number; + }; + assertEquals(board.required_net, 2500); + assertEquals(board.loan_recovery, 1200); + assertEquals(board.payable_net, 1300); + assertEquals(board.faltante, 0); + }); +}); + +Deno.test("un día no se marca en dos obras", async () => { + await withTestDb(async (db) => { + const { companyId, projectId, workerId } = await seedFixture(db); + const project2Id = await addSecondProject(db, companyId); + await setAttendance(db, TENANT_ID, { project_id: projectId, worker_id: workerId, work_date: "2026-08-24", present: true }); + const blocked = await setAttendance(db, TENANT_ID, { + project_id: project2Id, + worker_id: workerId, + work_date: "2026-08-24", + present: true, + }); + assertEquals("error" in blocked, true); + }); +}); + +Deno.test("préstamo de personal: el día en otra obra no se prellena aquí", async () => { + await withTestDb(async (db) => { + const { companyId, projectId, workerId } = await seedFixture(db); + const project2Id = await addSecondProject(db, companyId); + await setAttendance(db, TENANT_ID, { project_id: project2Id, worker_id: workerId, work_date: "2026-08-27", present: true }); + const weekId = await ensureWeek(db, TENANT_ID, "2026-08-24"); + await assembleWeek(db, weekId, "2026-08-27"); + const onA = await db.prepare( + "SELECT present FROM attendance WHERE worker_id=? AND project_id=? AND work_date='2026-08-27'", + ).get(workerId, projectId) as { present: boolean } | undefined; + assertEquals(onA, undefined); + const onB = await db.prepare( + "SELECT present FROM attendance WHERE worker_id=? AND project_id=? AND work_date='2026-08-27'", + ).get(workerId, project2Id) as { present: boolean }; + assertEquals(onB.present, true); + }); +}); + +Deno.test("destajo: estimado del corte, realizado menor, remanente y extra viernes", async () => { + await withTestDb(async (db) => { + const { companyId, projectId, workerId } = await seedFixture(db); + // Igual que el original: el jornalero de la fixture no debe seguir en + // el roster de "obra", para que el requerido sea solo el destajo. + await db.prepare("DELETE FROM assignments WHERE worker_id=?").run(workerId); + const worker2Id = await addSecondWorker(db, companyId); + const weekId = await ensureWeek(db, TENANT_ID, "2026-08-24"); + const job = await createDestajoJob(db, TENANT_ID, weekId, { + project_id: projectId, + worker_id: worker2Id, + concepto: "Muro tablaroca", + unit_code: "m2", + qty_total_estimated: 200, + unit_price: 100, + qty_planned: 80, + }); + await assembleWeek(db, weekId, "2026-08-27"); + let week = await db.prepare("SELECT required_net, payable_net FROM payroll_weeks WHERE id=?").get(weekId) as { + required_net: number; + payable_net: number; + }; + assertEquals(week.required_net, 8000); + const cut = await db.prepare("SELECT id FROM destajo_cut_lines WHERE job_id=?").get(job.id) as { id: number }; + await patchDestajoCut(db, weekId, cut.id, { qty_actual: 50 }); + week = await db.prepare("SELECT required_net, payable_net FROM payroll_weeks WHERE id=?").get(weekId) as { + required_net: number; + payable_net: number; + }; + assertEquals(week.required_net, 8000); + assertEquals(week.payable_net, 5000); + await patchDestajoCut(db, weekId, cut.id, { qty_extra: 10 }); + week = await db.prepare("SELECT required_net, payable_net FROM payroll_weeks WHERE id=?").get(weekId) as { + required_net: number; + payable_net: number; + }; + assertEquals(week.payable_net, 6000); + await payWeek(db, weekId, "2026-08-29"); + assertEquals(await jobRemainder(db, job.id), 140); + const nextId = await ensureWeek(db, TENANT_ID, addDays("2026-08-24", 7)); + assertExists(nextId); + const bundle = await getWeekBundle(db, TENANT_ID, "2026-08-31"); + assertEquals((bundle.destajo_jobs as { remainder: number }[])[0].remainder, 140); }); - assembleWeek(db, weekId, "2026-08-27"); - let week = db.prepare("SELECT required_net, payable_net FROM payroll_weeks WHERE id=?").get(weekId) as { - required_net: number; - payable_net: number; - }; - assertEquals(week.required_net, 8000); - const cut = db.prepare("SELECT id FROM destajo_cut_lines WHERE job_id=?").get(job.id) as { id: number }; - patchDestajoCut(db, weekId, cut.id, { qty_actual: 50 }); - week = db.prepare("SELECT required_net, payable_net FROM payroll_weeks WHERE id=?").get(weekId) as { - required_net: number; - payable_net: number; - }; - assertEquals(week.required_net, 8000); - assertEquals(week.payable_net, 5000); - patchDestajoCut(db, weekId, cut.id, { qty_extra: 10 }); - week = db.prepare("SELECT required_net, payable_net FROM payroll_weeks WHERE id=?").get(weekId) as { - required_net: number; - payable_net: number; - }; - assertEquals(week.payable_net, 6000); - payWeek(db, weekId); - assertEquals(jobRemainder(db, job.id), 140); - const nextId = ensureWeek(db, 1, addDays("2026-08-24", 7)); - assertExists(nextId); - const bundle = getWeekBundle(db, 1, "2026-08-31"); - assertEquals((bundle.destajo_jobs as { remainder: number }[])[0].remainder, 140); }); diff --git a/api/pdf.ts b/api/pdf.ts index b53071b..6a40a3b 100644 --- a/api/pdf.ts +++ b/api/pdf.ts @@ -1,13 +1,10 @@ -import type { Database } from "@db/sqlite"; -import { mkdir } from "node:fs/promises"; -import { join } from "node:path"; -// mkdir used in saveJobPdf import QRCode from "qrcode"; import { PDFDocument, StandardFonts, rgb, degrees, type PDFPage, type PDFFont, type PDFImage } from "pdf-lib"; -import { config, PDFS_DIR } from "./config.ts"; +import { config } from "./config.ts"; import { decryptBytes } from "./docs_crypto.ts"; import { fullName, frontName } from "./mx.ts"; -import { workerDir } from "./db.ts"; +import type { Db } from "./db.ts"; +import { badgeJobPdfKey, getObject, putObject, workerDocKey } from "./storage.ts"; const CM = 28.346456692913385; const CARD_W = 6.7 * CM; @@ -53,15 +50,15 @@ function hexRgb(hex: string) { ); } -export async function loadCurrentPhoto(db: Database, workerId: number): Promise { - const doc = db.prepare( +export async function loadCurrentPhoto(db: Db, workerId: number): Promise { + const doc = await db.prepare( `SELECT storage_name, iv FROM documents - WHERE worker_id = ? AND type_code = 'foto' AND is_current = 1 + WHERE worker_id = ? AND type_code = 'foto' AND is_current = true ORDER BY id DESC LIMIT 1`, ).get(workerId) as { storage_name: string; iv: string } | undefined; if (!doc) return null; try { - const enc = await Deno.readFile(join(workerDir(workerId), doc.storage_name)); + const enc = await getObject(workerDocKey(workerId, doc.storage_name)); return await decryptBytes(doc.iv, enc); } catch { return null; @@ -99,11 +96,11 @@ function drawCentered( } export async function generateBadgePdf( - db: Database, + db: Db, projectId: number, workerIds: number[], ): Promise { - const project = db.prepare( + const project = await db.prepare( "SELECT name, code, theme_id, logo_left_path, logo_right_path FROM projects WHERE id = ?", ).get(projectId) as { name: string; @@ -114,15 +111,14 @@ export async function generateBadgePdf( } | undefined; if (!project) throw new Error("Proyecto no encontrado"); - const placeholders = workerIds.map(() => "?").join(","); - const workers = db.prepare( + const workers = await db.prepare( `SELECT w.id, w.first_name, w.middle_name, w.last_name_p, w.last_name_m, w.curp, w.nss, w.blood_type, w.position, r.color AS risk_color, r.text_color AS risk_text FROM workers w JOIN risk_levels r ON r.code = w.risk_code - WHERE w.id IN (${placeholders})`, - ).all(...workerIds) as WorkerRow[]; + WHERE w.id = ANY(?)`, + ).all(workerIds) as WorkerRow[]; const byId = new Map(workers.map((w) => [w.id, w])); const ordered = workerIds.map((id) => byId.get(id)).filter(Boolean) as WorkerRow[]; @@ -135,12 +131,12 @@ export async function generateBadgePdf( let logoR: PDFImage | null = null; if (project.logo_left_path) { try { - logoL = await embedMaybe(pdf, await Deno.readFile(project.logo_left_path)); + logoL = await embedMaybe(pdf, await getObject(project.logo_left_path)); } catch { /* text fallback */ } } if (project.logo_right_path) { try { - logoR = await embedMaybe(pdf, await Deno.readFile(project.logo_right_path)); + logoR = await embedMaybe(pdf, await getObject(project.logo_right_path)); } catch { /* text fallback */ } } @@ -238,7 +234,6 @@ function drawFront( page.drawImage(photo, { x: px + 1, y: py + 1, width: photoW - 2, height: photoH - 2 }); } - const name = frontName(w).toUpperCase(); drawCentered(page, fontBold, w.first_name.toUpperCase(), x, py - 22, CARD_W, 13); drawCentered(page, fontBold, w.last_name_p.toUpperCase(), x, py - 38, CARD_W, 13); @@ -274,8 +269,6 @@ function drawBack( projectName: string, projectCode: string, ) { - const cx = x + CARD_W / 2; - const cy = y + CARD_H / 2; page.drawRectangle({ x, y, @@ -284,11 +277,7 @@ function drawBack( borderColor: rgb(0, 0, 0), borderWidth: 1.5, rotate: degrees(180), - // pdf-lib rotate is around origin of the op; we translate via origin option }); - // Draw rotated content using origin at card center - const opts = { rotate: degrees(180) as ReturnType }; - page.drawRectangle({ x: x + CARD_W, y: y + CARD_H, @@ -343,10 +332,9 @@ function drawBack( } export async function saveJobPdf(bytes: Uint8Array, jobId: number): Promise { - await mkdir(PDFS_DIR, { recursive: true }); - const path = join(PDFS_DIR, `gafetes-${jobId}.pdf`); - await Deno.writeFile(path, bytes); - return path; + const key = badgeJobPdfKey(jobId); + await putObject(key, bytes); + return key; } function wrap(text: string, font: PDFFont, size: number, max: number): string[] { diff --git a/api/pg.ts b/api/pg.ts new file mode 100644 index 0000000..80d074c --- /dev/null +++ b/api/pg.ts @@ -0,0 +1,138 @@ +import postgres from "npm:postgres@3"; + +/** + * Adaptador delgado sobre postgres.js que imita la forma + * `db.prepare(sql).get/all/run(...params)` que tenía el driver SQLite + * (@db/sqlite), para poder migrar ~80 endpoints sin reescribir cada query + * a mano en el mismo cambio que separa las bases/esquemas. Sigue siendo + * 100% parametrizado (nunca concatena valores en el texto SQL) -- lo único + * que cambia es la forma superficial de la llamada, no la seguridad. + * + * Traduce automáticamente placeholders `?` (estilo SQLite) a `$1, $2, ...` + * (estilo Postgres). Todo el acceso es async (a diferencia de better-sqlite3 + * style), así que cada call-site necesita `await`. + */ + +export type PgRow = Record; + +function toPositional(text: string): string { + let i = 0; + return text.replace(/\?/g, () => `$${++i}`); +} + +export class PreparedStatement { + constructor(private sql: postgres.Sql, private text: string) {} + + async get(...params: unknown[]): Promise { + const rows = await this.sql.unsafe(toPositional(this.text), params as never[]); + return rows[0] as PgRow | undefined; + } + + async all(...params: unknown[]): Promise { + const rows = await this.sql.unsafe(toPositional(this.text), params as never[]); + return rows as unknown as PgRow[]; + } + + /** Igual que .all/.get pero no espera filas de vuelta (INSERT/UPDATE/DELETE). */ + async run(...params: unknown[]): Promise<{ changes: number }> { + const rows = await this.sql.unsafe(toPositional(this.text), params as never[]); + return { changes: rows.count ?? rows.length }; + } +} + +/** Wrapper con la forma db.prepare()/db.exec() de @db/sqlite, sobre una + * conexión (o conexión reservada) de postgres.js. */ +export class PgDb { + constructor(public raw: postgres.Sql) {} + + prepare(text: string): PreparedStatement { + return new PreparedStatement(this.raw, text); + } + + async exec(text: string): Promise { + await this.raw.unsafe(text); + } + + /** Equivalente a last_insert_rowid(): dentro de la MISMA conexión/ + * transacción, lastval() devuelve el último valor de secuencia obtenido + * en esta sesión. Solo es seguro si se llama justo después del INSERT + * correspondiente, en la misma conexión reservada por request. */ + async lastInsertId(): Promise { + const rows = await this.raw.unsafe("SELECT lastval()::bigint AS id"); + return Number((rows[0] as unknown as { id: number | bigint }).id); + } +} + +export function createPool(url: string, options: postgres.Options> = {}) { + return postgres(url, { + max: 10, + idle_timeout: 30, + connect_timeout: 10, + types: { + // postgres.js devuelve NUMERIC como string por defecto (evita perder + // precisión en valores gigantes, tipo BigDecimal). Aquí los montos + // son jornales/presupuestos -- ya vivían como REAL/float en SQLite, + // así que parseFloat no introduce una regresión de precisión nueva, + // y evita tener que tocar cada call-site que hace aritmética sobre + // columnas NUMERIC (daily_wage, gross, balance, amount, etc.). + numeric: { + to: 1700, + from: [1700], + serialize: (x: number) => String(x), + parse: (x: string) => Number.parseFloat(x), + }, + // postgres.js devuelve date/timestamp(tz) como objetos Date por + // defecto. Todo el código heredado de SQLite trata fechas como + // strings (comparaciones lexicográficas, .slice(0,10), template + // literals) -- se fuerzan a texto aquí para no reescribir cada + // call-site de fechas en el mismo cambio que separa las bases. + date: { to: 1082, from: [1082], serialize: (x: string) => x, parse: (x: string) => x }, + timestamp: { to: 1114, from: [1114], serialize: (x: string) => x, parse: (x: string) => x }, + timestamptz: { to: 1184, from: [1184], serialize: (x: string) => x, parse: (x: string) => x }, + // Todas las PK son BIGINT GENERATED ALWAYS AS IDENTITY (pensando en + // escala futura), pero postgres.js devuelve bigint como string por + // defecto (evita perder precisión más allá de Number.MAX_SAFE_INTEGER). + // A esta escala de negocio los ids nunca se acercan a ese límite, y + // el código heredado los trata como number en todas partes (Map, + // comparaciones ===, etc.), así que se parsean a Number aquí. + bigint: { + to: 20, + from: [20], + serialize: (x: number) => String(x), + parse: (x: string) => Number(x), + }, + }, + ...options, + }); +} + +/** + * Ejecuta `fn` dentro de una transacción con `app.tenant_id` fijado vía + * set_config(..., is_local=true) -- así las políticas de Row Level Security + * (db/iam/changesets/002-rls.sql, db/core/changesets/005-rls.sql) filtran + * automáticamente por tenant, y el valor se limpia solo al terminar la + * transacción sin importar qué conexión del pool se reutilice después. + * + * tenantId = null significa "sin tenant" (p. ej. platform_admin o la + * API key legacy): las políticas de RLS son fail-closed, así que sin + * tenant_id fijado NO se ve ninguna fila con tenant_id NOT NULL. Las + * rutas que de verdad necesitan cruzar tenants deben pasar por el rol de + * soporte explícito (ver Fase 4/nota de seguridad sobre el bypass de + * X-API-Key), no por dejar tenantId en null "por si acaso". + */ +export async function withTenant( + sql: postgres.Sql, + tenantId: number | null, + fn: (scoped: PgDb) => Promise, +): Promise { + const result = await sql.begin(async (tx) => { + if (tenantId != null) { + await tx`SELECT set_config('app.tenant_id', ${String(tenantId)}, true)`; + } + return [await fn(new PgDb(tx as unknown as postgres.Sql))] as const; + }); + return (result as unknown as [T])[0]; +} + +export type { postgres }; +export default postgres; diff --git a/api/platform_db.ts b/api/platform_db.ts index 33a3b76..72b2d94 100644 --- a/api/platform_db.ts +++ b/api/platform_db.ts @@ -1,13 +1,21 @@ -import { Database } from "@db/sqlite"; -import { mkdir } from "node:fs/promises"; -import { DATA_DIR, PLATFORM_DB_PATH } from "./config.ts"; +import postgres, { createPool, PgDb } from "./pg.ts"; import { config } from "./config.ts"; -import { hashPassword } from "./crypto.ts"; -import { runLiquibase } from "./liquibase.ts"; -export type PlatformDb = Database; +/** + * panels_platform: control plane SaaS (tenants, platform_users, + * smtp_settings). Base de datos separada de panels_product -- ningún + * código de iam/core debe importar este módulo, y viceversa. Sin RLS: no + * es multi-tenant en el mismo sentido (son las cuentas del propio equipo + * de PANELS), así que un solo pool basta. + * + * El bootstrap del primer platform_admin ya NO ocurre aquí en cada arranque + * (antes: seedPlatform() en cada getPlatformDb()) -- es un comando explícito + * y one-shot, ver scripts/bootstrap-admin.ts (Fase 4). + */ -let platformDb: Database | null = null; +let pool: postgres.Sql | null = null; + +export type PlatformDb = PgDb; export type PlatformUser = { id: number; @@ -16,70 +24,25 @@ export type PlatformUser = { status: string; }; -export async function getPlatformDb(): Promise { - if (platformDb) return platformDb; - await mkdir(DATA_DIR, { recursive: true }); - await runLiquibase(); - platformDb = new Database(PLATFORM_DB_PATH); - platformDb.exec("PRAGMA foreign_keys = ON;"); - await seedPlatform(platformDb); - return platformDb; +export async function getPlatformDb(): Promise { + if (!pool) { + pool = createPool(config.databaseUrlPlatform, { max: 10 }); + await pool`SELECT 1`; + } + return new PgDb(pool); } -async function seedPlatform(database: Database) { - if (!config.seedPassword) { - console.warn("[platform] SEED_PASSWORD vacío; no se siembra usuario admin"); - } else { - const hash = await hashPassword(config.seedPassword); - const admin = database.prepare("SELECT id FROM platform_users WHERE username = ?").get("admin") as - | { id: number } - | undefined; - const legacy = database.prepare("SELECT id FROM platform_users WHERE username = ?").get("operador") as - | { id: number } - | undefined; - - if (legacy && !admin) { - database.prepare( - `UPDATE platform_users - SET username = ?, password_hash = ?, display_name = ? - WHERE id = ?`, - ).run("admin", hash, "Admin PANELS", legacy.id); - } else if (!admin) { - database.prepare( - `INSERT INTO platform_users (username, password_hash, display_name, status) - VALUES (?, ?, ?, 'activo')`, - ).run("admin", hash, "Admin PANELS"); - } else if (legacy) { - database.prepare("DELETE FROM platform_users WHERE id = ?").run(legacy.id); - } - - const sync = ["1", "true", "yes"].includes( - (Deno.env.get("SEED_SYNC_PASSWORD") ?? "").toLowerCase(), - ); - if (sync) { - const row = database.prepare("SELECT id FROM platform_users WHERE username = ?").get("admin") as - | { id: number } - | undefined; - if (row) { - database.prepare( - `UPDATE platform_users SET password_hash = ?, display_name = ? WHERE id = ?`, - ).run(hash, "Admin PANELS", row.id); - console.warn("[platform] SEED_SYNC_PASSWORD: password de admin actualizado"); - } - } - } - const arctec = database.prepare( - "SELECT id FROM tenants WHERE code IN ('ARCT2608', 'ARCTEC')", - ).get(); - if (!arctec) { - database.prepare( - `INSERT INTO tenants (id, code, name, status) VALUES (1, 'ARCT2608', 'ARCTEC', 'activo')`, - ).run(); - } +export async function closePlatformDb(): Promise { + await pool?.end({ timeout: 5 }); + pool = null; } -export function listTenants(database: Database) { - return database.prepare( +export async function pingPlatformDb(): Promise { + await getPlatformDb().then((db) => db.prepare("SELECT 1").get()); +} + +export async function listTenants(database: PlatformDb) { + return await database.prepare( `SELECT id, code, name, status, created_at, COALESCE(plan, 'trial') AS plan, valid_until, COALESCE(notes, '') AS notes, COALESCE(contact_email, '') AS contact_email, @@ -101,8 +64,10 @@ export function listTenants(database: Database) { }[]; } -export function tenantByCode(database: Database, code: string) { - return database.prepare("SELECT * FROM tenants WHERE code = ?").get(code.trim().toUpperCase()) as +export async function tenantByCode(database: PlatformDb, code: string) { + return await database.prepare("SELECT * FROM tenants WHERE code = ?").get( + code.trim().toUpperCase(), + ) as | { id: number; code: string; @@ -115,8 +80,8 @@ export function tenantByCode(database: Database, code: string) { | undefined; } -export function tenantById(database: Database, id: number) { - return database.prepare("SELECT * FROM tenants WHERE id = ?").get(id) as +export async function tenantById(database: PlatformDb, id: number) { + return await database.prepare("SELECT * FROM tenants WHERE id = ?").get(id) as | { id: number; code: string; @@ -130,6 +95,6 @@ export function tenantById(database: Database, id: number) { | undefined; } -export function lastInsertId(database: Database): number { - return Number((database.prepare("SELECT last_insert_rowid() AS id").get() as { id: number }).id); +export async function lastInsertId(database: PlatformDb): Promise { + return await database.lastInsertId(); } diff --git a/api/redis.ts b/api/redis.ts new file mode 100644 index 0000000..b51d956 --- /dev/null +++ b/api/redis.ts @@ -0,0 +1,51 @@ +import { createClient, type RedisClientType } from "npm:redis@4"; +import { config } from "./config.ts"; + +/** + * Dos clientes Redis separados, autenticados con usuarios ACL distintos + * (panels_iam_redis ~iam:*, panels_core_redis ~core:*, ver + * db/provision/05-redis-acl.sh). El prefijo de llave (`iam:`/`core:`) + * refuerza pero NO sustituye el ACL del servidor -- la separación real la + * da la credencial, no la convención de nombres. + * + * Redis no es fuente de verdad de nada aquí: sesiones (iam:session:*) se + * recuperan con un re-login; cache (core:cache:*) se recalcula desde + * Postgres. Por eso no hay lógica de reintento agresivo ni persistencia + * más allá de lo que ya ofrece el servidor. + */ + +let iamClient: RedisClientType | null = null; +let coreClient: RedisClientType | null = null; + +export async function getIamRedis(): Promise { + if (!iamClient) { + iamClient = createClient({ url: config.redisUrlIam }) as RedisClientType; + iamClient.on("error", (err) => console.error("[redis:iam]", err)); + await iamClient.connect(); + } + return iamClient; +} + +export async function getCoreRedis(): Promise { + if (!coreClient) { + coreClient = createClient({ url: config.redisUrlCore }) as RedisClientType; + coreClient.on("error", (err) => console.error("[redis:core]", err)); + await coreClient.connect(); + } + return coreClient; +} + +export async function pingRedis(): Promise<{ iam: boolean; core: boolean }> { + const [iam, core] = await Promise.all([ + getIamRedis().then((c) => c.ping()).then(() => true).catch(() => false), + getCoreRedis().then((c) => c.ping()).then(() => true).catch(() => false), + ]); + return { iam, core }; +} + +export async function closeRedis(): Promise { + await iamClient?.quit().catch(() => {}); + await coreClient?.quit().catch(() => {}); + iamClient = null; + coreClient = null; +} diff --git a/api/saas.ts b/api/saas.ts index f7dd69e..10b7647 100644 --- a/api/saas.ts +++ b/api/saas.ts @@ -1,6 +1,6 @@ -import type { Database } from "@db/sqlite"; import { generateSecurePassword, hashPassword } from "./crypto.ts"; -import { lastInsertId as appLastId } from "./db.ts"; +import { getCoreDb, type Db } from "./db.ts"; +import { withIamOwner } from "./iam_db.ts"; import { lastInsertId as platformLastId, listTenants as listTenantsRaw, @@ -107,29 +107,29 @@ export function codeDateStamp(when: Date = new Date()): string { return `${yy}${mm}`; } -function codeExists(platformDb: PlatformDb, appDb: Database, code: string): boolean { - return !!tenantByCode(platformDb, code) || !!companyByCode(appDb, code); +async function codeExists(platformDb: PlatformDb, coreDb: Db, code: string): Promise { + return !!await tenantByCode(platformDb, code) || !!await companyByCode(coreDb, code); } /** * Código único: prefijo del nombre comercial + YYMM de creación. * Ej. «ARCTEC» en ago-2026 → ARCT2608. Si choca, sufijo numérico. */ -export function allocateUniqueTenantCode( +export async function allocateUniqueTenantCode( platformDb: PlatformDb, - appDb: Database, + coreDb: Db, nombreComercial: string, createdAt: Date = new Date(), -): string { +): Promise { const base = shortCodeFromCommercialName(nombreComercial); const stamp = codeDateStamp(createdAt); const primary = `${base}${stamp}`; - if (!codeExists(platformDb, appDb, primary)) return primary; + if (!await codeExists(platformDb, coreDb, primary)) return primary; for (let n = 2; n < 1000; n++) { const suffix = String(n); const head = base.slice(0, Math.max(2, 4 - suffix.length)); const candidate = `${head}${stamp}${suffix}`; - if (candidate.length <= 16 && !codeExists(platformDb, appDb, candidate)) return candidate; + if (candidate.length <= 16 && !await codeExists(platformDb, coreDb, candidate)) return candidate; } return `E${stamp}${Date.now().toString(36).toUpperCase().slice(-4)}`; } @@ -178,16 +178,16 @@ export function requireSaasCompanyFields( return null; } -export function listTenants(platformDb: PlatformDb) { - return listTenantsRaw(platformDb); +export async function listTenants(platformDb: PlatformDb) { + return await listTenantsRaw(platformDb); } -export function getTenantDetail( +export async function getTenantDetail( platformDb: PlatformDb, - appDb: Database, + coreDb: Db, tenantId: number, -): TenantDetail | null { - const t = platformDb.prepare( +): Promise { + const t = await platformDb.prepare( `SELECT id, code, name, status, created_at, COALESCE(plan, 'trial') AS plan, valid_until, COALESCE(notes, '') AS notes, COALESCE(contact_email, '') AS contact_email, @@ -211,7 +211,11 @@ export function getTenantDetail( | undefined; if (!t) return null; - const company = appDb.prepare( + // company/admins viven en core/iam -- este lookup administrativo cruza + // tenants a propósito (consola SaaS viendo el detalle de UN tenant desde + // fuera de cualquier sesión de ese tenant), así que coreDb debe ser la + // conexión owner (bypassa RLS), no una conexión scoped por tenant. + const company = await coreDb.prepare( `SELECT id, code, name, COALESCE(rfc, '') AS rfc, COALESCE(razon_social, '') AS razon_social, @@ -230,20 +234,23 @@ export function getTenantDetail( FROM companies WHERE tenant_id = ? AND kind = 'principal' ORDER BY id LIMIT 1`, ).get(tenantId) as CompanySnapshot | undefined; - const admins = (appDb.prepare( - `SELECT id, username, display_name, COALESCE(email, '') AS email, role, - COALESCE(must_change_password, 0) AS must_change_password, created_at - FROM users WHERE tenant_id = ? AND role = 'tenant_admin' - ORDER BY id`, - ).all(tenantId) as Array>).map((a) => ({ - id: Number(a.id), - username: String(a.username), - display_name: String(a.display_name), - email: String(a.email || ""), - role: String(a.role), - must_change_password: Boolean(a.must_change_password), - created_at: String(a.created_at), - })); + const admins = await withIamOwner(async (iam) => { + const rows = await iam.prepare( + `SELECT id, username, display_name, COALESCE(email, '') AS email, role_code AS role, + COALESCE(must_change_password, false) AS must_change_password, created_at + FROM users WHERE tenant_id = ? AND role_code = 'tenant_admin' + ORDER BY id`, + ).all(tenantId) as Array>; + return rows.map((a) => ({ + id: Number(a.id), + username: String(a.username), + display_name: String(a.display_name), + email: String(a.email || ""), + role: String(a.role), + must_change_password: Boolean(a.must_change_password), + created_at: String(a.created_at), + })); + }); return { ...t, @@ -252,9 +259,19 @@ export function getTenantDetail( }; } +/** + * Alta de tenant: cruza panels_platform (tenants) + panels_product.core + * (companies) + panels_product.iam (users), tres bases/esquemas sin + * transacción distribuida posible entre ellos (Fase 5b del plan). Se + * implementa como compensación explícita: si algo falla después de crear + * el tenant y/o la empresa, se borra lo ya creado antes de devolver el + * error -- así un fallo a medio alta nunca deja un tenant sin empresa/admin + * o una empresa sin tenant. Es idempotente por `code`: si el caller + * reintenta tras un error ya limpiado, simplemente crea todo de nuevo. + */ export async function createTenant( platformDb: PlatformDb, - appDb: Database, + coreDb: Db, input: CreateTenantInput, ): Promise<{ tenant?: { id: number; code: string; name: string; status: string }; @@ -271,7 +288,7 @@ export async function createTenant( }); if (completeErr) return { error: completeErr }; - const code = allocateUniqueTenantCode(platformDb, appDb, profile.nombre_comercial); + const code = await allocateUniqueTenantCode(platformDb, coreDb, profile.nombre_comercial); const displayName = profile.nombre_comercial; const adminUser = adminUsernameForCode(code); @@ -279,7 +296,7 @@ export async function createTenant( const contactEmail = profile.email; const adminName = trim(input.admin_display_name) || contactName; - const userClash = appDb.prepare("SELECT id FROM users WHERE username = ?").get(adminUser); + const userClash = await withIamOwner((iam) => iam.prepare("SELECT id FROM users WHERE username = ?").get(adminUser)); if (userClash) return { error: `Ya existe el usuario ${adminUser}` }; const plan = trim(input.plan) || "trial"; @@ -288,53 +305,65 @@ export async function createTenant( const notes = trim(input.notes); const temporaryPassword = generateSecurePassword(); - platformDb.prepare( + // Paso 1: tenant en panels_platform. + await platformDb.prepare( `INSERT INTO tenants (code, name, status, plan, valid_until, notes, contact_email, contact_name) VALUES (?, ?, 'activo', ?, ?, ?, ?, ?)`, ).run(code, displayName, plan, validUntil, notes, contactEmail, contactName); - const tenantId = platformLastId(platformDb); + const tenantId = await platformLastId(platformDb); + let companyId: number | undefined; try { - appDb.prepare( - `INSERT INTO companies ( - code, name, parent_id, kind, status, tenant_id, - razon_social, nombre_comercial, rfc, regimen_fiscal, registro_patronal, clase_riesgo, - domicilio_fiscal, codigo_postal, ciudad, estado, telefono, email, representante_legal, giro - ) VALUES (?, ?, NULL, 'principal', 'activo', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ).run( - code, - displayName, - tenantId, - profile.razon_social, - profile.nombre_comercial, - profile.rfc, - profile.regimen_fiscal, - profile.registro_patronal, - profile.clase_riesgo, - profile.domicilio_fiscal, - profile.codigo_postal, - profile.ciudad, - profile.estado, - profile.telefono, - contactEmail, - profile.representante_legal, - profile.giro, + // Paso 2: empresa principal en core (rol owner: alta administrativa + // cruzando tenants, no una operación de un usuario ya autenticado). + companyId = await getCoreDb().then((core) => + core.prepare( + `INSERT INTO companies ( + code, name, parent_id, kind, status, tenant_id, + razon_social, nombre_comercial, rfc, regimen_fiscal, registro_patronal, clase_riesgo, + domicilio_fiscal, codigo_postal, ciudad, estado, telefono, email, representante_legal, giro + ) VALUES (?, ?, NULL, 'principal', 'activo', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run( + code, + displayName, + tenantId, + profile.razon_social, + profile.nombre_comercial, + profile.rfc, + profile.regimen_fiscal, + profile.registro_patronal, + profile.clase_riesgo, + profile.domicilio_fiscal, + profile.codigo_postal, + profile.ciudad, + profile.estado, + profile.telefono, + contactEmail, + profile.representante_legal, + profile.giro, + ).then(() => core.lastInsertId()) ); - const companyId = appLastId(appDb); + // Paso 3: admin del tenant en iam. const hash = await hashPassword(temporaryPassword); - appDb.prepare( - `INSERT INTO users ( - username, password_hash, display_name, company_id, tenant_id, role, - must_change_password, email - ) VALUES (?, ?, ?, ?, ?, 'tenant_admin', 1, ?)`, - ).run(adminUser, hash, adminName, companyId, tenantId, contactEmail); + await withIamOwner((iam) => + iam.prepare( + `INSERT INTO users ( + username, password_hash, display_name, company_id, tenant_id, role_code, + must_change_password, email + ) VALUES (?, ?, ?, ?, ?, 'tenant_admin', true, ?)`, + ).run(adminUser, hash, adminName, companyId, tenantId, contactEmail) + ); } catch (e) { - platformDb.prepare("DELETE FROM tenants WHERE id = ?").run(tenantId); + // Compensación: limpiar en orden inverso lo que sí se alcanzó a crear. + if (companyId != null) { + await getCoreDb().then((core) => core.prepare("DELETE FROM companies WHERE id = ?").run(companyId)); + } + await platformDb.prepare("DELETE FROM tenants WHERE id = ?").run(tenantId); return { error: e instanceof Error ? e.message : "Error al crear empresa" }; } - const tenant = platformDb.prepare( + const tenant = await platformDb.prepare( "SELECT id, code, name, status FROM tenants WHERE id = ?", ).get(tenantId) as { id: number; code: string; name: string; status: string }; @@ -359,16 +388,16 @@ export async function createTenant( }; } -export function updateTenant( +export async function updateTenant( platformDb: PlatformDb, - appDb: Database, + coreDb: Db, tenantId: number, input: UpdateTenantInput, -): { error?: string } { - const current = tenantById(platformDb, tenantId); +): Promise<{ error?: string }> { + const current = await tenantById(platformDb, tenantId); if (!current) return { error: "Empresa no encontrada" }; - const company = appDb.prepare( + const company = await coreDb.prepare( `SELECT * FROM companies WHERE tenant_id = ? AND kind = 'principal' ORDER BY id LIMIT 1`, ).get(tenantId) as Record | undefined; @@ -398,7 +427,7 @@ export function updateTenant( }; const profile = normalizeCompanyProfile(merged, current.name); - const row = platformDb.prepare( + const row = await platformDb.prepare( `SELECT COALESCE(plan, 'trial') AS plan, valid_until, COALESCE(notes, '') AS notes, COALESCE(contact_email, '') AS contact_email, COALESCE(contact_name, '') AS contact_name FROM tenants WHERE id = ?`, @@ -450,13 +479,13 @@ export function updateTenant( const displayName = profile.nombre_comercial || profile.razon_social || current.name; - platformDb.prepare( + await platformDb.prepare( `UPDATE tenants SET name = ?, status = ?, plan = ?, valid_until = ?, notes = ?, contact_email = ?, contact_name = ? WHERE id = ?`, ).run(displayName, status, plan, validUntil, notes, contactEmail, contactName, tenantId); if (company?.id) { - appDb.prepare( + await coreDb.prepare( `UPDATE companies SET name = ?, razon_social = ?, nombre_comercial = ?, rfc = ?, regimen_fiscal = ?, registro_patronal = ?, clase_riesgo = ?, domicilio_fiscal = ?, codigo_postal = ?, @@ -488,7 +517,7 @@ export function updateTenant( /** Regenera contraseña del admin, marca must_change_password y opcionalmente envía correo. */ export async function issueTenantAdminAccess( platformDb: PlatformDb, - appDb: Database, + coreDb: Db, tenantId: number, opts: { userId?: number; send_email?: boolean } = {}, ): Promise<{ @@ -496,7 +525,7 @@ export async function issueTenantAdminAccess( email?: { sent: boolean; error?: string }; error?: string; }> { - const detail = getTenantDetail(platformDb, appDb, tenantId); + const detail = await getTenantDetail(platformDb, coreDb, tenantId); if (!detail) return { error: "Empresa no encontrada" }; const admin = opts.userId ? detail.admins.find((a) => a.id === opts.userId) @@ -505,9 +534,11 @@ export async function issueTenantAdminAccess( const temporaryPassword = generateSecurePassword(); const hash = await hashPassword(temporaryPassword); - appDb.prepare( - `UPDATE users SET password_hash = ?, must_change_password = 1 WHERE id = ? AND tenant_id = ?`, - ).run(hash, admin.id, tenantId); + await withIamOwner((iam) => + iam.prepare( + `UPDATE users SET password_hash = ?, must_change_password = true WHERE id = ? AND tenant_id = ?`, + ).run(hash, admin.id, tenantId) + ); const contactEmail = detail.contact_email || admin.email; let emailResult: { sent: boolean; error?: string } = { sent: false }; @@ -565,16 +596,16 @@ async function sendAccessEmail( attachments: accessEmailAttachments(), }); if (result.sent) { - platformDb.prepare( - `UPDATE tenants SET access_sent_at = datetime('now') WHERE id = ?`, + await platformDb.prepare( + `UPDATE tenants SET access_sent_at = now() WHERE id = ?`, ).run(opts.tenantId); } return result; } -export function tenantAccessBlocked(platformDb: PlatformDb, tenantId: number | null): string | null { +export async function tenantAccessBlocked(platformDb: PlatformDb, tenantId: number | null): Promise { if (tenantId == null) return "Cuenta sin empresa asignada"; - const t = platformDb.prepare( + const t = await platformDb.prepare( `SELECT status, valid_until FROM tenants WHERE id = ?`, ).get(tenantId) as { status: string; valid_until: string | null } | undefined; if (!t) return "Empresa no encontrada"; diff --git a/api/scope.ts b/api/scope.ts new file mode 100644 index 0000000..dffa853 --- /dev/null +++ b/api/scope.ts @@ -0,0 +1,26 @@ +import type { Context, Next } from "hono"; +import type { AuthUser } from "./auth.ts"; +import { requireAuth, tenantScope } from "./auth.ts"; +import { withCoreTenant } from "./db.ts"; + +/** + * Middleware que abre la transacción de Postgres con app.tenant_id fijado + * (Row Level Security) para toda la duración del request, y la expone en + * `c.get("db")`. Encadenar SIEMPRE después de requireAuth en las rutas de + * `core` (workers/proyectos/empresas/presupuesto/nómina/documentos/gafetes). + * + * Esto es lo que hace que la Fase 4b (RLS) realmente proteja algo: sin + * este middleware, ninguna conexión tendría app.tenant_id fijado y las + * políticas fail-closed devolverían cero filas para todo. + */ +export async function withCoreScope(c: Context, next: Next) { + const user = c.get("user") as AuthUser; + const tid = tenantScope(user); + await withCoreTenant(tid, async (db) => { + c.set("db", db); + await next(); + }); +} + +/** Atajo para registrar rutas de `core`: `app.get(path, ...requireCoreAuth, handler)`. */ +export const requireCoreAuth = [requireAuth, withCoreScope] as const; diff --git a/api/scripts/bootstrap-admin.ts b/api/scripts/bootstrap-admin.ts new file mode 100644 index 0000000..4a44496 --- /dev/null +++ b/api/scripts/bootstrap-admin.ts @@ -0,0 +1,103 @@ +#!/usr/bin/env -S deno run --allow-net --allow-env --allow-read +/** + * PANELS · Fase 4 · bootstrap explícito y one-shot del primer administrador. + * + * Antes, `seed()` (api/db.ts) y `seedPlatform()` (api/platform_db.ts) + * creaban/actualizaban contraseñas de admin en CADA arranque de la API + * (incluyendo el peligroso SEED_SYNC_PASSWORD reescribiendo passwords en + * cada deploy). Ahora es un comando manual, corrido una vez por ambiente. + * + * Uso (correr SIEMPRE desde dentro de api/, para que se resuelva + * api/deno.json y sus dependencias npm): + * cd api + * set -a && source ../.env.dev-local && set +a + * deno run --allow-net --allow-env --allow-read scripts/bootstrap-admin.ts platform + * deno run --allow-net --allow-env --allow-read scripts/bootstrap-admin.ts tenant \ + * --tenant-id=1 --company-code=ARCT2608 --username=arct2608 --display-name="Administrador" + * + * Requiere SEED_PASSWORD en el entorno. No falla si el usuario ya existe: + * actualiza el hash y deja must_change_password=true. + */ +import { getPlatformDb } from "../platform_db.ts"; +import { getCoreDb } from "../db.ts"; +import { withIamOwner } from "../iam_db.ts"; +import { hashPassword } from "../crypto.ts"; +import { config } from "../config.ts"; + +function arg(name: string, fallback?: string): string | undefined { + const prefix = `--${name}=`; + const found = Deno.args.find((a) => a.startsWith(prefix)); + return found ? found.slice(prefix.length) : fallback; +} + +async function bootstrapPlatformAdmin() { + if (!config.seedPassword) { + throw new Error("Falta SEED_PASSWORD en el entorno"); + } + const pdb = await getPlatformDb(); + const hash = await hashPassword(config.seedPassword); + const existing = await pdb.prepare( + "SELECT id FROM platform_users WHERE username = 'admin'", + ).get(); + if (existing) { + await pdb.prepare( + "UPDATE platform_users SET password_hash = ?, display_name = 'Admin PANELS', status = 'activo' WHERE username = 'admin'", + ).run(hash); + console.log("[bootstrap] platform_users.admin actualizado"); + } else { + await pdb.prepare( + `INSERT INTO platform_users (username, password_hash, display_name, status) + VALUES ('admin', ?, 'Admin PANELS', 'activo')`, + ).run(hash); + console.log("[bootstrap] platform_users.admin creado"); + } +} + +async function bootstrapTenantAdmin() { + if (!config.seedPassword) { + throw new Error("Falta SEED_PASSWORD en el entorno"); + } + const tenantId = Number(arg("tenant-id", "1")); + const companyCode = arg("company-code", "ARCT2608")!; + const username = arg("username", "arct2608")!; + const displayName = arg("display-name", "Administrador")!; + + const core = await getCoreDb(); + const company = await core.prepare( + "SELECT id FROM companies WHERE code = ? AND tenant_id = ?", + ).get(companyCode, tenantId) as { id: number } | undefined; + if (!company) { + throw new Error( + `No existe la empresa ${companyCode} en tenant_id=${tenantId} -- corre primero las migraciones con --context-filter=dev`, + ); + } + + const hash = await hashPassword(config.seedPassword); + await withIamOwner(async (db) => { + const existing = await db.prepare("SELECT id FROM users WHERE username = ?").get(username); + if (existing) { + await db.prepare( + `UPDATE users SET password_hash = ?, display_name = ?, company_id = ?, tenant_id = ?, + role_code = 'tenant_admin', must_change_password = true WHERE username = ?`, + ).run(hash, displayName, company.id, tenantId, username); + console.log(`[bootstrap] iam.users.${username} actualizado (tenant_id=${tenantId})`); + } else { + await db.prepare( + `INSERT INTO users (username, password_hash, display_name, company_id, tenant_id, role_code, must_change_password, email) + VALUES (?, ?, ?, ?, ?, 'tenant_admin', true, '')`, + ).run(username, hash, displayName, company.id, tenantId); + console.log(`[bootstrap] iam.users.${username} creado (tenant_id=${tenantId})`); + } + }); +} + +const mode = Deno.args[0]; +if (mode === "platform") { + await bootstrapPlatformAdmin(); +} else if (mode === "tenant") { + await bootstrapTenantAdmin(); +} else { + console.error("Uso: bootstrap-admin.ts [--tenant-id=1] [--company-code=ARCT2608] [--username=arct2608] [--display-name=Administrador]"); + Deno.exit(1); +} +Deno.exit(0); diff --git a/api/seed_lista.ts b/api/seed_lista.ts index 1c602c1..c4c44e0 100644 --- a/api/seed_lista.ts +++ b/api/seed_lista.ts @@ -1,9 +1,14 @@ /** * Carga LISTA COLABORADORES GAFETES.xlsx al padrón para pruebas. * Completa CURP/RFC/NSS/contacto faltantes con valores válidos únicos. + * + * Uso: set -a && source ../.env.dev-local && set +a + * deno run --allow-net --allow-env --allow-read scripts/seed_lista.ts + * + * Corre contra un tenant fijo (por defecto 1); ajustar TENANT_ID si hace falta. */ import * as XLSX from "xlsx"; -import { getDb } from "./db.ts"; +import { withCoreTenant } from "./db.ts"; import { upsertWorker, storeDocument } from "./excel.ts"; import { resolveCompany } from "./companies.ts"; import { @@ -16,7 +21,8 @@ import { type WorkerInput, } from "./mx.ts"; -const FILE = "/mnt/c/Users/betom/Downloads/LISTA COLABORADORES GAFETES.xlsx"; +const FILE = Deno.env.get("SEED_LISTA_FILE") ?? "/mnt/c/Users/betom/Downloads/LISTA COLABORADORES GAFETES.xlsx"; +const TENANT_ID = Number(Deno.env.get("SEED_LISTA_TENANT_ID") ?? "1"); const CONS = "BCDFGHJKLMNPQRSTVWXYZ"; function cell(r: Record, k: string) { @@ -68,81 +74,82 @@ async function fetchPhoto(url: string): Promise { } } -const wb = XLSX.read(await Deno.readFile(FILE), { type: "array" }); -const db = await getDb(); -const project = db.prepare("SELECT id FROM projects ORDER BY id LIMIT 1").get() as { id: number } | undefined; -const risks = new Set((db.prepare("SELECT code FROM risk_levels").all() as { code: string }[]).map((x) => x.code)); +await withCoreTenant(TENANT_ID, async (db) => { + const wb = XLSX.read(await Deno.readFile(FILE), { type: "array" }); + const project = await db.prepare("SELECT id FROM projects ORDER BY id LIMIT 1").get() as { id: number } | undefined; + const risks = new Set((await db.prepare("SELECT code FROM risk_levels").all() as { code: string }[]).map((x) => x.code)); -type Job = { sheet: string; status: "activo" | "baja" }; -const jobs: Job[] = [ - { sheet: "ACTUALES", status: "activo" }, - { sheet: "HISTORICO", status: "baja" }, -]; + type Job = { sheet: string; status: "activo" | "baja" }; + const jobs: Job[] = [ + { sheet: "ACTUALES", status: "activo" }, + { sheet: "HISTORICO", status: "baja" }, + ]; -let inserted = 0, existed = 0, skipped = 0, photos = 0, i = 0; + let inserted = 0, existed = 0, skipped = 0, photos = 0, i = 0; -for (const job of jobs) { - const rows = XLSX.utils.sheet_to_json>(wb.Sheets[job.sheet], { defval: "" }); - for (const raw of rows) { - const first = cell(raw, "NOMBRE"); - const lastp = cell(raw, "APELLIDO PATERNO"); - if (!first || !lastp) { - skipped++; - continue; - } - i++; - let curp = cell(raw, "CURP"); - let rfc = cell(raw, "RFC"); - let nss = cell(raw, "NSS"); - if (validateCurp(curp)) curp = makeCurp(i); - if (validateRfc(rfc)) rfc = makeRfc(curp || makeCurp(i), i); - if (validateNss(nss)) nss = makeNss(1000 + i); - const ids = { curp, rfc, nss }; - const risk = canonicalRiskCode(cell(raw, "RIESGO")); - const hire = cell(raw, "ALTA").toUpperCase(); - const input: WorkerInput = { - first_name: first, - middle_name: cell(raw, "2 NOMBRE") || null, - last_name_p: lastp, - last_name_m: cell(raw, "APELLIDO MATERNO") || "X", - curp: ids.curp, - rfc: ids.rfc, - nss: ids.nss, - phone: cell(raw, "TELEFONO") || String(9981000000 + i), - email: cell(raw, "CORREO") || `${slug(first)}.${slug(lastp)}.${i}@pruebas.arctec.local`, - address: cell(raw, "DIRECCION") || "Sin domicilio en lista original", - blood_type: cell(raw, "TIPO SANGRE") || null, - hire_type: hire || "ARCT2608", - position: cell(raw, "CARGO") || "AYUDANTE", - risk_code: risks.has(risk) ? risk : "rojo", - work_type: cell(raw, "TIPO TRABAJO").toUpperCase() === "D" ? "D" : "N", - daily_wage: 450, - needs_badge: ["si", "sí", "yes"].includes(cell(raw, "GAFETE").toLowerCase()), - status: job.status, - }; - const errors = validateWorkerFields(input); - const company = resolveCompany(db, input) ?? resolveCompany(db, { hire_type: "ARCT2608" }); - if (!company) errors.hire_type = "Empresa no encontrada"; - if (Object.keys(errors).length || !company) { - console.log("SKIP", first, lastp, errors); - skipped++; - continue; - } - const n = { ...normalizeWorker(input), hire_type: company.code, company_id: company.id }; - const res = upsertWorker(db, n, job.status === "activo" ? project?.id ?? null : null); - if (res.action === "inserted") inserted++; - else existed++; - const url = cell(raw, "URL FOTO"); - if (url) { - const photo = await fetchPhoto(url); - if (photo) { - const mime = photo[0] === 0xff ? "image/jpeg" : "image/png"; - await storeDocument(db, res.id, "foto", "foto-lista.jpg", mime, photo, 1); - photos++; + for (const job of jobs) { + const rows = XLSX.utils.sheet_to_json>(wb.Sheets[job.sheet], { defval: "" }); + for (const raw of rows) { + const first = cell(raw, "NOMBRE"); + const lastp = cell(raw, "APELLIDO PATERNO"); + if (!first || !lastp) { + skipped++; + continue; + } + i++; + let curp = cell(raw, "CURP"); + let rfc = cell(raw, "RFC"); + let nss = cell(raw, "NSS"); + if (validateCurp(curp)) curp = makeCurp(i); + if (validateRfc(rfc)) rfc = makeRfc(curp || makeCurp(i), i); + if (validateNss(nss)) nss = makeNss(1000 + i); + const ids = { curp, rfc, nss }; + const risk = canonicalRiskCode(cell(raw, "RIESGO")); + const hire = cell(raw, "ALTA").toUpperCase(); + const input: WorkerInput = { + first_name: first, + middle_name: cell(raw, "2 NOMBRE") || null, + last_name_p: lastp, + last_name_m: cell(raw, "APELLIDO MATERNO") || "X", + curp: ids.curp, + rfc: ids.rfc, + nss: ids.nss, + phone: cell(raw, "TELEFONO") || String(9981000000 + i), + email: cell(raw, "CORREO") || `${slug(first)}.${slug(lastp)}.${i}@pruebas.arctec.local`, + address: cell(raw, "DIRECCION") || "Sin domicilio en lista original", + blood_type: cell(raw, "TIPO SANGRE") || null, + hire_type: hire || "ARCT2608", + position: cell(raw, "CARGO") || "AYUDANTE", + risk_code: risks.has(risk) ? risk : "rojo", + work_type: cell(raw, "TIPO TRABAJO").toUpperCase() === "D" ? "D" : "N", + daily_wage: 450, + needs_badge: ["si", "sí", "yes"].includes(cell(raw, "GAFETE").toLowerCase()), + status: job.status, + }; + const errors = validateWorkerFields(input); + const company = (await resolveCompany(db, input)) ?? (await resolveCompany(db, { hire_type: "ARCT2608" })); + if (!company) errors.hire_type = "Empresa no encontrada"; + if (Object.keys(errors).length || !company) { + console.log("SKIP", first, lastp, errors); + skipped++; + continue; + } + const n = { ...normalizeWorker(input), hire_type: company.code, company_id: company.id, tenant_id: TENANT_ID }; + const res = await upsertWorker(db, n, job.status === "activo" ? project?.id ?? null : null); + if (res.action === "inserted") inserted++; + else existed++; + const url = cell(raw, "URL FOTO"); + if (url) { + const photo = await fetchPhoto(url); + if (photo) { + const mime = photo[0] === 0xff ? "image/jpeg" : "image/png"; + await storeDocument(db, res.id, "foto", "foto-lista.jpg", mime, photo, null); + photos++; + } } } } -} -const total = db.prepare("SELECT COUNT(*) AS n FROM workers").get() as { n: number }; -console.log(JSON.stringify({ inserted, existed, skipped, photos, workers_in_db: total.n }, null, 2)); + const total = await db.prepare("SELECT COUNT(*) AS n FROM workers").get() as { n: number }; + console.log(JSON.stringify({ inserted, existed, skipped, photos, workers_in_db: total.n }, null, 2)); +}); diff --git a/api/sessions.ts b/api/sessions.ts new file mode 100644 index 0000000..5a0530c --- /dev/null +++ b/api/sessions.ts @@ -0,0 +1,98 @@ +import { getIamRedis } from "./redis.ts"; + +/** + * Sesiones en Redis (Fase 4): la cookie po_session ya no es un payload + * autocontenido firmado con HMAC -- es un id opaco. El estado real vive en + * `iam:session:` (hash con TTL) y hay un índice secundario + * `iam:user_sessions:` (set de ids) para poder revocar TODAS las + * sesiones de un usuario de un golpe (logout real, cambio de password, + * bloqueo de tenant) -- algo que el diseño anterior (HMAC stateless) no + * podía hacer sin esperar a que expirara la cookie. + */ + +export type SessionRealm = "app" | "platform"; + +export type SessionData = { + userId: number; + realm: SessionRealm; + tenantId: number | null; + issuedAt: number; +}; + +const TTL_SECONDS = 60 * 60 * 24 * 7; // 7 días, igual que el diseño anterior + +function sessionKey(id: string): string { + return `iam:session:${id}`; +} + +function userSessionsKey(userId: number, realm: SessionRealm): string { + return `iam:user_sessions:${realm}:${userId}`; +} + +function randomId(): string { + const bytes = crypto.getRandomValues(new Uint8Array(32)); + return btoa(String.fromCharCode(...bytes)).replaceAll("+", "-").replaceAll("/", "_").replaceAll( + "=", + "", + ); +} + +export async function createSession( + userId: number, + realm: SessionRealm, + tenantId: number | null, +): Promise { + const redis = await getIamRedis(); + const id = randomId(); + await redis.hSet(sessionKey(id), { + userId: String(userId), + realm, + tenantId: tenantId == null ? "" : String(tenantId), + issuedAt: String(Date.now()), + }); + await redis.expire(sessionKey(id), TTL_SECONDS); + await redis.sAdd(userSessionsKey(userId, realm), id); + return id; +} + +export async function getSession(id: string): Promise { + if (!id) return null; + const redis = await getIamRedis(); + const raw = await redis.hGetAll(sessionKey(id)); + if (!raw || Object.keys(raw).length === 0) return null; + // Refresca el TTL en cada acceso (sesión "deslizante", igual comportamiento + // que la cookie de 7 días anterior, que se renovaba en cada login). + await redis.expire(sessionKey(id), TTL_SECONDS); + return { + userId: Number(raw.userId), + realm: raw.realm === "platform" ? "platform" : "app", + tenantId: raw.tenantId ? Number(raw.tenantId) : null, + issuedAt: Number(raw.issuedAt), + }; +} + +export async function revokeSession(id: string): Promise { + if (!id) return; + const redis = await getIamRedis(); + const raw = await redis.hGetAll(sessionKey(id)); + await redis.del(sessionKey(id)); + if (raw?.userId) { + const realm: SessionRealm = raw.realm === "platform" ? "platform" : "app"; + await redis.sRem(userSessionsKey(Number(raw.userId), realm), id); + } +} + +/** Revoca TODAS las sesiones activas de un usuario -- logout real al + * cambiar password o al bloquear/suspender su tenant. */ +export async function revokeAllSessionsForUser( + userId: number, + realm: SessionRealm, +): Promise { + const redis = await getIamRedis(); + const key = userSessionsKey(userId, realm); + const ids = await redis.sMembers(key); + if (ids.length) { + await redis.del(ids.map(sessionKey)); + } + await redis.del(key); +} diff --git a/api/smtp.ts b/api/smtp.ts index 19baf48..0fdfce9 100644 --- a/api/smtp.ts +++ b/api/smtp.ts @@ -29,7 +29,7 @@ type SmtpRow = { username: string; password: string; from_address: string; - enabled: number; + enabled: boolean; updated_at: string; }; @@ -48,9 +48,9 @@ function fromEnv(): SmtpSettings { }; } -function readRow(platformDb: PlatformDb): SmtpRow | undefined { +async function readRow(platformDb: PlatformDb): Promise { try { - return platformDb.prepare( + return await platformDb.prepare( `SELECT host, port, username, password, from_address, enabled, updated_at FROM smtp_settings WHERE id = 1`, ).get() as SmtpRow | undefined; @@ -59,9 +59,11 @@ function readRow(platformDb: PlatformDb): SmtpRow | undefined { } } -/** Config efectiva: fila en platform.db si hay host; si no, variables de entorno. */ -export function resolveSmtp(platformDb: PlatformDb): SmtpSettings & { source: "db" | "env" | "none" } { - const row = readRow(platformDb); +/** Config efectiva: fila en panels_platform si hay host; si no, variables de entorno. */ +export async function resolveSmtp( + platformDb: PlatformDb, +): Promise { + const row = await readRow(platformDb); if (row && trim(row.host)) { return { host: trim(row.host), @@ -69,7 +71,7 @@ export function resolveSmtp(platformDb: PlatformDb): SmtpSettings & { source: "d username: trim(row.username), password: row.password ?? "", from_address: trim(row.from_address) || config.smtpFrom, - enabled: Number(row.enabled) === 1, + enabled: Boolean(row.enabled), updated_at: row.updated_at, source: "db", }; @@ -79,20 +81,20 @@ export function resolveSmtp(platformDb: PlatformDb): SmtpSettings & { source: "d return { ...env, enabled: false, source: "none" }; } -export function smtpConfigured(platformDb: PlatformDb): boolean { - const s = resolveSmtp(platformDb); +export async function smtpConfigured(platformDb: PlatformDb): Promise { + const s = await resolveSmtp(platformDb); return s.enabled && Boolean(s.host && s.from_address); } -export function smtpPublicView(platformDb: PlatformDb): SmtpPublic { - const s = resolveSmtp(platformDb); +export async function smtpPublicView(platformDb: PlatformDb): Promise { + const s = await resolveSmtp(platformDb); return { host: s.host, port: s.port, username: s.username, from_address: s.from_address, enabled: s.enabled, - configured: smtpConfigured(platformDb), + configured: await smtpConfigured(platformDb), has_password: Boolean(s.password), updated_at: s.updated_at ?? null, source: s.source, @@ -115,10 +117,10 @@ function looksLikeEmailFrom(value: string): boolean { return /(?:^|<)[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+(?:>|$)/.test(value); } -export function saveSmtpSettings( +export async function saveSmtpSettings( platformDb: PlatformDb, input: SaveSmtpInput, -): { error?: string; settings?: SmtpPublic } { +): Promise<{ error?: string; settings?: SmtpPublic }> { const host = trim(input.host); const fromAddress = trim(input.from_address); const username = trim(input.username); @@ -136,15 +138,15 @@ export function saveSmtpSettings( }; } - const current = readRow(platformDb); + const current = await readRow(platformDb); let password = input.password ?? ""; if ((input.keep_password || password === "") && current?.password) { password = current.password; } - platformDb.prepare( + await platformDb.prepare( `INSERT INTO smtp_settings (id, host, port, username, password, from_address, enabled, updated_at) - VALUES (1, ?, ?, ?, ?, ?, ?, datetime('now')) + VALUES (1, ?, ?, ?, ?, ?, ?, now()) ON CONFLICT(id) DO UPDATE SET host = excluded.host, port = excluded.port, @@ -152,8 +154,8 @@ export function saveSmtpSettings( password = excluded.password, from_address = excluded.from_address, enabled = excluded.enabled, - updated_at = datetime('now')`, - ).run(host, port, username, password, fromAddress, enabled ? 1 : 0); + updated_at = now()`, + ).run(host, port, username, password, fromAddress, enabled); - return { settings: smtpPublicView(platformDb) }; + return { settings: await smtpPublicView(platformDb) }; } diff --git a/api/storage.ts b/api/storage.ts new file mode 100644 index 0000000..0563039 --- /dev/null +++ b/api/storage.ts @@ -0,0 +1,100 @@ +import { S3Client, PutObjectCommand, GetObjectCommand } from "npm:@aws-sdk/client-s3@3"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { config, DATA_DIR } from "./config.ts"; + +/** + * Almacenamiento de archivos (Fase 4c): expedientes cifrados, PDFs de + * gafetes, logos. Antes vivían en el volumen local (panel-data) -- un + * volumen local no se comparte entre réplicas ni microservicios, así que + * es el mismo cuello de botella que motivó salir de SQLite, solo que en + * otra capa. El contenido YA viene cifrado con AES-GCM (docs_crypto.ts) + * antes de llegar aquí -- el bucket nunca ve texto plano. + * + * Backend real: Contabo Object Storage (S3-compatible). Si no hay + * credenciales S3 configuradas (dev local sin Contabo), cae a disco local + * bajo DATA_DIR -- útil para desarrollar/probar sin depender de Contabo, + * pero NO es la ruta recomendada para staging/producción (ver Fase 4c del + * plan: un volumen local no escala horizontalmente). + */ + +let client: S3Client | null = null; + +function s3Configured(): boolean { + return !!(config.s3Endpoint && config.s3Bucket && config.s3AccessKeyId && config.s3SecretAccessKey); +} + +function getClient(): S3Client { + if (!client) { + client = new S3Client({ + endpoint: config.s3Endpoint, + region: config.s3Region, + forcePathStyle: true, + credentials: { + accessKeyId: config.s3AccessKeyId, + secretAccessKey: config.s3SecretAccessKey, + }, + }); + } + return client; +} + +async function streamToUint8Array(body: unknown): Promise { + const chunks: Uint8Array[] = []; + // deno-lint-ignore no-explicit-any + for await (const chunk of body as any) { + chunks.push(chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk)); + } + const total = chunks.reduce((n, c) => n + c.length, 0); + const out = new Uint8Array(total); + let offset = 0; + for (const c of chunks) { + out.set(c, offset); + offset += c.length; + } + return out; +} + +export async function putObject(key: string, bytes: Uint8Array): Promise { + if (s3Configured()) { + await getClient().send( + new PutObjectCommand({ Bucket: config.s3Bucket, Key: key, Body: bytes }), + ); + return; + } + const path = join(DATA_DIR, "local-objects", key); + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, bytes); +} + +export async function getObject(key: string): Promise { + if (s3Configured()) { + const res = await getClient().send( + new GetObjectCommand({ Bucket: config.s3Bucket, Key: key }), + ); + return await streamToUint8Array(res.Body); + } + const path = join(DATA_DIR, "local-objects", key); + return await readFile(path); +} + +// --- Convenciones de key por dominio (equivalentes a los antiguos +// workerDir/projectDir/companyDir + Deno.readFile/writeFile) --- +export function workerDocKey(workerId: number, storageName: string): string { + return `expedientes/${workerId}/${storageName}`; +} +export function projectDocKey(projectId: number, storageName: string): string { + return `proyectos/${projectId}/${storageName}`; +} +export function companyDocKey(companyId: number, storageName: string): string { + return `empresas/${companyId}/${storageName}`; +} +export function badgeJobPdfKey(jobId: number): string { + return `pdfs/gafetes-${jobId}.pdf`; +} +export function loanReceiptPdfKey(loanId: number, weekId: number): string { + return `pdfs/prestamo-${loanId}-semana-${weekId}.pdf`; +} +export function projectLogoKey(projectId: number, side: "left" | "right", ext: string): string { + return `logos/${projectId}-${side}.${ext}`; +} diff --git a/api/test_helpers.ts b/api/test_helpers.ts new file mode 100644 index 0000000..5aa852b --- /dev/null +++ b/api/test_helpers.ts @@ -0,0 +1,54 @@ +import { createPool, PgDb, type postgres } from "./pg.ts"; + +/** + * Fase 3b: estrategia de tests con Postgres efímero. + * + * Los tests ya NO crean un esquema paralelo a mano (como hacía + * `new Database(":memory:")` de @db/sqlite): corren contra el MISMO + * baseline de Liquibase que producción (ver db/core/changesets), en la + * base de desarrollo local. Aislamiento entre tests: cada test corre + * dentro de una transacción que SIEMPRE se revierte al terminar (nunca + * hace commit), así que nunca contamina la base ni a otros tests, sin + * necesidad de TRUNCATE. + * + * Requiere DATABASE_URL_CORE_OWNER (o DATABASE_URL_CORE) apuntando a una + * base con las migraciones ya aplicadas -- ver db/provision/dev-local.sh + * y `./db/update.sh core --context-filter=dev`. + */ + +let pool: postgres.Sql | null = null; + +function getPool(): postgres.Sql { + if (!pool) { + const url = Deno.env.get("DATABASE_URL_CORE_OWNER") || Deno.env.get("DATABASE_URL_CORE"); + if (!url) { + throw new Error( + "Define DATABASE_URL_CORE_OWNER (o DATABASE_URL_CORE) para correr los tests contra Postgres -- ver db/provision/dev-local.sh", + ); + } + pool = createPool(url, { max: 5 }); + } + return pool; +} + +const ROLLBACK = Symbol("test-rollback"); + +/** Corre `fn(db)` dentro de una transacción que SIEMPRE se revierte. */ +export async function withTestDb(fn: (db: PgDb) => Promise): Promise { + const sql = getPool(); + let result: T = undefined as unknown as T; + try { + await sql.begin(async (tx) => { + result = await fn(new PgDb(tx as unknown as postgres.Sql)); + throw ROLLBACK; + }); + } catch (e) { + if (e !== ROLLBACK) throw e; + } + return result; +} + +export async function closeTestPool(): Promise { + await pool?.end({ timeout: 1 }); + pool = null; +} diff --git a/scripts/migrate-tenant-codes.ts b/scripts/migrate-tenant-codes.ts deleted file mode 100644 index c289be8..0000000 --- a/scripts/migrate-tenant-codes.ts +++ /dev/null @@ -1,129 +0,0 @@ -/** - * One-off: tenant/company code = nombre(4) + YYMM; username = code lowercase. - * Usage: deno run -A scripts/migrate-tenant-codes.ts - */ -import { Database } from "jsr:@db/sqlite@0.12"; - -const PLATFORM = new URL("../data/platform.db", import.meta.url).pathname; -const APP = new URL("../data/app.db", import.meta.url).pathname; - -function shortFromName(name: string): string { - const slug = (name ?? "") - .toString() - .normalize("NFD") - .replace(/\p{M}/gu, "") - .toUpperCase() - .replace(/[^A-Z0-9]+/g, "") - .slice(0, 4); - return slug.length >= 2 ? slug : (slug || "EMP").padEnd(2, "X").slice(0, 4); -} - -function stampFromCreated(createdAt: string): string { - const m = createdAt.match(/^(\d{4})-(\d{2})/); - if (!m) { - const d = new Date(); - return `${String(d.getFullYear()).slice(-2)}${String(d.getMonth() + 1).padStart(2, "0")}`; - } - return `${m[1].slice(-2)}${m[2]}`; -} - -const platform = new Database(PLATFORM); -const app = new Database(APP); -platform.exec("PRAGMA foreign_keys = ON;"); -app.exec("PRAGMA foreign_keys = ON;"); - -const tenants = platform.prepare( - "SELECT id, code, name, created_at FROM tenants ORDER BY id", -).all() as { id: number; code: string; name: string; created_at: string }[]; - -const used = new Set([ - ...(app.prepare("SELECT code FROM companies").all() as { code: string }[]).map((r) => r.code), - ...tenants.map((t) => t.code), -]); - -function allocate(name: string, createdAt: string, oldCode: string): string { - const base = shortFromName(name); - const stamp = stampFromCreated(createdAt); - let candidate = `${base}${stamp}`; - if (candidate === oldCode || !used.has(candidate)) return candidate; - for (let n = 2; n < 1000; n++) { - const suffix = String(n); - const head = base.slice(0, Math.max(2, 4 - suffix.length)); - candidate = `${head}${stamp}${suffix}`; - if (candidate === oldCode || !used.has(candidate)) return candidate; - } - throw new Error(`No unique code for ${name}`); -} - -type MapRow = { - tenantId: number; - oldCode: string; - newCode: string; - oldUser: string; - newUser: string; -}; - -const mappings: MapRow[] = []; -for (const t of tenants) { - const newCode = allocate(t.name, t.created_at, t.code); - used.add(newCode); - mappings.push({ - tenantId: t.id, - oldCode: t.code, - newCode, - oldUser: `adm.${t.code.toLowerCase()}`, - newUser: newCode.toLowerCase(), - }); -} - -console.log("Migrating:", mappings); - -for (const m of mappings) { - if (m.oldCode !== m.newCode) { - platform.prepare("UPDATE tenants SET code = ? WHERE id = ?").run(m.newCode, m.tenantId); - - const principal = app.prepare( - "SELECT id, code FROM companies WHERE tenant_id = ? AND kind = 'principal' ORDER BY id LIMIT 1", - ).get(m.tenantId) as { id: number; code: string } | undefined; - - if (principal && principal.code === m.oldCode) { - app.prepare("UPDATE companies SET code = ? WHERE id = ?").run(m.newCode, principal.id); - app.prepare("UPDATE workers SET hire_type = ? WHERE company_id = ? AND hire_type = ?") - .run(m.newCode, principal.id, m.oldCode); - } - } - - const clash = app.prepare("SELECT id FROM users WHERE username = ?").get(m.newUser); - if (!clash) { - app.prepare( - "UPDATE users SET username = ? WHERE tenant_id = ? AND username = ?", - ).run(m.newUser, m.tenantId, m.oldUser); - // also catch if username already equals old code lowercase without adm. - app.prepare( - "UPDATE users SET username = ? WHERE tenant_id = ? AND username = ? AND username != ?", - ).run(m.newUser, m.tenantId, m.oldCode.toLowerCase(), m.newUser); - } else { - // ensure old adm.* row is renamed only if target not taken by another user - const old = app.prepare( - "SELECT id FROM users WHERE tenant_id = ? AND username = ?", - ).get(m.tenantId, m.oldUser) as { id: number } | undefined; - if (old && (clash as { id: number }).id === old.id) { - // same row already correct - } else if (old) { - console.warn(`Skip user rename ${m.oldUser} → ${m.newUser}: target exists`); - } - } -} - -console.log("TENANTS", platform.prepare("SELECT id, code, name FROM tenants").all()); -console.log( - "PRINCIPALS", - app.prepare("SELECT id, code, tenant_id, kind FROM companies WHERE kind = 'principal'").all(), -); -console.log( - "USERS", - app.prepare("SELECT id, username, tenant_id, role FROM users").all(), -); - -platform.close(); -app.close();