From d458228625cdd27df74135d31285cd53771ab3d9 Mon Sep 17 00:00:00 2001 From: siddharthd Date: Tue, 28 Jul 2026 19:12:26 +1000 Subject: [PATCH] feat(orders): a fifth verdict, 'bad', between ok and never again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../0025_order_rating_bad/migration.sql | 14 +++++++++++ .../integration/order-reviews.test.ts | 19 ++++++++++++++ src/app/api/slack/interactive/route.ts | 3 ++- src/app/api/transactions/[id]/review/route.ts | 8 +++--- src/components/order-details.tsx | 4 ++- src/lib/hooks.ts | 2 +- src/lib/order-reviews.ts | 25 +++++++++++++------ src/lib/slack-blocks.ts | 1 + 8 files changed, 62 insertions(+), 14 deletions(-) create mode 100644 prisma/migrations/0025_order_rating_bad/migration.sql diff --git a/prisma/migrations/0025_order_rating_bad/migration.sql b/prisma/migrations/0025_order_rating_bad/migration.sql new file mode 100644 index 0000000..a140a25 --- /dev/null +++ b/prisma/migrations/0025_order_rating_bad/migration.sql @@ -0,0 +1,14 @@ +-- A fifth verdict: "bad", between "ok" and "never". +-- +-- "OK" to "Never again" is a big jump and most disappointments live in the gap +-- (user, 2026-07-28). Without it, a merely poor meal either flatters itself as +-- OK or gets blacklisted, and the blacklist is the signal that has to stay +-- sharp — `warn` remains exclusive to 'never' so it is not diluted by every +-- mediocre delivery. +-- +-- Safe as a straight widening: no existing row uses a value being removed, +-- because nothing is being removed. +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', 'bad', 'never')); diff --git a/src/__tests__/integration/order-reviews.test.ts b/src/__tests__/integration/order-reviews.test.ts index 5aada55..40b09f5 100644 --- a/src/__tests__/integration/order-reviews.test.ts +++ b/src/__tests__/integration/order-reviews.test.ts @@ -92,6 +92,25 @@ describe("order verdicts — one per person", () => { 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); + // "bad" is also a no — you would not choose it again. + body = await (await PUT(req({ rating: "bad" }), params(txnId))).json(); + expect(body.reviews[0].order_again).toBe(false); + }); + + it("only `never` raises the warning, not `bad`", async () => { + // The boundary the five-level scale exists for. A blacklist that fires for + // every mediocre meal is one nobody reads, so "bad" records the + // disappointment without triggering the alarm. + const older = await seedOrder("Thai Palace", "2026-06-01"); + await PUT(req({ participant_id: ownerId, rating: "bad" }), params(older)); + + let body = await (await GET(req(), params(txnId))).json(); + expect(body.merchant.counts.bad).toBe(1); + expect(body.merchant.warn).toBe(false); + + await PUT(req({ participant_id: ownerId, rating: "never" }), params(older)); + body = await (await GET(req(), params(txnId))).json(); + expect(body.merchant.warn).toBe(true); }); }); diff --git a/src/app/api/slack/interactive/route.ts b/src/app/api/slack/interactive/route.ts index e6bcddf..a8a34fd 100644 --- a/src/app/api/slack/interactive/route.ts +++ b/src/app/api/slack/interactive/route.ts @@ -341,7 +341,8 @@ async function setRating(transactionId: number, participantId: number, rating: R SET rating = EXCLUDED.rating, order_again = EXCLUDED.order_again, updated_at = now()`, - [transactionId, participantId, rating, rating !== "never"] + // "bad" and "never" both mean no; only "never" warns on a future order. + [transactionId, participantId, rating, rating !== "never" && rating !== "bad"] ); } diff --git a/src/app/api/transactions/[id]/review/route.ts b/src/app/api/transactions/[id]/review/route.ts index 13b9018..98c9b5b 100644 --- a/src/app/api/transactions/[id]/review/route.ts +++ b/src/app/api/transactions/[id]/review/route.ts @@ -155,10 +155,12 @@ export async function PUT( ) .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. + // `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"); + 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) diff --git a/src/components/order-details.tsx b/src/components/order-details.tsx index 4ae220b..d76bf54 100644 --- a/src/components/order-details.tsx +++ b/src/components/order-details.tsx @@ -33,6 +33,7 @@ const RATING_LABEL: Record = { loved: "Loved it", liked: "Liked it", ok: "OK", + bad: "Bad", never: "Never again", }; @@ -40,10 +41,11 @@ const RATING_STYLE: Record = { 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", + bad: "border-amber-800 bg-amber-950 text-amber-300", never: "border-red-800 bg-red-950 text-red-300", }; -const RATING_ORDER: OrderRating[] = ["loved", "liked", "ok", "never"]; +const RATING_ORDER: OrderRating[] = ["loved", "liked", "ok", "bad", "never"]; /** * The receipt behind a delivery order: what was actually bought, where it went, diff --git a/src/lib/hooks.ts b/src/lib/hooks.ts index 51553c7..01fe4fb 100644 --- a/src/lib/hooks.ts +++ b/src/lib/hooks.ts @@ -277,7 +277,7 @@ export function useOrderReceipt(transactionId: number) { }); } -export type OrderRating = "loved" | "liked" | "ok" | "never"; +export type OrderRating = "loved" | "liked" | "ok" | "bad" | "never"; export type ItemVerdict = "loved" | "never"; export interface ItemOpinion { diff --git a/src/lib/order-reviews.ts b/src/lib/order-reviews.ts index 960c76d..05d434e 100644 --- a/src/lib/order-reviews.ts +++ b/src/lib/order-reviews.ts @@ -26,14 +26,19 @@ import { queryRaw, queryRow } from "@/lib/db"; */ /** - * 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). + * Five levels. 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. "bad" was added + * because the jump from "ok" to "never again" is too big and most + * disappointments live in the gap (user, 2026-07-28). + * + * Only "never" raises the warning on a future order. A blacklist that fires for + * every mediocre meal stops being read, so "bad" records the disappointment + * without triggering the alarm. */ -export type Rating = "loved" | "liked" | "ok" | "never"; +export type Rating = "loved" | "liked" | "ok" | "bad" | "never"; -export const RATINGS: Rating[] = ["loved", "liked", "ok", "never"]; +export const RATINGS: Rating[] = ["loved", "liked", "ok", "bad", "never"]; /** * Per-item opinions, keyed by the line item's description. @@ -85,7 +90,11 @@ export interface MerchantVerdict { transaction_date: string | null; }[]; counts: Record; - /** True when this merchant has ever been marked `never`. */ + /** + * True when this merchant has ever been marked `never` — and ONLY `never`. + * "bad" is deliberately excluded: the warning is a blacklist, and one that + * fires for every mediocre meal is one nobody reads. + */ warn: boolean; /** * What to get and what to avoid here, pooled across every order from this @@ -163,7 +172,7 @@ export async function merchantVerdict( [merchant, exclude] ); - const counts: Record = { loved: 0, liked: 0, ok: 0, never: 0 }; + const counts: Record = { loved: 0, liked: 0, ok: 0, bad: 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 diff --git a/src/lib/slack-blocks.ts b/src/lib/slack-blocks.ts index 5c9602f..10893d2 100644 --- a/src/lib/slack-blocks.ts +++ b/src/lib/slack-blocks.ts @@ -30,6 +30,7 @@ const RATING_LABEL: Record = { loved: "Loved it", liked: "Liked it", ok: "OK", + bad: "Bad", never: "Never again", };