@porulle/adapter-shopify 0.10.8 → 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 +145 -19
- 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 +2 -2
- package/src/index.ts +200 -20
- 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,21 +1,83 @@
|
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
const zeroDecimalCurrencies = new Set([
|
|
14
|
+
"BIF",
|
|
15
|
+
"CLP",
|
|
16
|
+
"DJF",
|
|
17
|
+
"GNF",
|
|
18
|
+
"ISK",
|
|
19
|
+
"JPY",
|
|
20
|
+
"KMF",
|
|
21
|
+
"KRW",
|
|
22
|
+
"PYG",
|
|
23
|
+
"RWF",
|
|
24
|
+
"UGX",
|
|
25
|
+
"VND",
|
|
26
|
+
"VUV",
|
|
27
|
+
"XAF",
|
|
28
|
+
"XOF",
|
|
29
|
+
"XPF",
|
|
30
|
+
]);
|
|
31
|
+
function normalizeCurrency(value) {
|
|
32
|
+
if (typeof value !== "string" || value.trim() === "")
|
|
33
|
+
return undefined;
|
|
34
|
+
return value.trim().toUpperCase();
|
|
35
|
+
}
|
|
36
|
+
function parseMoney(value, currency) {
|
|
37
|
+
if (value == null || value.trim() === "")
|
|
38
|
+
return undefined;
|
|
39
|
+
const parsed = Number(value);
|
|
40
|
+
if (!Number.isFinite(parsed))
|
|
41
|
+
return undefined;
|
|
42
|
+
const exponent = zeroDecimalCurrencies.has(currency) ? 0 : 2;
|
|
43
|
+
return Math.round(parsed * (10 ** exponent));
|
|
44
|
+
}
|
|
45
|
+
function pricesForVariant(variant, currency) {
|
|
46
|
+
if (!currency)
|
|
47
|
+
return undefined;
|
|
48
|
+
const amount = parseMoney(variant.price, currency);
|
|
49
|
+
if (amount === undefined)
|
|
50
|
+
return undefined;
|
|
51
|
+
const compareAtAmount = parseMoney(variant.compare_at_price, currency);
|
|
52
|
+
return [{
|
|
53
|
+
currency,
|
|
54
|
+
amount,
|
|
55
|
+
...(compareAtAmount !== undefined && compareAtAmount !== amount ? { compareAtAmount } : {}),
|
|
56
|
+
}];
|
|
57
|
+
}
|
|
58
|
+
function catalogStatus(value) {
|
|
59
|
+
return value === "draft" || value === "active" || value === "archived" ? value : undefined;
|
|
60
|
+
}
|
|
61
|
+
function slugify(value) {
|
|
62
|
+
return value.toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
63
|
+
}
|
|
64
|
+
async function fetchShopCurrency(fetchImpl, url, accessToken) {
|
|
65
|
+
const result = await request(fetchImpl, url, accessToken);
|
|
66
|
+
return result.ok ? normalizeCurrency(result.value.data.shop?.currency) : undefined;
|
|
15
67
|
}
|
|
16
68
|
function apiBase(store, version) {
|
|
17
69
|
return `https://${store.storeDomain.replace(/^https?:\/\//, "").replace(/\/$/, "")}/admin/api/${version}`;
|
|
18
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
|
+
}
|
|
19
81
|
async function request(fetchImpl, url, accessToken, init) {
|
|
20
82
|
try {
|
|
21
83
|
const response = await fetchImpl(url, {
|
|
@@ -73,12 +135,19 @@ function validOAuthHmac(searchParams, secret) {
|
|
|
73
135
|
function oauthError(code, message) {
|
|
74
136
|
return Err({ code, message, retriable: false });
|
|
75
137
|
}
|
|
138
|
+
export function shopifyReauthorizeUrl(options, params) {
|
|
139
|
+
return shopifyConnector(options).buildAuthUrl({
|
|
140
|
+
...params,
|
|
141
|
+
scopes: params.scopes ?? [],
|
|
142
|
+
});
|
|
143
|
+
}
|
|
76
144
|
export function shopifyConnector(options = {}) {
|
|
77
145
|
const fetchImpl = options.fetchImpl ?? fetch;
|
|
78
146
|
const version = options.apiVersion ?? "2024-10";
|
|
147
|
+
const currencyCache = new Map();
|
|
79
148
|
return defineChannelConnector({
|
|
80
149
|
providerId: "shopify",
|
|
81
|
-
capabilities: { importCatalog: true, importInventory: true, pushOrder: true, receiveWebhooks: true },
|
|
150
|
+
capabilities: { importCatalog: true, importInventory: true, pushOrder: true, pushCatalog: true, receiveWebhooks: true },
|
|
82
151
|
buildAuthUrl(params) {
|
|
83
152
|
if (!options.clientId || !options.clientSecret || !options.appUrl) {
|
|
84
153
|
return oauthError("SHOPIFY_OAUTH_NOT_CONFIGURED", "Shopify OAuth requires clientId, clientSecret, and appUrl.");
|
|
@@ -125,7 +194,10 @@ export function shopifyConnector(options = {}) {
|
|
|
125
194
|
const body = await response.json();
|
|
126
195
|
if (typeof body.access_token !== "string" || !body.access_token)
|
|
127
196
|
return oauthError("SHOPIFY_TOKEN_INVALID", "Shopify token exchange did not return an access token.");
|
|
128
|
-
|
|
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 });
|
|
129
201
|
}
|
|
130
202
|
catch (error) {
|
|
131
203
|
return Err({ code: "SHOPIFY_TOKEN_EXCHANGE_FAILED", message: error instanceof Error ? error.message : "Shopify token exchange failed.", retriable: true });
|
|
@@ -135,6 +207,14 @@ export function shopifyConnector(options = {}) {
|
|
|
135
207
|
const token = credentials(store);
|
|
136
208
|
if (!token)
|
|
137
209
|
return Err({ code: "SHOPIFY_CREDENTIALS_REQUIRED", message: "Shopify accessToken is required." });
|
|
210
|
+
const currencyUrl = `${apiBase(store, version)}/shop.json`;
|
|
211
|
+
const currencyKey = apiBase(store, version);
|
|
212
|
+
let currencyPromise = currencyCache.get(currencyKey);
|
|
213
|
+
if (!currencyPromise) {
|
|
214
|
+
currencyPromise = fetchShopCurrency(fetchImpl, currencyUrl, token);
|
|
215
|
+
currencyCache.set(currencyKey, currencyPromise);
|
|
216
|
+
}
|
|
217
|
+
const currency = await currencyPromise;
|
|
138
218
|
const url = cursor ?? `${apiBase(store, version)}/products.json?limit=250`;
|
|
139
219
|
const result = await request(fetchImpl, url, token);
|
|
140
220
|
if (!result.ok)
|
|
@@ -142,18 +222,53 @@ export function shopifyConnector(options = {}) {
|
|
|
142
222
|
const link = result.value.response.headers.get("link") ?? "";
|
|
143
223
|
const next = link.match(/<([^>]+)>;\s*rel="next"/)?.[1] ?? null;
|
|
144
224
|
return Ok({
|
|
145
|
-
items: result.value.data.products.map((product) =>
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
225
|
+
items: result.value.data.products.map((product) => {
|
|
226
|
+
const options = product.options?.map((option, index) => ({
|
|
227
|
+
name: option.name,
|
|
228
|
+
displayName: option.name,
|
|
229
|
+
...(option.position != null ? { sortOrder: option.position } : { sortOrder: index }),
|
|
230
|
+
values: (option.values ?? []).map((value, valueIndex) => ({ value, displayValue: value, sortOrder: valueIndex })),
|
|
231
|
+
}));
|
|
232
|
+
const variants = (product.variants ?? []).map((variant) => {
|
|
233
|
+
const selectors = [variant.option1, variant.option2, variant.option3];
|
|
234
|
+
const optionValues = Object.fromEntries((product.options ?? []).slice(0, 3).flatMap((option, index) => {
|
|
235
|
+
const value = selectors[index];
|
|
236
|
+
return value != null && value !== "" ? [[option.name, value]] : [];
|
|
237
|
+
}));
|
|
238
|
+
const prices = pricesForVariant(variant, currency);
|
|
239
|
+
return {
|
|
240
|
+
externalId: String(variant.id),
|
|
241
|
+
...(variant.sku ? { sku: variant.sku } : {}),
|
|
242
|
+
...(variant.barcode ? { barcode: variant.barcode } : {}),
|
|
243
|
+
...(Object.keys(optionValues).length > 0 ? { optionValues } : {}),
|
|
244
|
+
...(prices ? { prices } : {}),
|
|
245
|
+
};
|
|
246
|
+
});
|
|
247
|
+
const category = product.product_type ? slugify(product.product_type) : "";
|
|
248
|
+
const status = catalogStatus(product.status);
|
|
249
|
+
return {
|
|
250
|
+
externalId: String(product.id),
|
|
251
|
+
slug: product.handle ?? String(product.id),
|
|
252
|
+
title: product.title,
|
|
253
|
+
attributes: [{ locale: "en", title: product.title, ...(product.body_html != null ? { description: product.body_html } : {}) }],
|
|
254
|
+
variants,
|
|
255
|
+
...(product.images ? {
|
|
256
|
+
images: product.images.map((image, index) => ({
|
|
257
|
+
externalId: String(image.id),
|
|
258
|
+
url: image.src,
|
|
259
|
+
...(image.alt != null ? { alt: image.alt } : {}),
|
|
260
|
+
role: index === 0 ? "primary" : "gallery",
|
|
261
|
+
...(image.position != null ? { sortOrder: image.position } : {}),
|
|
262
|
+
...(image.variant_ids != null ? { variantExternalIds: image.variant_ids.map(String) } : {}),
|
|
263
|
+
})),
|
|
264
|
+
} : {}),
|
|
265
|
+
...(options ? { options } : {}),
|
|
266
|
+
...(product.tags != null ? { tags: product.tags.split(",").map((tag) => tag.trim()).filter(Boolean) } : {}),
|
|
267
|
+
...(product.vendor ? { brand: product.vendor } : {}),
|
|
268
|
+
...(category ? { categories: [category] } : {}),
|
|
269
|
+
...(status ? { status } : {}),
|
|
270
|
+
};
|
|
271
|
+
}),
|
|
157
272
|
nextCursor: next,
|
|
158
273
|
});
|
|
159
274
|
},
|
|
@@ -169,6 +284,17 @@ export function shopifyConnector(options = {}) {
|
|
|
169
284
|
return result;
|
|
170
285
|
return Ok(result.value.data.inventory_levels.map((level) => ({ externalId: String(level.inventory_item_id), available: level.available ?? 0 })));
|
|
171
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
|
+
},
|
|
172
298
|
async pushOrder(store, slice) {
|
|
173
299
|
const token = credentials(store);
|
|
174
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>>;
|