Complete feature list, actor journeys, state flows, business rules, edge cases, and diagrams for the Products module.
Use this page for the product domain: what it does for users, admins, workers and systems, and how each flow behaves from start to finish.
| Source Type | Files or Docs | What Was Extracted |
|---|
| Technical design | apps/api/src/modules/products/shared/product-lifecycle.service.ts, product-filter.builder.ts, product-storefront-query.service.ts | State machine, filter composition, cursor pagination, literal-predicate rule |
| API | apps/api/src/modules/products/admin/product/*.controller.ts, customer/**/*.controller.ts, admin/job/product-job-admin.controller.ts, catalog/{admin,customer}/tag/*.controller.ts | Route surface, permissions, rate limits, actors |
| Backend | product-write.service.ts, product-write-facets.service.ts, product-bulk.service.ts, product-response.builder.ts, import-export/*, workers/*, outbox/* | Business rules, side effects, jobs, outbox enqueue |
| Schema | packages/db/src/schema/products/*.ts, packages/db/src/schema/catalog/tag.ts | Tables, constraints, composite FK, GENERATED state |
| Money | apps/api/src/utils/money/money.util.ts | Minor units, no-tax rule, derived discounts |
| Field | Value |
|---|
| Module | products |
| Submodule | catalog/tag (tag entity), outbox (enqueue infra), blog (linking) |
| Primary user value | A published catalogue of sellable products with filters, search and reliable URLs, maintained by admins through CRUD, bulk operations and file import/export |
| Actors | Guest, admin, worker/system, blog author |
| Main entry points | /api/admin/products, /api/admin/products/jobs, /api/admin/catalog/tags, /api/mobile/products, /api/mobile/products/discovery/:feed, /api/mobile/catalog/tags, blog post create/update/detail |
| Main outputs | Product responses (grouped shape with a populated inventory group), CSV exports, outbox_events rows, activity log entries, cache invalidations |
| Related docs | API, Backend |
| Actor | Can Do | Cannot Do | Auth Requirement | Notes |
|---|
| Guest | Browse list, search, suggestions, discovery feeds, product detail by slug (current or retired) | Admin mutations, see draft/archived products, see unlisted products outside direct URL | None (@Public()) | Rate-limited per IP (PUBLIC_READ 60/min, PUBLIC_SEARCH 60/min, PUBLIC_SUGGEST 300/min, PUBLIC_HIGH_FREQUENCY 300/min) |
| Admin | Full product CRUD, facet PATCHes, lifecycle transitions, bulk delete/restore/lifecycle/discovery, tag CRUD, import/export jobs, cancel jobs | Change lifecycle of archived products (read-only), edit products of another admin without the current version (optimistic lock), set stock status on tracked products | Admin JWT + Products_* / Tags_* / Inventory_* permission | Every mutation writes an activity record and invalidates cache |
| Worker/system | Import/export product jobs, outbox dispatch, stalled-job sweeps | — | BullMQ worker | Lease-based claim; cooperative cancellation; at-least-once delivery — consumers idempotent |
| Blog author | Link products to posts (productIds on create/update) | Link non-published products on the storefront view (published-only) | Blog permissions | Batched reads, no N+1 |
| Capability | Surface | Actor | Route/Trigger | State Read | State Written | Linked API Section |
|---|
| List products | Storefront | Guest | GET /api/mobile/products | Published rows + filters | — | API |
| Search products | Storefront | Guest | GET /api/mobile/products/search | Published rows, trigram rank | — | API §5 |
| Suggestions | Storefront | Guest | GET /api/mobile/products/suggestions | Published rows | — | API §5 |
| Product detail by slug | Storefront | Guest | GET /api/mobile/products/:slug | Slug ownership + row | — | API §5 |
| Discovery feeds | Storefront | Guest | GET /api/mobile/products/discovery/:feed | Partial index per flag | — | API §5 |
| Admin list / detail | Admin | Admin | GET /api/admin/products(/:publicId) | All rows incl. deleted | — | API §3 |
| Create product | Admin | Admin | POST /api/admin/products | — | product row, product_slug, inventory row, seo | API §3 |
| General update | Admin | Admin | PATCH /api/admin/products/:publicId | Row + version | product row | API §3 |
| Lifecycle transition | Admin | Admin | PATCH /:publicId/lifecycle | Row + thumbnail + publishedAt | product.status, seo.robots_index | API §3 |
| Facet PATCHes (stock/pricing/media/seo/discovery/tags) | Admin | Admin | PATCH /:publicId/{stock,pricing,media,seo,discovery,tags} | Row + inventory row (stock) | Facet fields, version bump | API §3 |
| Soft delete / restore | Admin | Admin | DELETE /:publicId, POST /:publicId/restore | Row | deleted_at | API §3 |
| Bulk ops | Admin | Admin | POST /api/admin/products/bulk/* | Rows | Bulk state changes | API §3.4 |
| Tag CRUD + visibility | Admin | Admin | /api/admin/catalog/tags | Tag rows + link counts | Tag rows | API §4 |
| Tag listing/detail | Storefront | Guest | /api/mobile/catalog/tags | Visible tags | — | API §6 |
| Import job | Admin/worker | Admin → system | POST /api/admin/products/jobs/import + processor | product_job + live products by SKU | Rows (create/update), job state | API §7 |
| Export job | Admin/worker | Admin → system | POST /api/admin/products/jobs/export + processor | Product rows | CSV file, job state | API §7 |
| Blog linking | Blog author | Author | blog post create/update | Posts + products | blog_post_product rows | API §9 |
A guest opens a category page. The frontend calls the storefront list with categorySlug, sorts by newest, and renders page 1; "load more" follows nextCursor.
- Product rows exist with
status = 'published' AND deleted_at IS NULL.
- The category slug resolves to a live category; its descendants are expanded server-side.
| Step | Actor/System | Action | Result | Source |
|---|
| 1 | Guest | Opens category URL | List request with categorySlug + sort | product-customer.controller.ts |
| 2 | Backend | Resolves slug → descendant category ids, composes filters, inlines the published literal | Index Scan over the partial index | product-storefront-filter-resolver.service.ts, product-filter.builder.ts |
| 3 | Backend | Keyset-paginates on (sortKey, id), over-fetches one row | data + nextCursor | product-storefront-query.service.ts |
| 4 | Backend | Batches slug, classification and media resolution | Grouped response, one query per concern | product-response-assembler.service.ts |
| 5 | Guest | Follows nextCursor until null | Stable ordering across pages | — |
| Branch | Condition | Behavior | Error/Result |
|---|
| Unknown category slug | Slug resolves to nothing | 404 before any query | CATEGORY_NOT_FOUND-style 404 |
| Tag filter resolves empty | Requested tag slugs unknown/hidden/deleted | Empty page, never the whole catalogue | Empty data, no error |
| Cursor replayed with different sort | sort changed between pages | Loud 400, restart pagination | PAGINATION_CURSOR_INVALID |
| Term under 2 chars | q too short | "No search" — filters still apply | Full filtered list |
relevance without term | sort=relevance, no usable q | Falls back to newest | No error |
Search composes with every list filter; ranking is trigram similarity on the product name, ordered by rank then id. relevance is legal only on /search.
GET /api/mobile/products/:slug accepts a current or retired slug. The slug ownership table holds current and retired slugs under one unique constraint, so an old URL can never resolve to a different product. The response's basic.slug is always the current slug; the frontend issues the redirect.
| Branch | Condition | Behavior |
|---|
| Retired slug | Slug owned by the product, is_current = false | 200 + current slug in basic.slug |
| Unlisted product | Direct URL only | Resolves here, nowhere else |
| Hidden/deleted/draft/archived | Not customer-visible | 404 on both slug branches |
Permission Products_CREATE, rate limit ADMIN_WRITE 10/min, Idempotency-Key required (scope product).
Side effects: product + product_slug + inventory (untracked) rows written in one transaction, activity record, cache invalidation, seo row forced non-indexable unless published.
PATCH /:publicId/lifecycle — see section 7. Failure branches: 409 PRODUCT_INVALID_STATUS_TRANSITION, PRODUCT_THUMBNAIL_REQUIRED_TO_PUBLISH, PRODUCT_ARCHIVED_NOT_EDITABLE, PRODUCT_VERSION_CONFLICT.
For a tracked product the column belongs to inventory: non-pre_order requests 409 INVENTORY_STOCK_STATUS_DERIVED; pre_order requires inventory oversell (409 INVENTORY_PRE_ORDER_REQUIRES_OVERSELL); leaving pre_order re-derives from inventory counters. Untracked products keep the free set.
POST /api/admin/products/bulk/{delete,restore,lifecycle,discovery} — per-item result (succeeded/failures), cap 100 unique ids (409 PRODUCT_BULK_LIMIT_EXCEEDED), Idempotency-Key required (scope product-bulk). One item failing does not roll back the rest.
See backend §10 — Jobs. Import needs Products_CREATE and Products_UPDATE (route checks the first, the service re-checks both — 403 PRODUCT_JOB_ENTITY_PERMISSION_DENIED otherwise). Both submits require Idempotency-Key; rate limit ADMIN_ASYNC_JOB_SUBMIT 10/hour.
| Entity | From | Event/Action | To | Guard Condition | Side Effects |
|---|
product | draft | publish | published | Thumbnail set; publishedAt stamped once | seo.robots_index = true; cache invalidation |
product | draft | archive | archived | None (may lack thumbnail) | seo.robots_index = false |
product | published | unlist | unlisted | — | robots_index = false; leaves listings but stays purchasable |
product | published/unlisted | archive | archived | — | Read-only; only transition out is → draft |
product | published/unlisted | unpublish | draft | — | publishedAt NOT cleared (answered "first live") |
product | archived | unarchive | draft | — | Admin-editable again |
published and unlisted both require published_at (CHECK-enforced); archived does not.
| Flow | DB Writes | Cache Effects | Jobs | Realtime | Analytics | Notifications |
|---|
| Create product | product, product_slug, inventory, seo | product/brand/tag/inventory domains | — | — | — | — |
| Lifecycle transition | product.status, seo.robots_index | product domain + search | — | — | — | — |
| Import job | product*, product_slug, inventory (per row), product_job | product/inventory domains | product.import_entities via outbox | — | — | — |
| Export job | product_job terminal row | — | product.export_entities via outbox | — | — | — |
| Blog link write | blog_post_product | blog domain | — | — | — | — |
| All admin mutations | Row + activity record | owning domain tags + Redis patterns | — | — | — | — |
| Scenario | Trigger | User/System Experience | Recovery | Source |
|---|
| Optimistic lock conflict | Another admin changed the row since the caller read it | 409 PRODUCT_VERSION_CONFLICT | Re-read, re-apply, retry | product-write.service.ts |
| Import job fails mid-run | Worker error | Job failed with errors[]; rows rolled back | Retry submit (idempotency key) or fix file | product-import.processor.ts |
| Export too large | Filter set matches > 50,000 rows | Job fails, no file | Narrow filters | product-export.service.ts |
| Cancelled import | Admin cancels a processing job | Cooperative abort — transaction rolls back | Submit again | product-job-state.ts |
| Outbox dispatch failure | Redis/BullMQ down at submit | Job row stays queued; enqueue row parked on retry | Dispatcher retries pending rows | modules/outbox/ |
- Actor capability diagram — §3/§4 tables (capability matrix doubles as the map).
- High-level module flow — see 5.2 search blueprint and 6.1 admin flow.
- Sequence diagram per major flow — §5.1.
- State machine diagram — §7.
- Data side-effect diagram — §9.
- Error branch diagram — §10 and 6.2/6.3.
| Feature | Minor Behavior | Actor | Trigger | User/System Result | Backend Side Effect | Source |
|---|
| Storefront list | Empty page for unresolvable tag set | Guest | tags filter | Empty data | WHERE false predicate | product-filter.builder.ts |
| Storefront list | Cursor-sort mismatch | Guest | Cursor from another sort | 400 | Throw before query | product-storefront-query.service.ts |
| Storefront list | onSale=false | Guest | Filter | Non-discounted products | selling_price >= mrp | product-filter.builder.ts |
| Storefront list | Over-fetch +1 | Guest | Any page | nextCursor presence | limit + 1 rows | product-storefront-query.service.ts |
| Detail | Thumbnail fallback | Guest | No thumbnailKey | First gallery item by order | resolveThumbnailSource | product-response.builder.ts |
| Detail | Retired slug | Guest | Old URL | 200 + current slug | Slug ownership lookup | slug-ownership.service.ts |
| Suggestions | No term | Guest | Empty q | Unfiltered suggestions | MIN term check | product-customer.service.ts |
| Admin list | Deleted-only view | Admin | includeDeleted | Restore workflow list | Partial index on deleted_at | product-admin.service.ts |
| Admin PATCH | Deleted entity guard | Admin | Update on deleted row | 409 | PRODUCT_ALREADY_DELETED-style guard | product-write-support.service.ts |
| Import | SKU match semantics | Admin | File row | Update live / create draft | Per-row upsert | product-import.service.ts |
| Import | Query-count guard | Worker | 500 rows / 3 ids | Exactly 3 warm-up queries | Memoised classification cache | product-write-support.service.ts |
| Jobs | isStalled signal | Admin | Detail view | Flag when past claim TTL | Derived, no sweep dependency | product-job-admin.service.ts |
| Rule | Business Reason | Actor Impact | Enforced In | API Impact | Backend Impact | Tests |
|---|
Storefront reads only published, non-deleted | Drafts and archived rows must never reach customers | Guest sees only live rows | SQL literal in product-filter.builder.ts | List/search/feed/detail | Partial-index usability (EXPLAIN-proven) | product-storefront-query.service.spec.ts |
published/unlisted require a thumbnail | A customer-visible product needs an image; drafts may not have one yet | Admin gets 409 on publish without image | DB CHECK + lifecycle service | PATCH /lifecycle | Transactional guard | product-lifecycle.service.spec.ts |
publishedAt set once, never cleared | "First live" is a question worth keeping | Admin sees original publish date | Schema comment + service | Response timestamps.publishedAt | Written on first live transition | schema probe |
archived is read-only | Archive is retirement, not editing | Admin must unarchive first | Lifecycle service | 409 on any facet PATCH | Single write path | spec |
| SKU unique among live rows | Soft-deleting releases the SKU for reuse | Admin can reuse after delete | Partial unique index | 409 PRODUCT_SKU_ALREADY_EXISTS | uq_product_sku_live | probe |
| Series must belong to the product's brand | Cross-brand series is unrepresentable | Admin gets 409/23503 | Composite FK + service check | Create/update reject | fk_product_brand_series | constraint probe 63/63 |
sellingPrice <= mrp | Money invariant of the whole commerce system | Admin cannot overprice | DB CHECK | 409 PRODUCT_SELLING_PRICE_ABOVE_MRP | CHECK chk_product_selling_price_not_above_mrp | probe |
pre_order requires inventory oversell | Pre-order freezes the stock status once tracked | Admin gets 409 without oversell | product-stock-write.util.ts | PATCH /stock | Narrowed write | spec |
| Optimistic lock on version | Concurrent editors must not silently overwrite | Admin gets 409 on stale write | Service + schema | 409 PRODUCT_VERSION_CONFLICT | version bumped per write | spec |
| Product Decision | User Benefit | Engineering Benefit | Alternative | Tradeoff | Risk |
|---|
Cursor pagination on (sortKey, id) | Stable ordering across pages | Keyset over offset; over-fetch instead of COUNT | Offset pages | Cursor only meaningful per sort | Cursor-sort mismatch 400 (fail loud) |
| Four separate discovery flags + partial indexes | One feed per flag, each index-sized | Each feed is an index scan | One composite flag index | Four indexes on writes | Write amplification accepted |
| Money in integer minor units | Exact prices on statements | Integer arithmetic, no drift | Floats/strings | Conversion ceremony at boundaries | isSafeInteger boundary documented |
pre_order as explicit stock status | Backorder workflow expressible | Enum value today, no later migration | Boolean pair | Inventory module derives status later | Narrowed product PATCH |
promotions/inventory reserved null keys | Consumers build against a stable shape | Additive modules, no breaking change | Omit keys until they exist | Consumers must not treat null as a bug | Documented as reserved |
| Flow | Edge Case | Trigger | Expected Behavior | User/System Feedback | Source |
|---|
| List | Duplicate action | Same cursor twice | Same page re-served | Same data/nextCursor | keyset WHERE |
| List | Empty state | No published rows | Empty data, nextCursor: null | Empty UI | query |
| List | Last item | Final page | No further cursor | nextCursor: null | over-fetch |
| Search | Too-short term | q < 2 chars | Filters apply, no search | Full filtered list | MIN term |
| Detail | Expired/retired slug | Renamed product, old URL | Current slug returned | Frontend redirect | slug ownership |
| Lifecycle | Duplicate publish | Publish twice | Second is legal (idempotent state) | 200 | state machine |
| Import | Duplicate submit | Same idempotency key | Replay returns original job | Same response | idempotency interceptor |
| Import | Concurrent cancel | Cancel during write | Cooperative abort, rollback | Job cancelled | PRODUCT_JOB_NOT_PROCESSING |
| Admin PATCH | Stale version | Two admins edit | 409 conflict | Re-read and retry | optimistic lock |
| Export | Zero rows | Empty filter set | Header-only CSV, completed | Download of valid file | export service |
| Cache | Stale read | Write without refetch | Invalidation after commit | Fresh on refetch | cache domains |
| Flow | Reads | Writes | Cache | Jobs/Events | Response Fields |
|---|
| List/search/feed | product, product_slug, product_tag_link, inventory | — | product domains | — | Grouped shape |
| Create/update | product, classification refs | product, product_slug, inventory, seo | invalidate domains | activity | Grouped + version/metadata (admin) |
| Import | product by SKU, classification | Rows + product_job | invalidate | outbox → product.import_entities | Job response |
| Export | product rows | product_job + CSV | — | outbox → product.export_entities | Job response |