@endora-commerce/mod-catalog 0.100.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +63 -0
- package/dist/admin/components/PackagingUnitsEditor.d.ts +12 -0
- package/dist/admin/components/PackagingUnitsEditor.d.ts.map +1 -0
- package/dist/admin/components/PackagingUnitsEditor.js +99 -0
- package/dist/admin/components/PackagingUnitsEditor.js.map +1 -0
- package/dist/admin/components/ProductAttributesTab.d.ts +13 -0
- package/dist/admin/components/ProductAttributesTab.d.ts.map +1 -0
- package/dist/admin/components/ProductAttributesTab.js +140 -0
- package/dist/admin/components/ProductAttributesTab.js.map +1 -0
- package/dist/admin/components/ProductInventoryTab.d.ts +14 -0
- package/dist/admin/components/ProductInventoryTab.d.ts.map +1 -0
- package/dist/admin/components/ProductInventoryTab.js +284 -0
- package/dist/admin/components/ProductInventoryTab.js.map +1 -0
- package/dist/admin/components/ProductScopeEditor.d.ts +94 -0
- package/dist/admin/components/ProductScopeEditor.d.ts.map +1 -0
- package/dist/admin/components/ProductScopeEditor.js +395 -0
- package/dist/admin/components/ProductScopeEditor.js.map +1 -0
- package/dist/admin/components/ProductsBulkEditDialog.d.ts +11 -0
- package/dist/admin/components/ProductsBulkEditDialog.d.ts.map +1 -0
- package/dist/admin/components/ProductsBulkEditDialog.js +304 -0
- package/dist/admin/components/ProductsBulkEditDialog.js.map +1 -0
- package/dist/admin/index.d.ts +45 -0
- package/dist/admin/index.d.ts.map +1 -0
- package/dist/admin/index.js +184 -0
- package/dist/admin/index.js.map +1 -0
- package/dist/admin/lib/resolve-product-selection.d.ts +21 -0
- package/dist/admin/lib/resolve-product-selection.d.ts.map +1 -0
- package/dist/admin/lib/resolve-product-selection.js +25 -0
- package/dist/admin/lib/resolve-product-selection.js.map +1 -0
- package/dist/admin/pages/AttachmentTypesPage.d.ts +18 -0
- package/dist/admin/pages/AttachmentTypesPage.d.ts.map +1 -0
- package/dist/admin/pages/AttachmentTypesPage.js +92 -0
- package/dist/admin/pages/AttachmentTypesPage.js.map +1 -0
- package/dist/admin/pages/AttributeSetsPage.d.ts +21 -0
- package/dist/admin/pages/AttributeSetsPage.d.ts.map +1 -0
- package/dist/admin/pages/AttributeSetsPage.js +158 -0
- package/dist/admin/pages/AttributeSetsPage.js.map +1 -0
- package/dist/admin/pages/AttributesManager.d.ts +9 -0
- package/dist/admin/pages/AttributesManager.d.ts.map +1 -0
- package/dist/admin/pages/AttributesManager.js +305 -0
- package/dist/admin/pages/AttributesManager.js.map +1 -0
- package/dist/admin/pages/BulkOperationDetailPage.d.ts +9 -0
- package/dist/admin/pages/BulkOperationDetailPage.d.ts.map +1 -0
- package/dist/admin/pages/BulkOperationDetailPage.js +187 -0
- package/dist/admin/pages/BulkOperationDetailPage.js.map +1 -0
- package/dist/admin/pages/BulkOperationsPage.d.ts +9 -0
- package/dist/admin/pages/BulkOperationsPage.d.ts.map +1 -0
- package/dist/admin/pages/BulkOperationsPage.js +98 -0
- package/dist/admin/pages/BulkOperationsPage.js.map +1 -0
- package/dist/admin/pages/CategoriesTree.d.ts +9 -0
- package/dist/admin/pages/CategoriesTree.d.ts.map +1 -0
- package/dist/admin/pages/CategoriesTree.js +176 -0
- package/dist/admin/pages/CategoriesTree.js.map +1 -0
- package/dist/admin/pages/ProductEditor.d.ts +13 -0
- package/dist/admin/pages/ProductEditor.d.ts.map +1 -0
- package/dist/admin/pages/ProductEditor.js +1050 -0
- package/dist/admin/pages/ProductEditor.js.map +1 -0
- package/dist/admin/pages/ProductsList.d.ts +9 -0
- package/dist/admin/pages/ProductsList.d.ts.map +1 -0
- package/dist/admin/pages/ProductsList.js +399 -0
- package/dist/admin/pages/ProductsList.js.map +1 -0
- package/dist/backend/commands/attribute-commands.d.ts +111 -0
- package/dist/backend/commands/attribute-commands.d.ts.map +1 -0
- package/dist/backend/commands/attribute-commands.js +482 -0
- package/dist/backend/commands/attribute-commands.js.map +1 -0
- package/dist/backend/demo/reset.d.ts +23 -0
- package/dist/backend/demo/reset.d.ts.map +1 -0
- package/dist/backend/demo/reset.js +41 -0
- package/dist/backend/demo/reset.js.map +1 -0
- package/dist/backend/demo/rows.d.ts +103 -0
- package/dist/backend/demo/rows.d.ts.map +1 -0
- package/dist/backend/demo/rows.js +162 -0
- package/dist/backend/demo/rows.js.map +1 -0
- package/dist/backend/demo/seed.d.ts +35 -0
- package/dist/backend/demo/seed.d.ts.map +1 -0
- package/dist/backend/demo/seed.js +177 -0
- package/dist/backend/demo/seed.js.map +1 -0
- package/dist/backend/entities/attachment-type.entity.d.ts +16 -0
- package/dist/backend/entities/attachment-type.entity.d.ts.map +1 -0
- package/dist/backend/entities/attachment-type.entity.js +57 -0
- package/dist/backend/entities/attachment-type.entity.js.map +1 -0
- package/dist/backend/entities/attribute-set-attribute.entity.d.ts +26 -0
- package/dist/backend/entities/attribute-set-attribute.entity.d.ts.map +1 -0
- package/dist/backend/entities/attribute-set-attribute.entity.js +55 -0
- package/dist/backend/entities/attribute-set-attribute.entity.js.map +1 -0
- package/dist/backend/entities/attribute-set.entity.d.ts +22 -0
- package/dist/backend/entities/attribute-set.entity.d.ts.map +1 -0
- package/dist/backend/entities/attribute-set.entity.js +67 -0
- package/dist/backend/entities/attribute-set.entity.js.map +1 -0
- package/dist/backend/entities/bulk-operation.entity.d.ts +78 -0
- package/dist/backend/entities/bulk-operation.entity.d.ts.map +1 -0
- package/dist/backend/entities/bulk-operation.entity.js +138 -0
- package/dist/backend/entities/bulk-operation.entity.js.map +1 -0
- package/dist/backend/entities/bundle-slot-option.entity.d.ts +23 -0
- package/dist/backend/entities/bundle-slot-option.entity.d.ts.map +1 -0
- package/dist/backend/entities/bundle-slot-option.entity.js +68 -0
- package/dist/backend/entities/bundle-slot-option.entity.js.map +1 -0
- package/dist/backend/entities/bundle-slot.entity.d.ts +25 -0
- package/dist/backend/entities/bundle-slot.entity.d.ts.map +1 -0
- package/dist/backend/entities/bundle-slot.entity.js +74 -0
- package/dist/backend/entities/bundle-slot.entity.js.map +1 -0
- package/dist/backend/entities/category.entity.d.ts +26 -0
- package/dist/backend/entities/category.entity.d.ts.map +1 -0
- package/dist/backend/entities/category.entity.js +119 -0
- package/dist/backend/entities/category.entity.js.map +1 -0
- package/dist/backend/entities/gallery-item-label.entity.d.ts +22 -0
- package/dist/backend/entities/gallery-item-label.entity.d.ts.map +1 -0
- package/dist/backend/entities/gallery-item-label.entity.js +50 -0
- package/dist/backend/entities/gallery-item-label.entity.js.map +1 -0
- package/dist/backend/entities/gallery-item.entity.d.ts +17 -0
- package/dist/backend/entities/gallery-item.entity.d.ts.map +1 -0
- package/dist/backend/entities/gallery-item.entity.js +58 -0
- package/dist/backend/entities/gallery-item.entity.js.map +1 -0
- package/dist/backend/entities/grouped-item.entity.d.ts +23 -0
- package/dist/backend/entities/grouped-item.entity.d.ts.map +1 -0
- package/dist/backend/entities/grouped-item.entity.js +68 -0
- package/dist/backend/entities/grouped-item.entity.js.map +1 -0
- package/dist/backend/entities/product-attachment.entity.d.ts +23 -0
- package/dist/backend/entities/product-attachment.entity.d.ts.map +1 -0
- package/dist/backend/entities/product-attachment.entity.js +77 -0
- package/dist/backend/entities/product-attachment.entity.js.map +1 -0
- package/dist/backend/entities/product-attribute.entity.d.ts +72 -0
- package/dist/backend/entities/product-attribute.entity.d.ts.map +1 -0
- package/dist/backend/entities/product-attribute.entity.js +161 -0
- package/dist/backend/entities/product-attribute.entity.js.map +1 -0
- package/dist/backend/entities/product-editor-preference.entity.d.ts +28 -0
- package/dist/backend/entities/product-editor-preference.entity.d.ts.map +1 -0
- package/dist/backend/entities/product-editor-preference.entity.js +63 -0
- package/dist/backend/entities/product-editor-preference.entity.js.map +1 -0
- package/dist/backend/entities/product-link.entity.d.ts +25 -0
- package/dist/backend/entities/product-link.entity.d.ts.map +1 -0
- package/dist/backend/entities/product-link.entity.js +70 -0
- package/dist/backend/entities/product-link.entity.js.map +1 -0
- package/dist/backend/entities/product-packaging-unit.entity.d.ts +26 -0
- package/dist/backend/entities/product-packaging-unit.entity.d.ts.map +1 -0
- package/dist/backend/entities/product-packaging-unit.entity.js +76 -0
- package/dist/backend/entities/product-packaging-unit.entity.js.map +1 -0
- package/dist/backend/entities/product-value-override.entity.d.ts +43 -0
- package/dist/backend/entities/product-value-override.entity.d.ts.map +1 -0
- package/dist/backend/entities/product-value-override.entity.js +90 -0
- package/dist/backend/entities/product-value-override.entity.js.map +1 -0
- package/dist/backend/entities/product-variant.entity.d.ts +18 -0
- package/dist/backend/entities/product-variant.entity.d.ts.map +1 -0
- package/dist/backend/entities/product-variant.entity.js +68 -0
- package/dist/backend/entities/product-variant.entity.js.map +1 -0
- package/dist/backend/entities/product.entity.d.ts +108 -0
- package/dist/backend/entities/product.entity.d.ts.map +1 -0
- package/dist/backend/entities/product.entity.js +230 -0
- package/dist/backend/entities/product.entity.js.map +1 -0
- package/dist/backend/index.d.ts +262 -0
- package/dist/backend/index.d.ts.map +1 -0
- package/dist/backend/index.js +642 -0
- package/dist/backend/index.js.map +1 -0
- package/dist/backend/plugin.d.ts +216 -0
- package/dist/backend/plugin.d.ts.map +1 -0
- package/dist/backend/plugin.js +223 -0
- package/dist/backend/plugin.js.map +1 -0
- package/dist/backend/prompt-tools.d.ts +47 -0
- package/dist/backend/prompt-tools.d.ts.map +1 -0
- package/dist/backend/prompt-tools.js +284 -0
- package/dist/backend/prompt-tools.js.map +1 -0
- package/dist/backend/routes.admin.d.ts +105 -0
- package/dist/backend/routes.admin.d.ts.map +1 -0
- package/dist/backend/routes.admin.js +1162 -0
- package/dist/backend/routes.admin.js.map +1 -0
- package/dist/backend/routes.api-key.d.ts +33 -0
- package/dist/backend/routes.api-key.d.ts.map +1 -0
- package/dist/backend/routes.api-key.js +43 -0
- package/dist/backend/routes.api-key.js.map +1 -0
- package/dist/backend/routes.external.d.ts +32 -0
- package/dist/backend/routes.external.d.ts.map +1 -0
- package/dist/backend/routes.external.js +115 -0
- package/dist/backend/routes.external.js.map +1 -0
- package/dist/backend/routes.public.d.ts +46 -0
- package/dist/backend/routes.public.d.ts.map +1 -0
- package/dist/backend/routes.public.js +318 -0
- package/dist/backend/routes.public.js.map +1 -0
- package/dist/backend/services/asset-references.d.ts +4 -0
- package/dist/backend/services/asset-references.d.ts.map +1 -0
- package/dist/backend/services/asset-references.js +104 -0
- package/dist/backend/services/asset-references.js.map +1 -0
- package/dist/backend/services/attachment.service.d.ts +45 -0
- package/dist/backend/services/attachment.service.d.ts.map +1 -0
- package/dist/backend/services/attachment.service.js +268 -0
- package/dist/backend/services/attachment.service.js.map +1 -0
- package/dist/backend/services/attribute-option-validator.d.ts +47 -0
- package/dist/backend/services/attribute-option-validator.d.ts.map +1 -0
- package/dist/backend/services/attribute-option-validator.js +69 -0
- package/dist/backend/services/attribute-option-validator.js.map +1 -0
- package/dist/backend/services/attribute-set-validations.d.ts +65 -0
- package/dist/backend/services/attribute-set-validations.d.ts.map +1 -0
- package/dist/backend/services/attribute-set-validations.js +89 -0
- package/dist/backend/services/attribute-set-validations.js.map +1 -0
- package/dist/backend/services/attribute-set.service.d.ts +50 -0
- package/dist/backend/services/attribute-set.service.d.ts.map +1 -0
- package/dist/backend/services/attribute-set.service.js +401 -0
- package/dist/backend/services/attribute-set.service.js.map +1 -0
- package/dist/backend/services/attribute-type-mapping.d.ts +66 -0
- package/dist/backend/services/attribute-type-mapping.d.ts.map +1 -0
- package/dist/backend/services/attribute-type-mapping.js +121 -0
- package/dist/backend/services/attribute-type-mapping.js.map +1 -0
- package/dist/backend/services/attribute-value-key.service.d.ts +48 -0
- package/dist/backend/services/attribute-value-key.service.d.ts.map +1 -0
- package/dist/backend/services/attribute-value-key.service.js +118 -0
- package/dist/backend/services/attribute-value-key.service.js.map +1 -0
- package/dist/backend/services/audit-references.d.ts +13 -0
- package/dist/backend/services/audit-references.d.ts.map +1 -0
- package/dist/backend/services/audit-references.js +51 -0
- package/dist/backend/services/audit-references.js.map +1 -0
- package/dist/backend/services/bulk-operation-queue.d.ts +26 -0
- package/dist/backend/services/bulk-operation-queue.d.ts.map +1 -0
- package/dist/backend/services/bulk-operation-queue.js +34 -0
- package/dist/backend/services/bulk-operation-queue.js.map +1 -0
- package/dist/backend/services/bulk-operation.service.d.ts +195 -0
- package/dist/backend/services/bulk-operation.service.d.ts.map +1 -0
- package/dist/backend/services/bulk-operation.service.js +510 -0
- package/dist/backend/services/bulk-operation.service.js.map +1 -0
- package/dist/backend/services/bundle.service.d.ts +87 -0
- package/dist/backend/services/bundle.service.d.ts.map +1 -0
- package/dist/backend/services/bundle.service.js +286 -0
- package/dist/backend/services/bundle.service.js.map +1 -0
- package/dist/backend/services/catalog-admin.service.d.ts +398 -0
- package/dist/backend/services/catalog-admin.service.d.ts.map +1 -0
- package/dist/backend/services/catalog-admin.service.js +1516 -0
- package/dist/backend/services/catalog-admin.service.js.map +1 -0
- package/dist/backend/services/catalog-attribute-read.service.d.ts +78 -0
- package/dist/backend/services/catalog-attribute-read.service.d.ts.map +1 -0
- package/dist/backend/services/catalog-attribute-read.service.js +216 -0
- package/dist/backend/services/catalog-attribute-read.service.js.map +1 -0
- package/dist/backend/services/catalog-bulk-import.service.d.ts +30 -0
- package/dist/backend/services/catalog-bulk-import.service.d.ts.map +1 -0
- package/dist/backend/services/catalog-bulk-import.service.js +136 -0
- package/dist/backend/services/catalog-bulk-import.service.js.map +1 -0
- package/dist/backend/services/catalog-bulk-update.service.d.ts +91 -0
- package/dist/backend/services/catalog-bulk-update.service.d.ts.map +1 -0
- package/dist/backend/services/catalog-bulk-update.service.js +330 -0
- package/dist/backend/services/catalog-bulk-update.service.js.map +1 -0
- package/dist/backend/services/catalog-category-read.service.d.ts +66 -0
- package/dist/backend/services/catalog-category-read.service.d.ts.map +1 -0
- package/dist/backend/services/catalog-category-read.service.js +248 -0
- package/dist/backend/services/catalog-category-read.service.js.map +1 -0
- package/dist/backend/services/catalog-org-price-decorator.d.ts +90 -0
- package/dist/backend/services/catalog-org-price-decorator.d.ts.map +1 -0
- package/dist/backend/services/catalog-org-price-decorator.js +184 -0
- package/dist/backend/services/catalog-org-price-decorator.js.map +1 -0
- package/dist/backend/services/catalog-product-filter.service.d.ts +82 -0
- package/dist/backend/services/catalog-product-filter.service.d.ts.map +1 -0
- package/dist/backend/services/catalog-product-filter.service.js +275 -0
- package/dist/backend/services/catalog-product-filter.service.js.map +1 -0
- package/dist/backend/services/catalog-product-read.service.d.ts +46 -0
- package/dist/backend/services/catalog-product-read.service.d.ts.map +1 -0
- package/dist/backend/services/catalog-product-read.service.js +197 -0
- package/dist/backend/services/catalog-product-read.service.js.map +1 -0
- package/dist/backend/services/catalog-query.service.d.ts +444 -0
- package/dist/backend/services/catalog-query.service.d.ts.map +1 -0
- package/dist/backend/services/catalog-query.service.js +1633 -0
- package/dist/backend/services/catalog-query.service.js.map +1 -0
- package/dist/backend/services/catalog-quick-search.service.d.ts +78 -0
- package/dist/backend/services/catalog-quick-search.service.d.ts.map +1 -0
- package/dist/backend/services/catalog-quick-search.service.js +159 -0
- package/dist/backend/services/catalog-quick-search.service.js.map +1 -0
- package/dist/backend/services/catalog-write-ports.d.ts +25 -0
- package/dist/backend/services/catalog-write-ports.d.ts.map +1 -0
- package/dist/backend/services/catalog-write-ports.js +63 -0
- package/dist/backend/services/catalog-write-ports.js.map +1 -0
- package/dist/backend/services/category-admin.service.d.ts +102 -0
- package/dist/backend/services/category-admin.service.d.ts.map +1 -0
- package/dist/backend/services/category-admin.service.js +271 -0
- package/dist/backend/services/category-admin.service.js.map +1 -0
- package/dist/backend/services/gallery.service.d.ts +85 -0
- package/dist/backend/services/gallery.service.d.ts.map +1 -0
- package/dist/backend/services/gallery.service.js +326 -0
- package/dist/backend/services/gallery.service.js.map +1 -0
- package/dist/backend/services/grouped.service.d.ts +39 -0
- package/dist/backend/services/grouped.service.d.ts.map +1 -0
- package/dist/backend/services/grouped.service.js +136 -0
- package/dist/backend/services/grouped.service.js.map +1 -0
- package/dist/backend/services/label-resolver.d.ts +16 -0
- package/dist/backend/services/label-resolver.d.ts.map +1 -0
- package/dist/backend/services/label-resolver.js +43 -0
- package/dist/backend/services/label-resolver.js.map +1 -0
- package/dist/backend/services/packaging-unit.service.d.ts +28 -0
- package/dist/backend/services/packaging-unit.service.d.ts.map +1 -0
- package/dist/backend/services/packaging-unit.service.js +197 -0
- package/dist/backend/services/packaging-unit.service.js.map +1 -0
- package/dist/backend/services/primary-asset-url.d.ts +4 -0
- package/dist/backend/services/primary-asset-url.d.ts.map +1 -0
- package/dist/backend/services/primary-asset-url.js +55 -0
- package/dist/backend/services/primary-asset-url.js.map +1 -0
- package/dist/backend/services/product-editor-preferences.service.d.ts +20 -0
- package/dist/backend/services/product-editor-preferences.service.d.ts.map +1 -0
- package/dist/backend/services/product-editor-preferences.service.js +44 -0
- package/dist/backend/services/product-editor-preferences.service.js.map +1 -0
- package/dist/backend/services/product-link.service.d.ts +169 -0
- package/dist/backend/services/product-link.service.d.ts.map +1 -0
- package/dist/backend/services/product-link.service.js +297 -0
- package/dist/backend/services/product-link.service.js.map +1 -0
- package/dist/backend/services/product-overrides.service.d.ts +77 -0
- package/dist/backend/services/product-overrides.service.d.ts.map +1 -0
- package/dist/backend/services/product-overrides.service.js +204 -0
- package/dist/backend/services/product-overrides.service.js.map +1 -0
- package/dist/backend/services/product-scope-context.service.d.ts +36 -0
- package/dist/backend/services/product-scope-context.service.d.ts.map +1 -0
- package/dist/backend/services/product-scope-context.service.js +106 -0
- package/dist/backend/services/product-scope-context.service.js.map +1 -0
- package/dist/backend/services/product-type-validations.d.ts +42 -0
- package/dist/backend/services/product-type-validations.d.ts.map +1 -0
- package/dist/backend/services/product-type-validations.js +55 -0
- package/dist/backend/services/product-type-validations.js.map +1 -0
- package/dist/backend/services/product-value-resolver.service.d.ts +59 -0
- package/dist/backend/services/product-value-resolver.service.d.ts.map +1 -0
- package/dist/backend/services/product-value-resolver.service.js +111 -0
- package/dist/backend/services/product-value-resolver.service.js.map +1 -0
- package/dist/backend/services/system-attribute-scopes.d.ts +18 -0
- package/dist/backend/services/system-attribute-scopes.d.ts.map +1 -0
- package/dist/backend/services/system-attribute-scopes.js +18 -0
- package/dist/backend/services/system-attribute-scopes.js.map +1 -0
- package/dist/backend/services/viewer-organization.d.ts +25 -0
- package/dist/backend/services/viewer-organization.d.ts.map +1 -0
- package/dist/backend/services/viewer-organization.js +31 -0
- package/dist/backend/services/viewer-organization.js.map +1 -0
- package/dist/manifest.d.ts +204 -0
- package/dist/manifest.d.ts.map +1 -0
- package/dist/manifest.js +686 -0
- package/dist/manifest.js.map +1 -0
- package/dist/migrations/20260429T064146_catalog_attribute_sets_init.d.ts +33 -0
- package/dist/migrations/20260429T064146_catalog_attribute_sets_init.d.ts.map +1 -0
- package/dist/migrations/20260429T064146_catalog_attribute_sets_init.js +118 -0
- package/dist/migrations/20260429T064146_catalog_attribute_sets_init.js.map +1 -0
- package/dist/migrations/20260429T070004_catalog_product_attribute_extensions.d.ts +24 -0
- package/dist/migrations/20260429T070004_catalog_product_attribute_extensions.d.ts.map +1 -0
- package/dist/migrations/20260429T070004_catalog_product_attribute_extensions.js +31 -0
- package/dist/migrations/20260429T070004_catalog_product_attribute_extensions.js.map +1 -0
- package/dist/migrations/20260429T102322_catalog_product_type_and_virtual_fields.d.ts +32 -0
- package/dist/migrations/20260429T102322_catalog_product_type_and_virtual_fields.d.ts.map +1 -0
- package/dist/migrations/20260429T102322_catalog_product_type_and_virtual_fields.js +58 -0
- package/dist/migrations/20260429T102322_catalog_product_type_and_virtual_fields.js.map +1 -0
- package/dist/migrations/20260429T111839_catalog_gallery_items_and_labels.d.ts +22 -0
- package/dist/migrations/20260429T111839_catalog_gallery_items_and_labels.d.ts.map +1 -0
- package/dist/migrations/20260429T111839_catalog_gallery_items_and_labels.js +66 -0
- package/dist/migrations/20260429T111839_catalog_gallery_items_and_labels.js.map +1 -0
- package/dist/migrations/20260429T112543_catalog_product_attachments.d.ts +18 -0
- package/dist/migrations/20260429T112543_catalog_product_attachments.d.ts.map +1 -0
- package/dist/migrations/20260429T112543_catalog_product_attachments.js +73 -0
- package/dist/migrations/20260429T112543_catalog_product_attachments.js.map +1 -0
- package/dist/migrations/20260429T123726_catalog_product_links.d.ts +21 -0
- package/dist/migrations/20260429T123726_catalog_product_links.d.ts.map +1 -0
- package/dist/migrations/20260429T123726_catalog_product_links.js +48 -0
- package/dist/migrations/20260429T123726_catalog_product_links.js.map +1 -0
- package/dist/migrations/20260429T130803_catalog_grouped_and_bundle.d.ts +23 -0
- package/dist/migrations/20260429T130803_catalog_grouped_and_bundle.d.ts.map +1 -0
- package/dist/migrations/20260429T130803_catalog_grouped_and_bundle.js +94 -0
- package/dist/migrations/20260429T130803_catalog_grouped_and_bundle.js.map +1 -0
- package/dist/migrations/20260501T185835_catalog_product_attribute_is_comparable.d.ts +24 -0
- package/dist/migrations/20260501T185835_catalog_product_attribute_is_comparable.d.ts.map +1 -0
- package/dist/migrations/20260501T185835_catalog_product_attribute_is_comparable.js +31 -0
- package/dist/migrations/20260501T185835_catalog_product_attribute_is_comparable.js.map +1 -0
- package/dist/migrations/20260505T060113_catalog_attribute_options_and_flags.d.ts +31 -0
- package/dist/migrations/20260505T060113_catalog_attribute_options_and_flags.d.ts.map +1 -0
- package/dist/migrations/20260505T060113_catalog_attribute_options_and_flags.js +133 -0
- package/dist/migrations/20260505T060113_catalog_attribute_options_and_flags.js.map +1 -0
- package/dist/migrations/20260515T082629_catalog_attribute_mass_editable.d.ts +16 -0
- package/dist/migrations/20260515T082629_catalog_attribute_mass_editable.d.ts.map +1 -0
- package/dist/migrations/20260515T082629_catalog_attribute_mass_editable.js +23 -0
- package/dist/migrations/20260515T082629_catalog_attribute_mass_editable.js.map +1 -0
- package/dist/migrations/20260526T124736_catalog_product_status_inactive.d.ts +10 -0
- package/dist/migrations/20260526T124736_catalog_product_status_inactive.d.ts.map +1 -0
- package/dist/migrations/20260526T124736_catalog_product_status_inactive.js +22 -0
- package/dist/migrations/20260526T124736_catalog_product_status_inactive.js.map +1 -0
- package/dist/migrations/20260611T140346_catalog_product_value_overrides_init.d.ts +38 -0
- package/dist/migrations/20260611T140346_catalog_product_value_overrides_init.d.ts.map +1 -0
- package/dist/migrations/20260611T140346_catalog_product_value_overrides_init.js +98 -0
- package/dist/migrations/20260611T140346_catalog_product_value_overrides_init.js.map +1 -0
- package/dist/migrations/20260611T140400_catalog_attribute_quick_searchable.d.ts +13 -0
- package/dist/migrations/20260611T140400_catalog_attribute_quick_searchable.d.ts.map +1 -0
- package/dist/migrations/20260611T140400_catalog_attribute_quick_searchable.js +17 -0
- package/dist/migrations/20260611T140400_catalog_attribute_quick_searchable.js.map +1 -0
- package/dist/migrations/20260611T140407_catalog_bulk_operations.d.ts +16 -0
- package/dist/migrations/20260611T140407_catalog_bulk_operations.d.ts.map +1 -0
- package/dist/migrations/20260611T140407_catalog_bulk_operations.js +42 -0
- package/dist/migrations/20260611T140407_catalog_bulk_operations.js.map +1 -0
- package/dist/migrations/20260611T140408_catalog_bulk_operation_logs.d.ts +14 -0
- package/dist/migrations/20260611T140408_catalog_bulk_operation_logs.d.ts.map +1 -0
- package/dist/migrations/20260611T140408_catalog_bulk_operation_logs.js +18 -0
- package/dist/migrations/20260611T140408_catalog_bulk_operation_logs.js.map +1 -0
- package/dist/migrations/20260611T140412_catalog_product_packaging_units.d.ts +11 -0
- package/dist/migrations/20260611T140412_catalog_product_packaging_units.d.ts.map +1 -0
- package/dist/migrations/20260611T140412_catalog_product_packaging_units.js +29 -0
- package/dist/migrations/20260611T140412_catalog_product_packaging_units.js.map +1 -0
- package/dist/migrations/20260718T060659_catalog_bulk_operation_revert_state.d.ts +23 -0
- package/dist/migrations/20260718T060659_catalog_bulk_operation_revert_state.d.ts.map +1 -0
- package/dist/migrations/20260718T060659_catalog_bulk_operation_revert_state.js +35 -0
- package/dist/migrations/20260718T060659_catalog_bulk_operation_revert_state.js.map +1 -0
- package/dist/migrations/20260718T200343_catalog_category_custom_field_values.d.ts +12 -0
- package/dist/migrations/20260718T200343_catalog_category_custom_field_values.d.ts.map +1 -0
- package/dist/migrations/20260718T200343_catalog_category_custom_field_values.js +16 -0
- package/dist/migrations/20260718T200343_catalog_category_custom_field_values.js.map +1 -0
- package/dist/migrations/20260723T230401_catalog_attributes_on_custom_fields.d.ts +32 -0
- package/dist/migrations/20260723T230401_catalog_attributes_on_custom_fields.d.ts.map +1 -0
- package/dist/migrations/20260723T230401_catalog_attributes_on_custom_fields.js +286 -0
- package/dist/migrations/20260723T230401_catalog_attributes_on_custom_fields.js.map +1 -0
- package/dist/migrations/20260804T152604_catalog_widen_product_sku.d.ts +36 -0
- package/dist/migrations/20260804T152604_catalog_widen_product_sku.d.ts.map +1 -0
- package/dist/migrations/20260804T152604_catalog_widen_product_sku.js +63 -0
- package/dist/migrations/20260804T152604_catalog_widen_product_sku.js.map +1 -0
- package/dist/migrations/20260804T160244_catalog_category_activation.d.ts +18 -0
- package/dist/migrations/20260804T160244_catalog_category_activation.d.ts.map +1 -0
- package/dist/migrations/20260804T160244_catalog_category_activation.js +22 -0
- package/dist/migrations/20260804T160244_catalog_category_activation.js.map +1 -0
- package/dist/migrations/20260912T094557_catalog_sales_channel_products.d.ts +27 -0
- package/dist/migrations/20260912T094557_catalog_sales_channel_products.d.ts.map +1 -0
- package/dist/migrations/20260912T094557_catalog_sales_channel_products.js +44 -0
- package/dist/migrations/20260912T094557_catalog_sales_channel_products.js.map +1 -0
- package/dist/migrations/20260912T094623_catalog_sales_channel_categories.d.ts +27 -0
- package/dist/migrations/20260912T094623_catalog_sales_channel_categories.d.ts.map +1 -0
- package/dist/migrations/20260912T094623_catalog_sales_channel_categories.js +44 -0
- package/dist/migrations/20260912T094623_catalog_sales_channel_categories.js.map +1 -0
- package/dist/migrations/20260925T125527_catalog_inventory_columns.d.ts +36 -0
- package/dist/migrations/20260925T125527_catalog_inventory_columns.d.ts.map +1 -0
- package/dist/migrations/20260925T125527_catalog_inventory_columns.js +87 -0
- package/dist/migrations/20260925T125527_catalog_inventory_columns.js.map +1 -0
- package/dist/migrations/index.d.ts +55 -0
- package/dist/migrations/index.d.ts.map +1 -0
- package/dist/migrations/index.js +80 -0
- package/dist/migrations/index.js.map +1 -0
- package/dist/ports/index.d.ts +96 -0
- package/dist/ports/index.d.ts.map +1 -0
- package/dist/ports/index.js +2 -0
- package/dist/ports/index.js.map +1 -0
- package/docs/catalog/attachments.md +69 -0
- package/docs/catalog/attribute-sets.md +63 -0
- package/docs/catalog/attributes.md +244 -0
- package/docs/catalog/composite-products.md +115 -0
- package/docs/catalog/gallery-and-labels.md +73 -0
- package/docs/catalog/packaging-units.md +67 -0
- package/docs/catalog/per-channel-per-language-overrides.md +234 -0
- package/docs/catalog/product-links.md +79 -0
- package/docs/catalog.md +207 -0
- package/i18n/en.json +649 -0
- package/i18n/pl.json +649 -0
- package/package.json +109 -0
- package/tailwind.css +14 -0
|
@@ -0,0 +1,1633 @@
|
|
|
1
|
+
import { Product } from '../entities/product.entity.js';
|
|
2
|
+
import { ProductVariant } from '../entities/product-variant.entity.js';
|
|
3
|
+
import { Category } from '../entities/category.entity.js';
|
|
4
|
+
import { AttributeSet } from '../entities/attribute-set.entity.js';
|
|
5
|
+
import { GalleryItem } from '../entities/gallery-item.entity.js';
|
|
6
|
+
import { GalleryItemLabel } from '../entities/gallery-item-label.entity.js';
|
|
7
|
+
import { ProductAttachment } from '../entities/product-attachment.entity.js';
|
|
8
|
+
import { AttachmentType } from '../entities/attachment-type.entity.js';
|
|
9
|
+
import { GroupedItem } from '../entities/grouped-item.entity.js';
|
|
10
|
+
import { ProductPackagingUnit } from '../entities/product-packaging-unit.entity.js';
|
|
11
|
+
import { BundleSlot } from '../entities/bundle-slot.entity.js';
|
|
12
|
+
import { BundleSlotOption } from '../entities/bundle-slot-option.entity.js';
|
|
13
|
+
import { resolvePrimaryAssetUrls } from './primary-asset-url.js';
|
|
14
|
+
import { viewerOrganizationFor } from './viewer-organization.js';
|
|
15
|
+
import { ERROR_CODES, isPriceSort, isProductVisibleTo, listingPriceMoney, } from '@endora-commerce/contracts';
|
|
16
|
+
import { HttpError } from '@endora-commerce/platform/http';
|
|
17
|
+
import { encodeCursor, decodeCursor } from '@endora-commerce/platform/http';
|
|
18
|
+
function encodeObjectCursor(value) {
|
|
19
|
+
return encodeCursor(JSON.stringify(value));
|
|
20
|
+
}
|
|
21
|
+
function decodeObjectCursor(cursor) {
|
|
22
|
+
const raw = decodeCursor(cursor);
|
|
23
|
+
if (!raw)
|
|
24
|
+
return null;
|
|
25
|
+
try {
|
|
26
|
+
return JSON.parse(raw);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function isRecord(value) {
|
|
33
|
+
return typeof value === 'object' && value !== null;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* How many source rows a price-ordered or price-filtered page may read before it
|
|
37
|
+
* returns short — FR-019, stated as the specification requires and **not** as a
|
|
38
|
+
* setting (research §R13).
|
|
39
|
+
*
|
|
40
|
+
* Eight times the page size. On the reference corpus it never bound: the first
|
|
41
|
+
* page of 50 cost 213 source rows against a budget of 400. It binds only where
|
|
42
|
+
* an operator restricts most of a catalogue from most viewers, and the answer
|
|
43
|
+
* there is a short page — which is this listing's existing behaviour, since the
|
|
44
|
+
* channel and audience filters already narrow a fetched page — rather than an
|
|
45
|
+
* unbounded scan.
|
|
46
|
+
*
|
|
47
|
+
* A setting was rejected because nobody has the number yet: the value it should
|
|
48
|
+
* take is a function of how much of a catalogue a deployment hides, and shipping
|
|
49
|
+
* an operator knob for a guess is the shape Constitution IV refuses. The `warn`
|
|
50
|
+
* below is what turns the question into evidence; adding the setting afterwards
|
|
51
|
+
* is additive.
|
|
52
|
+
*/
|
|
53
|
+
export const PRICE_PAGE_SCAN_BUDGET_MULTIPLE = 8;
|
|
54
|
+
/**
|
|
55
|
+
* The "everything strictly after this row" predicate, over whichever column the
|
|
56
|
+
* cursor names and in the direction that column is ordered in.
|
|
57
|
+
*
|
|
58
|
+
* One implementation for both shapes rather than one per keyset: they differ
|
|
59
|
+
* only in the column, and a second copy of this predicate is how it and the
|
|
60
|
+
* ordering came apart in the first place.
|
|
61
|
+
*/
|
|
62
|
+
function afterKeysetCursor(cursor, op) {
|
|
63
|
+
if ('slug' in cursor) {
|
|
64
|
+
return {
|
|
65
|
+
$or: [{ slug: { [op]: cursor.slug } }, { slug: cursor.slug, id: { [op]: cursor.id } }],
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
const at = new Date(cursor.createdAt);
|
|
69
|
+
return { $or: [{ createdAt: { [op]: at } }, { createdAt: at, id: { [op]: cursor.id } }] };
|
|
70
|
+
}
|
|
71
|
+
export class CatalogQueryService {
|
|
72
|
+
emFactory;
|
|
73
|
+
productLinkService;
|
|
74
|
+
customFieldDefinitions;
|
|
75
|
+
attributeRead;
|
|
76
|
+
listingPrices;
|
|
77
|
+
assets;
|
|
78
|
+
channelMembership;
|
|
79
|
+
organizationDetails;
|
|
80
|
+
listingPriceOrder;
|
|
81
|
+
log;
|
|
82
|
+
constructor(emFactory,
|
|
83
|
+
/**
|
|
84
|
+
* Feature 002 US4 — optional link provider so the PDP `productDetail`
|
|
85
|
+
* carries the pre-grouped Related/Up-sell/Cross-sell links. Optional
|
|
86
|
+
* so tests/composition that don't wire it stay green; when undefined,
|
|
87
|
+
* `productDetail.links` is omitted from the response.
|
|
88
|
+
*/
|
|
89
|
+
productLinkService,
|
|
90
|
+
/**
|
|
91
|
+
* Feature 055 (US4) — optional custom-field definition source. When wired,
|
|
92
|
+
* Category custom fields flagged `config.filterable === true` are merged into
|
|
93
|
+
* the storefront filter set. The catalog interprets the opaque `config` here;
|
|
94
|
+
* the custom-fields core stays unaware of catalog (Principle XIV / FR-006).
|
|
95
|
+
*/
|
|
96
|
+
customFieldDefinitions,
|
|
97
|
+
/**
|
|
98
|
+
* Feature 061 — composed attribute read model. Required for every
|
|
99
|
+
* attribute-metadata read (filters, PDP visible attributes, promo-rule and
|
|
100
|
+
* comparable key ports); optional in the signature so product-only
|
|
101
|
+
* fixtures keep constructing the service without attribute wiring.
|
|
102
|
+
*/
|
|
103
|
+
attributeRead,
|
|
104
|
+
/**
|
|
105
|
+
* Issue #132 — the pricing engine, resolved through the `pricingService`
|
|
106
|
+
* port. Every storefront-facing price this service emits comes from here;
|
|
107
|
+
* the catalogue no longer projects its own legacy default-price attribute,
|
|
108
|
+
* because that figure is not a price any price list stands behind.
|
|
109
|
+
*
|
|
110
|
+
* Optional only in the signature, and unwiring it is not a fallback: a
|
|
111
|
+
* listing asked to render a price without it fails loudly, the same way an
|
|
112
|
+
* unwired attribute read model does. `null` from the port is the engine's
|
|
113
|
+
* own "nothing applies" answer and is rendered as an absence.
|
|
114
|
+
*/
|
|
115
|
+
listingPrices,
|
|
116
|
+
/**
|
|
117
|
+
* Feature 075 — `assets_library`'s read port, where six raw
|
|
118
|
+
* `join assets a on a.id = …` clauses and an `em.find(Asset, …)` used to
|
|
119
|
+
* be. The bridge rows are still this module's; only the asset row is asked
|
|
120
|
+
* of its owner.
|
|
121
|
+
*
|
|
122
|
+
* Optional only in the signature, for the same reason the two above are:
|
|
123
|
+
* a fixture that renders no image never reaches it. A PDP that does and
|
|
124
|
+
* finds it unwired fails loudly rather than silently dropping every image.
|
|
125
|
+
*/
|
|
126
|
+
assets,
|
|
127
|
+
/**
|
|
128
|
+
* Issue #185 — the kernel's channel-membership accessor, where the
|
|
129
|
+
* hand-written `select product_id from sales_channel_products …` in
|
|
130
|
+
* {@link filterByChannel} used to be. Constitution XII says the
|
|
131
|
+
* `sales_channel_*` bridges are read and written only through this service;
|
|
132
|
+
* the query here named the table itself, which crosses the boundary while
|
|
133
|
+
* naming no import specifier.
|
|
134
|
+
*
|
|
135
|
+
* Optional only in the signature, and unwiring it is not a fallback: the
|
|
136
|
+
* channel filter fails loudly rather than quietly answering the full
|
|
137
|
+
* cross-channel set, which is the one degrade Principle XII rules out.
|
|
138
|
+
*/
|
|
139
|
+
channelMembership,
|
|
140
|
+
/**
|
|
141
|
+
* `organizations`' read model, for the one field the pricing engine needs
|
|
142
|
+
* beyond the viewer's organisation id: the customer group a group-targeted
|
|
143
|
+
* price list is selected by. Without it a buyer's catalogue card and their
|
|
144
|
+
* own cart line would resolve against different lists and quote different
|
|
145
|
+
* figures for one product.
|
|
146
|
+
*
|
|
147
|
+
* Optional only in the signature, and unwiring it is not a fallback: a
|
|
148
|
+
* listing asked to price a signed-in buyer without it fails loudly, the
|
|
149
|
+
* same way the two ports above do. An **anonymous** listing never reaches
|
|
150
|
+
* it, which is what keeps the fixtures that construct this service with no
|
|
151
|
+
* organisation wiring working unchanged.
|
|
152
|
+
*/
|
|
153
|
+
organizationDetails,
|
|
154
|
+
/**
|
|
155
|
+
* Feature 086 — the `ListingPriceOrderPort` slice of the same
|
|
156
|
+
* `pricingService` container {@link listingPrices} resolves.
|
|
157
|
+
*
|
|
158
|
+
* A separate constructor argument rather than a widened type on that one,
|
|
159
|
+
* because every fixture that builds this service with a listing-price stub
|
|
160
|
+
* would otherwise stop compiling for a capability it does not exercise. It
|
|
161
|
+
* is the same container, the same gate and the same declared edge — the port
|
|
162
|
+
* doc block says so, and `check:port-shape` compares it against the
|
|
163
|
+
* registration.
|
|
164
|
+
*
|
|
165
|
+
* Optional only in the signature, and unwiring it is not a fallback: a
|
|
166
|
+
* price-ordered listing asked for without it fails loudly rather than
|
|
167
|
+
* silently serving the default ordering under a `sort=price` the buyer
|
|
168
|
+
* chose.
|
|
169
|
+
*/
|
|
170
|
+
listingPriceOrder,
|
|
171
|
+
/**
|
|
172
|
+
* Feature 086 — where the scan budget says it bound.
|
|
173
|
+
*
|
|
174
|
+
* The budget is a constant rather than a setting because nobody has the
|
|
175
|
+
* number yet (research §R13), and this line is what turns the question into
|
|
176
|
+
* evidence instead of leaving it a guess: a deployment whose pages come back
|
|
177
|
+
* short says so in its own log, and the setting can be added afterwards
|
|
178
|
+
* without changing anything else. Optional, because a fixture that never
|
|
179
|
+
* binds never reaches it.
|
|
180
|
+
*/
|
|
181
|
+
log) {
|
|
182
|
+
this.emFactory = emFactory;
|
|
183
|
+
this.productLinkService = productLinkService;
|
|
184
|
+
this.customFieldDefinitions = customFieldDefinitions;
|
|
185
|
+
this.attributeRead = attributeRead;
|
|
186
|
+
this.listingPrices = listingPrices;
|
|
187
|
+
this.assets = assets;
|
|
188
|
+
this.channelMembership = channelMembership;
|
|
189
|
+
this.organizationDetails = organizationDetails;
|
|
190
|
+
this.listingPriceOrder = listingPriceOrder;
|
|
191
|
+
this.log = log;
|
|
192
|
+
}
|
|
193
|
+
#requireChannelMembership() {
|
|
194
|
+
if (!this.channelMembership) {
|
|
195
|
+
throw new Error('CatalogQueryService: the channel-membership port is not wired — a channel-scoped ' +
|
|
196
|
+
'listing cannot be answered without leaking the cross-channel set.');
|
|
197
|
+
}
|
|
198
|
+
return this.channelMembership;
|
|
199
|
+
}
|
|
200
|
+
#requireAssets() {
|
|
201
|
+
if (!this.assets) {
|
|
202
|
+
throw new Error('CatalogQueryService: the asset read port is not wired — product images are unavailable.');
|
|
203
|
+
}
|
|
204
|
+
return this.assets;
|
|
205
|
+
}
|
|
206
|
+
#requireAttributeRead() {
|
|
207
|
+
if (!this.attributeRead) {
|
|
208
|
+
throw new Error('CatalogQueryService: CatalogAttributeReadService is not wired — attribute reads are unavailable.');
|
|
209
|
+
}
|
|
210
|
+
return this.attributeRead;
|
|
211
|
+
}
|
|
212
|
+
#requireListingPrices() {
|
|
213
|
+
if (!this.listingPrices) {
|
|
214
|
+
throw new Error('CatalogQueryService: the pricing port is not wired — a listing cannot be priced.');
|
|
215
|
+
}
|
|
216
|
+
return this.listingPrices;
|
|
217
|
+
}
|
|
218
|
+
#requireListingPriceOrder() {
|
|
219
|
+
if (!this.listingPriceOrder) {
|
|
220
|
+
throw new Error('CatalogQueryService: the pricing ordering port is not wired — a price sort or a ' +
|
|
221
|
+
'price range cannot be answered, and answering it with another ordering would be ' +
|
|
222
|
+
'a sort control that silently does nothing.');
|
|
223
|
+
}
|
|
224
|
+
return this.listingPriceOrder;
|
|
225
|
+
}
|
|
226
|
+
#requireOrganizationDetails() {
|
|
227
|
+
if (!this.organizationDetails) {
|
|
228
|
+
throw new Error('CatalogQueryService: the organization read port is not wired — a signed-in buyer cannot be priced.');
|
|
229
|
+
}
|
|
230
|
+
return this.organizationDetails;
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* The chain's answer for a batch of products, keyed by product id, **for the
|
|
234
|
+
* viewer in front of the page**.
|
|
235
|
+
*
|
|
236
|
+
* A non-public sales channel withholds prices (R-18), and it withholds them
|
|
237
|
+
* *before* the resolution rather than after: the catalogue has nothing to ask
|
|
238
|
+
* about on a channel whose prices it may not show.
|
|
239
|
+
*
|
|
240
|
+
* The organisation is the whole of this method's share of the owner's ruling
|
|
241
|
+
* — an anonymous visitor sees the channel price, a signed-in buyer sees their
|
|
242
|
+
* organisation's. It is resolved once per page rather than per card, and it
|
|
243
|
+
* is `null` for every caller {@link viewerOrganizationFor} answers `null`
|
|
244
|
+
* for, so the anonymous request issues exactly the resolution it issued
|
|
245
|
+
* before and lands on exactly the cache entry it landed on before.
|
|
246
|
+
*/
|
|
247
|
+
async #listingPricesFor(products, channel, audience) {
|
|
248
|
+
if (products.length === 0)
|
|
249
|
+
return new Map();
|
|
250
|
+
if (!(channel?.isPublic ?? true))
|
|
251
|
+
return new Map();
|
|
252
|
+
return this.#requireListingPrices().resolveListingPrices({
|
|
253
|
+
products,
|
|
254
|
+
context: await this.#viewerPricingContext(channel, audience),
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* The viewer, as every pricing seam on this service reads them: the resolved
|
|
259
|
+
* channel and the buying organisation, or no organisation at all.
|
|
260
|
+
*
|
|
261
|
+
* Extracted so the page's prices and the page's **ordering** are resolved for
|
|
262
|
+
* one viewer rather than two — a card priced for the buyer and an order
|
|
263
|
+
* computed for somebody else is the exact failure feature 086 exists to
|
|
264
|
+
* prevent, and it would be one forgotten argument away if each seam built its
|
|
265
|
+
* own context.
|
|
266
|
+
*/
|
|
267
|
+
async #viewerPricingContext(channel, audience) {
|
|
268
|
+
const organization = audience.organizationId === null
|
|
269
|
+
? null
|
|
270
|
+
: await viewerOrganizationFor(this.#requireOrganizationDetails(), audience);
|
|
271
|
+
return {
|
|
272
|
+
salesChannel: {
|
|
273
|
+
id: channel?.id ?? '',
|
|
274
|
+
defaultCurrency: channel?.defaultCurrency ?? 'PLN',
|
|
275
|
+
},
|
|
276
|
+
organization,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* `ProductSummary.price` for one resolved chain answer. A product the channel
|
|
281
|
+
* withholds prices for is absent from the map and renders `null`, and so does
|
|
282
|
+
* the chain's `none` arm — the wire field has one spelling for "no price".
|
|
283
|
+
*/
|
|
284
|
+
#summaryPrice(resolved, productId) {
|
|
285
|
+
const price = resolved.get(productId);
|
|
286
|
+
return price === undefined ? null : listingPriceMoney(price);
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* {@link ListingCardReads} for a page, in a fixed number of statements
|
|
290
|
+
* regardless of how many cards it holds (issue #263).
|
|
291
|
+
*
|
|
292
|
+
* `resolvePrimaryAssetUrls` has taken an id list since feature 075 and needs
|
|
293
|
+
* no change — it was simply never handed more than one id from here, which is
|
|
294
|
+
* MR !793's finding repeating itself: a helper that batches in signature
|
|
295
|
+
* batches in practice only where the *page* is what calls it.
|
|
296
|
+
*
|
|
297
|
+
* The slug read carries an explicit `order by pc.product_id, pc.category_id`.
|
|
298
|
+
* That is not a new ordering: `product_categories` is keyed on exactly that
|
|
299
|
+
* pair, so the per-card `where pc.product_id = ?` was already answered from
|
|
300
|
+
* that index in that order. Writing it down is what keeps the array the same
|
|
301
|
+
* array now that a page-wide `in (…)` may reach the rows by another plan.
|
|
302
|
+
*/
|
|
303
|
+
async #listingCardReadsFor(em, productIds) {
|
|
304
|
+
const categorySlugsByProduct = new Map();
|
|
305
|
+
if (productIds.length === 0) {
|
|
306
|
+
return { assetUrlByProduct: new Map(), categorySlugsByProduct };
|
|
307
|
+
}
|
|
308
|
+
// Primary asset — for listings prefer the gallery's Thumbnail (US3),
|
|
309
|
+
// then Base Image, then any first gallery item, finally the legacy
|
|
310
|
+
// product_assets row. Resolution chain pinned by T096.
|
|
311
|
+
const assetUrlByProduct = await resolvePrimaryAssetUrls(em, this.#requireAssets(), productIds);
|
|
312
|
+
const ids = [...productIds];
|
|
313
|
+
const catRows = await em.execute(`select pc.product_id::text as product_id, c.slug
|
|
314
|
+
from product_categories pc
|
|
315
|
+
join categories c on c.id = pc.category_id
|
|
316
|
+
where pc.product_id in (${ids.map(() => '?').join(',')})
|
|
317
|
+
order by pc.product_id, pc.category_id`, ids);
|
|
318
|
+
for (const row of catRows) {
|
|
319
|
+
const slugs = categorySlugsByProduct.get(row.product_id) ?? [];
|
|
320
|
+
slugs.push(row.slug);
|
|
321
|
+
categorySlugsByProduct.set(row.product_id, slugs);
|
|
322
|
+
}
|
|
323
|
+
return { assetUrlByProduct, categorySlugsByProduct };
|
|
324
|
+
}
|
|
325
|
+
// ------------------------------------------------------------------
|
|
326
|
+
// Products
|
|
327
|
+
// ------------------------------------------------------------------
|
|
328
|
+
async listProducts(params, ctx) {
|
|
329
|
+
const em = this.emFactory();
|
|
330
|
+
const channel = ctx.resolvedChannel;
|
|
331
|
+
// --- Filter validation: any filter[attr.<key>] where the attribute is
|
|
332
|
+
// not filterable must return 400 FILTER_NOT_ALLOWED (FR-005, T043).
|
|
333
|
+
if (params.attributeFilters) {
|
|
334
|
+
const keys = Object.keys(params.attributeFilters);
|
|
335
|
+
if (keys.length > 0) {
|
|
336
|
+
const views = await this.#requireAttributeRead().listAll();
|
|
337
|
+
const byKey = new Map(views.map((a) => [a.key, a]));
|
|
338
|
+
for (const k of keys) {
|
|
339
|
+
const a = byKey.get(k);
|
|
340
|
+
if (!a || !a.isFilterable) {
|
|
341
|
+
throw new HttpError(400, ERROR_CODES.FILTER_NOT_ALLOWED, `Attribute "${k}" is not filterable.`, [{ path: `filter[attr.${k}]`, issue: 'attribute is not filterable' }]);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
const where = {
|
|
347
|
+
status: 'active',
|
|
348
|
+
deletedAt: null,
|
|
349
|
+
};
|
|
350
|
+
if (params.q) {
|
|
351
|
+
// MVP: DB-only search. Production target uses Meilisearch behind this
|
|
352
|
+
// same query method (T067/T068 — Phase 10 polish); for now we match:
|
|
353
|
+
// 1. SKU ILIKE (covers the seeded EXAMPLE-… ids)
|
|
354
|
+
// 2. attributeValues at any key flagged isSearchable=true
|
|
355
|
+
// The attribute-values match uses Postgres ILIKE on the JSONB cast to
|
|
356
|
+
// text, which is fine for the test corpus and good enough until Meili.
|
|
357
|
+
const searchableKeys = await this.searchableAttributeKeys(em);
|
|
358
|
+
const orClauses = [
|
|
359
|
+
{ sku: { $ilike: `%${params.q}%` } },
|
|
360
|
+
];
|
|
361
|
+
for (const key of searchableKeys) {
|
|
362
|
+
// MikroORM's `expr()` would be cleaner; raw cast keeps the dependency
|
|
363
|
+
// surface minimal here.
|
|
364
|
+
orClauses.push({
|
|
365
|
+
// Match any product whose attributeValues[key] (case-insensitive)
|
|
366
|
+
// contains the query.
|
|
367
|
+
attributeValues: this.searchableJsonbClause(key, params.q),
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
where['$or'] = orClauses;
|
|
371
|
+
}
|
|
372
|
+
if (params.changedSince) {
|
|
373
|
+
where['updatedAt'] = { $gt: new Date(params.changedSince) };
|
|
374
|
+
}
|
|
375
|
+
// Feature 086 — the two paths that read the viewer's own price. Both are
|
|
376
|
+
// chunked and both are bounded; everything else takes the single fetch it
|
|
377
|
+
// always took.
|
|
378
|
+
if (isPriceSort(params.sort)) {
|
|
379
|
+
return this.#listByViewerPrice(em, params, ctx, where, params.sort);
|
|
380
|
+
}
|
|
381
|
+
if (params.minPrice !== undefined || params.maxPrice !== undefined) {
|
|
382
|
+
return this.#listWithPriceRange(em, params, ctx, where);
|
|
383
|
+
}
|
|
384
|
+
// Cursor decoding — over the keyset this ordering pages by, which is
|
|
385
|
+
// `{ createdAt, id }` for the default sort and `{ slug, id }` for `name`.
|
|
386
|
+
const keyset = this.keysetFor(params.sort);
|
|
387
|
+
let cursorClause = null;
|
|
388
|
+
if (params.cursor) {
|
|
389
|
+
const decoded = keyset.parse(decodeObjectCursor(params.cursor));
|
|
390
|
+
if (decoded)
|
|
391
|
+
cursorClause = keyset.after(decoded);
|
|
392
|
+
}
|
|
393
|
+
const effectiveWhere = cursorClause ? { $and: [where, cursorClause] } : where;
|
|
394
|
+
// Over-fetch by one to detect hasMore.
|
|
395
|
+
const products = await em.find(Product, effectiveWhere, {
|
|
396
|
+
limit: params.limit + 1,
|
|
397
|
+
orderBy: this.orderForSort(params.sort),
|
|
398
|
+
});
|
|
399
|
+
const hasMore = products.length > params.limit;
|
|
400
|
+
const page = hasMore ? products.slice(0, params.limit) : products;
|
|
401
|
+
const nextCursor = hasMore && page.length > 0 ? encodeObjectCursor(keyset.of(page[page.length - 1])) : null;
|
|
402
|
+
// Sales Channel membership — only products associated with the channel are
|
|
403
|
+
// returned, failing closed to the empty set.
|
|
404
|
+
const visibleIds = await this.filterByChannel(page.map((p) => p.id), channel);
|
|
405
|
+
// Attribute filter post-filtering (simple equality on attributeValues JSONB).
|
|
406
|
+
const filtered = page.filter((p) => visibleIds.has(p.id) && this.#passesPageFilters(p, params, ctx));
|
|
407
|
+
// Category filter
|
|
408
|
+
let categoryFilteredIds = null;
|
|
409
|
+
if (params.categorySlug) {
|
|
410
|
+
categoryFilteredIds = await this.productIdsInCategoryTree(em, params.categorySlug);
|
|
411
|
+
}
|
|
412
|
+
// Build the summaries. The page is resolved **once** — `toSummary` used to
|
|
413
|
+
// ask `price_lists` for a batch of one, so a 50-card page made 50 calls and
|
|
414
|
+
// paid the resolution's fixed cost (the active lists, the settings pair, the
|
|
415
|
+
// organisation's chain) 50 times over. Resolving the page here and handing
|
|
416
|
+
// the map down is what makes `resolveListingPrices` a batch in practice
|
|
417
|
+
// rather than only in signature.
|
|
418
|
+
//
|
|
419
|
+
// Its asset and its category slugs are resolved the same way and for the
|
|
420
|
+
// same reason (issue #263): both helpers took an id list already, and both
|
|
421
|
+
// were being handed one id at a time from inside the loop.
|
|
422
|
+
const priceable = filtered.filter((p) => categoryFilteredIds ? categoryFilteredIds.has(p.id) : true);
|
|
423
|
+
const resolvedPrices = await this.#listingPricesFor(priceable, channel, ctx.audience);
|
|
424
|
+
const cardReads = await this.#listingCardReadsFor(em, priceable.map((p) => p.id));
|
|
425
|
+
const summaries = priceable.map((p) => this.toSummary(p, channel, ctx.preferredLanguage, resolvedPrices, cardReads));
|
|
426
|
+
return {
|
|
427
|
+
data: summaries,
|
|
428
|
+
pagination: { cursor: nextCursor, hasMore, limit: params.limit },
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
/**
|
|
432
|
+
* The channel/audience/attribute predicates a fetched page is narrowed by,
|
|
433
|
+
* for one product.
|
|
434
|
+
*
|
|
435
|
+
* The channel test is the caller's, because it is one `in (…)` for the whole
|
|
436
|
+
* chunk; the other two are per row and were already written inline. They are
|
|
437
|
+
* collected here so the price paths below apply **exactly** the same
|
|
438
|
+
* narrowing as the default path — a price ordering that filtered differently
|
|
439
|
+
* would make a hidden row observable through a position or a page boundary,
|
|
440
|
+
* which is the disclosure FR-014 forbids and which neither the relevance sort
|
|
441
|
+
* nor the name sort could have had.
|
|
442
|
+
*/
|
|
443
|
+
#passesPageFilters(product, params, ctx) {
|
|
444
|
+
// Issue #227 — the second scoping axis, alongside the channel one. It is
|
|
445
|
+
// applied on the page rather than in the `where` because the allow-list
|
|
446
|
+
// test is a JSONB containment the ORM query object cannot spell, and
|
|
447
|
+
// splitting the two axes across the query and the page would leave the
|
|
448
|
+
// `limit` accounting to reason about twice instead of once. The known cost
|
|
449
|
+
// is a page narrowed after the fetch coming back shorter than `limit`, and
|
|
450
|
+
// it is bounded by how much of a catalogue an operator restricts.
|
|
451
|
+
if (!isProductVisibleTo(product, ctx.audience))
|
|
452
|
+
return false;
|
|
453
|
+
if (!params.attributeFilters)
|
|
454
|
+
return true;
|
|
455
|
+
for (const [k, values] of Object.entries(params.attributeFilters)) {
|
|
456
|
+
const av = product.attributeValues[k];
|
|
457
|
+
if (av === undefined)
|
|
458
|
+
return false;
|
|
459
|
+
if (!values.map((v) => String(v)).includes(String(av)))
|
|
460
|
+
return false;
|
|
461
|
+
}
|
|
462
|
+
return true;
|
|
463
|
+
}
|
|
464
|
+
/** The two range bounds as decimal strings — the comparison happens in `numeric`. */
|
|
465
|
+
#amountRange(params) {
|
|
466
|
+
if (params.minPrice === undefined && params.maxPrice === undefined)
|
|
467
|
+
return undefined;
|
|
468
|
+
return {
|
|
469
|
+
...(params.minPrice !== undefined ? { min: params.minPrice.toFixed(4) } : {}),
|
|
470
|
+
...(params.maxPrice !== undefined ? { max: params.maxPrice.toFixed(4) } : {}),
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
/**
|
|
474
|
+
* The listing ordered by **the viewer's own** resolved unit price (feature
|
|
475
|
+
* 086, US1/US2/US4).
|
|
476
|
+
*
|
|
477
|
+
* Two streams, in this order and never the other way round:
|
|
478
|
+
*
|
|
479
|
+
* 1. the **priced** stream, which `price_lists` answers in resolved-price
|
|
480
|
+
* order over its own tables. This service intersects every chunk with
|
|
481
|
+
* channel membership, `isProductVisibleTo` and the attribute filters
|
|
482
|
+
* before a row can reach the page, the cursor or a count — the port
|
|
483
|
+
* cannot do it, says so in its own contract, and a chunk from it may name
|
|
484
|
+
* products the viewer must never see;
|
|
485
|
+
* 2. the **tail**: visible products no applicable price list prices. They
|
|
486
|
+
* appear, at the end, **in both directions**, ordered among themselves by
|
|
487
|
+
* the listing's default ordering. "No price" is not a large number or a
|
|
488
|
+
* small one — it is the absence of the value being ordered by — so a tail
|
|
489
|
+
* that flipped with the direction would lead "most expensive first" with
|
|
490
|
+
* the products nobody has priced, which reads as a bug on every shop that
|
|
491
|
+
* has one.
|
|
492
|
+
*
|
|
493
|
+
* **The tail is excluded by a price range** (FR-012): a range is a claim about
|
|
494
|
+
* a number, and a product with no number does not satisfy it. That is the one
|
|
495
|
+
* place the answer is exclusion, and it is exclusion from a filter the buyer
|
|
496
|
+
* typed rather than from the catalogue.
|
|
497
|
+
*
|
|
498
|
+
* **The legacy product price attribute joins the tail rather than
|
|
499
|
+
* interleaving** (spec 086, clarification 1). `listingPriceFrom`'s second arm
|
|
500
|
+
* — the price assigned directly to the Product — is not part of the ordering:
|
|
501
|
+
* the priced stream is the price-list relation, and everything else is the
|
|
502
|
+
* tail. Measured incidence of a product carrying that attribute *and* priced
|
|
503
|
+
* by no active list: zero. Interleaving it would mean ordering across
|
|
504
|
+
* `price_list_price_brackets` and `products.attribute_values` in one
|
|
505
|
+
* statement — two modules' tables, which `check:module-boundary` refuses — and
|
|
506
|
+
* the operator's remedy is one action: price the product on the default list.
|
|
507
|
+
* The decision is expressed here, in which set feeds which stream, and
|
|
508
|
+
* nowhere else.
|
|
509
|
+
*/
|
|
510
|
+
async #listByViewerPrice(em, params, ctx, where, sort) {
|
|
511
|
+
const channel = ctx.resolvedChannel;
|
|
512
|
+
const direction = sort === 'price' ? 'asc' : 'desc';
|
|
513
|
+
const context = await this.#viewerPricingContext(channel, ctx.audience);
|
|
514
|
+
const amountRange = this.#amountRange(params);
|
|
515
|
+
const restrictToProductIds = params.categorySlug
|
|
516
|
+
? [...(await this.productIdsInCategoryTree(em, params.categorySlug))]
|
|
517
|
+
: undefined;
|
|
518
|
+
const decoded = params.cursor ? decodeObjectCursor(params.cursor) : null;
|
|
519
|
+
// A cursor issued under another sort is discarded rather than
|
|
520
|
+
// reinterpreted: the existing behaviour for a cursor that does not parse.
|
|
521
|
+
const startInTail = decoded?.stream === 'tail';
|
|
522
|
+
let priceCursor = decoded?.stream === 'priced' ? { amount: decoded.amount, productId: decoded.productId } : null;
|
|
523
|
+
let tailCursor = decoded?.stream === 'tail' ? { createdAt: decoded.createdAt, id: decoded.id } : null;
|
|
524
|
+
const budget = PRICE_PAGE_SCAN_BUDGET_MULTIPLE * params.limit;
|
|
525
|
+
// Over-fetch by one, exactly as the default path does, so a full page and
|
|
526
|
+
// its `hasMore` come out of **one** chunk. Asking for `limit` instead costs
|
|
527
|
+
// a second round trip on every full page — measured at four extra
|
|
528
|
+
// statements, and invisible to a ceiling expressed as an absolute number.
|
|
529
|
+
const target = params.limit + 1;
|
|
530
|
+
const chunkSize = params.limit + 1;
|
|
531
|
+
let spent = 0;
|
|
532
|
+
let bound = false;
|
|
533
|
+
const collected = [];
|
|
534
|
+
const cursors = [];
|
|
535
|
+
let pricedExhausted = startInTail;
|
|
536
|
+
// --- the priced stream -------------------------------------------------
|
|
537
|
+
while (!pricedExhausted && collected.length < target) {
|
|
538
|
+
if (spent >= budget) {
|
|
539
|
+
bound = true;
|
|
540
|
+
break;
|
|
541
|
+
}
|
|
542
|
+
const chunk = await this.#requireListingPriceOrder().orderByUnitPrice({
|
|
543
|
+
context,
|
|
544
|
+
direction,
|
|
545
|
+
after: priceCursor,
|
|
546
|
+
limit: chunkSize,
|
|
547
|
+
...(restrictToProductIds !== undefined ? { restrictToProductIds } : {}),
|
|
548
|
+
...(amountRange ? { amountRange } : {}),
|
|
549
|
+
});
|
|
550
|
+
spent += Math.max(chunk.sourceRowsRead, chunk.rows.length);
|
|
551
|
+
pricedExhausted = chunk.exhausted;
|
|
552
|
+
if (chunk.rows.length === 0) {
|
|
553
|
+
if (!chunk.exhausted)
|
|
554
|
+
continue;
|
|
555
|
+
break;
|
|
556
|
+
}
|
|
557
|
+
const last = chunk.rows[chunk.rows.length - 1];
|
|
558
|
+
priceCursor = { amount: last.amount, productId: last.productId };
|
|
559
|
+
const byId = await this.#visibleProductsInOrder(em, chunk.rows.map((row) => row.productId), where, params, ctx);
|
|
560
|
+
for (const row of chunk.rows) {
|
|
561
|
+
const product = byId.get(row.productId);
|
|
562
|
+
if (!product)
|
|
563
|
+
continue;
|
|
564
|
+
collected.push(product);
|
|
565
|
+
cursors.push({ stream: 'priced', amount: row.amount, productId: row.productId });
|
|
566
|
+
if (collected.length >= target)
|
|
567
|
+
break;
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
// --- the unpriced tail -------------------------------------------------
|
|
571
|
+
// Skipped entirely under a range filter (FR-012), and only entered once the
|
|
572
|
+
// priced stream is genuinely over — `exhausted`, which a short chunk does
|
|
573
|
+
// not imply, because the provider may have stopped on its own bound.
|
|
574
|
+
const wantsTail = amountRange === undefined && pricedExhausted && !bound;
|
|
575
|
+
if (wantsTail) {
|
|
576
|
+
const categoryIds = params.categorySlug
|
|
577
|
+
? new Set(restrictToProductIds ?? [])
|
|
578
|
+
: null;
|
|
579
|
+
while (collected.length < target) {
|
|
580
|
+
if (spent >= budget) {
|
|
581
|
+
bound = true;
|
|
582
|
+
break;
|
|
583
|
+
}
|
|
584
|
+
const chunkWhere = tailCursor
|
|
585
|
+
? {
|
|
586
|
+
$and: [
|
|
587
|
+
where,
|
|
588
|
+
{
|
|
589
|
+
$or: [
|
|
590
|
+
{ createdAt: { $lt: new Date(tailCursor.createdAt) } },
|
|
591
|
+
{ createdAt: new Date(tailCursor.createdAt), id: { $lt: tailCursor.id } },
|
|
592
|
+
],
|
|
593
|
+
},
|
|
594
|
+
],
|
|
595
|
+
}
|
|
596
|
+
: where;
|
|
597
|
+
const rows = await em.find(Product, chunkWhere, {
|
|
598
|
+
limit: chunkSize,
|
|
599
|
+
// The listing's own default ordering, in both directions: the tail is
|
|
600
|
+
// not ordered by a price it does not have.
|
|
601
|
+
orderBy: { createdAt: 'desc', id: 'desc' },
|
|
602
|
+
});
|
|
603
|
+
spent += rows.length;
|
|
604
|
+
if (rows.length === 0)
|
|
605
|
+
break;
|
|
606
|
+
const lastRow = rows[rows.length - 1];
|
|
607
|
+
tailCursor = { createdAt: lastRow.createdAt.toISOString(), id: lastRow.id };
|
|
608
|
+
const priced = await this.#requireListingPriceOrder().pricedProductIds({
|
|
609
|
+
context,
|
|
610
|
+
productIds: rows.map((r) => r.id),
|
|
611
|
+
});
|
|
612
|
+
const candidates = rows.filter((r) => !priced.has(r.id) && (categoryIds === null || categoryIds.has(r.id)));
|
|
613
|
+
const visibleIds = await this.filterByChannel(candidates.map((r) => r.id), channel);
|
|
614
|
+
for (const product of candidates) {
|
|
615
|
+
if (!visibleIds.has(product.id))
|
|
616
|
+
continue;
|
|
617
|
+
if (!this.#passesPageFilters(product, params, ctx))
|
|
618
|
+
continue;
|
|
619
|
+
collected.push(product);
|
|
620
|
+
cursors.push({
|
|
621
|
+
stream: 'tail',
|
|
622
|
+
createdAt: product.createdAt.toISOString(),
|
|
623
|
+
id: product.id,
|
|
624
|
+
});
|
|
625
|
+
if (collected.length >= target)
|
|
626
|
+
break;
|
|
627
|
+
}
|
|
628
|
+
if (rows.length < chunkSize)
|
|
629
|
+
break;
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
if (bound) {
|
|
633
|
+
this.log?.warn({ budget, spent, limit: params.limit, sort }, 'catalog: price-ordered page hit its scan budget and is returning short');
|
|
634
|
+
}
|
|
635
|
+
const hasMore = collected.length > params.limit;
|
|
636
|
+
const page = collected.slice(0, params.limit);
|
|
637
|
+
const nextCursor = hasMore && page.length > 0 ? encodeObjectCursor(cursors[params.limit - 1]) : null;
|
|
638
|
+
const resolvedPrices = await this.#listingPricesFor(page, channel, ctx.audience);
|
|
639
|
+
const cardReads = await this.#listingCardReadsFor(em, page.map((p) => p.id));
|
|
640
|
+
return {
|
|
641
|
+
data: page.map((p) => this.toSummary(p, channel, ctx.preferredLanguage, resolvedPrices, cardReads)),
|
|
642
|
+
// No total count, no rank, no "showing 51–100 of 4 213" (FR-015): an
|
|
643
|
+
// aggregate over the priced set is an aggregate over rows the viewer may
|
|
644
|
+
// not be allowed to see.
|
|
645
|
+
pagination: { cursor: nextCursor, hasMore, limit: params.limit },
|
|
646
|
+
};
|
|
647
|
+
}
|
|
648
|
+
/**
|
|
649
|
+
* A price **range** under an ordering that is not a price ordering (research
|
|
650
|
+
* §R12).
|
|
651
|
+
*
|
|
652
|
+
* The ordering is this module's own and the price is a predicate it cannot
|
|
653
|
+
* spell, so the page is walked in chunks and each chunk is narrowed by the
|
|
654
|
+
* prices it was **already going to resolve** — `resolveListingPrices` is
|
|
655
|
+
* called for the page whatever the sort, so in the common case this costs no
|
|
656
|
+
* additional query at all. The failure mode is a short page under a highly
|
|
657
|
+
* selective range, which is this listing's existing behaviour rather than a
|
|
658
|
+
* new one, and it is bounded by the same scan budget.
|
|
659
|
+
*
|
|
660
|
+
* A product no applicable list prices is excluded, exactly as it is under a
|
|
661
|
+
* price ordering: the chain's `none` and `product` arms are both absences of
|
|
662
|
+
* a price-list figure, and a range is a claim about one.
|
|
663
|
+
*/
|
|
664
|
+
async #listWithPriceRange(em, params, ctx, where) {
|
|
665
|
+
const channel = ctx.resolvedChannel;
|
|
666
|
+
const min = params.minPrice;
|
|
667
|
+
const max = params.maxPrice;
|
|
668
|
+
const categoryFilteredIds = params.categorySlug
|
|
669
|
+
? await this.productIdsInCategoryTree(em, params.categorySlug)
|
|
670
|
+
: null;
|
|
671
|
+
// The chunk walk and the page it hands back page by the same keyset, and
|
|
672
|
+
// that keyset is the one this ordering sorts on — `keysetFor`.
|
|
673
|
+
const keyset = this.keysetFor(params.sort);
|
|
674
|
+
let cursor = params.cursor
|
|
675
|
+
? keyset.parse(decodeObjectCursor(params.cursor))
|
|
676
|
+
: null;
|
|
677
|
+
const budget = PRICE_PAGE_SCAN_BUDGET_MULTIPLE * params.limit;
|
|
678
|
+
const target = params.limit + 1;
|
|
679
|
+
// Over-fetch by one, for the same reason the price path does: a full page
|
|
680
|
+
// and its `hasMore` should come out of one chunk.
|
|
681
|
+
const chunkSize = params.limit + 1;
|
|
682
|
+
let spent = 0;
|
|
683
|
+
let bound = false;
|
|
684
|
+
const collected = [];
|
|
685
|
+
const prices = new Map();
|
|
686
|
+
while (collected.length < target) {
|
|
687
|
+
if (spent >= budget) {
|
|
688
|
+
bound = true;
|
|
689
|
+
break;
|
|
690
|
+
}
|
|
691
|
+
const chunkWhere = cursor ? { $and: [where, keyset.after(cursor)] } : where;
|
|
692
|
+
const rows = await em.find(Product, chunkWhere, {
|
|
693
|
+
limit: chunkSize,
|
|
694
|
+
orderBy: this.orderForSort(params.sort),
|
|
695
|
+
});
|
|
696
|
+
spent += rows.length;
|
|
697
|
+
if (rows.length === 0)
|
|
698
|
+
break;
|
|
699
|
+
const lastRow = rows[rows.length - 1];
|
|
700
|
+
cursor = keyset.of(lastRow);
|
|
701
|
+
const visibleIds = await this.filterByChannel(rows.map((r) => r.id), channel);
|
|
702
|
+
const candidates = rows.filter((r) => visibleIds.has(r.id) &&
|
|
703
|
+
this.#passesPageFilters(r, params, ctx) &&
|
|
704
|
+
(categoryFilteredIds === null || categoryFilteredIds.has(r.id)));
|
|
705
|
+
const chunkPrices = await this.#listingPricesFor(candidates, channel, ctx.audience);
|
|
706
|
+
for (const product of candidates) {
|
|
707
|
+
const price = chunkPrices.get(product.id);
|
|
708
|
+
if (price === undefined || price.source !== 'price_list')
|
|
709
|
+
continue;
|
|
710
|
+
const amount = Number(price.amount);
|
|
711
|
+
if (!Number.isFinite(amount))
|
|
712
|
+
continue;
|
|
713
|
+
if (min !== undefined && amount < min)
|
|
714
|
+
continue;
|
|
715
|
+
if (max !== undefined && amount > max)
|
|
716
|
+
continue;
|
|
717
|
+
collected.push(product);
|
|
718
|
+
prices.set(product.id, price);
|
|
719
|
+
if (collected.length >= target)
|
|
720
|
+
break;
|
|
721
|
+
}
|
|
722
|
+
if (rows.length < chunkSize)
|
|
723
|
+
break;
|
|
724
|
+
}
|
|
725
|
+
if (bound) {
|
|
726
|
+
this.log?.warn({ budget, spent, limit: params.limit, sort: params.sort ?? 'relevance' }, 'catalog: price-filtered page hit its scan budget and is returning short');
|
|
727
|
+
}
|
|
728
|
+
const hasMore = collected.length > params.limit;
|
|
729
|
+
const page = collected.slice(0, params.limit);
|
|
730
|
+
const last = page[page.length - 1];
|
|
731
|
+
const nextCursor = hasMore && last ? encodeObjectCursor(keyset.of(last)) : null;
|
|
732
|
+
const cardReads = await this.#listingCardReadsFor(em, page.map((p) => p.id));
|
|
733
|
+
return {
|
|
734
|
+
data: page.map((p) => this.toSummary(p, channel, ctx.preferredLanguage, prices, cardReads)),
|
|
735
|
+
pagination: { cursor: nextCursor, hasMore, limit: params.limit },
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
/**
|
|
739
|
+
* The products behind an ordered chunk of ids, narrowed by everything this
|
|
740
|
+
* module narrows a page by, keyed by id.
|
|
741
|
+
*
|
|
742
|
+
* The order comes from the caller, not from here: `em.find` with an `in (…)`
|
|
743
|
+
* answers in whatever order it likes, and the ordering being reproduced is the
|
|
744
|
+
* price one.
|
|
745
|
+
*/
|
|
746
|
+
async #visibleProductsInOrder(em, productIds, where, params, ctx) {
|
|
747
|
+
const out = new Map();
|
|
748
|
+
if (productIds.length === 0)
|
|
749
|
+
return out;
|
|
750
|
+
const rows = await em.find(Product, { $and: [where, { id: { $in: [...productIds] } }] });
|
|
751
|
+
const visibleIds = await this.filterByChannel(rows.map((r) => r.id), ctx.resolvedChannel);
|
|
752
|
+
for (const product of rows) {
|
|
753
|
+
if (!visibleIds.has(product.id))
|
|
754
|
+
continue;
|
|
755
|
+
if (!this.#passesPageFilters(product, params, ctx))
|
|
756
|
+
continue;
|
|
757
|
+
out.set(product.id, product);
|
|
758
|
+
}
|
|
759
|
+
return out;
|
|
760
|
+
}
|
|
761
|
+
async getProductByIdOrSlug(idOrSlug, ctx) {
|
|
762
|
+
const em = this.emFactory();
|
|
763
|
+
const channel = ctx.resolvedChannel;
|
|
764
|
+
const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(idOrSlug);
|
|
765
|
+
const product = await em.findOne(Product, isUuid ? { id: idOrSlug } : { slug: idOrSlug });
|
|
766
|
+
if (!product || product.deletedAt) {
|
|
767
|
+
throw new HttpError(404, ERROR_CODES.PRODUCT_NOT_FOUND, 'Product not found.');
|
|
768
|
+
}
|
|
769
|
+
if (product.status === 'inactive') {
|
|
770
|
+
throw new HttpError(410, ERROR_CODES.PRODUCT_ARCHIVED, 'Product is inactive.');
|
|
771
|
+
}
|
|
772
|
+
// Visibility — if the product is not associated with the requested channel,
|
|
773
|
+
// behave like it doesn't exist. Avoids exposing non-public catalogue.
|
|
774
|
+
const visibleIds = await this.filterByChannel([product.id], channel);
|
|
775
|
+
if (!visibleIds.has(product.id)) {
|
|
776
|
+
throw new HttpError(404, ERROR_CODES.PRODUCT_NOT_FOUND, 'Product not found.');
|
|
777
|
+
}
|
|
778
|
+
// Issue #227 — and the same answer for the audience axis. 404 rather than
|
|
779
|
+
// 403: a caller who may not see the row may not learn it exists either,
|
|
780
|
+
// and a 403 on a slug tells them it does. This is also the gate the
|
|
781
|
+
// `/links` and `/bundle-configuration/validate` routes stand behind, since
|
|
782
|
+
// both resolve their subject through here first.
|
|
783
|
+
if (!isProductVisibleTo(product, ctx.audience)) {
|
|
784
|
+
throw new HttpError(404, ERROR_CODES.PRODUCT_NOT_FOUND, 'Product not found.');
|
|
785
|
+
}
|
|
786
|
+
const summary = this.toSummary(product, channel, ctx.preferredLanguage, await this.#listingPricesFor([product], channel, ctx.audience), await this.#listingCardReadsFor(em, [product.id]));
|
|
787
|
+
// Categories
|
|
788
|
+
const categoryRows = await em.execute(`select category_id from product_categories where product_id = ?`, [product.id]);
|
|
789
|
+
const categoryIds = categoryRows.map((r) => r.category_id);
|
|
790
|
+
// Feature 068 — the PDP breadcrumb/category list is customer-facing: skip
|
|
791
|
+
// deactivated categories, and deleted ones (never filtered here before).
|
|
792
|
+
const categories = await em.find(Category, {
|
|
793
|
+
id: { $in: categoryIds },
|
|
794
|
+
deletedAt: null,
|
|
795
|
+
isActive: true,
|
|
796
|
+
});
|
|
797
|
+
// Assets
|
|
798
|
+
const assetRows = await em.execute(`select asset_id, position from product_assets where product_id = ? order by position asc`, [product.id]);
|
|
799
|
+
const assetIds = assetRows.map((r) => r.asset_id);
|
|
800
|
+
const assets = await this.#requireAssets().findByIds(assetIds);
|
|
801
|
+
// Variants
|
|
802
|
+
const variants = product.type === 'configurable' ? await em.find(ProductVariant, { parentProductId: product.id }) : [];
|
|
803
|
+
// Feature 002 — Attribute Set wired to this Product. Pulled in a
|
|
804
|
+
// single findOne so PDP renders include the set's localized name
|
|
805
|
+
// without a follow-up call.
|
|
806
|
+
const attributeSetEntity = await em.findOne(AttributeSet, {
|
|
807
|
+
id: product.attributeSetId,
|
|
808
|
+
});
|
|
809
|
+
// Feature 002 US3 — eager-load gallery + attachments for the PDP.
|
|
810
|
+
// Gallery: items + label bridge zipped per item; Asset urls
|
|
811
|
+
// resolved into a flat shape the storefront can render directly.
|
|
812
|
+
const galleryItems = await em.find(GalleryItem, { productId: product.id }, { orderBy: { position: 'asc', id: 'asc' } });
|
|
813
|
+
const galleryAssetIds = galleryItems.map((g) => g.assetId);
|
|
814
|
+
const galleryAssetsById = new Map();
|
|
815
|
+
if (galleryAssetIds.length > 0) {
|
|
816
|
+
const galleryAssets = await this.#requireAssets().findByIds(galleryAssetIds);
|
|
817
|
+
for (const a of galleryAssets)
|
|
818
|
+
galleryAssetsById.set(a.id, a);
|
|
819
|
+
}
|
|
820
|
+
const galleryLabels = galleryItems.length > 0
|
|
821
|
+
? await em.find(GalleryItemLabel, { productId: product.id })
|
|
822
|
+
: [];
|
|
823
|
+
const labelsByItem = new Map();
|
|
824
|
+
for (const l of galleryLabels) {
|
|
825
|
+
const existing = labelsByItem.get(l.galleryItemId) ?? [];
|
|
826
|
+
existing.push(l.label);
|
|
827
|
+
labelsByItem.set(l.galleryItemId, existing);
|
|
828
|
+
}
|
|
829
|
+
// Attachments + their types, eager-loaded in two queries.
|
|
830
|
+
const attachmentRows = await em.find(ProductAttachment, { productId: product.id }, { orderBy: { position: 'asc', id: 'asc' } });
|
|
831
|
+
const attachmentTypeIds = [...new Set(attachmentRows.map((a) => a.attachmentTypeId))];
|
|
832
|
+
const attachmentTypesById = new Map();
|
|
833
|
+
if (attachmentTypeIds.length > 0) {
|
|
834
|
+
const types = await em.find(AttachmentType, { id: { $in: attachmentTypeIds } });
|
|
835
|
+
for (const t of types)
|
|
836
|
+
attachmentTypesById.set(t.id, t);
|
|
837
|
+
}
|
|
838
|
+
const attachmentAssetIds = [...new Set(attachmentRows.map((a) => a.assetId))];
|
|
839
|
+
const attachmentAssetsById = new Map();
|
|
840
|
+
if (attachmentAssetIds.length > 0) {
|
|
841
|
+
const aAssets = await this.#requireAssets().findByIds(attachmentAssetIds);
|
|
842
|
+
for (const a of aAssets)
|
|
843
|
+
attachmentAssetsById.set(a.id, a);
|
|
844
|
+
}
|
|
845
|
+
const descriptionText = this.pickLang(product.description, ctx.preferredLanguage, channel);
|
|
846
|
+
const nameText = this.pickLang(product.name, ctx.preferredLanguage, channel);
|
|
847
|
+
// Feature 012 / FR-030 — "Parametry produktu" tab projection.
|
|
848
|
+
// Resolve every attribute that is flagged isVisibleOnProductPage
|
|
849
|
+
// AND has a non-empty value on this product. Render the value per
|
|
850
|
+
// the active locale (option label for select / enum / multiselect,
|
|
851
|
+
// formatted scalar otherwise). Sorted by AttributeSetAttribute
|
|
852
|
+
// position when assigned, by attribute key otherwise.
|
|
853
|
+
const visibleAttributes = await this.buildVisibleAttributesProjection(em, product, ctx.preferredLanguage, channel);
|
|
854
|
+
const detail = {
|
|
855
|
+
...summary,
|
|
856
|
+
description: descriptionText,
|
|
857
|
+
attributeValues: this.coerceAttributeValues(product.attributeValues),
|
|
858
|
+
assets: assets.map((a) => ({
|
|
859
|
+
id: a.id,
|
|
860
|
+
kind: a.kind,
|
|
861
|
+
url: a.storageUrl,
|
|
862
|
+
altText: a.altText ? this.pickLang(a.altText, ctx.preferredLanguage, channel) : null,
|
|
863
|
+
})),
|
|
864
|
+
variants: variants.map((v) => ({
|
|
865
|
+
id: v.id,
|
|
866
|
+
sku: v.sku,
|
|
867
|
+
variantAttributeValues: v.variantAttributeValues,
|
|
868
|
+
priceOverride: v.priceOverride != null ? Number(v.priceOverride) : null,
|
|
869
|
+
stockLevel: v.stockLevel ?? null,
|
|
870
|
+
})),
|
|
871
|
+
categories: categories.map((c) => ({
|
|
872
|
+
id: c.id,
|
|
873
|
+
name: this.pickLang(c.name, ctx.preferredLanguage, channel),
|
|
874
|
+
slug: c.slug,
|
|
875
|
+
})),
|
|
876
|
+
seo: {
|
|
877
|
+
metaTitle: nameText,
|
|
878
|
+
metaDescription: descriptionText.slice(0, 160),
|
|
879
|
+
openGraph: {
|
|
880
|
+
title: nameText,
|
|
881
|
+
description: descriptionText.slice(0, 240),
|
|
882
|
+
// T097 — OG image prefers gallery Base Image (then Thumbnail,
|
|
883
|
+
// then primaryAssetUrl). Base Image is the marketer's hero
|
|
884
|
+
// shot, so social shares should land on it.
|
|
885
|
+
imageUrl: (() => {
|
|
886
|
+
const base = galleryItems
|
|
887
|
+
.map((g) => ({
|
|
888
|
+
asset: galleryAssetsById.get(g.assetId) ?? null,
|
|
889
|
+
labels: labelsByItem.get(g.id) ?? [],
|
|
890
|
+
}))
|
|
891
|
+
.find((g) => g.asset && g.labels.includes('base_image'));
|
|
892
|
+
if (base?.asset)
|
|
893
|
+
return base.asset.storageUrl;
|
|
894
|
+
const thumb = galleryItems
|
|
895
|
+
.map((g) => ({
|
|
896
|
+
asset: galleryAssetsById.get(g.assetId) ?? null,
|
|
897
|
+
labels: labelsByItem.get(g.id) ?? [],
|
|
898
|
+
}))
|
|
899
|
+
.find((g) => g.asset && g.labels.includes('thumbnail'));
|
|
900
|
+
if (thumb?.asset)
|
|
901
|
+
return thumb.asset.storageUrl;
|
|
902
|
+
return summary.primaryAssetUrl;
|
|
903
|
+
})(),
|
|
904
|
+
},
|
|
905
|
+
},
|
|
906
|
+
structuredDataJsonLd: {
|
|
907
|
+
'@context': 'https://schema.org/',
|
|
908
|
+
'@type': 'Product',
|
|
909
|
+
name: nameText,
|
|
910
|
+
sku: product.sku,
|
|
911
|
+
description: descriptionText,
|
|
912
|
+
...(summary.price
|
|
913
|
+
? {
|
|
914
|
+
offers: {
|
|
915
|
+
'@type': 'Offer',
|
|
916
|
+
price: summary.price.amount,
|
|
917
|
+
priceCurrency: summary.price.currency,
|
|
918
|
+
availability: summary.stockIndicator === 'out_of_stock'
|
|
919
|
+
? 'https://schema.org/OutOfStock'
|
|
920
|
+
: 'https://schema.org/InStock',
|
|
921
|
+
},
|
|
922
|
+
}
|
|
923
|
+
: {}),
|
|
924
|
+
},
|
|
925
|
+
...(attributeSetEntity
|
|
926
|
+
? {
|
|
927
|
+
attributeSet: {
|
|
928
|
+
id: attributeSetEntity.id,
|
|
929
|
+
code: attributeSetEntity.code,
|
|
930
|
+
name: attributeSetEntity.name,
|
|
931
|
+
},
|
|
932
|
+
}
|
|
933
|
+
: {}),
|
|
934
|
+
gallery: galleryItems.flatMap((g) => {
|
|
935
|
+
const a = galleryAssetsById.get(g.assetId);
|
|
936
|
+
if (!a)
|
|
937
|
+
return [];
|
|
938
|
+
return [
|
|
939
|
+
{
|
|
940
|
+
id: g.id,
|
|
941
|
+
position: g.position,
|
|
942
|
+
labels: [...(labelsByItem.get(g.id) ?? [])].sort(),
|
|
943
|
+
asset: { id: a.id, kind: a.kind, url: a.storageUrl },
|
|
944
|
+
},
|
|
945
|
+
];
|
|
946
|
+
}),
|
|
947
|
+
attachments: attachmentRows.flatMap((row) => {
|
|
948
|
+
const t = attachmentTypesById.get(row.attachmentTypeId);
|
|
949
|
+
const a = attachmentAssetsById.get(row.assetId);
|
|
950
|
+
if (!t || !a)
|
|
951
|
+
return [];
|
|
952
|
+
return [
|
|
953
|
+
{
|
|
954
|
+
id: row.id,
|
|
955
|
+
position: row.position,
|
|
956
|
+
name: row.name,
|
|
957
|
+
description: row.description ?? null,
|
|
958
|
+
type: { id: t.id, code: t.code, name: t.name },
|
|
959
|
+
asset: {
|
|
960
|
+
id: a.id,
|
|
961
|
+
kind: a.kind,
|
|
962
|
+
url: a.storageUrl,
|
|
963
|
+
filename: a.filename,
|
|
964
|
+
sizeBytes: Number(a.sizeBytes),
|
|
965
|
+
mimeType: a.mimeType,
|
|
966
|
+
},
|
|
967
|
+
},
|
|
968
|
+
];
|
|
969
|
+
}),
|
|
970
|
+
visibleAttributes,
|
|
971
|
+
};
|
|
972
|
+
// Feature 002 US4 — pre-grouped Product Links. Inactive targets and
|
|
973
|
+
// channel-restricted ones are filtered by listForStorefront. Default
|
|
974
|
+
// page sizes per spec.md US4 Assumptions: related=8, up-sell=4,
|
|
975
|
+
// cross-sell=4.
|
|
976
|
+
if (this.productLinkService) {
|
|
977
|
+
const linkRows = await this.productLinkService.listForStorefront(product.id, {
|
|
978
|
+
resolvedChannel: ctx.resolvedChannel,
|
|
979
|
+
audience: ctx.audience,
|
|
980
|
+
...(ctx.preferredLanguage ? { preferredLanguage: ctx.preferredLanguage } : {}),
|
|
981
|
+
});
|
|
982
|
+
const sliced = (rows, n) => rows.slice(0, n);
|
|
983
|
+
detail.links = {
|
|
984
|
+
related: sliced(linkRows.filter((l) => l.kind === 'related'), 8),
|
|
985
|
+
upSell: sliced(linkRows.filter((l) => l.kind === 'up_sell'), 4),
|
|
986
|
+
crossSell: sliced(linkRows.filter((l) => l.kind === 'cross_sell'), 4),
|
|
987
|
+
};
|
|
988
|
+
}
|
|
989
|
+
// Feature 002 US5 — type-discriminated composite payload. Only the
|
|
990
|
+
// branch matching `product.type` is populated; other branches are
|
|
991
|
+
// omitted so the response stays compact and the storefront can do a
|
|
992
|
+
// simple type switch.
|
|
993
|
+
if (product.type === 'grouped') {
|
|
994
|
+
const items = await em.find(GroupedItem, { parentProductId: product.id }, { orderBy: { position: 'asc', id: 'asc' } });
|
|
995
|
+
const childIds = items.map((i) => i.childProductId);
|
|
996
|
+
const summaries = await this.summariesForIds(em, childIds, ctx, channel);
|
|
997
|
+
detail.groupedItems = items.flatMap((i) => {
|
|
998
|
+
const summary = summaries.get(i.childProductId);
|
|
999
|
+
if (!summary)
|
|
1000
|
+
return [];
|
|
1001
|
+
return [{ id: i.id, position: i.position, quantity: i.quantity, product: summary }];
|
|
1002
|
+
});
|
|
1003
|
+
}
|
|
1004
|
+
else if (product.type === 'bundle') {
|
|
1005
|
+
const slots = await em.find(BundleSlot, { parentProductId: product.id }, { orderBy: { position: 'asc', id: 'asc' } });
|
|
1006
|
+
const slotIds = slots.map((s) => s.id);
|
|
1007
|
+
const options = slotIds.length > 0
|
|
1008
|
+
? await em.find(BundleSlotOption, { slotId: { $in: slotIds } }, { orderBy: { position: 'asc', id: 'asc' } })
|
|
1009
|
+
: [];
|
|
1010
|
+
const optsBySlot = new Map();
|
|
1011
|
+
for (const opt of options) {
|
|
1012
|
+
const list = optsBySlot.get(opt.slotId) ?? [];
|
|
1013
|
+
list.push(opt);
|
|
1014
|
+
optsBySlot.set(opt.slotId, list);
|
|
1015
|
+
}
|
|
1016
|
+
const optionProductIds = [...new Set(options.map((o) => o.optionProductId))];
|
|
1017
|
+
const summaries = await this.summariesForIds(em, optionProductIds, ctx, channel);
|
|
1018
|
+
detail.bundleSlots = slots.map((s) => ({
|
|
1019
|
+
id: s.id,
|
|
1020
|
+
name: s.name,
|
|
1021
|
+
minQuantity: s.minQuantity,
|
|
1022
|
+
maxQuantity: s.maxQuantity,
|
|
1023
|
+
position: s.position,
|
|
1024
|
+
options: (optsBySlot.get(s.id) ?? []).flatMap((o) => {
|
|
1025
|
+
const summary = summaries.get(o.optionProductId);
|
|
1026
|
+
if (!summary)
|
|
1027
|
+
return [];
|
|
1028
|
+
return [
|
|
1029
|
+
{
|
|
1030
|
+
id: o.id,
|
|
1031
|
+
defaultQuantity: o.defaultQuantity,
|
|
1032
|
+
position: o.position,
|
|
1033
|
+
product: summary,
|
|
1034
|
+
},
|
|
1035
|
+
];
|
|
1036
|
+
}),
|
|
1037
|
+
}));
|
|
1038
|
+
}
|
|
1039
|
+
else if (product.type === 'virtual') {
|
|
1040
|
+
detail.virtual = {
|
|
1041
|
+
downloadAssetId: product.downloadAssetId ?? null,
|
|
1042
|
+
downloadUrl: product.downloadUrl ?? null,
|
|
1043
|
+
};
|
|
1044
|
+
}
|
|
1045
|
+
// Feature 043 — packaging units (only meaningful for the eligible types,
|
|
1046
|
+
// which are also the only ones that can have rows).
|
|
1047
|
+
if (product.type === 'simple' || product.type === 'configurable') {
|
|
1048
|
+
const packagingUnits = await em.find(ProductPackagingUnit, { productId: product.id }, { orderBy: { position: 'asc', name: 'asc' } });
|
|
1049
|
+
if (packagingUnits.length > 0) {
|
|
1050
|
+
detail.packagingUnits = packagingUnits.map((u) => ({
|
|
1051
|
+
id: u.id,
|
|
1052
|
+
name: u.name,
|
|
1053
|
+
baseQuantity: u.baseQuantity,
|
|
1054
|
+
position: u.position,
|
|
1055
|
+
isDefault: u.isDefault,
|
|
1056
|
+
}));
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
return detail;
|
|
1060
|
+
}
|
|
1061
|
+
/**
|
|
1062
|
+
* Resolve a batch of products into storefront-shape summaries (id, sku,
|
|
1063
|
+
* slug, localized name, primary asset url via gallery thumb chain,
|
|
1064
|
+
* price honoring sales-channel public flag). Used by US5 composite
|
|
1065
|
+
* eager-load — keeps the per-product fanout to a constant 3-4 queries
|
|
1066
|
+
* regardless of how many children/options a parent has.
|
|
1067
|
+
*/
|
|
1068
|
+
async summariesForIds(em, ids, ctx, channel) {
|
|
1069
|
+
const result = new Map();
|
|
1070
|
+
if (ids.length === 0)
|
|
1071
|
+
return result;
|
|
1072
|
+
const products = await em.find(Product, { id: { $in: ids } });
|
|
1073
|
+
// Primary asset url via gallery thumb chain -> base_image -> first item
|
|
1074
|
+
// -> legacy product_assets first row. One helper, shared with `toSummary`
|
|
1075
|
+
// and `ProductLinkService`, so the chain and the port call have one home.
|
|
1076
|
+
const assetUrlByProduct = await resolvePrimaryAssetUrls(em, this.#requireAssets(), ids);
|
|
1077
|
+
const resolvedPrices = await this.#listingPricesFor(products, channel, ctx.audience);
|
|
1078
|
+
for (const p of products) {
|
|
1079
|
+
const price = this.#summaryPrice(resolvedPrices, p.id);
|
|
1080
|
+
result.set(p.id, {
|
|
1081
|
+
id: p.id,
|
|
1082
|
+
sku: p.sku,
|
|
1083
|
+
slug: p.slug,
|
|
1084
|
+
name: this.pickLang(p.name, ctx.preferredLanguage, channel),
|
|
1085
|
+
primaryAssetUrl: assetUrlByProduct.get(p.id) ?? null,
|
|
1086
|
+
price,
|
|
1087
|
+
});
|
|
1088
|
+
}
|
|
1089
|
+
return result;
|
|
1090
|
+
}
|
|
1091
|
+
// ------------------------------------------------------------------
|
|
1092
|
+
// Categories
|
|
1093
|
+
// ------------------------------------------------------------------
|
|
1094
|
+
async getCategoryTree(ctx) {
|
|
1095
|
+
const em = this.emFactory();
|
|
1096
|
+
const channel = ctx.resolvedChannel;
|
|
1097
|
+
// Feature 068 — an inactive category is invisible to customers, and so is
|
|
1098
|
+
// everything under it: its children never reach `build()` because only
|
|
1099
|
+
// roots seed the walk.
|
|
1100
|
+
const rows = await em.find(Category, { deletedAt: null, isActive: true }, { orderBy: { sortOrder: 'asc' } });
|
|
1101
|
+
// Directly-assigned products per category that this caller may see.
|
|
1102
|
+
const directSets = await this.directProductSetsByCategory(em, channel, ctx.audience);
|
|
1103
|
+
const byParent = new Map();
|
|
1104
|
+
for (const row of rows) {
|
|
1105
|
+
const key = row.parentCategoryId ?? null;
|
|
1106
|
+
const list = byParent.get(key) ?? [];
|
|
1107
|
+
list.push(row);
|
|
1108
|
+
byParent.set(key, list);
|
|
1109
|
+
}
|
|
1110
|
+
// Roll up to a *distinct* product count over each category's whole subtree
|
|
1111
|
+
// (self + descendants). A parent category — e.g. the catalog root — would
|
|
1112
|
+
// otherwise show 0 because products are assigned to its leaf categories,
|
|
1113
|
+
// not to it directly. Counting a Set de-duplicates products that sit in
|
|
1114
|
+
// more than one branch of the subtree.
|
|
1115
|
+
const subtreeCounts = new Map();
|
|
1116
|
+
const collectSubtree = (categoryId) => {
|
|
1117
|
+
const acc = new Set(directSets.get(categoryId) ?? []);
|
|
1118
|
+
for (const child of byParent.get(categoryId) ?? []) {
|
|
1119
|
+
for (const pid of collectSubtree(child.id))
|
|
1120
|
+
acc.add(pid);
|
|
1121
|
+
}
|
|
1122
|
+
subtreeCounts.set(categoryId, acc.size);
|
|
1123
|
+
return acc;
|
|
1124
|
+
};
|
|
1125
|
+
for (const root of byParent.get(null) ?? [])
|
|
1126
|
+
collectSubtree(root.id);
|
|
1127
|
+
const build = (parentId) => {
|
|
1128
|
+
const children = byParent.get(parentId) ?? [];
|
|
1129
|
+
return children.map((c) => ({
|
|
1130
|
+
id: c.id,
|
|
1131
|
+
name: this.pickLang(c.name, ctx.preferredLanguage, channel),
|
|
1132
|
+
slug: c.slug,
|
|
1133
|
+
sortOrder: c.sortOrder,
|
|
1134
|
+
productCount: subtreeCounts.get(c.id) ?? 0,
|
|
1135
|
+
children: build(c.id),
|
|
1136
|
+
}));
|
|
1137
|
+
};
|
|
1138
|
+
return build(null);
|
|
1139
|
+
}
|
|
1140
|
+
// ------------------------------------------------------------------
|
|
1141
|
+
// Filters
|
|
1142
|
+
// ------------------------------------------------------------------
|
|
1143
|
+
async getFilterDefinitions(ctx) {
|
|
1144
|
+
const em = this.emFactory();
|
|
1145
|
+
const channel = ctx.resolvedChannel;
|
|
1146
|
+
const attrs = await this.#requireAttributeRead().listByFlag('isFilterable');
|
|
1147
|
+
// Build option / range facets by scanning Products in the channel.
|
|
1148
|
+
const products = await em.find(Product, { status: 'active', deletedAt: null });
|
|
1149
|
+
const visibleIds = await this.filterByChannel(products.map((p) => p.id), channel);
|
|
1150
|
+
// Issue #227 — a facet count is a disclosure too. "Brass (3)" on a
|
|
1151
|
+
// catalogue holding two brass products the caller may see is the third
|
|
1152
|
+
// one, named by arithmetic.
|
|
1153
|
+
const visible = products.filter((p) => visibleIds.has(p.id) && isProductVisibleTo(p, ctx.audience));
|
|
1154
|
+
const definitions = attrs.map((a) => {
|
|
1155
|
+
const label = this.pickLang(a.label, ctx.preferredLanguage, channel);
|
|
1156
|
+
const def = {
|
|
1157
|
+
attributeKey: a.key,
|
|
1158
|
+
label,
|
|
1159
|
+
valueType: a.valueType,
|
|
1160
|
+
filterPosition: a.filterPosition,
|
|
1161
|
+
};
|
|
1162
|
+
if (a.valueType === 'enum' ||
|
|
1163
|
+
a.valueType === 'select' ||
|
|
1164
|
+
a.valueType === 'multiselect' ||
|
|
1165
|
+
a.valueType === 'boolean' ||
|
|
1166
|
+
a.valueType === 'string') {
|
|
1167
|
+
const optionCounts = new Map();
|
|
1168
|
+
for (const p of visible) {
|
|
1169
|
+
const v = p.attributeValues[a.key];
|
|
1170
|
+
if (v === undefined || v === null)
|
|
1171
|
+
continue;
|
|
1172
|
+
// multiselect carries an array of selected option values.
|
|
1173
|
+
if (Array.isArray(v)) {
|
|
1174
|
+
for (const item of v) {
|
|
1175
|
+
const key = String(item);
|
|
1176
|
+
optionCounts.set(key, (optionCounts.get(key) ?? 0) + 1);
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
else {
|
|
1180
|
+
const key = String(v);
|
|
1181
|
+
optionCounts.set(key, (optionCounts.get(key) ?? 0) + 1);
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
def.options = Array.from(optionCounts.entries()).map(([value, count]) => ({
|
|
1185
|
+
value,
|
|
1186
|
+
label: value,
|
|
1187
|
+
count,
|
|
1188
|
+
}));
|
|
1189
|
+
}
|
|
1190
|
+
else if (a.valueType === 'number' || a.valueType === 'price' || a.valueType === 'date') {
|
|
1191
|
+
let min;
|
|
1192
|
+
let max;
|
|
1193
|
+
for (const p of visible) {
|
|
1194
|
+
const raw = p.attributeValues[a.key];
|
|
1195
|
+
const n = typeof raw === 'number' ? raw : Number(raw);
|
|
1196
|
+
if (!Number.isFinite(n))
|
|
1197
|
+
continue;
|
|
1198
|
+
if (min === undefined || n < min)
|
|
1199
|
+
min = n;
|
|
1200
|
+
if (max === undefined || n > max)
|
|
1201
|
+
max = n;
|
|
1202
|
+
}
|
|
1203
|
+
if (min !== undefined && max !== undefined) {
|
|
1204
|
+
def.range = { min, max };
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
return def;
|
|
1208
|
+
});
|
|
1209
|
+
// Feature 012 / FR-029 — omit filters that have zero values across
|
|
1210
|
+
// every visible product. Option-style filters with an empty options
|
|
1211
|
+
// array (no facet hits) are skipped; range-style filters with no
|
|
1212
|
+
// resolved min/max are also skipped.
|
|
1213
|
+
const nonEmpty = definitions.filter((d) => {
|
|
1214
|
+
if (d.options !== undefined)
|
|
1215
|
+
return d.options.length > 0;
|
|
1216
|
+
if (d.range !== undefined)
|
|
1217
|
+
return true;
|
|
1218
|
+
return false;
|
|
1219
|
+
});
|
|
1220
|
+
// Feature 055 (US4) — merge Category custom fields flagged `config.filterable`.
|
|
1221
|
+
// These are category-level filters whose options come from the field
|
|
1222
|
+
// definition, not from a product-facet scan, so they are appended AFTER the
|
|
1223
|
+
// product-facet `nonEmpty` drop. The catalog interprets the opaque `config`
|
|
1224
|
+
// (FR-006); the generic custom-fields core is unaware of catalog.
|
|
1225
|
+
const customFilters = await this.buildCustomFieldFilters(ctx, channel);
|
|
1226
|
+
const merged = [...nonEmpty, ...customFilters];
|
|
1227
|
+
// Feature 012 / FR-027 + FR-028 — pre-sort by filterPosition ASC,
|
|
1228
|
+
// then by resolved label ASC. Storefront consumes the order verbatim.
|
|
1229
|
+
merged.sort((a, b) => {
|
|
1230
|
+
if (a.filterPosition !== b.filterPosition)
|
|
1231
|
+
return a.filterPosition - b.filterPosition;
|
|
1232
|
+
return a.label.localeCompare(b.label);
|
|
1233
|
+
});
|
|
1234
|
+
return merged;
|
|
1235
|
+
}
|
|
1236
|
+
/**
|
|
1237
|
+
* Feature 055 (US4) — resolve the filterable Category custom fields into
|
|
1238
|
+
* {@link FilterDefinition}s. Returns `[]` when no definition source is wired.
|
|
1239
|
+
* Select/multiselect fields carry their defined options; scalar fields carry
|
|
1240
|
+
* neither options nor range (they render as a keyword/value filter client-side).
|
|
1241
|
+
*/
|
|
1242
|
+
async buildCustomFieldFilters(ctx, channel) {
|
|
1243
|
+
if (!this.customFieldDefinitions)
|
|
1244
|
+
return [];
|
|
1245
|
+
const defs = await this.customFieldDefinitions.listForEntity('category');
|
|
1246
|
+
const out = [];
|
|
1247
|
+
for (const { definition, options } of defs) {
|
|
1248
|
+
if (definition.config?.['filterable'] !== true)
|
|
1249
|
+
continue;
|
|
1250
|
+
const label = this.pickLang(definition.label, ctx.preferredLanguage, channel) || definition.labelDefault;
|
|
1251
|
+
const isSelect = definition.valueType === 'select' || definition.valueType === 'multiselect';
|
|
1252
|
+
const def = {
|
|
1253
|
+
// Namespaced so a custom field cannot collide with a product attribute key.
|
|
1254
|
+
attributeKey: `cf.${definition.key}`,
|
|
1255
|
+
label,
|
|
1256
|
+
valueType: definition.valueType === 'text' ? 'string' : definition.valueType,
|
|
1257
|
+
filterPosition: definition.sortOrder,
|
|
1258
|
+
...(isSelect
|
|
1259
|
+
? {
|
|
1260
|
+
options: options.map((o) => ({
|
|
1261
|
+
value: o.value,
|
|
1262
|
+
label: this.pickLang(o.label, ctx.preferredLanguage, channel) || o.labelDefault,
|
|
1263
|
+
count: 0,
|
|
1264
|
+
})),
|
|
1265
|
+
}
|
|
1266
|
+
: {}),
|
|
1267
|
+
};
|
|
1268
|
+
out.push(def);
|
|
1269
|
+
}
|
|
1270
|
+
return out;
|
|
1271
|
+
}
|
|
1272
|
+
// ------------------------------------------------------------------
|
|
1273
|
+
// Internal helpers
|
|
1274
|
+
// ------------------------------------------------------------------
|
|
1275
|
+
/**
|
|
1276
|
+
* Feature 012 / FR-030 — build the "Parametry produktu" tab payload
|
|
1277
|
+
* for one product. Returns every attribute that meets BOTH:
|
|
1278
|
+
* 1. attribute.isVisibleOnProductPage === true
|
|
1279
|
+
* 2. product.attributeValues[attribute.key] is non-null + non-empty
|
|
1280
|
+
*
|
|
1281
|
+
* For select / enum / multiselect types `valueRendered` is the
|
|
1282
|
+
* resolved per-locale option label (with fallback to labelDefault);
|
|
1283
|
+
* for boolean types it's 'Yes' / 'No'; for number / price / string
|
|
1284
|
+
* types it's the value coerced to string.
|
|
1285
|
+
*
|
|
1286
|
+
* Sorted by AttributeSetAttribute.position when the product has an
|
|
1287
|
+
* Attribute Set, else by attribute key ASC for determinism.
|
|
1288
|
+
*/
|
|
1289
|
+
async buildVisibleAttributesProjection(em, product, preferredLanguage, channel) {
|
|
1290
|
+
const values = product.attributeValues ?? {};
|
|
1291
|
+
const valueKeys = Object.keys(values).filter((k) => {
|
|
1292
|
+
const v = values[k];
|
|
1293
|
+
return v !== undefined && v !== null && v !== '';
|
|
1294
|
+
});
|
|
1295
|
+
if (valueKeys.length === 0)
|
|
1296
|
+
return [];
|
|
1297
|
+
// Feature 061 — attribute metadata + options come from the composed view.
|
|
1298
|
+
const valueKeySet = new Set(valueKeys);
|
|
1299
|
+
const allViews = await this.#requireAttributeRead().listAll();
|
|
1300
|
+
const attrs = allViews.filter((a) => valueKeySet.has(a.key) && a.isVisibleOnProductPage);
|
|
1301
|
+
if (attrs.length === 0)
|
|
1302
|
+
return [];
|
|
1303
|
+
const optionLabelByAttrAndValue = new Map();
|
|
1304
|
+
for (const a of attrs) {
|
|
1305
|
+
if (a.options.length === 0)
|
|
1306
|
+
continue;
|
|
1307
|
+
const bucket = new Map();
|
|
1308
|
+
for (const o of a.options) {
|
|
1309
|
+
bucket.set(o.value, { label: o.label ?? {}, labelDefault: o.labelDefault });
|
|
1310
|
+
}
|
|
1311
|
+
optionLabelByAttrAndValue.set(a.id, bucket);
|
|
1312
|
+
}
|
|
1313
|
+
const renderOptionLabel = (attrId, value) => {
|
|
1314
|
+
const bucket = optionLabelByAttrAndValue.get(attrId);
|
|
1315
|
+
const meta = bucket?.get(value);
|
|
1316
|
+
if (!meta)
|
|
1317
|
+
return value;
|
|
1318
|
+
return this.pickLang(meta.label, preferredLanguage, channel) || meta.labelDefault;
|
|
1319
|
+
};
|
|
1320
|
+
// Pull the AttributeSetAttribute positions for sort determinism
|
|
1321
|
+
// (membership is definition-keyed since feature 061).
|
|
1322
|
+
let positionByKey = new Map();
|
|
1323
|
+
if (product.attributeSetId) {
|
|
1324
|
+
const posRows = (await em.execute(`select custom_field_definition_id, position
|
|
1325
|
+
from attribute_set_attributes
|
|
1326
|
+
where attribute_set_id = ?`, [product.attributeSetId]));
|
|
1327
|
+
const keyByDefinitionId = new Map(allViews.map((v) => [v.customFieldDefinitionId, v.key]));
|
|
1328
|
+
positionByKey = new Map(posRows
|
|
1329
|
+
.map((r) => [keyByDefinitionId.get(r.custom_field_definition_id), r.position])
|
|
1330
|
+
.filter((pair) => pair[0] !== undefined));
|
|
1331
|
+
}
|
|
1332
|
+
const items = attrs.map((a) => {
|
|
1333
|
+
const raw = values[a.key];
|
|
1334
|
+
let valueRendered = '';
|
|
1335
|
+
if (Array.isArray(raw)) {
|
|
1336
|
+
// multiselect — comma-separated joined option labels
|
|
1337
|
+
valueRendered = raw
|
|
1338
|
+
.map((item) => renderOptionLabel(a.id, String(item)))
|
|
1339
|
+
.filter((s) => s !== '')
|
|
1340
|
+
.join(', ');
|
|
1341
|
+
}
|
|
1342
|
+
else if (a.valueType === 'select' || a.valueType === 'enum') {
|
|
1343
|
+
valueRendered = renderOptionLabel(a.id, String(raw));
|
|
1344
|
+
}
|
|
1345
|
+
else if (a.valueType === 'boolean') {
|
|
1346
|
+
valueRendered = raw === true || raw === 'true' ? 'Yes' : 'No';
|
|
1347
|
+
}
|
|
1348
|
+
else {
|
|
1349
|
+
valueRendered = String(raw);
|
|
1350
|
+
}
|
|
1351
|
+
const label = this.pickLang(a.label, preferredLanguage, channel) || a.labelDefault;
|
|
1352
|
+
return {
|
|
1353
|
+
key: a.key,
|
|
1354
|
+
label,
|
|
1355
|
+
valueType: a.valueType,
|
|
1356
|
+
valueRendered,
|
|
1357
|
+
};
|
|
1358
|
+
});
|
|
1359
|
+
items.sort((x, y) => {
|
|
1360
|
+
const px = positionByKey.get(x.key) ?? Number.MAX_SAFE_INTEGER;
|
|
1361
|
+
const py = positionByKey.get(y.key) ?? Number.MAX_SAFE_INTEGER;
|
|
1362
|
+
if (px !== py)
|
|
1363
|
+
return px - py;
|
|
1364
|
+
return x.key.localeCompare(y.key);
|
|
1365
|
+
});
|
|
1366
|
+
return items;
|
|
1367
|
+
}
|
|
1368
|
+
/** Cached per-call list of attribute keys that should match free-text search. */
|
|
1369
|
+
async searchableAttributeKeys(_em) {
|
|
1370
|
+
const attrs = await this.#requireAttributeRead().listByFlag('isSearchable');
|
|
1371
|
+
return attrs.map((a) => a.key);
|
|
1372
|
+
}
|
|
1373
|
+
/**
|
|
1374
|
+
* Feature 007 — list of attribute keys flagged `is_comparable=true`,
|
|
1375
|
+
* sorted alphabetically. Consumed by the comparisons module's
|
|
1376
|
+
* comparison-page projection (`ComparableAttributeProjection`). Public
|
|
1377
|
+
* because it crosses a module boundary (Constitution I — comparisons
|
|
1378
|
+
* MUST consume catalog through a documented service port, not by
|
|
1379
|
+
* importing internals).
|
|
1380
|
+
*/
|
|
1381
|
+
async comparableAttributeKeys() {
|
|
1382
|
+
// listByFlag orders by key ASC (legacy parity).
|
|
1383
|
+
const attrs = await this.#requireAttributeRead().listByFlag('isComparable');
|
|
1384
|
+
return attrs.map((a) => a.key);
|
|
1385
|
+
}
|
|
1386
|
+
/**
|
|
1387
|
+
* Feature 012 / US8 — list of attribute keys flagged `is_promo_rule=true`,
|
|
1388
|
+
* sorted alphabetically. Consumed by the promotions module's rule-target
|
|
1389
|
+
* picker endpoint and by the promotion-rule resolver's skip-on-toggle
|
|
1390
|
+
* check (FR-039). Public because it crosses a module boundary
|
|
1391
|
+
* (Constitution I — promotions MUST consume catalog through a documented
|
|
1392
|
+
* service port, not by importing internals).
|
|
1393
|
+
*/
|
|
1394
|
+
async promoRuleAttributeKeys() {
|
|
1395
|
+
// listByFlag orders by key ASC (legacy parity).
|
|
1396
|
+
const attrs = await this.#requireAttributeRead().listByFlag('isPromoRule');
|
|
1397
|
+
return attrs.map((a) => a.key);
|
|
1398
|
+
}
|
|
1399
|
+
/**
|
|
1400
|
+
* Feature 012 / US8 — load one attribute by key with its option list
|
|
1401
|
+
* inline. Returns `null` when the attribute does not exist. The option
|
|
1402
|
+
* list is empty for non-select-style types. Used by:
|
|
1403
|
+
* - Promotion-rule editor (criterion picker payload)
|
|
1404
|
+
* - PromotionRuleService.matches() to validate option values
|
|
1405
|
+
* against the attribute's authoritative option set.
|
|
1406
|
+
*/
|
|
1407
|
+
async getAttributeWithOptions(key) {
|
|
1408
|
+
const attr = await this.#requireAttributeRead().getByIdOrKey(key);
|
|
1409
|
+
if (!attr)
|
|
1410
|
+
return null;
|
|
1411
|
+
const options = [...attr.options]
|
|
1412
|
+
.sort((a, b) => a.sortOrder - b.sortOrder || a.value.localeCompare(b.value))
|
|
1413
|
+
.map((o) => ({
|
|
1414
|
+
value: o.value,
|
|
1415
|
+
label: o.label ?? {},
|
|
1416
|
+
labelDefault: o.labelDefault,
|
|
1417
|
+
}));
|
|
1418
|
+
return {
|
|
1419
|
+
id: attr.id,
|
|
1420
|
+
key: attr.key,
|
|
1421
|
+
label: attr.label ?? {},
|
|
1422
|
+
labelDefault: attr.labelDefault,
|
|
1423
|
+
valueType: attr.valueType,
|
|
1424
|
+
isPromoRule: attr.isPromoRule,
|
|
1425
|
+
options,
|
|
1426
|
+
};
|
|
1427
|
+
}
|
|
1428
|
+
/**
|
|
1429
|
+
* Build a MikroORM where-clause for "attributeValues[key] contains query
|
|
1430
|
+
* (case-insensitive)". MikroORM doesn't have a first-class JSONB query
|
|
1431
|
+
* helper for the `->>` operator, so we use `$jsonb` style by selecting the
|
|
1432
|
+
* value via the operators object.
|
|
1433
|
+
*/
|
|
1434
|
+
searchableJsonbClause(key, query) {
|
|
1435
|
+
// expr` returns the property at `key` cast to text; ILIKE handles
|
|
1436
|
+
// case-insensitivity. We rely on MikroORM's `raw()` operator-form passed
|
|
1437
|
+
// through to Knex.
|
|
1438
|
+
return { [key]: { $ilike: `%${query}%` } };
|
|
1439
|
+
}
|
|
1440
|
+
orderForSort(sort) {
|
|
1441
|
+
switch (sort) {
|
|
1442
|
+
case 'name':
|
|
1443
|
+
return { slug: 'asc', id: 'asc' };
|
|
1444
|
+
case '-name':
|
|
1445
|
+
return { slug: 'desc', id: 'desc' };
|
|
1446
|
+
case '-createdAt':
|
|
1447
|
+
case 'relevance':
|
|
1448
|
+
default:
|
|
1449
|
+
return { createdAt: 'desc', id: 'desc' };
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
/**
|
|
1453
|
+
* The keyset a listing pages by — **derived from the ordering it serves**,
|
|
1454
|
+
* never assumed.
|
|
1455
|
+
*
|
|
1456
|
+
* A chunk is fetched with `orderForSort(sort)` and the walk then asks for
|
|
1457
|
+
* "everything after the last row of it". Those are one decision, and they had
|
|
1458
|
+
* come apart: the `after` predicate was written over `created_at` whatever the
|
|
1459
|
+
* ordering, so under `sort=name` — which orders by `slug` — it excluded the
|
|
1460
|
+
* rows *newer* than the last one served instead of the rows already served.
|
|
1461
|
+
* That set has no relation to the page, so the walk both repeats products and
|
|
1462
|
+
* never reaches the ones between the repeats:
|
|
1463
|
+
* `?q=…&sort=name&minPrice=0&maxPrice=100000&limit=3` served
|
|
1464
|
+
* `[A, B, A]` on one page and then re-issued a cursor pointing at `B`.
|
|
1465
|
+
*
|
|
1466
|
+
* Deriving it here is what keeps the two in step for the next ordering
|
|
1467
|
+
* somebody adds: `orderForSort` is the only place that says what a sort orders
|
|
1468
|
+
* by, and this is the only place that says how to page it.
|
|
1469
|
+
*/
|
|
1470
|
+
keysetFor(sort) {
|
|
1471
|
+
const order = this.orderForSort(sort);
|
|
1472
|
+
const slugDirection = order['slug'];
|
|
1473
|
+
if (slugDirection !== undefined) {
|
|
1474
|
+
// `slug` is unique, so the `id` tie-break can never fire. It is written
|
|
1475
|
+
// anyway because the ordering names it, and a keyset that drops a column
|
|
1476
|
+
// its ordering carries is the defect above in miniature.
|
|
1477
|
+
const op = slugDirection === 'asc' ? '$gt' : '$lt';
|
|
1478
|
+
return {
|
|
1479
|
+
of: (product) => ({ slug: product.slug, id: product.id }),
|
|
1480
|
+
parse: (decoded) => isRecord(decoded) &&
|
|
1481
|
+
typeof decoded['slug'] === 'string' &&
|
|
1482
|
+
typeof decoded['id'] === 'string'
|
|
1483
|
+
? { slug: decoded['slug'], id: decoded['id'] }
|
|
1484
|
+
: null,
|
|
1485
|
+
after: (cursor) => afterKeysetCursor(cursor, op),
|
|
1486
|
+
};
|
|
1487
|
+
}
|
|
1488
|
+
// The default ordering is `createdAt desc, id desc`, in every sort that
|
|
1489
|
+
// reaches here, so the direction is not read off the map the way the slug
|
|
1490
|
+
// one is — it has nothing to read.
|
|
1491
|
+
return {
|
|
1492
|
+
of: (product) => ({ createdAt: product.createdAt.toISOString(), id: product.id }),
|
|
1493
|
+
parse: (decoded) => isRecord(decoded) &&
|
|
1494
|
+
typeof decoded['createdAt'] === 'string' &&
|
|
1495
|
+
typeof decoded['id'] === 'string' &&
|
|
1496
|
+
!Number.isNaN(new Date(decoded['createdAt']).getTime())
|
|
1497
|
+
? { createdAt: decoded['createdAt'], id: decoded['id'] }
|
|
1498
|
+
: null,
|
|
1499
|
+
after: (cursor) => afterKeysetCursor(cursor, '$lt'),
|
|
1500
|
+
};
|
|
1501
|
+
}
|
|
1502
|
+
async filterByChannel(productIds, channel) {
|
|
1503
|
+
if (productIds.length === 0)
|
|
1504
|
+
return new Set();
|
|
1505
|
+
// Feature 053 / Principle XII: a channel is ALWAYS resolved, so the catalog
|
|
1506
|
+
// constrains to the resolved channel's membership and fails closed to an
|
|
1507
|
+
// empty set — never the full cross-channel set. Through the kernel's
|
|
1508
|
+
// accessor since issue #185; this was a `select … from
|
|
1509
|
+
// sales_channel_products` written here, which is the clause's own
|
|
1510
|
+
// counter-example.
|
|
1511
|
+
const visible = await this.#requireChannelMembership().filterEntityIdsInChannel(channel.id, 'product', productIds);
|
|
1512
|
+
return new Set(visible);
|
|
1513
|
+
}
|
|
1514
|
+
async productIdsInCategoryTree(em, categorySlug) {
|
|
1515
|
+
// Feature 068 — an inactive category narrows to nothing, and an inactive
|
|
1516
|
+
// branch contributes no products to an active ancestor.
|
|
1517
|
+
const root = await em.findOne(Category, {
|
|
1518
|
+
slug: categorySlug,
|
|
1519
|
+
deletedAt: null,
|
|
1520
|
+
isActive: true,
|
|
1521
|
+
});
|
|
1522
|
+
if (!root)
|
|
1523
|
+
return new Set();
|
|
1524
|
+
// Collect descendant ids (BFS).
|
|
1525
|
+
const all = [root.id];
|
|
1526
|
+
let frontier = [root.id];
|
|
1527
|
+
while (frontier.length > 0) {
|
|
1528
|
+
const children = await em.find(Category, {
|
|
1529
|
+
parentCategoryId: { $in: frontier },
|
|
1530
|
+
deletedAt: null,
|
|
1531
|
+
isActive: true,
|
|
1532
|
+
});
|
|
1533
|
+
const nextIds = children.map((c) => c.id);
|
|
1534
|
+
all.push(...nextIds);
|
|
1535
|
+
frontier = nextIds;
|
|
1536
|
+
}
|
|
1537
|
+
const rows = await em.execute(`select product_id from product_categories where category_id in (${all.map(() => '?').join(',')})`, all);
|
|
1538
|
+
return new Set(rows.map((r) => r.product_id));
|
|
1539
|
+
}
|
|
1540
|
+
/**
|
|
1541
|
+
* Set of channel-visible product ids directly assigned to each category
|
|
1542
|
+
* (i.e. via a `product_categories` row). Subtree roll-up to ancestors is
|
|
1543
|
+
* done by the caller, which has the parent map to walk.
|
|
1544
|
+
*/
|
|
1545
|
+
async directProductSetsByCategory(em, channel, audience) {
|
|
1546
|
+
const base = await em.execute(`select category_id, product_id from product_categories`);
|
|
1547
|
+
if (base.length === 0)
|
|
1548
|
+
return new Map();
|
|
1549
|
+
// Channel membership.
|
|
1550
|
+
const visibleIds = await this.filterByChannel(base.map((r) => r.product_id), channel);
|
|
1551
|
+
// Issue #227 — and the audience axis, which needs the two columns this
|
|
1552
|
+
// path never loaded: `product_categories` carries ids and nothing else, so
|
|
1553
|
+
// the rows come back here rather than the predicate going down there. The
|
|
1554
|
+
// category tree publishes a `productCount` per node, and a count is the
|
|
1555
|
+
// one field a restricted product can still move.
|
|
1556
|
+
const restricted = new Set((await em.find(Product, { id: { $in: [...visibleIds] } }, { fields: ['id', 'visibility', 'allowedOrganizationIds'] }))
|
|
1557
|
+
.filter((p) => !isProductVisibleTo(p, audience))
|
|
1558
|
+
.map((p) => p.id));
|
|
1559
|
+
const sets = new Map();
|
|
1560
|
+
for (const row of base) {
|
|
1561
|
+
if (!visibleIds.has(row.product_id))
|
|
1562
|
+
continue;
|
|
1563
|
+
if (restricted.has(row.product_id))
|
|
1564
|
+
continue;
|
|
1565
|
+
let set = sets.get(row.category_id);
|
|
1566
|
+
if (!set) {
|
|
1567
|
+
set = new Set();
|
|
1568
|
+
sets.set(row.category_id, set);
|
|
1569
|
+
}
|
|
1570
|
+
set.add(row.product_id);
|
|
1571
|
+
}
|
|
1572
|
+
return sets;
|
|
1573
|
+
}
|
|
1574
|
+
/**
|
|
1575
|
+
* One product's listing summary.
|
|
1576
|
+
*
|
|
1577
|
+
* Every input is the **page's**, resolved by the caller, and all of them are
|
|
1578
|
+
* required rather than optional. This method used to resolve its own price,
|
|
1579
|
+
* its own primary asset and its own category slugs, which meant every listing
|
|
1580
|
+
* path that mapped over a page paid all three once per card — 150 of the ~158
|
|
1581
|
+
* statements a 50-item page cost after the price was hoisted (issue #263).
|
|
1582
|
+
* Making the parameters mandatory is what stops the next caller from quietly
|
|
1583
|
+
* re-opening that.
|
|
1584
|
+
*
|
|
1585
|
+
* It takes no `EntityManager` and is synchronous for the same reason: with
|
|
1586
|
+
* nothing to query through and nothing to await, a per-card read cannot be
|
|
1587
|
+
* added back here without the change being the point of the diff.
|
|
1588
|
+
*/
|
|
1589
|
+
toSummary(product, channel, preferredLanguage, resolvedPrices, cardReads) {
|
|
1590
|
+
// Price — the pricing engine's answer for this product on this channel
|
|
1591
|
+
// (issue #132), read out of the page's resolution. Sales Channel visibility
|
|
1592
|
+
// still strips it on a non-public channel; what changed is that the figure
|
|
1593
|
+
// underneath is one a price list stands behind rather than the catalogue's
|
|
1594
|
+
// own legacy attribute.
|
|
1595
|
+
const price = this.#summaryPrice(resolvedPrices, product.id);
|
|
1596
|
+
const nameText = this.pickLang(product.name, preferredLanguage, channel);
|
|
1597
|
+
return {
|
|
1598
|
+
id: product.id,
|
|
1599
|
+
sku: product.sku,
|
|
1600
|
+
type: product.type,
|
|
1601
|
+
name: nameText,
|
|
1602
|
+
slug: product.slug,
|
|
1603
|
+
categorySlugs: cardReads.categorySlugsByProduct.get(product.id) ?? [],
|
|
1604
|
+
primaryAssetUrl: cardReads.assetUrlByProduct.get(product.id) ?? null,
|
|
1605
|
+
price,
|
|
1606
|
+
stockIndicator: null,
|
|
1607
|
+
stockLevel: null,
|
|
1608
|
+
};
|
|
1609
|
+
}
|
|
1610
|
+
pickLang(blob, preferred, channel) {
|
|
1611
|
+
const preferredLangs = [preferred, channel?.defaultLanguage, 'en-US', 'en'].filter((v) => typeof v === 'string');
|
|
1612
|
+
for (const lang of preferredLangs) {
|
|
1613
|
+
const hit = blob[lang];
|
|
1614
|
+
if (hit)
|
|
1615
|
+
return hit;
|
|
1616
|
+
}
|
|
1617
|
+
const anyKey = Object.keys(blob)[0];
|
|
1618
|
+
return anyKey ? (blob[anyKey] ?? '') : '';
|
|
1619
|
+
}
|
|
1620
|
+
coerceAttributeValues(v) {
|
|
1621
|
+
const out = {};
|
|
1622
|
+
for (const [k, val] of Object.entries(v)) {
|
|
1623
|
+
if (typeof val === 'string' || typeof val === 'number' || typeof val === 'boolean') {
|
|
1624
|
+
out[k] = val;
|
|
1625
|
+
}
|
|
1626
|
+
else if (val !== null && val !== undefined) {
|
|
1627
|
+
out[k] = String(val);
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1630
|
+
return out;
|
|
1631
|
+
}
|
|
1632
|
+
}
|
|
1633
|
+
//# sourceMappingURL=catalog-query.service.js.map
|