103 lines
4.8 KiB
Markdown
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.
|