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:
parent
6883971bb7
commit
83aec143d4
5 changed files with 94 additions and 0 deletions
|
|
@ -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",
|
||||
|
|
|
|||
50
scripts/migrations/2026-10-30-add-quote-email-to.ts
Normal file
50
scripts/migrations/2026-10-30-add-quote-email-to.ts
Normal 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);
|
||||
});
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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 || "",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue