/** * Server-side PDF rendering. Every renderer here goes through * `renderToBuffer()` from `@react-pdf/renderer`. * * ## Why not `pdf().toString()` * * Every renderer used to funnel through * * const instance = pdf(); * const pdfString = await instance.toString(); * return Buffer.from(pdfString, "binary"); * * on the belief that `toBuffer()` was unusable under jsdom (Vitest's * environment). That belief was half right and led somewhere much * worse than a test-only problem. * * Three things are going on, and only the first was ever real: * * 1. `pdf(el).toBuffer()` is NOT the buffer API. It resolves to the * PDFKit *stream* (react-pdf carries a TODO to rename it * `toStream`), and under jsdom it resolves to a bare * `PDFDocument` handle. That is the jsdom breakage the old * comment described — but it applies to that method, not to the * module-level `renderToBuffer()` helper, which collects the * stream into a real Node Buffer and works fine under jsdom. * * 2. `toString()` renders with `compress: false`. react-pdf's own * source says why: "when rendering to string if compress=true * the document is blank". So every PDF this app produced was * uncompressed — larger than necessary — and the method is * flagged upstream as buggy (react-pdf issues #2112, #2095). * * 3. The round-trip through a JS string corrupts binary streams. * A raster image (PNG/JPEG) arrives as a Flate-compressed binary * blob that does not survive being stringified and re-encoded as * latin1. The image object ends up embedded in the file but is * never painted. Text is ASCII in the content stream, so text * renders fine and the corruption is invisible until something * raster goes in — which is why this sat latent for so long. The * letterhead logo hid it completely: that logo is an SVG, and * SVG is text, so it round-tripped perfectly. * * `renderToBuffer()` fixes all three at once and needs no jsdom * fallback. `render-image-pipeline.test.ts` is the regression guard — * it renders a real raster image and asserts the bytes come back * intact, so this cannot silently come back. */ import { renderToBuffer } from "@react-pdf/renderer"; import { buildQrMatrix, type QrMatrix } from "./qr"; import { QuoteDocument, type QuoteDocumentProps } from "./quote-document"; import { PurchaseOrderDocument, type PurchaseOrderDocumentProps, } from "./purchase-order-document"; import { StockPurchaseDocument, type StockPurchaseDocumentProps, } from "./stock-purchase-document"; import { DeliveryNoteDocument, type DeliveryNoteDocumentProps, } from "./delivery-note-document"; import { StockPurchaseListDocument, type StockPurchaseListDocumentProps, } from "./stock-purchase-list-document"; import { StockItemStocktakeDocument, type StockItemStocktakeDocumentProps, } from "./stock-item-stocktake-document"; import { RepairRequestDocument, type RepairRequestDocumentProps, } from "./repair-request-document"; import { RepPerformanceDetailDocument, RepPerformanceTeamDocument, type RepPerformanceDetailDocumentProps, type RepPerformanceTeamDocumentProps, } from "./rep-performance-detail-document"; /** * Server-side render of a `` to a PDF Buffer. * * Runs entirely in-process via `renderToBuffer()` — no headless * Chromium, no Playwright/Puppeteer, no browser binary in the Docker * image. See the note at the top of this file for why that specific * API and not `pdf().toString()`. * * Always returns a fresh Buffer; never cached. Reads of letterhead, * quote, items, and customer happen in the API route before this is * called, so any edit in PB propagates on the next generation. */ export async function renderQuotePdf(props: QuoteDocumentProps): Promise { return renderToBuffer(); } /** * Server-side render of a `` to a PDF Buffer. * * Same in-process Node pipeline as `renderQuotePdf` — no headless * Chromium, no browser binary. The PO PDF document component is a * pure function of its props, so the API route resolves the PO + * items + supplier + order + customer + letterhead before calling * this, then uploads the resulting Buffer to * `purchase_orders.documents` (the embedded file, multi field on * the PO record itself — replaced 2026-07-06 the separate * `purchase_order_pdfs` collection). * * Same `renderToBuffer()` pipeline as every other renderer here. */ export async function renderPurchaseOrderPdf( props: PurchaseOrderDocumentProps ): Promise { return renderToBuffer(); } /** * Server-side render of a `` to a PDF Buffer. * * Stock POs are pure stock-replenishment orders with no customer in * the loop — the document layout is a slimmed-down version of the * customer-PO PDF (no Order Ref line, no "For: "). Same * `renderToBuffer()` pipeline as the rest of this file. * * The route drops the resulting Buffer into * `stock_purchases.documents` (file, multi) + appends a metadata * entry to `stock_purchases.documents_history` (json) — same pattern * as the customer-PO PDF route. Mirroring the storage shape lets * the existing `/api/v1/files/...` proxy serve stock PDFs the same * way it serves customer POs. */ export async function renderStockPurchasePdf( props: StockPurchaseDocumentProps ): Promise { return renderToBuffer(); } /** * Server-side render of a `` to a PDF Buffer. * * Same in-process Node pipeline as the quote + PO renderers. The * `delivery-note` API route calls this right after it stamps the * status flip on the items, then uploads the resulting Buffer to * each item's `order_item_attachements` field — a multi-file field * (`maxSelect: 10`) already on the `order_items` collection. The * PDF is named `DN{YY}{MM}{DD}{NN}.pdf` (e.g. `DN26071401.pdf`) so * the retrieval side can disambiguate it from other attachments on * the same item. */ export async function renderDeliveryNotePdf(props: DeliveryNoteDocumentProps): Promise { // Every delivery note carries a QR of its own number so the // warehouse/driver scanner app can resolve the note straight off // the printed page. Built here rather than in the document so the // QR encoder stays out of the presentational component. // // Non-fatal: a QR that fails to encode must never cost the operator // their delivery note. The document renders without it. let qr: QrMatrix | null = null; try { qr = buildQrMatrix(props.header.deliveryNoteNumber); } catch (err) { console.error("[renderDeliveryNotePdf] QR build failed:", err); } return renderToBuffer(); } /** * Server-side render of a `` to a PDF Buffer. * * Same in-process Node pipeline as the other renderers. The * `repair-request` API route calls this right after it resolves the * repair + supplier + letterhead, then uploads the resulting Buffer * to the repair's `repair_request` file field (multi, maxSelect 10). * * The PDF is named `RR-{repairNumber}.pdf` (e.g. `RR-REP-2026-08-003.pdf`) * so the operator can find it on the repair detail page by name * (PB's file URLs include the encoded name). */ export async function renderRepairRequestPdf( props: RepairRequestDocumentProps ): Promise { return renderToBuffer(); } /** * Server-side render of the stock-orders *list* PDF — every * purchase in one document, not one PDF per PO. Sibling of * `renderStockPurchasePdf` (which is the single-PO, supplier-facing * PDF). Both use the same `renderToBuffer()` pipeline, which needs no * browser binary. */ export async function renderStockPurchaseListPdf( props: StockPurchaseListDocumentProps ): Promise { return renderToBuffer(); } /** * Server-side render of the rep-performance drill-down PDF. Same * in-process pipeline as the other list-style reports. */ export async function renderRepPerformanceDetailPdf( props: RepPerformanceDetailDocumentProps ): Promise { return renderToBuffer(); } /** * Server-side render of the team-wide rep-performance PDF (all reps, * ranked). Same pipeline as the per-rep variant. */ export async function renderRepPerformanceTeamPdf( props: RepPerformanceTeamDocumentProps ): Promise { return renderToBuffer(); } /** * Server-side render of the stocktake PDF — every active stock * item in one document. Same in-process Node pipeline as the other * renderers. The blank "Actual" column is intentional — the operator * fills it in on the printed page during the walk-through. */ export async function renderStockItemStocktakePdf( props: StockItemStocktakeDocumentProps ): Promise { return renderToBuffer(); }