diff --git a/src/app/(app)/orders/page.tsx b/src/app/(app)/orders/page.tsx index 15a08db..5d9927f 100644 --- a/src/app/(app)/orders/page.tsx +++ b/src/app/(app)/orders/page.tsx @@ -186,7 +186,7 @@ function OrdersReviewModalMount({ customer: import("@/lib/types").Customer; company: import("@/lib/types").Company | null; customers: import("@/lib/types").Customer[]; - quoteEmail: { subject: string; body: string }; + quoteEmail: { sender: string; subject: string; body: string }; attachments: import("@/lib/types").QuoteAttachment[]; } | null>(null); const [error, setError] = useState(null); @@ -199,7 +199,7 @@ function OrdersReviewModalMount({ order: import("@/lib/types").Order; items: OrderItem[]; customers: import("@/lib/types").Customer[]; - quoteEmail: { subject: string; body: string }; + quoteEmail: { sender: string; subject: string; body: string }; }>(`/api/v1/orders/${encodeURIComponent(orderId)}/review`); if (cancelled) return; diff --git a/src/components/orders-review-modal.test.tsx b/src/components/orders-review-modal.test.tsx index 878d531..6ec074c 100644 --- a/src/components/orders-review-modal.test.tsx +++ b/src/components/orders-review-modal.test.tsx @@ -122,7 +122,7 @@ const baseProps = () => ({ customers: [baseCustomer], customer: baseCustomer, company: baseCompany, - quoteEmail: { subject: "Re: RFQ", body: "Hi team..." }, + quoteEmail: { sender: "jane@acme.test", subject: "Re: RFQ", body: "Hi team..." }, attachments: [], }); @@ -825,6 +825,63 @@ describe("OrdersReviewModal", () => { }); }); + describe("source of truth email header (From + Subject)", () => { + it("renders sender and subject above the collapsible email body — visible without expanding", () => { + // The From: line and Subject: line must always be on screen, + // independent of whether the operator has expanded the email + // body. They're the quick-scan header for the audit trail. + render(); + const header = screen.getByTestId("review-email-header"); + expect(header).toBeInTheDocument(); + expect(screen.getByTestId("review-email-sender")).toHaveTextContent( + "jane@acme.test", + ); + expect(screen.getByTestId("review-email-subject")).toHaveTextContent( + "Re: RFQ", + ); + // And the email body toggle is still collapsed by default. + expect( + screen.queryByTestId("review-email-body"), + ).not.toBeInTheDocument(); + }); + + it("renders '(unknown sender)' when sender is empty", () => { + // Empty sender → friendly placeholder so the From: row is + // never blank (matches the existing '(no subject)' / '(no + // body)' pattern). + render( + , + ); + expect(screen.getByTestId("review-email-sender")).toHaveTextContent( + "(unknown sender)", + ); + expect(screen.getByTestId("review-email-subject")).toHaveTextContent( + "RFQ", + ); + }); + + it("renders '(no subject)' when subject is empty but sender is present", () => { + // Subject-less emails are real (some RFQs ship with no + // subject line). Subject placeholder applies independently + // from sender — sender is still shown if we have it. + render( + , + ); + expect(screen.getByTestId("review-email-sender")).toHaveTextContent( + "jane@acme.test", + ); + expect(screen.getByTestId("review-email-subject")).toHaveTextContent( + "(no subject)", + ); + }); + }); + describe("customer cascade (company + rep)", () => { const expandedCustomerA: Customer = { ...baseCustomer, diff --git a/src/components/orders-review-modal.tsx b/src/components/orders-review-modal.tsx index 49563f3..06b1599 100644 --- a/src/components/orders-review-modal.tsx +++ b/src/components/orders-review-modal.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { AlertTriangle, + AtSign, Check, ChevronDown, ChevronRight, @@ -67,7 +68,7 @@ export interface OrdersReviewModalProps { customers: Customer[]; customer: Customer; company: Company | null; - quoteEmail: { subject: string; body: string }; + quoteEmail: { sender: string; subject: string; body: string }; attachments: QuoteAttachment[]; } @@ -1215,7 +1216,7 @@ function SourceOfTruth({ quoteEmail, attachments, }: { - quoteEmail: { subject: string; body: string }; + quoteEmail: { sender: string; subject: string; body: string }; attachments: QuoteAttachment[]; }) { // Email body is always rendered; the operator can expand it when @@ -1241,26 +1242,66 @@ function SourceOfTruth({ return (
+ {/* From + Subject header — ALWAYS visible above the + collapsible email body. The operator needs to see who sent + the mail and what the subject was at a glance, without + having to expand the email. Resolution chain for the + sender lives in `getOrderReviewData` — by the time it + reaches this component, `sender` is already the best + available signal (order snapshot → quote → customer + email), or empty string for unknown. Empty renders as + "(unknown sender)" to match the existing "(no subject)" + / "(no body)" placeholder pattern. */} +
+
+ + + {quoteEmail.sender || ( + + (unknown sender) + + )} + +
+
+ + + {quoteEmail.subject || ( + + (no subject) + + )} + +
+
{/* Email body — ALWAYS visible (collapsible). Audit trail of the customer's request: the operator reads it when an AI field looks wrong. Long quotes get a collapse toggle so the preview pane stays usable. */} -
+
{emailOpen && (
+ {/* Subject repeats here too, inside the body, so + operators who DO open the email still see From → + Subject → Body in reading order. Cheap to repeat + (already in `quoteEmail`). */}

{quoteEmail.subject || "(no subject)"}

diff --git a/src/lib/server/orders-service-review.test.ts b/src/lib/server/orders-service-review.test.ts index 3009531..317433a 100644 --- a/src/lib/server/orders-service-review.test.ts +++ b/src/lib/server/orders-service-review.test.ts @@ -1013,6 +1013,7 @@ describe("getOrderReviewData", () => { const result = await getOrderReviewData(order.id); expect(result.quoteEmail).toEqual({ + sender: "", subject: "RFQ for 10x M10 bolts", body: "Hi team, please quote on the attached.\nThanks", }); @@ -1041,6 +1042,7 @@ describe("getOrderReviewData", () => { const result = await getOrderReviewData(order.id); expect(result.quoteEmail).toEqual({ + sender: "", subject: "Quote Q-001 attached", body: "Please find our quote attached.", }); @@ -1067,8 +1069,87 @@ describe("getOrderReviewData", () => { const result = await getOrderReviewData(order.id); expect(result.quoteEmail).toEqual({ + sender: "", subject: "Updated RFQ body", body: "Latest customer message", }); }); + + it("surfaces the order's own email_sender when set", async () => { + // The n8n ingest agent snapshots the inbound From: address onto + // the order itself (orders.email_sender). The review modal's + // "From:" line must read from that snapshot — not the related + // quote's email_sender — so the value matches what the customer + // actually sent on this specific order. + const order = await pbMock.collection("orders").create({ + order_number: "ORD-2026-07-001", + customer: "cust-1", + email_sender: "jane@acme.test", + email_subject: "RFQ", + email_body: "Body", + }); + + const result = await getOrderReviewData(order.id); + + expect(result.quoteEmail.sender).toBe("jane@acme.test"); + }); + + it("falls back to the related quote's email_sender when the order column is empty", async () => { + // Pre-snapshot orders (created before orders.email_sender + // existed) carry the original From: address on the related + // quote (`quote.email_sender`). The modal must still show a + // useful sender for these legacy rows. + const quote = await pbMock.collection("quotes").create({ + id: "quote-1", + quote_number: "Q-001", + email_sender: "jane@acme.test", + email_subject: "RFQ", + email_body: "Body", + }); + const order = await pbMock.collection("orders").create({ + order_number: "ORD-2026-07-001", + customer: "cust-1", + quote: "quote-1", + // email_sender intentionally absent. + }); + + const result = await getOrderReviewData(order.id); + + expect(result.quoteEmail.sender).toBe("jane@acme.test"); + }); + + it("falls back to the linked customer's email when neither order nor quote has email_sender", async () => { + // Last-resort fallback so the modal always has a From: line. + // The customer's general email isn't necessarily the address + // that triggered *this* order, but it's the best signal we + // have without snapshot data. + await pbMock.collection("customers").create({ + id: "cust-1", + customer: "Jane", + email: "jane@acme.test", + }); + const order = await pbMock.collection("orders").create({ + order_number: "ORD-2026-07-001", + customer: "cust-1", + // email_sender absent on both order and quote. + }); + + const result = await getOrderReviewData(order.id); + + expect(result.quoteEmail.sender).toBe("jane@acme.test"); + }); + + it("returns an empty sender when no signal is available (modal renders '(unknown sender)')", async () => { + // No order.email_sender, no related quote, no customer email. + // The modal must still render — empty sender becomes the + // "(unknown sender)" placeholder rather than the page failing. + const order = await pbMock.collection("orders").create({ + order_number: "ORD-2026-07-001", + customer: "", + }); + + const result = await getOrderReviewData(order.id); + + expect(result.quoteEmail.sender).toBe(""); + }); }); diff --git a/src/lib/server/orders-service.ts b/src/lib/server/orders-service.ts index 32e0cde..50ef557 100644 --- a/src/lib/server/orders-service.ts +++ b/src/lib/server/orders-service.ts @@ -748,6 +748,13 @@ export async function getOrdersArchiveData( } export interface OrderReviewEmail { + /** + * From: address of the customer email that originated this order. + * Resolved with the same fallback chain as `subject` / `body` — + * see `getOrderReviewData` below. Empty string means the sender + * is genuinely unknown (the modal renders `(unknown sender)`). + */ + sender: string; subject: string; body: string; } @@ -758,10 +765,11 @@ export async function getOrderReviewData(orderId: string): Promise<{ customers: Customer[]; /** * Source email for the review modal. Prefers the order's own - * `email_subject` / `email_body` (populated by the n8n agent when - * it ingests the customer email that triggered the order). Falls - * back to the related `quote.email_*` for older orders that were - * created before the snapshot columns existed on `orders`. + * `email_subject` / `email_body` / `email_sender` (populated by + * the n8n agent when it ingests the customer email that + * triggered the order). Falls back to the related `quote.email_*` + * for older orders that were created before the snapshot + * columns existed on `orders`. */ quoteEmail: OrderReviewEmail; }> { @@ -790,19 +798,31 @@ export async function getOrderReviewData(orderId: string): Promise<{ // The quote side carries the *operator's* email_body, not the // customer's, so this fallback is best-effort — the order // columns are the audit trail we actually want to show. + // + // Sender resolution: order.email_sender → quote.email_sender → + // customer.email (last resort). The customer's general email + // isn't necessarily the address that triggered this specific + // order — it's only used so the modal has *something* to show + // for pre-snapshot orders. The empty-string fallthrough renders + // as "(unknown sender)" in the modal. const orderSubject = (order.email_subject || "").trim(); const orderBody = (order.email_body || "").trim(); + const orderSender = (order.email_sender || "").trim(); const quote = order.expand?.quote as - | { email_subject?: string; email_body?: string } + | { email_subject?: string; email_body?: string; email_sender?: string } | undefined; const quoteSubject = (quote?.email_subject || "").trim(); const quoteBody = (quote?.email_body || "").trim(); + const quoteSender = (quote?.email_sender || "").trim(); + const customerEmail = ((order.expand?.customer as Customer | undefined)?.email || "").trim(); + const sender = orderSender || quoteSender || customerEmail; return { order, items, customers, quoteEmail: { + sender, subject: orderSubject || quoteSubject, body: orderBody || quoteBody, },