Catalog Module
HTTP surface for the product catalog — vendor-facing CRUD over products, variants, options, tabs; vendor-side "request a new taxonomy entry" workflow; admin CRUD + approval over…
HTTP surface for the product catalog — vendor-facing CRUD over products, variants, options, tabs; vendor-side "request a new taxonomy entry" workflow; admin CRUD + approval over the platform-wide taxonomy (brands, categories, tags, ingredients); admin read of products and a flat cross-product variant picker; and unauthenticated public reads of active taxonomy for the storefront.
Source:
api-modules/catalog(registered viaCatalogModule.forRoot()inapps/api/src/app.module.ts).Cart / order / inventory consume product+variant data through the catalog module; the cart pricing snapshot reads
product_variant.price/special_priceand the order line snapshotshsn_codefrom the variant. HSN classification lives onproduct_variant, notproduct— variants in the same family can have different HSN codes (cosmetics vs. electronics packs).
Conventions
Authentication
| Endpoint group | Auth | Permission |
|---|---|---|
GET /store/catalog/** | none | — |
/vendor/products/**, /vendor/products/:productId/variants/**, /vendor/products/:productId/tabs/**, /vendor/catalog/requests/** | required (vendor session) | active vendor in session |
GET /admin/products, GET /admin/products/:id/detail, GET /admin/variants | required | product: view |
GET /admin/catalog/brands/** | required | brand: read |
POST /admin/catalog/brands | required | brand: create |
PUT /admin/catalog/brands/:id, POST /admin/catalog/brands/:id/restore | required | brand: update |
DELETE /admin/catalog/brands/:id | required | brand: delete |
POST /admin/catalog/brands/requests/:id/approve|reject | required | brand: approve |
/admin/catalog/categories/** | same as brands but on category:* | — |
/admin/catalog/tags/** | same on tag:* | — |
/admin/catalog/ingredients/** | same on ingredient:* | — |
Vendor endpoints resolve the active vendor via resolveActiveVendorId(session). Vendor-owned ids that belong to a different vendor return 404 Not Found (no row leak).
Response envelope
Successful responses are wrapped by ResponseInterceptor:
{
"data": <payload>,
"message": "Success",
"statusCode": 200,
"metadata": { /* optional, e.g. pagination */ }
}Some admin endpoints return a picker envelope instead of a plain page — see Picker responses below.
Error envelope
statusCode | errorCode examples |
|---|---|
| 400 | BAD_REQUEST, VALIDATION_ERROR |
| 401 | UNAUTHORIZED |
| 403 | FORBIDDEN |
| 404 | NOT_FOUND |
| 409 | CONFLICT, UNIQUE_VIOLATION (slug or SKU collision), FOREIGN_KEY_VIOLATION |
| 422 | UNPROCESSABLE_ENTITY |
| 500 | INTERNAL_SERVER_ERROR, DATABASE_ERROR |
Lifecycle / soft-delete
Brands, categories, tags, ingredients, products, variants, and product tabs all soft-delete via a deletedAt timestamp. A DELETE flips deletedAt; POST /restore clears it. Soft-deleted rows are hidden from default listings and public reads but visible in admin list filters.
Slugs are unique among non-deleted rows. Deleting a brand/category/tag/ingredient frees its slug for re-use; restoring a row may fail with UNIQUE_VIOLATION if the slug has been taken in the meantime.
Currency
Variant price fields (price, specialPrice) are integer subunits (paise / cents / eurocents — single currency per tenant). Send and receive integers; never decimal currency strings.
Picker responses
GET /admin/catalog/brands, GET /admin/catalog/categories, GET /admin/catalog/tags, GET /admin/catalog/ingredients, and GET /admin/variants return a picker envelope that pairs the regular paginated items[] with a pinned[] list of always-shown rows. Use the selectedIds=<csv> query param to pre-pin currently-selected items so the picker never has to manually search to render an already-applied selection:
{
"data": {
"items": [ /* page of unpinned rows */ ],
"pinned": [ /* full rows for every id in selectedIds */ ]
},
"metadata": { /* page, limit, total — for items[] only */ }
}When selectedIds is absent, pinned is [].
Domain events
Emitted via EventEmitter2 after writes — consumed by the search reindexer and the cart price-cache invalidator.
| Event | Fired when |
|---|---|
catalog.product.created | POST /vendor/products |
catalog.product.updated | any product write (basics, media, options, sync) |
catalog.product.deleted | DELETE /vendor/products/:id |
catalog.variant.created / .updated / .deleted | variant writes |
catalog.brand.*, .category.*, .tag.*, .ingredient.* | admin taxonomy writes |
catalog.request.submitted / .approved / .rejected | vendor request lifecycle |
Domain types
Taxonomy item — CatalogItemResponse
Shared by brand / category / tag / ingredient. Used by both admin and public reads.
type CatalogItemResponse = {
id: string;
title: string;
description: string | null;
slug: string; // lowercase alnum + hyphens
image: string | null;
metadata: Record<string, unknown> | null;
isActive: boolean;
createdAt: string; // ISO
updatedAt: string;
deletedAt: string | null;
};
// Admin list-only variant adds an aggregated banner count from the
// polymorphic `banner` table (so the UI can warn before deleting a
// brand/category that still has banners attached).
type AdminCatalogItemResponse = CatalogItemResponse & {
bannerCount: number;
};Product
A product belongs to one vendor and has zero-to-many variants, options, tabs, categories, tags, and ingredients. India GST classification (hsCode/midCode) on the product is informational; the canonical HSN for orders/invoices is on the variant (hsnCode).
type ProductStatus = "draft" | "active" | "archived";
type ProductVisibility = "public" | "private";
type ProductDetail = {
id: string;
vendorId: string;
title: string;
slug: string;
subtitle: string | null;
description: string | null;
brandId: string | null;
primaryCategoryId: string | null;
material: string | null;
countryOfOrigin: string | null;
hsCode: string | null; // informational
midCode: string | null;
thumbnail: string | null;
images: string[];
metaTitle: string | null;
metaDescription: string | null;
ogImage: string | null;
status: ProductStatus;
visibility: ProductVisibility;
publishedAt: string | null; // ISO
categories: CatalogItemResponse[];
tags: CatalogItemResponse[];
ingredients: CatalogItemResponse[];
options: ProductOption[]; // see "Options + variants" below
variants: ProductVariant[];
tabs: ProductTab[];
createdAt: string;
updatedAt: string;
deletedAt: string | null;
};Product variant
type ProductVariant = {
id: string;
productId: string;
thumbnail: string | null;
images: string[];
price: number | null; // subunits
specialPrice: number | null; // subunits; must be < price when both set
specialPriceStart: string | null; // ISO
specialPriceEnd: string | null;
sku: string | null;
ean: string | null;
upc: string | null;
barcode: string | null;
hsnCode: string | null; // India GST classification — canonical for orders
minQuantityPerCart: number | null;
maxQuantityPerCart: number | null; // must be >= minQuantityPerCart when both set
sortOrder: number;
optionValueIds: string[]; // links the variant to one value per option
createdAt: string;
updatedAt: string;
deletedAt: string | null;
};Product option + tabs
Options + their values define the option matrix (Color, Size, etc.). A variant references one optionValueId per option.
type ProductOption = {
id: string;
productId: string;
name: string; // e.g. "Color"
position: number;
values: ProductOptionValue[];
};
type ProductOptionValue = {
id: string;
optionId: string;
label: string; // e.g. "Red"
position: number;
};
// "Tabs" are free-form rich-text blocks ("Description", "Care",
// "Returns" etc.) rendered as accordions on the product page.
type ProductTab = {
id: string;
productId: string;
title: string;
body: string; // markdown / sanitized HTML
position: number;
};Catalog requests
Vendor-submitted taxonomy proposals (new brand, new category, etc.). Admin reviews and approves into the canonical table or rejects with a reason.
type CatalogRequest = CatalogItemResponse & {
status: "pending" | "approved" | "rejected";
vendorId: string;
requestedByUserId: string;
rejectionReason: string | null;
approvedAt: string | null;
rejectedAt: string | null;
resultingItemId: string | null; // populated on approval
};
// Category-only adds parent linkage.
type CategoryRequest = CatalogRequest & {
parentId: string | null;
};Storefront — GET /store/catalog/**
Unauthenticated reads of active (non-archived, non-deleted) taxonomy items. Used by storefront category pages, brand pages, etc.
| Method + path | Returns |
|---|---|
GET /store/catalog/categories | paginated list of CatalogItemResponse (active only) |
GET /store/catalog/categories/tree | nested tree, parent → children |
GET /store/catalog/categories/slug/:slug | active category by slug |
GET /store/catalog/categories/:id | active category by id |
GET /store/catalog/brands | paginated list of CatalogItemResponse (active only) |
GET /store/catalog/brands/slug/:slug | active brand by slug |
GET /store/catalog/brands/:id | active brand by id |
GET /store/catalog/tags | paginated list |
GET /store/catalog/tags/slug/:slug | active tag by slug |
GET /store/catalog/tags/:id | active tag by id |
GET /store/catalog/ingredients | paginated list |
GET /store/catalog/ingredients/slug/:slug | active ingredient by slug |
GET /store/catalog/ingredients/:id | active ingredient by id |
Soft-deleted or isActive=false rows return 404 Not Found.
Product browsing on the storefront uses the search module (docs/search.md) — not catalog — because it needs filters, facets, and full-text search.
Vendor — Products
Base path: /vendor/products. Scope: active vendor on the session.
POST /vendor/products — Create a product
Body (all fields validated by createProductSchema)
{
"title": "Red Tee",
"slug": "red-tee", // optional — auto-derived from title if omitted
"subtitle": "...",
"description": "...",
"brandId": "01J9...",
"primaryCategoryId": "01J9...",
"material": "100% cotton",
"countryOfOrigin": "IN",
"hsCode": "61091000", // informational
"midCode": null,
"thumbnail": "https://...",
"images": ["https://...", "..."],
"metaTitle": "...",
"metaDescription": "...",
"ogImage": "https://...",
"status": "draft", // "draft" | "active" | "archived"
"visibility": "public", // "public" | "private"
"publishedAt": null,
"categoryIds": ["01J9..."],
"tagIds": ["01J9..."],
"ingredientIds": ["01J9..."],
"options": [
{ "name": "Color", "position": 0, "values": [
{ "label": "Red", "position": 0 },
{ "label": "Blue", "position": 1 }
] }
],
"variants": [
{
"sku": "TSHIRT-RED-M",
"price": 99900,
"specialPrice": 89900,
"hsnCode": "61091000", // canonical for orders
"minQuantityPerCart": 1,
"maxQuantityPerCart": 10,
"optionValueIds": ["<Red>", "<M>"]
}
],
"tabs": [
{ "title": "Care", "body": "Machine wash cold", "position": 0 }
]
}Notable constraints:
slugmust match/^[a-z0-9]+(?:-[a-z0-9]+)*$/. Unique among non-deleted products.- On a variant:
specialPricemust be< pricewhen both are set;specialPriceEndmust be afterspecialPriceStart;maxQuantityPerCart >= minQuantityPerCart. - All foreign-key ids (
brandId,categoryIds,tagIds,ingredientIds, etc.) must reference non-deleted rows in the canonical tables.
Response 201 — ProductDetail.
GET /vendor/products — List my products
Standard QueryDto query — page / limit / search / sort. Returns paginated ProductDetail (without nested option matrix; use :id/detail for the full edit shape).
GET /vendor/products/:id — Get product (summary)
Returns the product row + first-class fields. No nested options/variants/tabs — use :id/detail for those.
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Product not owned by active vendor |
GET /vendor/products/:id/detail — Full product for edit
Returns ProductDetail including options, variants, tabs, and resolved categories[] / tags[] / ingredients[].
PATCH /vendor/products/:id/basics — Update descriptive + SEO + status + taxonomy
Partial update. Body fields are exactly the create-time fields except thumbnail, images, options, variants, tabs (those have their own endpoints).
Response 200 — ProductDetail.
PATCH /vendor/products/:id/media — Update thumbnail + images
{
"thumbnail": "https://...",
"images": ["https://...", "..."]
}PUT /vendor/products/:id/options — Replace options
Whole-set replace of options[] (and their values[]). Existing variants whose optionValueIds no longer resolve are detached (sort order preserved but option-value links emptied). Vendors typically use /sync instead of mutating options in isolation.
PUT /vendor/products/:id/sync — Atomic full-product sync
Replace basics + media + options + variants + tabs in one transaction. Use this from the edit screen so the partial-failure surface is small (a write either fully succeeds or fully rolls back).
Body combines the bodies of the five endpoints above. See dto/product/sync-product.dto.ts for the exact schema.
DELETE /vendor/products/:id — Soft delete
Sets deletedAt. Reversible via support tooling (no public restore endpoint on the vendor surface).
Vendor — Product variants
Base path: /vendor/products/:productId/variants. The variant matrix can also be edited via PUT /vendor/products/:id/sync; these per-variant endpoints are for incremental UIs.
| Method + path | Body | Notes |
|---|---|---|
GET / | — | List variants for product (active vendor only) |
POST / | CreateProductVariantInput | Create. specialPrice < price, specialPriceEnd > specialPriceStart, max >= min cross-field rules apply |
PUT /reorder | { ids: string[] } | Replace sort order. ids[] must exactly match the current variant set |
PATCH /:variantId | partial CreateProductVariantInput | Field-level update; the same cross-field rules apply to the post-merge value |
DELETE /:variantId | — | Soft delete |
A variant's hsnCode is what's snapshotted onto the order_line.hsn_code_at_order at place-time (see order.md).
Vendor — Product tabs
Base path: /vendor/products/:productId/tabs. Free-form rich-text blocks rendered as accordions on the storefront product page.
| Method + path | Body | Notes |
|---|---|---|
GET / | — | List tabs (active vendor only), ordered by position |
POST / | CreateProductTabInput | Create |
PUT /reorder | { ids: string[] } | Replace sort order |
PATCH /:tabId | partial | Update one tab |
DELETE /:tabId | — | Soft delete |
Vendor — Catalog requests
Base path: /vendor/catalog/requests. Vendors propose new taxonomy entries (a brand we don't carry yet, a missing category, etc.) and admin reviews. Approved requests get the canonical id written back as resultingItemId.
Pattern per taxonomy (categories, brands, tags, ingredients):
| Method + path | Body | Notes |
|---|---|---|
POST /:taxonomy | CreateCatalogRequestInput (categories adds parentId) | Submit a new request; starts pending |
PUT /:taxonomy/:id | same | Update a still-pending request. rejected/approved requests reject with 409 |
GET /:taxonomy | — | List my own requests for this taxonomy |
GET /:taxonomy/:id | — | Get a request by id (any vendor — non-leak: it's vendor-visible because admins routinely link them) |
CreateCatalogRequestInput is the regular CreateCatalogItemInput minus isActive (admin sets the active flag on approval).
Admin — Products
| Method + path | Permission | Notes |
|---|---|---|
GET /admin/products | product: view | List across all vendors. Accepts AdminProductQueryDto — page/limit/search + vendor/status/visibility filters |
GET /admin/products/:id/detail | product: view | Full ProductDetail plus vendor info |
GET /admin/variants | product: view | Cross-product variant picker; returns the picker envelope of AdminVariantListItem (variant + parent product summary). Searches across product title and variant SKU |
Admin variant picker is used by features that target individual variants (e.g. free-gift rules, discount include/exclude filters).
Admin — Brands / Categories / Tags / Ingredients
Four parallel resources under /admin/catalog/{brands|categories|tags|ingredients}. Identical shape; only the permission resource differs (brand, category, tag, ingredient).
Item CRUD
| Method + path | Permission | Notes |
|---|---|---|
GET / | <resource>: read | Picker envelope of AdminCatalogItemResponse (with bannerCount). selectedIds=<csv> pre-pins rows |
GET /tree (category only) | category: read | Hierarchical tree (active + inactive, admin scope) |
GET /:id | <resource>: read | Returns CatalogItemResponse. 404 on unknown id |
POST / | <resource>: create | Body: CreateCatalogItemInput. Slug unique among non-deleted |
PUT /:id | <resource>: update | Body: UpdateCatalogItemInput (partial) |
DELETE /:id | <resource>: delete | Soft delete |
POST /:id/restore | <resource>: update | Clears deletedAt. May 409 if slug conflicts post-restore |
CreateCatalogItemInput
{
"title": "Red",
"description": "...",
"slug": "red",
"image": "https://...",
"metadata": { "any": "json" },
"isActive": true
}| Field | Type | Constraints |
|---|---|---|
title | string | 1..255 chars |
description | string | null? | ≤2000 chars |
slug | string | 1..255 chars, regex ^[a-z0-9]+(?:-[a-z0-9]+)*$ |
image | string | null? | URL |
metadata | Record<string, unknown> | null? | Free-form JSON |
isActive | boolean? | Default true |
Requests review
Sub-resource under each taxonomy at requests/. Admin reviews vendor-submitted proposals.
| Method + path | Permission | Notes |
|---|---|---|
GET /requests/all | <resource>: read | Paginated list of pending+rejected+approved requests |
GET /requests/:id | <resource>: read | Request detail |
POST /requests/:id/approve | <resource>: approve | Creates the canonical item from the request payload; stamps approvedAt + resultingItemId |
POST /requests/:id/reject | <resource>: approve | Body: { reason: string } (1..2000 chars). Stamps rejectedAt |
Approving a request is idempotent in name — a second approve on an already-approved request returns 409. The same applies to reject.
Configuration
No env vars specific to the catalog module. Behavior is entirely controlled via vendor settings (which are managed by the settings module) and the platform RBAC permissions.
Related modules
product-attribute— vendor + admin attribute groups attached to products. Seeproduct-attribute.md.search— storefront product browsing / filtering / facet listing. Catalog writes emit events the search reindexer consumes. Seesearch.md.cart+order— cart pricing reads fromproduct_variant; order line snapshotshsn_code_at_orderfrom the variant. Seecart.md,order.md.banner— admin taxonomy lists includebannerCountso deleting a brand/category warns about attached banners.
Cart Module
HTTP surface for the shopping cart — storefront read/mutate (guest + logged-in), customer-side coupon and free-gift application, guest→customer merge on login, checkout…
Customer Module
HTTP surface for the customer-side address book (storefront) and an admin customer picker. The customer identity itself lives in Better-Auth's user table; this module owns the…