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 {