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