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_code null → resume at the photo step; reward_code set → fully done, show the code.

Public reads below run executably against the seeded Linen Shirt product (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 by tests/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}. PublicReview is 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" = 400

GET /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_rating rounded 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 as pending (not publicly visible until approved); auto_publish goes 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" = 404

POST /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 the review-reward template) | {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" = 400

POST /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 reference publicUrl in 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