api: migrar todo el backend de SQLite a Postgres + Redis (fase 2-4e)

Fase 2 (driver):
- api/pg.ts: adaptador delgado sobre postgres.js (prepare/get/all/run,
  placeholders ? -> $n, withTenant con set_config para RLS), con parsers
  de tipo custom (numeric/date/timestamp(tz)/bigint) para que el resto
  del codigo heredado de SQLite (fechas/montos como string, ids como
  number) siga funcionando sin reescribir cada call-site a mano.
- api/platform_db.ts, api/iam_db.ts (nuevo), api/db.ts: pools separados
  por base/esquema (panels_platform, panels_product.iam,
  panels_product.core), owner pool para bootstrap/scripts/lookups
  administrativos que cruzan tenant a proposito.
- api/redis.ts: clientes iam/core separados (ACL panels_iam_redis /
  panels_core_redis).
- api/sessions.ts + auth.ts: sesiones ahora en Redis (cookie = id opaco,
  no HMAC autocontenido); revocacion real (logout, cambio de password).
- api/storage.ts (Fase 4c): documentos/PDFs via Contabo Object Storage
  (S3), con fallback a disco local si no hay credenciales S3 (dev).
- api/scope.ts: middleware withCoreScope/requireCoreAuth que abre la
  transaccion con app.tenant_id fijado (RLS) para cada request.
- api/cache.ts (Fase 4e): cache Redis con tenant_id obligatorio en la
  llave; aplicado a /v1/catalogs.

Fase 3 (reescritura SQL, ~80 endpoints en main.ts/companies.ts/budget.ts/
payroll.ts/payroll_http.ts/excel.ts/saas.ts/smtp.ts):
- Todo async/await, sintaxis Postgres (COALESCE, ~ regex, ON CONFLICT,
  now()/current_date, booleanos reales, RETURNING via lastInsertId()).
- IDOR cross-tenant cerrado: GET/PATCH /v1/projects/:id, /v1/workers/:id
  ya no dependen de que el handler recuerde el WHERE tenant_id -- Row
  Level Security lo hace estructuralmente (verificado con un segundo
  tenant real: 404 en vez de fuga de datos).
- API key ya no ve todos los tenants: ahora exige X-Tenant-Id explicito.

Fase 3b (tests): api/test_helpers.ts corre cada test en una transaccion
que siempre se revierte, contra el mismo baseline de Liquibase que
produccion (ya no un esquema SQLite escrito a mano). payroll_test.ts
reescrito con fixtures reales; 11/11 pasan contra Postgres.

Fase 4 (IAM/RBAC): iam.roles/permissions/role_permissions formalizados
(ver db/iam ya en fase 1); uploaded_by/created_by ahora son snapshot
desnormalizado (uploaded_by_id/name); seed() en runtime eliminado,
reemplazado por scripts/bootstrap-admin.ts (one-shot).

Fase 4d (zona horaria): nuevo endpoint /v1/configuracion (GET/PUT),
PAYROLL_TZ hardcodeado reemplazado por tenant_settings.timezone,
document_validity.ts ya no usa new Date() crudo.

Verificado end-to-end contra Postgres+Redis reales: login, sesiones,
catalogos con cache, alta de trabajador, subida/descarga de documento
cifrado, y el fix de IDOR probado con un segundo tenant real (403/404
en vez de fuga de datos).

Co-authored-by: alberto.martinez <alberto.martinez@mrdev.mx>
This commit is contained in:
Cursor Agent 2026-09-02 20:47:45 +00:00
parent b87b0205a8
commit 493829d028
No known key found for this signature in database
34 changed files with 2761 additions and 1938 deletions

View file

@ -1,9 +1,10 @@
import type { Context, Next } from "hono"; import type { Context, Next } from "hono";
import { deleteCookie, getCookie, setCookie } from "hono/cookie"; 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 { getPlatformDb } from "./platform_db.ts";
import { config } from "./config.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 AuthRealm = "app" | "platform";
export type AuthRole = "platform_admin" | "tenant_admin" | "user"; export type AuthRole = "platform_admin" | "tenant_admin" | "user";
@ -22,34 +23,33 @@ export type AuthUser = {
must_change_password: boolean; 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 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") { export async function createSessionCookie(
const payload: SessionPayload = { uid: userId, exp: Date.now() + TTL * 1000, realm }; c: Context,
const body = b64urlJson(payload); userId: number,
const sig = await hmacSign(config.sessionSecret, body); realm: AuthRealm = "app",
setCookie(c, COOKIE, `${body}.${sig}`, { tenantId: number | null = null,
) {
const id = await createSession(userId, realm, tenantId);
setCookie(c, COOKIE, id, {
httpOnly: true, httpOnly: true,
path: "/", path: "/",
sameSite: "Lax", sameSite: "Lax",
secure: config.cookieSecure, 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: "/" }); deleteCookie(c, COOKIE, { path: "/" });
} }
function asAppUser(row: Record<string, unknown>): AuthUser { function asAppUser(row: Record<string, unknown>): AuthUser {
const role = (row.role as string) || "user"; const role = (row.role_code as string) || "user";
return { return {
id: Number(row.id), id: Number(row.id),
username: String(row.username), username: String(row.username),
@ -82,35 +82,35 @@ function asPlatformUser(row: { id: number; username: string; display_name: strin
} }
async function userFromCookie(c: Context): Promise<AuthUser | null> { async function userFromCookie(c: Context): Promise<AuthUser | null> {
const raw = getCookie(c, COOKIE); const id = getCookie(c, COOKIE);
if (!raw || !raw.includes(".")) return null; if (!id) return null;
const [body, sig] = raw.split("."); const session = await getSession(id);
if (!await hmacVerify(config.sessionSecret, body, sig)) return null; if (!session) return null;
let payload: SessionPayload;
try {
payload = b64urlJsonParse<SessionPayload>(body);
} catch {
return null;
}
if (payload.exp < Date.now()) return null;
const realm: AuthRealm = payload.realm === "platform" ? "platform" : "app";
if (realm === "platform") { if (session.realm === "platform") {
const pdb = await getPlatformDb(); const pdb = await getPlatformDb();
const row = pdb.prepare( const row = await pdb.prepare(
`SELECT id, username, display_name FROM platform_users `SELECT id, username, display_name FROM platform_users
WHERE id = ? AND status = 'activo'`, 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; return row ? asPlatformUser(row) : null;
} }
const db = await getDb(); return await getFreshAppUser(session.userId, session.tenantId);
const row = db.prepare( }
`SELECT ${USER_SELECT}
FROM users u LEFT JOIN companies c ON c.id = u.company_id /** Relee un usuario app (iam) fresco de la base y lo enriquece con su
WHERE u.id = ?`, * empresa (core) -- usado tras login y tras cambiar contraseña. */
).get(payload.uid) as Record<string, unknown> | undefined; export async function getFreshAppUser(userId: number, tenantId: number | null): Promise<AuthUser | null> {
return row ? asAppUser(row) : null; 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 { function apiKeyOk(c: Context): boolean {
@ -124,6 +124,15 @@ function apiKeyOk(c: Context): boolean {
export async function requireAuth(c: Context, next: Next) { export async function requireAuth(c: Context, next: Next) {
if (apiKeyOk(c)) { 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", { c.set("user", {
id: 0, id: 0,
username: "api", username: "api",
@ -132,7 +141,7 @@ export async function requireAuth(c: Context, next: Next) {
company_code: null, company_code: null,
company_name: null, company_name: null,
company_kind: null, company_kind: null,
tenant_id: null, tenant_id: tenantId,
role: "tenant_admin", role: "tenant_admin",
realm: "app", realm: "app",
must_change_password: false, must_change_password: false,
@ -166,17 +175,21 @@ export async function login(username: string, password: string): Promise<AuthUse
const pass = password.trim(); const pass = password.trim();
if (!user || !pass) return null; if (!user || !pass) return null;
const db = await getDb(); // username es único globalmente (no por tenant) -- se resuelve con el
const appRow = db.prepare( // pool que omite RLS (ver iam_db.ts#findUserByUsernameAnyTenant); recién
`SELECT ${USER_SELECT}, u.password_hash // después de esto se conoce el tenant_id para todo lo demás.
FROM users u LEFT JOIN companies c ON c.id = u.company_id const appRow = await findUserByUsernameAnyTenant(user);
WHERE u.username = ?`, if (appRow && await verifyPassword(pass, appRow.password_hash as string)) {
).get(user) as (Record<string, unknown> & { password_hash: string }) | undefined; const authUser = await withIamTenant(
if (appRow && await verifyPassword(pass, appRow.password_hash)) { appRow.tenant_id == null ? null : Number(appRow.tenant_id),
const authUser = asAppUser(appRow); async (db) => {
const { tenantAccessBlocked } = await import("./saas.ts"); const company = await enrichWithCompanyViaCore(Number(appRow.company_id) || null);
return asAppUser({ ...appRow, ...company });
},
);
const pdb = await getPlatformDb(); 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) { if (blocked) {
throw Object.assign(new Error(blocked), { code: "TENANT_BLOCKED" }); throw Object.assign(new Error(blocked), { code: "TENANT_BLOCKED" });
} }
@ -184,7 +197,7 @@ export async function login(username: string, password: string): Promise<AuthUse
} }
const pdb = await getPlatformDb(); const pdb = await getPlatformDb();
const plat = pdb.prepare( const plat = await pdb.prepare(
`SELECT id, username, display_name, password_hash FROM platform_users `SELECT id, username, display_name, password_hash FROM platform_users
WHERE username = ? AND status = 'activo'`, WHERE username = ? AND status = 'activo'`,
).get(user) as ).get(user) as
@ -196,22 +209,49 @@ export async function login(username: string, password: string): Promise<AuthUse
return null; return null;
} }
/** company_id de iam.users es una referencia lógica a core.companies(id)
* (sin FK -- esquemas aislados). Esta función vive en auth.ts para no
* crear un import cruzado iam<->core; 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( export async function changePassword(
userId: number, userId: number,
tenantId: number | null,
currentPassword: string, currentPassword: string,
newPassword: string, newPassword: string,
): Promise<{ error?: string }> { ): Promise<{ error?: string }> {
const next = (newPassword ?? "").trim(); const next = (newPassword ?? "").trim();
if (next.length < 8) return { error: "La nueva contraseña debe tener al menos 8 caracteres" }; if (next.length < 8) return { error: "La nueva contraseña debe tener al menos 8 caracteres" };
const db = await getDb(); return await withIamTenant(tenantId, async (db) => {
const row = db.prepare("SELECT password_hash FROM users WHERE id = ?").get(userId) as const row = await db.prepare("SELECT password_hash FROM users WHERE id = ?").get(userId) as
| { password_hash: string } | { password_hash: string }
| undefined; | undefined;
if (!row) return { error: "Usuario no encontrado" }; if (!row) return { error: "Usuario no encontrado" };
if (!await verifyPassword(currentPassword, row.password_hash)) { if (!await verifyPassword(currentPassword, row.password_hash)) {
return { error: "Contraseña actual incorrecta" }; return { error: "Contraseña actual incorrecta" };
} }
const hash = await hashPassword(next); const hash = await hashPassword(next);
db.prepare("UPDATE users SET password_hash = ?, must_change_password = 0 WHERE id = ?").run(hash, userId); await db.prepare(
return {}; "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 {};
});
} }

View file

@ -1,9 +1,5 @@
import * as XLSX from "xlsx"; import * as XLSX from "xlsx";
import type { Database } from "@db/sqlite"; import type { Db } from "./db.ts";
function lastId(database: Database): number {
return Number((database.prepare("SELECT last_insert_rowid() AS id").get() as { id: number }).id);
}
export const BUDGET_IVA = 0.16; export const BUDGET_IVA = 0.16;
@ -266,7 +262,7 @@ function looksOpus(rows: unknown[][], headerAt: number, map: ColMap) {
return false; return false;
} }
function parseWbsRows(rows: unknown[][], headerAt: number, map: ColMap): ParsedBudget { function parseWbsRows(rows: unknown[][], headerAt: number, map: ColMap): Omit<ParsedBudget, "format"> {
const groups: ParsedGroup[] = []; const groups: ParsedGroup[] = [];
const items: ParsedBudgetItem[] = []; const items: ParsedBudgetItem[] = [];
let skipped = 0; let skipped = 0;
@ -326,7 +322,7 @@ function parseWbsRows(rows: unknown[][], headerAt: number, map: ColMap): ParsedB
return { groups, items, skipped }; return { groups, items, skipped };
} }
function parseOpusRows(rows: unknown[][], headerAt: number, map: ColMap): ParsedBudget { function parseOpusRows(rows: unknown[][], headerAt: number, map: ColMap): Omit<ParsedBudget, "format"> {
const groups: ParsedGroup[] = []; const groups: ParsedGroup[] = [];
const items: ParsedBudgetItem[] = []; const items: ParsedBudgetItem[] = [];
let skipped = 0; let skipped = 0;
@ -428,7 +424,7 @@ function parseOpusRows(rows: unknown[][], headerAt: number, map: ColMap): Parsed
return { groups, items, skipped }; return { groups, items, skipped };
} }
function parseFlatRows(rows: unknown[][], headerAt: number, map: ColMap): ParsedBudget { function parseFlatRows(rows: unknown[][], headerAt: number, map: ColMap): Omit<ParsedBudget, "format"> {
const groups: ParsedGroup[] = []; const groups: ParsedGroup[] = [];
const items: ParsedBudgetItem[] = []; const items: ParsedBudgetItem[] = [];
let skipped = 0; let skipped = 0;
@ -645,14 +641,12 @@ function compareWbs(a: string, b: string) {
return a.localeCompare(b, undefined, { numeric: true }); return a.localeCompare(b, undefined, { numeric: true });
} }
export function listBudget(database: Database, projectId: number) { export async function listBudget(database: Db, projectId: number) {
const chapterCols = (database.prepare("PRAGMA table_info(budget_chapters)").all() as { name: string }[]).map((c) => c.name); const chapters = await database.prepare(
const wbsExpr = chapterCols.includes("wbs") ? "COALESCE(c.wbs, '') AS wbs" : "'' AS wbs"; `SELECT c.id, c.project_id, c.parent_id, c.code, c.name, COALESCE(c.wbs, '') AS wbs, c.sort_order
const chapters = database.prepare(
`SELECT c.id, c.project_id, c.parent_id, c.code, c.name, ${wbsExpr}, c.sort_order
FROM budget_chapters c WHERE c.project_id = ? ORDER BY c.sort_order, c.id`, FROM budget_chapters c WHERE c.project_id = ? ORDER BY c.sort_order, c.id`,
).all(projectId) as ChapterRow[]; ).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 `SELECT i.*, ch.name AS chapter_name, ch.code AS chapter_code
FROM budget_items i FROM budget_items i
LEFT JOIN budget_chapters ch ON ch.id = i.chapter_id LEFT JOIN budget_chapters ch ON ch.id = i.chapter_id
@ -750,64 +744,47 @@ export function listBudget(database: Database, projectId: number) {
}; };
} }
function insertChapter( async function insertChapter(
database: Database, database: Db,
projectId: number, projectId: number,
parentId: number | null, parentId: number | null,
code: string, code: string,
name: string, name: string,
wbs: string, wbs: string,
sort: number, sort: number,
) { ): Promise<number> {
const cols = (database.prepare("PRAGMA table_info(budget_chapters)").all() as { name: string }[]).map((c) => c.name); await database.prepare(
if (cols.includes("wbs")) { "INSERT INTO budget_chapters (project_id, parent_id, code, name, wbs, sort_order) VALUES (?, ?, ?, ?, ?, ?)",
database.prepare( ).run(projectId, parentId, code, name, wbs, sort);
"INSERT INTO budget_chapters (project_id, parent_id, code, name, wbs, sort_order) VALUES (?, ?, ?, ?, ?, ?)", return await database.lastInsertId();
).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);
} }
export function replaceBudgetFromParsed(database: Database, projectId: number, parsed: ParsedBudget) { export async function replaceBudgetFromParsed(database: Db, projectId: number, parsed: ParsedBudget): Promise<void> {
database.prepare("DELETE FROM budget_items WHERE project_id = ?").run(projectId); await database.prepare("DELETE FROM budget_items WHERE project_id = ?").run(projectId);
database.prepare("DELETE FROM budget_chapters 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 groups = [...parsed.groups].sort((a, b) => compareWbs(a.wbs, b.wbs));
const ids = new Map<string, number>(); const ids = new Map<string, number>();
let sort = 1; let sort = 1;
for (const group of groups) { for (const group of groups) {
const parentId = group.parentWbs ? ids.get(group.parentWbs) || null : null; 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); ids.set(group.wbs, id);
sort++; 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; let itemSort = 1;
for (const item of parsed.items) { for (const item of parsed.items) {
const chapterId = item.parentWbs ? ids.get(item.parentWbs) || null : null; const chapterId = item.parentWbs ? ids.get(item.parentWbs) || null : null;
const amount = Math.round((item.quantity * item.unit_price || item.amount) * 100) / 100; const amount = Math.round((item.quantity * item.unit_price || item.amount) * 100) / 100;
if (hasWbs) { await database.prepare(
database.prepare( `INSERT INTO budget_items
`INSERT INTO budget_items (project_id, chapter_id, code, description, unit, quantity, unit_price, amount, sort_order, wbs)
(project_id, chapter_id, code, description, unit, quantity, unit_price, amount, sort_order, wbs) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ).run(projectId, chapterId, item.code, item.description, item.unit, item.quantity, item.unit_price, amount, itemSort, item.wbs);
).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);
}
itemSort++; itemSort++;
} }
} }
export function replaceBudgetFromItems(database: Database, projectId: number, parsed: ParsedBudgetItem[]) { export async function replaceBudgetFromItems(database: Db, projectId: number, parsed: ParsedBudgetItem[]): Promise<void> {
const groups: ParsedGroup[] = []; const groups: ParsedGroup[] = [];
const seen = new Set<string>(); const seen = new Set<string>();
for (const item of parsed) { for (const item of parsed) {
@ -822,7 +799,7 @@ export function replaceBudgetFromItems(database: Database, projectId: number, pa
name: item.chapter || "General", 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) { 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<BudgetImportReport> {
const read = readBudgetSheet(bytes); const read = readBudgetSheet(bytes);
if ("error" in read) { if ("error" in read) {
return { replaced: false, chapters: 0, inserted: 0, skipped: 0, errors: [{ row: 0, messages: [read.error] }] }; 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."] }], 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 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 iva = Math.round(subtotal * BUDGET_IVA * 100) / 100;
const totals = { const totals = {
@ -950,7 +927,7 @@ export function importBudgetExcel(database: Database, projectId: number, bytes:
item_count: parsed.items.length, item_count: parsed.items.length,
}; };
// El monto de contrato del proyecto refleja el subtotal del presupuesto importado. // 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 { return {
replaced: true, replaced: true,
chapters: parsed.groups.length, chapters: parsed.groups.length,

57
api/cache.ts Normal file
View file

@ -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<T>(
redis: Awaited<ReturnType<typeof getCoreRedis>>,
key: string,
ttlSeconds: number,
compute: () => Promise<T>,
): Promise<T> {
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<T>(key: string, ttlSeconds: number, compute: () => Promise<T>): Promise<T> {
const redis = await getCoreRedis();
return await cached(redis, key, ttlSeconds, compute);
}
export async function cacheIam<T>(key: string, ttlSeconds: number, compute: () => Promise<T>): Promise<T> {
const redis = await getIamRedis();
return await cached(redis, key, ttlSeconds, compute);
}
export async function invalidateCore(key: string): Promise<void> {
await (await getCoreRedis()).del(key).catch(() => {});
}

View file

@ -1,8 +1,4 @@
import type { Database } from "@db/sqlite"; import type { Db } from "./db.ts";
function lastId(database: Database): number {
return Number((database.prepare("SELECT last_insert_rowid() AS id").get() as { id: number }).id);
}
export type Company = { export type Company = {
id: number; id: number;
@ -64,36 +60,41 @@ function normRfc(value: unknown): string {
return trimText(value).toUpperCase().replace(/\s+/g, ""); 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<Company[]> {
// 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 where = tenantId != null ? "WHERE c.tenant_id = ?" : "";
const params = tenantId != null ? [tenantId] : []; 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 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 (SELECT COUNT(*) FROM workers w WHERE w.company_id = c.id) AS worker_count
FROM companies c FROM companies c
LEFT JOIN companies p ON p.id = c.parent_id LEFT JOIN companies p ON p.id = c.parent_id
${where} ${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[]; ).all(...params) as Company[];
} }
export function companyById(database: Database, id: number): Company | undefined { export async function companyById(database: Db, id: number): Promise<Company | undefined> {
return database.prepare("SELECT * FROM companies WHERE id = ?").get(id) as Company | undefined; return await database.prepare("SELECT * FROM companies WHERE id = ?").get(id) as
}
export function companyByCode(database: Database, code: string): Company | undefined {
return database.prepare("SELECT * FROM companies WHERE code = ?").get(normCompanyCode(code)) as
| Company | Company
| undefined; | undefined;
} }
export function principalCompany(database: Database, tenantId?: number | null): Company | undefined { export async function companyByCode(database: Db, code: string): Promise<Company | undefined> {
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<Company | undefined> {
if (tenantId != null) { if (tenantId != null) {
return database.prepare( return await database.prepare(
"SELECT * FROM companies WHERE kind = 'principal' AND tenant_id = ? ORDER BY id LIMIT 1", "SELECT * FROM companies WHERE kind = 'principal' AND tenant_id = ? ORDER BY id LIMIT 1",
).get(tenantId) as Company | undefined; ).get(tenantId) as Company | undefined;
} }
return database.prepare( return await database.prepare(
"SELECT * FROM companies WHERE kind = 'principal' ORDER BY id LIMIT 1", "SELECT * FROM companies WHERE kind = 'principal' ORDER BY id LIMIT 1",
).get() as Company | undefined; ).get() as Company | undefined;
} }
@ -112,26 +113,26 @@ export function companyCodeFromName(name: string): string {
return slug || "EMP"; return slug || "EMP";
} }
export function nextCompanyCode(database: Database, name: string): string { export async function nextCompanyCode(database: Db, name: string): Promise<string> {
const base = companyCodeFromName(name); const base = companyCodeFromName(name);
if (!companyByCode(database, base)) return base; if (!await companyByCode(database, base)) return base;
const row = database.prepare( const row = await database.prepare(
`SELECT COALESCE(MAX(CAST(substr(code, 5) AS INTEGER)), 0) + 1 AS n `SELECT COALESCE(MAX(substring(code from 5)::integer), 0) + 1 AS n
FROM companies WHERE code GLOB 'EMP-[0-9][0-9][0-9][0-9]*'`, FROM companies WHERE code ~ '^EMP-[0-9]{4}'`,
).get() as { n: number }; ).get() as { n: number };
return `EMP-${String(row.n).padStart(4, "0")}`; return `EMP-${String(row.n).padStart(4, "0")}`;
} }
export function resolveCompany( export async function resolveCompany(
database: Database, database: Db,
body: { company_id?: number | null; hire_type?: string | null }, body: { company_id?: number | null; hire_type?: string | null },
): Company | undefined { ): Promise<Company | undefined> {
if (body.company_id) { 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; if (byId) return byId;
} }
const code = normCompanyCode(body.hire_type); const code = normCompanyCode(body.hire_type);
if (code) return companyByCode(database, code); if (code) return await companyByCode(database, code);
return undefined; return undefined;
} }
@ -142,7 +143,10 @@ export function validateCompanyCode(code: string): string | null {
return 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); const rfc = normRfc(input.rfc);
if (rfc && !RFC_RE.test(rfc)) { if (rfc && !RFC_RE.test(rfc)) {
return "RFC inválido (formato mexicano de 12 o 13 caracteres)"; return "RFC inválido (formato mexicano de 12 o 13 caracteres)";
@ -190,68 +194,79 @@ function profileFromInput(input: CompanyProfileInput, fallbackName = "") {
return normalizeCompanyProfile(input, fallbackName); return normalizeCompanyProfile(input, fallbackName);
} }
export function createSubcompany( export async function createSubcompany(
database: Database, database: Db,
input: CompanyProfileInput, input: CompanyProfileInput,
tenantId?: number | null, tenantId?: number | null,
): { company?: Company; error?: string } { ): Promise<{ company?: Company; error?: string }> {
const profileErr = validateProfile(input, { requireLegal: true }); const profileErr = validateProfile(input, { requireLegal: true });
if (profileErr) return { error: profileErr }; if (profileErr) return { error: profileErr };
const profile = profileFromInput(input); const profile = profileFromInput(input);
if (!profile.name) return { error: "Nombre de empresa obligatorio" }; 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" }; if (!principal) return { error: "No hay empresa principal" };
const parentId = input.parent_id ? Number(input.parent_id) : principal.id; 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 (!parent) return { error: "Empresa padre no encontrada" };
if (tenantId != null && parent.tenant_id != null && parent.tenant_id !== tenantId) { if (tenantId != null && parent.tenant_id != null && parent.tenant_id !== tenantId) {
return { error: "Empresa padre de otro tenant" }; return { error: "Empresa padre de otro tenant" };
} }
let code = normCompanyCode(input.code); let code = normCompanyCode(input.code);
if (!code) code = nextCompanyCode(database, profile.name); if (!code) code = await nextCompanyCode(database, profile.name);
const codeErr = validateCompanyCode(code); const codeErr = validateCompanyCode(code);
if (codeErr) return { error: codeErr }; 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; const tid = tenantId ?? principal.tenant_id ?? null;
database.prepare( try {
`INSERT INTO companies ( await database.prepare(
code, name, parent_id, kind, status, tenant_id, `INSERT INTO companies (
registro_patronal, razon_social, nombre_comercial, rfc, regimen_fiscal, clase_riesgo, code, name, parent_id, kind, status, tenant_id,
domicilio_fiscal, codigo_postal, ciudad, estado, telefono, email, representante_legal, giro registro_patronal, razon_social, nombre_comercial, rfc, regimen_fiscal, clase_riesgo,
) VALUES (?, ?, ?, 'sub', 'activo', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, domicilio_fiscal, codigo_postal, ciudad, estado, telefono, email, representante_legal, giro
).run( ) VALUES (?, ?, ?, 'sub', 'activo', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
code, ).run(
profile.name, code,
parent.id, profile.name,
tid, parent.id,
profile.registro_patronal, tid,
profile.razon_social, profile.registro_patronal,
profile.nombre_comercial, profile.razon_social,
profile.rfc, profile.nombre_comercial,
profile.regimen_fiscal, profile.rfc,
profile.clase_riesgo, profile.regimen_fiscal,
profile.domicilio_fiscal, profile.clase_riesgo,
profile.codigo_postal, profile.domicilio_fiscal,
profile.ciudad, profile.codigo_postal,
profile.estado, profile.ciudad,
profile.telefono, profile.estado,
profile.email, profile.telefono,
profile.representante_legal, profile.email,
profile.giro, profile.representante_legal,
); profile.giro,
return { company: companyById(database, lastId(database)) }; );
} 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( export async function updateCompany(
database: Database, database: Db,
id: number, id: number,
input: CompanyProfileInput, input: CompanyProfileInput,
): { company?: Company; error?: string; status?: 400 | 404 } { tenantId?: number | null,
const current = companyById(database, id); ): Promise<{ company?: Company; error?: string; status?: 400 | 404 }> {
if (!current) return { error: "Empresa no encontrada", status: 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); const profileErr = validateProfile(input);
if (profileErr) return { error: profileErr, status: 400 }; if (profileErr) return { error: profileErr, status: 400 };
@ -285,7 +300,7 @@ export function updateCompany(
code = normCompanyCode(input.code); code = normCompanyCode(input.code);
const codeErr = validateCompanyCode(code); const codeErr = validateCompanyCode(code);
if (codeErr) return { error: codeErr, status: 400 }; 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 }; 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"]; status = input.status as Company["status"];
} }
database.prepare( try {
`UPDATE companies SET await database.prepare(
name = ?, code = ?, status = ?, `UPDATE companies SET
registro_patronal = ?, razon_social = ?, nombre_comercial = ?, rfc = ?, regimen_fiscal = ?, clase_riesgo = ?, name = ?, code = ?, status = ?,
domicilio_fiscal = ?, codigo_postal = ?, ciudad = ?, estado = ?, telefono = ?, email = ?, registro_patronal = ?, razon_social = ?, nombre_comercial = ?, rfc = ?, regimen_fiscal = ?, clase_riesgo = ?,
representante_legal = ?, giro = ? domicilio_fiscal = ?, codigo_postal = ?, ciudad = ?, estado = ?, telefono = ?, email = ?,
WHERE id = ?`, representante_legal = ?, giro = ?
).run( WHERE id = ?`,
profile.name, ).run(
code, profile.name,
status, code,
profile.registro_patronal, status,
profile.razon_social, profile.registro_patronal,
profile.nombre_comercial, profile.razon_social,
profile.rfc, profile.nombre_comercial,
profile.regimen_fiscal, profile.rfc,
profile.clase_riesgo, profile.regimen_fiscal,
profile.domicilio_fiscal, profile.clase_riesgo,
profile.codigo_postal, profile.domicilio_fiscal,
profile.ciudad, profile.codigo_postal,
profile.estado, profile.ciudad,
profile.telefono, profile.estado,
profile.email, profile.telefono,
profile.representante_legal, profile.email,
profile.giro, profile.representante_legal,
id, profile.giro,
); id,
if (code !== current.code) { );
database.prepare("UPDATE workers SET hire_type = ? WHERE company_id = ?").run(code, 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";
} }

View file

@ -4,20 +4,39 @@ import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url)); const here = dirname(fileURLToPath(import.meta.url));
export const ROOT = join(here, ".."); export const ROOT = join(here, "..");
export const DATA_DIR = join(ROOT, "data"); export const DATA_DIR = join(ROOT, "data");
export const DB_PATH = join(DATA_DIR, "app.db"); // Cache local de archivos descifrados/temporales (no la fuente de verdad --
export const PLATFORM_DB_PATH = join(DATA_DIR, "platform.db"); // esa vive en Contabo Object Storage, ver api/storage.ts / Fase 4c).
export const EXPEDIENTES_DIR = join(DATA_DIR, "expedientes"); export const TMP_DIR = join(DATA_DIR, "tmp");
export const PDFS_DIR = join(DATA_DIR, "pdfs");
export const LOGOS_DIR = join(DATA_DIR, "logos"); function required(name: string): string {
export const PROJECTS_DIR = join(DATA_DIR, "proyectos"); const value = Deno.env.get(name);
export const COMPANIES_DIR = join(DATA_DIR, "empresas"); 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 = { export const config = {
port: Number(Deno.env.get("PORT") ?? "8000"), 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") ?? "", apiKey: Deno.env.get("API_KEY") ?? "",
docsKeyHex: Deno.env.get("DOCS_KEY") ?? docsKeyHex: requiredOrDevFallback(
"DOCS_KEY",
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
),
/** Solo vía env. Compose exige SEED_PASSWORD; no hay default en código. */ /** Solo vía env. Compose exige SEED_PASSWORD; no hay default en código. */
seedPassword: Deno.env.get("SEED_PASSWORD") ?? "", seedPassword: Deno.env.get("SEED_PASSWORD") ?? "",
vcardBase: Deno.env.get("VCARD_BASE") ?? "https://vcard.arctec.com.mx?info=", vcardBase: Deno.env.get("VCARD_BASE") ?? "https://vcard.arctec.com.mx?info=",
@ -32,4 +51,38 @@ export const config = {
cookieSecure: ["1", "true", "yes"].includes( cookieSecure: ["1", "true", "yes"].includes(
(Deno.env.get("COOKIE_SECURE") ?? "").toLowerCase(), (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,
}; };

View file

@ -25,7 +25,7 @@ export async function hashPassword(password: string): Promise<string> {
["deriveBits"], ["deriveBits"],
); );
const bits = await crypto.subtle.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, key,
256, 256,
); );
@ -55,7 +55,7 @@ export async function verifyPassword(password: string, stored: string): Promise<
); );
const bits = new Uint8Array( const bits = new Uint8Array(
await crypto.subtle.deriveBits( 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, key,
256, 256,
), ),
@ -66,44 +66,8 @@ export async function verifyPassword(password: string, stored: string): Promise<
return diff === 0; return diff === 0;
} }
export async function hmacSign(secret: string, payload: string): Promise<string> {
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<boolean> {
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<T>(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<string> { export async function sha256Hex(data: Uint8Array): Promise<string> {
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 }; export { toHex, fromHex, encoder, decoder };

339
api/db.ts
View file

@ -1,93 +1,64 @@
import { Database } from "@db/sqlite"; import postgres, { createPool, PgDb, withTenant } from "./pg.ts";
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 { config } from "./config.ts"; import { config } from "./config.ts";
import { hashPassword } from "./crypto.ts"; import { evaluateDocumentValidity, freshnessRequired, type ImssStatus, type WorkerImssContext } from "./document_validity.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";
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<Database> { let corePool: postgres.Sql | null = null;
if (db) return db; let coreOwnerPool: postgres.Sql | null = null;
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;
}
export function seedDocumentTypes(database: Database) { function getCorePool(): postgres.Sql {
const upsert = database.prepare( if (!corePool) {
`INSERT INTO document_types corePool = createPool(config.databaseUrlCore, { max: 20 });
(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,
);
} }
return corePool;
} }
export function seedProjectDocumentTypes(database: Database) { function getCoreOwnerPool(): postgres.Sql {
const upsert = database.prepare( if (!coreOwnerPool) {
`INSERT INTO project_document_types (code, label, required, category) coreOwnerPool = createPool(config.databaseUrlCoreOwner || config.databaseUrlCore, { max: 3 });
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);
} }
return coreOwnerPool;
} }
export function seedCompanyDocumentTypes(database: Database) { export async function pingCoreDb(): Promise<void> {
const upsert = database.prepare( await getCorePool()`SELECT 1`;
`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 function nextProjectCode(database: Database): string { /** El único camino "normal" para atender un request: abre una transacción
const row = database.prepare( * con app.tenant_id fijado (Row Level Security), y la cierra sola al
`SELECT COALESCE(MAX(CAST(substr(code, 5) AS INTEGER)), 0) + 1 AS n * terminar `fn` (commit) o al lanzar (rollback). Ver api/pg.ts#withTenant
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]*'`, * y las políticas en db/core/changesets/005-rls.sql. */
).get() as { n: number }; export async function withCoreTenant<T>(
return `PRY-${String(row.n).padStart(4, "0")}`; tenantId: number | null,
fn: (db: Db) => Promise<T>,
): Promise<T> {
return await withTenant(getCorePool(), tenantId, fn);
}
/** Conexión SIN RLS (rol _owner, dueño de las tablas) -- reservada para
* scripts (bootstrap, ETL) y para resoluciones internas puntuales que
* necesitan cruzar tenants a propósito (ej. auth.ts al enriquecer el login
* con el nombre de la empresa). NO usar en handlers de request normales. */
export async function getCoreDb(): Promise<Db> {
return new PgDb(getCoreOwnerPool());
}
export async function closeCoreDb(): Promise<void> {
await corePool?.end({ timeout: 5 });
await coreOwnerPool?.end({ timeout: 5 });
corePool = null;
coreOwnerPool = null;
}
export async function lastInsertId(database: Db): Promise<number> {
return await database.lastInsertId();
} }
export const PROJECT_STATUSES = ["activo", "pausado", "concluido", "cancelado"] as const; export 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); return (PROJECT_STATUSES as readonly string[]).includes(value);
} }
export function projectById(database: Database, id: number) { export async function nextProjectCode(database: Db): Promise<string> {
return database.prepare("SELECT * FROM projects WHERE id = ?").get(id) as 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 } | { id: number; code: string; name: string; status: string }
| undefined; | undefined;
} }
@ -122,103 +101,17 @@ export function projectMustBe(
return null; return null;
} }
async function seed(database: Database) { export async function workerImssContext(database: Db, workerId: number): Promise<WorkerImssContext> {
const risks = [ const w = await database.prepare(
["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(
"SELECT imss_status, imss_alta_at, last_rehire_at FROM workers WHERE id = ?", "SELECT imss_status, imss_alta_at, last_rehire_at FROM workers WHERE id = ?",
).get(workerId) as { ).get(workerId) as {
imss_status: ImssStatus; imss_status: ImssStatus;
imss_alta_at: string | null; imss_alta_at: string | null;
last_rehire_at: string | null; last_rehire_at: string | null;
} | undefined; } | undefined;
const altaDoc = database.prepare( const altaDoc = await database.prepare(
`SELECT imss_alta_at, uploaded_at FROM documents `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`, LIMIT 1`,
).get(workerId) as { imss_alta_at: string | null; uploaded_at: string } | undefined; ).get(workerId) as { imss_alta_at: string | null; uploaded_at: string } | undefined;
return { return {
@ -228,25 +121,30 @@ export function workerImssContext(database: Database, workerId: number): WorkerI
}; };
} }
/** Checklist con vigencia; contexto IMSS vía imssFlagsFor / checklistItemsFor. */ /** Checklist con vigencia; contexto IMSS vía imssFlagsFor / checklistItemsFor.
export function checklistItemsFor(database: Database, workerId: number) { * tenantId (Fase 4d) resuelve la zona horaria del tenant para calcular
const types = database.prepare( * "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, `SELECT code, label, required, validity_mode, freshness_days,
requires_issued_at, requires_expires_at, category requires_issued_at, requires_expires_at, category
FROM document_types`, FROM document_types`,
).all() as { ).all() as {
code: string; code: string;
label: string; label: string;
required: number; required: boolean;
validity_mode: string; validity_mode: string;
freshness_days: number | null; freshness_days: number | null;
requires_issued_at: number; requires_issued_at: boolean;
requires_expires_at: number; requires_expires_at: boolean;
category: string; category: string;
}[]; }[];
const currentDocs = database.prepare( const currentDocs = await database.prepare(
`SELECT type_code, issued_at, expires_at, uploaded_at, imss_alta_at `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 { ).all(workerId) as {
type_code: string; type_code: string;
issued_at: string | null; issued_at: string | null;
@ -255,7 +153,7 @@ export function checklistItemsFor(database: Database, workerId: number) {
imss_alta_at: string | null; imss_alta_at: string | null;
}[]; }[];
const byType = new Map(currentDocs.map((d) => [d.type_code, d])); const byType = new Map(currentDocs.map((d) => [d.type_code, d]));
const ctx = workerImssContext(database, workerId); const ctx = await workerImssContext(database, workerId);
return { return {
ctx, ctx,
@ -270,7 +168,7 @@ export function checklistItemsFor(database: Database, workerId: number) {
requires_issued_at: !!t.requires_issued_at, requires_issued_at: !!t.requires_issued_at,
requires_expires_at: !!t.requires_expires_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 { return {
code: t.code, code: t.code,
label: t.label, label: t.label,
@ -290,11 +188,11 @@ export function checklistItemsFor(database: Database, workerId: number) {
}; };
} }
export function imssFlagsFor(database: Database, workerId: number) { export async function imssFlagsFor(database: Db, workerId: number, tenantId: number | null = null) {
const { ctx, freshness_required: needFresh, items } = checklistItemsFor(database, workerId); 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 requiredOk = items.filter((i) => i.required).every((i) => i.present && i.valid);
const inProject = !!database.prepare( const inProject = !!await database.prepare(
"SELECT 1 FROM assignments WHERE worker_id = ? AND active = 1 LIMIT 1", "SELECT 1 FROM assignments WHERE worker_id = ? AND active = true LIMIT 1",
).get(workerId); ).get(workerId);
const hasImss = ctx.imss_status === "alta"; const hasImss = ctx.imss_status === "alta";
return { return {
@ -306,62 +204,45 @@ export function imssFlagsFor(database: Database, workerId: number) {
}; };
} }
export function checklistFor(database: Database, workerId: number) { export async function checklistFor(database: Db, workerId: number, tenantId: number | null = null) {
return checklistItemsFor(database, workerId).items; return (await checklistItemsFor(database, workerId, tenantId)).items;
} }
export function refreshPipeline(database: Database, workerId: number) { export async function refreshPipeline(database: Db, workerId: number, tenantId: number | null = null): Promise<void> {
const w = database.prepare("SELECT status FROM workers WHERE id = ?").get(workerId) as const w = await database.prepare("SELECT status FROM workers WHERE id = ?").get(workerId) as
| { status: string } | { status: string }
| undefined; | undefined;
if (!w) return; if (!w) return;
if (w.status === "baja") { 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; return;
} }
const items = checklistFor(database, workerId); const items = await checklistFor(database, workerId, tenantId);
const photo = items.find((i) => i.code === "foto"); const photo = items.find((i) => i.code === "foto");
const photoOk = photo?.present && photo?.valid; const photoOk = photo?.present && photo?.valid;
const requiredOk = items.filter((i) => i.required).every((i) => i.present && i.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 `SELECT 1 FROM badge_job_people p
JOIN badge_jobs j ON j.id = p.job_id JOIN badge_jobs j ON j.id = p.job_id
WHERE p.worker_id = ? LIMIT 1`, WHERE p.worker_id = ? LIMIT 1`,
).get(workerId); ).get(workerId);
let pipeline = "incompleto"; let pipeline = "incompleto";
if (requiredOk && photoOk) pipeline = printed ? "impreso" : "listo_gafete"; if (requiredOk && photoOk) pipeline = printed ? "impreso" : "listo_gafete";
const assigned = database.prepare( const assigned = await database.prepare(
"SELECT 1 FROM assignments WHERE worker_id = ? AND active = 1 LIMIT 1", "SELECT 1 FROM assignments WHERE worker_id = ? AND active = true LIMIT 1",
).get(workerId); ).get(workerId);
if (pipeline !== "incompleto" && assigned) { if (pipeline !== "incompleto" && assigned) {
pipeline = printed ? "activo" : "listo_gafete"; 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 { export async function projectChecklistFor(database: Db, projectId: number) {
const row = database.prepare("SELECT last_insert_rowid() AS id").get() as { id: number }; const types = await database.prepare(
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(
"SELECT code, label, required, category FROM project_document_types", "SELECT code, label, required, category FROM project_document_types",
).all() as { code: string; label: string; required: number; category: string }[]; ).all() as { code: string; label: string; required: boolean; category: string }[];
const current = database.prepare( const current = await database.prepare(
`SELECT type_code FROM project_documents WHERE project_id = ? AND is_current = 1`, `SELECT type_code FROM project_documents WHERE project_id = ? AND is_current = true`,
).all(projectId) as { type_code: string }[]; ).all(projectId) as { type_code: string }[];
const have = new Set(current.map((c) => c.type_code)); const have = new Set(current.map((c) => c.type_code));
return types.map((t) => ({ return types.map((t) => ({
@ -373,12 +254,12 @@ export function projectChecklistFor(database: Database, projectId: number) {
})); }));
} }
export function companyChecklistFor(database: Database, companyId: number) { export async function companyChecklistFor(database: Db, companyId: number) {
const types = database.prepare( const types = await database.prepare(
"SELECT code, label, required, category FROM company_document_types", "SELECT code, label, required, category FROM company_document_types",
).all() as { code: string; label: string; required: number; category: string }[]; ).all() as { code: string; label: string; required: boolean; category: string }[];
const current = database.prepare( const current = await database.prepare(
`SELECT type_code FROM company_documents WHERE company_id = ? AND is_current = 1`, `SELECT type_code FROM company_documents WHERE company_id = ? AND is_current = true`,
).all(companyId) as { type_code: string }[]; ).all(companyId) as { type_code: string }[];
const have = new Set(current.map((c) => c.type_code)); const have = new Set(current.map((c) => c.type_code));
return types.map((t) => ({ return types.map((t) => ({
@ -389,3 +270,21 @@ export function companyChecklistFor(database: Database, companyId: number) {
present: have.has(t.code), present: have.has(t.code),
})); }));
} }
/** Zona horaria del tenant (Fase 4d) -- reemplaza el PAYROLL_TZ
* hardcodeado. Con default sensato si el tenant no la configuró. */
export async function tenantTimezone(database: Db, tenantId: number | null): Promise<string> {
if (tenantId == null) return "America/Mexico_City";
const row = await database.prepare(
"SELECT timezone FROM tenant_settings WHERE tenant_id = ?",
).get(tenantId) as { timezone: string } | undefined;
return row?.timezone || "America/Mexico_City";
}
/** "Hoy" en la zona horaria del tenant, formato ISO (YYYY-MM-DD). Usado por
* nómina y por validación de vigencia de documentos -- antes eran dos
* nociones de "hoy" distintas (PAYROLL_TZ vs. new Date() crudo); ahora es
* una sola función parametrizada por tenant. */
export function todayInTimezone(timezone: string, now = new Date()): string {
return now.toLocaleDateString("en-CA", { timeZone: timezone });
}

View file

@ -2,14 +2,13 @@
"name": "panel-obra-api", "name": "panel-obra-api",
"exports": "./main.ts", "exports": "./main.ts",
"tasks": { "tasks": {
"dev": "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 --allow-ffi --allow-run --env-file=../.env main.ts", "start": "deno run --allow-net --allow-read --allow-write --allow-env main.ts",
"migrate": "cd .. && ./db/update.sh all", "migrate": "cd .. && ./db/update.sh all",
"check": "deno check main.ts", "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": { "imports": {
"@db/sqlite": "jsr:@db/sqlite@0.12",
"hono": "jsr:@hono/hono@4", "hono": "jsr:@hono/hono@4",
"xlsx": "npm:xlsx@0.18.5", "xlsx": "npm:xlsx@0.18.5",
"pdf-lib": "npm:pdf-lib@1.17.1", "pdf-lib": "npm:pdf-lib@1.17.1",

View file

@ -1,84 +1,233 @@
{ {
"version": "5", "version": "5",
"specifiers": { "specifiers": {
"jsr:@db/sqlite@0.12": "0.12.0",
"jsr:@denosaurs/plug@1": "1.1.0",
"jsr:@hono/hono@4": "4.13.2", "jsr:@hono/hono@4": "4.13.2",
"jsr:@std/assert@0.217": "0.217.0",
"jsr:@std/assert@1": "1.0.19", "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.12": "1.0.14",
"jsr:@std/internal@^1.0.14": "1.0.14", "npm:@aws-sdk/client-s3@3": "3.1124.0",
"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:@types/node@*": "22.15.15", "npm:@types/node@*": "22.15.15",
"npm:nodemailer@6.9.16": "6.9.16", "npm:nodemailer@6.9.16": "6.9.16",
"npm:pdf-lib@1.17.1": "1.17.1", "npm:pdf-lib@1.17.1": "1.17.1",
"npm:postgres@3": "3.4.9",
"npm:qrcode@1.5.4": "1.5.4", "npm:qrcode@1.5.4": "1.5.4",
"npm:redis@4": "4.7.1",
"npm:xlsx@0.18.5": "0.18.5" "npm:xlsx@0.18.5": "0.18.5"
}, },
"jsr": { "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": { "@hono/hono@4.13.2": {
"integrity": "715d8cc1b6b5d6b9e6a7519059778d775b2d79fffdc026ac0ccdd9971e75809c" "integrity": "715d8cc1b6b5d6b9e6a7519059778d775b2d79fffdc026ac0ccdd9971e75809c"
}, },
"@std/assert@0.217.0": {
"integrity": "c98e279362ca6982d5285c3b89517b757c1e3477ee9f14eb2fdf80a45aaa9642"
},
"@std/assert@1.0.19": { "@std/assert@1.0.19": {
"integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e", "integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e",
"dependencies": [ "dependencies": [
"jsr:@std/internal@^1.0.12" "jsr:@std/internal"
]
},
"@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"
] ]
}, },
"@std/internal@1.0.14": { "@std/internal@1.0.14": {
"integrity": "291516b3d4c35024d6ffbc0a9df5bf4c64116e05b50012cf846710152d2ffdf7" "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": { "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": { "@pdf-lib/standard-fonts@1.0.0": {
"integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==", "integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==",
"dependencies": [ "dependencies": [
@ -91,6 +240,89 @@
"pako" "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": { "@types/node@22.15.15": {
"integrity": "sha512-R5muMcZob3/Jjchn5LcO8jdKwSCbzqmPB6ruBxMcf9kbxtniZHP327s6C37iOfuw8mbKK3cAQa7sEl7afLrQ8A==", "integrity": "sha512-R5muMcZob3/Jjchn5LcO8jdKwSCbzqmPB6ruBxMcf9kbxtniZHP327s6C37iOfuw8mbKK3cAQa7sEl7afLrQ8A==",
"dependencies": [ "dependencies": [
@ -109,6 +341,9 @@
"color-convert" "color-convert"
] ]
}, },
"bowser@2.14.1": {
"integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="
},
"camelcase@5.3.1": { "camelcase@5.3.1": {
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==" "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="
}, },
@ -127,6 +362,9 @@
"wrap-ansi" "wrap-ansi"
] ]
}, },
"cluster-key-slot@1.1.2": {
"integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA=="
},
"codepage@1.15.0": { "codepage@1.15.0": {
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==" "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA=="
}, },
@ -162,6 +400,9 @@
"frac@1.1.2": { "frac@1.1.2": {
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==" "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA=="
}, },
"generic-pool@3.9.0": {
"integrity": "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g=="
},
"get-caller-file@2.0.5": { "get-caller-file@2.0.5": {
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==" "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="
}, },
@ -204,12 +445,15 @@
"@pdf-lib/standard-fonts", "@pdf-lib/standard-fonts",
"@pdf-lib/upng", "@pdf-lib/upng",
"pako", "pako",
"tslib" "tslib@1.14.1"
] ]
}, },
"pngjs@5.0.0": { "pngjs@5.0.0": {
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==" "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="
}, },
"postgres@3.4.9": {
"integrity": "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw=="
},
"qrcode@1.5.4": { "qrcode@1.5.4": {
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"dependencies": [ "dependencies": [
@ -219,6 +463,17 @@
], ],
"bin": true "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": { "require-directory@2.1.1": {
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==" "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="
}, },
@ -251,6 +506,9 @@
"tslib@1.14.1": { "tslib@1.14.1": {
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="
}, },
"tslib@2.8.1": {
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
},
"undici-types@6.21.0": { "undici-types@6.21.0": {
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==" "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="
}, },
@ -287,6 +545,9 @@
"y18n@4.0.3": { "y18n@4.0.3": {
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==" "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="
}, },
"yallist@4.0.0": {
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="
},
"yargs-parser@18.1.3": { "yargs-parser@18.1.3": {
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"dependencies": [ "dependencies": [
@ -348,7 +609,6 @@
}, },
"workspace": { "workspace": {
"dependencies": [ "dependencies": [
"jsr:@db/sqlite@0.12",
"jsr:@hono/hono@4", "jsr:@hono/hono@4",
"npm:pdf-lib@1.17.1", "npm:pdf-lib@1.17.1",
"npm:qrcode@1.5.4", "npm:qrcode@1.5.4",

View file

@ -6,14 +6,14 @@ async function docsKey(): Promise<CryptoKey> {
if (raw.length !== 32) { if (raw.length !== 32) {
throw new Error("DOCS_KEY must be 64 hex chars (32 bytes)"); 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 }> { export async function encryptBytes(plain: Uint8Array): Promise<{ iv: string; cipher: Uint8Array }> {
const key = await docsKey(); const key = await docsKey();
const iv = crypto.getRandomValues(new Uint8Array(12)); const iv = crypto.getRandomValues(new Uint8Array(12));
const cipher = new Uint8Array( 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 }; return { iv: toHex(iv), cipher };
} }
@ -22,6 +22,6 @@ export async function decryptBytes(ivHex: string, cipher: Uint8Array): Promise<U
const key = await docsKey(); const key = await docsKey();
const iv = fromHex(ivHex); const iv = fromHex(ivHex);
return new Uint8Array( return new Uint8Array(
await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, cipher), await crypto.subtle.decrypt({ name: "AES-GCM", iv: iv as BufferSource }, key, cipher as BufferSource),
); );
} }

View file

@ -52,11 +52,17 @@ export function freshnessRequired(ctx: WorkerImssContext): boolean {
return alta < rehire; return alta < rehire;
} }
/**
* `today` es un string ISO (YYYY-MM-DD) en la zona horaria del tenant
* (Fase 4d) -- ya no `new Date()` crudo (hora del servidor/UTC). El
* caller (api/db.ts#checklistItemsFor) lo resuelve con
* tenantTimezone()/todayInTimezone() antes de llegar aquí.
*/
export function evaluateDocumentValidity( export function evaluateDocumentValidity(
policy: DocTypePolicy, policy: DocTypePolicy,
doc: CurrentDocDates | null | undefined, doc: CurrentDocDates | null | undefined,
ctx: WorkerImssContext, ctx: WorkerImssContext,
today = new Date(), today: string = new Date().toISOString().slice(0, 10),
): { ): {
present: boolean; present: boolean;
valid: boolean; valid: boolean;
@ -89,7 +95,8 @@ export function evaluateDocumentValidity(
}; };
} }
const exp = parseDay(expiresAt); const exp = parseDay(expiresAt);
if (exp && exp < new Date(today.toISOString().slice(0, 10) + "T12:00:00")) { const todayDate = parseDay(today);
if (exp && todayDate && exp < todayDate) {
return { return {
present: true, present: true,
valid: false, valid: false,
@ -120,7 +127,8 @@ export function evaluateDocumentValidity(
if (needFresh && issuedAt) { if (needFresh && issuedAt) {
const issued = parseDay(issuedAt); const issued = parseDay(issuedAt);
const days = policy.freshness_days ?? 90; const days = policy.freshness_days ?? 90;
if (issued && daysBetween(issued, today) > days) { const todayForFreshness = parseDay(today) ?? new Date();
if (issued && daysBetween(issued, todayForFreshness) > days) {
return { return {
present: true, present: true,
valid: false, valid: false,

View file

@ -79,7 +79,7 @@ Deno.test("CURP stale when freshness required", () => {
curpPolicy, curpPolicy,
{ type_code: "curp", issued_at: "2025-01-01", expires_at: null, uploaded_at: "2025-01-02" }, { type_code: "curp", issued_at: "2025-01-01", expires_at: null, uploaded_at: "2025-01-02" },
ctx, ctx,
new Date("2026-08-20T12:00:00"), "2026-08-20",
); );
assertEquals(result.validity_status, "stale_for_alta"); assertEquals(result.validity_status, "stale_for_alta");
assertEquals(result.valid, false); assertEquals(result.valid, false);
@ -95,7 +95,7 @@ Deno.test("CURP old but ok when already alta", () => {
curpPolicy, curpPolicy,
{ type_code: "curp", issued_at: "2025-01-01", expires_at: null, uploaded_at: "2025-01-02" }, { type_code: "curp", issued_at: "2025-01-01", expires_at: null, uploaded_at: "2025-01-02" },
ctx, ctx,
new Date("2026-08-20T12:00:00"), "2026-08-20",
); );
assertEquals(result.validity_status, "ok"); assertEquals(result.validity_status, "ok");
assertEquals(result.valid, true); assertEquals(result.valid, true);
@ -111,7 +111,7 @@ Deno.test("INE expired always invalid", () => {
inePolicy, inePolicy,
{ type_code: "ine", issued_at: null, expires_at: "2025-12-31", uploaded_at: "2025-01-01" }, { type_code: "ine", issued_at: null, expires_at: "2025-12-31", uploaded_at: "2025-01-01" },
ctx, ctx,
new Date("2026-08-20T12:00:00"), "2026-08-20",
); );
assertEquals(result.validity_status, "expired"); assertEquals(result.validity_status, "expired");
assertEquals(result.valid, false); assertEquals(result.valid, false);

View file

@ -1,12 +1,11 @@
import * as XLSX from "xlsx"; import * as XLSX from "xlsx";
import type { Database } from "@db/sqlite"; import type { Db } from "./db.ts";
import { mkdir } from "node:fs/promises";
import { join } from "node:path";
import { encryptBytes } from "./docs_crypto.ts"; import { encryptBytes } from "./docs_crypto.ts";
import { sha256Hex } from "./crypto.ts"; import { sha256Hex } from "./crypto.ts";
import { canonicalRiskCode, normalizeWorker, validateWorkerFields, formatNss, normUpper, type WorkerInput } from "./mx.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 { resolveCompany } from "./companies.ts";
import { companyDocKey, projectDocKey, putObject, workerDocKey } from "./storage.ts";
export const IMPORT_COLUMNS = [ export const IMPORT_COLUMNS = [
"NOMBRE", "NOMBRE",
@ -190,8 +189,8 @@ export function buildImportTemplate(companies: { code: string; name: string }[]
return out instanceof Uint8Array ? out : new Uint8Array(out); return out instanceof Uint8Array ? out : new Uint8Array(out);
} }
function findExisting(db: Database, curp: string, rfc: string, nss: string) { async function findExisting(db: Db, curp: string, rfc: string, nss: string) {
return db.prepare( return await db.prepare(
`SELECT id, first_name, last_name_p, curp, rfc, nss FROM workers `SELECT id, first_name, last_name_p, curp, rfc, nss FROM workers
WHERE curp = ? OR rfc = ? OR nss = ? LIMIT 1`, WHERE curp = ? OR rfc = ? OR nss = ? LIMIT 1`,
).get(curp, rfc, nss) as ).get(curp, rfc, nss) as
@ -200,7 +199,7 @@ function findExisting(db: Database, curp: string, rfc: string, nss: string) {
} }
export async function storeDocument( export async function storeDocument(
db: Database, db: Db,
workerId: number, workerId: number,
type: string, type: string,
filename: string, filename: string,
@ -215,16 +214,14 @@ export async function storeDocument(
imss_baja_at?: string | null; 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"); if (!allowed) throw new Error("Tipo de documento no válido");
const { iv, cipher } = await encryptBytes(bytes); const { iv, cipher } = await encryptBytes(bytes);
const hash = await sha256Hex(bytes); const hash = await sha256Hex(bytes);
const storage = `${crypto.randomUUID()}.enc`; const storage = `${crypto.randomUUID()}.enc`;
const dir = workerDir(workerId); await putObject(workerDocKey(workerId, storage), cipher);
await mkdir(dir, { recursive: true }); await db.prepare("UPDATE documents SET is_current = false WHERE worker_id = ? AND type_code = ?").run(
await Deno.writeFile(join(dir, storage), cipher);
db.prepare("UPDATE documents SET is_current = 0 WHERE worker_id = ? AND type_code = ?").run(
workerId, workerId,
type, type,
); );
@ -236,11 +233,11 @@ export async function storeDocument(
const imssBajaAt = meta.imss_baja_at || (type === "baja_imss" ? today : null); const imssBajaAt = meta.imss_baja_at || (type === "baja_imss" ? today : null);
const movementDate = type === "baja_imss" ? imssBajaAt : imssAltaAt; const movementDate = type === "baja_imss" ? imssBajaAt : imssAltaAt;
db.prepare( await db.prepare(
`INSERT INTO documents `INSERT INTO documents
(worker_id, type_code, original_name, mime, size_bytes, sha256, iv, storage_name, is_current, (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) parse_status, issued_at, expires_at, imss_company_id, imss_alta_at, uploaded_by_id, uploaded_by_name)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, 'manual', ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, true, 'manual', ?, ?, ?, ?, ?, '')`,
).run( ).run(
workerId, workerId,
type, type,
@ -259,16 +256,16 @@ export async function storeDocument(
if (type === "alta_imss") { if (type === "alta_imss") {
const companyId = imssCompanyId || 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 || ?.company_id ||
null; null;
const company = companyId 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 } | { id: number; code: string }
| undefined | undefined
: undefined; : undefined;
if (!company) throw new Error("Empresa patrón no válida"); if (!company) throw new Error("Empresa patrón no válida");
db.prepare( await db.prepare(
`UPDATE workers SET `UPDATE workers SET
imss_status = 'alta', imss_status = 'alta',
imss_company_id = ?, imss_company_id = ?,
@ -276,29 +273,29 @@ export async function storeDocument(
imss_baja_at = NULL, imss_baja_at = NULL,
company_id = ?, company_id = ?,
hire_type = ?, hire_type = ?,
updated_at = datetime('now') updated_at = now()
WHERE id = ?`, WHERE id = ?`,
).run(company.id, imssAltaAt, company.id, company.code, workerId); ).run(company.id, imssAltaAt, company.id, company.code, workerId);
} }
if (type === "baja_imss") { if (type === "baja_imss") {
db.prepare( await db.prepare(
`UPDATE workers SET `UPDATE workers SET
imss_status = 'baja_imss', imss_status = 'baja_imss',
imss_baja_at = ?, imss_baja_at = ?,
imss_company_id = NULL, imss_company_id = NULL,
company_id = NULL, company_id = NULL,
hire_type = '', hire_type = '',
updated_at = datetime('now') updated_at = now()
WHERE id = ?`, WHERE id = ?`,
).run(imssBajaAt, workerId); ).run(imssBajaAt, workerId);
} }
refreshPipeline(db, workerId); await refreshPipeline(db, workerId);
} }
export async function storeProjectDocument( export async function storeProjectDocument(
db: Database, db: Db,
projectId: number, projectId: number,
type: string, type: string,
filename: string, filename: string,
@ -306,27 +303,25 @@ export async function storeProjectDocument(
bytes: Uint8Array, bytes: Uint8Array,
userId: number | null, 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"); if (!allowed) throw new Error("Tipo de documento no válido");
const { iv, cipher } = await encryptBytes(bytes); const { iv, cipher } = await encryptBytes(bytes);
const hash = await sha256Hex(bytes); const hash = await sha256Hex(bytes);
const storage = `${crypto.randomUUID()}.enc`; const storage = `${crypto.randomUUID()}.enc`;
const dir = projectDir(projectId); await putObject(projectDocKey(projectId, storage), cipher);
await mkdir(dir, { recursive: true }); await db.prepare("UPDATE project_documents SET is_current = false WHERE project_id = ? AND type_code = ?").run(
await Deno.writeFile(join(dir, storage), cipher);
db.prepare("UPDATE project_documents SET is_current = 0 WHERE project_id = ? AND type_code = ?").run(
projectId, projectId,
type, type,
); );
db.prepare( await db.prepare(
`INSERT INTO project_documents `INSERT INTO project_documents
(project_id, type_code, original_name, mime, size_bytes, sha256, iv, storage_name, is_current, parse_status, uploaded_by) (project_id, type_code, original_name, mime, size_bytes, sha256, iv, storage_name, is_current, parse_status, uploaded_by_id, uploaded_by_name)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, 'manual', ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, true, 'manual', ?, '')`,
).run(projectId, type, filename, mime, bytes.byteLength, hash, iv, storage, userId); ).run(projectId, type, filename, mime, bytes.byteLength, hash, iv, storage, userId);
} }
export async function storeCompanyDocument( export async function storeCompanyDocument(
db: Database, db: Db,
companyId: number, companyId: number,
type: string, type: string,
filename: string, filename: string,
@ -334,22 +329,20 @@ export async function storeCompanyDocument(
bytes: Uint8Array, bytes: Uint8Array,
userId: number | null, 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"); if (!allowed) throw new Error("Tipo de documento no válido");
const { iv, cipher } = await encryptBytes(bytes); const { iv, cipher } = await encryptBytes(bytes);
const hash = await sha256Hex(bytes); const hash = await sha256Hex(bytes);
const storage = `${crypto.randomUUID()}.enc`; const storage = `${crypto.randomUUID()}.enc`;
const dir = companyDir(companyId); await putObject(companyDocKey(companyId, storage), cipher);
await mkdir(dir, { recursive: true }); await db.prepare("UPDATE company_documents SET is_current = false WHERE company_id = ? AND type_code = ?").run(
await Deno.writeFile(join(dir, storage), cipher);
db.prepare("UPDATE company_documents SET is_current = 0 WHERE company_id = ? AND type_code = ?").run(
companyId, companyId,
type, type,
); );
db.prepare( await db.prepare(
`INSERT INTO company_documents `INSERT INTO company_documents
(company_id, type_code, original_name, mime, size_bytes, sha256, iv, storage_name, is_current, parse_status, uploaded_by) (company_id, type_code, original_name, mime, size_bytes, sha256, iv, storage_name, is_current, parse_status, uploaded_by_id, uploaded_by_name)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, 'manual', ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, true, 'manual', ?, '')`,
).run(companyId, type, filename, mime, bytes.byteLength, hash, iv, storage, userId); ).run(companyId, type, filename, mime, bytes.byteLength, hash, iv, storage, userId);
} }
@ -364,19 +357,19 @@ async function fetchPhoto(url: string): Promise<Uint8Array | null> {
} }
} }
function upsertWorker( async function upsertWorker(
db: Database, db: Db,
input: ReturnType<typeof normalizeWorker> & { company_id: number; tenant_id?: number | null }, input: ReturnType<typeof normalizeWorker> & { company_id: number; tenant_id?: number | null },
projectId: 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) { if (existing) {
db.prepare( await db.prepare(
`UPDATE workers SET `UPDATE workers SET
first_name=?, middle_name=?, last_name_p=?, last_name_m=?, first_name=?, middle_name=?, last_name_p=?, last_name_m=?,
curp=?, rfc=?, nss=?, phone=?, email=?, address=?, blood_type=?, curp=?, rfc=?, nss=?, phone=?, email=?, address=?, blood_type=?,
hire_type=?, company_id=?, tenant_id=COALESCE(?, tenant_id), position=?, risk_code=?, work_type=?, daily_wage=?, 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=?`, WHERE id=?`,
).run( ).run(
input.first_name, input.first_name,
@ -401,12 +394,12 @@ function upsertWorker(
input.status, input.status,
existing.id, existing.id,
); );
if (projectId) assign(db, existing.id, projectId); if (projectId) await assign(db, existing.id, projectId);
refreshPipeline(db, existing.id); await refreshPipeline(db, existing.id, input.tenant_id ?? null);
const matched = existing.curp === input.curp ? "CURP" : existing.rfc === input.rfc ? "RFC" : "NSS"; const matched = existing.curp === input.curp ? "CURP" : existing.rfc === input.rfc ? "RFC" : "NSS";
return { id: existing.id, action: "existed" as const, matched }; return { id: existing.id, action: "existed" as const, matched };
} }
db.prepare( await db.prepare(
`INSERT INTO workers `INSERT INTO workers
(first_name, middle_name, last_name_p, last_name_m, curp, rfc, nss, phone, email, address, (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) 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.status,
input.tenant_id ?? null, input.tenant_id ?? null,
); );
const id = lastInsertId(db); const id = await lastInsertId(db);
if (projectId) assign(db, id, projectId); if (projectId) await assign(db, id, projectId);
refreshPipeline(db, id); await refreshPipeline(db, id, input.tenant_id ?? null);
return { id, action: "inserted" as const, matched: null as string | null }; return { id, action: "inserted" as const, matched: null as string | null };
} }
function assign(db: Database, workerId: number, projectId: number) { async function assign(db: Db, workerId: number, projectId: number) {
db.prepare( await db.prepare(
`INSERT INTO assignments (worker_id, project_id, active, start_date) `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 ON CONFLICT(worker_id, project_id) DO UPDATE SET
active=1, active=true,
start_date=CASE WHEN assignments.active=0 THEN excluded.start_date ELSE assignments.start_date END, start_date=CASE WHEN assignments.active=false THEN excluded.start_date ELSE assignments.start_date END,
end_date=NULL`, end_date=NULL`,
).run(workerId, projectId); ).run(workerId, projectId);
} }
@ -460,14 +453,14 @@ export type ImportReport = {
}; };
export async function importExcel( export async function importExcel(
db: Database, db: Db,
bytes: Uint8Array, bytes: Uint8Array,
projectId: number | null, projectId: number | null,
userId: number | null, userId: number | null,
): Promise<ImportReport> { ): Promise<ImportReport> {
const wb = XLSX.read(bytes, { type: "array" }); const wb = XLSX.read(bytes, { type: "array" });
const risks = new Set( 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 = { const report: ImportReport = {
inserted: 0, inserted: 0,
@ -509,7 +502,7 @@ export async function importExcel(
const nombre = [data["NOMBRE"], data["APELLIDO PATERNO"], data["APELLIDO MATERNO"]].filter(Boolean).join(" "); const nombre = [data["NOMBRE"], data["APELLIDO PATERNO"], data["APELLIDO MATERNO"]].filter(Boolean).join(" ");
const input = rowToInput(data, job.fallback); const input = rowToInput(data, job.fallback);
const errors = validateWorkerFields(input); 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 (!company) errors.hire_type = `Empresa no encontrada (${data["ALTA"] || "—"})`;
if (input.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(input.email)) { if (input.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(input.email)) {
errors.email = "Correo inválido"; errors.email = "Correo inválido";
@ -546,7 +539,7 @@ export async function importExcel(
company_id: company!.id, company_id: company!.id,
tenant_id: company!.tenant_id ?? 1, 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++; if (res.action === "inserted") report.inserted++;
else { else {
report.existed++; report.existed++;

72
api/iam_db.ts Normal file
View file

@ -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<void> {
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<string, unknown> | 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<T>(
tenantId: number | null,
fn: (db: PgDb) => Promise<T>,
): Promise<T> {
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<T>(fn: (db: PgDb) => Promise<T>): Promise<T> {
return await fn(new PgDb(getAuthPool()));
}
export async function closeIamDb(): Promise<void> {
await appPool?.end({ timeout: 5 });
await authPool?.end({ timeout: 5 });
appPool = null;
authPool = null;
}

View file

@ -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<boolean> {
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<void> {
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<void> {
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;
}

View file

@ -18,8 +18,8 @@ export type MailPayload = {
attachments?: MailAttachment[]; attachments?: MailAttachment[];
}; };
export function smtpConfigured(platformDb: PlatformDb): boolean { export async function smtpConfigured(platformDb: PlatformDb): Promise<boolean> {
return smtpConfiguredFromDb(platformDb); return await smtpConfiguredFromDb(platformDb);
} }
/** Envía correo por SMTP. Si no hay SMTP, no falla: sent=false. */ /** Envía correo por SMTP. Si no hay SMTP, no falla: sent=false. */
@ -27,11 +27,11 @@ export async function sendMail(
platformDb: PlatformDb, platformDb: PlatformDb,
payload: MailPayload, payload: MailPayload,
): Promise<{ sent: boolean; error?: string; message_id?: string }> { ): Promise<{ sent: boolean; error?: string; message_id?: string }> {
if (!smtpConfigured(platformDb)) { if (!await smtpConfigured(platformDb)) {
return { sent: false, error: "SMTP no configurado o deshabilitado" }; return { sent: false, error: "SMTP no configurado o deshabilitado" };
} }
const s = resolveSmtp(platformDb); const s = await resolveSmtp(platformDb);
const from = (s.from_address ?? "").trim(); const from = (s.from_address ?? "").trim();
if (!from || !/(?:^|<)[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+(?:>|$)/.test(from)) { if (!from || !/(?:^|<)[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+(?:>|$)/.test(from)) {
return { return {

File diff suppressed because it is too large Load diff

View file

@ -174,7 +174,7 @@ export function normalizeWorker(body: WorkerInput) {
risk_code: canonicalRiskCode(body.risk_code), risk_code: canonicalRiskCode(body.risk_code),
work_type: (body.work_type ?? "").toUpperCase(), work_type: (body.work_type ?? "").toUpperCase(),
daily_wage: Number(body.daily_wage ?? 0), 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", status: body.status === "baja" ? "baja" : "activo",
}; };
} }

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,8 @@
import type { Hono } from "hono"; import type { Context, Hono } from "hono";
import type { AuthUser } from "./auth.ts"; import type { AuthUser } from "./auth.ts";
import { tenantScope, requireAuth } from "./auth.ts"; import { tenantScope } from "./auth.ts";
import { getDb, lastInsertId, projectById, projectMustBe } from "./db.ts"; import { requireCoreAuth } from "./scope.ts";
import { lastInsertId, projectById, projectMustBe, type Db } from "./db.ts";
import { generateLoanReceiptPdf } from "./pdf.ts"; import { generateLoanReceiptPdf } from "./pdf.ts";
import { fullName } from "./mx.ts"; import { fullName } from "./mx.ts";
import { import {
@ -19,38 +20,34 @@ import {
payWeek, payWeek,
patchDestajoCut, patchDestajoCut,
removeAdminLine, removeAdminLine,
resolveToday,
saveSettings, saveSettings,
setAttendance, setAttendance,
tenantKey, tenantKey,
todayIso,
updateAdminLine, updateAdminLine,
weekContaining, weekContaining,
weekCsv, weekCsv,
} from "./payroll.ts"; } 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 { function tid(c: { get: (k: "user") => AuthUser }): number {
return tenantKey(tenantScope(c.get("user"))); return tenantKey(tenantScope(c.get("user")));
} }
async function pdfBody( function pdfBody(c: Context, bytes: Uint8Array, filename: string) {
c: { header: (k: string, v: string) => void; body: (data: ArrayBuffer) => unknown },
bytes: Uint8Array,
filename: string,
) {
c.header("Content-Type", "application/pdf"); c.header("Content-Type", "application/pdf");
c.header("Content-Disposition", `attachment; filename="${filename}"`); c.header("Content-Disposition", `attachment; filename="${filename}"`);
return c.body(bytes.buffer as ArrayBuffer); return c.body(bytes.buffer as ArrayBuffer);
} }
export function registerPayrollRoutes(app: App) { 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 projectId = Number(c.req.query("project_id"));
const from = c.req.query("from") ?? ""; const from = c.req.query("from") ?? "";
const to = c.req.query("to") ?? ""; const to = c.req.query("to") ?? "";
const db = await getDb(); const db = c.get("db");
const rows = db.prepare( const rows = await db.prepare(
`SELECT a.*, w.first_name, w.last_name_p FROM attendance a `SELECT a.*, w.first_name, w.last_name_p FROM attendance a
JOIN workers w ON w.id=a.worker_id JOIN workers w ON w.id=a.worker_id
WHERE a.project_id=? AND a.work_date BETWEEN ? AND ? WHERE a.project_id=? AND a.work_date BETWEEN ? AND ?
@ -59,72 +56,72 @@ export function registerPayrollRoutes(app: App) {
return c.json({ attendance: rows }); 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<{ const body = await c.req.json<{
project_id: number; project_id: number;
worker_id: number; worker_id: number;
work_date: string; work_date: string;
present: boolean; present: boolean;
}>(); }>();
const db = await getDb(); const db = c.get("db");
const blocked = projectMustBe( const blocked = projectMustBe(
projectById(db, body.project_id), await projectById(db, body.project_id),
["activo", "pausado"], ["activo", "pausado"],
"No se registra asistencia en un proyecto concluido o cancelado", "No se registra asistencia en un proyecto concluido o cancelado",
); );
if (blocked) return c.json({ error: blocked.error }, blocked.status); 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) { if ("error" in result) {
return c.json({ error: result.error, other: result.other }, result.status === 409 ? 409 : 400); return c.json({ error: result.error, other: result.other }, result.status === 409 ? 409 : 400);
} }
return c.json({ ok: true }); return c.json({ ok: true });
}); });
app.get("/v1/payroll/settings", requireAuth, async (c) => { app.get("/v1/payroll/settings", ...requireCoreAuth, async (c) => {
const db = await getDb(); const db = c.get("db");
const tenantId = tid(c); 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) => { app.patch("/v1/payroll/settings", ...requireCoreAuth, async (c) => {
const db = await getDb(); const db = c.get("db");
const body = await c.req.json<{ const body = await c.req.json<{
loan_commission_enabled?: number | boolean; loan_commission_enabled?: number | boolean;
loan_commission_pct?: number; loan_commission_pct?: number;
loan_small_max?: 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 loan_commission_enabled: body.loan_commission_enabled === undefined
? undefined ? undefined
: body.loan_commission_enabled ? 1 : 0, : Boolean(body.loan_commission_enabled),
loan_commission_pct: body.loan_commission_pct, loan_commission_pct: body.loan_commission_pct,
loan_small_max: body.loan_small_max, loan_small_max: body.loan_small_max,
}); });
return c.json({ settings }); 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 body = await c.req.json<{ code: string; label?: string }>();
const db = await getDb(); const db = c.get("db");
try { 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); return c.json({ id }, 201);
} catch (e) { } catch (e) {
return c.json({ error: e instanceof Error ? e.message : "No se pudo guardar" }, 400); return c.json({ error: e instanceof Error ? e.message : "No se pudo guardar" }, 400);
} }
}); });
app.get("/v1/loans", requireAuth, async (c) => { app.get("/v1/loans", ...requireCoreAuth, async (c) => {
const db = await getDb(); const db = c.get("db");
const workerId = c.req.query("worker_id"); const workerId = c.req.query("worker_id");
const sql = workerId 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 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"; : "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 }); return c.json({ loans });
}); });
app.post("/v1/loans", requireAuth, async (c) => { app.post("/v1/loans", ...requireCoreAuth, async (c) => {
const body = await c.req.json<{ const body = await c.req.json<{
worker_id: number; worker_id: number;
amount?: number; amount?: number;
@ -136,9 +133,9 @@ export function registerPayrollRoutes(app: App) {
plan?: "single" | "installments"; plan?: "single" | "installments";
installments_n?: number; installments_n?: number;
}>(); }>();
const db = await getDb(); const db = c.get("db");
try { try {
const created = createLoan(db, tid(c), { const created = await createLoan(db, tid(c), {
worker_id: body.worker_id, worker_id: body.worker_id,
delivered: Number(body.delivered ?? body.amount ?? 0), delivered: Number(body.delivered ?? body.amount ?? 0),
commission_enabled: body.commission_enabled, 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 id = Number(c.req.param("id"));
const db = await getDb(); const db = c.get("db");
const loan = db.prepare( const loan = await db.prepare(
`SELECT l.*, w.first_name, w.middle_name, w.last_name_p, w.last_name_m `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=?`, FROM loans l JOIN workers w ON w.id=l.worker_id WHERE l.id=?`,
).get(id) as { ).get(id) as {
@ -202,10 +199,10 @@ export function registerPayrollRoutes(app: App) {
return pdfBody(c, bytes, `prestamo-aceptacion-${id}.pdf`); 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 id = Number(c.req.param("id"));
const db = await getDb(); const db = c.get("db");
const pay = db.prepare( 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 `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 FROM loan_payments p
JOIN loans l ON l.id=p.loan_id 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`); return pdfBody(c, bytes, `prestamo-pago-${id}.pdf`);
}); });
app.get("/v1/payroll/weeks", requireAuth, async (c) => { app.get("/v1/payroll/weeks", ...requireCoreAuth, async (c) => {
const db = await getDb(); const db = c.get("db");
const tenantId = tid(c); const tenantId = tid(c);
const start = c.req.query("week_start") || weekContaining(todayIso()).weekStart; const start = c.req.query("week_start") || weekContaining(await resolveToday(db, tenantId)).weekStart;
const bundle = getWeekBundle(db, tenantId, start); const bundle = await getWeekBundle(db, tenantId, start);
return c.json(bundle); return c.json(bundle);
}); });
app.get("/v1/payroll/weeks/open", requireAuth, async (c) => { app.get("/v1/payroll/weeks/open", ...requireCoreAuth, async (c) => {
const db = await getDb(); const db = c.get("db");
return c.json({ weeks: listOpenWeeks(db, tid(c)) }); return c.json({ weeks: await listOpenWeeks(db, tid(c)) });
}); });
app.post("/v1/payroll/weeks/:id/assemble", requireAuth, async (c) => { app.post("/v1/payroll/weeks/:id/assemble", ...requireCoreAuth, async (c) => {
const db = await getDb(); const db = c.get("db");
try { 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 }); return c.json({ ok: true });
} catch (e) { } catch (e) {
const err = e as Error & { status?: number }; 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) => { app.post("/v1/payroll/weeks/:id/pay", ...requireCoreAuth, async (c) => {
const db = await getDb(); const db = c.get("db");
try { 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 }); return c.json({ ok: true });
} catch (e) { } catch (e) {
const err = e as Error & { status?: number }; 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 id = Number(c.req.param("id"));
const db = await getDb(); const db = c.get("db");
const csv = weekCsv(db, id); const csv = await weekCsv(db, id);
c.header("Content-Type", "text/csv; charset=utf-8"); c.header("Content-Type", "text/csv; charset=utf-8");
c.header("Content-Disposition", `attachment; filename="nomina-${id}.csv"`); c.header("Content-Disposition", `attachment; filename="nomina-${id}.csv"`);
return c.body(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 body = await c.req.json<{ project_id: number; worker_id: number }>();
const db = await getDb(); const db = c.get("db");
try { 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 }); return c.json({ ok: true });
} catch (e) { } catch (e) {
const err = e as Error & { status?: number }; 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 body = await c.req.json<{ worker_id: number; amount: number; project_id?: number | null }>();
const db = await getDb(); const db = c.get("db");
try { try {
addAdminLine(db, Number(c.req.param("id")), body); await addAdminLine(db, Number(c.req.param("id")), body);
return c.json({ ok: true }); return c.json({ ok: true });
} catch (e) { } catch (e) {
const err = e as Error & { status?: number }; 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 body = await c.req.json<{ amount: number }>();
const db = await getDb(); const db = c.get("db");
try { 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 }); return c.json({ ok: true });
} catch (e) { } catch (e) {
const err = e as Error & { status?: number }; 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) => { app.delete("/v1/payroll/weeks/:id/admin/:lineId", ...requireCoreAuth, async (c) => {
const db = await getDb(); const db = c.get("db");
try { 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 }); return c.json({ ok: true });
} catch (e) { } catch (e) {
const err = e as Error & { status?: number }; 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<{ const body = await c.req.json<{
week_id: number; week_id: number;
project_id: number; project_id: number;
@ -341,9 +340,9 @@ export function registerPayrollRoutes(app: App) {
qty_planned?: number; qty_planned?: number;
qty_extra?: number; qty_extra?: number;
}>(); }>();
const db = await getDb(); const db = c.get("db");
try { 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); return c.json(created, 201);
} catch (e) { } catch (e) {
const err = e as Error & { status?: number }; 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<{ const body = await c.req.json<{
week_id: number; week_id: number;
qty_planned?: number; qty_planned?: number;
qty_actual?: number; qty_actual?: number;
qty_extra?: number; qty_extra?: number;
}>(); }>();
const db = await getDb(); const db = c.get("db");
try { 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 }); return c.json({ ok: true });
} catch (e) { } catch (e) {
const err = e as Error & { status?: number }; 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<{ const body = await c.req.json<{
project_id: number; project_id: number;
week_start: string; week_start: string;
week_end: string; week_end: string;
extra_discounts?: Record<number, number>; extra_discounts?: Record<number, number>;
}>(); }>();
const db = await getDb(); const db = c.get("db");
const blocked = projectMustBe( const blocked = projectMustBe(
projectById(db, body.project_id), await projectById(db, body.project_id),
["activo", "pausado"], ["activo", "pausado"],
"No se genera nómina de un proyecto concluido o cancelado", "No se genera nómina de un proyecto concluido o cancelado",
); );
if (blocked) return c.json({ error: blocked.error }, blocked.status); 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')", "INSERT INTO payroll_periods (project_id, week_start, week_end, status) VALUES (?, ?, ?, 'draft')",
).run(body.project_id, body.week_start, body.week_end); ).run(body.project_id, body.week_start, body.week_end);
const periodId = lastInsertId(db); const periodId = await lastInsertId(db);
const workers = db.prepare( const workers = await db.prepare(
`SELECT w.id, w.daily_wage FROM workers w `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'`, WHERE w.status='activo'`,
).all(body.project_id) as { id: number; daily_wage: number }[]; ).all(body.project_id) as { id: number; daily_wage: number }[];
const ins = db.prepare( const ins = db.prepare(
@ -396,47 +395,47 @@ export function registerPayrollRoutes(app: App) {
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
); );
for (const w of workers) { for (const w of workers) {
const att = db.prepare( const att = await db.prepare(
`SELECT COUNT(*) AS n FROM attendance `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 }; ).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 gross = days * w.daily_wage;
const extra = Number(body.extra_discounts?.[w.id] ?? 0); const extra = Number(body.extra_discounts?.[w.id] ?? 0);
const net = gross - extra; 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 }); return c.json({ id: periodId });
}); });
app.get("/v1/payroll/periods", requireAuth, async (c) => { app.get("/v1/payroll/periods", ...requireCoreAuth, async (c) => {
const db = await getDb(); const db = c.get("db");
const projectId = c.req.query("project_id"); const projectId = c.req.query("project_id");
const sql = projectId 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 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`; : `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 }); 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 id = Number(c.req.param("id"));
const db = await getDb(); const db = c.get("db");
const period = db.prepare( 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=?`, `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); ).get(id);
if (!period) return c.json({ error: "Periodo no encontrado" }, 404); 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 `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`, JOIN workers w ON w.id=l.worker_id WHERE l.period_id=? ORDER BY w.last_name_p`,
).all(id); ).all(id);
return c.json({ period, lines }); 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 id = Number(c.req.param("id"));
const db = await getDb(); const db = c.get("db");
const lines = db.prepare( 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 `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=?`, FROM payroll_lines l JOIN workers w ON w.id=l.worker_id WHERE l.period_id=?`,
).all(id) as Record<string, unknown>[]; ).all(id) as Record<string, unknown>[];
@ -450,11 +449,11 @@ export function registerPayrollRoutes(app: App) {
return c.body(csv); 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 id = Number(c.req.param("id"));
const { status } = await c.req.json<{ status: string }>(); const { status } = await c.req.json<{ status: string }>();
const db = await getDb(); const db = c.get("db");
db.prepare("UPDATE payroll_periods SET status=? WHERE id=?").run(status, id); await db.prepare("UPDATE payroll_periods SET status=? WHERE id=?").run(status, id);
return c.json({ ok: true }); return c.json({ ok: true });
}); });
} }

View file

@ -1,5 +1,6 @@
import { assertEquals, assertExists, assertThrows } from "jsr:@std/assert@1"; import { assertEquals, assertExists, assertRejects } from "jsr:@std/assert@1";
import { Database } from "@db/sqlite"; import type { PgDb } from "./pg.ts";
import { withTestDb } from "./test_helpers.ts";
import { import {
addDays, addDays,
assembleWeek, assembleWeek,
@ -22,92 +23,58 @@ import {
weekContaining, weekContaining,
} from "./payroll.ts"; } from "./payroll.ts";
function mem() { // tenant_id fijo y solo-de-tests: cada test corre en su propia transacción
const db = new Database(":memory:"); // que siempre se revierte (ver test_helpers.ts), así que reusar el mismo
db.exec("PRAGMA foreign_keys = ON"); // id entre tests nunca colisiona -- nada de esto llega a persistir.
db.exec(` const TENANT_ID = 999001;
CREATE TABLE companies (id INTEGER PRIMARY KEY, name TEXT, tenant_id INTEGER);
CREATE TABLE projects ( type Fixture = { companyId: number; projectId: number; workerId: number };
id INTEGER PRIMARY KEY, code TEXT, name TEXT, status TEXT DEFAULT 'activo', tenant_id INTEGER
); async function seedFixture(db: PgDb, opts: { workType?: "N" | "D"; dailyWage?: number } = {}): Promise<Fixture> {
CREATE TABLE workers ( await db.prepare(
id INTEGER PRIMARY KEY, `INSERT INTO companies (code, name, kind, tenant_id) VALUES ('TSTCO', 'Test Co', 'principal', ?)`,
first_name TEXT, last_name_p TEXT, position TEXT DEFAULT 'Ayudante', ).run(TENANT_ID);
work_type TEXT DEFAULT 'N', daily_wage REAL DEFAULT 0, const companyId = await db.lastInsertId();
status TEXT DEFAULT 'activo', pipeline_status TEXT DEFAULT 'activo', tenant_id INTEGER
); await db.prepare(
CREATE TABLE assignments ( `INSERT INTO projects (code, name, status, theme_id, company_id, tenant_id)
id INTEGER PRIMARY KEY, worker_id INTEGER, project_id INTEGER, active INTEGER DEFAULT 1 VALUES ('TST-0001', 'Obra Norte', 'activo', 'arctec-dos-logos-fold', ?, ?)`,
); ).run(companyId, TENANT_ID);
CREATE TABLE attendance ( const projectId = await db.lastInsertId();
id INTEGER PRIMARY KEY,
worker_id INTEGER, project_id INTEGER, work_date TEXT, present INTEGER DEFAULT 1, await db.prepare(
UNIQUE (worker_id, project_id, work_date) `INSERT INTO workers
); (first_name, last_name_p, last_name_m, curp, rfc, nss, phone, email, address,
CREATE TABLE loans ( hire_type, company_id, position, risk_code, work_type, daily_wage, tenant_id)
id INTEGER PRIMARY KEY, VALUES ('Juan', 'Perez', 'Lopez', 'CURPTEST0001', 'RFCTEST0001', 'NSSTEST0001', '9990000001',
worker_id INTEGER, amount REAL, delivered REAL DEFAULT 0, balance REAL, 'juan@example.com', 'Sin domicilio', 'TSTCO', ?, 'Ayudante', 'rojo', ?, ?, ?)`,
weekly_payment REAL DEFAULT 0, note TEXT, ).run(companyId, opts.workType ?? "N", opts.dailyWage ?? 500, TENANT_ID);
commission_pct REAL DEFAULT 0, commission_amount REAL DEFAULT 0, const workerId = await db.lastInsertId();
plan TEXT DEFAULT 'single', installments_n INTEGER DEFAULT 1, first_due TEXT,
created_at TEXT DEFAULT (datetime('now')) await db.prepare(
); `INSERT INTO assignments (worker_id, project_id, active, start_date) VALUES (?, ?, true, current_date)`,
CREATE TABLE payroll_settings ( ).run(workerId, projectId);
tenant_id INTEGER PRIMARY KEY,
loan_commission_enabled INTEGER DEFAULT 1, return { companyId, projectId, workerId };
loan_commission_pct REAL DEFAULT 10, }
loan_small_max REAL DEFAULT 500
); async function addSecondProject(db: PgDb, companyId: number): Promise<number> {
CREATE TABLE payroll_weeks ( await db.prepare(
id INTEGER PRIMARY KEY, `INSERT INTO projects (code, name, status, theme_id, company_id, tenant_id)
week_start TEXT, week_end TEXT, status TEXT DEFAULT 'draft', VALUES ('TST-0002', 'Obra Sur', 'activo', 'arctec-dos-logos-fold', ?, ?)`,
required_net REAL DEFAULT 0, payable_net REAL DEFAULT 0, ).run(companyId, TENANT_ID);
assembled_at TEXT, paid_at TEXT, tenant_id INTEGER DEFAULT 0, return await db.lastInsertId();
created_at TEXT DEFAULT (datetime('now')) }
);
CREATE UNIQUE INDEX idx_weeks ON payroll_weeks(tenant_id, week_start); async function addSecondWorker(db: PgDb, companyId: number): Promise<number> {
CREATE TABLE payroll_sheets ( await db.prepare(
id INTEGER PRIMARY KEY, week_id INTEGER, kind TEXT, project_id INTEGER `INSERT INTO workers
); (first_name, last_name_p, last_name_m, curp, rfc, nss, phone, email, address,
CREATE UNIQUE INDEX idx_sheets ON payroll_sheets(week_id, kind, IFNULL(project_id, 0)); hire_type, company_id, position, risk_code, work_type, daily_wage, tenant_id)
CREATE TABLE payroll_week_lines ( VALUES ('Luis', 'Destajo', 'Lopez', 'CURPTEST0002', 'RFCTEST0002', 'NSSTEST0002', '9990000002',
id INTEGER PRIMARY KEY, 'luis@example.com', 'Sin domicilio', 'TSTCO', ?, 'Ayudante', 'rojo', 'D', 0, ?)`,
sheet_id INTEGER, worker_id INTEGER, destajo_cut_line_id INTEGER, project_id INTEGER, ).run(companyId, TENANT_ID);
days REAL DEFAULT 0, daily_wage REAL DEFAULT 0, return await db.lastInsertId();
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;
} }
Deno.test("semana lun-sáb y corte destajo jue-jue", () => { 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"); 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(weekAssembleDate("2026-08-24"), "2026-08-27");
assertEquals(canAssembleWeek("2026-08-24", "2026-08-26"), false); 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-27"), true);
assertEquals(canAssembleWeek("2026-08-24", "2026-08-29"), true); assertEquals(canAssembleWeek("2026-08-24", "2026-08-29"), true);
assertEquals(canAssembleWeek("2026-08-24", "2026-08-31"), true); assertEquals(canAssembleWeek("2026-08-24", "2026-08-31"), true);
assertEquals(canAssembleWeek("2026-08-31", "2026-08-26"), false); assertEquals(canAssembleWeek("2026-08-31", "2026-08-26"), false);
const db = mem();
const weekId = ensureWeek(db, 1, "2026-08-24"); await withTestDb(async (db) => {
assertThrows( await seedFixture(db);
() => assembleWeek(db, weekId, "2026-08-26"), const weekId = await ensureWeek(db, TENANT_ID, "2026-08-24");
Error, await assertRejects(
"jueves", () => assembleWeek(db, weekId, "2026-08-26"),
); Error,
const draft = db.prepare("SELECT status FROM payroll_weeks WHERE id=?").get(weekId) as { status: string }; "jueves",
assertEquals(draft.status, "draft"); );
assembleWeek(db, weekId, "2026-08-28"); const draft = await db.prepare("SELECT status FROM payroll_weeks WHERE id=?").get(weekId) as { status: string };
const after = db.prepare("SELECT status FROM payroll_weeks WHERE id=?").get(weekId) as { status: string }; assertEquals(draft.status, "draft");
assertEquals(after.status, "assembled"); 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", () => { 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", () => { Deno.test("armar jueves prellena jue-vie-sáb y congela requerida", async () => {
const db = mem(); await withTestDb(async (db) => {
db.prepare("INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (1, 1, '2026-08-24', 1)").run(); const { projectId, workerId } = await seedFixture(db);
db.prepare("INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (1, 1, '2026-08-25', 1)").run(); await db.prepare(
const weekId = ensureWeek(db, 1, "2026-08-24"); "INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (?, ?, '2026-08-24', true)",
assembleWeek(db, weekId, "2026-08-27"); ).run(workerId, projectId);
const week = db.prepare("SELECT * FROM payroll_weeks WHERE id=?").get(weekId) as { await db.prepare(
status: string; "INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (?, ?, '2026-08-25', true)",
required_net: number; ).run(workerId, projectId);
payable_net: number; const weekId = await ensureWeek(db, TENANT_ID, "2026-08-24");
}; await assembleWeek(db, weekId, "2026-08-27");
assertEquals(week.status, "assembled"); const week = await db.prepare("SELECT * FROM payroll_weeks WHERE id=?").get(weekId) as {
assertEquals(week.required_net, 2500); status: string;
assertEquals(week.payable_net, 2500); required_net: number;
const thu = db.prepare( payable_net: number;
"SELECT present FROM attendance WHERE worker_id=1 AND work_date='2026-08-27'", };
).get() as { present: number }; assertEquals(week.status, "assembled");
assertEquals(thu.present, 1); assertEquals(week.required_net, 2500);
}); assertEquals(week.payable_net, 2500);
const thu = await db.prepare(
Deno.test("falta viernes baja a pagar y no toca requerida", () => { "SELECT present FROM attendance WHERE worker_id=? AND work_date='2026-08-27'",
const db = mem(); ).get(workerId) as { present: boolean };
db.prepare("INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (1, 1, '2026-08-24', 1)").run(); assertEquals(thu.present, true);
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 }); Deno.test("falta viernes baja a pagar y no toca requerida", async () => {
assertEquals("ok" in r && r.ok, true); await withTestDb(async (db) => {
const week = db.prepare("SELECT required_net, payable_net FROM payroll_weeks WHERE id=?").get(weekId) as { const { projectId, workerId } = await seedFixture(db);
required_net: number; await db.prepare(
payable_net: number; "INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (?, ?, '2026-08-24', true)",
}; ).run(workerId, projectId);
assertEquals(week.required_net, 2500); await db.prepare(
assertEquals(week.payable_net, 2000); "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");
Deno.test("préstamo no baja saldo al armar; sí al pagar", () => { await assembleWeek(db, weekId, "2026-08-27");
const db = mem(); const r = await setAttendance(db, TENANT_ID, {
db.prepare("INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (1, 1, '2026-08-24', 1)").run(); project_id: projectId,
db.prepare("INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (1, 1, '2026-08-25', 1)").run(); worker_id: workerId,
db.prepare("INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (1, 1, '2026-08-26', 1)").run(); work_date: "2026-08-28",
createLoan(db, 1, { worker_id: 1, delivered: 2000, commission_pct: 10, grantIso: "2026-08-24" }); present: false,
const weekId = ensureWeek(db, 1, "2026-08-24"); });
assembleWeek(db, weekId, "2026-08-27"); assertEquals("ok" in r && r.ok, true);
const before = db.prepare("SELECT balance FROM loans WHERE worker_id=1").get() as { balance: number }; const week = await db.prepare("SELECT required_net, payable_net FROM payroll_weeks WHERE id=?").get(weekId) as {
assertEquals(before.balance, 2200); required_net: number;
const week = db.prepare("SELECT required_net FROM payroll_weeks WHERE id=?").get(weekId) as { required_net: number }; payable_net: number;
assertEquals(week.required_net, 800); };
payWeek(db, weekId); assertEquals(week.required_net, 2500);
const after = db.prepare("SELECT balance FROM loans WHERE worker_id=1").get() as { balance: number }; assertEquals(week.payable_net, 2000);
assertEquals(after.balance, 0); });
}); });
Deno.test("tablero: recuperación de préstamos no es sobrante", () => { Deno.test("préstamo no baja saldo al armar; sí al pagar", async () => {
const db = mem(); await withTestDb(async (db) => {
db.prepare("INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (1, 1, '2026-08-24', 1)").run(); const { projectId, workerId } = await seedFixture(db);
db.prepare("INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (1, 1, '2026-08-25', 1)").run(); for (const day of ["2026-08-24", "2026-08-25", "2026-08-26"]) {
const weekId = ensureWeek(db, 1, "2026-08-24"); await db.prepare(
assembleWeek(db, weekId, "2026-08-27"); "INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (?, ?, ?, true)",
createLoan(db, 1, { worker_id: 1, delivered: 1000, commission_pct: 20, grantIso: "2026-08-24" }); ).run(workerId, projectId, day);
const bundle = getWeekBundle(db, 1, "2026-08-24"); }
const board = bundle.board as { await createLoan(db, TENANT_ID, { worker_id: workerId, delivered: 2000, commission_pct: 10, grantIso: "2026-08-24" });
required_net: number; const weekId = await ensureWeek(db, TENANT_ID, "2026-08-24");
payable_net: number; await assembleWeek(db, weekId, "2026-08-27");
loan_recovery: number; const before = await db.prepare("SELECT balance FROM loans WHERE worker_id=?").get(workerId) as { balance: number };
faltante: 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(board.required_net, 2500); assertEquals(week.required_net, 800);
assertEquals(board.loan_recovery, 1200); await payWeek(db, weekId, "2026-08-29");
assertEquals(board.payable_net, 1300); const after = await db.prepare("SELECT balance FROM loans WHERE worker_id=?").get(workerId) as { balance: number };
assertEquals(board.faltante, 0); assertEquals(after.balance, 0);
}); });
});
Deno.test("un día no se marca en dos obras", () => {
const db = mem(); Deno.test("tablero: recuperación de préstamos no es sobrante", async () => {
db.prepare("INSERT INTO projects (id, code, name, status, tenant_id) VALUES (2, 'PRY-0002', 'Obra Sur', 'activo', 1)").run(); await withTestDb(async (db) => {
setAttendance(db, 1, { project_id: 1, worker_id: 1, work_date: "2026-08-24", present: true }); const { projectId, workerId } = await seedFixture(db);
const blocked = setAttendance(db, 1, { project_id: 2, worker_id: 1, work_date: "2026-08-24", present: true }); await db.prepare(
assertEquals("error" in blocked, true); "INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (?, ?, '2026-08-24', true)",
}); ).run(workerId, projectId);
await db.prepare(
Deno.test("préstamo de personal: el día en otra obra no se prellena aquí", () => { "INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (?, ?, '2026-08-25', true)",
const db = mem(); ).run(workerId, projectId);
db.prepare("INSERT INTO projects (id, code, name, status, tenant_id) VALUES (2, 'PRY-0002', 'Obra Sur', 'activo', 1)").run(); const weekId = await ensureWeek(db, TENANT_ID, "2026-08-24");
setAttendance(db, 1, { project_id: 2, worker_id: 1, work_date: "2026-08-27", present: true }); await assembleWeek(db, weekId, "2026-08-27");
const weekId = ensureWeek(db, 1, "2026-08-24"); await createLoan(db, TENANT_ID, { worker_id: workerId, delivered: 1000, commission_pct: 20, grantIso: "2026-08-24" });
assembleWeek(db, weekId, "2026-08-27"); const bundle = await getWeekBundle(db, TENANT_ID, "2026-08-24");
const onA = db.prepare( const board = bundle.board as {
"SELECT present FROM attendance WHERE worker_id=1 AND project_id=1 AND work_date='2026-08-27'", required_net: number;
).get() as { present: number } | undefined; payable_net: number;
assertEquals(onA, undefined); loan_recovery: number;
const onB = db.prepare( faltante: number;
"SELECT present FROM attendance WHERE worker_id=1 AND project_id=2 AND work_date='2026-08-27'", };
).get() as { present: number }; assertEquals(board.required_net, 2500);
assertEquals(onB.present, 1); assertEquals(board.loan_recovery, 1200);
}); assertEquals(board.payable_net, 1300);
assertEquals(board.faltante, 0);
});
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(); Deno.test("un día no se marca en dos obras", async () => {
db.prepare( await withTestDb(async (db) => {
"INSERT INTO workers (id, first_name, last_name_p, work_type, daily_wage, tenant_id) VALUES (2, 'Luis', 'Destajo', 'D', 0, 1)", const { companyId, projectId, workerId } = await seedFixture(db);
).run(); const project2Id = await addSecondProject(db, companyId);
const weekId = ensureWeek(db, 1, "2026-08-24"); await setAttendance(db, TENANT_ID, { project_id: projectId, worker_id: workerId, work_date: "2026-08-24", present: true });
const job = createDestajoJob(db, 1, weekId, { const blocked = await setAttendance(db, TENANT_ID, {
project_id: 1, project_id: project2Id,
worker_id: 2, worker_id: workerId,
concepto: "Muro tablaroca", work_date: "2026-08-24",
unit_code: "m2", present: true,
qty_total_estimated: 200, });
unit_price: 100, assertEquals("error" in blocked, true);
qty_planned: 80, });
});
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);
}); });

View file

@ -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 QRCode from "qrcode";
import { PDFDocument, StandardFonts, rgb, degrees, type PDFPage, type PDFFont, type PDFImage } from "pdf-lib"; 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 { decryptBytes } from "./docs_crypto.ts";
import { fullName, frontName } from "./mx.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 CM = 28.346456692913385;
const CARD_W = 6.7 * CM; const CARD_W = 6.7 * CM;
@ -53,15 +50,15 @@ function hexRgb(hex: string) {
); );
} }
export async function loadCurrentPhoto(db: Database, workerId: number): Promise<Uint8Array | null> { export async function loadCurrentPhoto(db: Db, workerId: number): Promise<Uint8Array | null> {
const doc = db.prepare( const doc = await db.prepare(
`SELECT storage_name, iv FROM documents `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`, ORDER BY id DESC LIMIT 1`,
).get(workerId) as { storage_name: string; iv: string } | undefined; ).get(workerId) as { storage_name: string; iv: string } | undefined;
if (!doc) return null; if (!doc) return null;
try { 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); return await decryptBytes(doc.iv, enc);
} catch { } catch {
return null; return null;
@ -99,11 +96,11 @@ function drawCentered(
} }
export async function generateBadgePdf( export async function generateBadgePdf(
db: Database, db: Db,
projectId: number, projectId: number,
workerIds: number[], workerIds: number[],
): Promise<Uint8Array> { ): Promise<Uint8Array> {
const project = db.prepare( const project = await db.prepare(
"SELECT name, code, theme_id, logo_left_path, logo_right_path FROM projects WHERE id = ?", "SELECT name, code, theme_id, logo_left_path, logo_right_path FROM projects WHERE id = ?",
).get(projectId) as { ).get(projectId) as {
name: string; name: string;
@ -114,15 +111,14 @@ export async function generateBadgePdf(
} | undefined; } | undefined;
if (!project) throw new Error("Proyecto no encontrado"); if (!project) throw new Error("Proyecto no encontrado");
const placeholders = workerIds.map(() => "?").join(","); const workers = await db.prepare(
const workers = db.prepare(
`SELECT w.id, w.first_name, w.middle_name, w.last_name_p, w.last_name_m, `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, w.curp, w.nss, w.blood_type, w.position,
r.color AS risk_color, r.text_color AS risk_text r.color AS risk_color, r.text_color AS risk_text
FROM workers w FROM workers w
JOIN risk_levels r ON r.code = w.risk_code JOIN risk_levels r ON r.code = w.risk_code
WHERE w.id IN (${placeholders})`, WHERE w.id = ANY(?)`,
).all(...workerIds) as WorkerRow[]; ).all(workerIds) as WorkerRow[];
const byId = new Map(workers.map((w) => [w.id, w])); const byId = new Map(workers.map((w) => [w.id, w]));
const ordered = workerIds.map((id) => byId.get(id)).filter(Boolean) as WorkerRow[]; 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; let logoR: PDFImage | null = null;
if (project.logo_left_path) { if (project.logo_left_path) {
try { 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 */ } } catch { /* text fallback */ }
} }
if (project.logo_right_path) { if (project.logo_right_path) {
try { 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 */ } } catch { /* text fallback */ }
} }
@ -238,7 +234,6 @@ function drawFront(
page.drawImage(photo, { x: px + 1, y: py + 1, width: photoW - 2, height: photoH - 2 }); 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.first_name.toUpperCase(), x, py - 22, CARD_W, 13);
drawCentered(page, fontBold, w.last_name_p.toUpperCase(), x, py - 38, 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, projectName: string,
projectCode: string, projectCode: string,
) { ) {
const cx = x + CARD_W / 2;
const cy = y + CARD_H / 2;
page.drawRectangle({ page.drawRectangle({
x, x,
y, y,
@ -284,11 +277,7 @@ function drawBack(
borderColor: rgb(0, 0, 0), borderColor: rgb(0, 0, 0),
borderWidth: 1.5, borderWidth: 1.5,
rotate: degrees(180), 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<typeof degrees> };
page.drawRectangle({ page.drawRectangle({
x: x + CARD_W, x: x + CARD_W,
y: y + CARD_H, y: y + CARD_H,
@ -343,10 +332,9 @@ function drawBack(
} }
export async function saveJobPdf(bytes: Uint8Array, jobId: number): Promise<string> { export async function saveJobPdf(bytes: Uint8Array, jobId: number): Promise<string> {
await mkdir(PDFS_DIR, { recursive: true }); const key = badgeJobPdfKey(jobId);
const path = join(PDFS_DIR, `gafetes-${jobId}.pdf`); await putObject(key, bytes);
await Deno.writeFile(path, bytes); return key;
return path;
} }
function wrap(text: string, font: PDFFont, size: number, max: number): string[] { function wrap(text: string, font: PDFFont, size: number, max: number): string[] {

138
api/pg.ts Normal file
View file

@ -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<string, unknown>;
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<PgRow | undefined> {
const rows = await this.sql.unsafe(toPositional(this.text), params as never[]);
return rows[0] as PgRow | undefined;
}
async all(...params: unknown[]): Promise<PgRow[]> {
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<void> {
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<number> {
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<Record<string, never>> = {}) {
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<number,...>,
// 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<T>(
sql: postgres.Sql,
tenantId: number | null,
fn: (scoped: PgDb) => Promise<T>,
): Promise<T> {
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;

View file

@ -1,13 +1,21 @@
import { Database } from "@db/sqlite"; import postgres, { createPool, PgDb } from "./pg.ts";
import { mkdir } from "node:fs/promises";
import { DATA_DIR, PLATFORM_DB_PATH } from "./config.ts";
import { config } from "./config.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 = { export type PlatformUser = {
id: number; id: number;
@ -16,70 +24,25 @@ export type PlatformUser = {
status: string; status: string;
}; };
export async function getPlatformDb(): Promise<Database> { export async function getPlatformDb(): Promise<PlatformDb> {
if (platformDb) return platformDb; if (!pool) {
await mkdir(DATA_DIR, { recursive: true }); pool = createPool(config.databaseUrlPlatform, { max: 10 });
await runLiquibase(); await pool`SELECT 1`;
platformDb = new Database(PLATFORM_DB_PATH); }
platformDb.exec("PRAGMA foreign_keys = ON;"); return new PgDb(pool);
await seedPlatform(platformDb);
return platformDb;
} }
async function seedPlatform(database: Database) { export async function closePlatformDb(): Promise<void> {
if (!config.seedPassword) { await pool?.end({ timeout: 5 });
console.warn("[platform] SEED_PASSWORD vacío; no se siembra usuario admin"); pool = null;
} 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 function listTenants(database: Database) { export async function pingPlatformDb(): Promise<void> {
return database.prepare( 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, `SELECT id, code, name, status, created_at,
COALESCE(plan, 'trial') AS plan, valid_until, COALESCE(notes, '') AS notes, COALESCE(plan, 'trial') AS plan, valid_until, COALESCE(notes, '') AS notes,
COALESCE(contact_email, '') AS contact_email, COALESCE(contact_email, '') AS contact_email,
@ -101,8 +64,10 @@ export function listTenants(database: Database) {
}[]; }[];
} }
export function tenantByCode(database: Database, code: string) { export async function tenantByCode(database: PlatformDb, code: string) {
return database.prepare("SELECT * FROM tenants WHERE code = ?").get(code.trim().toUpperCase()) as return await database.prepare("SELECT * FROM tenants WHERE code = ?").get(
code.trim().toUpperCase(),
) as
| { | {
id: number; id: number;
code: string; code: string;
@ -115,8 +80,8 @@ export function tenantByCode(database: Database, code: string) {
| undefined; | undefined;
} }
export function tenantById(database: Database, id: number) { export async function tenantById(database: PlatformDb, id: number) {
return database.prepare("SELECT * FROM tenants WHERE id = ?").get(id) as return await database.prepare("SELECT * FROM tenants WHERE id = ?").get(id) as
| { | {
id: number; id: number;
code: string; code: string;
@ -130,6 +95,6 @@ export function tenantById(database: Database, id: number) {
| undefined; | undefined;
} }
export function lastInsertId(database: Database): number { export async function lastInsertId(database: PlatformDb): Promise<number> {
return Number((database.prepare("SELECT last_insert_rowid() AS id").get() as { id: number }).id); return await database.lastInsertId();
} }

51
api/redis.ts Normal file
View file

@ -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<RedisClientType> {
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<RedisClientType> {
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<void> {
await iamClient?.quit().catch(() => {});
await coreClient?.quit().catch(() => {});
iamClient = null;
coreClient = null;
}

View file

@ -1,6 +1,6 @@
import type { Database } from "@db/sqlite";
import { generateSecurePassword, hashPassword } from "./crypto.ts"; 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 { import {
lastInsertId as platformLastId, lastInsertId as platformLastId,
listTenants as listTenantsRaw, listTenants as listTenantsRaw,
@ -107,29 +107,29 @@ export function codeDateStamp(when: Date = new Date()): string {
return `${yy}${mm}`; return `${yy}${mm}`;
} }
function codeExists(platformDb: PlatformDb, appDb: Database, code: string): boolean { async function codeExists(platformDb: PlatformDb, coreDb: Db, code: string): Promise<boolean> {
return !!tenantByCode(platformDb, code) || !!companyByCode(appDb, code); return !!await tenantByCode(platformDb, code) || !!await companyByCode(coreDb, code);
} }
/** /**
* Código único: prefijo del nombre comercial + YYMM de creación. * Código único: prefijo del nombre comercial + YYMM de creación.
* Ej. «ARCTEC» en ago-2026 → ARCT2608. Si choca, sufijo numérico. * Ej. «ARCTEC» en ago-2026 → ARCT2608. Si choca, sufijo numérico.
*/ */
export function allocateUniqueTenantCode( export async function allocateUniqueTenantCode(
platformDb: PlatformDb, platformDb: PlatformDb,
appDb: Database, coreDb: Db,
nombreComercial: string, nombreComercial: string,
createdAt: Date = new Date(), createdAt: Date = new Date(),
): string { ): Promise<string> {
const base = shortCodeFromCommercialName(nombreComercial); const base = shortCodeFromCommercialName(nombreComercial);
const stamp = codeDateStamp(createdAt); const stamp = codeDateStamp(createdAt);
const primary = `${base}${stamp}`; 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++) { for (let n = 2; n < 1000; n++) {
const suffix = String(n); const suffix = String(n);
const head = base.slice(0, Math.max(2, 4 - suffix.length)); const head = base.slice(0, Math.max(2, 4 - suffix.length));
const candidate = `${head}${stamp}${suffix}`; 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)}`; return `E${stamp}${Date.now().toString(36).toUpperCase().slice(-4)}`;
} }
@ -178,16 +178,16 @@ export function requireSaasCompanyFields(
return null; return null;
} }
export function listTenants(platformDb: PlatformDb) { export async function listTenants(platformDb: PlatformDb) {
return listTenantsRaw(platformDb); return await listTenantsRaw(platformDb);
} }
export function getTenantDetail( export async function getTenantDetail(
platformDb: PlatformDb, platformDb: PlatformDb,
appDb: Database, coreDb: Db,
tenantId: number, tenantId: number,
): TenantDetail | null { ): Promise<TenantDetail | null> {
const t = platformDb.prepare( const t = await platformDb.prepare(
`SELECT id, code, name, status, created_at, `SELECT id, code, name, status, created_at,
COALESCE(plan, 'trial') AS plan, valid_until, COALESCE(notes, '') AS notes, COALESCE(plan, 'trial') AS plan, valid_until, COALESCE(notes, '') AS notes,
COALESCE(contact_email, '') AS contact_email, COALESCE(contact_email, '') AS contact_email,
@ -211,7 +211,11 @@ export function getTenantDetail(
| undefined; | undefined;
if (!t) return null; 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, `SELECT id, code, name,
COALESCE(rfc, '') AS rfc, COALESCE(rfc, '') AS rfc,
COALESCE(razon_social, '') AS razon_social, 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`, FROM companies WHERE tenant_id = ? AND kind = 'principal' ORDER BY id LIMIT 1`,
).get(tenantId) as CompanySnapshot | undefined; ).get(tenantId) as CompanySnapshot | undefined;
const admins = (appDb.prepare( const admins = await withIamOwner(async (iam) => {
`SELECT id, username, display_name, COALESCE(email, '') AS email, role, const rows = await iam.prepare(
COALESCE(must_change_password, 0) AS must_change_password, created_at `SELECT id, username, display_name, COALESCE(email, '') AS email, role_code AS role,
FROM users WHERE tenant_id = ? AND role = 'tenant_admin' COALESCE(must_change_password, false) AS must_change_password, created_at
ORDER BY id`, FROM users WHERE tenant_id = ? AND role_code = 'tenant_admin'
).all(tenantId) as Array<Record<string, unknown>>).map((a) => ({ ORDER BY id`,
id: Number(a.id), ).all(tenantId) as Array<Record<string, unknown>>;
username: String(a.username), return rows.map((a) => ({
display_name: String(a.display_name), id: Number(a.id),
email: String(a.email || ""), username: String(a.username),
role: String(a.role), display_name: String(a.display_name),
must_change_password: Boolean(a.must_change_password), email: String(a.email || ""),
created_at: String(a.created_at), role: String(a.role),
})); must_change_password: Boolean(a.must_change_password),
created_at: String(a.created_at),
}));
});
return { return {
...t, ...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( export async function createTenant(
platformDb: PlatformDb, platformDb: PlatformDb,
appDb: Database, coreDb: Db,
input: CreateTenantInput, input: CreateTenantInput,
): Promise<{ ): Promise<{
tenant?: { id: number; code: string; name: string; status: string }; tenant?: { id: number; code: string; name: string; status: string };
@ -271,7 +288,7 @@ export async function createTenant(
}); });
if (completeErr) return { error: completeErr }; 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 displayName = profile.nombre_comercial;
const adminUser = adminUsernameForCode(code); const adminUser = adminUsernameForCode(code);
@ -279,7 +296,7 @@ export async function createTenant(
const contactEmail = profile.email; const contactEmail = profile.email;
const adminName = trim(input.admin_display_name) || contactName; 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}` }; if (userClash) return { error: `Ya existe el usuario ${adminUser}` };
const plan = trim(input.plan) || "trial"; const plan = trim(input.plan) || "trial";
@ -288,53 +305,65 @@ export async function createTenant(
const notes = trim(input.notes); const notes = trim(input.notes);
const temporaryPassword = generateSecurePassword(); 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) `INSERT INTO tenants (code, name, status, plan, valid_until, notes, contact_email, contact_name)
VALUES (?, ?, 'activo', ?, ?, ?, ?, ?)`, VALUES (?, ?, 'activo', ?, ?, ?, ?, ?)`,
).run(code, displayName, plan, validUntil, notes, contactEmail, contactName); ).run(code, displayName, plan, validUntil, notes, contactEmail, contactName);
const tenantId = platformLastId(platformDb); const tenantId = await platformLastId(platformDb);
let companyId: number | undefined;
try { try {
appDb.prepare( // Paso 2: empresa principal en core (rol owner: alta administrativa
`INSERT INTO companies ( // cruzando tenants, no una operación de un usuario ya autenticado).
code, name, parent_id, kind, status, tenant_id, companyId = await getCoreDb().then((core) =>
razon_social, nombre_comercial, rfc, regimen_fiscal, registro_patronal, clase_riesgo, core.prepare(
domicilio_fiscal, codigo_postal, ciudad, estado, telefono, email, representante_legal, giro `INSERT INTO companies (
) VALUES (?, ?, NULL, 'principal', 'activo', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, code, name, parent_id, kind, status, tenant_id,
).run( razon_social, nombre_comercial, rfc, regimen_fiscal, registro_patronal, clase_riesgo,
code, domicilio_fiscal, codigo_postal, ciudad, estado, telefono, email, representante_legal, giro
displayName, ) VALUES (?, ?, NULL, 'principal', 'activo', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
tenantId, ).run(
profile.razon_social, code,
profile.nombre_comercial, displayName,
profile.rfc, tenantId,
profile.regimen_fiscal, profile.razon_social,
profile.registro_patronal, profile.nombre_comercial,
profile.clase_riesgo, profile.rfc,
profile.domicilio_fiscal, profile.regimen_fiscal,
profile.codigo_postal, profile.registro_patronal,
profile.ciudad, profile.clase_riesgo,
profile.estado, profile.domicilio_fiscal,
profile.telefono, profile.codigo_postal,
contactEmail, profile.ciudad,
profile.representante_legal, profile.estado,
profile.giro, 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); const hash = await hashPassword(temporaryPassword);
appDb.prepare( await withIamOwner((iam) =>
`INSERT INTO users ( iam.prepare(
username, password_hash, display_name, company_id, tenant_id, role, `INSERT INTO users (
must_change_password, email username, password_hash, display_name, company_id, tenant_id, role_code,
) VALUES (?, ?, ?, ?, ?, 'tenant_admin', 1, ?)`, must_change_password, email
).run(adminUser, hash, adminName, companyId, tenantId, contactEmail); ) VALUES (?, ?, ?, ?, ?, 'tenant_admin', true, ?)`,
).run(adminUser, hash, adminName, companyId, tenantId, contactEmail)
);
} catch (e) { } 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" }; 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 = ?", "SELECT id, code, name, status FROM tenants WHERE id = ?",
).get(tenantId) as { id: number; code: string; name: string; status: string }; ).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, platformDb: PlatformDb,
appDb: Database, coreDb: Db,
tenantId: number, tenantId: number,
input: UpdateTenantInput, input: UpdateTenantInput,
): { error?: string } { ): Promise<{ error?: string }> {
const current = tenantById(platformDb, tenantId); const current = await tenantById(platformDb, tenantId);
if (!current) return { error: "Empresa no encontrada" }; 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`, `SELECT * FROM companies WHERE tenant_id = ? AND kind = 'principal' ORDER BY id LIMIT 1`,
).get(tenantId) as Record<string, unknown> | undefined; ).get(tenantId) as Record<string, unknown> | undefined;
@ -398,7 +427,7 @@ export function updateTenant(
}; };
const profile = normalizeCompanyProfile(merged, current.name); 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, `SELECT COALESCE(plan, 'trial') AS plan, valid_until, COALESCE(notes, '') AS notes,
COALESCE(contact_email, '') AS contact_email, COALESCE(contact_name, '') AS contact_name COALESCE(contact_email, '') AS contact_email, COALESCE(contact_name, '') AS contact_name
FROM tenants WHERE id = ?`, FROM tenants WHERE id = ?`,
@ -450,13 +479,13 @@ export function updateTenant(
const displayName = profile.nombre_comercial || profile.razon_social || current.name; const displayName = profile.nombre_comercial || profile.razon_social || current.name;
platformDb.prepare( await platformDb.prepare(
`UPDATE tenants SET name = ?, status = ?, plan = ?, valid_until = ?, notes = ?, `UPDATE tenants SET name = ?, status = ?, plan = ?, valid_until = ?, notes = ?,
contact_email = ?, contact_name = ? WHERE id = ?`, contact_email = ?, contact_name = ? WHERE id = ?`,
).run(displayName, status, plan, validUntil, notes, contactEmail, contactName, tenantId); ).run(displayName, status, plan, validUntil, notes, contactEmail, contactName, tenantId);
if (company?.id) { if (company?.id) {
appDb.prepare( await coreDb.prepare(
`UPDATE companies SET `UPDATE companies SET
name = ?, razon_social = ?, nombre_comercial = ?, rfc = ?, regimen_fiscal = ?, name = ?, razon_social = ?, nombre_comercial = ?, rfc = ?, regimen_fiscal = ?,
registro_patronal = ?, clase_riesgo = ?, domicilio_fiscal = ?, codigo_postal = ?, 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. */ /** Regenera contraseña del admin, marca must_change_password y opcionalmente envía correo. */
export async function issueTenantAdminAccess( export async function issueTenantAdminAccess(
platformDb: PlatformDb, platformDb: PlatformDb,
appDb: Database, coreDb: Db,
tenantId: number, tenantId: number,
opts: { userId?: number; send_email?: boolean } = {}, opts: { userId?: number; send_email?: boolean } = {},
): Promise<{ ): Promise<{
@ -496,7 +525,7 @@ export async function issueTenantAdminAccess(
email?: { sent: boolean; error?: string }; email?: { sent: boolean; error?: string };
error?: string; error?: string;
}> { }> {
const detail = getTenantDetail(platformDb, appDb, tenantId); const detail = await getTenantDetail(platformDb, coreDb, tenantId);
if (!detail) return { error: "Empresa no encontrada" }; if (!detail) return { error: "Empresa no encontrada" };
const admin = opts.userId const admin = opts.userId
? detail.admins.find((a) => a.id === opts.userId) ? detail.admins.find((a) => a.id === opts.userId)
@ -505,9 +534,11 @@ export async function issueTenantAdminAccess(
const temporaryPassword = generateSecurePassword(); const temporaryPassword = generateSecurePassword();
const hash = await hashPassword(temporaryPassword); const hash = await hashPassword(temporaryPassword);
appDb.prepare( await withIamOwner((iam) =>
`UPDATE users SET password_hash = ?, must_change_password = 1 WHERE id = ? AND tenant_id = ?`, iam.prepare(
).run(hash, admin.id, tenantId); `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; const contactEmail = detail.contact_email || admin.email;
let emailResult: { sent: boolean; error?: string } = { sent: false }; let emailResult: { sent: boolean; error?: string } = { sent: false };
@ -565,16 +596,16 @@ async function sendAccessEmail(
attachments: accessEmailAttachments(), attachments: accessEmailAttachments(),
}); });
if (result.sent) { if (result.sent) {
platformDb.prepare( await platformDb.prepare(
`UPDATE tenants SET access_sent_at = datetime('now') WHERE id = ?`, `UPDATE tenants SET access_sent_at = now() WHERE id = ?`,
).run(opts.tenantId); ).run(opts.tenantId);
} }
return result; return result;
} }
export function tenantAccessBlocked(platformDb: PlatformDb, tenantId: number | null): string | null { export async function tenantAccessBlocked(platformDb: PlatformDb, tenantId: number | null): Promise<string | null> {
if (tenantId == null) return "Cuenta sin empresa asignada"; 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 = ?`, `SELECT status, valid_until FROM tenants WHERE id = ?`,
).get(tenantId) as { status: string; valid_until: string | null } | undefined; ).get(tenantId) as { status: string; valid_until: string | null } | undefined;
if (!t) return "Empresa no encontrada"; if (!t) return "Empresa no encontrada";

26
api/scope.ts Normal file
View file

@ -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;

View file

@ -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 <platform|tenant> [--tenant-id=1] [--company-code=ARCT2608] [--username=arct2608] [--display-name=Administrador]");
Deno.exit(1);
}
Deno.exit(0);

View file

@ -1,9 +1,14 @@
/** /**
* Carga LISTA COLABORADORES GAFETES.xlsx al padrón para pruebas. * Carga LISTA COLABORADORES GAFETES.xlsx al padrón para pruebas.
* Completa CURP/RFC/NSS/contacto faltantes con valores válidos únicos. * 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 * as XLSX from "xlsx";
import { getDb } from "./db.ts"; import { withCoreTenant } from "./db.ts";
import { upsertWorker, storeDocument } from "./excel.ts"; import { upsertWorker, storeDocument } from "./excel.ts";
import { resolveCompany } from "./companies.ts"; import { resolveCompany } from "./companies.ts";
import { import {
@ -16,7 +21,8 @@ import {
type WorkerInput, type WorkerInput,
} from "./mx.ts"; } 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"; const CONS = "BCDFGHJKLMNPQRSTVWXYZ";
function cell(r: Record<string, unknown>, k: string) { function cell(r: Record<string, unknown>, k: string) {
@ -68,81 +74,82 @@ async function fetchPhoto(url: string): Promise<Uint8Array | null> {
} }
} }
const wb = XLSX.read(await Deno.readFile(FILE), { type: "array" }); await withCoreTenant(TENANT_ID, async (db) => {
const db = await getDb(); const wb = XLSX.read(await Deno.readFile(FILE), { type: "array" });
const project = db.prepare("SELECT id FROM projects ORDER BY id LIMIT 1").get() as { id: number } | undefined; const project = await 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)); 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" }; type Job = { sheet: string; status: "activo" | "baja" };
const jobs: Job[] = [ const jobs: Job[] = [
{ sheet: "ACTUALES", status: "activo" }, { sheet: "ACTUALES", status: "activo" },
{ sheet: "HISTORICO", status: "baja" }, { 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) { for (const job of jobs) {
const rows = XLSX.utils.sheet_to_json<Record<string, unknown>>(wb.Sheets[job.sheet], { defval: "" }); const rows = XLSX.utils.sheet_to_json<Record<string, unknown>>(wb.Sheets[job.sheet], { defval: "" });
for (const raw of rows) { for (const raw of rows) {
const first = cell(raw, "NOMBRE"); const first = cell(raw, "NOMBRE");
const lastp = cell(raw, "APELLIDO PATERNO"); const lastp = cell(raw, "APELLIDO PATERNO");
if (!first || !lastp) { if (!first || !lastp) {
skipped++; skipped++;
continue; continue;
} }
i++; i++;
let curp = cell(raw, "CURP"); let curp = cell(raw, "CURP");
let rfc = cell(raw, "RFC"); let rfc = cell(raw, "RFC");
let nss = cell(raw, "NSS"); let nss = cell(raw, "NSS");
if (validateCurp(curp)) curp = makeCurp(i); if (validateCurp(curp)) curp = makeCurp(i);
if (validateRfc(rfc)) rfc = makeRfc(curp || makeCurp(i), i); if (validateRfc(rfc)) rfc = makeRfc(curp || makeCurp(i), i);
if (validateNss(nss)) nss = makeNss(1000 + i); if (validateNss(nss)) nss = makeNss(1000 + i);
const ids = { curp, rfc, nss }; const ids = { curp, rfc, nss };
const risk = canonicalRiskCode(cell(raw, "RIESGO")); const risk = canonicalRiskCode(cell(raw, "RIESGO"));
const hire = cell(raw, "ALTA").toUpperCase(); const hire = cell(raw, "ALTA").toUpperCase();
const input: WorkerInput = { const input: WorkerInput = {
first_name: first, first_name: first,
middle_name: cell(raw, "2 NOMBRE") || null, middle_name: cell(raw, "2 NOMBRE") || null,
last_name_p: lastp, last_name_p: lastp,
last_name_m: cell(raw, "APELLIDO MATERNO") || "X", last_name_m: cell(raw, "APELLIDO MATERNO") || "X",
curp: ids.curp, curp: ids.curp,
rfc: ids.rfc, rfc: ids.rfc,
nss: ids.nss, nss: ids.nss,
phone: cell(raw, "TELEFONO") || String(9981000000 + i), phone: cell(raw, "TELEFONO") || String(9981000000 + i),
email: cell(raw, "CORREO") || `${slug(first)}.${slug(lastp)}.${i}@pruebas.arctec.local`, email: cell(raw, "CORREO") || `${slug(first)}.${slug(lastp)}.${i}@pruebas.arctec.local`,
address: cell(raw, "DIRECCION") || "Sin domicilio en lista original", address: cell(raw, "DIRECCION") || "Sin domicilio en lista original",
blood_type: cell(raw, "TIPO SANGRE") || null, blood_type: cell(raw, "TIPO SANGRE") || null,
hire_type: hire || "ARCT2608", hire_type: hire || "ARCT2608",
position: cell(raw, "CARGO") || "AYUDANTE", position: cell(raw, "CARGO") || "AYUDANTE",
risk_code: risks.has(risk) ? risk : "rojo", risk_code: risks.has(risk) ? risk : "rojo",
work_type: cell(raw, "TIPO TRABAJO").toUpperCase() === "D" ? "D" : "N", work_type: cell(raw, "TIPO TRABAJO").toUpperCase() === "D" ? "D" : "N",
daily_wage: 450, daily_wage: 450,
needs_badge: ["si", "sí", "yes"].includes(cell(raw, "GAFETE").toLowerCase()), needs_badge: ["si", "sí", "yes"].includes(cell(raw, "GAFETE").toLowerCase()),
status: job.status, status: job.status,
}; };
const errors = validateWorkerFields(input); const errors = validateWorkerFields(input);
const company = resolveCompany(db, input) ?? resolveCompany(db, { hire_type: "ARCT2608" }); const company = (await resolveCompany(db, input)) ?? (await resolveCompany(db, { hire_type: "ARCT2608" }));
if (!company) errors.hire_type = "Empresa no encontrada"; if (!company) errors.hire_type = "Empresa no encontrada";
if (Object.keys(errors).length || !company) { if (Object.keys(errors).length || !company) {
console.log("SKIP", first, lastp, errors); console.log("SKIP", first, lastp, errors);
skipped++; skipped++;
continue; continue;
} }
const n = { ...normalizeWorker(input), hire_type: company.code, company_id: company.id }; const n = { ...normalizeWorker(input), hire_type: company.code, company_id: company.id, tenant_id: TENANT_ID };
const res = upsertWorker(db, n, job.status === "activo" ? project?.id ?? null : null); const res = await upsertWorker(db, n, job.status === "activo" ? project?.id ?? null : null);
if (res.action === "inserted") inserted++; if (res.action === "inserted") inserted++;
else existed++; else existed++;
const url = cell(raw, "URL FOTO"); const url = cell(raw, "URL FOTO");
if (url) { if (url) {
const photo = await fetchPhoto(url); const photo = await fetchPhoto(url);
if (photo) { if (photo) {
const mime = photo[0] === 0xff ? "image/jpeg" : "image/png"; const mime = photo[0] === 0xff ? "image/jpeg" : "image/png";
await storeDocument(db, res.id, "foto", "foto-lista.jpg", mime, photo, 1); await storeDocument(db, res.id, "foto", "foto-lista.jpg", mime, photo, null);
photos++; photos++;
}
} }
} }
} }
}
const total = db.prepare("SELECT COUNT(*) AS n FROM workers").get() as { n: number }; 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)); console.log(JSON.stringify({ inserted, existed, skipped, photos, workers_in_db: total.n }, null, 2));
});

98
api/sessions.ts Normal file
View file

@ -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:<id>` (hash con TTL) y hay un índice secundario
* `iam:user_sessions:<user_id>` (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<string> {
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<SessionData | null> {
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<void> {
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<void> {
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);
}

View file

@ -29,7 +29,7 @@ type SmtpRow = {
username: string; username: string;
password: string; password: string;
from_address: string; from_address: string;
enabled: number; enabled: boolean;
updated_at: string; updated_at: string;
}; };
@ -48,9 +48,9 @@ function fromEnv(): SmtpSettings {
}; };
} }
function readRow(platformDb: PlatformDb): SmtpRow | undefined { async function readRow(platformDb: PlatformDb): Promise<SmtpRow | undefined> {
try { try {
return platformDb.prepare( return await platformDb.prepare(
`SELECT host, port, username, password, from_address, enabled, updated_at `SELECT host, port, username, password, from_address, enabled, updated_at
FROM smtp_settings WHERE id = 1`, FROM smtp_settings WHERE id = 1`,
).get() as SmtpRow | undefined; ).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. */ /** Config efectiva: fila en panels_platform si hay host; si no, variables de entorno. */
export function resolveSmtp(platformDb: PlatformDb): SmtpSettings & { source: "db" | "env" | "none" } { export async function resolveSmtp(
const row = readRow(platformDb); platformDb: PlatformDb,
): Promise<SmtpSettings & { source: "db" | "env" | "none" }> {
const row = await readRow(platformDb);
if (row && trim(row.host)) { if (row && trim(row.host)) {
return { return {
host: trim(row.host), host: trim(row.host),
@ -69,7 +71,7 @@ export function resolveSmtp(platformDb: PlatformDb): SmtpSettings & { source: "d
username: trim(row.username), username: trim(row.username),
password: row.password ?? "", password: row.password ?? "",
from_address: trim(row.from_address) || config.smtpFrom, from_address: trim(row.from_address) || config.smtpFrom,
enabled: Number(row.enabled) === 1, enabled: Boolean(row.enabled),
updated_at: row.updated_at, updated_at: row.updated_at,
source: "db", source: "db",
}; };
@ -79,20 +81,20 @@ export function resolveSmtp(platformDb: PlatformDb): SmtpSettings & { source: "d
return { ...env, enabled: false, source: "none" }; return { ...env, enabled: false, source: "none" };
} }
export function smtpConfigured(platformDb: PlatformDb): boolean { export async function smtpConfigured(platformDb: PlatformDb): Promise<boolean> {
const s = resolveSmtp(platformDb); const s = await resolveSmtp(platformDb);
return s.enabled && Boolean(s.host && s.from_address); return s.enabled && Boolean(s.host && s.from_address);
} }
export function smtpPublicView(platformDb: PlatformDb): SmtpPublic { export async function smtpPublicView(platformDb: PlatformDb): Promise<SmtpPublic> {
const s = resolveSmtp(platformDb); const s = await resolveSmtp(platformDb);
return { return {
host: s.host, host: s.host,
port: s.port, port: s.port,
username: s.username, username: s.username,
from_address: s.from_address, from_address: s.from_address,
enabled: s.enabled, enabled: s.enabled,
configured: smtpConfigured(platformDb), configured: await smtpConfigured(platformDb),
has_password: Boolean(s.password), has_password: Boolean(s.password),
updated_at: s.updated_at ?? null, updated_at: s.updated_at ?? null,
source: s.source, source: s.source,
@ -115,10 +117,10 @@ function looksLikeEmailFrom(value: string): boolean {
return /(?:^|<)[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+(?:>|$)/.test(value); return /(?:^|<)[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+(?:>|$)/.test(value);
} }
export function saveSmtpSettings( export async function saveSmtpSettings(
platformDb: PlatformDb, platformDb: PlatformDb,
input: SaveSmtpInput, input: SaveSmtpInput,
): { error?: string; settings?: SmtpPublic } { ): Promise<{ error?: string; settings?: SmtpPublic }> {
const host = trim(input.host); const host = trim(input.host);
const fromAddress = trim(input.from_address); const fromAddress = trim(input.from_address);
const username = trim(input.username); 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 ?? ""; let password = input.password ?? "";
if ((input.keep_password || password === "") && current?.password) { if ((input.keep_password || password === "") && current?.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) `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 ON CONFLICT(id) DO UPDATE SET
host = excluded.host, host = excluded.host,
port = excluded.port, port = excluded.port,
@ -152,8 +154,8 @@ export function saveSmtpSettings(
password = excluded.password, password = excluded.password,
from_address = excluded.from_address, from_address = excluded.from_address,
enabled = excluded.enabled, enabled = excluded.enabled,
updated_at = datetime('now')`, updated_at = now()`,
).run(host, port, username, password, fromAddress, enabled ? 1 : 0); ).run(host, port, username, password, fromAddress, enabled);
return { settings: smtpPublicView(platformDb) }; return { settings: await smtpPublicView(platformDb) };
} }

100
api/storage.ts Normal file
View file

@ -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<Uint8Array> {
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<void> {
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<Uint8Array> {
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}`;
}

54
api/test_helpers.ts Normal file
View file

@ -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<T>(fn: (db: PgDb) => Promise<T>): Promise<T> {
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<void> {
await pool?.end({ timeout: 1 });
pool = null;
}

View file

@ -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<string>([
...(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();