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 / brand has exactly one row. 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 / DN / PO without a code deploy. */ export type LetterheadCode = "CNL" | "ISHERWOOD" | "WESTSCOTT GROUP OF COMPANIES"; export const LETTERHEAD_CODES: readonly LetterheadCode[] = [ "CNL", "ISHERWOOD", "WESTSCOTT GROUP OF COMPANIES", ] as const; /** * Canonical letterhead for ALL delivery-note PDFs, regardless of * which subsidiary the customer belongs to. The subsidiary name * still appears in the PDF meta block (so the printed document * tells the driver which subsidiary the delivery is for) but the * letterhead/header is always the Westcott Group one. * * Centralised here so the delivery-notes service and the invoicing * API (which both render delivery-note PDFs) can't drift apart * again. If the brand ever needs to change, this is the only * constant to touch. */ export const DELIVERY_NOTE_LETTERHEAD_CODE: LetterheadCode = "WESTSCOTT GROUP OF COMPANIES"; /** * Helper that returns the canonical delivery-note letterhead code. * Use this anywhere a delivery-note PDF is being rendered so the * three-letter `?? "CNL"` fallbacks that used to live in the * invoicing API routes stay gone. */ export function deliveryNoteLetterheadCode(): LetterheadCode { return DELIVERY_NOTE_LETTERHEAD_CODE; } /** * Letterhead for Repair Request PDFs (the "send to supplier for * analysis" document). Always CNL — a repair request is a paper * document the office hands to the supplier with the machine, and * it identifies the *sender* (the office), not the recipient. * Centralised so the service layer + the (future) any-other * Repair Request renderer can't drift apart. */ export const REPAIR_REQUEST_LETTERHEAD_CODE: LetterheadCode = "CNL"; /** * Helper that returns the canonical Repair Request letterhead code. * Mirror of `deliveryNoteLetterheadCode()`. */ export function repairRequestLetterheadCode(): LetterheadCode { return REPAIR_REQUEST_LETTERHEAD_CODE; } /** * Pick the letterhead code that matches a given company name. The * invoice / delivery-note flows don't always carry an explicit * letterhead (the operator just hits "Send"), so we infer it from * the order's billing company — "ISHERWOOD" or "CNL" in the name * wins, otherwise we fall back to `null` and the caller decides * (typically defaulting to CNL). */ export function letterheadForCompany(companyName: string | undefined | null): LetterheadCode | null { const upper = (companyName ?? "").trim().toUpperCase(); if (!upper) return null; if (upper.includes("ISHERWOOD")) return "ISHERWOOD"; if (upper.includes("CNL")) return "CNL"; return null; } 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; /** VAT rate the company charges. 15 for VAT-registered, 0 for exempt / below threshold. */ vat_percent: number; } /** * 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. * * The logo is embedded as SVG because `public/letterhead/.svg` * IS an SVG source file — @react-pdf parses the XML and embeds vector * primitives. (An earlier version of this comment blamed a * "@react-pdf byte-corruption bug that replaces every byte ≥ 0x80 with * 0xFD" and used SVG as a workaround. The corruption was real but the * culprit was this app's own render pipeline — `pdf().toString()` * re-encodes the PDF as latin1, so U+FFFD replacement characters * truncated to 0xFD. Fixed by using `renderToBuffer()`; see the note * at the top of `lib/pdf/render.tsx`. Raster logos work again.) * * **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; } }