diff --git a/src/app/api/v1/quotes/[id]/pdf/route.ts b/src/app/api/v1/quotes/[id]/pdf/route.ts index 6cad3ae..a3e3329 100644 --- a/src/app/api/v1/quotes/[id]/pdf/route.ts +++ b/src/app/api/v1/quotes/[id]/pdf/route.ts @@ -1,42 +1,34 @@ import { NextRequest, NextResponse } from "next/server"; -import { getAdminPocketBase } from "@/lib/server/pocketbase-admin"; import { - fetchLetterhead, - LETTERHEAD_CODES, - loadLetterheadLogoDataUri, - type LetterheadCode, -} from "@/lib/letterhead"; -import { renderQuotePdf } from "@/lib/pdf/render"; + generateQuotePdf, + type GenerateQuotePdfOutcome, + type QuotePdfReadinessIssue, +} from "@/lib/server/quote-pdf-service"; +import { getAdminPocketBase } from "@/lib/server/pocketbase-admin"; import { PLACEHOLDER_CUSTOMER_IDS, PLACEHOLDER_CUSTOMER_NAMES, } from "@/lib/placeholder-customers"; -import type { Quote, QuoteItem, Customer, QuoteAttachment } from "@/lib/types"; +import type { QuoteAttachment } from "@/lib/types"; /** * Quote PDF API — `POST /api/v1/quotes/[id]/pdf` * - * Body: `{ letterhead: "CNL" | "ISHERWOOD" }` + * Body: `{ letterhead: "CNL" | "ISHERWOOD" | ... }` * - * Flow: - * 1. Validate body + fetch quote, included items, customer. - * 2. Validate customer is real (not the "SELECT/CREATE CUSTOMER" or - * "Unknown Sender" sentinels that email intake parks on). - * 3. Validate every priced item is fully ready: processed, has - * supplier, cost_price > 0, selling_price > 0. - * 4. Fetch the matching `letterhead_settings` row. - * 5. Inline the logo as a base64 data URI (so @react-pdf doesn't fetch - * during render). - * 6. Render PDF via @react-pdf/renderer. - * 7. Save the PDF to `quote_attachments` (kind="pdf", linked to quote). - * 8. Flip quote status to `approval_pending` (or - * `partial_approval_pending` if not every priced item is ready) - * and stamp `pdf_generated_at`. Skipped if the quote is already - * in a terminal state (`approved` / `emailed` / `complete` / - * `cancelled` / `sending`) or in an approval state (idempotent - * regen). `rejected` is NOT terminal — regen after a rejection - * bounces the quote back into the rep queue. - * 9. Return `{ attachmentId, status, pdfGeneratedAt, filename }`. + * Thin wrapper around the canonical `generateQuotePdf` service. + * The route is responsible for: + * + * - Validating the HTTP body shape (letterhead is required). + * - Mapping the service's discriminated outcome to the HTTP + * response shape the UI already speaks (200 with the + * attachment metadata, 422 with the readiness issue list, + * 500 with a generic render-error body). + * + * All the actual business logic (placeholder customer gate, + * item-readiness gate, render, save, status flip) lives in the + * service so the same flow can be reused from `sendQuoteEmail` + * when it detects a stale PDF. * * `GET /api/v1/quotes/[id]/pdf?attachmentId=xxx` * @@ -50,44 +42,6 @@ import type { Quote, QuoteItem, Customer, QuoteAttachment } from "@/lib/types"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; // never cache; every render is fresh -// Statuses where auto-flipping to `approval_pending` would clobber -// business state. Anything else flips forward to `approval_pending` -// (or `partial_approval_pending` if not all items are priced) on -// successful PDF render — PDF generation is the moment the quote -// becomes "ready for rep review" and the operator no longer needs -// to click a separate "Submit for Approval" button. -// -// Two important non-terminal statuses are exempt from the flip: -// -// - `sending` — in-flight SMTP send (n8n / PB hook). Clobbering -// this would orphan the send. The operator can regenerate -// once the send completes (`emailed` is terminal) or fails -// (`mail_failed` flips to approval_pending on the next regen). -// - `approval_pending` / `partial_approval_pending` — idempotent -// regen while the quote is in the rep's queue; we only update -// `pdf_generated_at` and don't bounce it back to itself. -const TERMINAL_STATUSES = new Set([ - "approved", - "emailed", - "complete", - "cancelled", - "sending", -]); -const APPROVAL_STATES = new Set([ - "approval_pending", - "partial_approval_pending", -]); - -/** - * Customer IDs / names that mean "this customer isn't real, the email - * intake parked the quote here because the inbound email was missing - * a From: header or the operator hasn't picked one yet." PDF generation - * must refuse these — sending a quote addressed to "Unknown Sender" - * with no email is a data leak waiting to happen. - * - * The actual sets live in `@/lib/placeholder-customers` so the - * picker, this guard, and the service-layer validation can't drift. - */ /** * Shape of the 422 response we return when validation fails. The UI * reads `code` to show the right message and `details` to highlight @@ -104,11 +58,7 @@ type ValidationErrorBody = { customerName?: string; itemIds?: string[]; itemDescriptions?: string[]; - issues?: Array<{ - itemId: string; - description: string; - missing: string[]; - }>; + issues?: QuotePdfReadinessIssue[]; }; }; @@ -129,264 +79,63 @@ export async function POST( return NextResponse.json({ error: "Invalid JSON body" }, { status: 422 }); } - const letterheadCode = body.letterhead as LetterheadCode | undefined; - if (!letterheadCode || !LETTERHEAD_CODES.includes(letterheadCode)) { + if (!body.letterhead || typeof body.letterhead !== "string") { return NextResponse.json( - { - error: `letterhead is required and must be one of: ${LETTERHEAD_CODES.join(", ")}`, - }, + { error: "letterhead is required" }, { status: 422 } ); } - try { - const pb = await getAdminPocketBase(); + // The service does the actual work; we just map the outcome to + // the existing HTTP shape so the route's wire contract is + // unchanged. `letterhead` is validated inside the service + // (against LETTERHEAD_CODES) and falls back to the quote's + // stored letterhead when the caller passes something unknown — + // same lenient behaviour as before, so a stale UI that sends + // `letterhead: "CNL"` for an ISHERWOOD quote doesn't 500. + const outcome = await generateQuotePdf(quoteId, { + letterhead: body.letterhead, + }); - // Quote + customer. We expand the customer's company relation - // too so the PDF can show the company name (not just the contact) - // in the "TO:" block. The Company record's `company` field is - // what the form picker shows in the form, and what the approvals - // list / customer search already surface — the PDF was the odd - // one out. PB inlines the relation in the same response, no - // extra round-trip. - const quote = await pb.collection("quotes").getOne(quoteId, { - expand: "customer,customer.company", - }); - // No-quote / decline-letter flag. When true, we render every - // active item through to the PDF regardless of price — this is - // the PDF companion to the no-quote feature (Tasks 1-4 of the - // plan). The PDF component itself will render "Not quoted" cells - // for these rows (Task 8). DiagnosePdfReadiness was relaxed in - // Task 5 so priced quotes still surface their gaps; for - // is_no_quote quotes, we just render. - const isNoQuote = !!quote.is_no_quote; - const customer = quote.expand?.customer as Customer | undefined; - if (!customer) { - return NextResponse.json( - { error: "Quote has no customer — cannot render PDF" }, - { status: 422 } - ); - } - - // Block on placeholder customer. The office has to pick a real one - // on the process page before they can generate a PDF. - if ( - PLACEHOLDER_CUSTOMER_IDS.has(customer.id) || - PLACEHOLDER_CUSTOMER_NAMES.has((customer.customer || "").trim()) || - !customer.email - ) { - const body: ValidationErrorBody = { - error: - "This quote is still pointed at a placeholder / unnamed customer. " + - "Pick the real customer on the process page before generating a PDF.", - code: "placeholder_customer", - details: { - customerId: customer.id, - customerName: customer.customer || "(blank)", - }, - }; - return NextResponse.json(body, { status: 422 }); - } - - // Load ACTIVE items for this quote. "Active" = priced items that - // haven't been moved to a continuation quote. Items where - // `moved_to_quote != null` were cloned off to a continuation by - // the partial-approval split — they stay in PB for audit but - // are no longer "this quote's items". Filtering them here means - // the parent quote's PDF never accidentally re-includes items - // that now belong to a separate quote. - const allItems = await pb - .collection("quote_items") - .getFullList({ - filter: `quote = "${quoteId}" && (moved_to_quote = null || moved_to_quote = "")`, - sort: "created", - }); - - // A priced item is ready for the quote when: - // - is_processed = true (user clicked Save on the pricing modal) - // - supplier is set (every PDF line item has a supplier ref) - // - cost_price > 0 (the supplier gave us a price) - // - selling_price > 0 (we computed a sell price) - // Items missing any of these get flagged with `missing` so the UI - // can highlight them. - const ready: QuoteItem[] = []; - const issues: ValidationErrorBody["details"]["issues"] = []; - for (const it of allItems) { - const missing: string[] = []; - if (!it.is_processed) missing.push("not processed"); - if (!it.supplier) missing.push("no supplier"); - if (!(it.cost_price > 0)) missing.push("cost price"); - if (!(it.selling_price > 0)) missing.push("selling price"); - if (missing.length === 0) { - ready.push(it); - } else { - issues!.push({ - itemId: it.id, - description: it.description || "(no description)", - missing, - }); - } - } - - // itemsForPdf: what we hand to . For priced quotes - // this is the priced/processed subset (the `ready` array above). - // For no-quote / decline letters we bypass the filter entirely - // and render ALL active items — the PDF component itself decides - // how to render the price/total cells (Task 8 wires the "Not - // quoted" text in those columns). This is the third pricing-gate - // relaxation of the no-quote plan; Tasks 4-6 covered submit / - // diagnose / send. - // - // We keep `ready` populated for the downstream status-flip logic - // (decides approval_pending vs partial_approval_pending) and the - // partial-quote note below ("5 of 15 items"), both of which still - // apply to priced quotes. - const itemsForPdf = isNoQuote ? allItems : ready; - - if (!isNoQuote && ready.length === 0) { - const body: ValidationErrorBody = { - error: - allItems.length === 0 - ? "This quote has no items yet. Add line items on the quote detail page first." - : "No items are ready to quote. Every item needs: processed ✓, supplier ✓, cost price, and selling price.", - code: allItems.length === 0 ? "no_items" : "items_not_ready", - details: { - itemIds: issues!.map((i) => i.itemId), - itemDescriptions: issues!.map((i) => i.description), - issues, - }, - }; - return NextResponse.json(body, { status: 422 }); - } - - // Partial-quote note (added 2026-07-24). When the quote is in a - // partial-approval status AND there are still unpriced items, - // surface an amber callout on the PDF: "5 of 15 items — remaining - // items to follow". The customer sees the work-in-progress without - // seeing the internal "quote 1 of 3" chain. - // - // We compute "unpriced" as: items on the quote that aren't - // ready. Already moved items are excluded by the allItems filter - // above, so "unpriced" = allItems.length - ready.length. - const isPartial = - quote.status === "partial_approval_pending" || - quote.status === "partial_approved"; - const unpricedCount = allItems.length - ready.length; - const partialNote = - isPartial && unpricedCount > 0 - ? `${ready.length} of ${allItems.length} items — remaining ${unpricedCount} item${unpricedCount === 1 ? "" : "s"} to follow in a separate quote.` - : null; - - // Letterhead + logo (SVG as a data URI). @react-pdf v4.5.1 corrupts - // any raster (PNG/JPEG) byte ≥ 0x80, so we ship SVG instead — text - // goes through a different code path and sidesteps the bug. See - // `logoDataUri` JSDoc on QuoteDocumentProps for the full story. - const letterhead = await fetchLetterhead(letterheadCode); - const logoDataUri = await loadLetterheadLogoDataUri(letterheadCode); - - // Render - const pdfBuffer = await renderQuotePdf({ - quote, - items: itemsForPdf, - customer, - letterhead, - logoDataUri, - partialNote, - quoteNotes: quote.quote_notes ?? null, - noQuote: isNoQuote, - }); - - // Build a stable, human-readable filename - const safeNumber = String(quote.quote_number || quoteId).replace( - /[^\w.-]/g, - "_" - ); - const filename = `Quote-${letterheadCode}-${safeNumber}.pdf`; - - // Save to quote_attachments (multipart) - const form = new FormData(); - form.append( - "file", - new Blob([new Uint8Array(pdfBuffer)], { type: "application/pdf" }), - filename - ); - form.append("quote", quoteId); - form.append("filename", filename); - form.append("content_type", "application/pdf"); - form.append("size_bytes", String(pdfBuffer.length)); - form.append("kind", "pdf"); - form.append("email_message_id", ""); // not from email intake - form.append("uploaded_by", "process-page"); - const saved = await pb - .collection("quote_attachments") - .create(form); - - // Flip status to `approval_pending` (or `partial_approval_pending` - // if some items are unpriced) on successful render — PDF generation - // IS the moment the quote becomes "ready for rep review", so the - // operator no longer has to click a separate "Submit for Approval" - // button. The flip is conditional on: - // - // - Status not in TERMINAL_STATUSES (don't clobber approved / - // emailed / cancelled / complete / in-flight sending) - // - Status not in APPROVAL_STATES (idempotent regen while in - // the rep queue — just refresh `pdf_generated_at`) - // - // `rejected` is intentionally NOT in TERMINAL_STATUSES, so a regen - // after a rejection + price fix bounces the quote back into the - // rep's queue without a separate "resubmit" click. - let newStatus = quote.status; - let pdfGeneratedAt: string | null = quote.pdf_generated_at || null; - if (TERMINAL_STATUSES.has(quote.status) || APPROVAL_STATES.has(quote.status)) { - // Idempotent / no-flip path: still update the timestamp so we - // know the PDF was regenerated, but don't change the status. - if (!TERMINAL_STATUSES.has(quote.status)) { - pdfGeneratedAt = new Date().toISOString(); - await pb.collection("quotes").update(quoteId, { - pdf_generated_at: pdfGeneratedAt, - }); - } - } else { - pdfGeneratedAt = new Date().toISOString(); - // Pick partial vs full based on whether every priced item is - // ready to send. `ready` is the array of priced, has-supplier - // items (defined earlier in the validation block). - const targetStatus = !isNoQuote && ready.length !== allItems.length - ? "partial_approval_pending" - : "approval_pending"; - await pb.collection("quotes").update(quoteId, { - status: targetStatus, - pdf_generated_at: pdfGeneratedAt, - }); - newStatus = targetStatus; - } + return renderOutcomeToResponse(outcome); +} +/** + * Map a `GenerateQuotePdfOutcome` to the HTTP response shape the + * UI + MCP layer already speak. Centralised here so any future + * caller of the service can re-use the same translation without + * copy-pasting the status-mapping logic. + */ +function renderOutcomeToResponse(outcome: GenerateQuotePdfOutcome) { + if (outcome.kind === "ok") { return NextResponse.json({ ok: true, - attachmentId: saved.id, - status: newStatus, - pdfGeneratedAt, - filename, - // Surface which items were skipped so the UI can flag them too. - skippedItemIds: issues!.map((i) => i.itemId), - // Partial-quote context (added 2026-07-24). The approvals page - // reads these to render the "5 of 15" badge and decide whether - // to show the "Approve & send partial" CTA. Null when this - // is a full quote. - readyItemCount: ready.length, - totalItemCount: allItems.length, - isPartial: !!partialNote, + attachmentId: outcome.attachmentId, + status: outcome.status, + pdfGeneratedAt: outcome.pdfGeneratedAt, + filename: outcome.filename, + skippedItemIds: outcome.skippedItemIds, + readyItemCount: outcome.readyItemCount, + totalItemCount: outcome.totalItemCount, + isPartial: outcome.isPartial, }); - } catch (err) { - console.error("[quote-pdf] render failed:", err); - return NextResponse.json( - { - error: "PDF render failed", - detail: err instanceof Error ? err.message : String(err), - }, - { status: 500 } - ); } + if (outcome.kind === "readiness_failure") { + const body: ValidationErrorBody = { + error: outcome.message, + code: outcome.code, + details: outcome.details, + }; + return NextResponse.json(body, { status: 422 }); + } + // render_error + return NextResponse.json( + { + error: outcome.message, + detail: outcome.detail, + }, + { status: 500 } + ); } // ───────────────────────────────────────────────────────────────────────────── @@ -421,9 +170,12 @@ export async function PATCH( const pb = await getAdminPocketBase(); const customer = await pb .collection("customers") - .getOne(newCustomerId); + .getOne<{ id: string; customer: string; email?: string }>(newCustomerId); - // Same placeholder block as the PDF route. + // Same placeholder block as the PDF route. We also reject if + // the new customer has no email — the operator would hit + // `no_recipient` at send time anyway, but failing here gives + // a clearer error earlier. if ( PLACEHOLDER_CUSTOMER_IDS.has(customer.id) || PLACEHOLDER_CUSTOMER_NAMES.has((customer.customer || "").trim()) || @@ -531,4 +283,4 @@ export async function GET( { status: 500 } ); } -} \ No newline at end of file +} diff --git a/src/lib/server/quote-pdf-service.test.ts b/src/lib/server/quote-pdf-service.test.ts new file mode 100644 index 0000000..bacdcb5 --- /dev/null +++ b/src/lib/server/quote-pdf-service.test.ts @@ -0,0 +1,587 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; + +// The mock state has to be hoisted to the top of the file because Vitest +// hoists `vi.mock` factories above all imports. Mirrors the pattern used +// in quotes-service.test.ts so the assertions here exercise the same PB +// semantics (filter parsing, sort, `updated` ISO timestamps). +const { pbMock, clearStore } = vi.hoisted(() => { + const store: Record>> = {}; + let seq = 0; + const nextId = () => `mock-${++seq}`; + + const getOrCreate = (collection: string) => { + if (!store[collection]) store[collection] = new Map(); + return store[collection]; + }; + + const stripOuterParens = (s: string): string => { + const trimmed = s.trim(); + if (!trimmed.startsWith("(") || !trimmed.endsWith(")")) return s; + let depth = 0; + for (let i = 0; i < trimmed.length; i++) { + if (trimmed[i] === "(") depth++; + else if (trimmed[i] === ")") { + depth--; + if (depth === 0 && i < trimmed.length - 1) return s; + } + } + return depth === 0 ? trimmed.slice(1, -1) : s; + }; + + type FilterLeaf = { + field: string; + op: "=" | "!=" | "=~" | "!~" | ">=" | "<="; + value: string; + }; + + const parseFilter = (raw: string): FilterLeaf | { and: [unknown, unknown] } | { or: [unknown, unknown] } | null => { + let s = raw.trim(); + for (;;) { + const next = stripOuterParens(s); + if (next === s) break; + s = next; + } + // Top-level `||` first (PB: `||` is lower precedence than `&&`). + let depth = 0; + for (let i = 0; i < s.length - 1; i++) { + const c = s[i]; + if (c === "(") depth++; + else if (c === ")") depth--; + else if (depth === 0 && s[i] === "|" && s[i + 1] === "|") { + return { + or: [parseFilter(s.slice(0, i)), parseFilter(s.slice(i + 2))], + }; + } + } + depth = 0; + for (let i = 0; i < s.length - 1; i++) { + const c = s[i]; + if (c === "(") depth++; + else if (c === ")") depth--; + else if (depth === 0 && s[i] === "&" && s[i + 1] === "&") { + return { + and: [parseFilter(s.slice(0, i)), parseFilter(s.slice(i + 2))], + }; + } + } + // Leaf: `field op "value"`. Operators we care about for the + // staleness probe: `=`, `!=`, `>=`, `<=`. Quote-handling is + // minimal — values are read as the quoted string between the + // operator and the end of the trimmed string. + const m = s.match(/^(\w+)\s*(=|!=|=~|!~|>=|<=)\s*("?)(.*?)\3$/); + if (!m) return null; + return { + field: m[1], + op: m[2] as FilterLeaf["op"], + value: m[4], + }; + }; + + const evalFilter = (node: unknown, row: Record): boolean => { + if (node === null) return true; + if (typeof node === "object" && node !== null) { + if ("and" in node) { + const n = node as { and: [unknown, unknown] }; + return evalFilter(n.and[0], row) && evalFilter(n.and[1], row); + } + if ("or" in node) { + const n = node as { or: [unknown, unknown] }; + return evalFilter(n.or[0], row) || evalFilter(n.or[1], row); + } + const leaf = node as FilterLeaf; + const rv = row[leaf.field]; + const sv = leaf.value; + switch (leaf.op) { + case "=": + return String(rv ?? "") === sv; + case "!=": + return String(rv ?? "") !== sv; + case "=~": + return String(rv ?? "").includes(sv); + case "!~": + return !String(rv ?? "").includes(sv); + case ">=": + return String(rv ?? "") >= sv; + case "<=": + return String(rv ?? "") <= sv; + } + } + return true; + }; + + const matches = (row: Record, filter?: string): boolean => { + if (!filter) return true; + const parsed = parseFilter(filter); + if (!parsed) return true; + return evalFilter(parsed, row); + }; + + const pbMock = { + collection(name: string) { + const coll = getOrCreate(name); + return { + async getOne(id: string, opts?: { fields?: string; expand?: string }) { + const row = coll.get(id); + if (!row) throw new Error(`404: ${name}/${id} not found`); + // Mock the `fields` projection (only the requested fields). + if (opts?.fields) { + const projected: Record = {}; + for (const f of opts.fields.split(",")) { + if (f in row) projected[f] = row[f]; + } + return { ...projected, id, collectionId: `pbc_${name}`, collectionName: name } as T; + } + return { ...row, id, collectionId: `pbc_${name}`, collectionName: name } as T; + }, + async getList( + page: number, + perPage: number, + opts?: { + filter?: string; + sort?: string; + fields?: string; + expand?: string; + batch?: number; + }, + ) { + const all = Array.from(coll.values()); + const filtered = opts?.filter + ? all.filter((r) => matches(r, opts.filter)) + : all; + // Sort. We only support a single `field` direction and + // `-field`. Sufficient for `uploaded_at` / `updated`. + const sorted = [...filtered]; + if (opts?.sort) { + const desc = opts.sort.startsWith("-"); + const field = desc ? opts.sort.slice(1) : opts.sort; + sorted.sort((a, b) => { + const av = String(a[field] ?? ""); + const bv = String(b[field] ?? ""); + return desc ? bv.localeCompare(av) : av.localeCompare(bv); + }); + } + const items = sorted + .slice((page - 1) * perPage, page * perPage) + .map((r) => { + if (opts?.fields) { + const projected: Record = {}; + for (const f of opts.fields.split(",")) { + if (f in r) projected[f] = r[f]; + } + return { ...projected, id: r.id }; + } + return { ...r, id: r.id }; + }) as unknown as T[]; + return { items, totalItems: filtered.length, totalPages: Math.max(1, Math.ceil(filtered.length / perPage)) }; + }, + async getFullList( + opts?: { filter?: string; fields?: string; sort?: string; batch?: number }, + ) { + const res = await this.getList(1, opts?.batch ?? 500, opts); + return res.items; + }, + async create(data: Record) { + const id = nextId(); + const now = new Date().toISOString(); + // New PB rows default to `is_processed: false` for quote_items + // — matches the production schema default. Operators flip this + // to true when they save the pricing modal. + const merged: Record = { + cost_price: 0, + selling_price: 0, + markup: 0, + is_processed: false, + moved_to_quote: null, + supplier: null, + ...data, + created: data.created || now, + updated: now, + }; + coll.set(id, merged); + return { ...merged, id }; + }, + async update(id: string, patch: Record) { + const existing = coll.get(id); + if (!existing) throw new Error(`404: ${id} not found`); + const now = new Date().toISOString(); + for (const [key, value] of Object.entries(patch)) { + if (key === "updated") continue; + if (value === undefined) continue; + existing[key] = value; + } + existing.updated = patch.updated === undefined ? now : String(patch.updated); + return { ...existing, id }; + }, + async delete(id: string) { + coll.delete(id); + }, + }; + }, + }; + + const clearStore = () => { + for (const key of Object.keys(store)) delete store[key]; + seq = 0; + }; + + return { store, pbMock, clearStore }; +}); + +vi.mock("@/lib/server/pocketbase-admin", () => ({ + getAdminPocketBase: vi.fn().mockResolvedValue(pbMock), +})); + +// Mock the renderer's side-effects (no actual PDF bytes here — we +// assert the service contract, not the rendered bytes; the +// dedicated render.test.ts owns visual smoke tests). +vi.mock("@/lib/pdf/render", () => ({ + renderQuotePdf: vi.fn().mockResolvedValue(new Uint8Array([0x25, 0x50, 0x44, 0x46])), // "%PDF" +})); + +// Mock the letterhead helpers — they read from PB and disk, which +// we don't want this unit test to touch. The render service is +// tested separately. +vi.mock("@/lib/letterhead", () => ({ + fetchLetterhead: vi.fn().mockResolvedValue({ + id: "lh1", + collectionId: "pbc_letterhead", + collectionName: "letterhead_settings", + created: "2026-01-01T00:00:00Z", + updated: "2026-01-01T00:00:00Z", + display_name: "CNL Mining Supplies", + address: "47 Station Street, Carletonville", + phone: "+27 18 786 2897", + email: "quotes@wfsales.co.za", + vat_number: "4840229449", + registration_number: "2014/004024/07", + bank_name: "Standard Bank", + bank_account_number: "022196552", + bank_branch_code: "016141", + terms: "Standard terms", + default_markup_percent: 30, + footer_text: "", + logo: "", + is_active: true, + company: "CNL Mining Supplies (Pty) Ltd", + accent_color: "#738224", + }), + loadLetterheadLogoDataUri: vi.fn().mockResolvedValue(null), + LETTERHEAD_CODES: ["CNL", "ISHERWOOD", "WESTSCOTT GROUP OF COMPANIES"], +})); + +import { isQuotePdfStale, generateQuotePdf } from "./quote-pdf-service"; + +// ───────────────────────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Create a customer, quote, and PDF attachment row in one shot. + * Returns the ids so tests can target downstream writes. The PDF + * timestamp defaults to "now" — callers that need a specific + * timestamp can pass `pdfUploadedAt` for the staleness probe. + */ +async function seedQuoteWithPdf(opts: { + pdfUploadedAt?: string; + customerName?: string; + customerEmail?: string; +} = {}) { + const customer = await pbMock.collection("customers").create({ + customer: opts.customerName ?? "Acme Mining", + email: opts.customerEmail ?? "buyer@acme.co.za", + company: "Acme", + dateadded: "", + lastvisiteddate: "", + rep: "", + coordinates: "", + description: "", + alternative_email: "", + buyer_email: "", + number: "", + }); + const quote = await pbMock.collection("quotes").create({ + quote_number: "Q-2026-09-001", + customer: customer.id, + status: "approval_pending", + notes: "", + email_sender: "", + email_subject: "", + email_body: "", + has_attachments: false, + company_letterhead: "CNL", + }); + if (opts.pdfUploadedAt !== null) { + await pbMock.collection("quote_attachments").create({ + quote: quote.id, + filename: "Quote-CNL-Q-2026-09-001.pdf", + file: ["pdf-token"], + content_type: "application/pdf", + size_bytes: 12345, + email_message_id: "", + kind: "pdf", + direction: "outbound", + uploaded_at: opts.pdfUploadedAt ?? new Date().toISOString(), + }); + } + return { quote, customer }; +} + +beforeEach(() => { + clearStore(); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// isQuotePdfStale +// ───────────────────────────────────────────────────────────────────────────── + +describe("isQuotePdfStale", () => { + it("returns true when no PDF exists yet", async () => { + const customer = await pbMock.collection("customers").create({ + customer: "Acme", + email: "buyer@acme.co.za", + company: "Acme", + dateadded: "", + lastvisiteddate: "", + rep: "", + coordinates: "", + description: "", + alternative_email: "", + buyer_email: "", + number: "", + }); + const quote = await pbMock.collection("quotes").create({ + quote_number: "Q-2026-09-001", + customer: customer.id, + status: "approval_pending", + notes: "", + email_sender: "", + email_subject: "", + email_body: "", + has_attachments: false, + company_letterhead: "CNL", + }); + expect(await isQuotePdfStale(quote.id)).toBe(true); + }); + + it("returns false when items + quote haven't changed since the PDF", async () => { + // Seed a quote + PDF that share the same ISO timestamp as the + // quote's `updated`. After we touch neither, the probe should + // see PDF as at-or-after both and return false. + const now = new Date("2026-09-07T10:00:00Z").toISOString(); + const { quote } = await seedQuoteWithPdf({ pdfUploadedAt: now }); + // The quote mock sets `updated` to now-on-create; force it to + // match the PDF timestamp exactly so the probe has no drift. + await pbMock.collection("quotes").update(quote.id, { updated: now }); + + expect(await isQuotePdfStale(quote.id)).toBe(false); + }); + + it("returns true when an item has been updated after the PDF", async () => { + const pdfAt = new Date("2026-09-07T10:00:00Z").toISOString(); + const { quote } = await seedQuoteWithPdf({ pdfUploadedAt: pdfAt }); + // Force the quote + PDF to share the baseline timestamp. + await pbMock.collection("quotes").update(quote.id, { updated: pdfAt }); + // Add an item that's strictly newer. + await pbMock.collection("quote_items").create({ + quote: quote.id, + description: "M16 hex bolt", + quantity: 1, + cost_price: 2.39, + selling_price: 3.11, + markup: 30, + supplier: "sup-acme", + is_processed: true, + moved_to_quote: null, + }); + + expect(await isQuotePdfStale(quote.id)).toBe(true); + }); + + it("returns true when the quote row has been updated after the PDF", async () => { + // Covers quote-level changes that flow into the PDF (notes, + // validity_days, follow_up_due, letterhead, is_no_quote, + // customer reassignment). + const pdfAt = new Date("2026-09-07T10:00:00Z").toISOString(); + const { quote } = await seedQuoteWithPdf({ pdfUploadedAt: pdfAt }); + // Quote.update bumps `updated` to a fresh `now()` (mock + // default). We simulate an edit to quote_notes. + await pbMock.collection("quotes").update(quote.id, { + quote_notes: "Updated terms", + }); + + expect(await isQuotePdfStale(quote.id)).toBe(true); + }); + + it("ignores items that were moved to a continuation quote", async () => { + // A continuation move sets `moved_to_quote` on the item so it + // no longer belongs to this quote. Such edits should not + // mark this quote's PDF as stale. + const pdfAt = new Date("2026-09-07T10:00:00Z").toISOString(); + const { quote } = await seedQuoteWithPdf({ pdfUploadedAt: pdfAt }); + await pbMock.collection("quotes").update(quote.id, { updated: pdfAt }); + await pbMock.collection("quote_items").create({ + quote: quote.id, + description: "M16 hex bolt", + quantity: 1, + cost_price: 2.39, + selling_price: 3.11, + markup: 30, + supplier: "sup-acme", + is_processed: true, + moved_to_quote: "CONTINUATION_QUOTE_ID", + }); + + expect(await isQuotePdfStale(quote.id)).toBe(false); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// generateQuotePdf — focused on the failure paths we surface from +// sendQuoteEmail. The render smoke test lives in render.test.ts. +// ───────────────────────────────────────────────────────────────────────────── + +describe("generateQuotePdf", () => { + it("returns placeholder_customer when the customer is a sentinel", async () => { + // Seed a quote pointing at the Unknown Sender sentinel. + const customer = await pbMock.collection("customers").create({ + customer: "Unknown Sender", + email: "unknown-sender@no-from.wfdash.local", + company: "", + dateadded: "", + lastvisiteddate: "", + rep: "", + coordinates: "", + description: "", + alternative_email: "", + buyer_email: "", + number: "", + }); + const quote = await pbMock.collection("quotes").create({ + quote_number: "Q-2026-09-001", + customer: customer.id, + status: "approval_pending", + notes: "", + email_sender: "", + email_subject: "", + email_body: "", + has_attachments: false, + company_letterhead: "CNL", + }); + + const outcome = await generateQuotePdf(quote.id, { letterhead: "CNL" }); + expect(outcome.kind).toBe("readiness_failure"); + if (outcome.kind === "readiness_failure") { + expect(outcome.code).toBe("placeholder_customer"); + expect(outcome.details.customerName).toBe("Unknown Sender"); + } + }); + + it("returns items_not_ready when no items are priced", async () => { + const { quote } = await seedQuoteWithPdf(); + // No quote_items seeded — nothing is ready. + const outcome = await generateQuotePdf(quote.id, { letterhead: "CNL" }); + expect(outcome.kind).toBe("readiness_failure"); + if (outcome.kind === "readiness_failure") { + expect(outcome.code).toBe("no_items"); + } + }); + + it("returns items_not_ready when at least one item is missing fields", async () => { + const { quote } = await seedQuoteWithPdf(); + // Seed one ready line + one line missing a supplier so the + // readiness gate rejects the render. + await pbMock.collection("quote_items").create({ + quote: quote.id, + description: "M16 hex bolt", + quantity: 1, + cost_price: 2.39, + selling_price: 3.11, + markup: 30, + supplier: "sup-acme", + is_processed: true, + moved_to_quote: null, + }); + await pbMock.collection("quote_items").create({ + quote: quote.id, + description: "Unpriced item", + quantity: 1, + cost_price: 0, + selling_price: 0, + markup: 0, + supplier: null, + is_processed: false, + moved_to_quote: null, + }); + + const outcome = await generateQuotePdf(quote.id, { letterhead: "CNL" }); + expect(outcome.kind).toBe("readiness_failure"); + if (outcome.kind === "readiness_failure") { + expect(outcome.code).toBe("items_not_ready"); + expect(outcome.details.issues?.length).toBe(1); + expect(outcome.details.issues?.[0].description).toBe("Unpriced item"); + expect(outcome.details.issues?.[0].missing).toContain("no supplier"); + } + }); + + it("returns ok + saves a fresh attachment on the happy path", async () => { + const { quote } = await seedQuoteWithPdf(); + await pbMock.collection("quote_items").create({ + quote: quote.id, + description: "M16 hex bolt", + quantity: 1, + cost_price: 2.39, + selling_price: 3.11, + markup: 30, + supplier: "sup-acme", + is_processed: true, + moved_to_quote: null, + }); + + const outcome = await generateQuotePdf(quote.id, { letterhead: "CNL" }); + expect(outcome.kind).toBe("ok"); + if (outcome.kind === "ok") { + expect(outcome.attachmentId).toBeTruthy(); + expect(outcome.filename).toMatch(/^Quote-CNL-.*\.pdf$/); + expect(outcome.readyItemCount).toBe(1); + expect(outcome.totalItemCount).toBe(1); + expect(outcome.isPartial).toBe(false); + + // The PDF attachment should now exist on the quote (two PDFs + // total: the one seeded + the freshly-rendered one). + const atts = await pbMock + .collection("quote_attachments") + .getFullList<{ kind: string }>({ + filter: `quote = "${quote.id}" && kind = "pdf"`, + }); + expect(atts.length).toBe(2); + } + }); + + it("renders all items (including unpriced) for a no-quote / decline letter", async () => { + // For `is_no_quote` quotes the readiness gate is relaxed — every + // active item renders through, even without supplier / prices. + const { quote } = await seedQuoteWithPdf(); + await pbMock.collection("quotes").update(quote.id, { + is_no_quote: true, + }); + await pbMock.collection("quote_items").create({ + quote: quote.id, + description: "Declined line", + quantity: 1, + cost_price: 0, + selling_price: 0, + markup: 0, + supplier: null, + is_processed: false, + moved_to_quote: null, + }); + + const outcome = await generateQuotePdf(quote.id, { letterhead: "CNL" }); + expect(outcome.kind).toBe("ok"); + if (outcome.kind === "ok") { + // All items rendered even though none was priced. + expect(outcome.totalItemCount).toBe(1); + expect(outcome.readyItemCount).toBe(1); + } + }); +}); diff --git a/src/lib/server/quote-pdf-service.ts b/src/lib/server/quote-pdf-service.ts new file mode 100644 index 0000000..ee927fd --- /dev/null +++ b/src/lib/server/quote-pdf-service.ts @@ -0,0 +1,488 @@ +import { getAdminPocketBase } from "./pocketbase-admin"; +import { + fetchLetterhead, + LETTERHEAD_CODES, + loadLetterheadLogoDataUri, + type LetterheadCode, +} from "@/lib/letterhead"; +import { renderQuotePdf } from "@/lib/pdf/render"; +import { + PLACEHOLDER_CUSTOMER_IDS, + PLACEHOLDER_CUSTOMER_NAMES, +} from "@/lib/placeholder-customers"; +import type { + Quote, + QuoteItem, + Customer, + QuoteAttachment, +} from "@/lib/types"; + +/** + * Quote PDF service — server-side render + staleness check. + * + * Why this lives in `src/lib/server/` instead of the route handler: + * the PDF render pipeline needs to be called from two places: + * + * 1. `POST /api/v1/quotes/[id]/pdf` — the operator clicking + * "Generate PDF" on the process page. + * 2. `sendQuoteEmail` (in `quotes-service.ts`) — when the + * outbound send detects that the most recent PDF is older + * than the items (operator edited markup / cost / selling + * price after the last render). The send would otherwise + * ship a stale PDF to the customer. + * + * Putting the render logic in the route handler meant + * `sendQuoteEmail` couldn't reach it without an internal HTTP + * fetch loop. Extracting it here keeps both callers honest and + * follows the data-layer discipline in AGENTS.md (the route + * becomes a thin wrapper that validates the HTTP body + maps + * the outcome to a response). + * + * Status handling on success: + * + * - The render auto-flips the quote to `approval_pending` (or + * `partial_approval_pending` if not every priced item is + * ready). This is unchanged from the previous route-based + * implementation — PDF generation is the moment the quote + * becomes "ready for rep review" and replaces the old + * "Submit for Approval" button. + * - When the quote is already in `approval_pending` / + * `partial_approval_pending`, the route does an idempotent + * regen — `pdf_generated_at` gets bumped but status stays + * put. Same in this service. + * - When the quote is in a terminal state (`approved`, + * `emailed`, `complete`, `cancelled`) or in-flight (`sending`), + * the render still produces a fresh PDF but the status is + * NOT changed. The caller (the route or `sendQuoteEmail`) is + * responsible for status semantics — this service just + * renders and saves. + * + * `runtime = "nodejs"` is required because @react-pdf/renderer + * can't run on Edge. The route handler that wraps this service + * already declares that — no change here. + */ + +// ───────────────────────────────────────────────────────────────────────────── +// Shared status sets — kept in lock-step with the route's TERMINAL_STATUSES +// and APPROVAL_STATES constants. The route imports these so the two can't +// drift. +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Statuses where auto-flipping to `approval_pending` would clobber + * business state. Anything else flips forward to `approval_pending` + * (or `partial_approval_pending` if not all items are priced) on + * successful PDF render. + * + * Two important non-terminal statuses are exempt from the flip: + * + * - `sending` — in-flight SMTP send (n8n / PB hook). Clobbering + * this would orphan the send. + * - `approval_pending` / `partial_approval_pending` — idempotent + * regen while the quote is in the rep's queue; we only update + * `pdf_generated_at` and don't bounce it back to itself. + */ +export const TERMINAL_STATUSES = new Set([ + "approved", + "emailed", + "complete", + "cancelled", + "sending", +]); + +export const APPROVAL_STATES = new Set([ + "approval_pending", + "partial_approval_pending", +]); + +// ───────────────────────────────────────────────────────────────────────────── +// Outcome shape +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Per-line readiness issue raised by the pre-render gate. Same + * shape as the existing `ValidationErrorBody.details.issues` + * array in the route — kept identical so the UI, the route's + * 422 body, and the `sendQuoteEmail` failure all render the same + * list with no translation. + */ +export interface QuotePdfReadinessIssue { + itemId: string; + description: string; + missing: string[]; +} + +/** + * What the service returns from a render call. Discriminated union + * with a `kind` field so callers can branch without `try/catch`. + * + * - `kind: "ok"` — render + save succeeded. `attachmentId` is + * the freshly-saved `quote_attachments` row. + * - `kind: "readiness_failure"` — pre-render validation rejected + * (placeholder customer, no items, items not ready). Caller + * should treat this as a non-recoverable send failure. + * - `kind: "render_error"` — render crashed or PB threw. Caller + * can retry or surface to the operator. + */ +export type GenerateQuotePdfOutcome = + | { + kind: "ok"; + attachmentId: string; + filename: string; + /** The quote status AFTER the render completed. */ + status: string; + pdfGeneratedAt: string; + /** IDs of items that were filtered out of the PDF (not ready). */ + skippedItemIds: string[]; + readyItemCount: number; + totalItemCount: number; + isPartial: boolean; + } + | { + kind: "readiness_failure"; + code: "placeholder_customer" | "no_items" | "items_not_ready"; + message: string; + details: { + customerId?: string; + customerName?: string; + itemIds?: string[]; + itemDescriptions?: string[]; + issues?: QuotePdfReadinessIssue[]; + }; + } + | { + kind: "render_error"; + message: string; + detail?: string; + }; + +// ───────────────────────────────────────────────────────────────────────────── +// Public API +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Generate a fresh PDF for a quote, save it as a `quote_attachments` + * row (`kind="pdf"`), and update `quote.pdf_generated_at`. + * + * Returns a discriminated outcome — callers map this to either an + * HTTP response (the route) or a `SendQuoteEmailFailure` (the + * send-email pipeline). + * + * @param quoteId PB id of the quote to render + * @param options.letterhead Optional override; defaults to the quote's + * `company_letterhead` (CNL for uninitialised rows) + */ +export async function generateQuotePdf( + quoteId: string, + options?: { letterhead?: string }, +): Promise { + const pb = await getAdminPocketBase(); + + try { + // ── 1. Load quote + customer ───────────────────────────── + // We expand customer.company so the PDF can render the company + // name (not just the contact) in the "TO:" block. PB inlines + // the relation in the same response, no extra round-trip. + let quote: Quote; + try { + quote = await pb.collection("quotes").getOne(quoteId, { + expand: "customer,customer.company", + }); + } catch (err) { + return { + kind: "render_error", + message: "Quote not found", + detail: err instanceof Error ? err.message : String(err), + }; + } + + // No-quote / decline-letter flag. When true, every active item + // renders through to the PDF regardless of price — the PDF + // component itself renders "Not quoted" cells for those rows. + const isNoQuote = !!quote.is_no_quote; + const customer = quote.expand?.customer as Customer | undefined; + if (!customer) { + return { + kind: "render_error", + message: "Quote has no customer — cannot render PDF", + }; + } + + // ── 2. Letterhead resolution ───────────────────────────── + // Default to the quote's stored letterhead; let an explicit + // caller override win (the route does this from the request + // body; the send-email pipeline uses the stored value). + const requestedCode = options?.letterhead || quote.company_letterhead; + const letterheadCode = (LETTERHEAD_CODES as readonly string[]).includes( + requestedCode || "", + ) + ? (requestedCode as LetterheadCode) + : ("CNL" as LetterheadCode); + + // ── 3. Customer readiness gate ─────────────────────────── + // Block on placeholder customer. The office has to pick a + // real one on the process page before they can generate a + // PDF — sending a quote addressed to "Unknown Sender" is a + // data leak waiting to happen. + if ( + PLACEHOLDER_CUSTOMER_IDS.has(customer.id) || + PLACEHOLDER_CUSTOMER_NAMES.has((customer.customer || "").trim()) || + !customer.email + ) { + return { + kind: "readiness_failure", + code: "placeholder_customer", + message: + "This quote is still pointed at a placeholder / unnamed customer. " + + "Pick the real customer on the process page before generating a PDF.", + details: { + customerId: customer.id, + customerName: customer.customer || "(blank)", + }, + }; + } + + // ── 4. Item readiness gate ─────────────────────────────── + // Active items = priced items that haven't been moved to a + // continuation quote. Items with `moved_to_quote != null` + // were cloned off by the partial-approval split — they stay + // in PB for audit but are no longer "this quote's items". + // Filtering them here means the parent quote's PDF never + // accidentally re-includes items that now belong to a + // separate quote. + const allItems = await pb + .collection("quote_items") + .getFullList({ + filter: `quote = "${quoteId}" && (moved_to_quote = null || moved_to_quote = "")`, + sort: "created", + }); + + // A priced item is ready for the quote when: + // - is_processed = true (user clicked Save on the pricing modal) + // - supplier is set (every PDF line item has a supplier ref) + // - cost_price > 0 (the supplier gave us a price) + // - selling_price > 0 (we computed a sell price) + // Items missing any of these get flagged with `missing` so the UI + // can highlight them. + const ready: QuoteItem[] = []; + const issues: QuotePdfReadinessIssue[] = []; + for (const it of allItems) { + const missing: string[] = []; + if (!it.is_processed) missing.push("not processed"); + if (!it.supplier) missing.push("no supplier"); + if (!(it.cost_price > 0)) missing.push("cost price"); + if (!(it.selling_price > 0)) missing.push("selling price"); + if (missing.length === 0) { + ready.push(it); + } else { + issues.push({ + itemId: it.id, + description: it.description || "(no description)", + missing, + }); + } + } + + // itemsForPdf: what we hand to . For priced + // quotes this is the priced/processed subset (`ready`). For + // no-quote / decline letters we bypass the filter and render + // ALL active items — the PDF component decides how to render + // the price/total cells. + const itemsForPdf = isNoQuote ? allItems : ready; + + if (!isNoQuote && ready.length === 0) { + return { + kind: "readiness_failure", + code: allItems.length === 0 ? "no_items" : "items_not_ready", + message: + allItems.length === 0 + ? "This quote has no items yet. Add line items on the quote detail page first." + : "No items are ready to quote. Every item needs: processed ✓, supplier ✓, cost price, and selling price.", + details: { + itemIds: issues.map((i) => i.itemId), + itemDescriptions: issues.map((i) => i.description), + issues, + }, + }; + } + + // Partial-quote note: when the quote is in a partial-approval + // status AND there are still unpriced items, surface an amber + // callout: "5 of 15 items — remaining items to follow". + const isPartial = + quote.status === "partial_approval_pending" || + quote.status === "partial_approved"; + const unpricedCount = allItems.length - ready.length; + const partialNote = + isPartial && unpricedCount > 0 + ? `${ready.length} of ${allItems.length} items — remaining ${unpricedCount} item${unpricedCount === 1 ? "" : "s"} to follow in a separate quote.` + : null; + + // ── 5. Render ──────────────────────────────────────────── + // Letterhead + logo (SVG as a data URI). @react-pdf v4.5.1 + // corrupts any raster (PNG/JPEG) byte ≥ 0x80, so we ship SVG + // instead. + const letterhead = await fetchLetterhead(letterheadCode); + const logoDataUri = await loadLetterheadLogoDataUri(letterheadCode); + + const pdfBuffer = await renderQuotePdf({ + quote, + items: itemsForPdf, + customer, + letterhead, + logoDataUri, + partialNote, + quoteNotes: quote.quote_notes ?? null, + noQuote: isNoQuote, + }); + + // Build a stable, human-readable filename + const safeNumber = String(quote.quote_number || quoteId).replace( + /[^\w.-]/g, + "_", + ); + const filename = `Quote-${letterheadCode}-${safeNumber}.pdf`; + + // ── 6. Save to quote_attachments ───────────────────────── + const form = new FormData(); + form.append( + "file", + new Blob([new Uint8Array(pdfBuffer)], { type: "application/pdf" }), + filename, + ); + form.append("quote", quoteId); + form.append("filename", filename); + form.append("content_type", "application/pdf"); + form.append("size_bytes", String(pdfBuffer.length)); + form.append("kind", "pdf"); + form.append("email_message_id", ""); // not from email intake + form.append("uploaded_by", "quote-pdf-service"); + const saved = await pb + .collection("quote_attachments") + .create(form); + + // ── 7. Status flip / timestamp bump ────────────────────── + let newStatus = quote.status; + let pdfGeneratedAt: string | null = quote.pdf_generated_at || null; + if ( + TERMINAL_STATUSES.has(quote.status) || + APPROVAL_STATES.has(quote.status) + ) { + // Idempotent / no-flip path: still update the timestamp so + // we know the PDF was regenerated, but don't change the + // status. (Skipped entirely for true-terminal statuses — + // `approved` / `emailed` / `complete` / `cancelled` — those + // shouldn't be silently re-stamped.) + if (!TERMINAL_STATUSES.has(quote.status)) { + pdfGeneratedAt = new Date().toISOString(); + await pb.collection("quotes").update(quoteId, { + pdf_generated_at: pdfGeneratedAt, + }); + } + } else { + pdfGeneratedAt = new Date().toISOString(); + const targetStatus = + !isNoQuote && ready.length !== allItems.length + ? "partial_approval_pending" + : "approval_pending"; + await pb.collection("quotes").update(quoteId, { + status: targetStatus, + pdf_generated_at: pdfGeneratedAt, + }); + newStatus = targetStatus; + } + + return { + kind: "ok", + attachmentId: saved.id, + filename, + status: newStatus, + pdfGeneratedAt: pdfGeneratedAt || new Date().toISOString(), + skippedItemIds: issues.map((i) => i.itemId), + readyItemCount: ready.length, + totalItemCount: allItems.length, + isPartial: !!partialNote, + }; + } catch (err) { + return { + kind: "render_error", + message: "PDF render failed", + detail: err instanceof Error ? err.message : String(err), + }; + } +} + +/** + * Returns `true` when the most recent PDF (if any) for this quote + * is older than the most recent edit to its priced items OR to + * the quote itself. In that case `generateQuotePdf` should be + * called before the email send so the customer receives a PDF + * that reflects the operator's latest edits. + * + * Staleness rule — a PDF is stale when ANY of: + * + * - No `kind="pdf"` attachment exists for the quote yet (the + * first send needs a render). + * - The most recent PDF's `uploaded_at` is older than the most + * recent `quote_items.updated` for active items on this quote. + * This catches markup / cost / selling / supplier / description + * edits — every PATCH bumps `updated`. + * - The most recent PDF's `uploaded_at` is older than the quote + * row's own `updated`. This catches quote-level changes that + * flow into the PDF: `quote_notes`, `validity_days`, + * `follow_up_due`, `company_letterhead`, `is_no_quote`, + * customer reassignment, etc. + * + * "Active items" excludes rows with `moved_to_quote != null` — + * those belong to a continuation quote and don't appear on this + * quote's PDF. + * + * Cheap: two PB queries (one for items, one for the most recent + * PDF — `quote.updated` is already on the row that callers of + * this function tend to have). Sub-millisecond on a healthy PB. + */ +export async function isQuotePdfStale(quoteId: string): Promise { + const pb = await getAdminPocketBase(); + + // Look up the most recent PDF. We only need the uploaded_at + // field — `fields: "uploaded_at"` keeps the wire payload tiny + // even when this PDF is one of many on the quote (the operator + // may have uploaded dozens of attachments over the lifecycle). + const latestPdf = await pb + .collection("quote_attachments") + .getList<{ uploaded_at?: string }>(1, 1, { + filter: `quote = "${quoteId}" && kind = "pdf"`, + sort: "-uploaded_at", + fields: "uploaded_at", + }); + const pdfAt = latestPdf.items[0]?.uploaded_at; + + // No PDF yet → stale (the send needs one). + if (!pdfAt) return true; + + // Find the most recent `updated` across active items on this + // quote. We pull only `updated` — even on a quote with 200 + // lines, that's a few KB over the wire. + const latestItem = await pb + .collection("quote_items") + .getList<{ updated?: string }>(1, 1, { + filter: `quote = "${quoteId}" && (moved_to_quote = null || moved_to_quote = "")`, + sort: "-updated", + fields: "updated", + }); + const itemAt = latestItem.items[0]?.updated; + + // Find the most recent quote-level edit. Pulled explicitly + // rather than re-using a caller-loaded quote so this helper + // is self-contained. + const quote = await pb + .collection("quotes") + .getOne<{ updated: string }>(quoteId, { fields: "updated" }); + const quoteAt = quote.updated; + + // Both PB timestamps are ISO strings — lexicographic compare + // matches chronological compare for ISO 8601 with consistent + // timezone offsets, which PB always emits (UTC, no fractional + // seconds). `String(a) > String(b)` is sufficient. + return itemAt > pdfAt || quoteAt > pdfAt; +} diff --git a/src/lib/server/quotes-service.test.ts b/src/lib/server/quotes-service.test.ts index 05dc31a..3961be1 100644 --- a/src/lib/server/quotes-service.test.ts +++ b/src/lib/server/quotes-service.test.ts @@ -247,8 +247,11 @@ const { pbMock, clearStore } = vi.hoisted(() => { return { store, pbMock, clearStore }; }); -vi.mock("@/lib/server/pocketbase-admin", () => ({ - getAdminPocketBase: vi.fn().mockResolvedValue(pbMock), +vi.mock("./quote-pdf-service", () => ({ + isQuotePdfStale: vi.fn(), + generateQuotePdf: vi.fn(), + isQuotePdfStaleMock: vi.fn(), + generateQuotePdfMock: vi.fn(), })); import { @@ -1381,6 +1384,197 @@ describe("sendQuoteEmail", () => { }); }); +/** + * Stale-PDF auto-regen (added 2026-09-07). + * + * Bug: the operator edits markup on the approval screen, then + * clicks Approve & Send. The previous `sendQuoteEmail` path + * looked up the most recent `kind="pdf"` attachment and shipped + * it as-is — the price changes never made it into the email. + * + * Fix: before flipping to `sending`, the service runs a + * staleness probe and re-renders via `generateQuotePdf` when any + * priced item or the quote row itself has been updated after the + * PDF. If the regen fails the send is refused (we don't fall + * back to the stale PDF). The probe is mocked out via + * `isQuotePdfStale` so each test below controls exactly which + * branch is exercised. + */ +describe("sendQuoteEmail stale-PDF auto-regen", () => { + /** + * Reach into the vi.mock factory for the pdf service and force + * the probe / render functions to return what THIS test wants. + * Resets to defaults at the end so neighbouring tests are + * unaffected. + */ + async function withMocks(opts: { + isStale: boolean; + regenOutcome?: "ok" | "items_not_ready" | "placeholder_customer" | "render_error"; + }) { + const { isQuotePdfStaleMock, generateQuotePdfMock } = await import( + "./quote-pdf-service" + ); + (isQuotePdfStaleMock as unknown as ReturnType).mockResolvedValue( + opts.isStale, + ); + const regenKind = opts.regenOutcome ?? "ok"; + if (regenKind === "ok") { + (generateQuotePdfMock as unknown as ReturnType).mockResolvedValueOnce({ + kind: "ok", + attachmentId: "fresh-pdf-id", + filename: "Quote-CNL-regen.pdf", + status: "approval_pending", + pdfGeneratedAt: new Date().toISOString(), + skippedItemIds: [], + readyItemCount: 1, + totalItemCount: 1, + isPartial: false, + }); + } else if (regenKind === "items_not_ready") { + (generateQuotePdfMock as unknown as ReturnType).mockResolvedValueOnce({ + kind: "readiness_failure", + code: "items_not_ready", + message: "1 item missing supplier", + details: { + itemIds: ["item-1"], + issues: [ + { + itemId: "item-1", + description: "M16 hex bolt", + missing: ["no supplier"], + }, + ], + }, + }); + } else if (regenKind === "placeholder_customer") { + (generateQuotePdfMock as unknown as ReturnType).mockResolvedValueOnce({ + kind: "readiness_failure", + code: "placeholder_customer", + message: "Quote pointed at placeholder customer", + details: { customerId: "cust-1", customerName: "Unknown Sender" }, + }); + } else { + (generateQuotePdfMock as unknown as ReturnType).mockResolvedValueOnce({ + kind: "render_error", + message: "Render crashed", + detail: "JSX out of memory", + }); + } + } + + function clearMocks() { + return Promise.all([ + import("./quote-pdf-service").then(({ isQuotePdfStaleMock, generateQuotePdfMock }) => { + (isQuotePdfStaleMock as unknown as ReturnType).mockReset(); + (generateQuotePdfMock as unknown as ReturnType).mockReset(); + }), + ]); + } + + it("regenerates the PDF when items have changed since the last render", async () => { + const { quote } = await seedReadyQuote(); + await withMocks({ isStale: true, regenOutcome: "ok" }); + + const result = await sendQuoteEmail(quote.id, { + cc: "", + bcc: "", + subject: "", + message: "", + attachAuthLetters: true, + selectedAttachmentIds: [], + }); + expect(result.failure).toBeNull(); + expect(result.quote.status).toBe("sending"); + + const { generateQuotePdfMock } = await import("./quote-pdf-service"); + expect(generateQuotePdfMock).toHaveBeenCalledTimes(1); + expect(generateQuotePdfMock).toHaveBeenCalledWith(quote.id); + await clearMocks(); + }); + + it("does NOT regenerate when the PDF is fresh", async () => { + const { quote } = await seedReadyQuote(); + await withMocks({ isStale: false, regenOutcome: "ok" }); + + const result = await sendQuoteEmail(quote.id, { + cc: "", + bcc: "", + subject: "", + message: "", + attachAuthLetters: true, + selectedAttachmentIds: [], + }); + expect(result.failure).toBeNull(); + expect(result.quote.status).toBe("sending"); + + const { generateQuotePdfMock } = await import("./quote-pdf-service"); + expect(generateQuotePdfMock).not.toHaveBeenCalled(); + await clearMocks(); + }); + + it("refuses to send when the regen hits items_not_ready", async () => { + // Operator blanked a price between the last render and the + // send click. We refuse to ship the stale PDF and surface + // the per-line issue so the operator sees exactly what to + // fix. + const { quote } = await seedReadyQuote(); + await withMocks({ isStale: true, regenOutcome: "items_not_ready" }); + + const result = await sendQuoteEmail(quote.id, { + cc: "", + bcc: "", + subject: "", + message: "", + attachAuthLetters: true, + selectedAttachmentIds: [], + }); + expect(result.failure?.code).toBe("items_not_ready"); + expect(result.failure?.message).toMatch(/Could not regenerate the PDF/); + expect(result.failure?.details.issues?.[0].missing).toContain("no supplier"); + expect(result.quote.status).toBe("mail_failed"); + await clearMocks(); + }); + + it("refuses to send when the regen hits placeholder_customer", async () => { + // The customer got swapped to the Unknown Sender sentinel + // between the last render and the send click. We refuse to + // ship the stale PDF — the operator must fix the customer + // pointer first. + const { quote } = await seedReadyQuote(); + await withMocks({ isStale: true, regenOutcome: "placeholder_customer" }); + + const result = await sendQuoteEmail(quote.id, { + cc: "", + bcc: "", + subject: "", + message: "", + attachAuthLetters: true, + selectedAttachmentIds: [], + }); + expect(result.failure?.code).toBe("placeholder_customer"); + expect(result.quote.status).toBe("mail_failed"); + await clearMocks(); + }); + + it("refuses to send when the render itself crashes", async () => { + const { quote } = await seedReadyQuote(); + await withMocks({ isStale: true, regenOutcome: "render_error" }); + + const result = await sendQuoteEmail(quote.id, { + cc: "", + bcc: "", + subject: "", + message: "", + attachAuthLetters: true, + selectedAttachmentIds: [], + }); + expect(result.failure).not.toBeNull(); + expect(result.failure?.message).toMatch(/PDF regeneration failed/); + expect(result.quote.status).toBe("mail_failed"); + await clearMocks(); + }); +}); + /** * diagnosePdfReadiness is private to quotes-service.ts, but it's * reachable through sendQuoteEmail when the quote has no PDF. We use diff --git a/src/lib/server/quotes-service.ts b/src/lib/server/quotes-service.ts index 35563d8..995ed9e 100644 --- a/src/lib/server/quotes-service.ts +++ b/src/lib/server/quotes-service.ts @@ -2,6 +2,10 @@ import { getAdminPocketBase } from "./pocketbase-admin"; import { QUOTE_VALID_DAYS } from "@/lib/pdf/format"; import { createQuoteItem } from "./quote-items-service"; import { searchQuotes } from "./quotes-search-service"; +import { + generateQuotePdf, + isQuotePdfStale, +} from "./quote-pdf-service"; import { PLACEHOLDER_CUSTOMER_IDS, PLACEHOLDER_CUSTOMER_NAMES, @@ -1997,32 +2001,90 @@ export async function sendQuoteEmail( } } - // ── 6. Validate PDF exists (most recent kind=pdf attachment). The - // outbound send includes the PDF as the first attachment; no - // PDF = nothing useful to send. + // ── 6. PDF presence + staleness check ──────────────────────── // - // Closed 2026-08-15: the previous path bailed with a bare - // "no_pdf" message and the agent had no way to tell the - // operator what was actually missing. We now run the same - // readiness check that the PDF render route does - // (placeholder customer, no items, items_not_ready) and - // surface the per-line `missing` lists back to the agent, - // which can present them to the operator verbatim. The - // operator decides whether to fix the gaps or render the - // PDF themselves; the server doesn't auto-render — Dave - // explicitly opted out of that on 2026-08-15 (don't send - // partial / stale data without operator eyes on it). + // The outbound send attaches the most recent `kind="pdf"` + // attachment to the email. We have to defend against TWO + // failure modes here: + // + // (a) No PDF generated yet → diagnose what's blocking the + // render (placeholder customer, no items, items not + // ready) and bail with the same shape the route uses. + // + // (b) PDF exists but is STALE — i.e. the operator (or rep + // on the approvals page) edited markup / cost / selling / + // supplier / description / quote_notes / letterhead / + // customer AFTER the last render. Without the staleness + // check the email ships a PDF with the operator's old + // numbers — the customer sees something different from + // what the operator just approved. That's the bug + // reported 2026-09-07. + // + // When stale, we call `generateQuotePdf` from the service + // layer to render a fresh PDF before the send kicks off. The + // service runs the same readiness gates as the manual + // `/api/v1/quotes/[id]/pdf` POST, so a regen that hits + // `items_not_ready` (operator blanked prices in the time + // since the last render) refuses to render and we surface + // that as a `mail_failed` — we never silently fall back to + // the stale PDF. The previous "don't auto-render on send" + // stance (Dave 2026-08-15) was about not rendering against + // partial / placeholder data without operator eyes; this + // fix preserves that — if the regen fails the operator + // gets a clear failure message and stays in control. const pdfs = await pb .collection(QUOTE_ATTACHMENTS_COLLECTION) - .getList(1, 1, { + .getList<{ id: string; uploaded_at?: string }>(1, 1, { filter: `quote = "${quoteId}" && kind = "pdf"`, sort: "-uploaded_at", + fields: "id,uploaded_at", }); if (pdfs.items.length === 0) { const readiness = await diagnosePdfReadiness(pb, quoteId, quote); return markPreSendFailure(pb, quote, readiness); } + // Cheap staleness probe (2 PB queries). Skipped when the + // pre-send path is already under time pressure — but in + // practice the `pdfs` query above is the dominant cost, so + // adding two more parallel queries is fine. If the probe + // says stale we regenerate before continuing. + const stale = await isQuotePdfStale(quoteId); + if (stale) { + const regen = await generateQuotePdf(quoteId); + if (regen.kind === "readiness_failure") { + // The PDF can't be re-rendered (operator blanked prices + // between the last render and the send click, or the + // customer got swapped to a placeholder). Treat this as + // a pre-send failure — same shape as the "no PDF" path + // above, so the UI's banner + retry flow handle it + // uniformly. We surface the per-line issues list from + // the readiness failure so the operator sees exactly + // which rows are blocking the render. + return markPreSendFailure(pb, quote, { + code: regen.code, + message: + "Could not regenerate the PDF with the latest changes — the email was not sent. " + + regen.message, + details: regen.details, + }); + } + if (regen.kind === "render_error") { + // The render itself crashed (PB outage, render crash). + // Same shape — refuse to send, don't ship the stale PDF. + return markPreSendFailure(pb, quote, { + code: "items_not_ready", + message: + "PDF regeneration failed: " + + regen.message + + (regen.detail ? ` (${regen.detail})` : ""), + }); + } + // regen.kind === "ok": a fresh PDF is now in PB; the + // existing pdfs.items query above will pick it up via the + // kind="pdf" lookup n8n runs at SMTP-time. + } + // ── 7. Filter selectedAttachmentIds to only those that actually // belong to this quote. Tampered clients can't smuggle in // attachments from other quotes.