Files
web_sport/docs/adr/0021-a-review-is-anchored-to-an-order-line.md
2026-08-13 23:20:23 +07:00

103 lines
4.8 KiB
Markdown

# ADR-0021: A review is anchored to an order line
- **Status:** Accepted
- **Date:** 2026-08-13
## Context
Product reviews are worth having only if they are worth believing. The usual
shape — an open form on the product page, with a "verified purchase" badge
awarded to reviews the system can match back to an order — gets this backwards.
The badge becomes the exception, unverified reviews become the bulk of the
content, and moderation becomes a full-time job rather than a screen someone
checks.
The badge is also weaker than it looks. Matching on product + email means
anyone who guesses a customer's address can post as them, and anyone who bought
once can review every colourway.
Meanwhile this store has no customer accounts yet (M8). A design that depends
on login would mean no reviews until then.
## Decision
**`Review.orderLineId` is unique and required.** A review hangs off the exact
purchased item, not off a product plus a claim about who is writing.
This makes "verified purchase" **structural**. There is no unverified review
because there is no row to put one in. `isVerifiedPurchase` is sent to the
storefront as a constant `true` — not because the check is skipped, but because
the schema already made it unfalsifiable.
The unique constraint gives **one review per item purchased** for free. Buying
the same jacket twice earns two reviews; buying it once earns one.
**Authorisation is the order id plus the email on that order.** The same bar
the guest order lookup already sets, and the same capability URL the
confirmation page uses (ADR-0019's neighbour: the id is a UUIDv7, unguessable,
and holding the link is the authorisation). No account required, which is what
lets reviews ship before M8.
Wrong email and unknown order return the **same 404**, so the endpoint cannot
be used to test whether an order exists.
**Everything starts `PENDING`.** Publication requires a decision. The
alternative — publish then moderate — means the first person to read abuse on a
product page is a customer.
**The rating aggregate lives on `products` as `rating_sum` + `rating_count`,**
and is **recomputed**, never incremented. Two integers rather than a stored
average, because approving one more 4-star review is `sum + 4, count + 1`:
exact, and reversible. See Consequences.
## Consequences
A shopper cannot review a product they own but bought elsewhere. That is the
correct trade here: the alternative is an open submission endpoint, which is a
spam surface needing a moderation _team_ rather than a moderation _screen_.
Reviews work for guests today and keep working when accounts arrive — an
`orderLine` already reaches a `customerId` through its order when there is one.
Recompute-not-increment is what makes a moderator's decision reversible. Every
incremental scheme needs a compensating delta per path (approve, reject,
un-reject, edit), and the first missed path leaves a product displaying a
rating no review supports, with nothing to notice it by. Recomputing from the
approved rows is one statement that is correct from any starting state — and it
is exercised: rejecting an approved review takes its stars back out.
Moderation drops the whole catalog cache. `products.rating_*` is part of the
read model, `cache-keys.ts` states the contract as "invalidated on write, TTL
as a safety net", and without this a moderator approves a review, reloads the
product page, sees the old figure for up to five minutes and concludes the
button is broken.
The distribution is sent alongside the average because they answer different
questions. A 3.0 of straight 3s is a mediocre product; a 3.0 of 5s and 1s is a
product with a sizing problem. Only the bars distinguish them.
Ratings are constrained `BETWEEN 1 AND 5` in the database as well as in Zod.
The column feeds a stored SUM, so one bad row skews a product's average
silently and permanently.
## Alternatives considered
**Open reviews with a verified badge.** More content, most of it unverifiable,
and a badge that a determined poster can forge by guessing an email.
**Require an account.** Cleanest identity story, and it would have meant no
reviews at all until M8 — while making guest buyers, who are most of them,
second-class.
**Store the average directly.** One column instead of two, and it must be
recomputed from scratch to stay honest anyway — at which point it is strictly
worse than the pair, since it also accumulates floating-point drift.
**Publish immediately, moderate later.** Faster for the honest majority, and it
puts the store's name under whatever the first bad actor writes.
**Gate on delivery ("only review what arrived").** Correct in principle and
currently unknowable: the store has no shipping integration until M10, so
"delivered" is not a fact the system holds. Cancelled orders are refused; the
rest are allowed, and the gate tightens when the data exists.