Products API Reference
Complete API contracts for the Products module, including routes, auth, DTOs, responses, errors, examples, and integration notes.
Products - API Reference
Audience: Frontend engineers, mobile engineers, backend engineers, QA, and API consumers. Scope: Admin, storefront and job-facing APIs owned by the Products module, plus the tag surface owned by catalog.
1. Documentation Evidence
| Area | Files Inspected | What Was Verified |
|---|---|---|
| Controllers | admin/product/product-admin.controller.ts, product-admin-bulk.controller.ts, admin/job/product-job-admin.controller.ts, customer/product/product-customer.controller.ts, customer/discovery/product-discovery-customer.controller.ts, catalog/{admin,customer}/tag/*.controller.ts | Routes, methods, guards, decorators, status codes, route ordering |
| DTOs | dto/*.ts under each leaf | Request, query, response, validation, examples |
| Services | product-write.service.ts, product-write-facets.service.ts, product-bulk.service.ts, product-admin.service.ts, product-customer.service.ts, product-discovery-customer.service.ts, product-job-admin.service.ts, catalog-tag-*.service.ts | Behavior, side effects, response mapping, errors |
| Schema | packages/db/src/schema/products/*.ts, packages/db/src/schema/catalog/tag.ts | IDs, enums, persisted fields, constraints |
| Jobs/cache | packages/jobs/src/index.ts, cache-invalidation.tags.ts | Queue names, payloads, cache domains |
| Error registry | apps/api/src/common/types/error-codes.ts | PRODUCT_*, TAG_*, PAGINATION_*, INVENTORY_* codes |
2. Module Summary
| Field | Value |
|---|---|
| Module name | products (tags owned by catalog) |
| Module slug | products |
| Primary actors | guest, admin, worker, internal system, blog author |
| API surfaces | admin, mobile (storefront) |
| Base route prefixes | /api/admin/products, /api/mobile/products, /api/admin/catalog/tags, /api/mobile/catalog/tags |
| Auth model | @Public() storefront; JwtAuthGuard + RoleGuard admin |
| Persistence | PostgreSQL (product, product_slug, product_tag_link, blog_post_product, tag), Redis (cache), BullMQ via outbox |
| Runtime source of truth | PostgreSQL tables |
| Sibling docs | Backend, Features and flows |
3. Concepts and Terminology
| Term | Meaning | Source File | Used By |
|---|---|---|---|
publicId | The exposed UUID v7 identifier of a product/tag. The integer PK never leaves the service layer | schema | All routes |
slug | Route-safe URL key owned by product_slug; current + retired under one unique | slug-ownership.service.ts | Storefront :slug routes |
canonicalSlug | The current slug returned when a request resolved via a retired one | customer service | Detail responses |
status | Lifecycle: draft | published | unlisted | archived | product-status enum | Lifecycle routes, filters |
stockStatus | in_stock | out_of_stock | low_stock | pre_order | product-stock-status enum | Stock routes, filters |
version | Optimistic lock counter; every write bumps it | product schema | All admin mutations |
cursor | Opaque keyset pagination token on (sortKey, id) | compound-cursor.util.ts | Storefront lists, feeds |
inventory group | Reserved null before inventory landed; now populated by the inventory module | product-response.builder.ts | Product responses |
promotions | Reserved null — additive module | product-response.builder.ts | Product responses |
Idempotency-Key | Required header on creates/bulk/job submits | idempotency.interceptor.ts | Mutating routes |
4. API Surface Map
| Surface | Method | Path | Actor | Auth/Guard | Permission | Controller | Purpose |
|---|---|---|---|---|---|---|---|
| Admin | GET | /api/admin/products | Admin | JWT+Role | Products_READ | ProductAdminController | Offset-paginated admin list |
| Admin | GET | /api/admin/products/:publicId | Admin | JWT+Role | Products_READ | ProductAdminController | Admin detail |
| Admin | POST | /api/admin/products | Admin | JWT+Role | Products_CREATE | ProductAdminController | Create (201) |
| Admin | PATCH | /api/admin/products/:publicId | Admin | JWT+Role | Products_UPDATE | ProductAdminController | General update |
| Admin | PATCH | /api/admin/products/:publicId/lifecycle | Admin | JWT+Role | Products_UPDATE | ProductAdminController | Lifecycle transition |
| Admin | PATCH | /api/admin/products/:publicId/stock | Admin | JWT+Role | Products_UPDATE | ProductAdminController | Stock status (narrowed for tracked) |
| Admin | PATCH | /api/admin/products/:publicId/pricing | Admin | JWT+Role | Products_UPDATE | ProductAdminController | Pricing |
| Admin | PATCH | /api/admin/products/:publicId/media | Admin | JWT+Role | Products_UPDATE | ProductAdminController | Media |
| Admin | PATCH | /api/admin/products/:publicId/seo | Admin | JWT+Role | Products_UPDATE | ProductAdminController | SEO |
| Admin | PATCH | /api/admin/products/:publicId/discovery | Admin | JWT+Role | Products_UPDATE | ProductAdminController | Discovery flags |
| Admin | PATCH | /api/admin/products/:publicId/tags | Admin | JWT+Role | Products_UPDATE | ProductAdminController | Tag membership replace |
| Admin | DELETE | /api/admin/products/:publicId | Admin | JWT+Role | Products_DELETE | ProductAdminController | Soft delete (200, message-only) |
| Admin | POST | /api/admin/products/:publicId/restore | Admin | JWT+Role | Products_RESTORE | ProductAdminController | Restore (200) |
| Admin | POST | /api/admin/products/bulk/delete | Admin | JWT+Role | Products_DELETE | ProductAdminBulkController | Bulk soft delete |
| Admin | POST | /api/admin/products/bulk/restore | Admin | JWT+Role | Products_RESTORE | ProductAdminBulkController | Bulk restore |
| Admin | POST | /api/admin/products/bulk/lifecycle | Admin | JWT+Role | Products_UPDATE | ProductAdminBulkController | Bulk lifecycle |
| Admin | POST | /api/admin/products/bulk/discovery | Admin | JWT+Role | Products_UPDATE | ProductAdminBulkController | Bulk discovery flags |
| Admin | POST | /api/admin/products/jobs/import | Admin | JWT+Role | Products_CREATE + service pair | ProductJobAdminController | Import job |
| Admin | POST | /api/admin/products/jobs/export | Admin | JWT+Role | Products_READ | ProductJobAdminController | Export job |
| Admin | POST | /api/admin/products/jobs/:publicId/cancel | Admin | JWT+Role | Products_UPDATE | ProductJobAdminController | Cancel job |
| Admin | GET | /api/admin/products/jobs | Admin | JWT+Role | Products_READ | ProductJobAdminController | Job list |
| Admin | GET | /api/admin/products/jobs/:publicId | Admin | JWT+Role | Products_READ | ProductJobAdminController | Job detail |
| Admin | GET | /api/admin/catalog/tags | Admin | JWT+Role | Tags_READ | CatalogTagAdminController | Tag list |
| Admin | GET | /api/admin/catalog/tags/:publicId | Admin | JWT+Role | Tags_READ | CatalogTagAdminController | Tag detail |
| Admin | POST | /api/admin/catalog/tags | Admin | JWT+Role | Tags_CREATE | CatalogTagAdminController | Create tag (201) |
| Admin | PATCH | /api/admin/catalog/tags/:publicId | Admin | JWT+Role | Tags_UPDATE | CatalogTagAdminController | Update tag |
| Admin | PATCH | /api/admin/catalog/tags/:publicId/visibility | Admin | JWT+Role | Tags_UPDATE | CatalogTagAdminController | Visibility |
| Admin | DELETE | /api/admin/catalog/tags/:publicId | Admin | JWT+Role | Tags_DELETE | CatalogTagAdminController | Soft delete (200, message-only) |
| Admin | POST | /api/admin/catalog/tags/:publicId/restore | Admin | JWT+Role | Tags_RESTORE | CatalogTagAdminController | Restore (200) |
| Mobile | GET | /api/mobile/products | Guest | @Public() | — | ProductCustomerController | Filtered list + cursor |
| Mobile | GET | /api/mobile/products/search | Guest | @Public() | — | ProductCustomerController | Search + filters + cursor |
| Mobile | GET | /api/mobile/products/suggestions | Guest | @Public() | — | ProductCustomerController | Autocomplete (≤10) |
| Mobile | GET | /api/mobile/products/:slug | Guest | @Public() | — | ProductCustomerController | Detail by slug |
| Mobile | GET | /api/mobile/products/discovery/:feed | Guest | @Public() | — | ProductDiscoveryCustomerController | Discovery feeds |
| Mobile | GET | /api/mobile/catalog/tags | Guest | @Public() | — | CatalogTagCustomerController | Visible tags |
| Mobile | GET | /api/mobile/catalog/tags/:slug | Guest | @Public() | — | CatalogTagCustomerController | Tag by slug |
Route ordering is load-bearing: literal segments (search, suggestions, bulk, import, export) are declared before :slug/:publicId routes, or the param routes swallow them (verified live: unauthenticated bulk returns 401, not 400).
5. Auth, Identity, and Permissions
| Surface | Guard/Decorator | Identity Shape | Permission | Guest Allowed | Notes |
|---|---|---|---|---|---|
| Storefront | @Public() + IpThrottlerGuard | None | N/A | Yes | Rate limits: PUBLIC_READ 60/min, PUBLIC_SEARCH 60/min (ip+device), PUBLIC_SUGGEST 300/min (ip+device), PUBLIC_HIGH_FREQUENCY 300/min (feeds) |
| Admin | JwtAuthGuard, RoleGuard | req.user (id, role) | Products_* / Tags_* | No | superadmin bypasses; permissions resolved via RoleService.getPermissionsForRoleName |
| Import submit | route Products_CREATE + service re-check | req.user | Products_CREATE and Products_UPDATE | No | 403 PRODUCT_JOB_ENTITY_PERMISSION_DENIED without the pair |
Idempotency (Idempotency-Key header): create (product), restore (product-restore), bulk (product-bulk), job import (product-job-import), job export (product-job-export). Missing → 400 IDEMPOTENCY_KEY_REQUIRED; different payload same key → 409 IDEMPOTENCY_KEY_CONFLICT; in-flight → 409 IDEMPOTENCY_REQUEST_IN_PROGRESS.
6. DTO and Model Reference
6.1 CreateProductDto (body of POST /api/admin/products)
| Field | Type | Required | Default | Validation | Example | Source |
|---|---|---|---|---|---|---|
name | string | Yes | N/A | @IsString, 1..255 | "Grey Linen Sofa" | create-product.dto.ts |
sku | string | No | NULL | max 64 | "SKU-0001" | |
slug | string | No | generated | slug regex, max 260 | "grey-linen-sofa" | |
shortDescription | string | No | NULL | max 500 | "A three-seater in grey linen" | |
description | string | No | NULL | — | "Full description..." | |
categoryPublicId | UUID v7 | Yes | N/A | @IsUUID("7") | 018f4e2a-… | |
brandPublicId | UUID v7 | No | NULL | @IsUUID("7") | 018f4e2a-… | |
brandSeriesPublicId | UUID v7 | No | NULL | @IsUUID("7") | 018f4e2a-… | series must match brand |
mrp | number | Yes | N/A | @IsInt, >= 0, <= MAX_SAFE_INTEGER | 100000 | minor units |
sellingPrice | number | Yes | N/A | same | 80000 | minor units; <= mrp |
version | number | No | N/A | >= 1 | 1 | optimistic lock |
6.2 Facet PATCH DTOs
| DTO | Fields | Validation |
|---|---|---|
UpdateProductLifecycleDto | status (enum), version | @IsIn(productStatusEnum) |
UpdateProductStockDto | stockStatus (enum), version | @IsIn(productStockStatusEnum) |
UpdateProductPricingDto | mrp, sellingPrice, version | minor units; sellingPrice <= mrp |
UpdateProductMediaDto | thumbnailKey, thumbnailAlt, gallery[] (key/alt/order), videos[], attachments[] | caps 30/10/20; alt requires key; each videos[] item sets exactly one of key (storage key, <video src>) or embedUrl (absolute https:// on the host allowlist, <iframe>) — neither or both is 400 PRODUCT_VIDEO_SOURCE_REQUIRED / PRODUCT_VIDEO_SOURCE_CONFLICT. Allowlist: www.youtube.com, www.youtube-nocookie.com, player.vimeo.com — youtu.be is not accepted |
UpdateProductSeoDto | SEO metadata fields | — |
UpdateProductDiscoveryDto | featured, trending, bestSeller, newArrival | booleans |
UpdateProductTagsDto | tagPublicIds[] | max 25 tags |
6.3 Bulk DTOs
ProductBulkIdsDto { publicIds: string[] } (non-empty, @IsUUID("7", { each: true })); ProductBulkLifecycleDto adds status; ProductBulkDiscoveryDto adds the four flags. Result: { succeeded: string[], failures: [{ publicId, errorCode }] }.
6.4 Storefront query DTOs
| DTO | Fields |
|---|---|
ProductStorefrontFilterDto (base) | categorySlug, brandSlug, seriesSlug, tags[], stockStatus[], minPrice, maxPrice, onSale, featured, trending, bestSeller, newArrival, q (max 100), cursor, limit (1..100, default 20) — onSale=true is a union of catalogue markdown and any product covered by a live special-deal campaign, not a single price comparison; see Backend §9.1 |
FetchProductsDto | base + sort in newest | priceAsc | priceDesc | discount (default newest) |
SearchProductsDto | base + sort in list sorts + relevance |
FetchProductDiscoveryDto | cursor, limit only |
ProductSuggestionsQueryDto | q, scoping param |
7. Enum Reference
| Enum | Value | Meaning | Runtime Effect | Source |
|---|---|---|---|---|
product_status | draft | Not customer-visible | Excluded from every storefront read | enums.ts |
product_status | published | In listings, search, feeds; indexable | Only status with robots_index = true | |
product_status | unlisted | Direct URL only | Detail-only; not indexable; purchasable | |
product_status | archived | Retired, admin-visible, read-only | Only transition out: → draft | |
product_stock_status | in_stock / out_of_stock / low_stock / pre_order | Availability projection | pre_order narrowing via inventory rules | |
product_job_kind | import / export | Job kind | Permissions differ | |
product_job_status | queued / processing / completed / failed / cancelled | Job lifecycle | Lease claim, cooperative cancel |
8. Endpoint Reference
8.1 GET /api/mobile/products
Purpose
The storefront listing. Called for category/brand landing pages, tag pages and filtered browsing; paginate with nextCursor.
Source Evidence
| Evidence | Path |
|---|---|
| Controller | customer/product/product-customer.controller.ts |
| DTO | customer/product/dto/fetch-products.dto.ts, product-storefront-filter.dto.ts, shared/product-card-response.dto.ts |
| Service | shared/product-storefront-query.service.ts, product-storefront-filter-resolver.service.ts, shared/product-card-assembler.service.ts |
| Schema | packages/db/src/schema/products/product.ts |
Auth and Permissions
- Auth: none —
@Public() - Guard chain:
IpThrottlerGuard - Permission: N/A
- Guest support: yes
- Rate limit:
PUBLIC_READ60/min - Idempotency: N/A (GET)
Request
| Part | Required | Details |
|---|---|---|
| Query | No | Any subset of the filter DTO + sort + cursor + limit |
Response
200 — envelope with data (product card array), errorCode: null, nextCursor (string or null).
This route returns the card shape, not the detail shape. Every listing surface — this route, /search and /discovery/:feed — is a grid, and a grid renders a thumbnail, a name, a price, a brand and a stock badge. Sending the detail response per row shipped a gallery, videos, attachments, specifications, SEO metadata and tags for products nobody had opened, and cost six extra database reads per page plus one signed storage URL per media item rather than per product.
Every field a card does carry keeps the exact path and meaning it has on GET /:slug, so one renderer serves both — pricing.sellingPrice and media.thumbnail.url mean the same thing on either response. ProductCardShape is declared as a Pick<> over the detail shape, so renaming or removing a detail member breaks the card type at compile time — but a member added to the detail shape and never picked fails nothing, which is the one kind of drift the Pick<> tie does not catch.
The pricing block, since variants and special deals
mrp and sellingPrice are rollups — the LOWEST across the product's live, active variants.
They are what every price index and every sort reads, and they are the "from Rs X" a card renders.
maxSellingPrice is the upper bound, isPriceRange is maxSellingPrice > sellingPrice, and
variantCount is 1 when the storefront should render no picker.
| Field | Render it as |
|---|---|
effectivePrice | The price. What the customer pays |
sellingPrice | The struck-through "was" figure — only when deal is non-null |
deal.effectiveMaxPrice | The upper end of the band, when isPriceRange |
deal.discountBps | A badge. 2000 is 20%. Never arithmetic |
variants[] — DETAIL ONLY
GET /api/mobile/products/{slug} carries the configurations the customer may choose between,
cheapest position first. A listing card does not: twenty cards times each product's variant count
would multiply the hottest read in the system to populate a picker no card renders, and
pricing.variantCount is the one bit a card needs — it decides between "Add to cart" and
"View options".
| Field | Render it as |
|---|---|
id | product_variant.public_id. Send this as variantPublicId on an add, and use it as the path segment of PUT/DELETE /cart/items/{id} |
name | The option label. null means the variant IS the product — render the product name alone |
isDefault | Which one an add naming no variant resolves to. Open the picker on it |
pricing.effectivePrice | What choosing THIS configuration costs. Never product.pricing.sellingPrice, which is the rollup |
pricing.mrp | The variant's own struck-through figure |
availability.inStock | false renders the option disabled, not hidden — the customer may still want to see that it exists |
availability.availableQuantity | Units purchasable. null when untracked — never 0 |
Withdrawn and soft-deleted variants are absent, not disabled: they are no longer offered at all. Always at least one entry for a live product; an empty array means every configuration has been withdrawn and the product is unbuyable.
"variants": [
{
"id": "019fc692-…-00aa",
"name": "512GB / Blue",
"sku": "iphone-15-512-blue",
"isDefault": false,
"pricing": {
"mrp": 10000000, "sellingPrice": 9000000, "effectivePrice": 8100000,
"currency": "NPR",
"deal": { "basePrice": 9000000, "effectivePrice": 8100000, "saving": 900000, "discountBps": 1000 }
},
"availability": { "inStock": true, "availableQuantity": 9 }
}
]All money is integer minor units (paisa), as everywhere else on this surface. A consumer divides by 100 exactly once, at its own boundary.
deal is null when no campaign covers the product, and effectivePrice then equals
sellingPrice. A campaign is applied at READ time and never written to a price column, so
cancelling one takes effect immediately.
Three rules, and each of them has a failure mode behind it:
- Never total or charge from
sellingPrice. It is the cheapest variant's price. On a cart line the charged figure ispricing.unitPrice, which is that line's own variant. saving + effectivePrice === basePriceexactly. The backend computes the saving and subtracts it precisely so a receipt's two numbers reconstruct the original. Recomputing either fromdiscountBpsdiffers by a paisa at rounding boundaries.- Render the band as
effectivePrice–deal.effectiveMaxPrice. Pairing the discounted floor with the undiscountedmaxSellingPriceshows a top figure nobody is charged.
A campaign does not reorder a price-sorted listing — sorting reads the rollup, because it needs an index. A stated, accepted limitation.
For the gallery, specifications, tags, SEO, description or timestamps, read the detail route (8.4).
{
"message": "Products fetched successfully",
"data": [
{
"basic": { "id": "018f4e2a-7b3c-7c1e-9b2a-3d4e5f6a7b8f", "name": "Grey Linen Sofa", "slug": "grey-linen-sofa" },
"pricing": {
"mrp": 100000, "sellingPrice": 80000, "maxSellingPrice": 120000,
"isPriceRange": true, "variantCount": 3,
"discount": 20000, "discountPercentage": 20, "currency": "NPR",
"effectivePrice": 64000,
"deal": { "basePrice": 80000, "effectivePrice": 64000, "saving": 16000, "discountBps": 2000, "effectiveMaxPrice": 96000 }
},
"classification": { "brand": { "publicId": "018f4e2a-…", "name": "Acme Furniture", "slug": "acme-furniture" } },
"media": { "thumbnail": { "url": "/public/sofa.jpg", "alt": "a grey sofa", "expiresAt": null } },
"status": { "lifecycle": "published", "saleStatus": "on_sale", "stockStatus": "in_stock", "purchasable": true },
"inventory": { "stockStatus": "low_stock", "isLowStock": true, "isOutOfStock": false, "isTracked": true, "available": 3 }
}
],
"errorCode": null,
"nextCursor": "eyJzb3J0IjoibmV3ZXN0IiwidmFsdWUiOiIxNzQ2..."
}Notes on the fields that are easy to misread:
status.purchasableis the single owner of "can this be bought". Do not re-derive it frominventory— that answers how many there are, which is a different question, and isnullfor a product with no inventory row.classification.brandis the only classification a card carries, and it isnullfor a product with no brand. Category, brand series and tags are detail-response fields.status.visibilityis absent — it is derived fromlifecycle, and a listing only ever emitspublishedrows.ratingis absent, deliberately. A rating per card is a join per page. Ask the Reviews module for the summaries a page needs in one call.media.thumbnailfalls back to the lowest-ordergallery image when the product has no explicit thumbnail. The rest of the gallery is neither included nor signed.
Side Effects
None — read-only; Redis cache on the resolved key.
Error Cases
| HTTP | Code | Condition |
|---|---|---|
| 400 | PAGINATION_CURSOR_INVALID | Cursor minted by a different sort |
| 404 | category/brand/series not-found | Unknown classification slug |
Example Requests
GET /api/mobile/products?categorySlug=sofas&tags=best-seller&sort=priceAsc&limit=20 HTTP/1.18.2 GET /api/mobile/products/search
Same contract as 8.1 — including the card shape — plus sort=relevance; term < 2 chars is treated as no search. Rate limit PUBLIC_SEARCH 60/min. Ranking is similarity(name, term) DESC, id DESC.
8.3 GET /api/mobile/products/suggestions
200 with at most 10 items { entityType-less: publicId, name, slug }; no pagination. Rate limit PUBLIC_SUGGEST 300/min.
8.4 GET /api/mobile/products/:slug
Purpose
Product detail. Accepts a current or retired slug; unlisted products resolve here only.
Auth and Permissions
@Public(), PUBLIC_READ 60/min.
Response
200 — single grouped product, the full detail shape (customer surface: no version, no metadata). basic.slug is always the current slug.
This is the only storefront product route that returns the detail shape, and deliberately so: a product page renders the gallery, the specifications and the SEO block. It also carries rating and canonicalSlug, neither of which appears on a listing.
Error Cases
| HTTP | Code | Condition |
|---|---|---|
| 404 | PRODUCT_NOT_FOUND | Missing, hidden, deleted, draft or archived — on both slug branches |
Edge Cases
Retired slug → 200 with current slug (frontend redirects; API does not 3xx). Hidden/deleted → 404. unlisted → 200 here, absent everywhere else.
8.5 GET /api/mobile/products/discovery/:feed
feed in featured | trending | best-seller | new-arrival | on-sale. 200 with cursor-paginated items in the same card shape as 8.1; rate limit PUBLIC_HIGH_FREQUENCY 300/min. Unknown feed → 400 PRODUCT_INVALID_DISCOVERY_FEED. The feed itself is the filter — only cursor/limit query params.
The discovery flags themselves are not on the response. The feed you called already says which one is set, and no grid renders the other three.
8.6 POST /api/admin/products
Purpose
Create a product as draft with an inventory row, slug and SEO link in one transaction.
Auth and Permissions
- Auth: bearer token
- Guard chain:
JwtAuthGuard,RoleGuard,IpThrottlerGuard - Permission:
Products_CREATE - Rate limit:
ADMIN_WRITE10/min - Idempotency:
Idempotency-Key(scopeproduct)
Request
Body per §6.1 (minimal: { "name", "categoryPublicId", "mrp", "sellingPrice" }).
Response
201 — envelope with the grouped product (admin surface: version, metadata).
Side Effects
product + product_slug + inventory (untracked) + optional seo rows in one transaction; activity record; cache invalidation (product/tag/inventory domains).
Error Cases
| HTTP | Code | Condition |
|---|---|---|
| 400 | IDEMPOTENCY_KEY_REQUIRED | Missing header |
| 404 | PRODUCT_CATEGORY_NOT_FOUND / PRODUCT_BRAND_NOT_FOUND / PRODUCT_SERIES_NOT_FOUND | Unknown classification reference |
| 409 | PRODUCT_SKU_ALREADY_EXISTS | Live row holds the SKU |
| 409 | PRODUCT_SERIES_BRAND_MISMATCH | Series belongs to a different brand |
| 409 | PRODUCT_SERIES_REQUIRES_BRAND | Series without brand |
| 409 | IDEMPOTENCY_KEY_CONFLICT / IDEMPOTENCY_REQUEST_IN_PROGRESS | Key misuse |
8.7 PATCH /api/admin/products/:publicId
General update; cannot change lifecycle/stock/pricing/media/seo/discovery/tags (each has its own endpoint). 409 PRODUCT_VERSION_CONFLICT on stale version; 409 PRODUCT_ALREADY_DELETED on a deleted row; 409 PRODUCT_ARCHIVED_NOT_EDITABLE while archived. 200 with grouped response.
8.8 PATCH /api/admin/products/:publicId/lifecycle
See Features and flows §7. Errors: 409 PRODUCT_INVALID_STATUS_TRANSITION, PRODUCT_THUMBNAIL_REQUIRED_TO_PUBLISH, PRODUCT_ARCHIVED_NOT_EDITABLE, PRODUCT_VERSION_CONFLICT. Side effects: product.status, seo.robots_index, activity, cache.
8.9 PATCH /api/admin/products/:publicId/stock
Purpose
Set the product stock status. Narrowed for tracked products — the column belongs to inventory once tracking is on.
Request
{ "stockStatus": "in_stock", "version": 3 }Error Cases
| HTTP | Code | Condition |
|---|---|---|
| 409 | INVENTORY_STOCK_STATUS_DERIVED | Tracked product, non-pre_order request |
| 409 | INVENTORY_PRE_ORDER_REQUIRES_OVERSELL | pre_order without inventory oversell |
| 409 | PRODUCT_VERSION_CONFLICT | Stale version |
Leaving pre_order on a tracked product re-derives the status from inventory counters; untracked products keep the unrestricted set.
8.10 PATCH /:publicId/{pricing,media,seo,discovery,tags}
Same envelope, permission and rate limit as the general PATCH; payloads per §6.2. Side effects: row update + version bump + activity + cache invalidation. Errors: 409 PRODUCT_VERSION_CONFLICT, PRODUCT_ARCHIVED_NOT_EDITABLE, PRODUCT_ALREADY_DELETED, media caps (PRODUCT_GALLERY_LIMIT_EXCEEDED 30, PRODUCT_ATTACHMENT_LIMIT_EXCEEDED 20, PRODUCT_VIDEO_LIMIT_EXCEEDED 10), PRODUCT_TAG_NOT_FOUND, PRODUCT_TAG_LIMIT_EXCEEDED (25).
8.11 DELETE /api/admin/products/:publicId
200 message-only body ({ message, errorCode }, no data). 404 PRODUCT_NOT_FOUND; 409 PRODUCT_ALREADY_DELETED. Soft delete only — hard delete is structurally unsupported (product_slug RESTRICT).
8.12 POST /api/admin/products/:publicId/restore
200 with grouped response. 409 PRODUCT_NOT_DELETED. Idempotency-Key (scope product-restore).
8.13 POST /api/admin/products/bulk/*
Four bulk routes; 200 with { succeeded, failures }; cap 100 unique ids (409 PRODUCT_BULK_LIMIT_EXCEEDED); rate limit ADMIN_BULK_WRITE 5/min; Idempotency-Key (scope product-bulk). Bulk delete/restore guard per item (PRODUCT_NOT_FOUND, PRODUCT_ALREADY_DELETED, PRODUCT_NOT_DELETED); bulk lifecycle validates each transition; bulk discovery sets the four flags.
8.14 POST /api/admin/products/jobs/import
multipart/form-data (file + entity). Requires Products_CREATE at the route and Products_CREATE + Products_UPDATE in the service. Idempotency-Key (scope product-job-import); rate limit ADMIN_ASYNC_JOB_SUBMIT 10/hour. Errors: 400 PRODUCT_IMPORT_FILE_INVALID / PRODUCT_IMPORT_FILE_TOO_LARGE / IDEMPOTENCY_KEY_REQUIRED; 403 PRODUCT_JOB_ENTITY_PERMISSION_DENIED / PERMISSION_ROLE_NOT_ASSIGNED; 409 idempotency conflicts. Returns 200 with the queued job.
8.15 POST /api/admin/products/jobs/export
JSON body { entity, includeDeleted?, search? }. Permission Products_READ; Idempotency-Key (scope product-job-export). Returns the queued job; a filter set matching > 50,000 rows fails the job (PRODUCT_EXPORT_TOO_LARGE).
8.16 POST /api/admin/products/jobs/:publicId/cancel
200 with the job row; 404 PRODUCT_JOB_NOT_FOUND; 409 PRODUCT_JOB_NOT_CANCELLABLE from a terminal state. Cooperative — a processing import aborts and rolls back.
8.17 GET /api/admin/products/jobs(/:publicId)
List (paginated, filters kind/entity/status, ordered by createdAt) and detail (adds startedAt, isStalled, retained errors). Permission Products_READ, ADMIN_READ 30/min.
8.18 GET /api/admin/products/jobs/import-template
Downloads the column template for an import file — header row only, no example row, because a downloadable import file is eventually uploaded unmodified and a data row would create junk. Zero rows is a no-op.
entity is product — required even though it is the only value, so the three template routes read alike.
Permission Products_READ — the file carries no data, only column names. Rate limit ADMIN_READ 30/min. Returns 200 as text/csv; charset=utf-8 with Content-Disposition: attachment. An unknown entity fails DTO validation with 400.
Columns: sku, name, categoryPublicId, brandPublicId, brandSeriesPublicId, mrp, sellingPrice, shortDescription, description. The classification columns are publicIds, unlike catalog import which links by slug.
The list is generated from the same constant the row builder's fields are typed against, so a column cannot silently diverge from what the parser reads: satisfies rejects a column that is not a row field, an Exclude assertion rejects a row field with no column, and a round-trip spec parses the emitted template back through the real builder.
Route ordering is load-bearing — this literal segment is declared above @Get(":publicId") in the controller. Declared after it, the wildcard swallows the path and the request fails as "publicId must be a UUID".
8.19 Tag endpoints (/api/admin/catalog/tags, /api/mobile/catalog/tags)
Admin: list/detail/create(201)/update/visibility/delete(200 message-only)/restore(200). Permissions Tags_*, rate limits ADMIN_READ/ADMIN_WRITE. Errors: 404 TAG_NOT_FOUND; 409 TAG_SLUG_ALREADY_EXISTS, TAG_ALREADY_DELETED, TAG_NOT_DELETED, TAG_HAS_LINKED_PRODUCTS (delete while linked). Storefront: GET /api/mobile/catalog/tags (visible tags), GET /:slug (404 TAG_NOT_FOUND), PUBLIC_READ 60/min.
9. Flow Diagrams
9.1 Route Ownership
9.2 Request Sequence (admin mutation)
9.3 Error Branch (lifecycle)
10. Pagination, Sorting, Filtering, and Search
| Endpoint | Pagination Type | Default Size | Max Size | Sort Fields | Filters | Result Cap |
|---|---|---|---|---|---|---|
GET /api/mobile/products | keyset cursor (sortKey, id) | 20 | 100 | newest, priceAsc, priceDesc, discount | categorySlug (descendants), brandSlug, seriesSlug, tags (AND), min/maxPrice, stockStatus, onSale, 4 flags, q | per page |
GET /api/mobile/products/search | keyset cursor | 20 | 100 | list sorts + relevance | same | per page |
GET /api/mobile/products/discovery/:feed | keyset cursor | 20 | 100 | newest (feed ordering) | none (feed is the filter) | per page |
GET /api/admin/products | offset (page/size) | 20 | 100 | sort/order on admin columns | status, includeDeleted, categoryId, brandId, seriesId, tagIds, price, stock, flags, search | offset cap 10,000 |
Cursor behavior: opaque; sort-bound (400 PAGINATION_CURSOR_INVALID on mismatch); nextCursor: null = last page; over-fetch +1 instead of COUNT.
11. Caching, Jobs, and External Integrations
| Integration | Used? | Details | Source |
|---|---|---|---|
| Redis cache | Yes | Product + tag cache domains; CACHE_TTL.STANDARD (300s); all keys under PRODUCT_CACHE_PREFIX = product:v2:, still inside the product:* invalidation glob; cleared by product/tag writes. The v2 segment versions the SERIALIZED shape — bump it whenever a product read's response shape changes, or warm keys serve the old shape for a full TTL after deploy | cache-invalidation.tags.ts, shared/products.constants.ts |
| BullMQ | Yes | PRODUCTS queue: product.import_entities, product.export_entities; enqueued exclusively via the transactional outbox; PRODUCTS registered in REGISTERED_QUEUES for defaultJobOptions | packages/jobs/src/index.ts |
| External API | No | — | — |
13. Mandatory Deep API Documentation Pack
13.1 Route-by-Route Completeness Matrix
| Route | Controller Method | DTOs | Service Method | Guards | Permissions | Cache | Jobs | DB Touches | Errors | Documented? |
|---|---|---|---|---|---|---|---|---|---|---|
GET /api/mobile/products | findAll | FetchProductsDto, ProductCardResponseDto | ProductCustomerService.list | Public+IpThrottle | — | product | — | product, product slugs, brand, brand slugs, inventory | Cursor 400 | Yes |
GET /api/mobile/products/search | search | SearchProductsDto, ProductCardResponseDto | ProductCustomerService.search | Public+IpThrottle | — | product | — | same | — | Yes |
GET /api/mobile/products/suggestions | suggestions | ProductSuggestionsQueryDto | ProductCustomerService.suggestions | Public+IpThrottle | — | product | — | product | — | Yes |
GET /api/mobile/products/:slug | findBySlug | param, ProductCustomerResponseDto | ProductCustomerService.findBySlug | Public+IpThrottle | — | product | — | slug, product, category, brand, brand series, tags, seo, inventory, rating | 404 | Yes |
GET /api/mobile/products/discovery/:feed | findFeed | FetchProductDiscoveryDto, ProductCardResponseDto | ProductDiscoveryCustomerService.feed | Public+IpThrottle | — | product | — | product, product slugs, brand, brand slugs, inventory | 400 feed | Yes |
GET /api/admin/products | findAll | FetchProductDto | ProductAdminService.findAll | JWT+Role+IpThrottle | Products_READ | — | — | product | 400 sort/page | Yes |
GET /api/admin/products/:publicId | findById | ProductParamsDto | ProductAdminService.findById | JWT+Role+IpThrottle | Products_READ | — | — | product | 404 | Yes |
POST /api/admin/products | create | CreateProductDto | ProductWriteService.create | JWT+Role+IpThrottle+Idempotency | Products_CREATE | invalidate | — | product, slug, inventory, seo | 404/409/400 | Yes |
PATCH /api/admin/products/:publicId | update | UpdateProductDto | ProductWriteService.update | JWT+Role+IpThrottle | Products_UPDATE | invalidate | — | product | 409/404 | Yes |
PATCH /:publicId/lifecycle | updateLifecycle | UpdateProductLifecycleDto | ProductWriteFacetsService.updateLifecycle | same | Products_UPDATE | invalidate | — | product, seo | 409 | Yes |
PATCH /:publicId/stock | updateStock | UpdateProductStockDto | …updateStock | same | Products_UPDATE | invalidate | — | inventory (FOR SHARE), product | 409 | Yes |
PATCH /:publicId/pricing | updatePricing | UpdateProductPricingDto | …updatePricing | same | Products_UPDATE | invalidate | — | product | 409 | Yes |
PATCH /:publicId/media | updateMedia | UpdateProductMediaDto | …updateMedia | same | Products_UPDATE | invalidate | — | product | 409 caps | Yes |
PATCH /:publicId/seo | updateSeo | UpdateProductSeoDto | …updateSeo | same | Products_UPDATE | invalidate | — | product, seo | 409 | Yes |
PATCH /:publicId/discovery | updateDiscovery | UpdateProductDiscoveryDto | …updateDiscovery | same | Products_UPDATE | invalidate | — | product | 409 | Yes |
PATCH /:publicId/tags | updateTags | UpdateProductTagsDto | …updateTags | same | Products_UPDATE | invalidate | — | product_tag_link | 404/409 | Yes |
DELETE /api/admin/products/:publicId | delete | ProductParamsDto | ProductWriteService.delete | same | Products_DELETE | invalidate | — | product | 404/409 | Yes |
POST /:publicId/restore | restore | ProductParamsDto | ProductWriteService.restore | JWT+Role+IpThrottle+Idempotency | Products_RESTORE | invalidate | — | product | 404/409 | Yes |
POST /api/admin/products/bulk/* (4) | 4 methods | Bulk DTOs | ProductBulkService.* | JWT+Role+IpThrottle+Idempotency | per route | invalidate | — | product | 409 | Yes |
POST /api/admin/products/jobs/import | submitImport | SubmitProductImportDto | ProductJobAdminService.submitImport | JWT+Role+IpThrottle+Idempotency | Products_CREATE(+UPDATE) | — | outbox → product.import_entities | product_job, outbox_events | 400/403/409 | Yes |
POST /api/admin/products/jobs/export | submitExport | SubmitProductExportDto | …submitExport | same | Products_READ | — | outbox → product.export_entities | product_job, outbox_events | 400/409 | Yes |
POST /api/admin/products/jobs/:publicId/cancel | cancel | ProductJobParamsDto | …cancel | JWT+Role+IpThrottle | Products_UPDATE | — | — | product_job | 404/409 | Yes |
GET /api/admin/products/jobs | findAll | FetchProductJobDto | …findAll | JWT+Role+IpThrottle | Products_READ | — | — | product_job | — | Yes |
GET /api/admin/products/jobs/:publicId | findById | ProductJobParamsDto | …findById | JWT+Role+IpThrottle | Products_READ | — | — | product_job | 404 | Yes |
GET /api/admin/catalog/tags | findAll | FetchCatalogTagDto | CatalogTagAdminService.findAll | JWT+Role+IpThrottle | Tags_READ | — | — | tag | 400 | Yes |
GET /api/admin/catalog/tags/:publicId | findById | CatalogTagParamsDto | …findById | same | Tags_READ | — | — | tag | 404 | Yes |
POST /api/admin/catalog/tags | create | CreateCatalogTagDto | …create | JWT+Role+IpThrottle | Tags_CREATE | invalidate | — | tag | 409 | Yes |
PATCH /api/admin/catalog/tags/:publicId | update | UpdateCatalogTagDto | …update | same | Tags_UPDATE | invalidate | — | tag | 404/409 | Yes |
PATCH /api/admin/catalog/tags/:publicId/visibility | setVisibility | SetCatalogTagVisibilityDto | …setVisibility | same | Tags_UPDATE | invalidate | — | tag | 404/409 | Yes |
DELETE /api/admin/catalog/tags/:publicId | delete | CatalogTagParamsDto | …delete | same | Tags_DELETE | invalidate | — | tag | 404/409 | Yes |
POST /api/admin/catalog/tags/:publicId/restore | restore | CatalogTagParamsDto | …restore | same | Tags_RESTORE | invalidate | — | tag | 404/409 | Yes |
GET /api/mobile/catalog/tags | findAll | — | CatalogTagCustomerService.findAll | Public+IpThrottle | — | tag | — | tag | — | Yes |
GET /api/mobile/catalog/tags/:slug | findBySlug | param | …findBySlug | Public+IpThrottle | — | tag | — | tag | 404 | Yes |
13.2 Request/Response Exhaustiveness
Covered in §8: minimal + full create payloads (§6.1/8.6), success responses (§8.1, 8.4), empty-list behavior (data: [], nextCursor: null), validation error (400 IDEMPOTENCY_KEY_REQUIRED representative), domain errors per endpoint (§8 error tables), rate-limit behavior (throttler 429 on exceeding per-surface limits), admin permission errors (403).
13.3 API Diagram Pack
Route ownership (§9.1), sequence per endpoint family (§9.2, backend §7), activity diagrams per mutation family (§9.3, features §6), error decision trees (§9.3), data contract map (backend §5.3 ER + §6 service tables), async/job flow (backend §7.3 import sequence), cache flow (backend §11).
13.4 Consumer Integration Notes
| Consumer | Required Knowledge | Failure Handling | Contract Stability |
|---|---|---|---|
| Web frontend | Storefront routes, filter DTO, cursor sort-binding, nextCursor, listings return the card shape and the detail route returns the full one | 400 PAGINATION_CURSOR_INVALID → restart without cursor; 404 → remove from listings | Changed — listing shape narrowed, see §13.6 |
| Mobile app | PUBLIC_READ/PUBLIC_SEARCH/PUBLIC_SUGGEST rate limits, ip+device keys on search/suggest, the card shape on all three listing routes | 429 → back off; offline retry on GETs safe | Changed — listing shape narrowed, see §13.6 |
| Admin panel | Products_* permissions, optimistic version, idempotency headers on creates/bulk/jobs | 409 PRODUCT_VERSION_CONFLICT → re-read and re-apply | Stable |
| QA | Lifecycle transition table, tracked-stock narrowing, retired-slug behavior | Reproduce via exact error codes | Stable |
| Blog authors | productIds on post create/update; undefined keeps, [] clears | Storefront detail shows published products only | Stable |
13.5 API Tradeoffs and Rationale
| Decision | Chosen Behavior | Alternatives Considered | Why This Tradeoff | Risk | Mitigation |
|---|---|---|---|---|---|
| Cursor pagination | Keyset on (sortKey, id) | Offset pages | Stable ordering, no deep-offset cost | Cursor sort-bound | Loud 400 on mismatch |
| Facet PATCH endpoints | Seven single-purpose PATCHes + general PATCH | One mega-PATCH | Enforces lifecycle/stock/pricing invariants per concern | More routes | Grouped response keeps clients stable |
promotions/inventory reserved null | Always-present null keys | Omit until needed | Additive modules, no breaking change | Consumers must ignore null | Documented as reserved |
| Optimistic locking | version on every write | Last-write-wins | Products have many concurrently-edited fields | 409 churn | Re-read/retry flow |
| Storefront literal status | Inlined SQL literal | Bound parameter | Partial-index usability (EXPLAIN-proven) | Odd-looking code | Documented, spec-asserted |
Card shape on listings, detail shape on /:slug | Two response shapes, one assembler each | One DTO for both surfaces; or a view=card|full query parameter | A listing is a grid and a grid renders six fields. One shape meant six needless reads per page and a signed URL per media item rather than per product — measured at 42,572 → 13,092 bytes for a 20-product page. A view parameter would have doubled the cache keyspace and left both shapes reachable on both routes for no consumer that wanted it | Two shapes could drift, or a client could bind a card to a field only the detail carries | ProductCardShape is declared as a Pick<> over ProductResponseShape, so renaming or removing a detail member breaks the card type at compile time — but a member added to the detail shape and never picked fails nothing. ProductCardResponseDto both implements that shape and asserts an equal keyof set against it: implements alone fixes member TYPES, and misses both an OPTIONAL member added to the shape and never declared on the DTO — the version?/metadata? pattern — and a member REMOVED from the shape, either of which is how a Swagger contract goes stale with a green build; the key-set assertion closes both. ProductCustomerResponseDto deliberately has no such assertion, because it legitimately carries rating and canonicalSlug the shape does not. Both builders share the same derivation helpers, so discount, saleStatus and purchasable cannot disagree |
13.6 API Change Impact
| Change | Affected Consumers | Backend Impact | Data Impact | Migration Needed? | Compatibility Plan |
|---|---|---|---|---|---|
inventory group populated | Web/mobile/admin | Assembler + inventory service | None (was null) | No | Reserved-null contract made it additive |
PATCH /stock 409 for tracked | Admin panel | resolveStockStatusForWrite | None | No | Untracked products unchanged |
Listing routes narrowed to the card shape (GET /products, /search, /discovery/:feed) | Web frontend, mobile app. Admin panel unaffected — /api/admin/products is a different surface and is unchanged. | New ProductCardAssembler. ProductResponseAssembler untouched, so the /:slug detail and admin paths are unchanged. Cart and wishlist were moved onto the card shape in a follow-up — see their own change-impact notes | None — read-path projection only, no schema or migration | No | Breaking, and shipped without a compatibility window because nothing consumed the removed fields: happy-shop-frontend serves products from local fixtures and has no HTTP client for these routes, and Happy-shop-admin does not call the storefront surface. A consumer that needs a removed field reads the detail route. Re-adding any dropped field later is additive and non-breaking. PRODUCT_CACHE_PREFIX was bumped to product:v2: in the same change so warm Redis keys cannot serve the old shape after deploy. |
14. Zero-Omission API Checklist
- Every controller route is documented (§4, §8, §13.1).
- Every parent route prefix and runtime URL is documented (§2, §4).
- Every DTO field, enum, default, transform and validator is documented (§6, §7).
- Every response field, nullable field, generated field and omitted raw entity field is documented (§8.1, backend §5.2).
- Every auth, guard, permission, public decorator and guest identity branch is documented (§5).
- Every success, validation, auth, permission, not-found, conflict, rate-limit and server-error branch is documented (§8 error tables).
- Every DB read/write, cache hit/miss/invalidation, queue job, audit log and external call is documented (§11, backend §9/§10/§11).
- Every route has examples for minimal request, success response and representative failures (§8).
- Every endpoint family has route, sequence, activity and error diagrams (§9, backend §7).
- Every tradeoff and compatibility risk is documented (§13.5, §13.6).
- The API doc links to backend and features/flows (§1, See Also).
15. Integration Checklist
- Every route from controllers is documented.
- Every DTO field is documented.
- Every enum value is documented.
- Every response envelope is documented.
- Every error code is documented.
- Every auth guard and permission is documented.
- Every cache key, queue job and external call is documented.
- Every diagram matches the current code.
- The API doc links to backend and features/flows.
Variants
Since the variant migration, price, SKU and stock live on the variant, not on the product.
product.mrp and product.selling_price are trigger-maintained rollups over the product's live,
active variants, and inventory, cart_item, checkout_session_item, order_item and
pos_sale_item all key on variant_id.
| Method | Path | Permission |
|---|---|---|
GET | /api/admin/products/{productPublicId}/variants | Products_READ |
GET | /api/admin/products/{productPublicId}/variants/{publicId} | Products_READ |
POST | /api/admin/products/{productPublicId}/variants | Products_CREATE |
PATCH | /api/admin/products/{productPublicId}/variants/{publicId} | Products_UPDATE |
DELETE | /api/admin/products/{productPublicId}/variants/{publicId} | Products_DELETE |
Permissions reuse Products_* deliberately. A separate Variants_* set would create roles that can
edit a product but not its price, which is not a role anybody wants.
Rules
- Every product has at least one variant, and exactly one is the default. Deleting the last live one is refused.
- Writing a variant's price moves the product's rollup, because a statement-level trigger
recomputes
selling_price,mrp,max_selling_priceandvariant_countfrom the variants. The admin UI must re-read the product after a variant write rather than assuming its price fields are unchanged. - Writes are optimistically locked on
version. Send theversionyou read. - A variant carries exactly one value per option axis the product declares, and no two variants of a product may share a combination. Both are enforced by the database.
- Repricing the PRODUCT row directly is meaningless — it is derived, and the next variant write overwrites it.
Error codes
| errorCode | HTTP | Meaning |
|---|---|---|
PRODUCT_VARIANT_NOT_FOUND | 404 | No such variant on that product |
PRODUCT_VARIANT_VERSION_CONFLICT | 409 | Someone else saved first; re-read |
PRODUCT_VARIANT_CANNOT_DELETE_LAST | 409 | A product must keep one live variant |
PRODUCT_VARIANT_SKU_ALREADY_EXISTS | 409 | |
PRODUCT_VARIANT_OPTION_VALUES_INVALID | 400 | Missing an axis, or a value from another product |
PRODUCT_VARIANT_DUPLICATE_COMBINATION | 409 | That option combination already exists |
PRODUCT_VARIANT_CANNOT_DEACTIVATE_DEFAULT | 409 | Promote another variant first |
Media
Each variant carries its own thumbnailKey, thumbnailAlt and ordered gallery[] — never shared
with the product's own gallery, though the two use the identical element shape ({key, alt, order})
so the storefront can compose them into one slide strip.
CreateProductVariantDto / UpdateProductVariantDto accept thumbnailKey (storage key,
IsStorageKey), thumbnailAlt and gallery[] (ProductVariantGalleryItemDto[], capped at
MAX_VARIANT_GALLERY_ITEMS = 20 — lower than the product's 30 because it is read on every
product-detail request for every live variant). The gallery editor always sends the field
explicitly: omitting gallery leaves the stored gallery untouched; sending [] clears it.
Admin response (ProductVariantResponseDto) carries both the raw key and the resolved URL —
thumbnailKey / thumbnailUrl, and each gallery item's key / url — because an operator
legitimately previews objects that are not yet public. The customer response never does this; see
below.
Customer contract — variants[].media on GET /api/mobile/products/{slug}:
{
thumbnail: { url: string; alt: string | null } | null;
gallery: { url: string; alt: string | null; order: number }[];
}Resolution order (shared with the product-level rule, not a second implementation): the variant's
own thumbnailKey, trimmed, when non-empty; else the gallery entry with the lowest order
(ties broken by array position); else null. thumbnail: null with an empty gallery is the
normal case — "this variant adds no media of its own" — and the storefront falls back to the
product's thumbnail, never a placeholder. An unresolvable key is dropped from the gallery and
never emitted as { url: "" }, which is truthy and would defeat a caller's if (media.thumbnail)
check. See Backend §5.4 for the
full rule and why the trim is required.
Related products
GET /api/products/related/{slug}
Public, also served under /api/mobile. Cached, cursor-paginated, and returns the same card
shape as every other listing.
It reuses ProductStorefrontQueryService rather than running its own query, and that is the whole
design: it guarantees the identical published/not-deleted predicate, the identical keyset
pagination, the identical card shape and the identical price rollups as GET /products. A
hand-rolled query here would be a second place for the storefront's visibility rules to drift.
Products are related by sharing the subject's category or subcategory. The subject itself is excluded.
See Also
- Backend doc: /docs/developer/products/backend
- Features and flows doc: /docs/developer/products/feature
- TDD: not yet published