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:
@@ -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"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user