docs: the verdict scale, and the two Slack rules that cost real data
ci / lint-test (push) Successful in 43s

This commit is contained in:
2026-07-28 19:17:25 +10:00
parent d458228625
commit 7cf247951a
+48 -18
View File
@@ -185,21 +185,26 @@ from ordinary internal transfers. The loan model below is unbuilt.
### Order verdicts — "never order from here again" ### Order verdicts — "never order from here again"
Built 2026-07-28 (migration 0024). `order_reviews` was previously a table wired Built 2026-07-28 (migrations 0024, 0025). `order_reviews` was previously a table
to nothing; it now backs `GET`/`PUT /api/transactions/[id]/review`, with the UI wired to nothing; it now backs `GET`/`PUT /api/transactions/[id]/review`, the UI
inside `components/order-details.tsx`. Logic in `lib/order-reviews.ts`. 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 - **Per person, not per order.** `UNIQUE (transaction_id, participant_id)`. A
shared meal produces two opinions that routinely disagree, and the shared meal produces two opinions that routinely disagree, and the
disagreement is the useful part. `PUT` defaults to the **signed-in user**, disagreement is the useful part. `PUT` defaults to the **signed-in user**,
not the owner — Sonu authenticates through the same Traefik OAuth as not the owner — Sonu authenticates through the same Traefik OAuth as
participant 4, so an owner default would file her verdict under his name. participant 4, so an owner default would file her verdict under his name.
- **Four levels** — `loved`, `liked`, `ok`, `never`. Three collapsed the - **Five levels** — `loved`, `liked`, `ok`, `bad`, `never`. Three collapsed the
distinction that decides a re-order: "loved" and "liked" both mean "would distinction that decides a re-order; `bad` was added because the jump from
order again" but only one is worth a detour, and "ok" is not a `ok` to `never again` is too big and most disappointments live in the gap.
recommendation. - **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 - **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 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 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.** - **An ABSENT `item_verdicts` means "leave them alone"; `[]` clears them.**
Without that distinction a note-only save wipes every per-item opinion — the 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 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 **The merchant is the restaurant, not the courier.** `orderDescription` does not
writes `transaction_splits`, because the split already IS the record that an append the platform — that was added on request and reversed on 2026-07-28,
order was shared and two records of one fact drift apart. Safe to clear on an because it fragmented the merchant and the platform already renders in the Order
order because an ingested order is post-cutover by construction. 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`, `expense_metadata.merchant_normalized` — never `transactions.merchant_name`,
which is a bank descriptor and reads differently for the same restaurant on which is a bank descriptor.
different nights.
`/api/orders/ingest` returns `prior_verdict` so the n8n Slack nudge can warn at **Sharing is a real 50/50 split, not a flag** — the split already IS the record,
the moment an order lands. 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 (`<id>: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 ### The shared loan