diff --git a/prisma/migrations/0024_order_verdict_scale/migration.sql b/prisma/migrations/0024_order_verdict_scale/migration.sql new file mode 100644 index 0000000..25d1783 --- /dev/null +++ b/prisma/migrations/0024_order_verdict_scale/migration.sql @@ -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": "", "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'); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 910345e..dde4ba6 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 { diff --git a/src/__tests__/integration/helpers.ts b/src/__tests__/integration/helpers.ts index c06745b..21c2319 100644 --- a/src/__tests__/integration/helpers.ts +++ b/src/__tests__/integration/helpers.ts @@ -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, })); } diff --git a/src/__tests__/integration/order-reviews.test.ts b/src/__tests__/integration/order-reviews.test.ts new file mode 100644 index 0000000..a332875 --- /dev/null +++ b/src/__tests__/integration/order-reviews.test.ts @@ -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); + }); +}); diff --git a/src/app/api/orders/ingest/route.ts b/src/app/api/orders/ingest/route.ts index 8173687..01259c2 100644 --- a/src/app/api/orders/ingest/route.ts +++ b/src/app/api/orders/ingest/route.ts @@ -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 diff --git a/src/app/api/transactions/[id]/review/route.ts b/src/app/api/transactions/[id]/review/route.ts new file mode 100644 index 0000000..13b9018 --- /dev/null +++ b/src/app/api/transactions/[id]/review/route.ts @@ -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(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)); +} diff --git a/src/components/order-details.tsx b/src/components/order-details.tsx index fa309dd..4ae220b 100644 --- a/src/components/order-details.tsx +++ b/src/components/order-details.tsx @@ -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 = { doordash: "DoorDash", @@ -9,11 +20,40 @@ const PLATFORM_LABEL: Record = { }; /** - * 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 = { + loved: "Loved it", + liked: "Liked it", + ok: "OK", + never: "Never again", +}; + +const RATING_STYLE: Record = { + 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 (
@@ -50,7 +95,7 @@ export function OrderDetails({ {items.length > 0 ? (
    {items.map((it, i) => ( -
  • +
  • {it.qty}× {it.description} @@ -58,6 +103,14 @@ export function OrderDetails({ {it.options.join(" · ")} )} + {fmt(Number(it.amount))}
  • ))} @@ -90,6 +143,307 @@ export function OrderDetails({ {receipt.order_reference}

    )} + + +
+ ); +} + +/** + * 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 ( + + {(["loved", "never"] as ItemVerdict[]).map((v) => ( + + ))} + + ); +} + +/** + * 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 ( +
+ + {shared && ( + + ask {otherName} for her verdict too + + )} +
+ ); +} + +/** + * 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(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 ( +
+ {warn && ( +

+ Marked “never again” here before. +

+ )} + + {items.length > 0 && ( +

+ {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(" · ")} +

+ )} + + + +
+ {reviewers.map((r) => ( + + ))} +
+ +
+ {RATING_ORDER.map((r) => ( + + ))} +
+ + 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) => ( +

+ {o.participant_name}:{" "} + {o.rating && RATING_LABEL[o.rating]} + {o.note && — {o.note}} +

+ ))} + + {history.length > 0 && ( +
    + {history.map((h) => ( +
  • + {h.transaction_date} + + {h.participant_name} + {h.rating ? ` · ${RATING_LABEL[h.rating]}` : ""} + + {h.note && {h.note}} +
  • + ))} +
+ )}
); } diff --git a/src/lib/hooks.ts b/src/lib/hooks.ts index e8a33d3..51553c7 100644 --- a/src/lib/hooks.ts +++ b/src/lib/hooks.ts @@ -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; + 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({ + 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"] }); }, }); } diff --git a/src/lib/order-reviews.ts b/src/lib/order-reviews.ts new file mode 100644 index 0000000..d02a3a1 --- /dev/null +++ b/src/lib/order-reviews.ts @@ -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; + /** 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 { + 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 { + 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 = { 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(); + 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) + ), + }; +}