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

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

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

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

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

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

Co-authored-by: alberto.martinez <alberto.martinez@mrdev.mx>
2026-09-02 20:47:45 +00:00

384 lines
10 KiB
TypeScript

import QRCode from "qrcode";
import { PDFDocument, StandardFonts, rgb, degrees, type PDFPage, type PDFFont, type PDFImage } from "pdf-lib";
import { config } from "./config.ts";
import { decryptBytes } from "./docs_crypto.ts";
import { fullName, frontName } from "./mx.ts";
import type { Db } from "./db.ts";
import { badgeJobPdfKey, getObject, putObject, workerDocKey } from "./storage.ts";
const CM = 28.346456692913385;
const CARD_W = 6.7 * CM;
const CARD_H = 10.5 * CM;
const PAGE_W = 792;
const PAGE_H = 612;
type WorkerRow = {
id: number;
first_name: string;
middle_name: string | null;
last_name_p: string;
last_name_m: string;
curp: string;
nss: string;
blood_type: string | null;
position: string;
risk_color: string;
risk_text: string;
photo_storage?: string | null;
photo_iv?: string | null;
};
export function badgeVcardUrl(curp: string) {
const token = btoa(curp).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
return `${config.vcardBase}${token}`;
}
export async function badgeQrPng(curp: string): Promise<Uint8Array> {
return await QRCode.toBuffer(badgeVcardUrl(curp), {
errorCorrectionLevel: "H",
width: 280,
margin: 1,
}) as Uint8Array;
}
function hexRgb(hex: string) {
const h = hex.replace("#", "");
return rgb(
parseInt(h.slice(0, 2), 16) / 255,
parseInt(h.slice(2, 4), 16) / 255,
parseInt(h.slice(4, 6), 16) / 255,
);
}
export async function loadCurrentPhoto(db: Db, workerId: number): Promise<Uint8Array | null> {
const doc = await db.prepare(
`SELECT storage_name, iv FROM documents
WHERE worker_id = ? AND type_code = 'foto' AND is_current = true
ORDER BY id DESC LIMIT 1`,
).get(workerId) as { storage_name: string; iv: string } | undefined;
if (!doc) return null;
try {
const enc = await getObject(workerDocKey(workerId, doc.storage_name));
return await decryptBytes(doc.iv, enc);
} catch {
return null;
}
}
async function embedMaybe(
pdf: PDFDocument,
bytes: Uint8Array | null,
): Promise<PDFImage | null> {
if (!bytes) return null;
try {
return await pdf.embedJpg(bytes);
} catch {
try {
return await pdf.embedPng(bytes);
} catch {
return null;
}
}
}
function drawCentered(
page: PDFPage,
font: PDFFont,
text: string,
x: number,
y: number,
w: number,
size: number,
color = rgb(0, 0, 0),
) {
const tw = font.widthOfTextAtSize(text, size);
page.drawText(text, { x: x + Math.max(0, (w - tw) / 2), y, size, font, color });
}
export async function generateBadgePdf(
db: Db,
projectId: number,
workerIds: number[],
): Promise<Uint8Array> {
const project = await db.prepare(
"SELECT name, code, theme_id, logo_left_path, logo_right_path FROM projects WHERE id = ?",
).get(projectId) as {
name: string;
code: string;
theme_id: string;
logo_left_path: string | null;
logo_right_path: string | null;
} | undefined;
if (!project) throw new Error("Proyecto no encontrado");
const workers = await db.prepare(
`SELECT w.id, w.first_name, w.middle_name, w.last_name_p, w.last_name_m,
w.curp, w.nss, w.blood_type, w.position,
r.color AS risk_color, r.text_color AS risk_text
FROM workers w
JOIN risk_levels r ON r.code = w.risk_code
WHERE w.id = ANY(?)`,
).all(workerIds) as WorkerRow[];
const byId = new Map(workers.map((w) => [w.id, w]));
const ordered = workerIds.map((id) => byId.get(id)).filter(Boolean) as WorkerRow[];
const pdf = await PDFDocument.create();
const font = await pdf.embedFont(StandardFonts.Helvetica);
const fontBold = await pdf.embedFont(StandardFonts.HelveticaBold);
let logoL: PDFImage | null = null;
let logoR: PDFImage | null = null;
if (project.logo_left_path) {
try {
logoL = await embedMaybe(pdf, await getObject(project.logo_left_path));
} catch { /* text fallback */ }
}
if (project.logo_right_path) {
try {
logoR = await embedMaybe(pdf, await getObject(project.logo_right_path));
} catch { /* text fallback */ }
}
const gap = 4;
const totalW = 4 * CARD_W + 3 * gap;
const originX = (PAGE_W - totalW) / 2;
const topY = PAGE_H - 8 - CARD_H;
const botY = 8;
const chunk = 4;
for (let i = 0; i < ordered.length; i += chunk) {
const group = ordered.slice(i, i + chunk);
const page = pdf.addPage([PAGE_W, PAGE_H]);
for (let c = 0; c < group.length; c++) {
const w = group[c];
const x = originX + c * (CARD_W + gap);
const photo = await embedMaybe(pdf, await loadCurrentPhoto(db, w.id));
const qrPng = await badgeQrPng(w.curp);
const qrImg = await pdf.embedPng(qrPng);
drawFront(page, font, fontBold, x, topY, w, photo, logoL, logoR);
drawBack(page, font, fontBold, x, botY, w, qrImg, project.name, project.code);
}
}
return await pdf.save();
}
function drawFront(
page: PDFPage,
font: PDFFont,
fontBold: PDFFont,
x: number,
y: number,
w: WorkerRow,
photo: PDFImage | null,
logoL: PDFImage | null,
logoR: PDFImage | null,
) {
page.drawRectangle({
x,
y,
width: CARD_W,
height: CARD_H,
borderColor: rgb(0, 0, 0),
borderWidth: 1.5,
});
const logoH = CARD_H * 0.16;
if (logoL) {
const scale = Math.min((CARD_W / 2 - 8) / logoL.width, (logoH - 4) / logoL.height);
page.drawImage(logoL, {
x: x + 6,
y: y + CARD_H - logoH - 4,
width: logoL.width * scale,
height: logoL.height * scale,
});
} else {
page.drawText("Construcciones Arctec", {
x: x + 6,
y: y + CARD_H - 22,
size: 7,
font: fontBold,
});
}
if (logoR) {
const scale = Math.min((CARD_W / 2 - 8) / logoR.width, (logoH - 4) / logoR.height);
page.drawImage(logoR, {
x: x + CARD_W / 2 + 4,
y: y + CARD_H - logoH - 4,
width: logoR.width * scale,
height: logoR.height * scale,
});
} else {
page.drawText("Zendala", {
x: x + CARD_W / 2 + 8,
y: y + CARD_H - 22,
size: 9,
font: fontBold,
color: rgb(0.1, 0.25, 0.55),
});
}
const photoW = 3.5 * CM;
const photoH = 4.5 * CM;
const px = x + (CARD_W - photoW) / 2;
const py = y + CARD_H - logoH - photoH - 18;
page.drawRectangle({
x: px,
y: py,
width: photoW,
height: photoH,
borderColor: rgb(0, 0, 0),
borderWidth: 2,
});
if (photo) {
page.drawImage(photo, { x: px + 1, y: py + 1, width: photoW - 2, height: photoH - 2 });
}
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);
const barH = 28;
const barY = y + 10;
page.drawRectangle({
x: x + 1.5,
y: barY,
width: CARD_W - 3,
height: barH,
color: hexRgb(w.risk_color),
});
drawCentered(
page,
fontBold,
w.position.toUpperCase(),
x,
barY + 8,
CARD_W,
11,
hexRgb(w.risk_text || "#FFFFFF"),
);
}
function drawBack(
page: PDFPage,
font: PDFFont,
fontBold: PDFFont,
x: number,
y: number,
w: WorkerRow,
qr: PDFImage,
projectName: string,
projectCode: string,
) {
page.drawRectangle({
x,
y,
width: CARD_W,
height: CARD_H,
borderColor: rgb(0, 0, 0),
borderWidth: 1.5,
rotate: degrees(180),
});
page.drawRectangle({
x: x + CARD_W,
y: y + CARD_H,
width: CARD_W,
height: CARD_H,
borderColor: rgb(0, 0, 0),
borderWidth: 1.5,
rotate: degrees(180),
});
const qrSize = CARD_W * 0.5;
page.drawImage(qr, {
x: x + CARD_W - (CARD_W - qrSize) / 2 - qrSize,
y: y + CARD_H - 16 - qrSize,
width: qrSize,
height: qrSize,
rotate: degrees(180),
});
const lines = [
`GRUPO SANGUINEO: ${w.blood_type ?? ""}`,
`CURP: ${w.curp}`,
`NSS: ${w.nss}`,
`NOMBRE: ${fullName(w)}`,
];
let ly = y + CARD_H - qrSize - 36;
for (const line of lines) {
page.drawText(line, {
x: x + CARD_W - 12,
y: ly,
size: 7,
font,
rotate: degrees(180),
maxWidth: CARD_W - 20,
});
ly -= 12;
}
page.drawText((projectCode || "").toUpperCase(), {
x: x + CARD_W - 20,
y: y + 36,
size: 10,
font: fontBold,
rotate: degrees(180),
});
page.drawText(projectName.toUpperCase(), {
x: x + CARD_W - 20,
y: y + 22,
size: 10,
font: fontBold,
rotate: degrees(180),
});
}
export async function saveJobPdf(bytes: Uint8Array, jobId: number): Promise<string> {
const key = badgeJobPdfKey(jobId);
await putObject(key, bytes);
return key;
}
function wrap(text: string, font: PDFFont, size: number, max: number): string[] {
const words = text.split(/\s+/);
const lines: string[] = [];
let cur = "";
for (const word of words) {
const next = cur ? `${cur} ${word}` : word;
if (font.widthOfTextAtSize(next, size) > max && cur) {
lines.push(cur);
cur = word;
} else {
cur = next;
}
}
if (cur) lines.push(cur);
return lines;
}
export async function generateLoanReceiptPdf(opts: {
title: string;
subtitle: string;
person: string;
lines: { label: string; value: string }[];
}): Promise<Uint8Array> {
const pdf = await PDFDocument.create();
const page = pdf.addPage([612, 792]);
const font = await pdf.embedFont(StandardFonts.Helvetica);
const bold = await pdf.embedFont(StandardFonts.HelveticaBold);
page.drawText("ARCTEC / PANEL OBRA", { x: 56, y: 740, size: 11, font: bold, color: rgb(0.12, 0.18, 0.28) });
page.drawText(opts.title, { x: 56, y: 710, size: 18, font: bold });
page.drawText(opts.subtitle, { x: 56, y: 688, size: 10, font, color: rgb(0.3, 0.3, 0.3) });
page.drawText(opts.person, { x: 56, y: 650, size: 14, font: bold });
let y = 610;
for (const row of opts.lines) {
page.drawText(row.label, { x: 56, y, size: 10, font, color: rgb(0.35, 0.35, 0.35) });
const valueLines = wrap(row.value, bold, 11, 280);
let vy = y;
for (const line of valueLines) {
page.drawText(line, { x: 280, y: vy, size: 11, font: bold });
vy -= 14;
}
y = Math.min(y, vy) - 18;
}
page.drawText("Recibo interno. No es un CFDI.", { x: 56, y: 80, size: 9, font, color: rgb(0.4, 0.4, 0.4) });
return await pdf.save();
}