Supercommerce API Docs
Full Module Docs

Reviews Module

HTTP surface for product reviews — customer submission + anonymous read, vendor moderation of reviews on their own products (gated by admin settings), and admin full-CRUD with…

HTTP surface for product reviews — customer submission + anonymous read, vendor moderation of reviews on their own products (gated by admin settings), and admin full-CRUD with approve/reject/spam-flagging.

Source: api-modules/reviews (registered via ReviewsModule.forRoot() in apps/api/src/app.module.ts).

The module owns review rows, per-review image attachments, and a per-product aggregate (avg rating + 1–5 star distribution) that the storefront product page reads. Cache invalidation on the aggregate is event-driven (proactive) with a 60 s TTL safety net.


Conventions

Authentication

Endpoint groupAuthPermission
GET /store/products/:productId/reviews, GET /store/products/:productId/reviews/aggregatenone
POST /store/products/:productId/reviewsrequired (customer)
GET /vendor/reviews, PATCH /vendor/reviews/:id, POST /vendor/reviews/:id/approve|reject|mark-spam|unmark-spam, DELETE /vendor/reviews/:idrequired (vendor session)active vendor in session + per-action admin gate (see below)
GET /admin/reviews, GET /admin/reviews/:idrequiredreview: read
POST /admin/reviewsrequiredreview: create
PATCH /admin/reviews/:id, POST /admin/reviews/:id/restorerequiredreview: update
POST /admin/reviews/:id/approverequiredreview: approve
POST /admin/reviews/:id/rejectrequiredreview: reject
POST /admin/reviews/:id/mark-spam, POST /admin/reviews/:id/unmark-spamrequiredreview: mark-spam
DELETE /admin/reviews/:idrequiredreview: delete

Vendor write actions are additionally gated by admin settings (admin.reviews.allow_vendor_edit, _approve, _reject, _mark_spam, _delete, _show_spam) — when the platform disables a capability, the controller still accepts the call but ReviewsService rejects with 403 Forbidden. The same settings drive whether spam rows surface in the vendor's list at all (_show_spam).

Vendor scope is read from the session and never accepted from the client. ReviewsService verifies the review's product belongs to that vendor before mutating; cross-vendor ids return 404 Not Found.

Response envelope

Successful responses are wrapped by ResponseInterceptor:

{
  "data": <payload>,
  "message": "Success",
  "statusCode": 200,
  "metadata": { /* optional, e.g. pagination */ }
}

Error envelope

statusCodeerrorCode examples
400BAD_REQUEST, VALIDATION_ERROR
401UNAUTHORIZED
403FORBIDDEN (missing permission, vendor write disabled by platform setting)
404NOT_FOUND
409CONFLICT, INVALID_TRANSITION (e.g. approving a deleted row)
500INTERNAL_SERVER_ERROR, DATABASE_ERROR

Lifecycle

review.status values: pending (default for customer submissions) → approved or rejected. The flag is_spam is orthogonal to status — a spam review can be in any status; spam is the operator's filter for abusive content, status is the publication decision. Storefront reads filter to status="approved" AND is_spam=false AND deleted_at IS NULL.

ActionAllowed fromSets
submit(new)status="pending", is_spam=false
approvepending, rejectedstatus="approved", stamps approved_at + approved_by
rejectpending, approvedstatus="rejected", stamps rejected_at + rejected_by
mark-spamany non-deletedis_spam=true
unmark-spamany non-deletedis_spam=false
softDeleteanystamps deleted_at; row hidden everywhere
restoredeletedclears deleted_at

State changes invalidate the aggregate cache for the affected product (event-driven).


Domain types

ReviewResponse

type ReviewStatus = "pending" | "approved" | "rejected";

type ReviewResponse = {
  id: string;
  productId: string;
  userId: string | null;            // null when admin recorded an anonymous review
  authorFirstName: string | null;   // populated when userId is null
  authorLastName: string | null;
  title: string | null;
  content: string;                  // 1..5000 chars
  stars: number;                    // 1..5
  recommended: boolean | null;      // nullable thumbs-up signal
  isVerifiedPurchase: boolean;
  isSpam: boolean;
  status: ReviewStatus;
  approvedAt: string | null;
  approvedBy: string | null;        // user id of the approver
  rejectedAt: string | null;
  rejectedBy: string | null;
  createdBy: string | null;         // admin user id when admin-created
  createdAt: string;
  updatedAt: string;
  deletedAt: string | null;
  images: ReviewImage[];
};

type ReviewImage = {
  id: string;
  url: string;                       // CDN URL of the uploaded image
  sortOrder: number;
};

ReviewAggregateResponse

type ReviewAggregateResponse = {
  productId: string;
  avg: number;                       // 0.0..5.0; 0 when no approved reviews
  count: number;                     // number of approved + non-spam + non-deleted reviews
  distribution: {                    // count per star tier
    "1": number;
    "2": number;
    "3": number;
    "4": number;
    "5": number;
  };
};

Author identity rule

Each review carries author info either as a userId (linking to a customer) or as (authorFirstName, authorLastName) — exclusive. The XOR is enforced at three layers: the admin-create zod schema, the service layer for updates, and a DB CHECK constraint. Customer-side submissions always populate userId from the session and leave the name fields null.


Storefront

GET /store/products/:productId/reviews — List approved reviews

Anonymous. Returns only status="approved" AND is_spam=false AND deleted_at IS NULL — those filters are hardcoded in the service rather than driven from query params so a malicious client cannot ask for pending or rejected entries.

Query

NameTypeDefaultConstraints
pageint1>= 1
limitint201..100
orderByenum"newest"newest | oldest | stars-desc | stars-asc

Response 200 — paginated envelope of ReviewResponse[].


GET /store/products/:productId/reviews/aggregate — Average + distribution

Anonymous. Returns the cached aggregate (60 s TTL, proactively invalidated on review-status changes).

Response 200ReviewAggregateResponse.


POST /store/products/:productId/reviews — Submit a review

Requires a customer session. Author identity is taken from the session — never accepted from the body. Submissions land at status="pending" by default (admin-configurable to auto-approve in a future iteration).

Body

{
  "title": "Great fit",                          // optional, ≤200 chars
  "content": "Loved the color and material.",    // required, 1..5000 chars
  "stars": 5,                                    // required, 1..5
  "recommended": true,                           // optional thumbs-up signal
  "images": [
    { "url": "https://cdn.example/r/abc.jpg", "sortOrder": 0 }
  ]
}

Response 201ReviewResponse with status="pending".


Vendor

Base path: /vendor/reviews. Scope: active vendor on the session.

GET /vendor/reviews — List reviews on my products

Query

NameTypeDefaultConstraints
productIdstring?filter to one product on this vendor
statusReviewStatus?filter
page, limit, orderBysame as storefront

Spam is hidden unless admin.reviews.allow_vendor_show_spam is enabled — without that flag the vendor's UI does not see content the platform decided to suppress.

Response 200 — paginated envelope of ReviewResponse[].


PATCH /vendor/reviews/:id — Edit limited fields

Vendors can edit title, content, and recommended — but not stars and not author identity. This preserves rating integrity (vendor cannot boost their own rating) and keeps author attribution honest.

Gated by admin.reviews.allow_vendor_edit. Disabled → 403.

Body

{
  "title": "...",
  "content": "...",
  "recommended": true
}

Response 200 — updated ReviewResponse.


POST /vendor/reviews/:id/approve / reject / mark-spam / unmark-spam

Each gated by its own admin setting (allow_vendor_approve, _reject, _mark_spam). Disabled → 403.

Response 200 — updated ReviewResponse.


DELETE /vendor/reviews/:id — Soft delete

Gated by admin.reviews.allow_vendor_delete. Disabled → 403.

Response 200 — updated ReviewResponse with deletedAt stamped.


Admin

Base path: /admin/reviews. RBAC permissions under the review:* resource.

GET /admin/reviews — Filtered list

Required permission: review:read.

Query

NameTypeNotes
productIdstring?filter
vendorIdstring?filter — joins through product.vendor_id
userIdstring?filter to a single customer's reviews
statusReviewStatus?filter
isSpam"true" | "false"filter; absent = no filter
includeDeleted"true" | "false"default false
page, limit, orderBysame as storefront

Response 200 — paginated envelope of ReviewResponse[].


GET /admin/reviews/:id — One review

Required permission: review:read. Returns ReviewResponse (even when soft-deleted).


POST /admin/reviews — Create on behalf of a customer

Required permission: review:create. Use for "received a review on paper, entering it manually" scenarios.

Body

{
  "productId": "01J9...",
  "userId": "01J9...",                  // XOR with author fields
  "authorFirstName": "Ada",             // XOR with userId
  "authorLastName": "Lovelace",
  "title": "...",
  "content": "...",
  "stars": 5,
  "recommended": true,
  "isVerifiedPurchase": true,
  "status": "approved",                 // optional override; default "pending"
  "images": [{ "url": "...", "sortOrder": 0 }]
}

Exactly one of userId OR (authorFirstName + authorLastName) must be present — enforced by zod, the service, and a DB CHECK.

Response 201ReviewResponse.


PATCH /admin/reviews/:id — Full edit

Required permission: review:update. Admin can edit every content field (title, content, stars, recommended), the author identity (retroactively attaching a userId to an anonymous review, or vice versa — re-validated against the XOR rule), and replace the image set wholesale (omit images to leave them alone).

Body

{
  "title": "...",
  "content": "...",
  "stars": 5,
  "recommended": true,
  "authorFirstName": "...",
  "authorLastName": "...",
  "images": [{ "url": "...", "sortOrder": 0 }]
}

Response 200ReviewResponse.


POST /admin/reviews/:id/approve / reject

Required permissions: review:approve / review:reject. Stamps approved_at + approved_by (or rejected_at + rejected_by).


POST /admin/reviews/:id/mark-spam / unmark-spam

Required permission: review:mark-spam. Flips is_spam. Independent of status.


DELETE /admin/reviews/:id — Soft delete

Required permission: review:delete. Stamps deleted_at — hides the row everywhere.


POST /admin/reviews/:id/restore — Restore

Required permission: review:update. Clears deleted_at.


Domain events

Emitted via EventEmitter2 after every write — consumed by the aggregate cache invalidator and the notifications module.

EventFired when
review.submittedcustomer submits a review
review.admin-createdadmin creates a review on behalf of a customer
review.updatedcontent / image / identity fields change
review.approvedstatus → approved
review.rejectedstatus → rejected
review.marked-spam / review.unmarked-spamspam flag toggles
review.deleted / review.restoredsoft-delete toggle

Configuration

Process env — none specific to reviews.

Admin settings (admin.reviews.* group)

SettingDefaultPurpose
allow_vendor_editfalseLets vendors edit title / content / recommended on their own products' reviews
allow_vendor_approvefalseLets vendors approve reviews on their own products
allow_vendor_rejectfalseLets vendors reject reviews on their own products
allow_vendor_mark_spamfalseLets vendors flag reviews on their own products as spam
allow_vendor_deletefalseLets vendors soft-delete reviews on their own products
allow_vendor_show_spamfalseWhen false, spam reviews are hidden from the vendor list — admin's spam classification suppresses them on the vendor side

Constants

NameValuePurpose
REVIEW_AGGREGATE_TTL_SECONDS60Aggregate cache TTL (safety net; events invalidate proactively)
REVIEW_AGGREGATE_CACHE_PREFIX"review:aggregate:v1"Versioned cache key prefix — bump on payload shape changes
DEFAULT_REVIEW_PAGE_SIZE20
MAX_REVIEW_PAGE_SIZE100

  • catalog — products that reviews attach to. See catalog.md.
  • cache — review aggregate cache.
  • settings — vendor-write feature gates live in the admin.reviews settings group. See settings.md.

On this page