@sixtyninecommerce/admin 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/README.md +1501 -23
  2. package/dist/auth/index.d.ts +7 -1
  3. package/dist/auth/index.js +8 -1
  4. package/dist/catalog/documents.d.ts +142 -0
  5. package/dist/catalog/documents.js +114 -0
  6. package/dist/catalog/index.d.ts +48 -7
  7. package/dist/catalog/index.js +6 -1
  8. package/dist/catalog/metadata.d.ts +244 -0
  9. package/dist/catalog/metadata.js +220 -0
  10. package/dist/catalog/properties.d.ts +38 -0
  11. package/dist/catalog/properties.js +52 -1
  12. package/dist/catalog/recommendations.d.ts +33 -6
  13. package/dist/catalog/recommendations.js +94 -2
  14. package/dist/catalog/reviews.d.ts +17 -4
  15. package/dist/catalog/reviews.js +7 -0
  16. package/dist/catalog/variants.d.ts +60 -0
  17. package/dist/catalog/variants.js +39 -0
  18. package/dist/catalog/visibility.d.ts +18 -2
  19. package/dist/client.d.ts +29 -3
  20. package/dist/client.js +19 -8
  21. package/dist/content/assets.d.ts +3 -4
  22. package/dist/content/forms.d.ts +332 -1
  23. package/dist/content/forms.js +140 -4
  24. package/dist/content/items.d.ts +31 -8
  25. package/dist/content/items.js +1 -1
  26. package/dist/content/menus.d.ts +254 -0
  27. package/dist/content/menus.js +70 -0
  28. package/dist/content/strings.d.ts +137 -0
  29. package/dist/content/strings.js +82 -0
  30. package/dist/customers/delivery-terms.d.ts +44 -0
  31. package/dist/customers/delivery-terms.js +32 -0
  32. package/dist/customers/index.d.ts +15 -0
  33. package/dist/customers/index.js +4 -0
  34. package/dist/documents/emails.d.ts +150 -0
  35. package/dist/documents/emails.js +220 -0
  36. package/dist/documents/index.d.ts +13 -0
  37. package/dist/documents/index.js +2 -0
  38. package/dist/fulfillments/index.d.ts +119 -1
  39. package/dist/fulfillments/index.js +124 -4
  40. package/dist/index.d.ts +43 -23
  41. package/dist/index.js +11 -5
  42. package/dist/integrations/exact-online-types.d.ts +542 -0
  43. package/dist/integrations/exact-online.d.ts +541 -5
  44. package/dist/integrations/exact-online.js +714 -1
  45. package/dist/integrations/index.d.ts +23 -8
  46. package/dist/integrations/index.js +9 -4
  47. package/dist/integrations/pipeline-discoveries.d.ts +65 -0
  48. package/dist/integrations/pipeline-discoveries.js +65 -0
  49. package/dist/integrations/pipeline-runs.d.ts +119 -0
  50. package/dist/integrations/pipeline-runs.js +87 -0
  51. package/dist/integrations/pipelines.d.ts +73 -2
  52. package/dist/integrations/pipelines.js +36 -1
  53. package/dist/integrations/price-import.d.ts +223 -0
  54. package/dist/integrations/price-import.js +124 -0
  55. package/dist/integrations/types.d.ts +221 -3
  56. package/dist/integrations/types.js +4 -3
  57. package/dist/meta/index.d.ts +10 -0
  58. package/dist/metafields/index.d.ts +8 -1
  59. package/dist/metafields/index.js +10 -0
  60. package/dist/orders/cancel-with-money.d.ts +10 -0
  61. package/dist/orders/cancel-with-money.js +2 -0
  62. package/dist/orders/commit-edit.d.ts +16 -0
  63. package/dist/orders/commit-edit.js +14 -1
  64. package/dist/orders/deposit-policy.d.ts +3 -1
  65. package/dist/orders/index.d.ts +22 -11
  66. package/dist/orders/index.js +5 -2
  67. package/dist/orders/list.d.ts +26 -5
  68. package/dist/orders/list.js +123 -13
  69. package/dist/orders/manual-discounts.d.ts +13 -5
  70. package/dist/orders/money-view.d.ts +149 -0
  71. package/dist/orders/money-view.js +99 -0
  72. package/dist/orders/plan-edit.d.ts +34 -2
  73. package/dist/orders/plan-edit.js +110 -12
  74. package/dist/orders/surcharges.d.ts +12 -0
  75. package/dist/orders/surcharges.js +34 -9
  76. package/dist/payments/account-credit.d.ts +17 -1
  77. package/dist/payments/account-credit.js +25 -2
  78. package/dist/payments/credit-noted-fields.d.ts +8 -1
  79. package/dist/payments/credit-noted-fields.js +34 -2
  80. package/dist/payments/customer-consent.d.ts +30 -0
  81. package/dist/payments/customer-consent.js +49 -0
  82. package/dist/payments/index.d.ts +89 -14
  83. package/dist/payments/index.js +22 -2
  84. package/dist/payments/requests.d.ts +6 -3
  85. package/dist/payments/requests.js +1 -0
  86. package/dist/pickups/index.d.ts +5 -3
  87. package/dist/pickups/index.js +5 -2
  88. package/dist/platform/email-gateways.d.ts +165 -0
  89. package/dist/platform/email-gateways.js +78 -0
  90. package/dist/platform/tax.d.ts +11 -3
  91. package/dist/platform/tax.js +30 -0
  92. package/dist/pos/exchanges.d.ts +6 -1
  93. package/dist/pos/index.d.ts +229 -10
  94. package/dist/pos/index.js +113 -4
  95. package/dist/pos/offline-sales.d.ts +44 -3
  96. package/dist/pos/offline-sales.js +57 -3
  97. package/dist/pos/returns.d.ts +28 -1
  98. package/dist/pos/returns.js +36 -6
  99. package/dist/pricing/price-lists.d.ts +3 -1
  100. package/dist/purchasing/alerts.d.ts +19 -4
  101. package/dist/purchasing/alerts.js +29 -3
  102. package/dist/purchasing/backorders.d.ts +61 -0
  103. package/dist/purchasing/backorders.js +92 -0
  104. package/dist/purchasing/index.d.ts +21 -3
  105. package/dist/purchasing/index.js +8 -0
  106. package/dist/purchasing/links.d.ts +263 -0
  107. package/dist/purchasing/links.js +192 -0
  108. package/dist/purchasing/purchase-orders.d.ts +4 -1
  109. package/dist/purchasing/receipts.d.ts +396 -0
  110. package/dist/purchasing/receipts.js +287 -0
  111. package/dist/purchasing/returns.d.ts +273 -0
  112. package/dist/purchasing/returns.js +229 -0
  113. package/dist/quotes/index.d.ts +20 -3
  114. package/dist/returns/index.d.ts +62 -1
  115. package/dist/returns/index.js +61 -0
  116. package/dist/seo/config.d.ts +128 -0
  117. package/dist/seo/config.js +95 -0
  118. package/dist/seo/redirects.d.ts +125 -0
  119. package/dist/seo/redirects.js +60 -1
  120. package/dist/seo/settings.d.ts +147 -0
  121. package/dist/seo/settings.js +209 -0
  122. package/dist/shipping/index.d.ts +6 -0
  123. package/dist/shipping/index.js +2 -0
  124. package/dist/shipping/methods.d.ts +8 -1
  125. package/dist/shipping/myparcel.d.ts +415 -1
  126. package/dist/shipping/myparcel.js +291 -3
  127. package/dist/shipping/rate-tables.d.ts +149 -4
  128. package/dist/shipping/rate-tables.js +229 -23
  129. package/dist/shipping/ship-to-countries.d.ts +75 -0
  130. package/dist/shipping/ship-to-countries.js +47 -0
  131. package/dist/stock/by-location.d.ts +86 -0
  132. package/dist/stock/by-location.js +94 -0
  133. package/dist/stock/index.d.ts +8 -1
  134. package/dist/stock/index.js +4 -0
  135. package/dist/stock/settings.d.ts +106 -0
  136. package/dist/stock/settings.js +103 -0
  137. package/dist/stock/transfers.d.ts +171 -10
  138. package/dist/stock/transfers.js +82 -4
  139. package/dist/stored-value/index.d.ts +98 -7
  140. package/dist/stored-value/index.js +71 -4
  141. package/dist/tax/display-policy.d.ts +105 -0
  142. package/dist/tax/display-policy.js +84 -0
  143. package/dist/tax/index.d.ts +3 -0
  144. package/dist/tax/index.js +2 -0
  145. package/dist/tenant/email-templates.d.ts +222 -1
  146. package/dist/tenant/email-templates.js +227 -10
  147. package/package.json +2 -2
@@ -18,10 +18,135 @@ export interface RedirectCreateInput {
18
18
  statusCode?: number;
19
19
  }
20
20
  export type RedirectUpdateInput = Partial<RedirectCreateInput>;
21
+ /**
22
+ * The most rows one {@link RedirectsModule.import} takes. Mirrors `ArrayMaxSize` on the plugins
23
+ * service's `ImportRedirectsDto` (`services/plugins/src/redirects/dto/import-redirects.dto.ts`),
24
+ * pinned by `__tests__/redirects.spec.ts`. Change both sides together.
25
+ */
26
+ export declare const REDIRECT_IMPORT_MAX_ROWS = 5000;
27
+ /**
28
+ * How long an import may take before the call gives up, unless the caller says otherwise: the
29
+ * service saves the rows one by one, and a full batch takes far longer than a single write.
30
+ */
31
+ export declare const REDIRECT_IMPORT_TIMEOUT_MS = 60000;
32
+ /**
33
+ * One row of {@link RedirectsModule.import}. Not checked before sending: the service judges each
34
+ * row on its own and reports a bad one in {@link RedirectImportResult.rejected}, so a malformed
35
+ * row never costs the rest of the batch.
36
+ */
37
+ export interface RedirectImportRow {
38
+ /** One exact path, e.g. `/product/old-slug`. */
39
+ oldPath: string;
40
+ /** A path, or an absolute `https://` URL. */
41
+ newPath: string;
42
+ /** 301 (the default), 302, 303, 307 or 308. */
43
+ statusCode?: number;
44
+ }
45
+ /** A row the service refused, and why. The other rows still landed. */
46
+ export interface RedirectImportRejection {
47
+ /** The row's position in the array sent, from 0. */
48
+ index: number;
49
+ /** The row's source path as sent. */
50
+ oldPath: string;
51
+ /**
52
+ * The service's sentence for the person fixing the file: a source that is not one exact path,
53
+ * a destination that is neither a path nor an `https://` URL, a status code outside the five,
54
+ * a source that redirects to itself, or a row that would close a redirect loop or a chain past
55
+ * the hop limit (with the rows already stored or with an earlier row of the same batch). There
56
+ * is no code per row yet.
57
+ */
58
+ message: string;
59
+ }
60
+ /**
61
+ * What an import did. A row whose source the store already redirects UPDATES that redirect
62
+ * (same source, so never a duplicate); any other accepted row creates one.
63
+ */
64
+ export interface RedirectImportResult {
65
+ created: number;
66
+ updated: number;
67
+ /** Empty when every row landed. */
68
+ rejected: RedirectImportRejection[];
69
+ }
70
+ /**
71
+ * `admin.seo.redirects` — the ACTIVE store's URL redirects, on the plugins service. Storefronts read
72
+ * the same rows live from the public redirect map (`@sixtyninecommerce/client/next`'s
73
+ * `createRedirectMiddleware`), so a write here changes what the store's old URLs answer within a
74
+ * few minutes, with no deployment.
75
+ *
76
+ * Every method needs `UpdateChannel` (`FORBIDDEN` → `PermissionDeniedError`, 403, otherwise). The
77
+ * writes that set up a redirect (`create`, `update`, `import`) are refused in an organization's
78
+ * workspace with `ORGANIZATION_WORKSPACE_NOT_A_STORE` (`BusinessRuleError`, 422): a workspace has
79
+ * no storefront. Removing one is never refused there.
80
+ */
21
81
  export interface RedirectsModule {
82
+ /**
83
+ * The store's redirects, newest first, a page at a time (`limit` 1 to 200; 50 by default).
84
+ * Iterate the page (`for await`) to walk every page.
85
+ *
86
+ * @effect read
87
+ * @permission UpdateChannel
88
+ */
22
89
  list(options?: ListOptions): Promise<Page<Redirect>>;
90
+ /**
91
+ * Add one redirect. The service applies the same path rules as `import`: a malformed path, a
92
+ * self-redirect or a status code outside the five is `ValidationError` (400); a source the store
93
+ * already redirects, a loop, or a chain past the service's hop limit is `ConflictError` (409).
94
+ * The idempotency key is honoured: a repeat answers the first response.
95
+ *
96
+ * @effect write
97
+ * @permission UpdateChannel
98
+ */
23
99
  create(input: RedirectCreateInput, options?: Call): Promise<Redirect>;
100
+ /**
101
+ * Change the fields `input` names; at least one. The same rules as `create`; an unknown id is
102
+ * `NotFoundError` (404).
103
+ *
104
+ * @effect write
105
+ * @permission UpdateChannel
106
+ */
24
107
  update(id: string, input: RedirectUpdateInput, options?: Call): Promise<Redirect>;
108
+ /**
109
+ * Remove one redirect: the old URL stops redirecting once the storefront's copy of the map
110
+ * expires. An unknown id is `NotFoundError` (404).
111
+ *
112
+ * @effect destructive
113
+ * @permission UpdateChannel
114
+ */
25
115
  delete(id: string, options?: Call): Promise<void>;
116
+ /**
117
+ * Add or update up to {@link REDIRECT_IMPORT_MAX_ROWS} redirects in one call, matched on the
118
+ * source path (a source the store already redirects is updated, never duplicated). Each row is
119
+ * judged on its own: a bad one is answered in `rejected` by its index and the rest still land.
120
+ * Rows are applied in order, so a later row may build on an earlier one, and a loop split
121
+ * across two rows is refused on the second.
122
+ *
123
+ * Not atomic and not deduplicated by an idempotency key, but safe to repeat: sending the same
124
+ * rows again updates them to what they already are.
125
+ *
126
+ * **Coded refusals:**
127
+ * - `REDIRECT_IMPORT_INVALID` → `ValidationError` (400), before any request: `rows` is not an
128
+ * array of 1 to {@link REDIRECT_IMPORT_MAX_ROWS} entries. Send a larger file in chunks.
129
+ * - `ORGANIZATION_WORKSPACE_NOT_A_STORE` → `BusinessRuleError` (422): the active store is an
130
+ * organization's workspace.
131
+ * - `FORBIDDEN` → `PermissionDeniedError` (403): the credential lacks `UpdateChannel`.
132
+ *
133
+ * Waits up to {@link REDIRECT_IMPORT_TIMEOUT_MS}, longer than the client's default deadline. A
134
+ * timed-out import may still have landed (the service keeps saving): list the redirects, or
135
+ * send the same rows again, which is safe.
136
+ *
137
+ * @effect write
138
+ * @permission UpdateChannel
139
+ */
140
+ import(rows: readonly RedirectImportRow[], options?: Call): Promise<RedirectImportResult>;
141
+ /**
142
+ * The store's redirects as two Nginx `map` blocks (text: `$redirect_uri` and
143
+ * `$redirect_status`, keyed on `$uri`), for a site that is not on the storefront SDK: include
144
+ * it in the server config and redirect on the mapped values. One entry per source path, those
145
+ * a storefront cannot serve included.
146
+ *
147
+ * @effect read
148
+ * @permission UpdateChannel
149
+ */
150
+ exportNginx(options?: Call): Promise<string>;
26
151
  }
27
152
  export declare function createRedirectsModule(transport: Transport): RedirectsModule;
@@ -1,4 +1,25 @@
1
- import { createPage, readRestPage } from '@sixtyninecommerce/core';
1
+ import { createPage, readRestPage, ValidationError, } from '@sixtyninecommerce/core';
2
+ /**
3
+ * The most rows one {@link RedirectsModule.import} takes. Mirrors `ArrayMaxSize` on the plugins
4
+ * service's `ImportRedirectsDto` (`services/plugins/src/redirects/dto/import-redirects.dto.ts`),
5
+ * pinned by `__tests__/redirects.spec.ts`. Change both sides together.
6
+ */
7
+ export const REDIRECT_IMPORT_MAX_ROWS = 5000;
8
+ /**
9
+ * How long an import may take before the call gives up, unless the caller says otherwise: the
10
+ * service saves the rows one by one, and a full batch takes far longer than a single write.
11
+ */
12
+ export const REDIRECT_IMPORT_TIMEOUT_MS = 60_000;
13
+ const IMPORT_INVALID = 'REDIRECT_IMPORT_INVALID';
14
+ /** The import's answer, read defensively: an older service may leave `rejected` out. */
15
+ function importResultOf(body) {
16
+ const record = (typeof body === 'object' && body !== null ? body : {});
17
+ const count = (value) => (typeof value === 'number' ? value : 0);
18
+ const rejected = Array.isArray(record.rejected)
19
+ ? record.rejected
20
+ : [];
21
+ return { created: count(record.created), updated: count(record.updated), rejected };
22
+ }
2
23
  export function createRedirectsModule(transport) {
3
24
  async function list(options) {
4
25
  // `GET /redirects` answers with a bare array unless the request OPTS IN.
@@ -33,5 +54,43 @@ export function createRedirectsModule(transport) {
33
54
  // discards the body rather than pretending it is a Redirect.
34
55
  await transport.rest('plugins', { method: 'DELETE', path: `/redirects/${encodeURIComponent(id)}`, expect: 'void' }, options);
35
56
  },
57
+ async import(rows, options) {
58
+ if (!Array.isArray(rows) || rows.length === 0 || rows.length > REDIRECT_IMPORT_MAX_ROWS) {
59
+ throw new ValidationError({
60
+ code: IMPORT_INVALID,
61
+ message: `Send 1 to ${String(REDIRECT_IMPORT_MAX_ROWS)} redirects per import; split a larger file into several calls.`,
62
+ status: 400,
63
+ service: 'plugins',
64
+ details: [
65
+ {
66
+ field: 'rows',
67
+ code: IMPORT_INVALID,
68
+ message: Array.isArray(rows)
69
+ ? `${String(rows.length)} rows sent.`
70
+ : 'rows is not an array.',
71
+ },
72
+ ],
73
+ });
74
+ }
75
+ const body = await transport.rest('plugins', {
76
+ method: 'POST',
77
+ path: '/redirects/import',
78
+ // Only the three row fields go out: the service's DTO refuses any other key, which
79
+ // would fail the whole batch rather than the one row.
80
+ body: {
81
+ redirects: rows.map((row) => ({
82
+ oldPath: row.oldPath,
83
+ newPath: row.newPath,
84
+ ...(row.statusCode === undefined ? {} : { statusCode: row.statusCode }),
85
+ })),
86
+ },
87
+ }, { timeoutMs: REDIRECT_IMPORT_TIMEOUT_MS, ...options });
88
+ return importResultOf(body);
89
+ },
90
+ async exportNginx(options) {
91
+ // A text body, never JSON: `blob` keeps the transport from parsing or case-converting it.
92
+ const answer = await transport.rest('plugins', { method: 'GET', path: '/redirects/nginx', expect: 'blob' }, options);
93
+ return answer.blob.text();
94
+ },
36
95
  };
37
96
  }
@@ -0,0 +1,147 @@
1
+ import { type Transport } from '@sixtyninecommerce/core';
2
+ import type { SettingsSourceKind } from '../tenant/settings-sources.js';
3
+ import type { Call } from '../types.js';
4
+ /**
5
+ * How a language shows in storefront URLs. Mirrors the meta catalog's `URL_LOCALE_STRATEGIES`
6
+ * (`packages/shared/src/meta-catalog/plugin-config-schemas.ts`), which the engine enforces,
7
+ * pinned by `__tests__/seo-settings.spec.ts`.
8
+ */
9
+ export declare const URL_LOCALE_STRATEGIES: readonly ["path-prefix", "subdomain", "none"];
10
+ export type UrlLocaleStrategy = (typeof URL_LOCALE_STRATEGIES)[number];
11
+ /** What an AI crawler may do with the store's pages. */
12
+ export type AiBotDirective = 'allow' | 'deny';
13
+ /**
14
+ * The store's SEO defaults: the `settings.seo` group's channel half, which a member store may take
15
+ * from its organization.
16
+ */
17
+ export interface SeoDefaults {
18
+ /** Without the `@`; storefronts emit it as `twitter:site`. Null for none. */
19
+ twitterHandle: string | null;
20
+ /** The asset shared as the OpenGraph image when a product has none; null for none. */
21
+ defaultOgImageId: string | null;
22
+ /** AI crawler → `allow` or `deny`. A crawler left out follows the storefront's own default. */
23
+ aiBotDefaults: Record<string, AiBotDirective>;
24
+ /** The storefront advertises its AI-friendly endpoints at `/llms.txt`. */
25
+ llmsTxtEnabled: boolean;
26
+ /** Plain text merged into the store's `robots.txt`; null for none. */
27
+ robotsTxtCustom: string | null;
28
+ }
29
+ /** Where the SEO defaults storefronts get come from (`settings.seo`). */
30
+ export interface SeoDefaultsSource {
31
+ /** `channel`: the store's own defaults apply; `organization`: its organization's. */
32
+ source: SettingsSourceKind | (string & {});
33
+ /**
34
+ * The organization locks the group: {@link SeoSettingsModule.set} refuses a changed default with
35
+ * `MANAGED_BY_ORGANIZATION`, SuperAdmins included.
36
+ */
37
+ locked: boolean;
38
+ /** The organization shares the group without locking it, so the store may override it. */
39
+ inheritable: boolean;
40
+ /** The store keeps its own defaults although the organization shares the group. */
41
+ overridden: boolean;
42
+ /** The store's organization; null for a store outside any organization. */
43
+ organizationName: string | null;
44
+ /** The defaults storefronts get: the store's own while `source` is `channel`, else the organization's. */
45
+ effective: SeoDefaults;
46
+ }
47
+ /**
48
+ * The ACTIVE store's SEO settings as its channel stores them. The top-level defaults are the
49
+ * store's OWN values, which {@link SeoSettingsModule.set} writes; while the store takes
50
+ * `settings.seo` from its organization, storefronts get `defaultsSource.effective` instead.
51
+ */
52
+ export interface SeoSettings extends SeoDefaults {
53
+ /** The store these are the settings of (its channel id). */
54
+ channelId: string;
55
+ defaultLanguageCode: string;
56
+ /** The store's languages, its default included. */
57
+ availableLanguageCodes: string[];
58
+ /**
59
+ * The storefront's address, the base of canonical, hreflang and sitemap URLs; null when none.
60
+ * Always the store's own. While the store has a default web head, the head's address is copied
61
+ * over it on every head change.
62
+ */
63
+ storefrontUrl: string | null;
64
+ /**
65
+ * How a language shows in storefront URLs; `path-prefix` when the store never chose. A string
66
+ * the engine accepts that this SDK does not name yet passes through. Always the store's own.
67
+ */
68
+ urlLocaleStrategy: UrlLocaleStrategy | (string & {});
69
+ /** The default language carries a prefix too. Always the store's own. */
70
+ urlLocalePrefixDefault: boolean;
71
+ defaultsSource: SeoDefaultsSource;
72
+ }
73
+ /**
74
+ * A patch of {@link SeoSettings}: a key left out is unchanged, `null` clears it. At least one key.
75
+ * The read-only keys `get` answers (`channelId`, the languages, `defaultsSource`) are ignored, so
76
+ * `set({ ...settings, urlLocaleStrategy: 'subdomain' })` works.
77
+ */
78
+ export interface SeoSettingsPatch {
79
+ /** At most 255 characters; not checked to be a URL. */
80
+ storefrontUrl?: string | null;
81
+ /**
82
+ * One of {@link URL_LOCALE_STRATEGIES}, or null for the default (`path-prefix`). Typed as any
83
+ * string so a strategy `get` answered (one this SDK may not name yet) can be sent back; the
84
+ * engine refuses one it does not know (`USER_INPUT_ERROR`).
85
+ */
86
+ urlLocaleStrategy?: UrlLocaleStrategy | (string & {}) | null;
87
+ urlLocalePrefixDefault?: boolean | null;
88
+ /** At most 50 characters, without the `@`. */
89
+ twitterHandle?: string | null;
90
+ defaultOgImageId?: string | null;
91
+ /** The whole map (it replaces the stored one), as an object: the SDK encodes it. */
92
+ aiBotDefaults?: Record<string, AiBotDirective> | null;
93
+ llmsTxtEnabled?: boolean | null;
94
+ robotsTxtCustom?: string | null;
95
+ }
96
+ /**
97
+ * `admin.seo.settings`: the ACTIVE store's SEO channel settings (Settings → SEO): the storefront
98
+ * URL and how languages show in its URLs, and the SEO defaults (share card, AI crawlers,
99
+ * `llms.txt`, `robots.txt`). Stored as Channel custom fields; written through core `updateChannel`
100
+ * (typed per domain, ruling G80).
101
+ *
102
+ * The other half of the page, the SEO automation (automatic redirects, IndexNow, the audit
103
+ * schedule), lives on the plugins service: `admin.seo.config`.
104
+ */
105
+ export interface SeoSettingsModule {
106
+ /**
107
+ * The settings, and where the defaults storefronts get come from. Reading needs an
108
+ * administrator of the store with any permission beyond `Authenticated`.
109
+ *
110
+ * @effect read
111
+ */
112
+ get(options?: Call): Promise<SeoSettings>;
113
+ /**
114
+ * Change the settings `patch` names, and answer them all as the engine now stores them. The
115
+ * storefront picks the change up on its next read of the store's settings.
116
+ *
117
+ * **Coded refusals:**
118
+ * - `SEO_SETTINGS_INVALID` → `ValidationError` (400), before any request: an empty patch, a
119
+ * key that is not an SEO setting, or an `aiBotDefaults` that is not an object (or null). One
120
+ * `details[]` entry per key, `field` naming it.
121
+ * - `CHANNEL_SETTINGS_INVALID` → `ValidationError` (400), from the engine: an `aiBotDefaults`
122
+ * the platform's schema refuses (a directive other than `allow` or `deny`).
123
+ * `details[].field` is `customFields.aiBotDefaults`.
124
+ * - `USER_INPUT_ERROR` → `ValidationError` (400): a `urlLocaleStrategy` outside
125
+ * {@link URL_LOCALE_STRATEGIES}.
126
+ * - `MANAGED_BY_ORGANIZATION` → `BusinessRuleError` (422): a changed default while the
127
+ * organization locks `settings.seo`.
128
+ * - `ORGANIZATION_SETTINGS_INHERITED` → `BusinessRuleError` (422): a changed default while the
129
+ * store inherits the group; `tenant.settingsSources.override('settings.seo')` it first.
130
+ * Sending the stored value back unchanged is accepted and changes nothing.
131
+ * - `ORGANIZATION_WORKSPACE_NOT_A_STORE` → `BusinessRuleError` (422): a storefront URL for an
132
+ * organization's workspace, which has no storefront.
133
+ * - `FORBIDDEN` → `PermissionDeniedError` (403): the credential lacks `UpdateChannel`.
134
+ *
135
+ * @effect write
136
+ * @permission UpdateChannel
137
+ */
138
+ set(patch: SeoSettingsPatch, options?: Call): Promise<SeoSettings>;
139
+ }
140
+ export declare const SEO_SETTINGS_QUERY = "\n query SeoSettings {\n activeChannel { \n id\n defaultLanguageCode\n availableLanguageCodes\n customFields {\n storefrontUrl\n urlLocaleStrategy\n urlLocalePrefixDefault\n twitterHandle\n defaultOgImageId\n aiBotDefaults\n llmsTxtEnabled\n robotsTxtCustom\n }\n }\n channelSettingsSources { group source locked overridden inheritable organizationName values }\n }\n";
141
+ export declare const UPDATE_SEO_SETTINGS_MUTATION = "\n mutation UpdateSeoSettings($input: UpdateChannelInput!) {\n updateChannel(input: $input) {\n ... on Channel { \n id\n defaultLanguageCode\n availableLanguageCodes\n customFields {\n storefrontUrl\n urlLocaleStrategy\n urlLocalePrefixDefault\n twitterHandle\n defaultOgImageId\n aiBotDefaults\n llmsTxtEnabled\n robotsTxtCustom\n }\n }\n ... on ErrorResult { errorCode message }\n }\n }\n";
142
+ /**
143
+ * `aiBotDefaults` as the column stores it (JSON text) or already decoded (an organization's
144
+ * `values`); a directive other than `allow` or `deny` is left out, an unreadable value is none.
145
+ */
146
+ export declare function readAiBotDefaults(raw: unknown): Record<string, AiBotDirective>;
147
+ export declare function createSeoSettingsModule(transport: Transport): SeoSettingsModule;
@@ -0,0 +1,209 @@
1
+ import { errorFromErrorResult, isErrorResult, NotFoundError, ValidationError, } from '@sixtyninecommerce/core';
2
+ /**
3
+ * How a language shows in storefront URLs. Mirrors the meta catalog's `URL_LOCALE_STRATEGIES`
4
+ * (`packages/shared/src/meta-catalog/plugin-config-schemas.ts`), which the engine enforces,
5
+ * pinned by `__tests__/seo-settings.spec.ts`.
6
+ */
7
+ export const URL_LOCALE_STRATEGIES = ['path-prefix', 'subdomain', 'none'];
8
+ /** The settings group the defaults belong to (multi-region spec §5.3). */
9
+ const SEO_GROUP = 'settings.seo';
10
+ const CHANNEL_FIELDS = /* GraphQL */ `
11
+ id
12
+ defaultLanguageCode
13
+ availableLanguageCodes
14
+ customFields {
15
+ storefrontUrl
16
+ urlLocaleStrategy
17
+ urlLocalePrefixDefault
18
+ twitterHandle
19
+ defaultOgImageId
20
+ aiBotDefaults
21
+ llmsTxtEnabled
22
+ robotsTxtCustom
23
+ }
24
+ `;
25
+ export const SEO_SETTINGS_QUERY = /* GraphQL */ `
26
+ query SeoSettings {
27
+ activeChannel { ${CHANNEL_FIELDS} }
28
+ channelSettingsSources { group source locked overridden inheritable organizationName values }
29
+ }
30
+ `;
31
+ export const UPDATE_SEO_SETTINGS_MUTATION = /* GraphQL */ `
32
+ mutation UpdateSeoSettings($input: UpdateChannelInput!) {
33
+ updateChannel(input: $input) {
34
+ ... on Channel { ${CHANNEL_FIELDS} }
35
+ ... on ErrorResult { errorCode message }
36
+ }
37
+ }
38
+ `;
39
+ const INVALID = 'SEO_SETTINGS_INVALID';
40
+ const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
41
+ const stringOrNull = (value) => (typeof value === 'string' ? value : null);
42
+ /**
43
+ * `aiBotDefaults` as the column stores it (JSON text) or already decoded (an organization's
44
+ * `values`); a directive other than `allow` or `deny` is left out, an unreadable value is none.
45
+ */
46
+ export function readAiBotDefaults(raw) {
47
+ let parsed = raw;
48
+ if (typeof raw === 'string') {
49
+ if (raw === '')
50
+ return {};
51
+ try {
52
+ parsed = JSON.parse(raw);
53
+ }
54
+ catch {
55
+ return {};
56
+ }
57
+ }
58
+ if (!isRecord(parsed))
59
+ return {};
60
+ const out = {};
61
+ for (const [bot, directive] of Object.entries(parsed)) {
62
+ if (directive === 'allow' || directive === 'deny')
63
+ out[bot] = directive;
64
+ }
65
+ return out;
66
+ }
67
+ function defaultsOf(fields) {
68
+ return {
69
+ twitterHandle: stringOrNull(fields.twitterHandle),
70
+ defaultOgImageId: stringOrNull(fields.defaultOgImageId),
71
+ aiBotDefaults: readAiBotDefaults(fields.aiBotDefaults),
72
+ llmsTxtEnabled: fields.llmsTxtEnabled === true,
73
+ robotsTxtCustom: stringOrNull(fields.robotsTxtCustom),
74
+ };
75
+ }
76
+ function sourceOf(sources, own) {
77
+ const group = sources.find((item) => item.group === SEO_GROUP);
78
+ if (!group) {
79
+ return {
80
+ source: 'channel',
81
+ locked: false,
82
+ inheritable: false,
83
+ overridden: false,
84
+ organizationName: null,
85
+ effective: own,
86
+ };
87
+ }
88
+ // The store's own defaults when its own apply: after `set` the sources are the ones read BEFORE
89
+ // the write, so their values would answer the old defaults. The organization's otherwise.
90
+ return {
91
+ source: group.source,
92
+ locked: group.locked,
93
+ inheritable: group.inheritable,
94
+ overridden: group.overridden,
95
+ organizationName: group.organizationName ?? null,
96
+ effective: group.source !== 'channel' && group.values ? defaultsOf(group.values) : own,
97
+ };
98
+ }
99
+ function settingsOf(channel, sources) {
100
+ const fields = channel.customFields ?? {};
101
+ const own = defaultsOf(fields);
102
+ return {
103
+ channelId: channel.id,
104
+ defaultLanguageCode: channel.defaultLanguageCode,
105
+ availableLanguageCodes: channel.availableLanguageCodes ?? [channel.defaultLanguageCode],
106
+ storefrontUrl: stringOrNull(fields.storefrontUrl),
107
+ urlLocaleStrategy: stringOrNull(fields.urlLocaleStrategy) ?? 'path-prefix',
108
+ urlLocalePrefixDefault: fields.urlLocalePrefixDefault === true,
109
+ ...own,
110
+ defaultsSource: sourceOf(sources, own),
111
+ };
112
+ }
113
+ const PATCH_KEYS = [
114
+ 'storefrontUrl',
115
+ 'urlLocaleStrategy',
116
+ 'urlLocalePrefixDefault',
117
+ 'twitterHandle',
118
+ 'defaultOgImageId',
119
+ 'aiBotDefaults',
120
+ 'llmsTxtEnabled',
121
+ 'robotsTxtCustom',
122
+ ];
123
+ const KNOWN_KEYS = new Set(PATCH_KEYS);
124
+ /** What `get` answers beside the settings: sent back in a spread, never written. */
125
+ const READ_ONLY_KEYS = new Set([
126
+ 'channelId',
127
+ 'defaultLanguageCode',
128
+ 'availableLanguageCodes',
129
+ 'defaultsSource',
130
+ ]);
131
+ function problemsOf(patch) {
132
+ const problems = [];
133
+ const add = (field, message) => problems.push({ field, code: INVALID, message });
134
+ if (!isRecord(patch)) {
135
+ add('patch', "Pass the settings to change as an object, e.g. { urlLocaleStrategy: 'none' }.");
136
+ return problems;
137
+ }
138
+ const named = Object.keys(patch).filter((key) => patch[key] !== undefined && !READ_ONLY_KEYS.has(key));
139
+ if (named.length === 0) {
140
+ add('patch', `Name at least one setting to change: ${PATCH_KEYS.join(', ')}.`);
141
+ }
142
+ for (const key of named) {
143
+ if (!KNOWN_KEYS.has(key)) {
144
+ add(key, `${key} is not an SEO setting; the settings are ${PATCH_KEYS.join(', ')}.`);
145
+ }
146
+ }
147
+ const bots = patch.aiBotDefaults;
148
+ if (bots !== undefined && bots !== null && !isRecord(bots)) {
149
+ // A string would be encoded a second time and stored as a JSON string, not a map.
150
+ add('aiBotDefaults', "aiBotDefaults must be an object of crawler → 'allow' | 'deny' (the SDK encodes it), or null to clear it.");
151
+ }
152
+ return problems;
153
+ }
154
+ /** The custom fields `patch` writes: only the keys it names, the bot map as the column's JSON. */
155
+ function customFieldsOf(patch) {
156
+ const fields = {};
157
+ for (const key of PATCH_KEYS) {
158
+ const value = patch[key];
159
+ if (value === undefined)
160
+ continue;
161
+ fields[key] = key === 'aiBotDefaults' && value !== null ? JSON.stringify(value) : value;
162
+ }
163
+ return fields;
164
+ }
165
+ export function createSeoSettingsModule(transport) {
166
+ const read = async (options) => {
167
+ const data = await transport.graphql({ api: 'admin', query: SEO_SETTINGS_QUERY }, options);
168
+ if (!data.activeChannel) {
169
+ throw new NotFoundError({
170
+ code: 'CHANNEL_NOT_FOUND',
171
+ message: 'No store is active for this session, so it has no SEO settings to read.',
172
+ status: 404,
173
+ service: 'vendure',
174
+ });
175
+ }
176
+ return { channel: data.activeChannel, sources: data.channelSettingsSources ?? [] };
177
+ };
178
+ return {
179
+ async get(options) {
180
+ const { channel, sources } = await read(options);
181
+ return settingsOf(channel, sources);
182
+ },
183
+ async set(patch, options) {
184
+ const problems = problemsOf(patch);
185
+ if (problems.length > 0) {
186
+ throw new ValidationError({
187
+ code: INVALID,
188
+ message: problems.map((problem) => problem.message).join(' '),
189
+ status: 400,
190
+ service: 'vendure',
191
+ details: problems,
192
+ });
193
+ }
194
+ // The id `updateChannel` needs, read with the SAME call options, so a per-call tenant
195
+ // writes its own store rather than the client's.
196
+ const { channel, sources } = await read(options);
197
+ const data = await transport.graphql({
198
+ api: 'admin',
199
+ query: UPDATE_SEO_SETTINGS_MUTATION,
200
+ variables: { input: { id: channel.id, customFields: customFieldsOf(patch) } },
201
+ }, options);
202
+ if (isErrorResult(data.updateChannel))
203
+ throw errorFromErrorResult(data.updateChannel);
204
+ // An SEO settings write never changes where the defaults come from, so the sources read
205
+ // just before still hold; `sourceOf` takes the new own defaults when the store's apply.
206
+ return settingsOf(data.updateChannel, sources);
207
+ },
208
+ };
209
+ }
@@ -12,6 +12,7 @@ import { type MyparcelModule } from './myparcel.js';
12
12
  import { type ParcelCountSuggestionMethod } from './parcel-count-suggestion.js';
13
13
  import { type ParcelRuleModule } from './parcel-rule.js';
14
14
  import { type RateTablesModule } from './rate-tables.js';
15
+ import { type ShipToCountriesModule } from './ship-to-countries.js';
15
16
  /** `admin.shipping` (spec §9.2 `shipping`). Always the store the client is scoped to. */
16
17
  export interface ShippingModule {
17
18
  /** The store's shipping methods: what checkout offers and what each charges. */
@@ -44,5 +45,10 @@ export interface ShippingModule {
44
45
  * default profile, and who is emailed per handler when a fulfilment is created.
45
46
  */
46
47
  fulfillmentProfiles: FulfillmentProfilesModule;
48
+ /**
49
+ * The countries the store ships to, as checkout applies them: which ones its storefront offers,
50
+ * which selected ones no shopper can choose, saved or previewed.
51
+ */
52
+ shipToCountries: ShipToCountriesModule;
47
53
  }
48
54
  export declare function createShippingModule(transport: Transport): ShippingModule;
@@ -11,6 +11,7 @@ import { createMyparcelModule } from './myparcel.js';
11
11
  import { createParcelCountSuggestion, } from './parcel-count-suggestion.js';
12
12
  import { createParcelRuleModule } from './parcel-rule.js';
13
13
  import { createRateTablesModule } from './rate-tables.js';
14
+ import { createShipToCountriesModule } from './ship-to-countries.js';
14
15
  export function createShippingModule(transport) {
15
16
  return {
16
17
  methods: createShippingMethodsModule(transport),
@@ -21,5 +22,6 @@ export function createShippingModule(transport) {
21
22
  hendriks: createHendriksModule(transport),
22
23
  myparcel: createMyparcelModule(transport),
23
24
  fulfillmentProfiles: createFulfillmentProfilesModule(transport),
25
+ shipToCountries: createShipToCountriesModule(transport),
24
26
  };
25
27
  }
@@ -154,6 +154,11 @@ export interface ShippingMethodsModule {
154
154
  * - The engine's own input refusal (`ValidationError`, 400): a handler, checker or calculator
155
155
  * code it has not registered, a missing argument, or an `extraParcelPrice` outside
156
156
  * 0–10,000,000.
157
+ * - `SHIPPING_ZONE_CONFIG_NOT_FOUND` (`NotFoundError`, 404): the `weight-zone-shipping-calculator`
158
+ * names no rate table of the store (`admin.shipping.rateTables`): its `shippingZoneConfigId` is
159
+ * empty or left out (detail code `required`), or is not one of the store's table ids (`enum`;
160
+ * another store's, a deleted one and a malformed one read alike). Such a method would quote no
161
+ * price. `details[0].field` points at the argument (`/input/calculator/arguments/<i>/value`).
157
162
  *
158
163
  * @effect write
159
164
  */
@@ -165,7 +170,9 @@ export interface ShippingMethodsModule {
165
170
  *
166
171
  * One method may be held by several stores, and a change reaches every one of them. Refused,
167
172
  * with nothing saved, as {@link create} is (`PLUGIN_ACCESS_DENIED` only for a handler, checker
168
- * or calculator the patch CHANGES: one the method already has is no new pick), and:
173
+ * or calculator the patch CHANGES: one the method already has is no new pick;
174
+ * `SHIPPING_ZONE_CONFIG_NOT_FOUND` whenever the patch sends a weight-zone calculator, judged in
175
+ * the store the client is on, and never when it sends none), and:
169
176
  *
170
177
  * - `ENTITY_NOT_FOUND` (`NotFoundError`, 404): the store has no such method (another store's
171
178
  * reads the same).