catalog
The product catalog: Products, ProductVariants, Categories, ProductAttributes, and SalesChannels. Owns all read paths the storefront depends on and the admin-side authoring surface.
Public surface
Admin routes are gated by catalog:read (list / get) /
catalog:write (mutations).
| Verb + Path | Audience | Purpose |
|---|---|---|
GET /api/v1/catalog/products | storefront / API key | List/search/filter products in the active Sales Channel |
GET /api/v1/catalog/products/:idOrSlug | storefront | Product detail (price omitted on non-public Sales Channels) |
GET /api/v1/catalog/categories | storefront | Nested category tree |
GET /api/v1/catalog/filters | storefront | Filterable attributes for the active Sales Channel |
GET /api/v1/catalog/sitemap.xml | crawlers | SEO sitemap |
GET /api/v1/admin/catalog/products?includeArchived | admin | Admin product list (includes drafts; archived rows opt-in) |
GET /api/v1/admin/catalog/products/:id | admin | Product detail |
POST /api/v1/admin/catalog/products | admin | Create product (type immutable post-create; sku is editable) |
PATCH /api/v1/admin/catalog/products/:id | admin | Update (incl. sku); writes an audit row with stateBefore / stateAfter; refuses with 409 sku_in_use if the new SKU already belongs to another product |
DELETE /api/v1/admin/catalog/products/:id | admin | Archive (soft) |
GET /api/v1/admin/catalog/attributes | admin | List attributes |
GET /api/v1/admin/catalog/attributes/by-flag?flag=isPromoRule|isComparable|... | admin | Picker payload — every attribute carrying the requested flag |
GET /api/v1/admin/catalog/attributes/:idOrKey | admin | Single attribute read |
POST /api/v1/admin/catalog/attributes | admin | Create attribute (accepts the new flags + inline options[] for select-style types) |
PATCH /api/v1/admin/catalog/attributes/:key | admin | Hot-toggle isFilterable / isSearchable / isVariantAxis / isPromoRule / isComparable / isVisibleOnProductPage / isRequired / filterPosition (re-emits attribute.updated.v1) |
DELETE /api/v1/admin/catalog/attributes/:idOrKey | admin | Delete; refused with 409 attribute_in_use_by_set while any Attribute Set still references it |
GET /api/v1/admin/catalog/attributes/:idOrKey/options | admin | List option-list rows for select/enum/multiselect attributes |
POST /api/v1/admin/catalog/attributes/:idOrKey/options | admin | Append an option |
PATCH /api/v1/admin/catalog/attribute-options/:optionId | admin | Patch label / labelDefault / isDefault / sortOrder (option value is immutable) |
DELETE /api/v1/admin/catalog/attribute-options/:optionId | admin | Remove; refused with 409 option_in_use while any product still carries the value |
POST /api/v1/admin/catalog/attribute-set-preview | admin | Preview which Set's attributes will be edited / hidden when an operator switches a product's Attribute Set |
GET /api/v1/admin/catalog/categories | admin | Flat list, the UI folds into a tree |
POST /api/v1/admin/catalog/categories | admin | Create (parent must exist) |
PATCH /api/v1/admin/catalog/categories/:id | admin | Update; reparenting walks the new parent's chain to refuse cycles (409) |
DELETE /api/v1/admin/catalog/categories/:id | admin | Soft-delete; rejects with 409 if any active child still references the row |
PUT /api/v1/catalog/products/by-sku/:sku | API key | Idempotent upsert (PIM sync) |
Entities
Product, ProductVariant, Category, ProductAttribute,
SalesChannel, plus the M:N bridges
product_categories, sales_channel_products, product_assets.
Events emitted
product.created.v1, product.updated.v1, product.archived.v1,
attribute.updated.v1. Picked up by the search indexer and bridged to
webhook subscribers.
Extension points
- Per-Sales-Channel pricing — query service receives a SalesChannel
context; new gating (e.g. customer-segment-specific catalogs) is added by
composing into
catalog-query.service.ts. - Slug uniqueness — the slug is unique across all Sales Channels by
default; override the slugifier in
catalog-admin.service.tsif locale collisions become a concern.
Product structure and composition surfaces
The catalog grew several capability surfaces, each with its own page:
- Attribute Sets — reusable attribute schemas pinned to Products, with a system Default
- Product Gallery — image / video gallery with Base / Small / Thumbnail label invariants enforced at the database level
- Attachments — downloadable files (certificates, tech specs, ...) with a typed dictionary
- Product Links — Related, Up-sell, Cross-sell pairings driving cross-merchandising on the PDP and cart
- Composite Products —
grouped(fixed children),bundle(configurable slots),virtual(digital delivery)
Five product types are now supported: simple, configurable,
grouped, bundle, virtual. simple and configurable are the
original pair; the other three were added later.
Attribute extensions on the Catalog read paths
The Attributes work added the operational surface the storefront needs to render rich product information and the search / promotions modules need to resolve customer queries. The dedicated Attributes page covers the attribute authoring surface in full — this section only summarises what changed on the Catalog read paths.
New attribute flags
ProductAttribute gains four behavioural flags + a numeric position +
a per-locale label fallback:
isPromoRule(boolean) — picker eligibility for the Promotion Rule editor'sattributecriterion variantisVisibleOnProductPage(boolean) — surface the attribute on the storefront PDP "Parametry produktu" tab when the product carries a valueisRequired(boolean) — enforced at product save time when the attribute is part of the product's Attribute SetfilterPosition(number) — sort key for the storefront filter sidebar (lower comes first; ties broken by label)labelDefault(string) — fallback used when the active locale has no matching key in the per-localelabelJSONB
Option lists
Select-style attribute types (select, enum, multiselect) carry an
ordered option list — each row keyed by (definition, value) with
per-locale label + fallback + sort order + default flag. The legacy
enum_values: string[] JSONB column on product_attributes was
decommissioned by migration 032 (into the catalog-owned
attribute_options table), and migration 102 moved the
rows into the generic custom_field_options table. Existing readers
project the option list back into the legacy form for backward
compatibility at the API boundary.
Editable SKU
Product sku is mutable. The internal canonical reference for every
cross-module link (assets, links, RFQ items, ...) is the Product.id
UUID, which never changes. Updating the SKU writes an audit row and
refuses with 409 sku_in_use if the new value already belongs to
another product.
Attribute Set swap
When an operator assigns a different Attribute Set to a Product, the
admin form re-renders to show only the new Set's attributes. Values
for attributes outside the new Set stay in the JSONB column server-side
— switching back surfaces them again. The
attribute-set-preview endpoint lets the editor warn the operator
which fields will be hidden vs. retained before they confirm.
Cross-module read surface
Two methods on CatalogQueryService cross module boundaries (the
documented service ports):
comparableAttributeKeys(): string[]— ComparepromoRuleAttributeKeys(): string[]+getAttributeWithOptions(key)— PromotionsbuildVisibleAttributesProjection()— internal, used by the PDP detail response to assemble thevisibleAttributes[]payload
Attributes as Custom Field extensions
The attribute definition store has converged onto the generic Custom
Fields layer that the custom_fields module owns, adapter-shaped rather
than rewritten. Nothing changed on the
HTTP surface — every endpoint above keeps its shape — but the storage
and ownership model is different:
- A product attribute is a catalog extension of a product-host Custom
Field definition. The generic identity (
key, per-localelabel+labelDefault,valueType,required) lives on acustom_field_definitionsrow withentity_type = 'product'. Theproduct_attributestable remains, rebuilt as a thin 1:1 extension row (custom_field_definition_idUNIQUE FK) carrying only the catalog behaviour flags (isSearchable,isFilterable,isVariantAxis,displayAsSlider,isComparable,quickSearchable,isPromoRule,filterPosition,isVisibleOnProductPage,channelScoped,languageScoped,massEditable) plus two presentation refinements (selectDisplay,numericKind) that keep the legacyenum/selectandnumber/pricedistinctions lossless. Flags stay catalog-owned — the generic core never interprets them. - Options live in
custom_field_options. The catalog-ownedattribute_optionstable is gone; option lists are ordinary Custom Field option rows on the product-host definition. - Single write surface:
/catalog/attributes. Attribute and option mutations are catalog Commands that create/update/delete the definition and the extension together in one transaction (one audit row), using the transactional apply seam exported bycustom_fields. The generic Custom Fields admin surface lists product definitions read-only and refuses mutations with409 host_managed. - Migration
102_attributes_on_custom_fields.tsperformed the one-time convergence in a single transaction: backfilled one definition per legacy attribute (key, labels, mapped value type, required, deterministic sort order), movedattribute_optionsrows intocustom_field_options, re-keyedattribute_set_attributesto definition ids, addedcustom_field_definition_id/select_display/numeric_kindtoproduct_attributes, dropped the duplicated columns (key,label,label_default,value_type,is_required), and droppedattribute_options. The migration is reversible (down()restores the legacy shape) and aborts loudly on a reserved-key collision. - Attribute values did not move —
products.attribute_values,product_variants.variant_attribute_values, andproduct_value_overrideskeep their shape and catalog ownership (the host owns its data).
Internal consumers (search, quick order, comparisons, bulk edit, the
promotions port, the scope editor) read attributes through the
catalog-exported CatalogAttributeReadService, which composes the
definition and the extension into the legacy-shaped
CatalogAttributeView.