ci / lint-test (push) Successful in 47s
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.
189 lines
6.8 KiB
TypeScript
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));
|
|
}
|