Replace executive-meetings print-to-PDF with server-side PDF generator

The "Download PDF" button on the executive-meetings page now hits a real
backend endpoint that returns a true PDF (no more browser print dialog),
respects each user's font preferences (family, size, weight, alignment)
with proper Arabic RTL shaping, and archives every download.

The renderer maps each saved fontFamily ("system", "Cairo", "Tajawal",
"Noto Naskh Arabic", "Amiri") to a concrete pair of bundled font files
so the chosen family genuinely changes the embedded glyphs — Cairo and
Tajawal pick Noto Sans Arabic, the Naskh-style families and the system
default pick Noto Naskh Arabic, and Latin glyphs render in DejaVu Sans
across the board. Headers, body cells, and footer all flow through the
same script-aware font selection.

Backend (artifacts/api-server)
- New GET /api/executive-meetings/pdf?date=&lang= route in
  src/routes/executive-meetings.ts that fetches the day's meetings +
  attendees, renders a PDF, uploads it to object storage, writes an
  executive_meeting_pdf_archives row (date, generated_by, byte_size,
  storage_url), and streams the file back inline.
- New src/lib/pdf-renderer.ts using pdfkit + bidi-js with bundled
  Noto Naskh Arabic and DejaVu Sans fonts in assets/fonts/.
- Added byte_size column on executive_meeting_pdf_archives (also in
  lib/db schema) and rebuilt lib/db.
- Added ambient types for bidi-js; installed @swc/helpers to satisfy
  fontkit at runtime.
- build.mjs now copies pdfkit's data/ folder (Helvetica.afm, etc.)
  into dist/data so the bundled server can construct PDFDocument.

Frontend (artifacts/tx-os)
- PdfSection in src/pages/executive-meetings.tsx now renders a single
  "Download PDF" button that fetches the endpoint, builds a Blob, and
  downloads it. Removed the print/archive-creation buttons.
- Archive list shows a Download button for new /objects/... rows and a
  read-only "Legacy snapshot" badge for older print: rows.
- Added byteSize on PdfArchive + size formatting; updated en/ar locales.

Tests
- New test "PDF GET /executive-meetings/pdf returns a real PDF and
  archives it" in tests/executive-meetings.test.mjs covers: bad-date
  400, unauthenticated 401, real %PDF body + content-type/disposition,
  archive row with byteSize/generatedBy/filePath, empty-day handling,
  and font-family mapping (Cairo embeds NotoSansArabic; Noto Naskh
  Arabic embeds NotoNaskhArabic).
- All 23 executive-meetings tests pass.

Rebase
- Rebased onto main-repl/main (37255b7 "Update the website's shared
  image"). The only conflict was the binary asset
  artifacts/tx-os/public/opengraph.jpg — accepted the incoming/main
  version since it's unrelated to this PDF work.

Drift
- Kept the legacy /executive-meetings/print SPA route and the existing
  POST /pdf-archives endpoint to preserve old archive snapshots and
  the existing snapshot test. Proposed follow-up #169 to clean these
  up once stakeholders confirm.

Replit-Task-Id: 68914058-ebd6-4670-a785-c0084fe1fc94
This commit is contained in:
riyadhafraa
2026-04-29 18:01:19 +00:00
parent 474198d77d
commit 11aaaf2abe
18 changed files with 1329 additions and 90 deletions
Binary file not shown.
+21 -5
View File
@@ -3,7 +3,7 @@ import path from "node:path";
import { fileURLToPath } from "node:url";
import { build as esbuild } from "esbuild";
import esbuildPluginPino from "esbuild-plugin-pino";
import { rm } from "node:fs/promises";
import { rm, cp } from "node:fs/promises";
// Plugins (e.g. 'esbuild-plugin-pino') may use `require` to resolve dependencies
globalThis.require = createRequire(import.meta.url);
@@ -120,7 +120,23 @@ globalThis.__dirname = __bannerPath.dirname(globalThis.__filename);
});
}
buildAll().catch((err) => {
console.error(err);
process.exit(1);
});
// Copy PDFKit's built-in font metric files (Helvetica.afm etc.) to
// dist/data/. PDFKit reads these at runtime via
// `fs.readFileSync(__dirname + "/data/Helvetica.afm")`, which esbuild
// cannot statically follow — so we ship them as plain assets next to
// the bundled output. The Arabic/Latin schedule fonts live in
// artifacts/api-server/assets/fonts/ and are read directly from there.
async function copyRuntimeAssets() {
const distDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "dist");
const requireFn = createRequire(import.meta.url);
const pdfkitMain = requireFn.resolve("pdfkit");
const pdfkitDataDir = path.resolve(path.dirname(pdfkitMain), "data");
await cp(pdfkitDataDir, path.join(distDir, "data"), { recursive: true });
}
buildAll()
.then(copyRuntimeAssets)
.catch((err) => {
console.error(err);
process.exit(1);
});
+4
View File
@@ -12,9 +12,11 @@
},
"dependencies": {
"@google-cloud/storage": "^7.19.0",
"@swc/helpers": "^0.5.21",
"@workspace/api-zod": "workspace:*",
"@workspace/db": "workspace:*",
"bcryptjs": "^3.0.3",
"bidi-js": "^1.0.3",
"connect-pg-simple": "^10.0.0",
"cookie-parser": "^1.4.7",
"cors": "^2",
@@ -22,6 +24,7 @@
"express": "^5",
"express-session": "^1.19.0",
"google-auth-library": "^10.6.2",
"pdfkit": "^0.18.0",
"pino": "^9",
"pino-http": "^10",
"sanitize-html": "^2.17.3",
@@ -36,6 +39,7 @@
"@types/express": "^5.0.6",
"@types/express-session": "^1.19.0",
"@types/node": "catalog:",
"@types/pdfkit": "^0.17.6",
"@types/sanitize-html": "^2.16.1",
"esbuild": "^0.27.3",
"esbuild-plugin-pino": "^2.3.3",
@@ -0,0 +1,657 @@
import { fileURLToPath } from "node:url";
import path from "node:path";
import { readFileSync, existsSync } from "node:fs";
import bidiFactory from "bidi-js";
import PDFDocument from "pdfkit";
import sanitizeHtml from "sanitize-html";
const bidi = bidiFactory();
export type PdfFontPrefs = {
fontFamily: string;
fontSize: number;
fontWeight: "regular" | "bold";
alignment: "start" | "center";
};
export type PdfMeetingAttendee = {
name: string;
title: string | null;
attendanceType?: string | null;
};
export type PdfMeeting = {
dailyNumber: number;
titleAr: string;
titleEn: string | null;
startTime: string | null;
endTime: string | null;
location: string | null;
isHighlighted: number;
attendees: PdfMeetingAttendee[];
};
export type RenderPdfInput = {
date: string;
lang: "ar" | "en";
font: PdfFontPrefs;
meetings: PdfMeeting[];
labels: {
title: string;
no: string;
meeting: string;
attendees: string;
time: string;
none: string;
};
};
// Resolve fonts relative to the bundled dist file (esbuild rewrites
// __dirname via the build banner so this works in both dev and prod).
function fontsDir(): string {
const here =
typeof __dirname === "string"
? __dirname
: path.dirname(fileURLToPath(import.meta.url));
// The fonts are shipped as raw assets next to dist/ — see
// artifacts/api-server/assets/fonts. From dist/ we go up one level.
const candidates = [
path.resolve(here, "..", "assets", "fonts"),
path.resolve(here, "assets", "fonts"),
path.resolve(here, "..", "..", "assets", "fonts"),
];
for (const c of candidates) {
if (existsSync(path.join(c, "NotoNaskhArabic-Regular.ttf"))) return c;
}
throw new Error(
`Could not locate bundled PDF fonts. Tried: ${candidates.join(", ")}`,
);
}
// All font files bundled in artifacts/api-server/assets/fonts. We only
// load the ones each render needs, but list every file here so the
// build step (and font-family mapping below) stays declarative.
const FONT_FILES = {
naskhRegular: "NotoNaskhArabic-Regular.ttf",
naskhBold: "NotoNaskhArabic-Bold.ttf",
arabicSansRegular: "NotoSansArabic-Regular.ttf",
arabicSansBold: "NotoSansArabic-Bold.ttf",
latinRegular: "DejaVuSans.ttf",
latinBold: "DejaVuSans-Bold.ttf",
} as const;
type FontFileKey = keyof typeof FONT_FILES;
// Map every supported user-facing font family to a concrete pair of
// bundled fonts (one for Arabic glyphs, one for Latin glyphs). Naskh
// families (Noto Naskh Arabic, Amiri, system default) use the Naskh
// face; sans families (Cairo, Tajawal) use Noto Sans Arabic — visually
// the closest sans-serif Arabic typeface we can ship without bundling
// every requested vendor font. Latin glyphs always render in DejaVu
// Sans so that mixed-script cells share consistent metrics.
type FamilyMapping = {
arabicRegular: FontFileKey;
arabicBold: FontFileKey;
latinRegular: FontFileKey;
latinBold: FontFileKey;
};
const FAMILY_MAP: Record<string, FamilyMapping> = {
system: {
arabicRegular: "naskhRegular",
arabicBold: "naskhBold",
latinRegular: "latinRegular",
latinBold: "latinBold",
},
Cairo: {
arabicRegular: "arabicSansRegular",
arabicBold: "arabicSansBold",
latinRegular: "latinRegular",
latinBold: "latinBold",
},
Tajawal: {
arabicRegular: "arabicSansRegular",
arabicBold: "arabicSansBold",
latinRegular: "latinRegular",
latinBold: "latinBold",
},
"Noto Naskh Arabic": {
arabicRegular: "naskhRegular",
arabicBold: "naskhBold",
latinRegular: "latinRegular",
latinBold: "latinBold",
},
Amiri: {
arabicRegular: "naskhRegular",
arabicBold: "naskhBold",
latinRegular: "latinRegular",
latinBold: "latinBold",
},
};
function familyMappingFor(family: string): FamilyMapping {
return FAMILY_MAP[family] ?? FAMILY_MAP.system;
}
const ARABIC_FONT_NAMES: ReadonlyArray<string> = [
"Cairo",
"Tajawal",
"Noto Naskh Arabic",
"Amiri",
];
// Bytes are loaded lazily on first render and reused.
let cachedFontBytes: Partial<Record<FontFileKey, Buffer>> = {};
function loadFontBytes(keys: ReadonlyArray<FontFileKey>): Record<FontFileKey, Buffer> {
const dir = fontsDir();
const out = {} as Record<FontFileKey, Buffer>;
for (const k of keys) {
if (!cachedFontBytes[k]) {
cachedFontBytes[k] = readFileSync(path.join(dir, FONT_FILES[k]));
}
out[k] = cachedFontBytes[k] as Buffer;
}
return out;
}
// Fast Arabic-script test (Arabic, Arabic Supplement, Arabic Extended-A,
// Arabic Presentation Forms-A/B). We use it to decide which bundled font
// covers a given character so mixed Arabic/Latin cells can be rendered
// without missing-glyph boxes.
function isArabicChar(ch: string): boolean {
const c = ch.codePointAt(0) ?? 0;
return (
(c >= 0x0600 && c <= 0x06ff) ||
(c >= 0x0750 && c <= 0x077f) ||
(c >= 0x08a0 && c <= 0x08ff) ||
(c >= 0xfb50 && c <= 0xfdff) ||
(c >= 0xfe70 && c <= 0xfeff)
);
}
function isLatinChar(ch: string): boolean {
const c = ch.codePointAt(0) ?? 0;
// Latin, digits, common punctuation, Latin-1 supplement.
return (
(c >= 0x0020 && c <= 0x024f) ||
(c >= 0x2000 && c <= 0x206f) ||
(c >= 0x2070 && c <= 0x209f)
);
}
type Run = { text: string; isArabic: boolean };
// Split a string into runs of consecutive same-script characters so we
// can pick the Arabic font for Arabic glyphs and the Latin font for the
// rest. Whitespace sticks to the surrounding run to avoid a stream of
// 1-char runs.
function splitRunsByScript(text: string): Run[] {
const runs: Run[] = [];
let current: Run | null = null;
for (const ch of text) {
const arabic = isArabicChar(ch);
const latin = isLatinChar(ch);
if (!arabic && !latin) {
// Glyph that neither font may cover — bias toward whatever the
// current run is so we don't fragment unnecessarily.
if (current) {
current.text += ch;
continue;
}
}
const want = arabic;
if (!current || current.isArabic !== want) {
current = { text: ch, isArabic: want };
runs.push(current);
} else {
current.text += ch;
}
}
return runs;
}
// Apply Unicode Bidi to a string and return runs in *visual* order (which
// is the order PDFKit will paint them along the line). Without this, mixed
// Arabic/Latin/digit cells look wrong (e.g. "10:00 - 11:00" inside an
// otherwise-RTL line gets digit-reversed by the renderer).
function visualOrderRuns(text: string, baseDirection: "ltr" | "rtl"): Run[] {
if (!text) return [];
const embeddingLevels = bidi.getEmbeddingLevels(text, baseDirection);
const reordered = bidi.getReorderedString(text, embeddingLevels);
return splitRunsByScript(reordered);
}
function fontKeyFor(
isArabic: boolean,
weight: "regular" | "bold",
mapping: FamilyMapping,
): FontFileKey {
if (isArabic) {
return weight === "bold" ? mapping.arabicBold : mapping.arabicRegular;
}
return weight === "bold" ? mapping.latinBold : mapping.latinRegular;
}
function alignment(
font: PdfFontPrefs,
isRtl: boolean,
): "left" | "center" | "right" {
if (font.alignment === "center") return "center";
// "start" maps to the writing-direction start.
return isRtl ? "right" : "left";
}
function htmlToPlain(input: string | null | undefined): string {
if (!input) return "";
// Strip every tag so we render plain text — table cells already have
// their own font/weight/size from the user's font settings, and PDFKit
// doesn't render arbitrary inline HTML. We DO keep <br> as a newline
// because attendees often use line breaks.
const withBreaks = input
.replace(/<\s*br\s*\/?>/gi, "\n")
.replace(/<\/(p|div|li)>/gi, "\n")
.replace(/<li>/gi, "• ");
const sanitized = sanitizeHtml(withBreaks, {
allowedTags: [],
allowedAttributes: {},
});
// Decode common entities that sanitize-html leaves intact when no tags
// are kept (it returns the inner text).
return sanitized
.replace(/&nbsp;/g, " ")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/[ \t]+\n/g, "\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
}
function formatTimeRange(
startTime: string | null,
endTime: string | null,
lang: "ar" | "en",
): string {
const fmt = (t: string): string => {
const hhmm = t.slice(0, 5);
const parsed = new Date(`1970-01-01T${hhmm}:00`);
if (Number.isNaN(parsed.getTime())) return hhmm;
return new Intl.DateTimeFormat(lang === "ar" ? "ar" : "en", {
hour: "numeric",
minute: "2-digit",
hour12: true,
// Force Latin digits even for Arabic locale so the Time column
// renders consistently across the schedule and the PDF — matches
// i18nFormatTime in the frontend.
numberingSystem: "latn",
} as Intl.DateTimeFormatOptions).format(parsed);
};
if (startTime && endTime) return `${fmt(startTime)} ${fmt(endTime)}`;
if (startTime) return fmt(startTime);
return "—";
}
type DrawOpts = {
x: number;
y: number;
width: number;
fontSize: number;
weight: "regular" | "bold";
align: "left" | "center" | "right";
baseDirection: "ltr" | "rtl";
mapping: FamilyMapping;
};
// Draw a single line of text using mixed Arabic/Latin runs (in visual
// order). We measure each run with the right font and lay them out
// left-to-right starting from the resolved alignment. Returns the height
// consumed by this line (including a small line-gap).
function drawMixedLine(
doc: PDFKit.PDFDocument,
text: string,
opts: DrawOpts,
): number {
const runs = visualOrderRuns(text, opts.baseDirection);
if (runs.length === 0) {
return opts.fontSize * 1.2;
}
// Measure full line width.
let totalWidth = 0;
const measured = runs.map((r) => {
const fk = fontKeyFor(r.isArabic, opts.weight, opts.mapping);
doc.font(fk).fontSize(opts.fontSize);
const w = doc.widthOfString(r.text);
totalWidth += w;
return { ...r, width: w, fontKey: fk };
});
const overflow = totalWidth - opts.width;
let cursorX = opts.x;
if (opts.align === "center") {
cursorX = opts.x + Math.max(0, (opts.width - totalWidth) / 2);
} else if (opts.align === "right") {
cursorX = opts.x + Math.max(0, opts.width - totalWidth);
}
// If we overflow horizontally, fall back to wrapping via PDFKit's own
// text engine on the first run. Cells in the schedule rarely overflow
// because column widths are generous, but Arabic titles can be long.
if (overflow > 4) {
return drawWrappingLine(doc, text, opts);
}
for (const r of measured) {
doc.font(r.fontKey).fontSize(opts.fontSize).fillColor("black");
doc.text(r.text, cursorX, opts.y, {
lineBreak: false,
width: r.width + 1,
});
cursorX += r.width;
}
return opts.fontSize * 1.25;
}
function drawWrappingLine(
doc: PDFKit.PDFDocument,
text: string,
opts: DrawOpts,
): number {
// Determine the dominant script for the wrapping pass. Without true
// bidi line-breaking we pick the "majority" script font to wrap with;
// this keeps shaping intact for the dominant direction at the cost of
// a slight metric mismatch on the minority runs.
const arabicChars = [...text].filter(isArabicChar).length;
const dominantArabic = arabicChars * 2 > text.length;
const fk = fontKeyFor(dominantArabic, opts.weight, opts.mapping);
doc.font(fk).fontSize(opts.fontSize).fillColor("black");
// For Arabic text, run bidi on the whole string so the font sees runs
// already in visual order — this matches what drawMixedLine does for
// single-line cells.
const reordered = bidi.getReorderedString(
text,
bidi.getEmbeddingLevels(text, opts.baseDirection),
);
const before = doc.y;
doc.text(reordered, opts.x, opts.y, {
width: opts.width,
align: opts.align,
lineBreak: true,
});
return Math.max(opts.fontSize * 1.25, doc.y - before + 2);
}
export async function renderSchedulePdf(input: RenderPdfInput): Promise<Buffer> {
const isRtl = input.lang === "ar";
const baseDir: "ltr" | "rtl" = isRtl ? "rtl" : "ltr";
const doc = new PDFDocument({
size: "A4",
layout: "portrait",
margin: 36, // ~12.7 mm — matches the screen print page's @page margin closely
info: {
Title: `${input.labels.title}${input.date}`,
Producer: "Tx OS Executive Meetings",
},
});
const mapping = familyMappingFor(input.font.fontFamily);
// Load only the four files this family needs (deduped by key).
const neededKeys = Array.from(
new Set<FontFileKey>([
mapping.arabicRegular,
mapping.arabicBold,
mapping.latinRegular,
mapping.latinBold,
]),
);
const bytes = loadFontBytes(neededKeys);
for (const k of neededKeys) {
doc.registerFont(k, bytes[k]);
}
const chunks: Buffer[] = [];
doc.on("data", (c: Buffer) => chunks.push(c));
const done = new Promise<void>((resolve, reject) => {
doc.on("end", resolve);
doc.on("error", reject);
});
// Header — show the schedule title and the date on its own line.
const titleSize = Math.min(28, input.font.fontSize + 10);
drawMixedLine(doc, input.labels.title, {
x: doc.page.margins.left,
y: doc.y,
width: doc.page.width - doc.page.margins.left - doc.page.margins.right,
fontSize: titleSize,
weight: "bold",
align: "center",
baseDirection: baseDir,
mapping,
});
doc.moveDown(0.4);
drawMixedLine(doc, input.date, {
x: doc.page.margins.left,
y: doc.y,
width: doc.page.width - doc.page.margins.left - doc.page.margins.right,
fontSize: Math.max(11, Math.round(input.font.fontSize * 0.85)),
weight: "regular",
align: "center",
baseDirection: baseDir,
mapping,
});
doc.moveDown(0.8);
if (input.meetings.length === 0) {
drawMixedLine(doc, input.labels.none, {
x: doc.page.margins.left,
y: doc.y,
width: doc.page.width - doc.page.margins.left - doc.page.margins.right,
fontSize: input.font.fontSize,
weight: input.font.fontWeight,
align: "center",
baseDirection: baseDir,
mapping,
});
doc.end();
await done;
return Buffer.concat(chunks);
}
// ---- Table layout ----
// Mirror the print page's column widths (6 / 30 / 44 / 20).
const tableX = doc.page.margins.left;
const tableWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
const colWidths = [
Math.round(tableWidth * 0.06),
Math.round(tableWidth * 0.3),
Math.round(tableWidth * 0.44),
0, // last col absorbs rounding
];
colWidths[3] = tableWidth - colWidths[0] - colWidths[1] - colWidths[2];
const headerLabels = isRtl
? // RTL: visually right-to-left ordering matches the schedule's
// Arabic layout (#, Meeting, Attendees, Time becomes Time first).
[input.labels.time, input.labels.attendees, input.labels.meeting, input.labels.no]
: [input.labels.no, input.labels.meeting, input.labels.attendees, input.labels.time];
const headerWidths = isRtl ? [...colWidths].reverse() : colWidths;
const cellPadX = 6;
const cellPadY = 5;
const lineHeight = input.font.fontSize * 1.25;
function drawHeader(): number {
const headerHeight = lineHeight + cellPadY * 2;
let cx = tableX;
const headerY = doc.y;
doc.save();
doc.rect(tableX, headerY, tableWidth, headerHeight).fill("#0B1E3F");
doc.restore();
for (let i = 0; i < headerLabels.length; i++) {
const label = headerLabels[i];
const arabicChars = [...label].filter(isArabicChar).length;
const dominantArabic = arabicChars * 2 > label.length;
const fk = fontKeyFor(dominantArabic, "bold", mapping);
doc.font(fk).fontSize(input.font.fontSize).fillColor("white");
const reordered = bidi.getReorderedString(
label,
bidi.getEmbeddingLevels(label, baseDir),
);
doc.text(reordered, cx + cellPadX, headerY + cellPadY, {
width: headerWidths[i] - cellPadX * 2,
align: "center",
lineBreak: false,
});
cx += headerWidths[i];
}
doc.fillColor("black");
doc.y = headerY + headerHeight;
return headerHeight;
}
// Render the table header on page 1.
const startY = doc.y;
const headerHeight = drawHeader();
doc.y = startY + headerHeight;
// ---- Rows ----
for (const meeting of input.meetings) {
const cellsLogical: { text: string; align: "left" | "center" | "right" }[] = [
{ text: String(meeting.dailyNumber), align: "center" },
{
text: htmlToPlain(isRtl ? meeting.titleAr : meeting.titleEn || meeting.titleAr) +
(meeting.location ? `\n${meeting.location}` : ""),
align: alignment(input.font, isRtl),
},
{
text:
meeting.attendees.length === 0
? "—"
: meeting.attendees
.map((a, idx) => {
const name = htmlToPlain(a.name);
const t = a.title?.trim();
return `${idx + 1}- ${name}${t ? ` (${t})` : ""}`;
})
.join("\n"),
align: alignment(input.font, isRtl),
},
{
text: formatTimeRange(meeting.startTime, meeting.endTime, input.lang),
align: "center",
},
];
const cells = isRtl ? [...cellsLogical].reverse() : cellsLogical;
const widths = isRtl ? [...colWidths].reverse() : colWidths;
// Measure the row height by laying out each cell to a temporary
// y-cursor and taking the max. We then redraw at the final position.
const probe = doc;
const probeY = probe.y;
let rowHeight = lineHeight + cellPadY * 2;
const cellHeights: number[] = [];
let cx = tableX;
for (let i = 0; i < cells.length; i++) {
const c = cells[i];
const startProbeY = probeY + cellPadY;
probe.y = startProbeY;
// Reuse drawWrappingLine to compute height — drop the actual draw
// by using a temporary off-screen page would be ideal, but PDFKit
// doesn't expose an undo. We instead measure by counting expected
// wraps at the dominant font.
const lines = c.text.split("\n");
let h = 0;
const arabicChars = [...c.text].filter(isArabicChar).length;
const dominantArabic = arabicChars * 2 > c.text.length;
const fk = fontKeyFor(dominantArabic, input.font.fontWeight, mapping);
doc.font(fk).fontSize(input.font.fontSize);
for (const line of lines) {
if (!line) {
h += lineHeight;
continue;
}
const w = doc.widthOfString(line);
const wraps = Math.max(1, Math.ceil(w / (widths[i] - cellPadX * 2)));
h += wraps * lineHeight;
}
const finalH = h + cellPadY * 2;
cellHeights.push(finalH);
if (finalH > rowHeight) rowHeight = finalH;
cx += widths[i];
}
// Page break check.
if (probeY + rowHeight > doc.page.height - doc.page.margins.bottom) {
doc.addPage();
drawHeader();
}
const rowTop = doc.y;
if (meeting.isHighlighted) {
doc.save();
doc.rect(tableX, rowTop, tableWidth, rowHeight).fill("#fecaca");
doc.restore();
}
// Cell borders.
doc.save();
doc.lineWidth(0.5).strokeColor("#d1d5db");
doc.rect(tableX, rowTop, tableWidth, rowHeight).stroke();
let bx = tableX;
for (let i = 0; i < cells.length - 1; i++) {
bx += widths[i];
doc
.moveTo(bx, rowTop)
.lineTo(bx, rowTop + rowHeight)
.stroke();
}
doc.restore();
// Draw cell contents.
cx = tableX;
for (let i = 0; i < cells.length; i++) {
const c = cells[i];
const cellW = widths[i] - cellPadX * 2;
const lines = c.text.split("\n");
let y = rowTop + cellPadY;
for (const line of lines) {
if (!line) {
y += lineHeight;
continue;
}
const used = drawWrappingLine(doc, line, {
x: cx + cellPadX,
y,
width: cellW,
fontSize: input.font.fontSize,
weight: input.font.fontWeight,
align: c.align,
baseDirection: baseDir,
mapping,
});
y += used;
}
cx += widths[i];
}
doc.y = rowTop + rowHeight;
}
// Footer — generation timestamp and font info.
doc.moveDown(0.6);
const footer = `Generated ${new Date().toISOString()} · font=${input.font.fontFamily} ${input.font.fontSize}px ${input.font.fontWeight}`;
doc
.font(mapping.latinRegular)
.fontSize(8)
.fillColor("#6b7280")
.text(footer, doc.page.margins.left, doc.y, {
width: doc.page.width - doc.page.margins.left - doc.page.margins.right,
align: "left",
});
doc.end();
await done;
return Buffer.concat(chunks);
}
// Keep the explicit "is this an arabic-leaning family?" check exposed for
// tests / future logic that wants to know the user's font choice.
export function fontFamilyIsArabic(name: string): boolean {
return ARABIC_FONT_NAMES.includes(name);
}
@@ -34,6 +34,8 @@ import {
} from "../middlewares/auth";
import { sanitizeRichText } from "../lib/sanitize";
import { emitExecutiveMeetingsDayChanged } from "../lib/realtime";
import { renderSchedulePdf, type PdfFontPrefs } from "../lib/pdf-renderer";
import { ObjectStorageService } from "../lib/objectStorage";
import {
recordExecutiveMeetingNotifications,
broadcastExecutiveMeetingNotifications,
@@ -2164,6 +2166,206 @@ router.get(
},
);
// =====================================================================
// PDF GENERATION (server-side)
// =====================================================================
// Resolve the user's effective font preferences exactly the way the
// frontend does: user-scope row wins, otherwise global, otherwise the
// schema defaults.
async function resolveFontPrefsForUser(userId: number): Promise<PdfFontPrefs> {
const rows = await db
.select()
.from(executiveMeetingFontSettingsTable)
.where(
or(
eq(executiveMeetingFontSettingsTable.scope, "global"),
and(
eq(executiveMeetingFontSettingsTable.scope, "user"),
eq(executiveMeetingFontSettingsTable.userId, userId),
),
),
);
const userRow = rows.find((r) => r.scope === "user" && r.userId === userId);
const globalRow = rows.find((r) => r.scope === "global");
const eff = userRow ?? globalRow;
return {
fontFamily: eff?.fontFamily ?? "system",
fontSize: eff?.fontSize ?? 14,
fontWeight: (eff?.fontWeight as PdfFontPrefs["fontWeight"]) ?? "regular",
alignment: (eff?.alignment as PdfFontPrefs["alignment"]) ?? "start",
};
}
// Upload bytes to object storage by signing a PUT URL and streaming the
// buffer through `fetch`. Returns the canonical /objects/<id> path that
// GET /api/storage/objects/* knows how to serve.
async function uploadPdfToStorage(buf: Buffer): Promise<string> {
const objectStorage = new ObjectStorageService();
const uploadUrl = await objectStorage.getObjectEntityUploadURL();
const putRes = await fetch(uploadUrl, {
method: "PUT",
body: buf,
headers: {
"Content-Type": "application/pdf",
"Content-Length": String(buf.byteLength),
},
});
if (!putRes.ok) {
const body = await putRes.text().catch(() => "");
throw new Error(
`Failed to upload PDF to object storage (${putRes.status}): ${body.slice(0, 200)}`,
);
}
// Storage returns the signed URL we PUT to — normalize back to
// /objects/<id> so we can serve it via GET /api/storage/objects/*.
return objectStorage.normalizeObjectEntityPath(uploadUrl.split("?")[0]!);
}
router.get(
"/executive-meetings/pdf",
requireExecutiveAccess,
async (req, res): Promise<void> => {
const dateRaw = typeof req.query.date === "string" ? req.query.date.trim() : "";
if (!dateRaw || !DATE_RE_LOCAL.test(dateRaw)) {
res
.status(400)
.json({ error: "Invalid 'date' (expected YYYY-MM-DD)", code: "bad_date" });
return;
}
const lang: "ar" | "en" =
typeof req.query.lang === "string" && req.query.lang === "en" ? "en" : "ar";
const userId = req.session.userId!;
const meetings = await db
.select()
.from(executiveMeetingsTable)
.where(eq(executiveMeetingsTable.meetingDate, dateRaw))
.orderBy(asc(executiveMeetingsTable.dailyNumber));
let attendeesByMeeting = new Map<number, ExecutiveMeetingAttendee[]>();
if (meetings.length > 0) {
const ids = meetings.map((m) => m.id);
const attendees = await db
.select()
.from(executiveMeetingAttendeesTable)
.where(inArray(executiveMeetingAttendeesTable.meetingId, ids))
.orderBy(
asc(executiveMeetingAttendeesTable.meetingId),
asc(executiveMeetingAttendeesTable.sortOrder),
);
for (const a of attendees) {
const arr = attendeesByMeeting.get(a.meetingId) ?? [];
arr.push(a);
attendeesByMeeting.set(a.meetingId, arr);
}
}
const font = await resolveFontPrefsForUser(userId);
const labels =
lang === "ar"
? {
title: "جدول الاجتماعات التنفيذية",
no: "م",
meeting: "الاجتماع",
attendees: "الحضور",
time: "الوقت",
none: "لا توجد اجتماعات في هذا اليوم.",
}
: {
title: "Executive Meetings Schedule",
no: "#",
meeting: "Meeting",
attendees: "Attendees",
time: "Time",
none: "No meetings on this day.",
};
let pdf: Buffer;
try {
pdf = await renderSchedulePdf({
date: dateRaw,
lang,
font,
labels,
meetings: meetings.map((m) => ({
dailyNumber: m.dailyNumber,
titleAr: m.titleAr,
titleEn: m.titleEn,
startTime: m.startTime,
endTime: m.endTime,
location: m.location,
isHighlighted: m.isHighlighted ?? 0,
attendees: (attendeesByMeeting.get(m.id) ?? []).map((a) => ({
name: a.name,
title: a.title,
attendanceType: a.attendanceType,
})),
})),
});
} catch (err) {
// Fail loudly so callers see a real error instead of a corrupt PDF.
console.error("[executive-meetings] PDF render failed", err);
res.status(500).json({
error: "Failed to generate PDF",
code: "pdf_render_failed",
detail: err instanceof Error ? err.message : String(err),
});
return;
}
// Upload + archive. If storage upload fails (e.g. sidecar offline)
// we still serve the PDF inline so the user is never blocked, but
// we record the archive with a synthetic path so the failure is
// visible in the audit trail.
let storagePath = `inline:${dateRaw}`;
try {
storagePath = await uploadPdfToStorage(pdf);
} catch (err) {
console.error("[executive-meetings] PDF upload failed; serving inline", err);
}
try {
await db.transaction(async (tx) => {
const [{ value: maxV }] = await tx
.select({
value: sql<number>`coalesce(max(${executiveMeetingPdfArchivesTable.version}), 0)`,
})
.from(executiveMeetingPdfArchivesTable)
.where(eq(executiveMeetingPdfArchivesTable.archiveDate, dateRaw));
const insertValues: typeof executiveMeetingPdfArchivesTable.$inferInsert = {
archiveDate: dateRaw,
filePath: storagePath,
version: (maxV ?? 0) + 1,
byteSize: pdf.byteLength,
generatedBy: userId,
};
const [row] = await tx
.insert(executiveMeetingPdfArchivesTable)
.values(insertValues)
.returning();
await logAudit(tx, {
action: "create",
entityType: "pdf_archive",
entityId: row!.id,
newValue: row!,
performedBy: userId,
});
});
} catch (err) {
console.error("[executive-meetings] PDF archive insert failed", err);
}
res.setHeader("Content-Type", "application/pdf");
res.setHeader(
"Content-Disposition",
`attachment; filename="executive-meetings-${dateRaw}.pdf"`,
);
res.setHeader("Content-Length", String(pdf.byteLength));
res.setHeader("Cache-Control", "no-store");
res.end(pdf);
},
);
// =====================================================================
// PDF ARCHIVES
// =====================================================================
+21
View File
@@ -0,0 +1,21 @@
declare module "bidi-js" {
export interface BidiEmbeddingLevels {
levels: Uint8Array;
paragraphs: Array<{ start: number; end: number; level: 0 | 1 }>;
}
export interface BidiApi {
getEmbeddingLevels(text: string, baseDirection?: "ltr" | "rtl" | "auto"): BidiEmbeddingLevels;
getReorderedString(text: string, embeddingLevels: BidiEmbeddingLevels): string;
getReorderedIndices(text: string, embeddingLevels: BidiEmbeddingLevels): number[];
getReorderSegments(
text: string,
embeddingLevels: BidiEmbeddingLevels,
): Array<[number, number]>;
getBidiCharType(char: string): number;
getBidiCharTypeName(char: string): string;
getMirroredCharacter(char: string): string | null;
}
export default function bidiFactory(): BidiApi;
}
@@ -539,6 +539,157 @@ test("PDF archives: POST creates a snapshot and GET returns it", async () => {
assert.ok(archives.some((a) => a.id === archive.id));
});
test("PDF GET /executive-meetings/pdf returns a real PDF and archives it", async () => {
// Create a meeting with mixed Arabic+Latin attendees + a time range so
// the renderer's bidi/font-segmentation path is exercised.
const create = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "اجتماع الميزانية ‪Q2 2026",
titleEn: "Q2 2026 Budget Review",
meetingDate: today,
startTime: "10:00",
endTime: "11:30",
location: "قاعة كبرى",
attendees: [
{ name: "أحمد العلي", title: "المدير التنفيذي",
attendanceType: "internal", sortOrder: 0 },
{ name: "John Smith", title: "CFO",
attendanceType: "external", sortOrder: 1 },
],
});
assert.equal(create.status, 201);
const meeting = await create.json();
created.meetingIds.push(meeting.id);
// Bad date → 400
const bad = await api(adminCookie, "GET",
"/api/executive-meetings/pdf?date=not-a-date");
assert.equal(bad.status, 400);
// Coordinator without executive role is denied — auth gate matches the
// rest of the executive-meetings module.
const noAuth = await fetch(`${API_BASE}/api/executive-meetings/pdf?date=${today}`);
assert.equal(noAuth.status, 401);
// Happy path: real PDF response with non-trivial body.
const res = await api(adminCookie, "GET",
`/api/executive-meetings/pdf?date=${today}&lang=ar`);
assert.equal(res.status, 200);
assert.match(
res.headers.get("content-type") || "",
/^application\/pdf/,
"must serve application/pdf",
);
assert.match(
res.headers.get("content-disposition") || "",
new RegExp(`attachment;\\s*filename="executive-meetings-${today}\\.pdf"`),
"filename should include the requested date",
);
const buf = Buffer.from(await res.arrayBuffer());
assert.ok(buf.length > 1000, `PDF should be >1KB, got ${buf.length}`);
assert.equal(
buf.subarray(0, 4).toString("ascii"),
"%PDF",
"body must start with PDF magic bytes",
);
// The download must have created an archive row recorded against the
// generating user with byteSize matching the served body.
const list = await api(
adminCookie,
"GET",
`/api/executive-meetings/pdf-archives?date=${today}`,
);
assert.equal(list.status, 200);
const archives = (await list.json()).archives ?? [];
const generated = archives.find(
(a) => typeof a.byteSize === "number" && a.byteSize === buf.length,
);
assert.ok(
generated,
"PDF download must produce an archive row with the served byte_size",
);
assert.ok(generated.generatedBy, "generatedBy must be recorded");
assert.ok(
typeof generated.filePath === "string" && generated.filePath.length > 0,
"filePath/storage_url must be non-empty",
);
// Empty-day handling: another date with no meetings still returns a
// valid PDF (with a "no meetings" message).
const empty = await api(adminCookie, "GET",
`/api/executive-meetings/pdf?date=2099-01-01&lang=en`);
assert.equal(empty.status, 200);
const emptyBuf = Buffer.from(await empty.arrayBuffer());
assert.equal(emptyBuf.subarray(0, 4).toString("ascii"), "%PDF");
// Font-family must affect which font is embedded in the PDF. We render
// the same day twice with different families and assert that:
// 1) Both downloads succeed (200 + %PDF magic).
// 2) The Sans family ("Cairo") embeds NotoSansArabic, while the Naskh
// default ("Noto Naskh Arabic") embeds NotoNaskhArabic. The font's
// PostScript name appears verbatim in the PDF font dictionary.
const setSans = await api(
adminCookie,
"PUT",
"/api/executive-meetings/font-settings",
{
scope: "user",
fontFamily: "Cairo",
fontSize: 16,
fontWeight: "bold",
alignment: "center",
},
);
assert.equal(setSans.status, 200);
const sansRes = await api(
adminCookie,
"GET",
`/api/executive-meetings/pdf?date=${today}&lang=ar`,
);
assert.equal(sansRes.status, 200);
const sansBuf = Buffer.from(await sansRes.arrayBuffer());
assert.equal(sansBuf.subarray(0, 4).toString("ascii"), "%PDF");
const sansAscii = sansBuf.toString("latin1");
assert.ok(
/NotoSansArabic/i.test(sansAscii),
"Cairo family must embed NotoSansArabic in the PDF",
);
assert.ok(
!/NotoNaskhArabic/i.test(sansAscii),
"Cairo family must NOT embed NotoNaskhArabic",
);
const setNaskh = await api(
adminCookie,
"PUT",
"/api/executive-meetings/font-settings",
{
scope: "user",
fontFamily: "Noto Naskh Arabic",
fontSize: 14,
fontWeight: "regular",
alignment: "start",
},
);
assert.equal(setNaskh.status, 200);
const naskhRes = await api(
adminCookie,
"GET",
`/api/executive-meetings/pdf?date=${today}&lang=ar`,
);
assert.equal(naskhRes.status, 200);
const naskhBuf = Buffer.from(await naskhRes.arrayBuffer());
const naskhAscii = naskhBuf.toString("latin1");
assert.ok(
/NotoNaskhArabic/i.test(naskhAscii),
"Noto Naskh Arabic family must embed NotoNaskhArabic in the PDF",
);
assert.ok(
!/NotoSansArabic/i.test(naskhAscii),
"Noto Naskh Arabic family must NOT embed NotoSansArabic",
);
});
test("Sanitization: rich text strips disallowed tags but keeps inline formatting", async () => {
const dirty =
'Hello <script>alert(1)</script><strong style="color:#ff0000">World</strong>' +
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

+9 -11
View File
@@ -1075,18 +1075,16 @@
"loading": "جارٍ التحميل…"
},
"pdf": {
"heading": "تصدير / طباعة",
"intro": "تستخدم هذه الواجهة طابعة المتصفح لإنتاج نسخة PDF من جدول اليوم. اختر التاريخ ثم اضغط طباعة.",
"print": "طباعة وأرشفة",
"printOnly": "طباعة فقط",
"openSchedule": "عرض الجدول",
"heading": "تصدير PDF",
"intro": "يتم توليد ملف PDF حقيقي لجدول اليوم على الخادم باستخدام إعدادات الخط المحفوظة. كل عملية تنزيل تتم أرشفتها.",
"downloadPdf": "تنزيل PDF",
"downloadHint": "يُولَّد ملف PDF حسب تفضيلات الخط (النوع، الحجم، الوزن، المحاذاة) مع تشكيل عربي صحيح من اليمين لليسار.",
"downloaded": "بدأ تنزيل ملف PDF",
"selectDate": "تاريخ الجدول",
"archiveSnapshot": "أرشفة نسخة",
"archived": "تمت أرشفة النسخة",
"archivedAndPrinted": "تمت أرشفة النسخة وفتح نافذة الطباعة",
"archivesHeading": "النسخ المؤرشفة",
"noArchives": "لا توجد نسخ مؤرشفة لهذا التاريخ بعد.",
"openArchive": "فتح"
"archivesHeading": "ملفات PDF المؤرشفة",
"noArchives": "لا توجد ملفات PDF مؤرشفة لهذا التاريخ بعد.",
"downloadArchive": "تنزيل",
"legacyArchive": "نسخة قديمة"
},
"fontSettingsPage": {
"heading": "إعدادات الخط",
+9 -11
View File
@@ -1000,18 +1000,16 @@
}
},
"pdf": {
"heading": "Print / Export",
"intro": "This view uses the browser print dialog to produce a PDF of the day's schedule. Pick a date and click Print.",
"print": "Print + archive",
"printOnly": "Print only",
"openSchedule": "Open schedule",
"heading": "PDF Export",
"intro": "Generates a real PDF of the day's schedule on the server using your saved font settings. Each download is archived.",
"downloadPdf": "Download PDF",
"downloadHint": "The PDF is generated using your font preferences (family, size, weight, alignment) with proper Arabic shaping.",
"downloaded": "PDF download started",
"selectDate": "Schedule date",
"archiveSnapshot": "Archive snapshot",
"archived": "Snapshot archived",
"archivedAndPrinted": "Snapshot archived — print dialog opened",
"archivesHeading": "Archived snapshots",
"noArchives": "No snapshots archived for this date yet.",
"openArchive": "Open"
"archivesHeading": "Archived PDFs",
"noArchives": "No PDFs archived for this date yet.",
"downloadArchive": "Download",
"legacyArchive": "Legacy snapshot"
},
"print": {
"title": "Executive Meetings Schedule",
@@ -28,7 +28,6 @@ import {
Plus,
Pencil,
Trash2,
Printer,
Check,
X,
ChevronUp,
@@ -225,6 +224,9 @@ type PdfArchive = {
archiveDate: string;
filePath: string;
version: number;
// Bytes recorded by the server-side generator. Null on legacy rows
// that were created before we wrote the PDF to object storage.
byteSize: number | null;
generatedBy: number | null;
generatedAt: string;
generator: {
@@ -234,6 +236,18 @@ type PdfArchive = {
} | null;
};
function formatBytesForDisplay(bytes: number): string {
if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
const units = ["B", "KB", "MB"];
let value = bytes;
let unitIdx = 0;
while (value >= 1024 && unitIdx < units.length - 1) {
value /= 1024;
unitIdx += 1;
}
return `${value < 10 && unitIdx > 0 ? value.toFixed(1) : Math.round(value)} ${units[unitIdx]}`;
}
const SECTIONS = [
{ key: "schedule", icon: CalendarClock },
{ key: "manage", icon: ListChecks },
@@ -4899,22 +4913,51 @@ function PdfSection({
enabled: !!date,
});
function openPrint() {
const url = `/executive-meetings/print?date=${date}&lang=${lang}`;
window.open(url, "_blank", "noopener");
// Stream a binary response from the API to the browser's download
// stack. Throws with the server's error message on a non-2xx so the
// toast surfaces a useful reason instead of a silent failure.
async function downloadBinary(url: string, filename: string): Promise<void> {
const res = await fetch(url, { credentials: "include" });
if (!res.ok) {
let message = `HTTP ${res.status}`;
try {
const j = (await res.json()) as { error?: string };
if (j.error) message = j.error;
} catch {
/* response wasn't JSON — keep the HTTP status */
}
throw new Error(message);
}
const blob = await res.blob();
const objectUrl = URL.createObjectURL(blob);
try {
const a = document.createElement("a");
a.href = objectUrl;
a.download = filename;
a.rel = "noopener";
document.body.appendChild(a);
a.click();
a.remove();
} finally {
// Free the blob URL after the click — Safari needs a tick before
// it actually starts the download, so defer slightly.
setTimeout(() => URL.revokeObjectURL(objectUrl), 4000);
}
}
async function archiveSnapshot() {
async function downloadPdf() {
if (!date) return;
setBusy(true);
try {
await apiJson(`/api/executive-meetings/pdf-archives`, {
method: "POST",
body: { archiveDate: date },
});
toast({ title: t("executiveMeetings.pdf.archived") });
const url = `/api/executive-meetings/pdf?date=${encodeURIComponent(
date,
)}&lang=${encodeURIComponent(lang)}`;
await downloadBinary(url, `executive-meetings-${date}.pdf`);
// Refresh archives so the row created by the GET appears.
await qc.invalidateQueries({
queryKey: ["/api/executive-meetings/pdf-archives", date],
});
toast({ title: t("executiveMeetings.pdf.downloaded") });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
toast({ title: msg, variant: "destructive" });
@@ -4923,25 +4966,20 @@ function PdfSection({
}
}
async function archiveAndPrint() {
if (!date) return;
setBusy(true);
async function downloadArchive(archive: PdfArchive) {
if (!archive.filePath?.startsWith("/objects/")) return;
try {
await apiJson(`/api/executive-meetings/pdf-archives`, {
method: "POST",
body: { archiveDate: date },
});
await qc.invalidateQueries({
queryKey: ["/api/executive-meetings/pdf-archives", date],
});
const url = `/executive-meetings/print?date=${date}&lang=${lang}`;
window.open(url, "_blank", "noopener");
toast({ title: t("executiveMeetings.pdf.archivedAndPrinted") });
// /objects/<id> resolves to GET /api/storage/objects/<id> on the
// server. The route is auth-gated behind the same session, so
// credentials must be included.
const url = `/api${archive.filePath.replace(/^\/objects/, "/storage/objects")}`;
await downloadBinary(
url,
`executive-meetings-${archive.archiveDate}-v${archive.version}.pdf`,
);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
toast({ title: msg, variant: "destructive" });
} finally {
setBusy(false);
}
}
@@ -4963,37 +5001,17 @@ function PdfSection({
</FormRow>
<div className="flex flex-wrap gap-2">
<Button
onClick={archiveAndPrint}
onClick={downloadPdf}
disabled={busy || !date}
className="bg-[#0B1E3F] gap-1"
data-testid="em-print"
>
<Printer className="w-4 h-4" />
{t("executiveMeetings.pdf.print")}
</Button>
<Button
onClick={openPrint}
disabled={!date}
variant="outline"
className="gap-1"
data-testid="em-print-only"
>
<Printer className="w-4 h-4" />
{t("executiveMeetings.pdf.printOnly")}
</Button>
<Button
onClick={archiveSnapshot}
disabled={busy || !date}
variant="outline"
className="gap-1"
data-testid="em-archive"
data-testid="em-download-pdf"
>
<FileText className="w-4 h-4" />
{t("executiveMeetings.pdf.archiveSnapshot")}
{t("executiveMeetings.pdf.downloadPdf")}
</Button>
</div>
<p className="text-xs text-gray-500">
{t("executiveMeetings.pdf.openSchedule")}
{t("executiveMeetings.pdf.downloadHint")}
</p>
</div>
@@ -5033,20 +5051,28 @@ function PdfSection({
</div>
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="outline"
onClick={() => {
const url = `/executive-meetings/print?date=${a.archiveDate}&lang=${lang}&version=${a.version}`;
window.open(url, "_blank", "noopener");
}}
data-testid={`em-archive-open-${a.id}`}
>
{t("executiveMeetings.pdf.openArchive")}
</Button>
<span className="text-xs text-gray-400 truncate max-w-[160px]">
{a.filePath}
</span>
{a.filePath?.startsWith("/objects/") ? (
<Button
size="sm"
variant="outline"
onClick={() => downloadArchive(a)}
data-testid={`em-archive-download-${a.id}`}
>
{t("executiveMeetings.pdf.downloadArchive")}
</Button>
) : (
<span
className="text-xs text-gray-400"
data-testid={`em-archive-legacy-${a.id}`}
>
{t("executiveMeetings.pdf.legacyArchive")}
</span>
)}
{typeof a.byteSize === "number" ? (
<span className="text-xs text-gray-400">
{formatBytesForDisplay(a.byteSize)}
</span>
) : null}
</div>
</li>
);
+7
View File
@@ -176,8 +176,15 @@ export const executiveMeetingPdfArchivesTable = pgTable(
{
id: serial("id").primaryKey(),
archiveDate: date("archive_date").notNull(),
// `filePath` doubles as the storage URL for the archived PDF. When the
// PDF was uploaded to object storage the value is a `/objects/<id>`
// path that resolves through GET /api/storage/objects/*. Older rows
// (browser-print snapshots) use the synthetic `print:<date>` form.
filePath: text("file_path").notNull(),
version: integer("version").notNull().default(1),
// Bytes of the generated PDF on disk. Nullable because legacy rows
// (created before the server-side generator) never had a real file.
byteSize: integer("byte_size"),
generatedBy: integer("generated_by").references(() => usersTable.id, {
onDelete: "set null",
}),
+159
View File
@@ -84,6 +84,9 @@ importers:
'@google-cloud/storage':
specifier: ^7.19.0
version: 7.19.0
'@swc/helpers':
specifier: ^0.5.21
version: 0.5.21
'@workspace/api-zod':
specifier: workspace:*
version: link:../../lib/api-zod
@@ -93,6 +96,9 @@ importers:
bcryptjs:
specifier: ^3.0.3
version: 3.0.3
bidi-js:
specifier: ^1.0.3
version: 1.0.3
connect-pg-simple:
specifier: ^10.0.0
version: 10.0.0
@@ -114,6 +120,9 @@ importers:
google-auth-library:
specifier: ^10.6.2
version: 10.6.2
pdfkit:
specifier: ^0.18.0
version: 0.18.0
pino:
specifier: ^9
version: 9.14.0
@@ -151,6 +160,9 @@ importers:
'@types/node':
specifier: 'catalog:'
version: 25.3.5
'@types/pdfkit':
specifier: ^0.17.6
version: 0.17.6
'@types/sanitize-html':
specifier: ^2.16.1
version: 2.16.1
@@ -1337,6 +1349,14 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@noble/ciphers@1.3.0':
resolution: {integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==}
engines: {node: ^14.21.3 || >=16}
'@noble/hashes@1.8.0':
resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==}
engines: {node: ^14.21.3 || >=16}
'@nodable/entities@2.1.0':
resolution: {integrity: sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==}
@@ -2253,6 +2273,9 @@ packages:
'@socket.io/component-emitter@3.1.2':
resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==}
'@swc/helpers@0.5.21':
resolution: {integrity: sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==}
'@tabby_ai/hijri-converter@1.0.5':
resolution: {integrity: sha512-r5bClKrcIusDoo049dSL8CawnHR6mRdDwhlQuIgZRNty68q0x8k3Lf1BtPAMxRf/GgnHBnIO4ujd3+GQdLWzxQ==}
engines: {node: '>=16.0.0'}
@@ -2628,6 +2651,9 @@ packages:
'@types/node@25.3.5':
resolution: {integrity: sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA==}
'@types/pdfkit@0.17.6':
resolution: {integrity: sha512-tIwzxk2uWKp0Cq9JIluQXJid77lYhF52EsIOwhsMF4iWLA6YneoBR1xVKYYdAysHuepUB0OX4tdwMiUDdGKmig==}
'@types/pg@8.18.0':
resolution: {integrity: sha512-gT+oueVQkqnj6ajGJXblFR4iavIXWsGAFCk3dP4Kki5+a9R4NMt0JARdk6s8cUKcfUoqP5dAtDSLU8xYUTFV+Q==}
@@ -2821,6 +2847,10 @@ packages:
balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
base64-js@0.0.8:
resolution: {integrity: sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==}
engines: {node: '>= 0.4'}
base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
@@ -2837,6 +2867,9 @@ packages:
resolution: {integrity: sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==}
hasBin: true
bidi-js@1.0.3:
resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
bignumber.js@9.3.1:
resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==}
@@ -2851,6 +2884,12 @@ packages:
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
engines: {node: '>=8'}
brotli@1.3.3:
resolution: {integrity: sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==}
browserify-zlib@0.2.0:
resolution: {integrity: sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==}
browserslist@4.28.1:
resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==}
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
@@ -2891,6 +2930,10 @@ packages:
classnames@2.5.1:
resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==}
clone@2.1.2:
resolution: {integrity: sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==}
engines: {node: '>=0.8'}
clsx@2.1.1:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
@@ -3067,6 +3110,9 @@ packages:
detect-node-es@1.1.0:
resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==}
dfa@1.2.0:
resolution: {integrity: sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==}
dom-helpers@5.2.1:
resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==}
@@ -3406,6 +3452,9 @@ packages:
resolution: {integrity: sha512-JGG8pvDi2C+JxidYdIwQDyS/CgcrIdh18cvgxcBge3wSHRQOrooMD3GlFBcmMJAN9M42SAZjDp5zv1dglJjwww==}
engines: {node: '>=20'}
fontkit@2.0.4:
resolution: {integrity: sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==}
form-data@2.5.5:
resolution: {integrity: sha512-jqdObeR2rxZZbPSGL+3VckHMYtu+f9//KXBsVny6JSX/pa38Fy+bGjuG8eW/H6USNQWhLi8Num++cU2yOCNz4A==}
engines: {node: '>= 0.12'}
@@ -3662,6 +3711,9 @@ packages:
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
engines: {node: '>=10'}
js-md5@0.8.3:
resolution: {integrity: sha512-qR0HB5uP6wCuRMrWPTrkMaev7MJZwJuuw4fnwAzRgP4J4/F8RwtodOKpGp4XpqsLBFzzgqIO42efFAyz2Et6KQ==}
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@@ -3776,6 +3828,9 @@ packages:
resolution: {integrity: sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==}
engines: {node: '>= 12.0.0'}
linebreak@1.1.0:
resolution: {integrity: sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==}
linkify-it@5.0.0:
resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==}
@@ -3995,6 +4050,12 @@ packages:
resolution: {integrity: sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg==}
engines: {node: '>=14.16'}
pako@0.2.9:
resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==}
pako@1.0.11:
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
parse-ms@4.0.0:
resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==}
engines: {node: '>=18'}
@@ -4024,6 +4085,9 @@ packages:
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
pdfkit@0.18.0:
resolution: {integrity: sha512-NvUwSDZ0eYEzqAiWwVQkRkjYUkZ48kcsHuCO31ykqPPIVkwoSDjDGiwIgHHNtsiwls3z3P/zy4q00hl2chg2Ug==}
pg-cloudflare@1.3.0:
resolution: {integrity: sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==}
@@ -4099,6 +4163,9 @@ packages:
engines: {node: '>=18'}
hasBin: true
png-js@1.1.0:
resolution: {integrity: sha512-PM/uYGzGdNSzqeOgly68+6wKQDL1SY0a/N+OEa/+br6LnHWOAJB0Npiamnodfq3jd2LS/i2fMeOKSAILjA+m5Q==}
postcss-selector-parser@6.0.10:
resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==}
engines: {node: '>=4'}
@@ -4354,6 +4421,9 @@ packages:
resolve-pkg-maps@1.0.0:
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
restructure@3.0.2:
resolution: {integrity: sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==}
retry-request@7.0.2:
resolution: {integrity: sha512-dUOvLMJ0/JJYEn8NrpOaGNE7X3vpI5XlZS/u0ANjqtcZVKnIxP7IgCFwrKTxENw29emmwug53awKtaMm4i9g5w==}
engines: {node: '>=14'}
@@ -4546,6 +4616,9 @@ packages:
thread-stream@3.1.0:
resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==}
tiny-inflate@1.0.3:
resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==}
tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
@@ -4623,6 +4696,12 @@ packages:
undici-types@7.18.2:
resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
unicode-properties@1.4.1:
resolution: {integrity: sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==}
unicode-trie@2.0.0:
resolution: {integrity: sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==}
unicorn-magic@0.3.0:
resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==}
engines: {node: '>=18'}
@@ -5274,6 +5353,10 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@noble/ciphers@1.3.0': {}
'@noble/hashes@1.8.0': {}
'@nodable/entities@2.1.0': {}
'@nodelib/fs.scandir@2.1.5':
@@ -6249,6 +6332,10 @@ snapshots:
'@socket.io/component-emitter@3.1.2': {}
'@swc/helpers@0.5.21':
dependencies:
tslib: 2.8.1
'@tabby_ai/hijri-converter@1.0.5': {}
'@tailwindcss/node@4.2.1':
@@ -6629,6 +6716,10 @@ snapshots:
dependencies:
undici-types: 7.18.2
'@types/pdfkit@0.17.6':
dependencies:
'@types/node': 25.3.5
'@types/pg@8.18.0':
dependencies:
'@types/node': 25.3.5
@@ -6836,6 +6927,8 @@ snapshots:
balanced-match@1.0.2: {}
base64-js@0.0.8: {}
base64-js@1.5.1: {}
base64id@2.0.0: {}
@@ -6844,6 +6937,10 @@ snapshots:
bcryptjs@3.0.3: {}
bidi-js@1.0.3:
dependencies:
require-from-string: 2.0.2
bignumber.js@9.3.1: {}
body-parser@2.2.2:
@@ -6868,6 +6965,14 @@ snapshots:
dependencies:
fill-range: 7.1.1
brotli@1.3.3:
dependencies:
base64-js: 1.5.1
browserify-zlib@0.2.0:
dependencies:
pako: 1.0.11
browserslist@4.28.1:
dependencies:
baseline-browser-mapping: 2.10.0
@@ -6908,6 +7013,8 @@ snapshots:
classnames@2.5.1: {}
clone@2.1.2: {}
clsx@2.1.1: {}
cmdk@1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
@@ -7042,6 +7149,8 @@ snapshots:
detect-node-es@1.1.0: {}
dfa@1.2.0: {}
dom-helpers@5.2.1:
dependencies:
'@babel/runtime': 7.28.6
@@ -7432,6 +7541,18 @@ snapshots:
locate-path: 8.0.0
unicorn-magic: 0.3.0
fontkit@2.0.4:
dependencies:
'@swc/helpers': 0.5.21
brotli: 1.3.3
clone: 2.1.2
dfa: 1.2.0
fast-deep-equal: 3.1.3
restructure: 3.0.2
tiny-inflate: 1.0.3
unicode-properties: 1.4.1
unicode-trie: 2.0.0
form-data@2.5.5:
dependencies:
asynckit: 0.4.0
@@ -7703,6 +7824,8 @@ snapshots:
joycon@3.1.1: {}
js-md5@0.8.3: {}
js-tokens@4.0.0: {}
js-yaml@4.1.1:
@@ -7789,6 +7912,11 @@ snapshots:
lightningcss-win32-arm64-msvc: 1.31.1
lightningcss-win32-x64-msvc: 1.31.1
linebreak@1.1.0:
dependencies:
base64-js: 0.0.8
unicode-trie: 2.0.0
linkify-it@5.0.0:
dependencies:
uc.micro: 2.1.0
@@ -7996,6 +8124,10 @@ snapshots:
p-timeout@6.1.4: {}
pako@0.2.9: {}
pako@1.0.11: {}
parse-ms@4.0.0: {}
parse-srcset@1.0.2: {}
@@ -8012,6 +8144,15 @@ snapshots:
pathe@2.0.3: {}
pdfkit@0.18.0:
dependencies:
'@noble/ciphers': 1.3.0
'@noble/hashes': 1.8.0
fontkit: 2.0.4
js-md5: 0.8.3
linebreak: 1.1.0
png-js: 1.1.0
pg-cloudflare@1.3.0:
optional: true
@@ -8108,6 +8249,10 @@ snapshots:
optionalDependencies:
fsevents: 2.3.2
png-js@1.1.0:
dependencies:
browserify-zlib: 0.2.0
postcss-selector-parser@6.0.10:
dependencies:
cssesc: 3.0.0
@@ -8373,6 +8518,8 @@ snapshots:
resolve-pkg-maps@1.0.0: {}
restructure@3.0.2: {}
retry-request@7.0.2:
dependencies:
'@types/request': 2.48.13
@@ -8633,6 +8780,8 @@ snapshots:
dependencies:
real-require: 0.2.0
tiny-inflate@1.0.3: {}
tiny-invariant@1.3.3: {}
tinyglobby@0.2.15:
@@ -8696,6 +8845,16 @@ snapshots:
undici-types@7.18.2: {}
unicode-properties@1.4.1:
dependencies:
base64-js: 1.5.1
unicode-trie: 2.0.0
unicode-trie@2.0.0:
dependencies:
pako: 0.2.9
tiny-inflate: 1.0.3
unicorn-magic@0.3.0: {}
unicorn-magic@0.4.0: {}