From 3d009b144f0577dba12f2d9a682b2d8cb59ca68f Mon Sep 17 00:00:00 2001 From: Dave Date: Wed, 9 Sep 2026 18:09:27 +0200 Subject: [PATCH] fix(quotes): persist letterhead choice on terminal-status quotes too The first letterhead-persistence fix (1e3121d) gated the WHOLE PB write for generateQuotePdf's operator-choice persistence behind "quote.status is not terminal" -- because terminal statuses (approved/emailed/complete/cancelled/sending) deliberately skip the pdf_generated_at re-stamp. But regenerating a PDF on an already-terminal quote (e.g. clicking "ISherwood" on an already-`emailed` quote from the process page) is a normal, supported operator action -- confirmed live: it renders the right letterhead and toasts "quote kept at emailed". That regen hit the terminal branch and skipped the PB write entirely, so company_letterhead never updated. A later auto-regen (sendQuoteEmail's staleness check, e.g. triggered by a markup edit) then fell back to the stale stored value and shipped the wrong letterhead again -- reproducing the original bug through a path the first fix didn't cover. Verified against the live quote via a direct PocketBase read (bypassing app/MCP caching): company_letterhead was unchanged by the first fix's terminal-status regen, and is now correctly persisted while pdf_generated_at/status stay untouched as intended. Split the letterhead write out from the timestamp/status write so it always persists on an explicit operator choice regardless of quote status. Added a regression test for the terminal-status + explicit letterhead case. Also syncs the n8n "Quote Outbound Send" workflow snapshot with two more live fixes made during testing: - Build attachments list: this.helpers.getBinaryDataBuffer() takes an item INDEX (number), not an item object -- per n8n's own docs. Was passing the item object, which threw an opaque non-Error value on every real send. - Send Email (SMTP): the html field injected the plain-text email_message (with literal \n breaks) straight into HTML, so clients collapsed every paragraph into one block. Now converts \n to
first. Also captures that the four WhatsApp-notify nodes and the workflow itself are currently disabled/inactive in the live instance (manual n8n edits during testing, not part of this fix). Co-Authored-By: Claude Sonnet 5 --- ...tbound Send (with-outbound-attachments).json | 211 ++++++------------ src/lib/server/quote-pdf-service.test.ts | 62 +++++ src/lib/server/quote-pdf-service.ts | 15 ++ 3 files changed, 143 insertions(+), 145 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 cc716d4..9e3c440 100644 --- a/n8n_workfows/WFDASH — Quote Outbound Send (with-outbound-attachments).json +++ b/n8n_workfows/WFDASH — Quote Outbound Send (with-outbound-attachments).json @@ -7,13 +7,13 @@ "fromEmail": "=quotes@wfsales.co.za", "toEmail": "={{ $('View customers').item.json.email }}", "subject": "=QUOTE-{{ $('Search quotes').item.json.quote_number }} REF - {{ $('Search quotes').item.json.quote_reference }}", - "html": "={{ $('Search quotes').item.json.email_message }}", + "html": "={{ ($('Search quotes').item.json.email_message || '').replace(/\\n/g, '
') }}", "options": { "appendAttribution": false, - "ccEmail": "={{ $('View users1').item.json.email }}", + "attachments": "={{ Object.keys($binary || {}).join(',') }}", + "ccEmail": "={{ ($('Search quotes').item.json.email_cc || '').trim() }}", "bccEmail": "=", - "replyTo": "quotes@wfsales.co.za", - "attachments": "={{ $json.attachments && $json.attachments.length ? $json.attachments : [] }}" + "replyTo": "quotes@wfsales.co.za" } }, "id": "8c00fc59-3492-42d3-8f3f-66a86eddfc31", @@ -21,19 +21,13 @@ "type": "n8n-nodes-base.emailSend", "typeVersion": 2.1, "position": [ - -464, - 288 + -384, + 448 ], "retryOnFail": true, "maxTries": 3, "waitBetweenTries": 5000, "webhookId": "d03c93e2-7bf8-4006-8542-5d94a87e68d1", - "credentials": { - "smtp": { - "id": "R1iZHg4Q5J32w0tA", - "name": "QUOTES@WFSALES" - } - }, "onError": "continueErrorOutput" }, { @@ -42,7 +36,7 @@ "typeVersion": 1, "position": [ -2176, - 288 + 352 ], "id": "81e80622-3c74-4aa5-b94e-638c0ba42507", "name": "When clicking ‘Execute workflow’" @@ -59,17 +53,11 @@ "type": "n8n-nodes-pocketbase.pocketbaseHttp", "typeVersion": 1, "position": [ - -1808, - 288 + -1952, + 448 ], "id": "9ffcb69c-e88c-477a-8b1b-d65f7b2db527", - "name": "Search quotes", - "credentials": { - "pocketbaseHttpApi": { - "id": "hLwB1QPIJEWsuVyW", - "name": "PocketBase HTTP account" - } - } + "name": "Search quotes" }, { "parameters": { @@ -82,17 +70,11 @@ "type": "n8n-nodes-pocketbase.pocketbaseHttp", "typeVersion": 1, "position": [ - -1584, - 288 + -1728, + 448 ], "id": "eba6b537-d696-4d72-a794-85149c645fbd", - "name": "View customers", - "credentials": { - "pocketbaseHttpApi": { - "id": "hLwB1QPIJEWsuVyW", - "name": "PocketBase HTTP account" - } - } + "name": "View customers" }, { "parameters": { @@ -102,8 +84,8 @@ "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.5, "position": [ - -688, - 288 + -832, + 448 ], "id": "d8fbb522-14b6-493a-ad78-9e0ae4b02eb2", "name": "HTTP Request" @@ -119,17 +101,11 @@ "type": "n8n-nodes-pocketbase.pocketbaseHttp", "typeVersion": 1, "position": [ - -1136, - 288 + -1280, + 448 ], "id": "14fb9665-84b5-49f3-9f68-3d2d3e2a6167", - "name": "Search quote_attachments", - "credentials": { - "pocketbaseHttpApi": { - "id": "hLwB1QPIJEWsuVyW", - "name": "PocketBase HTTP account" - } - } + "name": "Search quote_attachments" }, { "parameters": { @@ -138,8 +114,8 @@ "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ - -912, - 288 + -1056, + 448 ], "id": "17c9488c-6567-45fb-af45-2ecb841542a7", "name": "Code in JavaScript" @@ -165,17 +141,11 @@ "type": "n8n-nodes-pocketbase.pocketbaseHttp", "typeVersion": 1, "position": [ - -240, - 272 + -160, + 352 ], "id": "22f9730d-a5e2-44aa-b93f-587b8e37848c", - "name": "Update quotes", - "credentials": { - "pocketbaseHttpApi": { - "id": "hLwB1QPIJEWsuVyW", - "name": "PocketBase HTTP account" - } - } + "name": "Update quotes" }, { "parameters": { @@ -188,17 +158,11 @@ "type": "n8n-nodes-pocketbase.pocketbaseHttp", "typeVersion": 1, "position": [ - 208, - 272 + 288, + 352 ], "id": "1d675ee9-9027-4fc9-ba82-a9b9ff83681c", - "name": "View users", - "credentials": { - "pocketbaseHttpApi": { - "id": "hLwB1QPIJEWsuVyW", - "name": "PocketBase HTTP account" - } - } + "name": "View users" }, { "parameters": { @@ -211,17 +175,12 @@ "type": "@aldinokemal2104/n8n-nodes-gowa.gowa", "typeVersion": 1, "position": [ - 1552, + 1632, 256 ], "id": "0b6b950d-13b0-4d53-8acb-5d427afe40c6", "name": "Send media files", - "credentials": { - "goWhatsappApi": { - "id": "NcclW50L0w20LhlV", - "name": "GOWA account" - } - } + "disabled": true }, { "parameters": { @@ -231,8 +190,8 @@ "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.5, "position": [ - 1104, - 272 + 1184, + 352 ], "id": "230c9ea7-8fcd-4373-901b-b1def618ff17", "name": "HTTP Request1" @@ -248,17 +207,11 @@ "type": "n8n-nodes-pocketbase.pocketbaseHttp", "typeVersion": 1, "position": [ - 656, - 272 + 736, + 352 ], "id": "79596ab4-97e5-46d9-960f-96e0392321a7", - "name": "Search quote_attachments1", - "credentials": { - "pocketbaseHttpApi": { - "id": "hLwB1QPIJEWsuVyW", - "name": "PocketBase HTTP account" - } - } + "name": "Search quote_attachments1" }, { "parameters": { @@ -267,8 +220,8 @@ "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ - 880, - 272 + 960, + 352 ], "id": "01009e23-7df8-4b01-a203-0148373b36d1", "name": "Code in JavaScript1" @@ -284,18 +237,12 @@ "type": "n8n-nodes-pocketbase.pocketbaseHttp", "typeVersion": 1, "position": [ - 432, - 272 + 512, + 352 ], "id": "d312acc9-3b1b-4804-be96-47e386cf9feb", "name": "VIEW claimed", "alwaysOutputData": true, - "credentials": { - "pocketbaseHttpApi": { - "id": "hLwB1QPIJEWsuVyW", - "name": "PocketBase HTTP account" - } - }, "onError": "continueRegularOutput" }, { @@ -309,17 +256,11 @@ "type": "n8n-nodes-pocketbase.pocketbaseHttp", "typeVersion": 1, "position": [ - -1360, - 288 + -1504, + 448 ], "id": "217ab67d-8bbd-4fd8-bea7-1f2e936c0913", - "name": "View users1", - "credentials": { - "pocketbaseHttpApi": { - "id": "hLwB1QPIJEWsuVyW", - "name": "PocketBase HTTP account" - } - } + "name": "View users1" }, { "parameters": { @@ -328,8 +269,8 @@ "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ - -16, - 272 + 64, + 352 ], "id": "49aa5965-281f-467c-9616-413d4de54fb7", "name": "Code in JavaScript2" @@ -377,15 +318,9 @@ "type": "n8n-nodes-pocketbase.pocketbaseHttp", "typeVersion": 1, "position": [ - -240, - 480 - ], - "credentials": { - "pocketbaseHttpApi": { - "id": "hLwB1QPIJEWsuVyW", - "name": "PocketBase HTTP account" - } - } + -160, + 544 + ] }, { "parameters": { @@ -398,17 +333,11 @@ "type": "n8n-nodes-pocketbase.pocketbaseHttp", "typeVersion": 1, "position": [ - -16, - 480 + 64, + 544 ], "id": "221aefad-c648-4e1f-9511-487b4a17f468", - "name": "SEARCH FAILED QUOTES", - "credentials": { - "pocketbaseHttpApi": { - "id": "hLwB1QPIJEWsuVyW", - "name": "PocketBase HTTP account" - } - } + "name": "SEARCH FAILED QUOTES" }, { "parameters": { @@ -417,8 +346,8 @@ "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ - 208, - 480 + 288, + 544 ], "id": "cacf85ba-3b38-4ddd-9a6b-aa9b6e7d290d", "name": "FILTER FAILED QUOTES" @@ -431,8 +360,8 @@ "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.5, "position": [ - 432, - 480 + 512, + 544 ], "id": "7243dd8c-33b7-4b44-8f39-35d3ee5617e6", "name": "DOWNLOAD FAILED QUOTE" @@ -448,17 +377,13 @@ "type": "@aldinokemal2104/n8n-nodes-gowa.gowa", "typeVersion": 1, "position": [ - 656, - 480 + 736, + 544 ], "id": "2639682b-2714-400f-8a57-fe4d7f7823be", "name": "SEND FAILED MESSAGE", - "credentials": { - "goWhatsappApi": { - "id": "NcclW50L0w20LhlV", - "name": "GOWA account" - } - } + "disabled": true, + "onError": "continueErrorOutput" }, { "parameters": { @@ -471,17 +396,12 @@ "type": "@aldinokemal2104/n8n-nodes-gowa.gowa", "typeVersion": 1, "position": [ - 1552, - 464 + 1632, + 448 ], "id": "9d9f79b3-9598-427d-8602-958e769e8529", "name": "Send media files1", - "credentials": { - "goWhatsappApi": { - "id": "NcclW50L0w20LhlV", - "name": "GOWA account" - } - } + "disabled": true }, { "parameters": { @@ -510,23 +430,24 @@ "type": "n8n-nodes-base.if", "typeVersion": 2.3, "position": [ - 1296, - 272 + 1408, + 352 ], "id": "db6ed47b-8a63-4c65-9260-87ab30683752", - "name": "If" + "name": "If", + "disabled": true }, { "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// 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" + "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// that helper threw an opaque error on this instance (execution\n// 163729). A plain base64 `data` field is the same wire shape n8n\n// expects and doesn't need prepareBinaryData's extra bookkeeping.\n//\n// For the auto-generated PDF: `this.helpers.getBinaryDataBuffer()`\n// takes an ITEM INDEX (number), not an item object -- per n8n's docs,\n// `getBinaryDataBuffer(itemIndex, binaryPropertyName)`. Execution\n// 163852 was passing the upstream item object instead of an index,\n// which threw a non-Error opaque value. This node's only upstream\n// node is `HTTP Request` (single item in, single item out), so index\n// 0 is always the right item.\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 buf = await this.helpers.getBinaryDataBuffer(0, '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", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ - -576, - 288 + -608, + 448 ] } ], diff --git a/src/lib/server/quote-pdf-service.test.ts b/src/lib/server/quote-pdf-service.test.ts index 3320ee3..0018f3d 100644 --- a/src/lib/server/quote-pdf-service.test.ts +++ b/src/lib/server/quote-pdf-service.test.ts @@ -802,6 +802,68 @@ describe("generateQuotePdf", () => { .getOne<{ company_letterhead: string }>(quote.id); expect(after.company_letterhead).toBe("ISHERWOOD"); }); + + it("persists the operator's letterhead choice even when the quote is already terminal (e.g. emailed)", async () => { + // Regression: the first fix for the silent letterhead-flip bug + // gated the WHOLE persistence write (letterhead included) + // behind "quote.status is not terminal" — because terminal + // statuses (approved/emailed/complete/cancelled/sending) + // deliberately skip the pdf_generated_at re-stamp. But the + // process page's CNL/ISherwood buttons are usable on an + // already-`emailed` quote (regenerating a PDF after the first + // send is a normal operator action, confirmed live: clicking + // ISherwood on an `emailed` quote renders an ISHERWOOD PDF and + // toasts "quote kept at emailed"). That regen hit the terminal + // branch, rendered correctly, but skipped the PB write + // entirely — so `company_letterhead` never updated, and a + // later auto-regen (sendQuoteEmail's staleness check, e.g. + // triggered by a markup edit) fell back to the stale CNL value + // and shipped the wrong letterhead again. Confirmed against the + // real quote via a direct PocketBase read: company_letterhead + // and pdf_generated_at were both untouched by the "first fix". + const { quote } = await seedQuoteWithPdf(); + await pbMock.collection("quotes").update(quote.id, { + status: "emailed", + }); + 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 before = await pbMock + .collection("quotes") + .getOne<{ pdf_generated_at: string }>(quote.id); + + const outcome = await generateQuotePdf(quote.id, { + letterhead: "ISHERWOOD", + }); + expect(outcome.kind).toBe("ok"); + if (outcome.kind !== "ok") throw new Error("expected ok outcome"); + expect(outcome.companyLetterhead).toBe("ISHERWOOD"); + // Status stays exactly as it was — terminal statuses are never + // silently flipped by a PDF regen. + expect(outcome.status).toBe("emailed"); + + const after = await pbMock + .collection("quotes") + .getOne<{ company_letterhead: string; pdf_generated_at: string; status: string }>( + quote.id, + ); + // The letterhead choice IS persisted now, even on a terminal + // quote — this is the fix. + expect(after.company_letterhead).toBe("ISHERWOOD"); + // But pdf_generated_at and status are untouched, exactly as + // before — terminal-status semantics are preserved. + expect(after.pdf_generated_at).toBe(before.pdf_generated_at); + expect(after.status).toBe("emailed"); + }); }); // ───────────────────────────────────────────────────────────────────────────── diff --git a/src/lib/server/quote-pdf-service.ts b/src/lib/server/quote-pdf-service.ts index 916f0d7..7862860 100644 --- a/src/lib/server/quote-pdf-service.ts +++ b/src/lib/server/quote-pdf-service.ts @@ -475,6 +475,21 @@ export async function generateQuotePdf( ...(autoClaimUserId ? { claimed_by: autoClaimUserId } : {}), ...letterheadPersistence, }); + } else if (Object.keys(letterheadPersistence).length > 0) { + // Terminal statuses (approved/emailed/complete/cancelled/ + // sending) skip the pdf_generated_at re-stamp above on + // purpose, but an explicit operator letterhead choice must + // still be persisted regardless of status. This is the gap + // that let the silent letterhead-flip bug survive the first + // fix: regenerating on an already-`emailed` quote (exactly + // what the process page's CNL/ISherwood buttons allow) hit + // this terminal branch, rendered the right letterhead, but + // never wrote it back — so a later auto-regen (e.g. from + // sendQuoteEmail's staleness check after a markup edit) + // fell back to the stale stored value and shipped the wrong + // letterhead again. Deliberately does NOT touch + // pdf_generated_at or status here. + await pb.collection("quotes").update(quoteId, letterheadPersistence); } } else { pdfGeneratedAt = new Date().toISOString();