From a19ad698cd7b82649aa2e11d7ec9e2bd14e8f5d0 Mon Sep 17 00:00:00 2001 From: Dave Date: Wed, 9 Sep 2026 15:44:29 +0200 Subject: [PATCH] fix(quotes): finish migrating quote detail page to split attachment fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 2026-09-09 attachment split (cd359b7) moved outbound/internal attachments to `quotes.outbound_attachments` / `internal_attachments`, migrating the approvals and process pages but missing the main quote detail page. It was still reading/writing the legacy `quote_attachments` collection (`direction="outbound"/"internal"`), which nothing downstream reads anymore. Effect: the Attachments tab and Approve & Send modal on this page couldn't see files uploaded via the new field, so the modal's "selected attachments" checkboxes were built from an empty list — operator-attached outbound files silently never made it into `email_outbound_attachment_ids`, and the customer got an email with no attachments even when the operator had attached one. - Outbound/internal tabs, upload, and delete now go through `/api/v1/quotes/[id]/outbound-attachments` and `/internal-attachments` instead of writing `quote_attachments` rows directly. - Send modal's attachment list is synthesized from the quote row's token fields (same pattern as the approvals page), so `selectedAttachmentIds` actually match what the server validates. - Attachment preview dialog fetches via the `/api/v1/files/quotes/...` proxy instead of PB's raw `/api/files/quote_attachments/...`, which pointed at the wrong collection/id after this fix. Also fixes the n8n "WFDASH — Quote Outbound Send" workflow: three Code nodes filtered quote_attachments rows to filenames containing 'Quote-CNL' or 'Quote-ISHERWOOD' before picking the latest PDF. Since PDF filenames are `Quote-{letterheadCode}-{number}.pdf` and there's a third valid letterhead (WESTSCOTT GROUP OF COMPANIES), any quote sent under that letterhead would starve the whole send chain of an input item and silently no-op. The upstream PB query already scopes correctly by quote id + kind, so the filter was redundant as well as wrong — removed it, kept the latest-PDF-per-quote dedup. Repo snapshot in n8n_workfows/ synced to match the deployed workflow (PIEAkPYYYZLGl6Pj). Co-Authored-By: Claude Sonnet 5 --- ...tbound Send (with-outbound-attachments).json | 16 +- src/app/(app)/quotes/[id]/page.tsx | 294 +++++++++++------- 2 files changed, 197 insertions(+), 113 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 ee5cec6..d5858cb 100644 --- a/n8n_workfows/WFDASH — Quote Outbound Send (with-outbound-attachments).json +++ b/n8n_workfows/WFDASH — Quote Outbound Send (with-outbound-attachments).json @@ -1,5 +1,5 @@ { - "name": "WFDASH \u2014 Quote Outbound Send (push-payload + outbound-attachments)", + "name": "WFDASH — Quote Outbound Send (push-payload + outbound-attachments)", "description": "Sends approved quote emails with the auto-generated PDF AND any operator-selected outbound attachments (quotes.outbound_attachments). Triggered by Next.js flipping the quote to status='sending'. The Build attachments list Code node merges the PDF binary with each operator-selected outbound token (validated server-side in sendQuoteEmail and persisted as email_outbound_attachment_ids JSON), downloading the outbound files from PB. SMTP failure now flips the quote to mail_failed (was send_failed, which the retry path didn't recognise).", "nodes": [ { @@ -45,7 +45,7 @@ 288 ], "id": "81e80622-3c74-4aa5-b94e-638c0ba42507", - "name": "When clicking \u2018Execute workflow\u2019" + "name": "When clicking ‘Execute workflow’" }, { "parameters": { @@ -133,7 +133,7 @@ }, { "parameters": { - "jsCode": "// 1. Only keep attachments whose filename matches allowed quote prefixes\nconst allowed = ['Quote-CNL', 'Quote-ISHERWOOD'];\n\nconst items = $input.all();\n\nconst filtered = items.filter(item => {\n const filename = item.json.filename || '';\n return allowed.some(prefix => filename.includes(prefix));\n});\n\n// 2. Keep only the latest PDF attachment row per quote\nconst byQuote = {};\nfor (const item of filtered) {\n const quoteId = item.json.quote;\n const uploaded = new Date(item.json.uploaded_at).getTime();\n\n if (!byQuote[quoteId] || uploaded > byQuote[quoteId].uploaded) {\n byQuote[quoteId] = { item, uploaded };\n }\n}\n\nreturn Object.values(byQuote).map(entry => entry.item);" + "jsCode": "// Keep only the latest PDF attachment row per quote.\n// (Upstream `Search quote_attachments` already scopes to this quote's\n// id and kind = 'pdf'; no further filename filtering needed here.)\nconst items = $input.all();\n\nconst byQuote = {};\nfor (const item of items) {\n const quoteId = item.json.quote;\n const uploaded = new Date(item.json.uploaded_at).getTime();\n\n if (!byQuote[quoteId] || uploaded > byQuote[quoteId].uploaded) {\n byQuote[quoteId] = { item, uploaded };\n }\n}\n\nreturn Object.values(byQuote).map(entry => entry.item);" }, "type": "n8n-nodes-base.code", "typeVersion": 2, @@ -262,7 +262,7 @@ }, { "parameters": { - "jsCode": "// 1. Only keep attachments whose filename matches allowed quote prefixes\nconst allowed = ['Quote-CNL', 'Quote-ISHERWOOD'];\n\nconst items = $input.all();\n\nconst filtered = items.filter(item => {\n const filename = item.json.filename || '';\n return allowed.some(prefix => filename.includes(prefix));\n});\n\n// 2. Keep only the latest PDF attachment row per quote\nconst byQuote = {};\nfor (const item of filtered) {\n const quoteId = item.json.quote;\n const uploaded = new Date(item.json.uploaded_at).getTime();\n\n if (!byQuote[quoteId] || uploaded > byQuote[quoteId].uploaded) {\n byQuote[quoteId] = { item, uploaded };\n }\n}\n\nreturn Object.values(byQuote).map(entry => entry.item);" + "jsCode": "// Keep only the latest PDF attachment row per quote.\n// (Upstream `Search quote_attachments1` already scopes to this quote's\n// id and kind = 'pdf'; no further filename filtering needed here.)\nconst items = $input.all();\n\nconst byQuote = {};\nfor (const item of items) {\n const quoteId = item.json.quote;\n const uploaded = new Date(item.json.uploaded_at).getTime();\n\n if (!byQuote[quoteId] || uploaded > byQuote[quoteId].uploaded) {\n byQuote[quoteId] = { item, uploaded };\n }\n}\n\nreturn Object.values(byQuote).map(entry => entry.item);" }, "type": "n8n-nodes-base.code", "typeVersion": 2, @@ -412,7 +412,7 @@ }, { "parameters": { - "jsCode": "// 1. Only keep attachments whose filename matches allowed quote prefixes\nconst allowed = ['Quote-CNL', 'Quote-ISHERWOOD'];\n\nconst items = $input.all();\n\nconst filtered = items.filter(item => {\n const filename = item.json.filename || '';\n return allowed.some(prefix => filename.includes(prefix));\n});\n\n// 2. Keep only the latest PDF attachment row per quote\nconst byQuote = {};\nfor (const item of filtered) {\n const quoteId = item.json.quote;\n const uploaded = new Date(item.json.uploaded_at).getTime();\n\n if (!byQuote[quoteId] || uploaded > byQuote[quoteId].uploaded) {\n byQuote[quoteId] = { item, uploaded };\n }\n}\n\nreturn Object.values(byQuote).map(entry => entry.item);" + "jsCode": "// Keep only the latest PDF attachment row per quote.\n// (Upstream `SEARCH FAILED QUOTES` already scopes to this quote's\n// id and kind = 'pdf'; no further filename filtering needed here.)\nconst items = $input.all();\n\nconst byQuote = {};\nfor (const item of items) {\n const quoteId = item.json.quote;\n const uploaded = new Date(item.json.uploaded_at).getTime();\n\n if (!byQuote[quoteId] || uploaded > byQuote[quoteId].uploaded) {\n byQuote[quoteId] = { item, uploaded };\n }\n}\n\nreturn Object.values(byQuote).map(entry => entry.item);" }, "type": "n8n-nodes-base.code", "typeVersion": 2, @@ -518,7 +518,7 @@ }, { "parameters": { - "jsCode": "// Build the full attachments list for the outbound SMTP send.\n//\n// The PDF comes in as binary on the input item (the upstream\n// `HTTP Request` node downloaded it from PB's quote_attachments\n// collection). The outbound attachments the operator chose live on\n// the parent quote row in `quotes.outbound_attachments`; the dashboard\n// writes the chosen tokens as a JSON array in\n// `email_outbound_attachment_ids` when the operator clicks \"Send\".\n//\n// We trust the server-side validation that produced\n// `email_outbound_attachment_ids` (see sendQuoteEmail in\n// quotes-service.ts \u2014 it filters tokens against the row's actual\n// `outbound_attachments` field, so a tampered client can't smuggle in\n// files from another quote), and we never download files that aren't\n// present in `outbound_attachments` either.\n//\n// The output shape matches the deployed `WFDASH - Purchase Order Send`\n// workflow's Code node output: an `attachments` array of\n// `{name, content (base64), type}` records. The SMTP node's\n// `options.attachments` expression picks this array up directly.\n\nconst PB_BASE = 'https://pocketbase-gnqc88suy8fqk6wyryjjdpvl.169.239.180.130.sslip.io';\n\nconst first = $input.first();\nconst binary = (first && first.binary) || {};\nconst pdfBin = binary.data;\n\nconst quoteRow = ($('Search quotes').first() || {}).json || {};\nconst quoteId = quoteRow.id;\nconst quoteNumber = quoteRow.quote_number || 'quote';\n\n// Pull the operator-selected outbound tokens. The dashboard writes\n// this as a JSON-encoded array of PB file tokens (the same shape used\n// in `quotes.outbound_attachments`). Empty until the operator's first\n// send completes.\nconst allOutboundRaw = quoteRow.outbound_attachments;\nconst allOutbound = Array.isArray(allOutboundRaw)\n ? allOutboundRaw\n : allOutboundRaw\n ? [allOutboundRaw]\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\n// Intersect: tokens must be BOTH in `outbound_attachments` AND in the\n// operator's selection. Defends against drift between the modal and\n// the row (operator unchecks a file in the modal between the snapshot\n// and the send).\nconst tokensToAttach = allOutbound.filter((t) => selected.includes(t));\n\nconst attachments = [];\n\n// 1. PDF (from upstream binary).\nif (pdfBin) {\n try {\n const buf = await this.helpers.getBinaryDataBuffer(first, 'data');\n const mime = pdfBin.mimeType || 'application/pdf';\n const fallbackName = (quoteNumber + '.pdf').replace(/[\\\\/:*?\"<>|]+/g, '_');\n const name = pdfBin.fileName || fallbackName;\n attachments.push({\n name,\n content: Buffer.from(buf).toString('base64'),\n type: mime,\n });\n } catch (err) {\n // PDF download didn't yield binary. We still attempt the send\n // without it so the operator at least sees the outbound attachments\n // (which is the actual gap this code node closes).\n }\n}\n\n// 2. Outbound attachments (download each from PB).\n// Token format is `_` per PB file fields.\n// Strip the random prefix so the operator sees the friendly name.\nfor (const token of tokensToAttach) {\n const idx = token.indexOf('_');\n const displayName = idx >= 0 ? token.slice(idx + 1) : token;\n try {\n const data = await this.helpers.httpRequest({\n method: 'GET',\n url: PB_BASE + '/api/files/quotes/' + quoteId + '/' + token,\n encoding: 'base64',\n returnBinary: false,\n timeout: 15000,\n });\n attachments.push({\n name: displayName,\n content: typeof data === 'string' ? data : (data && data.data) || '',\n type: 'application/octet-stream',\n });\n } catch (err) {\n // Skip individual download failures (token removed between send\n // and execute, network blip) \u2014 the rest of the send still\n // proceeds. Operator will see the count in `attachmentCount`.\n }\n}\n\nreturn [{\n json: Object.assign({}, first.json, {\n attachments,\n attachmentCount: attachments.length,\n }),\n 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// 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" }, "id": "ab09c2d4-3f11-4d6e-a9a1-9b0d3c1f47e2", "name": "Build attachments list", @@ -549,7 +549,7 @@ ] ] }, - "When clicking \u2018Execute workflow\u2019": { + "When clicking ‘Execute workflow’": { "main": [ [ { @@ -808,4 +808,4 @@ ] } } -} \ No newline at end of file +} diff --git a/src/app/(app)/quotes/[id]/page.tsx b/src/app/(app)/quotes/[id]/page.tsx index 7248609..aec850c 100644 --- a/src/app/(app)/quotes/[id]/page.tsx +++ b/src/app/(app)/quotes/[id]/page.tsx @@ -389,17 +389,98 @@ function ResubmitBanner({ ); } +/** + * Synthesize a `QuoteAttachment`-shaped record from a PB file token + * living on `quotes.outbound_attachments` / `quotes.internal_attachments`. + * + * PB stores file fields with `_` tokens. The + * operator-visible filename is the suffix after the first + * underscore. We synthesize the rest of the shape with placeholders + * so `AttachmentsCard` and `SendQuoteModal`-equivalent selection + * (both of which expect `QuoteAttachment[]`) can render + pre-select + * by `id`/token. Other fields (`content_type`, `size_bytes`) are + * best-effort — degrade gracefully since we don't have a real PB + * row to read them from. + * + * Mirrors `tokenToQuoteAttachment` in the approvals review page — + * kept as a local copy rather than a shared import since each page + * owns its own small attachment-list surface. + */ +function tokenToQuoteAttachment( + token: string, + quoteId: string, + direction: "outbound" | "internal", +): QuoteAttachment { + const underscoreIdx = token.indexOf("_"); + const filename = underscoreIdx >= 0 ? token.slice(underscoreIdx + 1) : token; + const ext = filename.split(".").pop()?.toLowerCase() ?? ""; + const contentTypeByExt: Record = { + pdf: "application/pdf", + png: "image/png", + jpg: "image/jpeg", + jpeg: "image/jpeg", + gif: "image/gif", + webp: "image/webp", + doc: "application/msword", + docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + xls: "application/vnd.ms-excel", + xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + txt: "text/plain", + csv: "text/csv", + zip: "application/zip", + dwg: "application/acad", + }; + const kind: QuoteAttachment["kind"] = + ext === "pdf" + ? "pdf" + : ["png", "jpg", "jpeg", "gif", "webp"].includes(ext) + ? "image" + : ["doc", "docx", "xls", "xlsx", "csv", "txt"].includes(ext) + ? "doc" + : "other"; + return { + // `id` is the PB token — callers use it for selection tracking + // (the `SendQuoteModal`'s `selectedAttachmentIds` payload) and + // as the React key. The server validates these tokens against + // `quotes.outbound_attachments` / `internal_attachments` (see + // `sendQuoteEmail` / the attachment routes in quotes-service.ts). + id: token, + collectionId: "", + collectionName: "quotes", + created: "", + updated: "", + filename, + file: [token], + content_type: contentTypeByExt[ext] ?? "application/octet-stream", + size_bytes: 0, + email_message_id: "", + uploaded_at: "", + kind, + direction, + quote: quoteId, + }; +} + /** * AttachmentsCard — generic uploader card shared by the outbound * (for the buyer) and internal (price evidence) attachment - * sections. Both sections write to the same `quote_attachments` - * PB collection; they differ only in the `direction` value the - * uploader stamps on the row and the labels/icon shown in the UI. + * sections. * - * - `direction = "outbound"` → files the customer receives - * (picked up by the n8n email-send workflow) - * - `direction = "internal"` → price-evidence files the office - * uses to justify the prices (never sent to the buyer) + * - outbound → files the customer receives (picked up by the n8n + * email-send workflow); stored as PB file tokens on + * `quotes.outbound_attachments`, via + * `/api/v1/quotes/[id]/outbound-attachments`. + * - internal → price-evidence files the office uses to justify + * the prices (never sent to the buyer); stored on + * `quotes.internal_attachments`, via + * `/api/v1/quotes/[id]/internal-attachments`. + * + * These live directly on the quote row (as of the 2026-09-09 split) + * rather than in the legacy `quote_attachments` collection's + * `direction` field — the old direction-filtered rows are a dead + * end for outbound/internal now; only `kind = "pdf"` / `"inbound"` + * rows in that collection still matter (the generated PDF + the + * original RFQ email attachments). * * UX: * - Drag-and-drop or click-to-pick upload zone (single file at a @@ -1092,10 +1173,30 @@ export default function QuoteDetailPage({ params }: { params: Promise<{ id: stri setItems(quoteItems); const quoteAttachments = await getQuoteAttachments(id); setAttachments(quoteAttachments); - const outbound = await getQuoteAttachments(id, "outbound"); - setOutboundAttachments(outbound); - const internal = await getQuoteAttachments(id, "internal"); - setInternalAttachments(internal); + // Outbound/internal files live on the quote row itself + // (`quotes.outbound_attachments` / `internal_attachments`, + // added in the 2026-09-09 attachment split) — NOT in the + // legacy `quote_attachments` collection's `direction` field, + // which only the generated PDF (`kind = "pdf"`) and inbound + // RFQ email attachments still populate. + const outboundTokensRaw = data.outbound_attachments; + const outboundTokens = Array.isArray(outboundTokensRaw) + ? outboundTokensRaw + : outboundTokensRaw + ? [outboundTokensRaw] + : []; + setOutboundAttachments( + outboundTokens.map((t) => tokenToQuoteAttachment(t, id, "outbound")), + ); + const internalTokensRaw = data.internal_attachments; + const internalTokens = Array.isArray(internalTokensRaw) + ? internalTokensRaw + : internalTokensRaw + ? [internalTokensRaw] + : []; + setInternalAttachments( + internalTokens.map((t) => tokenToQuoteAttachment(t, id, "internal")), + ); const linkedOrder = await getOrderByQuote(id); setLinkedOrderId(linkedOrder?.id || null); // Also pull every order whose `potential_quote` points at us @@ -1168,30 +1269,27 @@ export default function QuoteDetailPage({ params }: { params: Promise<{ id: stri } }, [id, router]); - // Re-fetch only the outbound attachments — cheaper than a full - // quote reload after upload/delete. - const refreshOutboundAttachments = useCallback(async () => { - try { - const outbound = await getQuoteAttachments(id, "outbound"); - setOutboundAttachments(outbound); - } catch (err: unknown) { - toast.error(err instanceof Error ? err.message : "Failed to refresh attachments"); - } - }, [id]); + // Upload/delete on `/api/v1/quotes/[id]/outbound-attachments` and + // `/internal-attachments` both respond with the field's full, + // post-mutation token list — so we can resync state directly from + // the response instead of round-tripping through PB again. + const applyOutboundTokens = useCallback( + (tokens: string[]) => { + setOutboundAttachments( + tokens.map((t) => tokenToQuoteAttachment(t, id, "outbound")), + ); + }, + [id], + ); - // Same idea for the internal / price-evidence attachments — - // separate fetch so an upload doesn't trigger a full quote - // reload. Uses the "internal" direction value, which must exist - // on the `direction` select in the PB admin (see comment on - // the internalAttachments state above). - const refreshInternalAttachments = useCallback(async () => { - try { - const internal = await getQuoteAttachments(id, "internal"); - setInternalAttachments(internal); - } catch (err: unknown) { - toast.error(err instanceof Error ? err.message : "Failed to refresh attachments"); - } - }, [id]); + const applyInternalTokens = useCallback( + (tokens: string[]) => { + setInternalAttachments( + tokens.map((t) => tokenToQuoteAttachment(t, id, "internal")), + ); + }, + [id], + ); useEffect(() => { fetchQuote(); @@ -1457,29 +1555,28 @@ export default function QuoteDetailPage({ params }: { params: Promise<{ id: stri /** * Upload an outbound attachment — a file the operator wants to - * send with the quote email when it's approved / sent. We POST - * multipart directly to PB's collection (no API route needed — - * the file field is a standard PB file field on quote_attachments). - * - * The new row carries `direction="outbound"` so the email-send - * workflow can pick it up alongside the generated PDF. + * send with the quote email when it's approved / sent. Goes to + * `quotes.outbound_attachments` via the API route (which the n8n + * outbound-send workflow and `sendQuoteEmail` both read directly + * off the quote row) — NOT the legacy `quote_attachments` + * collection, which nothing downstream reads for this anymore. */ const handleOutboundUpload = async (file: File) => { setOutboundUploading(true); try { - const pb = getPocketBase(); const form = new FormData(); - form.append("quote", id); - form.append("filename", file.name); form.append("file", file); - form.append("content_type", file.type || "application/octet-stream"); - form.append("size_bytes", String(file.size)); - form.append("kind", classifyFileKind(file)); - form.append("direction", "outbound"); - form.append("email_message_id", ""); // not from email intake - await pb.collection("quote_attachments").create(form); + const res = await fetch(`/api/v1/quotes/${id}/outbound-attachments`, { + method: "POST", + body: form, + }); + if (!res.ok) { + const body = (await res.json().catch(() => ({}))) as { error?: string }; + throw new Error(body.error || `HTTP ${res.status}`); + } + const body = (await res.json()) as { attachments: string[] }; + applyOutboundTokens(body.attachments); toast.success(`Added ${file.name}`); - await refreshOutboundAttachments(); } catch (err: unknown) { toast.error(err instanceof Error ? err.message : "Failed to upload file"); } finally { @@ -1488,19 +1585,25 @@ export default function QuoteDetailPage({ params }: { params: Promise<{ id: stri }; /** - * Delete an outbound attachment row from PB (which also removes - * the underlying file via the collection's cascade rule). We only - * allow deleting outbound files from this UI — inbound attachments + * Remove a token from `quotes.outbound_attachments`. We only allow + * deleting outbound files from this UI — inbound attachments * belong to the email history and shouldn't be edited here. */ const handleOutboundDelete = async (att: QuoteAttachment) => { if (att.direction !== "outbound") return; setOutboundDeleting(att.id); try { - const pb = getPocketBase(); - await pb.collection("quote_attachments").delete(att.id); + const res = await fetch( + `/api/v1/quotes/${id}/outbound-attachments/${encodeURIComponent(att.id)}`, + { method: "DELETE" }, + ); + if (!res.ok) { + const body = (await res.json().catch(() => ({}))) as { error?: string }; + throw new Error(body.error || `HTTP ${res.status}`); + } + const body = (await res.json()) as { attachments: string[] }; + applyOutboundTokens(body.attachments); toast.success(`Removed ${att.filename}`); - await refreshOutboundAttachments(); } catch (err: unknown) { toast.error(err instanceof Error ? err.message : "Failed to delete attachment"); } finally { @@ -1510,30 +1613,25 @@ export default function QuoteDetailPage({ params }: { params: Promise<{ id: stri /** * Internal / price-evidence upload. Mirrors handleOutboundUpload - * but tags the row with `direction = "internal"` so the n8n - * outbound-email workflow skips it. The "internal" value must - * exist on the `direction` select in the PB admin - * (Collections → quote_attachments → direction → Options). If - * the operator hits an upload that 500s with "Failed to create - * record", the value is missing and needs to be added in the - * PB admin. + * but writes to `quotes.internal_attachments`, which the outbound + * email pipeline never reads. */ const handleInternalUpload = async (file: File) => { setInternalUploading(true); try { - const pb = getPocketBase(); const form = new FormData(); - form.append("quote", id); - form.append("filename", file.name); form.append("file", file); - form.append("content_type", file.type || "application/octet-stream"); - form.append("size_bytes", String(file.size)); - form.append("kind", classifyFileKind(file)); - form.append("direction", "internal"); - form.append("email_message_id", ""); // not from email intake - await pb.collection("quote_attachments").create(form); + const res = await fetch(`/api/v1/quotes/${id}/internal-attachments`, { + method: "POST", + body: form, + }); + if (!res.ok) { + const body = (await res.json().catch(() => ({}))) as { error?: string }; + throw new Error(body.error || `HTTP ${res.status}`); + } + const body = (await res.json()) as { attachments: string[] }; + applyInternalTokens(body.attachments); toast.success(`Added ${file.name}`); - await refreshInternalAttachments(); } catch (err: unknown) { toast.error(err instanceof Error ? err.message : "Failed to upload file"); } finally { @@ -1542,7 +1640,7 @@ export default function QuoteDetailPage({ params }: { params: Promise<{ id: stri }; /** - * Delete an internal / price-evidence attachment. Guarded the + * Remove a token from `quotes.internal_attachments`. Guarded the * same way as the outbound delete — only rows whose `direction` * is "internal" are eligible, so an accidentally-routed inbound * or generated row can't be deleted from this UI. @@ -1551,10 +1649,17 @@ export default function QuoteDetailPage({ params }: { params: Promise<{ id: stri if (att.direction !== "internal") return; setInternalDeleting(att.id); try { - const pb = getPocketBase(); - await pb.collection("quote_attachments").delete(att.id); + const res = await fetch( + `/api/v1/quotes/${id}/internal-attachments/${encodeURIComponent(att.id)}`, + { method: "DELETE" }, + ); + if (!res.ok) { + const body = (await res.json().catch(() => ({}))) as { error?: string }; + throw new Error(body.error || `HTTP ${res.status}`); + } + const body = (await res.json()) as { attachments: string[] }; + applyInternalTokens(body.attachments); toast.success(`Removed ${att.filename}`); - await refreshInternalAttachments(); } catch (err: unknown) { toast.error(err instanceof Error ? err.message : "Failed to delete attachment"); } finally { @@ -3456,27 +3561,6 @@ function roundTo1(n: number): number { return Math.round(n * 10) / 10; } -/** - * Map a `File` to the `kind` enum used by `quote_attachments`. Keeps - * the Outbound Attachments card's icon consistent with the inbound - * email card without forcing the operator to pick a kind. - */ -function classifyFileKind(file: File): "pdf" | "image" | "doc" | "other" { - const ct = (file.type || "").toLowerCase(); - const fn = (file.name || "").toLowerCase(); - if (ct === "application/pdf" || fn.endsWith(".pdf")) return "pdf"; - if (ct.startsWith("image/")) return "image"; - if ( - ct.includes("officedocument") || - ct.includes("msword") || - ct.includes("ms-excel") || - /\.(docx?|xlsx?|csv|ods|rtf)$/.test(fn) - ) { - return "doc"; - } - return "other"; -} - /** * Structured error shape for the PDF viewer modal. We map the route's * `code` field onto a kind we render distinctly — the raw error @@ -4560,14 +4644,14 @@ function OutboundAttachmentPreviewDialog({ setLoading(true); setError(null); try { - const pb = getPocketBase(); - const token = pb.authStore.token; const storedName = Array.isArray(attachment.file) ? attachment.file[0] : attachment.file; - const base = process.env.NEXT_PUBLIC_POCKETBASE_URL || "http://127.0.0.1:8090"; - const fileUrl = `${base}/api/files/quote_attachments/${attachment.id}/${storedName}`; - const res = await fetch(fileUrl, { - headers: token ? { Authorization: token } : undefined, - }); + // Outbound/internal attachments are file tokens living on + // the `quotes` record itself (`attachment.quote`), not rows + // in the legacy `quote_attachments` collection — fetch via + // the WFDASH file proxy per AGENTS.md, never PB's raw + // `/api/files/...` directly. + const fileUrl = `/api/v1/files/quotes/${attachment.quote}/${storedName}`; + const res = await fetch(fileUrl); if (!res.ok) throw new Error(`HTTP ${res.status}`); const blob = await res.blob(); if (cancelled) return;