@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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@porulle/adapter-shopify",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
}
|
|
11
11
|
},
|
|
12
12
|
"dependencies": {
|
|
13
|
-
"@porulle/core": "0.
|
|
13
|
+
"@porulle/core": "0.13.0"
|
|
14
14
|
},
|
|
15
15
|
"devDependencies": {
|
|
16
16
|
"@types/node": "^24.5.2",
|
package/src/index.ts
CHANGED
|
@@ -2,14 +2,30 @@ import { defineChannelConnector, Err, Ok } from "@porulle/core";
|
|
|
2
2
|
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
3
3
|
import type {
|
|
4
4
|
ChannelCatalogPage,
|
|
5
|
+
ChannelCatalogPrice,
|
|
5
6
|
ChannelConnector,
|
|
6
7
|
ChannelConnectorError,
|
|
7
8
|
ChannelInventoryLevel,
|
|
9
|
+
ChannelPushCatalogItem,
|
|
10
|
+
ChannelPushCatalogResult,
|
|
8
11
|
ChannelStore,
|
|
9
12
|
ChannelOrderSlice,
|
|
10
13
|
ChannelOrderStatus,
|
|
11
14
|
Result,
|
|
12
15
|
} from "@porulle/core";
|
|
16
|
+
import {
|
|
17
|
+
pushCatalog as executePushCatalog,
|
|
18
|
+
} from "./push-catalog.js";
|
|
19
|
+
|
|
20
|
+
export {
|
|
21
|
+
PORULLE_METAFIELD_NAMESPACE,
|
|
22
|
+
PUSH_CATALOG_SCOPE,
|
|
23
|
+
SHOPIFY_NATIVE_PRODUCT_FIELDS,
|
|
24
|
+
SHOPIFY_NATIVE_VARIANT_FIELDS,
|
|
25
|
+
shopifyGrantedScopes,
|
|
26
|
+
shopifyPushCatalogEnabled,
|
|
27
|
+
shopifyWriteProductsScopeMissingError,
|
|
28
|
+
} from "./push-catalog.js";
|
|
13
29
|
|
|
14
30
|
export interface ShopifyConnectorOptions {
|
|
15
31
|
fetchImpl?: typeof fetch;
|
|
@@ -26,6 +42,7 @@ export const REQUIRED_SCOPES = [
|
|
|
26
42
|
"read_orders",
|
|
27
43
|
"write_orders",
|
|
28
44
|
"read_fulfillments",
|
|
45
|
+
"write_products",
|
|
29
46
|
] as const;
|
|
30
47
|
|
|
31
48
|
type ShopifyProduct = {
|
|
@@ -33,19 +50,108 @@ type ShopifyProduct = {
|
|
|
33
50
|
title: string;
|
|
34
51
|
handle?: string;
|
|
35
52
|
body_html?: string;
|
|
36
|
-
|
|
53
|
+
vendor?: string | null;
|
|
54
|
+
product_type?: string | null;
|
|
55
|
+
tags?: string | null;
|
|
56
|
+
status?: string | null;
|
|
57
|
+
images?: Array<{
|
|
58
|
+
id: number | string;
|
|
59
|
+
src: string;
|
|
60
|
+
alt?: string | null;
|
|
61
|
+
position?: number | null;
|
|
62
|
+
variant_ids?: Array<number | string> | null;
|
|
63
|
+
}>;
|
|
64
|
+
options?: Array<{
|
|
65
|
+
name: string;
|
|
66
|
+
position?: number | null;
|
|
67
|
+
values?: string[];
|
|
68
|
+
}>;
|
|
69
|
+
variants?: Array<{
|
|
70
|
+
id: number | string;
|
|
71
|
+
sku?: string | null;
|
|
72
|
+
barcode?: string | null;
|
|
73
|
+
price?: string | null;
|
|
74
|
+
compare_at_price?: string | null;
|
|
75
|
+
option1?: string | null;
|
|
76
|
+
option2?: string | null;
|
|
77
|
+
option3?: string | null;
|
|
78
|
+
}>;
|
|
37
79
|
};
|
|
38
80
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
81
|
+
const zeroDecimalCurrencies = new Set([
|
|
82
|
+
"BIF",
|
|
83
|
+
"CLP",
|
|
84
|
+
"DJF",
|
|
85
|
+
"GNF",
|
|
86
|
+
"ISK",
|
|
87
|
+
"JPY",
|
|
88
|
+
"KMF",
|
|
89
|
+
"KRW",
|
|
90
|
+
"PYG",
|
|
91
|
+
"RWF",
|
|
92
|
+
"UGX",
|
|
93
|
+
"VND",
|
|
94
|
+
"VUV",
|
|
95
|
+
"XAF",
|
|
96
|
+
"XOF",
|
|
97
|
+
"XPF",
|
|
98
|
+
]);
|
|
99
|
+
|
|
100
|
+
function normalizeCurrency(value: unknown): string | undefined {
|
|
101
|
+
if (typeof value !== "string" || value.trim() === "") return undefined;
|
|
102
|
+
return value.trim().toUpperCase();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function parseMoney(value: string | null | undefined, currency: string): number | undefined {
|
|
106
|
+
if (value == null || value.trim() === "") return undefined;
|
|
107
|
+
const parsed = Number(value);
|
|
108
|
+
if (!Number.isFinite(parsed)) return undefined;
|
|
109
|
+
const exponent = zeroDecimalCurrencies.has(currency) ? 0 : 2;
|
|
110
|
+
return Math.round(parsed * (10 ** exponent));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function pricesForVariant(
|
|
114
|
+
variant: NonNullable<ShopifyProduct["variants"]>[number],
|
|
115
|
+
currency: string | undefined,
|
|
116
|
+
): ChannelCatalogPrice[] | undefined {
|
|
117
|
+
if (!currency) return undefined;
|
|
118
|
+
const amount = parseMoney(variant.price, currency);
|
|
119
|
+
if (amount === undefined) return undefined;
|
|
120
|
+
const compareAtAmount = parseMoney(variant.compare_at_price, currency);
|
|
121
|
+
return [{
|
|
122
|
+
currency,
|
|
123
|
+
amount,
|
|
124
|
+
...(compareAtAmount !== undefined && compareAtAmount !== amount ? { compareAtAmount } : {}),
|
|
125
|
+
}];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function catalogStatus(value: string | null | undefined): "draft" | "active" | "archived" | undefined {
|
|
129
|
+
return value === "draft" || value === "active" || value === "archived" ? value : undefined;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function slugify(value: string): string {
|
|
133
|
+
return value.toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function fetchShopCurrency(fetchImpl: typeof fetch, url: string, accessToken: string): Promise<string | undefined> {
|
|
137
|
+
const result = await request<{ shop?: { currency?: string | null } }>(fetchImpl, url, accessToken);
|
|
138
|
+
return result.ok ? normalizeCurrency(result.value.data.shop?.currency) : undefined;
|
|
43
139
|
}
|
|
44
140
|
|
|
45
141
|
function apiBase(store: ChannelStore, version: string): string {
|
|
46
142
|
return `https://${store.storeDomain.replace(/^https?:\/\//, "").replace(/\/$/, "")}/admin/api/${version}`;
|
|
47
143
|
}
|
|
48
144
|
|
|
145
|
+
function shopifyOAuthStartUrl(appUrl: string, storeDomain: string): string | undefined {
|
|
146
|
+
try {
|
|
147
|
+
const url = new URL("/api/channels/oauth/shopify/start", appUrl);
|
|
148
|
+
url.searchParams.set("shop", storeDomain);
|
|
149
|
+
return url.toString();
|
|
150
|
+
} catch {
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
49
155
|
async function request<T>(fetchImpl: typeof fetch, url: string, accessToken: string, init?: RequestInit): Promise<Result<{ data: T; response: Response }>> {
|
|
50
156
|
try {
|
|
51
157
|
const response = await fetchImpl(url, {
|
|
@@ -103,12 +209,29 @@ function oauthError(code: string, message: string): Result<never, ChannelConnect
|
|
|
103
209
|
return Err({ code, message, retriable: false });
|
|
104
210
|
}
|
|
105
211
|
|
|
212
|
+
export function shopifyReauthorizeUrl(
|
|
213
|
+
options: ShopifyConnectorOptions,
|
|
214
|
+
params: {
|
|
215
|
+
storeDomain: string;
|
|
216
|
+
state: string;
|
|
217
|
+
redirectUri: string;
|
|
218
|
+
callbackUri: string;
|
|
219
|
+
scopes?: string[];
|
|
220
|
+
},
|
|
221
|
+
): Result<string, ChannelConnectorError> {
|
|
222
|
+
return shopifyConnector(options).buildAuthUrl!({
|
|
223
|
+
...params,
|
|
224
|
+
scopes: params.scopes ?? [],
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
106
228
|
export function shopifyConnector(options: ShopifyConnectorOptions = {}): ChannelConnector {
|
|
107
229
|
const fetchImpl = options.fetchImpl ?? fetch;
|
|
108
230
|
const version = options.apiVersion ?? "2024-10";
|
|
231
|
+
const currencyCache = new Map<string, Promise<string | undefined>>();
|
|
109
232
|
return defineChannelConnector({
|
|
110
233
|
providerId: "shopify",
|
|
111
|
-
capabilities: { importCatalog: true, importInventory: true, pushOrder: true, receiveWebhooks: true },
|
|
234
|
+
capabilities: { importCatalog: true, importInventory: true, pushOrder: true, pushCatalog: true, receiveWebhooks: true },
|
|
112
235
|
buildAuthUrl(params) {
|
|
113
236
|
if (!options.clientId || !options.clientSecret || !options.appUrl) {
|
|
114
237
|
return oauthError("SHOPIFY_OAUTH_NOT_CONFIGURED", "Shopify OAuth requires clientId, clientSecret, and appUrl.");
|
|
@@ -149,9 +272,12 @@ export function shopifyConnector(options: ShopifyConnectorOptions = {}): Channel
|
|
|
149
272
|
body: JSON.stringify({ client_id: options.clientId, client_secret: options.clientSecret, code }),
|
|
150
273
|
});
|
|
151
274
|
if (!response.ok) return oauthError("SHOPIFY_TOKEN_EXCHANGE_FAILED", `Shopify token exchange failed (${response.status}).`);
|
|
152
|
-
const body = await response.json() as { access_token?: unknown };
|
|
275
|
+
const body = await response.json() as { access_token?: unknown; scope?: unknown };
|
|
153
276
|
if (typeof body.access_token !== "string" || !body.access_token) return oauthError("SHOPIFY_TOKEN_INVALID", "Shopify token exchange did not return an access token.");
|
|
154
|
-
|
|
277
|
+
const grantedScopes = typeof body.scope === "string"
|
|
278
|
+
? body.scope.split(",").map((scope) => scope.trim()).filter(Boolean)
|
|
279
|
+
: [];
|
|
280
|
+
return Ok({ credentials: { accessToken: body.access_token, grantedScopes }, storeDomain: shopDomain });
|
|
155
281
|
} catch (error) {
|
|
156
282
|
return Err({ code: "SHOPIFY_TOKEN_EXCHANGE_FAILED", message: error instanceof Error ? error.message : "Shopify token exchange failed.", retriable: true });
|
|
157
283
|
}
|
|
@@ -159,24 +285,67 @@ export function shopifyConnector(options: ShopifyConnectorOptions = {}): Channel
|
|
|
159
285
|
async importCatalog(store, cursor): Promise<Result<ChannelCatalogPage>> {
|
|
160
286
|
const token = credentials(store);
|
|
161
287
|
if (!token) return Err({ code: "SHOPIFY_CREDENTIALS_REQUIRED", message: "Shopify accessToken is required." });
|
|
288
|
+
const currencyUrl = `${apiBase(store, version)}/shop.json`;
|
|
289
|
+
const currencyKey = apiBase(store, version);
|
|
290
|
+
let currencyPromise = currencyCache.get(currencyKey);
|
|
291
|
+
if (!currencyPromise) {
|
|
292
|
+
currencyPromise = fetchShopCurrency(fetchImpl, currencyUrl, token);
|
|
293
|
+
currencyCache.set(currencyKey, currencyPromise);
|
|
294
|
+
}
|
|
295
|
+
const currency = await currencyPromise;
|
|
162
296
|
const url = cursor ?? `${apiBase(store, version)}/products.json?limit=250`;
|
|
163
297
|
const result = await request<{ products: ShopifyProduct[] }>(fetchImpl, url, token);
|
|
164
298
|
if (!result.ok) return result;
|
|
165
299
|
const link = result.value.response.headers.get("link") ?? "";
|
|
166
300
|
const next = link.match(/<([^>]+)>;\s*rel="next"/)?.[1] ?? null;
|
|
167
301
|
return Ok({
|
|
168
|
-
items: result.value.data.products.map((product) =>
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
302
|
+
items: result.value.data.products.map((product) => {
|
|
303
|
+
const options = product.options?.map((option, index) => ({
|
|
304
|
+
name: option.name,
|
|
305
|
+
displayName: option.name,
|
|
306
|
+
...(option.position != null ? { sortOrder: option.position } : { sortOrder: index }),
|
|
307
|
+
values: (option.values ?? []).map((value, valueIndex) => ({ value, displayValue: value, sortOrder: valueIndex })),
|
|
308
|
+
}));
|
|
309
|
+
const variants = (product.variants ?? []).map((variant) => {
|
|
310
|
+
const selectors = [variant.option1, variant.option2, variant.option3];
|
|
311
|
+
const optionValues = Object.fromEntries((product.options ?? []).slice(0, 3).flatMap((option, index) => {
|
|
312
|
+
const value = selectors[index];
|
|
313
|
+
return value != null && value !== "" ? [[option.name, value] as const] : [];
|
|
314
|
+
}));
|
|
315
|
+
const prices = pricesForVariant(variant, currency);
|
|
316
|
+
return {
|
|
317
|
+
externalId: String(variant.id),
|
|
318
|
+
...(variant.sku ? { sku: variant.sku } : {}),
|
|
319
|
+
...(variant.barcode ? { barcode: variant.barcode } : {}),
|
|
320
|
+
...(Object.keys(optionValues).length > 0 ? { optionValues } : {}),
|
|
321
|
+
...(prices ? { prices } : {}),
|
|
322
|
+
};
|
|
323
|
+
});
|
|
324
|
+
const category = product.product_type ? slugify(product.product_type) : "";
|
|
325
|
+
const status = catalogStatus(product.status);
|
|
326
|
+
return {
|
|
327
|
+
externalId: String(product.id),
|
|
328
|
+
slug: product.handle ?? String(product.id),
|
|
329
|
+
title: product.title,
|
|
330
|
+
attributes: [{ locale: "en", title: product.title, ...(product.body_html != null ? { description: product.body_html } : {}) }],
|
|
331
|
+
variants,
|
|
332
|
+
...(product.images ? {
|
|
333
|
+
images: product.images.map((image, index) => ({
|
|
334
|
+
externalId: String(image.id),
|
|
335
|
+
url: image.src,
|
|
336
|
+
...(image.alt != null ? { alt: image.alt } : {}),
|
|
337
|
+
role: index === 0 ? "primary" as const : "gallery" as const,
|
|
338
|
+
...(image.position != null ? { sortOrder: image.position } : {}),
|
|
339
|
+
...(image.variant_ids != null ? { variantExternalIds: image.variant_ids.map(String) } : {}),
|
|
340
|
+
})),
|
|
341
|
+
} : {}),
|
|
342
|
+
...(options ? { options } : {}),
|
|
343
|
+
...(product.tags != null ? { tags: product.tags.split(",").map((tag) => tag.trim()).filter(Boolean) } : {}),
|
|
344
|
+
...(product.vendor ? { brand: product.vendor } : {}),
|
|
345
|
+
...(category ? { categories: [category] } : {}),
|
|
346
|
+
...(status ? { status } : {}),
|
|
347
|
+
};
|
|
348
|
+
}),
|
|
180
349
|
nextCursor: next,
|
|
181
350
|
});
|
|
182
351
|
},
|
|
@@ -189,6 +358,17 @@ export function shopifyConnector(options: ShopifyConnectorOptions = {}): Channel
|
|
|
189
358
|
if (!result.ok) return result;
|
|
190
359
|
return Ok(result.value.data.inventory_levels.map((level) => ({ externalId: String(level.inventory_item_id), available: level.available ?? 0 })));
|
|
191
360
|
},
|
|
361
|
+
async pushCatalog(store: ChannelStore, items: ChannelPushCatalogItem[], opts?: { dryRun?: boolean }): Promise<Result<ChannelPushCatalogResult, ChannelConnectorError>> {
|
|
362
|
+
const oauthStartUrl = options.appUrl ? shopifyOAuthStartUrl(options.appUrl, store.storeDomain) : undefined;
|
|
363
|
+
return executePushCatalog({
|
|
364
|
+
fetchImpl,
|
|
365
|
+
apiBase: (target) => apiBase(target, version),
|
|
366
|
+
credentials,
|
|
367
|
+
}, store, items, {
|
|
368
|
+
...(opts?.dryRun === true ? { dryRun: true } : {}),
|
|
369
|
+
...(oauthStartUrl ? { reauthorizeUrl: oauthStartUrl } : {}),
|
|
370
|
+
});
|
|
371
|
+
},
|
|
192
372
|
async pushOrder(store, slice: ChannelOrderSlice) {
|
|
193
373
|
const token = credentials(store);
|
|
194
374
|
if (!token) return Err({ code: "SHOPIFY_CREDENTIALS_REQUIRED", message: "Shopify accessToken is required.", retriable: false });
|