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"
|
### 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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user