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:
- 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 isDISPATCHED/DELIVEREDand 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:
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:
- builds each photo's 512px CDN URL,
<productImagePath>/fit-in/512x512/<key>(ReviewService.moderationPhotoUrls); - calls
OpenAiReviewModerationService: one chat call toreviews-moderation-modelwith 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 toabuse,hate,sexual,violence,personal-data,spam,off-topic,unsuitable-photo. The model is told to approve honest negative reviews; - applies the verdict (
ReviewService.moderationCompleted): inaimode an approved review is published; otherwise it becomesNEEDS_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 realreviewCount. 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 claimreviewCount1 for it). - Search. The index carries
ratingandreviewCount. 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) readProduct.ratingtoo.
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.