Search Module — Storefront
HTTP surface for the storefront product search — Typesense-backed faceted search and autocomplete suggestions. Mirrors the beautybarn response shape so the catalog/listing screens…
HTTP surface for the storefront product search — Typesense-backed faceted search and autocomplete suggestions. Mirrors the beautybarn response shape so the catalog/listing screens have a near drop-in surface during the re-platform.
Source:
api-modules/search/src/controllers/store-product-search.controller.ts.The admin reindex/backfill surface lives in
docs/separated/admin/search.md.Text searches (non-empty
q, first page) are recorded asynchronously for admin search analytics (search-analytics.md); suggestions and filter-only browsing are not. A better-auth session cookie, when present, attributes the event — the endpoints themselves remain fully public.
Conventions
Authentication
| Endpoint | Auth |
|---|---|
GET /store/product-search | none (public) |
GET /store/product-search/suggestions | none (public) |
Response envelope
The search and suggestions endpoints embed their own data key so the wire envelope is the standard { data, metadata?, message, statusCode }:
{
"data": { /* products + facets, or suggestions + products */ },
"metadata": { /* on /store/product-search only */ },
"message": "Success",
"statusCode": 200
}The metadata uses the named-pagination shape (total / items / perPage / currentPage / lastPage) rather than the offset-based total / limit / offset / hasMore shape — search is page-based.
Error envelope
statusCode | errorCode examples |
|---|---|
| 400 | BAD_REQUEST, VALIDATION_ERROR (malformed attributes JSON, bad price coercion) |
| 500 | INTERNAL_SERVER_ERROR, SEARCH_UPSTREAM_ERROR |
Currency
All variant price fields (price, specialPrice, originalPrice, currentPrice, specialPriceActive, priceStart, priceEnd, minPrice/maxPrice query) are integer subunits (paise / cents / eurocents).
Domain types
ProductSearchVariant
type ProductSearchVariant = {
id: string;
sku: string | null;
price: number | null;
specialPrice: number | null;
specialPriceStartDate: string | null; // ISO
specialPriceEndDate: string | null; // ISO
inventoryQuantity: number; // on-hand − reserved − safety stock
isOrderable: boolean; // true even at 0 for backorder/untracked
minQuantityPerCart: number | null;
maxQuantityPerCart: number | null;
thumbnail: string | null;
images: string[];
/** Computed at response time from the fields above. */
originalPrice: number | null; // = price
currentPrice: number | null; // = specialPriceActive ?? price
specialPriceActive: number | null; // specialPrice if window currently includes now, else null
};ProductSearchProduct
type ProductSearchProduct = {
id: string;
title: string;
subtitle: string | null;
description: string | null;
slug: string; // (beautybarn called this "handle")
thumbnail: string | null;
images: string[];
/** min/max of currentPrice across in-stock variants; null when no variant is priced. */
priceStart: number | null;
priceEnd: number | null;
brand: { id: string; slug: string; name: string } | null;
inStock: boolean; // any variant is orderable
hasActiveSpecial: boolean;
/** Average star rating across approved, non-spam reviews and the count
* backing it. `average` is 0 and `count` is 0 when there are no reviews. */
rating: { average: number; count: number };
variants: ProductSearchVariant[];
/** Present ONLY when the request passes `withReviews=true`. The product's
* newest approved, non-spam reviews (up to 10), newest first. Omitted
* (key absent) otherwise. */
reviews?: ProductSearchReview[];
};ProductSearchReview
Returned only when withReviews=true. A slim, denormalized snapshot embedded in the search index — enough to render a "latest reviews" preview on a product card without a second call to GET /store/products/:id/reviews.
type ProductSearchReview = {
id: string;
reviewerName: string | null; // display name only — customer name or admin-entered first+last
title: string | null;
content: string;
stars: number; // 1-5
recommended: boolean | null;
isVerifiedPurchase: boolean;
createdAt: string; // ISO 8601
images: string[]; // image URLs, ordered
};Facets
type ProductSearchBrandFacet = {
id: string;
slug: string;
name: string;
productCount: number;
};
type ProductSearchIngredientFacet = {
id: string;
slug: string;
name: string;
productCount: number;
};
type ProductSearchAttributeFacet = {
code: string;
title: string;
values: Array<{ value: string; productCount: number }>;
};
type ProductSearchRatingFacet = {
rating: number; // 1-5
productCount: number; // cumulative — products with rating.average >= this value
};Endpoints
GET /store/product-search — Faceted product search
Returns paginated products plus four facet blocks (brands, ingredients, attributes, ratings) computed against the same filtered set so client-side facet UI counts stay accurate. ratings is always 5 rows (1-5, descending) with cumulative "N stars & up" counts — matching how minRating filtering already works — rather than exact per-star counts, so selecting "4★" is expected to also include 5★ products.
Query
| Name | Type | Default | Notes |
|---|---|---|---|
q | string? | — | Trimmed, max 200 chars. Full-text query against title + description. |
vendors | csv string? | — | Comma-separated vendor ids. Restricts results to those sellers — powers the storefront seller page (pass the id from GET /store/vendors/:slug). |
brands | csv string? | — | Comma-separated brand slugs (or ids — depends on the index field). |
categories | csv string? | — | Comma-separated category slugs/ids. |
tags | csv string? | — | Comma-separated tag slugs/ids. |
ingredients | csv string? | — | Comma-separated ingredient slugs/ids. |
skus | csv string? | — | Comma-separated SKUs (variant-level). |
attributes | JSON-encoded string? | — | { "<attribute-code>": "value" | string[] }. Malformed JSON or non-string values returns 400. |
minPrice | int? | — | Subunit lower bound (inclusive). |
maxPrice | int? | — | Subunit upper bound (inclusive). |
minRating | number? | — | Inclusive lower bound on the product's average rating (0..5, fractional). Filters rating.average >= minRating. |
maxRating | number? | — | Inclusive upper bound on the product's average rating (0..5, fractional). |
inStock | "true" | "false"? | — | Coerced to boolean. When omitted, no filter is applied. |
hasActiveSpecial | "true" | "false"? | — | Coerced to boolean. |
withReviews | "true" | "false"? | — | When true, each product carries a reviews[] array of its latest approved reviews (up to 10, newest first). Off by default — the reviews blob is omitted from the response entirely unless requested. |
page | int | 1 | 1..1000 |
limit | int | DEFAULT_SEARCH_LIMIT | 1..MAX_SEARCH_LIMIT |
sortBy | enum? | relevance (implicit) | One of relevance / price-asc / price-desc / new / best-selling / inventory-high / inventory-low / rating-desc / rating-asc. Every sort carries a deterministic tiebreaker, so applying a filter only narrows the list — it never re-orders the products that remain. |
Response 200
{
"data": {
"products": [
{
"id": "01J9...",
"title": "Hydrating Toner",
"subtitle": "200ml",
"description": "...",
"slug": "hydrating-toner",
"thumbnail": "https://cdn.example/p/hydrating-toner.jpg",
"images": ["https://cdn.example/p/hydrating-toner-2.jpg"],
"priceStart": 49900,
"priceEnd": 79900,
"brand": { "id": "01J9...", "slug": "skin-co", "name": "Skin Co" },
"inStock": true,
"hasActiveSpecial": false,
"rating": { "average": 4.5, "count": 12 },
"variants": [ /* ProductSearchVariant[] */ ]
}
],
"brands": [ { "id": "01J9...", "slug": "skin-co", "name": "Skin Co", "productCount": 12 } ],
"ingredients": [ { "id": "01J9...", "slug": "niacinamide", "name": "Niacinamide", "productCount": 8 } ],
"attributes": [
{ "code": "size", "title": "Size", "values": [ { "value": "200ml", "productCount": 5 } ] }
],
"ratings": [
{ "rating": 5, "productCount": 18 },
{ "rating": 4, "productCount": 30 },
{ "rating": 3, "productCount": 35 },
{ "rating": 2, "productCount": 36 },
{ "rating": 1, "productCount": 36 }
]
},
"metadata": {
"total": 42,
"items": 20,
"perPage": 20,
"currentPage": 1,
"lastPage": 3
},
"message": "Success",
"statusCode": 200
}GET /store/product-search/suggestions — Autocomplete
Returns distinct title suggestions plus a small set of matching products for an inline search dropdown.
Query
| Name | Type | Default | Notes |
|---|---|---|---|
q | string | — required | Trimmed, 2..100 chars |
limit | int | DEFAULT_SUGGESTIONS_LIMIT | 1..20 |
Response 200
{
"data": {
"suggestions": ["Hydrating Toner", "Hydrating Serum"],
"products": [ /* ProductSearchProduct[] */ ]
},
"message": "Success",
"statusCode": 200
}Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR | q < 2 chars |
Related modules
catalog— products and variants returned here are reindexed from the catalog module's writes.inventory—inStock/isOrderable/inventoryQuantityreflect the inventory module's current view (with a small replication lag — the search index is eventually consistent).inventoryQuantityis the same figure checkout will honour: on-hand minus reserved minus safety stock. Filter and render offisOrderable, notinventoryQuantity > 0— a backorderable or untracked variant is orderable while reporting zero (untracked variants carry a large sentinel count).inStockis true when any variant of the product is orderable.product-attribute—attributesfacet values come from the product-attribute module.
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…
Serviceability Module — Storefront
Ask whether the store delivers to a pincode before the shopper commits to it. Returns the operator's own message plus whether it is a soft warning or a hard block, so the PDP and checkout can render either.