From 83aec143d419ee35fdde9106b632c28364d5a6cc Mon Sep 17 00:00:00 2001 From: Dave Date: Fri, 30 Oct 2026 23:32:39 +0200 Subject: [PATCH] feat(quotes): record who a quote is sent to (email_to) sendQuoteEmail now stamps the resolved To address on quotes.email_to. The n8n send workflow mails it (falling back to customer.email), so the rep's To override and the sender fallback are honoured, and the audit hook shows the recipient in the quote history. Needs the live step `npm run pb:migrate:quote-email-to`. Co-Authored-By: Claude Sonnet 5.5 --- package.json | 1 + .../2026-10-30-add-quote-email-to.ts | 50 +++++++++++++++++++ src/lib/server/quotes-service.test.ts | 31 ++++++++++++ src/lib/server/quotes-service.ts | 3 ++ src/lib/types.ts | 9 ++++ 5 files changed, 94 insertions(+) create mode 100644 scripts/migrations/2026-10-30-add-quote-email-to.ts diff --git a/package.json b/package.json index 25c5fb4..5436a88 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "pb:migrate:quote-assistant": "npx tsx scripts/migrations/2026-10-16-add-quote-assistant-fields.ts", "pb:migrate:collection-ai": "npx tsx scripts/migrations/2026-10-17-add-collection-ai.ts", "pb:migrate:pool-pending": "npx tsx scripts/migrations/2026-10-30-add-stock-pool-pending.ts", + "pb:migrate:quote-email-to": "npx tsx scripts/migrations/2026-10-30-add-quote-email-to.ts", "pb:migrate:collection-auto-push": "npx tsx scripts/migrations/2026-10-28-add-collection-auto-push.ts", "pb:migrate:order-assistant": "npx tsx scripts/migrations/2026-10-24-add-order-assistant-fields.ts", "pb:migrate:po-number-unique": "npx tsx scripts/migrations/2026-10-19-po-number-unique-index.ts", diff --git a/scripts/migrations/2026-10-30-add-quote-email-to.ts b/scripts/migrations/2026-10-30-add-quote-email-to.ts new file mode 100644 index 0000000..b88afed --- /dev/null +++ b/scripts/migrations/2026-10-30-add-quote-email-to.ts @@ -0,0 +1,50 @@ +/** + * One-shot migration: add `email_to` to the live `quotes` collection + * (idempotent). + * + * `sendQuoteEmail` stamps the resolved To: address here, the n8n + * "Quote Outbound Send" workflow mails it, and the audit hook records + * it, so the quote history says who a quote was sent to. + * + * Run with: + * + * npm run pb:migrate:quote-email-to + * + * Safe to run multiple times. `max` is set explicitly: a PB text field + * with `max: 0` is NOT unlimited, it caps at 5000. + */ +import "./_env-loader"; +import PocketBase from "pocketbase"; + +const PB_URL = process.env.NEXT_PUBLIC_POCKETBASE_URL || process.env.PB_URL || "http://127.0.0.1:8090"; +const ADMIN_EMAIL = process.env.PB_ADMIN_EMAIL; +const ADMIN_PASSWORD = process.env.PB_ADMIN_PASSWORD; + +async function main() { + if (!ADMIN_EMAIL || !ADMIN_PASSWORD) { + throw new Error("Set PB_ADMIN_EMAIL and PB_ADMIN_PASSWORD env vars."); + } + + const pb = new PocketBase(PB_URL); + await pb.collection("_superusers").authWithPassword(ADMIN_EMAIL, ADMIN_PASSWORD); + console.log(`[email_to] Authenticated against ${PB_URL}`); + + const collection = await pb.collections.getOne("quotes"); + if (collection.fields.some((f: { name: string }) => f.name === "email_to")) { + console.log("[email_to] Field already exists, nothing to do."); + return; + } + + await pb.collections.update(collection.id, { + fields: [ + ...collection.fields, + { name: "email_to", type: "text", required: false, presentable: false, max: 500 }, + ], + }); + console.log("[email_to] Field added to quotes."); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/src/lib/server/quotes-service.test.ts b/src/lib/server/quotes-service.test.ts index 36467f2..50ff093 100644 --- a/src/lib/server/quotes-service.test.ts +++ b/src/lib/server/quotes-service.test.ts @@ -1235,6 +1235,35 @@ describe("sendQuoteEmail", () => { expect(result.quote.email_error).toBe(""); }); + it("stamps the resolved recipient on email_to so n8n and the history agree", async () => { + const { quote } = await seedReadyQuote({ customerEmail: "buyer@acme.co.za" }); + const result = await sendQuoteEmail(quote.id, { + cc: "", + bcc: "", + subject: "", + message: "", + attachAuthLetters: false, + selectedAttachmentIds: [], + }); + expect(result.failure).toBeNull(); + expect(result.quote.email_to).toBe("buyer@acme.co.za"); + }); + + it("stamps the To override from the modal on email_to", async () => { + const { quote } = await seedReadyQuote({ customerEmail: "buyer@acme.co.za" }); + const result = await sendQuoteEmail(quote.id, { + to: " other@acme.co.za ", + cc: "", + bcc: "", + subject: "", + message: "", + attachAuthLetters: false, + selectedAttachmentIds: [], + }); + expect(result.failure).toBeNull(); + expect(result.quote.email_to).toBe("other@acme.co.za"); + }); + it("captures selectedAttachmentIds into email_outbound_attachment_ids JSON, filters foreign tokens", async () => { const { quote } = await seedReadyQuote(); // Operator-uploaded files now live on the quote row's @@ -1299,6 +1328,8 @@ describe("sendQuoteEmail", () => { }); expect(result.failure).toBeNull(); expect(result.quote.status).toBe("sending"); + // The fallback address is what n8n must mail, not the empty customer.email. + expect(result.quote.email_to).toBe("walkin@buyer.co.za"); }); it("rejects with placeholder_customer when customer is the Unknown Sender sentinel", async () => { diff --git a/src/lib/server/quotes-service.ts b/src/lib/server/quotes-service.ts index 4073d8b..3e7f43f 100644 --- a/src/lib/server/quotes-service.ts +++ b/src/lib/server/quotes-service.ts @@ -2301,6 +2301,9 @@ export async function sendQuoteEmail( sending_started_at: new Date().toISOString(), email_error: "", email_outbound_attachment_ids: JSON.stringify(validIds), + // The resolved recipient. n8n mails this address, and the audit + // hook records it, so the history shows who the quote went to. + email_to: toAddress, // Persist operator's last-used composition so reopening the // modal shows what was sent. email_cc: payload.cc || "", diff --git a/src/lib/types.ts b/src/lib/types.ts index 87d6be0..59e0485 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -363,6 +363,15 @@ export interface Quote extends BaseRecord { */ email_cc: string; email_bcc: string; + /** + * The To: address the send was validated against, stamped by + * `sendQuoteEmail` (payload.to → customer.email → alternative → + * email_sender). The n8n send workflow mails THIS address (falling + * back to customer.email when empty), so what the rep saw in the + * modal, what the history shows and what was mailed are one value. + * Needs the manual live step `npm run pb:migrate:quote-email-to`. + */ + email_to: string; email_subject_outbound: string; email_message: string; attach_auth_letters: boolean;