Catalog Module — Vendor surface
Vendor-facing CRUD over the vendor's own products, variants, options, and tabs, plus the vendor-side "request a new taxonomy entry" workflow (new brand, category, tag, or…
Vendor-facing CRUD over the vendor's own products, variants, options, and tabs, plus the vendor-side "request a new taxonomy entry" workflow (new brand, category, tag, or ingredient). A product belongs to one vendor and carries a soft-delete flag; variants carry the canonical HSN code that the order module snapshots at place-time.
Source:
api-modules/catalog/src/controllers/vendor-product.controller.ts,api-modules/catalog/src/controllers/vendor-product-variant.controller.ts,api-modules/catalog/src/controllers/vendor-product-tab.controller.ts,api-modules/catalog/src/controllers/vendor-catalog-request.controller.ts.
Conventions
Authentication
All endpoints require a Better-Auth bearer session with an active vendor.
Authorization: Bearer <session-token>The vendor is resolved via resolveActiveVendorId(session) — prefers session.session.activeOrganizationId (better-auth canonical) and falls back to activeVendorId.
Tenant scoping
Every read and write scopes to the active vendor's id. Product ids, variant ids, or catalog-request ids that belong to a different vendor return 404 Not Found, never 403 — so existence of another vendor's row can't be inferred.
Response envelope
{
"data": <payload>,
"message": "Success",
"statusCode": 200,
"metadata": { /* optional, e.g. pagination */ }
}Error envelope
statusCode | errorCode examples |
|---|---|
| 400 | BAD_REQUEST, VALIDATION_ERROR |
| 401 | UNAUTHORIZED |
| 404 | NOT_FOUND |
| 409 | CONFLICT, UNIQUE_VIOLATION (slug taken) |
| 500 | INTERNAL_SERVER_ERROR, DATABASE_ERROR |
Money + dates
All monetary fields (price, specialPrice) are integer subunits (paise / cents / eurocents). Date fields (publishedAt, specialPriceStart, specialPriceEnd) are ISO datetimes.
HSN code
HSN classification lives on product_variant (hsnCode), not on product. The product also carries informational hsCode/midCode but the canonical value snapshotted onto order_line.hsn_code_at_order is the variant's hsnCode. Empty strings are rejected — send null explicitly to clear.
Domain types
ProductStatus, ProductVisibility
type ProductStatus = "draft" | "active" | "archived";
type ProductVisibility = "public" | "private";ProductDetail
Returned by :id/detail and writes that touch the whole product.
type ProductDetail = {
id: string;
vendorId: string;
title: string;
slug: string; // /^[a-z0-9]+(?:-[a-z0-9]+)*$/
subtitle: string | null;
description: string | null;
brandId: string | null;
primaryCategoryId: string | null;
material: string | null;
countryOfOrigin: string | null;
hsCode: string | null; // informational only
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[];
variants: ProductVariant[];
tabs: ProductTab[];
createdAt: string;
updatedAt: string;
deletedAt: string | null;
};ProductVariant
type ProductVariant = {
id: string;
productId: string;
thumbnail: string | null;
images: string[];
price: number | null; // subunits
specialPrice: number | null; // subunits; < price when both set
specialPriceStart: string | null; // ISO
specialPriceEnd: string | null; // > specialPriceStart when both set
sku: string | null;
ean: string | null;
upc: string | null;
barcode: string | null;
hsnCode: string | null; // canonical for orders/invoices
minQuantityPerCart: number | null;
maxQuantityPerCart: number | null; // >= minQuantityPerCart when both set
sortOrder: number;
optionValueIds: string[]; // one value per option
createdAt: string;
updatedAt: string;
deletedAt: string | null;
};ProductOption, ProductTab
type ProductOption = {
id: string;
productId: string;
name: string; // e.g. "Color"
sortOrder: number;
values: Array<{ id: string; value: string; sortOrder: number }>;
};
type ProductTab = {
id: string;
productId: string;
title: string;
body: string | null;
isActive: boolean;
sortOrder: number;
};CatalogRequest
type CatalogRequestStatus = "pending" | "approved" | "rejected";
type CatalogRequest = {
id: string;
title: string;
description: string | null;
slug: string;
image: string | null;
metadata: Record<string, unknown> | null;
status: CatalogRequestStatus;
vendorId: string;
requestedByUserId: string;
rejectionReason: string | null;
approvedAt: string | null;
rejectedAt: string | null;
resultingItemId: string | null; // canonical id populated on approval
createdAt: string;
updatedAt: string;
};
// Categories add parent linkage.
type CategoryRequest = CatalogRequest & { parentId: string | null };Products
Base path: /vendor/products.
POST /vendor/products — Create a product
Single-call create that can include the entire option matrix, variants, and tabs.
Body — CreateProductInput
{
"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", // default "draft"
"visibility": "public", // default "public"
"publishedAt": null,
"categoryIds": ["01J9..."],
"tagIds": ["01J9..."],
"ingredientIds": ["01J9..."],
"options": [
{ "name": "Color", "sortOrder": 0, "values": [
{ "value": "Red", "sortOrder": 0 },
{ "value": "Blue", "sortOrder": 1 }
] }
],
"variants": [
{
"sku": "TSHIRT-RED-M",
"price": 99900,
"specialPrice": 89900,
"hsnCode": "61091000", // canonical
"minQuantityPerCart": 1,
"maxQuantityPerCart": 10,
"optionValues": [
{ "optionName": "Color", "value": "Red" },
{ "optionName": "Size", "value": "M" }
]
}
],
"tabs": [
{ "title": "Care", "body": "Machine wash cold", "sortOrder": 0 }
]
}Notable constraints:
slugmatches/^[a-z0-9]+(?:-[a-z0-9]+)*$/and is unique among non-deleted products.title≤ 255 chars.- All foreign-key ids (
brandId,categoryIds,tagIds,ingredientIds) must reference non-deleted rows in the canonical tables. - Variant cross-field rules:
specialPrice < pricewhen both set,specialPriceEnd > specialPriceStartwhen both set,maxQuantityPerCart >= minQuantityPerCartwhen both set. - Nested variant
optionValues[]uses(optionName, value)tuples — the server resolves them to option-value ids inside the parent transaction.
Response 201 — ProductDetail.
Side effects — emits catalog.product.created (consumed by the search reindexer + cart price-cache invalidator).
Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR | Body fails zod (slug regex, cross-field rules, etc.) |
| 409 | UNIQUE_VIOLATION | Slug already taken |
GET /vendor/products — List my products
Standard QueryDto query — page / limit / search / sort. Returns paginated products (summary shape — no nested option matrix).
Response 200 — paginated envelope.
GET /vendor/products/:id — Get product summary
Returns the product row + first-class fields. No nested options, variants, or 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[].
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Product not owned by active vendor |
PATCH /vendor/products/:id/basics — Update descriptive + SEO + status + taxonomy
Partial update. Body fields are the same first-class fields as POST minus thumbnail, images, options, variants, tabs (those have their own endpoints).
Body — UpdateProductBasicsInput (every field optional)
{
"title": "Red Tee v2",
"slug": "red-tee-v2",
"status": "active",
"visibility": "public",
"publishedAt": "2026-05-01T00:00:00.000Z",
"categoryIds": ["01J9..."],
"tagIds": ["01J9..."],
"ingredientIds": []
}Response 200 — ProductDetail. Emits catalog.product.updated.
Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR | Body fails zod |
| 404 | NOT_FOUND | Product not owned by active vendor |
| 409 | UNIQUE_VIOLATION | Slug change collides with another product |
PATCH /vendor/products/:id/media — Update thumbnail + images
Body
{
"thumbnail": "https://...", // nullable
"images": ["https://...", "..."]
}Response 200 — ProductDetail. Emits catalog.product.updated.
PUT /vendor/products/:id/options — Replace product options
Whole-set replace of options[] (and each option's values[]). Existing variants whose optionValueIds no longer resolve are detached (sort order preserved but option-value links emptied). Most edit UIs use /sync instead.
Body
{
"options": [
{ "name": "Size", "sortOrder": 0, "values": [
{ "value": "S", "sortOrder": 0 },
{ "value": "M", "sortOrder": 1 },
{ "value": "L", "sortOrder": 2 }
] }
]
}Response 200 — ProductDetail. Emits catalog.product.updated.
PUT /vendor/products/:id/sync — Atomic full-product sync
Replace basics + media + options + variants + tabs in one transaction. The partial-failure surface is small — a write either fully succeeds or fully rolls back. Most edit screens call this on save.
Body — combines UpdateProductBasicsInput, UpdateProductMediaInput, options array, variants array, tabs array. Variants and tabs may carry id to update in-place, or omit it to insert.
{
"basics": { /* UpdateProductBasicsInput */ },
"media": { "thumbnail": "...", "images": ["..."] },
"options": [ /* ProductOption[] (replace-all) */ ],
"variants": [
{
"id": "01J9...", // omit to create
"sku": "TSHIRT-RED-M",
"price": 99900,
"hsnCode": "61091000",
"optionValues": [
{ "optionName": "Color", "value": "Red" },
{ "optionName": "Size", "value": "M" }
]
}
],
"tabs": [
{ "id": "01J9...", "title": "Care", "body": "...", "isActive": true, "sortOrder": 0 }
]
}Response 200 — ProductDetail. Emits catalog.product.updated.
DELETE /vendor/products/:id — Soft delete
Sets deletedAt. Reversible via support tooling — there is no public restore endpoint on the vendor surface.
Response 200 — emits catalog.product.deleted.
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Product not owned by active 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 exist for incremental UIs.
A variant's hsnCode is what's snapshotted onto order_line.hsn_code_at_order at place-time (see order.md).
GET /vendor/products/:productId/variants — List variants
Returns all non-deleted variants for the product, ordered by sortOrder.
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Product not owned by active vendor |
POST /vendor/products/:productId/variants — Create a variant
Body — CreateProductVariantInput
{
"thumbnail": "https://...",
"images": ["https://..."],
"price": 99900,
"specialPrice": 89900,
"specialPriceStart": "2026-05-01T00:00:00.000Z",
"specialPriceEnd": "2026-05-15T00:00:00.000Z",
"sku": "TSHIRT-RED-M",
"ean": null,
"upc": null,
"barcode": null,
"hsnCode": "61091000",
"minQuantityPerCart": 1,
"maxQuantityPerCart": 10,
"sortOrder": 0,
"optionValueIds": ["<color-red>", "<size-m>"]
}Cross-field rules:
specialPrice < pricewhen both set.specialPriceEnd > specialPriceStartwhen both set.maxQuantityPerCart >= minQuantityPerCartwhen both set.hsnCodemust be non-empty after trimming, ≤ 32 chars; sendnullto clear.
Response 201 — created ProductVariant. Emits catalog.variant.created.
PUT /vendor/products/:productId/variants/reorder — Reorder variants
Body
{
"variants": [
{ "variantId": "01J9...", "sortOrder": 0 },
{ "variantId": "01J9...", "sortOrder": 1 }
]
}variants[] must be at least one entry; entries should cover every non-deleted variant for stable ordering.
Response 200 — list of variants in new order. Emits catalog.variant.updated.
PATCH /vendor/products/:productId/variants/:variantId — Update a variant
Field-level partial update. The same cross-field rules apply to the post-merge value.
Response 200 — updated ProductVariant. Emits catalog.variant.updated.
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Product or variant not owned by active vendor |
DELETE /vendor/products/:productId/variants/:variantId — Soft delete a variant
Sets deletedAt. Emits catalog.variant.deleted.
Product tabs
Base path: /vendor/products/:productId/tabs. Free-form rich-text blocks rendered as accordions on the storefront product page.
GET /vendor/products/:productId/tabs — List tabs
Returns active + inactive tabs for the product, ordered by sortOrder.
POST /vendor/products/:productId/tabs — Create a tab
Body
{
"title": "Care",
"body": "Machine wash cold", // nullable
"isActive": true, // default true
"sortOrder": 0 // optional
}Response 201 — created ProductTab.
PUT /vendor/products/:productId/tabs/reorder — Reorder tabs
Body
{
"tabs": [
{ "tabId": "01J9...", "sortOrder": 0 },
{ "tabId": "01J9...", "sortOrder": 1 }
]
}PATCH /vendor/products/:productId/tabs/:tabId — Update a tab
Partial update — title, body, isActive, sortOrder.
DELETE /vendor/products/:productId/tabs/:tabId — Soft delete a tab
Catalog requests
Base path: /vendor/catalog/requests. Vendors propose new taxonomy entries (a brand we don't carry yet, a missing category, etc.). Admin reviews and approves into the canonical table or rejects with a reason. Approved requests get the canonical id written back as resultingItemId.
Four parallel sub-resources — categories, brands, tags, ingredients. Body shape is the standard CreateCatalogItemInput minus isActive (admin sets that flag on approval); the categories variant additionally accepts a nullable parentId.
POST /vendor/catalog/requests/:taxonomy — Submit a request
:taxonomy ∈ categories | brands | tags | ingredients.
Body — CreateCatalogRequestInput
{
"title": "Sustainable Cotton",
"description": "...",
"slug": "sustainable-cotton", // regex /^[a-z0-9]+(?:-[a-z0-9]+)*$/
"image": "https://...", // nullable
"metadata": { "any": "json" } // nullable
}For categories, additionally:
{ "parentId": "01J9..." } // nullableResponse 201 — CatalogRequest with status="pending". Emits catalog.request.submitted.
Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR | Body fails zod (slug regex, missing title) |
PUT /vendor/catalog/requests/:taxonomy/:id — Update a pending request
Allowed only while the request is still pending. Already-approved or rejected requests return 409.
Response 200 — updated CatalogRequest.
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Request not owned by active vendor |
| 409 | CONFLICT | Request is not pending |
GET /vendor/catalog/requests/:taxonomy — List my requests
Standard QueryDto. Scoped to the active vendor.
Response 200 — paginated envelope of CatalogRequest.
GET /vendor/catalog/requests/:taxonomy/:id — Get a request by id
Returns the request. Catalog requests are intentionally accessible by any authenticated user (admins regularly link to them); the row itself carries vendorId so a vendor can verify ownership client-side.
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Unknown id |
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.request.submitted / .approved / .rejected | Vendor request lifecycle |
Related modules
inventory— every variant has aninventoryrow. Vendor stock policy + manual adjustments live ininventory.md.product-attribute— vendor + admin attribute groups attached to products. Seeproduct-attribute.md.search— storefront product browsing / filtering / facets. Catalog writes emit events the reindexer consumes. Seesearch.md.cart+order— cart pricing reads fromproduct_variant; order line snapshotshsn_code_at_orderfrom the variant.
Cart Module — Vendor surface
Vendor-facing HTTP endpoints for browsing carts that contain the vendor's own products and for vendor-scoped cart funnel analytics. There are no write operations: a vendor cannot…
Inventory Module — Vendor surface
Vendor-facing HTTP endpoints for managing product-variant stock levels, policies, manual adjustments, audit trails, vendor-wide variant listing, and bulk CSV imports.