@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
|
@@ -0,0 +1,574 @@
|
|
|
1
|
+
import { Err, Ok } from "@porulle/core";
|
|
2
|
+
import type {
|
|
3
|
+
ChannelConnectorError,
|
|
4
|
+
ChannelPushCatalogField,
|
|
5
|
+
ChannelPushCatalogItem,
|
|
6
|
+
ChannelPushCatalogItemOutcome,
|
|
7
|
+
ChannelPushCatalogPreviousField,
|
|
8
|
+
ChannelPushCatalogResult,
|
|
9
|
+
ChannelStore,
|
|
10
|
+
Result,
|
|
11
|
+
} from "@porulle/core";
|
|
12
|
+
|
|
13
|
+
export const PUSH_CATALOG_SCOPE = "write_products";
|
|
14
|
+
export const PORULLE_METAFIELD_NAMESPACE = "porulle";
|
|
15
|
+
|
|
16
|
+
export const SHOPIFY_NATIVE_PRODUCT_FIELDS = new Set([
|
|
17
|
+
"title",
|
|
18
|
+
"body_html",
|
|
19
|
+
"vendor",
|
|
20
|
+
"product_type",
|
|
21
|
+
"handle",
|
|
22
|
+
"status",
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
export const SHOPIFY_NATIVE_VARIANT_FIELDS = new Set([
|
|
26
|
+
"sku",
|
|
27
|
+
"barcode",
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
type ShopifyMetafield = {
|
|
31
|
+
id: number | string;
|
|
32
|
+
namespace: string;
|
|
33
|
+
key: string;
|
|
34
|
+
value: string;
|
|
35
|
+
type?: string;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
type ShopifyProductSnapshot = {
|
|
39
|
+
id: number | string;
|
|
40
|
+
title?: string | null;
|
|
41
|
+
body_html?: string | null;
|
|
42
|
+
vendor?: string | null;
|
|
43
|
+
product_type?: string | null;
|
|
44
|
+
handle?: string | null;
|
|
45
|
+
status?: string | null;
|
|
46
|
+
tags?: string | null;
|
|
47
|
+
updated_at?: string | null;
|
|
48
|
+
variants?: ShopifyVariantSnapshot[];
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
type ShopifyVariantSnapshot = {
|
|
52
|
+
id: number | string;
|
|
53
|
+
sku?: string | null;
|
|
54
|
+
barcode?: string | null;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
interface PushRequestResult<T> {
|
|
58
|
+
ok: true;
|
|
59
|
+
data: T;
|
|
60
|
+
response: Response;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface PushRequestFailure {
|
|
64
|
+
ok: false;
|
|
65
|
+
error: ChannelConnectorError;
|
|
66
|
+
status?: number;
|
|
67
|
+
response?: Response;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
type PushRequestResponse<T> = PushRequestResult<T> | PushRequestFailure;
|
|
71
|
+
|
|
72
|
+
export interface PushCatalogDeps {
|
|
73
|
+
fetchImpl: typeof fetch;
|
|
74
|
+
apiBase: (store: ChannelStore) => string;
|
|
75
|
+
credentials: (store: ChannelStore) => string | undefined;
|
|
76
|
+
sleep?: (ms: number) => Promise<void>;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function shopifyGrantedScopes(store: ChannelStore): string[] {
|
|
80
|
+
const raw = store.credentials.grantedScopes;
|
|
81
|
+
if (Array.isArray(raw)) {
|
|
82
|
+
return raw.filter((scope): scope is string => typeof scope === "string" && scope.length > 0);
|
|
83
|
+
}
|
|
84
|
+
if (typeof raw === "string" && raw.length > 0) {
|
|
85
|
+
return raw.split(",").map((scope) => scope.trim()).filter(Boolean);
|
|
86
|
+
}
|
|
87
|
+
return [];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function shopifyPushCatalogEnabled(store: ChannelStore): boolean {
|
|
91
|
+
return shopifyGrantedScopes(store).includes(PUSH_CATALOG_SCOPE);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function shopifyWriteProductsScopeMissingError(reauthorizeUrl?: string): ChannelConnectorError {
|
|
95
|
+
const suffix = reauthorizeUrl
|
|
96
|
+
? ` Re-authorize at ${reauthorizeUrl}.`
|
|
97
|
+
: " Re-authorize the store through the Shopify OAuth start route.";
|
|
98
|
+
return {
|
|
99
|
+
code: "SHOPIFY_WRITE_PRODUCTS_SCOPE_MISSING",
|
|
100
|
+
message: `Shopify store is missing the ${PUSH_CATALOG_SCOPE} scope.${suffix}`,
|
|
101
|
+
retriable: false,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function parseCallLimit(header: string | null): { current: number; max: number } | undefined {
|
|
106
|
+
const match = header?.match(/^(\d+)\/(\d+)$/);
|
|
107
|
+
if (!match) return undefined;
|
|
108
|
+
return { current: Number(match[1]), max: Number(match[2]) };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function metafieldType(value: unknown): string {
|
|
112
|
+
if (typeof value === "boolean") return "boolean";
|
|
113
|
+
if (typeof value === "number") return Number.isInteger(value) ? "number_integer" : "number_decimal";
|
|
114
|
+
if (typeof value === "object" && value !== null) return "json";
|
|
115
|
+
return "single_line_text_field";
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function metafieldValue(value: unknown): string {
|
|
119
|
+
if (typeof value === "string") return value;
|
|
120
|
+
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
121
|
+
return JSON.stringify(value);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function remoteKey(field: ChannelPushCatalogField): string | undefined {
|
|
125
|
+
if (typeof field.remoteKey === "string" && field.remoteKey.length > 0) return field.remoteKey;
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function missingRemoteKeyError(field: ChannelPushCatalogField): ChannelConnectorError {
|
|
130
|
+
return {
|
|
131
|
+
code: "SHOPIFY_REMOTE_KEY_REQUIRED",
|
|
132
|
+
message: `Shopify catalog field ${field.fieldPath} requires a remoteKey.`,
|
|
133
|
+
retriable: false,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function isNativeProductField(field: ChannelPushCatalogField): boolean {
|
|
138
|
+
const key = remoteKey(field);
|
|
139
|
+
return field.intent === "display" && key !== undefined && SHOPIFY_NATIVE_PRODUCT_FIELDS.has(key);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function isNativeVariantField(field: ChannelPushCatalogField): boolean {
|
|
143
|
+
const key = remoteKey(field);
|
|
144
|
+
return field.intent === "display" && key !== undefined && SHOPIFY_NATIVE_VARIANT_FIELDS.has(key);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function isMetafieldField(field: ChannelPushCatalogField): boolean {
|
|
148
|
+
if (field.intent === "filterable") return true;
|
|
149
|
+
if (field.intent === "tag") return false;
|
|
150
|
+
return field.intent === "display" && remoteKey(field) !== undefined && !isNativeProductField(field) && !isNativeVariantField(field);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function parseTags(value: string | null | undefined): string[] {
|
|
154
|
+
if (!value) return [];
|
|
155
|
+
return value.split(",").map((tag) => tag.trim()).filter(Boolean);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function mergeTags(existing: string[], pushed: string[]): string {
|
|
159
|
+
return [...new Set([...existing, ...pushed])].join(", ");
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function collectPreviousFields(
|
|
163
|
+
product: ShopifyProductSnapshot,
|
|
164
|
+
metafields: ShopifyMetafield[],
|
|
165
|
+
variantMetafields: Map<string, ShopifyMetafield[]>,
|
|
166
|
+
item: ChannelPushCatalogItem,
|
|
167
|
+
): ChannelPushCatalogPreviousField[] {
|
|
168
|
+
const previous: ChannelPushCatalogPreviousField[] = [];
|
|
169
|
+
const metafieldByKey = new Map(metafields
|
|
170
|
+
.filter((entry) => entry.namespace === PORULLE_METAFIELD_NAMESPACE)
|
|
171
|
+
.map((entry) => [entry.key, entry]));
|
|
172
|
+
for (const field of item.fields) {
|
|
173
|
+
if (field.intent === "tag") {
|
|
174
|
+
const tag = field.value == null ? "" : String(field.value);
|
|
175
|
+
previous.push({
|
|
176
|
+
fieldPath: field.fieldPath,
|
|
177
|
+
value: tag.length > 0 && parseTags(product.tags).includes(tag) ? tag : null,
|
|
178
|
+
});
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
if (isNativeProductField(field)) {
|
|
182
|
+
const key = remoteKey(field);
|
|
183
|
+
if (key !== undefined) previous.push({ fieldPath: field.fieldPath, value: product[key as keyof ShopifyProductSnapshot] ?? null });
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
if (isMetafieldField(field)) {
|
|
187
|
+
const key = remoteKey(field);
|
|
188
|
+
if (key !== undefined) {
|
|
189
|
+
const existing = metafieldByKey.get(key);
|
|
190
|
+
previous.push({ fieldPath: field.fieldPath, value: existing?.value ?? null });
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
for (const variant of item.variants ?? []) {
|
|
195
|
+
for (const field of variant.fields) {
|
|
196
|
+
if (isNativeVariantField(field)) {
|
|
197
|
+
const key = remoteKey(field);
|
|
198
|
+
const snapshot = product.variants?.find((entry) => String(entry.id) === variant.externalId);
|
|
199
|
+
previous.push({ fieldPath: field.fieldPath, value: key !== undefined ? snapshot?.[key as keyof ShopifyVariantSnapshot] ?? null : null });
|
|
200
|
+
} else if (isMetafieldField(field)) {
|
|
201
|
+
const key = remoteKey(field);
|
|
202
|
+
if (key !== undefined) {
|
|
203
|
+
const existing = new Map((variantMetafields.get(variant.externalId) ?? [])
|
|
204
|
+
.filter((entry) => entry.namespace === PORULLE_METAFIELD_NAMESPACE)
|
|
205
|
+
.map((entry) => [entry.key, entry])).get(key);
|
|
206
|
+
previous.push({ fieldPath: field.fieldPath, value: existing?.value ?? null });
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return previous;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function pushRequest<T>(
|
|
215
|
+
deps: PushCatalogDeps,
|
|
216
|
+
url: string,
|
|
217
|
+
token: string,
|
|
218
|
+
init?: RequestInit,
|
|
219
|
+
): Promise<PushRequestResponse<T>> {
|
|
220
|
+
const sleep = deps.sleep ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
221
|
+
try {
|
|
222
|
+
const response = await deps.fetchImpl(url, {
|
|
223
|
+
...init,
|
|
224
|
+
headers: {
|
|
225
|
+
accept: "application/json",
|
|
226
|
+
...(init?.headers ?? {}),
|
|
227
|
+
"x-shopify-access-token": token,
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
const limit = parseCallLimit(response.headers.get("x-shopify-shop-api-call-limit"));
|
|
231
|
+
if (limit && limit.current >= Math.max(1, limit.max - 2)) {
|
|
232
|
+
await sleep(500);
|
|
233
|
+
}
|
|
234
|
+
if (response.status === 429) {
|
|
235
|
+
return {
|
|
236
|
+
ok: false,
|
|
237
|
+
status: 429,
|
|
238
|
+
response,
|
|
239
|
+
error: {
|
|
240
|
+
code: "SHOPIFY_RATE_LIMITED",
|
|
241
|
+
message: "Shopify API rate limit exceeded.",
|
|
242
|
+
retriable: true,
|
|
243
|
+
},
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
const method = (init?.method ?? "GET").toUpperCase();
|
|
247
|
+
if (response.status === 403 && method !== "GET") {
|
|
248
|
+
return {
|
|
249
|
+
ok: false,
|
|
250
|
+
status: 403,
|
|
251
|
+
response,
|
|
252
|
+
error: shopifyWriteProductsScopeMissingError(),
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
if (!response.ok) {
|
|
256
|
+
return {
|
|
257
|
+
ok: false,
|
|
258
|
+
status: response.status,
|
|
259
|
+
response,
|
|
260
|
+
error: {
|
|
261
|
+
code: "SHOPIFY_API_FAILED",
|
|
262
|
+
message: `Shopify API request failed (${response.status}) for ${url}.`,
|
|
263
|
+
retriable: response.status >= 500,
|
|
264
|
+
},
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
const text = await response.text();
|
|
268
|
+
const data = text.length > 0 ? JSON.parse(text) as T : {} as T;
|
|
269
|
+
return { ok: true, data, response };
|
|
270
|
+
} catch (error) {
|
|
271
|
+
return {
|
|
272
|
+
ok: false,
|
|
273
|
+
error: {
|
|
274
|
+
code: "SHOPIFY_API_FAILED",
|
|
275
|
+
message: error instanceof Error ? error.message : "Shopify API request failed.",
|
|
276
|
+
retriable: true,
|
|
277
|
+
},
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async function loadProductSnapshot(
|
|
283
|
+
deps: PushCatalogDeps,
|
|
284
|
+
store: ChannelStore,
|
|
285
|
+
token: string,
|
|
286
|
+
externalId: string,
|
|
287
|
+
variantIds: string[],
|
|
288
|
+
): Promise<Result<{
|
|
289
|
+
product: ShopifyProductSnapshot;
|
|
290
|
+
metafields: ShopifyMetafield[];
|
|
291
|
+
variantMetafields: Map<string, ShopifyMetafield[]>;
|
|
292
|
+
}>> {
|
|
293
|
+
const base = deps.apiBase(store);
|
|
294
|
+
const productResult = await pushRequest<{ product: ShopifyProductSnapshot }>(
|
|
295
|
+
deps,
|
|
296
|
+
`${base}/products/${encodeURIComponent(externalId)}.json`,
|
|
297
|
+
token,
|
|
298
|
+
);
|
|
299
|
+
if (!productResult.ok) return productResult;
|
|
300
|
+
const metafieldsResult = await pushRequest<{ metafields: ShopifyMetafield[] }>(
|
|
301
|
+
deps,
|
|
302
|
+
`${base}/products/${encodeURIComponent(externalId)}/metafields.json?namespace=${encodeURIComponent(PORULLE_METAFIELD_NAMESPACE)}`,
|
|
303
|
+
token,
|
|
304
|
+
);
|
|
305
|
+
if (!metafieldsResult.ok) return metafieldsResult;
|
|
306
|
+
const variantMetafields = new Map<string, ShopifyMetafield[]>();
|
|
307
|
+
for (const variantId of variantIds) {
|
|
308
|
+
const variantResult = await pushRequest<{ metafields: ShopifyMetafield[] }>(
|
|
309
|
+
deps,
|
|
310
|
+
`${base}/variants/${encodeURIComponent(variantId)}/metafields.json?namespace=${encodeURIComponent(PORULLE_METAFIELD_NAMESPACE)}`,
|
|
311
|
+
token,
|
|
312
|
+
);
|
|
313
|
+
if (!variantResult.ok) return variantResult;
|
|
314
|
+
variantMetafields.set(variantId, variantResult.data.metafields ?? []);
|
|
315
|
+
}
|
|
316
|
+
return Ok({
|
|
317
|
+
product: productResult.data.product,
|
|
318
|
+
metafields: metafieldsResult.data.metafields ?? [],
|
|
319
|
+
variantMetafields,
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async function writeNativeProduct(
|
|
324
|
+
deps: PushCatalogDeps,
|
|
325
|
+
store: ChannelStore,
|
|
326
|
+
token: string,
|
|
327
|
+
externalId: string,
|
|
328
|
+
fields: ChannelPushCatalogField[],
|
|
329
|
+
tagValues: string[],
|
|
330
|
+
existingTags: string[],
|
|
331
|
+
): Promise<PushRequestResponse<{ product: ShopifyProductSnapshot }>> {
|
|
332
|
+
const product: Record<string, unknown> = { id: externalId };
|
|
333
|
+
for (const field of fields) {
|
|
334
|
+
if (!isNativeProductField(field)) continue;
|
|
335
|
+
const key = remoteKey(field);
|
|
336
|
+
if (key !== undefined) product[key] = field.value;
|
|
337
|
+
}
|
|
338
|
+
if (tagValues.length > 0) {
|
|
339
|
+
product.tags = mergeTags(existingTags, tagValues);
|
|
340
|
+
}
|
|
341
|
+
if (Object.keys(product).length <= 1) {
|
|
342
|
+
return {
|
|
343
|
+
ok: true,
|
|
344
|
+
data: { product: { id: externalId } },
|
|
345
|
+
response: new Response(null, { status: 200 }),
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
return pushRequest<{ product: ShopifyProductSnapshot }>(
|
|
349
|
+
deps,
|
|
350
|
+
`${deps.apiBase(store)}/products/${encodeURIComponent(externalId)}.json`,
|
|
351
|
+
token,
|
|
352
|
+
{
|
|
353
|
+
method: "PUT",
|
|
354
|
+
headers: { "content-type": "application/json" },
|
|
355
|
+
body: JSON.stringify({ product }),
|
|
356
|
+
},
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
async function writeVariantFields(
|
|
361
|
+
deps: PushCatalogDeps,
|
|
362
|
+
store: ChannelStore,
|
|
363
|
+
token: string,
|
|
364
|
+
variantExternalId: string,
|
|
365
|
+
fields: ChannelPushCatalogField[],
|
|
366
|
+
): Promise<PushRequestResponse<{ variant: ShopifyVariantSnapshot }>> {
|
|
367
|
+
const variant: Record<string, unknown> = { id: variantExternalId };
|
|
368
|
+
for (const field of fields) {
|
|
369
|
+
if (!isNativeVariantField(field)) continue;
|
|
370
|
+
const key = remoteKey(field);
|
|
371
|
+
if (key !== undefined) variant[key] = field.value;
|
|
372
|
+
}
|
|
373
|
+
if (Object.keys(variant).length <= 1) {
|
|
374
|
+
return {
|
|
375
|
+
ok: true,
|
|
376
|
+
data: { variant: { id: variantExternalId } },
|
|
377
|
+
response: new Response(null, { status: 200 }),
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
return pushRequest<{ variant: ShopifyVariantSnapshot }>(
|
|
381
|
+
deps,
|
|
382
|
+
`${deps.apiBase(store)}/variants/${encodeURIComponent(variantExternalId)}.json`,
|
|
383
|
+
token,
|
|
384
|
+
{
|
|
385
|
+
method: "PUT",
|
|
386
|
+
headers: { "content-type": "application/json" },
|
|
387
|
+
body: JSON.stringify({ variant }),
|
|
388
|
+
},
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
async function writeMetafield(
|
|
393
|
+
deps: PushCatalogDeps,
|
|
394
|
+
store: ChannelStore,
|
|
395
|
+
token: string,
|
|
396
|
+
externalId: string,
|
|
397
|
+
field: ChannelPushCatalogField,
|
|
398
|
+
existing: ShopifyMetafield | undefined,
|
|
399
|
+
resource: "product" | "variant",
|
|
400
|
+
): Promise<PushRequestResponse<{ metafield: ShopifyMetafield }>> {
|
|
401
|
+
const key = remoteKey(field);
|
|
402
|
+
if (key === undefined) return { ok: false, error: missingRemoteKeyError(field) };
|
|
403
|
+
const payload = {
|
|
404
|
+
metafield: {
|
|
405
|
+
namespace: PORULLE_METAFIELD_NAMESPACE,
|
|
406
|
+
key,
|
|
407
|
+
value: metafieldValue(field.value),
|
|
408
|
+
type: metafieldType(field.value),
|
|
409
|
+
},
|
|
410
|
+
};
|
|
411
|
+
if (existing) {
|
|
412
|
+
return pushRequest<{ metafield: ShopifyMetafield }>(
|
|
413
|
+
deps,
|
|
414
|
+
`${deps.apiBase(store)}/metafields/${encodeURIComponent(String(existing.id))}.json`,
|
|
415
|
+
token,
|
|
416
|
+
{
|
|
417
|
+
method: "PUT",
|
|
418
|
+
headers: { "content-type": "application/json" },
|
|
419
|
+
body: JSON.stringify(payload),
|
|
420
|
+
},
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
return pushRequest<{ metafield: ShopifyMetafield }>(
|
|
424
|
+
deps,
|
|
425
|
+
`${deps.apiBase(store)}/${resource}s/${encodeURIComponent(externalId)}/metafields.json`,
|
|
426
|
+
token,
|
|
427
|
+
{
|
|
428
|
+
method: "POST",
|
|
429
|
+
headers: { "content-type": "application/json" },
|
|
430
|
+
body: JSON.stringify(payload),
|
|
431
|
+
},
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
async function pushCatalogItem(
|
|
436
|
+
deps: PushCatalogDeps,
|
|
437
|
+
store: ChannelStore,
|
|
438
|
+
token: string,
|
|
439
|
+
item: ChannelPushCatalogItem,
|
|
440
|
+
dryRun: boolean,
|
|
441
|
+
): Promise<ChannelPushCatalogItemOutcome> {
|
|
442
|
+
const missingField = [...item.fields, ...(item.variants ?? []).flatMap((variant) => variant.fields)]
|
|
443
|
+
.find((field) => field.intent !== "tag" && remoteKey(field) === undefined);
|
|
444
|
+
if (missingField) return { externalId: item.externalId, ok: false, error: missingRemoteKeyError(missingField) };
|
|
445
|
+
if (item.images && item.images.length > 0) {
|
|
446
|
+
return {
|
|
447
|
+
externalId: item.externalId,
|
|
448
|
+
ok: false,
|
|
449
|
+
error: {
|
|
450
|
+
code: "SHOPIFY_IMAGES_NOT_WRITTEN",
|
|
451
|
+
message: "Shopify catalog image pushes are not written by this adapter.",
|
|
452
|
+
retriable: false,
|
|
453
|
+
},
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const variantIds = (item.variants ?? [])
|
|
458
|
+
.filter((variant) => variant.fields.some((field) => isMetafieldField(field)))
|
|
459
|
+
.map((variant) => variant.externalId);
|
|
460
|
+
const snapshot = await loadProductSnapshot(deps, store, token, item.externalId, variantIds);
|
|
461
|
+
if (!snapshot.ok) {
|
|
462
|
+
return { externalId: item.externalId, ok: false, error: snapshot.error };
|
|
463
|
+
}
|
|
464
|
+
const previousFields = collectPreviousFields(snapshot.value.product, snapshot.value.metafields, snapshot.value.variantMetafields, item);
|
|
465
|
+
if (dryRun) {
|
|
466
|
+
return {
|
|
467
|
+
externalId: item.externalId,
|
|
468
|
+
ok: true,
|
|
469
|
+
...(previousFields.length > 0 ? { previousFields } : {}),
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const metafieldByKey = new Map(snapshot.value.metafields
|
|
474
|
+
.filter((entry) => entry.namespace === PORULLE_METAFIELD_NAMESPACE)
|
|
475
|
+
.map((entry) => [entry.key, entry]));
|
|
476
|
+
const productFields = item.fields.filter((field) => field.intent !== "tag");
|
|
477
|
+
const tagValues = item.fields
|
|
478
|
+
.filter((field) => field.intent === "tag")
|
|
479
|
+
.flatMap((field) => {
|
|
480
|
+
if (typeof field.value === "string") return [field.value];
|
|
481
|
+
if (Array.isArray(field.value)) return field.value.filter((entry): entry is string => typeof entry === "string");
|
|
482
|
+
return field.value == null ? [] : [String(field.value)];
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
const nativeResult = await writeNativeProduct(
|
|
486
|
+
deps,
|
|
487
|
+
store,
|
|
488
|
+
token,
|
|
489
|
+
item.externalId,
|
|
490
|
+
productFields,
|
|
491
|
+
tagValues,
|
|
492
|
+
parseTags(snapshot.value.product.tags),
|
|
493
|
+
);
|
|
494
|
+
if (!nativeResult.ok) {
|
|
495
|
+
return { externalId: item.externalId, ok: false, error: nativeResult.error };
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
for (const field of productFields) {
|
|
499
|
+
if (!isMetafieldField(field)) continue;
|
|
500
|
+
const key = remoteKey(field);
|
|
501
|
+
const metafieldResult = await writeMetafield(
|
|
502
|
+
deps,
|
|
503
|
+
store,
|
|
504
|
+
token,
|
|
505
|
+
item.externalId,
|
|
506
|
+
field,
|
|
507
|
+
key !== undefined ? metafieldByKey.get(key) : undefined,
|
|
508
|
+
"product",
|
|
509
|
+
);
|
|
510
|
+
if (!metafieldResult.ok) {
|
|
511
|
+
return { externalId: item.externalId, ok: false, error: metafieldResult.error };
|
|
512
|
+
}
|
|
513
|
+
if (metafieldResult.data.metafield) {
|
|
514
|
+
metafieldByKey.set(metafieldResult.data.metafield.key, metafieldResult.data.metafield);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
for (const variant of item.variants ?? []) {
|
|
519
|
+
const variantMetafieldByKey = new Map((snapshot.value.variantMetafields.get(variant.externalId) ?? [])
|
|
520
|
+
.filter((entry) => entry.namespace === PORULLE_METAFIELD_NAMESPACE)
|
|
521
|
+
.map((entry) => [entry.key, entry]));
|
|
522
|
+
for (const field of variant.fields) {
|
|
523
|
+
if (!isMetafieldField(field)) continue;
|
|
524
|
+
const key = remoteKey(field);
|
|
525
|
+
const metafieldResult = await writeMetafield(
|
|
526
|
+
deps,
|
|
527
|
+
store,
|
|
528
|
+
token,
|
|
529
|
+
variant.externalId,
|
|
530
|
+
field,
|
|
531
|
+
key !== undefined ? variantMetafieldByKey.get(key) : undefined,
|
|
532
|
+
"variant",
|
|
533
|
+
);
|
|
534
|
+
if (!metafieldResult.ok) {
|
|
535
|
+
return { externalId: item.externalId, ok: false, error: metafieldResult.error };
|
|
536
|
+
}
|
|
537
|
+
if (metafieldResult.data.metafield) {
|
|
538
|
+
variantMetafieldByKey.set(metafieldResult.data.metafield.key, metafieldResult.data.metafield);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
const variantResult = await writeVariantFields(deps, store, token, variant.externalId, variant.fields);
|
|
542
|
+
if (!variantResult.ok) {
|
|
543
|
+
return { externalId: item.externalId, ok: false, error: variantResult.error };
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
return {
|
|
548
|
+
externalId: item.externalId,
|
|
549
|
+
ok: true,
|
|
550
|
+
...(nativeResult.data.product.updated_at ? { remoteUpdatedAt: nativeResult.data.product.updated_at } : {}),
|
|
551
|
+
...(previousFields.length > 0 ? { previousFields } : {}),
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
export async function pushCatalog(
|
|
556
|
+
deps: PushCatalogDeps,
|
|
557
|
+
store: ChannelStore,
|
|
558
|
+
items: ChannelPushCatalogItem[],
|
|
559
|
+
opts?: { dryRun?: boolean; reauthorizeUrl?: string },
|
|
560
|
+
): Promise<Result<ChannelPushCatalogResult, ChannelConnectorError>> {
|
|
561
|
+
const token = deps.credentials(store);
|
|
562
|
+
if (!token) {
|
|
563
|
+
return Err({ code: "SHOPIFY_CREDENTIALS_REQUIRED", message: "Shopify accessToken is required.", retriable: false });
|
|
564
|
+
}
|
|
565
|
+
if (!shopifyPushCatalogEnabled(store)) {
|
|
566
|
+
return Err(shopifyWriteProductsScopeMissingError(opts?.reauthorizeUrl));
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
const outcomes: ChannelPushCatalogItemOutcome[] = [];
|
|
570
|
+
for (const item of items) {
|
|
571
|
+
outcomes.push(await pushCatalogItem(deps, store, token, item, opts?.dryRun === true));
|
|
572
|
+
}
|
|
573
|
+
return Ok({ outcomes });
|
|
574
|
+
}
|