Reviews — widget, token wizard, photo rewards
Verified-purchase reviews. Reviews exist only via a
single-use, order-scoped, expiring token minted by the request scanner
and mailed as <review_link_base>/<token> — the token IS the auth for
every write; no login. All public reads serve status='visible' rows only
— hidden/pending/deleted never leak (RLS-enforced too).
The wizard is two-step: submit (rating + body, consumes the token) → photo (attach media, mint the reward code). Resume rule: token consumed
review.reward_codenull → resume at the photo step;reward_codeset → fully done, show the code.
Public reads below run executably against the seeded
Linen Shirtproduct (prod_01tst00000000000000000001, scripts/seed-fixtures.ts) — shape holds at any review count, including zero. Token-gated writes need a server-minted token, so their happy paths are pinned bytests/store/reviews-store.test.ts+tests/contract/reviews-widget.contract.test.ts; here the error contracts run executably.
GET /api/store/reviews — list visible reviews
- Purpose: the paginated review list under the PDP widget.
- Auth: anon (
x-client-id). - Request: query
{product_id (required), sort? default|rating|date, order? asc|desc, limit? (≤50, default 10), offset?}.default= with- media first, newest within. - Response:
{reviews: [PublicReview…], count, has_more}.PublicReviewis EXACTLY these keys (leak guard — no email/order/IP/ reward/status; media entries hidden by moderation are filtered out):
{
"reviews": [
{
"id": "uuid",
"customer_name": "Елена Г.",
"rating": 5,
"title": null, // always null — the form has no title
"body": "Страхотна риза!",
"media": [ { "type": "image", "url": "https://…", "thumb": "https://…", "w": 0, "h": 0, "bytes": 0 } ],
"admin_response": null,
"admin_response_at": null,
"created_at": "ISO-8601"
}
],
"count": 1,
"has_more": false
}- Errors:
400 validation_failed(missing product_id, bad sort/limit). - SDK:
reviews.listReviews(client, query) - Components: review list / masonry grid (review widget family).
curl -sf "$BASE/api/store/reviews?product_id=prod_01tst00000000000000000001&sort=date" \
-H "x-client-id: $CLIENT_ID" | grep -q '"has_more"'
STATUS=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/api/store/reviews" \
-H "x-client-id: $CLIENT_ID")
test "$STATUS" = 400GET /api/store/reviews/aggregate — star-badge stats
- Purpose: the PDP star rating + histogram. Visible only. Edge-cached
60s (
Cache-Control: public, max-age=60, s-maxage=60). - Auth: anon (
x-client-id). - Request: query
{product_id}. - Response:
{product_id, count, avg_rating, distribution: {"1"…"5"}}—avg_ratingrounded to 1 decimal, 0 when no reviews. - Errors:
400 validation_failed. - SDK:
reviews.getAggregate(client, productId) - Components: star badge (PDP + product cards).
curl -sf "$BASE/api/store/reviews/aggregate?product_id=prod_01tst00000000000000000001" \
-H "x-client-id: $CLIENT_ID" | grep -q '"distribution"'GET /api/store/reviews/widget — one-call widget payload
- Purpose: aggregate + first page (sized/sorted per the store's Settings → Reviews display options) + display options in ONE call — what the product widget + star badge mount from. Edge-cached 60s.
- Auth: anon (
x-client-id). - Request: query
{product_id}. - Response:
{
"product_id": "prod_…",
"aggregate": { /* ReviewAggregate — shape above */ },
"reviews": [ /* PublicReview[] — first page */ ],
"count": 3,
"has_more": false,
"options": { "layout": "masonry", "page_size": 6, "photo_first": true }
}- Errors:
400 validation_failed. - SDK:
reviews.getWidget(client, productId) - Components: the review widget (masonry/list) + star badge.
- Settings:
widget_layout,widget_page_size,widget_photo_first(admin → Settings → Reviews).
BODY=$(curl -sf "$BASE/api/store/reviews/widget?product_id=prod_01tst00000000000000000001" \
-H "x-client-id: $CLIENT_ID")
echo "$BODY" | grep -q '"aggregate"'
echo "$BODY" | grep -q '"options"'
echo "$BODY" | grep -q '"photo_first"'GET /api/store/reviews/token/:token — validate + form context
- Purpose: bootstrap the review form from the emailed link: validity, product card, greeting, and the RESUME state. Never cached — state changes on submit.
- Auth: anon (
x-client-id); the token is the bearer secret. - Response — ALWAYS 200,
TokenValidation:
// invalid
{ "valid": false, "reason": "not_found" } // or "expired" | "invalid" (malformed/too short)
// valid
{
"valid": true,
"already_submitted": false,
"review": null, // {id, reward_code} once submitted
"product": { "id": "prod_…", "handle": "linen-shirt", "title": "Linen Shirt", "thumbnail": "https://…" },
"customer_name": "Елена",
"expires_at": "ISO-8601"
}- Errors: none over HTTP — failures are in-band (
valid: false). - SDK:
reviews.validateToken(client, token) - Components: review wizard entry route.
# Unknown (but well-formed) token → in-band not_found, HTTP 200.
curl -sf "$BASE/api/store/reviews/token/doc-not-a-real-token-$RUN" \
-H "x-client-id: $CLIENT_ID" | grep -q '"reason":"not_found"'
# Malformed (too short) → "invalid".
curl -sf "$BASE/api/store/reviews/token/short" \
-H "x-client-id: $CLIENT_ID" | grep -q '"reason":"invalid"'POST /api/store/reviews — submit (wizard step 1)
- Purpose: create the review from a token. Consumes the token; idempotent on the (order, product) unique — a race/retry returns the existing review and still consumes the token.
- Auth: anon (
x-client-id); the token is the auth. - Request:
{token, rating: 1–5 int, body (REQUIRED — a rating alone is not a review; HTML stripped, ≤2000 chars), media?}— media ≤ 7 items (≤6 images + ≤1 video), URLs must be on the store's file host (from upload-url below). No title field. - Response:
{id, success: true}. - Errors:
400 invalid_data(shape / empty-after-sanitize body / media rule) ·404 not_found(unknown token) ·409 conflict(token consumed — replay) ·410 gone(expired) ·429 rate_limited(3/h per IP). - SDK:
reviews.submitReview(client, input) - Components: review wizard, step 1.
- Settings:
moderation_mode: "hold"lands the review aspending(not publicly visible until approved);auto_publishgoes live at once.
STATUS=$(curl -s -o /dev/null -w '%{http_code}' -X POST "$BASE/api/store/reviews" \
-H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
-d '{"token": "doc-not-a-real-token-'$RUN'", "rating": 5, "body": "great"}')
test "$STATUS" = 404POST /api/store/reviews/:id/photo — attach media + reward (step 2)
- Purpose: attach media and mint the single-use reward code (a REAL promotion, percentage-off-order — 10% default). The reward is for the PHOTO, never the rating. Idempotent — retries return the same code; a promo-mint failure never loses the media. A CONSUMED token is accepted (step 1 consumed it).
- Auth: anon (
x-client-id); the token must match the review's (order, product) pair. - Request:
{token, media (1–7 items, ≤6 images + ≤1 video, file-host URLs only)}. - Response:
{code}(fresh mint — also emails thereview-rewardtemplate) |{code, already_issued: true}(retry) |{code: null, message}(mint failed; media saved). - Errors:
400 invalid_data·403 forbidden(token does not match this review) ·404 not_found(review or token unknown). - SDK:
reviews.attachReviewPhoto(client, reviewId, input) - Components: review wizard, step 2 (photo + reward reveal).
- Settings:
reward_enabled,reward_percentage.
STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
-X POST "$BASE/api/store/reviews/00000000-0000-0000-0000-00000000dead/photo" \
-H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
-d '{"token": "doc-not-a-real-token-'$RUN'", "media": [{"type": "image", "url": "https://example.com/x.jpg"}]}')
test "$STATUS" = 400POST /api/store/reviews/upload-url — signed media upload
- Purpose: get a signed R2 PUT for the photo step. Upload the raw file
to
uploadUrl, then referencepublicUrlin the media array. A consumed token is accepted; expiry still applies. - Auth: anon (
x-client-id); the token is the auth. - Request:
{token, name, type (image/jpeg|jpg|png|webp or video/mp4|quicktime), size?}— caps: image ≤ 8MB, video ≤ 50MB. - Response:
{uploadUrl, publicUrl, key, filename}. - Errors:
400 invalid_data(unsupported MIME, too large) ·404 not_found(unknown token) ·410 gone(expired token). - SDK:
reviews.createUploadUrl(client, input) - Components: review wizard photo picker.
STATUS=$(curl -s -o /dev/null -w '%{http_code}' -X POST "$BASE/api/store/reviews/upload-url" \
-H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
-d '{"token": "doc-not-a-real-token-'$RUN'", "name": "x.jpg", "type": "image/jpeg"}')
test "$STATUS" = 404