@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,1516 @@
|
|
|
1
|
+
import { randomUUID } from 'crypto';
|
|
2
|
+
import { UniqueConstraintViolationException } from '@mikro-orm/core';
|
|
3
|
+
import { ERROR_CODES,
|
|
4
|
+
// Aliased: the private method below keeps the name, so the shared function
|
|
5
|
+
// needs one the class body cannot shadow.
|
|
6
|
+
slugify as slugifyText, } from '@endora-commerce/contracts';
|
|
7
|
+
import { HttpError } from '@endora-commerce/platform/http';
|
|
8
|
+
import { Category } from '../entities/category.entity.js';
|
|
9
|
+
import { Product } from '../entities/product.entity.js';
|
|
10
|
+
import { ProductVariant } from '../entities/product-variant.entity.js';
|
|
11
|
+
import { assertVirtualDownloadFields, ProductTypeValidationError, } from './product-type-validations.js';
|
|
12
|
+
import { createAttributeCommand, createAttributeOptionCommand, deleteAttributeCommand, deleteAttributeOptionCommand, updateAttributeCommand, updateAttributeOptionCommand, } from '../commands/attribute-commands.js';
|
|
13
|
+
// Feature 061 — the API-form mapping helpers moved next to the R7 map; the
|
|
14
|
+
// re-exports keep the long-standing import site (routes, tests) stable.
|
|
15
|
+
export { dbToApiAttributeType, resolveAttributeApiType } from './attribute-type-mapping.js';
|
|
16
|
+
/**
|
|
17
|
+
* Virtual attribute-value keys that are NOT bound to any AttributeSet but are
|
|
18
|
+
* legitimately stored under `product.attributeValues`. `defaultPrice` / `price`
|
|
19
|
+
* are the per-product base price the Details tab edits and the PriceList engine
|
|
20
|
+
* reads (see `default-price-list-migration.ts`). They must never be rejected by
|
|
21
|
+
* `assertAttributeValueKeysAllowed`, otherwise every save that carries a price
|
|
22
|
+
* (i.e. nearly all of them) fails with ATTRIBUTE_VALUE_REJECTED.
|
|
23
|
+
*/
|
|
24
|
+
const VIRTUAL_ATTRIBUTE_VALUE_KEYS = new Set([
|
|
25
|
+
'defaultPrice',
|
|
26
|
+
'price',
|
|
27
|
+
]);
|
|
28
|
+
/**
|
|
29
|
+
* Feature 068 — the width of `products.sku` / `product_variants.sku` and the
|
|
30
|
+
* cap the create and update contracts enforce. Nothing may write a longer
|
|
31
|
+
* identifier, derived or otherwise.
|
|
32
|
+
*/
|
|
33
|
+
const SKU_MAX_LENGTH = 255;
|
|
34
|
+
/**
|
|
35
|
+
* Catalog write-path service (admin write surface).
|
|
36
|
+
* Every mutation emits a typed event on the shared bus so the search indexer
|
|
37
|
+
* (T067) and webhook bridge (Phase 2 T037) can react.
|
|
38
|
+
*/
|
|
39
|
+
export class CatalogAdminService {
|
|
40
|
+
emFactory;
|
|
41
|
+
events;
|
|
42
|
+
auditLog;
|
|
43
|
+
salesChannelMembership;
|
|
44
|
+
commandBus;
|
|
45
|
+
attributeRead;
|
|
46
|
+
customFields;
|
|
47
|
+
copyWarehouseThresholds;
|
|
48
|
+
constructor(emFactory, events, auditLog,
|
|
49
|
+
/**
|
|
50
|
+
* Feature 005 / T027 — when injected, every newly-created Product
|
|
51
|
+
* that does not declare explicit channel membership lands in the
|
|
52
|
+
* system-default Sales Channel automatically (FR-011). Optional so
|
|
53
|
+
* existing tests that construct this service without sales-channels
|
|
54
|
+
* keep compiling; production composition.ts always provides it.
|
|
55
|
+
*/
|
|
56
|
+
salesChannelMembership,
|
|
57
|
+
/**
|
|
58
|
+
* Feature 054 — when injected, `updateProductAudited` records the admin
|
|
59
|
+
* single-update through the Command Bus (co-transactional audit + event).
|
|
60
|
+
*/
|
|
61
|
+
commandBus,
|
|
62
|
+
/**
|
|
63
|
+
* Feature 061 — composed attribute read model. Required for every
|
|
64
|
+
* attribute/option method; optional in the signature so legacy product-only
|
|
65
|
+
* fixtures keep constructing the service without attribute wiring.
|
|
66
|
+
*/
|
|
67
|
+
attributeRead,
|
|
68
|
+
/**
|
|
69
|
+
* Feature 061 — the custom_fields transactional apply seam. Required for
|
|
70
|
+
* attribute/option mutations. The committed-state definition read it used
|
|
71
|
+
* to carry alongside them is `attributeRead`'s since T053(b).
|
|
72
|
+
*/
|
|
73
|
+
customFields,
|
|
74
|
+
/**
|
|
75
|
+
* Issue #185 — `inventory`'s per-warehouse threshold copy, presence-decided
|
|
76
|
+
* by the wiring. Optional for the same reason every collaborator above it
|
|
77
|
+
* is: fixtures that duplicate a product without an inventory composition
|
|
78
|
+
* keep constructing this service, and a duplicate with no thresholds copied
|
|
79
|
+
* is the module's declared degrade rather than a failure.
|
|
80
|
+
*/
|
|
81
|
+
copyWarehouseThresholds) {
|
|
82
|
+
this.emFactory = emFactory;
|
|
83
|
+
this.events = events;
|
|
84
|
+
this.auditLog = auditLog;
|
|
85
|
+
this.salesChannelMembership = salesChannelMembership;
|
|
86
|
+
this.commandBus = commandBus;
|
|
87
|
+
this.attributeRead = attributeRead;
|
|
88
|
+
this.customFields = customFields;
|
|
89
|
+
this.copyWarehouseThresholds = copyWarehouseThresholds;
|
|
90
|
+
}
|
|
91
|
+
#requireAttributeRead() {
|
|
92
|
+
if (!this.attributeRead) {
|
|
93
|
+
throw new Error('CatalogAdminService: CatalogAttributeReadService is not wired — attribute reads are unavailable.');
|
|
94
|
+
}
|
|
95
|
+
return this.attributeRead;
|
|
96
|
+
}
|
|
97
|
+
#requireCustomFields() {
|
|
98
|
+
if (!this.customFields) {
|
|
99
|
+
throw new Error('CatalogAdminService: the custom_fields definition port is not wired — attribute writes are unavailable.');
|
|
100
|
+
}
|
|
101
|
+
return this.customFields;
|
|
102
|
+
}
|
|
103
|
+
#attributeCommandDeps() {
|
|
104
|
+
const read = this.#requireAttributeRead();
|
|
105
|
+
return {
|
|
106
|
+
apply: this.#requireCustomFields(),
|
|
107
|
+
// T053(b) — the definition read comes off the published read port, not
|
|
108
|
+
// off the apply seam. Two collaborators rather than one because they are
|
|
109
|
+
// two questions: a co-transactional write that must take the caller's
|
|
110
|
+
// `EntityManager`, and a committed-state read that must not.
|
|
111
|
+
readDefinition: (id) => read.getDefinitionById(id),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Feature 061 — run a catalog Command through the bus (audited) or, in
|
|
116
|
+
* bus-less fixtures, directly on a transactional em (no audit — same
|
|
117
|
+
* fallback contract as {@link #auditedWrite}). The domain event declared on
|
|
118
|
+
* the command is emitted either way (on commit only).
|
|
119
|
+
*/
|
|
120
|
+
async #runCommand(command) {
|
|
121
|
+
if (this.commandBus) {
|
|
122
|
+
return this.commandBus.run(command);
|
|
123
|
+
}
|
|
124
|
+
const em = this.emFactory();
|
|
125
|
+
const result = await em.transactional(async (tem) => {
|
|
126
|
+
const outcome = await command.run({
|
|
127
|
+
em: tem,
|
|
128
|
+
actor: { actorAdminUserId: null, impersonatedCustomerAccountId: null, kind: 'system' },
|
|
129
|
+
});
|
|
130
|
+
return outcome.result;
|
|
131
|
+
});
|
|
132
|
+
const evt = command.event?.(result);
|
|
133
|
+
if (evt) {
|
|
134
|
+
this.events.emit(evt.eventName, evt.payload);
|
|
135
|
+
}
|
|
136
|
+
return result;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Enqueues a full Meilisearch reindex (a `search_reindex` bulk operation)
|
|
140
|
+
* when an attribute's `searchable` flag flips. Set by the catalog plugin
|
|
141
|
+
* once the BulkOperationService exists (it is constructed after this
|
|
142
|
+
* service). When unset, a flag change still emits `attribute.updated.v1`
|
|
143
|
+
* (the lightweight settings refresh) but no reindex is queued.
|
|
144
|
+
*/
|
|
145
|
+
enqueueSearchReindex;
|
|
146
|
+
setSearchReindexEnqueuer(fn) {
|
|
147
|
+
this.enqueueSearchReindex = fn;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Feature 068 — product creation runs as the `product.create` Command, so the
|
|
151
|
+
* audit entry, the domain event and the row commit or roll back as one unit
|
|
152
|
+
* (Principle XIII). This closed the gap that made every worker-created product
|
|
153
|
+
* unaudited: the audit used to be a hand-written post-commit call that only
|
|
154
|
+
* fired when the caller supplied `auditCtx`, which no background caller has.
|
|
155
|
+
*
|
|
156
|
+
* `_auditCtx` is retained so the long-standing admin call site keeps compiling;
|
|
157
|
+
* the actor is now derived server-side from the ambient TenantContext.
|
|
158
|
+
*/
|
|
159
|
+
async createProduct(req, _auditCtx) {
|
|
160
|
+
// Feature 002 (T047): cross-field validation for virtual download
|
|
161
|
+
// fields. Zod's .refine() catches most cases at the boundary; the
|
|
162
|
+
// service-level guard is the belt-and-braces backstop for any
|
|
163
|
+
// call path that bypasses the schema (e.g. internal seeding).
|
|
164
|
+
try {
|
|
165
|
+
assertVirtualDownloadFields({
|
|
166
|
+
type: req.type,
|
|
167
|
+
downloadAssetId: req.downloadAssetId ?? null,
|
|
168
|
+
downloadUrl: req.downloadUrl ?? null,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
catch (err) {
|
|
172
|
+
if (err instanceof ProductTypeValidationError) {
|
|
173
|
+
throw new HttpError(400, ERROR_CODES.VALIDATION_FAILED, err.message);
|
|
174
|
+
}
|
|
175
|
+
throw err;
|
|
176
|
+
}
|
|
177
|
+
// The id is allocated up front so the Command can name its audit target
|
|
178
|
+
// before the row exists (same shape as `duplicateProduct`).
|
|
179
|
+
const productId = randomUUID();
|
|
180
|
+
let product;
|
|
181
|
+
try {
|
|
182
|
+
product = await this.#runCommand(this.#createProductCommand(productId, req));
|
|
183
|
+
}
|
|
184
|
+
catch (err) {
|
|
185
|
+
if (err instanceof UniqueConstraintViolationException) {
|
|
186
|
+
throw new HttpError(409, ERROR_CODES.SKU_ALREADY_EXISTS, `SKU "${req.sku}" already exists.`);
|
|
187
|
+
}
|
|
188
|
+
throw err;
|
|
189
|
+
}
|
|
190
|
+
// Feature 005 / FR-011 — bind to Default unless this product was
|
|
191
|
+
// already given memberships through some other path. Stays OUTSIDE the
|
|
192
|
+
// Command: the membership service writes on its own em, which cannot see
|
|
193
|
+
// the product row until the Command's transaction has committed.
|
|
194
|
+
if (this.salesChannelMembership) {
|
|
195
|
+
await this.salesChannelMembership.bindToDefaultIfEmpty('product', product.id);
|
|
196
|
+
}
|
|
197
|
+
return product;
|
|
198
|
+
}
|
|
199
|
+
#createProductCommand(productId, req) {
|
|
200
|
+
return {
|
|
201
|
+
action: 'product.create',
|
|
202
|
+
objectType: 'product',
|
|
203
|
+
objectId: productId,
|
|
204
|
+
run: async ({ em }) => {
|
|
205
|
+
// Feature 068 — `products.slug` is `@Unique()`, so the derived slug has
|
|
206
|
+
// to be allocated against the live table. Without this, two products
|
|
207
|
+
// sharing a name collided on the slug index and the catch above
|
|
208
|
+
// mislabelled the failure `SKU_ALREADY_EXISTS`.
|
|
209
|
+
const slug = await this.allocateUniqueSlug(em, this.anyValue(req.name) || req.sku);
|
|
210
|
+
const product = em.create(Product, {
|
|
211
|
+
id: productId,
|
|
212
|
+
sku: req.sku,
|
|
213
|
+
slug,
|
|
214
|
+
type: req.type,
|
|
215
|
+
status: req.status ?? 'draft',
|
|
216
|
+
name: req.name,
|
|
217
|
+
description: req.description,
|
|
218
|
+
stockMode: req.stockMode ?? null,
|
|
219
|
+
visibility: req.visibility,
|
|
220
|
+
attributeValues: req.attributeValues,
|
|
221
|
+
allowedOrganizationIds: req.allowedOrganizationIds ?? [],
|
|
222
|
+
// Feature 002 (T034): use the requested AttributeSet, else fall
|
|
223
|
+
// back to the entity's compile-time default (system Default Set).
|
|
224
|
+
...(req.attributeSetId ? { attributeSetId: req.attributeSetId } : {}),
|
|
225
|
+
// Feature 002 (T047): persist virtual download fields when set.
|
|
226
|
+
...(req.downloadAssetId !== undefined
|
|
227
|
+
? { downloadAssetId: req.downloadAssetId }
|
|
228
|
+
: {}),
|
|
229
|
+
...(req.downloadUrl !== undefined ? { downloadUrl: req.downloadUrl } : {}),
|
|
230
|
+
});
|
|
231
|
+
// Feature 002 (T023): the keys in `attributeValues` MUST belong to
|
|
232
|
+
// the Product's AttributeSet. The entity defaults `attributeSetId`
|
|
233
|
+
// to the system Default Set; future API surface revisions will let
|
|
234
|
+
// the admin pick a custom Set explicitly. See data-model.md §1.1.
|
|
235
|
+
await this.assertAttributeValueKeysAllowed(em, product.attributeSetId, req.attributeValues);
|
|
236
|
+
em.persist(product);
|
|
237
|
+
await em.flush();
|
|
238
|
+
return {
|
|
239
|
+
result: product,
|
|
240
|
+
after: {
|
|
241
|
+
sku: product.sku,
|
|
242
|
+
name: { ...product.name },
|
|
243
|
+
status: product.status,
|
|
244
|
+
visibility: product.visibility,
|
|
245
|
+
stockMode: product.stockMode,
|
|
246
|
+
},
|
|
247
|
+
};
|
|
248
|
+
},
|
|
249
|
+
event: (product) => ({
|
|
250
|
+
eventName: 'product.created.v1',
|
|
251
|
+
payload: {
|
|
252
|
+
eventId: randomUUID(),
|
|
253
|
+
occurredAt: new Date().toISOString(),
|
|
254
|
+
productId: product.id,
|
|
255
|
+
sku: product.sku,
|
|
256
|
+
},
|
|
257
|
+
}),
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
async updateProduct(id, req, auditCtx) {
|
|
261
|
+
const em = this.emFactory();
|
|
262
|
+
const r = await this.#applyProductUpdate(em, id, req);
|
|
263
|
+
await em.flush();
|
|
264
|
+
if (this.auditLog && auditCtx) {
|
|
265
|
+
await this.auditLog.record({
|
|
266
|
+
actorAdminUserId: auditCtx.actorAdminUserId,
|
|
267
|
+
...(auditCtx.impersonatedCustomerAccountId !== undefined
|
|
268
|
+
? { impersonatedCustomerAccountId: auditCtx.impersonatedCustomerAccountId }
|
|
269
|
+
: {}),
|
|
270
|
+
action: 'product.update',
|
|
271
|
+
objectType: 'product',
|
|
272
|
+
objectId: r.product.id,
|
|
273
|
+
stateBefore: r.stateBefore,
|
|
274
|
+
stateAfter: { ...r.stateAfter, changedFields: r.changedFields },
|
|
275
|
+
...(auditCtx.ipAddress !== undefined ? { ipAddress: auditCtx.ipAddress } : {}),
|
|
276
|
+
...(auditCtx.userAgent !== undefined ? { userAgent: auditCtx.userAgent } : {}),
|
|
277
|
+
...(auditCtx.requestId !== undefined ? { requestId: auditCtx.requestId } : {}),
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
this.events.emit('product.updated.v1', {
|
|
281
|
+
eventId: randomUUID(),
|
|
282
|
+
occurredAt: new Date().toISOString(),
|
|
283
|
+
productId: r.product.id,
|
|
284
|
+
changedFields: r.changedFields,
|
|
285
|
+
});
|
|
286
|
+
return r.product;
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Feature 054 — admin single-update path, audited co-transactionally via the
|
|
290
|
+
* Command Bus (one audit row + the event on commit, none on rollback). Falls
|
|
291
|
+
* back to the legacy `updateProduct` when no bus is injected (bus-less tests).
|
|
292
|
+
*/
|
|
293
|
+
async updateProductAudited(id, req) {
|
|
294
|
+
if (!this.commandBus)
|
|
295
|
+
return this.updateProduct(id, req);
|
|
296
|
+
return this.commandBus.run(this.#updateProductCommand(id, req));
|
|
297
|
+
}
|
|
298
|
+
#updateProductCommand(id, req) {
|
|
299
|
+
let changedFields = [];
|
|
300
|
+
return {
|
|
301
|
+
action: 'product.update',
|
|
302
|
+
objectType: 'product',
|
|
303
|
+
objectId: id,
|
|
304
|
+
run: async ({ em }) => {
|
|
305
|
+
const r = await this.#applyProductUpdate(em, id, req);
|
|
306
|
+
changedFields = r.changedFields;
|
|
307
|
+
return {
|
|
308
|
+
result: r.product,
|
|
309
|
+
before: r.stateBefore,
|
|
310
|
+
after: { ...r.stateAfter, changedFields },
|
|
311
|
+
};
|
|
312
|
+
},
|
|
313
|
+
event: (product) => ({
|
|
314
|
+
eventName: 'product.updated.v1',
|
|
315
|
+
payload: {
|
|
316
|
+
eventId: randomUUID(),
|
|
317
|
+
occurredAt: new Date().toISOString(),
|
|
318
|
+
productId: product.id,
|
|
319
|
+
changedFields,
|
|
320
|
+
},
|
|
321
|
+
}),
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Pure product-update write on the given em — no flush, no audit, no event.
|
|
326
|
+
* Shared by the legacy `updateProduct` and the audited Command path.
|
|
327
|
+
*/
|
|
328
|
+
async #applyProductUpdate(em, id, req) {
|
|
329
|
+
const product = await em.findOne(Product, { id });
|
|
330
|
+
if (!product) {
|
|
331
|
+
throw new HttpError(404, ERROR_CODES.PRODUCT_NOT_FOUND, 'Product not found.');
|
|
332
|
+
}
|
|
333
|
+
const stateBefore = {
|
|
334
|
+
sku: product.sku,
|
|
335
|
+
name: { ...product.name },
|
|
336
|
+
description: { ...product.description },
|
|
337
|
+
stockMode: product.stockMode,
|
|
338
|
+
visibility: product.visibility,
|
|
339
|
+
status: product.status,
|
|
340
|
+
archivedAt: product.archivedAt ?? null,
|
|
341
|
+
attributeValues: { ...product.attributeValues },
|
|
342
|
+
allowedOrganizationIds: [...product.allowedOrganizationIds],
|
|
343
|
+
};
|
|
344
|
+
const changedFields = [];
|
|
345
|
+
// Feature 012 / FR-016 — SKU is mutable. Refused with 400 sku_in_use
|
|
346
|
+
// when the new SKU collides with another product. The internal UUID
|
|
347
|
+
// (product.id) is the canonical reference; snapshot tables keep the
|
|
348
|
+
// SKU value frozen at snapshot time so historical orders / RFQs /
|
|
349
|
+
// invoices stay stable.
|
|
350
|
+
if (req.sku !== undefined && req.sku.trim() !== product.sku) {
|
|
351
|
+
const trimmed = req.sku.trim();
|
|
352
|
+
// Feature 068 — 255 is the width of `products.sku` and the cap the
|
|
353
|
+
// create contract enforces. This guard used to read 160 while the
|
|
354
|
+
// column held 64, so a 100-character rename passed validation and then
|
|
355
|
+
// failed at the database; all three now agree.
|
|
356
|
+
if (trimmed.length === 0 || trimmed.length > 255) {
|
|
357
|
+
throw new HttpError(400, ERROR_CODES.VALIDATION_FAILED, 'invalid_sku');
|
|
358
|
+
}
|
|
359
|
+
const conflict = await em.findOne(Product, { sku: trimmed });
|
|
360
|
+
if (conflict && conflict.id !== product.id) {
|
|
361
|
+
throw new HttpError(400, ERROR_CODES.VALIDATION_FAILED, `sku_in_use { conflictingProductId: ${conflict.id}, conflictingSku: ${trimmed} }`);
|
|
362
|
+
}
|
|
363
|
+
// Feature 025 — SKUs share a global namespace with variants (see createVariant).
|
|
364
|
+
const variantConflict = await em.findOne(ProductVariant, { sku: trimmed });
|
|
365
|
+
if (variantConflict) {
|
|
366
|
+
throw new HttpError(400, ERROR_CODES.VALIDATION_FAILED, `sku_in_use { conflictingVariantId: ${variantConflict.id}, conflictingSku: ${trimmed} }`);
|
|
367
|
+
}
|
|
368
|
+
product.sku = trimmed;
|
|
369
|
+
changedFields.push('sku');
|
|
370
|
+
}
|
|
371
|
+
if (req.name) {
|
|
372
|
+
product.name = req.name;
|
|
373
|
+
changedFields.push('name');
|
|
374
|
+
}
|
|
375
|
+
if (req.description) {
|
|
376
|
+
product.description = req.description;
|
|
377
|
+
changedFields.push('description');
|
|
378
|
+
}
|
|
379
|
+
if (req.stockMode !== undefined) {
|
|
380
|
+
product.stockMode = req.stockMode;
|
|
381
|
+
changedFields.push('stockMode');
|
|
382
|
+
}
|
|
383
|
+
if (req.visibility) {
|
|
384
|
+
product.visibility = req.visibility;
|
|
385
|
+
changedFields.push('visibility');
|
|
386
|
+
}
|
|
387
|
+
// Feature 022 / 032 — status field. Cross-field rule: transitioning to
|
|
388
|
+
// `inactive` sets archivedAt; transitioning away clears it.
|
|
389
|
+
if (req.status !== undefined && req.status !== product.status) {
|
|
390
|
+
product.status = req.status;
|
|
391
|
+
if (req.status === 'inactive') {
|
|
392
|
+
product.archivedAt = new Date();
|
|
393
|
+
}
|
|
394
|
+
else {
|
|
395
|
+
product.archivedAt = null;
|
|
396
|
+
}
|
|
397
|
+
changedFields.push('status');
|
|
398
|
+
}
|
|
399
|
+
// Feature 002 (T034) — attribute_set_id swap. Persist BEFORE
|
|
400
|
+
// attribute_values so the validation sees the new set's allowed keys.
|
|
401
|
+
if (req.attributeSetId !== undefined && req.attributeSetId !== product.attributeSetId) {
|
|
402
|
+
product.attributeSetId = req.attributeSetId;
|
|
403
|
+
changedFields.push('attributeSetId');
|
|
404
|
+
}
|
|
405
|
+
if (req.attributeValues) {
|
|
406
|
+
// Feature 002 (T023) — validate the patched keys against the
|
|
407
|
+
// Product's current AttributeSet. The merged object keys are all
|
|
408
|
+
// valid as long as both pre-existing and incoming keys live in
|
|
409
|
+
// the set; we validate the incoming patch only since the existing
|
|
410
|
+
// values were already validated at their time of write.
|
|
411
|
+
await this.assertAttributeValueKeysAllowed(em, product.attributeSetId, req.attributeValues, new Set(Object.keys(product.attributeValues ?? {})));
|
|
412
|
+
product.attributeValues = { ...product.attributeValues, ...req.attributeValues };
|
|
413
|
+
changedFields.push('attributeValues');
|
|
414
|
+
}
|
|
415
|
+
// Feature 012 / FR-013 — refuse the save if any required attribute
|
|
416
|
+
// in the assigned set is left without a value. The merged map is
|
|
417
|
+
// the source of truth here (a previously-set value satisfies the
|
|
418
|
+
// requirement even when the current patch omits it).
|
|
419
|
+
if (product.attributeSetId &&
|
|
420
|
+
(req.attributeValues !== undefined ||
|
|
421
|
+
req.attributeSetId !== undefined)) {
|
|
422
|
+
await this.assertRequiredAttributesPresent(em, product.attributeSetId, product.attributeValues ?? {});
|
|
423
|
+
}
|
|
424
|
+
if (req.allowedOrganizationIds) {
|
|
425
|
+
product.allowedOrganizationIds = req.allowedOrganizationIds;
|
|
426
|
+
changedFields.push('allowedOrganizationIds');
|
|
427
|
+
}
|
|
428
|
+
// Feature 022 — category membership writes. `categoryIds` is the
|
|
429
|
+
// canonical set; rows are diffed against the current `product_categories`
|
|
430
|
+
// bridge and only added/removed rows are touched. Callers wanting
|
|
431
|
+
// `add` (union) semantics compute the union before passing it in
|
|
432
|
+
// (see CatalogBulkUpdateService).
|
|
433
|
+
if (req.categoryIds !== undefined) {
|
|
434
|
+
const conn = em.getConnection();
|
|
435
|
+
const txCtx = em.getTransactionContext();
|
|
436
|
+
const currentRows = (await conn.execute(`select category_id from product_categories where product_id = ?`, [product.id], 'all', txCtx));
|
|
437
|
+
const current = new Set(currentRows.map((r) => r.category_id));
|
|
438
|
+
const target = new Set(req.categoryIds);
|
|
439
|
+
const toAdd = req.categoryIds.filter((id) => !current.has(id));
|
|
440
|
+
const toRemove = [...current].filter((id) => !target.has(id));
|
|
441
|
+
if (toRemove.length > 0) {
|
|
442
|
+
const placeholders = toRemove.map(() => '?').join(',');
|
|
443
|
+
await conn.execute(`delete from product_categories where product_id = ? and category_id in (${placeholders})`, [product.id, ...toRemove], 'run', txCtx);
|
|
444
|
+
}
|
|
445
|
+
if (toAdd.length > 0) {
|
|
446
|
+
const placeholders = toAdd.map(() => '(?,?)').join(',');
|
|
447
|
+
const params = [];
|
|
448
|
+
for (const cid of toAdd) {
|
|
449
|
+
params.push(product.id, cid);
|
|
450
|
+
}
|
|
451
|
+
await conn.execute(`insert into product_categories (product_id, category_id) values ${placeholders}`, params, 'run', txCtx);
|
|
452
|
+
}
|
|
453
|
+
if (toAdd.length > 0 || toRemove.length > 0) {
|
|
454
|
+
changedFields.push('categoryIds');
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
// Feature 010 — per-product stock-management flags.
|
|
458
|
+
if (req.manageStock !== undefined) {
|
|
459
|
+
product.manageStock = req.manageStock;
|
|
460
|
+
changedFields.push('manageStock');
|
|
461
|
+
}
|
|
462
|
+
if (req.backorderEnabled !== undefined) {
|
|
463
|
+
product.backorderEnabled = req.backorderEnabled;
|
|
464
|
+
changedFields.push('backorderEnabled');
|
|
465
|
+
}
|
|
466
|
+
if (req.lowStockThreshold !== undefined) {
|
|
467
|
+
product.lowStockThreshold = req.lowStockThreshold;
|
|
468
|
+
changedFields.push('lowStockThreshold');
|
|
469
|
+
}
|
|
470
|
+
if (req.lowStockThresholdMode !== undefined) {
|
|
471
|
+
product.lowStockThresholdMode = req.lowStockThresholdMode;
|
|
472
|
+
changedFields.push('lowStockThresholdMode');
|
|
473
|
+
}
|
|
474
|
+
if (req.fulfilmentStrategy !== undefined) {
|
|
475
|
+
product.fulfilmentStrategy = req.fulfilmentStrategy;
|
|
476
|
+
changedFields.push('fulfilmentStrategy');
|
|
477
|
+
}
|
|
478
|
+
if (req.fulfilmentStrategyWarehouseOrder !== undefined) {
|
|
479
|
+
product.fulfilmentStrategyWarehouseOrder = req.fulfilmentStrategyWarehouseOrder;
|
|
480
|
+
changedFields.push('fulfilmentStrategyWarehouseOrder');
|
|
481
|
+
}
|
|
482
|
+
const stateAfter = {
|
|
483
|
+
name: { ...product.name },
|
|
484
|
+
description: { ...product.description },
|
|
485
|
+
stockMode: product.stockMode,
|
|
486
|
+
visibility: product.visibility,
|
|
487
|
+
status: product.status,
|
|
488
|
+
archivedAt: product.archivedAt ?? null,
|
|
489
|
+
attributeValues: { ...product.attributeValues },
|
|
490
|
+
allowedOrganizationIds: [...product.allowedOrganizationIds],
|
|
491
|
+
};
|
|
492
|
+
return { product, stateBefore, stateAfter, changedFields };
|
|
493
|
+
}
|
|
494
|
+
/**
|
|
495
|
+
* Duplicate an existing Product: copies the core row plus bridge tables
|
|
496
|
+
* (categories, sales-channel memberships, gallery items + labels,
|
|
497
|
+
* attachments, related/up-sell/cross-sell links, grouped children,
|
|
498
|
+
* bundle slots + options, variants).
|
|
499
|
+
*
|
|
500
|
+
* The duplicated row gets a fresh UUID; the SKU is derived from the
|
|
501
|
+
* source by appending `-copy`, then `-copy-2`, `-copy-3`, … until a
|
|
502
|
+
* free slot is found. Status is reset to `'draft'` and `archivedAt`
|
|
503
|
+
* is cleared so the operator can review before publishing.
|
|
504
|
+
*
|
|
505
|
+
* Variants have their own globally-unique SKUs; each is suffixed in
|
|
506
|
+
* the same way against the variant SKU space.
|
|
507
|
+
*/
|
|
508
|
+
/**
|
|
509
|
+
* Feature 054 — run a catalog write through the Command Bus (co-transactional
|
|
510
|
+
* audit) or a plain forked em (bus-less tests). `write` performs the mutation
|
|
511
|
+
* on the given em and returns the caller value + before/after snapshot.
|
|
512
|
+
*/
|
|
513
|
+
async #auditedWrite(action, objectType, objectId, write) {
|
|
514
|
+
if (this.commandBus) {
|
|
515
|
+
return this.commandBus.run({ action, objectType, objectId, run: ({ em }) => write(em) });
|
|
516
|
+
}
|
|
517
|
+
const em = this.emFactory();
|
|
518
|
+
const w = await write(em);
|
|
519
|
+
await em.flush();
|
|
520
|
+
return w.result;
|
|
521
|
+
}
|
|
522
|
+
async duplicateProduct(id) {
|
|
523
|
+
const em = this.emFactory();
|
|
524
|
+
const source = await em.findOne(Product, { id });
|
|
525
|
+
if (!source) {
|
|
526
|
+
throw new HttpError(404, ERROR_CODES.PRODUCT_NOT_FOUND, 'Product not found.');
|
|
527
|
+
}
|
|
528
|
+
const newSku = await this.allocateCopySku(em, source.sku);
|
|
529
|
+
const newSlug = await this.allocateCopySlug(em, source.slug);
|
|
530
|
+
// The dup product row + its audit are co-transactional; the bridge copies
|
|
531
|
+
// run AFTER on this em (matches the pre-054 two-step) — the dup FK target
|
|
532
|
+
// exists once committed, and a partially-copied dup is no worse than today.
|
|
533
|
+
const dupId = randomUUID();
|
|
534
|
+
const dup = await this.#auditedWrite('product.duplicate', 'product', dupId, async (cem) => {
|
|
535
|
+
const created = cem.create(Product, {
|
|
536
|
+
id: dupId,
|
|
537
|
+
sku: newSku,
|
|
538
|
+
slug: newSlug,
|
|
539
|
+
type: source.type,
|
|
540
|
+
status: 'draft',
|
|
541
|
+
name: this.suffixCopyNames(source.name),
|
|
542
|
+
description: { ...source.description },
|
|
543
|
+
stockMode: source.stockMode ?? null,
|
|
544
|
+
visibility: source.visibility,
|
|
545
|
+
attributeValues: { ...source.attributeValues },
|
|
546
|
+
allowedOrganizationIds: [...source.allowedOrganizationIds],
|
|
547
|
+
attributeSetId: source.attributeSetId,
|
|
548
|
+
...(source.downloadAssetId !== undefined && source.downloadAssetId !== null
|
|
549
|
+
? { downloadAssetId: source.downloadAssetId }
|
|
550
|
+
: {}),
|
|
551
|
+
...(source.downloadUrl !== undefined && source.downloadUrl !== null
|
|
552
|
+
? { downloadUrl: source.downloadUrl }
|
|
553
|
+
: {}),
|
|
554
|
+
manageStock: source.manageStock,
|
|
555
|
+
backorderEnabled: source.backorderEnabled,
|
|
556
|
+
...(source.lowStockThreshold !== undefined && source.lowStockThreshold !== null
|
|
557
|
+
? { lowStockThreshold: source.lowStockThreshold }
|
|
558
|
+
: {}),
|
|
559
|
+
lowStockThresholdMode: source.lowStockThresholdMode,
|
|
560
|
+
...(source.fulfilmentStrategy !== undefined && source.fulfilmentStrategy !== null
|
|
561
|
+
? { fulfilmentStrategy: source.fulfilmentStrategy }
|
|
562
|
+
: {}),
|
|
563
|
+
...(source.fulfilmentStrategyWarehouseOrder !== undefined &&
|
|
564
|
+
source.fulfilmentStrategyWarehouseOrder !== null
|
|
565
|
+
? {
|
|
566
|
+
fulfilmentStrategyWarehouseOrder: [
|
|
567
|
+
...source.fulfilmentStrategyWarehouseOrder,
|
|
568
|
+
],
|
|
569
|
+
}
|
|
570
|
+
: {}),
|
|
571
|
+
});
|
|
572
|
+
await cem.flush();
|
|
573
|
+
return {
|
|
574
|
+
result: created,
|
|
575
|
+
before: null,
|
|
576
|
+
after: { sku: newSku, slug: newSlug, sourceProductId: id },
|
|
577
|
+
};
|
|
578
|
+
});
|
|
579
|
+
// product_categories (bridge)
|
|
580
|
+
await em.execute(`insert into "product_categories" ("product_id", "category_id")
|
|
581
|
+
select ?, "category_id" from "product_categories" where "product_id" = ?`, [dup.id, source.id]);
|
|
582
|
+
// The channel assortment, through the kernel's membership service (issue
|
|
583
|
+
// #185). This was an `insert … select` against the bridge table, which is
|
|
584
|
+
// Principle XII's accessor clause and Principle XIII in one statement: the
|
|
585
|
+
// bridge was written directly and not one of the memberships the duplicate
|
|
586
|
+
// gained was audited. `copyMemberships` adds them one audited call at a
|
|
587
|
+
// time.
|
|
588
|
+
if (this.salesChannelMembership) {
|
|
589
|
+
await this.salesChannelMembership.copyMemberships('product', source.id, dup.id);
|
|
590
|
+
}
|
|
591
|
+
// gallery_items + gallery_item_labels — we need a fresh UUID per item
|
|
592
|
+
// and to rewrite the bridge rows to the new ids.
|
|
593
|
+
const galleryRows = (await em.execute(`select "id", "asset_id", "position" from "gallery_items"
|
|
594
|
+
where "product_id" = ? order by "position" asc`, [source.id]));
|
|
595
|
+
if (galleryRows.length > 0) {
|
|
596
|
+
const idMap = new Map();
|
|
597
|
+
for (const row of galleryRows) {
|
|
598
|
+
const newId = randomUUID();
|
|
599
|
+
idMap.set(row.id, newId);
|
|
600
|
+
await em.execute(`insert into "gallery_items"
|
|
601
|
+
("id", "product_id", "asset_id", "position", "created_at", "updated_at")
|
|
602
|
+
values (?, ?, ?, ?, now(), now())`, [newId, dup.id, row.asset_id, row.position]);
|
|
603
|
+
}
|
|
604
|
+
const labelRows = (await em.execute(`select "gallery_item_id", "label" from "gallery_item_labels"
|
|
605
|
+
where "product_id" = ?`, [source.id]));
|
|
606
|
+
for (const lbl of labelRows) {
|
|
607
|
+
const mapped = idMap.get(lbl.gallery_item_id);
|
|
608
|
+
if (!mapped)
|
|
609
|
+
continue;
|
|
610
|
+
await em.execute(`insert into "gallery_item_labels"
|
|
611
|
+
("gallery_item_id", "product_id", "label") values (?, ?, ?)`, [mapped, dup.id, lbl.label]);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
// product_attachments
|
|
615
|
+
await em.execute(`insert into "product_attachments"
|
|
616
|
+
("id", "product_id", "asset_id", "attachment_type_id", "name", "description", "position", "created_at", "updated_at")
|
|
617
|
+
select gen_random_uuid(), ?, "asset_id", "attachment_type_id", "name", "description", "position", now(), now()
|
|
618
|
+
from "product_attachments" where "product_id" = ?`, [dup.id, source.id]);
|
|
619
|
+
// product_links (only outgoing links are copied — incoming links from
|
|
620
|
+
// other products toward the source product stay attached to the source)
|
|
621
|
+
await em.execute(`insert into "product_links"
|
|
622
|
+
("id", "source_product_id", "target_product_id", "kind", "position", "created_at", "updated_at")
|
|
623
|
+
select gen_random_uuid(), ?, "target_product_id", "kind", "position", now(), now()
|
|
624
|
+
from "product_links" where "source_product_id" = ?`, [dup.id, source.id]);
|
|
625
|
+
// grouped_items (children of a grouped product)
|
|
626
|
+
if (source.type === 'grouped') {
|
|
627
|
+
await em.execute(`insert into "grouped_items"
|
|
628
|
+
("id", "parent_product_id", "child_product_id", "quantity", "position", "created_at", "updated_at")
|
|
629
|
+
select gen_random_uuid(), ?, "child_product_id", "quantity", "position", now(), now()
|
|
630
|
+
from "grouped_items" where "parent_product_id" = ?`, [dup.id, source.id]);
|
|
631
|
+
}
|
|
632
|
+
// bundle_slots + bundle_slot_options
|
|
633
|
+
if (source.type === 'bundle') {
|
|
634
|
+
const slotRows = (await em.execute(`select "id", "name", "min_quantity", "max_quantity", "position"
|
|
635
|
+
from "bundle_slots" where "parent_product_id" = ?`, [source.id]));
|
|
636
|
+
for (const slot of slotRows) {
|
|
637
|
+
const newSlotId = randomUUID();
|
|
638
|
+
await em.execute(`insert into "bundle_slots"
|
|
639
|
+
("id", "parent_product_id", "name", "min_quantity", "max_quantity", "position", "created_at", "updated_at")
|
|
640
|
+
values (?, ?, ?::jsonb, ?, ?, ?, now(), now())`, [
|
|
641
|
+
newSlotId,
|
|
642
|
+
dup.id,
|
|
643
|
+
JSON.stringify(slot.name),
|
|
644
|
+
slot.min_quantity,
|
|
645
|
+
slot.max_quantity,
|
|
646
|
+
slot.position,
|
|
647
|
+
]);
|
|
648
|
+
await em.execute(`insert into "bundle_slot_options"
|
|
649
|
+
("id", "slot_id", "option_product_id", "default_quantity", "position", "created_at", "updated_at")
|
|
650
|
+
select gen_random_uuid(), ?, "option_product_id", "default_quantity", "position", now(), now()
|
|
651
|
+
from "bundle_slot_options" where "slot_id" = ?`, [newSlotId, slot.id]);
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
// Per-(product, warehouse) low-stock thresholds, so the duplicate inherits
|
|
655
|
+
// the source's alerting profile. `inventory`'s rows and `inventory`'s
|
|
656
|
+
// Command since issue #185 — this used to be an `insert … select` into that
|
|
657
|
+
// module's table from here, which kept writing while an operator had the
|
|
658
|
+
// module switched off. No `catch` around it: the wiring decides presence in
|
|
659
|
+
// front of the gate and the degrade arrives as `'not-present'`.
|
|
660
|
+
if (this.copyWarehouseThresholds) {
|
|
661
|
+
await this.copyWarehouseThresholds({
|
|
662
|
+
sourceProductId: source.id,
|
|
663
|
+
targetProductId: dup.id,
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
// product_variants (configurable products) — each variant has its own
|
|
667
|
+
// unique SKU; we allocate copies the same way as the parent SKU.
|
|
668
|
+
if (source.type === 'configurable') {
|
|
669
|
+
const variants = await em.find(ProductVariant, { parentProductId: source.id });
|
|
670
|
+
for (const v of variants) {
|
|
671
|
+
const variantSku = await this.allocateCopySku(em, v.sku);
|
|
672
|
+
const newVariant = em.create(ProductVariant, {
|
|
673
|
+
parentProductId: dup.id,
|
|
674
|
+
sku: variantSku,
|
|
675
|
+
variantAttributeValues: { ...v.variantAttributeValues },
|
|
676
|
+
...(v.priceOverride != null ? { priceOverride: v.priceOverride } : {}),
|
|
677
|
+
...(v.stockLevel != null ? { stockLevel: v.stockLevel } : {}),
|
|
678
|
+
});
|
|
679
|
+
em.persist(newVariant);
|
|
680
|
+
}
|
|
681
|
+
await em.flush();
|
|
682
|
+
}
|
|
683
|
+
this.events.emit('product.created.v1', {
|
|
684
|
+
eventId: randomUUID(),
|
|
685
|
+
occurredAt: new Date().toISOString(),
|
|
686
|
+
productId: dup.id,
|
|
687
|
+
sku: dup.sku,
|
|
688
|
+
});
|
|
689
|
+
return dup;
|
|
690
|
+
}
|
|
691
|
+
/**
|
|
692
|
+
* Find a free SKU derived from `baseSku` by appending `-copy`,
|
|
693
|
+
* `-copy-2`, `-copy-3`, … until both Product and ProductVariant tables
|
|
694
|
+
* are clear (SKUs share a global namespace per `createVariant`).
|
|
695
|
+
* Bounded by 1000 attempts so a pathological collision can't hang.
|
|
696
|
+
*
|
|
697
|
+
* Feature 068 — the result must fit `SKU_MAX_LENGTH`. It is the *base* that
|
|
698
|
+
* gets shortened to make room, never the suffix: cutting the suffix would
|
|
699
|
+
* collapse every attempt onto the same string, so a long-SKU product could
|
|
700
|
+
* never be duplicated at all.
|
|
701
|
+
*/
|
|
702
|
+
async allocateCopySku(em, baseSku) {
|
|
703
|
+
for (let i = 0; i < 1000; i += 1) {
|
|
704
|
+
const suffix = i === 0 ? '-copy' : `-copy-${i + 1}`;
|
|
705
|
+
const base = baseSku.slice(0, SKU_MAX_LENGTH - suffix.length);
|
|
706
|
+
const candidate = `${base}${suffix}`;
|
|
707
|
+
const productHit = await em.findOne(Product, { sku: candidate });
|
|
708
|
+
if (productHit)
|
|
709
|
+
continue;
|
|
710
|
+
const variantHit = await em.findOne(ProductVariant, { sku: candidate });
|
|
711
|
+
if (variantHit)
|
|
712
|
+
continue;
|
|
713
|
+
return candidate;
|
|
714
|
+
}
|
|
715
|
+
throw new HttpError(409, ERROR_CODES.SKU_ALREADY_EXISTS, `Could not allocate a unique SKU derived from "${baseSku}".`);
|
|
716
|
+
}
|
|
717
|
+
async allocateCopySlug(em, baseSlug) {
|
|
718
|
+
return this.allocateUniqueSlug(em, `${baseSlug}-copy`);
|
|
719
|
+
}
|
|
720
|
+
/**
|
|
721
|
+
* Find a free slug derived from `source`: the slugified value itself, then
|
|
722
|
+
* `-2`, `-3`, … until `products.slug` (which is `@Unique()`) is clear.
|
|
723
|
+
* Bounded by 1000 attempts so a pathological collision can't hang.
|
|
724
|
+
*
|
|
725
|
+
* Feature 068 — used by the create path as well as the duplication path; a
|
|
726
|
+
* name collision on create used to reach the database and be reported as a
|
|
727
|
+
* SKU conflict.
|
|
728
|
+
*/
|
|
729
|
+
async allocateUniqueSlug(em, source) {
|
|
730
|
+
const root = this.slugify(source);
|
|
731
|
+
for (let i = 0; i < 1000; i += 1) {
|
|
732
|
+
const candidate = i === 0 ? root : this.slugify(`${root}-${i + 1}`);
|
|
733
|
+
const hit = await em.findOne(Product, { slug: candidate });
|
|
734
|
+
if (!hit)
|
|
735
|
+
return candidate;
|
|
736
|
+
}
|
|
737
|
+
throw new HttpError(409, ERROR_CODES.VALIDATION_FAILED, `Could not allocate a unique slug derived from "${source}".`);
|
|
738
|
+
}
|
|
739
|
+
/**
|
|
740
|
+
* Multilingual `name`: tag every locale with a "(copy)" suffix so the
|
|
741
|
+
* duplicated product is obviously a clone in lists. Empty locales are
|
|
742
|
+
* left untouched.
|
|
743
|
+
*/
|
|
744
|
+
suffixCopyNames(name) {
|
|
745
|
+
const out = {};
|
|
746
|
+
for (const [locale, value] of Object.entries(name)) {
|
|
747
|
+
if (!value || value.trim() === '') {
|
|
748
|
+
out[locale] = value;
|
|
749
|
+
}
|
|
750
|
+
else {
|
|
751
|
+
out[locale] = `${value} (copy)`.slice(0, 255);
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
return out;
|
|
755
|
+
}
|
|
756
|
+
/**
|
|
757
|
+
* @deprecated Use `updateProduct` with `status: 'inactive'` instead.
|
|
758
|
+
* Kept for internal callers that still emit `product.archived.v1`.
|
|
759
|
+
*/
|
|
760
|
+
async archiveProduct(id, auditCtx) {
|
|
761
|
+
await this.updateProduct(id, { status: 'inactive' }, auditCtx);
|
|
762
|
+
this.events.emit('product.archived.v1', {
|
|
763
|
+
eventId: randomUUID(),
|
|
764
|
+
occurredAt: new Date().toISOString(),
|
|
765
|
+
productId: id,
|
|
766
|
+
});
|
|
767
|
+
}
|
|
768
|
+
async assertProductDeletable(em, productId) {
|
|
769
|
+
// `em.execute`, not `em.getKnex()`: the caller is `product.delete`'s Command
|
|
770
|
+
// body, so this guard runs inside `CommandBus.run`'s transaction, and a knex
|
|
771
|
+
// instance is connection-level — it read the state outside the transaction
|
|
772
|
+
// whose write it is guarding (issue #200). `check:transaction-context`
|
|
773
|
+
// cannot see this one: it is a method call away from the `run` that carries
|
|
774
|
+
// the transaction, and following that hop would mean guessing at callers.
|
|
775
|
+
const orderRows = (await em.execute(`select count(*)::int as count from "order_items" where "product_id" = ?`, [productId]));
|
|
776
|
+
if (Number(orderRows[0]?.count ?? 0) > 0) {
|
|
777
|
+
throw new HttpError(409, ERROR_CODES.PRODUCT_DELETE_BLOCKED, 'Product cannot be deleted because it is referenced by order lines.');
|
|
778
|
+
}
|
|
779
|
+
const cartRows = (await em.execute(`select count(*)::int as count from "cart_items" where "product_id" = ?`, [productId]));
|
|
780
|
+
if (Number(cartRows[0]?.count ?? 0) > 0) {
|
|
781
|
+
throw new HttpError(409, ERROR_CODES.PRODUCT_DELETE_BLOCKED, 'Product cannot be deleted because it is referenced by cart lines.');
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
/**
|
|
785
|
+
* Feature 068 — soft-delete runs as the `product.delete` Command: the write,
|
|
786
|
+
* the audit entry and `product.deleted.v1` share one transaction. `_auditCtx`
|
|
787
|
+
* is retained for the existing admin call site; the actor is server-derived.
|
|
788
|
+
*/
|
|
789
|
+
async deleteProduct(id, _auditCtx) {
|
|
790
|
+
await this.#runCommand(this.#deleteProductCommand(id));
|
|
791
|
+
}
|
|
792
|
+
#deleteProductCommand(id) {
|
|
793
|
+
return {
|
|
794
|
+
action: 'product.delete',
|
|
795
|
+
objectType: 'product',
|
|
796
|
+
objectId: id,
|
|
797
|
+
run: async ({ em }) => {
|
|
798
|
+
const product = await em.findOne(Product, { id, deletedAt: null });
|
|
799
|
+
if (!product) {
|
|
800
|
+
throw new HttpError(404, ERROR_CODES.PRODUCT_NOT_FOUND, 'Product not found.');
|
|
801
|
+
}
|
|
802
|
+
await this.assertProductDeletable(em, id);
|
|
803
|
+
const stateBefore = {
|
|
804
|
+
status: product.status,
|
|
805
|
+
sku: product.sku,
|
|
806
|
+
name: { ...product.name },
|
|
807
|
+
deletedAt: product.deletedAt ?? null,
|
|
808
|
+
};
|
|
809
|
+
product.deletedAt = new Date();
|
|
810
|
+
await em.flush();
|
|
811
|
+
return {
|
|
812
|
+
result: { id: product.id },
|
|
813
|
+
before: stateBefore,
|
|
814
|
+
after: {
|
|
815
|
+
...stateBefore,
|
|
816
|
+
deletedAt: product.deletedAt?.toISOString() ?? null,
|
|
817
|
+
},
|
|
818
|
+
};
|
|
819
|
+
},
|
|
820
|
+
event: (result) => ({
|
|
821
|
+
eventName: 'product.deleted.v1',
|
|
822
|
+
payload: {
|
|
823
|
+
eventId: randomUUID(),
|
|
824
|
+
occurredAt: new Date().toISOString(),
|
|
825
|
+
productId: result.id,
|
|
826
|
+
},
|
|
827
|
+
}),
|
|
828
|
+
};
|
|
829
|
+
}
|
|
830
|
+
// ------------------------------------------------------------------
|
|
831
|
+
// Attributes
|
|
832
|
+
// ------------------------------------------------------------------
|
|
833
|
+
async createAttribute(req) {
|
|
834
|
+
const read = this.#requireAttributeRead();
|
|
835
|
+
// Definition sort order: append after the current tail (matches the CF
|
|
836
|
+
// admin surface's manual ordering semantics).
|
|
837
|
+
const existing = await read.listAll();
|
|
838
|
+
const nextSortOrder = existing.length;
|
|
839
|
+
const extensionId = randomUUID();
|
|
840
|
+
await this.#runCommand(createAttributeCommand(this.#attributeCommandDeps(), req, {
|
|
841
|
+
extensionId,
|
|
842
|
+
sortOrder: nextSortOrder,
|
|
843
|
+
}));
|
|
844
|
+
await this.#requireCustomFields().publishInvalidate('product');
|
|
845
|
+
const view = await read.getByIdOrKey(extensionId);
|
|
846
|
+
if (!view) {
|
|
847
|
+
throw new HttpError(404, ERROR_CODES.NOT_FOUND, `Attribute "${req.key}" not found.`);
|
|
848
|
+
}
|
|
849
|
+
return view;
|
|
850
|
+
}
|
|
851
|
+
async updateAttributeByIdOrKey(idOrKey, req, auditCtx) {
|
|
852
|
+
const attr = await this.getAttributeByIdOrKey(idOrKey);
|
|
853
|
+
return this.applyAttributeUpdate(attr, req, auditCtx);
|
|
854
|
+
}
|
|
855
|
+
async updateAttribute(key, req, auditCtx) {
|
|
856
|
+
const attr = await this.getAttributeByIdOrKey(key);
|
|
857
|
+
return this.applyAttributeUpdate(attr, req, auditCtx);
|
|
858
|
+
}
|
|
859
|
+
async applyAttributeUpdate(attr, req, auditCtx) {
|
|
860
|
+
// Capture the searchable flag before applying so we can tell a real
|
|
861
|
+
// flip apart from a save that left it untouched (only a real change
|
|
862
|
+
// warrants a full reindex).
|
|
863
|
+
const previousIsSearchable = attr.isSearchable;
|
|
864
|
+
await this.#runCommand(updateAttributeCommand(this.#attributeCommandDeps(), attr.id, req));
|
|
865
|
+
await this.#requireCustomFields().publishInvalidate('product');
|
|
866
|
+
const updated = await this.#requireAttributeRead().getByIdOrKey(attr.id);
|
|
867
|
+
if (!updated) {
|
|
868
|
+
throw new HttpError(404, ERROR_CODES.NOT_FOUND, `Attribute "${attr.id}" not found.`);
|
|
869
|
+
}
|
|
870
|
+
// Feature: when the `searchable` flag actually flips, queue a full
|
|
871
|
+
// Meilisearch reindex as a bulk operation (the `search:reindex` CLI
|
|
872
|
+
// equivalent). The command's event only refreshes Meili's searchable-field
|
|
873
|
+
// settings; a flag flip needs the documents re-pushed so the field
|
|
874
|
+
// starts/stops contributing to matches. Best-effort — a failure to
|
|
875
|
+
// enqueue must not fail the attribute save.
|
|
876
|
+
if (req.isSearchable !== undefined &&
|
|
877
|
+
updated.isSearchable !== previousIsSearchable &&
|
|
878
|
+
this.enqueueSearchReindex) {
|
|
879
|
+
try {
|
|
880
|
+
await this.enqueueSearchReindex({
|
|
881
|
+
actorAdminUserId: auditCtx?.actorAdminUserId ?? null,
|
|
882
|
+
attributeKey: updated.key,
|
|
883
|
+
});
|
|
884
|
+
}
|
|
885
|
+
catch {
|
|
886
|
+
/* enqueue is best-effort — the save already succeeded */
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
return updated;
|
|
890
|
+
}
|
|
891
|
+
// --- Read methods (admin lists / detail) --------------------------------
|
|
892
|
+
async listProducts(options = {}) {
|
|
893
|
+
const em = this.emFactory();
|
|
894
|
+
const page = Math.max(0, options.page ?? 0);
|
|
895
|
+
const pageSize = Math.min(Math.max(1, options.pageSize ?? 20), 500);
|
|
896
|
+
// `status` overrides `includeArchived` — if the caller explicitly asks for
|
|
897
|
+
// a specific status (including `inactive`), we honour it; otherwise the
|
|
898
|
+
// legacy `includeArchived` flag controls whether inactive rows appear.
|
|
899
|
+
const where = { deletedAt: null };
|
|
900
|
+
if (options.status) {
|
|
901
|
+
where['status'] = options.status;
|
|
902
|
+
}
|
|
903
|
+
else if (!options.includeArchived) {
|
|
904
|
+
where['status'] = { $ne: 'inactive' };
|
|
905
|
+
}
|
|
906
|
+
if (options.type) {
|
|
907
|
+
where['type'] = options.type;
|
|
908
|
+
}
|
|
909
|
+
const categoryProductIds = options.categorySlug?.trim()
|
|
910
|
+
? await this.productIdsInCategoryTree(em, options.categorySlug.trim())
|
|
911
|
+
: null;
|
|
912
|
+
if (categoryProductIds && categoryProductIds.size === 0) {
|
|
913
|
+
// `em.execute`, not `em.getKnex()`: a knex handle takes its own pooled
|
|
914
|
+
// connection, so the status badges would be counted from outside a
|
|
915
|
+
// transaction the caller holds open while `em.find` above answers from
|
|
916
|
+
// inside it — one screen, two views of `products` (issue #207).
|
|
917
|
+
const countRowsEmpty = (await em.execute(`select status, count(*) as count from products where deleted_at is null group by status`));
|
|
918
|
+
const countsEmpty = { all: 0, active: 0, draft: 0, inactive: 0 };
|
|
919
|
+
for (const row of countRowsEmpty) {
|
|
920
|
+
const n = Number(row.count) || 0;
|
|
921
|
+
countsEmpty.all += n;
|
|
922
|
+
if (row.status === 'active')
|
|
923
|
+
countsEmpty.active = n;
|
|
924
|
+
else if (row.status === 'draft')
|
|
925
|
+
countsEmpty.draft = n;
|
|
926
|
+
else if (row.status === 'inactive')
|
|
927
|
+
countsEmpty.inactive = n;
|
|
928
|
+
}
|
|
929
|
+
return { items: [], page, pageSize, total: 0, counts: countsEmpty };
|
|
930
|
+
}
|
|
931
|
+
if (categoryProductIds) {
|
|
932
|
+
where['id'] = { $in: [...categoryProductIds] };
|
|
933
|
+
}
|
|
934
|
+
let items;
|
|
935
|
+
let total;
|
|
936
|
+
const trimmedQ = options.q?.trim();
|
|
937
|
+
if (trimmedQ || categoryProductIds) {
|
|
938
|
+
// Text search and/or category filter: page via knex, then re-hydrate.
|
|
939
|
+
const knex = em.getKnex();
|
|
940
|
+
const baseQuery = knex('products').where((qb) => {
|
|
941
|
+
qb.whereNull('deleted_at');
|
|
942
|
+
if (options.status)
|
|
943
|
+
qb.where('status', options.status);
|
|
944
|
+
else if (!options.includeArchived)
|
|
945
|
+
qb.whereNot('status', 'inactive');
|
|
946
|
+
if (options.type)
|
|
947
|
+
qb.where('type', options.type);
|
|
948
|
+
if (categoryProductIds)
|
|
949
|
+
qb.whereIn('id', [...categoryProductIds]);
|
|
950
|
+
if (trimmedQ) {
|
|
951
|
+
const needle = `%${trimmedQ.toLowerCase()}%`;
|
|
952
|
+
qb.andWhere((inner) => {
|
|
953
|
+
inner
|
|
954
|
+
.whereRaw('LOWER("sku") LIKE ?', [needle])
|
|
955
|
+
.orWhereRaw('LOWER("slug") LIKE ?', [needle])
|
|
956
|
+
.orWhereRaw('LOWER("name"::text) LIKE ?', [needle]);
|
|
957
|
+
});
|
|
958
|
+
}
|
|
959
|
+
});
|
|
960
|
+
// Both pages run through `em.execute(builder)` rather than by awaiting
|
|
961
|
+
// the builder: a knex handle carries no transaction context, so the page
|
|
962
|
+
// a caller inside a transaction is shown would be computed from rows that
|
|
963
|
+
// transaction has not written yet (issue #207). The builder is kept
|
|
964
|
+
// rather than rewritten as a statement because the filters above are
|
|
965
|
+
// assembled at runtime; `execute` compiles it and runs it with the
|
|
966
|
+
// EntityManager's transaction context, so the SQL is byte-for-byte the
|
|
967
|
+
// one this method already sent.
|
|
968
|
+
const totalRows = (await em.execute(baseQuery.clone().count('* as count')));
|
|
969
|
+
total = Number(totalRows[0]?.count ?? 0);
|
|
970
|
+
const idRows = (await em.execute(baseQuery.clone().orderBy('created_at', 'desc').offset(page * pageSize).limit(pageSize).select('id')));
|
|
971
|
+
const ids = idRows.map((r) => r.id);
|
|
972
|
+
if (ids.length === 0) {
|
|
973
|
+
items = [];
|
|
974
|
+
}
|
|
975
|
+
else {
|
|
976
|
+
const found = await em.find(Product, { id: { $in: ids } });
|
|
977
|
+
// Preserve the SQL ordering (created_at DESC) — `find` returns rows
|
|
978
|
+
// in arbitrary order when filtering by `$in`.
|
|
979
|
+
const byId = new Map(found.map((p) => [p.id, p]));
|
|
980
|
+
items = ids.map((id) => byId.get(id)).filter((p) => Boolean(p));
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
else {
|
|
984
|
+
[items, total] = await em.findAndCount(Product, where, {
|
|
985
|
+
orderBy: { createdAt: 'desc' },
|
|
986
|
+
offset: page * pageSize,
|
|
987
|
+
limit: pageSize,
|
|
988
|
+
});
|
|
989
|
+
}
|
|
990
|
+
// Counts are computed across the *full* product set (including archived)
|
|
991
|
+
// so the admin's status tabs always have honest badges, regardless of
|
|
992
|
+
// which tab is currently active.
|
|
993
|
+
// `em.execute`, not `em.getKnex()` — same reason as the empty-category
|
|
994
|
+
// branch above.
|
|
995
|
+
const countRows = (await em.execute(`select status, count(*) as count from products where deleted_at is null group by status`));
|
|
996
|
+
const counts = { all: 0, active: 0, draft: 0, inactive: 0 };
|
|
997
|
+
for (const row of countRows) {
|
|
998
|
+
const n = Number(row.count) || 0;
|
|
999
|
+
counts.all += n;
|
|
1000
|
+
if (row.status === 'active')
|
|
1001
|
+
counts.active = n;
|
|
1002
|
+
else if (row.status === 'draft')
|
|
1003
|
+
counts.draft = n;
|
|
1004
|
+
else if (row.status === 'inactive')
|
|
1005
|
+
counts.inactive = n;
|
|
1006
|
+
}
|
|
1007
|
+
return { items, page, pageSize, total, counts };
|
|
1008
|
+
}
|
|
1009
|
+
/**
|
|
1010
|
+
* Feature 033 — return all product ids matching list filters (no pagination).
|
|
1011
|
+
* Reuses the same filter semantics as {@link listProducts}.
|
|
1012
|
+
*/
|
|
1013
|
+
async resolveProductIds(options = {}) {
|
|
1014
|
+
const maxSelectionSize = Number(process.env['CATALOG_MAX_RESOLVE_IDS'] ?? 10_000);
|
|
1015
|
+
const em = this.emFactory();
|
|
1016
|
+
const trimmedQ = options.q?.trim();
|
|
1017
|
+
const knex = em.getKnex();
|
|
1018
|
+
const applyListFilters = (qb) => {
|
|
1019
|
+
// Mirror listProducts: soft-deleted rows are never selectable, and the
|
|
1020
|
+
// withdrawn status is `inactive` (feature 032 renamed `archived`).
|
|
1021
|
+
qb.whereNull('deleted_at');
|
|
1022
|
+
if (options.status) {
|
|
1023
|
+
qb.where('status', options.status);
|
|
1024
|
+
}
|
|
1025
|
+
else if (!options.includeArchived) {
|
|
1026
|
+
qb.whereNot('status', 'inactive');
|
|
1027
|
+
}
|
|
1028
|
+
if (options.type) {
|
|
1029
|
+
qb.where('type', options.type);
|
|
1030
|
+
}
|
|
1031
|
+
if (trimmedQ) {
|
|
1032
|
+
const needle = `%${trimmedQ.toLowerCase()}%`;
|
|
1033
|
+
qb.andWhere((inner) => {
|
|
1034
|
+
inner
|
|
1035
|
+
.whereRaw('LOWER("sku") LIKE ?', [needle])
|
|
1036
|
+
.orWhereRaw('LOWER("slug") LIKE ?', [needle])
|
|
1037
|
+
.orWhereRaw('LOWER("name"::text) LIKE ?', [needle]);
|
|
1038
|
+
});
|
|
1039
|
+
}
|
|
1040
|
+
return qb;
|
|
1041
|
+
};
|
|
1042
|
+
// `em.execute(builder)`, not an awaited builder — see `listProducts`
|
|
1043
|
+
// (issue #207); the filters are assembled at runtime, so the builder stays
|
|
1044
|
+
// and `execute` supplies the EntityManager's transaction context.
|
|
1045
|
+
const countRows = (await em.execute(applyListFilters(knex('products')).clone().count('* as count')));
|
|
1046
|
+
const total = Number(countRows[0]?.count ?? 0);
|
|
1047
|
+
if (total > maxSelectionSize) {
|
|
1048
|
+
throw new HttpError(400, ERROR_CODES.SELECTION_TOO_LARGE, `Selection matches ${total} products; max ${maxSelectionSize}.`, { total, maxSelectionSize });
|
|
1049
|
+
}
|
|
1050
|
+
const idRows = (await em.execute(applyListFilters(knex('products')).clone().orderBy('created_at', 'desc').select('id')));
|
|
1051
|
+
const productIds = idRows.map((r) => r.id);
|
|
1052
|
+
return { productIds, total: productIds.length };
|
|
1053
|
+
}
|
|
1054
|
+
async getProductById(id) {
|
|
1055
|
+
const em = this.emFactory();
|
|
1056
|
+
const product = await em.findOne(Product, { id, deletedAt: null });
|
|
1057
|
+
if (!product) {
|
|
1058
|
+
throw new HttpError(404, ERROR_CODES.PRODUCT_NOT_FOUND, 'Product not found.');
|
|
1059
|
+
}
|
|
1060
|
+
return product;
|
|
1061
|
+
}
|
|
1062
|
+
/** Category membership ids for admin product editor (feature 031). */
|
|
1063
|
+
async getProductCategoryIds(productId) {
|
|
1064
|
+
const em = this.emFactory();
|
|
1065
|
+
const rows = (await em.execute(`select category_id from product_categories where product_id = ?`, [productId]));
|
|
1066
|
+
return rows.map((r) => r.category_id);
|
|
1067
|
+
}
|
|
1068
|
+
/** Product ids in a category tree (root + descendants), for admin list filters. */
|
|
1069
|
+
async productIdsInCategoryTree(em, categorySlug) {
|
|
1070
|
+
const root = await em.findOne(Category, { slug: categorySlug, deletedAt: null });
|
|
1071
|
+
if (!root)
|
|
1072
|
+
return new Set();
|
|
1073
|
+
const all = [root.id];
|
|
1074
|
+
let frontier = [root.id];
|
|
1075
|
+
while (frontier.length > 0) {
|
|
1076
|
+
const children = await em.find(Category, {
|
|
1077
|
+
parentCategoryId: { $in: frontier },
|
|
1078
|
+
deletedAt: null,
|
|
1079
|
+
});
|
|
1080
|
+
const nextIds = children.map((c) => c.id);
|
|
1081
|
+
all.push(...nextIds);
|
|
1082
|
+
frontier = nextIds;
|
|
1083
|
+
}
|
|
1084
|
+
const rows = await em.execute(`select product_id from product_categories where category_id in (${all.map(() => '?').join(',')})`, all);
|
|
1085
|
+
return new Set(rows.map((r) => r.product_id));
|
|
1086
|
+
}
|
|
1087
|
+
/**
|
|
1088
|
+
* Batch-by-id read. Returns matching products for the given id set,
|
|
1089
|
+
* deduped server-side. Includes archived rows so callers can resolve
|
|
1090
|
+
* names for already-attached references (e.g. ProductEditor's link
|
|
1091
|
+
* tables) regardless of current status. Paginated for callers that
|
|
1092
|
+
* stream large id sets across multiple requests.
|
|
1093
|
+
*/
|
|
1094
|
+
async listProductsByIds(input) {
|
|
1095
|
+
const em = this.emFactory();
|
|
1096
|
+
const page = Math.max(0, input.page ?? 0);
|
|
1097
|
+
const pageSize = Math.min(Math.max(1, input.pageSize ?? 50), 500);
|
|
1098
|
+
const uniqueIds = Array.from(new Set(input.ids));
|
|
1099
|
+
if (uniqueIds.length === 0) {
|
|
1100
|
+
return { items: [], page, pageSize, total: 0 };
|
|
1101
|
+
}
|
|
1102
|
+
const [items, total] = await em.findAndCount(Product, { id: { $in: uniqueIds } }, {
|
|
1103
|
+
orderBy: { createdAt: 'desc' },
|
|
1104
|
+
offset: page * pageSize,
|
|
1105
|
+
limit: pageSize,
|
|
1106
|
+
});
|
|
1107
|
+
return { items, page, pageSize, total };
|
|
1108
|
+
}
|
|
1109
|
+
async listAttributes() {
|
|
1110
|
+
// Legacy list ordering was `key ASC`; preserved for the admin surface.
|
|
1111
|
+
const views = await this.#requireAttributeRead().listAll();
|
|
1112
|
+
return [...views].sort((a, b) => a.key.localeCompare(b.key));
|
|
1113
|
+
}
|
|
1114
|
+
/**
|
|
1115
|
+
* Feature 012 — read every attribute carrying a given boolean flag.
|
|
1116
|
+
* Used by the Promotion Rule editor's criterion picker
|
|
1117
|
+
* (`flag = isPromoRule`) and the Compare-page column picker
|
|
1118
|
+
* (`flag = isComparable`). Other flags are surfaced for symmetry.
|
|
1119
|
+
*/
|
|
1120
|
+
async listAttributesByFlag(flag) {
|
|
1121
|
+
const read = this.#requireAttributeRead();
|
|
1122
|
+
// `isRequired` lives on the definition (not an extension column) — filter
|
|
1123
|
+
// the composed views. `isMassEditable` is exposed as a separate API flag
|
|
1124
|
+
// name; the backing extension field is `massEditable` (no `is` prefix).
|
|
1125
|
+
if (flag === 'isRequired') {
|
|
1126
|
+
const all = await read.listAll();
|
|
1127
|
+
return all
|
|
1128
|
+
.filter((v) => v.isRequired)
|
|
1129
|
+
.sort((a, b) => a.key.localeCompare(b.key));
|
|
1130
|
+
}
|
|
1131
|
+
return read.listByFlag(flag === 'isMassEditable' ? 'massEditable' : flag);
|
|
1132
|
+
}
|
|
1133
|
+
/**
|
|
1134
|
+
* Feature 012 / US4 — list every option for one attribute, ordered
|
|
1135
|
+
* by sortOrder ASC then value ASC. Backed by `custom_field_options`
|
|
1136
|
+
* through the composed view (feature 061); `attributeId` on the result
|
|
1137
|
+
* is the attribute (extension) id the admin API has always exposed.
|
|
1138
|
+
*/
|
|
1139
|
+
async listAttributeOptions(attributeId) {
|
|
1140
|
+
const attr = await this.getAttributeByIdOrKey(attributeId);
|
|
1141
|
+
return [...attr.options]
|
|
1142
|
+
.sort((a, b) => a.sortOrder - b.sortOrder || a.value.localeCompare(b.value))
|
|
1143
|
+
.map((o) => ({
|
|
1144
|
+
id: o.id,
|
|
1145
|
+
attributeId: attr.id,
|
|
1146
|
+
value: o.value,
|
|
1147
|
+
label: o.label,
|
|
1148
|
+
labelDefault: o.labelDefault,
|
|
1149
|
+
isDefault: o.isDefault,
|
|
1150
|
+
sortOrder: o.sortOrder,
|
|
1151
|
+
createdAt: o.createdAt,
|
|
1152
|
+
updatedAt: o.updatedAt,
|
|
1153
|
+
}));
|
|
1154
|
+
}
|
|
1155
|
+
/** Feature 061 — the option-command target slice of a composed view. */
|
|
1156
|
+
#optionCommandTarget(attr) {
|
|
1157
|
+
return {
|
|
1158
|
+
extensionId: attr.id,
|
|
1159
|
+
definitionId: attr.customFieldDefinitionId,
|
|
1160
|
+
key: attr.key,
|
|
1161
|
+
valueType: attr.valueType,
|
|
1162
|
+
options: attr.options,
|
|
1163
|
+
};
|
|
1164
|
+
}
|
|
1165
|
+
/** Feature 061 — resolve the attribute whose option list contains `optionId`. */
|
|
1166
|
+
async #attributeByOptionId(optionId) {
|
|
1167
|
+
const all = await this.#requireAttributeRead().listAll();
|
|
1168
|
+
const attr = all.find((v) => v.options.some((o) => o.id === optionId));
|
|
1169
|
+
if (!attr) {
|
|
1170
|
+
throw new HttpError(404, ERROR_CODES.NOT_FOUND, `Attribute option ${optionId} not found.`);
|
|
1171
|
+
}
|
|
1172
|
+
return attr;
|
|
1173
|
+
}
|
|
1174
|
+
/** Feature 012 / US4 — append an option (validated cross-list). */
|
|
1175
|
+
async addAttributeOption(attributeIdOrKey, input) {
|
|
1176
|
+
const attr = await this.getAttributeByIdOrKey(attributeIdOrKey);
|
|
1177
|
+
const result = await this.#runCommand(createAttributeOptionCommand(this.#attributeCommandDeps(), this.#optionCommandTarget(attr), input));
|
|
1178
|
+
await this.#requireCustomFields().publishInvalidate('product');
|
|
1179
|
+
return result;
|
|
1180
|
+
}
|
|
1181
|
+
/** Feature 012 / US4 — patch one option (value is immutable per FR-026). */
|
|
1182
|
+
async patchAttributeOption(optionId, input) {
|
|
1183
|
+
const attr = await this.#attributeByOptionId(optionId);
|
|
1184
|
+
const result = await this.#runCommand(updateAttributeOptionCommand(this.#attributeCommandDeps(), this.#optionCommandTarget(attr), optionId, input));
|
|
1185
|
+
await this.#requireCustomFields().publishInvalidate('product');
|
|
1186
|
+
return result;
|
|
1187
|
+
}
|
|
1188
|
+
/** Feature 012 / US4 — remove one option. Refused while products carry it (FR-025). */
|
|
1189
|
+
async removeAttributeOption(optionId) {
|
|
1190
|
+
const attr = await this.#attributeByOptionId(optionId);
|
|
1191
|
+
await this.#runCommand(deleteAttributeOptionCommand(this.#attributeCommandDeps(), this.#optionCommandTarget(attr), optionId));
|
|
1192
|
+
await this.#requireCustomFields().publishInvalidate('product');
|
|
1193
|
+
}
|
|
1194
|
+
/**
|
|
1195
|
+
* Feature 012 — projection of the legacy `enumValues: string[]` shape
|
|
1196
|
+
* from the option rows for one attribute. Returns `null` when the
|
|
1197
|
+
* attribute has no options.
|
|
1198
|
+
*/
|
|
1199
|
+
async getAttributeOptionValues(attributeId) {
|
|
1200
|
+
const view = await this.#requireAttributeRead().getByIdOrKey(attributeId);
|
|
1201
|
+
if (!view || view.options.length === 0)
|
|
1202
|
+
return null;
|
|
1203
|
+
return [...view.options]
|
|
1204
|
+
.sort((a, b) => a.sortOrder - b.sortOrder || a.value.localeCompare(b.value))
|
|
1205
|
+
.map((o) => o.value);
|
|
1206
|
+
}
|
|
1207
|
+
/** Feature 012 — bulk variant of getAttributeOptionValues for the list endpoint. */
|
|
1208
|
+
async getAttributeOptionValuesByIds(attributeIds) {
|
|
1209
|
+
const out = new Map();
|
|
1210
|
+
if (attributeIds.length === 0)
|
|
1211
|
+
return out;
|
|
1212
|
+
const wanted = new Set(attributeIds);
|
|
1213
|
+
const all = await this.#requireAttributeRead().listAll();
|
|
1214
|
+
for (const view of all) {
|
|
1215
|
+
if (!wanted.has(view.id) || view.options.length === 0)
|
|
1216
|
+
continue;
|
|
1217
|
+
out.set(view.id, [...view.options]
|
|
1218
|
+
.sort((a, b) => a.sortOrder - b.sortOrder || a.value.localeCompare(b.value))
|
|
1219
|
+
.map((o) => o.value));
|
|
1220
|
+
}
|
|
1221
|
+
return out;
|
|
1222
|
+
}
|
|
1223
|
+
/** Feature 012 — read a single attribute by UUID or snake_case key. */
|
|
1224
|
+
async getAttributeByIdOrKey(idOrKey) {
|
|
1225
|
+
const view = await this.#requireAttributeRead().getByIdOrKey(idOrKey);
|
|
1226
|
+
if (!view) {
|
|
1227
|
+
throw new HttpError(404, ERROR_CODES.NOT_FOUND, `Attribute "${idOrKey}" not found.`);
|
|
1228
|
+
}
|
|
1229
|
+
return view;
|
|
1230
|
+
}
|
|
1231
|
+
/**
|
|
1232
|
+
* Feature 012 — delete an attribute. Refused while any Attribute Set or
|
|
1233
|
+
* product still references it (FR-006). The structured error names the
|
|
1234
|
+
* dependent rows so the admin UI can guide the operator. Deletes the
|
|
1235
|
+
* extension AND the backing definition (+ options cascade) in one
|
|
1236
|
+
* transaction (feature 061).
|
|
1237
|
+
*/
|
|
1238
|
+
async deleteAttribute(idOrKey) {
|
|
1239
|
+
const attr = await this.getAttributeByIdOrKey(idOrKey);
|
|
1240
|
+
await this.#runCommand(deleteAttributeCommand(this.#attributeCommandDeps(), {
|
|
1241
|
+
idOrKey,
|
|
1242
|
+
extensionId: attr.id,
|
|
1243
|
+
definitionId: attr.customFieldDefinitionId,
|
|
1244
|
+
key: attr.key,
|
|
1245
|
+
legacyValueType: attr.valueType,
|
|
1246
|
+
}));
|
|
1247
|
+
await this.#requireCustomFields().publishInvalidate('product');
|
|
1248
|
+
}
|
|
1249
|
+
// ------------------------------------------------------------------
|
|
1250
|
+
// ===== Feature 002 (T054 backend prereq) — Variants CRUD =================
|
|
1251
|
+
async createVariant(parentProductId, req) {
|
|
1252
|
+
const em = this.emFactory();
|
|
1253
|
+
const parent = await em.findOne(Product, { id: parentProductId });
|
|
1254
|
+
if (!parent) {
|
|
1255
|
+
throw new HttpError(404, ERROR_CODES.PRODUCT_NOT_FOUND, `Product ${parentProductId} not found.`);
|
|
1256
|
+
}
|
|
1257
|
+
if (parent.type !== 'configurable') {
|
|
1258
|
+
throw new HttpError(400, ERROR_CODES.VALIDATION_FAILED, `Variants can only be added to configurable Products; this Product is ${parent.type}.`);
|
|
1259
|
+
}
|
|
1260
|
+
// SKU uniqueness MUST hold across both products and variants.
|
|
1261
|
+
const existingProduct = await em.findOne(Product, { sku: req.sku });
|
|
1262
|
+
if (existingProduct) {
|
|
1263
|
+
throw new HttpError(409, ERROR_CODES.SKU_ALREADY_EXISTS, `SKU "${req.sku}" already taken by an existing Product.`);
|
|
1264
|
+
}
|
|
1265
|
+
const variantId = randomUUID();
|
|
1266
|
+
const variant = await this.#auditedWrite('product_variant.create', 'product_variant', variantId, async (cem) => {
|
|
1267
|
+
const v = cem.create(ProductVariant, {
|
|
1268
|
+
id: variantId,
|
|
1269
|
+
parentProductId,
|
|
1270
|
+
sku: req.sku,
|
|
1271
|
+
variantAttributeValues: req.variantAttributeValues,
|
|
1272
|
+
...(req.priceOverride !== undefined
|
|
1273
|
+
? { priceOverride: String(req.priceOverride) }
|
|
1274
|
+
: {}),
|
|
1275
|
+
...(req.stockLevel !== undefined ? { stockLevel: req.stockLevel } : {}),
|
|
1276
|
+
});
|
|
1277
|
+
try {
|
|
1278
|
+
await cem.flush();
|
|
1279
|
+
}
|
|
1280
|
+
catch (err) {
|
|
1281
|
+
if (err instanceof UniqueConstraintViolationException) {
|
|
1282
|
+
throw new HttpError(409, ERROR_CODES.SKU_ALREADY_EXISTS, `SKU "${req.sku}" already taken by an existing Variant.`);
|
|
1283
|
+
}
|
|
1284
|
+
throw err;
|
|
1285
|
+
}
|
|
1286
|
+
return { result: v, before: null, after: { parentProductId, sku: req.sku } };
|
|
1287
|
+
});
|
|
1288
|
+
this.events.emit('product.updated.v1', {
|
|
1289
|
+
eventId: randomUUID(),
|
|
1290
|
+
occurredAt: new Date().toISOString(),
|
|
1291
|
+
productId: parentProductId,
|
|
1292
|
+
changedFields: ['variants'],
|
|
1293
|
+
});
|
|
1294
|
+
return variant;
|
|
1295
|
+
}
|
|
1296
|
+
async updateVariant(parentProductId, variantId, req) {
|
|
1297
|
+
const variant = await this.#auditedWrite('product_variant.update', 'product_variant', variantId, async (em) => {
|
|
1298
|
+
const v = await em.findOne(ProductVariant, { id: variantId, parentProductId });
|
|
1299
|
+
if (!v) {
|
|
1300
|
+
throw new HttpError(404, ERROR_CODES.NOT_FOUND, `Variant ${variantId} not found under Product ${parentProductId}.`);
|
|
1301
|
+
}
|
|
1302
|
+
const before = {
|
|
1303
|
+
sku: v.sku,
|
|
1304
|
+
variantAttributeValues: { ...v.variantAttributeValues },
|
|
1305
|
+
priceOverride: v.priceOverride,
|
|
1306
|
+
stockLevel: v.stockLevel,
|
|
1307
|
+
};
|
|
1308
|
+
if (req.variantAttributeValues !== undefined) {
|
|
1309
|
+
v.variantAttributeValues = req.variantAttributeValues;
|
|
1310
|
+
}
|
|
1311
|
+
if (req.priceOverride !== undefined) {
|
|
1312
|
+
v.priceOverride = String(req.priceOverride);
|
|
1313
|
+
}
|
|
1314
|
+
if (req.stockLevel !== undefined) {
|
|
1315
|
+
v.stockLevel = req.stockLevel;
|
|
1316
|
+
}
|
|
1317
|
+
return {
|
|
1318
|
+
result: v,
|
|
1319
|
+
before,
|
|
1320
|
+
after: { sku: v.sku, priceOverride: v.priceOverride, stockLevel: v.stockLevel },
|
|
1321
|
+
};
|
|
1322
|
+
});
|
|
1323
|
+
this.events.emit('product.updated.v1', {
|
|
1324
|
+
eventId: randomUUID(),
|
|
1325
|
+
occurredAt: new Date().toISOString(),
|
|
1326
|
+
productId: parentProductId,
|
|
1327
|
+
changedFields: ['variants'],
|
|
1328
|
+
});
|
|
1329
|
+
return variant;
|
|
1330
|
+
}
|
|
1331
|
+
async deleteVariant(parentProductId, variantId) {
|
|
1332
|
+
await this.#auditedWrite('product_variant.delete', 'product_variant', variantId, async (em) => {
|
|
1333
|
+
const variant = await em.findOne(ProductVariant, { id: variantId, parentProductId });
|
|
1334
|
+
if (!variant) {
|
|
1335
|
+
// DELETE is idempotent — but we still 404 here so admins notice
|
|
1336
|
+
// typo'd ids. Foundation pattern (admin DELETE on missing rows
|
|
1337
|
+
// returns 404 too, e.g. category soft-delete).
|
|
1338
|
+
throw new HttpError(404, ERROR_CODES.NOT_FOUND, `Variant ${variantId} not found under Product ${parentProductId}.`);
|
|
1339
|
+
}
|
|
1340
|
+
const before = { parentProductId, sku: variant.sku };
|
|
1341
|
+
em.remove(variant);
|
|
1342
|
+
return { result: undefined, before, after: null };
|
|
1343
|
+
});
|
|
1344
|
+
this.events.emit('product.updated.v1', {
|
|
1345
|
+
eventId: randomUUID(),
|
|
1346
|
+
occurredAt: new Date().toISOString(),
|
|
1347
|
+
productId: parentProductId,
|
|
1348
|
+
changedFields: ['variants'],
|
|
1349
|
+
});
|
|
1350
|
+
}
|
|
1351
|
+
/**
|
|
1352
|
+
* Feature 002 (T023) — reject unknown keys in `attributeValues` against
|
|
1353
|
+
* the Product's AttributeSet. Empty input is a no-op (a Product with
|
|
1354
|
+
* zero attribute values is always valid).
|
|
1355
|
+
*
|
|
1356
|
+
* Throws 400 ATTRIBUTE_VALUE_REJECTED with `details: [{ path, issue }]`
|
|
1357
|
+
* listing the rejected keys.
|
|
1358
|
+
*/
|
|
1359
|
+
async assertAttributeValueKeysAllowed(em, attributeSetId, attributeValues,
|
|
1360
|
+
// Keys already persisted on the Product. These were validated at their
|
|
1361
|
+
// time of write, so re-sending them (e.g. the admin form round-trips the
|
|
1362
|
+
// full value map when only the SKU changed, or when the AttributeSet was
|
|
1363
|
+
// swapped leaving orphan keys behind) must NOT be rejected — only keys
|
|
1364
|
+
// that are genuinely new to this save are checked against the set.
|
|
1365
|
+
existingKeys = new Set()) {
|
|
1366
|
+
if (!attributeValues)
|
|
1367
|
+
return;
|
|
1368
|
+
const keys = Object.keys(attributeValues);
|
|
1369
|
+
if (keys.length === 0)
|
|
1370
|
+
return;
|
|
1371
|
+
// Feature 061 — set membership is keyed by definition id; the key lives on
|
|
1372
|
+
// the definition, resolved through the composed view (Principle I).
|
|
1373
|
+
//
|
|
1374
|
+
// `em.execute`, not `em.getConnection().execute`: this guard runs on the
|
|
1375
|
+
// Command's `em` inside the create/update transaction, so read on a pooled
|
|
1376
|
+
// connection it answered from outside the very transaction it is guarding
|
|
1377
|
+
// (issue #207).
|
|
1378
|
+
const rows = (await em.execute(`select custom_field_definition_id
|
|
1379
|
+
from attribute_set_attributes
|
|
1380
|
+
where attribute_set_id = ?`, [attributeSetId]));
|
|
1381
|
+
const views = await this.#requireAttributeRead().listAll();
|
|
1382
|
+
const keyByDefinitionId = new Map(views.map((v) => [v.customFieldDefinitionId, v.key]));
|
|
1383
|
+
const allowed = new Set(rows
|
|
1384
|
+
.map((r) => keyByDefinitionId.get(r.custom_field_definition_id))
|
|
1385
|
+
.filter((k) => k !== undefined));
|
|
1386
|
+
const rejected = keys.filter((k) => !allowed.has(k) &&
|
|
1387
|
+
!existingKeys.has(k) &&
|
|
1388
|
+
!VIRTUAL_ATTRIBUTE_VALUE_KEYS.has(k));
|
|
1389
|
+
if (rejected.length > 0) {
|
|
1390
|
+
throw new HttpError(400, ERROR_CODES.ATTRIBUTE_VALUE_REJECTED, `Attribute key(s) not in this Product's Attribute Set: ${rejected.join(', ')}.`, rejected.map((k) => ({
|
|
1391
|
+
path: `attributeValues.${k}`,
|
|
1392
|
+
issue: 'attribute is not assigned to this Product\'s AttributeSet',
|
|
1393
|
+
})));
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
/**
|
|
1397
|
+
* Feature 012 / FR-013 — refuse a product save that leaves any
|
|
1398
|
+
* required attribute (from the assigned set) without a value. Called
|
|
1399
|
+
* after `assertAttributeValueKeysAllowed`; uses the merged
|
|
1400
|
+
* (existing + patched) value map so a previously-set value satisfies
|
|
1401
|
+
* the requirement even when the current patch omits it.
|
|
1402
|
+
*/
|
|
1403
|
+
async assertRequiredAttributesPresent(em, attributeSetId, mergedAttributeValues) {
|
|
1404
|
+
// Feature 061 — the required flag lives on the definition (composed view).
|
|
1405
|
+
const rows = (await em.execute(`select custom_field_definition_id
|
|
1406
|
+
from attribute_set_attributes
|
|
1407
|
+
where attribute_set_id = ?`, [attributeSetId]));
|
|
1408
|
+
const views = await this.#requireAttributeRead().listAll();
|
|
1409
|
+
const viewByDefinitionId = new Map(views.map((v) => [v.customFieldDefinitionId, v]));
|
|
1410
|
+
const missing = rows
|
|
1411
|
+
.map((r) => viewByDefinitionId.get(r.custom_field_definition_id))
|
|
1412
|
+
.filter((v) => v !== undefined && v.isRequired)
|
|
1413
|
+
.map((v) => v.key)
|
|
1414
|
+
.filter((k) => {
|
|
1415
|
+
const v = mergedAttributeValues[k];
|
|
1416
|
+
return v === undefined || v === null || v === '';
|
|
1417
|
+
});
|
|
1418
|
+
if (missing.length > 0) {
|
|
1419
|
+
throw new HttpError(400, ERROR_CODES.VALIDATION_FAILED, `missing_required_attribute_values: ${missing.join(', ')}`);
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
/**
|
|
1423
|
+
* Feature 012 / US2 — preview a Set swap on a Product. Returns the
|
|
1424
|
+
* shape documented in `contracts/attribute-sets.contract.md` —
|
|
1425
|
+
* attributesAdded, attributesRemoved, valuesPreserved (R-7),
|
|
1426
|
+
* requiredButMissing.
|
|
1427
|
+
*/
|
|
1428
|
+
async previewAttributeSetSwap(productId, targetSetId) {
|
|
1429
|
+
const em = this.emFactory();
|
|
1430
|
+
const product = await em.findOne(Product, { id: productId });
|
|
1431
|
+
if (!product) {
|
|
1432
|
+
throw new HttpError(404, ERROR_CODES.PRODUCT_NOT_FOUND, 'Product not found.');
|
|
1433
|
+
}
|
|
1434
|
+
// Feature 061 — membership is definition-keyed; identity fields come from
|
|
1435
|
+
// the composed view.
|
|
1436
|
+
const views = await this.#requireAttributeRead().listAll();
|
|
1437
|
+
const viewByDefinitionId = new Map(views.map((v) => [v.customFieldDefinitionId, v]));
|
|
1438
|
+
const fetchKeys = async (setId) => {
|
|
1439
|
+
const out = new Map();
|
|
1440
|
+
if (!setId)
|
|
1441
|
+
return out;
|
|
1442
|
+
const rows = (await em.execute(`select custom_field_definition_id
|
|
1443
|
+
from attribute_set_attributes
|
|
1444
|
+
where attribute_set_id = ?`, [setId]));
|
|
1445
|
+
for (const r of rows) {
|
|
1446
|
+
const view = viewByDefinitionId.get(r.custom_field_definition_id);
|
|
1447
|
+
if (!view)
|
|
1448
|
+
continue;
|
|
1449
|
+
out.set(view.key, { labelDefault: view.labelDefault, isRequired: view.isRequired });
|
|
1450
|
+
}
|
|
1451
|
+
return out;
|
|
1452
|
+
};
|
|
1453
|
+
const before = await fetchKeys(product.attributeSetId ?? null);
|
|
1454
|
+
const after = await fetchKeys(targetSetId);
|
|
1455
|
+
const attributesAdded = [];
|
|
1456
|
+
const attributesRemoved = [];
|
|
1457
|
+
for (const [key, meta] of after) {
|
|
1458
|
+
if (!before.has(key))
|
|
1459
|
+
attributesAdded.push({ key, labelDefault: meta.labelDefault, isRequired: meta.isRequired });
|
|
1460
|
+
}
|
|
1461
|
+
for (const [key, meta] of before) {
|
|
1462
|
+
if (!after.has(key))
|
|
1463
|
+
attributesRemoved.push({ key, labelDefault: meta.labelDefault });
|
|
1464
|
+
}
|
|
1465
|
+
const currentValues = product.attributeValues ?? {};
|
|
1466
|
+
const valuesPreserved = [];
|
|
1467
|
+
for (const [key, value] of Object.entries(currentValues)) {
|
|
1468
|
+
if (!after.has(key) && value !== undefined && value !== null && value !== '') {
|
|
1469
|
+
valuesPreserved.push({ key, valueSample: value });
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
const requiredButMissing = [];
|
|
1473
|
+
for (const [key, meta] of after) {
|
|
1474
|
+
if (meta.isRequired) {
|
|
1475
|
+
const v = currentValues[key];
|
|
1476
|
+
if (v === undefined || v === null || v === '') {
|
|
1477
|
+
requiredButMissing.push({ key, labelDefault: meta.labelDefault });
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
return { attributesAdded, attributesRemoved, valuesPreserved, requiredButMissing };
|
|
1482
|
+
}
|
|
1483
|
+
/**
|
|
1484
|
+
* A product slug — the value that becomes a storefront URL and sits under
|
|
1485
|
+
* `products.slug`'s unique index. Callers go through `allocateUniqueSlug`,
|
|
1486
|
+
* which allocates against the live table rather than trusting this to be free.
|
|
1487
|
+
*
|
|
1488
|
+
* The fold is `slugify` from `@endora-commerce/contracts`, **imported, never
|
|
1489
|
+
* re-implemented** (issue #245). The private chain this carried normalised
|
|
1490
|
+
* with `NFKD` and stripped the combining marks, which does nothing to `ł` —
|
|
1491
|
+
* U+0142 has no canonical decomposition — so the `[^a-z0-9]+` collapse
|
|
1492
|
+
* deleted it: `Łączniki` produced `aczniki` and `Wiertła` produced `wiert-a`.
|
|
1493
|
+
* Every Polish product name reached the storefront a letter short.
|
|
1494
|
+
*
|
|
1495
|
+
* **The shared fold is NFD, so this gives up NFKD's compatibility mappings**,
|
|
1496
|
+
* and here that is worth stating precisely because the slug is
|
|
1497
|
+
* unique-constrained: two names `NFKD` kept apart can now fold together
|
|
1498
|
+
* (`Kabel²` and `Kabel³` both give `kabel`). It cannot become a constraint
|
|
1499
|
+
* violation — `allocateUniqueSlug` probes the table and suffixes `-2`, `-3`,
|
|
1500
|
+
* … exactly as it already does for two products sharing a plain name — and
|
|
1501
|
+
* the characters that can cause it (`fi`, superscripts, full-width forms) are
|
|
1502
|
+
* not typed into product names, while `ł` is in most of them.
|
|
1503
|
+
*
|
|
1504
|
+
* Slugs already stored are **not** migrated (owner's ruling, 2026-08-19).
|
|
1505
|
+
* Nothing re-derives a slug to find an existing product: this runs on create
|
|
1506
|
+
* and on duplicate, and duplicate re-slugs an already-slugged string.
|
|
1507
|
+
*/
|
|
1508
|
+
slugify(value) {
|
|
1509
|
+
return slugifyText(value, { maxLength: 160 });
|
|
1510
|
+
}
|
|
1511
|
+
anyValue(blob) {
|
|
1512
|
+
const key = Object.keys(blob)[0];
|
|
1513
|
+
return key ? (blob[key] ?? '') : '';
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
//# sourceMappingURL=catalog-admin.service.js.map
|