2 Commits
Author SHA1 Message Date
siddharthd b8919a4775 feat(transactions): honour ?q= so a link can land on one row
ci / lint-test (push) Successful in 42s
The Slack order nudge links here. Without it the link drops you at the
top of an unfiltered ledger and the merchant has to be found by hand,
which is how a nudge stops being opened.
2026-07-28 15:34:24 +10:00
siddharthd 0cb46a087b feat(orders): record what we thought of an order, per person
The ledger already knew we had ordered from a place; it did not know the
food was bad. Orders got repeated from places we disliked because nobody
remembered by the time the next one went in. That is what the receipt
ingestion was for (ING-9) and the last piece was missing: order_reviews
existed as a table with no API, no UI and no writes.

Four levels, not three. "Loved" and "liked" are both "would order again"
but only one is worth a detour, and "ok" is not a recommendation.

A verdict belongs to a person, not to an order. A shared meal produces
two opinions and they routinely disagree — that disagreement is the
useful part, and the old UNIQUE on transaction_id alone could not hold
it. Now UNIQUE (transaction_id, participant_id), and the default is the
signed-in user rather than the owner: Sonu authenticates through the same
Traefik OAuth as participant 4, so an owner default would have filed her
verdict under his name.

Per-item opinions key on the item DESCRIPTION, not its index. An index is
meaningless across orders; "the Pad Thai here is good" is the signal that
has to survive into the next order from the same merchant. Only the two
poles are offered — a per-item "ok" answers neither of the questions you
ask at order time.

Sharing is recorded as a real 50/50 split, not a decorative flag. The
split already IS the record that an order was shared, and two records of
one fact drift apart.

An ABSENT item_verdicts means "leave them alone"; an empty array clears
them. Without that distinction a note-only save silently wipes every
per-item opinion — the same shape as the bug that reset `settled` on
split rewrites, and just as invisible on screen. Mutation-tested: making
keepItems a no-op fails exactly one test.

mockDbWithPool gained queryRow. Omitting an export from the mock makes it
undefined at the call site, which fails as "not a function" and reads
like a code bug rather than a test-harness gap.
2026-07-28 15:31:22 +10:00
10 changed files with 1133 additions and 11 deletions
@@ -0,0 +1,52 @@
-- A four-level verdict, per-item opinions, and one verdict per PERSON.
--
-- Three levels collapsed the distinction that actually drives a re-order:
-- "loved" and "liked" are both "would order again", but only one is worth a
-- detour, and "ok" is not a recommendation. Asked for by the user 2026-07-28.
--
-- Safe as a straight swap: order_reviews had 0 rows when this was written, so
-- there are no old values to map. If that ever stops being true, map
-- again->liked, fine->ok, never->never BEFORE adding the constraint.
ALTER TABLE order_reviews DROP CONSTRAINT IF EXISTS chk_order_review_rating;
ALTER TABLE order_reviews ADD CONSTRAINT chk_order_review_rating
CHECK (rating IS NULL OR rating IN ('loved', 'liked', 'ok', 'never'));
-- ---------------------------------------------------------------------------
-- A verdict belongs to a person, not to an order.
--
-- A shared meal produces two opinions and they routinely disagree — that
-- disagreement is the useful part, and one row per transaction cannot hold it.
-- The Slack nudge asks whether the order was shared; a yes both splits the
-- expense and asks the other person for their verdict, so the second row is
-- the normal case for anything shared, not an edge case.
--
-- No DEFAULT on participant_id on purpose: a verdict silently attributed to
-- whoever happens to be id 1 is worse than an insert that fails loudly.
ALTER TABLE order_reviews
ADD COLUMN IF NOT EXISTS participant_id integer
REFERENCES participants(id) ON DELETE CASCADE;
UPDATE order_reviews SET participant_id = 1 WHERE participant_id IS NULL;
ALTER TABLE order_reviews ALTER COLUMN participant_id SET NOT NULL;
-- Replace the per-transaction uniqueness with per-transaction-per-person.
-- Dropping this is what allows the second opinion to exist at all.
ALTER TABLE order_reviews DROP CONSTRAINT IF EXISTS order_reviews_transaction_id_key;
ALTER TABLE order_reviews
ADD CONSTRAINT order_reviews_transaction_participant_key
UNIQUE (transaction_id, participant_id);
-- ---------------------------------------------------------------------------
-- item_verdicts already exists as jsonb DEFAULT '[]'. It has never been
-- written. The shape is now fixed as:
-- [{"item": "<line item description>", "verdict": "loved"|"never"}]
--
-- Keyed by description rather than by position in line_items: an index is
-- meaningless across orders, and the reusable signal is "the Pad Thai here is
-- good", which has to survive into the next order from the same merchant.
-- Only the poles are offered — a per-item "ok" is noise nobody would ever read.
ALTER TABLE order_reviews ADD CONSTRAINT chk_order_review_item_verdicts
CHECK (jsonb_typeof(item_verdicts) = 'array');
+7 -2
View File
@@ -42,6 +42,7 @@ model participants {
account_owner_mappings account_owner_mappings[]
payments_sent split_payments[] @relation("payments_from")
payments_received split_payments[] @relation("payments_to")
order_reviews order_reviews[]
}
model account_owner_mappings {
@@ -192,7 +193,7 @@ model transactions {
superseded_by transactions? @relation("superseded", fields: [superseded_by_id], references: [id], onDelete: SetNull)
supersedes transactions[] @relation("superseded")
expense_metadata expense_metadata?
order_review order_reviews?
order_reviews order_reviews[]
}
model expense_metadata {
@@ -221,7 +222,8 @@ model expense_metadata {
model order_reviews {
id Int @id @default(autoincrement())
transaction_id Int @unique
transaction_id Int
participant_id Int
rating String?
order_again Boolean?
note String?
@@ -229,6 +231,9 @@ model order_reviews {
created_at DateTime @default(now())
updated_at DateTime @updatedAt
transaction transactions @relation(fields: [transaction_id], references: [id], onDelete: Cascade)
participant participants @relation(fields: [participant_id], references: [id], onDelete: Cascade)
@@unique([transaction_id, participant_id], name: "order_reviews_transaction_participant_key")
}
model rule_apply_runs {
+7
View File
@@ -16,6 +16,13 @@ export function mockDbWithPool(p: Pool) {
const result = await p.query(sql, params);
return result.rows;
},
// Mirrors the real module: a mock that omits an export makes it `undefined`
// at the call site, so any route using queryRow fails with a confusing
// "not a function" rather than a query error.
queryRow: async (sql: string, params: unknown[] = []) => {
const result = await p.query(sql, params);
return result.rows[0] ?? null;
},
prisma: p,
}));
}
@@ -0,0 +1,208 @@
import { describe, it, expect, beforeAll, beforeEach, vi } from "vitest";
import { createPool, mockDbWithPool, resetDB } from "./helpers";
/**
* Verdicts on orders — the "never order from here again" memory (ING-9).
*
* These cover the two things that are easy to get silently wrong and invisible
* on screen when you do: a second person's verdict overwriting the first, and a
* note-only save wiping the per-item opinions. Both are the same shape as the
* bug that reset `settled` on split rewrites.
*/
const pool = createPool();
mockDbWithPool(pool);
let GET: any;
let PUT: any;
let ownerId: number;
let otherId: number;
let txnId: number;
const req = (body?: unknown) =>
({ headers: { get: () => null }, json: async () => body }) as any;
const params = (id: number) => ({ params: Promise.resolve({ id: String(id) }) });
beforeAll(async () => {
vi.doMock("@/lib/auth", () => ({
getCurrentUser: async () => ({ id: ownerId, name: "Owner", email: "o@x" }),
}));
vi.doMock("@/lib/queries", () => ({ canAccessTransactions: async () => true }));
({ GET, PUT } = await import("../../app/api/transactions/[id]/review/route"));
});
/** An ingested order: a transaction plus the expense_metadata behind it. */
async function seedOrder(merchant: string, date = "2026-07-01") {
const t = await pool.query(
`INSERT INTO transactions (transaction_date, description, amount, transaction_type, merchant_name, owner_id)
VALUES ($1, $2, 48.20, 'debit', $3, $4) RETURNING id`,
[date, `DoorDash ${merchant}`, merchant, ownerId]
);
const id = t.rows[0].id as number;
await pool.query(
`INSERT INTO expense_metadata (transaction_id, source, order_reference, merchant_normalized, line_items)
VALUES ($1, 'email', $2, $3, '[]'::jsonb)`,
[id, `ref-${id}`, merchant]
);
return id;
}
beforeEach(async () => {
await resetDB(pool);
const a = await pool.query(`INSERT INTO participants (name) VALUES ('Owner') RETURNING id`);
const b = await pool.query(`INSERT INTO participants (name) VALUES ('Other') RETURNING id`);
ownerId = a.rows[0].id;
otherId = b.rows[0].id;
txnId = await seedOrder("Thai Palace");
});
describe("order verdicts — one per person", () => {
it("keeps both people's verdicts on the same order", async () => {
await PUT(req({ participant_id: ownerId, rating: "loved" }), params(txnId));
const res = await PUT(req({ participant_id: otherId, rating: "never" }), params(txnId));
const body = await res.json();
// The bug this guards: a UNIQUE on transaction_id alone made the second
// save overwrite the first, and the disagreement is the useful part.
expect(body.reviews).toHaveLength(2);
expect(body.reviews.find((r: any) => r.participant_id === ownerId).rating).toBe("loved");
expect(body.reviews.find((r: any) => r.participant_id === otherId).rating).toBe("never");
});
it("defaults the verdict to the signed-in user, not the owner", async () => {
const res = await PUT(req({ rating: "ok" }), params(txnId));
const body = await res.json();
expect(body.reviews[0].participant_id).toBe(ownerId);
});
it("revising a verdict updates rather than duplicating", async () => {
await PUT(req({ participant_id: ownerId, rating: "loved" }), params(txnId));
const res = await PUT(req({ participant_id: ownerId, rating: "never" }), params(txnId));
const body = await res.json();
expect(body.reviews).toHaveLength(1);
expect(body.reviews[0].rating).toBe("never");
});
it("rejects a rating outside the scale", async () => {
const res = await PUT(req({ rating: "amazing" }), params(txnId));
expect(res.status).toBe(400);
});
it("derives order_again from the rating", async () => {
let body = await (await PUT(req({ rating: "never" }), params(txnId))).json();
expect(body.reviews[0].order_again).toBe(false);
body = await (await PUT(req({ rating: "ok" }), params(txnId))).json();
expect(body.reviews[0].order_again).toBe(true);
});
});
describe("item verdicts", () => {
it("a note-only save does not wipe item opinions", async () => {
await PUT(
req({
rating: "liked",
item_verdicts: [{ item: "Pad Thai", verdict: "loved" }],
}),
params(txnId)
);
// No item_verdicts key at all — the shape a note-only form sends.
const res = await PUT(req({ rating: "liked", note: "slow delivery" }), params(txnId));
const body = await res.json();
expect(body.reviews[0].item_verdicts).toEqual([
{ item: "Pad Thai", verdict: "loved" },
]);
expect(body.reviews[0].note).toBe("slow delivery");
});
it("an explicit empty array does clear them", async () => {
await PUT(
req({ rating: "liked", item_verdicts: [{ item: "Pad Thai", verdict: "loved" }] }),
params(txnId)
);
const res = await PUT(req({ rating: "liked", item_verdicts: [] }), params(txnId));
const body = await res.json();
expect(body.reviews[0].item_verdicts).toEqual([]);
});
it("drops malformed entries without failing the save", async () => {
const res = await PUT(
req({
rating: "ok",
item_verdicts: [
{ item: "Pad Thai", verdict: "loved" },
{ item: "", verdict: "loved" },
{ item: "Curry", verdict: "middling" },
],
}),
params(txnId)
);
const body = await res.json();
expect(body.reviews[0].rating).toBe("ok");
expect(body.reviews[0].item_verdicts).toEqual([
{ item: "Pad Thai", verdict: "loved" },
]);
});
});
describe("merchant history", () => {
it("warns when the merchant was ever marked never, and excludes this order", async () => {
const older = await seedOrder("Thai Palace", "2026-06-01");
await PUT(req({ participant_id: ownerId, rating: "never", note: "cold" }), params(older));
const body = await (await GET(req(), params(txnId))).json();
expect(body.merchant.warn).toBe(true);
expect(body.merchant.counts.never).toBe(1);
expect(body.merchant.history.map((h: any) => h.transaction_id)).toEqual([older]);
});
it("does not carry a verdict across different merchants", async () => {
const other = await seedOrder("Pizza Place", "2026-06-01");
await PUT(req({ participant_id: ownerId, rating: "never" }), params(other));
const body = await (await GET(req(), params(txnId))).json();
expect(body.merchant.warn).toBe(false);
});
it("pools item opinions across the merchant's orders", async () => {
const older = await seedOrder("Thai Palace", "2026-06-01");
await PUT(
req({ item_verdicts: [{ item: "Pad Thai", verdict: "loved" }] }),
params(older)
);
const older2 = await seedOrder("Thai Palace", "2026-05-01");
await PUT(
req({ item_verdicts: [{ item: "pad thai", verdict: "loved" }] }),
params(older2)
);
const body = await (await GET(req(), params(txnId))).json();
// Case-folded: the same dish comes back capitalised differently between
// receipts, and two entries for one dish is not a track record.
expect(body.merchant.items).toEqual([{ item: "Pad Thai", loved: 2, never: 0 }]);
});
it("keeps item opinions from reviews that have no overall rating", async () => {
const older = await seedOrder("Thai Palace", "2026-06-01");
await PUT(
req({ item_verdicts: [{ item: "Satay", verdict: "never" }] }),
params(older)
);
const body = await (await GET(req(), params(txnId))).json();
expect(body.merchant.items).toEqual([{ item: "Satay", loved: 0, never: 1 }]);
});
});
describe("share state", () => {
it("reports splits so the panel can show shared vs just me", async () => {
let body = await (await GET(req(), params(txnId))).json();
expect(body.splits).toEqual([]);
await pool.query(
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent)
VALUES ($1, $2, 50)`,
[txnId, otherId]
);
body = await (await GET(req(), params(txnId))).json();
expect(body.splits).toHaveLength(1);
expect(body.splits[0].participant_id).toBe(otherId);
});
});
+14
View File
@@ -11,6 +11,7 @@ import {
NotAReceiptError,
type MessageMeta,
} from "@/lib/order-ingestion";
import { merchantVerdict } from "@/lib/order-reviews";
/**
* Machine ingest endpoint for order receipts.
@@ -77,6 +78,14 @@ export async function POST(req: NextRequest) {
subject: meta.subject,
sender: meta.sender,
});
// What we said about this merchant before, so the Slack nudge can warn at
// the moment the order lands rather than waiting for someone to open the
// app. `result.transactionId` is excluded because a brand-new order has no
// verdict yet — anything found is genuinely a previous visit.
const verdict = result.skipped
? null
: await merchantVerdict(order.merchant_name, result.transactionId);
return NextResponse.json({
kind: "order",
order_reference: order.order_reference,
@@ -85,6 +94,11 @@ export async function POST(req: NextRequest) {
currency: order.currency,
is_family: order.is_family,
...result,
prior_verdict: verdict && {
warn: verdict.warn,
counts: verdict.counts,
last_note: verdict.history.find((h) => h.note)?.note ?? null,
},
});
} catch (e) {
// Not a receipt: promotions, delivery updates, adjustment and refund
@@ -0,0 +1,186 @@
import { NextRequest, NextResponse } from "next/server";
import { queryRaw, queryRow } from "@/lib/db";
import { getCurrentUser } from "@/lib/auth";
import { canAccessTransactions } from "@/lib/queries";
import {
ITEM_VERDICTS,
RATINGS,
merchantForTransaction,
merchantVerdict,
type ItemOpinion,
type OrderReview,
type Rating,
} from "@/lib/order-reviews";
/**
* Verdicts on one delivery order, plus what was said about this merchant
* before.
*
* Both halves come back together on purpose: the panel is useless without the
* history — the whole reason to open it is to see whether this place has
* disappointed us before. Two round trips would let it render the form first
* and the warning second, which is the order that lets you re-order by
* mistake.
*
* `reviews` is a list, not one row. A shared meal has two opinions and they
* routinely disagree; collapsing them to one would keep whichever was saved
* last and silently discard the other person's.
*/
async function authorise(req: NextRequest, id: string) {
const user = await getCurrentUser(req);
if (!user) return { error: NextResponse.json({ error: "Unauthorized" }, { status: 403 }) };
if (!(await canAccessTransactions(user.id, [Number(id)]))) {
return { error: NextResponse.json({ error: "Forbidden" }, { status: 403 }) };
}
return { user };
}
const SELECT_REVIEWS = `
SELECT r.transaction_id, r.participant_id, p.name AS participant_name,
r.rating, r.order_again, r.note, r.item_verdicts, r.updated_at
FROM order_reviews r
JOIN participants p ON p.id = r.participant_id
WHERE r.transaction_id = $1
ORDER BY r.participant_id`;
/**
* Everything the order panel needs that is not the receipt itself.
*
* The splits come back here rather than from a separate endpoint because the
* panel asks one question — "was this shared, and what did we think of it" —
* and the sharing half is answered by whether a split exists. A second request
* would let the verdict render before the share state, which is the order that
* invites a duplicate split.
*/
async function panelState(transactionId: number) {
const [reviews, splits, merchant] = await Promise.all([
queryRaw<OrderReview>(SELECT_REVIEWS, [transactionId]),
queryRaw<{ participant_id: number; share_percent: string }>(
`SELECT participant_id, share_percent FROM transaction_splits
WHERE transaction_id = $1 ORDER BY participant_id`,
[transactionId]
),
merchantForTransaction(transactionId),
]);
return {
reviews,
splits,
merchant: await merchantVerdict(merchant, transactionId),
};
}
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const auth = await authorise(req, id);
if (auth.error) return auth.error;
const transactionId = Number(id);
return NextResponse.json(await panelState(transactionId));
}
/**
* Record or change one person's verdict.
*
* Upsert rather than insert: a verdict is an opinion and opinions get revised.
* `ON CONFLICT (transaction_id, participant_id)` keeps one row per person per
* order however many times the buttons are pressed — and, critically, lets the
* second person's verdict land without touching the first.
*
* A null rating is meaningful — it clears the verdict rather than deleting the
* row, so a note and the item opinions survive changing your mind about the
* overall call.
*/
export async function PUT(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const auth = await authorise(req, id);
if (auth.error) return auth.error;
const transactionId = Number(id);
let body: {
participant_id?: number;
rating?: Rating | null;
order_again?: boolean | null;
note?: string | null;
item_verdicts?: ItemOpinion[] | null;
};
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "invalid JSON" }, { status: 400 });
}
// Defaults to whoever is signed in, NOT to the owner: Sonu authenticates
// through the same Traefik OAuth as participant 4, so an owner default would
// silently file her verdict under his name. An explicit participant_id is
// still honoured — one person entering both opinions at the table is the
// common case in a two-person household.
const participantId = body.participant_id ?? auth.user!.id;
const rating = body.rating ?? null;
if (rating !== null && !RATINGS.includes(rating)) {
// The DB has the same CHECK constraint; failing here gives a usable message
// instead of a 500 carrying a Postgres constraint name.
return NextResponse.json(
{ error: `rating must be one of ${RATINGS.join(", ")} or null` },
{ status: 400 }
);
}
const note = typeof body.note === "string" ? body.note.trim() || null : null;
// An ABSENT item_verdicts means "leave them alone"; an empty array means
// "clear them". Without that distinction, saving a note from a form that
// does not carry the item state silently wipes every per-item opinion — the
// same shape as the bug that reset `settled` on split rewrites, and just as
// invisible on screen.
const keepItems = body.item_verdicts === undefined;
// Drop anything malformed rather than reject the whole save: the rating and
// the note are the parts the user is watching, and failing their edit over a
// bad item entry loses the input they actually gave.
const itemVerdicts: ItemOpinion[] = (body.item_verdicts ?? [])
.filter(
(v): v is ItemOpinion =>
!!v &&
typeof v.item === "string" &&
v.item.trim().length > 0 &&
ITEM_VERDICTS.includes(v.verdict)
)
.map((v) => ({ item: v.item.trim(), verdict: v.verdict }));
// `order_again` is derived when the caller does not say. "never" is the only
// rating that answers the question on its own; "ok" is not a refusal.
const orderAgain =
body.order_again ?? (rating === null ? null : rating !== "never");
await queryRow(
`INSERT INTO order_reviews (transaction_id, participant_id, rating, order_again, note, item_verdicts)
VALUES ($1, $2, $3, $4, $5, $6::jsonb)
ON CONFLICT (transaction_id, participant_id) DO UPDATE
SET rating = EXCLUDED.rating,
order_again = EXCLUDED.order_again,
note = EXCLUDED.note,
item_verdicts = CASE WHEN $7::boolean
THEN order_reviews.item_verdicts
ELSE EXCLUDED.item_verdicts END,
updated_at = now()`,
[
transactionId,
participantId,
rating,
orderAgain,
note,
JSON.stringify(itemVerdicts),
keepItems,
]
);
return NextResponse.json(await panelState(transactionId));
}
+10 -3
View File
@@ -507,6 +507,13 @@ function TransactionsContent() {
const searchParams = useSearchParams();
const initialStatementId = searchParams.get("statement_id") || "";
// `?q=` lands the view on a specific row. The Slack order nudge links here,
// and without it the link drops you at the top of an unfiltered ledger and
// the merchant has to be found by hand — which is how a nudge stops getting
// opened.
const initialQuery = searchParams.get("q") || "";
const initialParsed = parseQuery(initialQuery);
const [filters, setFilters] = useState({
from: "",
to: "",
@@ -514,7 +521,7 @@ function TransactionsContent() {
bank_names: [] as string[],
tag_ids: [] as string[],
transaction_types: [] as string[],
search: "",
search: initialParsed.text,
statement_id: initialStatementId,
sort_by: "transaction_date",
sort_dir: "desc",
@@ -525,8 +532,8 @@ function TransactionsContent() {
has_split: "" as string,
trip_id: "" as string,
});
const [queryInput, setQueryInput] = useState("");
const [queryTokens, setQueryTokens] = useState<QueryToken[]>([]);
const [queryInput, setQueryInput] = useState(initialQuery);
const [queryTokens, setQueryTokens] = useState<QueryToken[]>(initialParsed.tokens);
function handleQueryChange(val: string) {
setQueryInput(val);
+360 -6
View File
@@ -1,6 +1,17 @@
"use client";
import { useOrderReceipt, type OrderReceipt } from "@/lib/hooks";
import { useMemo, useState } from "react";
import {
useOrderReceipt,
useOrderReview,
useParticipants,
useSetOrderReview,
useSetSplits,
type ItemOpinion,
type ItemVerdict,
type OrderReceipt,
type OrderRating,
} from "@/lib/hooks";
const PLATFORM_LABEL: Record<string, string> = {
doordash: "DoorDash",
@@ -9,11 +20,40 @@ const PLATFORM_LABEL: Record<string, string> = {
};
/**
* The receipt behind a delivery order: what was actually bought, and where it
* went. All of it was already stored at ingest and none of it was reachable —
* the row showed a merchant and a total and nothing else.
* Who records verdicts. A two-person household with one primary user: the
* owner records almost everything, and the only other consumer is Sonu (user,
* 2026-07-28). Mirrors OWNER_PARTICIPANT_ID / SECOND_CONSUMER_ID in
* `lib/order-reviews.ts` — duplicated rather than imported because that module
* pulls in the database client and this is a client component.
*/
const OWNER_PARTICIPANT_ID = 1;
const SECOND_CONSUMER_ID = 4;
const RATING_LABEL: Record<OrderRating, string> = {
loved: "Loved it",
liked: "Liked it",
ok: "OK",
never: "Never again",
};
const RATING_STYLE: Record<OrderRating, string> = {
loved: "border-emerald-600 bg-emerald-950 text-emerald-300",
liked: "border-emerald-800 bg-emerald-950/50 text-emerald-400",
ok: "border-zinc-600 bg-zinc-800 text-zinc-300",
never: "border-red-800 bg-red-950 text-red-300",
};
const RATING_ORDER: OrderRating[] = ["loved", "liked", "ok", "never"];
/**
* The receipt behind a delivery order: what was actually bought, where it went,
* and what we thought of it.
*
* Read-only on purpose. This is what a provider sent, not something to edit.
* The receipt half is read-only — it is what a provider sent, not something to
* edit. The verdict half is the only part of an order that changes, and it is
* the reason the receipts are ingested at all (ING-9): the ledger already knew
* we had ordered from here, but not that it was bad, so orders got repeated
* from places we disliked because nobody remembered.
*/
export function OrderDetails({
transactionId,
@@ -26,6 +66,9 @@ export function OrderDetails({
bare?: boolean;
}) {
const { data: receipt, isLoading } = useOrderReceipt(transactionId);
const { data: review } = useOrderReview(transactionId);
const [reviewer, setReviewer] = useState(OWNER_PARTICIPANT_ID);
if (isLoading || !receipt) return null;
const cur = receipt.currency ?? currency ?? "AUD";
@@ -33,6 +76,8 @@ export function OrderDetails({
const items: OrderReceipt["line_items"] = receipt.line_items ?? [];
const route: OrderReceipt["route"] = receipt.route ?? [];
const mine = review?.reviews.find((r) => r.participant_id === reviewer);
return (
<div className={bare ? "" : "border-t border-zinc-800 pt-4"}>
<div className="flex items-baseline justify-between mb-2">
@@ -50,7 +95,7 @@ export function OrderDetails({
{items.length > 0 ? (
<ul className="space-y-1.5 mb-3">
{items.map((it, i) => (
<li key={i} className="flex gap-2 text-xs">
<li key={i} className="flex gap-2 text-xs items-start">
<span className="text-zinc-600 tabular-nums shrink-0">{it.qty}×</span>
<span className="text-zinc-300 flex-1 min-w-0">
{it.description}
@@ -58,6 +103,14 @@ export function OrderDetails({
<span className="block text-zinc-600">{it.options.join(" · ")}</span>
)}
</span>
<ItemVerdictToggle
transactionId={transactionId}
reviewer={reviewer}
item={it.description}
current={mine?.item_verdicts ?? []}
rating={mine?.rating ?? null}
note={mine?.note ?? null}
/>
<span className="text-zinc-400 tabular-nums shrink-0">{fmt(Number(it.amount))}</span>
</li>
))}
@@ -90,6 +143,307 @@ export function OrderDetails({
{receipt.order_reference}
</p>
)}
<OrderVerdict
transactionId={transactionId}
reviewer={reviewer}
onReviewerChange={setReviewer}
/>
</div>
);
}
/**
* Loved / never on a single line item, for the currently selected reviewer.
*
* Only the two poles are offered. A per-item "OK" is noise: the question at the
* next order is "what should I get, what should I avoid", and a middling dish
* answers neither.
*
* Every press sends the whole item array plus the current rating and note,
* because the endpoint upserts a row rather than patching fields — sending a
* partial would blank whatever it omitted.
*/
function ItemVerdictToggle({
transactionId,
reviewer,
item,
current,
rating,
note,
}: {
transactionId: number;
reviewer: number;
item: string;
current: ItemOpinion[];
rating: OrderRating | null;
note: string | null;
}) {
const save = useSetOrderReview();
const existing = current.find(
(v) => v.item.trim().toLowerCase() === item.trim().toLowerCase()
);
const toggle = (verdict: ItemVerdict) => {
const rest = current.filter(
(v) => v.item.trim().toLowerCase() !== item.trim().toLowerCase()
);
// Pressing the active verdict clears it — a mis-tap must be reversible, and
// there is no other route back to "no opinion on this dish".
const next =
existing?.verdict === verdict ? rest : [...rest, { item, verdict }];
save.mutate({
transactionId,
participantId: reviewer,
rating,
note,
itemVerdicts: next,
});
};
return (
<span className="flex gap-0.5 shrink-0">
{(["loved", "never"] as ItemVerdict[]).map((v) => (
<button
key={v}
type="button"
onClick={() => toggle(v)}
disabled={save.isPending}
title={v === "loved" ? "Loved this item" : "Never order this again"}
className={`rounded px-1 leading-none transition-opacity disabled:opacity-40 ${
existing?.verdict === v
? "opacity-100"
: "opacity-25 hover:opacity-60"
}`}
>
{v === "loved" ? "👍" : "👎"}
</button>
))}
</span>
);
}
/**
* Was this order shared? One tap, and the split is the answer.
*
* "Shared" means shared in both senses — we both ate it and we both pay for it
* — so this writes a real 50/50 `transaction_splits` row rather than a
* decorative flag (user, 2026-07-28: the split was part of the original
* requirement). There is no separate "shared" column precisely because the
* split already IS that record, and two records of one fact drift apart.
*
* Unsharing clears the splits. That is safe on an order because an ingested
* order is post-cutover by construction — the DB CHECK forbids credits orders
* before 2026-01-09 — so no settled historical obligation can be sitting on it
* to lose.
*/
function SharedToggle({
transactionId,
splits,
otherName,
}: {
transactionId: number;
splits: { participant_id: number; share_percent: string }[];
otherName: string;
}) {
const setSplits = useSetSplits();
const shared = splits.some((s) => s.participant_id === SECOND_CONSUMER_ID);
return (
<div className="mb-2 flex items-center gap-2">
<button
type="button"
disabled={setSplits.isPending}
onClick={() =>
setSplits.mutate({
transactionId,
splits: shared
? []
: [{ participant_id: SECOND_CONSUMER_ID, share_percent: 50 }],
})
}
className={`rounded border px-2 py-1 text-xs transition-colors disabled:opacity-50 ${
shared
? "border-sky-700 bg-sky-950 text-sky-300"
: "border-zinc-700 text-zinc-500 hover:border-zinc-600 hover:text-zinc-300"
}`}
>
{shared ? `Shared 50/50 with ${otherName}` : "Just me"}
</button>
{shared && (
<span className="text-[11px] text-zinc-600">
ask {otherName} for her verdict too
</span>
)}
</div>
);
}
/**
* The overall verdict, whose it is, and this merchant's track record.
*
* The history sits above the buttons deliberately: it is read before the next
* order, not after, and burying it under the form is how you re-order from a
* place you already rejected.
*/
function OrderVerdict({
transactionId,
reviewer,
onReviewerChange,
}: {
transactionId: number;
reviewer: number;
onReviewerChange: (id: number) => void;
}) {
const { data, isLoading } = useOrderReview(transactionId);
const { data: participants } = useParticipants();
const save = useSetOrderReview();
const [noteDraft, setNoteDraft] = useState<string | null>(null);
const reviewers = useMemo(
() =>
[OWNER_PARTICIPANT_ID, SECOND_CONSUMER_ID].map((id) => ({
id,
name:
id === OWNER_PARTICIPANT_ID
? "Me"
: participants?.find((p) => p.id === id)?.name ?? "Them",
})),
[participants]
);
// No merchant means no receipt behind this row — nothing to have a view on.
if (isLoading || !data?.merchant) return null;
const mine = data.reviews.find((r) => r.participant_id === reviewer);
const current = mine?.rating ?? null;
const noteValue = noteDraft ?? mine?.note ?? "";
const { history, warn, items } = data.merchant;
const others = data.reviews.filter((r) => r.participant_id !== reviewer && r.rating);
const set = (rating: OrderRating) =>
save.mutate({
transactionId,
participantId: reviewer,
// Pressing the active rating clears it — otherwise a mis-tap is
// permanent, and there is no other way back to "no opinion".
rating: rating === current ? null : rating,
note: noteValue.trim() || null,
});
return (
<div className="mt-4 border-t border-zinc-800 pt-3">
{warn && (
<p className="mb-2 text-xs text-red-400">
Marked &ldquo;never again&rdquo; here before.
</p>
)}
{items.length > 0 && (
<p className="mb-2 text-[11px] text-zinc-500">
{items
.filter((i) => i.loved > i.never)
.slice(0, 3)
.map((i) => `👍 ${i.item}`)
.concat(
items
.filter((i) => i.never > 0)
.slice(0, 3)
.map((i) => `👎 ${i.item}`)
)
.join(" · ")}
</p>
)}
<SharedToggle
transactionId={transactionId}
splits={data.splits}
otherName={reviewers[1].name}
/>
<div className="flex items-center gap-2 mb-2">
{reviewers.map((r) => (
<button
key={r.id}
type="button"
onClick={() => {
setNoteDraft(null); // the draft belongs to the person who typed it
onReviewerChange(r.id);
}}
className={`text-xs transition-colors ${
reviewer === r.id
? "text-zinc-200 underline underline-offset-4"
: "text-zinc-600 hover:text-zinc-400"
}`}
>
{r.name}
{data.reviews.some((v) => v.participant_id === r.id && v.rating) && " ✓"}
</button>
))}
</div>
<div className="flex flex-wrap gap-1.5">
{RATING_ORDER.map((r) => (
<button
key={r}
type="button"
onClick={() => set(r)}
disabled={save.isPending}
className={`rounded border px-2 py-1 text-xs transition-colors disabled:opacity-50 ${
current === r
? RATING_STYLE[r]
: "border-zinc-700 text-zinc-500 hover:border-zinc-600 hover:text-zinc-300"
}`}
>
{RATING_LABEL[r]}
</button>
))}
</div>
<input
type="text"
value={noteValue}
placeholder="What was wrong (or right)?"
onChange={(e) => setNoteDraft(e.target.value)}
onBlur={() => {
const next = noteValue.trim() || null;
if (next !== (mine?.note ?? null)) {
save.mutate({
transactionId,
participantId: reviewer,
rating: current,
note: next,
});
}
}}
className="mt-2 w-full rounded border border-zinc-800 bg-zinc-900 px-2 py-1 text-xs text-zinc-300 placeholder:text-zinc-700 focus:border-zinc-600 focus:outline-none"
/>
{others.map((o) => (
<p key={o.participant_id} className="mt-1.5 text-[11px] text-zinc-500">
<span className="text-zinc-400">{o.participant_name}:</span>{" "}
{o.rating && RATING_LABEL[o.rating]}
{o.note && <span className="italic"> {o.note}</span>}
</p>
))}
{history.length > 0 && (
<ul className="mt-2 space-y-1">
{history.map((h) => (
<li
key={`${h.transaction_id}-${h.participant_id}`}
className="text-[11px] text-zinc-600"
>
<span className="tabular-nums">{h.transaction_date}</span>
<span className="ml-1.5 text-zinc-500">
{h.participant_name}
{h.rating ? ` · ${RATING_LABEL[h.rating]}` : ""}
</span>
{h.note && <span className="ml-1.5 italic">{h.note}</span>}
</li>
))}
</ul>
)}
</div>
);
}
+99
View File
@@ -277,6 +277,103 @@ export function useOrderReceipt(transactionId: number) {
});
}
export type OrderRating = "loved" | "liked" | "ok" | "never";
export type ItemVerdict = "loved" | "never";
export interface ItemOpinion {
item: string;
verdict: ItemVerdict;
}
export interface OrderReviewRow {
transaction_id: number;
participant_id: number;
participant_name: string;
rating: OrderRating | null;
order_again: boolean | null;
note: string | null;
item_verdicts: ItemOpinion[];
}
export interface OrderReviewState {
/** One row per person who has an opinion. Empty until someone records one. */
reviews: OrderReviewRow[];
/** Current splits — an empty list means the order was not shared. */
splits: { participant_id: number; share_percent: string }[];
merchant: {
merchant: string;
history: {
transaction_id: number;
participant_id: number;
participant_name: string;
rating: OrderRating | null;
note: string | null;
transaction_date: string | null;
}[];
counts: Record<OrderRating, number>;
warn: boolean;
items: { item: string; loved: number; never: number }[];
} | null;
}
/**
* The verdict on an order and this merchant's track record.
*
* No `staleTime: Infinity` here, unlike the receipt hook next to it — a receipt
* never changes, but a verdict is the one part of an order that does.
*/
export function useOrderReview(transactionId: number) {
return useQuery<OrderReviewState>({
queryKey: ["order-review", transactionId],
queryFn: async () => {
const res = await fetch(`/api/transactions/${transactionId}/review`);
if (!res.ok) return { reviews: [], splits: [], merchant: null };
return res.json();
},
});
}
export function useSetOrderReview() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({
transactionId,
participantId,
rating,
note,
itemVerdicts,
}: {
transactionId: number;
participantId: number;
rating: OrderRating | null;
note?: string | null;
/** Omit to leave existing item opinions untouched. */
itemVerdicts?: ItemOpinion[];
}) => {
const res = await fetch(`/api/transactions/${transactionId}/review`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
participant_id: participantId,
rating,
note,
...(itemVerdicts === undefined ? {} : { item_verdicts: itemVerdicts }),
}),
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || "Failed to save verdict");
}
return res.json();
},
// Every order from the same merchant now shows a different track record,
// so invalidate the whole key rather than this one transaction.
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["order-review"] });
},
});
}
export function useSetSplits() {
const qc = useQueryClient();
return useMutation({
@@ -302,6 +399,8 @@ export function useSetSplits() {
qc.invalidateQueries({ queryKey: ["splits"] });
qc.invalidateQueries({ queryKey: ["shared-transactions"] });
qc.invalidateQueries({ queryKey: ["participant-balances"] });
// The order panel shows share state from this same data.
qc.invalidateQueries({ queryKey: ["order-review"] });
},
});
}
+190
View File
@@ -0,0 +1,190 @@
import { queryRaw, queryRow } from "@/lib/db";
/**
* Verdicts on delivery orders — the "don't order from here again" memory.
*
* The problem this exists for is not accounting. Orders were placed twice from
* places we disliked because nobody remembered by the time the next order went
* in (user, 2026-07-28). The ledger already knew we had been there; it just had
* nowhere to record what we thought of it.
*
* **A verdict is recorded per order per person, but read per merchant.**
* `order_reviews` keys on `(transaction_id, participant_id)`, because what you
* are judging is one delivery — this Thai place was bad *that night*, with
* those items — and because a shared meal produces two opinions that routinely
* disagree. That disagreement is the useful part; one row per transaction
* cannot hold it.
*
* The signal you need later is about the merchant, so it is derived by
* aggregating a merchant's orders rather than stored on one. Storing it per
* merchant instead would mean the second verdict silently overwrites the first
* and you lose the fact that it was fine twice and awful once.
*
* The join key is `expense_metadata.merchant_normalized`, not
* `transactions.merchant_name`: the latter is a bank descriptor and reads
* differently for the same restaurant on different nights.
*/
/**
* Four levels, because three collapsed the distinction that decides a
* re-order: "loved" and "liked" are both "would order again", but only one is
* worth going out of your way for, and "ok" is not a recommendation at all
* (user, 2026-07-28).
*/
export type Rating = "loved" | "liked" | "ok" | "never";
export const RATINGS: Rating[] = ["loved", "liked", "ok", "never"];
/**
* Per-item opinions, keyed by the line item's description.
*
* Only the poles are offered. A per-item "ok" is noise: the useful question at
* the next order is "what should I get / what should I avoid here", and a
* middling dish answers neither.
*/
export type ItemVerdict = "loved" | "never";
export const ITEM_VERDICTS: ItemVerdict[] = ["loved", "never"];
export interface ItemOpinion {
item: string;
verdict: ItemVerdict;
}
/**
* Whose verdict this is by default, and who the other one is.
*
* A two-person household with one primary user: the owner records almost every
* verdict, and the only other consumer is Sonu (user, 2026-07-28). Named rather
* than inlined so the Slack nudge, the split it creates and the second verdict
* it asks for cannot drift apart.
*/
export const OWNER_PARTICIPANT_ID = 1;
export const SECOND_CONSUMER_ID = 4;
export interface OrderReview {
transaction_id: number;
participant_id: number;
participant_name?: string;
rating: Rating | null;
order_again: boolean | null;
note: string | null;
item_verdicts: ItemOpinion[];
updated_at: string;
}
export interface MerchantVerdict {
merchant: string;
/** Verdicts on OTHER orders from this merchant, newest first. */
history: {
transaction_id: number;
participant_id: number;
participant_name: string;
rating: Rating | null;
note: string | null;
transaction_date: string | null;
}[];
counts: Record<Rating, number>;
/** True when this merchant has ever been marked `never`. */
warn: boolean;
/**
* What to get and what to avoid here, pooled across every order from this
* merchant. This is the payoff for recording items at all — the order-level
* rating tells you whether to come back, this tells you what to order when
* you do.
*/
items: { item: string; loved: number; never: number }[];
}
/**
* `merchant_normalized` for a transaction, resolving both directions.
*
* A card-settled order has no transaction of its own — the statement line is
* the transaction and the receipt points at it through
* `matched_transaction_id`. Looking only at `transaction_id` misses exactly the
* orders that were paid by card, which is most of them.
*/
export async function merchantForTransaction(
transactionId: number
): Promise<string | null> {
const row = await queryRow<{ merchant_normalized: string | null }>(
`SELECT merchant_normalized FROM expense_metadata
WHERE transaction_id = $1 OR matched_transaction_id = $1
LIMIT 1`,
[transactionId]
);
return row?.merchant_normalized ?? null;
}
/**
* What we have previously said about a merchant.
*
* `exclude` drops the order being looked at, so the panel shows "what you said
* the other times" rather than echoing the verdict you are currently editing.
* Pass null when there is no current order — the ingest path, where the whole
* point is that nothing has been said about this one yet.
*/
export async function merchantVerdict(
merchant: string | null,
exclude: number | null = null
): Promise<MerchantVerdict | null> {
if (!merchant) return null;
const rows = await queryRaw<{
transaction_id: number;
participant_id: number;
participant_name: string;
rating: Rating | null;
note: string | null;
transaction_date: string | null;
item_verdicts: ItemOpinion[] | null;
}>(
// `rating IS NOT NULL` is deliberately NOT in the WHERE clause: a review
// can carry item verdicts and no overall rating, and dropping those would
// lose exactly the "the noodles here are great" signal this exists for.
`SELECT r.transaction_id, r.participant_id, p.name AS participant_name,
r.rating, r.note, r.item_verdicts,
to_char(t.transaction_date, 'YYYY-MM-DD') AS transaction_date
FROM order_reviews r
JOIN transactions t ON t.id = r.transaction_id
JOIN participants p ON p.id = r.participant_id
JOIN expense_metadata em
ON em.transaction_id = r.transaction_id
OR em.matched_transaction_id = r.transaction_id
WHERE em.merchant_normalized = $1
AND ($2::int IS NULL OR r.transaction_id <> $2)
ORDER BY t.transaction_date DESC, r.participant_id
LIMIT 50`,
[merchant, exclude]
);
const counts: Record<Rating, number> = { loved: 0, liked: 0, ok: 0, never: 0 };
for (const r of rows) if (r.rating) counts[r.rating] += 1;
// Pool item opinions across orders. Case-folded because the same dish comes
// back with inconsistent capitalisation between receipts; the first spelling
// seen is kept for display.
const pool = new Map<string, { item: string; loved: number; never: number }>();
for (const r of rows) {
for (const v of r.item_verdicts ?? []) {
if (!v?.item) continue;
const key = v.item.trim().toLowerCase();
const entry = pool.get(key) ?? { item: v.item.trim(), loved: 0, never: 0 };
if (v.verdict === "loved") entry.loved += 1;
else if (v.verdict === "never") entry.never += 1;
pool.set(key, entry);
}
}
return {
merchant,
history: rows
.filter((r) => r.rating !== null || r.note)
.map(({ item_verdicts: _drop, ...h }) => h),
counts,
warn: counts.never > 0,
items: [...pool.values()].sort(
(a, b) => b.loved + b.never - (a.loved + a.never)
),
};
}