Skip to content

Product Reviews

Customers who bought a product can review it: an overall star rating, any extra ratings the shop asks for (quality, size and fit...), a headline, a comment and up to four photos. Only buyers can review, because the only way in is a signed link emailed to the customer after their order is dispatched. Each review and its photos go through a cheap AI check before they're published; anything the check doesn't pass waits for staff.

Reviews are part of commerce-core (uk.co.kedos.badger.settbuilder.reviews), not a separate service: they need the order, its lines and the customer, the transactional email queue, the per-site OpenAI key and the media bucket with its resizing CDN, all of which are already here. Everything goes through ReviewService, so the feature could be moved out later behind the same interface.

Turning it on

Settings › Orders and email › Product reviews:

Setting Key Default What it does
Product Reviews reviews-enabled off The master switch. Off: no emails, the review page says the link has expired, and the extension renders nothing
Email Customers for Reviews reviews-request-email-enabled on Send the "How was your order?" email
Days After Dispatch reviews-request-delay-days 7 How long after dispatch to ask
Review Request Subject / Template reviews-request-email-subject / -template How was your order #{0}? / reviewRequest {0} is the order number
Extra Ratings reviews-criteria empty See Extra ratings
Product Types Not Reviewed reviews-excluded-product-types donationProduct,virtualGiftCard,physicalGiftCard,giftCardTopUp Product type decorators whose lines can't be reviewed
Allow Photos reviews-photos-enabled on Up to 4 photos per review
Moderation reviews-moderation-mode ai ai publishes what the check passes; manual holds every review for staff, with the check's opinion shown
Moderation Model reviews-moderation-model gpt-5-nano The OpenAI model used for the check
Review Link Signing Key reviews-link-signing-key generated Secret for the email links; created on first use, never shown, not exported

Then add the Product Reviews extension (productReviews) to products, or better, to the product stereotype, in the lowerBannerSlot (below the product details; every theme renders it). It has two settings: the heading and how many reviews to show per page.

Extra ratings

reviews-criteria holds one rating per line, key: Label:

quality: Quality
value: Value for money
fit: Size & fit (Runs small | Runs large)
  • A plain line is a 1-5 star rating.
  • A line ending in (low | high) is a five-point scale whose middle is best, labelled at each end. Customers pick 1-5 (3 is "Just right"); the product page shows the average as a marker on a line from "Runs small" to "Runs large", and each review shows e.g. "Size & fit: Runs small (a little)".
  • Keys are stored on reviews, so don't rename a key once reviews use it (change the label instead). At most six criteria. All are optional for the customer; only the overall rating is required.

The criteria are site-wide: a "fit" rating appears on every product's form. Per-collection criteria are not built yet.

Who can review what

ReviewService.findReviewableItems(order) decides. A product on an order is reviewable when:

  • reviews are on;
  • its line was dispatched: the line's fulfilment state is FULFILLED, or the order is DISPATCHED / DELIVERED and the line wasn't cancelled. So a line dispatched on its own (partial dispatch) can be reviewed before the rest of the order ships;
  • none of the line's product type decorators is in reviews-excluded-product-types;
  • the product still exists.

A product appears once per order, however many lines (sizes) it was bought on, and can be reviewed once per order (a unique index on siteId + orderId + productId backs this up against double submits). A customer who buys the same product again on another order can review it again.

The review request email

ReviewRequestSweepJob runs on the worker every hour at :15 (badger.reviews.request-sweep-schedule). For each live site with reviews and the request email on, it finds orders whose DISPATCHED state entry falls between delay and delay + 3 days ago and that don't yet carry the REVIEW_REQUEST_EMAIL_QUEUED activity entry (OrderService.findOrdersDispatchedBetween). For each it calls ReviewService.requestReviews, which emails the customer if anything on the order is still unreviewed and then records the activity entry, so an order is only ever asked once.

The three-day catch-up window means turning reviews on doesn't email a shop's whole order history: only orders dispatched in the last delay + 3 days are asked.

The email (templates/bootstrap/email/reviewRequest.html, sent by ReviewRequestEmailSender through the normal email job queue) lists the products with a star link each and a Write a review button. All links go to:

https://<shop domain>/reviews/write?token=<signed token>

The token (ReviewLinkTokenService) is base64url(orderId|expiry).HMAC-SHA256, keyed per site, and lasts 60 days. It only lets its holder review that order's products. It never carries the customer's email address.

The review page

ReviewWriteController (/reviews/write) shows one form per reviewable product: stars, any extra ratings, headline, comment, photos and the name to show. The name defaults to the buyer's first name and last initial ("Sam T.") and the customer can change it. The email address is never shown. Products already reviewed show a "thanks" note instead of a form.

Templates: nova/reviews/write.html (Nova and every Nova-based theme, using the theme's auth-head) and bootstrap/reviews/write.html, plus reviews/linkInvalid.html for expired or tampered links.

Photos. The page shrinks photos in the browser to at most 1600px JPEG before upload (which also turns HEIC and the like into JPEG). On the server, ReviewImages re-encodes every photo as a JPEG of at most 1600px whether or not the browser did. That drops the photo's metadata, including the GPS position phones record, which matters because the CDN serves the stored file as it is. Photos the JDK can't read are refused with a message.

Each photo goes straight into the site's main media bucket at <siteId>/media/review-images/<random UUID>.jpg (UserUploadService.storeInMainBucket; no UserUpload or Media record, so review photos stay out of the media library). That's so the AI check and the product page can both use the resizing CDN. The photo is reachable by its URL before the review is checked, but the key is random and appears nowhere until the review is published.

Text. Control characters are stripped, runs of blank lines collapsed, and lengths capped (headline 120, comment 5000, name 40). Templates print everything with th:text.

Moderation

On submit the review is saved as PENDING and a ReviewSubmittedEventBean is sent to review-moderation-queue. On the worker, ReviewModerationListener:

  1. builds each photo's 512px CDN URL, <productImagePath>/fit-in/512x512/<key> (ReviewService.moderationPhotoUrls);
  2. calls OpenAiReviewModerationService: one chat call to reviews-moderation-model with the instructions, the product name, the review (fenced as data so instructions inside it are judged, not followed) and the photo URLs. OpenAI fetches the small renditions itself, as it does for image tagging, so the worker never downloads or resizes a photo. The model answers with JSON: {approved, reasons[], rejectedPhotos[], summary}. Reasons are limited to abuse, hate, sexual, violence, personal-data, spam, off-topic, unsuitable-photo. The model is told to approve honest negative reviews;
  3. applies the verdict (ReviewService.moderationCompleted): in ai mode an approved review is published; otherwise it becomes NEEDS_REVIEW. Any rejected photo, or a photo that couldn't be read, fails the review.

The key is the site's ai-openai-api-key, else the platform's ai.platform.openai.apiKey, as for image tagging. With no key, an API error or an unreadable reply, the review goes to NEEDS_REVIEW (moderationUnavailable). It is never published unchecked and never left pending because of an error. If the queue is down at submit time, the review stays PENDING and staff can publish it or press Check again.

If the CDN can't serve a photo (OpenAI can't fetch it), the call fails and the review waits for staff. On a local stack whose image path isn't reachable from the internet, every review with photos ends up there.

Publishing only changes the status (the photos are already in place) and rebuilds the product's ProductReviewSummary. Rejecting a review, or removing a photo, deletes the photo from the bucket; a rejected review published later keeps its text but not its photos. Renditions the CDN has already cached can outlive the deletion until the CDN cache expires.

Photos submitted before October 2026 were held as UserUploads in the private user-uploads bucket. Those are still checked (through a short-lived signed URL, full size) and copied to the main bucket when published (UserUploadService.publishToMainBucket).

Staff screens

Catalogue › Reviews (/admin/catalogue/reviews, merchandisers and admins; ReviewAdminController) opens on the reviews Waiting for a decision, oldest first, with tabs for Published, Rejected and All.

A review's page shows the review, its photos (800px from the CDN; an older photo still in the private bucket through a short-lived signed URL), the AI check's verdict, reasons and summary (any photo it flagged is outlined), and the history. Actions: Publish, Reject / Take down (with a reason for your records; the customer isn't told), Remove a single photo, Check again, and Delete review.

On the product page

ProductReviewsExtension reads the product's ProductReviewSummary (one small document, kept in step on every publish and take-down) and a page of published reviews, newest first (?reviewPage=2). It shows:

  • the average to one decimal, the star breakdown with bars, and the review count;
  • each extra rating's average: a bar for stars, a marker on the low-to-high line for a scale;
  • each review with its stars, headline, name, "Verified buyer", date, the variant bought ("Bought: Large"), its extra ratings and photo thumbnails linking to a larger image.

Templates: nova/extensions/productReviews/main.html and bootstrap/extensions/productReviews/main.html.

Data

Collection What
productReviews ProductReview: product, order, rating, criteria ratings, text, photos (main-bucket key; older photos also an upload id), status, AI verdict, history. Indexed by site + product + status + publishedAt, site + status + created, and unique site + order + product
productReviewSummaries ProductReviewSummary: per product count, average, star distribution, criteria averages
userUploads Only photos submitted before October 2026 (purpose review, reference = review id)

Reviews are customer content, so site data transfer doesn't export them. The review settings travel with the rest of the site configuration (the signing key doesn't, being a secret).

Demo sites

Every demo site has reviews on, its own extra ratings (Sauce Panic rates heat from "Milder than expected" to "Hotter than expected", Halden rates boot fit) and ten sample reviews from dev-scripts/demo-sites/reviews/<siteId>.mjs. The seeder loads them from reviews.json through ReviewService.replaceSampleReviews, which marks them with made-up sample- order ids so a re-seed replaces them and never touches real customers' reviews.

Ratings, search and structured data

Every publish, take-down or delete rebuilds the product's summary and then puts the average on the product itself (ReviewServiceImpl.syncProductRating): Product.rating (out of 100) and Product.reviewCount. The average is rounded to one decimal first, so the stars at the top of the page read the same as the reviews block (4.3 is stored as 86). The product is saved through ProductService, which evicts its caches and reindexes it, only if something changed. When the last review is taken down, a rating that came from reviews is cleared. A rating set by hand in the admin (with no reviewCount) is never touched, though a product's first published review replaces it.

From there:

  • Product page. Every theme's rating at the top shows the average to one decimal (whole stars for a hand-set rating) and, with reviews, links "12 reviews" to the reviews block. A reviewed rating wins over a variant's hand-set one (DProduct.getRating).
  • Structured data. Only a rating from reviews is marked up as schema.org aggregateRating, with its real reviewCount. A hand-set rating still shows but isn't published as one, because Google's review snippet rules don't allow a self-set rating presented as reviews (themes used to claim reviewCount 1 for it).
  • Search. The index carries rating and reviewCount. The rating filter (4★ & up) and the Top rated sort use the average, and Featured puts well-reviewed products in stock first (3+ reviews averaging 4 stars or more). See Search and Discovery.
  • Legacy collection pages sorting by rating (validSortByValues) read Product.rating too.

Not built yet

  • Staff replies to reviews, "was this helpful" votes, sorting and filtering reviews on the product page.
  • Per-collection criteria (e.g. fit only for clothing).
  • Removing or anonymising a customer's reviews when their account is erased.
  • MCP tools and REST endpoints for reviews.