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;