@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 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
- function parseMoney(value) {
11
- if (!value)
12
- return 0;
13
- const parsed = Number.parseFloat(value);
14
- return Number.isFinite(parsed) ? Math.round(parsed * 100) : 0;
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
- return Ok({ credentials: { accessToken: body.access_token }, storeDomain: shopDomain });
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
- externalId: String(product.id),
147
- slug: product.handle ?? String(product.id),
148
- title: product.title,
149
- ...(product.body_html ? { description: product.body_html } : {}),
150
- variants: (product.variants ?? []).map((variant) => ({
151
- externalId: String(variant.id),
152
- ...(variant.sku ? { sku: variant.sku } : {}),
153
- ...(variant.barcode ? { barcode: variant.barcode } : {}),
154
- metadata: { price: parseMoney(variant.price) },
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>>;