mirror of
https://origin.cursor.com/mrdevmx/panels.git
synced 2026-10-09 14:13:18 +00:00
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:
parent
b87b0205a8
commit
493829d028
34 changed files with 2761 additions and 1938 deletions
148
api/auth.ts
148
api/auth.ts
|
|
@ -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,15 +209,35 @@ 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" };
|
||||||
|
|
@ -212,6 +245,13 @@ export async function changePassword(
|
||||||
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(
|
||||||
|
"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 {};
|
return {};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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")) {
|
|
||||||
database.prepare(
|
|
||||||
"INSERT INTO budget_chapters (project_id, parent_id, code, name, wbs, sort_order) VALUES (?, ?, ?, ?, ?, ?)",
|
"INSERT INTO budget_chapters (project_id, parent_id, code, name, wbs, sort_order) VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
).run(projectId, parentId, code, name, wbs, sort);
|
).run(projectId, parentId, code, name, wbs, sort);
|
||||||
} else {
|
return await database.lastInsertId();
|
||||||
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
57
api/cache.ts
Normal 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(() => {});
|
||||||
|
}
|
||||||
119
api/companies.ts
119
api/companies.ts
|
|
@ -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,33 +194,34 @@ 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 {
|
||||||
|
await database.prepare(
|
||||||
`INSERT INTO companies (
|
`INSERT INTO companies (
|
||||||
code, name, parent_id, kind, status, tenant_id,
|
code, name, parent_id, kind, status, tenant_id,
|
||||||
registro_patronal, razon_social, nombre_comercial, rfc, regimen_fiscal, clase_riesgo,
|
registro_patronal, razon_social, nombre_comercial, rfc, regimen_fiscal, clase_riesgo,
|
||||||
|
|
@ -242,16 +247,26 @@ export function createSubcompany(
|
||||||
profile.representante_legal,
|
profile.representante_legal,
|
||||||
profile.giro,
|
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,7 +315,8 @@ export function updateCompany(
|
||||||
status = input.status as Company["status"];
|
status = input.status as Company["status"];
|
||||||
}
|
}
|
||||||
|
|
||||||
database.prepare(
|
try {
|
||||||
|
await database.prepare(
|
||||||
`UPDATE companies SET
|
`UPDATE companies SET
|
||||||
name = ?, code = ?, status = ?,
|
name = ?, code = ?, status = ?,
|
||||||
registro_patronal = ?, razon_social = ?, nombre_comercial = ?, rfc = ?, regimen_fiscal = ?, clase_riesgo = ?,
|
registro_patronal = ?, razon_social = ?, nombre_comercial = ?, rfc = ?, regimen_fiscal = ?, clase_riesgo = ?,
|
||||||
|
|
@ -327,8 +343,17 @@ export function updateCompany(
|
||||||
profile.giro,
|
profile.giro,
|
||||||
id,
|
id,
|
||||||
);
|
);
|
||||||
if (code !== current.code) {
|
} catch (e) {
|
||||||
database.prepare("UPDATE workers SET hire_type = ? WHERE company_id = ?").run(code, id);
|
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";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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
339
api/db.ts
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
374
api/deno.lock
374
api/deno.lock
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
113
api/excel.ts
113
api/excel.ts
|
|
@ -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
72
api/iam_db.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
660
api/main.ts
660
api/main.ts
File diff suppressed because it is too large
Load diff
|
|
@ -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",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
461
api/payroll.ts
461
api/payroll.ts
File diff suppressed because it is too large
Load diff
|
|
@ -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 });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
const weekId = await ensureWeek(db, TENANT_ID, "2026-08-24");
|
||||||
|
await assertRejects(
|
||||||
() => assembleWeek(db, weekId, "2026-08-26"),
|
() => assembleWeek(db, weekId, "2026-08-26"),
|
||||||
Error,
|
Error,
|
||||||
"jueves",
|
"jueves",
|
||||||
);
|
);
|
||||||
const draft = db.prepare("SELECT status FROM payroll_weeks WHERE id=?").get(weekId) as { status: string };
|
const draft = await db.prepare("SELECT status FROM payroll_weeks WHERE id=?").get(weekId) as { status: string };
|
||||||
assertEquals(draft.status, "draft");
|
assertEquals(draft.status, "draft");
|
||||||
assembleWeek(db, weekId, "2026-08-28");
|
await assembleWeek(db, weekId, "2026-08-28");
|
||||||
const after = db.prepare("SELECT status FROM payroll_weeks WHERE id=?").get(weekId) as { status: string };
|
const after = await db.prepare("SELECT status FROM payroll_weeks WHERE id=?").get(weekId) as { status: string };
|
||||||
assertEquals(after.status, "assembled");
|
assertEquals(after.status, "assembled");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test("comisión suma al saldo", () => {
|
Deno.test("comisión suma al saldo", () => {
|
||||||
|
|
@ -180,13 +150,18 @@ 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(
|
||||||
|
"INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (?, ?, '2026-08-25', true)",
|
||||||
|
).run(workerId, projectId);
|
||||||
|
const weekId = await ensureWeek(db, TENANT_ID, "2026-08-24");
|
||||||
|
await assembleWeek(db, weekId, "2026-08-27");
|
||||||
|
const week = await db.prepare("SELECT * FROM payroll_weeks WHERE id=?").get(weekId) as {
|
||||||
status: string;
|
status: string;
|
||||||
required_net: number;
|
required_net: number;
|
||||||
payable_net: number;
|
payable_net: number;
|
||||||
|
|
@ -194,53 +169,74 @@ Deno.test("armar jueves prellena jue-vie-sáb y congela requerida", () => {
|
||||||
assertEquals(week.status, "assembled");
|
assertEquals(week.status, "assembled");
|
||||||
assertEquals(week.required_net, 2500);
|
assertEquals(week.required_net, 2500);
|
||||||
assertEquals(week.payable_net, 2500);
|
assertEquals(week.payable_net, 2500);
|
||||||
const thu = db.prepare(
|
const thu = await db.prepare(
|
||||||
"SELECT present FROM attendance WHERE worker_id=1 AND work_date='2026-08-27'",
|
"SELECT present FROM attendance WHERE worker_id=? AND work_date='2026-08-27'",
|
||||||
).get() as { present: number };
|
).get(workerId) as { present: boolean };
|
||||||
assertEquals(thu.present, 1);
|
assertEquals(thu.present, true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test("falta viernes baja a pagar y no toca requerida", () => {
|
Deno.test("falta viernes baja a pagar y no toca 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 r = setAttendance(db, 1, { project_id: 1, worker_id: 1, work_date: "2026-08-28", present: false });
|
await db.prepare(
|
||||||
|
"INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (?, ?, '2026-08-25', true)",
|
||||||
|
).run(workerId, projectId);
|
||||||
|
const weekId = await ensureWeek(db, TENANT_ID, "2026-08-24");
|
||||||
|
await assembleWeek(db, weekId, "2026-08-27");
|
||||||
|
const r = await setAttendance(db, TENANT_ID, {
|
||||||
|
project_id: projectId,
|
||||||
|
worker_id: workerId,
|
||||||
|
work_date: "2026-08-28",
|
||||||
|
present: false,
|
||||||
|
});
|
||||||
assertEquals("ok" in r && r.ok, true);
|
assertEquals("ok" in r && r.ok, true);
|
||||||
const week = db.prepare("SELECT required_net, payable_net FROM payroll_weeks WHERE id=?").get(weekId) as {
|
const week = await db.prepare("SELECT required_net, payable_net FROM payroll_weeks WHERE id=?").get(weekId) as {
|
||||||
required_net: number;
|
required_net: number;
|
||||||
payable_net: number;
|
payable_net: number;
|
||||||
};
|
};
|
||||||
assertEquals(week.required_net, 2500);
|
assertEquals(week.required_net, 2500);
|
||||||
assertEquals(week.payable_net, 2000);
|
assertEquals(week.payable_net, 2000);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test("préstamo no baja saldo al armar; sí al pagar", () => {
|
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"]) {
|
||||||
db.prepare("INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (1, 1, '2026-08-26', 1)").run();
|
await db.prepare(
|
||||||
createLoan(db, 1, { worker_id: 1, delivered: 2000, commission_pct: 10, grantIso: "2026-08-24" });
|
"INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (?, ?, ?, true)",
|
||||||
const weekId = ensureWeek(db, 1, "2026-08-24");
|
).run(workerId, projectId, day);
|
||||||
assembleWeek(db, weekId, "2026-08-27");
|
}
|
||||||
const before = db.prepare("SELECT balance FROM loans WHERE worker_id=1").get() as { balance: number };
|
await createLoan(db, TENANT_ID, { worker_id: workerId, delivered: 2000, commission_pct: 10, grantIso: "2026-08-24" });
|
||||||
|
const weekId = await ensureWeek(db, TENANT_ID, "2026-08-24");
|
||||||
|
await assembleWeek(db, weekId, "2026-08-27");
|
||||||
|
const before = await db.prepare("SELECT balance FROM loans WHERE worker_id=?").get(workerId) as { balance: number };
|
||||||
assertEquals(before.balance, 2200);
|
assertEquals(before.balance, 2200);
|
||||||
const week = db.prepare("SELECT required_net FROM payroll_weeks WHERE id=?").get(weekId) as { required_net: number };
|
const week = await db.prepare("SELECT required_net FROM payroll_weeks WHERE id=?").get(weekId) as { required_net: number };
|
||||||
assertEquals(week.required_net, 800);
|
assertEquals(week.required_net, 800);
|
||||||
payWeek(db, weekId);
|
await payWeek(db, weekId, "2026-08-29");
|
||||||
const after = db.prepare("SELECT balance FROM loans WHERE worker_id=1").get() as { balance: number };
|
const after = await db.prepare("SELECT balance FROM loans WHERE worker_id=?").get(workerId) as { balance: number };
|
||||||
assertEquals(after.balance, 0);
|
assertEquals(after.balance, 0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test("tablero: recuperación de préstamos no es sobrante", () => {
|
Deno.test("tablero: recuperación de préstamos no es sobrante", 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);
|
||||||
createLoan(db, 1, { worker_id: 1, delivered: 1000, commission_pct: 20, grantIso: "2026-08-24" });
|
await db.prepare(
|
||||||
const bundle = getWeekBundle(db, 1, "2026-08-24");
|
"INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (?, ?, '2026-08-25', true)",
|
||||||
|
).run(workerId, projectId);
|
||||||
|
const weekId = await ensureWeek(db, TENANT_ID, "2026-08-24");
|
||||||
|
await assembleWeek(db, weekId, "2026-08-27");
|
||||||
|
await createLoan(db, TENANT_ID, { worker_id: workerId, delivered: 1000, commission_pct: 20, grantIso: "2026-08-24" });
|
||||||
|
const bundle = await getWeekBundle(db, TENANT_ID, "2026-08-24");
|
||||||
const board = bundle.board as {
|
const board = bundle.board as {
|
||||||
required_net: number;
|
required_net: number;
|
||||||
payable_net: number;
|
payable_net: number;
|
||||||
|
|
@ -251,73 +247,84 @@ Deno.test("tablero: recuperación de préstamos no es sobrante", () => {
|
||||||
assertEquals(board.loan_recovery, 1200);
|
assertEquals(board.loan_recovery, 1200);
|
||||||
assertEquals(board.payable_net, 1300);
|
assertEquals(board.payable_net, 1300);
|
||||||
assertEquals(board.faltante, 0);
|
assertEquals(board.faltante, 0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test("un día no se marca en dos obras", () => {
|
Deno.test("un día no se marca en dos obras", async () => {
|
||||||
const db = mem();
|
await withTestDb(async (db) => {
|
||||||
db.prepare("INSERT INTO projects (id, code, name, status, tenant_id) VALUES (2, 'PRY-0002', 'Obra Sur', 'activo', 1)").run();
|
const { companyId, projectId, workerId } = await seedFixture(db);
|
||||||
setAttendance(db, 1, { project_id: 1, worker_id: 1, work_date: "2026-08-24", present: true });
|
const project2Id = await addSecondProject(db, companyId);
|
||||||
const blocked = setAttendance(db, 1, { project_id: 2, worker_id: 1, work_date: "2026-08-24", present: true });
|
await setAttendance(db, TENANT_ID, { project_id: projectId, worker_id: workerId, work_date: "2026-08-24", present: true });
|
||||||
|
const blocked = await setAttendance(db, TENANT_ID, {
|
||||||
|
project_id: project2Id,
|
||||||
|
worker_id: workerId,
|
||||||
|
work_date: "2026-08-24",
|
||||||
|
present: true,
|
||||||
|
});
|
||||||
assertEquals("error" in blocked, true);
|
assertEquals("error" in blocked, true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test("préstamo de personal: el día en otra obra no se prellena aquí", () => {
|
Deno.test("préstamo de personal: el día en otra obra no se prellena aquí", async () => {
|
||||||
const db = mem();
|
await withTestDb(async (db) => {
|
||||||
db.prepare("INSERT INTO projects (id, code, name, status, tenant_id) VALUES (2, 'PRY-0002', 'Obra Sur', 'activo', 1)").run();
|
const { companyId, projectId, workerId } = await seedFixture(db);
|
||||||
setAttendance(db, 1, { project_id: 2, worker_id: 1, work_date: "2026-08-27", present: true });
|
const project2Id = await addSecondProject(db, companyId);
|
||||||
const weekId = ensureWeek(db, 1, "2026-08-24");
|
await setAttendance(db, TENANT_ID, { project_id: project2Id, worker_id: workerId, work_date: "2026-08-27", present: true });
|
||||||
assembleWeek(db, weekId, "2026-08-27");
|
const weekId = await ensureWeek(db, TENANT_ID, "2026-08-24");
|
||||||
const onA = db.prepare(
|
await assembleWeek(db, weekId, "2026-08-27");
|
||||||
"SELECT present FROM attendance WHERE worker_id=1 AND project_id=1 AND work_date='2026-08-27'",
|
const onA = await db.prepare(
|
||||||
).get() as { present: number } | undefined;
|
"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);
|
assertEquals(onA, undefined);
|
||||||
const onB = db.prepare(
|
const onB = await db.prepare(
|
||||||
"SELECT present FROM attendance WHERE worker_id=1 AND project_id=2 AND work_date='2026-08-27'",
|
"SELECT present FROM attendance WHERE worker_id=? AND project_id=? AND work_date='2026-08-27'",
|
||||||
).get() as { present: number };
|
).get(workerId, project2Id) as { present: boolean };
|
||||||
assertEquals(onB.present, 1);
|
assertEquals(onB.present, true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Deno.test("destajo: estimado del corte, realizado menor, remanente y extra viernes", async () => {
|
||||||
Deno.test("destajo: estimado del corte, realizado menor, remanente y extra viernes", () => {
|
await withTestDb(async (db) => {
|
||||||
const db = mem();
|
const { companyId, projectId, workerId } = await seedFixture(db);
|
||||||
db.prepare("DELETE FROM assignments WHERE worker_id=1").run();
|
// Igual que el original: el jornalero de la fixture no debe seguir en
|
||||||
db.prepare(
|
// el roster de "obra", para que el requerido sea solo el destajo.
|
||||||
"INSERT INTO workers (id, first_name, last_name_p, work_type, daily_wage, tenant_id) VALUES (2, 'Luis', 'Destajo', 'D', 0, 1)",
|
await db.prepare("DELETE FROM assignments WHERE worker_id=?").run(workerId);
|
||||||
).run();
|
const worker2Id = await addSecondWorker(db, companyId);
|
||||||
const weekId = ensureWeek(db, 1, "2026-08-24");
|
const weekId = await ensureWeek(db, TENANT_ID, "2026-08-24");
|
||||||
const job = createDestajoJob(db, 1, weekId, {
|
const job = await createDestajoJob(db, TENANT_ID, weekId, {
|
||||||
project_id: 1,
|
project_id: projectId,
|
||||||
worker_id: 2,
|
worker_id: worker2Id,
|
||||||
concepto: "Muro tablaroca",
|
concepto: "Muro tablaroca",
|
||||||
unit_code: "m2",
|
unit_code: "m2",
|
||||||
qty_total_estimated: 200,
|
qty_total_estimated: 200,
|
||||||
unit_price: 100,
|
unit_price: 100,
|
||||||
qty_planned: 80,
|
qty_planned: 80,
|
||||||
});
|
});
|
||||||
assembleWeek(db, weekId, "2026-08-27");
|
await assembleWeek(db, weekId, "2026-08-27");
|
||||||
let week = db.prepare("SELECT required_net, payable_net FROM payroll_weeks WHERE id=?").get(weekId) as {
|
let week = await db.prepare("SELECT required_net, payable_net FROM payroll_weeks WHERE id=?").get(weekId) as {
|
||||||
required_net: number;
|
required_net: number;
|
||||||
payable_net: number;
|
payable_net: number;
|
||||||
};
|
};
|
||||||
assertEquals(week.required_net, 8000);
|
assertEquals(week.required_net, 8000);
|
||||||
const cut = db.prepare("SELECT id FROM destajo_cut_lines WHERE job_id=?").get(job.id) as { id: number };
|
const cut = await 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 });
|
await patchDestajoCut(db, weekId, cut.id, { qty_actual: 50 });
|
||||||
week = db.prepare("SELECT required_net, payable_net FROM payroll_weeks WHERE id=?").get(weekId) as {
|
week = await db.prepare("SELECT required_net, payable_net FROM payroll_weeks WHERE id=?").get(weekId) as {
|
||||||
required_net: number;
|
required_net: number;
|
||||||
payable_net: number;
|
payable_net: number;
|
||||||
};
|
};
|
||||||
assertEquals(week.required_net, 8000);
|
assertEquals(week.required_net, 8000);
|
||||||
assertEquals(week.payable_net, 5000);
|
assertEquals(week.payable_net, 5000);
|
||||||
patchDestajoCut(db, weekId, cut.id, { qty_extra: 10 });
|
await patchDestajoCut(db, weekId, cut.id, { qty_extra: 10 });
|
||||||
week = db.prepare("SELECT required_net, payable_net FROM payroll_weeks WHERE id=?").get(weekId) as {
|
week = await db.prepare("SELECT required_net, payable_net FROM payroll_weeks WHERE id=?").get(weekId) as {
|
||||||
required_net: number;
|
required_net: number;
|
||||||
payable_net: number;
|
payable_net: number;
|
||||||
};
|
};
|
||||||
assertEquals(week.payable_net, 6000);
|
assertEquals(week.payable_net, 6000);
|
||||||
payWeek(db, weekId);
|
await payWeek(db, weekId, "2026-08-29");
|
||||||
assertEquals(jobRemainder(db, job.id), 140);
|
assertEquals(await jobRemainder(db, job.id), 140);
|
||||||
const nextId = ensureWeek(db, 1, addDays("2026-08-24", 7));
|
const nextId = await ensureWeek(db, TENANT_ID, addDays("2026-08-24", 7));
|
||||||
assertExists(nextId);
|
assertExists(nextId);
|
||||||
const bundle = getWeekBundle(db, 1, "2026-08-31");
|
const bundle = await getWeekBundle(db, TENANT_ID, "2026-08-31");
|
||||||
assertEquals((bundle.destajo_jobs as { remainder: number }[])[0].remainder, 140);
|
assertEquals((bundle.destajo_jobs as { remainder: number }[])[0].remainder, 140);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
46
api/pdf.ts
46
api/pdf.ts
|
|
@ -1,13 +1,10 @@
|
||||||
import type { Database } from "@db/sqlite";
|
|
||||||
import { mkdir } from "node:fs/promises";
|
|
||||||
import { join } from "node:path";
|
|
||||||
// mkdir used in saveJobPdf
|
|
||||||
import QRCode from "qrcode";
|
import 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
138
api/pg.ts
Normal 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;
|
||||||
|
|
@ -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
51
api/redis.ts
Normal 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;
|
||||||
|
}
|
||||||
133
api/saas.ts
133
api/saas.ts
|
|
@ -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,12 +234,14 @@ 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
|
||||||
|
FROM users WHERE tenant_id = ? AND role_code = 'tenant_admin'
|
||||||
ORDER BY id`,
|
ORDER BY id`,
|
||||||
).all(tenantId) as Array<Record<string, unknown>>).map((a) => ({
|
).all(tenantId) as Array<Record<string, unknown>>;
|
||||||
|
return rows.map((a) => ({
|
||||||
id: Number(a.id),
|
id: Number(a.id),
|
||||||
username: String(a.username),
|
username: String(a.username),
|
||||||
display_name: String(a.display_name),
|
display_name: String(a.display_name),
|
||||||
|
|
@ -244,6 +250,7 @@ export function getTenantDetail(
|
||||||
must_change_password: Boolean(a.must_change_password),
|
must_change_password: Boolean(a.must_change_password),
|
||||||
created_at: String(a.created_at),
|
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,14 +305,19 @@ 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
|
||||||
|
// cruzando tenants, no una operación de un usuario ya autenticado).
|
||||||
|
companyId = await getCoreDb().then((core) =>
|
||||||
|
core.prepare(
|
||||||
`INSERT INTO companies (
|
`INSERT INTO companies (
|
||||||
code, name, parent_id, kind, status, tenant_id,
|
code, name, parent_id, kind, status, tenant_id,
|
||||||
razon_social, nombre_comercial, rfc, regimen_fiscal, registro_patronal, clase_riesgo,
|
razon_social, nombre_comercial, rfc, regimen_fiscal, registro_patronal, clase_riesgo,
|
||||||
|
|
@ -319,22 +341,29 @@ export async function createTenant(
|
||||||
contactEmail,
|
contactEmail,
|
||||||
profile.representante_legal,
|
profile.representante_legal,
|
||||||
profile.giro,
|
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) =>
|
||||||
|
iam.prepare(
|
||||||
`INSERT INTO users (
|
`INSERT INTO users (
|
||||||
username, password_hash, display_name, company_id, tenant_id, role,
|
username, password_hash, display_name, company_id, tenant_id, role_code,
|
||||||
must_change_password, email
|
must_change_password, email
|
||||||
) VALUES (?, ?, ?, ?, ?, 'tenant_admin', 1, ?)`,
|
) VALUES (?, ?, ?, ?, ?, 'tenant_admin', true, ?)`,
|
||||||
).run(adminUser, hash, adminName, companyId, tenantId, contactEmail);
|
).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
26
api/scope.ts
Normal 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;
|
||||||
103
api/scripts/bootstrap-admin.ts
Normal file
103
api/scripts/bootstrap-admin.ts
Normal 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);
|
||||||
|
|
@ -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,20 +74,20 @@ 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");
|
||||||
|
|
@ -121,15 +127,15 @@ for (const job of jobs) {
|
||||||
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");
|
||||||
|
|
@ -137,12 +143,13 @@ for (const job of jobs) {
|
||||||
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
98
api/sessions.ts
Normal 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);
|
||||||
|
}
|
||||||
42
api/smtp.ts
42
api/smtp.ts
|
|
@ -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
100
api/storage.ts
Normal 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
54
api/test_helpers.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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();
|
|
||||||
Loading…
Reference in a new issue