orders: a browse surface for the purchase history the ledger cannot show
ci / lint-test (push) Successful in 59s
ci / lint-test (push) Successful in 59s
The spine holds ~6,300 purchase orders back to 2006, ~4,600 of them itemised, and 61 reach a transaction. Everything else has been visible only through SQL. This adds /orders and /orders/[entityKey] over it. The point of the page is the manifest. /transactions can only ever say "AMAZON AU MARKETPLACE SYDNEY"; a row here says what was in the box, which is the one thing the ledger structurally cannot carry. Five lanes, because the shapes genuinely differ — retail ends refunded or returned 14.1% of the time against food's 5.1%, food has no meaningful ETA where grocery has one on 54.8% of orders, digital never ships at all. The lane comes from order_lane() in migration 018 rather than a column, because slug 'uber' carries 394 taxi rides and 485 Eats orders. Defaults to this year: 449 orders rather than 6,283. Twenty-one years is the archive, not the working set. Three things the data forced. Unknown amounts render "not stated", never $0.00, because 1,648 orders have no amount and a zero would be false. A full reversal strikes the figure through; a partial refund does not, since striking $191.40 when $13.33 came back is a lie — the charge stays primary and the credit sits under it with the net. And rows with no amount, no reference and one lifecycle event are hidden by default, which lifts amount coverage from 74% to 84%; the toggle says on its face that it is a workaround for board 210 rather than a fix. Reads are raw SQL in lib/order-feed.ts rather than queries.ts: the spine is written by the ingestion-engine, is not in prisma/schema.prisma and never will be, and mixing it into a file where everything is Prisma-modelled would destroy that invariant. /orders is gated by an explicit viewer allowlist. Not because the three people listed need protecting from each other — everything here is on one person's cards — but because a participant is an accounting entity and any participant row with an email is a login. Adding someone to split a holiday must not silently hand them the purchase history. Verified live: gate returns 403 for non-participants and for a missing identity header; search "drone" finds the DJI order through its line items; the detail page renders its three lifecycle events and its Afterpay settlement sibling; and a bridged Amazon order shows both split-shipment charges.
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
import { getOrderFeed, getOrderFacets, canViewOrders } from "@/lib/order-feed";
|
||||
|
||||
/**
|
||||
* GET /api/orders — the order browse list.
|
||||
*
|
||||
* Note this route is a sibling of /api/orders/ingest (the n8n webhook). Next
|
||||
* resolves static segments before dynamic ones, so `ingest` is unaffected by
|
||||
* the [entityKey] route next to it — but it does mean "ingest" is now a
|
||||
* reserved order key. Every entity key starts "order_", so no real collision.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const user = await getCurrentUser(req);
|
||||
if (!user) return NextResponse.json({ error: "unauthorized" }, { status: 403 });
|
||||
// The spine has no owner column — this is a participant gate, not a row
|
||||
// filter. See ORDER_VIEWERS in lib/order-feed.ts for why it is an allowlist.
|
||||
if (!canViewOrders(user.id)) {
|
||||
return NextResponse.json({ error: "forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const p = req.nextUrl.searchParams;
|
||||
const list = (k: string) => p.get(k)?.split(",").filter(Boolean);
|
||||
|
||||
const filters = {
|
||||
lane: p.get("lane") ?? undefined,
|
||||
platforms: list("platforms"),
|
||||
statuses: list("statuses"),
|
||||
from: p.get("from") ?? undefined,
|
||||
to: p.get("to") ?? undefined,
|
||||
search: p.get("search") ?? undefined,
|
||||
currency: p.get("currency") ?? undefined,
|
||||
has_transaction: p.get("has_transaction") ?? undefined,
|
||||
// buildParams encodes booleans as "1" and omits them when false, so an
|
||||
// absent param means "on" here — the default hides lifecycle-only rows.
|
||||
hide_lifecycle_only: p.get("show_lifecycle_only") !== "1",
|
||||
sort_by: p.get("sort_by") ?? undefined,
|
||||
sort_dir: p.get("sort_dir") ?? undefined,
|
||||
limit: p.get("limit") ? Number(p.get("limit")) : undefined,
|
||||
offset: p.get("offset") ? Number(p.get("offset")) : undefined,
|
||||
};
|
||||
|
||||
const [result, facets] = await Promise.all([
|
||||
getOrderFeed(filters),
|
||||
getOrderFacets(filters),
|
||||
]);
|
||||
|
||||
return NextResponse.json({ ...result, facets });
|
||||
}
|
||||
Reference in New Issue
Block a user