docs: the verdict scale, and the two Slack rules that cost real data
ci / lint-test (push) Successful in 43s
ci / lint-test (push) Successful in 43s
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user