Reviews Module — Storefront
HTTP surface for product reviews on the storefront — anonymous list and aggregate reads of approved reviews, plus authenticated submission. Status filters are hardcoded…
HTTP surface for product reviews on the storefront — anonymous list and aggregate reads of approved reviews, plus authenticated submission. Status filters are hardcoded server-side so a malicious client can't ask for pending, rejected, or spam rows.
Source:
api-modules/reviews/src/controllers/store-reviews.controller.ts.Admin moderation, vendor visibility, and the verified-purchase signal computation live in sibling docs.
Conventions
Authentication
| Endpoint | Auth |
|---|---|
GET /store/products/:productId/reviews | none (public) |
GET /store/products/:productId/reviews/aggregate | none (public) |
POST /store/products/:productId/reviews | required (customer session) |
The submission endpoint takes the author identity from the session — userId is never accepted from the body.
Response envelope
{
"data": <payload>,
"message": "Success",
"statusCode": 200,
"metadata": { /* on the list endpoint */ }
}The list endpoint uses the ApiWrappedPaginatedResponse shape with metadata: { total, items, perPage, currentPage, lastPage }.
Error envelope
statusCode | errorCode examples |
|---|---|
| 400 | BAD_REQUEST, VALIDATION_ERROR |
| 401 | UNAUTHORIZED |
| 404 | NOT_FOUND |
| 500 | INTERNAL_SERVER_ERROR, DATABASE_ERROR |
Visibility
Storefront list and aggregate endpoints only ever return rows that are all of:
status = "approved",deletedAt IS NULL,isSpam = false.
These filters are hardcoded inside ReviewsService — clients cannot widen them via query params.
Domain types
ReviewImageResponse
type ReviewImageResponse = {
id: string;
url: string;
sortOrder: number; // 0-based; ascending
};ReviewResponse
type ReviewStatus = "pending" | "approved" | "rejected";
type ReviewResponse = {
id: string;
productId: string;
userId: string | null; // null for admin-created reviews
authorFirstName: string | null;
authorLastName: string | null;
title: string | null;
content: string;
stars: 1 | 2 | 3 | 4 | 5;
recommended: boolean | null;
isVerifiedPurchase: boolean;
isSpam: boolean; // always false on storefront reads
status: ReviewStatus; // always "approved" on storefront reads
approvedAt: string | null;
approvedBy: string | null;
rejectedAt: string | null;
rejectedBy: string | null;
createdBy: string | null;
createdAt: string; // ISO
updatedAt: string;
deletedAt: string | null; // always null on storefront reads
images: ReviewImageResponse[];
};ReviewAggregateResponse
type ReviewAggregateResponse = {
productId: string;
avg: number; // average over approved reviews (0..5)
count: number; // count of approved reviews
distribution: {
"1": number;
"2": number;
"3": number;
"4": number;
"5": number;
};
};Endpoints
Base path: /store/products/:productId/reviews.
GET /store/products/:productId/reviews — List approved reviews
Paginated list of approved, non-deleted, non-spam reviews for a product.
Path params
| Name | Notes |
|---|---|
productId | Product UUID |
Query
| Name | Type | Default | Notes |
|---|---|---|---|
page | int | 1 | >= 1 |
limit | int | (module default) | 1..MAX_REVIEW_PAGE_SIZE |
orderBy | enum? | — | newest / oldest / stars-desc / stars-asc |
Response 200 — paginated ReviewResponse[].
{
"data": [
{
"id": "01J9...",
"productId": "01J9...",
"userId": "01J9...",
"authorFirstName": "Ada",
"authorLastName": "L.",
"title": "Loved it",
"content": "Works exactly as described.",
"stars": 5,
"recommended": true,
"isVerifiedPurchase": true,
"isSpam": false,
"status": "approved",
"approvedAt": "2026-05-01T08:00:00.000Z",
"approvedBy": "01J9...",
"rejectedAt": null,
"rejectedBy": null,
"createdBy": null,
"createdAt": "2026-04-30T10:00:00.000Z",
"updatedAt": "2026-05-01T08:00:00.000Z",
"deletedAt": null,
"images": []
}
],
"metadata": {
"total": 47,
"items": 1,
"perPage": 20,
"currentPage": 1,
"lastPage": 3
}
}GET /store/products/:productId/reviews/aggregate — Average + star distribution
Aggregate over the same approved-only set. Useful for the product card / hero rating summary.
Response 200 — ReviewAggregateResponse.
{
"data": {
"productId": "01J9...",
"avg": 4.62,
"count": 47,
"distribution": { "1": 1, "2": 0, "3": 3, "4": 10, "5": 33 }
}
}POST /store/products/:productId/reviews — Submit a review
Authenticated. Submits a review for the product. The author is the session user; userId is never read from the body.
The service may set isVerifiedPurchase=true automatically based on the customer's order history for this product (the heuristic lives inside ReviewsService). The newly-created row may not be visible via the public list immediately — submissions land in status: "pending" by default and become visible only after moderation.
Body
{
"title": "Loved it", // optional, trimmed, 1..200 chars
"content": "Works exactly as described.", // required, trimmed, 1..5000 chars
"stars": 5, // integer 1..5
"recommended": true, // optional boolean
"images": [ // optional
{ "url": "https://cdn.example/r/1.jpg", "sortOrder": 0 }
]
}| Field | Constraints |
|---|---|
title | trimmed, 1..200 chars, nullable, optional |
content | trimmed, 1..5000 chars, required |
stars | integer 1..5 |
recommended | boolean, nullable, optional |
images[].url | must be a valid URL |
images[].sortOrder | integer >= 0 |
Response 201 — ReviewResponse (the newly-created row, typically with status: "pending").
Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR | Body fails zod |
| 401 | UNAUTHORIZED | No customer session |
Related modules
catalog— products referenced byproductIdcome from this module.order— used byReviewsServiceto compute theisVerifiedPurchasesignal.
Payment Razorpay Module — Storefront
HTTP surface for the customer-side payment-verify callback used by the Razorpay Checkout SDK after the user completes payment. Verifies the HMAC signature, pins to the place-time…
Rewards Module — Storefront
HTTP surface for the customer-facing rewards/loyalty plugin. Customers earn points on three actions (account registration, product purchase, product review) and redeem them at the…