diff --git a/CLAUDE.md b/CLAUDE.md index 18d6e48..16824bf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -185,21 +185,26 @@ from ordinary internal transfers. The loan model below is unbuilt. ### Order verdicts — "never order from here again" -Built 2026-07-28 (migration 0024). `order_reviews` was previously a table wired -to nothing; it now backs `GET`/`PUT /api/transactions/[id]/review`, with the UI -inside `components/order-details.tsx`. Logic in `lib/order-reviews.ts`. +Built 2026-07-28 (migrations 0024, 0025). `order_reviews` was previously a table +wired to nothing; it now backs `GET`/`PUT /api/transactions/[id]/review`, the UI +in `components/order-details.tsx`, and the Slack card in +`lib/slack-blocks.ts` + `app/api/slack/interactive/route.ts`. Logic in +`lib/order-reviews.ts`. -Four things about the shape, each of which is load-bearing: +Five things about the shape, each load-bearing: - **Per person, not per order.** `UNIQUE (transaction_id, participant_id)`. A shared meal produces two opinions that routinely disagree, and the disagreement is the useful part. `PUT` defaults to the **signed-in user**, not the owner — Sonu authenticates through the same Traefik OAuth as participant 4, so an owner default would file her verdict under his name. -- **Four levels** — `loved`, `liked`, `ok`, `never`. Three collapsed the - distinction that decides a re-order: "loved" and "liked" both mean "would - order again" but only one is worth a detour, and "ok" is not a - recommendation. +- **Five levels** — `loved`, `liked`, `ok`, `bad`, `never`. Three collapsed the + distinction that decides a re-order; `bad` was added because the jump from + `ok` to `never again` is too big and most disappointments live in the gap. +- **Only `never` sets `warn`.** A blacklist that fires for every mediocre meal + is one nobody reads. `bad` and `never` both set `order_again = false` — you + would not choose either — but only `never` raises the alarm on a future + order. "Would I order it" and "warn me about it" are different questions. - **Item verdicts key on the item DESCRIPTION**, not its index — an index is meaningless across orders, and "the Pad Thai here is good" has to survive into the next order from the same merchant. Pooled case-folded across the @@ -208,20 +213,45 @@ Four things about the shape, each of which is load-bearing: - **An ABSENT `item_verdicts` means "leave them alone"; `[]` clears them.** Without that distinction a note-only save wipes every per-item opinion — the same shape as the bug that reset `settled` on split rewrites, and just as - invisible on screen. + invisible on screen. Mutation-tested. -**Sharing is a real 50/50 split, not a flag.** The "Shared with Sonu" toggle -writes `transaction_splits`, because the split already IS the record that an -order was shared and two records of one fact drift apart. Safe to clear on an -order because an ingested order is post-cutover by construction. +**The merchant is the restaurant, not the courier.** `orderDescription` does not +append the platform — that was added on request and reversed on 2026-07-28, +because it fragmented the merchant and the platform already renders in the Order +details panel. 81 descriptions were backfilled. `merchantVerdict` joins +**case-folded**: the platforms capitalise differently (`TEG Kebabs & Biryani` vs +`TEG KEBABS & BIRYANI`) and an exact match kept two separate histories, so a +"never again" through one app never warned in the other. -The merchant signal is *derived* by aggregating a merchant's orders on +The merchant signal is *derived* by aggregating on `expense_metadata.merchant_normalized` — never `transactions.merchant_name`, -which is a bank descriptor and reads differently for the same restaurant on -different nights. +which is a bank descriptor. -`/api/orders/ingest` returns `prior_verdict` so the n8n Slack nudge can warn at -the moment an order lands. +**Sharing is a real 50/50 split, not a flag** — the split already IS the record, +and two records of one fact drift apart. The toggle **refuses** when a +participant outside {1, 4} is present or the second consumer's share is not 50: +splits are made by hand here, so a third party or an uneven share is deliberate +and one tap must not flatten it. It says which case it refused on. + +`/api/orders/ingest` returns `prior_verdict` (so the nudge can warn inline) and +`slack_blocks` (so Block Kit stays in tested code rather than n8n expressions). + +### Slack cards — two rules that cost real data + +1. **Update via `response_url`, never the HTTP response body.** Block Kit + interactivity ignores the response body; replacing a message that way is + legacy attachment-style behaviour. Getting this wrong meant every press wrote + correctly and left the card stale, so a working button looked dead, got + pressed again, and toggled itself back — three splits were lost before + `conversations.history` showing `edited: false` settled it. `response_url` + needs no bot token, so the app posts it directly. +2. **Never hand-write a card.** A card built with a guessed `shared: false` + mislabels an already-shared order and the button then deletes the split. + Render by calling the interactive endpoint with a no-op verb (`:noop`): + it writes nothing and returns blocks built from live state. + +Slack reaches this route through an n8n webhook, not directly — see the +smarthome repo's CLAUDE.md and the `slack-interactive-via-n8n` memory. ### The shared loan