Files
finance-app/src/app/api/transactions/[id]/review/route.ts
T
siddharthd d458228625
ci / lint-test (push) Successful in 47s
feat(orders): a fifth verdict, 'bad', between ok and never again
The jump from "OK" to "Never again" is too big and most
disappointments live in the gap (user, 2026-07-28) — so a merely poor
meal either flattered itself as OK or got blacklisted.

Only 'never' raises the warning on a future order. A blacklist that fires
for every mediocre delivery is one nobody reads, so 'bad' records the
disappointment without triggering the alarm. Both set order_again = false
— you would not choose either again — and that split between "would I
order it" and "warn me about it" is the point of the extra level.

Migration widens the CHECK; nothing is removed, so no existing row needs
mapping.
2026-07-28 19:12:26 +10:00

189 lines
6.8 KiB
TypeScript

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. "bad" and "never"
// both answer no — you would not choose either again — but only "never"
// raises the warning on a future order, so the blacklist stays sharp.
const orderAgain =
body.order_again ??
(rating === null ? null : rating !== "never" && rating !== "bad");
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));
}