@endora-commerce/contracts 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 (327) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +34 -0
  3. package/dist/actor.d.ts +79 -0
  4. package/dist/actor.d.ts.map +1 -0
  5. package/dist/actor.js +41 -0
  6. package/dist/actor.js.map +1 -0
  7. package/dist/addresses.d.ts +134 -0
  8. package/dist/addresses.d.ts.map +1 -0
  9. package/dist/addresses.js +16 -0
  10. package/dist/addresses.js.map +1 -0
  11. package/dist/admin-actions.d.ts +367 -0
  12. package/dist/admin-actions.d.ts.map +1 -0
  13. package/dist/admin-actions.js +287 -0
  14. package/dist/admin-actions.js.map +1 -0
  15. package/dist/admin-contributions.d.ts +518 -0
  16. package/dist/admin-contributions.d.ts.map +1 -0
  17. package/dist/admin-contributions.js +495 -0
  18. package/dist/admin-contributions.js.map +1 -0
  19. package/dist/admin-i18n.d.ts +135 -0
  20. package/dist/admin-i18n.d.ts.map +1 -0
  21. package/dist/admin-i18n.js +72 -0
  22. package/dist/admin-i18n.js.map +1 -0
  23. package/dist/admin-notifications.d.ts +55 -0
  24. package/dist/admin-notifications.d.ts.map +1 -0
  25. package/dist/admin-notifications.js +16 -0
  26. package/dist/admin-notifications.js.map +1 -0
  27. package/dist/admin-roles.d.ts +125 -0
  28. package/dist/admin-roles.d.ts.map +1 -0
  29. package/dist/admin-roles.js +2 -0
  30. package/dist/admin-roles.js.map +1 -0
  31. package/dist/admin-users.d.ts +178 -0
  32. package/dist/admin-users.d.ts.map +1 -0
  33. package/dist/admin-users.js +14 -0
  34. package/dist/admin-users.js.map +1 -0
  35. package/dist/admin.d.ts +243 -0
  36. package/dist/admin.d.ts.map +1 -0
  37. package/dist/admin.js +246 -0
  38. package/dist/admin.js.map +1 -0
  39. package/dist/analytics.d.ts +123 -0
  40. package/dist/analytics.d.ts.map +1 -0
  41. package/dist/analytics.js +68 -0
  42. package/dist/analytics.js.map +1 -0
  43. package/dist/api-keys.d.ts +97 -0
  44. package/dist/api-keys.d.ts.map +1 -0
  45. package/dist/api-keys.js +64 -0
  46. package/dist/api-keys.js.map +1 -0
  47. package/dist/assets-library.d.ts +684 -0
  48. package/dist/assets-library.d.ts.map +1 -0
  49. package/dist/assets-library.js +181 -0
  50. package/dist/assets-library.js.map +1 -0
  51. package/dist/audit-logs.d.ts +141 -0
  52. package/dist/audit-logs.d.ts.map +1 -0
  53. package/dist/audit-logs.js +31 -0
  54. package/dist/audit-logs.js.map +1 -0
  55. package/dist/auth.d.ts +174 -0
  56. package/dist/auth.d.ts.map +1 -0
  57. package/dist/auth.js +27 -0
  58. package/dist/auth.js.map +1 -0
  59. package/dist/blog.d.ts +669 -0
  60. package/dist/blog.d.ts.map +1 -0
  61. package/dist/blog.js +360 -0
  62. package/dist/blog.js.map +1 -0
  63. package/dist/capabilities.d.ts +40 -0
  64. package/dist/capabilities.d.ts.map +1 -0
  65. package/dist/capabilities.js +38 -0
  66. package/dist/capabilities.js.map +1 -0
  67. package/dist/carts.d.ts +1367 -0
  68. package/dist/carts.d.ts.map +1 -0
  69. package/dist/carts.js +405 -0
  70. package/dist/carts.js.map +1 -0
  71. package/dist/catalog.d.ts +2855 -0
  72. package/dist/catalog.d.ts.map +1 -0
  73. package/dist/catalog.js +1543 -0
  74. package/dist/catalog.js.map +1 -0
  75. package/dist/cms.d.ts +872 -0
  76. package/dist/cms.d.ts.map +1 -0
  77. package/dist/cms.js +468 -0
  78. package/dist/cms.js.map +1 -0
  79. package/dist/common.d.ts +82 -0
  80. package/dist/common.d.ts.map +1 -0
  81. package/dist/common.js +72 -0
  82. package/dist/common.js.map +1 -0
  83. package/dist/comparisons.d.ts +487 -0
  84. package/dist/comparisons.d.ts.map +1 -0
  85. package/dist/comparisons.js +221 -0
  86. package/dist/comparisons.js.map +1 -0
  87. package/dist/credentials.d.ts +292 -0
  88. package/dist/credentials.d.ts.map +1 -0
  89. package/dist/credentials.js +142 -0
  90. package/dist/credentials.js.map +1 -0
  91. package/dist/credit-limits.d.ts +111 -0
  92. package/dist/credit-limits.d.ts.map +1 -0
  93. package/dist/credit-limits.js +35 -0
  94. package/dist/credit-limits.js.map +1 -0
  95. package/dist/currencies.d.ts +127 -0
  96. package/dist/currencies.d.ts.map +1 -0
  97. package/dist/currencies.js +20 -0
  98. package/dist/currencies.js.map +1 -0
  99. package/dist/custom-fields.d.ts +345 -0
  100. package/dist/custom-fields.d.ts.map +1 -0
  101. package/dist/custom-fields.js +185 -0
  102. package/dist/custom-fields.js.map +1 -0
  103. package/dist/customer-accounts.d.ts +690 -0
  104. package/dist/customer-accounts.d.ts.map +1 -0
  105. package/dist/customer-accounts.js +41 -0
  106. package/dist/customer-accounts.js.map +1 -0
  107. package/dist/customers.d.ts +305 -0
  108. package/dist/customers.d.ts.map +1 -0
  109. package/dist/customers.js +158 -0
  110. package/dist/customers.js.map +1 -0
  111. package/dist/dictionary.d.ts +580 -0
  112. package/dist/dictionary.d.ts.map +1 -0
  113. package/dist/dictionary.js +297 -0
  114. package/dist/dictionary.js.map +1 -0
  115. package/dist/email-address.d.ts +62 -0
  116. package/dist/email-address.d.ts.map +1 -0
  117. package/dist/email-address.js +64 -0
  118. package/dist/email-address.js.map +1 -0
  119. package/dist/email.d.ts +175 -0
  120. package/dist/email.d.ts.map +1 -0
  121. package/dist/email.js +45 -0
  122. package/dist/email.js.map +1 -0
  123. package/dist/envelopes.d.ts +15 -0
  124. package/dist/envelopes.d.ts.map +1 -0
  125. package/dist/envelopes.js +16 -0
  126. package/dist/envelopes.js.map +1 -0
  127. package/dist/environment-inputs.d.ts +306 -0
  128. package/dist/environment-inputs.d.ts.map +1 -0
  129. package/dist/environment-inputs.js +277 -0
  130. package/dist/environment-inputs.js.map +1 -0
  131. package/dist/erp-connector.d.ts +52 -0
  132. package/dist/erp-connector.d.ts.map +1 -0
  133. package/dist/erp-connector.js +34 -0
  134. package/dist/erp-connector.js.map +1 -0
  135. package/dist/errors.d.ts +455 -0
  136. package/dist/errors.d.ts.map +1 -0
  137. package/dist/errors.js +532 -0
  138. package/dist/errors.js.map +1 -0
  139. package/dist/google-analytics.d.ts +181 -0
  140. package/dist/google-analytics.d.ts.map +1 -0
  141. package/dist/google-analytics.js +176 -0
  142. package/dist/google-analytics.js.map +1 -0
  143. package/dist/google-tag-manager.d.ts +111 -0
  144. package/dist/google-tag-manager.d.ts.map +1 -0
  145. package/dist/google-tag-manager.js +129 -0
  146. package/dist/google-tag-manager.js.map +1 -0
  147. package/dist/i18n.d.ts +69 -0
  148. package/dist/i18n.d.ts.map +1 -0
  149. package/dist/i18n.js +59 -0
  150. package/dist/i18n.js.map +1 -0
  151. package/dist/import-export.d.ts +63 -0
  152. package/dist/import-export.d.ts.map +1 -0
  153. package/dist/import-export.js +37 -0
  154. package/dist/import-export.js.map +1 -0
  155. package/dist/index.d.ts +82 -0
  156. package/dist/index.d.ts.map +1 -0
  157. package/dist/index.js +126 -0
  158. package/dist/index.js.map +1 -0
  159. package/dist/inventory.d.ts +673 -0
  160. package/dist/inventory.d.ts.map +1 -0
  161. package/dist/inventory.js +412 -0
  162. package/dist/inventory.js.map +1 -0
  163. package/dist/invoice-ledger.d.ts +366 -0
  164. package/dist/invoice-ledger.d.ts.map +1 -0
  165. package/dist/invoice-ledger.js +114 -0
  166. package/dist/invoice-ledger.js.map +1 -0
  167. package/dist/invoices.d.ts +845 -0
  168. package/dist/invoices.d.ts.map +1 -0
  169. package/dist/invoices.js +314 -0
  170. package/dist/invoices.js.map +1 -0
  171. package/dist/kernel.d.ts +49 -0
  172. package/dist/kernel.d.ts.map +1 -0
  173. package/dist/kernel.js +19 -0
  174. package/dist/kernel.js.map +1 -0
  175. package/dist/languages.d.ts +122 -0
  176. package/dist/languages.d.ts.map +1 -0
  177. package/dist/languages.js +24 -0
  178. package/dist/languages.js.map +1 -0
  179. package/dist/linkedin-ads.d.ts +167 -0
  180. package/dist/linkedin-ads.d.ts.map +1 -0
  181. package/dist/linkedin-ads.js +156 -0
  182. package/dist/linkedin-ads.js.map +1 -0
  183. package/dist/megamenu.d.ts +556 -0
  184. package/dist/megamenu.d.ts.map +1 -0
  185. package/dist/megamenu.js +186 -0
  186. package/dist/megamenu.js.map +1 -0
  187. package/dist/meta-ads.d.ts +126 -0
  188. package/dist/meta-ads.d.ts.map +1 -0
  189. package/dist/meta-ads.js +112 -0
  190. package/dist/meta-ads.js.map +1 -0
  191. package/dist/mfa.d.ts +274 -0
  192. package/dist/mfa.d.ts.map +1 -0
  193. package/dist/mfa.js +187 -0
  194. package/dist/mfa.js.map +1 -0
  195. package/dist/modules.d.ts +1706 -0
  196. package/dist/modules.d.ts.map +1 -0
  197. package/dist/modules.js +1390 -0
  198. package/dist/modules.js.map +1 -0
  199. package/dist/newsletter.d.ts +611 -0
  200. package/dist/newsletter.d.ts.map +1 -0
  201. package/dist/newsletter.js +345 -0
  202. package/dist/newsletter.js.map +1 -0
  203. package/dist/orders.d.ts +1175 -0
  204. package/dist/orders.d.ts.map +1 -0
  205. package/dist/orders.js +630 -0
  206. package/dist/orders.js.map +1 -0
  207. package/dist/organizations.d.ts +938 -0
  208. package/dist/organizations.d.ts.map +1 -0
  209. package/dist/organizations.js +418 -0
  210. package/dist/organizations.js.map +1 -0
  211. package/dist/pagination.d.ts +21 -0
  212. package/dist/pagination.d.ts.map +1 -0
  213. package/dist/pagination.js +22 -0
  214. package/dist/pagination.js.map +1 -0
  215. package/dist/payment-methods.d.ts +472 -0
  216. package/dist/payment-methods.d.ts.map +1 -0
  217. package/dist/payment-methods.js +175 -0
  218. package/dist/payment-methods.js.map +1 -0
  219. package/dist/payment-return-url.d.ts +53 -0
  220. package/dist/payment-return-url.d.ts.map +1 -0
  221. package/dist/payment-return-url.js +35 -0
  222. package/dist/payment-return-url.js.map +1 -0
  223. package/dist/payments.d.ts +386 -0
  224. package/dist/payments.d.ts.map +1 -0
  225. package/dist/payments.js +84 -0
  226. package/dist/payments.js.map +1 -0
  227. package/dist/pim-connector.d.ts +60 -0
  228. package/dist/pim-connector.d.ts.map +1 -0
  229. package/dist/pim-connector.js +43 -0
  230. package/dist/pim-connector.js.map +1 -0
  231. package/dist/pim-field-path.d.ts +6 -0
  232. package/dist/pim-field-path.d.ts.map +1 -0
  233. package/dist/pim-field-path.js +101 -0
  234. package/dist/pim-field-path.js.map +1 -0
  235. package/dist/platform-language.d.ts +20 -0
  236. package/dist/platform-language.d.ts.map +1 -0
  237. package/dist/platform-language.js +22 -0
  238. package/dist/platform-language.js.map +1 -0
  239. package/dist/price-lists.d.ts +685 -0
  240. package/dist/price-lists.d.ts.map +1 -0
  241. package/dist/price-lists.js +330 -0
  242. package/dist/price-lists.js.map +1 -0
  243. package/dist/product-feeds.d.ts +2837 -0
  244. package/dist/product-feeds.d.ts.map +1 -0
  245. package/dist/product-feeds.js +1504 -0
  246. package/dist/product-feeds.js.map +1 -0
  247. package/dist/product-scope-overrides.d.ts +134 -0
  248. package/dist/product-scope-overrides.d.ts.map +1 -0
  249. package/dist/product-scope-overrides.js +82 -0
  250. package/dist/product-scope-overrides.js.map +1 -0
  251. package/dist/product-value-resolver.d.ts +88 -0
  252. package/dist/product-value-resolver.d.ts.map +1 -0
  253. package/dist/product-value-resolver.js +128 -0
  254. package/dist/product-value-resolver.js.map +1 -0
  255. package/dist/promotions.d.ts +678 -0
  256. package/dist/promotions.d.ts.map +1 -0
  257. package/dist/promotions.js +479 -0
  258. package/dist/promotions.js.map +1 -0
  259. package/dist/prompt-actions.d.ts +582 -0
  260. package/dist/prompt-actions.d.ts.map +1 -0
  261. package/dist/prompt-actions.js +221 -0
  262. package/dist/prompt-actions.js.map +1 -0
  263. package/dist/pwa.d.ts +293 -0
  264. package/dist/pwa.d.ts.map +1 -0
  265. package/dist/pwa.js +204 -0
  266. package/dist/pwa.js.map +1 -0
  267. package/dist/quick-order.d.ts +340 -0
  268. package/dist/quick-order.d.ts.map +1 -0
  269. package/dist/quick-order.js +177 -0
  270. package/dist/quick-order.js.map +1 -0
  271. package/dist/quote-requests.d.ts +538 -0
  272. package/dist/quote-requests.d.ts.map +1 -0
  273. package/dist/quote-requests.js +308 -0
  274. package/dist/quote-requests.js.map +1 -0
  275. package/dist/returns.d.ts +774 -0
  276. package/dist/returns.d.ts.map +1 -0
  277. package/dist/returns.js +389 -0
  278. package/dist/returns.js.map +1 -0
  279. package/dist/sales-channels.d.ts +392 -0
  280. package/dist/sales-channels.d.ts.map +1 -0
  281. package/dist/sales-channels.js +285 -0
  282. package/dist/sales-channels.js.map +1 -0
  283. package/dist/scope-notice.d.ts +60 -0
  284. package/dist/scope-notice.d.ts.map +1 -0
  285. package/dist/scope-notice.js +56 -0
  286. package/dist/scope-notice.js.map +1 -0
  287. package/dist/search.d.ts +321 -0
  288. package/dist/search.d.ts.map +1 -0
  289. package/dist/search.js +160 -0
  290. package/dist/search.js.map +1 -0
  291. package/dist/seo.d.ts +113 -0
  292. package/dist/seo.d.ts.map +1 -0
  293. package/dist/seo.js +63 -0
  294. package/dist/seo.js.map +1 -0
  295. package/dist/settings.d.ts +453 -0
  296. package/dist/settings.d.ts.map +1 -0
  297. package/dist/settings.js +337 -0
  298. package/dist/settings.js.map +1 -0
  299. package/dist/shipments.d.ts +140 -0
  300. package/dist/shipments.d.ts.map +1 -0
  301. package/dist/shipments.js +14 -0
  302. package/dist/shipments.js.map +1 -0
  303. package/dist/shipping-methods.d.ts +350 -0
  304. package/dist/shipping-methods.d.ts.map +1 -0
  305. package/dist/shipping-methods.js +99 -0
  306. package/dist/shipping-methods.js.map +1 -0
  307. package/dist/shopping-lists.d.ts +122 -0
  308. package/dist/shopping-lists.d.ts.map +1 -0
  309. package/dist/shopping-lists.js +92 -0
  310. package/dist/shopping-lists.js.map +1 -0
  311. package/dist/taxes.d.ts +106 -0
  312. package/dist/taxes.d.ts.map +1 -0
  313. package/dist/taxes.js +80 -0
  314. package/dist/taxes.js.map +1 -0
  315. package/dist/text-normalization.d.ts +199 -0
  316. package/dist/text-normalization.d.ts.map +1 -0
  317. package/dist/text-normalization.js +205 -0
  318. package/dist/text-normalization.js.map +1 -0
  319. package/dist/transactional-emails.d.ts +459 -0
  320. package/dist/transactional-emails.d.ts.map +1 -0
  321. package/dist/transactional-emails.js +212 -0
  322. package/dist/transactional-emails.js.map +1 -0
  323. package/dist/webhooks.d.ts +69 -0
  324. package/dist/webhooks.d.ts.map +1 -0
  325. package/dist/webhooks.js +53 -0
  326. package/dist/webhooks.js.map +1 -0
  327. package/package.json +46 -0
@@ -0,0 +1,1543 @@
1
+ import { z } from 'zod';
2
+ import { isoDateTimeSchema, moneySchema, multilingualStringSchema, productVisibilitySchema, uuidSchema, } from './common.js';
3
+ /**
4
+ * Catalog module contracts — Source of truth per Principle V.
5
+ * See specs/001-b2b-platform-foundation/contracts/catalog.contract.md.
6
+ */
7
+ // --- Primitives --------------------------------------------------------------
8
+ /**
9
+ * Product types. Foundation 001 used `simple | variant | grouped |
10
+ * virtual`; feature 002 (data-model.md §1.1) renames `variant` →
11
+ * `configurable` and adds `bundle`. The application enum is the only
12
+ * source of truth — the DB column is `varchar(16)` with no CHECK
13
+ * constraint (research.md R-1).
14
+ */
15
+ export const productTypeSchema = z.enum([
16
+ 'simple',
17
+ 'configurable',
18
+ 'grouped',
19
+ 'bundle',
20
+ 'virtual',
21
+ ]);
22
+ export const productStatusSchema = z.enum(['draft', 'active', 'inactive']);
23
+ /** Maps legacy `archived` writes to `inactive` (feature 032). */
24
+ export function coerceProductStatusWrite(value) {
25
+ return value === 'archived' ? 'inactive' : value;
26
+ }
27
+ export const productStatusWriteSchema = z.preprocess(coerceProductStatusWrite, productStatusSchema);
28
+ export const stockModeSchema = z.enum(['categorical', 'numeric']);
29
+ export const stockIndicatorSchema = z.enum(['available', 'to_order', 'out_of_stock']);
30
+ /**
31
+ * DB-level attribute value types. Foundation 001 introduced the original
32
+ * 5-element enum (`string | number | boolean | enum | date`). Feature 002
33
+ * adds `multiselect` and `price` per data-model.md §1.2.
34
+ *
35
+ * The API-facing presentation form (`apiAttributeTypeSchema` below)
36
+ * surfaces additional affordances (`input`, `select`, `slider`) that
37
+ * map to this DB enum + the sibling `displayAsSlider` flag — see
38
+ * research.md R-4 / R-7.
39
+ */
40
+ export const attributeValueTypeSchema = z.enum([
41
+ 'string',
42
+ 'number',
43
+ 'boolean',
44
+ 'enum',
45
+ 'date',
46
+ 'multiselect',
47
+ 'price',
48
+ 'select',
49
+ ]);
50
+ /**
51
+ * API-facing attribute type form for feature 002 contracts. Maps onto
52
+ * `attributeValueTypeSchema` + `displayAsSlider` in the service layer.
53
+ */
54
+ export const apiAttributeTypeSchema = z.enum([
55
+ 'input',
56
+ 'number',
57
+ 'select',
58
+ 'multiselect',
59
+ 'price',
60
+ 'slider',
61
+ ]);
62
+ /**
63
+ * The API-form type of a stored attribute, derived from the pair the catalogue
64
+ * persists (`valueType`, `displayAsSlider`). This is the single definition:
65
+ * the catalogue reports every attribute's `type` through it, and a connector
66
+ * that binds a source attribute to an existing one compares against it — two
67
+ * derivations of one rule drift, and a drift creates attributes the catalogue
68
+ * then reports as another kind.
69
+ *
70
+ * A slider flag only means something on a numeric type (`number`, `price`);
71
+ * elsewhere it is ignored. `string`, `boolean` and `date` have no richer API
72
+ * form and surface as `input`; `enum` and `select` share one affordance.
73
+ */
74
+ export function apiAttributeTypeOf(valueType, displayAsSlider) {
75
+ if (displayAsSlider && (valueType === 'number' || valueType === 'price'))
76
+ return 'slider';
77
+ switch (valueType) {
78
+ case 'number':
79
+ return 'number';
80
+ case 'price':
81
+ return 'price';
82
+ case 'enum':
83
+ case 'select':
84
+ return 'select';
85
+ case 'multiselect':
86
+ return 'multiselect';
87
+ case 'string':
88
+ case 'boolean':
89
+ case 'date':
90
+ return 'input';
91
+ }
92
+ }
93
+ export const assetKindSchema = z.enum(['image', 'video', 'pdf', 'certificate', 'other']);
94
+ // --- Feature 012 — Attribute options (rich per-option metadata) -------------
95
+ const attributeOptionValueRegex = /^[a-z0-9_-]{1,200}$/;
96
+ export const attributeOptionSchema = z.object({
97
+ id: uuidSchema,
98
+ attributeId: uuidSchema,
99
+ value: z.string().regex(attributeOptionValueRegex),
100
+ label: z.record(z.string().min(2), z.string().min(1).max(200)),
101
+ labelDefault: z.string().min(1).max(200),
102
+ isDefault: z.boolean(),
103
+ sortOrder: z.number().int().min(0),
104
+ createdAt: isoDateTimeSchema,
105
+ updatedAt: isoDateTimeSchema,
106
+ });
107
+ export const createAttributeOptionRequestSchema = z.object({
108
+ value: z.string().regex(attributeOptionValueRegex),
109
+ label: z.record(z.string().min(2), z.string().min(1).max(200)).optional(),
110
+ labelDefault: z.string().min(1).max(200),
111
+ isDefault: z.boolean().optional(),
112
+ sortOrder: z.number().int().min(0).optional(),
113
+ });
114
+ export const replaceAttributeOptionsRequestSchema = z.object({
115
+ options: z.array(createAttributeOptionRequestSchema),
116
+ });
117
+ export const patchAttributeOptionRequestSchema = z
118
+ .object({
119
+ label: z.record(z.string().min(2), z.string().min(1).max(200)).optional(),
120
+ labelDefault: z.string().min(1).max(200).optional(),
121
+ isDefault: z.boolean().optional(),
122
+ sortOrder: z.number().int().min(0).optional(),
123
+ })
124
+ .strict();
125
+ // --- Assets (linked from catalog) -------------------------------------------
126
+ export const productAssetSchema = z.object({
127
+ id: uuidSchema,
128
+ kind: assetKindSchema,
129
+ url: z.string().url(),
130
+ altText: z.string().nullable(),
131
+ });
132
+ // --- Feature 062 — external (api-key) catalog read decorations ---------------
133
+ /**
134
+ * Availability indication mirrored from the inventory display bands the
135
+ * storefront shows (`available` = stock not managed for the product).
136
+ */
137
+ export const productAvailabilityBandSchema = z.enum([
138
+ 'high',
139
+ 'medium',
140
+ 'low',
141
+ 'out_of_stock',
142
+ 'available',
143
+ ]);
144
+ export const productAvailabilitySchema = z.object({
145
+ band: productAvailabilityBandSchema,
146
+ inStock: z.boolean(),
147
+ });
148
+ /**
149
+ * One rung of the bound Organization's resolved quantity-bracket price
150
+ * ladder (external product detail only).
151
+ */
152
+ export const productPriceTierSchema = z.object({
153
+ minQuantity: z.number().int().min(1),
154
+ amount: z.number().finite(),
155
+ currency: z.string().length(3),
156
+ isSale: z.boolean(),
157
+ });
158
+ // --- Product summary (list response) ----------------------------------------
159
+ export const productSummarySchema = z.object({
160
+ id: uuidSchema,
161
+ sku: z.string().min(1).max(255),
162
+ type: productTypeSchema,
163
+ name: z.string(),
164
+ slug: z.string(),
165
+ categorySlugs: z.array(z.string()),
166
+ primaryAssetUrl: z.string().url().nullable(),
167
+ price: moneySchema.nullable(),
168
+ stockIndicator: stockIndicatorSchema.nullable(),
169
+ stockLevel: z.number().int().nullable(),
170
+ /**
171
+ * Feature 062 — external namespace only (`/api/v1/external/catalog/*`):
172
+ * set to `true` for bound api-key callers when the Organization-effective
173
+ * price resolved to `null`. Never present on the public surface.
174
+ */
175
+ priceUnavailable: z.boolean().optional(),
176
+ /**
177
+ * Feature 062 — external namespace only: channel-public availability
178
+ * indication (band + in-stock flag), present for bound and unbound keys.
179
+ */
180
+ availability: productAvailabilitySchema.optional(),
181
+ });
182
+ // --- Product variant --------------------------------------------------------
183
+ export const productVariantSchema = z.object({
184
+ id: uuidSchema,
185
+ sku: z.string().min(1).max(255),
186
+ variantAttributeValues: z.record(z.string(), z.unknown()),
187
+ priceOverride: z.number().finite().nullable(),
188
+ stockLevel: z.number().int().nullable(),
189
+ });
190
+ // --- Product detail (PDP response) ------------------------------------------
191
+ export const seoMetaSchema = z.object({
192
+ metaTitle: z.string(),
193
+ metaDescription: z.string(),
194
+ openGraph: z.object({
195
+ title: z.string(),
196
+ description: z.string(),
197
+ imageUrl: z.string().url().nullable(),
198
+ }),
199
+ });
200
+ /**
201
+ * Forward declaration so productDetailSchema can reference link summaries.
202
+ * The exported `productLinkSummarySchema` below is the canonical name —
203
+ * this `*Inline` alias exists only to avoid a circular import.
204
+ */
205
+ const productLinkSummarySchemaInline = z.object({
206
+ id: uuidSchema,
207
+ kind: z.enum(['related', 'up_sell', 'cross_sell']),
208
+ position: z.number().int().nonnegative(),
209
+ product: z.object({
210
+ id: uuidSchema,
211
+ sku: z.string(),
212
+ slug: z.string(),
213
+ name: z.string(),
214
+ primaryAssetUrl: z.string().nullable(),
215
+ price: moneySchema.nullable(),
216
+ }),
217
+ });
218
+ export const productDetailSchema = productSummarySchema.extend({
219
+ description: z.string(),
220
+ attributeValues: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])),
221
+ assets: z.array(productAssetSchema),
222
+ variants: z.array(productVariantSchema),
223
+ categories: z.array(z.object({
224
+ id: uuidSchema,
225
+ name: z.string(),
226
+ slug: z.string(),
227
+ })),
228
+ seo: seoMetaSchema,
229
+ structuredDataJsonLd: z.record(z.string(), z.unknown()),
230
+ /**
231
+ * Feature 002 — the AttributeSet wired to this Product. Optional so
232
+ * foundation-era clients (and any storefront cache that hasn't been
233
+ * refreshed yet) keep parsing the response. Once admin UI + storefront
234
+ * consume this field everywhere, it can be tightened to required.
235
+ */
236
+ attributeSet: z
237
+ .object({
238
+ id: uuidSchema,
239
+ code: z.string(),
240
+ name: multilingualStringSchema,
241
+ })
242
+ .optional(),
243
+ /**
244
+ * Feature 002 US3 — gallery items with their assigned labels. Each
245
+ * item carries the Asset's resolved url + kind so storefront PDP
246
+ * doesn't need a follow-up fetch. Optional for the same backwards-
247
+ * compat reason as attributeSet.
248
+ */
249
+ gallery: z
250
+ .array(z.object({
251
+ id: uuidSchema,
252
+ position: z.number().int().nonnegative(),
253
+ labels: z.array(z.enum(['base_image', 'small_image', 'thumbnail'])),
254
+ asset: z.object({
255
+ id: uuidSchema,
256
+ kind: z.string(),
257
+ url: z.string(),
258
+ }),
259
+ }))
260
+ .optional(),
261
+ /**
262
+ * Feature 002 US5 — composite product payloads. Discriminated by
263
+ * `type`: only one of these is populated at a time. Optional so
264
+ * foundation-era simple/configurable products keep parsing.
265
+ * - `groupedItems`: when type='grouped', children with quantities
266
+ * - `bundleSlots`: when type='bundle', slots with their options
267
+ * - `virtual`: when type='virtual', download asset/url
268
+ */
269
+ groupedItems: z
270
+ .array(z.object({
271
+ id: uuidSchema,
272
+ position: z.number().int().nonnegative(),
273
+ quantity: z.number().int().positive(),
274
+ product: z.object({
275
+ id: uuidSchema,
276
+ sku: z.string(),
277
+ slug: z.string(),
278
+ name: z.string(),
279
+ primaryAssetUrl: z.string().nullable(),
280
+ price: moneySchema.nullable(),
281
+ }),
282
+ }))
283
+ .optional(),
284
+ bundleSlots: z
285
+ .array(z.object({
286
+ id: uuidSchema,
287
+ name: multilingualStringSchema,
288
+ minQuantity: z.number().int().nonnegative(),
289
+ maxQuantity: z.number().int().positive(),
290
+ position: z.number().int().nonnegative(),
291
+ options: z.array(z.object({
292
+ id: uuidSchema,
293
+ defaultQuantity: z.number().int().positive(),
294
+ position: z.number().int().nonnegative(),
295
+ product: z.object({
296
+ id: uuidSchema,
297
+ sku: z.string(),
298
+ slug: z.string(),
299
+ name: z.string(),
300
+ primaryAssetUrl: z.string().nullable(),
301
+ price: moneySchema.nullable(),
302
+ }),
303
+ })),
304
+ }))
305
+ .optional(),
306
+ virtual: z
307
+ .object({
308
+ downloadAssetId: uuidSchema.nullable(),
309
+ downloadUrl: z.string().nullable(),
310
+ })
311
+ .optional(),
312
+ /**
313
+ * Feature 002 US4 — pre-grouped Product Links surfaced on the PDP.
314
+ * Optional for the same backwards-compat reason as the other US3/US4
315
+ * fields. Inactive targets and channel-restricted ones are pre-filtered
316
+ * by `ProductLinkService.listForStorefront`.
317
+ */
318
+ links: z
319
+ .object({
320
+ related: z.array(productLinkSummarySchemaInline),
321
+ upSell: z.array(productLinkSummarySchemaInline),
322
+ crossSell: z.array(productLinkSummarySchemaInline),
323
+ })
324
+ .optional(),
325
+ /**
326
+ * Feature 002 US3 — product attachments with their type + Asset.
327
+ * Optional like the rest.
328
+ */
329
+ attachments: z
330
+ .array(z.object({
331
+ id: uuidSchema,
332
+ position: z.number().int().nonnegative(),
333
+ name: z.string(),
334
+ description: z.string().nullable(),
335
+ type: z.object({
336
+ id: uuidSchema,
337
+ code: z.string(),
338
+ name: multilingualStringSchema,
339
+ }),
340
+ asset: z.object({
341
+ id: uuidSchema,
342
+ kind: z.string(),
343
+ url: z.string(),
344
+ filename: z.string(),
345
+ sizeBytes: z.number(),
346
+ mimeType: z.string(),
347
+ }),
348
+ }))
349
+ .optional(),
350
+ /**
351
+ * Feature 012 / FR-030 — every attribute that meets BOTH conditions:
352
+ * 1. attribute.isVisibleOnProductPage === true
353
+ * 2. product.attributeValues[attribute.key] is non-null + non-empty
354
+ * For select / enum / multiselect types, `valueRendered` is the
355
+ * resolved per-locale option label (with fallback to labelDefault).
356
+ * For other types it's a formatted string ('123.45', 'Yes', etc.).
357
+ * Optional for backwards-compat with foundation-era cached responses.
358
+ */
359
+ visibleAttributes: z
360
+ .array(z.object({
361
+ key: z.string(),
362
+ label: z.string(),
363
+ valueType: attributeValueTypeSchema,
364
+ valueRendered: z.string(),
365
+ }))
366
+ .optional(),
367
+ /**
368
+ * Feature 043 — named packaging units (e.g. "Paleta" = 480 pieces) the
369
+ * buyer can order by. Present only for eligible product types
370
+ * (simple / configurable); omitted or empty otherwise.
371
+ */
372
+ packagingUnits: z
373
+ .array(z.object({
374
+ id: uuidSchema,
375
+ name: z.string(),
376
+ baseQuantity: z.number().int().positive(),
377
+ position: z.number().int().nonnegative(),
378
+ isDefault: z.boolean(),
379
+ }))
380
+ .optional(),
381
+ /**
382
+ * Feature 062 — external namespace only: the bound Organization's resolved
383
+ * quantity-bracket price ladder. Never present on the public surface nor
384
+ * for unbound api-key callers.
385
+ */
386
+ priceTiers: z.array(productPriceTierSchema).optional(),
387
+ });
388
+ // --- Feature 062 — external bulk pricing (`POST /api/v1/external/catalog/prices`) ---
389
+ export const catalogBulkPriceRequestSchema = z.object({
390
+ lines: z
391
+ .array(z.object({
392
+ sku: z.string().min(1),
393
+ quantity: z.number().int().min(1),
394
+ }))
395
+ .min(1)
396
+ .max(200),
397
+ });
398
+ export const catalogBulkPriceMissReasonSchema = z.enum([
399
+ 'sku_not_in_assortment',
400
+ 'price_unavailable',
401
+ ]);
402
+ /**
403
+ * Per-line result, order-preserving. Misses are data, not errors — the
404
+ * partner needs a total answer for a basket. `amount` is the exact decimal
405
+ * string the pricing resolver charges the bound org on the bound channel at
406
+ * that quantity (SC-001 parity with cart pricing).
407
+ */
408
+ export const catalogBulkPriceLineSchema = z.union([
409
+ z.object({
410
+ sku: z.string(),
411
+ quantity: z.number().int(),
412
+ price: z.object({
413
+ amount: z.string(),
414
+ currency: z.string().length(3),
415
+ isSale: z.boolean(),
416
+ bracketStartQuantity: z.number().int(),
417
+ priceListId: uuidSchema,
418
+ }),
419
+ }),
420
+ z.object({
421
+ sku: z.string(),
422
+ quantity: z.number().int(),
423
+ price: z.null(),
424
+ reason: catalogBulkPriceMissReasonSchema,
425
+ }),
426
+ ]);
427
+ export const catalogBulkPriceResponseSchema = z.object({
428
+ data: z.array(catalogBulkPriceLineSchema),
429
+ });
430
+ export const categoryNodeSchema = z.lazy(() => z.object({
431
+ id: uuidSchema,
432
+ name: z.string(),
433
+ slug: z.string(),
434
+ sortOrder: z.number().int(),
435
+ productCount: z.number().int().nonnegative(),
436
+ children: z.array(categoryNodeSchema),
437
+ }));
438
+ // --- Filter definitions (for storefront filter panel) -----------------------
439
+ export const filterOptionSchema = z.object({
440
+ value: z.string(),
441
+ label: z.string(),
442
+ count: z.number().int().nonnegative(),
443
+ });
444
+ export const filterRangeSchema = z.object({
445
+ min: z.number(),
446
+ max: z.number(),
447
+ });
448
+ export const filterDefinitionSchema = z.object({
449
+ attributeKey: z.string(),
450
+ label: z.string(),
451
+ valueType: attributeValueTypeSchema,
452
+ options: z.array(filterOptionSchema).optional(),
453
+ range: filterRangeSchema.optional(),
454
+ /**
455
+ * Feature 012 / FR-027 — ascending sort order on the storefront
456
+ * filter sidebar. Ties are broken alphabetically by `label`. The
457
+ * service pre-sorts the response so consumers don't need to.
458
+ */
459
+ filterPosition: z.number().int().min(0).default(0),
460
+ });
461
+ // --- Admin write-surface requests -------------------------------------------
462
+ // Base shape (no cross-field refine) so updateProductRequestSchema can
463
+ // `.partial()` it. The cross-field rule for virtual download fields is
464
+ // applied as a separate refine on the create variant below.
465
+ const baseProductRequestObject = z.object({
466
+ sku: z.string().min(1).max(255),
467
+ type: productTypeSchema,
468
+ name: multilingualStringSchema,
469
+ description: multilingualStringSchema,
470
+ categoryIds: z.array(uuidSchema),
471
+ attributeValues: z.record(z.string(), z.unknown()),
472
+ stockMode: stockModeSchema.optional(),
473
+ visibility: productVisibilitySchema,
474
+ // Feature 022 — accepted by the single-product PATCH and the bulk
475
+ // update endpoint. Cross-field rule on `archivedAt` is enforced in
476
+ // the service layer (CatalogAdminService.updateProduct).
477
+ // Feature 032 — `archived` write alias → `inactive`.
478
+ status: productStatusWriteSchema.optional(),
479
+ allowedOrganizationIds: z.array(uuidSchema).optional(),
480
+ assetIds: z.array(uuidSchema).optional(),
481
+ initialStock: z.number().int().nonnegative().optional(),
482
+ /**
483
+ * Feature 002 — Attribute Set the Product is wired to. Optional in the
484
+ * request: when omitted, the system Default Set is used.
485
+ */
486
+ attributeSetId: uuidSchema.optional(),
487
+ /**
488
+ * Feature 002 — virtual product download fields (data-model.md §1.1).
489
+ * Exactly one MUST be set when type='virtual'; both MUST be null on
490
+ * any other type. Refine below enforces the cross-field rule on the
491
+ * create-side; updates land it via service-layer guard (T047).
492
+ */
493
+ downloadAssetId: uuidSchema.nullable().optional(),
494
+ downloadUrl: z.string().url().max(2048).nullable().optional(),
495
+ /**
496
+ * Feature 010 — per-product stock-management flags.
497
+ * `manageStock=false` ⇒ storefront treats the product as always available.
498
+ * `backorderEnabled=true` ⇒ zero-stock checkout is accepted with the
499
+ * resulting allocation flagged `is_backorder = true`.
500
+ * `lowStockThreshold` is the cumulative on-hand at-or-below which an
501
+ * email alert fires. `fulfilmentStrategy` overrides the global strategy
502
+ * for this product; `fulfilmentStrategyWarehouseOrder` carries the walk
503
+ * order used when the strategy is `defined_order`.
504
+ */
505
+ manageStock: z.boolean().optional(),
506
+ backorderEnabled: z.boolean().optional(),
507
+ lowStockThreshold: z.number().int().nonnegative().nullable().optional(),
508
+ /**
509
+ * Determines how `lowStockThreshold` is interpreted.
510
+ * - `'cumulative'`: one threshold against the cumulative on-hand.
511
+ * - `'per_warehouse'`: per-(product, warehouse) thresholds maintained
512
+ * via the inventory admin surface; the value of `lowStockThreshold`
513
+ * is then unused.
514
+ */
515
+ lowStockThresholdMode: z.enum(['cumulative', 'per_warehouse']).optional(),
516
+ fulfilmentStrategy: z
517
+ .enum(['any', 'default_first', 'lowest_stock_first', 'highest_stock_first', 'defined_order'])
518
+ .nullable()
519
+ .optional(),
520
+ fulfilmentStrategyWarehouseOrder: z.array(uuidSchema).nullable().optional(),
521
+ });
522
+ export const createProductRequestSchema = baseProductRequestObject.refine((v) => {
523
+ const hasAsset = v.downloadAssetId != null;
524
+ const hasUrl = v.downloadUrl != null;
525
+ if (v.type === 'virtual')
526
+ return hasAsset !== hasUrl; // exactly one of
527
+ return !hasAsset && !hasUrl; // non-virtual must have neither
528
+ }, {
529
+ message: 'virtual products require exactly one of `downloadAssetId` or `downloadUrl`; non-virtual products MUST have neither.',
530
+ path: ['downloadUrl'],
531
+ });
532
+ // `type` is immutable after creation (409 FIELD_IMMUTABLE if sent).
533
+ // Feature 012 / FR-016 — `sku` is now editable. Collision with another
534
+ // product's SKU is refused with 400 sku_in_use; snapshot tables on
535
+ // orders / quote-requests / invoices keep displaying the SKU value
536
+ // frozen at snapshot time.
537
+ export const updateProductRequestSchema = baseProductRequestObject
538
+ .partial()
539
+ .omit({ type: true });
540
+ // Admin batch-by-id lookup. The body carries the id set (deduped server-
541
+ // side) plus optional pagination so callers can stream large lookups
542
+ // across multiple requests. Hard cap of 500 ids per call mirrors the
543
+ // service-layer `pageSize` ceiling and keeps a single request bounded.
544
+ export const batchByIdProductsRequestSchema = z.object({
545
+ ids: z.array(z.string().uuid()).max(500),
546
+ page: z.number().int().min(0).optional(),
547
+ pageSize: z.number().int().min(1).max(500).optional(),
548
+ });
549
+ // ---------------------------------------------------------------------------
550
+ // Feature 033 — Resolve product IDs by list filters (collection selection)
551
+ // ---------------------------------------------------------------------------
552
+ export const MAX_RESOLVE_SELECTION_SIZE = 10_000;
553
+ export const resolveProductIdsRequestSchema = z.object({
554
+ status: productStatusSchema.optional(),
555
+ type: productTypeSchema.optional(),
556
+ q: z.string().trim().min(1).optional(),
557
+ includeArchived: z.boolean().optional(),
558
+ });
559
+ export const resolveProductIdsResponseSchema = z.object({
560
+ data: z.object({
561
+ productIds: z.array(uuidSchema),
562
+ total: z.number().int().nonnegative(),
563
+ }),
564
+ });
565
+ // ---------------------------------------------------------------------------
566
+ // Feature 022 — Products Bulk Edit
567
+ // ---------------------------------------------------------------------------
568
+ const bulkEditModeSchema = z.enum(['add', 'replace']);
569
+ // Categories also support `remove` (subtract the given categories from each
570
+ // product's current memberships); sales channels keep the two-mode enum.
571
+ const bulkEditCategoryModeSchema = z.enum(['add', 'replace', 'remove']);
572
+ const bulkUpdateFieldsObject = z.object({
573
+ status: productStatusWriteSchema.optional(),
574
+ visibility: productVisibilitySchema.optional(),
575
+ salesChannels: z
576
+ .object({
577
+ mode: bulkEditModeSchema,
578
+ channelIds: z.array(uuidSchema),
579
+ })
580
+ .optional(),
581
+ categories: z
582
+ .object({
583
+ mode: bulkEditCategoryModeSchema,
584
+ categoryIds: z.array(uuidSchema),
585
+ })
586
+ .optional(),
587
+ // Feature 022 — assign (or clear, with null) the Attribute Set on every
588
+ // selected product.
589
+ attributeSetId: uuidSchema.nullable().optional(),
590
+ attributeValues: z.record(z.string(), z.unknown()).optional(),
591
+ });
592
+ export const bulkUpdateProductsRequestSchema = z.object({
593
+ // The non-empty constraint is part of the schema (a Zod-level
594
+ // validation failure). The 200-item soft cap is enforced inside the
595
+ // handler so the response carries the dedicated `BULK_TOO_LARGE`
596
+ // code along with `details.maxBatchSize` / `details.recommendedSplitInto`.
597
+ // An upper hard limit at 10_000 prevents pathological payloads from
598
+ // ever reaching the cap check.
599
+ productIds: z.array(uuidSchema).min(1).max(10_000),
600
+ fields: bulkUpdateFieldsObject.refine((f) => f.status !== undefined ||
601
+ f.visibility !== undefined ||
602
+ f.salesChannels !== undefined ||
603
+ f.categories !== undefined ||
604
+ f.attributeSetId !== undefined ||
605
+ (f.attributeValues !== undefined && Object.keys(f.attributeValues).length > 0), { message: 'at least one field must be present' }),
606
+ });
607
+ export const bulkUpdateProductResultSchema = z.object({
608
+ productId: z.string().uuid(),
609
+ status: z.enum(['succeeded', 'skipped', 'failed']),
610
+ reason: z
611
+ .enum([
612
+ 'attribute_not_in_set',
613
+ 'validation_failed',
614
+ 'permission_denied',
615
+ 'concurrent_modification',
616
+ 'product_not_found',
617
+ ])
618
+ .optional(),
619
+ details: z
620
+ .object({
621
+ code: z.string().optional(),
622
+ message: z.string().optional(),
623
+ attribute: z.string().optional(),
624
+ })
625
+ .optional(),
626
+ changedFields: z.array(z.string()).optional(),
627
+ });
628
+ export const bulkUpdateProductsResponseSchema = z.object({
629
+ data: z.object({
630
+ bulkOperationId: z.string().uuid(),
631
+ summary: z.object({
632
+ succeeded: z.number().int().nonnegative(),
633
+ skipped: z.number().int().nonnegative(),
634
+ failed: z.number().int().nonnegative(),
635
+ total: z.number().int().nonnegative(),
636
+ }),
637
+ results: z.array(bulkUpdateProductResultSchema),
638
+ }),
639
+ });
640
+ // ---------------------------------------------------------------------------
641
+ // Queued bulk operations
642
+ //
643
+ // Selections above the synchronous threshold are not applied inline.
644
+ // Instead the handler enqueues a `BulkOperation` and returns the ack
645
+ // below; the work is finished off-thread by the in-process sweeper, and
646
+ // progress is observable through the bulk-operations list endpoints.
647
+ // ---------------------------------------------------------------------------
648
+ export const bulkUpdateQueuedResponseSchema = z.object({
649
+ data: z.object({
650
+ queued: z.literal(true),
651
+ bulkOperationId: z.string().uuid(),
652
+ total: z.number().int().nonnegative(),
653
+ }),
654
+ });
655
+ export const bulkOperationStatusSchema = z.enum([
656
+ 'pending',
657
+ 'running',
658
+ 'completed',
659
+ 'failed',
660
+ ]);
661
+ /**
662
+ * One timestamped lifecycle event in a bulk operation's log trail, surfaced
663
+ * in the bulk-operations detail view next to the per-element results.
664
+ */
665
+ export const bulkOperationLogEntrySchema = z.object({
666
+ ts: z.string(),
667
+ level: z.enum(['info', 'warn', 'error']),
668
+ message: z.string(),
669
+ });
670
+ /**
671
+ * Known bulk-operation kinds. `type` on the record is an open string (the
672
+ * queue is generic), but these are the kinds the platform ships:
673
+ * - `product_bulk_update` — the queued large product bulk-edit (feature 022).
674
+ * - `search_reindex` — a full Meilisearch reindex (the `search:reindex`
675
+ * CLI equivalent), enqueued when an attribute's `searchable` flag changes.
676
+ */
677
+ export const BULK_OPERATION_TYPES = {
678
+ PRODUCT_BULK_UPDATE: 'product_bulk_update',
679
+ SEARCH_REINDEX: 'search_reindex',
680
+ };
681
+ export const bulkOperationSchema = z.object({
682
+ id: z.string().uuid(),
683
+ type: z.string(),
684
+ status: bulkOperationStatusSchema,
685
+ requestedByAdminUserId: z.string().uuid(),
686
+ total: z.number().int().nonnegative(),
687
+ processed: z.number().int().nonnegative(),
688
+ succeeded: z.number().int().nonnegative(),
689
+ skipped: z.number().int().nonnegative(),
690
+ failed: z.number().int().nonnegative(),
691
+ touchedFields: z.array(z.string()),
692
+ results: z.array(bulkUpdateProductResultSchema).nullable(),
693
+ logs: z.array(bulkOperationLogEntrySchema).nullable(),
694
+ error: z.string().nullable(),
695
+ createdAt: z.string(),
696
+ startedAt: z.string().nullable(),
697
+ finishedAt: z.string().nullable(),
698
+ // Feature 054 — undo affordance.
699
+ reversible: z.boolean(),
700
+ undoStatus: z.enum(['none', 'reverted', 'partially_reverted']),
701
+ undoneAt: z.string().nullable(),
702
+ });
703
+ /** Response of POST /admin/catalog/bulk-operations/:id/undo. */
704
+ export const bulkOperationUndoResponseSchema = z.object({
705
+ data: z.object({
706
+ undoStatus: z.enum(['none', 'reverted', 'partially_reverted']),
707
+ reverted: z.number().int().nonnegative(),
708
+ conflicts: z.array(z.object({ recordId: z.string(), reason: z.string() })),
709
+ }),
710
+ });
711
+ export const bulkOperationsListResponseSchema = z.object({
712
+ data: z.array(bulkOperationSchema),
713
+ pagination: z.object({
714
+ total: z.number().int().nonnegative(),
715
+ limit: z.number().int().positive(),
716
+ offset: z.number().int().nonnegative(),
717
+ }),
718
+ });
719
+ export const bulkOperationResponseSchema = z.object({ data: bulkOperationSchema });
720
+ export const createVariantRequestSchema = z.object({
721
+ sku: z.string().min(1).max(255),
722
+ variantAttributeValues: z.record(z.string(), z.unknown()),
723
+ priceOverride: z.number().finite().optional(),
724
+ stockLevel: z.number().int().nonnegative().optional(),
725
+ });
726
+ export const updateVariantRequestSchema = createVariantRequestSchema
727
+ .partial()
728
+ .omit({ sku: true });
729
+ /**
730
+ * Slider-numeric-kind discriminant (feature 002 T013/T021/T022). The API
731
+ * `type=slider` form needs an extra hint so the service knows whether the
732
+ * underlying DB `valueType` is `number` or `price`.
733
+ */
734
+ export const numericKindSchema = z.enum(['number', 'price']);
735
+ const baseCreateAttributeObject = z.object({
736
+ key: z
737
+ .string()
738
+ .min(1)
739
+ .max(64)
740
+ .regex(/^[a-z][a-z0-9_]*$/, 'must be snake_case, start with a letter'),
741
+ label: multilingualStringSchema,
742
+ /**
743
+ * Feature 002 — preferred API form. When `type` is present it wins over
744
+ * `valueType` (legacy form, kept for backward-compat). Service maps it
745
+ * onto the DB enum + `displayAsSlider` flag (research R-7):
746
+ * input → valueType=string
747
+ * number → valueType=number
748
+ * select → valueType=enum
749
+ * multiselect→ valueType=multiselect (requires enumValues)
750
+ * price → valueType=price
751
+ * slider → valueType=number|price (per numericKind) + displayAsSlider=true
752
+ */
753
+ type: apiAttributeTypeSchema.optional(),
754
+ numericKind: numericKindSchema.optional(),
755
+ /** Legacy form. At least one of `type` or `valueType` MUST be set. */
756
+ valueType: attributeValueTypeSchema.optional(),
757
+ enumValues: z.array(z.string()).optional(),
758
+ isSearchable: z.boolean(),
759
+ isFilterable: z.boolean(),
760
+ isVariantAxis: z.boolean(),
761
+ /**
762
+ * Feature 002 — presentation hint. Honored only when the resolved
763
+ * underlying type is `number`/`price`. Implicitly `true` when
764
+ * `type=slider`. Service rejects with INVALID_DISPLAY_AS_SLIDER if
765
+ * supplied for an incompatible underlying type.
766
+ */
767
+ displayAsSlider: z.boolean().optional(),
768
+ /**
769
+ * Feature 007 — selects whether the attribute appears as a body row on
770
+ * the Compare module's comparison page. Independent of isSearchable /
771
+ * isFilterable. Defaults to false on create when omitted.
772
+ */
773
+ isComparable: z.boolean().optional(),
774
+ /**
775
+ * Feature 012 — fallback label used when the active locale is missing
776
+ * from `label`. Defaults server-side to the en-US label (or the first
777
+ * available label, or the attribute key) when omitted on create.
778
+ */
779
+ labelDefault: z.string().min(1).max(200).optional(),
780
+ /** Feature 012 — enforced at product save time when the attribute is in the assigned set. */
781
+ isRequired: z.boolean().optional(),
782
+ /** Feature 012 — surfaces the attribute in the Promotion Rule criterion picker. */
783
+ isPromoRule: z.boolean().optional(),
784
+ /** Feature 012 — ascending sort order on the storefront filter sidebar. Defaults 0. */
785
+ filterPosition: z.number().int().min(0).max(10000).optional(),
786
+ /** Feature 012 — gates inclusion in the storefront PDP "Parametry produktu" tab. */
787
+ isVisibleOnProductPage: z.boolean().optional(),
788
+ /**
789
+ * Feature 023 — when `true` the attribute may carry per-Sales-Channel
790
+ * overrides. Default `false` (global-only). Independent of
791
+ * `languageScoped`; the two flags compose into one of four effective
792
+ * scopes (`global` / `language` / `channel` / `channel+language`).
793
+ */
794
+ channelScoped: z.boolean().optional(),
795
+ /**
796
+ * Feature 023 — when `true` the attribute's value is keyed by
797
+ * language at every slot. For user-defined attributes the baseline
798
+ * is stored as `Record<lang, value>` in `products.attribute_values`;
799
+ * for the system Name / Description attributes this flag is pinned
800
+ * `true` by SYSTEM_ATTRIBUTE_SCOPES.
801
+ */
802
+ languageScoped: z.boolean().optional(),
803
+ /**
804
+ * Feature 022 (products bulk edit) — makes the attribute available in
805
+ * the Products Bulk Edit dialog's attribute field list. Default false;
806
+ * operators opt each attribute in explicitly.
807
+ */
808
+ massEditable: z.boolean().optional(),
809
+ /** Feature 039 — values participate in Quick Order search. Default false. */
810
+ quickSearchable: z.boolean().optional(),
811
+ /**
812
+ * Feature 012 — rich option list for select / enum / multiselect types.
813
+ * When supplied alongside the legacy `enumValues`, this wins. The
814
+ * service layer creates corresponding `custom_field_options` rows
815
+ * (feature 061; formerly the catalog-owned `attribute_options` table).
816
+ */
817
+ options: z.array(createAttributeOptionRequestSchema).optional(),
818
+ });
819
+ export const createAttributeRequestSchema = baseCreateAttributeObject
820
+ .refine((v) => v.type !== undefined || v.valueType !== undefined, {
821
+ message: 'either `type` (preferred) or `valueType` (legacy) is required',
822
+ path: ['type'],
823
+ })
824
+ .refine((v) => {
825
+ // Select-style attributes need either the legacy `enumValues: string[]`
826
+ // shape OR the new feature-012 `options: AttributeOption[]` shape.
827
+ // The service maps either to `custom_field_options` rows (feature 061).
828
+ const wantsEnum = v.type === 'multiselect' ||
829
+ v.type === 'select' ||
830
+ v.valueType === 'enum' ||
831
+ v.valueType === 'multiselect' ||
832
+ v.valueType === 'select';
833
+ if (!wantsEnum)
834
+ return true;
835
+ const hasLegacy = Array.isArray(v.enumValues) && v.enumValues.length > 0;
836
+ const hasOptions = Array.isArray(v.options) && v.options.length > 0;
837
+ return hasLegacy || hasOptions;
838
+ }, {
839
+ message: 'either enumValues (legacy) or options (feature 012) is required for select-style attributes',
840
+ path: ['options'],
841
+ })
842
+ .refine((v) => (v.type === 'slider' ? v.numericKind !== undefined : true), {
843
+ message: 'numericKind is required when type=slider',
844
+ path: ['numericKind'],
845
+ });
846
+ export const updateAttributeRequestSchema = z
847
+ .object({
848
+ label: multilingualStringSchema.optional(),
849
+ /** Feature 002 — same API form as create. */
850
+ type: apiAttributeTypeSchema.optional(),
851
+ numericKind: numericKindSchema.optional(),
852
+ enumValues: z.array(z.string()).optional(),
853
+ isSearchable: z.boolean().optional(),
854
+ isFilterable: z.boolean().optional(),
855
+ isVariantAxis: z.boolean().optional(),
856
+ displayAsSlider: z.boolean().optional(),
857
+ /** Feature 007 — toggles the Compare-page row for this attribute. */
858
+ isComparable: z.boolean().optional(),
859
+ /** Feature 012 — fallback label used when the active locale is missing from `label`. */
860
+ labelDefault: z.string().min(1).max(200).optional(),
861
+ /** Feature 012 — enforced at product save time when the attribute is in the assigned set. */
862
+ isRequired: z.boolean().optional(),
863
+ /** Feature 012 — surfaces the attribute in the Promotion Rule criterion picker. */
864
+ isPromoRule: z.boolean().optional(),
865
+ /** Feature 012 — ascending sort order on the storefront filter sidebar. */
866
+ filterPosition: z.number().int().min(0).max(10000).optional(),
867
+ /** Feature 012 — gates inclusion in the storefront PDP "Parametry produktu" tab. */
868
+ isVisibleOnProductPage: z.boolean().optional(),
869
+ /** Feature 023 — see `baseCreateAttributeObject.channelScoped`. */
870
+ channelScoped: z.boolean().optional(),
871
+ /** Feature 023 — see `baseCreateAttributeObject.languageScoped`. */
872
+ languageScoped: z.boolean().optional(),
873
+ /** Feature 022 (products bulk edit) — toggles bulk-editability. */
874
+ massEditable: z.boolean().optional(),
875
+ /** Feature 039 — values participate in Quick Order search. */
876
+ quickSearchable: z.boolean().optional(),
877
+ })
878
+ .strict()
879
+ .refine((v) => (v.type === 'slider' ? v.numericKind !== undefined : true), {
880
+ message: 'numericKind is required when type=slider',
881
+ path: ['numericKind'],
882
+ });
883
+ /**
884
+ * Feature 061 — admin attribute payload returned by
885
+ * `GET/POST/PATCH /api/v1/admin/catalog/attributes*`. Formalizes the shape
886
+ * `serializeAdminAttribute` has emitted since features 002/012/022/023/039 and
887
+ * adds the single additive field `customFieldDefinitionId` — the backing
888
+ * product-host Custom Field definition (contracts/attribute-admin-api.md).
889
+ * All request schemas above are byte-compatible and unchanged (FR-007, SC-003).
890
+ */
891
+ export const adminAttributeResponseSchema = z.object({
892
+ id: uuidSchema,
893
+ key: z.string(),
894
+ label: multilingualStringSchema,
895
+ labelDefault: z.string(),
896
+ /** API-form type (feature 002) — emitted alongside the legacy `valueType`. */
897
+ type: apiAttributeTypeSchema,
898
+ /** Present only when `type === 'slider'`. */
899
+ numericKind: numericKindSchema.optional(),
900
+ valueType: attributeValueTypeSchema,
901
+ /** Legacy projection of the option values; `null` when not fetched or absent. */
902
+ enumValues: z.array(z.string()).nullable(),
903
+ isSearchable: z.boolean(),
904
+ isFilterable: z.boolean(),
905
+ isVariantAxis: z.boolean(),
906
+ displayAsSlider: z.boolean(),
907
+ isComparable: z.boolean(),
908
+ isRequired: z.boolean(),
909
+ isPromoRule: z.boolean(),
910
+ filterPosition: z.number().int(),
911
+ isVisibleOnProductPage: z.boolean(),
912
+ massEditable: z.boolean(),
913
+ quickSearchable: z.boolean(),
914
+ /** Feature 061 (additive) — id of the backing product-host Custom Field definition. */
915
+ customFieldDefinitionId: uuidSchema,
916
+ createdAt: isoDateTimeSchema,
917
+ updatedAt: isoDateTimeSchema,
918
+ });
919
+ export const createCategoryRequestSchema = z.object({
920
+ parentCategoryId: uuidSchema.nullable().optional(),
921
+ name: multilingualStringSchema,
922
+ slug: z
923
+ .string()
924
+ .min(1)
925
+ .max(160)
926
+ .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, 'must be kebab-case'),
927
+ sortOrder: z.number().int().optional(),
928
+ /**
929
+ * Feature 068 — activation switch. Omitted means active: a category created
930
+ * by an administrator is visible unless they say otherwise. Integrations
931
+ * that discover categories (e.g. the Ergonode importer) pass `false` so a
932
+ * first import never exposes a source hierarchy to customers.
933
+ */
934
+ isActive: z.boolean().optional(),
935
+ });
936
+ export const updateCategoryRequestSchema = z
937
+ .object({
938
+ parentCategoryId: uuidSchema.nullable().optional(),
939
+ name: multilingualStringSchema.optional(),
940
+ slug: z
941
+ .string()
942
+ .min(1)
943
+ .max(160)
944
+ .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, 'must be kebab-case')
945
+ .optional(),
946
+ sortOrder: z.number().int().optional(),
947
+ /**
948
+ * Feature 068 — activation switch. `false` hides the category from every
949
+ * customer-facing read; the admin tree keeps listing it so it can be
950
+ * re-enabled.
951
+ */
952
+ isActive: z.boolean().optional(),
953
+ /** Feature 013 / US5 — Library Asset rendered as the category's main image. */
954
+ mainImageAssetId: uuidSchema.nullable().optional(),
955
+ /** Feature 055 — custom-field values for this category (validated on write). */
956
+ customFieldValues: z.record(z.string(), z.unknown()).optional(),
957
+ })
958
+ .strict();
959
+ // --- Storefront list/query ---------------------------------------------------
960
+ /**
961
+ * The orderings the storefront listing accepts.
962
+ *
963
+ * Feature 086 adds `price` / `-price`, following the convention the two `name`
964
+ * members set: the bare member ascends, the `-` prefix descends. "Price" is the
965
+ * **viewer's own** resolved unit price at quantity 1 — the figure the card
966
+ * renders — never a stored base price, a channel price or anything a search
967
+ * index carries.
968
+ */
969
+ export const productListSortSchema = z.enum([
970
+ 'relevance',
971
+ '-createdAt',
972
+ 'name',
973
+ '-name',
974
+ 'price',
975
+ '-price',
976
+ ]);
977
+ /** The two orderings feature 086 added, as a narrowing a consumer can reuse. */
978
+ export function isPriceSort(sort) {
979
+ return sort === 'price' || sort === '-price';
980
+ }
981
+ export const productListQuerySchema = z
982
+ .object({
983
+ q: z.string().optional(),
984
+ limit: z.coerce.number().int().positive().max(200).default(50),
985
+ cursor: z.string().optional(),
986
+ sort: productListSortSchema.optional(),
987
+ changedSince: isoDateTimeSchema.optional(),
988
+ /**
989
+ * Feature 086 — inclusive bounds on the **viewer's own** resolved unit
990
+ * price, in the currency the response quotes. Both optional and
991
+ * independent, and both compose with every ordering rather than only with
992
+ * the two price ones (FR-009).
993
+ *
994
+ * Numbers on the wire, decimal strings by the time they reach the pricing
995
+ * relation: the comparison happens in `numeric`, never in a float.
996
+ */
997
+ minPrice: z.coerce.number().nonnegative().finite().optional(),
998
+ maxPrice: z.coerce.number().nonnegative().finite().optional(),
999
+ })
1000
+ .refine((v) => v.minPrice === undefined || v.maxPrice === undefined || v.minPrice <= v.maxPrice, {
1001
+ // FR-008 — a minimum above a maximum is a 400, not an empty page. An
1002
+ // empty page for a contradictory range is indistinguishable from an empty
1003
+ // page for a genuine one, and a buyer who typed the bounds the wrong way
1004
+ // round should be told which two they were.
1005
+ message: 'minPrice must not exceed maxPrice',
1006
+ path: ['minPrice'],
1007
+ });
1008
+ /**
1009
+ * What the listing surface may offer this viewer on this page — feature 086 /
1010
+ * FR-023.
1011
+ *
1012
+ * It rides on the listing response because the storefront has to decide whether
1013
+ * to *render* the price controls, and the answer depends on the viewer and the
1014
+ * channel: a non-public channel publishes no prices, and
1015
+ * `pricing.unauthenticated_display_mode = none` hides them until login. A
1016
+ * storefront that guessed would guess wrong on exactly the deployments that
1017
+ * care, and a control that offers an ordering the API refuses is a worse defect
1018
+ * than no control.
1019
+ *
1020
+ * It is **not** on the filter-definitions endpoint, which is anonymous and
1021
+ * shared (FR-010): a per-viewer answer may not travel on a response whose cache
1022
+ * key omits the viewer.
1023
+ */
1024
+ export const productListCapabilitiesSchema = z.object({
1025
+ /** May this page be ordered by price, and narrowed to a price range? */
1026
+ priceOrdering: z.boolean(),
1027
+ });
1028
+ // --- Notify-when-available --------------------------------------------------
1029
+ export const notifyWhenAvailableRequestSchema = z.object({
1030
+ variantId: uuidSchema.optional(),
1031
+ });
1032
+ export const notifyWhenAvailableResponseSchema = z.object({
1033
+ subscriptionId: uuidSchema,
1034
+ requestedAt: isoDateTimeSchema,
1035
+ });
1036
+ // --- Feature 002 — Attribute Sets -------------------------------------------
1037
+ // See specs/002-catalog-module/contracts/catalog-002.contract.md.
1038
+ const attributeSetCodeSchema = z
1039
+ .string()
1040
+ .min(1)
1041
+ .max(64)
1042
+ .regex(/^[a-z0-9_]+$/, 'must be snake_case');
1043
+ export const attributeSetSchema = z.object({
1044
+ id: uuidSchema,
1045
+ code: attributeSetCodeSchema,
1046
+ name: multilingualStringSchema,
1047
+ description: multilingualStringSchema.nullable(),
1048
+ isSystem: z.boolean(),
1049
+ attributeCount: z.number().int().nonnegative(),
1050
+ productCount: z.number().int().nonnegative(),
1051
+ createdAt: isoDateTimeSchema,
1052
+ updatedAt: isoDateTimeSchema,
1053
+ });
1054
+ export const attributeSetAssignedAttributeSchema = z.object({
1055
+ id: uuidSchema,
1056
+ key: z.string(),
1057
+ label: multilingualStringSchema,
1058
+ valueType: attributeValueTypeSchema,
1059
+ position: z.number().int().nonnegative(),
1060
+ /**
1061
+ * Feature 023 — the attribute's value is keyed by language, so the product
1062
+ * editor renders it (and anything scoped to it, such as feature 068's
1063
+ * overwrite protection) per language rather than once for the attribute.
1064
+ */
1065
+ languageScoped: z.boolean(),
1066
+ });
1067
+ export const attributeSetDetailSchema = attributeSetSchema.extend({
1068
+ attributes: z.array(attributeSetAssignedAttributeSchema),
1069
+ });
1070
+ export const createAttributeSetRequestSchema = z
1071
+ .object({
1072
+ code: attributeSetCodeSchema,
1073
+ name: multilingualStringSchema,
1074
+ description: multilingualStringSchema.optional(),
1075
+ attributeIds: z.array(uuidSchema).optional(),
1076
+ })
1077
+ .strict();
1078
+ export const updateAttributeSetRequestSchema = z
1079
+ .object({
1080
+ code: attributeSetCodeSchema.optional(),
1081
+ name: multilingualStringSchema.optional(),
1082
+ description: multilingualStringSchema.nullable().optional(),
1083
+ })
1084
+ .strict();
1085
+ export const assignAttributesRequestSchema = z
1086
+ .object({
1087
+ assignments: z
1088
+ .array(z.object({
1089
+ attributeId: uuidSchema,
1090
+ position: z.number().int().nonnegative().optional(),
1091
+ }))
1092
+ .min(1),
1093
+ })
1094
+ .strict();
1095
+ // --- Feature 002 — Gallery (US3) --------------------------------------------
1096
+ export const galleryLabelSchema = z.enum(['base_image', 'small_image', 'thumbnail']);
1097
+ export const galleryItemSchema = z.object({
1098
+ id: uuidSchema,
1099
+ productId: uuidSchema,
1100
+ assetId: uuidSchema,
1101
+ position: z.number().int().nonnegative(),
1102
+ labels: z.array(galleryLabelSchema),
1103
+ createdAt: isoDateTimeSchema,
1104
+ updatedAt: isoDateTimeSchema,
1105
+ });
1106
+ export const createGalleryItemRequestSchema = z
1107
+ .object({
1108
+ assetId: uuidSchema,
1109
+ position: z.number().int().nonnegative().optional(),
1110
+ labels: z.array(galleryLabelSchema).optional(),
1111
+ })
1112
+ .strict();
1113
+ export const updateGalleryItemRequestSchema = z
1114
+ .object({
1115
+ position: z.number().int().nonnegative().optional(),
1116
+ labels: z.array(galleryLabelSchema).optional(),
1117
+ })
1118
+ .strict();
1119
+ export const reorderGalleryRequestSchema = z
1120
+ .object({
1121
+ orderedGalleryItemIds: z.array(uuidSchema).min(1),
1122
+ })
1123
+ .strict();
1124
+ // --- Feature 002 — Attachments (US3) ----------------------------------------
1125
+ export const attachmentTypeSchema = z.object({
1126
+ id: uuidSchema,
1127
+ code: z
1128
+ .string()
1129
+ .min(1)
1130
+ .max(64)
1131
+ .regex(/^[a-z0-9_]+$/, 'must be snake_case'),
1132
+ name: multilingualStringSchema,
1133
+ position: z.number().int().nonnegative(),
1134
+ usageCount: z.number().int().nonnegative(),
1135
+ createdAt: isoDateTimeSchema,
1136
+ updatedAt: isoDateTimeSchema,
1137
+ });
1138
+ export const createAttachmentTypeRequestSchema = z
1139
+ .object({
1140
+ code: z
1141
+ .string()
1142
+ .min(1)
1143
+ .max(64)
1144
+ .regex(/^[a-z0-9_]+$/, 'must be snake_case'),
1145
+ name: multilingualStringSchema,
1146
+ position: z.number().int().nonnegative().optional(),
1147
+ })
1148
+ .strict();
1149
+ export const updateAttachmentTypeRequestSchema = z
1150
+ .object({
1151
+ code: z
1152
+ .string()
1153
+ .min(1)
1154
+ .max(64)
1155
+ .regex(/^[a-z0-9_]+$/, 'must be snake_case')
1156
+ .optional(),
1157
+ name: multilingualStringSchema.optional(),
1158
+ position: z.number().int().nonnegative().optional(),
1159
+ })
1160
+ .strict();
1161
+ export const productAttachmentSchema = z.object({
1162
+ id: uuidSchema,
1163
+ productId: uuidSchema,
1164
+ assetId: uuidSchema,
1165
+ attachmentTypeId: uuidSchema,
1166
+ name: z.string().min(1).max(160),
1167
+ description: z.string().nullable(),
1168
+ position: z.number().int().nonnegative(),
1169
+ createdAt: isoDateTimeSchema,
1170
+ updatedAt: isoDateTimeSchema,
1171
+ });
1172
+ export const createAttachmentRequestSchema = z
1173
+ .object({
1174
+ assetId: uuidSchema,
1175
+ attachmentTypeId: uuidSchema,
1176
+ name: z.string().min(1).max(160),
1177
+ description: z.string().nullable().optional(),
1178
+ position: z.number().int().nonnegative().optional(),
1179
+ })
1180
+ .strict();
1181
+ export const updateAttachmentRequestSchema = z
1182
+ .object({
1183
+ attachmentTypeId: uuidSchema.optional(),
1184
+ name: z.string().min(1).max(160).optional(),
1185
+ description: z.string().nullable().optional(),
1186
+ position: z.number().int().nonnegative().optional(),
1187
+ })
1188
+ .strict();
1189
+ // --- Packaging Units (Feature 043) ------------------------------------------
1190
+ /**
1191
+ * A named ordering unit attached to a product (e.g. "Paleta" = 480 pieces).
1192
+ * Managed in the Inventory section of the admin product card; surfaced on the
1193
+ * storefront product page so buyers can order by the unit.
1194
+ */
1195
+ export const packagingUnitSchema = z.object({
1196
+ id: uuidSchema,
1197
+ productId: uuidSchema,
1198
+ name: z.string().min(1).max(160),
1199
+ baseQuantity: z.number().int().positive(),
1200
+ position: z.number().int().nonnegative(),
1201
+ isDefault: z.boolean(),
1202
+ createdAt: z.string(),
1203
+ updatedAt: z.string(),
1204
+ });
1205
+ export const createPackagingUnitRequestSchema = z
1206
+ .object({
1207
+ name: z.string().trim().min(1).max(160),
1208
+ baseQuantity: z.number().int().positive(),
1209
+ isDefault: z.boolean().optional(),
1210
+ position: z.number().int().nonnegative().optional(),
1211
+ })
1212
+ .strict();
1213
+ export const updatePackagingUnitRequestSchema = z
1214
+ .object({
1215
+ name: z.string().trim().min(1).max(160).optional(),
1216
+ baseQuantity: z.number().int().positive().optional(),
1217
+ isDefault: z.boolean().optional(),
1218
+ position: z.number().int().nonnegative().optional(),
1219
+ })
1220
+ .strict();
1221
+ export const reorderPackagingUnitsRequestSchema = z
1222
+ .object({
1223
+ orderedIds: z.array(uuidSchema).min(1),
1224
+ })
1225
+ .strict();
1226
+ // --- Product Links (Feature 002 US4) ----------------------------------------
1227
+ export const productLinkKindSchema = z.enum(['related', 'up_sell', 'cross_sell']);
1228
+ export const productLinkSchema = z.object({
1229
+ id: uuidSchema,
1230
+ sourceProductId: uuidSchema,
1231
+ targetProductId: uuidSchema,
1232
+ kind: productLinkKindSchema,
1233
+ position: z.number().int().nonnegative(),
1234
+ });
1235
+ /**
1236
+ * Bulk-create payload (T104). One transaction, all-or-nothing —
1237
+ * partial inserts on a duplicate or self-link MUST roll back the
1238
+ * entire batch (FR + research). Each entry pins its kind so admins
1239
+ * can submit a mixed batch in a single round trip.
1240
+ */
1241
+ export const bulkCreateLinksRequestSchema = z.object({
1242
+ links: z
1243
+ .array(z.object({
1244
+ targetProductId: uuidSchema,
1245
+ kind: productLinkKindSchema,
1246
+ position: z.number().int().nonnegative().optional(),
1247
+ }))
1248
+ .min(1),
1249
+ });
1250
+ export const reorderLinksRequestSchema = z.object({
1251
+ /** Ordered list of link ids — index becomes `position` per (source, kind). */
1252
+ linkIds: z.array(uuidSchema).min(1),
1253
+ });
1254
+ /**
1255
+ * Storefront-shape link entry (T107) — the listing carries enough Product
1256
+ * fields for a card render without a follow-up fetch. Inactive targets
1257
+ * are filtered out by `listForStorefront` so the storefront never sees
1258
+ * `status='archived'` rows.
1259
+ */
1260
+ // --- Composite products (Feature 002 US5) -----------------------------------
1261
+ export const groupedItemSchema = z.object({
1262
+ id: uuidSchema,
1263
+ parentProductId: uuidSchema,
1264
+ childProductId: uuidSchema,
1265
+ quantity: z.number().int().positive(),
1266
+ position: z.number().int().nonnegative(),
1267
+ });
1268
+ export const createGroupedItemRequestSchema = z
1269
+ .object({
1270
+ childProductId: uuidSchema,
1271
+ quantity: z.number().int().positive(),
1272
+ position: z.number().int().nonnegative().optional(),
1273
+ })
1274
+ .strict();
1275
+ export const updateGroupedItemRequestSchema = z
1276
+ .object({
1277
+ quantity: z.number().int().positive().optional(),
1278
+ position: z.number().int().nonnegative().optional(),
1279
+ })
1280
+ .strict();
1281
+ export const bundleSlotOptionSchema = z.object({
1282
+ id: uuidSchema,
1283
+ slotId: uuidSchema,
1284
+ optionProductId: uuidSchema,
1285
+ defaultQuantity: z.number().int().positive(),
1286
+ position: z.number().int().nonnegative(),
1287
+ });
1288
+ export const bundleSlotSchema = z.object({
1289
+ id: uuidSchema,
1290
+ parentProductId: uuidSchema,
1291
+ name: multilingualStringSchema,
1292
+ minQuantity: z.number().int().nonnegative(),
1293
+ maxQuantity: z.number().int().positive(),
1294
+ position: z.number().int().nonnegative(),
1295
+ options: z.array(bundleSlotOptionSchema),
1296
+ });
1297
+ export const createBundleSlotRequestSchema = z
1298
+ .object({
1299
+ name: multilingualStringSchema,
1300
+ minQuantity: z.number().int().nonnegative().optional(),
1301
+ maxQuantity: z.number().int().positive(),
1302
+ position: z.number().int().nonnegative().optional(),
1303
+ })
1304
+ .strict()
1305
+ .refine((v) => (v.minQuantity ?? 0) <= v.maxQuantity, {
1306
+ message: 'minQuantity must be <= maxQuantity',
1307
+ path: ['minQuantity'],
1308
+ });
1309
+ export const updateBundleSlotRequestSchema = z
1310
+ .object({
1311
+ name: multilingualStringSchema.optional(),
1312
+ minQuantity: z.number().int().nonnegative().optional(),
1313
+ maxQuantity: z.number().int().positive().optional(),
1314
+ position: z.number().int().nonnegative().optional(),
1315
+ })
1316
+ .strict();
1317
+ export const createBundleSlotOptionRequestSchema = z
1318
+ .object({
1319
+ optionProductId: uuidSchema,
1320
+ defaultQuantity: z.number().int().positive().optional(),
1321
+ position: z.number().int().nonnegative().optional(),
1322
+ })
1323
+ .strict();
1324
+ /**
1325
+ * Buyer's bundle configuration — what the storefront posts to the
1326
+ * `/bundle-configuration/validate` endpoint. One selection per slot,
1327
+ * referencing the chosen option's id and the buyer-picked quantity.
1328
+ */
1329
+ export const bundleConfigurationSelectionSchema = z.object({
1330
+ slotId: uuidSchema,
1331
+ optionId: uuidSchema,
1332
+ quantity: z.number().int().positive(),
1333
+ });
1334
+ export const validateBundleConfigurationRequestSchema = z
1335
+ .object({
1336
+ selections: z.array(bundleConfigurationSelectionSchema),
1337
+ })
1338
+ .strict();
1339
+ export const bundleValidationErrorSchema = z.object({
1340
+ code: z.enum(['MIN_NOT_MET', 'MAX_EXCEEDED', 'UNKNOWN_OPTION']),
1341
+ slotId: uuidSchema.optional(),
1342
+ message: z.string(),
1343
+ });
1344
+ export const bundleValidationResultSchema = z.object({
1345
+ valid: z.boolean(),
1346
+ errors: z.array(bundleValidationErrorSchema),
1347
+ resolvedSelections: z.array(z.object({
1348
+ slotId: uuidSchema,
1349
+ optionId: uuidSchema,
1350
+ optionProductId: uuidSchema,
1351
+ quantity: z.number().int().positive(),
1352
+ })),
1353
+ });
1354
+ export const productLinkSummarySchema = z.object({
1355
+ id: uuidSchema,
1356
+ kind: productLinkKindSchema,
1357
+ position: z.number().int().nonnegative(),
1358
+ product: z.object({
1359
+ id: uuidSchema,
1360
+ sku: z.string(),
1361
+ slug: z.string(),
1362
+ name: z.string(),
1363
+ primaryAssetUrl: z.string().nullable(),
1364
+ price: moneySchema.nullable(),
1365
+ }),
1366
+ });
1367
+ /**
1368
+ * The most restrictive audience there is. Anything it may see, every other
1369
+ * audience may see too — which is what makes it the right default for a path
1370
+ * that has not yet been taught to resolve its caller, and the right constant
1371
+ * for a test that means "the public".
1372
+ */
1373
+ export const ANONYMOUS_PRODUCT_AUDIENCE = {
1374
+ organizationId: null,
1375
+ authenticated: false,
1376
+ };
1377
+ /**
1378
+ * Does this audience get to see this product?
1379
+ *
1380
+ * **This is the platform's one answer.** `Product.visibility` and
1381
+ * `Product.allowedOrganizationIds` have been persisted, defaulted and
1382
+ * operator-editable since the foundation migration, and until issue #227 a
1383
+ * single read path out of two dozen enforced them — `catalog`'s quick-search,
1384
+ * repaired for issue #174 after a buyer's type-ahead disclosed products
1385
+ * restricted to other organisations. Every other surface answered the question
1386
+ * its own way or not at all, so the repair starts by making the question have
1387
+ * one answer that a listing, a PDP, a search hit, a cart line, a comparison and
1388
+ * a feed row can all reach.
1389
+ *
1390
+ * It lives in `@endora-commerce/contracts` rather than in `catalog` because the record it
1391
+ * reads is already published here: twenty modules hold a
1392
+ * {@link CatalogProductRecord}, both columns are on it, and a predicate over a
1393
+ * published shape needs no port, no manifest edge and no `catalog` on the other
1394
+ * end of a call. A module that holds the row can enforce; a module that cannot
1395
+ * hold the row has nothing to enforce over.
1396
+ *
1397
+ * The rule, in the order it is decided:
1398
+ *
1399
+ * 1. **A non-empty `allowedOrganizationIds` decides alone**, and it restricts
1400
+ * whatever `visibility` says — `public` included. `public` with an
1401
+ * allow-list naming three organisations is a state an operator can save
1402
+ * today, and reading it as "public wins" discloses exactly the rows the
1403
+ * operator named someone else on.
1404
+ * 2. Otherwise the allow-list is empty and the answer is `visibility`'s alone:
1405
+ * `public` to everybody; `logged_in_only` to any authenticated caller;
1406
+ * `organization_restricted` **to nobody**. That last one is the reading
1407
+ * that surprises: the restriction was asked for and names no organisation,
1408
+ * so the permissive reading of it would disclose the row to the whole
1409
+ * world.
1410
+ *
1411
+ * ## The two SQL restatements of this rule, and how they differ
1412
+ *
1413
+ * A predicate over a record cannot be pushed into a query, and two read paths
1414
+ * must filter in SQL rather than after it. So `catalog` states this rule twice
1415
+ * more, in SQL, and the two statements are **not** copies of each other — they
1416
+ * answer for different audiences and are meant to differ (issue #262):
1417
+ *
1418
+ * - **`catalog-quick-search.service.ts`** answers for a **signed-in buyer**.
1419
+ * `CatalogQuickSearchParams.organizationId` is required and there is no
1420
+ * anonymous spelling, so the audience is
1421
+ * `{ organizationId, authenticated: true }` by construction. It is the
1422
+ * restatement where `@>` containment over the JSONB array does real work:
1423
+ * the buyer's id has to be an *element* of the allow-list rather than a
1424
+ * substring of the serialised bag. It is SQL because the statement carries
1425
+ * a `limit`, and a post-filter would hand a buyer a short page — or an
1426
+ * empty one — while visible rows waited behind the restricted ones.
1427
+ * - **`catalog-product-filter.service.ts`'s `sellableFloor`** answers for
1428
+ * {@link ANONYMOUS_PRODUCT_AUDIENCE}, because the port's only consumer is a
1429
+ * product feed and a feed is read by Google. With no organisation to
1430
+ * contain, the containment branch can never match, so that restatement
1431
+ * collapses to two equalities — `visibility = 'public'` and an empty
1432
+ * allow-list. It is SQL because `countSellable` is the number an operator
1433
+ * is shown before saving and `listSellable` is what the next run emits, and
1434
+ * the two must be one query's answer.
1435
+ *
1436
+ * Neither is licensed to drift toward the other: the containment clause would
1437
+ * be dead weight in the feed floor, and the two equalities would hide from a
1438
+ * buyer every row his own organisation is named on. What keeps both honest is
1439
+ * that each has a parity test which **derives** its expectation from this
1440
+ * function over `productVisibilitySchema.options`, so a fourth visibility value
1441
+ * forces every side to be decided rather than letting one keep an accidental
1442
+ * default — this predicate falls through to `return true`, both SQL sites fail
1443
+ * closed:
1444
+ *
1445
+ * - `backend/test/integration/catalog/quick-search-audience-parity.test.ts`
1446
+ * — visibility × (empty, own org, another org, several including own,
1447
+ * a near-miss string) × three viewers;
1448
+ * - `backend/test/integration/catalog/product-filter-port.test.ts`
1449
+ * — visibility × (empty, non-empty) for the anonymous audience, on
1450
+ * `listSellable` and `countSellable` alike.
1451
+ *
1452
+ * `backend/test/unit/catalog/product-visibility-predicate.test.ts` is the truth
1453
+ * table all three are read against.
1454
+ *
1455
+ * Unifying the three into one shared SQL fragment was considered and refused:
1456
+ * the builder would have to live here, and a fragment knows table and column
1457
+ * names — the persistence shape. This package knows API shapes and depends on
1458
+ * `zod` alone (FR-034). Keeping that line is worth more than removing the
1459
+ * duplication, so the duplication is kept and pinned instead.
1460
+ *
1461
+ * What this predicate is **not** is the channel answer. Channel scoping is
1462
+ * Principle XII's, travels through `sales_channel_products` and the sanctioned
1463
+ * bridge accessors, and is a second filter every buyer-facing path owes on top
1464
+ * of this one.
1465
+ *
1466
+ * That second filter has two spellings and neither is here, by an owner ruling
1467
+ * of 2026-08-21 (issue #259): the channel is a property of the **request**, not
1468
+ * of the viewer's relationship to the product, so folding it in would merge two
1469
+ * questions and make this predicate asynchronous. The **view** side spells it
1470
+ * as `CatalogQueryService.filterByChannel`; the **acquisition** side — cart
1471
+ * add, comparison add, a quote line, a saved list, a pasted quick-order SKU —
1472
+ * spells it as `productIdsInRequestChannel` in
1473
+ * `backend/src/kernel/sales-channels/request-channel-assortment.ts`. Every one
1474
+ * of those refuses out-of-assortment with the *same* answer it gives a
1475
+ * restricted row and an absent one, so the pair cannot be used to enumerate an
1476
+ * operator's private assortment.
1477
+ *
1478
+ * **Two re-acquisition paths are exempt, by the same ruling**: `orders`'
1479
+ * reorder and `quote_requests`' `convertToOrder`. Neither names a product the
1480
+ * caller supplied — each rebuilds a cart from lines the buyer already holds a
1481
+ * commitment on, a placed order or a quote the seller approved at agreed
1482
+ * prices — and refusing would strand a buyer holding an approved quote they
1483
+ * cannot act on. Read that as decided, not as the two seams that were missed;
1484
+ * each carries the reason at its own call site, including the second-order
1485
+ * consequence that makes the obvious repair of the first one wrong. Two
1486
+ * operator surfaces are exempt on the operator's-permission ground instead:
1487
+ * `RfqAdminService.createOnBehalf` and `quick_order`'s `'unrestricted'` import
1488
+ * arm, both of which say so where they stand.
1489
+ */
1490
+ export function isProductVisibleTo(product, audience) {
1491
+ const allowed = product.allowedOrganizationIds ?? [];
1492
+ if (allowed.length > 0) {
1493
+ return audience.organizationId !== null && allowed.includes(audience.organizationId);
1494
+ }
1495
+ if (product.visibility === 'organization_restricted')
1496
+ return false;
1497
+ if (product.visibility === 'logged_in_only')
1498
+ return audience.authenticated;
1499
+ return true;
1500
+ }
1501
+ /**
1502
+ * Scope flags for the **system** product attributes — the ones that are not
1503
+ * rows in `product_attributes` and therefore carry no DB-stored scope flags.
1504
+ *
1505
+ * Published as a **constant, not a port** (FR-013): `name` and `description`
1506
+ * are channel- and language-scoped because the product table stores them as
1507
+ * per-locale JSONB, which is a fact about the schema rather than about whether
1508
+ * a module is switched on. `search`'s indexer reads it to decide which
1509
+ * overrides to resolve.
1510
+ *
1511
+ * Adding another system attribute is a one-line change here plus a resolver
1512
+ * consumer. The reserved keys MUST NOT collide with `product_attributes.key`
1513
+ * — enforced at write time by the override-service validator.
1514
+ */
1515
+ export const SYSTEM_ATTRIBUTE_SCOPES = {
1516
+ name: { channelScoped: true, languageScoped: true },
1517
+ description: { channelScoped: true, languageScoped: true },
1518
+ };
1519
+ export function isSystemAttributeKey(key) {
1520
+ return Object.prototype.hasOwnProperty.call(SYSTEM_ATTRIBUTE_SCOPES, key);
1521
+ }
1522
+ /**
1523
+ * Resolve the effective scope of an attribute given its key and (for
1524
+ * user-defined attributes) its scope flags. Returns the system-pinned scope
1525
+ * when the key is reserved; falls back to the row's flags otherwise; returns
1526
+ * `{ false, false }` when neither applies (the caller should treat that as
1527
+ * global-only).
1528
+ */
1529
+ export function getAttributeScope(attributeKey, productAttributeRow) {
1530
+ if (isSystemAttributeKey(attributeKey)) {
1531
+ // Keyed by `SystemAttributeKey`, so the lookup is non-undefined here; TS'
1532
+ // index signature still widens under `noUncheckedIndexedAccess`.
1533
+ return SYSTEM_ATTRIBUTE_SCOPES[attributeKey];
1534
+ }
1535
+ if (productAttributeRow) {
1536
+ return {
1537
+ channelScoped: productAttributeRow.channelScoped,
1538
+ languageScoped: productAttributeRow.languageScoped,
1539
+ };
1540
+ }
1541
+ return { channelScoped: false, languageScoped: false };
1542
+ }
1543
+ //# sourceMappingURL=catalog.js.map