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(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)); }