import { readFile } from "node:fs/promises"; import path from "node:path"; import { getAdminPocketBase } from "./server/pocketbase-admin"; import type { BaseRecord } from "./types"; /** * Letterhead — server-side read model for a row in `letterhead_settings`. * * Each company (CNL, ISHERWOOD) has exactly one row (enforced by the * `idx_letterhead_settings_one_per_company` unique index). The PDF * generator fetches this row fresh on every render so edits in the * PB admin UI (logo swap, bank detail fix, terms tweak, accent color * change) propagate to the next quote without a code deploy. */ export type LetterheadCode = "CNL" | "ISHERWOOD"; export const LETTERHEAD_CODES: readonly LetterheadCode[] = ["CNL", "ISHERWOOD"] as const; export interface Letterhead extends BaseRecord { display_name: string; address: string; phone: string; email: string; vat_number: string; registration_number: string; bank_name: string; bank_account_number: string; bank_branch_code: string; terms: string; default_markup_percent: number; footer_text: string; /** PB file token (filename) — combine with `letterheadLogoUrl` for a usable URL. */ logo: string; is_active: boolean; company: string; /** Hex like "#738224". Set per-company so the PDF table header + accents differ. */ accent_color: string; } /** * Look up the letterhead row for a given company code. * * Matches on the `display_name` field (e.g. "CNL Mining Supplies" matches * the `CNL` code). Throws if no row matches — that's a config bug, not a * user error, so it surfaces as a 500 in the API. */ export async function fetchLetterhead(code: LetterheadCode): Promise { if (!LETTERHEAD_CODES.includes(code)) { throw new Error(`Unknown letterhead code: ${code}`); } const pb = await getAdminPocketBase(); const rows = await pb.collection("letterhead_settings").getFullList({ filter: `display_name ~ "${code}"`, }); if (rows.length === 0) { throw new Error( `No letterhead_settings row matching code ${code}. ` + `Seed one in the PB admin UI (Settings → Collections → letterhead_settings).` ); } return rows[0]; } /** * Public file URL for the logo. PB's `/api/files/{collection}/{record}/{filename}` * endpoint serves without auth, so the PDF renderer can fetch this directly * — though we still inline as data URI in the API route to avoid extra * round trips during render and to keep the document component pure. */ export function letterheadLogoUrl(row: Letterhead): string | null { if (!row.logo) return null; const base = process.env.NEXT_PUBLIC_POCKETBASE_URL || "http://127.0.0.1:8090"; return `${base}/api/files/${row.collectionId}/${row.id}/${row.logo}`; } /** Default accent for a code if the row has no `accent_color` set. */ export function defaultAccentFor(code: LetterheadCode): string { return code === "CNL" ? "#738224" : "#7ed957"; } /** * Load the company logo SVG from `public/letterhead/.svg` and * return it as a `data:image/svg+xml;base64,...` URI suitable for * `` in @react-pdf. * * We embed SVG instead of the original raster (PNG/JPEG) logos because * @react-pdf/renderer v4.5.1 has a confirmed byte-corruption bug that * replaces every byte ≥ 0x80 in raster image data with `0xFD`. SVG is * text-based, so it sidesteps that code path — @react-pdf parses the * SVG XML and embeds vector primitives directly into the PDF stream. * * **Base64, not URL-encoded.** The bundled PDFKit only matches * `^data:.+?;base64,(.*)$` when decoding inline data URIs (see * `PDFImage.open` in `@react-pdf/pdfkit/lib/pdfkit.js`). A * `;charset=utf-8,` URI is treated as a filesystem path * and silently dropped. Base64 it is — yes, the data URI is ~33% * bigger than URL-encoded, but for a 1x-per-quote render that's * a non-issue. * * Returns `null` if the file is missing or unreadable — that's a * non-fatal degradation (the PDF still renders, just without a logo), * so callers should treat it as best-effort. */ export async function loadLetterheadLogoDataUri( code: LetterheadCode ): Promise { try { const filePath = path.join( process.cwd(), "public", "letterhead", `${code.toLowerCase()}.svg` ); const raw = await readFile(filePath); return `data:image/svg+xml;base64,${raw.toString("base64")}`; } catch (err) { console.warn(`[letterhead] could not load logo for ${code}:`, err); return null; } }