Add quote PDF generation and stale PDF handling
This commit is contained in:
parent
6bedf27617
commit
66559af422
5 changed files with 1421 additions and 338 deletions
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
587
src/lib/server/quote-pdf-service.test.ts
Normal file
587
src/lib/server/quote-pdf-service.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
488
src/lib/server/quote-pdf-service.ts
Normal file
488
src/lib/server/quote-pdf-service.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue