@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.
Files changed (443) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +63 -0
  3. package/dist/admin/components/PackagingUnitsEditor.d.ts +12 -0
  4. package/dist/admin/components/PackagingUnitsEditor.d.ts.map +1 -0
  5. package/dist/admin/components/PackagingUnitsEditor.js +99 -0
  6. package/dist/admin/components/PackagingUnitsEditor.js.map +1 -0
  7. package/dist/admin/components/ProductAttributesTab.d.ts +13 -0
  8. package/dist/admin/components/ProductAttributesTab.d.ts.map +1 -0
  9. package/dist/admin/components/ProductAttributesTab.js +140 -0
  10. package/dist/admin/components/ProductAttributesTab.js.map +1 -0
  11. package/dist/admin/components/ProductInventoryTab.d.ts +14 -0
  12. package/dist/admin/components/ProductInventoryTab.d.ts.map +1 -0
  13. package/dist/admin/components/ProductInventoryTab.js +284 -0
  14. package/dist/admin/components/ProductInventoryTab.js.map +1 -0
  15. package/dist/admin/components/ProductScopeEditor.d.ts +94 -0
  16. package/dist/admin/components/ProductScopeEditor.d.ts.map +1 -0
  17. package/dist/admin/components/ProductScopeEditor.js +395 -0
  18. package/dist/admin/components/ProductScopeEditor.js.map +1 -0
  19. package/dist/admin/components/ProductsBulkEditDialog.d.ts +11 -0
  20. package/dist/admin/components/ProductsBulkEditDialog.d.ts.map +1 -0
  21. package/dist/admin/components/ProductsBulkEditDialog.js +304 -0
  22. package/dist/admin/components/ProductsBulkEditDialog.js.map +1 -0
  23. package/dist/admin/index.d.ts +45 -0
  24. package/dist/admin/index.d.ts.map +1 -0
  25. package/dist/admin/index.js +184 -0
  26. package/dist/admin/index.js.map +1 -0
  27. package/dist/admin/lib/resolve-product-selection.d.ts +21 -0
  28. package/dist/admin/lib/resolve-product-selection.d.ts.map +1 -0
  29. package/dist/admin/lib/resolve-product-selection.js +25 -0
  30. package/dist/admin/lib/resolve-product-selection.js.map +1 -0
  31. package/dist/admin/pages/AttachmentTypesPage.d.ts +18 -0
  32. package/dist/admin/pages/AttachmentTypesPage.d.ts.map +1 -0
  33. package/dist/admin/pages/AttachmentTypesPage.js +92 -0
  34. package/dist/admin/pages/AttachmentTypesPage.js.map +1 -0
  35. package/dist/admin/pages/AttributeSetsPage.d.ts +21 -0
  36. package/dist/admin/pages/AttributeSetsPage.d.ts.map +1 -0
  37. package/dist/admin/pages/AttributeSetsPage.js +158 -0
  38. package/dist/admin/pages/AttributeSetsPage.js.map +1 -0
  39. package/dist/admin/pages/AttributesManager.d.ts +9 -0
  40. package/dist/admin/pages/AttributesManager.d.ts.map +1 -0
  41. package/dist/admin/pages/AttributesManager.js +305 -0
  42. package/dist/admin/pages/AttributesManager.js.map +1 -0
  43. package/dist/admin/pages/BulkOperationDetailPage.d.ts +9 -0
  44. package/dist/admin/pages/BulkOperationDetailPage.d.ts.map +1 -0
  45. package/dist/admin/pages/BulkOperationDetailPage.js +187 -0
  46. package/dist/admin/pages/BulkOperationDetailPage.js.map +1 -0
  47. package/dist/admin/pages/BulkOperationsPage.d.ts +9 -0
  48. package/dist/admin/pages/BulkOperationsPage.d.ts.map +1 -0
  49. package/dist/admin/pages/BulkOperationsPage.js +98 -0
  50. package/dist/admin/pages/BulkOperationsPage.js.map +1 -0
  51. package/dist/admin/pages/CategoriesTree.d.ts +9 -0
  52. package/dist/admin/pages/CategoriesTree.d.ts.map +1 -0
  53. package/dist/admin/pages/CategoriesTree.js +176 -0
  54. package/dist/admin/pages/CategoriesTree.js.map +1 -0
  55. package/dist/admin/pages/ProductEditor.d.ts +13 -0
  56. package/dist/admin/pages/ProductEditor.d.ts.map +1 -0
  57. package/dist/admin/pages/ProductEditor.js +1050 -0
  58. package/dist/admin/pages/ProductEditor.js.map +1 -0
  59. package/dist/admin/pages/ProductsList.d.ts +9 -0
  60. package/dist/admin/pages/ProductsList.d.ts.map +1 -0
  61. package/dist/admin/pages/ProductsList.js +399 -0
  62. package/dist/admin/pages/ProductsList.js.map +1 -0
  63. package/dist/backend/commands/attribute-commands.d.ts +111 -0
  64. package/dist/backend/commands/attribute-commands.d.ts.map +1 -0
  65. package/dist/backend/commands/attribute-commands.js +482 -0
  66. package/dist/backend/commands/attribute-commands.js.map +1 -0
  67. package/dist/backend/demo/reset.d.ts +23 -0
  68. package/dist/backend/demo/reset.d.ts.map +1 -0
  69. package/dist/backend/demo/reset.js +41 -0
  70. package/dist/backend/demo/reset.js.map +1 -0
  71. package/dist/backend/demo/rows.d.ts +103 -0
  72. package/dist/backend/demo/rows.d.ts.map +1 -0
  73. package/dist/backend/demo/rows.js +162 -0
  74. package/dist/backend/demo/rows.js.map +1 -0
  75. package/dist/backend/demo/seed.d.ts +35 -0
  76. package/dist/backend/demo/seed.d.ts.map +1 -0
  77. package/dist/backend/demo/seed.js +177 -0
  78. package/dist/backend/demo/seed.js.map +1 -0
  79. package/dist/backend/entities/attachment-type.entity.d.ts +16 -0
  80. package/dist/backend/entities/attachment-type.entity.d.ts.map +1 -0
  81. package/dist/backend/entities/attachment-type.entity.js +57 -0
  82. package/dist/backend/entities/attachment-type.entity.js.map +1 -0
  83. package/dist/backend/entities/attribute-set-attribute.entity.d.ts +26 -0
  84. package/dist/backend/entities/attribute-set-attribute.entity.d.ts.map +1 -0
  85. package/dist/backend/entities/attribute-set-attribute.entity.js +55 -0
  86. package/dist/backend/entities/attribute-set-attribute.entity.js.map +1 -0
  87. package/dist/backend/entities/attribute-set.entity.d.ts +22 -0
  88. package/dist/backend/entities/attribute-set.entity.d.ts.map +1 -0
  89. package/dist/backend/entities/attribute-set.entity.js +67 -0
  90. package/dist/backend/entities/attribute-set.entity.js.map +1 -0
  91. package/dist/backend/entities/bulk-operation.entity.d.ts +78 -0
  92. package/dist/backend/entities/bulk-operation.entity.d.ts.map +1 -0
  93. package/dist/backend/entities/bulk-operation.entity.js +138 -0
  94. package/dist/backend/entities/bulk-operation.entity.js.map +1 -0
  95. package/dist/backend/entities/bundle-slot-option.entity.d.ts +23 -0
  96. package/dist/backend/entities/bundle-slot-option.entity.d.ts.map +1 -0
  97. package/dist/backend/entities/bundle-slot-option.entity.js +68 -0
  98. package/dist/backend/entities/bundle-slot-option.entity.js.map +1 -0
  99. package/dist/backend/entities/bundle-slot.entity.d.ts +25 -0
  100. package/dist/backend/entities/bundle-slot.entity.d.ts.map +1 -0
  101. package/dist/backend/entities/bundle-slot.entity.js +74 -0
  102. package/dist/backend/entities/bundle-slot.entity.js.map +1 -0
  103. package/dist/backend/entities/category.entity.d.ts +26 -0
  104. package/dist/backend/entities/category.entity.d.ts.map +1 -0
  105. package/dist/backend/entities/category.entity.js +119 -0
  106. package/dist/backend/entities/category.entity.js.map +1 -0
  107. package/dist/backend/entities/gallery-item-label.entity.d.ts +22 -0
  108. package/dist/backend/entities/gallery-item-label.entity.d.ts.map +1 -0
  109. package/dist/backend/entities/gallery-item-label.entity.js +50 -0
  110. package/dist/backend/entities/gallery-item-label.entity.js.map +1 -0
  111. package/dist/backend/entities/gallery-item.entity.d.ts +17 -0
  112. package/dist/backend/entities/gallery-item.entity.d.ts.map +1 -0
  113. package/dist/backend/entities/gallery-item.entity.js +58 -0
  114. package/dist/backend/entities/gallery-item.entity.js.map +1 -0
  115. package/dist/backend/entities/grouped-item.entity.d.ts +23 -0
  116. package/dist/backend/entities/grouped-item.entity.d.ts.map +1 -0
  117. package/dist/backend/entities/grouped-item.entity.js +68 -0
  118. package/dist/backend/entities/grouped-item.entity.js.map +1 -0
  119. package/dist/backend/entities/product-attachment.entity.d.ts +23 -0
  120. package/dist/backend/entities/product-attachment.entity.d.ts.map +1 -0
  121. package/dist/backend/entities/product-attachment.entity.js +77 -0
  122. package/dist/backend/entities/product-attachment.entity.js.map +1 -0
  123. package/dist/backend/entities/product-attribute.entity.d.ts +72 -0
  124. package/dist/backend/entities/product-attribute.entity.d.ts.map +1 -0
  125. package/dist/backend/entities/product-attribute.entity.js +161 -0
  126. package/dist/backend/entities/product-attribute.entity.js.map +1 -0
  127. package/dist/backend/entities/product-editor-preference.entity.d.ts +28 -0
  128. package/dist/backend/entities/product-editor-preference.entity.d.ts.map +1 -0
  129. package/dist/backend/entities/product-editor-preference.entity.js +63 -0
  130. package/dist/backend/entities/product-editor-preference.entity.js.map +1 -0
  131. package/dist/backend/entities/product-link.entity.d.ts +25 -0
  132. package/dist/backend/entities/product-link.entity.d.ts.map +1 -0
  133. package/dist/backend/entities/product-link.entity.js +70 -0
  134. package/dist/backend/entities/product-link.entity.js.map +1 -0
  135. package/dist/backend/entities/product-packaging-unit.entity.d.ts +26 -0
  136. package/dist/backend/entities/product-packaging-unit.entity.d.ts.map +1 -0
  137. package/dist/backend/entities/product-packaging-unit.entity.js +76 -0
  138. package/dist/backend/entities/product-packaging-unit.entity.js.map +1 -0
  139. package/dist/backend/entities/product-value-override.entity.d.ts +43 -0
  140. package/dist/backend/entities/product-value-override.entity.d.ts.map +1 -0
  141. package/dist/backend/entities/product-value-override.entity.js +90 -0
  142. package/dist/backend/entities/product-value-override.entity.js.map +1 -0
  143. package/dist/backend/entities/product-variant.entity.d.ts +18 -0
  144. package/dist/backend/entities/product-variant.entity.d.ts.map +1 -0
  145. package/dist/backend/entities/product-variant.entity.js +68 -0
  146. package/dist/backend/entities/product-variant.entity.js.map +1 -0
  147. package/dist/backend/entities/product.entity.d.ts +108 -0
  148. package/dist/backend/entities/product.entity.d.ts.map +1 -0
  149. package/dist/backend/entities/product.entity.js +230 -0
  150. package/dist/backend/entities/product.entity.js.map +1 -0
  151. package/dist/backend/index.d.ts +262 -0
  152. package/dist/backend/index.d.ts.map +1 -0
  153. package/dist/backend/index.js +642 -0
  154. package/dist/backend/index.js.map +1 -0
  155. package/dist/backend/plugin.d.ts +216 -0
  156. package/dist/backend/plugin.d.ts.map +1 -0
  157. package/dist/backend/plugin.js +223 -0
  158. package/dist/backend/plugin.js.map +1 -0
  159. package/dist/backend/prompt-tools.d.ts +47 -0
  160. package/dist/backend/prompt-tools.d.ts.map +1 -0
  161. package/dist/backend/prompt-tools.js +284 -0
  162. package/dist/backend/prompt-tools.js.map +1 -0
  163. package/dist/backend/routes.admin.d.ts +105 -0
  164. package/dist/backend/routes.admin.d.ts.map +1 -0
  165. package/dist/backend/routes.admin.js +1162 -0
  166. package/dist/backend/routes.admin.js.map +1 -0
  167. package/dist/backend/routes.api-key.d.ts +33 -0
  168. package/dist/backend/routes.api-key.d.ts.map +1 -0
  169. package/dist/backend/routes.api-key.js +43 -0
  170. package/dist/backend/routes.api-key.js.map +1 -0
  171. package/dist/backend/routes.external.d.ts +32 -0
  172. package/dist/backend/routes.external.d.ts.map +1 -0
  173. package/dist/backend/routes.external.js +115 -0
  174. package/dist/backend/routes.external.js.map +1 -0
  175. package/dist/backend/routes.public.d.ts +46 -0
  176. package/dist/backend/routes.public.d.ts.map +1 -0
  177. package/dist/backend/routes.public.js +318 -0
  178. package/dist/backend/routes.public.js.map +1 -0
  179. package/dist/backend/services/asset-references.d.ts +4 -0
  180. package/dist/backend/services/asset-references.d.ts.map +1 -0
  181. package/dist/backend/services/asset-references.js +104 -0
  182. package/dist/backend/services/asset-references.js.map +1 -0
  183. package/dist/backend/services/attachment.service.d.ts +45 -0
  184. package/dist/backend/services/attachment.service.d.ts.map +1 -0
  185. package/dist/backend/services/attachment.service.js +268 -0
  186. package/dist/backend/services/attachment.service.js.map +1 -0
  187. package/dist/backend/services/attribute-option-validator.d.ts +47 -0
  188. package/dist/backend/services/attribute-option-validator.d.ts.map +1 -0
  189. package/dist/backend/services/attribute-option-validator.js +69 -0
  190. package/dist/backend/services/attribute-option-validator.js.map +1 -0
  191. package/dist/backend/services/attribute-set-validations.d.ts +65 -0
  192. package/dist/backend/services/attribute-set-validations.d.ts.map +1 -0
  193. package/dist/backend/services/attribute-set-validations.js +89 -0
  194. package/dist/backend/services/attribute-set-validations.js.map +1 -0
  195. package/dist/backend/services/attribute-set.service.d.ts +50 -0
  196. package/dist/backend/services/attribute-set.service.d.ts.map +1 -0
  197. package/dist/backend/services/attribute-set.service.js +401 -0
  198. package/dist/backend/services/attribute-set.service.js.map +1 -0
  199. package/dist/backend/services/attribute-type-mapping.d.ts +66 -0
  200. package/dist/backend/services/attribute-type-mapping.d.ts.map +1 -0
  201. package/dist/backend/services/attribute-type-mapping.js +121 -0
  202. package/dist/backend/services/attribute-type-mapping.js.map +1 -0
  203. package/dist/backend/services/attribute-value-key.service.d.ts +48 -0
  204. package/dist/backend/services/attribute-value-key.service.d.ts.map +1 -0
  205. package/dist/backend/services/attribute-value-key.service.js +118 -0
  206. package/dist/backend/services/attribute-value-key.service.js.map +1 -0
  207. package/dist/backend/services/audit-references.d.ts +13 -0
  208. package/dist/backend/services/audit-references.d.ts.map +1 -0
  209. package/dist/backend/services/audit-references.js +51 -0
  210. package/dist/backend/services/audit-references.js.map +1 -0
  211. package/dist/backend/services/bulk-operation-queue.d.ts +26 -0
  212. package/dist/backend/services/bulk-operation-queue.d.ts.map +1 -0
  213. package/dist/backend/services/bulk-operation-queue.js +34 -0
  214. package/dist/backend/services/bulk-operation-queue.js.map +1 -0
  215. package/dist/backend/services/bulk-operation.service.d.ts +195 -0
  216. package/dist/backend/services/bulk-operation.service.d.ts.map +1 -0
  217. package/dist/backend/services/bulk-operation.service.js +510 -0
  218. package/dist/backend/services/bulk-operation.service.js.map +1 -0
  219. package/dist/backend/services/bundle.service.d.ts +87 -0
  220. package/dist/backend/services/bundle.service.d.ts.map +1 -0
  221. package/dist/backend/services/bundle.service.js +286 -0
  222. package/dist/backend/services/bundle.service.js.map +1 -0
  223. package/dist/backend/services/catalog-admin.service.d.ts +398 -0
  224. package/dist/backend/services/catalog-admin.service.d.ts.map +1 -0
  225. package/dist/backend/services/catalog-admin.service.js +1516 -0
  226. package/dist/backend/services/catalog-admin.service.js.map +1 -0
  227. package/dist/backend/services/catalog-attribute-read.service.d.ts +78 -0
  228. package/dist/backend/services/catalog-attribute-read.service.d.ts.map +1 -0
  229. package/dist/backend/services/catalog-attribute-read.service.js +216 -0
  230. package/dist/backend/services/catalog-attribute-read.service.js.map +1 -0
  231. package/dist/backend/services/catalog-bulk-import.service.d.ts +30 -0
  232. package/dist/backend/services/catalog-bulk-import.service.d.ts.map +1 -0
  233. package/dist/backend/services/catalog-bulk-import.service.js +136 -0
  234. package/dist/backend/services/catalog-bulk-import.service.js.map +1 -0
  235. package/dist/backend/services/catalog-bulk-update.service.d.ts +91 -0
  236. package/dist/backend/services/catalog-bulk-update.service.d.ts.map +1 -0
  237. package/dist/backend/services/catalog-bulk-update.service.js +330 -0
  238. package/dist/backend/services/catalog-bulk-update.service.js.map +1 -0
  239. package/dist/backend/services/catalog-category-read.service.d.ts +66 -0
  240. package/dist/backend/services/catalog-category-read.service.d.ts.map +1 -0
  241. package/dist/backend/services/catalog-category-read.service.js +248 -0
  242. package/dist/backend/services/catalog-category-read.service.js.map +1 -0
  243. package/dist/backend/services/catalog-org-price-decorator.d.ts +90 -0
  244. package/dist/backend/services/catalog-org-price-decorator.d.ts.map +1 -0
  245. package/dist/backend/services/catalog-org-price-decorator.js +184 -0
  246. package/dist/backend/services/catalog-org-price-decorator.js.map +1 -0
  247. package/dist/backend/services/catalog-product-filter.service.d.ts +82 -0
  248. package/dist/backend/services/catalog-product-filter.service.d.ts.map +1 -0
  249. package/dist/backend/services/catalog-product-filter.service.js +275 -0
  250. package/dist/backend/services/catalog-product-filter.service.js.map +1 -0
  251. package/dist/backend/services/catalog-product-read.service.d.ts +46 -0
  252. package/dist/backend/services/catalog-product-read.service.d.ts.map +1 -0
  253. package/dist/backend/services/catalog-product-read.service.js +197 -0
  254. package/dist/backend/services/catalog-product-read.service.js.map +1 -0
  255. package/dist/backend/services/catalog-query.service.d.ts +444 -0
  256. package/dist/backend/services/catalog-query.service.d.ts.map +1 -0
  257. package/dist/backend/services/catalog-query.service.js +1633 -0
  258. package/dist/backend/services/catalog-query.service.js.map +1 -0
  259. package/dist/backend/services/catalog-quick-search.service.d.ts +78 -0
  260. package/dist/backend/services/catalog-quick-search.service.d.ts.map +1 -0
  261. package/dist/backend/services/catalog-quick-search.service.js +159 -0
  262. package/dist/backend/services/catalog-quick-search.service.js.map +1 -0
  263. package/dist/backend/services/catalog-write-ports.d.ts +25 -0
  264. package/dist/backend/services/catalog-write-ports.d.ts.map +1 -0
  265. package/dist/backend/services/catalog-write-ports.js +63 -0
  266. package/dist/backend/services/catalog-write-ports.js.map +1 -0
  267. package/dist/backend/services/category-admin.service.d.ts +102 -0
  268. package/dist/backend/services/category-admin.service.d.ts.map +1 -0
  269. package/dist/backend/services/category-admin.service.js +271 -0
  270. package/dist/backend/services/category-admin.service.js.map +1 -0
  271. package/dist/backend/services/gallery.service.d.ts +85 -0
  272. package/dist/backend/services/gallery.service.d.ts.map +1 -0
  273. package/dist/backend/services/gallery.service.js +326 -0
  274. package/dist/backend/services/gallery.service.js.map +1 -0
  275. package/dist/backend/services/grouped.service.d.ts +39 -0
  276. package/dist/backend/services/grouped.service.d.ts.map +1 -0
  277. package/dist/backend/services/grouped.service.js +136 -0
  278. package/dist/backend/services/grouped.service.js.map +1 -0
  279. package/dist/backend/services/label-resolver.d.ts +16 -0
  280. package/dist/backend/services/label-resolver.d.ts.map +1 -0
  281. package/dist/backend/services/label-resolver.js +43 -0
  282. package/dist/backend/services/label-resolver.js.map +1 -0
  283. package/dist/backend/services/packaging-unit.service.d.ts +28 -0
  284. package/dist/backend/services/packaging-unit.service.d.ts.map +1 -0
  285. package/dist/backend/services/packaging-unit.service.js +197 -0
  286. package/dist/backend/services/packaging-unit.service.js.map +1 -0
  287. package/dist/backend/services/primary-asset-url.d.ts +4 -0
  288. package/dist/backend/services/primary-asset-url.d.ts.map +1 -0
  289. package/dist/backend/services/primary-asset-url.js +55 -0
  290. package/dist/backend/services/primary-asset-url.js.map +1 -0
  291. package/dist/backend/services/product-editor-preferences.service.d.ts +20 -0
  292. package/dist/backend/services/product-editor-preferences.service.d.ts.map +1 -0
  293. package/dist/backend/services/product-editor-preferences.service.js +44 -0
  294. package/dist/backend/services/product-editor-preferences.service.js.map +1 -0
  295. package/dist/backend/services/product-link.service.d.ts +169 -0
  296. package/dist/backend/services/product-link.service.d.ts.map +1 -0
  297. package/dist/backend/services/product-link.service.js +297 -0
  298. package/dist/backend/services/product-link.service.js.map +1 -0
  299. package/dist/backend/services/product-overrides.service.d.ts +77 -0
  300. package/dist/backend/services/product-overrides.service.d.ts.map +1 -0
  301. package/dist/backend/services/product-overrides.service.js +204 -0
  302. package/dist/backend/services/product-overrides.service.js.map +1 -0
  303. package/dist/backend/services/product-scope-context.service.d.ts +36 -0
  304. package/dist/backend/services/product-scope-context.service.d.ts.map +1 -0
  305. package/dist/backend/services/product-scope-context.service.js +106 -0
  306. package/dist/backend/services/product-scope-context.service.js.map +1 -0
  307. package/dist/backend/services/product-type-validations.d.ts +42 -0
  308. package/dist/backend/services/product-type-validations.d.ts.map +1 -0
  309. package/dist/backend/services/product-type-validations.js +55 -0
  310. package/dist/backend/services/product-type-validations.js.map +1 -0
  311. package/dist/backend/services/product-value-resolver.service.d.ts +59 -0
  312. package/dist/backend/services/product-value-resolver.service.d.ts.map +1 -0
  313. package/dist/backend/services/product-value-resolver.service.js +111 -0
  314. package/dist/backend/services/product-value-resolver.service.js.map +1 -0
  315. package/dist/backend/services/system-attribute-scopes.d.ts +18 -0
  316. package/dist/backend/services/system-attribute-scopes.d.ts.map +1 -0
  317. package/dist/backend/services/system-attribute-scopes.js +18 -0
  318. package/dist/backend/services/system-attribute-scopes.js.map +1 -0
  319. package/dist/backend/services/viewer-organization.d.ts +25 -0
  320. package/dist/backend/services/viewer-organization.d.ts.map +1 -0
  321. package/dist/backend/services/viewer-organization.js +31 -0
  322. package/dist/backend/services/viewer-organization.js.map +1 -0
  323. package/dist/manifest.d.ts +204 -0
  324. package/dist/manifest.d.ts.map +1 -0
  325. package/dist/manifest.js +686 -0
  326. package/dist/manifest.js.map +1 -0
  327. package/dist/migrations/20260429T064146_catalog_attribute_sets_init.d.ts +33 -0
  328. package/dist/migrations/20260429T064146_catalog_attribute_sets_init.d.ts.map +1 -0
  329. package/dist/migrations/20260429T064146_catalog_attribute_sets_init.js +118 -0
  330. package/dist/migrations/20260429T064146_catalog_attribute_sets_init.js.map +1 -0
  331. package/dist/migrations/20260429T070004_catalog_product_attribute_extensions.d.ts +24 -0
  332. package/dist/migrations/20260429T070004_catalog_product_attribute_extensions.d.ts.map +1 -0
  333. package/dist/migrations/20260429T070004_catalog_product_attribute_extensions.js +31 -0
  334. package/dist/migrations/20260429T070004_catalog_product_attribute_extensions.js.map +1 -0
  335. package/dist/migrations/20260429T102322_catalog_product_type_and_virtual_fields.d.ts +32 -0
  336. package/dist/migrations/20260429T102322_catalog_product_type_and_virtual_fields.d.ts.map +1 -0
  337. package/dist/migrations/20260429T102322_catalog_product_type_and_virtual_fields.js +58 -0
  338. package/dist/migrations/20260429T102322_catalog_product_type_and_virtual_fields.js.map +1 -0
  339. package/dist/migrations/20260429T111839_catalog_gallery_items_and_labels.d.ts +22 -0
  340. package/dist/migrations/20260429T111839_catalog_gallery_items_and_labels.d.ts.map +1 -0
  341. package/dist/migrations/20260429T111839_catalog_gallery_items_and_labels.js +66 -0
  342. package/dist/migrations/20260429T111839_catalog_gallery_items_and_labels.js.map +1 -0
  343. package/dist/migrations/20260429T112543_catalog_product_attachments.d.ts +18 -0
  344. package/dist/migrations/20260429T112543_catalog_product_attachments.d.ts.map +1 -0
  345. package/dist/migrations/20260429T112543_catalog_product_attachments.js +73 -0
  346. package/dist/migrations/20260429T112543_catalog_product_attachments.js.map +1 -0
  347. package/dist/migrations/20260429T123726_catalog_product_links.d.ts +21 -0
  348. package/dist/migrations/20260429T123726_catalog_product_links.d.ts.map +1 -0
  349. package/dist/migrations/20260429T123726_catalog_product_links.js +48 -0
  350. package/dist/migrations/20260429T123726_catalog_product_links.js.map +1 -0
  351. package/dist/migrations/20260429T130803_catalog_grouped_and_bundle.d.ts +23 -0
  352. package/dist/migrations/20260429T130803_catalog_grouped_and_bundle.d.ts.map +1 -0
  353. package/dist/migrations/20260429T130803_catalog_grouped_and_bundle.js +94 -0
  354. package/dist/migrations/20260429T130803_catalog_grouped_and_bundle.js.map +1 -0
  355. package/dist/migrations/20260501T185835_catalog_product_attribute_is_comparable.d.ts +24 -0
  356. package/dist/migrations/20260501T185835_catalog_product_attribute_is_comparable.d.ts.map +1 -0
  357. package/dist/migrations/20260501T185835_catalog_product_attribute_is_comparable.js +31 -0
  358. package/dist/migrations/20260501T185835_catalog_product_attribute_is_comparable.js.map +1 -0
  359. package/dist/migrations/20260505T060113_catalog_attribute_options_and_flags.d.ts +31 -0
  360. package/dist/migrations/20260505T060113_catalog_attribute_options_and_flags.d.ts.map +1 -0
  361. package/dist/migrations/20260505T060113_catalog_attribute_options_and_flags.js +133 -0
  362. package/dist/migrations/20260505T060113_catalog_attribute_options_and_flags.js.map +1 -0
  363. package/dist/migrations/20260515T082629_catalog_attribute_mass_editable.d.ts +16 -0
  364. package/dist/migrations/20260515T082629_catalog_attribute_mass_editable.d.ts.map +1 -0
  365. package/dist/migrations/20260515T082629_catalog_attribute_mass_editable.js +23 -0
  366. package/dist/migrations/20260515T082629_catalog_attribute_mass_editable.js.map +1 -0
  367. package/dist/migrations/20260526T124736_catalog_product_status_inactive.d.ts +10 -0
  368. package/dist/migrations/20260526T124736_catalog_product_status_inactive.d.ts.map +1 -0
  369. package/dist/migrations/20260526T124736_catalog_product_status_inactive.js +22 -0
  370. package/dist/migrations/20260526T124736_catalog_product_status_inactive.js.map +1 -0
  371. package/dist/migrations/20260611T140346_catalog_product_value_overrides_init.d.ts +38 -0
  372. package/dist/migrations/20260611T140346_catalog_product_value_overrides_init.d.ts.map +1 -0
  373. package/dist/migrations/20260611T140346_catalog_product_value_overrides_init.js +98 -0
  374. package/dist/migrations/20260611T140346_catalog_product_value_overrides_init.js.map +1 -0
  375. package/dist/migrations/20260611T140400_catalog_attribute_quick_searchable.d.ts +13 -0
  376. package/dist/migrations/20260611T140400_catalog_attribute_quick_searchable.d.ts.map +1 -0
  377. package/dist/migrations/20260611T140400_catalog_attribute_quick_searchable.js +17 -0
  378. package/dist/migrations/20260611T140400_catalog_attribute_quick_searchable.js.map +1 -0
  379. package/dist/migrations/20260611T140407_catalog_bulk_operations.d.ts +16 -0
  380. package/dist/migrations/20260611T140407_catalog_bulk_operations.d.ts.map +1 -0
  381. package/dist/migrations/20260611T140407_catalog_bulk_operations.js +42 -0
  382. package/dist/migrations/20260611T140407_catalog_bulk_operations.js.map +1 -0
  383. package/dist/migrations/20260611T140408_catalog_bulk_operation_logs.d.ts +14 -0
  384. package/dist/migrations/20260611T140408_catalog_bulk_operation_logs.d.ts.map +1 -0
  385. package/dist/migrations/20260611T140408_catalog_bulk_operation_logs.js +18 -0
  386. package/dist/migrations/20260611T140408_catalog_bulk_operation_logs.js.map +1 -0
  387. package/dist/migrations/20260611T140412_catalog_product_packaging_units.d.ts +11 -0
  388. package/dist/migrations/20260611T140412_catalog_product_packaging_units.d.ts.map +1 -0
  389. package/dist/migrations/20260611T140412_catalog_product_packaging_units.js +29 -0
  390. package/dist/migrations/20260611T140412_catalog_product_packaging_units.js.map +1 -0
  391. package/dist/migrations/20260718T060659_catalog_bulk_operation_revert_state.d.ts +23 -0
  392. package/dist/migrations/20260718T060659_catalog_bulk_operation_revert_state.d.ts.map +1 -0
  393. package/dist/migrations/20260718T060659_catalog_bulk_operation_revert_state.js +35 -0
  394. package/dist/migrations/20260718T060659_catalog_bulk_operation_revert_state.js.map +1 -0
  395. package/dist/migrations/20260718T200343_catalog_category_custom_field_values.d.ts +12 -0
  396. package/dist/migrations/20260718T200343_catalog_category_custom_field_values.d.ts.map +1 -0
  397. package/dist/migrations/20260718T200343_catalog_category_custom_field_values.js +16 -0
  398. package/dist/migrations/20260718T200343_catalog_category_custom_field_values.js.map +1 -0
  399. package/dist/migrations/20260723T230401_catalog_attributes_on_custom_fields.d.ts +32 -0
  400. package/dist/migrations/20260723T230401_catalog_attributes_on_custom_fields.d.ts.map +1 -0
  401. package/dist/migrations/20260723T230401_catalog_attributes_on_custom_fields.js +286 -0
  402. package/dist/migrations/20260723T230401_catalog_attributes_on_custom_fields.js.map +1 -0
  403. package/dist/migrations/20260804T152604_catalog_widen_product_sku.d.ts +36 -0
  404. package/dist/migrations/20260804T152604_catalog_widen_product_sku.d.ts.map +1 -0
  405. package/dist/migrations/20260804T152604_catalog_widen_product_sku.js +63 -0
  406. package/dist/migrations/20260804T152604_catalog_widen_product_sku.js.map +1 -0
  407. package/dist/migrations/20260804T160244_catalog_category_activation.d.ts +18 -0
  408. package/dist/migrations/20260804T160244_catalog_category_activation.d.ts.map +1 -0
  409. package/dist/migrations/20260804T160244_catalog_category_activation.js +22 -0
  410. package/dist/migrations/20260804T160244_catalog_category_activation.js.map +1 -0
  411. package/dist/migrations/20260912T094557_catalog_sales_channel_products.d.ts +27 -0
  412. package/dist/migrations/20260912T094557_catalog_sales_channel_products.d.ts.map +1 -0
  413. package/dist/migrations/20260912T094557_catalog_sales_channel_products.js +44 -0
  414. package/dist/migrations/20260912T094557_catalog_sales_channel_products.js.map +1 -0
  415. package/dist/migrations/20260912T094623_catalog_sales_channel_categories.d.ts +27 -0
  416. package/dist/migrations/20260912T094623_catalog_sales_channel_categories.d.ts.map +1 -0
  417. package/dist/migrations/20260912T094623_catalog_sales_channel_categories.js +44 -0
  418. package/dist/migrations/20260912T094623_catalog_sales_channel_categories.js.map +1 -0
  419. package/dist/migrations/20260925T125527_catalog_inventory_columns.d.ts +36 -0
  420. package/dist/migrations/20260925T125527_catalog_inventory_columns.d.ts.map +1 -0
  421. package/dist/migrations/20260925T125527_catalog_inventory_columns.js +87 -0
  422. package/dist/migrations/20260925T125527_catalog_inventory_columns.js.map +1 -0
  423. package/dist/migrations/index.d.ts +55 -0
  424. package/dist/migrations/index.d.ts.map +1 -0
  425. package/dist/migrations/index.js +80 -0
  426. package/dist/migrations/index.js.map +1 -0
  427. package/dist/ports/index.d.ts +96 -0
  428. package/dist/ports/index.d.ts.map +1 -0
  429. package/dist/ports/index.js +2 -0
  430. package/dist/ports/index.js.map +1 -0
  431. package/docs/catalog/attachments.md +69 -0
  432. package/docs/catalog/attribute-sets.md +63 -0
  433. package/docs/catalog/attributes.md +244 -0
  434. package/docs/catalog/composite-products.md +115 -0
  435. package/docs/catalog/gallery-and-labels.md +73 -0
  436. package/docs/catalog/packaging-units.md +67 -0
  437. package/docs/catalog/per-channel-per-language-overrides.md +234 -0
  438. package/docs/catalog/product-links.md +79 -0
  439. package/docs/catalog.md +207 -0
  440. package/i18n/en.json +649 -0
  441. package/i18n/pl.json +649 -0
  442. package/package.json +109 -0
  443. 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