From 1e3121d8387c7c7c7d6958691be147a3a6842f3d Mon Sep 17 00:00:00 2001 From: Dave Date: Wed, 9 Sep 2026 16:06:40 +0200 Subject: [PATCH] fix(quotes): persist operator's letterhead choice to prevent silent letterhead-flip This commit addresses the silent letterhead-flip bug reported on 2026-09-09, where the operator's selected letterhead was not being saved, leading to incorrect letterhead being used in subsequent PDF generations. The operator's choice is now persisted in the quote row during PDF generation, ensuring that the correct letterhead is used for future auto-regens and email sends. Changes include: - Updated the `generateQuotePdf` function to include the operator's letterhead choice in the database write. - Adjusted the API response to surface the persisted letterhead for UI synchronization. - Added tests to verify that the letterhead choice is correctly saved and not overwritten by empty or invalid inputs. This fix enhances the reliability of the quote generation process and improves user experience by maintaining the operator's selections. --- ...tbound Send (with-outbound-attachments).json | 2 +- src/app/(app)/quotes/[id]/process/page.tsx | 13 ++ src/app/api/v1/quotes/[id]/pdf/route.ts | 9 ++ src/lib/server/quote-pdf-service.test.ts | 118 ++++++++++++++++++ src/lib/server/quote-pdf-service.ts | 36 +++++- 5 files changed, 176 insertions(+), 2 deletions(-) diff --git a/n8n_workfows/WFDASH — Quote Outbound Send (with-outbound-attachments).json b/n8n_workfows/WFDASH — Quote Outbound Send (with-outbound-attachments).json index d5858cb..cc716d4 100644 --- a/n8n_workfows/WFDASH — Quote Outbound Send (with-outbound-attachments).json +++ b/n8n_workfows/WFDASH — Quote Outbound Send (with-outbound-attachments).json @@ -518,7 +518,7 @@ }, { "parameters": { - "jsCode": "// Build the outbound attachments payload for the SMTP send.\n//\n// n8n's SMTP node v2.1 reads attachments from BINARY properties on\n// the input item. Each file we want attached becomes a named\n// `binary.` entry on the single item we return. The SMTP node's\n// `options.attachments` expression resolves\n// `Object.keys($binary).join(',')` and Nodemailer does the rest.\n//\n// Earlier versions of this node tried to pass an inline\n// `{filename, content, encoding, type}` array via the `attachments`\n// option — but the n8n v2.1 wrapper ignores that array and just sends\n// the bare email body. The smoke test (execution 163366) showed the\n// message went out at 887 bytes with no attachment bytes attached.\n//\n// For the auto-generated PDF, we just propagate the `binary.data`\n// property the upstream `HTTP Request` node already populated (it\n// downloads the file from PB, see the Code in JavaScript node before\n// it). The HTTP Request node's binary works out of the box — execution\n// 163544 confirmed that.\n//\n// For the operator-chosen outbound attachments, we still need to\n// download each one fresh from PB. We do that via\n// `this.helpers.httpRequest` with `encoding: 'arrayBuffer'`. `fetch`\n// is NOT available in n8n's Code node sandbox — execution 163544\n// logged \"fetch is not defined\". `encoding: null` was removed in n8n\n// 1.0+ — execution 163563 logged \"[object Object]\" because of it.\n\nconst PB_BASE = 'https://pocketbase-gnqc88suy8fqk6wyryjjdpvl.169.239.180.130.sslip.io';\n\nconst quoteRow = ($('Search quotes').first() || {}).json || {};\nconst quoteId = quoteRow.id;\nconst quoteNumber = quoteRow.quote_number || 'quote';\n\n// PDF metadata from the upstream `HTTP Request` node — its binary.data\n// already contains the downloaded PDF bytes. We just rename it.\nconst pdfMeta = ($('HTTP Request').first() || {}).json || {};\nconst pdfFile = (pdfMeta.file && pdfMeta.file[0]) || '';\nconst pdfRawName = pdfMeta.filename || (quoteNumber + '.pdf');\nconst pdfName = String(pdfRawName)\n .replace(/[\\\\/:*?\"<>|]+/g, '_')\n .trim() || 'quote.pdf';\n\n// Outbound token intersection — defends against drift between the\n// operator's modal selection and the row's denormalised\n// `outbound_attachments` field.\nconst allOutbound = Array.isArray(quoteRow.outbound_attachments)\n ? quoteRow.outbound_attachments\n : quoteRow.outbound_attachments\n ? [quoteRow.outbound_attachments]\n : [];\n\nlet selected = [];\ntry {\n const parsed = JSON.parse(quoteRow.email_outbound_attachment_ids || '[]');\n if (Array.isArray(parsed)) selected = parsed;\n} catch (e) {\n selected = [];\n}\n\nconst tokensToAttach = allOutbound.filter((t) => selected.includes(t));\n\nconst MIME_BY_EXT = {\n pdf: 'application/pdf',\n png: 'image/png',\n jpg: 'image/jpeg',\n jpeg: 'image/jpeg',\n gif: 'image/gif',\n txt: 'text/plain',\n csv: 'text/csv',\n doc: 'application/msword',\n docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n xls: 'application/vnd.ms-excel',\n xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',\n zip: 'application/zip',\n};\n\nconst binary = {};\nconst filenames = [];\nconst errors = [];\n\nfunction describeErr(err) {\n if (!err) return 'unknown error';\n if (err instanceof Error) return (err.message || err.name || 'Error') + (err.cause ? ' (cause: ' + describeErr(err.cause) + ')' : '');\n if (typeof err === 'string') return err;\n try {\n return JSON.stringify(err);\n } catch (e) {\n return String(err);\n }\n}\n\nasync function downloadToBinary(url, displayName, mime) {\n // Per the current n8n httpRequest helper contract, `encoding:\n // 'arrayBuffer'` returns a raw Buffer. The older `encoding: null`\n // shape was removed in n8n 1.0+.\n const resp = await this.helpers.httpRequest({\n method: 'GET',\n url,\n encoding: 'arrayBuffer',\n timeout: 15000,\n returnFullResponse: true,\n });\n const buf = resp && resp.body;\n const status = resp && resp.statusCode;\n if (!buf) {\n throw new Error('Empty response body (status ' + status + ')');\n }\n return await this.helpers.prepareBinaryData(buf, displayName, mime);\n}\n\n// 1. Auto-generated PDF: rename the upstream binary.data → binary.pdf.\nif (pdfFile && pdfMeta.id) {\n const upstream = ($('HTTP Request').first() || {}).binary || {};\n const pdfBin = upstream.data;\n if (pdfBin) {\n try {\n // prepareBinaryData wants the raw bytes, not the upstream\n // descriptor. Get the buffer and re-wrap with the friendly\n // display name so the operator sees Quote-CNL-Q202609291.pdf\n // in the email, not quote_cnl_q202609291_xxx.pdf.\n const upstreamItem = $('HTTP Request').first();\n const buf = await this.helpers.getBinaryDataBuffer(upstreamItem, 'data');\n binary.pdf = await this.helpers.prepareBinaryData(buf, pdfName, 'application/pdf');\n filenames.push(pdfName);\n } catch (err) {\n errors.push('PDF (' + pdfFile + '): ' + describeErr(err));\n }\n } else {\n errors.push('PDF (' + pdfFile + '): upstream HTTP Request produced no binary.data');\n }\n}\n\n// 2. Outbound files the operator chose.\nfor (let i = 0; i < tokensToAttach.length; i++) {\n const token = tokensToAttach[i];\n const idx = token.indexOf('_');\n const displayName = ((idx >= 0 ? token.slice(idx + 1) : token)\n .replace(/[\\\\/:*?\"<>|]+/g, '_')\n .trim()) || token;\n try {\n const url = PB_BASE + '/api/files/quotes/' + quoteId + '/' + token;\n const ext = displayName.split('.').pop().toLowerCase();\n const mime = MIME_BY_EXT[ext] || 'application/octet-stream';\n binary['outbound_' + i] = await downloadToBinary.call(this, url, displayName, mime);\n filenames.push(displayName);\n } catch (err) {\n errors.push(displayName + ': ' + describeErr(err));\n }\n}\n\n// Preserve the upstream item's json metadata so the SMTP node's\n// subject / html / to / cc expressions still resolve. We expose the\n// attachment metadata as JSON for logging on the dashboard.\nconst firstInput = $input.first() || {};\nconst json = Object.assign({}, firstInput.json || {}, pdfMeta, {\n attachment_count: filenames.length,\n attachment_filenames: filenames,\n attachment_errors: errors,\n});\n\nif (Object.keys(binary).length === 0) {\n return [{ json, binary: undefined }];\n}\n\nreturn [{ json, binary }];\n" + "jsCode": "// Build the outbound attachments payload for the SMTP send.\n//\n// n8n's SMTP node v2.1 reads attachments from BINARY properties on\n// the input item. Each file we want attached becomes a named\n// `binary.` entry on the single item we return. The SMTP node's\n// `options.attachments` expression resolves\n// `Object.keys($binary).join(',')` and Nodemailer does the rest.\n//\n// Binary objects are built by hand ({data: base64, mimeType, fileName,\n// fileExtension}) instead of via `this.helpers.prepareBinaryData`.\n// Execution 163729 showed prepareBinaryData throwing an opaque\n// error that serialised to '{}' for BOTH the PDF buffer (from\n// getBinaryDataBuffer) and the outbound-token buffer (from\n// httpRequest) -- two otherwise-unrelated code paths that only share\n// that one trailing call. Building the binary object manually from\n// the same Buffer sidesteps whatever prepareBinaryData was choking\n// on (likely something in this instance's binary-data-manager /\n// filesystem-mode bookkeeping) since a plain base64 `data` field is\n// the same wire shape and doesn't need the helper's extra bookkeeping.\n//\n// For the auto-generated PDF, the upstream `HTTP Request` node's\n// `binary.data` already holds the downloaded bytes -- we just pull\n// the Buffer via `getBinaryDataBuffer` and re-wrap it under a\n// friendly name.\n//\n// For the operator-chosen outbound attachments, we download each\n// fresh from PB via `this.helpers.httpRequest` with `encoding:\n// 'arraybuffer'` (lowercase, matching n8n's IHttpRequestOptions --\n// `fetch` is NOT available in the Code node sandbox, and `encoding:\n// null` was removed in n8n 1.0+).\n\nconst PB_BASE = 'https://pocketbase-gnqc88suy8fqk6wyryjjdpvl.169.239.180.130.sslip.io';\n\nconst quoteRow = ($('Search quotes').first() || {}).json || {};\nconst quoteId = quoteRow.id;\nconst quoteNumber = quoteRow.quote_number || 'quote';\n\n// PDF metadata from the upstream `HTTP Request` node -- its binary.data\n// already contains the downloaded PDF bytes. We just rename it.\nconst pdfMeta = ($('HTTP Request').first() || {}).json || {};\nconst pdfFile = (pdfMeta.file && pdfMeta.file[0]) || '';\nconst pdfRawName = pdfMeta.filename || (quoteNumber + '.pdf');\nconst pdfName = String(pdfRawName)\n .replace(/[\\\\/:*?\"<>|]+/g, '_')\n .trim() || 'quote.pdf';\n\n// Outbound token intersection -- defends against drift between the\n// operator's modal selection and the row's denormalised\n// `outbound_attachments` field.\nconst allOutbound = Array.isArray(quoteRow.outbound_attachments)\n ? quoteRow.outbound_attachments\n : quoteRow.outbound_attachments\n ? [quoteRow.outbound_attachments]\n : [];\n\nlet selected = [];\ntry {\n const parsed = JSON.parse(quoteRow.email_outbound_attachment_ids || '[]');\n if (Array.isArray(parsed)) selected = parsed;\n} catch (e) {\n selected = [];\n}\n\nconst tokensToAttach = allOutbound.filter((t) => selected.includes(t));\n\nconst MIME_BY_EXT = {\n pdf: 'application/pdf',\n png: 'image/png',\n jpg: 'image/jpeg',\n jpeg: 'image/jpeg',\n gif: 'image/gif',\n txt: 'text/plain',\n csv: 'text/csv',\n doc: 'application/msword',\n docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n xls: 'application/vnd.ms-excel',\n xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',\n zip: 'application/zip',\n};\n\nconst binary = {};\nconst filenames = [];\nconst errors = [];\n\nfunction describeErr(err) {\n if (!err) return 'unknown error';\n if (err instanceof Error) {\n const own = Object.getOwnPropertyNames(err).filter((k) => k !== 'stack' && k !== 'message');\n const extra = own.length\n ? ' [' + own.map((k) => { try { return k + '=' + JSON.stringify(err[k]).slice(0, 200); } catch (e) { return k + '='; } }).join(', ') + ']'\n : '';\n return (err.message || err.name || 'Error') + extra;\n }\n if (typeof err === 'string') return err;\n try {\n const own = Object.getOwnPropertyNames(err);\n if (own.length === 0) {\n return 'non-Error thrown value with no own properties: ' + Object.prototype.toString.call(err);\n }\n return JSON.stringify(err, own);\n } catch (e) {\n return String(err);\n }\n}\n\n// Build an n8n IBinaryData object directly from a Buffer/ArrayBuffer,\n// bypassing `this.helpers.prepareBinaryData` (see comment above).\nfunction bufferToBinaryData(buf, fileName, mime) {\n const ext = (fileName.split('.').pop() || '').toLowerCase();\n return {\n data: Buffer.from(buf).toString('base64'),\n mimeType: mime,\n fileName,\n fileExtension: ext,\n };\n}\n\nasync function downloadArrayBuffer(url) {\n const resp = await this.helpers.httpRequest({\n method: 'GET',\n url,\n encoding: 'arraybuffer',\n timeout: 15000,\n returnFullResponse: true,\n });\n const buf = resp && resp.body;\n const status = resp && resp.statusCode;\n if (!buf) {\n throw new Error('Empty response body (status ' + status + ')');\n }\n return buf;\n}\n\n// 1. Auto-generated PDF: rename the upstream binary.data -> binary.pdf.\nif (pdfFile && pdfMeta.id) {\n const upstream = ($('HTTP Request').first() || {}).binary || {};\n const pdfBin = upstream.data;\n if (pdfBin) {\n try {\n const upstreamItem = $('HTTP Request').first();\n const buf = await this.helpers.getBinaryDataBuffer(upstreamItem, 'data');\n binary.pdf = bufferToBinaryData(buf, pdfName, 'application/pdf');\n filenames.push(pdfName);\n } catch (err) {\n errors.push('PDF (' + pdfFile + '): ' + describeErr(err));\n }\n } else {\n errors.push('PDF (' + pdfFile + '): upstream HTTP Request produced no binary.data');\n }\n}\n\n// 2. Outbound files the operator chose.\nfor (let i = 0; i < tokensToAttach.length; i++) {\n const token = tokensToAttach[i];\n const idx = token.indexOf('_');\n const displayName = ((idx >= 0 ? token.slice(idx + 1) : token)\n .replace(/[\\\\/:*?\"<>|]+/g, '_')\n .trim()) || token;\n try {\n const url = PB_BASE + '/api/files/quotes/' + quoteId + '/' + token;\n const ext = displayName.split('.').pop().toLowerCase();\n const mime = MIME_BY_EXT[ext] || 'application/octet-stream';\n const buf = await downloadArrayBuffer.call(this, url);\n binary['outbound_' + i] = bufferToBinaryData(buf, displayName, mime);\n filenames.push(displayName);\n } catch (err) {\n errors.push(displayName + ': ' + describeErr(err));\n }\n}\n\n// Preserve the upstream item's json metadata so the SMTP node's\n// subject / html / to / cc expressions still resolve. We expose the\n// attachment metadata as JSON for logging on the dashboard.\nconst firstInput = $input.first() || {};\nconst json = Object.assign({}, firstInput.json || {}, pdfMeta, {\n attachment_count: filenames.length,\n attachment_filenames: filenames,\n attachment_errors: errors,\n});\n\nif (Object.keys(binary).length === 0) {\n return [{ json, binary: undefined }];\n}\n\nreturn [{ json, binary }];\n" }, "id": "ab09c2d4-3f11-4d6e-a9a1-9b0d3c1f47e2", "name": "Build attachments list", diff --git a/src/app/(app)/quotes/[id]/process/page.tsx b/src/app/(app)/quotes/[id]/process/page.tsx index 26980a9..12d26bb 100644 --- a/src/app/(app)/quotes/[id]/process/page.tsx +++ b/src/app/(app)/quotes/[id]/process/page.tsx @@ -471,6 +471,14 @@ export default function QuoteProcessPage({ params }: { params: Promise<{ id: str // means "no claim"). Treat null and "" identically: the // pre-existing `claimed_by` is left alone in that case so a // stale local copy doesn't get clobbered. + // Same pattern for `company_letterhead`: the service + // persists the operator's explicit choice (CNL / ISherwood) + // in the same PB write, and surfaces the resolved value as + // `data.companyLetterhead` so the chip / dropdown updates + // without a full refetch — and so the next auto-regen + // (from `sendQuoteEmail`'s staleness check) ships the same + // letterhead, not the email-intake default. Fix for the + // silent letterhead-flip bug reported 2026-09-09. setQuote((prev) => prev ? { @@ -481,6 +489,11 @@ export default function QuoteProcessPage({ params }: { params: Promise<{ id: str typeof data.claimedBy === "string" && data.claimedBy !== "" ? data.claimedBy : prev.claimed_by, + company_letterhead: + typeof data.companyLetterhead === "string" && + data.companyLetterhead !== "" + ? data.companyLetterhead + : prev.company_letterhead, } : prev ); diff --git a/src/app/api/v1/quotes/[id]/pdf/route.ts b/src/app/api/v1/quotes/[id]/pdf/route.ts index 9daabba..564e920 100644 --- a/src/app/api/v1/quotes/[id]/pdf/route.ts +++ b/src/app/api/v1/quotes/[id]/pdf/route.ts @@ -136,6 +136,15 @@ function renderOutcomeToResponse(outcome: GenerateQuotePdfOutcome) { // already claimed by someone, or actor email didn't // resolve); the UI treats null and "" the same. claimedBy: outcome.claimedBy, + // Surface the post-update `company_letterhead` so the UI + // can sync the letterhead chip / dropdown without a full + // refetch. Same value `sendQuoteEmail`'s staleness regen + // will fall back to on the next send — fixes the silent + // letterhead-flip bug reported 2026-09-09 (the operator's + // CNL / ISherwood choice used to render correctly but + // wasn't persisted, so a later auto-regen shipped a PDF + // with the wrong letterhead to the customer). + companyLetterhead: outcome.companyLetterhead, }); } if (outcome.kind === "readiness_failure") { diff --git a/src/lib/server/quote-pdf-service.test.ts b/src/lib/server/quote-pdf-service.test.ts index 49ab439..3320ee3 100644 --- a/src/lib/server/quote-pdf-service.test.ts +++ b/src/lib/server/quote-pdf-service.test.ts @@ -684,6 +684,124 @@ describe("generateQuotePdf", () => { expect(outcome.readyItemCount).toBe(1); } }); + + it("persists the operator's letterhead choice to the quote row", async () => { + // Regression for the silent letterhead-flip bug reported + // 2026-09-09 (quote Q202609068, Masego Makoloi @ Norplats): + // the operator clicked "ISherwood" on the process page, the + // PDF rendered on ISHERWOOD letterhead, but the stored + // `company_letterhead` stayed at "CNL" (the email-intake + // default). A subsequent auto-regen — triggered by either + // a price edit bumping `item.updated` or the rep's + // `review-approve` markup tweak — used the stale stored + // value and shipped a CNL PDF to the customer. Fix: persist + // the operator's explicit choice on every successful render. + const { quote } = await seedQuoteWithPdf(); + await pbMock.collection("quote_items").create({ + quote: quote.id, + description: "Drywall & Panel Hoist", + quantity: 1, + cost_price: 9128, + selling_price: 10862.32, + markup: 19, + supplier: "sup-acme", + is_processed: true, + moved_to_quote: null, + }); + + const outcome = await generateQuotePdf(quote.id, { + letterhead: "ISHERWOOD", + }); + expect(outcome.kind).toBe("ok"); + if (outcome.kind !== "ok") throw new Error("expected ok outcome"); + + // Outcome echoes the resolved letterhead — same value the + // next regen / send will fall back to. + expect(outcome.companyLetterhead).toBe("ISHERWOOD"); + + // And the quote row reflects it. The seed writes "CNL" so a + // successful persistence flips it. + const after = await pbMock + .collection("quotes") + .getOne<{ company_letterhead: string }>(quote.id); + expect(after.company_letterhead).toBe("ISHERWOOD"); + }); + + it("does NOT change the stored letterhead when the caller passes none", async () => { + // `sendQuoteEmail`'s staleness check calls `generateQuotePdf` + // without `options.letterhead`. We must NOT silently rewrite + // the stored value to CNL just because the regen didn't pass + // an override — otherwise every auto-regen would silently + // flip the quote back to CNL (the email-intake default). + const { quote } = await seedQuoteWithPdf(); + await pbMock.collection("quotes").update(quote.id, { + company_letterhead: "ISHERWOOD", + }); + 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); // no letterhead override + expect(outcome.kind).toBe("ok"); + if (outcome.kind !== "ok") throw new Error("expected ok outcome"); + + // Stored letterhead still ISHERWOOD (the regen just used it). + const after = await pbMock + .collection("quotes") + .getOne<{ company_letterhead: string }>(quote.id); + expect(after.company_letterhead).toBe("ISHERWOOD"); + }); + + it("does NOT persist an unknown / typo'd letterhead code", async () => { + // Defence against a silent flip-to-CNL: if the caller passes + // a typo (e.g. "ISHERWOODS") the service treats it as no + // override (the LETTERHEAD_CODES check rejects it), falls + // back to the quote's stored `company_letterhead`, renders + // with that, and must NOT rewrite the stored value. The + // operator's known choice stays intact until they explicitly + // click a known letterhead button. + const { quote } = await seedQuoteWithPdf(); + await pbMock.collection("quotes").update(quote.id, { + company_letterhead: "ISHERWOOD", + }); + 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, { + // not in LETTERHEAD_CODES — service ignores the override + // and uses the quote's stored letterhead ("ISHERWOOD") + letterhead: "ISHERWOODS", + }); + expect(outcome.kind).toBe("ok"); + if (outcome.kind !== "ok") throw new Error("expected ok outcome"); + // Render used the stored value (ISHERWOOD) because the + // typo'd input was rejected. Outcome echoes what was + // actually rendered. + expect(outcome.companyLetterhead).toBe("ISHERWOOD"); + // And the stored value stayed exactly as the operator set + // it — no silent flip. + const after = await pbMock + .collection("quotes") + .getOne<{ company_letterhead: string }>(quote.id); + expect(after.company_letterhead).toBe("ISHERWOOD"); + }); }); // ───────────────────────────────────────────────────────────────────────────── diff --git a/src/lib/server/quote-pdf-service.ts b/src/lib/server/quote-pdf-service.ts index 1c2803d..916f0d7 100644 --- a/src/lib/server/quote-pdf-service.ts +++ b/src/lib/server/quote-pdf-service.ts @@ -147,6 +147,14 @@ export type GenerateQuotePdfOutcome = * status, existing claim, or actor email did not resolve). */ claimedBy: string | null; + /** + * The `company_letterhead` value persisted to the quote row in + * this render's PB write. Equals `letterheadCode` (the resolved + * letterhead that was just rendered) — the value the next + * regen / send will fall back to. Surfaced so the route can + * echo it back to the UI for optimistic local-state sync. + */ + companyLetterhead: string; } | { kind: "readiness_failure"; @@ -233,7 +241,12 @@ export async function generateQuotePdf( // 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 operatorLetterheadOverride = + typeof options?.letterhead === "string" && + (LETTERHEAD_CODES as readonly string[]).includes(options.letterhead) + ? (options.letterhead as LetterheadCode) + : null; + const requestedCode = operatorLetterheadOverride ?? quote.company_letterhead; const letterheadCode = (LETTERHEAD_CODES as readonly string[]).includes( requestedCode || "", ) @@ -426,6 +439,24 @@ export async function generateQuotePdf( // flip / timestamp bump — one PB write, no race window where // another client could observe a "claimed but no claimer" // state. Both update branches below honour `autoClaimUserId`. + // + // We ALSO persist the operator's explicit letterhead choice + // (when one was passed) in the same PB write. This is the + // fix for the silent letterhead-flip bug reported 2026-09-09: + // the process page's CNL / ISherwood buttons used to render + // the right letterhead but never persist the choice, so the + // next auto-regen (from `sendQuoteEmail`'s staleness check + // or from the rep's `review-approve`) used the stale stored + // value — typically the email-intake default of CNL — and + // shipped a PDF with the wrong letterhead to the customer. + // Persisting here makes the stored value track what was just + // rendered, so any future regen is faithful. We only persist + // when the caller passed a *known* letterhead code (not a + // typo'd unknown code) so a silent fallback to CNL doesn't + // silently rewrite the stored value either. + const letterheadPersistence = operatorLetterheadOverride + ? { company_letterhead: operatorLetterheadOverride } + : {}; let newStatus = quote.status; let pdfGeneratedAt: string | null = quote.pdf_generated_at || null; if ( @@ -442,6 +473,7 @@ export async function generateQuotePdf( await pb.collection("quotes").update(quoteId, { pdf_generated_at: pdfGeneratedAt, ...(autoClaimUserId ? { claimed_by: autoClaimUserId } : {}), + ...letterheadPersistence, }); } } else { @@ -454,6 +486,7 @@ export async function generateQuotePdf( status: targetStatus, pdf_generated_at: pdfGeneratedAt, ...(autoClaimUserId ? { claimed_by: autoClaimUserId } : {}), + ...letterheadPersistence, }); newStatus = targetStatus; } @@ -476,6 +509,7 @@ export async function generateQuotePdf( totalItemCount: allItems.length, isPartial: !!partialNote, claimedBy, + companyLetterhead: letterheadCode, }; } catch (err) { return {