Dynamic Product Listing Module — Admin
HTTP surface for managing dynamic product listing groups (named containers) and dynamic product listings (saved filter specs). Groups and listings are addressable by slug; the storefront…
HTTP surface for managing dynamic product listing groups (named containers) and the dynamic product listings (saved filter specs) inside each group. Groups and listings are addressable by slug; the storefront resolves the saved filters into live product results at read time. This admin surface is the only writer.
Source:
api-modules/dynamic-product-listing/src/controllers/admin-dynamic-product-listing-group.controller.ts,api-modules/dynamic-product-listing/src/controllers/admin-dynamic-product-listing.controller.ts.A group is a named, slug-addressable collection of listings. A listing is a reusable filter specification (brands, categories, price range, attributes, etc.) that the storefront resolves into matching products on demand, with optional runtime query overrides.
Conventions
Authentication
All endpoints require a Better-Auth admin session and a role granting the matching permission.
| Endpoint group | Permission |
|---|---|
GET /admin/dynamic-product-listing-groups, GET /admin/dynamic-product-listing-groups/:id | dynamicProductListingGroup: read |
POST /admin/dynamic-product-listing-groups, POST /admin/dynamic-product-listing-groups/:id/duplicate | dynamicProductListingGroup: create |
PUT /admin/dynamic-product-listing-groups/:id | dynamicProductListingGroup: update |
DELETE /admin/dynamic-product-listing-groups/:id | dynamicProductListingGroup: delete |
GET /admin/dynamic-product-listings, GET /admin/dynamic-product-listings/:id, GET /admin/dynamic-product-listings/available-filters, POST /admin/dynamic-product-listings/preview | dynamicProductListing: read |
POST /admin/dynamic-product-listings, POST /admin/dynamic-product-listings/:id/duplicate | dynamicProductListing: create |
PUT /admin/dynamic-product-listings/:id, PATCH /admin/dynamic-product-listings/reorder | dynamicProductListing: update |
DELETE /admin/dynamic-product-listings/:id | dynamicProductListing: delete |
Response envelope
Successful responses are wrapped by ResponseInterceptor:
{
"data": <payload>,
"message": "Success",
"statusCode": 200,
"metadata": { /* optional, e.g. pagination */ }
}Paginated lists
List endpoints return the standard offset-based pagination envelope:
{
"data": [ /* items */ ],
"message": "Success",
"statusCode": 200,
"metadata": { "total": 45, "limit": 20, "offset": 0, "hasMore": true }
}Query params shared by all list endpoints: searchValue, searchField, limit, offset, sortBy, sortDirection.
Error envelope
statusCode | errorCode examples |
|---|---|
| 400 | BAD_REQUEST, VALIDATION_ERROR |
| 401 | UNAUTHORIZED |
| 403 | FORBIDDEN |
| 404 | NOT_FOUND |
| 409 | UNIQUE_VIOLATION (slug collision) |
| 500 | INTERNAL_SERVER_ERROR, DATABASE_ERROR |
Lifecycle
Deleting a group does not delete its listings — it detaches them (FK set to null). Deleting a listing is a hard delete.
Domain types
DynamicProductListingGroupResponse
type DynamicProductListingGroupResponse = {
id: string;
title: string; // 1..255
slug: string; // lowercase alnum + hyphens, 1..255
metadata: Record<string, unknown> | null;
createdAt: string; // ISO
updatedAt: string;
};
type DynamicProductListingGroupWithListingsResponse = DynamicProductListingGroupResponse & {
listings: DynamicProductListingResponse[]; // nested listing objects
};DynamicProductListingResponse
type DynamicProductListingResponse = {
id: string;
title: string;
slug: string;
groupId: string | null;
subtitle: string | null;
description: string | null;
image: string | null;
order: number; // ASC sort within group, >= 0
filters: ListingFilters | null;
metadata: Record<string, unknown> | null;
createdAt: string; // ISO
updatedAt: string;
};ListingFilters
All fields optional. Saved as a JSON spec on the listing; the storefront resolves it into live search results.
type ListingFilters = {
q?: string; // free-text search term
brands?: string[];
categories?: string[];
tags?: string[];
ingredients?: string[];
skus?: string[];
attributes?: Record<string, string[]>; // code → allowed values
minPrice?: number; // integer subunits
maxPrice?: number; // integer subunits
inStock?: boolean;
hasActiveSpecial?: boolean;
withReviews?: boolean; // embed each product's latest approved reviews in the resolved response
sortBy?: "relevance" | "price-asc" | "price-desc" | "new" | "best-selling" | "inventory-high" | "inventory-low" | "rating-desc" | "rating-asc";
};Dynamic product listing groups
Base path: /admin/dynamic-product-listing-groups.
GET /admin/dynamic-product-listing-groups — List groups
Required permission: dynamicProductListingGroup: read. Standard QueryDto (searchValue, searchField, limit, offset, sortBy, sortDirection).
Response 200 — paginated envelope of DynamicProductListingGroupResponse[].
GET /admin/dynamic-product-listing-groups/:id — Get a group with its listings
Required permission: dynamicProductListingGroup: read. Returns the group with nested listings[].
Response 200 — DynamicProductListingGroupWithListingsResponse.
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Unknown id |
POST /admin/dynamic-product-listing-groups — Create a group
Required permission: dynamicProductListingGroup: create.
Body
{
"title": "Summer Collection",
"slug": "summer-collection",
"metadata": { "platform": "WEB" }
}| Field | Type | Constraints |
|---|---|---|
title | string | 1..255 |
slug | string | 1..255, lowercase alnum + hyphens |
metadata | Record<string, unknown> | null? | Free-form |
Response 201 — DynamicProductListingGroupResponse.
Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR | Body fails zod |
| 409 | UNIQUE_VIOLATION | slug already in use |
PUT /admin/dynamic-product-listing-groups/:id — Update a group
Required permission: dynamicProductListingGroup: update. Partial body (same fields as create).
Response 200 — DynamicProductListingGroupResponse.
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Unknown id |
| 409 | UNIQUE_VIOLATION | slug taken by another group |
DELETE /admin/dynamic-product-listing-groups/:id — Delete a group
Required permission: dynamicProductListingGroup: delete. Detaches child listings (sets their groupId to null); does not delete them.
Response 204 No Content.
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Unknown id |
POST /admin/dynamic-product-listing-groups/:id/duplicate — Duplicate a group
Required permission: dynamicProductListingGroup: create. Clones the source group with a fresh title + slug. Every child listing is cloned; each clone receives a deduplicated <slug>-copy[-N] slug.
Body
{ "title": "Summer Collection (v2)", "slug": "summer-collection-v2" }| Field | Type | Constraints |
|---|---|---|
title | string | 1..255 |
slug | string | 1..255, lowercase alnum + hyphens |
Response 201 — DynamicProductListingGroupWithListingsResponse.
Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR | Body fails zod |
| 404 | NOT_FOUND | Source group not found |
| 409 | UNIQUE_VIOLATION | New slug taken |
Dynamic product listings
Base path: /admin/dynamic-product-listings.
GET /admin/dynamic-product-listings — List listings
Required permission: dynamicProductListing: read. Standard QueryDto plus optional groupId to scope to one group.
Query params (additional)
| Name | Type | Notes |
|---|---|---|
groupId | string? | Scope results to a single group |
Response 200 — paginated envelope of DynamicProductListingResponse[].
GET /admin/dynamic-product-listings/available-filters — Get available facet catalog
Required permission: dynamicProductListing: read. Passes through to the product search engine and returns the full facet catalog (brands, ingredients, attributes) that can be used to build a ListingFilters spec. Useful for populating filter-builder UI dropdowns.
Envelope note: this is a product-search passthrough — the response format is the product-search engine's native envelope, not the standard
ResponseInterceptorwrapper.
Response 200 — product-search passthrough envelope (facets only).
POST /admin/dynamic-product-listings/preview — Preview unsaved filters
Required permission: dynamicProductListing: read. Runs a ListingFilters spec against the search engine without saving it, returning matching products and facets. Useful for live previewing a filter spec before committing it.
Envelope note: this is a product-search passthrough — the response format is the product-search engine's native envelope (products + facets), not the standard
ResponseInterceptorwrapper.
Body
{
"filters": {
"brands": ["acme"],
"minPrice": 10000,
"maxPrice": 50000,
"inStock": true,
"sortBy": "price-asc"
},
"page": 1,
"limit": 20
}| Field | Type | Constraints |
|---|---|---|
filters | ListingFilters | See ListingFilters |
page | int? | Default 1 |
limit | int? | Default 20 |
Response 200 — product-search passthrough envelope (products + facets).
Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR | Body fails zod |
PATCH /admin/dynamic-product-listings/reorder — Bulk reorder within a group
Required permission: dynamicProductListing: update. Reapplies order values for the supplied listing ids within a group.
Body
{
"groupId": "01J9aaaa...",
"items": [
{ "listingId": "01J9xxxx...", "order": 0 },
{ "listingId": "01J9yyyy...", "order": 1 },
{ "listingId": "01J9zzzz...", "order": 2 }
]
}| Field | Type | Constraints |
|---|---|---|
groupId | string | Target group |
items[].listingId | string | Required |
items[].order | int | >= 0 |
items | array | Min 1 entry |
Response 200 — reordered DynamicProductListingResponse[] in their new order.
Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR | Missing fields, empty items, etc. |
| 404 | NOT_FOUND | Group not found |
GET /admin/dynamic-product-listings/:id — Get one listing
Required permission: dynamicProductListing: read.
Response 200 — DynamicProductListingResponse.
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Unknown id |
POST /admin/dynamic-product-listings — Create a listing
Required permission: dynamicProductListing: create.
Body
{
"title": "Budget Supplements",
"slug": "budget-supplements",
"groupId": "01J9aaaa...",
"filters": {
"categories": ["supplements"],
"maxPrice": 50000,
"inStock": true,
"sortBy": "price-asc"
},
"image": "listings/budget-supplements.jpg",
"subtitle": "Great value picks",
"description": "Hand-curated affordable supplements under ₹500.",
"order": 0
}| Field | Type | Constraints |
|---|---|---|
title | string | 1..255 |
slug | string | 1..255, lowercase alnum + hyphens |
groupId | string | null? | Optional parent group |
filters | ListingFilters | null? | See ListingFilters |
image | string | null? | Storage key |
subtitle | string | null? | Short caption |
description | string | null? | Long description |
order | int? | >= 0. Default 0 |
metadata | Record<string, unknown> | null? | Free-form |
Response 201 — DynamicProductListingResponse.
Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR | Body fails zod |
| 409 | UNIQUE_VIOLATION | slug already in use |
PUT /admin/dynamic-product-listings/:id — Update a listing
Required permission: dynamicProductListing: update. Partial body (same fields as create).
Response 200 — DynamicProductListingResponse.
Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR | Body fails zod |
| 404 | NOT_FOUND | Unknown id |
| 409 | UNIQUE_VIOLATION | slug taken by another listing |
DELETE /admin/dynamic-product-listings/:id — Delete a listing
Required permission: dynamicProductListing: delete. Hard delete.
Response 204 No Content.
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Unknown id |
POST /admin/dynamic-product-listings/:id/duplicate — Duplicate a listing
Required permission: dynamicProductListing: create. Clones the listing with a new slug and optional new title. All fields (filters, image, subtitle, description, order, metadata) are copied from the source.
Body
{ "slug": "budget-supplements-v2", "title": "Budget Supplements (v2)" }| Field | Type | Constraints |
|---|---|---|
slug | string | 1..255, lowercase alnum + hyphens; must be unique |
title | string? | Optional override; defaults to source title |
Response 201 — DynamicProductListingResponse.
Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR | Body fails zod |
| 404 | NOT_FOUND | Source listing not found |
| 409 | UNIQUE_VIOLATION | New slug taken |
Related modules
admin-rbac— gates every endpoint viadynamicProductListingGroup:*/dynamicProductListing:*. Seeadmin-rbac.md.store/dynamic-product-listing— unauthenticated storefront read of groups and listings by slug, with live product resolution.search— the product-search engine that backs the/previewand/available-filterspassthroughs.
Dynamic Link Module — Admin
HTTP surface for managing dynamic link groups (CMS-style tile collections — e.g. homepage banner slots) and the dynamic links inside each group. Groups are addressable by slug and…
Email Module — Admin
Operator-configurable email: outbound provider (Resend/SMTP), store brand tokens for transactional emails, per-template content overrides, and the block-based campaign builder.