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. */ /** * Five levels. 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. "bad" was added * because the jump from "ok" to "never again" is too big and most * disappointments live in the gap (user, 2026-07-28). * * Only "never" raises the warning on a future order. A blacklist that fires for * every mediocre meal stops being read, so "bad" records the disappointment * without triggering the alarm. */ export type Rating = "loved" | "liked" | "ok" | "bad" | "never"; export const RATINGS: Rating[] = ["loved", "liked", "ok", "bad", "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; /** * True when this merchant has ever been marked `never` — and ONLY `never`. * "bad" is deliberately excluded: the warning is a blacklist, and one that * fires for every mediocre meal is one nobody reads. */ 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 { const row = await queryRow<{ merchant_normalized: string | null }>( `SELECT merchant_normalized FROM expense_metadata WHERE transaction_id = $1 OR matched_transaction_id = $1 -- Ordered the same way as replaceLineItems (line-items.ts) and -- matchTransaction (intake-router.ts), so every reader of this row -- resolves the same one a write lands on. NULLS LAST because DESC is -- NULLS FIRST and the row that merely POINTS at the transaction has a -- NULL transaction_id. -- Measured 2026-08-21: 667 expense_metadata rows, 147 of them -- pointing-only, and ZERO transactions resolving to more than one row by -- either link — so this changes nothing today. It is here so the readers -- still agree on the day multiplicity appears. ORDER BY (transaction_id = $1) DESC NULLS LAST, id 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 { 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 -- Case-folded: the platforms capitalise the same restaurant differently -- ("TEG Kebabs & Biryani" on Uber Eats, "TEG KEBABS & BIRYANI" on -- DoorDash). An exact match split one restaurant's history in two, so a -- "never again" recorded through one app never warned you in the other — -- silently defeating the whole point of the memory. WHERE lower(em.merchant_normalized) = lower($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 = { loved: 0, liked: 0, ok: 0, bad: 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(); 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) ), }; }