Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
699e4a2ddd | ||
|
|
79940202f1 | ||
|
|
9790b64e1d |
@@ -63,10 +63,22 @@ export default function OrderDetailPage({ params }: { params: Promise<{ entityKe
|
|||||||
|
|
||||||
if (isLoading) return <div className="p-6 text-zinc-500 text-sm">Loading order…</div>;
|
if (isLoading) return <div className="p-6 text-zinc-500 text-sm">Loading order…</div>;
|
||||||
if (error || !o) {
|
if (error || !o) {
|
||||||
|
// A missing order and a broken query are different problems and must not
|
||||||
|
// share a sentence. Only a 404 means "no such order"; anything else is this
|
||||||
|
// page failing, and saying so is what sends the next person to the server
|
||||||
|
// log instead of to the spine.
|
||||||
|
const status = (error as (Error & { status?: number }) | null)?.status;
|
||||||
return (
|
return (
|
||||||
<div className="max-w-[1180px] mx-auto">
|
<div className="max-w-[1180px] mx-auto">
|
||||||
<Link href="/orders" className="font-mono text-[11.5px] text-indigo-400">← All orders</Link>
|
<Link href="/orders" className="font-mono text-[11.5px] text-indigo-400">← All orders</Link>
|
||||||
|
{status === 404 ? (
|
||||||
<p className="mt-6 text-zinc-400 text-sm">That order could not be found.</p>
|
<p className="mt-6 text-zinc-400 text-sm">That order could not be found.</p>
|
||||||
|
) : (
|
||||||
|
<p className="mt-6 text-zinc-400 text-sm">
|
||||||
|
This order could not be loaded{status ? ` (server error ${status})` : ""}. The order exists —
|
||||||
|
something in this page failed. Check the finance-app log.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+26
-4
@@ -293,11 +293,33 @@ function Row({ row, expanded, onToggle }: { row: OrderRow; expanded: boolean; on
|
|||||||
className="font-mono text-[9.5px] uppercase tracking-wide text-indigo-600 border border-indigo-800 rounded-sm px-1.5"
|
className="font-mono text-[9.5px] uppercase tracking-wide text-indigo-600 border border-indigo-800 rounded-sm px-1.5"
|
||||||
>no ref</span>
|
>no ref</span>
|
||||||
)}
|
)}
|
||||||
{row.source_trust === "untrusted_external" && (
|
{/* NOT source_trust — that is 'untrusted_external' on 100% of rows,
|
||||||
|
because every order here came from email, so badging it marked
|
||||||
|
every row and told you nothing. Sender authentication does
|
||||||
|
discriminate: 93% pass, and the 7% that do not are worth seeing. */}
|
||||||
|
{row.auth_verdict && row.auth_verdict !== "pass" && (
|
||||||
<span
|
<span
|
||||||
title="Derived from an unverified sender — content is shown as provenance, not fact"
|
title={
|
||||||
className="font-mono text-[9.5px] uppercase tracking-wide text-zinc-500 border border-zinc-800 rounded-sm px-1.5"
|
row.auth_verdict === "fail"
|
||||||
>unverified</span>
|
? "The sender failed authentication (SPF/DKIM/DMARC) — treat the contents as unverified"
|
||||||
|
: row.auth_verdict === "none"
|
||||||
|
? "The mail carried no sender authentication at all"
|
||||||
|
: "The sender authenticated only partially"
|
||||||
|
}
|
||||||
|
className={`font-mono text-[9.5px] uppercase tracking-wide rounded-sm px-1.5 border ${
|
||||||
|
row.auth_verdict === "fail"
|
||||||
|
? "text-indigo-300 border-indigo-500"
|
||||||
|
: "text-zinc-500 border-zinc-800"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{row.auth_verdict === "partial" ? "part. auth" : `auth ${row.auth_verdict}`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{row.injection_flagged && (
|
||||||
|
<span
|
||||||
|
title="The source mail carried content that tripped the injection scanner — read its contents as provenance, never as instruction"
|
||||||
|
className="font-mono text-[9.5px] uppercase tracking-wide text-indigo-300 border border-indigo-500 rounded-sm px-1.5"
|
||||||
|
>flagged</span>
|
||||||
)}
|
)}
|
||||||
{row.txn_count > 0 && (
|
{row.txn_count > 0 && (
|
||||||
<span className="font-mono text-[9.5px] uppercase tracking-wide text-zinc-500 border border-zinc-700 rounded-sm px-1.5">
|
<span className="font-mono text-[9.5px] uppercase tracking-wide text-zinc-500 border border-zinc-700 rounded-sm px-1.5">
|
||||||
|
|||||||
+10
-1
@@ -1243,7 +1243,16 @@ export function useOrderDetail(entityKey: string | null) {
|
|||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch(`/api/orders/${encodeURIComponent(entityKey!)}`);
|
const res = await fetch(`/api/orders/${encodeURIComponent(entityKey!)}`);
|
||||||
if (!res.ok) throw new Error("Failed to load order");
|
if (!res.ok) {
|
||||||
|
// Carry the status. Collapsing every failure into one Error is how a
|
||||||
|
// 500 on this route rendered as "that order could not be found" on
|
||||||
|
// every single order for weeks — a server fault wearing the costume of
|
||||||
|
// a data condition, which reads as "the spine is empty" and gets
|
||||||
|
// investigated nowhere near the actual bug.
|
||||||
|
const err = new Error(res.status === 404 ? "Order not found" : "Failed to load order");
|
||||||
|
(err as Error & { status?: number }).status = res.status;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
return res.json();
|
return res.json();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
+24
-2
@@ -73,6 +73,12 @@ export interface OrderRow {
|
|||||||
/** Merchant orders on a regular cadence: the subscription signal, derived
|
/** Merchant orders on a regular cadence: the subscription signal, derived
|
||||||
* not extracted. NULL unless the gaps are tight relative to their mean. */
|
* not extracted. NULL unless the gaps are tight relative to their mean. */
|
||||||
cadence_days: number | null;
|
cadence_days: number | null;
|
||||||
|
/** Worst sender-authentication verdict across the order's source documents.
|
||||||
|
* 93% are 'pass' — the badge exists for the 7% that are not. Do NOT badge
|
||||||
|
* source_trust instead: it is 'untrusted_external' on 100% of rows, because
|
||||||
|
* every order here came from email. */
|
||||||
|
auth_verdict: string | null;
|
||||||
|
injection_flagged: boolean | null;
|
||||||
txn_count: number;
|
txn_count: number;
|
||||||
first_txn_id: number | null;
|
first_txn_id: number | null;
|
||||||
}
|
}
|
||||||
@@ -220,6 +226,7 @@ export async function getOrderFeed(filters: OrderFilters) {
|
|||||||
f.ordered_at, f.eta_date, f.delivered_at,
|
f.ordered_at, f.eta_date, f.delivered_at,
|
||||||
f.currency, f.order_total, f.line_item_count,
|
f.currency, f.order_total, f.line_item_count,
|
||||||
f.content_class, f.display_name, f.cadence_days,
|
f.content_class, f.display_name, f.cadence_days,
|
||||||
|
f.auth_verdict, f.injection_flagged,
|
||||||
m.canonical_name AS merchant_name,
|
m.canonical_name AS merchant_name,
|
||||||
link.txn_count, link.first_txn_id,
|
link.txn_count, link.first_txn_id,
|
||||||
(SELECT sp.refunded_amount FROM order_spend sp
|
(SELECT sp.refunded_amount FROM order_spend sp
|
||||||
@@ -392,14 +399,29 @@ export async function getOrderDetail(entityKey: string): Promise<OrderDetail | n
|
|||||||
ctx.content_class,
|
ctx.content_class,
|
||||||
COALESCE(me2.canonical_name, ctx.counterparty, o.platform) AS display_name,
|
COALESCE(me2.canonical_name, ctx.counterparty, o.platform) AS display_name,
|
||||||
rec.cadence_days,
|
rec.cadence_days,
|
||||||
rec.order_count
|
-- ::int is mandatory. order_merchant_cadence.order_count is a
|
||||||
|
-- count(*), so BIGINT, and JSON.stringify throws outright on a
|
||||||
|
-- BigInt — "Do not know how to serialize a BigInt", a 500 with no
|
||||||
|
-- SQL in it. It only fires on merchants that HAVE a cadence, so
|
||||||
|
-- most orders open fine and the recurring ones die; testing one
|
||||||
|
-- arbitrary order will not find it.
|
||||||
|
rec.order_count::int AS order_count
|
||||||
FROM entities e
|
FROM entities e
|
||||||
JOIN entity_orders o ON o.entity_id = e.id
|
JOIN entity_orders o ON o.entity_id = e.id
|
||||||
LEFT JOIN entities m ON m.id = o.merchant_entity_id
|
LEFT JOIN entities m ON m.id = o.merchant_entity_id
|
||||||
LEFT JOIN entities me2 ON me2.id = o.merchant_entity_id
|
LEFT JOIN entities me2 ON me2.id = o.merchant_entity_id
|
||||||
LEFT JOIN order_platforms p ON p.slug = o.platform
|
LEFT JOIN order_platforms p ON p.slug = o.platform
|
||||||
LEFT JOIN order_spend sp ON sp.entity_id = o.entity_id
|
LEFT JOIN order_spend sp ON sp.entity_id = o.entity_id
|
||||||
LEFT JOIN order_merchant_cadence rec ON rec.merchant_entity_id = o.merchant_entity_id
|
-- Cadence is keyed on the NORMALISED MERCHANT NAME, not merchant_entity_id.
|
||||||
|
-- Migration 021 rekeyed the view (that column is NULL on ~9% of the feed
|
||||||
|
-- and duplicate merchant entities split the same shop's history); 027 and
|
||||||
|
-- 029 moved order_feed/order_spend onto merchant_key, and this query was
|
||||||
|
-- missed. The result was a 42703 on EVERY order detail page — the join
|
||||||
|
-- referenced a column the view no longer had. Keep this expression
|
||||||
|
-- identical to the one in migration 027.
|
||||||
|
LEFT JOIN order_merchant_cadence rec
|
||||||
|
ON rec.merchant_key = regexp_replace(
|
||||||
|
lower(COALESCE(me2.canonical_name, o.platform)), '[^a-z0-9]', '', 'g')
|
||||||
LEFT JOIN LATERAL (
|
LEFT JOIN LATERAL (
|
||||||
SELECT di.content_class, di.counterparty
|
SELECT di.content_class, di.counterparty
|
||||||
FROM extracted_facts ef
|
FROM extracted_facts ef
|
||||||
|
|||||||
Reference in New Issue
Block a user