@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.
- package/LICENSE +21 -0
- package/README.md +34 -0
- package/dist/actor.d.ts +79 -0
- package/dist/actor.d.ts.map +1 -0
- package/dist/actor.js +41 -0
- package/dist/actor.js.map +1 -0
- package/dist/addresses.d.ts +134 -0
- package/dist/addresses.d.ts.map +1 -0
- package/dist/addresses.js +16 -0
- package/dist/addresses.js.map +1 -0
- package/dist/admin-actions.d.ts +367 -0
- package/dist/admin-actions.d.ts.map +1 -0
- package/dist/admin-actions.js +287 -0
- package/dist/admin-actions.js.map +1 -0
- package/dist/admin-contributions.d.ts +518 -0
- package/dist/admin-contributions.d.ts.map +1 -0
- package/dist/admin-contributions.js +495 -0
- package/dist/admin-contributions.js.map +1 -0
- package/dist/admin-i18n.d.ts +135 -0
- package/dist/admin-i18n.d.ts.map +1 -0
- package/dist/admin-i18n.js +72 -0
- package/dist/admin-i18n.js.map +1 -0
- package/dist/admin-notifications.d.ts +55 -0
- package/dist/admin-notifications.d.ts.map +1 -0
- package/dist/admin-notifications.js +16 -0
- package/dist/admin-notifications.js.map +1 -0
- package/dist/admin-roles.d.ts +125 -0
- package/dist/admin-roles.d.ts.map +1 -0
- package/dist/admin-roles.js +2 -0
- package/dist/admin-roles.js.map +1 -0
- package/dist/admin-users.d.ts +178 -0
- package/dist/admin-users.d.ts.map +1 -0
- package/dist/admin-users.js +14 -0
- package/dist/admin-users.js.map +1 -0
- package/dist/admin.d.ts +243 -0
- package/dist/admin.d.ts.map +1 -0
- package/dist/admin.js +246 -0
- package/dist/admin.js.map +1 -0
- package/dist/analytics.d.ts +123 -0
- package/dist/analytics.d.ts.map +1 -0
- package/dist/analytics.js +68 -0
- package/dist/analytics.js.map +1 -0
- package/dist/api-keys.d.ts +97 -0
- package/dist/api-keys.d.ts.map +1 -0
- package/dist/api-keys.js +64 -0
- package/dist/api-keys.js.map +1 -0
- package/dist/assets-library.d.ts +684 -0
- package/dist/assets-library.d.ts.map +1 -0
- package/dist/assets-library.js +181 -0
- package/dist/assets-library.js.map +1 -0
- package/dist/audit-logs.d.ts +141 -0
- package/dist/audit-logs.d.ts.map +1 -0
- package/dist/audit-logs.js +31 -0
- package/dist/audit-logs.js.map +1 -0
- package/dist/auth.d.ts +174 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +27 -0
- package/dist/auth.js.map +1 -0
- package/dist/blog.d.ts +669 -0
- package/dist/blog.d.ts.map +1 -0
- package/dist/blog.js +360 -0
- package/dist/blog.js.map +1 -0
- package/dist/capabilities.d.ts +40 -0
- package/dist/capabilities.d.ts.map +1 -0
- package/dist/capabilities.js +38 -0
- package/dist/capabilities.js.map +1 -0
- package/dist/carts.d.ts +1367 -0
- package/dist/carts.d.ts.map +1 -0
- package/dist/carts.js +405 -0
- package/dist/carts.js.map +1 -0
- package/dist/catalog.d.ts +2855 -0
- package/dist/catalog.d.ts.map +1 -0
- package/dist/catalog.js +1543 -0
- package/dist/catalog.js.map +1 -0
- package/dist/cms.d.ts +872 -0
- package/dist/cms.d.ts.map +1 -0
- package/dist/cms.js +468 -0
- package/dist/cms.js.map +1 -0
- package/dist/common.d.ts +82 -0
- package/dist/common.d.ts.map +1 -0
- package/dist/common.js +72 -0
- package/dist/common.js.map +1 -0
- package/dist/comparisons.d.ts +487 -0
- package/dist/comparisons.d.ts.map +1 -0
- package/dist/comparisons.js +221 -0
- package/dist/comparisons.js.map +1 -0
- package/dist/credentials.d.ts +292 -0
- package/dist/credentials.d.ts.map +1 -0
- package/dist/credentials.js +142 -0
- package/dist/credentials.js.map +1 -0
- package/dist/credit-limits.d.ts +111 -0
- package/dist/credit-limits.d.ts.map +1 -0
- package/dist/credit-limits.js +35 -0
- package/dist/credit-limits.js.map +1 -0
- package/dist/currencies.d.ts +127 -0
- package/dist/currencies.d.ts.map +1 -0
- package/dist/currencies.js +20 -0
- package/dist/currencies.js.map +1 -0
- package/dist/custom-fields.d.ts +345 -0
- package/dist/custom-fields.d.ts.map +1 -0
- package/dist/custom-fields.js +185 -0
- package/dist/custom-fields.js.map +1 -0
- package/dist/customer-accounts.d.ts +690 -0
- package/dist/customer-accounts.d.ts.map +1 -0
- package/dist/customer-accounts.js +41 -0
- package/dist/customer-accounts.js.map +1 -0
- package/dist/customers.d.ts +305 -0
- package/dist/customers.d.ts.map +1 -0
- package/dist/customers.js +158 -0
- package/dist/customers.js.map +1 -0
- package/dist/dictionary.d.ts +580 -0
- package/dist/dictionary.d.ts.map +1 -0
- package/dist/dictionary.js +297 -0
- package/dist/dictionary.js.map +1 -0
- package/dist/email-address.d.ts +62 -0
- package/dist/email-address.d.ts.map +1 -0
- package/dist/email-address.js +64 -0
- package/dist/email-address.js.map +1 -0
- package/dist/email.d.ts +175 -0
- package/dist/email.d.ts.map +1 -0
- package/dist/email.js +45 -0
- package/dist/email.js.map +1 -0
- package/dist/envelopes.d.ts +15 -0
- package/dist/envelopes.d.ts.map +1 -0
- package/dist/envelopes.js +16 -0
- package/dist/envelopes.js.map +1 -0
- package/dist/environment-inputs.d.ts +306 -0
- package/dist/environment-inputs.d.ts.map +1 -0
- package/dist/environment-inputs.js +277 -0
- package/dist/environment-inputs.js.map +1 -0
- package/dist/erp-connector.d.ts +52 -0
- package/dist/erp-connector.d.ts.map +1 -0
- package/dist/erp-connector.js +34 -0
- package/dist/erp-connector.js.map +1 -0
- package/dist/errors.d.ts +455 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +532 -0
- package/dist/errors.js.map +1 -0
- package/dist/google-analytics.d.ts +181 -0
- package/dist/google-analytics.d.ts.map +1 -0
- package/dist/google-analytics.js +176 -0
- package/dist/google-analytics.js.map +1 -0
- package/dist/google-tag-manager.d.ts +111 -0
- package/dist/google-tag-manager.d.ts.map +1 -0
- package/dist/google-tag-manager.js +129 -0
- package/dist/google-tag-manager.js.map +1 -0
- package/dist/i18n.d.ts +69 -0
- package/dist/i18n.d.ts.map +1 -0
- package/dist/i18n.js +59 -0
- package/dist/i18n.js.map +1 -0
- package/dist/import-export.d.ts +63 -0
- package/dist/import-export.d.ts.map +1 -0
- package/dist/import-export.js +37 -0
- package/dist/import-export.js.map +1 -0
- package/dist/index.d.ts +82 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +126 -0
- package/dist/index.js.map +1 -0
- package/dist/inventory.d.ts +673 -0
- package/dist/inventory.d.ts.map +1 -0
- package/dist/inventory.js +412 -0
- package/dist/inventory.js.map +1 -0
- package/dist/invoice-ledger.d.ts +366 -0
- package/dist/invoice-ledger.d.ts.map +1 -0
- package/dist/invoice-ledger.js +114 -0
- package/dist/invoice-ledger.js.map +1 -0
- package/dist/invoices.d.ts +845 -0
- package/dist/invoices.d.ts.map +1 -0
- package/dist/invoices.js +314 -0
- package/dist/invoices.js.map +1 -0
- package/dist/kernel.d.ts +49 -0
- package/dist/kernel.d.ts.map +1 -0
- package/dist/kernel.js +19 -0
- package/dist/kernel.js.map +1 -0
- package/dist/languages.d.ts +122 -0
- package/dist/languages.d.ts.map +1 -0
- package/dist/languages.js +24 -0
- package/dist/languages.js.map +1 -0
- package/dist/linkedin-ads.d.ts +167 -0
- package/dist/linkedin-ads.d.ts.map +1 -0
- package/dist/linkedin-ads.js +156 -0
- package/dist/linkedin-ads.js.map +1 -0
- package/dist/megamenu.d.ts +556 -0
- package/dist/megamenu.d.ts.map +1 -0
- package/dist/megamenu.js +186 -0
- package/dist/megamenu.js.map +1 -0
- package/dist/meta-ads.d.ts +126 -0
- package/dist/meta-ads.d.ts.map +1 -0
- package/dist/meta-ads.js +112 -0
- package/dist/meta-ads.js.map +1 -0
- package/dist/mfa.d.ts +274 -0
- package/dist/mfa.d.ts.map +1 -0
- package/dist/mfa.js +187 -0
- package/dist/mfa.js.map +1 -0
- package/dist/modules.d.ts +1706 -0
- package/dist/modules.d.ts.map +1 -0
- package/dist/modules.js +1390 -0
- package/dist/modules.js.map +1 -0
- package/dist/newsletter.d.ts +611 -0
- package/dist/newsletter.d.ts.map +1 -0
- package/dist/newsletter.js +345 -0
- package/dist/newsletter.js.map +1 -0
- package/dist/orders.d.ts +1175 -0
- package/dist/orders.d.ts.map +1 -0
- package/dist/orders.js +630 -0
- package/dist/orders.js.map +1 -0
- package/dist/organizations.d.ts +938 -0
- package/dist/organizations.d.ts.map +1 -0
- package/dist/organizations.js +418 -0
- package/dist/organizations.js.map +1 -0
- package/dist/pagination.d.ts +21 -0
- package/dist/pagination.d.ts.map +1 -0
- package/dist/pagination.js +22 -0
- package/dist/pagination.js.map +1 -0
- package/dist/payment-methods.d.ts +472 -0
- package/dist/payment-methods.d.ts.map +1 -0
- package/dist/payment-methods.js +175 -0
- package/dist/payment-methods.js.map +1 -0
- package/dist/payment-return-url.d.ts +53 -0
- package/dist/payment-return-url.d.ts.map +1 -0
- package/dist/payment-return-url.js +35 -0
- package/dist/payment-return-url.js.map +1 -0
- package/dist/payments.d.ts +386 -0
- package/dist/payments.d.ts.map +1 -0
- package/dist/payments.js +84 -0
- package/dist/payments.js.map +1 -0
- package/dist/pim-connector.d.ts +60 -0
- package/dist/pim-connector.d.ts.map +1 -0
- package/dist/pim-connector.js +43 -0
- package/dist/pim-connector.js.map +1 -0
- package/dist/pim-field-path.d.ts +6 -0
- package/dist/pim-field-path.d.ts.map +1 -0
- package/dist/pim-field-path.js +101 -0
- package/dist/pim-field-path.js.map +1 -0
- package/dist/platform-language.d.ts +20 -0
- package/dist/platform-language.d.ts.map +1 -0
- package/dist/platform-language.js +22 -0
- package/dist/platform-language.js.map +1 -0
- package/dist/price-lists.d.ts +685 -0
- package/dist/price-lists.d.ts.map +1 -0
- package/dist/price-lists.js +330 -0
- package/dist/price-lists.js.map +1 -0
- package/dist/product-feeds.d.ts +2837 -0
- package/dist/product-feeds.d.ts.map +1 -0
- package/dist/product-feeds.js +1504 -0
- package/dist/product-feeds.js.map +1 -0
- package/dist/product-scope-overrides.d.ts +134 -0
- package/dist/product-scope-overrides.d.ts.map +1 -0
- package/dist/product-scope-overrides.js +82 -0
- package/dist/product-scope-overrides.js.map +1 -0
- package/dist/product-value-resolver.d.ts +88 -0
- package/dist/product-value-resolver.d.ts.map +1 -0
- package/dist/product-value-resolver.js +128 -0
- package/dist/product-value-resolver.js.map +1 -0
- package/dist/promotions.d.ts +678 -0
- package/dist/promotions.d.ts.map +1 -0
- package/dist/promotions.js +479 -0
- package/dist/promotions.js.map +1 -0
- package/dist/prompt-actions.d.ts +582 -0
- package/dist/prompt-actions.d.ts.map +1 -0
- package/dist/prompt-actions.js +221 -0
- package/dist/prompt-actions.js.map +1 -0
- package/dist/pwa.d.ts +293 -0
- package/dist/pwa.d.ts.map +1 -0
- package/dist/pwa.js +204 -0
- package/dist/pwa.js.map +1 -0
- package/dist/quick-order.d.ts +340 -0
- package/dist/quick-order.d.ts.map +1 -0
- package/dist/quick-order.js +177 -0
- package/dist/quick-order.js.map +1 -0
- package/dist/quote-requests.d.ts +538 -0
- package/dist/quote-requests.d.ts.map +1 -0
- package/dist/quote-requests.js +308 -0
- package/dist/quote-requests.js.map +1 -0
- package/dist/returns.d.ts +774 -0
- package/dist/returns.d.ts.map +1 -0
- package/dist/returns.js +389 -0
- package/dist/returns.js.map +1 -0
- package/dist/sales-channels.d.ts +392 -0
- package/dist/sales-channels.d.ts.map +1 -0
- package/dist/sales-channels.js +285 -0
- package/dist/sales-channels.js.map +1 -0
- package/dist/scope-notice.d.ts +60 -0
- package/dist/scope-notice.d.ts.map +1 -0
- package/dist/scope-notice.js +56 -0
- package/dist/scope-notice.js.map +1 -0
- package/dist/search.d.ts +321 -0
- package/dist/search.d.ts.map +1 -0
- package/dist/search.js +160 -0
- package/dist/search.js.map +1 -0
- package/dist/seo.d.ts +113 -0
- package/dist/seo.d.ts.map +1 -0
- package/dist/seo.js +63 -0
- package/dist/seo.js.map +1 -0
- package/dist/settings.d.ts +453 -0
- package/dist/settings.d.ts.map +1 -0
- package/dist/settings.js +337 -0
- package/dist/settings.js.map +1 -0
- package/dist/shipments.d.ts +140 -0
- package/dist/shipments.d.ts.map +1 -0
- package/dist/shipments.js +14 -0
- package/dist/shipments.js.map +1 -0
- package/dist/shipping-methods.d.ts +350 -0
- package/dist/shipping-methods.d.ts.map +1 -0
- package/dist/shipping-methods.js +99 -0
- package/dist/shipping-methods.js.map +1 -0
- package/dist/shopping-lists.d.ts +122 -0
- package/dist/shopping-lists.d.ts.map +1 -0
- package/dist/shopping-lists.js +92 -0
- package/dist/shopping-lists.js.map +1 -0
- package/dist/taxes.d.ts +106 -0
- package/dist/taxes.d.ts.map +1 -0
- package/dist/taxes.js +80 -0
- package/dist/taxes.js.map +1 -0
- package/dist/text-normalization.d.ts +199 -0
- package/dist/text-normalization.d.ts.map +1 -0
- package/dist/text-normalization.js +205 -0
- package/dist/text-normalization.js.map +1 -0
- package/dist/transactional-emails.d.ts +459 -0
- package/dist/transactional-emails.d.ts.map +1 -0
- package/dist/transactional-emails.js +212 -0
- package/dist/transactional-emails.js.map +1 -0
- package/dist/webhooks.d.ts +69 -0
- package/dist/webhooks.d.ts.map +1 -0
- package/dist/webhooks.js +53 -0
- package/dist/webhooks.js.map +1 -0
- 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
|