@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,1504 @@
1
+ // Product Feed module — feature 067 contract surface.
2
+ // Single file with logical sections (matching the convention used by every
3
+ // other module in @endora-commerce/contracts):
4
+ // (1) Enumerations (provider, output format, granularity, run status, …).
5
+ // (2) Scheduling primitives (cron expression, IANA timezone, schedule).
6
+ // (3) The product-selection rule AST.
7
+ // (4) Feed Template and template-field DTOs.
8
+ // (5) Draft evaluation — template preview and selection match count.
9
+ // (6) Product Feed DTOs (binding, schedule, token).
10
+ // (7) Runs, issues and artefacts.
11
+ // (8) Provider taxonomies and category mappings.
12
+ // (9) The template portability envelope.
13
+ // (10) Module error codes.
14
+ // (11) Settings codes (Settings module, group `product_feeds`).
15
+ import { z } from 'zod';
16
+ import { isoDateTimeSchema, uuidSchema } from './common.js';
17
+ import { collectionEnvelope, dataEnvelope } from './envelopes.js';
18
+ import { listQuerySchema } from './pagination.js';
19
+ // ---------------------------------------------------------------------------
20
+ // (1) Enumerations
21
+ // ---------------------------------------------------------------------------
22
+ /** Providers the module knows how to shape output for. `custom` is operator-authored. */
23
+ export const feedProviderCodeSchema = z.enum([
24
+ 'google_merchant',
25
+ 'meta',
26
+ 'amazon',
27
+ 'ebay',
28
+ 'allegro',
29
+ 'custom',
30
+ ]);
31
+ /**
32
+ * Only Google and Meta publish a category taxonomy. A revision of one reaches
33
+ * the platform either bundled with the image or from an optional, off-by-default
34
+ * check (FR-077, FR-086); generation reads the revision in force and contacts
35
+ * nobody either way.
36
+ */
37
+ export const taxonomyProviderCodeSchema = z.enum(['google_merchant', 'meta']);
38
+ /**
39
+ * What the generated file is.
40
+ *
41
+ * `txt` is tab-separated like `tsv` and differs only in extension and media
42
+ * type — several marketplace importers (Google Merchant's own flat file among
43
+ * them) accept nothing else, and renaming the file is exactly the kind of step
44
+ * an operator should not have to know about.
45
+ *
46
+ * `xlsx` is the real Office Open XML workbook, not a spreadsheet-flavoured
47
+ * text file. The legacy binary `.xls` (BIFF8) is deliberately absent: it is
48
+ * superseded, and nothing in this stack can write it.
49
+ */
50
+ export const feedOutputFormatSchema = z.enum(['xml', 'csv', 'tsv', 'txt', 'xlsx']);
51
+ /** Formats that lay one item per row across fixed columns (everything but XML). */
52
+ export const TABULAR_FEED_FORMATS = ['csv', 'tsv', 'txt', 'xlsx'];
53
+ export function isTabularFeedFormat(format) {
54
+ return TABULAR_FEED_FORMATS.includes(format);
55
+ }
56
+ export const feedItemGranularitySchema = z.enum(['product', 'variant']);
57
+ export const feedPricePresentationSchema = z.enum(['net', 'gross']);
58
+ /**
59
+ * Closed catalogue of value sources a template field may bind to (FR-003).
60
+ *
61
+ * `attribute` and `custom_field` are two names for one registry: since feature
62
+ * 061 a product attribute IS a product-host Custom Field definition carrying a
63
+ * catalog extension. Both are accepted so a document exported from either
64
+ * vocabulary imports cleanly; both resolve through the same definitions read.
65
+ */
66
+ export const feedFieldSourceKindSchema = z.enum([
67
+ 'product_id',
68
+ 'sku',
69
+ 'name',
70
+ 'description',
71
+ 'slug',
72
+ 'product_type',
73
+ 'brand',
74
+ 'attribute',
75
+ 'custom_field',
76
+ 'price',
77
+ 'sale_price',
78
+ 'availability',
79
+ 'stock_quantity',
80
+ 'link',
81
+ 'image_link',
82
+ 'additional_image_link',
83
+ 'category_path',
84
+ 'provider_category',
85
+ 'grouping_id',
86
+ 'constant',
87
+ ]);
88
+ /** Closed transform list — deliberately not an expression language (FR-067). */
89
+ export const feedFieldTransformSchema = z.enum([
90
+ 'none',
91
+ 'upper',
92
+ 'lower',
93
+ 'trim',
94
+ 'truncate',
95
+ 'strip_html',
96
+ 'absolute_url',
97
+ ]);
98
+ export const feedRunStatusSchema = z.enum([
99
+ 'queued',
100
+ 'running',
101
+ 'completed',
102
+ 'completed_with_warnings',
103
+ 'empty',
104
+ 'failed',
105
+ 'skipped',
106
+ ]);
107
+ export const feedRunTriggerSchema = z.enum(['manual', 'scheduled']);
108
+ /** Enumerated run-issue reasons (FR-054). Operator-facing labels come from i18n. */
109
+ export const feedRunIssueReasonSchema = z.enum([
110
+ 'missing_price',
111
+ 'missing_image',
112
+ 'private_image_asset',
113
+ 'missing_required_field',
114
+ 'missing_translation',
115
+ 'unresolvable_link',
116
+ 'unmapped_provider_category',
117
+ 'stale_provider_category_mapping',
118
+ 'unsupported_product_type',
119
+ 'zero_tax_rate_on_gross_feed',
120
+ ]);
121
+ export const feedRunFailureCodeSchema = z.enum([
122
+ 'unbound_template_fields',
123
+ 'unknown_attribute',
124
+ 'channel_unavailable',
125
+ /**
126
+ * The template binds a required field to the platform `link` source, but the
127
+ * feed's sales channel has no storefront origin — `sales_channels.storefront_url`
128
+ * is empty and `STOREFRONT_BASE_URL` is unset. Without it every item loses its
129
+ * link and is skipped, so the run names the setting instead of reporting the
130
+ * symptom once per product.
131
+ */
132
+ 'storefront_url_unconfigured',
133
+ 'price_list_unavailable',
134
+ 'language_unavailable',
135
+ 'skip_threshold_exceeded',
136
+ 'storage_unavailable',
137
+ 'worker_lost',
138
+ 'internal_error',
139
+ ]);
140
+ // ---------------------------------------------------------------------------
141
+ // (2) Scheduling primitives
142
+ // ---------------------------------------------------------------------------
143
+ /**
144
+ * 5-field cron with minute granularity (FR-031). Validated here rather than by
145
+ * a round-trip through Redis, so a bad expression never reaches the scheduler.
146
+ * The runtime evaluation (next occurrence, DST) is BullMQ's, via the
147
+ * `cron-parser` it already bundles — see research §R5.
148
+ */
149
+ const CRON_FIELD = String.raw `(\*|[0-9]+|\*\/[0-9]+|[0-9]+(-[0-9]+)?(\/[0-9]+)?)(,([0-9]+|[0-9]+-[0-9]+)(\/[0-9]+)?)*`;
150
+ export const cronExpressionSchema = z
151
+ .string()
152
+ .trim()
153
+ .regex(new RegExp(`^${CRON_FIELD}( ${CRON_FIELD}){4}$`), 'invalid_cron_expression')
154
+ .max(64);
155
+ /**
156
+ * IANA timezone. Validated against the runtime's own tz database rather than a
157
+ * hard-coded list, so it cannot drift from what the scheduler will accept.
158
+ */
159
+ export const timezoneSchema = z
160
+ .string()
161
+ .max(64)
162
+ .refine((tz) => {
163
+ try {
164
+ new Intl.DateTimeFormat('en-US', { timeZone: tz });
165
+ return true;
166
+ }
167
+ catch {
168
+ return false;
169
+ }
170
+ }, { message: 'invalid_timezone' });
171
+ export const feedScheduleSchema = z
172
+ .object({
173
+ cron: cronExpressionSchema,
174
+ timezone: timezoneSchema,
175
+ })
176
+ .nullable();
177
+ // ---------------------------------------------------------------------------
178
+ // (2b) Cron helpers shared by the backend and the admin
179
+ //
180
+ // These live here, next to `cronExpressionSchema`, rather than inside the
181
+ // backend module because both sides need exactly them and duplicating them
182
+ // would guarantee drift: the backend refuses an out-of-range expression at save
183
+ // time, and the admin renders the live plain-language echo under the *Custom*
184
+ // input from the same grammar (FR-031, ux-design §2.2).
185
+ //
186
+ // Nothing here evaluates cron. There is no `cron-parser` import — it is a
187
+ // transitive dependency of BullMQ and not resolvable from `backend/` under
188
+ // pnpm, and hand-rolled next-occurrence arithmetic is the classic way to break
189
+ // a scheduler across a DST transition. Next-occurrence is BullMQ's, read back
190
+ // from `getJobSchedulers()` (research §R5, §R5.5).
191
+ // ---------------------------------------------------------------------------
192
+ /** Inclusive value range of each cron field, in field order. */
193
+ const FIELD_RANGES = [
194
+ { name: 'minute', min: 0, max: 59 },
195
+ { name: 'hour', min: 0, max: 23 },
196
+ { name: 'dayOfMonth', min: 1, max: 31 },
197
+ { name: 'month', min: 1, max: 12 },
198
+ // 0 and 7 both mean Sunday, which is why the ceiling is 7 and not 6.
199
+ { name: 'dayOfWeek', min: 0, max: 7 },
200
+ ];
201
+ const DAY_NAMES = [
202
+ 'Sunday',
203
+ 'Monday',
204
+ 'Tuesday',
205
+ 'Wednesday',
206
+ 'Thursday',
207
+ 'Friday',
208
+ 'Saturday',
209
+ 'Sunday',
210
+ ];
211
+ /**
212
+ * The presets the admin `Select` offers (ux-design §2.2 — "never make a
213
+ * merchandiser write cron"). Short on purpose: a preset list long enough to
214
+ * need scanning is a cron field with extra steps. Anything else is *Custom*.
215
+ */
216
+ export const SCHEDULE_PRESETS = [
217
+ { id: 'hourly', cron: '0 * * * *' },
218
+ { id: 'every4Hours', cron: '0 */4 * * *' },
219
+ { id: 'daily', cron: '0 3 * * *' },
220
+ ];
221
+ /** The preset a cron expression corresponds to, or null when it is custom. */
222
+ export function presetForCron(cron) {
223
+ const normalized = cron.trim().replace(/\s+/g, ' ');
224
+ return SCHEDULE_PRESETS.find((preset) => preset.cron === normalized)?.id ?? null;
225
+ }
226
+ export const CRON_BUILDER_FREQUENCIES = [
227
+ 'hourly',
228
+ 'daily',
229
+ 'weekly',
230
+ 'monthly',
231
+ ];
232
+ /** The expression a builder selection stands for. Always valid by construction. */
233
+ export function cronFromBuilder(value) {
234
+ switch (value.frequency) {
235
+ case 'hourly':
236
+ return `${value.minute} * * * *`;
237
+ case 'daily':
238
+ return `${value.minute} ${value.hour} * * *`;
239
+ case 'weekly':
240
+ return `${value.minute} ${value.hour} * * ${value.dayOfWeek}`;
241
+ case 'monthly':
242
+ return `${value.minute} ${value.hour} ${value.dayOfMonth} * *`;
243
+ }
244
+ }
245
+ /** A plain integer field — no `*`, no list, no range, no step. */
246
+ function exactField(field) {
247
+ return /^\d+$/.test(field) ? Number(field) : null;
248
+ }
249
+ /**
250
+ * The builder selection an expression corresponds to, or `null` when the
251
+ * expression says something the four frequencies cannot.
252
+ *
253
+ * The `null` is the important half. `0 * /4 * * *` is a perfectly good schedule
254
+ * that no frequency here describes; reporting it as "hourly" would show the
255
+ * operator a dropdown they never chose and quadruple the run rate if they then
256
+ * saved. Anything with a step, a list, a range, a fixed month, or both a
257
+ * day-of-month and a day-of-week (which cron ORs together) is refused, and the
258
+ * caller keeps the operator on the raw expression instead.
259
+ */
260
+ export function builderFromCron(cron) {
261
+ // Normalised before validating, not after: the grammar is anchored, so it
262
+ // rejects the padding a text input routinely carries, and this is fed
263
+ // straight from one.
264
+ const normalized = cron.trim().replace(/\s+/g, ' ');
265
+ if (!isValidCronExpression(normalized))
266
+ return null;
267
+ const [minuteField, hourField, domField, monthField, dowField] = normalized.split(' ');
268
+ // A schedule that fires every minute is not one of the offered frequencies.
269
+ const minute = exactField(minuteField);
270
+ if (minute === null)
271
+ return null;
272
+ if (monthField !== '*')
273
+ return null;
274
+ const hour = exactField(hourField);
275
+ const dayOfMonth = exactField(domField);
276
+ const dayOfWeek = exactField(dowField);
277
+ const hasDom = domField !== '*';
278
+ const hasDow = dowField !== '*';
279
+ // Cron ORs these two, so a schedule constraining both is neither weekly nor
280
+ // monthly — it is a union no single frequency names.
281
+ if (hasDom && hasDow)
282
+ return null;
283
+ if (hourField === '*') {
284
+ return hasDom || hasDow ? null : { frequency: 'hourly', minute };
285
+ }
286
+ if (hour === null)
287
+ return null;
288
+ if (hasDow) {
289
+ return dayOfWeek === null ? null : { frequency: 'weekly', dayOfWeek, hour, minute };
290
+ }
291
+ if (hasDom) {
292
+ return dayOfMonth === null ? null : { frequency: 'monthly', dayOfMonth, hour, minute };
293
+ }
294
+ return { frequency: 'daily', hour, minute };
295
+ }
296
+ export function isValidTimezone(timezone) {
297
+ return timezoneSchema.safeParse(timezone).success && timezone.trim() !== '';
298
+ }
299
+ /**
300
+ * Grammar (the contract's regex) **and** field ranges. Both, because a
301
+ * grammatically valid expression that can never fire is the worse failure: the
302
+ * operator sees a saved schedule and no runs.
303
+ */
304
+ export function isValidCronExpression(expression) {
305
+ if (!cronExpressionSchema.safeParse(expression).success)
306
+ return false;
307
+ const fields = expression.trim().split(/\s+/);
308
+ if (fields.length !== FIELD_RANGES.length)
309
+ return false;
310
+ return fields.every((field, index) => fieldInRange(field, FIELD_RANGES[index]));
311
+ }
312
+ function fieldInRange(field, range) {
313
+ for (const term of field.split(',')) {
314
+ // `*`, `a`, `a-b`, `*/n`, `a/n`, `a-b/n` — the grammar the contract accepts.
315
+ const [values, step] = term.split('/');
316
+ if (step !== undefined && (!/^\d+$/.test(step) || Number(step) === 0))
317
+ return false;
318
+ if (values === '*' || values === undefined)
319
+ continue;
320
+ const bounds = values.split('-');
321
+ if (bounds.length > 2)
322
+ return false;
323
+ for (const bound of bounds) {
324
+ if (!/^\d+$/.test(bound))
325
+ return false;
326
+ const value = Number(bound);
327
+ if (value < range.min || value > range.max)
328
+ return false;
329
+ }
330
+ if (bounds.length === 2 && Number(bounds[0]) > Number(bounds[1]))
331
+ return false;
332
+ }
333
+ return true;
334
+ }
335
+ /**
336
+ * The sentence rendered live under the *Custom* input (ux-design §2.2).
337
+ *
338
+ * Returns `null` for an invalid expression so the caller shows
339
+ * `feeds.schedule.invalid` instead — an echo and an error must never be on
340
+ * screen at once. For a valid expression it always returns *something*: the
341
+ * shapes the presets produce get real prose, and anything else gets the
342
+ * expression back verbatim. Echoing the input is honest; an empty line under a
343
+ * valid expression reads like a rejection.
344
+ */
345
+ export function describeCronExpression(expression) {
346
+ if (!isValidCronExpression(expression))
347
+ return null;
348
+ const normalized = expression.trim().replace(/\s+/g, ' ');
349
+ const [minute, hour, dayOfMonth, month, dayOfWeek] = normalized.split(' ');
350
+ const everyDay = dayOfMonth === '*' && month === '*';
351
+ const dayClause = describeDayOfWeek(dayOfWeek);
352
+ const everyNMinutes = /^\*\/(\d+)$/.exec(minute);
353
+ if (everyNMinutes && hour === '*' && everyDay && dayClause === null) {
354
+ return `Every ${everyNMinutes[1]} minutes`;
355
+ }
356
+ const everyNHours = /^\*\/(\d+)$/.exec(hour);
357
+ if (everyNHours && /^\d+$/.test(minute) && everyDay && dayClause === null) {
358
+ return `Every ${everyNHours[1]} hours, at minute ${Number(minute)}`;
359
+ }
360
+ if (hour === '*' && /^\d+$/.test(minute) && everyDay && dayClause === null) {
361
+ return `Every hour, at minute ${Number(minute)}`;
362
+ }
363
+ if (/^\d+$/.test(minute) && /^\d+$/.test(hour) && everyDay) {
364
+ const at = `${pad(Number(hour))}:${pad(Number(minute))}`;
365
+ return dayClause === null ? `Every day at ${at}` : `At ${at}, ${dayClause}`;
366
+ }
367
+ return normalized;
368
+ }
369
+ /** `null` means "every day", which the callers phrase themselves. */
370
+ function describeDayOfWeek(field) {
371
+ if (field === '*')
372
+ return null;
373
+ const range = /^(\d)-(\d)$/.exec(field);
374
+ if (range) {
375
+ return `${DAY_NAMES[Number(range[1])]} to ${DAY_NAMES[Number(range[2])]}`;
376
+ }
377
+ if (/^\d$/.test(field))
378
+ return `on ${DAY_NAMES[Number(field)]}`;
379
+ // A list (`1,3,5`) has no short natural phrasing that stays unambiguous, so
380
+ // the caller falls back to echoing the whole expression.
381
+ return field;
382
+ }
383
+ function pad(value) {
384
+ return String(value).padStart(2, '0');
385
+ }
386
+ // ---------------------------------------------------------------------------
387
+ // (3) Product-selection rule AST
388
+ //
389
+ // Same node shape as the promotion rule AST (`all | condition | group`, depth
390
+ // <= 5) but over a PRODUCT field catalogue. Deliberately a separate schema:
391
+ // reusing `promotionRuleSchema` would offer `cartTotal` / `paymentMethod` as
392
+ // product filters, which is meaningless to the operator and unenforceable
393
+ // server-side. See research §R9.
394
+ // ---------------------------------------------------------------------------
395
+ export const productSelectionOpSchema = z.enum([
396
+ 'eq',
397
+ 'neq',
398
+ 'gt',
399
+ 'gte',
400
+ 'lt',
401
+ 'lte',
402
+ 'between',
403
+ 'in',
404
+ 'notIn',
405
+ 'contains',
406
+ 'startsWith',
407
+ 'isSet',
408
+ 'isNotSet',
409
+ ]);
410
+ /** Built-in, product-context fields the criteria builder offers (FR-025). */
411
+ export const productSelectionBuiltinFieldSchema = z.enum([
412
+ 'category',
413
+ 'productType',
414
+ 'status',
415
+ 'stockState',
416
+ 'price',
417
+ 'brand',
418
+ 'createdAt',
419
+ 'updatedAt',
420
+ ]);
421
+ const definitionKeyRe = /^[a-z][a-z0-9_]{0,63}$/;
422
+ export const productSelectionFieldSchema = z.discriminatedUnion('kind', [
423
+ z.object({ kind: z.literal('builtin'), key: productSelectionBuiltinFieldSchema }),
424
+ z.object({
425
+ kind: z.literal('attribute'),
426
+ attributeKey: z.string().regex(definitionKeyRe, 'invalid_attribute_key'),
427
+ }),
428
+ z.object({
429
+ kind: z.literal('customField'),
430
+ fieldKey: z.string().regex(definitionKeyRe, 'invalid_custom_field_key'),
431
+ }),
432
+ ]);
433
+ export const productSelectionValueSchema = z.union([z.string(), z.number(), z.boolean()]);
434
+ const productSelectionNodeSchema = z.lazy(() => z.discriminatedUnion('kind', [
435
+ z.object({ kind: z.literal('all') }),
436
+ z.object({
437
+ kind: z.literal('condition'),
438
+ field: productSelectionFieldSchema,
439
+ op: productSelectionOpSchema,
440
+ values: z.array(productSelectionValueSchema).max(1000),
441
+ }),
442
+ z.object({
443
+ kind: z.literal('group'),
444
+ op: z.enum(['AND', 'OR']),
445
+ children: z.array(productSelectionNodeSchema).min(1).max(20),
446
+ }),
447
+ ]));
448
+ export function productSelectionDepth(node) {
449
+ if (node.kind !== 'group')
450
+ return 0;
451
+ return 1 + Math.max(0, ...node.children.map(productSelectionDepth));
452
+ }
453
+ /** `{ kind: 'all' }` is the canonical "whole channel catalogue" (FR-024). */
454
+ export const productSelectionRuleSchema = productSelectionNodeSchema.refine((node) => productSelectionDepth(node) <= 5, { message: 'rule_depth_exceeds_5' });
455
+ // ---------------------------------------------------------------------------
456
+ // (4) Feed Template
457
+ // ---------------------------------------------------------------------------
458
+ export const feedTemplateFieldSchema = z.object({
459
+ id: uuidSchema,
460
+ outputName: z.string().min(1).max(128),
461
+ sourceKind: feedFieldSourceKindSchema,
462
+ sourceKey: z.string().max(128).nullable(),
463
+ constantValue: z.string().max(2048).nullable(),
464
+ fallbackValue: z.string().max(2048).nullable(),
465
+ providerRequired: z.boolean(),
466
+ transform: feedFieldTransformSchema.nullable(),
467
+ transformArg: z.string().max(64).nullable(),
468
+ sortOrder: z.number().int().nonnegative(),
469
+ /**
470
+ * Optional translation key for the one-sentence "gloss" the editor shows under
471
+ * the output name (ux-design §3.3, SC-013). Resolved in the `product_feeds`
472
+ * i18n namespace, so it ships in `en` + `pl` like every other operator string.
473
+ *
474
+ * Only the predefined templates set it — the platform explains `availability`
475
+ * and `gtin`, and must NOT invent meaning for an operator's own field name.
476
+ * A duplicate of a system template inherits it, which is the common path into
477
+ * the editor.
478
+ */
479
+ helpKey: z.string().max(128).nullable(),
480
+ /** True when an import could not resolve `sourceKey` locally (FR-015). Blocks generation (FR-016). */
481
+ unbound: z.boolean(),
482
+ });
483
+ const feedTemplateFieldWriteObject = z.object({
484
+ outputName: z.string().trim().min(1).max(128),
485
+ sourceKind: feedFieldSourceKindSchema,
486
+ sourceKey: z.string().max(128).nullable().optional(),
487
+ constantValue: z.string().max(2048).nullable().optional(),
488
+ fallbackValue: z.string().max(2048).nullable().optional(),
489
+ providerRequired: z.boolean().optional(),
490
+ transform: feedFieldTransformSchema.nullable().optional(),
491
+ transformArg: z.string().max(64).nullable().optional(),
492
+ sortOrder: z.number().int().nonnegative(),
493
+ helpKey: z.string().max(128).nullable().optional(),
494
+ });
495
+ /** Cross-field rules that FR-009 requires to be refused at save time. */
496
+ export const feedTemplateFieldWriteSchema = feedTemplateFieldWriteObject
497
+ .refine((f) => (f.sourceKind === 'constant') === (f.constantValue != null), {
498
+ message: 'constant_value_required_for_constant_source',
499
+ path: ['constantValue'],
500
+ })
501
+ .refine((f) => !['attribute', 'custom_field'].includes(f.sourceKind) || (f.sourceKey ?? '') !== '', { message: 'source_key_required', path: ['sourceKey'] });
502
+ export const feedTemplateSchema = z.object({
503
+ id: uuidSchema,
504
+ name: z.string().min(1).max(200),
505
+ description: z.string().max(2000).nullable(),
506
+ providerCode: feedProviderCodeSchema,
507
+ outputFormat: feedOutputFormatSchema,
508
+ itemGranularity: feedItemGranularitySchema,
509
+ taxonomyProviderCode: taxonomyProviderCodeSchema.nullable(),
510
+ /** Predefined templates are read-only; the admin offers duplication instead (FR-008). */
511
+ isSystem: z.boolean(),
512
+ systemCode: z.string().max(32).nullable(),
513
+ fields: z.array(feedTemplateFieldSchema),
514
+ /** Number of feeds referencing this template — drives the delete refusal message (FR-010). */
515
+ usedByFeedCount: z.number().int().nonnegative(),
516
+ version: z.number().int().positive(),
517
+ createdAt: isoDateTimeSchema,
518
+ updatedAt: isoDateTimeSchema,
519
+ });
520
+ export const createFeedTemplateRequestSchema = z.object({
521
+ name: z.string().trim().min(1).max(200),
522
+ description: z.string().max(2000).nullable().optional(),
523
+ providerCode: feedProviderCodeSchema.default('custom'),
524
+ outputFormat: feedOutputFormatSchema.default('xml'),
525
+ itemGranularity: feedItemGranularitySchema.default('product'),
526
+ taxonomyProviderCode: taxonomyProviderCodeSchema.nullable().optional(),
527
+ fields: z.array(feedTemplateFieldWriteSchema).max(200).default([]),
528
+ });
529
+ /**
530
+ * Full replacement of the field list — the structure editor saves the whole
531
+ * ordered list, so reordering, renaming, adding and removing are one atomic,
532
+ * single-audit-entry operation rather than a burst of per-field PATCHes.
533
+ *
534
+ * The four defaulted keys are re-declared without their defaults for the same
535
+ * reason `updateProductFeedRequestSchema` does: `z.object().partial()` makes a
536
+ * key optional but does **not** remove its `.default()`. A save that only
537
+ * renamed a template would otherwise arrive carrying `providerCode: 'custom'`,
538
+ * `outputFormat: 'xml'`, `itemGranularity: 'product'` and an empty `fields`
539
+ * array — silently rewriting a Google per-variant XML template into a custom
540
+ * per-product one, and emptying its field list.
541
+ */
542
+ export const updateFeedTemplateRequestSchema = createFeedTemplateRequestSchema
543
+ .omit({
544
+ providerCode: true,
545
+ outputFormat: true,
546
+ itemGranularity: true,
547
+ fields: true,
548
+ })
549
+ .partial()
550
+ .extend({
551
+ providerCode: feedProviderCodeSchema.optional(),
552
+ outputFormat: feedOutputFormatSchema.optional(),
553
+ itemGranularity: feedItemGranularitySchema.optional(),
554
+ fields: z.array(feedTemplateFieldWriteSchema).max(200).optional(),
555
+ });
556
+ export const duplicateFeedTemplateRequestSchema = z.object({
557
+ name: z.string().trim().min(1).max(200),
558
+ });
559
+ export const feedTemplateResponseSchema = dataEnvelope(feedTemplateSchema);
560
+ export const feedTemplateListResponseSchema = collectionEnvelope(feedTemplateSchema.omit({ fields: true }));
561
+ // ---------------------------------------------------------------------------
562
+ // (4b) Guided binding catalogue (FR-070)
563
+ //
564
+ // The editor's source picker renders EXACTLY this. It is a server-built list of
565
+ // what exists on THIS installation, which is what lets the operator choose a
566
+ // source instead of typing an internal key, a column name or a path.
567
+ // ---------------------------------------------------------------------------
568
+ /** Why a group exists, so the editor can order and head the picker's sections. */
569
+ export const feedFieldSourceGroupKindSchema = z.enum([
570
+ 'product_property',
571
+ 'price_and_stock',
572
+ 'attribute',
573
+ 'custom_field',
574
+ 'computed',
575
+ 'constant',
576
+ ]);
577
+ export const feedFieldSourceSchema = z.object({
578
+ sourceKind: feedFieldSourceKindSchema,
579
+ /** Set only for `attribute` / `custom_field`; the definition key, never a uuid. */
580
+ sourceKey: z.string().max(128).nullable().default(null),
581
+ /** Platform-owned sources are labelled from the module bundle… */
582
+ labelKey: z.string().max(128).nullable().default(null),
583
+ /** …operator-owned ones carry the definition's own label, already localized. */
584
+ label: z.string().max(200).nullable().default(null),
585
+ description: z.string().max(500).nullable().default(null),
586
+ /** Definition value type, so the editor can hint at what a binding will produce. */
587
+ valueType: z.string().max(32).nullable().default(null),
588
+ /**
589
+ * True for `provider_category`: a template declaring no taxonomy must not
590
+ * offer it (FR-082). The editor renders it disabled WITH the reason rather
591
+ * than hiding it, so the operator learns the rule instead of wondering.
592
+ */
593
+ requiresTaxonomy: z.boolean().default(false),
594
+ /**
595
+ * Some sources cannot be expressed in every output format (repeated values in
596
+ * a single CSV column). Carried per source so the editor explains rather than
597
+ * filters (ux-design §3.2).
598
+ */
599
+ unsupportedInFormats: z.array(feedOutputFormatSchema).default([]),
600
+ });
601
+ export const feedFieldSourceCatalogueSchema = z.object({
602
+ groups: z.array(z.object({
603
+ kind: feedFieldSourceGroupKindSchema,
604
+ sources: z.array(feedFieldSourceSchema),
605
+ })),
606
+ });
607
+ export const feedFieldSourceCatalogueResponseSchema = dataEnvelope(feedFieldSourceCatalogueSchema);
608
+ // ---------------------------------------------------------------------------
609
+ // (5) Draft evaluation — template preview and selection match count
610
+ //
611
+ // BOTH endpoints evaluate an UNSAVED, in-editor body. Resolving a preview by a
612
+ // persisted template id would force the operator to save broken intermediate
613
+ // states just to see a value, which puts SC-013 ("a working template in under
614
+ // 15 minutes, unaided") out of reach. The draft is the input; a persisted
615
+ // record is at most an optional base. Both are strictly side-effect-free: no
616
+ // run row, no issue row, no artefact, no audit entry.
617
+ // ---------------------------------------------------------------------------
618
+ /** The template exactly as it stands on screen — no id, possibly invalid. */
619
+ export const feedTemplateDraftSchema = z.object({
620
+ providerCode: feedProviderCodeSchema,
621
+ outputFormat: feedOutputFormatSchema,
622
+ itemGranularity: feedItemGranularitySchema,
623
+ taxonomyProviderCode: taxonomyProviderCodeSchema.nullable(),
624
+ fields: z.array(feedTemplateFieldWriteSchema).max(200),
625
+ });
626
+ /** The resolution context. Every part is optional; the server fills the rest. */
627
+ export const feedPreviewContextSchema = z.object({
628
+ /** Prefill everything from an existing feed (the editor's default when one exists). */
629
+ productFeedId: uuidSchema.optional(),
630
+ salesChannelId: uuidSchema.optional(),
631
+ languageCode: z.string().max(12).optional(),
632
+ currencyCode: z.string().length(3).optional(),
633
+ priceListId: uuidSchema.nullable().optional(),
634
+ pricePresentation: feedPricePresentationSchema.optional(),
635
+ taxCountry: z.string().length(2).optional(),
636
+ });
637
+ export const feedTemplatePreviewRequestSchema = z.object({
638
+ /**
639
+ * Optional persisted template the draft was derived from. Used ONLY to
640
+ * inherit settings the draft omits and to resolve `helpKey`s; the draft's
641
+ * own values always win. Absent for a never-saved template.
642
+ */
643
+ baseTemplateId: uuidSchema.optional(),
644
+ draft: feedTemplateDraftSchema,
645
+ context: feedPreviewContextSchema.default({}),
646
+ /** The sample product (and optionally variant) the operator picked. */
647
+ productId: uuidSchema,
648
+ variantId: uuidSchema.optional(),
649
+ });
650
+ export const feedTemplatePreviewFieldSchema = z.object({
651
+ outputName: z.string(),
652
+ /** Null means the field would be absent from the emitted item. */
653
+ value: z.string().nullable(),
654
+ /** How the value was obtained, so the operator can see a fallback doing the work. */
655
+ resolvedFrom: z.enum(['source', 'fallback', 'omitted']),
656
+ /** Set when this field alone would cause the item to be skipped (FR-072). */
657
+ wouldSkipItem: z.boolean(),
658
+ issueReason: feedRunIssueReasonSchema.nullable(),
659
+ /**
660
+ * A draft may reference an attribute or custom field that does not exist —
661
+ * the operator is mid-edit, or imported a template (FR-015). The preview
662
+ * reports it as a field-level fact; it is never a request failure.
663
+ */
664
+ unbound: z.boolean(),
665
+ /** Gloss key for this field, from the draft or inherited from `baseTemplateId`. */
666
+ helpKey: z.string().nullable(),
667
+ });
668
+ export const feedTemplatePreviewResponseSchema = dataEnvelope(z.object({
669
+ fields: z.array(feedTemplatePreviewFieldSchema),
670
+ wouldEmitItem: z.boolean(),
671
+ /** Populates the verdict banner: why this product would be left out. */
672
+ skipReason: feedRunIssueReasonSchema.nullable(),
673
+ /** The serialized item exactly as it would appear in the file. */
674
+ renderedItem: z.string(),
675
+ /** The context actually used, after server-side defaulting — the editor shows it. */
676
+ resolvedContext: z.object({
677
+ salesChannelId: uuidSchema,
678
+ languageCode: z.string(),
679
+ currencyCode: z.string(),
680
+ priceListId: uuidSchema.nullable(),
681
+ pricePresentation: feedPricePresentationSchema,
682
+ taxCountry: z.string().nullable(),
683
+ }),
684
+ }));
685
+ // ---------------------------------------------------------------------------
686
+ // (6) Product Feed
687
+ // ---------------------------------------------------------------------------
688
+ export const productFeedTokenSchema = z.object({
689
+ /** Non-secret display fragment. */
690
+ prefix: z.string().max(12).nullable(),
691
+ rotatedAt: isoDateTimeSchema.nullable(),
692
+ revokedAt: isoDateTimeSchema.nullable(),
693
+ /**
694
+ * Fully-qualified public URL, or null when revoked (FR-047).
695
+ *
696
+ * Carries the working link when {@link urlIsLive} is true. When it is false
697
+ * the token predates recoverable storage (or this deployment has no
698
+ * encryption key) and the URL is a masked, non-working display form.
699
+ */
700
+ url: z.string().url().nullable(),
701
+ /** Whether `url` is the real link rather than the masked form. */
702
+ urlIsLive: z.boolean(),
703
+ });
704
+ export const productFeedRunSummarySchema = z.object({
705
+ id: uuidSchema,
706
+ status: feedRunStatusSchema,
707
+ trigger: feedRunTriggerSchema,
708
+ startedAt: isoDateTimeSchema.nullable(),
709
+ finishedAt: isoDateTimeSchema.nullable(),
710
+ emittedCount: z.number().int().nonnegative(),
711
+ skippedCount: z.number().int().nonnegative(),
712
+ warningCount: z.number().int().nonnegative(),
713
+ failureCode: feedRunFailureCodeSchema.nullable(),
714
+ });
715
+ export const productFeedSchema = z.object({
716
+ id: uuidSchema,
717
+ name: z.string().min(1).max(200),
718
+ slug: z.string().min(1).max(160),
719
+ feedTemplateId: uuidSchema,
720
+ feedTemplateName: z.string(),
721
+ salesChannelId: uuidSchema,
722
+ salesChannelCode: z.string(),
723
+ languageCode: z.string().max(12),
724
+ currencyCode: z.string().length(3),
725
+ priceListId: uuidSchema.nullable(),
726
+ pricePresentation: feedPricePresentationSchema,
727
+ taxCountry: z.string().length(2).nullable(),
728
+ selectionRule: productSelectionRuleSchema,
729
+ schedule: feedScheduleSchema,
730
+ enabled: z.boolean(),
731
+ token: productFeedTokenSchema,
732
+ lastRun: productFeedRunSummarySchema.nullable(),
733
+ nextRunAt: isoDateTimeSchema.nullable(),
734
+ publishedArtefactId: uuidSchema.nullable(),
735
+ publishedItemCount: z.number().int().nonnegative().nullable(),
736
+ publishedAt: isoDateTimeSchema.nullable(),
737
+ /** True while a run holds the claim — the admin disables "Generate" on it (FR-033). */
738
+ isRunning: z.boolean(),
739
+ /** Set when the rolling average run duration exceeds half the schedule interval. */
740
+ scheduleTooTightWarning: z.boolean(),
741
+ version: z.number().int().positive(),
742
+ createdAt: isoDateTimeSchema,
743
+ updatedAt: isoDateTimeSchema,
744
+ });
745
+ const productFeedWriteObject = z.object({
746
+ name: z.string().trim().min(1).max(200),
747
+ slug: z
748
+ .string()
749
+ .trim()
750
+ .regex(/^[a-z0-9]+(-[a-z0-9]+)*$/, 'invalid_slug')
751
+ .max(160),
752
+ feedTemplateId: uuidSchema,
753
+ salesChannelId: uuidSchema,
754
+ languageCode: z.string().min(2).max(12),
755
+ currencyCode: z.string().length(3),
756
+ priceListId: uuidSchema.nullable().optional(),
757
+ pricePresentation: feedPricePresentationSchema.default('gross'),
758
+ taxCountry: z.string().length(2).nullable().optional(),
759
+ selectionRule: productSelectionRuleSchema.default({ kind: 'all' }),
760
+ schedule: feedScheduleSchema.default(null),
761
+ enabled: z.boolean().default(true),
762
+ });
763
+ export const createProductFeedRequestSchema = productFeedWriteObject.refine((f) => f.pricePresentation !== 'gross' || (f.taxCountry ?? '') !== '', { message: 'tax_country_required_for_gross_prices', path: ['taxCountry'] });
764
+ /**
765
+ * A PATCH carries only what the operator changed.
766
+ *
767
+ * The defaulted keys are re-declared without their defaults on purpose:
768
+ * `z.object().partial()` makes a key optional but does **not** remove its
769
+ * `.default()`, so a plain `.partial()` would inject `pricePresentation:
770
+ * 'gross'`, `selectionRule: {kind:'all'}`, `schedule: null` and `enabled: true`
771
+ * into every PATCH body. Renaming a feed would then silently flip it to gross
772
+ * prices (and fail cross-field validation for want of a `taxCountry`), reset
773
+ * its criteria and drop its schedule.
774
+ */
775
+ export const updateProductFeedRequestSchema = productFeedWriteObject
776
+ .omit({
777
+ pricePresentation: true,
778
+ selectionRule: true,
779
+ schedule: true,
780
+ enabled: true,
781
+ })
782
+ .partial()
783
+ .extend({
784
+ pricePresentation: feedPricePresentationSchema.optional(),
785
+ selectionRule: productSelectionRuleSchema.optional(),
786
+ schedule: feedScheduleSchema.optional(),
787
+ enabled: z.boolean().optional(),
788
+ });
789
+ export const duplicateProductFeedRequestSchema = z.object({
790
+ name: z.string().trim().min(1).max(200),
791
+ slug: z.string().trim().max(160),
792
+ languageCode: z.string().min(2).max(12).optional(),
793
+ currencyCode: z.string().length(3).optional(),
794
+ });
795
+ export const productFeedResponseSchema = dataEnvelope(productFeedSchema);
796
+ export const productFeedListResponseSchema = collectionEnvelope(productFeedSchema);
797
+ /** Returned exactly once, on create and on rotate. Never re-readable (FR-047). */
798
+ export const productFeedTokenIssuedResponseSchema = dataEnvelope(z.object({
799
+ token: z.string(),
800
+ url: z.string().url(),
801
+ prefix: z.string(),
802
+ rotatedAt: isoDateTimeSchema,
803
+ }));
804
+ /**
805
+ * Match count for a criteria set **before saving** (FR-028).
806
+ *
807
+ * Draft-shaped by construction: it takes a channel and a rule, never a feed id,
808
+ * so the count works on `/product-feeds/new` where no feed exists yet and on an
809
+ * edited-but-unsaved criteria panel. Side-effect-free.
810
+ */
811
+ export const productSelectionPreviewRequestSchema = z.object({
812
+ salesChannelId: uuidSchema,
813
+ selectionRule: productSelectionRuleSchema,
814
+ });
815
+ export const productSelectionPreviewResponseSchema = dataEnvelope(z.object({
816
+ matchedCount: z.number().int().nonnegative(),
817
+ /** A handful of matched products so the operator can sanity-check the rule. */
818
+ sample: z.array(z.object({ id: uuidSchema, sku: z.string(), name: z.string() })).max(10),
819
+ }));
820
+ // ---------------------------------------------------------------------------
821
+ // (7) Runs, issues, artefacts
822
+ // ---------------------------------------------------------------------------
823
+ export const feedRunIssueSchema = z.object({
824
+ id: uuidSchema,
825
+ severity: z.enum(['skip', 'warning']),
826
+ reason: feedRunIssueReasonSchema,
827
+ productId: uuidSchema.nullable(),
828
+ variantId: uuidSchema.nullable(),
829
+ sku: z.string().max(255).nullable(),
830
+ outputName: z.string().max(128).nullable(),
831
+ detail: z.string().max(255).nullable(),
832
+ });
833
+ export const feedRunSchema = productFeedRunSummarySchema.extend({
834
+ productFeedId: uuidSchema,
835
+ triggeredByAdminUserId: uuidSchema.nullable(),
836
+ consideredCount: z.number().int().nonnegative(),
837
+ durationMs: z.number().int().nonnegative().nullable(),
838
+ failureDetail: z.string().nullable(),
839
+ skipReason: z.enum(['already_running', 'feed_disabled']).nullable(),
840
+ issueOverflow: z.boolean(),
841
+ artefact: z
842
+ .object({
843
+ id: uuidSchema,
844
+ byteSize: z.number().int().nonnegative(),
845
+ itemCount: z.number().int().nonnegative(),
846
+ contentType: z.string(),
847
+ producedAt: isoDateTimeSchema,
848
+ isPublished: z.boolean(),
849
+ })
850
+ .nullable(),
851
+ createdAt: isoDateTimeSchema,
852
+ });
853
+ export const feedRunResponseSchema = dataEnvelope(feedRunSchema);
854
+ export const feedRunListResponseSchema = collectionEnvelope(feedRunSchema);
855
+ export const feedRunIssueListResponseSchema = collectionEnvelope(feedRunIssueSchema);
856
+ export const feedRunListQuerySchema = listQuerySchema.extend({
857
+ status: feedRunStatusSchema.optional(),
858
+ });
859
+ /** Accepted-and-enqueued acknowledgement; the work never runs inline (FR-032). */
860
+ export const startFeedRunResponseSchema = dataEnvelope(z.object({
861
+ runId: uuidSchema,
862
+ status: z.literal('queued'),
863
+ }));
864
+ // ---------------------------------------------------------------------------
865
+ // (8) Provider taxonomies and category mappings
866
+ // ---------------------------------------------------------------------------
867
+ export const feedTaxonomySchema = z.object({
868
+ providerCode: taxonomyProviderCodeSchema,
869
+ revision: z.string().max(32),
870
+ nodeCount: z.number().int().nonnegative(),
871
+ installedAt: isoDateTimeSchema,
872
+ });
873
+ export const feedTaxonomyNodeSchema = z.object({
874
+ externalId: z.string().max(32),
875
+ parentExternalId: z.string().max(32).nullable(),
876
+ label: z.string(),
877
+ fullPath: z.string(),
878
+ depth: z.number().int().nonnegative(),
879
+ });
880
+ export const feedTaxonomyNodeSearchQuerySchema = listQuerySchema.extend({
881
+ providerCode: taxonomyProviderCodeSchema,
882
+ /** Free-text over the localized full path. */
883
+ q: z.string().trim().min(1).max(200).optional(),
884
+ /** Label language; defaults to the administrator's admin language. */
885
+ lang: z.string().min(2).max(12).optional(),
886
+ });
887
+ export const feedTaxonomyMappingSchema = z.object({
888
+ categoryId: uuidSchema,
889
+ categoryName: z.string(),
890
+ categoryDepth: z.number().int().nonnegative(),
891
+ /** Null when neither this category nor any ancestor is mapped (FR-080). */
892
+ nodeExternalId: z.string().max(32).nullable(),
893
+ nodeFullPath: z.string().nullable(),
894
+ /** Where the value came from — 'explicit' | 'inherited' | 'none' (FR-080). */
895
+ origin: z.enum(['explicit', 'inherited', 'none']),
896
+ /** For 'inherited', the ancestor the value came from. */
897
+ inheritedFromCategoryId: uuidSchema.nullable(),
898
+ inheritedFromCategoryName: z.string().nullable(),
899
+ /** The mapped node vanished in the installed revision; kept, flagged (FR-085). */
900
+ stale: z.boolean(),
901
+ });
902
+ export const setFeedTaxonomyMappingRequestSchema = z.object({
903
+ providerCode: taxonomyProviderCodeSchema,
904
+ categoryId: uuidSchema,
905
+ /** Null clears the explicit mapping so the category inherits again. */
906
+ nodeExternalId: z.string().max(32).nullable(),
907
+ });
908
+ export const feedTaxonomyMappingListResponseSchema = collectionEnvelope(feedTaxonomyMappingSchema);
909
+ /** Coverage summary shown above the mapping surface (FR-079). */
910
+ export const feedTaxonomyCoverageResponseSchema = dataEnvelope(z.object({
911
+ providerCode: taxonomyProviderCodeSchema,
912
+ revision: z.string(),
913
+ totalCategories: z.number().int().nonnegative(),
914
+ explicitlyMapped: z.number().int().nonnegative(),
915
+ coveredByInheritance: z.number().int().nonnegative(),
916
+ uncovered: z.number().int().nonnegative(),
917
+ staleMappings: z.number().int().nonnegative(),
918
+ }));
919
+ // ---------------------------------------------------------------------------
920
+ // (8b) Taxonomy revision refresh (FR-086 – FR-099)
921
+ //
922
+ // The invariant every shape below serves: a check may only ADD an inactive
923
+ // revision. Only `promote` changes what a feed emits — which is why there is a
924
+ // `promote` request schema and no `activate` flag anywhere else.
925
+ //
926
+ // `feedTaxonomySchema` above is deliberately left alone: the richer revision
927
+ // shape is additive, so no already-shipped response changes.
928
+ // ---------------------------------------------------------------------------
929
+ /** Where a revision came from (FR-078). Both kinds are the same object to the operator. */
930
+ export const feedTaxonomyRevisionSourceSchema = z.enum(['bundled', 'fetched']);
931
+ /**
932
+ * Advisory markers rendered on the revisions list. `shrink` = the node count
933
+ * collapsed against the revision in force; the revision is still installed,
934
+ * because a valid smaller taxonomy is the provider's decision to make and the
935
+ * impact preview is where it becomes visible (research §R22).
936
+ */
937
+ export const feedTaxonomyRevisionFlagSchema = z.enum(['shrink']);
938
+ export const feedTaxonomyRevisionSchema = z.object({
939
+ id: uuidSchema,
940
+ providerCode: taxonomyProviderCodeSchema,
941
+ /** Google: its own published label. Meta: `YYYY-MM-DD-<hash8>` (FR-088). */
942
+ revision: z.string().max(32),
943
+ /** The single selector of the revision in force. A fetched revision lands `false` (FR-086). */
944
+ isCurrent: z.boolean(),
945
+ nodeCount: z.number().int().nonnegative(),
946
+ source: feedTaxonomyRevisionSourceSchema,
947
+ /** Per-language source URL map; empty for a bundled revision. */
948
+ sourceUrls: z.record(z.string(), z.string().url()).default({}),
949
+ installedAt: isoDateTimeSchema,
950
+ fetchedAt: isoDateTimeSchema.nullable(),
951
+ /** Null ⇒ never in force. That predicate is also what protects the pending candidate from retention (FR-097). */
952
+ promotedAt: isoDateTimeSchema.nullable(),
953
+ supersededAt: isoDateTimeSchema.nullable(),
954
+ flags: z.array(feedTaxonomyRevisionFlagSchema).default([]),
955
+ });
956
+ export const feedTaxonomyRevisionListResponseSchema = collectionEnvelope(feedTaxonomyRevisionSchema);
957
+ export const feedTaxonomyRevisionResponseSchema = dataEnvelope(feedTaxonomyRevisionSchema);
958
+ /** One shop category that changes state if the candidate is promoted (FR-094). */
959
+ export const feedTaxonomyImpactCategorySchema = z.object({
960
+ categoryId: uuidSchema,
961
+ categoryName: z.string(),
962
+ nodeExternalId: z.string().max(32),
963
+ /** Localized path of the node as the CURRENT revision knows it — after promotion it may not exist. */
964
+ nodeFullPath: z.string().nullable(),
965
+ effect: z.enum(['becomes_stale', 'becomes_live', 'loses_coverage']),
966
+ /** Descendants that lose their inherited value through this category (FR-094). */
967
+ descendantsLosingCoverage: z.number().int().nonnegative(),
968
+ });
969
+ export const feedTaxonomyRevisionImpactResponseSchema = dataEnvelope(z.object({
970
+ providerCode: taxonomyProviderCodeSchema,
971
+ candidateRevision: z.string().max(32),
972
+ /** Null when the provider has no revision in force yet — then nothing can go stale. */
973
+ currentRevision: z.string().max(32).nullable(),
974
+ nodeCountCurrent: z.number().int().nonnegative(),
975
+ nodeCountCandidate: z.number().int().nonnegative(),
976
+ nodesAdded: z.number().int().nonnegative(),
977
+ nodesRemoved: z.number().int().nonnegative(),
978
+ mappings: z.object({
979
+ total: z.number().int().nonnegative(),
980
+ wouldRemainLive: z.number().int().nonnegative(),
981
+ /** The number the operator must echo back on promote (FR-095). */
982
+ wouldBecomeStale: z.number().int().nonnegative(),
983
+ wouldBecomeLive: z.number().int().nonnegative(),
984
+ }),
985
+ categories: z.object({
986
+ total: z.number().int().nonnegative(),
987
+ coveredNow: z.number().int().nonnegative(),
988
+ /** Counts inherited coverage, not only explicit mappings — the whole point of FR-094. */
989
+ coveredAfter: z.number().int().nonnegative(),
990
+ losingCoverage: z.number().int().nonnegative(),
991
+ }),
992
+ /** Capped for display; the full set is the stale review list after promotion. */
993
+ affected: z.array(feedTaxonomyImpactCategorySchema).max(200),
994
+ affectedTruncated: z.boolean(),
995
+ }));
996
+ export const promoteFeedTaxonomyRevisionRequestSchema = z.object({
997
+ /**
998
+ * The figure the impact preview showed. Recomputed server-side; a mismatch is
999
+ * refused `409 impact_changed` (FR-095). This is what makes "the operator saw
1000
+ * the impact" a server-side fact rather than a UI convention, and it catches
1001
+ * the real case: a colleague edited mappings while the preview sat open.
1002
+ */
1003
+ expectedStaleMappingCount: z.number().int().nonnegative(),
1004
+ });
1005
+ export const feedTaxonomyCheckTriggerSchema = z.enum(['scheduled', 'manual']);
1006
+ export const feedTaxonomyCheckOutcomeSchema = z.enum([
1007
+ 'unchanged',
1008
+ 'installed',
1009
+ 'rejected',
1010
+ 'failed',
1011
+ ]);
1012
+ /** Why a check did not install anything. Closed set — the admin renders a translated line per value. */
1013
+ export const feedTaxonomyCheckReasonSchema = z.enum([
1014
+ 'transport',
1015
+ 'not_found',
1016
+ 'http_status',
1017
+ 'not_taxonomy',
1018
+ 'empty',
1019
+ 'too_large',
1020
+ 'truncated',
1021
+ 'no_nodes',
1022
+ 'implausible',
1023
+ 'incomplete_languages',
1024
+ ]);
1025
+ export const feedTaxonomyCheckSchema = z.object({
1026
+ id: uuidSchema,
1027
+ providerCode: taxonomyProviderCodeSchema,
1028
+ trigger: feedTaxonomyCheckTriggerSchema,
1029
+ startedAt: isoDateTimeSchema,
1030
+ /** Null while in flight — also the predicate that refuses an overlapping check (FR-096). */
1031
+ finishedAt: isoDateTimeSchema.nullable(),
1032
+ outcome: feedTaxonomyCheckOutcomeSchema.nullable(),
1033
+ reason: feedTaxonomyCheckReasonSchema.nullable(),
1034
+ /** One human-readable line. Never a stack trace, never response bytes. */
1035
+ detail: z.string().max(500).nullable(),
1036
+ httpStatus: z.number().int().nullable(),
1037
+ bytesRead: z.number().int().nonnegative().nullable(),
1038
+ /** Recorded whatever the outcome — this is what makes "unchanged" auditable. */
1039
+ contentHash: z.string().max(64).nullable(),
1040
+ installedTaxonomyId: uuidSchema.nullable(),
1041
+ });
1042
+ export const feedTaxonomyCheckListResponseSchema = collectionEnvelope(feedTaxonomyCheckSchema);
1043
+ export const feedTaxonomyCheckResponseSchema = dataEnvelope(feedTaxonomyCheckSchema);
1044
+ export const startFeedTaxonomyCheckRequestSchema = z.object({
1045
+ /**
1046
+ * Required, and named deliberately. The design sketch had this optional with
1047
+ * "omitted ⇒ every provider", but the response envelope is **one** check —
1048
+ * `dataEnvelope(feedTaxonomyCheckSchema)` — so an omitted provider could not
1049
+ * be answered without either inventing a second envelope or picking one of
1050
+ * the two checks arbitrarily. The admin always sends the provider tab the
1051
+ * operator is looking at, and the scheduled job (which does sweep both
1052
+ * providers) needs no request body at all.
1053
+ */
1054
+ providerCode: taxonomyProviderCodeSchema,
1055
+ });
1056
+ /**
1057
+ * Source-URL validation, applied at settings-write time AND again immediately
1058
+ * before the request (FR-091). Twice, because settings can also be written by a
1059
+ * seed, a migration or an overlay, so the request-time check is the one that
1060
+ * actually holds. The address-range and redirect checks are NOT expressible in
1061
+ * Zod and live in the fetcher — see research §R23.
1062
+ */
1063
+ export const feedTaxonomySourceUrlSchema = z
1064
+ .string()
1065
+ .url()
1066
+ .max(500)
1067
+ .refine((value) => value.startsWith('https://'), {
1068
+ message: 'Taxonomy source URLs must use https.',
1069
+ })
1070
+ .refine((value) => !/^https:\/\/[^/]*@/.test(value), {
1071
+ message: 'Taxonomy source URLs must not carry credentials.',
1072
+ })
1073
+ .refine((value) => !value.includes('#'), {
1074
+ message: 'Taxonomy source URLs must not carry a fragment.',
1075
+ });
1076
+ // ---------------------------------------------------------------------------
1077
+ // (9) Template portability envelope (FR-012 – FR-018)
1078
+ // ---------------------------------------------------------------------------
1079
+ export const FEED_TEMPLATE_DOCUMENT_FORMAT_VERSION = 1;
1080
+ /**
1081
+ * Deliberately carries NO uuid, NO timestamp, NO feed binding and NO secret, so
1082
+ * two exports of an unchanged template are byte-identical (FR-013). Bindings
1083
+ * travel as stable definition KEYS, which is what makes cross-installation
1084
+ * import resolvable at all.
1085
+ */
1086
+ export const feedTemplateDocumentSchema = z.object({
1087
+ formatVersion: z.literal(FEED_TEMPLATE_DOCUMENT_FORMAT_VERSION),
1088
+ template: z.object({
1089
+ name: z.string().min(1).max(200),
1090
+ description: z.string().max(2000).nullable(),
1091
+ providerCode: feedProviderCodeSchema,
1092
+ outputFormat: feedOutputFormatSchema,
1093
+ itemGranularity: feedItemGranularitySchema,
1094
+ taxonomyProviderCode: taxonomyProviderCodeSchema.nullable(),
1095
+ /**
1096
+ * Bounded by the same ceiling as `createFeedTemplateRequestSchema.fields`.
1097
+ * A document is untrusted input from another installation, so the size of
1098
+ * what an import may insert in one transaction is decided here, before any
1099
+ * of it is read.
1100
+ */
1101
+ fields: z
1102
+ .array(z.object({
1103
+ outputName: z.string().min(1).max(128),
1104
+ sourceKind: feedFieldSourceKindSchema,
1105
+ sourceKey: z.string().max(128).nullable(),
1106
+ constantValue: z.string().max(2048).nullable(),
1107
+ fallbackValue: z.string().max(2048).nullable(),
1108
+ providerRequired: z.boolean(),
1109
+ transform: feedFieldTransformSchema.nullable(),
1110
+ transformArg: z.string().max(64).nullable(),
1111
+ sortOrder: z.number().int().nonnegative(),
1112
+ /**
1113
+ * Travels with the document: it is a key into the `product_feeds` i18n
1114
+ * namespace, which ships with the module on every installation, so a
1115
+ * Google-derived template keeps its glosses after a cross-installation
1116
+ * import. An unknown key renders as no gloss, never as a raw key.
1117
+ */
1118
+ helpKey: z.string().max(128).nullable(),
1119
+ }))
1120
+ .max(200),
1121
+ }),
1122
+ });
1123
+ export const importFeedTemplateRequestSchema = z.object({
1124
+ document: feedTemplateDocumentSchema,
1125
+ /** Required when the name collides with an existing template (FR-017). */
1126
+ onNameConflict: z.enum(['create_copy', 'replace']).optional(),
1127
+ });
1128
+ export const importFeedTemplateResponseSchema = dataEnvelope(z.object({
1129
+ template: feedTemplateSchema,
1130
+ /** Fields whose source key does not exist locally; imported as unbound (FR-015). */
1131
+ unresolvedBindings: z.array(z.object({
1132
+ outputName: z.string(),
1133
+ sourceKind: feedFieldSourceKindSchema,
1134
+ sourceKey: z.string(),
1135
+ })),
1136
+ }));
1137
+ // ---------------------------------------------------------------------------
1138
+ // (10) Module error codes
1139
+ // ---------------------------------------------------------------------------
1140
+ export const PRODUCT_FEED_ERROR_CODES = {
1141
+ TEMPLATE_IS_SYSTEM: 'template_is_system',
1142
+ TEMPLATE_IN_USE: 'template_in_use',
1143
+ DUPLICATE_OUTPUT_NAME: 'duplicate_output_name',
1144
+ UNBOUND_TEMPLATE_FIELDS: 'unbound_template_fields',
1145
+ TAXONOMY_REQUIRED_FOR_PROVIDER_CATEGORY: 'taxonomy_required_for_provider_category',
1146
+ GROUPING_FIELD_REQUIRED_FOR_VARIANT_GRANULARITY: 'grouping_field_required_for_variant_granularity',
1147
+ FEED_ALREADY_RUNNING: 'feed_already_running',
1148
+ FEED_DISABLED: 'feed_disabled',
1149
+ TOKEN_REVOKED: 'token_revoked',
1150
+ TEMPLATE_NAME_CONFLICT: 'template_name_conflict',
1151
+ INVALID_TEMPLATE_DOCUMENT: 'invalid_template_document',
1152
+ UNKNOWN_TAXONOMY_NODE: 'unknown_taxonomy_node',
1153
+ /** `POST /checks` while the master switch is off (FR-087) — the response names the setting. */
1154
+ TAXONOMY_FETCH_DISABLED: 'taxonomy_fetch_disabled',
1155
+ /** A check for that provider is already in flight (FR-096). */
1156
+ TAXONOMY_CHECK_IN_PROGRESS: 'taxonomy_check_in_progress',
1157
+ /** `expectedStaleMappingCount` no longer matches the recomputed impact (FR-095). */
1158
+ IMPACT_CHANGED: 'impact_changed',
1159
+ /** The revision is already the one in force. */
1160
+ TAXONOMY_REVISION_ALREADY_CURRENT: 'taxonomy_revision_already_current',
1161
+ // Feature 070 — delivery.
1162
+ /** The target address is refused by the egress guard (SR-2, SR-4). */
1163
+ DELIVERY_TARGET_REFUSED: 'delivery_target_refused',
1164
+ /** `POST /delivery/test` on a feed that has no delivery configuration. */
1165
+ DELIVERY_NOT_CONFIGURED: 'delivery_not_configured',
1166
+ /** The connection test is rate-limited per feed (SR-5). */
1167
+ DELIVERY_TEST_RATE_LIMITED: 'delivery_test_rate_limited',
1168
+ };
1169
+ // ---------------------------------------------------------------------------
1170
+ // (11) Settings codes (Settings module, group `product_feeds`)
1171
+ // ---------------------------------------------------------------------------
1172
+ export const PRODUCT_FEED_SETTING_CODES = {
1173
+ /**
1174
+ * Feature 074 — the operator-activation control (Constitution XVII), and the
1175
+ * only one of these codes that decides whether the module exists. It is not
1176
+ * the same switch as `TAXONOMY_FETCH_ENABLED` below, which governs one
1177
+ * outbound refresh inside a module that is present.
1178
+ */
1179
+ ACTIVATION: 'product_feeds.enabled',
1180
+ ARTEFACT_RETENTION_COUNT: 'product_feeds.artefact_retention_count',
1181
+ MAX_CONCURRENT_RUNS: 'product_feeds.max_concurrent_runs',
1182
+ SKIP_SHARE_FAILURE_THRESHOLD: 'product_feeds.skip_share_failure_threshold',
1183
+ STALE_CLAIM_TIMEOUT_MINUTES: 'product_feeds.stale_claim_timeout_minutes',
1184
+ RUN_ISSUE_CAP: 'product_feeds.run_issue_cap',
1185
+ PUBLIC_FETCH_RATE_LIMIT_PER_MINUTE: 'product_feeds.public_fetch_rate_limit_per_minute',
1186
+ /** Above this many shop categories the mapping surface switches from tree to paged flat list. */
1187
+ CATEGORY_MAPPING_TREE_LIMIT: 'product_feeds.category_mapping_tree_limit',
1188
+ // Group `product_feeds_taxonomy` — revision refresh (FR-086 – FR-099).
1189
+ /**
1190
+ * Master switch. **Defaults to `false`** and off is a first-class state: when
1191
+ * it is off no Job Scheduler exists, `POST /checks` is refused, and the module
1192
+ * makes no outbound request at all (FR-087, research §R24).
1193
+ */
1194
+ TAXONOMY_FETCH_ENABLED: 'product_feeds.taxonomy_fetch_enabled',
1195
+ /** 5-field cron, validated by the module's existing `cronExpressionSchema`. Default `0 4 * * 1`, UTC. */
1196
+ TAXONOMY_FETCH_CRON: 'product_feeds.taxonomy_fetch_cron',
1197
+ /** Per-deployment overridable source URLs — a mirror or an internal proxy (FR-090). */
1198
+ TAXONOMY_SOURCE_URL_GOOGLE_EN: 'product_feeds.taxonomy_source_url_google_en',
1199
+ TAXONOMY_SOURCE_URL_GOOGLE_PL: 'product_feeds.taxonomy_source_url_google_pl',
1200
+ TAXONOMY_SOURCE_URL_META_EN: 'product_feeds.taxonomy_source_url_meta_en',
1201
+ TAXONOMY_SOURCE_URL_META_PL: 'product_feeds.taxonomy_source_url_meta_pl',
1202
+ /** Retained revisions per provider; the three protected classes are never counted out (FR-097). */
1203
+ TAXONOMY_REVISION_RETENTION_COUNT: 'product_feeds.taxonomy_revision_retention_count',
1204
+ // Feature 070 — delivery (group `product_feeds`).
1205
+ /** Attempts per published artefact before the delivery is abandoned (FR-104). */
1206
+ DELIVERY_MAX_ATTEMPTS: 'product_feeds.delivery_max_attempts',
1207
+ /** Ceiling on `POST /delivery/test` per feed per hour (SR-5). */
1208
+ DELIVERY_TEST_RATE_LIMIT_PER_HOUR: 'product_feeds.delivery_test_rate_limit_per_hour',
1209
+ };
1210
+ /**
1211
+ * Egress safety limits are deliberately NOT settings (FR-091, research §R23):
1212
+ * they are safety floors, not operator policy, and an admin screen must not be
1213
+ * able to widen an SSRF guard.
1214
+ */
1215
+ export const TAXONOMY_FETCH_LIMITS = {
1216
+ /** Per-request abort, via `AbortController` — the `SgtmClient` precedent. */
1217
+ REQUEST_TIMEOUT_MS: 20_000,
1218
+ /** Whole check, across both languages of one provider. */
1219
+ CHECK_BUDGET_MS: 120_000,
1220
+ /** Enforced while reading the stream, never trusted from `Content-Length`. */
1221
+ MAX_RESPONSE_BYTES: 8 * 1024 * 1024,
1222
+ /** Every hop re-validated against the same rules. */
1223
+ MAX_REDIRECTS: 3,
1224
+ /** Below this a parsed file is treated as truncated rather than as a small taxonomy. */
1225
+ MIN_PLAUSIBLE_NODES: 500,
1226
+ /** Checks retained per provider — roughly five months of weekly history. */
1227
+ CHECK_HISTORY_PER_PROVIDER: 20,
1228
+ };
1229
+ // ---------------------------------------------------------------------------
1230
+ // (12) Feed delivery — feature 070
1231
+ //
1232
+ // Where a successful run's artefact is PUSHED, and by what protocol. Delivery
1233
+ // runs after publication, never instead of it, so the pull URL keeps working
1234
+ // for a feed that uses both (spec § Scope).
1235
+ //
1236
+ // The operator request named five protocols; they are three mechanisms. `HTTP
1237
+ // Server`, `API` and `GraphQL` are the same two fields with the same help text
1238
+ // in the reference screenshots, so they are ONE stored protocol (`http`) with
1239
+ // an operator-visible label. Three code paths that must be kept byte-identical
1240
+ // forever is three ways to file the same bug.
1241
+ // ---------------------------------------------------------------------------
1242
+ /** The three mechanisms. Stored verbatim on `product_feed_deliveries.protocol`. */
1243
+ export const feedDeliveryProtocolSchema = z.enum(['sftp', 'ftp', 'http']);
1244
+ /**
1245
+ * The operator's vocabulary for the `http` protocol. Presentation only: it
1246
+ * selects a label and nothing else, and every value behaves identically.
1247
+ */
1248
+ export const feedDeliveryHttpLabelSchema = z.enum(['http_server', 'api', 'graphql']);
1249
+ export const feedDeliveryStatusSchema = z.enum(['succeeded', 'failed']);
1250
+ /**
1251
+ * Why an attempt failed, as a closed set an operator can be told about in their
1252
+ * own language. `failureDetail` carries the transport's own words, redacted
1253
+ * (FR-108); this is what the admin renders.
1254
+ */
1255
+ export const feedDeliveryFailureReasonSchema = z.enum([
1256
+ /** The configuration is incomplete or its credential is gone. */
1257
+ 'not_configured',
1258
+ /** The egress guard refused the address (SR-2, SR-4). */
1259
+ 'target_refused',
1260
+ /** The host answered but rejected the credentials. */
1261
+ 'authentication_failed',
1262
+ /** No usable connection — DNS, TCP, TLS or timeout. */
1263
+ 'connection_failed',
1264
+ /** Connected and authenticated, but the transfer itself did not complete. */
1265
+ 'transfer_failed',
1266
+ /** An HTTP target answered with a non-2xx status. */
1267
+ 'rejected_by_target',
1268
+ /** The artefact's bytes could not be read back from storage. */
1269
+ 'artefact_unavailable',
1270
+ 'internal_error',
1271
+ ]);
1272
+ /**
1273
+ * A transport refusal an operator can be told about, carrying the closed-set
1274
+ * reason above and the transport's own words.
1275
+ *
1276
+ * Adapters throw this rather than a bare `Error` so `product_feeds` does not
1277
+ * have to guess a reason from a message, and the service's classifier decides
1278
+ * on `instanceof`.
1279
+ *
1280
+ * **It lives here rather than beside the adapter interface because an adapter
1281
+ * is a contribution and its author is not always the module** (feature 080,
1282
+ * T040b). Every delivery adapter this platform runs is contributed from
1283
+ * outside `product_feeds` — the composition roots contribute the real three and
1284
+ * the test harness contributes refusing ones — so the class has to be nameable
1285
+ * from outside without naming the module's sources. Once the module is a
1286
+ * package that is not a style preference: a second evaluation of the module's
1287
+ * source is a second class object, `instanceof` is false across the two copies,
1288
+ * and every declared refusal silently reclassifies as `internal_error` and
1289
+ * becomes retryable (D-160.6.1; the same shape that made a KSeF outage answer
1290
+ * `UNEXPECTED`). `@endora-commerce/contracts` is resolved once, so the
1291
+ * comparison holds.
1292
+ *
1293
+ * `detail` is **not** redacted by the thrower — an adapter does not know the
1294
+ * full secret set. `DeliveryService` redacts on the way to the attempt row
1295
+ * (FR-108).
1296
+ */
1297
+ export class FeedDeliveryError extends Error {
1298
+ reason;
1299
+ cause;
1300
+ name = 'FeedDeliveryError';
1301
+ constructor(reason, message, cause) {
1302
+ super(message);
1303
+ this.reason = reason;
1304
+ this.cause = cause;
1305
+ }
1306
+ }
1307
+ /**
1308
+ * The header names whose VALUE is treated as a secret and stored through the
1309
+ * credentials module rather than in the configuration row (FR-107).
1310
+ *
1311
+ * A closed prefix/suffix rule rather than an operator toggle: an operator who
1312
+ * has to remember to tick "this one is secret" will one day not, and the token
1313
+ * lands in a jsonb column that every read returns. Matching is
1314
+ * case-insensitive on the header name.
1315
+ */
1316
+ export const FEED_DELIVERY_SECRET_HEADER_NAMES = [
1317
+ 'authorization',
1318
+ 'proxy-authorization',
1319
+ 'cookie',
1320
+ ];
1321
+ export const FEED_DELIVERY_SECRET_HEADER_SUFFIXES = [
1322
+ '-key',
1323
+ '-token',
1324
+ '-secret',
1325
+ '-password',
1326
+ '-auth',
1327
+ ];
1328
+ /** True when this header's value must be stored as a secret (FR-107). */
1329
+ export function isSecretDeliveryHeader(name) {
1330
+ const lower = name.trim().toLowerCase();
1331
+ if (FEED_DELIVERY_SECRET_HEADER_NAMES.includes(lower))
1332
+ return true;
1333
+ return FEED_DELIVERY_SECRET_HEADER_SUFFIXES.some((suffix) => lower.endsWith(suffix));
1334
+ }
1335
+ /**
1336
+ * The sentinel a read returns in place of a stored secret, and which a write
1337
+ * may send back to mean "keep what is there". The credentials module's own
1338
+ * write-only semantics, applied rather than re-invented.
1339
+ */
1340
+ export const FEED_DELIVERY_REDACTED = '[redacted]';
1341
+ /** RFC 7230 field-name grammar, minus the characters no real header uses. */
1342
+ const headerNameSchema = z
1343
+ .string()
1344
+ .trim()
1345
+ .min(1)
1346
+ .max(128)
1347
+ .regex(/^[A-Za-z0-9!#$%&'*+\-.^_`|~]+$/, 'invalid_header_name');
1348
+ /** No CR/LF: a header value that can inject a second header is a request smuggler. */
1349
+ const headerValueSchema = z
1350
+ .string()
1351
+ .max(2048)
1352
+ .regex(/^[^\r\n]*$/, 'invalid_header_value');
1353
+ export const feedDeliveryHeaderInputSchema = z.object({
1354
+ name: headerNameSchema,
1355
+ value: headerValueSchema,
1356
+ });
1357
+ /**
1358
+ * A header as read back. A secret one carries `value: null` and `isSet`, never
1359
+ * the stored token — the same rule the credentials DTO applies to every secret
1360
+ * field.
1361
+ */
1362
+ export const feedDeliveryHeaderSchema = z.object({
1363
+ name: z.string(),
1364
+ value: z.string().nullable(),
1365
+ secret: z.boolean(),
1366
+ isSet: z.boolean(),
1367
+ });
1368
+ const MAX_DELIVERY_HEADERS = 25;
1369
+ /**
1370
+ * The write body of `PUT /api/v1/admin/product-feeds/:id/delivery`.
1371
+ *
1372
+ * A discriminated union on `protocol`, so a body carrying an SFTP host and an
1373
+ * HTTP request URL is unrepresentable at the API boundary even though the table
1374
+ * is deliberately permissive (plan.md § Data model).
1375
+ *
1376
+ * `password` and `privateKey` are **write-only**: omitted, blank or
1377
+ * `[redacted]` preserves the stored envelope, so editing a directory path can
1378
+ * never silently erase a key.
1379
+ */
1380
+ const deliveryCommonSchema = {
1381
+ enabled: z.boolean(),
1382
+ /** Optimistic lock. Absent on the first write, which is the create. */
1383
+ expectedVersion: z.number().int().nonnegative().optional(),
1384
+ };
1385
+ const hostFieldSchema = z.string().trim().min(1).max(255);
1386
+ const portFieldSchema = z.number().int().min(1).max(65_535).nullable();
1387
+ export const upsertFeedDeliveryRequestSchema = z.discriminatedUnion('protocol', [
1388
+ z.object({
1389
+ ...deliveryCommonSchema,
1390
+ protocol: z.literal('sftp'),
1391
+ host: hostFieldSchema,
1392
+ port: portFieldSchema.optional(),
1393
+ username: z.string().trim().min(1).max(255),
1394
+ password: z.string().max(1024).optional(),
1395
+ privateKey: z.string().max(32_768).optional(),
1396
+ directoryPath: z.string().trim().max(1024).optional(),
1397
+ }),
1398
+ z.object({
1399
+ ...deliveryCommonSchema,
1400
+ protocol: z.literal('ftp'),
1401
+ host: hostFieldSchema,
1402
+ port: portFieldSchema.optional(),
1403
+ username: z.string().trim().min(1).max(255),
1404
+ password: z.string().max(1024).optional(),
1405
+ directoryPath: z.string().trim().max(1024).optional(),
1406
+ /** FTP's own connection mode. Passive is the one that works behind NAT. */
1407
+ passiveMode: z.boolean().optional(),
1408
+ }),
1409
+ z.object({
1410
+ ...deliveryCommonSchema,
1411
+ protocol: z.literal('http'),
1412
+ /** Presentation only — HTTP Server / API / GraphQL all POST identically. */
1413
+ httpLabel: feedDeliveryHttpLabelSchema.optional(),
1414
+ /** `https` only (SR-4); validated again against the egress guard on write. */
1415
+ requestUrl: z.string().trim().min(1).max(2048),
1416
+ headers: z.array(feedDeliveryHeaderInputSchema).max(MAX_DELIVERY_HEADERS).optional(),
1417
+ }),
1418
+ ]);
1419
+ /**
1420
+ * The delivery configuration as read back. Every secret is a boolean; nothing
1421
+ * on this shape can be replayed as a credential (FR-107).
1422
+ */
1423
+ export const feedDeliveryConfigSchema = z.object({
1424
+ id: uuidSchema,
1425
+ productFeedId: uuidSchema,
1426
+ enabled: z.boolean(),
1427
+ protocol: feedDeliveryProtocolSchema,
1428
+ httpLabel: feedDeliveryHttpLabelSchema.nullable(),
1429
+ host: z.string().nullable(),
1430
+ /** Null means "the protocol's own default" — 22 for SFTP, 21 for FTP. */
1431
+ port: z.number().int().nullable(),
1432
+ username: z.string().nullable(),
1433
+ directoryPath: z.string().nullable(),
1434
+ passiveMode: z.boolean(),
1435
+ requestUrl: z.string().nullable(),
1436
+ headers: z.array(feedDeliveryHeaderSchema),
1437
+ passwordSet: z.boolean(),
1438
+ privateKeySet: z.boolean(),
1439
+ version: z.number().int(),
1440
+ createdAt: isoDateTimeSchema,
1441
+ updatedAt: isoDateTimeSchema,
1442
+ });
1443
+ /** `GET`/`PUT /api/v1/admin/product-feeds/:id/delivery`. `null` means unconfigured. */
1444
+ export const feedDeliveryConfigResponseSchema = dataEnvelope(feedDeliveryConfigSchema.nullable());
1445
+ /**
1446
+ * One recorded attempt (FR-105). `target` is the **redacted display form** —
1447
+ * `sftp://user@host:22/path`, never a password and never a header value.
1448
+ */
1449
+ export const feedDeliveryAttemptSchema = z.object({
1450
+ id: uuidSchema,
1451
+ productFeedId: uuidSchema,
1452
+ feedRunId: uuidSchema.nullable(),
1453
+ feedArtefactId: uuidSchema.nullable(),
1454
+ protocol: feedDeliveryProtocolSchema,
1455
+ target: z.string(),
1456
+ status: feedDeliveryStatusSchema,
1457
+ failureReason: feedDeliveryFailureReasonSchema.nullable(),
1458
+ failureDetail: z.string().nullable(),
1459
+ /** 1-based, so "attempt 3 of 5" reads the way an operator counts. */
1460
+ attempt: z.number().int().positive(),
1461
+ /** A "Test Connection" attempt (FR-106) — it delivered no artefact. */
1462
+ isTest: z.boolean(),
1463
+ startedAt: isoDateTimeSchema,
1464
+ finishedAt: isoDateTimeSchema.nullable(),
1465
+ durationMs: z.number().int().nullable(),
1466
+ });
1467
+ /** `GET /api/v1/admin/product-feeds/:id/delivery/attempts`. */
1468
+ export const feedDeliveryAttemptListResponseSchema = collectionEnvelope(feedDeliveryAttemptSchema);
1469
+ /** `POST /api/v1/admin/product-feeds/:id/delivery/test` (FR-106). */
1470
+ export const feedDeliveryTestResponseSchema = dataEnvelope(z.object({
1471
+ ok: z.boolean(),
1472
+ failureReason: feedDeliveryFailureReasonSchema.nullable(),
1473
+ failureDetail: z.string().nullable(),
1474
+ attempt: feedDeliveryAttemptSchema,
1475
+ }));
1476
+ /**
1477
+ * Egress and transport safety limits. Not settings, for the reason
1478
+ * `TAXONOMY_FETCH_LIMITS` is not: an admin screen that can widen an SSRF guard
1479
+ * or remove a timeout is an SSRF guard with an off switch.
1480
+ */
1481
+ export const FEED_DELIVERY_LIMITS = {
1482
+ /** Per-attempt ceiling on the whole transfer, whatever the protocol. */
1483
+ TRANSFER_TIMEOUT_MS: 300_000,
1484
+ /** Connect + authenticate. A host that cannot answer this fast is down. */
1485
+ CONNECT_TIMEOUT_MS: 20_000,
1486
+ /**
1487
+ * One hop, re-validated, and the credential headers are re-attached only if
1488
+ * the host is unchanged (SR-3).
1489
+ */
1490
+ MAX_REDIRECTS: 1,
1491
+ /** Attempts retained per feed. */
1492
+ ATTEMPT_HISTORY_PER_FEED: 50,
1493
+ /** Delivery attempts per published artefact, unless the setting overrides it. */
1494
+ DEFAULT_MAX_ATTEMPTS: 5,
1495
+ /** Backoff base; the worker multiplies it exponentially per attempt. */
1496
+ RETRY_BACKOFF_MS: 60_000,
1497
+ /** `POST /delivery/test` per feed per hour, unless the setting overrides it. */
1498
+ DEFAULT_TEST_RATE_LIMIT_PER_HOUR: 20,
1499
+ /** The fixed, non-operator-controlled body a `http` connection test sends (SR-5). */
1500
+ TEST_PAYLOAD: 'endora-commerce feed delivery connection test',
1501
+ /** The filename a test writes and removes on an SFTP/FTP target. */
1502
+ TEST_FILENAME: '.endora-delivery-test',
1503
+ };
1504
+ //# sourceMappingURL=product-feeds.js.map