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";Product
The product row on its own — no option matrix, no variants, no tabs. This is what the summary read and every partial write return.
type Product = {
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;
attributeGroupId: 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
createdAt: string;
updatedAt: string;
deletedAt: string | null;
};
// `GET /vendor/products` rows add a rolled-up stock summary.
type ProductListItem = Product & { inventory: ProductInventorySummary };ProductDetail
Returned by :id/detail and by PUT /:id/sync. Note the shape is nested —
the product row sits under product, and taxonomy comes back as id arrays, not
as expanded objects.
type ProductDetail = {
product: Product;
options: ProductOption[];
variants: ProductVariant[];
tabs: ProductTab[];
categoryIds: string[];
tagIds: string[];
ingredientIds: string[];
attributeGroupId: string | null;
attributeAssignments: Array<{
id: string;
attributeId: string;
attributeValueId: string | null;
value: string | null;
sortOrder: number;
}>;
};ProductVariant
type ProductVariant = {
id: string;
productId: string;
vendorId: 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
weight: number | null; // grams
length: number | null; // cm
breadth: number | null; // cm
height: number | null; // cm
countryOfOrigin: string | null; // ISO 3166-1 alpha-2, e.g. "IN"
midCode: string | null;
minQuantityPerCart: number | null;
maxQuantityPerCart: number | null; // >= minQuantityPerCart when both set
sortOrder: number;
inventory: InventorySnapshot; // see inventory.md — read-only here
optionValues: Array<{ // one entry per option
optionValueId: string;
optionName: string;
value: string;
}>;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
};Stock is not a variant field. inventory is a read-only projection of the
inventory_stock row. Neither POST /vendor/products nor PUT /:id/sync
accepts stock in its body — writing on-hand quantity or the low-stock threshold
goes through the inventory endpoints (see inventory.md):
PATCH /vendor/products/:productId/variants/:variantId/inventory/policy
POST /vendor/products/:productId/variants/:variantId/inventory/adjustmentsProductOption, 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..."],
"attributeGroupId": "01J9...", // optional — the product's attribute group
"attributeAssignments": [ // optional — per-product attribute values
{ "attributeId": "01J9...", "attributeValueId": "01J9...", "sortOrder": 0 }, // select / multi_select
{ "attributeId": "01J9...", "value": "Cotton", "sortOrder": 0 } // text / number / boolean
],
"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,attributeGroupId) 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. attributeAssignments[]link the product to attribute definitions (managed via/vendor/product-attributes). Each row references a non-deletedattributeIdand, by the attribute's type, either anattributeValueId(select→ exactly one row;multi_select→ one row per chosen value) or a free-textvalue(text;numbermust parse numerically;booleanmust be"true"/"false"). Avalue/attributeValueIdmust belong to itsattributeId. WhenattributeGroupIdis set, everyisRequiredattribute of that group must be assigned. Indexed into search asattr_<code>facets.
Response 201 — Product (the product row only). The nested options,
variants and tabs you just sent are not echoed back, so a client that
needs the generated variant ids must follow up with GET /vendor/products/:id/detail.
Each created variant also gets an inventory_stock row, initialised to
quantityOnHand: 0 / trackInventory: true. Seeding real stock is a separate
call — see the inventory endpoints listed under ProductVariant above.
Side effects — emits vendor.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 — limit / offset / sort / filters — plus q. Returns paginated products (summary shape — no nested option matrix), scoped to the active vendor.
Query
| Name | Type | Default | Notes |
|---|---|---|---|
q | string? | — | Free-text search (trimmed, min 1). Case-insensitive substring match across the product's title, slug, subtitle and description, plus any non-deleted variant's sku, barcode, ean or upc. Identical field set on GET /admin/products |
searchValue + searchField | string? | — | Legacy single-column search (title or slug); prefer q |
sortBy | "title" | "createdAt" | "updatedAt" | "publishedAt"? | updatedAt | — |
sortDirection | "asc" | "desc" | "desc" | — |
filterField / filterValue, filters | — | — | status, visibility, brandId, primaryCategoryId |
limit | int | 100 | 1..500 |
offset | int | 0 | >= 0 |
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, resolved categoryIds[] / tagIds[] / ingredientIds[], the attributeGroupId, and attributeAssignments[] ({ id, attributeId, attributeValueId, value, sortOrder }) for rehydrating the attribute form.
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": [],
"attributeGroupId": "01J9...",
"attributeAssignments": [
{ "attributeId": "01J9...", "attributeValueId": "01J9..." }
]
}When present, attributeAssignments[] whole-set replaces the product's attribute links (same shape and validation as create).
Response 200 — Product (the product row only). Emits vendor.product.basics.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 — Product (the product row only). Emits vendor.product.media.updated.
PUT /vendor/products/:id/options — Replace product options
Whole-set replace of options[] (and each option's values[]). Existing variants whose optionValues[] 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 — { options: ProductOption[], values: ProductOptionValue[] } — the replaced option set, not the full product. Emits vendor.product.options.replaced.
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 — incl. attributeGroupId, attributeAssignments[] */ },
"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 (the full nested shape, including each variant's inventory snapshot). Emits vendor.product.synced.
DELETE /vendor/products/:id — Soft delete
Sets deletedAt. Reversible via support tooling — there is no public restore endpoint on the vendor surface.
Response 200 — Product (the soft-deleted row). Emits vendor.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.
There is no single "product updated" event — each write emits its own name.
| Event | Fired when |
|---|---|
vendor.product.created | POST /vendor/products |
vendor.product.basics.updated | PATCH /vendor/products/:id/basics |
vendor.product.media.updated | PATCH /vendor/products/:id/media |
vendor.product.options.replaced | PUT /vendor/products/:id/options |
vendor.product.synced | PUT /vendor/products/:id/sync |
vendor.product.deleted | DELETE /vendor/products/:id |
vendor.product.variant.created / .updated / .deleted | Variant writes |
vendor.product.variants.reordered | PUT /vendor/products/:productId/variants/reorder |
vendor.product.tab.created / .updated / .deleted | Tab writes |
vendor.product.tabs.reordered | PUT /vendor/products/:productId/tabs/reorder |
catalog.request.approved / .rejected | An admin resolves a vendor taxonomy request (submission itself emits nothing) |
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.
Catalog Import & Export — Vendor
Vendor-scoped mirror of the product import pipeline and product export, locked to the session vendor so a supplier can bring their own catalog in and take it back out.
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.