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"
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 (`<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