Skip to content

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:

  1. Load the SellerOrderLine (with its parent SellerOrder/Order) by id. Not found -> the same REVIEW_NOT_PURCHASED error as step 2 below (never distinguish "doesn't exist" from "not yours" - that would leak existence of other customers' order lines).
  2. line.SellerOrder.Order.CustomerId == cmd.CustomerId (the authenticated caller) - otherwise REVIEW_NOT_PURCHASED.
  3. line.SellerOrder.Status == Delivered - otherwise REVIEW_ORDER_NOT_DELIVERED. Processing, Confirmed, Packed, ReadyToShip, Shipped, and Cancelled purchases are all rejected.
  4. line.ProductId == cmd.ProductId (the route's product) - otherwise REVIEW_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: CreateProductReviewCommandHandler calls IProductReviewRepository.GetByCustomerAndProductAsync before creating, and throws REVIEW_ALREADY_EXISTS if one is found.
  • Database layer: ProductReviewConfiguration declares a unique index on (CustomerId, ProductId). Two concurrent requests can both pass the application-layer check before either commits; the second SaveChangesAsync then fails on the unique index, and ProductReviewRepository.SaveChangesAsync catches 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 same ProductReviewAlreadyExistsException
  • never a raw DbUpdateException escaping 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, CreatedAt only. No CustomerId, OrderId, SellerOrderId, SellerOrderLineId, SellerUserId, email, phone, or address is ever present on this type - ProductReviewFlowTests.PublicProjection_NeverExposesCustomerIdOrOrderChainOrPii asserts 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): adds ProductId/ProductName for the seller's own catalog context, but still no CustomerId or order-chain fields.
  • AdminReviewDto (Admin only): the full record, including CustomerId, 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 accessible aria-label summarizing the rating in words), a rating-distribution bar chart backed by GET .../reviews/summary, and a paginated (5-per-page) Visible review list backed by GET .../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 specific SellerOrderLineId as 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 same ReviewFormDialog, 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 and ProfilePage): list/edit the customer's own reviews, following the existing flat-route, PageHeader-plus-white-card layout convention used by /orders//profile rather than introducing a nested /account/* shell.
  • Seller Reviews (/seller/reviews): read-only table, filterable by rating and search text, using the existing SellerLayout sidebar.
  • Admin Reviews (/admin/reviews, /admin/reviews/:id): filterable list plus a detail page with Hide (reason required) / Restore actions, using the existing AdminLayout sidebar.

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