@sixtyninecommerce/admin 0.11.0 → 0.13.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/README.md +1509 -28
- package/dist/auth/index.d.ts +7 -1
- package/dist/auth/index.js +8 -1
- package/dist/catalog/documents.d.ts +142 -0
- package/dist/catalog/documents.js +114 -0
- package/dist/catalog/index.d.ts +48 -7
- package/dist/catalog/index.js +6 -1
- package/dist/catalog/metadata.d.ts +244 -0
- package/dist/catalog/metadata.js +220 -0
- package/dist/catalog/properties.d.ts +38 -0
- package/dist/catalog/properties.js +52 -1
- package/dist/catalog/recommendations.d.ts +33 -6
- package/dist/catalog/recommendations.js +94 -2
- package/dist/catalog/reviews.d.ts +17 -4
- package/dist/catalog/reviews.js +7 -0
- package/dist/catalog/variants.d.ts +60 -0
- package/dist/catalog/variants.js +39 -0
- package/dist/catalog/visibility.d.ts +18 -2
- package/dist/client.d.ts +29 -3
- package/dist/client.js +19 -8
- package/dist/content/assets.d.ts +3 -4
- package/dist/content/forms.d.ts +332 -1
- package/dist/content/forms.js +140 -4
- package/dist/content/items.d.ts +31 -8
- package/dist/content/items.js +1 -1
- package/dist/content/menus.d.ts +254 -0
- package/dist/content/menus.js +70 -0
- package/dist/content/strings.d.ts +137 -0
- package/dist/content/strings.js +82 -0
- package/dist/customers/delivery-terms.d.ts +44 -0
- package/dist/customers/delivery-terms.js +32 -0
- package/dist/customers/groups.d.ts +16 -5
- package/dist/customers/groups.js +6 -2
- package/dist/customers/index.d.ts +15 -0
- package/dist/customers/index.js +4 -0
- package/dist/documents/emails.d.ts +154 -0
- package/dist/documents/emails.js +220 -0
- package/dist/documents/index.d.ts +13 -0
- package/dist/documents/index.js +2 -0
- package/dist/fulfillments/create.d.ts +1 -1
- package/dist/fulfillments/create.js +5 -2
- package/dist/fulfillments/index.d.ts +119 -1
- package/dist/fulfillments/index.js +124 -4
- package/dist/fulfillments/warehouse.d.ts +29 -17
- package/dist/fulfillments/warehouse.js +32 -1
- package/dist/index.d.ts +43 -23
- package/dist/index.js +11 -5
- package/dist/integrations/exact-online-types.d.ts +542 -0
- package/dist/integrations/exact-online.d.ts +541 -5
- package/dist/integrations/exact-online.js +714 -1
- package/dist/integrations/index.d.ts +23 -8
- package/dist/integrations/index.js +9 -4
- package/dist/integrations/pipeline-discoveries.d.ts +65 -0
- package/dist/integrations/pipeline-discoveries.js +65 -0
- package/dist/integrations/pipeline-runs.d.ts +119 -0
- package/dist/integrations/pipeline-runs.js +87 -0
- package/dist/integrations/pipelines.d.ts +73 -2
- package/dist/integrations/pipelines.js +36 -1
- package/dist/integrations/price-import.d.ts +223 -0
- package/dist/integrations/price-import.js +124 -0
- package/dist/integrations/types.d.ts +221 -3
- package/dist/integrations/types.js +4 -3
- package/dist/meta/index.d.ts +10 -0
- package/dist/metafields/index.d.ts +8 -1
- package/dist/metafields/index.js +10 -0
- package/dist/orders/cancel-with-money.d.ts +10 -0
- package/dist/orders/cancel-with-money.js +2 -0
- package/dist/orders/commit-edit.d.ts +16 -0
- package/dist/orders/commit-edit.js +14 -1
- package/dist/orders/deposit-policy.d.ts +3 -1
- package/dist/orders/index.d.ts +22 -11
- package/dist/orders/index.js +5 -2
- package/dist/orders/list.d.ts +26 -5
- package/dist/orders/list.js +123 -13
- package/dist/orders/manual-discounts.d.ts +13 -5
- package/dist/orders/money-view.d.ts +149 -0
- package/dist/orders/money-view.js +99 -0
- package/dist/orders/plan-edit.d.ts +34 -2
- package/dist/orders/plan-edit.js +110 -12
- package/dist/orders/surcharges.d.ts +12 -0
- package/dist/orders/surcharges.js +34 -9
- package/dist/payments/account-credit.d.ts +17 -1
- package/dist/payments/account-credit.js +25 -2
- package/dist/payments/credit-noted-fields.d.ts +8 -1
- package/dist/payments/credit-noted-fields.js +34 -2
- package/dist/payments/customer-consent.d.ts +30 -0
- package/dist/payments/customer-consent.js +49 -0
- package/dist/payments/index.d.ts +89 -14
- package/dist/payments/index.js +22 -2
- package/dist/payments/requests.d.ts +6 -3
- package/dist/payments/requests.js +1 -0
- package/dist/pickups/index.d.ts +5 -3
- package/dist/pickups/index.js +5 -2
- package/dist/platform/email-gateways.d.ts +165 -0
- package/dist/platform/email-gateways.js +78 -0
- package/dist/platform/tax.d.ts +11 -3
- package/dist/platform/tax.js +30 -0
- package/dist/pos/exchanges.d.ts +6 -1
- package/dist/pos/index.d.ts +229 -10
- package/dist/pos/index.js +113 -4
- package/dist/pos/offline-sales.d.ts +44 -3
- package/dist/pos/offline-sales.js +57 -3
- package/dist/pos/returns.d.ts +28 -1
- package/dist/pos/returns.js +36 -6
- package/dist/pricing/price-lists.d.ts +3 -1
- package/dist/purchasing/alerts.d.ts +19 -4
- package/dist/purchasing/alerts.js +29 -3
- package/dist/purchasing/backorders.d.ts +61 -0
- package/dist/purchasing/backorders.js +92 -0
- package/dist/purchasing/index.d.ts +21 -3
- package/dist/purchasing/index.js +8 -0
- package/dist/purchasing/links.d.ts +263 -0
- package/dist/purchasing/links.js +192 -0
- package/dist/purchasing/purchase-orders.d.ts +4 -1
- package/dist/purchasing/receipts.d.ts +396 -0
- package/dist/purchasing/receipts.js +287 -0
- package/dist/purchasing/returns.d.ts +273 -0
- package/dist/purchasing/returns.js +229 -0
- package/dist/quotes/index.d.ts +20 -3
- package/dist/returns/index.d.ts +62 -1
- package/dist/returns/index.js +61 -0
- package/dist/seo/config.d.ts +128 -0
- package/dist/seo/config.js +95 -0
- package/dist/seo/redirects.d.ts +125 -0
- package/dist/seo/redirects.js +60 -1
- package/dist/seo/settings.d.ts +147 -0
- package/dist/seo/settings.js +209 -0
- package/dist/shipping/index.d.ts +6 -0
- package/dist/shipping/index.js +2 -0
- package/dist/shipping/methods.d.ts +8 -1
- package/dist/shipping/myparcel.d.ts +415 -1
- package/dist/shipping/myparcel.js +291 -3
- package/dist/shipping/rate-tables.d.ts +149 -4
- package/dist/shipping/rate-tables.js +229 -23
- package/dist/shipping/ship-to-countries.d.ts +75 -0
- package/dist/shipping/ship-to-countries.js +47 -0
- package/dist/stock/by-location.d.ts +86 -0
- package/dist/stock/by-location.js +94 -0
- package/dist/stock/index.d.ts +8 -1
- package/dist/stock/index.js +4 -0
- package/dist/stock/settings.d.ts +106 -0
- package/dist/stock/settings.js +103 -0
- package/dist/stock/transfers.d.ts +171 -10
- package/dist/stock/transfers.js +82 -4
- package/dist/stored-value/index.d.ts +98 -7
- package/dist/stored-value/index.js +71 -4
- package/dist/tax/display-policy.d.ts +105 -0
- package/dist/tax/display-policy.js +84 -0
- package/dist/tax/index.d.ts +3 -0
- package/dist/tax/index.js +2 -0
- package/dist/tenant/email-templates.d.ts +222 -1
- package/dist/tenant/email-templates.js +227 -10
- package/package.json +2 -2
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A product's SEO, social and AI-agent metadata (headless spec §9.2 `catalog`: "metadata";
|
|
3
|
+
* W10-10): what the storefront puts in a product page's `<head>`, its share cards and its
|
|
4
|
+
* structured data. One record per product and store; its texts are per language.
|
|
5
|
+
*
|
|
6
|
+
* - **Partial writes.** `upsert` changes only the fields it names. A translation is written per
|
|
7
|
+
* language: a language not sent keeps its values, and a field not sent in a sent language keeps
|
|
8
|
+
* its value (send `null` to clear one).
|
|
9
|
+
* - **Coverage.** `coverage` lists the store's products with their own meta title and description
|
|
10
|
+
* in one language, optionally only those missing one, filtered by the engine BEFORE the page is
|
|
11
|
+
* cut: every page is full and `totalItems` counts the filter.
|
|
12
|
+
* - **Bulk.** `bulkUpsert` writes many products, each through `upsert`'s rules in a transaction
|
|
13
|
+
* of its own and reported on its own (ruling G117).
|
|
14
|
+
*
|
|
15
|
+
* Every record is the store's own: a product of another store is never answered and never
|
|
16
|
+
* written.
|
|
17
|
+
*
|
|
18
|
+
* Permissions: `get` and `coverage` need `ReadCatalog`, the writes `UpdateCatalog`.
|
|
19
|
+
*
|
|
20
|
+
* Refusals, GraphQL errors mapped by `core`:
|
|
21
|
+
* - `PRODUCT_METADATA_INPUT_INVALID` → `ValidationError` (400): an input that breaks a rule (a
|
|
22
|
+
* length, an allowed value, an `https://` URL, an instant without an offset, a language sent
|
|
23
|
+
* twice). `details` names each failure (`field` an RFC 6901 pointer such as
|
|
24
|
+
* `/input/translations/0/seoTitle`, `code` the rule such as `INVALID_FIELD_LENGTH`). Nothing is
|
|
25
|
+
* written.
|
|
26
|
+
* - `ENTITY_NOT_FOUND` → `NotFoundError` (404): `upsert` of a product the store does not hold.
|
|
27
|
+
* - `TRANSLATION_LANGUAGE_NOT_OFFERED` → `ValidationError` (400): a new translation in a language
|
|
28
|
+
* the store does not offer.
|
|
29
|
+
* - `BULK_ACTION_INPUT_INVALID` → `ValidationError` (400): a `bulkUpsert` of no product, more than
|
|
30
|
+
* {@link MAX_BULK_METADATA_EDITS} or one product twice, refused before anything is sent.
|
|
31
|
+
* - `INVALID_DATETIME` → `ValidationError` (400): `metaRobotsUnavailableAfter` without an offset,
|
|
32
|
+
* refused before anything is sent.
|
|
33
|
+
* - `FORBIDDEN` → `PermissionDeniedError` (403) without the permission.
|
|
34
|
+
*
|
|
35
|
+
* Release: `coverage` and `bulkUpsert` need a platform release with `productMetadataCoverage` and
|
|
36
|
+
* `bulkUpsertProductMetadata` (deploy vendure-server first: an older one refuses the document as
|
|
37
|
+
* `GRAPHQL_VALIDATION_FAILED`); on such a release `upsert` also answers a validation failure as an
|
|
38
|
+
* uncoded `INTERNAL_SERVER_ERROR` and writes a row for a product the store does not hold. `get`
|
|
39
|
+
* and `delete` are served by every release this SDK supports. No method deduplicates on
|
|
40
|
+
* `idempotencyKey`; every write is naturally repeatable.
|
|
41
|
+
*/
|
|
42
|
+
import { NotFoundError, UpstreamError, ValidationError, } from '@sixtyninecommerce/core';
|
|
43
|
+
import { assertDeleted, assertInstant, DELETION_FIELDS, fieldOf, vendureListPage, } from './types.js';
|
|
44
|
+
/** How many products one `bulkUpsert` call may name. */
|
|
45
|
+
export const MAX_BULK_METADATA_EDITS = 200;
|
|
46
|
+
const IMAGE_FIELDS = /* GraphQL */ `
|
|
47
|
+
id
|
|
48
|
+
name
|
|
49
|
+
preview
|
|
50
|
+
source
|
|
51
|
+
type
|
|
52
|
+
mimeType
|
|
53
|
+
`;
|
|
54
|
+
const METADATA_FIELDS = /* GraphQL */ `
|
|
55
|
+
id
|
|
56
|
+
productId
|
|
57
|
+
channelId
|
|
58
|
+
metaRobotsIndex
|
|
59
|
+
metaRobotsFollow
|
|
60
|
+
metaRobotsNoarchive
|
|
61
|
+
metaRobotsNosnippet
|
|
62
|
+
metaRobotsNoimageindex
|
|
63
|
+
metaRobotsMaxImagePreview
|
|
64
|
+
metaRobotsMaxSnippet
|
|
65
|
+
metaRobotsMaxVideoPreview
|
|
66
|
+
metaRobotsUnavailableAfter
|
|
67
|
+
socialImageId
|
|
68
|
+
socialImage { ${IMAGE_FIELDS} }
|
|
69
|
+
twitterImageOverrideId
|
|
70
|
+
twitterImageOverride { ${IMAGE_FIELDS} }
|
|
71
|
+
twitterCardType
|
|
72
|
+
socialOgType
|
|
73
|
+
seoIncludeReviewsInJsonld
|
|
74
|
+
aiSummaryAudience
|
|
75
|
+
aiBotOverrides
|
|
76
|
+
qaEmitFaqpageJsonld
|
|
77
|
+
hreflangOverrides
|
|
78
|
+
resolvedBotDirectives
|
|
79
|
+
translations {
|
|
80
|
+
id
|
|
81
|
+
languageCode
|
|
82
|
+
seoTitle
|
|
83
|
+
seoDescription
|
|
84
|
+
canonicalUrl
|
|
85
|
+
breadcrumbOverride
|
|
86
|
+
focusKeywords
|
|
87
|
+
socialTitle
|
|
88
|
+
socialDescription
|
|
89
|
+
socialImageAlt
|
|
90
|
+
twitterTitleOverride
|
|
91
|
+
twitterDescriptionOverride
|
|
92
|
+
aiSummary
|
|
93
|
+
qaPairs
|
|
94
|
+
}
|
|
95
|
+
`;
|
|
96
|
+
export const PRODUCT_METADATA_QUERY = /* GraphQL */ `
|
|
97
|
+
query CatalogProductMetadata($productId: ID!) {
|
|
98
|
+
product(id: $productId) {
|
|
99
|
+
id
|
|
100
|
+
metadata { ${METADATA_FIELDS} }
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
`;
|
|
104
|
+
export const UPSERT_PRODUCT_METADATA_MUTATION = /* GraphQL */ `
|
|
105
|
+
mutation CatalogUpsertProductMetadata($productId: ID!, $input: ProductMetadataInput!) {
|
|
106
|
+
upsertProductMetadata(productId: $productId, input: $input) { ${METADATA_FIELDS} }
|
|
107
|
+
}
|
|
108
|
+
`;
|
|
109
|
+
export const DELETE_PRODUCT_METADATA_MUTATION = /* GraphQL */ `
|
|
110
|
+
mutation CatalogDeleteProductMetadata($productId: ID!) {
|
|
111
|
+
deleteProductMetadata(productId: $productId) { ${DELETION_FIELDS} }
|
|
112
|
+
}
|
|
113
|
+
`;
|
|
114
|
+
export const PRODUCT_METADATA_COVERAGE_QUERY = /* GraphQL */ `
|
|
115
|
+
query CatalogProductMetadataCoverage(
|
|
116
|
+
$languageCode: LanguageCode!
|
|
117
|
+
$filter: ProductMetadataCoverageFilter
|
|
118
|
+
$term: String
|
|
119
|
+
$options: ProductMetadataCoverageListOptions
|
|
120
|
+
) {
|
|
121
|
+
productMetadataCoverage(
|
|
122
|
+
languageCode: $languageCode
|
|
123
|
+
filter: $filter
|
|
124
|
+
term: $term
|
|
125
|
+
options: $options
|
|
126
|
+
) {
|
|
127
|
+
items {
|
|
128
|
+
productId
|
|
129
|
+
name
|
|
130
|
+
slug
|
|
131
|
+
enabled
|
|
132
|
+
seoTitle
|
|
133
|
+
seoDescription
|
|
134
|
+
}
|
|
135
|
+
totalItems
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
`;
|
|
139
|
+
export const BULK_UPSERT_PRODUCT_METADATA_MUTATION = /* GraphQL */ `
|
|
140
|
+
mutation CatalogBulkUpsertProductMetadata($edits: [ProductMetadataBulkEdit!]!) {
|
|
141
|
+
bulkUpsertProductMetadata(edits: $edits) {
|
|
142
|
+
items {
|
|
143
|
+
productId
|
|
144
|
+
status
|
|
145
|
+
code
|
|
146
|
+
message
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
`;
|
|
151
|
+
function bulkInvalid(message) {
|
|
152
|
+
return new ValidationError({
|
|
153
|
+
code: 'BULK_ACTION_INPUT_INVALID',
|
|
154
|
+
message,
|
|
155
|
+
status: 400,
|
|
156
|
+
service: 'vendure',
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
/** The edits as sent, refused before the request leaves when the engine would refuse them. */
|
|
160
|
+
function bulkEdits(edits) {
|
|
161
|
+
if (!Array.isArray(edits))
|
|
162
|
+
throw bulkInvalid('edits must be a list.');
|
|
163
|
+
if (edits.length === 0)
|
|
164
|
+
throw bulkInvalid('Name at least one product.');
|
|
165
|
+
if (edits.length > MAX_BULK_METADATA_EDITS) {
|
|
166
|
+
throw bulkInvalid(`A bulk upsert takes at most ${String(MAX_BULK_METADATA_EDITS)} products; this one names ${String(edits.length)}.`);
|
|
167
|
+
}
|
|
168
|
+
const ids = edits.map((edit) => String(edit.productId).toLowerCase());
|
|
169
|
+
if (new Set(ids).size !== ids.length)
|
|
170
|
+
throw bulkInvalid('Name each product once.');
|
|
171
|
+
for (const edit of edits) {
|
|
172
|
+
assertInstant('metaRobotsUnavailableAfter', edit.input.metaRobotsUnavailableAfter);
|
|
173
|
+
}
|
|
174
|
+
return edits.map((edit) => ({ productId: edit.productId, input: edit.input }));
|
|
175
|
+
}
|
|
176
|
+
export function createProductMetadataModule(transport) {
|
|
177
|
+
return {
|
|
178
|
+
async get(productId, options) {
|
|
179
|
+
const product = await fieldOf(transport, PRODUCT_METADATA_QUERY, 'product', { productId }, options);
|
|
180
|
+
if (!product) {
|
|
181
|
+
throw new NotFoundError({
|
|
182
|
+
code: 'PRODUCT_NOT_FOUND',
|
|
183
|
+
message: `This store has no product ${productId}.`,
|
|
184
|
+
status: 404,
|
|
185
|
+
service: 'vendure',
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
return product.metadata;
|
|
189
|
+
},
|
|
190
|
+
async upsert(productId, input, options) {
|
|
191
|
+
assertInstant('metaRobotsUnavailableAfter', input.metaRobotsUnavailableAfter);
|
|
192
|
+
return fieldOf(transport, UPSERT_PRODUCT_METADATA_MUTATION, 'upsertProductMetadata', { productId, input }, options);
|
|
193
|
+
},
|
|
194
|
+
async delete(productId, options) {
|
|
195
|
+
const response = await fieldOf(transport, DELETE_PRODUCT_METADATA_MUTATION, 'deleteProductMetadata', { productId }, options);
|
|
196
|
+
assertDeleted('PRODUCT_METADATA_NOT_DELETED', [response]);
|
|
197
|
+
},
|
|
198
|
+
async coverage(query) {
|
|
199
|
+
const { languageCode, filter, term, ...options } = query;
|
|
200
|
+
return vendureListPage(transport, PRODUCT_METADATA_COVERAGE_QUERY, 'productMetadataCoverage', (skip, take) => ({
|
|
201
|
+
languageCode,
|
|
202
|
+
filter: filter ?? 'ALL',
|
|
203
|
+
...(term === undefined || term.trim() === '' ? {} : { term }),
|
|
204
|
+
options: { skip, take },
|
|
205
|
+
}), options);
|
|
206
|
+
},
|
|
207
|
+
async bulkUpsert(edits, options) {
|
|
208
|
+
const data = await fieldOf(transport, BULK_UPSERT_PRODUCT_METADATA_MUTATION, 'bulkUpsertProductMetadata', { edits: bulkEdits(edits) }, options);
|
|
209
|
+
if (data === null) {
|
|
210
|
+
throw new UpstreamError({
|
|
211
|
+
code: 'UNEXPECTED_RESPONSE',
|
|
212
|
+
message: 'The engine answered bulkUpsertProductMetadata with nothing.',
|
|
213
|
+
status: 502,
|
|
214
|
+
service: 'vendure',
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
return data.items;
|
|
218
|
+
},
|
|
219
|
+
};
|
|
220
|
+
}
|
|
@@ -319,4 +319,42 @@ export declare const SET_PRODUCT_PROPERTY_VALUES_MUTATION = "\n mutation Catalo
|
|
|
319
319
|
export declare const SYNC_PROPERTY_FACETS_MUTATION = "\n mutation CatalogSyncPropertyFacets($propertyId: ID!) {\n syncPropertyFacets(propertyId: $propertyId) {\n propertyId\n items\n unresolved\n fieldFacets\n }\n }\n";
|
|
320
320
|
/** `catalog.syncContentFacets`; its contract is on {@link CatalogModule.syncContentFacets}. */
|
|
321
321
|
export declare function syncContentFacets(transport: Transport, propertyId: string, options?: Call): Promise<ContentFacetSyncResult>;
|
|
322
|
+
/**
|
|
323
|
+
* The most different content item ids one {@link CatalogModule.syncContentItemFacets} call takes
|
|
324
|
+
* (the engine's own bound); repeats count once.
|
|
325
|
+
*/
|
|
326
|
+
export declare const MAX_CONTENT_ITEM_FACET_SYNC_IDS = 500;
|
|
327
|
+
/** A property {@link CatalogModule.syncContentItemFacets} did not sync, and why. */
|
|
328
|
+
export interface ContentItemFacetSyncSkip {
|
|
329
|
+
propertyId: string;
|
|
330
|
+
/**
|
|
331
|
+
* The refusal the property's own sync gave: `MANAGED_BY_ORGANIZATION` for a property of the
|
|
332
|
+
* store's organization workspace (it is synced in the workspace), or any refusal
|
|
333
|
+
* {@link CatalogModule.syncContentFacets} documents.
|
|
334
|
+
*/
|
|
335
|
+
code: string;
|
|
336
|
+
/** The refusal's sentence, as the engine words it. */
|
|
337
|
+
message: string;
|
|
338
|
+
}
|
|
339
|
+
/** What {@link CatalogModule.syncContentItemFacets} did. */
|
|
340
|
+
export interface ContentItemFacetSyncResult {
|
|
341
|
+
/**
|
|
342
|
+
* Each filterable `content_item` property of the store whose values name at least one of the
|
|
343
|
+
* items, synced as {@link CatalogModule.syncContentFacets} syncs it.
|
|
344
|
+
*/
|
|
345
|
+
synced: ContentFacetSyncResult[];
|
|
346
|
+
/** Each such property that was refused; nothing of it was written, the others were synced. */
|
|
347
|
+
skipped: ContentItemFacetSyncSkip[];
|
|
348
|
+
/**
|
|
349
|
+
* Whether the call wrote anything: a filter value created, renamed or removed, or a product's
|
|
350
|
+
* filter assignments changed. False on a repeat that found everything in line.
|
|
351
|
+
*/
|
|
352
|
+
changed: boolean;
|
|
353
|
+
}
|
|
354
|
+
export declare const SYNC_CONTENT_ITEM_FACETS_MUTATION = "\n mutation CatalogSyncContentItemFacets($contentItemIds: [ID!]!) {\n syncContentItemFacets(contentItemIds: $contentItemIds) {\n synced {\n propertyId\n items\n unresolved\n fieldFacets\n }\n skipped {\n propertyId\n code\n message\n }\n changed\n }\n }\n";
|
|
355
|
+
/**
|
|
356
|
+
* `catalog.syncContentItemFacets`; its contract is on
|
|
357
|
+
* {@link CatalogModule.syncContentItemFacets}. The engine's own id checks run before sending.
|
|
358
|
+
*/
|
|
359
|
+
export declare function syncContentItemFacets(transport: Transport, contentItemIds: readonly string[], options?: Call): Promise<ContentItemFacetSyncResult>;
|
|
322
360
|
export declare function createPropertiesModule(transport: Transport): PropertiesModule;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createPage } from '@sixtyninecommerce/core';
|
|
1
|
+
import { createPage, ValidationError } from '@sixtyninecommerce/core';
|
|
2
2
|
import { assertDeleted, DELETION_FIELDS, fieldOf, } from './types.js';
|
|
3
3
|
const PROPERTY_FIELDS = /* GraphQL */ `
|
|
4
4
|
id
|
|
@@ -134,6 +134,57 @@ export const SYNC_PROPERTY_FACETS_MUTATION = /* GraphQL */ `
|
|
|
134
134
|
export function syncContentFacets(transport, propertyId, options) {
|
|
135
135
|
return fieldOf(transport, SYNC_PROPERTY_FACETS_MUTATION, 'syncPropertyFacets', { propertyId }, options);
|
|
136
136
|
}
|
|
137
|
+
/**
|
|
138
|
+
* The most different content item ids one {@link CatalogModule.syncContentItemFacets} call takes
|
|
139
|
+
* (the engine's own bound); repeats count once.
|
|
140
|
+
*/
|
|
141
|
+
export const MAX_CONTENT_ITEM_FACET_SYNC_IDS = 500;
|
|
142
|
+
export const SYNC_CONTENT_ITEM_FACETS_MUTATION = /* GraphQL */ `
|
|
143
|
+
mutation CatalogSyncContentItemFacets($contentItemIds: [ID!]!) {
|
|
144
|
+
syncContentItemFacets(contentItemIds: $contentItemIds) {
|
|
145
|
+
synced {
|
|
146
|
+
propertyId
|
|
147
|
+
items
|
|
148
|
+
unresolved
|
|
149
|
+
fieldFacets
|
|
150
|
+
}
|
|
151
|
+
skipped {
|
|
152
|
+
propertyId
|
|
153
|
+
code
|
|
154
|
+
message
|
|
155
|
+
}
|
|
156
|
+
changed
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
`;
|
|
160
|
+
function refuseContentItemIds(message) {
|
|
161
|
+
return new ValidationError({
|
|
162
|
+
code: 'CONTENT_ITEM_IDS_INVALID',
|
|
163
|
+
message: `${message} Nothing was sent.`,
|
|
164
|
+
status: 400,
|
|
165
|
+
service: 'vendure',
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* `catalog.syncContentItemFacets`; its contract is on
|
|
170
|
+
* {@link CatalogModule.syncContentItemFacets}. The engine's own id checks run before sending.
|
|
171
|
+
*/
|
|
172
|
+
export function syncContentItemFacets(transport, contentItemIds, options) {
|
|
173
|
+
if (!Array.isArray(contentItemIds)) {
|
|
174
|
+
return Promise.reject(refuseContentItemIds('Send the content item ids as a list.'));
|
|
175
|
+
}
|
|
176
|
+
const distinct = new Set();
|
|
177
|
+
for (const id of contentItemIds) {
|
|
178
|
+
if (typeof id !== 'string' || id.trim() === '') {
|
|
179
|
+
return Promise.reject(refuseContentItemIds('A content item id is blank.'));
|
|
180
|
+
}
|
|
181
|
+
distinct.add(id.trim().toLowerCase());
|
|
182
|
+
}
|
|
183
|
+
if (distinct.size > MAX_CONTENT_ITEM_FACET_SYNC_IDS) {
|
|
184
|
+
return Promise.reject(refuseContentItemIds(`Send at most ${String(MAX_CONTENT_ITEM_FACET_SYNC_IDS)} different content item ids per call; this one named ${String(distinct.size)}. Split them into several calls.`));
|
|
185
|
+
}
|
|
186
|
+
return fieldOf(transport, SYNC_CONTENT_ITEM_FACETS_MUTATION, 'syncContentItemFacets', { contentItemIds: [...contentItemIds] }, options);
|
|
187
|
+
}
|
|
137
188
|
export function createPropertiesModule(transport) {
|
|
138
189
|
const call = (document, field, variables, options) => fieldOf(transport, document, field, variables, options);
|
|
139
190
|
const deleted = async (document, field, code, id, options) => {
|
|
@@ -30,6 +30,11 @@
|
|
|
30
30
|
* not hold, each named in the message and in `details` (`field` `/targetProductIds/<index>`).
|
|
31
31
|
* - `RECOMMENDATION_GROUP_SLUG_TAKEN` → `ConflictError` (409): another of the store's groups holds
|
|
32
32
|
* the slug, also when two writes race for one free slug.
|
|
33
|
+
* - `RECOMMENDATION_MAX_ITEMS_INVALID` → `ValidationError` (400): a group's `maxItems` that is not
|
|
34
|
+
* a whole number from 1 to 50 ({@link RECOMMENDATION_MAX_ITEMS_RANGE}), on `groups.create` and
|
|
35
|
+
* on a `groups.update` that sends it, `details[0].field` `/input/maxItems`. This SDK refuses it
|
|
36
|
+
* before anything is sent, with the same code and detail, so an older platform release (which
|
|
37
|
+
* stores any value) never receives one.
|
|
33
38
|
* - `USER_INPUT_ERROR` → `ValidationError` (400), in the engine's words: a rule that does not fit
|
|
34
39
|
* its group or its type (a `MANUAL` group, a `sourceId` missing or sent where none goes), picks
|
|
35
40
|
* in a `DYNAMIC` group.
|
|
@@ -47,8 +52,17 @@
|
|
|
47
52
|
* No release gate: every operation here is served by every platform release this SDK supports.
|
|
48
53
|
* No method deduplicates on `idempotencyKey`: repeating a `create` makes a second row.
|
|
49
54
|
*/
|
|
50
|
-
import type
|
|
55
|
+
import { type Transport } from '@sixtyninecommerce/core';
|
|
51
56
|
import type { Call } from '../types.js';
|
|
57
|
+
/**
|
|
58
|
+
* The fewest and the most products a group may show (`maxItems`), both included. The platform
|
|
59
|
+
* refuses any other value with `RECOMMENDATION_MAX_ITEMS_INVALID`, and so does this SDK before
|
|
60
|
+
* sending.
|
|
61
|
+
*/
|
|
62
|
+
export declare const RECOMMENDATION_MAX_ITEMS_RANGE: {
|
|
63
|
+
readonly min: 1;
|
|
64
|
+
readonly max: 50;
|
|
65
|
+
};
|
|
52
66
|
/** What a `DYNAMIC` group's entry draws its products from. */
|
|
53
67
|
export type RecommendationSourceType = 'PRODUCT' | 'COLLECTION' | 'FACET_VALUE' | 'SAME_COLLECTION' | 'SAME_PROPERTIES';
|
|
54
68
|
/** `DYNAMIC`: rules (entries). `MANUAL`: products picked per viewed product (`manual.set`). */
|
|
@@ -82,7 +96,10 @@ export interface RecommendationGroupSummary {
|
|
|
82
96
|
/** Unique within the store; the storefront asks for one group by it. */
|
|
83
97
|
slug: string;
|
|
84
98
|
description: string | null;
|
|
85
|
-
/**
|
|
99
|
+
/**
|
|
100
|
+
* The most products the storefront shows for this group. Written as 1 to 50; a group stored
|
|
101
|
+
* outside that range before the platform enforced it keeps its value until `maxItems` is sent.
|
|
102
|
+
*/
|
|
86
103
|
maxItems: number;
|
|
87
104
|
position: number;
|
|
88
105
|
enabled: boolean;
|
|
@@ -107,7 +124,10 @@ export interface CreateRecommendationGroupInput {
|
|
|
107
124
|
/** Defaults to the name, lower-cased, every run of other characters than `a-z0-9` a `-`. */
|
|
108
125
|
slug?: string;
|
|
109
126
|
description?: string;
|
|
110
|
-
/**
|
|
127
|
+
/**
|
|
128
|
+
* Defaults to 10. A whole number from 1 to 50 ({@link RECOMMENDATION_MAX_ITEMS_RANGE}); any
|
|
129
|
+
* other is refused `RECOMMENDATION_MAX_ITEMS_INVALID` before it is sent.
|
|
130
|
+
*/
|
|
111
131
|
maxItems?: number;
|
|
112
132
|
/** Defaults to after the store's last group. */
|
|
113
133
|
position?: number;
|
|
@@ -121,6 +141,11 @@ export interface UpdateRecommendationGroupInput {
|
|
|
121
141
|
name?: string;
|
|
122
142
|
slug?: string;
|
|
123
143
|
description?: string | null;
|
|
144
|
+
/**
|
|
145
|
+
* A whole number from 1 to 50, else refused `RECOMMENDATION_MAX_ITEMS_INVALID` before it is
|
|
146
|
+
* sent. Left out, the stored value stays (and is not judged, so a group stored outside the
|
|
147
|
+
* range before the rule can still be renamed, moved or disabled).
|
|
148
|
+
*/
|
|
124
149
|
maxItems?: number;
|
|
125
150
|
position?: number;
|
|
126
151
|
enabled?: boolean;
|
|
@@ -155,14 +180,16 @@ export interface RecommendationsModule {
|
|
|
155
180
|
*/
|
|
156
181
|
get(id: string, options?: Call): Promise<RecommendationGroup | null>;
|
|
157
182
|
/**
|
|
158
|
-
* Refused
|
|
159
|
-
* slug, the defaulted one included
|
|
183
|
+
* Refused: a `maxItems` outside 1–50 (`RECOMMENDATION_MAX_ITEMS_INVALID`, before anything is
|
|
184
|
+
* sent); a slug the store's other group already holds, the defaulted one included
|
|
185
|
+
* (`RECOMMENDATION_GROUP_SLUG_TAKEN`).
|
|
160
186
|
*
|
|
161
187
|
* @effect write
|
|
162
188
|
*/
|
|
163
189
|
create(input: CreateRecommendationGroupInput, options?: Call): Promise<RecommendationGroup>;
|
|
164
190
|
/**
|
|
165
|
-
* Refused: a
|
|
191
|
+
* Refused: a `maxItems` sent outside 1–50 (`RECOMMENDATION_MAX_ITEMS_INVALID`, before anything
|
|
192
|
+
* is sent); a group the store does not have (`ENTITY_NOT_FOUND`); a slug another of its groups
|
|
166
193
|
* holds (`RECOMMENDATION_GROUP_SLUG_TAKEN`).
|
|
167
194
|
*
|
|
168
195
|
* @effect write
|
|
@@ -1,4 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Product recommendations (headless spec §9.2 `catalog`: "recommendations"; W10-13): the store's
|
|
3
|
+
* recommendation groups ("Customers also bought", "Complete the look"), what each one draws its
|
|
4
|
+
* products from, and the products hand-picked per product for a manual group.
|
|
5
|
+
*
|
|
6
|
+
* - **A `DYNAMIC` group's entries are its rules**, read in `position` order: one named product
|
|
7
|
+
* (`PRODUCT`), the products of a collection (`COLLECTION`) or with a facet value
|
|
8
|
+
* (`FACET_VALUE`), or the viewed product's own collections (`SAME_COLLECTION`) or properties
|
|
9
|
+
* (`SAME_PROPERTIES`). The storefront fills the group up to `maxItems` from the first entry on,
|
|
10
|
+
* never with the viewed product, a disabled product or one the store hides from the shopper.
|
|
11
|
+
* - **A `MANUAL` group has no entries**: its products are picked per viewed product
|
|
12
|
+
* (`manual.set`), and the storefront shows them in the order picked. A group's mode is chosen
|
|
13
|
+
* when it is created and never changes.
|
|
14
|
+
* - **A disabled group** (`enabled: false`) is kept but never shown on the storefront.
|
|
15
|
+
* - **Positions** are integers; a group or entry created without one goes after the last.
|
|
16
|
+
* `reorder` rewrites them as `0, 1, 2…` in the order given.
|
|
17
|
+
*
|
|
18
|
+
* Permissions: reads need `ReadCatalog`, writes `UpdateCatalog`. Every row is the store's own: a
|
|
19
|
+
* group or entry of another store is never answered and never changed (a read answers `null` or
|
|
20
|
+
* leaves it out; a write refuses it as unknown), and a rule only ever draws from the store's own
|
|
21
|
+
* products, collections and facet values.
|
|
22
|
+
*
|
|
23
|
+
* Refusals, GraphQL errors mapped by `core`. An unknown id and another store's get the same answer,
|
|
24
|
+
* so none tells whether another store holds the row:
|
|
25
|
+
* - `ENTITY_NOT_FOUND` → `NotFoundError` (404): a group or entry the store does not have, and the
|
|
26
|
+
* viewed product of `manual.set` when the store does not hold it.
|
|
27
|
+
* - `RECOMMENDATION_SOURCE_NOT_FOUND` → `NotFoundError` (404): a rule's `sourceId` that is not a
|
|
28
|
+
* product, collection or facet value of the store (`entries.create`, `entries.update`).
|
|
29
|
+
* - `RECOMMENDATION_TARGET_NOT_FOUND` → `NotFoundError` (404): `manual.set` picks the store does
|
|
30
|
+
* not hold, each named in the message and in `details` (`field` `/targetProductIds/<index>`).
|
|
31
|
+
* - `RECOMMENDATION_GROUP_SLUG_TAKEN` → `ConflictError` (409): another of the store's groups holds
|
|
32
|
+
* the slug, also when two writes race for one free slug.
|
|
33
|
+
* - `RECOMMENDATION_MAX_ITEMS_INVALID` → `ValidationError` (400): a group's `maxItems` that is not
|
|
34
|
+
* a whole number from 1 to 50 ({@link RECOMMENDATION_MAX_ITEMS_RANGE}), on `groups.create` and
|
|
35
|
+
* on a `groups.update` that sends it, `details[0].field` `/input/maxItems`. This SDK refuses it
|
|
36
|
+
* before anything is sent, with the same code and detail, so an older platform release (which
|
|
37
|
+
* stores any value) never receives one.
|
|
38
|
+
* - `USER_INPUT_ERROR` → `ValidationError` (400), in the engine's words: a rule that does not fit
|
|
39
|
+
* its group or its type (a `MANUAL` group, a `sourceId` missing or sent where none goes), picks
|
|
40
|
+
* in a `DYNAMIC` group.
|
|
41
|
+
* - `FORBIDDEN` → `PermissionDeniedError` (403) without the permission.
|
|
42
|
+
*
|
|
43
|
+
* An id that is not a uuid (any id argument, a `sourceId` and each pick included) is refused
|
|
44
|
+
* before all of these with core's `ENTITY_NOT_FOUND` (`NotFoundError`, 404, no `details`), so
|
|
45
|
+
* branch on the class for not-found and on the code only to tell which row is missing.
|
|
46
|
+
*
|
|
47
|
+
* A platform release older than these codes answers each not-found and a taken slug as
|
|
48
|
+
* `USER_INPUT_ERROR` (400) in its own words ("Recommendation group not found"), a slug lost in a
|
|
49
|
+
* race as `DUPLICATE_RECORD`, and takes another store's collection or facet value as a rule's
|
|
50
|
+
* source.
|
|
51
|
+
*
|
|
52
|
+
* No release gate: every operation here is served by every platform release this SDK supports.
|
|
53
|
+
* No method deduplicates on `idempotencyKey`: repeating a `create` makes a second row.
|
|
54
|
+
*/
|
|
55
|
+
import { ValidationError } from '@sixtyninecommerce/core';
|
|
1
56
|
import { assertDeleted, DELETION_FIELDS, fieldOf } from './types.js';
|
|
57
|
+
/**
|
|
58
|
+
* The fewest and the most products a group may show (`maxItems`), both included. The platform
|
|
59
|
+
* refuses any other value with `RECOMMENDATION_MAX_ITEMS_INVALID`, and so does this SDK before
|
|
60
|
+
* sending.
|
|
61
|
+
*/
|
|
62
|
+
export const RECOMMENDATION_MAX_ITEMS_RANGE = { min: 1, max: 50 };
|
|
2
63
|
/** The source types whose entry names a row (`sourceId`); the others must name none. */
|
|
3
64
|
export const RECOMMENDATION_SOURCE_TYPES_WITH_ID = [
|
|
4
65
|
'PRODUCT',
|
|
@@ -103,6 +164,31 @@ export const SET_PRODUCT_MANUAL_RECOMMENDATIONS_MUTATION = /* GraphQL */ `
|
|
|
103
164
|
) { ${GROUP_SUMMARY_FIELDS} }
|
|
104
165
|
}
|
|
105
166
|
`;
|
|
167
|
+
/**
|
|
168
|
+
* The platform's `maxItems` rule, checked before sending: a value present and not null must be a
|
|
169
|
+
* whole number in {@link RECOMMENDATION_MAX_ITEMS_RANGE}. Same code, message and detail as the
|
|
170
|
+
* engine's refusal, so a caller handles one shape whichever side refused.
|
|
171
|
+
*/
|
|
172
|
+
function assertMaxItems(input) {
|
|
173
|
+
const maxItems = input?.maxItems;
|
|
174
|
+
if (maxItems == null)
|
|
175
|
+
return;
|
|
176
|
+
const { min, max } = RECOMMENDATION_MAX_ITEMS_RANGE;
|
|
177
|
+
if (typeof maxItems === 'number' &&
|
|
178
|
+
Number.isInteger(maxItems) &&
|
|
179
|
+
maxItems >= min &&
|
|
180
|
+
maxItems <= max) {
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
const message = `Max items must be a whole number from ${min} to ${max}; got ${JSON.stringify(maxItems)}`;
|
|
184
|
+
throw new ValidationError({
|
|
185
|
+
code: 'RECOMMENDATION_MAX_ITEMS_INVALID',
|
|
186
|
+
message,
|
|
187
|
+
status: 400,
|
|
188
|
+
service: 'vendure',
|
|
189
|
+
details: [{ field: '/input/maxItems', code: 'OUT_OF_RANGE', message }],
|
|
190
|
+
});
|
|
191
|
+
}
|
|
106
192
|
/** Entries in `position` order, the older first on a tie, as the engine's own entry reads sort. */
|
|
107
193
|
function byPosition(entries) {
|
|
108
194
|
return [...entries].sort((a, b) => a.position - b.position || a.createdAt.localeCompare(b.createdAt));
|
|
@@ -120,8 +206,14 @@ export function createRecommendationsModule(transport) {
|
|
|
120
206
|
const group = await call(RECOMMENDATION_GROUP_QUERY, 'recommendationGroup', { id }, options);
|
|
121
207
|
return group === null ? null : withSortedEntries(group);
|
|
122
208
|
},
|
|
123
|
-
create: async (input, options) =>
|
|
124
|
-
|
|
209
|
+
create: async (input, options) => {
|
|
210
|
+
assertMaxItems(input);
|
|
211
|
+
return withSortedEntries(await call(CREATE_RECOMMENDATION_GROUP_MUTATION, 'createRecommendationGroup', { input }, options));
|
|
212
|
+
},
|
|
213
|
+
update: async (id, input, options) => {
|
|
214
|
+
assertMaxItems(input);
|
|
215
|
+
return withSortedEntries(await call(UPDATE_RECOMMENDATION_GROUP_MUTATION, 'updateRecommendationGroup', { id, input }, options));
|
|
216
|
+
},
|
|
125
217
|
async delete(id, options) {
|
|
126
218
|
const response = await call(DELETE_RECOMMENDATION_GROUP_MUTATION, 'deleteRecommendationGroup', { id }, options);
|
|
127
219
|
assertDeleted('RECOMMENDATION_GROUP_NOT_DELETED', [response]);
|
|
@@ -21,6 +21,13 @@
|
|
|
21
21
|
* `CUSTOMER_DATA_NOT_GRANTED` (`PermissionDeniedError`, 403). The answers of the writes are
|
|
22
22
|
* blanked the same way.
|
|
23
23
|
*
|
|
24
|
+
* Publishing needs `full` (user decision O6): approving a review (`moderate`, or `bulkModerate`
|
|
25
|
+
* with `APPROVED`) and `reply.set` publish under the store's name next to text a caller below
|
|
26
|
+
* `full` reads blanked, so an app installation below it (or a request narrowed below it) is
|
|
27
|
+
* refused them with `CUSTOMER_DATA_NOT_GRANTED` (`PermissionDeniedError`, 403; `bulkModerate`
|
|
28
|
+
* reports it on each review). It may still reject, flag or re-open a review and remove the
|
|
29
|
+
* store's reply. A staff session and a personal API token act at `full`.
|
|
30
|
+
*
|
|
24
31
|
* Permissions: reads need `ReadCatalog`, writes `UpdateCatalog`. Every row is the store's own: a
|
|
25
32
|
* review or attribute of another store is never answered and never changed.
|
|
26
33
|
*
|
|
@@ -266,7 +273,9 @@ export interface ReviewsModule {
|
|
|
266
273
|
* Give one review a status: `APPROVED` publishes it on the storefront; `REJECTED`, `FLAGGED`
|
|
267
274
|
* and `PENDING` take it off. Recorded in its moderation log with `reason`. The product's
|
|
268
275
|
* average rating follows within seconds. Refused (`ENTITY_NOT_FOUND`) for a review the store
|
|
269
|
-
* does not have.
|
|
276
|
+
* does not have. `APPROVED` is refused `CUSTOMER_DATA_NOT_GRANTED` (`PermissionDeniedError`) to
|
|
277
|
+
* a caller below `customerData: full`, whatever the review; the other statuses are open at every
|
|
278
|
+
* level.
|
|
270
279
|
*
|
|
271
280
|
* @effect write
|
|
272
281
|
*/
|
|
@@ -277,7 +286,9 @@ export interface ReviewsModule {
|
|
|
277
286
|
* (`ENTITY_NOT_FOUND`) changes nothing, and the others still go through. A selection of no
|
|
278
287
|
* review, more than {@link MAX_BULK_REVIEWS} or one twice is refused
|
|
279
288
|
* `BULK_ACTION_INPUT_INVALID` (400) before anything is sent. Not keyed: a repeat moderates
|
|
280
|
-
* again (and logs again). Needs a platform release with `moderateReviews`.
|
|
289
|
+
* again (and logs again). Needs a platform release with `moderateReviews`. `APPROVED` below
|
|
290
|
+
* `customerData: full` reports every review `FAILED` with code `CUSTOMER_DATA_NOT_GRANTED` and
|
|
291
|
+
* changes none; the call itself succeeds.
|
|
281
292
|
*
|
|
282
293
|
* @effect write
|
|
283
294
|
*/
|
|
@@ -287,13 +298,15 @@ export interface ReviewsModule {
|
|
|
287
298
|
/**
|
|
288
299
|
* Set (or replace) the store's reply, trimmed; shown under the review on the storefront once
|
|
289
300
|
* it is approved. Over 2,000 characters is refused (`USER_INPUT_ERROR`). An empty reply is
|
|
290
|
-
* stored as one: use `remove` to take a reply away.
|
|
301
|
+
* stored as one: use `remove` to take a reply away. Refused `CUSTOMER_DATA_NOT_GRANTED`
|
|
302
|
+
* (`PermissionDeniedError`) below `customerData: full`: the reply is published under the
|
|
303
|
+
* store's name.
|
|
291
304
|
*
|
|
292
305
|
* @effect write
|
|
293
306
|
*/
|
|
294
307
|
set(reviewId: string, reply: string, options?: Call): Promise<ProductReviewSummary>;
|
|
295
308
|
/**
|
|
296
|
-
* Take the store's reply away.
|
|
309
|
+
* Take the store's reply away. Open at every customer-data level: it publishes nothing.
|
|
297
310
|
*
|
|
298
311
|
* @effect destructive
|
|
299
312
|
*/
|
package/dist/catalog/reviews.js
CHANGED
|
@@ -21,6 +21,13 @@
|
|
|
21
21
|
* `CUSTOMER_DATA_NOT_GRANTED` (`PermissionDeniedError`, 403). The answers of the writes are
|
|
22
22
|
* blanked the same way.
|
|
23
23
|
*
|
|
24
|
+
* Publishing needs `full` (user decision O6): approving a review (`moderate`, or `bulkModerate`
|
|
25
|
+
* with `APPROVED`) and `reply.set` publish under the store's name next to text a caller below
|
|
26
|
+
* `full` reads blanked, so an app installation below it (or a request narrowed below it) is
|
|
27
|
+
* refused them with `CUSTOMER_DATA_NOT_GRANTED` (`PermissionDeniedError`, 403; `bulkModerate`
|
|
28
|
+
* reports it on each review). It may still reject, flag or re-open a review and remove the
|
|
29
|
+
* store's reply. A staff session and a personal API token act at `full`.
|
|
30
|
+
*
|
|
24
31
|
* Permissions: reads need `ReadCatalog`, writes `UpdateCatalog`. Every row is the store's own: a
|
|
25
32
|
* review or attribute of another store is never answered and never changed.
|
|
26
33
|
*
|