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();