Compare commits
2
Commits
d081d80a3f
...
b8919a4775
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8919a4775 | ||
|
|
0cb46a087b |
@@ -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[]
|
account_owner_mappings account_owner_mappings[]
|
||||||
payments_sent split_payments[] @relation("payments_from")
|
payments_sent split_payments[] @relation("payments_from")
|
||||||
payments_received split_payments[] @relation("payments_to")
|
payments_received split_payments[] @relation("payments_to")
|
||||||
|
order_reviews order_reviews[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model account_owner_mappings {
|
model account_owner_mappings {
|
||||||
@@ -192,7 +193,7 @@ model transactions {
|
|||||||
superseded_by transactions? @relation("superseded", fields: [superseded_by_id], references: [id], onDelete: SetNull)
|
superseded_by transactions? @relation("superseded", fields: [superseded_by_id], references: [id], onDelete: SetNull)
|
||||||
supersedes transactions[] @relation("superseded")
|
supersedes transactions[] @relation("superseded")
|
||||||
expense_metadata expense_metadata?
|
expense_metadata expense_metadata?
|
||||||
order_review order_reviews?
|
order_reviews order_reviews[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model expense_metadata {
|
model expense_metadata {
|
||||||
@@ -221,7 +222,8 @@ model expense_metadata {
|
|||||||
|
|
||||||
model order_reviews {
|
model order_reviews {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
transaction_id Int @unique
|
transaction_id Int
|
||||||
|
participant_id Int
|
||||||
rating String?
|
rating String?
|
||||||
order_again Boolean?
|
order_again Boolean?
|
||||||
note String?
|
note String?
|
||||||
@@ -229,6 +231,9 @@ model order_reviews {
|
|||||||
created_at DateTime @default(now())
|
created_at DateTime @default(now())
|
||||||
updated_at DateTime @updatedAt
|
updated_at DateTime @updatedAt
|
||||||
transaction transactions @relation(fields: [transaction_id], references: [id], onDelete: Cascade)
|
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 {
|
model rule_apply_runs {
|
||||||
|
|||||||
@@ -16,6 +16,13 @@ export function mockDbWithPool(p: Pool) {
|
|||||||
const result = await p.query(sql, params);
|
const result = await p.query(sql, params);
|
||||||
return result.rows;
|
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,
|
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,
|
NotAReceiptError,
|
||||||
type MessageMeta,
|
type MessageMeta,
|
||||||
} from "@/lib/order-ingestion";
|
} from "@/lib/order-ingestion";
|
||||||
|
import { merchantVerdict } from "@/lib/order-reviews";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Machine ingest endpoint for order receipts.
|
* Machine ingest endpoint for order receipts.
|
||||||
@@ -77,6 +78,14 @@ export async function POST(req: NextRequest) {
|
|||||||
subject: meta.subject,
|
subject: meta.subject,
|
||||||
sender: meta.sender,
|
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({
|
return NextResponse.json({
|
||||||
kind: "order",
|
kind: "order",
|
||||||
order_reference: order.order_reference,
|
order_reference: order.order_reference,
|
||||||
@@ -85,6 +94,11 @@ export async function POST(req: NextRequest) {
|
|||||||
currency: order.currency,
|
currency: order.currency,
|
||||||
is_family: order.is_family,
|
is_family: order.is_family,
|
||||||
...result,
|
...result,
|
||||||
|
prior_verdict: verdict && {
|
||||||
|
warn: verdict.warn,
|
||||||
|
counts: verdict.counts,
|
||||||
|
last_note: verdict.history.find((h) => h.note)?.note ?? null,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Not a receipt: promotions, delivery updates, adjustment and refund
|
// 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));
|
||||||
|
}
|
||||||
@@ -507,6 +507,13 @@ function TransactionsContent() {
|
|||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const initialStatementId = searchParams.get("statement_id") || "";
|
const initialStatementId = searchParams.get("statement_id") || "";
|
||||||
|
|
||||||
|
// `?q=` lands the view on a specific row. The Slack order nudge links here,
|
||||||
|
// and without it the link drops you at the top of an unfiltered ledger and
|
||||||
|
// the merchant has to be found by hand — which is how a nudge stops getting
|
||||||
|
// opened.
|
||||||
|
const initialQuery = searchParams.get("q") || "";
|
||||||
|
const initialParsed = parseQuery(initialQuery);
|
||||||
|
|
||||||
const [filters, setFilters] = useState({
|
const [filters, setFilters] = useState({
|
||||||
from: "",
|
from: "",
|
||||||
to: "",
|
to: "",
|
||||||
@@ -514,7 +521,7 @@ function TransactionsContent() {
|
|||||||
bank_names: [] as string[],
|
bank_names: [] as string[],
|
||||||
tag_ids: [] as string[],
|
tag_ids: [] as string[],
|
||||||
transaction_types: [] as string[],
|
transaction_types: [] as string[],
|
||||||
search: "",
|
search: initialParsed.text,
|
||||||
statement_id: initialStatementId,
|
statement_id: initialStatementId,
|
||||||
sort_by: "transaction_date",
|
sort_by: "transaction_date",
|
||||||
sort_dir: "desc",
|
sort_dir: "desc",
|
||||||
@@ -525,8 +532,8 @@ function TransactionsContent() {
|
|||||||
has_split: "" as string,
|
has_split: "" as string,
|
||||||
trip_id: "" as string,
|
trip_id: "" as string,
|
||||||
});
|
});
|
||||||
const [queryInput, setQueryInput] = useState("");
|
const [queryInput, setQueryInput] = useState(initialQuery);
|
||||||
const [queryTokens, setQueryTokens] = useState<QueryToken[]>([]);
|
const [queryTokens, setQueryTokens] = useState<QueryToken[]>(initialParsed.tokens);
|
||||||
|
|
||||||
function handleQueryChange(val: string) {
|
function handleQueryChange(val: string) {
|
||||||
setQueryInput(val);
|
setQueryInput(val);
|
||||||
|
|||||||
@@ -1,6 +1,17 @@
|
|||||||
"use client";
|
"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> = {
|
const PLATFORM_LABEL: Record<string, string> = {
|
||||||
doordash: "DoorDash",
|
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
|
* Who records verdicts. A two-person household with one primary user: the
|
||||||
* went. All of it was already stored at ingest and none of it was reachable —
|
* owner records almost everything, and the only other consumer is Sonu (user,
|
||||||
* the row showed a merchant and a total and nothing else.
|
* 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({
|
export function OrderDetails({
|
||||||
transactionId,
|
transactionId,
|
||||||
@@ -26,6 +66,9 @@ export function OrderDetails({
|
|||||||
bare?: boolean;
|
bare?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { data: receipt, isLoading } = useOrderReceipt(transactionId);
|
const { data: receipt, isLoading } = useOrderReceipt(transactionId);
|
||||||
|
const { data: review } = useOrderReview(transactionId);
|
||||||
|
const [reviewer, setReviewer] = useState(OWNER_PARTICIPANT_ID);
|
||||||
|
|
||||||
if (isLoading || !receipt) return null;
|
if (isLoading || !receipt) return null;
|
||||||
|
|
||||||
const cur = receipt.currency ?? currency ?? "AUD";
|
const cur = receipt.currency ?? currency ?? "AUD";
|
||||||
@@ -33,6 +76,8 @@ export function OrderDetails({
|
|||||||
const items: OrderReceipt["line_items"] = receipt.line_items ?? [];
|
const items: OrderReceipt["line_items"] = receipt.line_items ?? [];
|
||||||
const route: OrderReceipt["route"] = receipt.route ?? [];
|
const route: OrderReceipt["route"] = receipt.route ?? [];
|
||||||
|
|
||||||
|
const mine = review?.reviews.find((r) => r.participant_id === reviewer);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={bare ? "" : "border-t border-zinc-800 pt-4"}>
|
<div className={bare ? "" : "border-t border-zinc-800 pt-4"}>
|
||||||
<div className="flex items-baseline justify-between mb-2">
|
<div className="flex items-baseline justify-between mb-2">
|
||||||
@@ -50,7 +95,7 @@ export function OrderDetails({
|
|||||||
{items.length > 0 ? (
|
{items.length > 0 ? (
|
||||||
<ul className="space-y-1.5 mb-3">
|
<ul className="space-y-1.5 mb-3">
|
||||||
{items.map((it, i) => (
|
{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-600 tabular-nums shrink-0">{it.qty}×</span>
|
||||||
<span className="text-zinc-300 flex-1 min-w-0">
|
<span className="text-zinc-300 flex-1 min-w-0">
|
||||||
{it.description}
|
{it.description}
|
||||||
@@ -58,6 +103,14 @@ export function OrderDetails({
|
|||||||
<span className="block text-zinc-600">{it.options.join(" · ")}</span>
|
<span className="block text-zinc-600">{it.options.join(" · ")}</span>
|
||||||
)}
|
)}
|
||||||
</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>
|
<span className="text-zinc-400 tabular-nums shrink-0">{fmt(Number(it.amount))}</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
@@ -90,6 +143,307 @@ export function OrderDetails({
|
|||||||
{receipt.order_reference}
|
{receipt.order_reference}
|
||||||
</p>
|
</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>
|
</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() {
|
export function useSetSplits() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
@@ -302,6 +399,8 @@ export function useSetSplits() {
|
|||||||
qc.invalidateQueries({ queryKey: ["splits"] });
|
qc.invalidateQueries({ queryKey: ["splits"] });
|
||||||
qc.invalidateQueries({ queryKey: ["shared-transactions"] });
|
qc.invalidateQueries({ queryKey: ["shared-transactions"] });
|
||||||
qc.invalidateQueries({ queryKey: ["participant-balances"] });
|
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