feat(orders): show From + Subject in review modal Source of truth header

The Source of truth panel previously only showed the email subject and
body inside a collapsed-by-default toggle. Operators couldn't see who
sent the mail or what the subject was without expanding the body.

This change adds a persistent header above the collapsible body with
two always-visible lines:

  From:     jane@acme.test    (AtSign icon)
  Subject:  RFQ for 10x bolts  (Mail icon)

Sender resolution chain (in getOrderReviewData):

  1. order.email_sender  — n8n ingest snapshot of the actual From:
                           on the inbound RFQ (best signal)
  2. quote.email_sender  — covers pre-snapshot legacy orders
  3. customer.email      — last-resort fallback
  4. ''                  — renders as '(unknown sender)' placeholder

Subject already lived on the same record; the header just promotes it
out of the collapsed body so it's visible without expanding. The
existing '(no subject)' / '(no body)' placeholder pattern is matched
with a new '(unknown sender)' placeholder.

Tests: 7 new (3 modal header, 4 service sender fallback chain). 101
passing total.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
SJCoetz 2026-08-05 16:09:53 +02:00
parent 7f1603d9b0
commit ba1f916102
5 changed files with 217 additions and 14 deletions

View file

@ -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<string | null>(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;

View file

@ -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(<OrdersReviewModal {...baseProps()} />);
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(
<OrdersReviewModal
{...baseProps()}
quoteEmail={{ sender: "", subject: "RFQ", body: "" }}
/>,
);
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(
<OrdersReviewModal
{...baseProps()}
quoteEmail={{ sender: "jane@acme.test", subject: "", body: "" }}
/>,
);
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,

View file

@ -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 (
<div className="rounded-md border bg-muted/30 text-xs space-y-3" data-testid="review-source-of-truth">
{/* 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. */}
<div
className="px-3 pt-3 space-y-1.5"
data-testid="review-email-header"
>
<div className="flex items-start gap-1.5">
<AtSign className="h-3 w-3 mt-0.5 shrink-0 text-muted-foreground" />
<span
className="break-all text-foreground"
data-testid="review-email-sender"
>
{quoteEmail.sender || (
<span className="text-muted-foreground italic">
(unknown sender)
</span>
)}
</span>
</div>
<div className="flex items-start gap-1.5">
<Mail className="h-3 w-3 mt-0.5 shrink-0 text-muted-foreground" />
<span
className="break-words text-foreground font-medium"
data-testid="review-email-subject"
>
{quoteEmail.subject || (
<span className="text-muted-foreground italic font-normal">
(no subject)
</span>
)}
</span>
</div>
</div>
{/* 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. */}
<div className="border-b border-border/40">
<div className="border-t border-border/40">
<button
type="button"
onClick={() => setEmailOpen((open) => !open)}
aria-expanded={emailOpen}
aria-controls="review-email-body"
data-testid="review-email-toggle"
className="w-full px-3 pt-3 pb-2 flex items-center gap-1.5 text-left font-semibold uppercase tracking-wide text-muted-foreground hover:text-foreground"
className="w-full px-3 pt-2 pb-2 flex items-center gap-1.5 text-left font-semibold uppercase tracking-wide text-muted-foreground hover:text-foreground"
>
{emailOpen ? (
<ChevronDown className="h-3 w-3" />
) : (
<ChevronRight className="h-3 w-3" />
)}
<Mail className="h-3 w-3" />
Email
Email body
</button>
{emailOpen && (
<div
@ -1268,6 +1309,10 @@ function SourceOfTruth({
className="px-3 pb-3 space-y-1"
data-testid="review-email-body"
>
{/* 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`). */}
<p className="text-foreground font-medium break-words">
{quoteEmail.subject || "(no subject)"}
</p>

View file

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

View file

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