Products Backend Documentation
Backend architecture, data model, services, cache, queues, runtime rules, and operational behavior for the Products module.
Products - Backend Documentation
1. Documentation Evidence
| Area | Files Inspected | Verified Details |
|---|---|---|
| Module wiring | products.module.ts, products-{admin,customer,shared,worker}.module.ts | Aggregate + leaf composition, shared services |
| Controllers | admin/product/*.controller.ts, customer/**/*.controller.ts, admin/job/product-job-admin.controller.ts | Route ownership, guards, status codes, route ordering |
| Services | product-write.service.ts, product-write-facets.service.ts, product-bulk.service.ts, product-write-support.service.ts, product-admin.service.ts, product-storefront-query.service.ts, product-storefront-filter-resolver.service.ts | Validation order, transactions, optimistic locking, memoised classification |
| DTOs | dto/*.ts under each leaf | Request/query validation and defaults |
| Schema | packages/db/src/schema/products/*.ts, packages/db/src/schema/catalog/tag.ts | Tables, CHECKs, partial indexes, composite FK |
| Jobs | packages/jobs/src/index.ts, import-export/*, workers/* | Queue contracts, lease claim, cooperative cancellation |
| Cache | cache-invalidation.tags.ts, RedisCacheService | Domains, patterns, TTL |
| Money | apps/api/src/utils/money/money.util.ts | Minor units, no tax |
2. Backend Scope and Boundaries
Owns
- Product CRUD, the seven single-purpose facet PATCHes, lifecycle state machine and optimistic locking.
- Storefront discovery: multi-filter list, search, suggestions, feeds and cursor pagination.
- Product import/export jobs (enqueued through the outbox).
- Product slug ownership (via
SlugOwnershipService, shared with catalog). - Blog ↔ product linking (
blog_post_productlink table lives in the blog schema). - Product↔tag membership (
product_tag_link); thetagentity itself belongs to catalog.
Does Not Own
- The
tagtable — catalog owns it (catalog is the taxonomy module). - Inventory counters — the inventory module owns them;
product.stock_statusis a synchronous projection maintained under inventory rules. promotionsandinventoryresponse groups — reservednull, populated additively by their modules.- The outbox — generic infra in
modules/outbox/.
Source of Truth
| Concern | Source of Truth | Notes |
|---|---|---|
| Runtime state | PostgreSQL (product, product_slug, product_tag_link, blog_post_product) | |
| Slug history | product_slug ownership table — current AND retired under one unique | |
| Lifecycle legality | ProductLifecycleService (pure) + DB CHECKs | |
| Money | product.mrp / product.selling_price bigint minor units | Tax included, no tax field |
| Pricing invariants | DB CHECK chk_product_selling_price_not_above_mrp | Never only a DTO rule |
3. Module Composition
| Module | Type | Path | Controllers | Providers | Exports | Responsibility |
|---|---|---|---|---|---|---|
ProductsModule | Aggregate | apps/api/src/modules/products/products.module.ts | None | — | Leaf modules | Composes admin/customer/worker |
ProductsAdminModule | Leaf | products-admin.module.ts | ProductAdminController, ProductAdminBulkController, ProductJobAdminController | Services | — | Admin surface |
ProductsCustomerModule | Leaf | products-customer.module.ts | ProductCustomerController, ProductDiscoveryCustomerController | Services | — | Storefront surface |
ProductsSharedModule | Leaf | products-shared.module.ts | None | Shared services | Services | Cross-surface logic (filter builder, lifecycle, response builder) |
ProductsWorkerModule | Leaf | products-worker.module.ts | None | Processors/handlers | — | Import/export workers |
The four catalog customer leaves are composed into MobileModule at /api/mobile; ProductCustomerModule and ProductDiscoveryCustomerModule are listed directly in MOBILE_CHILDREN (the RouterModule does not recurse through two-level aggregates).
4. File and Directory Map
apps/api/src/modules/products/
products.module.ts
products-admin.module.ts
products-customer.module.ts
products-shared.module.ts
products-worker.module.ts
admin/
product/
product-admin.controller.ts # 13 routes: read surface + single-entity writes
product-admin-bulk.controller.ts # 4 bulk routes (registered BEFORE the admin controller)
product-write.service.ts # create / general update / delete / restore
product-write-facets.service.ts # lifecycle / stock / pricing / media / seo / discovery / tags
product-bulk.service.ts # bulk delete / restore / lifecycle / discovery
dto/
job/
product-job-admin.controller.ts # import / export / cancel / list / detail
product-job-admin.service.ts
product-job-import-upload.config.ts
customer/
product/ product-customer.controller.ts + service + dto
discovery/ product-discovery-customer.controller.ts + service + dto
import-export/
product-import-parser.ts # CSV/XLSX streaming parse, file validation
product-import-row.ts # row validation + SKU matching semantics
product-import.service.ts # validate-all-then-write-all + job terminal write
product-export.service.ts # CSV write + row cap
shared/
product-filter.builder.ts # the one filter predicate builder
product-storefront-query.service.ts # the one storefront listing engine
product-storefront-filter-resolver.service.ts
product-response.builder.ts # both response shapes: detail + card
product-response-assembler.service.ts # detail shape — 10 batched reads
product-card-assembler.service.ts # card shape — 4 batched reads, thumbnail only
product-card-response.dto.ts # Swagger contract for listing items
product-lifecycle.service.ts # the one state machine
product-write-support.service.ts # classification memo cache, shared guards
products.constants.ts # caps: 20/100/25/30/10/20/100/50k
products-activity-actions.ts # typed activity literals
workers/
product-queue.processor.ts # THE one @Processor on PRODUCTS
product-import.processor.ts # @Injectable handler
product-export.processor.ts # @Injectable handler
product-job-state.ts # claim/complete/fail transitions
product-job-sweep.processor.ts # stalled-job sweep handler
product-job-sweep.scheduler.ts # cron entryKey files:
| File | Purpose | Key Exports | Notes |
|---|---|---|---|
shared/product-filter.builder.ts | Every filter predicate, admin AND storefront | buildProductFilterConditions | Pure; storefront branch inlines the status literal |
shared/product-storefront-query.service.ts | One listing engine for list/search/feeds | ProductStorefrontQueryService | Keyset cursor on (sortKey, id), over-fetch +1 |
shared/product-response.builder.ts | Both grouped response shapes, sharing every derivation helper | buildProductResponse, buildProductCardResponse, ProductResponseShape, ProductCustomerDetailShape, ProductCardShape | Admin adds version/metadata. ProductCustomerDetailShape is the detail shape minus version and metadata — those two are attached only for surface: "admin", so the customer DTO needs a shape without them to implements against. ProductCardShape is 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 does not fail anything, which is the one kind of drift the Pick<> tie does not catch |
shared/product-card-response.dto.ts | Swagger contract for the card listing shape | ProductCardResponseDto, EXACT_CARD_CONTRACT | ProductCardResponseDto implements ProductCardShape — implements catches a REQUIRED member added or retyped, but misses an OPTIONAL member added to the shape and never declared on the DTO (the version?/metadata? pattern) and a member REMOVED from the shape. The SameKeys assertion beside the clause compares the keyof sets and closes both. ProductCustomerResponseDto deliberately has no such assertion — it carries rating and canonicalSlug the shape does not, so its key sets are unequal by design and its guarantee is genuinely weaker |
shared/product-card-assembler.service.ts | Card assembly for the three listing surfaces | ProductCardAssembler | 4 batched reads vs the detail assembler's 10, and signs one storage URL per product rather than per media item. A separate service, not a mode flag — see its header |
shared/product-lifecycle.service.ts | One state machine | ProductLifecycleService | Pure, no I/O |
shared/product-write-support.service.ts | Classification memo cache + shared guards | ProductWriteSupportService | 3 queries per batch, warmed in-tx for the write pass |
import-export/product-import.service.ts | Import orchestration | CatalogImportService | All-or-nothing, job terminal write in same tx |
5. Data Model
5.1 Schema Source
packages/db/src/schema/products/
index.ts
enums.ts
product.ts
product-tag-link.ts
blog-post-product.ts # lives in packages/db/src/schema/blog/ since the ownership fix
packages/db/src/schema/catalog/tag.ts5.2 Tables and Collections
product
| Column | Type | Nullable | Default | Index/Constraint | Relation | Notes |
|---|---|---|---|---|---|---|
id | serial | No | auto | PK | N/A | Internal PK, never exposed |
public_id | uuid v7 | No | $defaultFn | UNIQUE | N/A | The only exposed id |
sku | varchar(64) | Yes | NULL | partial UNIQUE uq_product_sku_live | N/A | Unique among live rows; soft delete releases it |
name | varchar(255) | No | — | trgm GIN | N/A | Non-blank CHECK |
short_description | varchar(500) | Yes | NULL | — | N/A | Card copy, listings never select the body |
description | text | Yes | NULL | trgm GIN | N/A | |
category_id | integer | No | — | FK + partial indexes | categories.id RESTRICT | |
brand_id / brand_series_id | integer | Yes | NULL | composite FK fk_product_brand_series | brands.id / brandSeries | Series must belong to the product's brand |
mrp / selling_price | bigint | No | — | CHECK selling_price <= mrp | N/A | Integer minor units; tax included |
status | product_status enum | No | draft | partial indexes | N/A | draft/published/unlisted/archived |
stock_status | product_stock_status enum | No | in_stock | — | N/A | Projection maintained by inventory rules |
published_at | timestamptz | Yes | NULL | — | N/A | Set once on first live; never cleared |
is_featured/trending/best_seller/new_arrival | boolean | No | false | one partial index each | N/A | Discovery flags |
thumbnail_key, thumbnail_alt | text/varchar | Yes | NULL | CHECK alt requires key | N/A | Storage keys, resolved to URLs at response time |
gallery, videos, attachments | jsonb | Yes | NULL | array CHECKs | N/A | Ordered media arrays, caps 30/10/20 |
specifications, dimensions, metadata | jsonb | Yes | NULL | object CHECKs; specs GIN jsonb_path_ops | N/A | Metadata admin-only in responses |
seo_id | uuid | Yes | NULL | partial UNIQUE | seo.id SET NULL | One SEO row per product |
version | integer | No | 1 | CHECK >= 1 | N/A | Optimistic lock |
deleted_at | timestamptz | Yes | NULL | index | N/A | Soft delete; hard delete unsupported |
created_at / updated_at | timestamptz | No | now() | indexes | N/A |
Lifecycle CHECKs: chk_product_live_requires_thumbnail (draft/archived exempt), chk_product_live_requires_published_at (published/unlisted require it), chk_product_series_requires_brand.
product_variant (media columns)
Full variant schema is not yet backfilled into this doc; this row covers only the columns the
media work added or touches. Source: packages/db/src/schema/products/product-variant.ts.
| Column | Type | Nullable | Default | Index/Constraint | Notes |
|---|---|---|---|---|---|
thumbnail_key | text | Yes | NULL | — | The variant's representative image. Same resolution rule as product.thumbnail_key — see §5.4. |
thumbnail_alt | varchar(255) | Yes | NULL | — | |
gallery | jsonb | Yes | NULL | CHECK chk_product_variant_gallery_is_array | Migration 0046. Element shape {key, alt, order} — identical to product.gallery's ProductGalleryItem, deliberately: one shape means the two lists cannot drift apart, and the storefront composes both into one slide strip. The CHECK validates only that the column is a JSON array; element shape is the write DTO's job. Capped at MAX_VARIANT_GALLERY_ITEMS = 20 (CreateProductVariantDto.gallery, @ArrayMaxSize) — lower than the product's 30 because a variant gallery is read on every product-detail request for every live variant, and is scoped to one configuration rather than the full shoot. |
product_slug
Identical shape to category_slug: slug varchar(260) UNIQUE across current and retired; partial UNIQUE uq_product_slug_current on product_id WHERE is_current; ON DELETE RESTRICT; soft delete never releases slugs.
tag (catalog) and product_tag_link (products)
tag: flat facet, slug unique among live rows only (no history table — a tag is a filter facet, not an SEO landing page). product_tag_link: composite PK (product_id, tag_id), RESTRICT both sides, plus idx_product_tag_link_tag for the filter path.
blog_post_product (blog schema)
(blog_post_id, product_id) PK; blog_post_id CASCADEs (blog hard-deletes), product_id RESTRICTs (products soft-delete); display_order non-negative CHECK.
5.3 Relationship Diagram
5.4 Variant media resolution
Implemented once, in apps/api/src/modules/products/shared/product-response.builder.ts, and shared
— not duplicated — between the product and the variant:
resolveThumbnailSource(thumbnailKey, thumbnailAlt, gallery):
thumbnailKey, TRIMMED, when non-empty
else the gallery entry with the lowest `order`, ties broken by array position (stable sort)
else nullresolveVariantThumbnailSource is a documented alias of the same function — not a second
implementation — because the selection rule is identical for both callers. The two responses
differ only in what a null result means to the caller: for a product, no thumbnail exists at
all; for a variant, this variant adds no media of its own and the caller falls back to the
product's thumbnail, never a placeholder.
The trim is load-bearing, not defensive padding: two of three seeded product_variant rows hold
thumbnail_key = '' rather than NULL, so ?? does not fire and would return the empty string.
' ' survives a naive nullif(..., '') and would resolve to the asset-origin root. Both were
probe-proven (probe-variant-gallery.sql, case F2) before this column existed.
Ordering is done in TypeScript (sortByOrder, ascending order, ties by array position — a
stable sort), never in SQL. (gallery->>'order')::int raises Postgres 22P02 on a malformed
value, which would 500 a public product-detail read for one bad row.
Customer contract — variants[].media (see API §Variants):
{
thumbnail: { url: string; alt: string | null } | null;
gallery: { url: string; alt: string | null; order: number }[];
}thumbnail: null with an empty gallery is the normal "this variant carries no media of its own"
case, not an error. An unresolvable key (the publicOnly guard refused it, or signing failed) is
dropped from the gallery and never emitted as { url: "" } — an empty string is truthy, so a
caller checking if (media.thumbnail) would render <img src=""> and the browser would
re-request the page URL as an image instead of falling back to the product thumbnail. Found by
probing a private key against the live endpoint: type-check and the full unit and integration
suite all pass with the empty string, because "" satisfies url: string.
Variant media keys resolve through StorageUrlResolver.resolveMany(keys, { publicOnly: true }) —
see §12 for the guard. product-variant-customer.service.ts collects one deduplicated Set of
keys per variant batch (thumbnail sources plus every gallery item) and resolves it once, rather
than once per occurrence.
5.5 Video source: key vs embedUrl
product.videos[] items carry two mutually exclusive sources — never both mean the same thing:
| Field | Renders as | Constraint |
|---|---|---|
key | <video src> | A public/-prefixed storage key (IsStorageKey) |
embedUrl | <iframe> | Absolute https:// on the allowlist www.youtube.com, www.youtube-nocookie.com, player.vimeo.com |
Exactly one must be present. Neither, or both, is a 400 at write time —
PRODUCT_VIDEO_SOURCE_REQUIRED / PRODUCT_VIDEO_SOURCE_CONFLICT
(IsExactlyOneVideoSource, product-media-item.dto.ts).
youtu.be is deliberately excluded from the allowlist — it is a share host, not an embed host, and
is absent from both apps' CSP frame-src. Accepting it would pass write-time validation and then
render nothing under CSP, with only a console line to explain why.
The control that matters is read-side, in resolveVideoMedia (same file as §5.4): every read
re-checks the host allowlist against item.embedUrl, and a legacy row whose key still holds an
absolute value (written before the split existed) is promoted to embedUrl only when its host is
allowlisted, and dropped — never rendered anywhere — otherwise. A write-time validator can be
bypassed by a data fix-up; a read-time guard cannot, because it runs on every request.
6. Services and Responsibilities
6.1 ProductWriteService
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
create() | POST /api/admin/products | classification refs | product, product_slug, inventory, seo | activity, cache invalidation | PRODUCT_CATEGORY_NOT_FOUND, PRODUCT_BRAND_NOT_FOUND, PRODUCT_SERIES_*, PRODUCT_SKU_ALREADY_EXISTS |
update() | PATCH /:publicId | row + version | product | activity, cache | PRODUCT_VERSION_CONFLICT, PRODUCT_ALREADY_DELETED |
delete() / restore() | DELETE, POST /restore | row | deleted_at | activity, cache | PRODUCT_ALREADY_DELETED / PRODUCT_NOT_DELETED |
Transaction boundaries: create writes product + slug + inventory row + SEO link in one transaction. Optimistic lock: every write bumps version and rejects a stale caller-supplied version.
6.2 ProductWriteFacetsService
Seven single-purpose PATCHes, each: assert editable (not archived → 409 PRODUCT_ARCHIVED_NOT_EDITABLE), apply, bump version, activity + cache. updateStock reads the inventory row FOR SHARE inside the transaction (inventory locked before product is the repo-wide ordering) and narrows via resolveStockStatusForWrite.
6.3 ProductStorefrontQueryService
The one listing engine. Builds filters via buildProductFilterConditions with storefrontLive: true (inlined status = 'published' AND deleted_at IS NULL literal), keyset-paginates on (sortKey, id), over-fetches limit + 1 to compute hasMore without a COUNT. Computed sorts (discount, relevance) use a hand-rolled keyset WHERE; a cursor replayed against a different sort throws 400 PAGINATION_CURSOR_INVALID.
6.4 ProductWriteSupportService
Memoised classification lookups: three queries warm the whole batch instead of three per row. null is a cached miss (distinct from undefined = not looked up). The import write pass warms its own cache inside its transaction so the soft-delete re-check reads the transaction's snapshot.
6.5 ProductLifecycleService
Pure state machine — see Runtime Flows §7.1. Returns what the caller must apply (nextStatus, setPublishedAtNow, robotsIndex); the caller applies it inside its own transaction.
7. Runtime Flows
7.1 Lifecycle transition
Legal transitions: draft → published | archived; published → unlisted | archived | draft; unlisted → published | archived | draft; archived → draft (read-only otherwise). Into published/unlisted requires a thumbnail; published_at is stamped once and never cleared; robots_index is true only for published, forced on every write path.
7.2 Storefront listing
Listings call ProductCardAssembler.assembleCards, not ProductResponseAssembler.assembleMany. That is 4 batched reads instead of 10 — no category, category-slug, brand-series, brand-series-slug, tag-join or seo read — and one signed storage URL per product instead of one per media item across all four media kinds. GET /:slug still uses the detail assembler, which is why the product page keeps its gallery, specifications and SEO block.
7.3 Import job
7.4 Cooperative cancellation
A processing import re-reads its own job status inside the write transaction and aborts (rolls back) the moment the row is no longer processing; the completing write returns its affected-row count and throws PRODUCT_JOB_NOT_PROCESSING on zero.
8. Money
All amounts are integer minor units (1000 = NPR 10.00), stored as bigint, never float or string. There is no tax field and none is to be added — tax is already included in MRP and selling price. discount/discountPercentage are derived (mrp - sellingPrice), never stored; "on sale" means sellingPrice < mrp. The money utility (utils/money/money.util.ts) is the first and only money representation in the codebase; CLAUDE.md forbids a second.
9. Filtering and Cursor Pagination
- Every filter optional, AND-composed by
buildProductFilterConditions:categorySlug(includes descendants via recursive CTE),brandSlug,seriesSlug,tags(AND semantics — every tag required; empty resolved set ⇒ empty page),minPrice/maxPrice(inclusive minor units),stockStatusarray,onSale, four discovery flags,q(name/description/sku, min 2 chars). - Sorts:
newest(default),priceAsc,priceDesc,discount(computedmrp - selling_price),relevance(search only; falls back tonewestwithout a usable term). - Cursor pagination on
(sortKey, id);limitdefault 20, max 100; opaque cursor, sort-bound (400 on mismatch). - Literal-predicate rule: every storefront index is partial on
status = 'published' AND deleted_at IS NULL; the query must inline the literal because a bound parameter is proven (EXPLAIN, 20k rows,force_generic_plan) to seq-scan the whole table. Never bind the status on a storefront read.
9.1 onSale is a union, not a single predicate
onSale=true returns catalogue markdown (sellingPrice < mrp) union any product covered by a
live special-deal campaign — a product priced at MRP whose whole discount comes from a campaign
target (product, brand, or brand series) is included even though it fails the price comparison.
buildOnSaleCondition (apps/api/src/modules/products/shared/product-filter.builder.ts:254-287,
called from buildProductFilterConditions:172-181) resolves the live deal index before the
query and injects the ids as SQL predicates — it does not post-filter query results, because
post-filtering can only remove rows the database already returned and a campaign-only product is
never returned by a bare price comparison in the first place.
onSale=true selling_price < mrp
OR id = ANY($dealProductIds)
OR brand_id = ANY($dealBrandIds)
OR brand_series_id = ANY($dealSeriesIds)
onSale=false selling_price >= mrp
AND id <> ALL($dealProductIds)
AND coalesce(brand_id, -1) <> ALL($dealBrandIds)
AND coalesce(brand_series_id, -1) <> ALL($dealSeriesIds)brand_id and brand_series_id are nullable FKs. A bare brand_id <> ALL($ids) evaluates to
NULL for a brandless product, which drops that row from both the onSale=true and
onSale=false pages — so the onSale=false branch wraps both columns in coalesce(..., -1). A
fixture with brand_id IS NULL exists in the filter-builder spec specifically so this regresses
loudly if the coalesce is ever removed.
MAX_ON_SALE_DEAL_IDS = 500 (apps/api/src/modules/products/shared/products.constants.ts:134)
caps the combined product+brand+series id count injected into the query. On overflow, or on any
Redis/DB failure resolving the live-deal index,
product-storefront-query.service.ts degrades to the plain selling_price < mrp predicate and logs
a warn — it never throws. A degraded sale page (fewer results) is preferred over a 500.
A degraded page must never be written to the cache, and the result carries a degraded: boolean
so a caller can tell. Caching it would keep serving a /sale page missing every campaign-only
product for the full 300-second TTL after the dependency recovered, when the very next request
could have produced the correct page for free — turning one transient Redis blip into five minutes
of wrong pricing.
All three surfaces that cache one of these results go through
RedisCacheService.getOrSetIf(key, produce, isCacheableQueryResult, ttl): the storefront list()
and search() and the discovery feeds. getOrSet writes its callback's return unconditionally —
that is its contract, not a defect — so it is the wrong primitive for a producer that can return a
knowingly wrong answer.
isCacheableQueryResult lives beside the degraded flag it tests
(product-storefront-query.service.ts) rather than at each call site, because the rule is a
property of the result and not of any one endpoint. Duplicating the !degraded test is how one of
the three eventually forgets — which is exactly what had happened: the discovery feeds got it right
and the two storefront surfaces did not.
sort=discount still orders by mrp - selling_price, so a campaign-only product (which has no
price gap) sorts last under "biggest saving" even though its card shows the campaign discount. This
is accepted, not a bug — recorded here rather than left silent.
idx_product_live_on_sale was dropped in migration 0035_drop_unusable_on_sale_index.sql
(DROP INDEX "idx_product_live_on_sale"). That partial index's predicate was selling_price < mrp;
a disjunction does not imply that predicate — a campaign-only row satisfies the query while failing
the comparison the index was built on — so Postgres cannot use a partial index in a disjunctive
query, at any data volume. Measured with EXPLAIN (enable_seqscan = off) against the seeded test
DB: before this change the old predicate used the index via a bitmap scan; after, the planner
ignores it entirely and falls back to idx_product_live_brand_created, applying the disjunction as
a post-scan filter. buildOnSaleCondition has exactly one caller
(buildProductFilterConditions), and that function has exactly three production call sites — admin,
storefront query, and customer product services — so the index had no other consumer and was pure
write amplification once the predicate it matched stopped being produced. See
packages/db/src/schema/products/product.ts:477-499 for the removal note and what to index instead
if /sale performance needs it later.
10. Jobs
| Queue | Jobs | Contract |
|---|---|---|
PRODUCTS | product.import_entities, product.export_entities | CatalogImportPayload-style { jobPublicId, entity, sourceFileUrl } / { jobPublicId, entity, filters } |
- Outbox enqueued — the job row and its scheduling commit together; direct
queue.add()in a handler is a defect. - Import: CSV/XLSX, 25 MB / 50,000 rows;
skurequired and matches a live product ⇒ update, else create asdraft; category/brand/series referenced by public id; all-or-nothing validation. - Export: CSV; > 50,000 matching rows fails (
PRODUCT_EXPORT_TOO_LARGE); zero-row export succeeds with a header-only file. - Lease-based claim; stalled sweep after the claim TTL; cooperative cancellation;
isStalledderived flag on detail. PRODUCTSis registered inREGISTERED_QUEUES(unlikeCATALOG/OUTBOX) so relayed jobs get env-configureddefaultJobOptionsinstead of BullMQ's bareattempts: 1.
11. Cache
| Domain | Revalidation tags | Redis patterns |
|---|---|---|
product | product:* tags + page:catalog where relevant | product:* |
catalog_tag | tag tags | catalog:tag:* |
Every admin mutation triggers triggerForWrite after commit; storefront reads are Redis-cached with CACHE_TTL.STANDARD (300s), and every key is built by CacheKeyUtil.build(PRODUCT_CACHE_PREFIX, …) with an op segment (list, search, suggest, detail, discovery). They are cleared by product and tag writes.
Variant writes count as product writes, and this is the part that was missing.
ProductVariantAdminService create / update / delete now call
ProductWriteSupportService.afterMutation after the transaction commits, exactly as every other
product write path does. Variants are where the price lives — product.selling_price and
product.mrp are rollups maintained by trg_product_variant_rollup_* — and where the stock row
lives, so a variant write moves precisely what this domain exists to clear. Until this was wired,
changing a price through the variant editor cleared neither Redis nor the storefront's Next cache,
and the site kept quoting the old price for a full TTL with nothing in any log to say so.
The domain named is product, not inventory. Both registrations clear identical tags and
identical Redis patterns today — deliberately duplicated rather than aliased so they can diverge
later — so naming both would do the same work twice and clear nothing extra. The writer names its
own domain.
The call sits after the commit. Inside the transaction it would fire for a write that may still roll back, and a network call inside a transaction is forbidden anyway. The variant spec asserts both directions: a purge on each successful write, and no purge when the write is refused by the optimistic lock or by the last-live-variant rule — those negative cases are what prove the placement rather than merely the presence.
PRODUCT_CACHE_PREFIX is product:v2: (shared/products.constants.ts), which sits inside the product:* invalidation glob above — so versioning the prefix does not break clearing.
The v2 versions the SERIALIZED SHAPE, not the data. Bump it whenever a product read's response shape changes. These endpoints cache a serialized response body, so a shape change with no bump means the same endpoint answers warm keys with the old shape and cold keys with the new one for a full 300-second TTL after deploy — non-deterministic, indistinguishable to the client, and invisible to the integration specs, which substitute a pass-through cache. v1 was the fat detail shape on listings; v2 is the card shape. Orphaned v1 keys are not deleted, they expire.
12. Security and Authorization
- Admin routes:
JwtAuthGuard+RoleGuardwithProducts_CREATE/READ/UPDATE/DELETE/RESTOREandTags_*permission codes;superadminbypasses. - Import submit re-checks the entity-specific permission pair (
Products_CREATEandProducts_UPDATE) because the route-level token alone would let a non-editor mass-create/update through a file (403PRODUCT_JOB_ENTITY_PERMISSION_DENIED). - Idempotency: create/restore/bulk/job-submit require an
Idempotency-Keyheader; replay returns the original response. - Rate limits per surface:
ADMIN_READ30/min,ADMIN_WRITE10/min,ADMIN_BULK_WRITE5/min,ADMIN_ASYNC_JOB_SUBMIT10/hour,PUBLIC_READ60/min,PUBLIC_SEARCH60/min (guest-facing),PUBLIC_SUGGEST300/min (guest-facing),PUBLIC_HIGH_FREQUENCY300/min (feeds). - Public surfaces never expose the integer PK; slugs are owned by
product_slugso a retired URL cannot be re-pointed. - Every storage key field (
thumbnailKey, gallery/video/attachmentkey) is validated byIsStorageKey()(apps/api/src/common/validators/storage-key.validator.ts) againstSTORAGE_KEY_PATTERN— anchored, case-sensitive,public/-only:^public\/[A-Za-z0-9][A-Za-z0-9._-]*(?:\/[A-Za-z0-9][A-Za-z0-9._-]*)*$. A path segment must start[A-Za-z0-9], so..is unrepresentable by construction. Case matters because the S3 driver testsstartsWith("public/")byte-exactly — aPublic/…key would miss the public branch and fall through togetSignedUrl, becoming a valid pre-signed URL for a key that was never meant to be public. StorageUrlResolver.resolve()/resolveMany()take{ publicOnly: true }on every customer-facing call site. A key that fails the fullSTORAGE_KEY_PATTERN(not just apublic/prefix check) resolves tonulland is never signed — logged at WARN, since a guard rejection is a security control firing, not a silent failure. Every admin call site deliberately omits it: an operator legitimately previews objects that are not yet public, and the response there carries the rawkeyalongside the resolvedurlfor exactly that reason — customer responses never do.resolveMany()isolates failures per key: one signing failure no longer rejects the wholePromise.alland 500s an entire product page. It also deduplicates the key set with aSetbefore resolving, so a repeated key (a shared placeholder thumbnail, the same swatch across several variants) is signed once.