Catalog Module — Admin
HTTP surface for platform-admin reads of products + variants and full CRUD over the platform-wide taxonomy (brands, categories, tags, ingredients), with a vendor-request approval…
HTTP surface for platform-admin reads of products + variants and full CRUD over the platform-wide taxonomy (brands, categories, tags, ingredients), with a vendor-request approval workflow per taxonomy. Vendor-side product/variant/tab CRUD and the public storefront reads live in the same module but are out of scope here — see vendor/catalog.md and store/catalog.md.
Source:
api-modules/catalog/src/controllers/admin-product.controller.ts,api-modules/catalog/src/controllers/admin-variant.controller.ts,api-modules/catalog/src/controllers/admin-brand.controller.ts,api-modules/catalog/src/controllers/admin-category.controller.ts,api-modules/catalog/src/controllers/admin-tag.controller.ts,api-modules/catalog/src/controllers/admin-ingredient.controller.ts.HSN classification lives on
product_variant, notproduct— variants in the same family can have different HSN codes. The admin product detail endpoint surfaces both the informational product-levelhsCodeand the canonical per-varianthsnCode.
Conventions
Authentication
All endpoints require a Better-Auth admin session and a role granting the matching permission.
| Endpoint group | Permission |
|---|---|
GET /admin/products, GET /admin/products/:id/detail, GET /admin/variants | product: view |
GET /admin/catalog/brands/** | brand: read |
POST /admin/catalog/brands | brand: create |
PUT /admin/catalog/brands/:id, POST /admin/catalog/brands/:id/restore | brand: update |
DELETE /admin/catalog/brands/:id | brand: delete |
POST /admin/catalog/brands/requests/:id/approve|reject | brand: approve |
/admin/catalog/categories/** | category:* (same shape as brands) |
/admin/catalog/tags/** | tag:* (same shape) |
/admin/catalog/ingredients/** | ingredient:* (same shape) |
Response envelope
Successful responses are wrapped by ResponseInterceptor:
{
"data": <payload>,
"message": "Success",
"statusCode": 200,
"metadata": { /* optional, e.g. pagination */ }
}Some admin list endpoints return a picker envelope (items[] + pinned[]) — see Picker responses.
Error envelope
statusCode | errorCode examples |
|---|---|
| 400 | BAD_REQUEST, VALIDATION_ERROR |
| 401 | UNAUTHORIZED |
| 403 | FORBIDDEN |
| 404 | NOT_FOUND |
| 409 | CONFLICT, UNIQUE_VIOLATION (slug collision), FOREIGN_KEY_VIOLATION |
| 500 | INTERNAL_SERVER_ERROR, DATABASE_ERROR |
Lifecycle / soft-delete
Brands, categories, tags, ingredients, products, and variants all soft-delete via deletedAt. DELETE flips deletedAt; POST /:id/restore clears it. Slugs are unique among non-deleted rows — soft-deleting frees the slug for re-use; restoring may then fail with UNIQUE_VIOLATION if the slug has been taken in the meantime.
Currency
Variant price / specialPrice fields are integer subunits (paise / cents). Single currency per tenant.
Domain types
Taxonomy item — CatalogItemResponse
Shared by brand / category / tag / ingredient.
type CatalogItemResponse = {
id: string;
title: string;
description: string | null;
slug: string; // lowercase alphanumeric + hyphens
image: string | null;
metadata: Record<string, unknown> | null;
isActive: boolean;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
};AdminCatalogItemResponse
Admin list-only variant adds an aggregated bannerCount 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 };AdminVariantListItem (variant picker)
type AdminVariantListItem = {
id: string;
productId: string;
productTitle: string;
sku: string | null;
thumbnail: string | null;
price: number | null; // subunits
};Catalog request
Vendor-submitted taxonomy proposals.
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
};
type CategoryRequest = CatalogRequest & { parentId: string | null };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": { "total": 412, "items": 20, "perPage": 20, "currentPage": 1, "lastPage": 21 }
}When selectedIds is absent, pinned is [].
Products
Base path: /admin/products. Reads only — vendors own product writes.
GET /admin/products — List products
Required permission: product: view. Cross-vendor list with filters and full-text search.
Query
| Name | Type | Default | Notes |
|---|---|---|---|
q | string? | — | Free-text search (trimmed, min 1) |
vendorId | string? | — | Filter to one vendor |
brandId | string? | — | Filter to one brand |
primaryCategoryId | string? | — | Filter on the primary category |
categoryId | string? | — | Match any of the product's categories |
tagId | string? | — | Match any of the product's tags |
ingredientId | string? | — | Match any ingredient |
status | "draft" | "active" | "archived"? | — | Product status |
visibility | "public" | "private"? | — | Product visibility |
createdFrom, createdTo | ISO datetime? | — | createdAt range |
publishedFrom, publishedTo | ISO datetime? | — | publishedAt range |
sortBy | "createdAt" | "updatedAt" | "publishedAt" | "title" | "vendorName" | "updatedAt" | — |
sortDirection | "asc" | "desc" | "desc" | — |
limit | int | 100 | 1..500 |
offset | int | 0 | >= 0 |
selectedIds | csv or string[]? | — | Pre-pin rows for the picker envelope |
Response 200 — picker envelope. Items shape is the service-defined admin product list row (vendor info, brand, status, etc.). Detail-shaped via GET /:id/detail.
GET /admin/products/:id/detail — Product detail
Required permission: product: view. Returns the product with vendor info, options, variants, tabs, and full taxonomy joins. Same shape the vendor product detail endpoint returns.
Response 200 — ProductDetail (vendor info + options + variants + tabs + categories/tags/ingredients arrays).
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Unknown product id |
Variants — cross-product picker
GET /admin/variants — Variant picker
Required permission: product: view. Flat list across every vendor's products. Standard QueryDto (page / limit / search / selectedIds). Search runs across product title and variant SKU.
Response 200 — picker envelope of AdminVariantListItem[]. See Picker responses.
Taxonomy CRUD (brand / category / tag / ingredient)
The four taxonomies share an almost-identical CRUD surface. Brand is the canonical example; per-taxonomy variations are listed at the end.
Base paths:
| Taxonomy | Base path |
|---|---|
| Brand | /admin/catalog/brands |
| Category | /admin/catalog/categories |
| Tag | /admin/catalog/tags |
| Ingredient | /admin/catalog/ingredients |
Per-taxonomy permissions:
| Resource | Read | Create | Update | Delete | Approve |
|---|---|---|---|---|---|
brand | brand: read | brand: create | brand: update | brand: delete | brand: approve |
category | category: read | category: create | category: update | category: delete | category: approve |
tag | tag: read | tag: create | tag: update | tag: delete | tag: approve |
ingredient | ingredient: read | ingredient: create | ingredient: update | ingredient: delete | ingredient: approve |
GET /admin/catalog/<taxonomy> — List
Required permission: read. Standard QueryDto (page / limit / search / filters[] / selectedIds).
Response 200 — picker envelope of AdminCatalogItemResponse[] (items + pinned, with bannerCount).
GET /admin/catalog/categories/tree — Category tree (category only)
Required permission: category: read. Returns the full nested category tree (parent → children).
Response 200 — nested CatalogItemResponse[].
GET /admin/catalog/<taxonomy>/:id — Get one
Required permission: read.
Response 200 — CatalogItemResponse.
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Unknown id |
POST /admin/catalog/<taxonomy> — Create
Required permission: create.
Body (brand/tag/ingredient)
{
"title": "L'Oréal",
"description": "...", // optional, max 2000
"slug": "loreal",
"image": "brands/loreal.png", // optional
"metadata": { "country": "FR" }, // optional
"isActive": true
}| Field | Type | Constraints |
|---|---|---|
title | string | 1..255 |
description | string | null? | max 2000 |
slug | string | 1..255, lowercase alnum + hyphens (/^[a-z0-9]+(?:-[a-z0-9]+)*$/) |
image | string | null? | Storage key (free-form) |
metadata | Record<string, unknown> | null? | Free-form |
isActive | boolean | Default true |
Category-only extras
| Field | Type | Notes |
|---|---|---|
parentId | string | null? | Parent category (omit/null for a root) |
sortOrder | int | >= 0. Default 0 |
Response 201 — CatalogItemResponse.
Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR | Body fails zod |
| 409 | UNIQUE_VIOLATION | Slug already in use by a non-deleted row |
PUT /admin/catalog/<taxonomy>/:id — Update
Required permission: update. Partial — every field is optional.
Response 200 — CatalogItemResponse.
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Unknown id |
| 409 | UNIQUE_VIOLATION | New slug collides with a non-deleted row |
DELETE /admin/catalog/<taxonomy>/:id — Soft-delete
Required permission: delete.
Response 200 — the deleted CatalogItemResponse.
POST /admin/catalog/<taxonomy>/:id/restore — Restore
Required permission: update.
Response 200 — restored CatalogItemResponse.
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Unknown id |
| 409 | UNIQUE_VIOLATION | Original slug has been re-used while soft-deleted |
Vendor catalog request approvals
Vendors submit new-taxonomy proposals via the vendor-side endpoints; admins review them through these endpoints.
Base path: /admin/catalog/<taxonomy>/requests. Same taxonomy table above.
GET /admin/catalog/<taxonomy>/requests/all — List requests
Required permission: read. Standard QueryDto.
Response 200 — paginated envelope of CatalogRequest[].
GET /admin/catalog/<taxonomy>/requests/:id — Get a request
Required permission: read.
Response 200 — CatalogRequest.
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Unknown id |
POST /admin/catalog/<taxonomy>/requests/:id/approve — Approve
Required permission: approve. Transitions the request to approved, creates the canonical taxonomy row from the request's fields, and stamps approvedAt + resultingItemId. The request session's user is recorded as the approver.
Response 200 — the approved CatalogRequest (with resultingItemId populated).
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Request not found or not in pending |
POST /admin/catalog/<taxonomy>/requests/:id/reject — Reject
Required permission: approve.
Body
{ "reason": "Duplicate of brand 'Existing Brand'" }| Field | Type | Constraints |
|---|---|---|
reason | string | 1..2000 |
Response 200 — the rejected CatalogRequest.
Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR | Empty / oversized reason |
| 404 | NOT_FOUND | Request not found or not in pending |
Domain events
Emitted via EventEmitter2 — consumed by the search reindexer and the cart price-cache invalidator.
| Event | Fired when |
|---|---|
catalog.brand.created / .updated / .deleted | Admin brand writes |
catalog.category.*, .tag.*, .ingredient.* | Admin taxonomy writes |
catalog.request.approved / .rejected | Admin approval flow |
Related modules
admin-rbac— gates every endpoint via the per-taxonomy permission strings above. Seeadmin-rbac.md.banner— banner rows reference the four taxonomy tables;bannerCounton the admin list comes from this join. Seebanner.md.search— consumes thecatalog.*events to keep the product index in sync.order— order line items snapshothsnCodefrom the variant. Seeorder.md.
Cart Module — Admin
HTTP surface for platform-admin oversight of carts: paginated browse + detail view (priced, vendor-allocated), force-discard, manual reservation release, and a suite of analytics…
Customer Module — Admin
HTTP surface for the admin-only customer picker. The customer's storefront address book lives in the same module but is out of scope here (see store/customer.md). The picker is…