payload-reviews
v1.0.1
Published
Product reviews for Payload ecommerce, with a verified purchase flag computed from real orders, moderation, and an aggregate rating that never drifts.
Maintainers
Readme
payload-reviews
Adds product reviews to a Payload shop, with a verified purchase flag computed from the orders that were actually paid for, moderation before anything is public, and an aggregate rating that is counted from the rows every time rather than adjusted by a delta.
- Extends
@payloadcms/plugin-ecommerce, adding a collection and two fields instead of replacing anything - Verified purchase is decided by a query against your orders, not by trusting the submitter
- Reviews are stored as text: nothing is composed into markup, nothing is escaped, every length is capped
- No runtime dependencies, no admin components, so it survives minor releases
Install
Requires Payload 3.88 or newer and @payloadcms/plugin-ecommerce 3.88 or newer. Verified against Payload 3.88.0 with the official plugin installed.
pnpm add payload-reviewsimport { ecommercePlugin } from '@payloadcms/plugin-ecommerce'
import { reviewsPlugin } from 'payload-reviews'
export default buildConfig({
plugins: [
ecommercePlugin({ ... }),
reviewsPlugin({
adminAccess: ({ req }) => req.user?.role === 'admin',
}),
],
})reviewsPlugin must come after ecommercePlugin, because it adds the aggregate fields to the products collection that plugin defines.
What was measured
Read in the published @payloadcms/[email protected], in the original TypeScript carried by its source maps.
There is nothing to extend
The plugin defines products, variants, carts, orders, addresses and transactions. There is no reviews collection, no rating field and no aggregate anywhere, so this package adds rather than overrides. Only two fields land on a collection you already have.
How a purchase is recognised
An order is created in src/payments/adapters/stripe/confirmOrder.ts, and only after the payment intent has come back succeeded. Two details decide how the verified flag has to be computed:
...(req.user ? { customer: req.user.id } : { customerEmail }),An order belongs to a signed in customer or carries an address, never both. A review is therefore matched on either half: the customer relationship if the reviewer was signed in, the address otherwise. Matching on the address alone would miss every order a member ever placed.
The order's items are the cart snapshot, each line carrying product, quantity and, when variants are enabled, variant. The product is on the line, not on the order, so the query reaches into the array.
Exactly what makes a review verified
One query against your orders collection, run when the review is written:
{
and: [
{ status: { in: ['processing', 'completed'] } },
{ 'items.product': { equals: product } },
{ or: [{ customer: { equals: customer } }, { customerEmail: { equals: email } }] },
]
}The statuses are the two of the four the plugin defines that mean money was taken and kept. cancelled and refunded do not count, and neither does a review with no customer and no address. All three parts are exported as purchaseQuery so that you can read what your own shop would answer:
import { purchaseQuery, verifyPurchase } from 'payload-reviews'How the aggregate is kept honest
The aggregate is never incremented and never decremented. Every time a review is created, changed or deleted, the product is recounted from the approved rows:
| Rating | Query |
| --- | --- |
| 1 | count of approved reviews of this product with rating: 1 |
| ... | ... |
| 5 | count of approved reviews of this product with rating: 5 |
Five counts on indexed columns give both the number and the sum exactly, whatever the volume: there is no page to scan, no limit to truncate at, and nothing that can accumulate an error. A rating outside the scale is counted by nobody and therefore cannot pull the average anywhere. The recount runs whatever changed, including an edit that could not have moved the number, because a rule with no exceptions is the one that can be trusted.
The recount happens in the collection's afterChange and afterDelete hooks with the request passed through, so it counts inside the same transaction as the write that triggered it and sees that write.
Options
| Option | Default | Meaning |
| --- | --- | --- |
| adminAccess | any signed in user | Rule for reading unapproved reviews, and for updating and deleting any review |
| averageFieldName | 'ratingAverage' | Field on the product holding the mean |
| bodyMaxLength | 4000 | Characters kept in the body |
| countFieldName | 'ratingCount' | Field on the product holding how many approved reviews it has |
| createAccess | anyone | Rule for writing a review |
| customersSlug | 'users' | Collection the customer relationship points at |
| disabled | false | Stops verifying, aggregating and refusing duplicates but keeps every field, so the database keeps its shape |
| maxRating | 5 | Highest rating that can be given, and how many count queries a recount runs |
| nameMaxLength | 80 | Characters kept in the author name |
| oneReviewPerCustomer | true | Refuses a second review of the same product by the same person |
| ordersSlug | 'orders' | Collection the verified flag is computed from |
| productsSlug | 'products' | Slug of the products collection |
| purchaseStatuses | ['processing', 'completed'] | Order statuses that count as a purchase |
| rebuildEndpointPath | '/product-reviews/rebuild' | Path of the endpoint that recounts every product |
| rebuildSecret | '' | Secret accepted by that endpoint as Authorization: Bearer <secret>. Empty means only a signed in user may call it |
| reviewsSlug | 'reviews' | Slug of the collection this package adds |
| startingStatus | 'pending' | Status a new review is given |
| titleMaxLength | 120 | Characters kept in the title |
| writeAggregate | true | Whether the aggregate is written onto the product |
A value that cannot be used is replaced by its default rather than being applied. A maxRating of 0 becomes 5, 120.9 becomes 120, a slug given as an empty string becomes its default, a path without a leading slash gains one, an empty purchaseStatuses becomes the default pair, and a startingStatus this package does not know becomes pending.
Access, and the one thing this package cannot know
A plugin cannot see your role model, so adminAccess defaults to any authenticated user. On a shop where customers sign in, that is too generous: it lets a customer read reviews that are still pending. Pass your own rule.
reviewsPlugin({
adminAccess: ({ req }) => req.user?.collection === 'admins',
createAccess: ({ req }) => Boolean(req.user),
})adminAccess has to answer true or false. A query constraint is treated as not an administrator, and that reader sees approved reviews only.
Reading is public and narrowed rather than refused: anyone the rule does not accept reads with { status: { equals: 'approved' } } added to their query, whether they come through REST, GraphQL or the local API with access checks on. Creating is open by default, because a shop that only takes reviews from signed in customers is the exception; updating and deleting are closed to the admin rule.
A submitter cannot approve their own review or claim a verified purchase. On creation the status is forced to startingStatus for anyone the admin rule does not accept, and verifiedPurchase is overwritten by the computed value on every write that could change it.
What it adds to your database
| Collection | Field | Type | Notes |
| --- | --- | --- | --- |
| reviews | product | relationship | required, indexed |
| reviews | rating | number | required, indexed, between 1 and maxRating |
| reviews | title | text | capped at titleMaxLength |
| reviews | body | textarea | capped at bodyMaxLength |
| reviews | authorName | text | capped at nameMaxLength |
| reviews | authorEmail | email | indexed, stored lower case |
| reviews | customer | relationship | indexed |
| reviews | status | select | indexed. pending, approved, rejected |
| reviews | verifiedPurchase | checkbox | indexed, read only in the admin panel, computed on every write |
| your products collection | ratingAverage | number | indexed, read only. Null when there are no approved reviews |
| your products collection | ratingCount | number | indexed, read only |
The reviews collection carries createdAt and updatedAt through Payload's own timestamps. Nothing else is added anywhere, and a field this package would add that your products collection already declares under that name is left alone.
What it adds to your API
| Endpoint | Method | Purpose |
| --- | --- | --- |
| /api/reviews | get, post | The collection's own REST endpoints. Reading is narrowed to approved reviews |
| /api/product-reviews/rebuild | post | Recounts every product. Returns { "rebuilt": n } |
The rebuild path deliberately does not start with reviews, because Payload resolves the first segment of a path to a collection before it looks at root endpoints. A path that would be shadowed is reported in the server log on startup.
Recounting by hand
import { productRating, rebuildProductRatings, writeProductRating } from 'payload-reviews'
const aggregate = await productRating(payload, productID)
await writeProductRating(payload, productID)
const rebuilt = await rebuildProductRatings(payload)Each takes the same options you passed to the plugin as its last argument, so passing nothing gives you the documented defaults. productRating counts without writing, which is also what you want when writeAggregate is off and the storefront asks for the number directly.
Honest limits
The verified flag is a photograph, not a subscription. It is computed when a review is created and whenever its product, customer or address changes. A refund the week after does not retract it, and an order placed the week after does not grant it. Re-run the affected reviews, or call verifyPurchase from your own refund flow, if that matters to your shop.
Matching by address is exact. A guest review is tied to an order through customerEmail. The address on the review is stored trimmed and lower cased, and the comparison is equals, which is case sensitive on both database adapters. An order whose address was stored with capital letters will not match a review written in lower case. A customer who signs in is matched on the relationship instead and is not affected.
An address alone is not proof of identity. Nothing in this package verifies that the person writing the review owns the address they typed. Somebody who knows a customer's address and what they bought can write a review that is marked verified. If that matters, close createAccess to signed in customers, where the identity comes from the session rather than from a form field.
Concurrent moderation can leave the aggregate one review behind. Two people approving two reviews of the same product in the same instant each count before the other has committed, and the later write wins with the number it read. The rows are never wrong, only the cached aggregate, and the rebuild endpoint puts it right. Approvals are admin actions, so this is a narrow window, but it is a real one.
Writing the aggregate touches your products collection. The ecommerce plugin defines products with drafts and autosave enabled, so a write from this package goes through the same versioning as any other update to a product. Set writeAggregate: false and read productRating directly if you would rather nothing wrote to products at all.
A recount costs one query per point of the scale. Five by default, ten if you set maxRating: 10, on every review write. That is the price of an aggregate that cannot drift.
A rebuild reads every product. It walks the collection a hundred at a time and recounts each one, so on a large catalogue it is a slow call. It is meant for an import, a restore, or a suspicion, not for a schedule.
One review per person is enforced on write, not by the database. The check is a query in a hook, so two submissions arriving in the same instant can both pass it. There is no unique index, because the pair that would have to be unique is nullable on both halves. A duplicate that slips through is a duplicate, not corruption, and it can be rejected in moderation.
A rejected review still blocks a new one. That is deliberate: otherwise a rejected review is only ever one resubmission away from being written again. Delete it if the customer should have another go.
Deleting a product leaves its reviews behind. They keep pointing at a document that is gone. This package adds no cascade, because deleting a customer's writing as a side effect of a catalogue change is not a decision a plugin should make for you.
The body is text, and rendering it safely is yours. Reviews are stored exactly as they were written, with control characters removed, whitespace trimmed and the length capped. Nothing is escaped and nothing is converted into markup, so a storefront that renders a review as HTML without escaping it is exposed by its own template, not by the stored value.
License
MIT. Copyright George Vasiliades, https://github.com/Poseidonas
