Add quote PDF generation and stale PDF handling

This commit is contained in:
Dave 2026-09-07 07:08:27 +02:00
parent 6bedf27617
commit 66559af422
5 changed files with 1421 additions and 338 deletions

View file

@ -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<Quote>(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<QuoteItem>({
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 <QuoteDocument>. 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<QuoteAttachment>(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<Customer>(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 }
);
}
}
}

View file

@ -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<string, Map<string, Record<string, unknown>>> = {};
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<string, unknown>): 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<string, unknown>, 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<T>(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<string, unknown> = {};
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<T>(
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<string, unknown> = {};
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<T>(
opts?: { filter?: string; fields?: string; sort?: string; batch?: number },
) {
const res = await this.getList<T>(1, opts?.batch ?? 500, opts);
return res.items;
},
async create(data: Record<string, unknown>) {
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<string, unknown> = {
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<string, unknown>) {
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);
}
});
});

View file

@ -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<GenerateQuotePdfOutcome> {
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<Quote>(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<QuoteItem>({
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 <QuoteDocument>. 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<QuoteAttachment>(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<boolean> {
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;
}

View file

@ -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<typeof vi.fn>).mockResolvedValue(
opts.isStale,
);
const regenKind = opts.regenOutcome ?? "ok";
if (regenKind === "ok") {
(generateQuotePdfMock as unknown as ReturnType<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mockReset();
(generateQuotePdfMock as unknown as ReturnType<typeof vi.fn>).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

View file

@ -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.