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:
@@ -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');
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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 “never again” 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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