Skip to content

feat(reviews): verified-purchase reviews, eligibility endpoint and own-review delete - #115

Merged
roncodes merged 2 commits into
release/v0.4.25from
feat/verified-reviews
Oct 7, 2026
Merged

roncodes merged 2 commits into
release/v0.4.25from
feat/verified-reviews

Conversation

@roncodes

@roncodes roncodes commented Oct 6, 2026 •

Copy link
Copy Markdown
Member

Summary

Reviews in the customer app have to come from a real purchase. Until now, any signed-in customer could review any store or product, there was no way for the app to know whether to offer "Write a review", and customers could not delete their own reviews because DELETE reviews/{id} was routed to find().

This PR does four things:

  • Verified-purchase reviews. A customer can review a store or product only for a completed order placed with that store, or containing that product. Each order can be reviewed once per subject. The review records which order it came from, in a new reviews.order_uuid column.
  • Eligibility endpoint. GET storefront/v1/reviews/eligibility?subject={store_or_product_id}[&order={order_id}] tells the app whether the signed-in customer can review a subject. When they can't, it says why.
  • Delete own review. DELETE reviews/{id} now routes to the existing delete() action. That action already only lets customers delete their own reviews.
  • Stricter validation and a safer resource.
    • rating must be an integer from 1 to 5, content is limited to 2000 characters, and a review carries at most 4 image or video files.
    • The public review resource now names the subject by public id. It previously returned the internal row id.
    • The resource adds subject_type, verified and is_mine.

Related Issue

Part of the storefront-app redesign: the new review screens need verified purchases, an eligibility check and own-review delete.

Type of Change

  • Bug fix
  • Feature
  • Refactor
  • Documentation
  • Test
  • Chore

Implementation Notes

  • Support/ReviewEligibility holds the whole decision.
    • Store subjects: the customer's orders with status = completed and meta.storefront_id equal to the store's public id.
    • Product subjects: completed orders whose payload has an entity with internal_id equal to the product's public id. Entity::fromStorefrontProduct sets that id at checkout.
    • Without order, it picks the newest completed order not yet reviewed for that subject, looking back over the last 50.
    • With order (public id or uuid), it only considers that order.
    • Reasons when not allowed: sign_in_required, unsupported_subject, no_completed_order, already_reviewed. For already_reviewed, the latest order and its review are returned so the app can link to it.
  • ReviewController::create runs the check after the existing subject and context guards. A refusal returns 403 {error, reason}, and an accepted review stores order_uuid.
  • ReviewController::eligibility returns {can_review, reason, message, order, review}. A missing or out-of-context subject returns the controller's usual 400 errors.
  • Review resource:
    • subject_id is the public id on public routes and stays the internal id on internal routes.
    • is_mine compares against the customer from Customer-Token. That customer is looked up once per request and cached on the request attributes, so a page of reviews doesn't repeat the token lookup for every review.
    • order_uuid is only exposed on internal routes.
  • Migration 2026_10_07_000000_add_order_uuid_to_reviews_table adds a nullable order_uuid and an index on (customer_uuid, subject_uuid, order_uuid). It is guarded with hasColumn.

Validation

  • Tests
  • Lint
  • Build
  • Manual validation

Command output / summary:

Full backend suite (local, PHP 8.4, Pest)
OK (585 tests, 3522 assertions)

Line coverage of every changed or added line in server/src (Xdebug):
  Support/ReviewEligibility.php           all covered
  Http/Controllers/v1/ReviewController    all covered
  Http/Resources/Review.php               all covered
  Models/Review.php                       all covered

php-cs-fixer --dry-run: clean for all changed files

New tests in server/tests/Unit/Support/ReviewEligibilityTest.php:

  • signed-out customers and unsupported subjects
  • the newest unreviewed store order is chosen, and "already reviewed" once every order is used
  • product orders matched through payload entities
  • targeting one order by public id or uuid, and another customer's order is not matched
  • the eligibility endpoint: missing subject, invalid subject, signed out, eligible, wrong order
  • create refusing a customer without a completed order (403 with reason, nothing stored)
  • resource subject_id, subject_type, verified and is_mine, including the once-per-request viewer lookup
  • the order() relation

Updated tests:

  • the existing authenticated-creation test now seeds completed orders. It asserts that each review records its order and that a fourth review for the same store is refused with already_reviewed.
  • request rule, resource and route contracts were updated to match.

phpstan (composer test:types) isn't run in CI and reports Eloquent magic-property errors across these files on main too. The new class adds the same kind of errors and no others.

Documentation Impact

  • No documentation changes needed
  • Documentation updated in fleetbase/fleetbase.io
  • Documentation needed but not included

API Reference Impact

  • No API reference changes needed
  • Updated fleetbase/postman
  • API reference updates required but not included

API reference notes:

  • New GET storefront/v1/reviews/eligibility.
  • POST storefront/v1/reviews: optional order, stricter validation, and a new 403 {error, reason} response.
  • DELETE storefront/v1/reviews/{id} now deletes the review.
  • Review objects:
    • subject_id is now a public id.
    • New fields: subject_type, verified and is_mine.

Documentation Notes

The storefront API docs on fleetbase.io and the Postman collection need the endpoint and fields above (follow-up).

Risk

  • Behaviour change. Customers without a completed order can no longer post reviews. Clients that relied on posting freely, such as older storefront-app builds, will get a 403 with a readable error.
  • Existing reviews keep order_uuid = null. They show as verified: false, and they don't block the customer from reviewing a completed order.
  • subject_id on public responses changes from an integer to a public id string. Current storefront-app code does not read it.
  • Conflict: fix(reviews): upload review photos without a public ACL #112 also edits ReviewController::create, in the photo upload part. Expect a small rebase conflict for whichever merges second.

Reviewer privacy

Public review payloads named the reviewer in full and included their email and phone number. Public responses now show the reviewer as first name and last initial ("Ada B."), with no email or phone. Internal (Console) responses are unchanged.

…n-review delete

- Customers can review a store or product only for a completed order placed with
  that store or containing that product, once per order per subject. Reviews
  record the order (new reviews.order_uuid).
- GET reviews/eligibility tells the app whether the signed-in customer can review
  a subject, and why not.
- DELETE reviews/{id} now routes to delete() instead of find(), so customers can
  remove their own reviews.
- Ratings must be whole stars from 1 to 5; content is limited to 2000 characters
  and uploads to four image or video files.
- The review resource names the subject by public id instead of the internal row
  id, and adds subject_type, verified and is_mine.
@codecov

codecov Bot commented Oct 6, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (62af547) to head (6b02d96).
⚠️ Report is 3 commits behind head on main.

Additional details and impacted files
@@             Coverage Diff             @@
##                main      #115   +/-   ##
===========================================
  Coverage     100.00%   100.00%           
- Complexity      2282      2307   +25     
===========================================
  Files            182       183    +1     
  Lines           9082      9154   +72     
===========================================
+ Hits            9082      9154   +72     
Flag Coverage Δ
backend 100.00% <100.00%> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant