@porulle/adapter-shopify 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 +21 -0
- package/dist/index.d.ts +10 -2
- package/dist/index.js +35 -2
- package/dist/push-catalog.d.ts +18 -0
- package/dist/push-catalog.js +401 -0
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/package.json +4 -4
- package/src/index.ts +59 -3
- package/src/push-catalog.ts +574 -0
package/README.md
CHANGED
|
@@ -1,3 +1,24 @@
|
|
|
1
1
|
# @porulle/adapter-shopify
|
|
2
2
|
|
|
3
3
|
Shopify ingress channel connector for Porulle.
|
|
4
|
+
|
|
5
|
+
## Catalog push and re-authorisation
|
|
6
|
+
|
|
7
|
+
Catalog push requires the `write_products` OAuth scope. The adapter advertises
|
|
8
|
+
`capabilities.pushCatalog: true`, but effective push access is resolved per
|
|
9
|
+
store from `credentials.grantedScopes` recorded during OAuth (`completeAuth`).
|
|
10
|
+
|
|
11
|
+
Stores connected before `write_products` was added to `REQUIRED_SCOPES` hold
|
|
12
|
+
tokens without that scope. When push is attempted, the adapter returns
|
|
13
|
+
`SHOPIFY_WRITE_PRODUCTS_SCOPE_MISSING` with `retriable: false` and a link to
|
|
14
|
+
Porulle's OAuth start route (when `appUrl` is configured).
|
|
15
|
+
|
|
16
|
+
Operators can recover without disconnecting the store:
|
|
17
|
+
|
|
18
|
+
1. Start Shopify OAuth again for the same store (`/api/channels/oauth/shopify/start` in a Porulle app with this adapter configured).
|
|
19
|
+
2. Approve the updated scope list, which now includes `write_products`.
|
|
20
|
+
3. Retry the catalog push job.
|
|
21
|
+
|
|
22
|
+
Use `shopifyReauthorizeUrl(options, params)` to build the authorize URL outside
|
|
23
|
+
the push error path, or `shopifyPushCatalogEnabled(store)` to check scope
|
|
24
|
+
coverage before enqueueing work.
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type { ChannelConnector } from "@porulle/core";
|
|
1
|
+
import type { ChannelConnector, ChannelConnectorError, Result } from "@porulle/core";
|
|
2
|
+
export { PORULLE_METAFIELD_NAMESPACE, PUSH_CATALOG_SCOPE, SHOPIFY_NATIVE_PRODUCT_FIELDS, SHOPIFY_NATIVE_VARIANT_FIELDS, shopifyGrantedScopes, shopifyPushCatalogEnabled, shopifyWriteProductsScopeMissingError, } from "./push-catalog.js";
|
|
2
3
|
export interface ShopifyConnectorOptions {
|
|
3
4
|
fetchImpl?: typeof fetch;
|
|
4
5
|
apiVersion?: string;
|
|
@@ -7,5 +8,12 @@ export interface ShopifyConnectorOptions {
|
|
|
7
8
|
appUrl?: string;
|
|
8
9
|
scopes?: string[];
|
|
9
10
|
}
|
|
10
|
-
export declare const REQUIRED_SCOPES: readonly ["read_products", "read_inventory", "read_orders", "write_orders", "read_fulfillments"];
|
|
11
|
+
export declare const REQUIRED_SCOPES: readonly ["read_products", "read_inventory", "read_orders", "write_orders", "read_fulfillments", "write_products"];
|
|
12
|
+
export declare function shopifyReauthorizeUrl(options: ShopifyConnectorOptions, params: {
|
|
13
|
+
storeDomain: string;
|
|
14
|
+
state: string;
|
|
15
|
+
redirectUri: string;
|
|
16
|
+
callbackUri: string;
|
|
17
|
+
scopes?: string[];
|
|
18
|
+
}): Result<string, ChannelConnectorError>;
|
|
11
19
|
export declare function shopifyConnector(options?: ShopifyConnectorOptions): ChannelConnector;
|
package/dist/index.js
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { defineChannelConnector, Err, Ok } from "@porulle/core";
|
|
2
2
|
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
3
|
+
import { pushCatalog as executePushCatalog, } from "./push-catalog.js";
|
|
4
|
+
export { PORULLE_METAFIELD_NAMESPACE, PUSH_CATALOG_SCOPE, SHOPIFY_NATIVE_PRODUCT_FIELDS, SHOPIFY_NATIVE_VARIANT_FIELDS, shopifyGrantedScopes, shopifyPushCatalogEnabled, shopifyWriteProductsScopeMissingError, } from "./push-catalog.js";
|
|
3
5
|
export const REQUIRED_SCOPES = [
|
|
4
6
|
"read_products",
|
|
5
7
|
"read_inventory",
|
|
6
8
|
"read_orders",
|
|
7
9
|
"write_orders",
|
|
8
10
|
"read_fulfillments",
|
|
11
|
+
"write_products",
|
|
9
12
|
];
|
|
10
13
|
const zeroDecimalCurrencies = new Set([
|
|
11
14
|
"BIF",
|
|
@@ -65,6 +68,16 @@ async function fetchShopCurrency(fetchImpl, url, accessToken) {
|
|
|
65
68
|
function apiBase(store, version) {
|
|
66
69
|
return `https://${store.storeDomain.replace(/^https?:\/\//, "").replace(/\/$/, "")}/admin/api/${version}`;
|
|
67
70
|
}
|
|
71
|
+
function shopifyOAuthStartUrl(appUrl, storeDomain) {
|
|
72
|
+
try {
|
|
73
|
+
const url = new URL("/api/channels/oauth/shopify/start", appUrl);
|
|
74
|
+
url.searchParams.set("shop", storeDomain);
|
|
75
|
+
return url.toString();
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
68
81
|
async function request(fetchImpl, url, accessToken, init) {
|
|
69
82
|
try {
|
|
70
83
|
const response = await fetchImpl(url, {
|
|
@@ -122,13 +135,19 @@ function validOAuthHmac(searchParams, secret) {
|
|
|
122
135
|
function oauthError(code, message) {
|
|
123
136
|
return Err({ code, message, retriable: false });
|
|
124
137
|
}
|
|
138
|
+
export function shopifyReauthorizeUrl(options, params) {
|
|
139
|
+
return shopifyConnector(options).buildAuthUrl({
|
|
140
|
+
...params,
|
|
141
|
+
scopes: params.scopes ?? [],
|
|
142
|
+
});
|
|
143
|
+
}
|
|
125
144
|
export function shopifyConnector(options = {}) {
|
|
126
145
|
const fetchImpl = options.fetchImpl ?? fetch;
|
|
127
146
|
const version = options.apiVersion ?? "2024-10";
|
|
128
147
|
const currencyCache = new Map();
|
|
129
148
|
return defineChannelConnector({
|
|
130
149
|
providerId: "shopify",
|
|
131
|
-
capabilities: { importCatalog: true, importInventory: true, pushOrder: true, receiveWebhooks: true },
|
|
150
|
+
capabilities: { importCatalog: true, importInventory: true, pushOrder: true, pushCatalog: true, receiveWebhooks: true },
|
|
132
151
|
buildAuthUrl(params) {
|
|
133
152
|
if (!options.clientId || !options.clientSecret || !options.appUrl) {
|
|
134
153
|
return oauthError("SHOPIFY_OAUTH_NOT_CONFIGURED", "Shopify OAuth requires clientId, clientSecret, and appUrl.");
|
|
@@ -175,7 +194,10 @@ export function shopifyConnector(options = {}) {
|
|
|
175
194
|
const body = await response.json();
|
|
176
195
|
if (typeof body.access_token !== "string" || !body.access_token)
|
|
177
196
|
return oauthError("SHOPIFY_TOKEN_INVALID", "Shopify token exchange did not return an access token.");
|
|
178
|
-
|
|
197
|
+
const grantedScopes = typeof body.scope === "string"
|
|
198
|
+
? body.scope.split(",").map((scope) => scope.trim()).filter(Boolean)
|
|
199
|
+
: [];
|
|
200
|
+
return Ok({ credentials: { accessToken: body.access_token, grantedScopes }, storeDomain: shopDomain });
|
|
179
201
|
}
|
|
180
202
|
catch (error) {
|
|
181
203
|
return Err({ code: "SHOPIFY_TOKEN_EXCHANGE_FAILED", message: error instanceof Error ? error.message : "Shopify token exchange failed.", retriable: true });
|
|
@@ -262,6 +284,17 @@ export function shopifyConnector(options = {}) {
|
|
|
262
284
|
return result;
|
|
263
285
|
return Ok(result.value.data.inventory_levels.map((level) => ({ externalId: String(level.inventory_item_id), available: level.available ?? 0 })));
|
|
264
286
|
},
|
|
287
|
+
async pushCatalog(store, items, opts) {
|
|
288
|
+
const oauthStartUrl = options.appUrl ? shopifyOAuthStartUrl(options.appUrl, store.storeDomain) : undefined;
|
|
289
|
+
return executePushCatalog({
|
|
290
|
+
fetchImpl,
|
|
291
|
+
apiBase: (target) => apiBase(target, version),
|
|
292
|
+
credentials,
|
|
293
|
+
}, store, items, {
|
|
294
|
+
...(opts?.dryRun === true ? { dryRun: true } : {}),
|
|
295
|
+
...(oauthStartUrl ? { reauthorizeUrl: oauthStartUrl } : {}),
|
|
296
|
+
});
|
|
297
|
+
},
|
|
265
298
|
async pushOrder(store, slice) {
|
|
266
299
|
const token = credentials(store);
|
|
267
300
|
if (!token)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ChannelConnectorError, ChannelPushCatalogItem, ChannelPushCatalogResult, ChannelStore, Result } from "@porulle/core";
|
|
2
|
+
export declare const PUSH_CATALOG_SCOPE = "write_products";
|
|
3
|
+
export declare const PORULLE_METAFIELD_NAMESPACE = "porulle";
|
|
4
|
+
export declare const SHOPIFY_NATIVE_PRODUCT_FIELDS: Set<string>;
|
|
5
|
+
export declare const SHOPIFY_NATIVE_VARIANT_FIELDS: Set<string>;
|
|
6
|
+
export interface PushCatalogDeps {
|
|
7
|
+
fetchImpl: typeof fetch;
|
|
8
|
+
apiBase: (store: ChannelStore) => string;
|
|
9
|
+
credentials: (store: ChannelStore) => string | undefined;
|
|
10
|
+
sleep?: (ms: number) => Promise<void>;
|
|
11
|
+
}
|
|
12
|
+
export declare function shopifyGrantedScopes(store: ChannelStore): string[];
|
|
13
|
+
export declare function shopifyPushCatalogEnabled(store: ChannelStore): boolean;
|
|
14
|
+
export declare function shopifyWriteProductsScopeMissingError(reauthorizeUrl?: string): ChannelConnectorError;
|
|
15
|
+
export declare function pushCatalog(deps: PushCatalogDeps, store: ChannelStore, items: ChannelPushCatalogItem[], opts?: {
|
|
16
|
+
dryRun?: boolean;
|
|
17
|
+
reauthorizeUrl?: string;
|
|
18
|
+
}): Promise<Result<ChannelPushCatalogResult, ChannelConnectorError>>;
|
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
import { Err, Ok } from "@porulle/core";
|
|
2
|
+
export const PUSH_CATALOG_SCOPE = "write_products";
|
|
3
|
+
export const PORULLE_METAFIELD_NAMESPACE = "porulle";
|
|
4
|
+
export const SHOPIFY_NATIVE_PRODUCT_FIELDS = new Set([
|
|
5
|
+
"title",
|
|
6
|
+
"body_html",
|
|
7
|
+
"vendor",
|
|
8
|
+
"product_type",
|
|
9
|
+
"handle",
|
|
10
|
+
"status",
|
|
11
|
+
]);
|
|
12
|
+
export const SHOPIFY_NATIVE_VARIANT_FIELDS = new Set([
|
|
13
|
+
"sku",
|
|
14
|
+
"barcode",
|
|
15
|
+
]);
|
|
16
|
+
export function shopifyGrantedScopes(store) {
|
|
17
|
+
const raw = store.credentials.grantedScopes;
|
|
18
|
+
if (Array.isArray(raw)) {
|
|
19
|
+
return raw.filter((scope) => typeof scope === "string" && scope.length > 0);
|
|
20
|
+
}
|
|
21
|
+
if (typeof raw === "string" && raw.length > 0) {
|
|
22
|
+
return raw.split(",").map((scope) => scope.trim()).filter(Boolean);
|
|
23
|
+
}
|
|
24
|
+
return [];
|
|
25
|
+
}
|
|
26
|
+
export function shopifyPushCatalogEnabled(store) {
|
|
27
|
+
return shopifyGrantedScopes(store).includes(PUSH_CATALOG_SCOPE);
|
|
28
|
+
}
|
|
29
|
+
export function shopifyWriteProductsScopeMissingError(reauthorizeUrl) {
|
|
30
|
+
const suffix = reauthorizeUrl
|
|
31
|
+
? ` Re-authorize at ${reauthorizeUrl}.`
|
|
32
|
+
: " Re-authorize the store through the Shopify OAuth start route.";
|
|
33
|
+
return {
|
|
34
|
+
code: "SHOPIFY_WRITE_PRODUCTS_SCOPE_MISSING",
|
|
35
|
+
message: `Shopify store is missing the ${PUSH_CATALOG_SCOPE} scope.${suffix}`,
|
|
36
|
+
retriable: false,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
function parseCallLimit(header) {
|
|
40
|
+
const match = header?.match(/^(\d+)\/(\d+)$/);
|
|
41
|
+
if (!match)
|
|
42
|
+
return undefined;
|
|
43
|
+
return { current: Number(match[1]), max: Number(match[2]) };
|
|
44
|
+
}
|
|
45
|
+
function metafieldType(value) {
|
|
46
|
+
if (typeof value === "boolean")
|
|
47
|
+
return "boolean";
|
|
48
|
+
if (typeof value === "number")
|
|
49
|
+
return Number.isInteger(value) ? "number_integer" : "number_decimal";
|
|
50
|
+
if (typeof value === "object" && value !== null)
|
|
51
|
+
return "json";
|
|
52
|
+
return "single_line_text_field";
|
|
53
|
+
}
|
|
54
|
+
function metafieldValue(value) {
|
|
55
|
+
if (typeof value === "string")
|
|
56
|
+
return value;
|
|
57
|
+
if (typeof value === "number" || typeof value === "boolean")
|
|
58
|
+
return String(value);
|
|
59
|
+
return JSON.stringify(value);
|
|
60
|
+
}
|
|
61
|
+
function remoteKey(field) {
|
|
62
|
+
if (typeof field.remoteKey === "string" && field.remoteKey.length > 0)
|
|
63
|
+
return field.remoteKey;
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
function missingRemoteKeyError(field) {
|
|
67
|
+
return {
|
|
68
|
+
code: "SHOPIFY_REMOTE_KEY_REQUIRED",
|
|
69
|
+
message: `Shopify catalog field ${field.fieldPath} requires a remoteKey.`,
|
|
70
|
+
retriable: false,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
function isNativeProductField(field) {
|
|
74
|
+
const key = remoteKey(field);
|
|
75
|
+
return field.intent === "display" && key !== undefined && SHOPIFY_NATIVE_PRODUCT_FIELDS.has(key);
|
|
76
|
+
}
|
|
77
|
+
function isNativeVariantField(field) {
|
|
78
|
+
const key = remoteKey(field);
|
|
79
|
+
return field.intent === "display" && key !== undefined && SHOPIFY_NATIVE_VARIANT_FIELDS.has(key);
|
|
80
|
+
}
|
|
81
|
+
function isMetafieldField(field) {
|
|
82
|
+
if (field.intent === "filterable")
|
|
83
|
+
return true;
|
|
84
|
+
if (field.intent === "tag")
|
|
85
|
+
return false;
|
|
86
|
+
return field.intent === "display" && remoteKey(field) !== undefined && !isNativeProductField(field) && !isNativeVariantField(field);
|
|
87
|
+
}
|
|
88
|
+
function parseTags(value) {
|
|
89
|
+
if (!value)
|
|
90
|
+
return [];
|
|
91
|
+
return value.split(",").map((tag) => tag.trim()).filter(Boolean);
|
|
92
|
+
}
|
|
93
|
+
function mergeTags(existing, pushed) {
|
|
94
|
+
return [...new Set([...existing, ...pushed])].join(", ");
|
|
95
|
+
}
|
|
96
|
+
function collectPreviousFields(product, metafields, variantMetafields, item) {
|
|
97
|
+
const previous = [];
|
|
98
|
+
const metafieldByKey = new Map(metafields
|
|
99
|
+
.filter((entry) => entry.namespace === PORULLE_METAFIELD_NAMESPACE)
|
|
100
|
+
.map((entry) => [entry.key, entry]));
|
|
101
|
+
for (const field of item.fields) {
|
|
102
|
+
if (field.intent === "tag") {
|
|
103
|
+
const tag = field.value == null ? "" : String(field.value);
|
|
104
|
+
previous.push({
|
|
105
|
+
fieldPath: field.fieldPath,
|
|
106
|
+
value: tag.length > 0 && parseTags(product.tags).includes(tag) ? tag : null,
|
|
107
|
+
});
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (isNativeProductField(field)) {
|
|
111
|
+
const key = remoteKey(field);
|
|
112
|
+
if (key !== undefined)
|
|
113
|
+
previous.push({ fieldPath: field.fieldPath, value: product[key] ?? null });
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (isMetafieldField(field)) {
|
|
117
|
+
const key = remoteKey(field);
|
|
118
|
+
if (key !== undefined) {
|
|
119
|
+
const existing = metafieldByKey.get(key);
|
|
120
|
+
previous.push({ fieldPath: field.fieldPath, value: existing?.value ?? null });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
for (const variant of item.variants ?? []) {
|
|
125
|
+
for (const field of variant.fields) {
|
|
126
|
+
if (isNativeVariantField(field)) {
|
|
127
|
+
const key = remoteKey(field);
|
|
128
|
+
const snapshot = product.variants?.find((entry) => String(entry.id) === variant.externalId);
|
|
129
|
+
previous.push({ fieldPath: field.fieldPath, value: key !== undefined ? snapshot?.[key] ?? null : null });
|
|
130
|
+
}
|
|
131
|
+
else if (isMetafieldField(field)) {
|
|
132
|
+
const key = remoteKey(field);
|
|
133
|
+
if (key !== undefined) {
|
|
134
|
+
const existing = new Map((variantMetafields.get(variant.externalId) ?? [])
|
|
135
|
+
.filter((entry) => entry.namespace === PORULLE_METAFIELD_NAMESPACE)
|
|
136
|
+
.map((entry) => [entry.key, entry])).get(key);
|
|
137
|
+
previous.push({ fieldPath: field.fieldPath, value: existing?.value ?? null });
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return previous;
|
|
143
|
+
}
|
|
144
|
+
async function pushRequest(deps, url, token, init) {
|
|
145
|
+
const sleep = deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
146
|
+
try {
|
|
147
|
+
const response = await deps.fetchImpl(url, {
|
|
148
|
+
...init,
|
|
149
|
+
headers: {
|
|
150
|
+
accept: "application/json",
|
|
151
|
+
...(init?.headers ?? {}),
|
|
152
|
+
"x-shopify-access-token": token,
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
const limit = parseCallLimit(response.headers.get("x-shopify-shop-api-call-limit"));
|
|
156
|
+
if (limit && limit.current >= Math.max(1, limit.max - 2)) {
|
|
157
|
+
await sleep(500);
|
|
158
|
+
}
|
|
159
|
+
if (response.status === 429) {
|
|
160
|
+
return {
|
|
161
|
+
ok: false,
|
|
162
|
+
status: 429,
|
|
163
|
+
response,
|
|
164
|
+
error: {
|
|
165
|
+
code: "SHOPIFY_RATE_LIMITED",
|
|
166
|
+
message: "Shopify API rate limit exceeded.",
|
|
167
|
+
retriable: true,
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
const method = (init?.method ?? "GET").toUpperCase();
|
|
172
|
+
if (response.status === 403 && method !== "GET") {
|
|
173
|
+
return {
|
|
174
|
+
ok: false,
|
|
175
|
+
status: 403,
|
|
176
|
+
response,
|
|
177
|
+
error: shopifyWriteProductsScopeMissingError(),
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
if (!response.ok) {
|
|
181
|
+
return {
|
|
182
|
+
ok: false,
|
|
183
|
+
status: response.status,
|
|
184
|
+
response,
|
|
185
|
+
error: {
|
|
186
|
+
code: "SHOPIFY_API_FAILED",
|
|
187
|
+
message: `Shopify API request failed (${response.status}) for ${url}.`,
|
|
188
|
+
retriable: response.status >= 500,
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
const text = await response.text();
|
|
193
|
+
const data = text.length > 0 ? JSON.parse(text) : {};
|
|
194
|
+
return { ok: true, data, response };
|
|
195
|
+
}
|
|
196
|
+
catch (error) {
|
|
197
|
+
return {
|
|
198
|
+
ok: false,
|
|
199
|
+
error: {
|
|
200
|
+
code: "SHOPIFY_API_FAILED",
|
|
201
|
+
message: error instanceof Error ? error.message : "Shopify API request failed.",
|
|
202
|
+
retriable: true,
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
async function loadProductSnapshot(deps, store, token, externalId, variantIds) {
|
|
208
|
+
const base = deps.apiBase(store);
|
|
209
|
+
const productResult = await pushRequest(deps, `${base}/products/${encodeURIComponent(externalId)}.json`, token);
|
|
210
|
+
if (!productResult.ok)
|
|
211
|
+
return productResult;
|
|
212
|
+
const metafieldsResult = await pushRequest(deps, `${base}/products/${encodeURIComponent(externalId)}/metafields.json?namespace=${encodeURIComponent(PORULLE_METAFIELD_NAMESPACE)}`, token);
|
|
213
|
+
if (!metafieldsResult.ok)
|
|
214
|
+
return metafieldsResult;
|
|
215
|
+
const variantMetafields = new Map();
|
|
216
|
+
for (const variantId of variantIds) {
|
|
217
|
+
const variantResult = await pushRequest(deps, `${base}/variants/${encodeURIComponent(variantId)}/metafields.json?namespace=${encodeURIComponent(PORULLE_METAFIELD_NAMESPACE)}`, token);
|
|
218
|
+
if (!variantResult.ok)
|
|
219
|
+
return variantResult;
|
|
220
|
+
variantMetafields.set(variantId, variantResult.data.metafields ?? []);
|
|
221
|
+
}
|
|
222
|
+
return Ok({
|
|
223
|
+
product: productResult.data.product,
|
|
224
|
+
metafields: metafieldsResult.data.metafields ?? [],
|
|
225
|
+
variantMetafields,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
async function writeNativeProduct(deps, store, token, externalId, fields, tagValues, existingTags) {
|
|
229
|
+
const product = { id: externalId };
|
|
230
|
+
for (const field of fields) {
|
|
231
|
+
if (!isNativeProductField(field))
|
|
232
|
+
continue;
|
|
233
|
+
const key = remoteKey(field);
|
|
234
|
+
if (key !== undefined)
|
|
235
|
+
product[key] = field.value;
|
|
236
|
+
}
|
|
237
|
+
if (tagValues.length > 0) {
|
|
238
|
+
product.tags = mergeTags(existingTags, tagValues);
|
|
239
|
+
}
|
|
240
|
+
if (Object.keys(product).length <= 1) {
|
|
241
|
+
return {
|
|
242
|
+
ok: true,
|
|
243
|
+
data: { product: { id: externalId } },
|
|
244
|
+
response: new Response(null, { status: 200 }),
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
return pushRequest(deps, `${deps.apiBase(store)}/products/${encodeURIComponent(externalId)}.json`, token, {
|
|
248
|
+
method: "PUT",
|
|
249
|
+
headers: { "content-type": "application/json" },
|
|
250
|
+
body: JSON.stringify({ product }),
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
async function writeVariantFields(deps, store, token, variantExternalId, fields) {
|
|
254
|
+
const variant = { id: variantExternalId };
|
|
255
|
+
for (const field of fields) {
|
|
256
|
+
if (!isNativeVariantField(field))
|
|
257
|
+
continue;
|
|
258
|
+
const key = remoteKey(field);
|
|
259
|
+
if (key !== undefined)
|
|
260
|
+
variant[key] = field.value;
|
|
261
|
+
}
|
|
262
|
+
if (Object.keys(variant).length <= 1) {
|
|
263
|
+
return {
|
|
264
|
+
ok: true,
|
|
265
|
+
data: { variant: { id: variantExternalId } },
|
|
266
|
+
response: new Response(null, { status: 200 }),
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
return pushRequest(deps, `${deps.apiBase(store)}/variants/${encodeURIComponent(variantExternalId)}.json`, token, {
|
|
270
|
+
method: "PUT",
|
|
271
|
+
headers: { "content-type": "application/json" },
|
|
272
|
+
body: JSON.stringify({ variant }),
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
async function writeMetafield(deps, store, token, externalId, field, existing, resource) {
|
|
276
|
+
const key = remoteKey(field);
|
|
277
|
+
if (key === undefined)
|
|
278
|
+
return { ok: false, error: missingRemoteKeyError(field) };
|
|
279
|
+
const payload = {
|
|
280
|
+
metafield: {
|
|
281
|
+
namespace: PORULLE_METAFIELD_NAMESPACE,
|
|
282
|
+
key,
|
|
283
|
+
value: metafieldValue(field.value),
|
|
284
|
+
type: metafieldType(field.value),
|
|
285
|
+
},
|
|
286
|
+
};
|
|
287
|
+
if (existing) {
|
|
288
|
+
return pushRequest(deps, `${deps.apiBase(store)}/metafields/${encodeURIComponent(String(existing.id))}.json`, token, {
|
|
289
|
+
method: "PUT",
|
|
290
|
+
headers: { "content-type": "application/json" },
|
|
291
|
+
body: JSON.stringify(payload),
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
return pushRequest(deps, `${deps.apiBase(store)}/${resource}s/${encodeURIComponent(externalId)}/metafields.json`, token, {
|
|
295
|
+
method: "POST",
|
|
296
|
+
headers: { "content-type": "application/json" },
|
|
297
|
+
body: JSON.stringify(payload),
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
async function pushCatalogItem(deps, store, token, item, dryRun) {
|
|
301
|
+
const missingField = [...item.fields, ...(item.variants ?? []).flatMap((variant) => variant.fields)]
|
|
302
|
+
.find((field) => field.intent !== "tag" && remoteKey(field) === undefined);
|
|
303
|
+
if (missingField)
|
|
304
|
+
return { externalId: item.externalId, ok: false, error: missingRemoteKeyError(missingField) };
|
|
305
|
+
if (item.images && item.images.length > 0) {
|
|
306
|
+
return {
|
|
307
|
+
externalId: item.externalId,
|
|
308
|
+
ok: false,
|
|
309
|
+
error: {
|
|
310
|
+
code: "SHOPIFY_IMAGES_NOT_WRITTEN",
|
|
311
|
+
message: "Shopify catalog image pushes are not written by this adapter.",
|
|
312
|
+
retriable: false,
|
|
313
|
+
},
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
const variantIds = (item.variants ?? [])
|
|
317
|
+
.filter((variant) => variant.fields.some((field) => isMetafieldField(field)))
|
|
318
|
+
.map((variant) => variant.externalId);
|
|
319
|
+
const snapshot = await loadProductSnapshot(deps, store, token, item.externalId, variantIds);
|
|
320
|
+
if (!snapshot.ok) {
|
|
321
|
+
return { externalId: item.externalId, ok: false, error: snapshot.error };
|
|
322
|
+
}
|
|
323
|
+
const previousFields = collectPreviousFields(snapshot.value.product, snapshot.value.metafields, snapshot.value.variantMetafields, item);
|
|
324
|
+
if (dryRun) {
|
|
325
|
+
return {
|
|
326
|
+
externalId: item.externalId,
|
|
327
|
+
ok: true,
|
|
328
|
+
...(previousFields.length > 0 ? { previousFields } : {}),
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
const metafieldByKey = new Map(snapshot.value.metafields
|
|
332
|
+
.filter((entry) => entry.namespace === PORULLE_METAFIELD_NAMESPACE)
|
|
333
|
+
.map((entry) => [entry.key, entry]));
|
|
334
|
+
const productFields = item.fields.filter((field) => field.intent !== "tag");
|
|
335
|
+
const tagValues = item.fields
|
|
336
|
+
.filter((field) => field.intent === "tag")
|
|
337
|
+
.flatMap((field) => {
|
|
338
|
+
if (typeof field.value === "string")
|
|
339
|
+
return [field.value];
|
|
340
|
+
if (Array.isArray(field.value))
|
|
341
|
+
return field.value.filter((entry) => typeof entry === "string");
|
|
342
|
+
return field.value == null ? [] : [String(field.value)];
|
|
343
|
+
});
|
|
344
|
+
const nativeResult = await writeNativeProduct(deps, store, token, item.externalId, productFields, tagValues, parseTags(snapshot.value.product.tags));
|
|
345
|
+
if (!nativeResult.ok) {
|
|
346
|
+
return { externalId: item.externalId, ok: false, error: nativeResult.error };
|
|
347
|
+
}
|
|
348
|
+
for (const field of productFields) {
|
|
349
|
+
if (!isMetafieldField(field))
|
|
350
|
+
continue;
|
|
351
|
+
const key = remoteKey(field);
|
|
352
|
+
const metafieldResult = await writeMetafield(deps, store, token, item.externalId, field, key !== undefined ? metafieldByKey.get(key) : undefined, "product");
|
|
353
|
+
if (!metafieldResult.ok) {
|
|
354
|
+
return { externalId: item.externalId, ok: false, error: metafieldResult.error };
|
|
355
|
+
}
|
|
356
|
+
if (metafieldResult.data.metafield) {
|
|
357
|
+
metafieldByKey.set(metafieldResult.data.metafield.key, metafieldResult.data.metafield);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
for (const variant of item.variants ?? []) {
|
|
361
|
+
const variantMetafieldByKey = new Map((snapshot.value.variantMetafields.get(variant.externalId) ?? [])
|
|
362
|
+
.filter((entry) => entry.namespace === PORULLE_METAFIELD_NAMESPACE)
|
|
363
|
+
.map((entry) => [entry.key, entry]));
|
|
364
|
+
for (const field of variant.fields) {
|
|
365
|
+
if (!isMetafieldField(field))
|
|
366
|
+
continue;
|
|
367
|
+
const key = remoteKey(field);
|
|
368
|
+
const metafieldResult = await writeMetafield(deps, store, token, variant.externalId, field, key !== undefined ? variantMetafieldByKey.get(key) : undefined, "variant");
|
|
369
|
+
if (!metafieldResult.ok) {
|
|
370
|
+
return { externalId: item.externalId, ok: false, error: metafieldResult.error };
|
|
371
|
+
}
|
|
372
|
+
if (metafieldResult.data.metafield) {
|
|
373
|
+
variantMetafieldByKey.set(metafieldResult.data.metafield.key, metafieldResult.data.metafield);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
const variantResult = await writeVariantFields(deps, store, token, variant.externalId, variant.fields);
|
|
377
|
+
if (!variantResult.ok) {
|
|
378
|
+
return { externalId: item.externalId, ok: false, error: variantResult.error };
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
return {
|
|
382
|
+
externalId: item.externalId,
|
|
383
|
+
ok: true,
|
|
384
|
+
...(nativeResult.data.product.updated_at ? { remoteUpdatedAt: nativeResult.data.product.updated_at } : {}),
|
|
385
|
+
...(previousFields.length > 0 ? { previousFields } : {}),
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
export async function pushCatalog(deps, store, items, opts) {
|
|
389
|
+
const token = deps.credentials(store);
|
|
390
|
+
if (!token) {
|
|
391
|
+
return Err({ code: "SHOPIFY_CREDENTIALS_REQUIRED", message: "Shopify accessToken is required.", retriable: false });
|
|
392
|
+
}
|
|
393
|
+
if (!shopifyPushCatalogEnabled(store)) {
|
|
394
|
+
return Err(shopifyWriteProductsScopeMissingError(opts?.reauthorizeUrl));
|
|
395
|
+
}
|
|
396
|
+
const outcomes = [];
|
|
397
|
+
for (const item of items) {
|
|
398
|
+
outcomes.push(await pushCatalogItem(deps, store, token, item, opts?.dryRun === true));
|
|
399
|
+
}
|
|
400
|
+
return Ok({ outcomes });
|
|
401
|
+
}
|