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 <noreply@anthropic.com>
This commit is contained in:
Dave 2026-10-30 23:32:39 +02:00
parent 6883971bb7
commit 83aec143d4
5 changed files with 94 additions and 0 deletions

View file

@ -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",

View file

@ -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);
});

View file

@ -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 () => {

View file

@ -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 || "",

View file

@ -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;