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.
This commit is contained in:
2026-07-28 15:31:22 +10:00
parent d081d80a3f
commit 0cb46a087b
9 changed files with 1123 additions and 8 deletions
+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"] });
},
});
}