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 viaReviewsModule.forRoot()inapps/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 group | Auth | Permission |
|---|---|---|
GET /store/products/:productId/reviews, GET /store/products/:productId/reviews/aggregate | none | — |
POST /store/products/:productId/reviews | required (customer) | — |
GET /vendor/reviews, PATCH /vendor/reviews/:id, POST /vendor/reviews/:id/approve|reject|mark-spam|unmark-spam, DELETE /vendor/reviews/:id | required (vendor session) | active vendor in session + per-action admin gate (see below) |
GET /admin/reviews, GET /admin/reviews/:id | required | review: read |
POST /admin/reviews | required | review: create |
PATCH /admin/reviews/:id, POST /admin/reviews/:id/restore | required | review: update |
POST /admin/reviews/:id/approve | required | review: approve |
POST /admin/reviews/:id/reject | required | review: reject |
POST /admin/reviews/:id/mark-spam, POST /admin/reviews/:id/unmark-spam | required | review: mark-spam |
DELETE /admin/reviews/:id | required | review: 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
statusCode | errorCode examples |
|---|---|
| 400 | BAD_REQUEST, VALIDATION_ERROR |
| 401 | UNAUTHORIZED |
| 403 | FORBIDDEN (missing permission, vendor write disabled by platform setting) |
| 404 | NOT_FOUND |
| 409 | CONFLICT, INVALID_TRANSITION (e.g. approving a deleted row) |
| 500 | INTERNAL_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.
| Action | Allowed from | Sets |
|---|---|---|
submit | (new) | status="pending", is_spam=false |
approve | pending, rejected | status="approved", stamps approved_at + approved_by |
reject | pending, approved | status="rejected", stamps rejected_at + rejected_by |
mark-spam | any non-deleted | is_spam=true |
unmark-spam | any non-deleted | is_spam=false |
softDelete | any | stamps deleted_at; row hidden everywhere |
restore | deleted | clears 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
| Name | Type | Default | Constraints |
|---|---|---|---|
page | int | 1 | >= 1 |
limit | int | 20 | 1..100 |
orderBy | enum | "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 200 — ReviewAggregateResponse.
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 201 — ReviewResponse with status="pending".
Vendor
Base path: /vendor/reviews. Scope: active vendor on the session.
GET /vendor/reviews — List reviews on my products
Query
| Name | Type | Default | Constraints |
|---|---|---|---|
productId | string? | — | filter to one product on this vendor |
status | ReviewStatus? | — | filter |
page, limit, orderBy | — | — | same 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
| Name | Type | Notes |
|---|---|---|
productId | string? | filter |
vendorId | string? | filter — joins through product.vendor_id |
userId | string? | filter to a single customer's reviews |
status | ReviewStatus? | filter |
isSpam | "true" | "false" | filter; absent = no filter |
includeDeleted | "true" | "false" | default false |
page, limit, orderBy | — | same 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 201 — ReviewResponse.
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 200 — ReviewResponse.
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.
| Event | Fired when |
|---|---|
review.submitted | customer submits a review |
review.admin-created | admin creates a review on behalf of a customer |
review.updated | content / image / identity fields change |
review.approved | status → approved |
review.rejected | status → rejected |
review.marked-spam / review.unmarked-spam | spam flag toggles |
review.deleted / review.restored | soft-delete toggle |
Configuration
Process env — none specific to reviews.
Admin settings (admin.reviews.* group)
| Setting | Default | Purpose |
|---|---|---|
allow_vendor_edit | false | Lets vendors edit title / content / recommended on their own products' reviews |
allow_vendor_approve | false | Lets vendors approve reviews on their own products |
allow_vendor_reject | false | Lets vendors reject reviews on their own products |
allow_vendor_mark_spam | false | Lets vendors flag reviews on their own products as spam |
allow_vendor_delete | false | Lets vendors soft-delete reviews on their own products |
allow_vendor_show_spam | false | When false, spam reviews are hidden from the vendor list — admin's spam classification suppresses them on the vendor side |
Constants
| Name | Value | Purpose |
|---|---|---|
REVIEW_AGGREGATE_TTL_SECONDS | 60 | Aggregate 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_SIZE | 20 | |
MAX_REVIEW_PAGE_SIZE | 100 |
Related modules
catalog— products that reviews attach to. Seecatalog.md.cache— review aggregate cache.settings— vendor-write feature gates live in theadmin.reviewssettings group. Seesettings.md.
Product Attribute Module
HTTP surface for product attributes (descriptive fields attached to products — material, country of origin, ingredient list, etc.) and attribute groups (reusable bundles of…
Search Module
Storefront-facing faceted product search and autocomplete, plus an admin-side bulk reindex trigger. Backed by Typesense via a provider-neutral SEARCH_PORT; the adapter is…