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