Marketplace Product Reviews & Ratings¶
Purpose¶
A verified-purchase, product-level review and rating system, built as a new ProductReview
bounded context alongside (never replacing) the pre-existing Review entity/ReviewsController.
The legacy system is left untouched - no data migration, no deletion - because it has no purchase
verification at all and its rating never actually recalculates the product (Product.UpdateRating
is never called anywhere in that path). This document describes the new system only, which is what
ProductDetailPage, /orders, /reviews, /seller/reviews, and /admin/reviews all use.
Verified purchase¶
A review can only ever be created against a real, owned, Delivered purchase - the backend proves
this itself and never trusts a client-supplied CustomerId, SellerId, SellerUserId, or
VerifiedPurchase flag. The creation payload from the client is intentionally thin:
POST /api/products/{productId}/reviews
{ "sellerOrderLineId": "...", "rating": 5, "title": "...", "comment": "..." }
sellerOrderLineId is used only as a lookup key. CreateProductReviewCommandHandler
independently re-derives and checks every fact that actually matters:
- Load the
SellerOrderLine(with its parentSellerOrder/Order) by id. Not found -> the sameREVIEW_NOT_PURCHASEDerror as step 2 below (never distinguish "doesn't exist" from "not yours" - that would leak existence of other customers' order lines). line.SellerOrder.Order.CustomerId == cmd.CustomerId(the authenticated caller) - otherwiseREVIEW_NOT_PURCHASED.line.SellerOrder.Status == Delivered- otherwiseREVIEW_ORDER_NOT_DELIVERED. Processing, Confirmed, Packed, ReadyToShip, Shipped, and Cancelled purchases are all rejected.line.ProductId == cmd.ProductId(the route's product) - otherwiseREVIEW_PRODUCT_MISMATCH.
Only after all four checks pass is IsVerifiedPurchase set to true on the new ProductReview -
and that is the only way it is ever set. There is no code path that creates an unverified review
or that lets a client flip the flag later (UpdateContent never touches it).
Review model¶
Reviews belong to the catalog Product, not to any one seller's listing - so a review written
against Seller A's listing is visible everywhere that product is sold, and follows the product if
Seller A stops selling it. The purchase context is preserved on the entity as an immutable
snapshot (OrderId, SellerOrderId, SellerOrderLineId) for audit purposes, but is never exposed
to public callers (see "Privacy" below).
public class ProductReview : BaseEntity
{
public Guid ProductId { get; private set; }
public Guid CustomerId { get; private set; }
public Guid OrderId { get; private set; }
public Guid SellerOrderId { get; private set; }
public Guid SellerOrderLineId { get; private set; }
public bool IsVerifiedPurchase { get; private set; } // always true - see above
public int Rating { get; private set; } // 1-5
public string? Title { get; private set; }
public string Comment { get; private set; }
public ProductReviewStatus Status { get; private set; } // Visible | Hidden
public DateTime? HiddenAt { get; private set; }
public Guid? HiddenByUserId { get; private set; }
public string? ModerationReason { get; private set; }
}
Status is deliberately just two states¶
ProductReviewStatus is only Visible and Hidden - no draft/pending-approval/flagged/appealed
states. A new review is Visible immediately (no pre-moderation queue - see "Deferred work"). Only
an Admin can move Visible -> Hidden (Hide, requires a reason) or Hidden -> Visible
(Restore). Both transitions throw REVIEW_INVALID_TRANSITION if called from the wrong state, so
hiding an already-hidden review or restoring an already-visible one is rejected rather than
silently no-op'd.
Editing preserves the purchase record¶
The review owner may edit Rating/Title/Comment via UpdateContent - this is the only mutator
that touches customer-authored content, and it never touches ProductId, CustomerId, any of the
order-chain fields, IsVerifiedPurchase, or moderation state. The verified-purchase reference
always remains the original one established at creation, even if the review text is edited years
later.
Deletion¶
Not implemented in this slice. Customers may edit an existing review but not delete it - this avoids having to define how a deleted review's history interacts with rating aggregates and moderation audit trails before there is a concrete need. Documented as future work below.
One review per customer per product¶
A customer may write one review per product (not per order, not per unit purchased) - buying the same product again does not unlock a second review slot. This is enforced at two independent layers:
- Application layer:
CreateProductReviewCommandHandlercallsIProductReviewRepository.GetByCustomerAndProductAsyncbefore creating, and throwsREVIEW_ALREADY_EXISTSif one is found. - Database layer:
ProductReviewConfigurationdeclares a unique index on(CustomerId, ProductId). Two concurrent requests can both pass the application-layer check before either commits; the secondSaveChangesAsyncthen fails on the unique index, andProductReviewRepository.SaveChangesAsynccatches that (recognizing both MySQL's "Duplicate entry" and SQLite's "UNIQUE constraint failed" wording, so the same code path is exercised by the test suite as by production) and re-throws it as the sameProductReviewAlreadyExistsException - never a raw
DbUpdateExceptionescaping to the API layer.
Rating validation and content safety¶
Rating must be an integer 1-5 - 0, 6, negative, and non-integer values are all rejected with
REVIEW_INVALID_RATING, checked identically on both Create and UpdateContent. Comment is
required (rejects empty/whitespace-only with REVIEW_COMMENT_REQUIRED) and capped at 2000
characters; Title is optional and capped at 150. Both are trimmed, never HTML-escaped or
sanitized server-side - because nothing in this codebase ever renders review content as HTML.
React renders all review text as plain text ({review.comment}, never
dangerouslySetInnerHTML), so a comment containing <script>alert(1)</script> is stored and
returned completely verbatim and simply displays as that literal string in the browser - it is
never executed. This is proved by both a domain test
(ProductReviewTests.Create_XssLikeContent_StoredAsPlainText_NeverExecuted) and an end-to-end
handler test that fetches it back through the public query
(ProductReviewFlowTests.PublicProjection_ScriptLikeContent_ReturnedAsOrdinaryTextField_NeverAltered).
Rating aggregation¶
AverageRating, ReviewCount, and the five/four/three/two/one-star counts are always computed
from Visible reviews only - a hidden review is excluded from every aggregate the moment it is
hidden, and included again the moment it is restored. Nothing is persisted redundantly for this:
IProductReviewRepository.GetVisibleRatingCountsAsync groups-and-counts on demand, and
ProductReviewMapper.ToSummaryDto computes the average from those counts. At this scale a live
aggregate query is cheap and always exactly correct - there is no separate denormalized counter
that could drift out of sync with the underlying reviews (unlike the legacy Product.AverageRating
column, which is real but never actually updated).
Display rounding is to one decimal place (e.g. an exact 3.75 average displays as 3.8,
4.6666... displays as 4.7) - a deliberate "sensible display rounding" choice, not a bug;
AggregateFourVisibleRatings... in ProductReviewFlowTests.cs proves both the exact math and the
rounding.
Product API integration¶
The product-detail endpoints (GET /api/products/{id}, GET /api/products/slug/{slug}) embed the
live rating summary directly in ProductDto (AverageRating, TotalReviews, and the five star-
count fields) via ProductMapper.ToDtoWithLiveRatingAsync - a live ProductReview aggregate query
runs only for these single-product detail handlers. List/summary endpoints deliberately do
not - GetProductsQuery, GetFeaturedProductsQuery, and GetSellerProductsQuery all continue
to use the product's own stored (legacy, best-effort) AverageRating/TotalReviews fields, so
that a product listing page never triggers one aggregate query per row.
Full review listings are paginated and separate from the product DTO:
| Endpoint | Access | Purpose |
|---|---|---|
GET /api/products/{productId}/reviews?page=&pageSize= |
Public | Paginated Visible reviews |
GET /api/products/{productId}/reviews/summary |
Public | Rating aggregate only |
POST /api/products/{productId}/reviews |
Customer | Create (verified-purchase, see above) |
GET /api/reviews/me |
Customer | The caller's own reviews |
GET /api/reviews/{id} |
Customer/Admin | Owner or Admin always; others only if Visible |
PUT /api/reviews/{id} |
Customer (owner) | Edit rating/title/comment |
GET /api/seller/reviews |
Seller, Admin | Read-only, scoped to the seller's own catalog |
GET /api/admin/reviews |
Admin | List/filter/search (status, rating, text) |
GET /api/admin/reviews/{id} |
Admin | Full detail including moderation audit fields |
POST /api/admin/reviews/{id}/hide |
Admin | Requires a reason |
POST /api/admin/reviews/{id}/restore |
Admin | Restores visibility, keeps audit history |
Privacy - three projection shapes¶
The same ProductReview entity is projected three different ways depending on who is asking,
never conditionally including/excluding fields on one shared DTO:
PublicReviewDto(anonymous/public reads):Id,Rating,Title,Comment,VerifiedPurchase,CustomerDisplayName,CreatedAtonly. NoCustomerId,OrderId,SellerOrderId,SellerOrderLineId,SellerUserId, email, phone, or address is ever present on this type -ProductReviewFlowTests.PublicProjection_NeverExposesCustomerIdOrOrderChainOrPiiasserts this by reflecting over the DTO's own property set, so the guarantee holds even as the handler evolves, not just today.SellerReviewDto(seller read-only access): addsProductId/ProductNamefor the seller's own catalog context, but still noCustomerIdor order-chain fields.AdminReviewDto(Admin only): the full record, includingCustomerId,OrderId,SellerOrderId,SellerOrderLineId, and moderation audit fields - needed for support/ investigation, never exposed elsewhere.
CustomerDisplayName is first-name + last-initial (e.g. "Asha K.") - a deliberate,
privacy-conscious convention chosen for this slice (ProductReviewAccess.DisplayName), since no
existing convention for a public-facing customer display name existed elsewhere in the codebase.
Seller access is read-only¶
A seller may list and search reviews for products they currently have an active listing for
(GET /api/seller/reviews, filterable by rating and free-text search across product name/title/
comment), but has no endpoint to hide, restore, edit, or delete a review, or to influence
IsVerifiedPurchase. There is no seller-side moderation action anywhere in
ProductReviewsController - HideReview/RestoreReview are [Authorize(Roles = "Admin")] only,
proven by a reflection-based controller-wiring test
(ProductReviewsControllerAuthorizationTests.ModerationActions_RequireAdminRole_SellerCannotReachThem)
rather than by a runtime 403, since attribute-based authorization is enforced by ASP.NET Core's
middleware, not the action body. Seller replies to a review are not built in this slice - see
"Deferred work".
Admin moderation¶
Admin lists/searches/filters all reviews (GET /api/admin/reviews, by status/rating/text),
inspects one in full detail, and can Hide (reason required, REVIEW_MODERATION_REASON_REQUIRED
if blank) or Restore. Hiding/restoring never rewrites the customer's original Title/Comment -
only Status/HiddenAt/HiddenByUserId/ModerationReason change, and those fields are preserved
(not cleared) across a restore, so the audit trail of "this was hidden once, for this reason, by
this admin" survives even after the review is visible again.
Error codes¶
| Code | Meaning |
|---|---|
REVIEW_NOT_FOUND |
Review id does not exist |
REVIEW_FORBIDDEN |
Caller is not the review's owner (and not Admin) |
REVIEW_INVALID_RATING |
Rating outside 1-5 |
REVIEW_COMMENT_REQUIRED |
Comment blank/whitespace-only |
REVIEW_NOT_PURCHASED |
Order line not found, or not owned by the caller |
REVIEW_ORDER_NOT_DELIVERED |
The referenced SellerOrder is not yet Delivered |
REVIEW_PRODUCT_MISMATCH |
The order line's product does not match the route's product |
REVIEW_ALREADY_EXISTS |
Caller already has a review for this product |
REVIEW_INVALID_TRANSITION |
Hide-while-Hidden or Restore-while-Visible |
REVIEW_MODERATION_REASON_REQUIRED |
Hide called with a blank reason |
Frontend¶
- Product detail (
ProductDetailPage.jsx): star rating + count near the title (with an accessiblearia-labelsummarizing the rating in words), a rating-distribution bar chart backed byGET .../reviews/summary, and a paginated (5-per-page) Visible review list backed byGET .../reviews. If the signed-in customer already has a review for this product, a link to edit it is shown instead of a duplicate creation entry point. - Write/edit a review: the primary creation entry point is
OrderDetailPage.jsx- each delivered line item gets a "Write a review" action (using that specificSellerOrderLineIdas the lookup key) that becomes "Edit your review" once one exists for that product, regardless of which order it was written from. Both open the sameReviewFormDialog, which never exposes "Verified Purchase" as an editable field - it only ever displays the value the backend already computed. - My Reviews (
/reviews, linked from the account dropdown andProfilePage): list/edit the customer's own reviews, following the existing flat-route,PageHeader-plus-white-card layout convention used by/orders//profilerather than introducing a nested/account/*shell. - Seller Reviews (
/seller/reviews): read-only table, filterable by rating and search text, using the existingSellerLayoutsidebar. - Admin Reviews (
/admin/reviews,/admin/reviews/:id): filterable list plus a detail page with Hide (reason required) / Restore actions, using the existingAdminLayoutsidebar.
Performance¶
Public review listing is paginated (default page size 10) - a product is never asked to return
every review it has ever received in one response. GetBySellerAsync/GetAllAsync project only
the fields their respective DTOs need. No Redis/Elasticsearch/message-queue/event-sourcing
infrastructure was introduced for this slice - the read patterns here are simple enough for direct
EF Core queries at the current scale.
Deferred work (explicitly out of scope for this slice)¶
- Review deletion by the customer.
- Seller replies to a review.
- Automated abuse/spam detection (rate limiting, profanity filtering, ML moderation) - all moderation today is manual, Admin-initiated.
- Pre-publication moderation queue - a new review is Visible immediately; moderation is reactive (Admin hides after the fact), not a review-before-publish gate.
See also¶
- Application Layer
- Marketplace Catalog Model
- Seller Order Fulfilment - the
Deliveredstatus this system depends on - Payments & Seller Settlements
- Marketplace Logistics
- Marketplace Notifications - the seller "new review" notification and the customer Hidden/Restored moderation notifications this system triggers
- Marketplace Reporting & Dashboards - surfaces this system's Visible-only average rating/review count per seller and marketplace-wide