@porulle/plugin-channel-connector 0.10.8 → 0.11.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/dist/catalog-field-mapping.d.ts +32 -0
- package/dist/catalog-field-mapping.js +178 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +74 -1
- package/dist/mock-connector.d.ts +31 -6
- package/dist/mock-connector.js +77 -2
- package/dist/schema.d.ts +57 -1
- package/dist/schema.js +4 -1
- package/dist/service.d.ts +81 -1
- package/dist/service.js +1358 -70
- package/package.json +2 -2
- package/src/catalog-field-mapping.ts +211 -0
- package/src/index.ts +98 -1
- package/src/mock-connector.ts +81 -2
- package/src/schema.ts +6 -0
- package/src/service.ts +1722 -71
package/dist/service.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import { CommerceInvalidTransitionError, CommerceValidationError, Ok, PluginErr, createSystemActor, } from "@porulle/core";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
2
|
+
import { CommerceInvalidTransitionError, CommerceValidationError, Ok, PluginErr, createTxContext, createSystemActor, } from "@porulle/core";
|
|
3
|
+
import { isValidFieldPath } from "@porulle/core";
|
|
4
|
+
import { and, eq, inArray, isNull } from "@porulle/core/drizzle";
|
|
5
|
+
import { brands, categories, customerAddresses, customers, entityMedia, entityTags, inventoryLevels, mediaAssets, optionTypes, optionValues, orderLineItems, orders, prices, sellableAttributes, sellableCustomFields, sellableEntities, entityFieldDefinitions, tags, variants, variantOptionValues, } from "@porulle/core/schema";
|
|
5
6
|
import { channelEntityMap, channelExportEvents, channelOrderExports, connectedStores, channelRefundEvents, channelRefundRequests, } from "./schema.js";
|
|
7
|
+
import { mergeCatalogFieldMapping, normalizeCatalogFieldMapping, selectCatalogFieldMapping, } from "./catalog-field-mapping.js";
|
|
6
8
|
const exportTransitions = {
|
|
7
9
|
pending: ["exported", "abandoned"],
|
|
8
10
|
exported: ["confirmed", "failed", "abandoned"],
|
|
@@ -16,6 +18,106 @@ export function canExportTransition(from, to) {
|
|
|
16
18
|
function hash(value) {
|
|
17
19
|
return createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
|
18
20
|
}
|
|
21
|
+
function mergeMetadata(existing, remote) {
|
|
22
|
+
return { ...(existing ?? {}), ...remote };
|
|
23
|
+
}
|
|
24
|
+
const attributeFields = ["title", "subtitle", "description", "richDescription", "seoTitle", "seoDescription"];
|
|
25
|
+
const pushImageRoles = ["primary", "gallery", "thumbnail", "video", "document"];
|
|
26
|
+
function customFieldValue(field) {
|
|
27
|
+
switch (field.fieldType) {
|
|
28
|
+
case "text":
|
|
29
|
+
case "relation":
|
|
30
|
+
case "select":
|
|
31
|
+
return field.textValue;
|
|
32
|
+
case "number":
|
|
33
|
+
return field.numberValue;
|
|
34
|
+
case "boolean":
|
|
35
|
+
return field.booleanValue;
|
|
36
|
+
case "date":
|
|
37
|
+
return field.dateValue;
|
|
38
|
+
case "json":
|
|
39
|
+
return field.jsonValue;
|
|
40
|
+
default:
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function pushCatalogIntent(fieldPath, target) {
|
|
45
|
+
if (fieldPath.startsWith("customFields.") && target === "attribute")
|
|
46
|
+
return "filterable";
|
|
47
|
+
if (fieldPath.startsWith("customFields.") || fieldPath.startsWith("entity.metadata."))
|
|
48
|
+
return "tag";
|
|
49
|
+
return "display";
|
|
50
|
+
}
|
|
51
|
+
function pushCatalogField(fieldPath, value, mapping) {
|
|
52
|
+
const segments = fieldPath.split(".");
|
|
53
|
+
const locale = fieldPath.startsWith("attributes.")
|
|
54
|
+
? segments[1]
|
|
55
|
+
: fieldPath.startsWith("customFields.")
|
|
56
|
+
? segments[2]
|
|
57
|
+
: undefined;
|
|
58
|
+
return {
|
|
59
|
+
fieldPath,
|
|
60
|
+
intent: pushCatalogIntent(fieldPath, mapping.target),
|
|
61
|
+
value,
|
|
62
|
+
...(locale !== undefined ? { locale } : {}),
|
|
63
|
+
remoteKey: mapping.remoteKey,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
function pushCatalogImageRole(value) {
|
|
67
|
+
return pushImageRoles.find((role) => role === value);
|
|
68
|
+
}
|
|
69
|
+
function importedFieldPaths(item) {
|
|
70
|
+
const paths = new Set(["entity.slug"]);
|
|
71
|
+
if (item.status !== undefined)
|
|
72
|
+
paths.add("entity.status");
|
|
73
|
+
for (const key of Object.keys(item.metadata ?? {})) {
|
|
74
|
+
const path = `entity.metadata.${key}`;
|
|
75
|
+
if (isValidFieldPath(path))
|
|
76
|
+
paths.add(path);
|
|
77
|
+
}
|
|
78
|
+
const attributes = item.attributes?.length
|
|
79
|
+
? item.attributes
|
|
80
|
+
: [{ locale: "en", title: item.title, ...(item.description !== undefined ? { description: item.description } : {}) }];
|
|
81
|
+
for (const attribute of attributes) {
|
|
82
|
+
for (const field of attributeFields) {
|
|
83
|
+
if (attribute[field] !== undefined)
|
|
84
|
+
paths.add(`attributes.${attribute.locale}.${field}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
for (const image of item.images ?? [])
|
|
88
|
+
paths.add(`media.${image.role}`);
|
|
89
|
+
if (item.options?.length)
|
|
90
|
+
paths.add("options");
|
|
91
|
+
if (item.variants.some((variant) => variant.sku !== undefined))
|
|
92
|
+
paths.add("variants.sku");
|
|
93
|
+
if (item.variants.some((variant) => variant.barcode !== undefined))
|
|
94
|
+
paths.add("variants.barcode");
|
|
95
|
+
for (const currency of item.variants.flatMap((variant) => variant.prices ?? []).map((price) => price.currency)) {
|
|
96
|
+
const path = `prices.${currency}`;
|
|
97
|
+
if (isValidFieldPath(path))
|
|
98
|
+
paths.add(path);
|
|
99
|
+
}
|
|
100
|
+
return [...paths];
|
|
101
|
+
}
|
|
102
|
+
function summarizeValue(value) {
|
|
103
|
+
const serialized = JSON.stringify(value);
|
|
104
|
+
if (serialized === undefined)
|
|
105
|
+
return String(value);
|
|
106
|
+
return serialized.length > 256 ? `${serialized.slice(0, 253)}...` : serialized;
|
|
107
|
+
}
|
|
108
|
+
function uniqueSkipped(skipped) {
|
|
109
|
+
const seen = new Set();
|
|
110
|
+
return skipped.filter((entry) => {
|
|
111
|
+
const key = `${entry.entityId}:${entry.fieldPath}`;
|
|
112
|
+
if (seen.has(key))
|
|
113
|
+
return false;
|
|
114
|
+
seen.add(key);
|
|
115
|
+
return true;
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
function ownerAllows(owners, path) {
|
|
119
|
+
return owners.get(path) !== "platform";
|
|
120
|
+
}
|
|
19
121
|
function stockFailure(line, reason) {
|
|
20
122
|
return `Cannot checkout line "${line.title ?? line.entityId}": ${reason}.`;
|
|
21
123
|
}
|
|
@@ -42,6 +144,8 @@ function redactStore(store) {
|
|
|
42
144
|
credentials: "[REDACTED]",
|
|
43
145
|
storeDomain: store.storeDomain,
|
|
44
146
|
status: store.status,
|
|
147
|
+
catalogWriteEnabled: store.catalogWriteEnabled,
|
|
148
|
+
catalogFieldMapping: store.catalogFieldMapping,
|
|
45
149
|
catalogCursor: store.catalogCursor,
|
|
46
150
|
inventoryCursor: store.inventoryCursor,
|
|
47
151
|
lastSyncAt: store.lastSyncAt,
|
|
@@ -79,6 +183,499 @@ export class ChannelConnectorService {
|
|
|
79
183
|
get catalog() {
|
|
80
184
|
return this.services.catalog;
|
|
81
185
|
}
|
|
186
|
+
get media() {
|
|
187
|
+
return this.services.media;
|
|
188
|
+
}
|
|
189
|
+
get pricing() {
|
|
190
|
+
return this.services.pricing;
|
|
191
|
+
}
|
|
192
|
+
filterOwnedFields(item, owners) {
|
|
193
|
+
return this.filterOwnedFieldsAtPaths(item, owners, importedFieldPaths(item));
|
|
194
|
+
}
|
|
195
|
+
filterOwnedFieldsAtPaths(item, owners, fieldPaths) {
|
|
196
|
+
const populated = new Set(fieldPaths);
|
|
197
|
+
const skipped = fieldPaths.filter((path) => owners.get(path) === "platform");
|
|
198
|
+
const blocked = new Set(skipped);
|
|
199
|
+
const attributes = (item.attributes?.length
|
|
200
|
+
? item.attributes
|
|
201
|
+
: [{ locale: "en", title: item.title, ...(item.description !== undefined ? { description: item.description } : {}) }])
|
|
202
|
+
.flatMap((attribute) => {
|
|
203
|
+
return [{
|
|
204
|
+
locale: attribute.locale,
|
|
205
|
+
title: attribute.title,
|
|
206
|
+
...Object.fromEntries(attributeFields.slice(1)
|
|
207
|
+
.filter((field) => attribute[field] !== undefined
|
|
208
|
+
&& populated.has(`attributes.${attribute.locale}.${field}`)
|
|
209
|
+
&& !blocked.has(`attributes.${attribute.locale}.${field}`))
|
|
210
|
+
.map((field) => [field, attribute[field]])),
|
|
211
|
+
}];
|
|
212
|
+
});
|
|
213
|
+
const writable = {
|
|
214
|
+
...item,
|
|
215
|
+
attributes,
|
|
216
|
+
metadata: Object.fromEntries(Object.entries(item.metadata ?? {}).filter(([key]) => populated.has(`entity.metadata.${key}`) && !blocked.has(`entity.metadata.${key}`))),
|
|
217
|
+
...(item.images !== undefined ? { images: item.images.filter((image) => populated.has(`media.${image.role}`) && !blocked.has(`media.${image.role}`)) } : {}),
|
|
218
|
+
...(item.options !== undefined ? { options: blocked.has("options") ? [] : item.options } : {}),
|
|
219
|
+
variants: item.variants.map((variant) => ({
|
|
220
|
+
externalId: variant.externalId,
|
|
221
|
+
...(variant.sku !== undefined && populated.has("variants.sku") && !blocked.has("variants.sku") ? { sku: variant.sku } : {}),
|
|
222
|
+
...(variant.barcode !== undefined && populated.has("variants.barcode") && !blocked.has("variants.barcode") ? { barcode: variant.barcode } : {}),
|
|
223
|
+
...(variant.metadata !== undefined ? { metadata: variant.metadata } : {}),
|
|
224
|
+
...(variant.optionValues !== undefined && populated.has("options") && !blocked.has("options") ? { optionValues: variant.optionValues } : {}),
|
|
225
|
+
...(variant.prices !== undefined
|
|
226
|
+
? { prices: variant.prices.filter((price) => populated.has(`prices.${price.currency}`) && !blocked.has(`prices.${price.currency}`)) }
|
|
227
|
+
: {}),
|
|
228
|
+
})),
|
|
229
|
+
};
|
|
230
|
+
return { writable, skipped, conflicts: [] };
|
|
231
|
+
}
|
|
232
|
+
filterConflictingFields(item, conflicts) {
|
|
233
|
+
if (conflicts.length === 0)
|
|
234
|
+
return { writable: item, conflicts: [] };
|
|
235
|
+
const blocked = new Set(conflicts);
|
|
236
|
+
const attributes = (item.attributes ?? []).flatMap((attribute) => {
|
|
237
|
+
return [{
|
|
238
|
+
locale: attribute.locale,
|
|
239
|
+
title: attribute.title,
|
|
240
|
+
...Object.fromEntries(attributeFields.slice(1)
|
|
241
|
+
.filter((field) => attribute[field] !== undefined && !blocked.has(`attributes.${attribute.locale}.${field}`))
|
|
242
|
+
.map((field) => [field, attribute[field]])),
|
|
243
|
+
}];
|
|
244
|
+
});
|
|
245
|
+
return {
|
|
246
|
+
writable: {
|
|
247
|
+
...item,
|
|
248
|
+
attributes,
|
|
249
|
+
metadata: Object.fromEntries(Object.entries(item.metadata ?? {}).filter(([key]) => !blocked.has(`entity.metadata.${key}`))),
|
|
250
|
+
...(item.images !== undefined ? { images: item.images.filter((image) => !blocked.has(`media.${image.role}`)) } : {}),
|
|
251
|
+
...(item.options !== undefined ? { options: blocked.has("options") ? [] : item.options } : {}),
|
|
252
|
+
variants: item.variants.map((variant) => ({
|
|
253
|
+
externalId: variant.externalId,
|
|
254
|
+
...(variant.sku !== undefined && !blocked.has("variants.sku") ? { sku: variant.sku } : {}),
|
|
255
|
+
...(variant.barcode !== undefined && !blocked.has("variants.barcode") ? { barcode: variant.barcode } : {}),
|
|
256
|
+
...(variant.metadata !== undefined ? { metadata: variant.metadata } : {}),
|
|
257
|
+
...(variant.optionValues !== undefined && !blocked.has("options") ? { optionValues: variant.optionValues } : {}),
|
|
258
|
+
...(variant.prices !== undefined
|
|
259
|
+
? { prices: variant.prices.filter((price) => !blocked.has(`prices.${price.currency}`)) }
|
|
260
|
+
: {}),
|
|
261
|
+
})),
|
|
262
|
+
},
|
|
263
|
+
conflicts,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
remoteFieldValue(item, path) {
|
|
267
|
+
const [root, segment, field] = path.split(".");
|
|
268
|
+
if (root === "entity" && segment === "slug")
|
|
269
|
+
return item.slug;
|
|
270
|
+
if (root === "entity" && segment === "status")
|
|
271
|
+
return item.status;
|
|
272
|
+
if (root === "entity" && segment === "metadata")
|
|
273
|
+
return item.metadata?.[field ?? ""];
|
|
274
|
+
if (root === "attributes" && segment && field) {
|
|
275
|
+
const attributes = item.attributes?.length
|
|
276
|
+
? item.attributes
|
|
277
|
+
: [{ locale: "en", title: item.title, ...(item.description !== undefined ? { description: item.description } : {}) }];
|
|
278
|
+
const attribute = attributes.find((row) => row.locale === segment);
|
|
279
|
+
return attribute?.[field];
|
|
280
|
+
}
|
|
281
|
+
if (root === "media" && segment)
|
|
282
|
+
return (item.images ?? []).filter((image) => image.role === segment).map((image) => image.externalId ?? image.url);
|
|
283
|
+
if (path === "options")
|
|
284
|
+
return item.options;
|
|
285
|
+
if (path === "variants.sku")
|
|
286
|
+
return item.variants.map((variant) => variant.sku);
|
|
287
|
+
if (path === "variants.barcode")
|
|
288
|
+
return item.variants.map((variant) => variant.barcode);
|
|
289
|
+
if (root === "prices" && segment)
|
|
290
|
+
return item.variants.flatMap((variant) => variant.prices ?? []).filter((price) => price.currency === segment);
|
|
291
|
+
return undefined;
|
|
292
|
+
}
|
|
293
|
+
async localFieldValue(entityId, entity, path) {
|
|
294
|
+
const [root, segment, field] = path.split(".");
|
|
295
|
+
if (root === "entity" && segment === "slug")
|
|
296
|
+
return entity.slug;
|
|
297
|
+
if (root === "entity" && segment === "status")
|
|
298
|
+
return entity.status;
|
|
299
|
+
if (root === "entity" && segment === "metadata")
|
|
300
|
+
return entity.metadata?.[field ?? ""];
|
|
301
|
+
if (root === "attributes" && segment && field) {
|
|
302
|
+
const [attribute] = await this.db.select().from(sellableAttributes).where(and(eq(sellableAttributes.entityId, entityId), eq(sellableAttributes.locale, segment)));
|
|
303
|
+
const values = attribute
|
|
304
|
+
? {
|
|
305
|
+
title: attribute.title,
|
|
306
|
+
subtitle: attribute.subtitle,
|
|
307
|
+
description: attribute.description,
|
|
308
|
+
richDescription: attribute.richDescription,
|
|
309
|
+
seoTitle: attribute.seoTitle,
|
|
310
|
+
seoDescription: attribute.seoDescription,
|
|
311
|
+
}
|
|
312
|
+
: {};
|
|
313
|
+
return values[field];
|
|
314
|
+
}
|
|
315
|
+
if (root === "media" && segment) {
|
|
316
|
+
const links = await this.db.select({ id: entityMedia.mediaAssetId }).from(entityMedia).where(and(eq(entityMedia.entityId, entityId), eq(entityMedia.role, segment)));
|
|
317
|
+
return links.map((link) => link.id);
|
|
318
|
+
}
|
|
319
|
+
if (path === "options") {
|
|
320
|
+
const types = await this.db.select().from(optionTypes).where(eq(optionTypes.entityId, entityId));
|
|
321
|
+
const values = await Promise.all(types.map(async (type) => ({
|
|
322
|
+
name: type.name,
|
|
323
|
+
values: await this.db.select().from(optionValues).where(eq(optionValues.optionTypeId, type.id)),
|
|
324
|
+
})));
|
|
325
|
+
return values;
|
|
326
|
+
}
|
|
327
|
+
if (path === "variants.sku" || path === "variants.barcode") {
|
|
328
|
+
const rows = await this.db.select().from(variants).where(eq(variants.entityId, entityId));
|
|
329
|
+
return rows.map((variant) => path === "variants.sku" ? variant.sku : variant.barcode);
|
|
330
|
+
}
|
|
331
|
+
if (root === "prices" && segment) {
|
|
332
|
+
const rows = await this.db.select().from(prices).where(and(eq(prices.entityId, entityId), eq(prices.currency, segment)));
|
|
333
|
+
return rows.map((price) => ({ amount: price.amount, compareAtAmount: price.compareAtAmount }));
|
|
334
|
+
}
|
|
335
|
+
return undefined;
|
|
336
|
+
}
|
|
337
|
+
async detectSharedConflicts(entityId, storeId, entity, mapping, item, owners, fieldPaths = importedFieldPaths(item), remoteHash = hash(item)) {
|
|
338
|
+
if (!mapping || mapping.syncHash === remoteHash)
|
|
339
|
+
return { paths: [], conflicts: [] };
|
|
340
|
+
const revisions = await this.catalog.repository.findRevisionMarkers(entityId, mapping.lastSyncedAt);
|
|
341
|
+
const localChanged = revisions.some((revision) => revision.reason !== "import");
|
|
342
|
+
if (!localChanged)
|
|
343
|
+
return { paths: [], conflicts: [] };
|
|
344
|
+
const paths = fieldPaths.filter((path) => owners.get(path) === "shared");
|
|
345
|
+
const conflicts = await Promise.all(paths.map(async (fieldPath) => ({
|
|
346
|
+
entityId,
|
|
347
|
+
storeId,
|
|
348
|
+
fieldPath,
|
|
349
|
+
localValueSummary: summarizeValue(await this.localFieldValue(entityId, entity, fieldPath)),
|
|
350
|
+
remoteValueSummary: summarizeValue(this.remoteFieldValue(item, fieldPath)),
|
|
351
|
+
})));
|
|
352
|
+
return { paths, conflicts };
|
|
353
|
+
}
|
|
354
|
+
async setCatalogAttributes(entityId, item, actor, blockedPaths = new Set()) {
|
|
355
|
+
const attributes = item.attributes ?? [{
|
|
356
|
+
locale: "en",
|
|
357
|
+
title: item.title,
|
|
358
|
+
...(item.description !== undefined ? { description: item.description } : {}),
|
|
359
|
+
}];
|
|
360
|
+
const existing = await this.db.select().from(sellableAttributes).where(eq(sellableAttributes.entityId, entityId));
|
|
361
|
+
let created = 0;
|
|
362
|
+
let changed = false;
|
|
363
|
+
for (const attribute of attributes) {
|
|
364
|
+
const current = existing.find((row) => row.locale === attribute.locale);
|
|
365
|
+
const titlePath = `attributes.${attribute.locale}.title`;
|
|
366
|
+
if (!current && blockedPaths.has(titlePath))
|
|
367
|
+
continue;
|
|
368
|
+
const title = blockedPaths.has(titlePath) ? current?.title : attribute.title;
|
|
369
|
+
if (title === undefined)
|
|
370
|
+
continue;
|
|
371
|
+
const writeAttribute = {
|
|
372
|
+
title,
|
|
373
|
+
...(attribute.subtitle !== undefined && !blockedPaths.has(`attributes.${attribute.locale}.subtitle`) ? { subtitle: attribute.subtitle } : current?.subtitle != null ? { subtitle: current.subtitle } : {}),
|
|
374
|
+
...(attribute.description !== undefined && !blockedPaths.has(`attributes.${attribute.locale}.description`) ? { description: attribute.description } : current?.description != null ? { description: current.description } : {}),
|
|
375
|
+
...(attribute.richDescription !== undefined && !blockedPaths.has(`attributes.${attribute.locale}.richDescription`) ? { richDescription: attribute.richDescription } : current?.richDescription != null ? { richDescription: current.richDescription } : {}),
|
|
376
|
+
...(attribute.seoTitle !== undefined && !blockedPaths.has(`attributes.${attribute.locale}.seoTitle`) ? { seoTitle: attribute.seoTitle } : current?.seoTitle != null ? { seoTitle: current.seoTitle } : {}),
|
|
377
|
+
...(attribute.seoDescription !== undefined && !blockedPaths.has(`attributes.${attribute.locale}.seoDescription`) ? { seoDescription: attribute.seoDescription } : current?.seoDescription != null ? { seoDescription: current.seoDescription } : {}),
|
|
378
|
+
};
|
|
379
|
+
if (!current) {
|
|
380
|
+
created += 1;
|
|
381
|
+
changed = true;
|
|
382
|
+
}
|
|
383
|
+
else if (attributeFields.some((field) => (current[field] == null ? null : current[field]) !== (writeAttribute[field] == null ? null : writeAttribute[field]))) {
|
|
384
|
+
changed = true;
|
|
385
|
+
}
|
|
386
|
+
const result = await this.catalog.setAttributes(entityId, attribute.locale, writeAttribute, actor);
|
|
387
|
+
if (!result.ok)
|
|
388
|
+
return PluginErr(result.error.message);
|
|
389
|
+
}
|
|
390
|
+
return Ok({ created, changed });
|
|
391
|
+
}
|
|
392
|
+
async upsertOptionAxes(entityId, item, actor) {
|
|
393
|
+
const optionValueIds = new Map();
|
|
394
|
+
const existingTypes = await this.db.select().from(optionTypes).where(eq(optionTypes.entityId, entityId));
|
|
395
|
+
let changed = false;
|
|
396
|
+
for (const [typeIndex, sourceType] of (item.options ?? []).entries()) {
|
|
397
|
+
let optionType = existingTypes.find((row) => row.name === sourceType.name);
|
|
398
|
+
if (!optionType) {
|
|
399
|
+
const created = await this.catalog.createOptionType({ entityId, name: sourceType.name, values: [] }, actor);
|
|
400
|
+
if (!created.ok)
|
|
401
|
+
return PluginErr(created.error.message);
|
|
402
|
+
const [createdType] = await this.db.select().from(optionTypes).where(eq(optionTypes.id, created.value.id));
|
|
403
|
+
if (!createdType)
|
|
404
|
+
return PluginErr(`Option type "${sourceType.name}" was not persisted.`);
|
|
405
|
+
optionType = createdType;
|
|
406
|
+
existingTypes.push(optionType);
|
|
407
|
+
changed = true;
|
|
408
|
+
}
|
|
409
|
+
await this.db.update(optionTypes).set({
|
|
410
|
+
displayName: sourceType.displayName,
|
|
411
|
+
sortOrder: sourceType.sortOrder ?? typeIndex,
|
|
412
|
+
}).where(eq(optionTypes.id, optionType.id));
|
|
413
|
+
const existingValues = await this.db.select().from(optionValues).where(eq(optionValues.optionTypeId, optionType.id));
|
|
414
|
+
const valueIds = new Map();
|
|
415
|
+
for (const [valueIndex, sourceValue] of sourceType.values.entries()) {
|
|
416
|
+
let optionValue = existingValues.find((row) => row.value === sourceValue.value);
|
|
417
|
+
if (!optionValue) {
|
|
418
|
+
const created = await this.catalog.createOptionValue({ optionTypeId: optionType.id, value: sourceValue.value }, actor);
|
|
419
|
+
if (!created.ok)
|
|
420
|
+
return PluginErr(created.error.message);
|
|
421
|
+
const [createdValue] = await this.db.select().from(optionValues).where(eq(optionValues.id, created.value.id));
|
|
422
|
+
if (!createdValue)
|
|
423
|
+
return PluginErr(`Option value "${sourceValue.value}" was not persisted.`);
|
|
424
|
+
optionValue = createdValue;
|
|
425
|
+
existingValues.push(optionValue);
|
|
426
|
+
changed = true;
|
|
427
|
+
}
|
|
428
|
+
await this.db.update(optionValues).set({
|
|
429
|
+
displayValue: sourceValue.displayValue,
|
|
430
|
+
sortOrder: sourceValue.sortOrder ?? valueIndex,
|
|
431
|
+
}).where(eq(optionValues.id, optionValue.id));
|
|
432
|
+
valueIds.set(sourceValue.value, optionValue.id);
|
|
433
|
+
}
|
|
434
|
+
optionValueIds.set(sourceType.name, valueIds);
|
|
435
|
+
}
|
|
436
|
+
return Ok({ value: optionValueIds, changed });
|
|
437
|
+
}
|
|
438
|
+
async upsertVariants(orgId, storeId, entityId, item, optionValueIds, actor, warnings, applyOptionValues, fullItem) {
|
|
439
|
+
const variantIds = new Map();
|
|
440
|
+
let repaired = 0;
|
|
441
|
+
let changed = false;
|
|
442
|
+
const mappings = await this.db.select().from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.kind, "variant"), eq(channelEntityMap.entityId, entityId)));
|
|
443
|
+
for (const sourceVariant of item.variants) {
|
|
444
|
+
const fullSourceVariant = fullItem.variants.find((variant) => variant.externalId === sourceVariant.externalId) ?? sourceVariant;
|
|
445
|
+
let mapping = mappings.find((row) => row.externalId === sourceVariant.externalId);
|
|
446
|
+
let variantId = mapping?.variantId;
|
|
447
|
+
const createdVariant = !variantId;
|
|
448
|
+
if (!variantId) {
|
|
449
|
+
const options = {};
|
|
450
|
+
for (const [name, value] of Object.entries(sourceVariant.optionValues ?? {})) {
|
|
451
|
+
const optionValueId = optionValueIds.get(name)?.get(value);
|
|
452
|
+
if (!optionValueId) {
|
|
453
|
+
warnings.push(`Skipped unmapped option "${name}=${value}" on variant "${sourceVariant.externalId}".`);
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
options[name] = value;
|
|
457
|
+
}
|
|
458
|
+
const created = await this.catalog.createVariant({
|
|
459
|
+
entityId,
|
|
460
|
+
options,
|
|
461
|
+
...(sourceVariant.sku !== undefined ? { sku: sourceVariant.sku } : {}),
|
|
462
|
+
...(sourceVariant.barcode !== undefined ? { barcode: sourceVariant.barcode } : {}),
|
|
463
|
+
}, actor);
|
|
464
|
+
if (!created.ok)
|
|
465
|
+
return PluginErr(created.error.message);
|
|
466
|
+
variantId = created.value.id;
|
|
467
|
+
const [createdMapping] = await this.db.insert(channelEntityMap).values({
|
|
468
|
+
organizationId: orgId,
|
|
469
|
+
storeId,
|
|
470
|
+
kind: "variant",
|
|
471
|
+
externalId: sourceVariant.externalId,
|
|
472
|
+
entityId,
|
|
473
|
+
variantId,
|
|
474
|
+
syncHash: hash(fullSourceVariant),
|
|
475
|
+
}).returning();
|
|
476
|
+
mapping = createdMapping;
|
|
477
|
+
if (mapping)
|
|
478
|
+
mappings.push(mapping);
|
|
479
|
+
}
|
|
480
|
+
if (!variantId) {
|
|
481
|
+
warnings.push(`Skipped variant "${sourceVariant.externalId}": no local variant mapping exists.`);
|
|
482
|
+
continue;
|
|
483
|
+
}
|
|
484
|
+
variantIds.set(sourceVariant.externalId, variantId);
|
|
485
|
+
if (applyOptionValues) {
|
|
486
|
+
const desiredOptionValueIds = Object.entries(sourceVariant.optionValues ?? {})
|
|
487
|
+
.map(([name, value]) => optionValueIds.get(name)?.get(value))
|
|
488
|
+
.filter((optionValueId) => optionValueId !== undefined);
|
|
489
|
+
const currentOptionValues = await this.db.select().from(variantOptionValues).where(eq(variantOptionValues.variantId, variantId));
|
|
490
|
+
const currentIds = currentOptionValues.map((row) => row.optionValueId).sort();
|
|
491
|
+
const desiredIds = [...new Set(desiredOptionValueIds)].sort();
|
|
492
|
+
if (currentIds.length !== desiredIds.length || currentIds.some((id, index) => id !== desiredIds[index])) {
|
|
493
|
+
await this.db.delete(variantOptionValues).where(eq(variantOptionValues.variantId, variantId));
|
|
494
|
+
if (desiredIds.length > 0) {
|
|
495
|
+
await this.db.insert(variantOptionValues).values(desiredIds.map((optionValueId) => ({ variantId, optionValueId }))).onConflictDoNothing();
|
|
496
|
+
repaired += 1;
|
|
497
|
+
}
|
|
498
|
+
changed = true;
|
|
499
|
+
}
|
|
500
|
+
if (createdVariant && desiredIds.length > 0) {
|
|
501
|
+
repaired += 1;
|
|
502
|
+
changed = true;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
for (const price of sourceVariant.prices ?? []) {
|
|
506
|
+
const priced = await this.pricing.setBasePrice({
|
|
507
|
+
entityId,
|
|
508
|
+
variantId,
|
|
509
|
+
currency: price.currency,
|
|
510
|
+
amount: price.amount,
|
|
511
|
+
compareAtAmount: price.compareAtAmount ?? null,
|
|
512
|
+
}, actor);
|
|
513
|
+
if (!priced.ok)
|
|
514
|
+
return PluginErr(priced.error.message);
|
|
515
|
+
}
|
|
516
|
+
if (mapping) {
|
|
517
|
+
await this.db.update(channelEntityMap).set({
|
|
518
|
+
syncHash: hash(fullSourceVariant),
|
|
519
|
+
}).where(eq(channelEntityMap.id, mapping.id));
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
return Ok({ value: variantIds, repaired, changed });
|
|
523
|
+
}
|
|
524
|
+
async applyTaxonomy(orgId, entityId, item, actor, warnings) {
|
|
525
|
+
const categoryRows = await this.db.select().from(categories).where(eq(categories.organizationId, orgId));
|
|
526
|
+
for (const slug of new Set(item.categories ?? [])) {
|
|
527
|
+
let category = categoryRows.find((row) => row.slug === slug);
|
|
528
|
+
if (category?.status === "archived") {
|
|
529
|
+
warnings.push(`Skipped archived category "${slug}".`);
|
|
530
|
+
continue;
|
|
531
|
+
}
|
|
532
|
+
if (!category) {
|
|
533
|
+
const created = await this.catalog.createCategory({ slug }, actor);
|
|
534
|
+
if (!created.ok)
|
|
535
|
+
return PluginErr(created.error.message);
|
|
536
|
+
const [createdCategory] = await this.db.select().from(categories).where(eq(categories.id, created.value.id));
|
|
537
|
+
if (!createdCategory)
|
|
538
|
+
return PluginErr(`Category "${slug}" was not persisted.`);
|
|
539
|
+
category = createdCategory;
|
|
540
|
+
categoryRows.push(category);
|
|
541
|
+
}
|
|
542
|
+
const linked = await this.catalog.addToCategory(entityId, category.id, actor);
|
|
543
|
+
if (!linked.ok)
|
|
544
|
+
return PluginErr(linked.error.message);
|
|
545
|
+
}
|
|
546
|
+
const brandRows = await this.db.select().from(brands).where(eq(brands.organizationId, orgId));
|
|
547
|
+
if (item.brand) {
|
|
548
|
+
let brand = brandRows.find((row) => row.slug === item.brand);
|
|
549
|
+
if (!brand) {
|
|
550
|
+
const created = await this.catalog.createBrand({ slug: item.brand, displayName: item.brand }, actor);
|
|
551
|
+
if (!created.ok)
|
|
552
|
+
return PluginErr(created.error.message);
|
|
553
|
+
const [createdBrand] = await this.db.select().from(brands).where(eq(brands.id, created.value.id));
|
|
554
|
+
if (!createdBrand)
|
|
555
|
+
return PluginErr(`Brand "${item.brand}" was not persisted.`);
|
|
556
|
+
brand = createdBrand;
|
|
557
|
+
brandRows.push(brand);
|
|
558
|
+
}
|
|
559
|
+
const linked = await this.catalog.addToBrand(entityId, brand.id, actor);
|
|
560
|
+
if (!linked.ok)
|
|
561
|
+
return PluginErr(linked.error.message);
|
|
562
|
+
}
|
|
563
|
+
const tagRows = await this.db.select().from(tags).where(eq(tags.organizationId, orgId));
|
|
564
|
+
for (const slug of new Set(item.tags ?? [])) {
|
|
565
|
+
let tag = tagRows.find((row) => row.slug === slug);
|
|
566
|
+
if (!tag) {
|
|
567
|
+
const [createdTag] = await this.db.insert(tags).values({ organizationId: orgId, slug, displayName: slug }).onConflictDoNothing().returning();
|
|
568
|
+
tag = createdTag ?? (await this.db.select().from(tags).where(and(eq(tags.organizationId, orgId), eq(tags.slug, slug))))[0];
|
|
569
|
+
if (!tag)
|
|
570
|
+
return PluginErr(`Tag "${slug}" was not persisted.`);
|
|
571
|
+
tagRows.push(tag);
|
|
572
|
+
}
|
|
573
|
+
await this.db.insert(entityTags).values({ entityId, tagId: tag.id }).onConflictDoNothing();
|
|
574
|
+
}
|
|
575
|
+
return Ok(undefined);
|
|
576
|
+
}
|
|
577
|
+
async applyMedia(orgId, entityId, item, variantIds, actor, warnings, owners) {
|
|
578
|
+
const assets = await this.db.select().from(mediaAssets).where(eq(mediaAssets.organizationId, orgId));
|
|
579
|
+
const links = await this.db.select().from(entityMedia).where(eq(entityMedia.entityId, entityId));
|
|
580
|
+
let imported = 0;
|
|
581
|
+
let changed = false;
|
|
582
|
+
const skipped = [];
|
|
583
|
+
for (const image of item.images ?? []) {
|
|
584
|
+
const urlHash = hash(image.url);
|
|
585
|
+
const asset = assets.find((row) => {
|
|
586
|
+
const metadata = row.metadata ?? {};
|
|
587
|
+
return (image.externalId != null && metadata.channelImageExternalId === image.externalId)
|
|
588
|
+
|| metadata.channelImageUrlHash === urlHash;
|
|
589
|
+
});
|
|
590
|
+
let mediaAssetId = asset?.id;
|
|
591
|
+
if (!mediaAssetId) {
|
|
592
|
+
let response;
|
|
593
|
+
try {
|
|
594
|
+
response = await fetch(image.url);
|
|
595
|
+
}
|
|
596
|
+
catch (error) {
|
|
597
|
+
warnings.push(`Skipped image "${image.externalId ?? image.url}": ${error instanceof Error ? error.message : "download failed"}.`);
|
|
598
|
+
continue;
|
|
599
|
+
}
|
|
600
|
+
if (!response.ok) {
|
|
601
|
+
warnings.push(`Skipped image "${image.externalId ?? image.url}": download returned ${response.status}.`);
|
|
602
|
+
continue;
|
|
603
|
+
}
|
|
604
|
+
const contentType = response.headers.get("content-type")?.split(";", 1)[0] ?? "image/jpeg";
|
|
605
|
+
const extension = contentType.split("/", 2)[1] ?? "jpg";
|
|
606
|
+
const uploaded = await this.media.upload({
|
|
607
|
+
filename: `${image.externalId ?? urlHash}.${extension}`,
|
|
608
|
+
contentType,
|
|
609
|
+
data: await response.arrayBuffer(),
|
|
610
|
+
...(image.alt !== undefined ? { alt: image.alt } : {}),
|
|
611
|
+
metadata: {
|
|
612
|
+
channelImageUrlHash: urlHash,
|
|
613
|
+
...(image.externalId !== undefined ? { channelImageExternalId: image.externalId } : {}),
|
|
614
|
+
},
|
|
615
|
+
origin: "imported",
|
|
616
|
+
}, actor);
|
|
617
|
+
if (!uploaded.ok) {
|
|
618
|
+
warnings.push(`Skipped image "${image.externalId ?? image.url}": ${uploaded.error.code === "STORAGE_NOT_SUPPORTED" ? "storage adapter is not configured" : uploaded.error.message}.`);
|
|
619
|
+
continue;
|
|
620
|
+
}
|
|
621
|
+
mediaAssetId = uploaded.value.id;
|
|
622
|
+
imported += 1;
|
|
623
|
+
changed = true;
|
|
624
|
+
const [createdAsset] = await this.db.select().from(mediaAssets).where(eq(mediaAssets.id, mediaAssetId));
|
|
625
|
+
if (createdAsset)
|
|
626
|
+
assets.push(createdAsset);
|
|
627
|
+
}
|
|
628
|
+
if (!mediaAssetId)
|
|
629
|
+
continue;
|
|
630
|
+
const targets = image.variantExternalIds?.length
|
|
631
|
+
? image.variantExternalIds.map((externalId) => ({ externalId, variantId: variantIds.get(externalId) }))
|
|
632
|
+
: [{ externalId: undefined, variantId: undefined }];
|
|
633
|
+
for (const target of targets) {
|
|
634
|
+
if (image.variantExternalIds?.length && !target.variantId) {
|
|
635
|
+
warnings.push(`Skipped image "${image.externalId ?? image.url}" for unmapped variant "${target.externalId}".`);
|
|
636
|
+
continue;
|
|
637
|
+
}
|
|
638
|
+
const existingLink = links.find((link) => link.mediaAssetId === mediaAssetId
|
|
639
|
+
&& (target.variantId === undefined ? link.variantId === null : link.variantId === target.variantId));
|
|
640
|
+
if (existingLink) {
|
|
641
|
+
if (existingLink.role !== image.role) {
|
|
642
|
+
const currentRolePath = `media.${existingLink.role}`;
|
|
643
|
+
const incomingRolePath = `media.${image.role}`;
|
|
644
|
+
for (const path of [currentRolePath, incomingRolePath]) {
|
|
645
|
+
if (owners.get(path) === "platform" && !skipped.includes(path))
|
|
646
|
+
skipped.push(path);
|
|
647
|
+
}
|
|
648
|
+
if (skipped.includes(currentRolePath) || skipped.includes(incomingRolePath))
|
|
649
|
+
continue;
|
|
650
|
+
}
|
|
651
|
+
if (existingLink.role !== image.role || existingLink.sortOrder !== (image.sortOrder ?? 0)) {
|
|
652
|
+
await this.db.update(entityMedia).set({ role: image.role, sortOrder: image.sortOrder ?? 0 }).where(and(eq(entityMedia.entityId, entityId), eq(entityMedia.mediaAssetId, mediaAssetId), target.variantId === undefined ? isNull(entityMedia.variantId) : eq(entityMedia.variantId, target.variantId)));
|
|
653
|
+
changed = true;
|
|
654
|
+
}
|
|
655
|
+
continue;
|
|
656
|
+
}
|
|
657
|
+
const attached = await this.media.attachToEntity({
|
|
658
|
+
entityId,
|
|
659
|
+
mediaAssetId,
|
|
660
|
+
role: image.role,
|
|
661
|
+
sortOrder: image.sortOrder ?? 0,
|
|
662
|
+
...(target.variantId !== undefined ? { variantId: target.variantId } : {}),
|
|
663
|
+
}, actor);
|
|
664
|
+
if (!attached.ok)
|
|
665
|
+
return PluginErr(attached.error.message);
|
|
666
|
+
changed = true;
|
|
667
|
+
links.push({
|
|
668
|
+
entityId,
|
|
669
|
+
mediaAssetId,
|
|
670
|
+
role: image.role,
|
|
671
|
+
sortOrder: image.sortOrder ?? 0,
|
|
672
|
+
variantId: target.variantId ?? null,
|
|
673
|
+
createdAt: new Date(),
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
return Ok({ imported, changed, skipped });
|
|
678
|
+
}
|
|
82
679
|
async getStoreRecord(orgId, id) {
|
|
83
680
|
const rows = await this.db
|
|
84
681
|
.select()
|
|
@@ -102,20 +699,208 @@ export class ChannelConnectorService {
|
|
|
102
699
|
.where(eq(connectedStores.storeDomain, shopDomain));
|
|
103
700
|
return rows;
|
|
104
701
|
}
|
|
702
|
+
resolveCatalogFieldMapping(store, filterableCustomFields, warnings = []) {
|
|
703
|
+
return mergeCatalogFieldMapping(store.provider, store.catalogFieldMapping, filterableCustomFields, warnings);
|
|
704
|
+
}
|
|
705
|
+
async buildCatalogPushItems(orgId, storeId, entityIds, options = {}) {
|
|
706
|
+
const store = await this.getStoreRecord(orgId, storeId);
|
|
707
|
+
if (!store || store.status !== "connected")
|
|
708
|
+
return PluginErr("Connected store not found.", "NOT_FOUND");
|
|
709
|
+
if (!store.catalogWriteEnabled)
|
|
710
|
+
return PluginErr("Catalog writes are disabled for this store.", "CATALOG_WRITE_DISABLED");
|
|
711
|
+
if (entityIds.length === 0)
|
|
712
|
+
return Ok({ items: [], skipped: [], warnings: [] });
|
|
713
|
+
const entities = await this.db.select().from(sellableEntities).where(and(eq(sellableEntities.organizationId, orgId), inArray(sellableEntities.id, entityIds)));
|
|
714
|
+
const entityById = new Map(entities.map((entity) => [entity.id, entity]));
|
|
715
|
+
const mappings = await this.db.select().from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.kind, "entity"), inArray(channelEntityMap.entityId, entityIds)));
|
|
716
|
+
const mappingByEntity = new Map(mappings.map((mapping) => [mapping.entityId, mapping]));
|
|
717
|
+
const items = [];
|
|
718
|
+
const skipped = [];
|
|
719
|
+
const warnings = [];
|
|
720
|
+
const revisionEntityIds = [];
|
|
721
|
+
for (const entityId of entityIds) {
|
|
722
|
+
const entity = entityById.get(entityId);
|
|
723
|
+
if (!entity)
|
|
724
|
+
return PluginErr("Catalog entity not found.", "NOT_FOUND");
|
|
725
|
+
if (entity.status !== "active") {
|
|
726
|
+
skipped.push({ entityId, fieldPath: "entity.status", reason: "entity_not_active" });
|
|
727
|
+
continue;
|
|
728
|
+
}
|
|
729
|
+
const entityMapping = mappingByEntity.get(entity.id);
|
|
730
|
+
if (!entityMapping) {
|
|
731
|
+
skipped.push({ entityId, fieldPath: "entity", reason: "unmapped_entity" });
|
|
732
|
+
continue;
|
|
733
|
+
}
|
|
734
|
+
const owners = await this.catalog.resolveFieldOwners(entity.id, storeId);
|
|
735
|
+
const attributes = await this.db.select().from(sellableAttributes).where(eq(sellableAttributes.entityId, entity.id));
|
|
736
|
+
const customFields = await this.db.select().from(sellableCustomFields).where(and(eq(sellableCustomFields.entityId, entity.id), eq(sellableCustomFields.status, "approved")));
|
|
737
|
+
const customFieldNames = [...new Set(customFields.map((field) => field.fieldName))];
|
|
738
|
+
const definitions = customFieldNames.length > 0
|
|
739
|
+
? await this.db.select({ name: entityFieldDefinitions.name, filterable: entityFieldDefinitions.filterable }).from(entityFieldDefinitions).where(and(eq(entityFieldDefinitions.organizationId, orgId), eq(entityFieldDefinitions.entityType, entity.type), inArray(entityFieldDefinitions.name, customFieldNames)))
|
|
740
|
+
: [];
|
|
741
|
+
const filterableCustomFields = Object.fromEntries(definitions.map((definition) => [
|
|
742
|
+
`customFields.${definition.name}.en`,
|
|
743
|
+
definition.filterable,
|
|
744
|
+
]));
|
|
745
|
+
for (const field of customFields) {
|
|
746
|
+
filterableCustomFields[`customFields.${field.fieldName}.${field.locale}`] = definitions.find((definition) => definition.name === field.fieldName)?.filterable ?? false;
|
|
747
|
+
}
|
|
748
|
+
const fieldMapping = this.resolveCatalogFieldMapping(store, filterableCustomFields, warnings);
|
|
749
|
+
const heldPaths = new Set(entityMapping.heldFieldPaths ?? []);
|
|
750
|
+
const fields = [];
|
|
751
|
+
const appendField = (fieldPath, value) => {
|
|
752
|
+
if (value === undefined || owners.get(fieldPath) !== "platform")
|
|
753
|
+
return;
|
|
754
|
+
if (heldPaths.has(fieldPath)) {
|
|
755
|
+
skipped.push({ entityId, fieldPath, reason: "held" });
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
const mapping = selectCatalogFieldMapping(fieldMapping, fieldPath);
|
|
759
|
+
if (!mapping) {
|
|
760
|
+
skipped.push({ entityId, fieldPath, reason: "no_mapping" });
|
|
761
|
+
return;
|
|
762
|
+
}
|
|
763
|
+
fields.push(pushCatalogField(fieldPath, value, mapping));
|
|
764
|
+
};
|
|
765
|
+
for (const attribute of attributes) {
|
|
766
|
+
for (const field of attributeFields) {
|
|
767
|
+
appendField(`attributes.${attribute.locale}.${field}`, attribute[field]);
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
for (const [key, value] of Object.entries(entity.metadata ?? {})) {
|
|
771
|
+
const fieldPath = `entity.metadata.${key}`;
|
|
772
|
+
if (isValidFieldPath(fieldPath))
|
|
773
|
+
appendField(fieldPath, value);
|
|
774
|
+
}
|
|
775
|
+
for (const customField of customFields) {
|
|
776
|
+
const fieldPath = `customFields.${customField.fieldName}.${customField.locale}`;
|
|
777
|
+
if (isValidFieldPath(fieldPath))
|
|
778
|
+
appendField(fieldPath, customFieldValue(customField));
|
|
779
|
+
}
|
|
780
|
+
const media = await this.media.listEntityMedia(entity.id, { orgId });
|
|
781
|
+
if (!media.ok)
|
|
782
|
+
return PluginErr(media.error.message);
|
|
783
|
+
const images = [];
|
|
784
|
+
for (const attached of media.value) {
|
|
785
|
+
const role = pushCatalogImageRole(attached.role);
|
|
786
|
+
if (!role)
|
|
787
|
+
continue;
|
|
788
|
+
const fieldPath = `media.${role}`;
|
|
789
|
+
if (owners.get(fieldPath) !== "platform")
|
|
790
|
+
continue;
|
|
791
|
+
if (heldPaths.has(fieldPath)) {
|
|
792
|
+
skipped.push({ entityId, fieldPath, reason: "held" });
|
|
793
|
+
continue;
|
|
794
|
+
}
|
|
795
|
+
if (!selectCatalogFieldMapping(fieldMapping, fieldPath)) {
|
|
796
|
+
skipped.push({ entityId, fieldPath, reason: "no_mapping" });
|
|
797
|
+
continue;
|
|
798
|
+
}
|
|
799
|
+
images.push({
|
|
800
|
+
url: attached.url,
|
|
801
|
+
role,
|
|
802
|
+
sortOrder: attached.sortOrder,
|
|
803
|
+
...(attached.alt !== null ? { alt: attached.alt } : {}),
|
|
804
|
+
});
|
|
805
|
+
}
|
|
806
|
+
fields.sort((left, right) => left.fieldPath.localeCompare(right.fieldPath));
|
|
807
|
+
const item = {
|
|
808
|
+
externalId: entityMapping.externalId,
|
|
809
|
+
fields,
|
|
810
|
+
...(images.length > 0 ? { images } : {}),
|
|
811
|
+
};
|
|
812
|
+
items.push(item);
|
|
813
|
+
if (options.recordRevision === true)
|
|
814
|
+
revisionEntityIds.push(entity.id);
|
|
815
|
+
}
|
|
816
|
+
if (options.recordRevision === true && revisionEntityIds.length > 0) {
|
|
817
|
+
const actor = createSystemActor(orgId);
|
|
818
|
+
try {
|
|
819
|
+
await this.transact(async (tx) => {
|
|
820
|
+
const txContext = createTxContext(tx, { actor });
|
|
821
|
+
for (const entityId of revisionEntityIds) {
|
|
822
|
+
const revision = await this.catalog.recordEntityRevision(entityId, actor, "push", txContext);
|
|
823
|
+
if (!revision.ok)
|
|
824
|
+
throw new Error(revision.error.message);
|
|
825
|
+
}
|
|
826
|
+
});
|
|
827
|
+
}
|
|
828
|
+
catch (error) {
|
|
829
|
+
return PluginErr(error instanceof Error ? error.message : "Failed to record catalog push revisions.");
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
return Ok({ items, skipped, warnings: [...new Set(warnings)] });
|
|
833
|
+
}
|
|
834
|
+
async getCatalogWriteSettings(orgId, storeId) {
|
|
835
|
+
const store = await this.getStoreRecord(orgId, storeId);
|
|
836
|
+
if (!store)
|
|
837
|
+
return PluginErr("Connected store not found.", "NOT_FOUND");
|
|
838
|
+
const warnings = [];
|
|
839
|
+
return Ok({
|
|
840
|
+
enabled: store.catalogWriteEnabled === true,
|
|
841
|
+
overrides: store.catalogFieldMapping,
|
|
842
|
+
merged: this.resolveCatalogFieldMapping(store, undefined, warnings),
|
|
843
|
+
...(warnings.length > 0 ? { warnings } : {}),
|
|
844
|
+
});
|
|
845
|
+
}
|
|
846
|
+
async updateCatalogWriteEnabled(orgId, storeId, enabled) {
|
|
847
|
+
const rows = await this.db
|
|
848
|
+
.update(connectedStores)
|
|
849
|
+
.set({ catalogWriteEnabled: enabled, updatedAt: new Date() })
|
|
850
|
+
.where(and(eq(connectedStores.organizationId, orgId), eq(connectedStores.id, storeId)))
|
|
851
|
+
.returning();
|
|
852
|
+
if (!rows[0])
|
|
853
|
+
return PluginErr("Connected store not found.", "NOT_FOUND");
|
|
854
|
+
return this.getCatalogWriteSettings(orgId, storeId);
|
|
855
|
+
}
|
|
856
|
+
async updateCatalogFieldMapping(orgId, storeId, mapping) {
|
|
857
|
+
const store = await this.getStoreRecord(orgId, storeId);
|
|
858
|
+
if (!store)
|
|
859
|
+
return PluginErr("Connected store not found.", "NOT_FOUND");
|
|
860
|
+
let normalized;
|
|
861
|
+
try {
|
|
862
|
+
normalized = normalizeCatalogFieldMapping(mapping, store.provider);
|
|
863
|
+
}
|
|
864
|
+
catch (error) {
|
|
865
|
+
return PluginErr(error instanceof Error ? error.message : "Catalog mapping is invalid.", "INVALID_MAPPING");
|
|
866
|
+
}
|
|
867
|
+
await this.db
|
|
868
|
+
.update(connectedStores)
|
|
869
|
+
.set({ catalogFieldMapping: normalized, updatedAt: new Date() })
|
|
870
|
+
.where(and(eq(connectedStores.organizationId, orgId), eq(connectedStores.id, storeId)));
|
|
871
|
+
return this.getCatalogWriteSettings(orgId, storeId);
|
|
872
|
+
}
|
|
105
873
|
async connectStore(orgId, input) {
|
|
106
874
|
if (!this.connectors.has(input.provider)) {
|
|
107
875
|
return PluginErr(`No connector registered for provider "${input.provider}".`, "NOT_FOUND");
|
|
108
876
|
}
|
|
109
|
-
const
|
|
110
|
-
.
|
|
111
|
-
.
|
|
112
|
-
organizationId
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
877
|
+
const existingRows = await this.db
|
|
878
|
+
.select()
|
|
879
|
+
.from(connectedStores)
|
|
880
|
+
.where(and(eq(connectedStores.organizationId, orgId), eq(connectedStores.provider, input.provider), eq(connectedStores.storeDomain, input.storeDomain)));
|
|
881
|
+
const reconnect = existingRows.find((row) => row.status !== "connected");
|
|
882
|
+
const rows = reconnect
|
|
883
|
+
? await this.db
|
|
884
|
+
.update(connectedStores)
|
|
885
|
+
.set({
|
|
886
|
+
credentials: input.credentials,
|
|
887
|
+
status: "connected",
|
|
888
|
+
catalogWriteEnabled: false,
|
|
889
|
+
webhookSecret: input.webhookSecret ?? crypto.randomUUID(),
|
|
890
|
+
updatedAt: new Date(),
|
|
891
|
+
})
|
|
892
|
+
.where(eq(connectedStores.id, reconnect.id))
|
|
893
|
+
.returning()
|
|
894
|
+
: await this.db
|
|
895
|
+
.insert(connectedStores)
|
|
896
|
+
.values({
|
|
897
|
+
organizationId: orgId,
|
|
898
|
+
provider: input.provider,
|
|
899
|
+
credentials: input.credentials,
|
|
900
|
+
storeDomain: input.storeDomain,
|
|
901
|
+
webhookSecret: input.webhookSecret ?? crypto.randomUUID(),
|
|
902
|
+
})
|
|
903
|
+
.returning();
|
|
119
904
|
const connector = this.connectors.get(input.provider);
|
|
120
905
|
const store = rows[0];
|
|
121
906
|
if (connector.registerWebhooks) {
|
|
@@ -258,78 +1043,458 @@ export class ChannelConnectorService {
|
|
|
258
1043
|
.update(connectedStores)
|
|
259
1044
|
.set({ catalogCursor: null, lastSyncAt: new Date(), updatedAt: new Date() })
|
|
260
1045
|
.where(and(eq(connectedStores.organizationId, orgId), eq(connectedStores.id, storeId)));
|
|
261
|
-
return Ok({
|
|
1046
|
+
return Ok({
|
|
1047
|
+
imported: result.value.imported,
|
|
1048
|
+
cursor: null,
|
|
1049
|
+
...(result.value.skipped.length > 0 ? { skipped: uniqueSkipped(result.value.skipped) } : {}),
|
|
1050
|
+
...(result.value.conflicts.length > 0 ? { conflicts: result.value.conflicts } : {}),
|
|
1051
|
+
...(result.value.warnings.length > 0 ? { warnings: result.value.warnings } : {}),
|
|
1052
|
+
});
|
|
262
1053
|
}
|
|
263
|
-
async
|
|
1054
|
+
async promoteLegacyAttributes(orgId, storeId, actor, dryRun) {
|
|
1055
|
+
const mappings = await this.db.select().from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.kind, "entity")));
|
|
1056
|
+
let created = 0;
|
|
1057
|
+
for (const entityId of new Set(mappings.map((mapping) => mapping.entityId))) {
|
|
1058
|
+
const [entity] = await this.db.select().from(sellableEntities).where(and(eq(sellableEntities.organizationId, orgId), eq(sellableEntities.id, entityId)));
|
|
1059
|
+
if (!entity)
|
|
1060
|
+
continue;
|
|
1061
|
+
const attributes = await this.db.select().from(sellableAttributes).where(eq(sellableAttributes.entityId, entity.id));
|
|
1062
|
+
if (attributes.length > 0)
|
|
1063
|
+
continue;
|
|
1064
|
+
const metadata = entity.metadata ?? {};
|
|
1065
|
+
if (typeof metadata.title !== "string")
|
|
1066
|
+
continue;
|
|
1067
|
+
const owners = await this.catalog.resolveFieldOwners(entity.id, storeId);
|
|
1068
|
+
if (owners.get("attributes.en.title") === "platform")
|
|
1069
|
+
continue;
|
|
1070
|
+
if (dryRun) {
|
|
1071
|
+
created += 1;
|
|
1072
|
+
continue;
|
|
1073
|
+
}
|
|
1074
|
+
const promoted = await this.catalog.setAttributes(entity.id, "en", {
|
|
1075
|
+
title: metadata.title,
|
|
1076
|
+
...(typeof metadata.description === "string" ? { description: metadata.description } : {}),
|
|
1077
|
+
}, actor);
|
|
1078
|
+
if (!promoted.ok)
|
|
1079
|
+
return PluginErr(promoted.error.message);
|
|
1080
|
+
const [confirmed] = await this.db.select({ id: sellableAttributes.id, title: sellableAttributes.title, description: sellableAttributes.description }).from(sellableAttributes).where(and(eq(sellableAttributes.entityId, entity.id), eq(sellableAttributes.locale, "en")));
|
|
1081
|
+
if (!confirmed || confirmed.title !== metadata.title || (typeof metadata.description === "string" && confirmed.description !== metadata.description)) {
|
|
1082
|
+
return PluginErr(`Legacy attributes for entity "${entity.id}" were not persisted.`);
|
|
1083
|
+
}
|
|
1084
|
+
const nextMetadata = { ...metadata };
|
|
1085
|
+
delete nextMetadata.title;
|
|
1086
|
+
if (typeof metadata.description === "string")
|
|
1087
|
+
delete nextMetadata.description;
|
|
1088
|
+
await this.db.update(sellableEntities).set({ metadata: nextMetadata, updatedAt: new Date() }).where(and(eq(sellableEntities.organizationId, orgId), eq(sellableEntities.id, entity.id)));
|
|
1089
|
+
created += 1;
|
|
1090
|
+
}
|
|
1091
|
+
return Ok(created);
|
|
1092
|
+
}
|
|
1093
|
+
async saveBackfillState(orgId, storeId, state) {
|
|
1094
|
+
const [store] = await this.db.select({ breakerState: connectedStores.breakerState }).from(connectedStores).where(and(eq(connectedStores.organizationId, orgId), eq(connectedStores.id, storeId)));
|
|
1095
|
+
await this.db.update(connectedStores).set({
|
|
1096
|
+
breakerState: { ...(store?.breakerState ?? {}), catalogBackfill: state },
|
|
1097
|
+
updatedAt: new Date(),
|
|
1098
|
+
}).where(and(eq(connectedStores.organizationId, orgId), eq(connectedStores.id, storeId)));
|
|
1099
|
+
}
|
|
1100
|
+
async backfillCatalog(orgId, storeId, actor, options = {}) {
|
|
1101
|
+
const store = await this.getStoreRecord(orgId, storeId);
|
|
1102
|
+
if (!store || store.status !== "connected")
|
|
1103
|
+
return PluginErr("Connected store not found.", "NOT_FOUND");
|
|
1104
|
+
const connector = this.connectors.get(store.provider);
|
|
1105
|
+
if (!connector)
|
|
1106
|
+
return PluginErr(`No connector registered for provider "${store.provider}".`);
|
|
1107
|
+
const dryRun = options.dryRun === true;
|
|
1108
|
+
const saved = store.breakerState.catalogBackfill;
|
|
1109
|
+
const savedState = saved && typeof saved === "object" ? saved : undefined;
|
|
1110
|
+
// Undefined resume derives from persisted state, so a retried job or a
|
|
1111
|
+
// re-triggered run continues an unfinished backfill instead of restarting.
|
|
1112
|
+
const resume = options.resume ?? (savedState !== undefined && !savedState.completedAt);
|
|
1113
|
+
if (resume && savedState?.completedAt && savedState.cursor === null) {
|
|
1114
|
+
return Ok({
|
|
1115
|
+
...savedState.report,
|
|
1116
|
+
cursor: null,
|
|
1117
|
+
complete: true,
|
|
1118
|
+
...(savedState.skipped?.length ? { skipped: savedState.skipped } : {}),
|
|
1119
|
+
...(savedState.conflicts?.length ? { conflicts: savedState.conflicts } : {}),
|
|
1120
|
+
...(savedState.warnings?.length ? { warnings: savedState.warnings } : {}),
|
|
1121
|
+
});
|
|
1122
|
+
}
|
|
1123
|
+
const report = resume && savedState ? { ...savedState.report } : {
|
|
1124
|
+
entitiesTouched: 0,
|
|
1125
|
+
attributesCreated: 0,
|
|
1126
|
+
mediaImported: 0,
|
|
1127
|
+
variantsGivenOptionValues: 0,
|
|
1128
|
+
};
|
|
1129
|
+
const skipped = resume && savedState?.skipped ? [...savedState.skipped] : [];
|
|
1130
|
+
const conflicts = resume && savedState?.conflicts ? [...savedState.conflicts] : [];
|
|
1131
|
+
const warnings = resume && savedState?.warnings ? [...savedState.warnings] : [];
|
|
1132
|
+
const promoted = await this.promoteLegacyAttributes(orgId, storeId, actor, dryRun);
|
|
1133
|
+
if (!promoted.ok)
|
|
1134
|
+
return promoted;
|
|
1135
|
+
report.attributesCreated += promoted.value;
|
|
1136
|
+
let cursor = resume && savedState?.cursor ? savedState.cursor : undefined;
|
|
1137
|
+
let pages = 0;
|
|
1138
|
+
if (!dryRun) {
|
|
1139
|
+
await this.saveBackfillState(orgId, storeId, {
|
|
1140
|
+
cursor: cursor ?? null,
|
|
1141
|
+
report,
|
|
1142
|
+
...(skipped.length > 0 ? { skipped: uniqueSkipped(skipped) } : {}),
|
|
1143
|
+
...(conflicts.length > 0 ? { conflicts } : {}),
|
|
1144
|
+
...(warnings.length > 0 ? { warnings } : {}),
|
|
1145
|
+
});
|
|
1146
|
+
}
|
|
1147
|
+
do {
|
|
1148
|
+
const page = await connector.importCatalog(store, cursor);
|
|
1149
|
+
if (!page.ok)
|
|
1150
|
+
return PluginErr(page.error.message);
|
|
1151
|
+
const converged = await this.convergeCatalogItems(orgId, storeId, page.value.items, actor, true, dryRun);
|
|
1152
|
+
if (!converged.ok)
|
|
1153
|
+
return converged;
|
|
1154
|
+
report.entitiesTouched += converged.value.entitiesTouched;
|
|
1155
|
+
report.attributesCreated += converged.value.attributesCreated;
|
|
1156
|
+
report.mediaImported += converged.value.mediaImported;
|
|
1157
|
+
report.variantsGivenOptionValues += converged.value.variantsGivenOptionValues;
|
|
1158
|
+
skipped.push(...converged.value.skipped);
|
|
1159
|
+
conflicts.push(...converged.value.conflicts);
|
|
1160
|
+
warnings.push(...converged.value.warnings);
|
|
1161
|
+
cursor = page.value.nextCursor ?? undefined;
|
|
1162
|
+
pages += 1;
|
|
1163
|
+
// The final state is written once with completedAt below; a cursor-null
|
|
1164
|
+
// checkpoint without it would read as a fresh start after a crash.
|
|
1165
|
+
if (!dryRun && cursor) {
|
|
1166
|
+
await this.saveBackfillState(orgId, storeId, {
|
|
1167
|
+
cursor,
|
|
1168
|
+
report,
|
|
1169
|
+
...(skipped.length > 0 ? { skipped: uniqueSkipped(skipped) } : {}),
|
|
1170
|
+
...(conflicts.length > 0 ? { conflicts } : {}),
|
|
1171
|
+
...(warnings.length > 0 ? { warnings } : {}),
|
|
1172
|
+
});
|
|
1173
|
+
}
|
|
1174
|
+
if (options.maxPages !== undefined && pages >= options.maxPages && cursor) {
|
|
1175
|
+
return Ok({
|
|
1176
|
+
...report,
|
|
1177
|
+
cursor,
|
|
1178
|
+
complete: false,
|
|
1179
|
+
...(skipped.length > 0 ? { skipped: uniqueSkipped(skipped) } : {}),
|
|
1180
|
+
...(conflicts.length > 0 ? { conflicts } : {}),
|
|
1181
|
+
...(warnings.length > 0 ? { warnings } : {}),
|
|
1182
|
+
});
|
|
1183
|
+
}
|
|
1184
|
+
} while (cursor);
|
|
1185
|
+
if (!dryRun) {
|
|
1186
|
+
await this.saveBackfillState(orgId, storeId, {
|
|
1187
|
+
cursor: null,
|
|
1188
|
+
report,
|
|
1189
|
+
...(skipped.length > 0 ? { skipped: uniqueSkipped(skipped) } : {}),
|
|
1190
|
+
...(conflicts.length > 0 ? { conflicts } : {}),
|
|
1191
|
+
...(warnings.length > 0 ? { warnings } : {}),
|
|
1192
|
+
completedAt: new Date().toISOString(),
|
|
1193
|
+
});
|
|
1194
|
+
}
|
|
1195
|
+
return Ok({
|
|
1196
|
+
...report,
|
|
1197
|
+
cursor: null,
|
|
1198
|
+
complete: true,
|
|
1199
|
+
...(skipped.length > 0 ? { skipped: uniqueSkipped(skipped) } : {}),
|
|
1200
|
+
...(conflicts.length > 0 ? { conflicts } : {}),
|
|
1201
|
+
...(warnings.length > 0 ? { warnings } : {}),
|
|
1202
|
+
});
|
|
1203
|
+
}
|
|
1204
|
+
async estimateCatalogItems(orgId, storeId, items) {
|
|
1205
|
+
const stats = {
|
|
1206
|
+
imported: 0,
|
|
1207
|
+
converged: 0,
|
|
1208
|
+
entitiesTouched: 0,
|
|
1209
|
+
attributesCreated: 0,
|
|
1210
|
+
mediaImported: 0,
|
|
1211
|
+
variantsGivenOptionValues: 0,
|
|
1212
|
+
skipped: [],
|
|
1213
|
+
conflicts: [],
|
|
1214
|
+
warnings: [],
|
|
1215
|
+
};
|
|
1216
|
+
const assets = await this.db.select().from(mediaAssets).where(eq(mediaAssets.organizationId, orgId));
|
|
1217
|
+
for (const item of items) {
|
|
1218
|
+
const [entityMapping] = await this.db.select().from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.kind, "entity"), eq(channelEntityMap.externalId, item.externalId)));
|
|
1219
|
+
if (!entityMapping) {
|
|
1220
|
+
stats.imported += 1;
|
|
1221
|
+
stats.entitiesTouched += 1;
|
|
1222
|
+
stats.attributesCreated += item.attributes?.length || 1;
|
|
1223
|
+
stats.variantsGivenOptionValues += item.variants.filter((variant) => Object.keys(variant.optionValues ?? {}).some((name) => item.options?.some((option) => option.name === name))).length;
|
|
1224
|
+
stats.mediaImported += item.images?.length ?? 0;
|
|
1225
|
+
continue;
|
|
1226
|
+
}
|
|
1227
|
+
const [entity] = await this.db.select().from(sellableEntities).where(and(eq(sellableEntities.organizationId, orgId), eq(sellableEntities.id, entityMapping.entityId)));
|
|
1228
|
+
if (!entity)
|
|
1229
|
+
continue;
|
|
1230
|
+
const owners = await this.catalog.resolveFieldOwners(entity.id, storeId);
|
|
1231
|
+
stats.skipped.push(...importedFieldPaths(item)
|
|
1232
|
+
.filter((path) => owners.get(path) === "platform")
|
|
1233
|
+
.map((fieldPath) => ({ entityId: entity.id, fieldPath })));
|
|
1234
|
+
let touched = false;
|
|
1235
|
+
const attributes = await this.db.select().from(sellableAttributes).where(eq(sellableAttributes.entityId, entity.id));
|
|
1236
|
+
const locales = new Set(attributes.map((attribute) => attribute.locale));
|
|
1237
|
+
const metadata = entity.metadata ?? {};
|
|
1238
|
+
if (attributes.length === 0 && typeof metadata.title === "string") {
|
|
1239
|
+
locales.add("en");
|
|
1240
|
+
touched = true;
|
|
1241
|
+
}
|
|
1242
|
+
const sourceAttributes = item.attributes?.length
|
|
1243
|
+
? item.attributes
|
|
1244
|
+
: [{ locale: "en", title: item.title, ...(item.description !== undefined ? { description: item.description } : {}) }];
|
|
1245
|
+
for (const attribute of sourceAttributes) {
|
|
1246
|
+
if (!locales.has(attribute.locale)) {
|
|
1247
|
+
stats.attributesCreated += 1;
|
|
1248
|
+
locales.add(attribute.locale);
|
|
1249
|
+
touched = true;
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
const remoteMetadata = mergeMetadata(entity.metadata, item.metadata ?? {});
|
|
1253
|
+
const remoteStatus = item.status ?? (entity.status === "archived" ? "active" : undefined);
|
|
1254
|
+
const entityChanged = entity.slug !== item.slug
|
|
1255
|
+
|| hash(remoteMetadata) !== hash(entity.metadata ?? {})
|
|
1256
|
+
|| (remoteStatus !== undefined && remoteStatus !== entity.status);
|
|
1257
|
+
if (entityChanged) {
|
|
1258
|
+
stats.converged += 1;
|
|
1259
|
+
touched = true;
|
|
1260
|
+
}
|
|
1261
|
+
const optionValueIds = new Map();
|
|
1262
|
+
const existingTypes = await this.db.select().from(optionTypes).where(eq(optionTypes.entityId, entity.id));
|
|
1263
|
+
for (const sourceType of item.options ?? []) {
|
|
1264
|
+
const existingType = existingTypes.find((optionType) => optionType.name === sourceType.name);
|
|
1265
|
+
if (!existingType) {
|
|
1266
|
+
touched = true;
|
|
1267
|
+
optionValueIds.set(sourceType.name, new Map(sourceType.values.map((value) => [value.value, `new:${sourceType.name}:${value.value}`])));
|
|
1268
|
+
continue;
|
|
1269
|
+
}
|
|
1270
|
+
const existingValues = await this.db.select().from(optionValues).where(eq(optionValues.optionTypeId, existingType.id));
|
|
1271
|
+
const valueIds = new Map();
|
|
1272
|
+
for (const sourceValue of sourceType.values) {
|
|
1273
|
+
const existingValue = existingValues.find((value) => value.value === sourceValue.value);
|
|
1274
|
+
if (!existingValue)
|
|
1275
|
+
touched = true;
|
|
1276
|
+
valueIds.set(sourceValue.value, existingValue?.id ?? `new:${sourceType.name}:${sourceValue.value}`);
|
|
1277
|
+
}
|
|
1278
|
+
optionValueIds.set(sourceType.name, valueIds);
|
|
1279
|
+
}
|
|
1280
|
+
const variantMappings = await this.db.select().from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.kind, "variant"), eq(channelEntityMap.entityId, entity.id)));
|
|
1281
|
+
const variantIds = new Map();
|
|
1282
|
+
for (const sourceVariant of item.variants) {
|
|
1283
|
+
const mapping = variantMappings.find((row) => row.externalId === sourceVariant.externalId);
|
|
1284
|
+
const variantId = mapping?.variantId ?? `new:${sourceVariant.externalId}`;
|
|
1285
|
+
variantIds.set(sourceVariant.externalId, variantId);
|
|
1286
|
+
const desiredIds = [...new Set(Object.entries(sourceVariant.optionValues ?? {})
|
|
1287
|
+
.map(([name, value]) => optionValueIds.get(name)?.get(value))
|
|
1288
|
+
.filter((optionValueId) => optionValueId !== undefined))].sort();
|
|
1289
|
+
if (!mapping?.variantId) {
|
|
1290
|
+
if (desiredIds.length > 0)
|
|
1291
|
+
stats.variantsGivenOptionValues += 1;
|
|
1292
|
+
touched = true;
|
|
1293
|
+
continue;
|
|
1294
|
+
}
|
|
1295
|
+
const current = await this.db.select().from(variantOptionValues).where(eq(variantOptionValues.variantId, mapping.variantId));
|
|
1296
|
+
const currentIds = current.map((row) => row.optionValueId).sort();
|
|
1297
|
+
if (currentIds.length !== desiredIds.length || currentIds.some((id, index) => id !== desiredIds[index])) {
|
|
1298
|
+
if (desiredIds.length > 0)
|
|
1299
|
+
stats.variantsGivenOptionValues += 1;
|
|
1300
|
+
touched = true;
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
const links = await this.db.select().from(entityMedia).where(eq(entityMedia.entityId, entity.id));
|
|
1304
|
+
for (const image of item.images ?? []) {
|
|
1305
|
+
const urlHash = hash(image.url);
|
|
1306
|
+
const asset = assets.find((row) => {
|
|
1307
|
+
const assetMetadata = row.metadata ?? {};
|
|
1308
|
+
return (image.externalId != null && assetMetadata.channelImageExternalId === image.externalId)
|
|
1309
|
+
|| assetMetadata.channelImageUrlHash === urlHash;
|
|
1310
|
+
});
|
|
1311
|
+
const mediaAssetId = asset?.id ?? `new:${urlHash}`;
|
|
1312
|
+
if (!asset)
|
|
1313
|
+
stats.mediaImported += 1;
|
|
1314
|
+
const targets = image.variantExternalIds?.length
|
|
1315
|
+
? image.variantExternalIds.map((externalId) => ({ externalId, variantId: variantIds.get(externalId) }))
|
|
1316
|
+
: [{ externalId: undefined, variantId: undefined }];
|
|
1317
|
+
for (const target of targets) {
|
|
1318
|
+
if (image.variantExternalIds?.length && !target.variantId) {
|
|
1319
|
+
stats.warnings.push(`Skipped image "${image.externalId ?? image.url}" for unmapped variant "${target.externalId}".`);
|
|
1320
|
+
continue;
|
|
1321
|
+
}
|
|
1322
|
+
const existingLink = links.find((link) => link.mediaAssetId === mediaAssetId && (target.variantId === undefined ? link.variantId === null : link.variantId === target.variantId));
|
|
1323
|
+
if (!existingLink)
|
|
1324
|
+
touched = true;
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
if (touched)
|
|
1328
|
+
stats.entitiesTouched += 1;
|
|
1329
|
+
}
|
|
1330
|
+
return Ok(stats);
|
|
1331
|
+
}
|
|
1332
|
+
async convergeCatalogItems(orgId, storeId, items, actor, force = false, dryRun = false) {
|
|
1333
|
+
if (dryRun)
|
|
1334
|
+
return this.estimateCatalogItems(orgId, storeId, items);
|
|
264
1335
|
let imported = 0;
|
|
265
1336
|
let converged = 0;
|
|
1337
|
+
let entitiesTouched = 0;
|
|
1338
|
+
let attributesCreated = 0;
|
|
1339
|
+
let mediaImported = 0;
|
|
1340
|
+
let variantsGivenOptionValues = 0;
|
|
1341
|
+
const skipped = [];
|
|
1342
|
+
const conflicts = [];
|
|
1343
|
+
const warnings = [];
|
|
266
1344
|
for (const item of items) {
|
|
1345
|
+
const remoteHash = hash(item);
|
|
267
1346
|
const existing = await this.db
|
|
268
1347
|
.select()
|
|
269
1348
|
.from(channelEntityMap)
|
|
270
1349
|
.where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.kind, "entity"), eq(channelEntityMap.externalId, item.externalId)));
|
|
271
1350
|
const entityMapping = existing.find((entry) => entry.kind === "entity");
|
|
1351
|
+
let entityId;
|
|
1352
|
+
let isNew = false;
|
|
1353
|
+
let entityTouched = false;
|
|
1354
|
+
let existingEntity;
|
|
272
1355
|
if (entityMapping) {
|
|
273
1356
|
const [entity] = await this.db.select().from(sellableEntities).where(and(eq(sellableEntities.organizationId, orgId), eq(sellableEntities.id, entityMapping.entityId)));
|
|
274
|
-
if (
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
metadata: {
|
|
278
|
-
...(item.metadata ?? {}),
|
|
279
|
-
title: item.title,
|
|
280
|
-
...(item.description !== undefined ? { description: item.description } : {}),
|
|
281
|
-
},
|
|
282
|
-
...(entity?.status === "archived" ? { status: "active", isVisible: true } : {}),
|
|
283
|
-
}, actor);
|
|
284
|
-
if (!updated.ok)
|
|
285
|
-
return PluginErr(updated.error.message);
|
|
286
|
-
await this.db.update(channelEntityMap).set({ syncHash: hash(item), lastSyncedAt: new Date() }).where(eq(channelEntityMap.id, entityMapping.id));
|
|
287
|
-
converged += 1;
|
|
1357
|
+
if (!entity) {
|
|
1358
|
+
warnings.push(`Skipped "${item.externalId}": mapped entity ${entityMapping.entityId} no longer exists.`);
|
|
1359
|
+
continue;
|
|
288
1360
|
}
|
|
289
|
-
|
|
1361
|
+
entityId = entityMapping.entityId;
|
|
1362
|
+
existingEntity = entity;
|
|
290
1363
|
}
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
...(
|
|
299
|
-
},
|
|
300
|
-
}, actor);
|
|
301
|
-
if (!entity.ok)
|
|
302
|
-
return PluginErr(entity.error.message);
|
|
303
|
-
await this.db.insert(channelEntityMap).values({
|
|
304
|
-
organizationId: orgId,
|
|
305
|
-
storeId,
|
|
306
|
-
kind: "entity",
|
|
307
|
-
externalId: item.externalId,
|
|
308
|
-
entityId: entity.value.id,
|
|
309
|
-
syncHash: hash(item),
|
|
310
|
-
});
|
|
311
|
-
for (const sourceVariant of item.variants) {
|
|
312
|
-
const variant = await this.catalog.createVariant({
|
|
313
|
-
entityId: entity.value.id,
|
|
314
|
-
options: {},
|
|
315
|
-
...(sourceVariant.sku !== undefined ? { sku: sourceVariant.sku } : {}),
|
|
316
|
-
...(sourceVariant.barcode !== undefined ? { barcode: sourceVariant.barcode } : {}),
|
|
1364
|
+
else {
|
|
1365
|
+
const status = item.status;
|
|
1366
|
+
const entity = await this.catalog.create({
|
|
1367
|
+
type: "product",
|
|
1368
|
+
slug: item.slug,
|
|
1369
|
+
sourceStoreId: storeId,
|
|
1370
|
+
metadata: mergeMetadata(undefined, item.metadata ?? {}),
|
|
1371
|
+
...(status !== undefined ? { status, isVisible: status === "active" } : {}),
|
|
317
1372
|
}, actor);
|
|
318
|
-
if (!
|
|
319
|
-
return PluginErr(
|
|
1373
|
+
if (!entity.ok)
|
|
1374
|
+
return PluginErr(entity.error.message);
|
|
1375
|
+
entityId = entity.value.id;
|
|
1376
|
+
isNew = true;
|
|
1377
|
+
imported += 1;
|
|
1378
|
+
entityTouched = true;
|
|
1379
|
+
}
|
|
1380
|
+
const ownershipBeforeSeed = await this.catalog.resolveFieldOwners(entityId, storeId);
|
|
1381
|
+
const seedPaths = importedFieldPaths(item).filter((path) => !ownershipBeforeSeed.has(path));
|
|
1382
|
+
const seeded = await this.catalog.seedImportedFieldOwnership(entityId, storeId, seedPaths);
|
|
1383
|
+
if (!seeded.ok)
|
|
1384
|
+
return PluginErr(seeded.error.message);
|
|
1385
|
+
for (const path of seedPaths)
|
|
1386
|
+
ownershipBeforeSeed.set(path, "store");
|
|
1387
|
+
const owners = ownershipBeforeSeed;
|
|
1388
|
+
const remoteChanged = entityMapping === undefined || entityMapping.syncHash !== remoteHash;
|
|
1389
|
+
// An unchanged remote item writes nothing and advances no baseline:
|
|
1390
|
+
// converging a stale replay would revert local edits to shared and
|
|
1391
|
+
// unowned fields that the store never actually changed.
|
|
1392
|
+
if (!force && !remoteChanged && existingEntity && existingEntity.status !== "archived") {
|
|
1393
|
+
continue;
|
|
1394
|
+
}
|
|
1395
|
+
const shared = existingEntity
|
|
1396
|
+
? await this.detectSharedConflicts(entityId, storeId, existingEntity, entityMapping, item, owners)
|
|
1397
|
+
: { paths: [], conflicts: [] };
|
|
1398
|
+
const owned = this.filterOwnedFields(item, owners);
|
|
1399
|
+
const heldSharedPaths = [...new Set([...(entityMapping?.heldFieldPaths ?? []), ...shared.paths])];
|
|
1400
|
+
const held = this.filterConflictingFields(owned.writable, heldSharedPaths);
|
|
1401
|
+
const writable = held.writable;
|
|
1402
|
+
const blockedPaths = new Set([...owned.skipped, ...heldSharedPaths]);
|
|
1403
|
+
skipped.push(...owned.skipped.map((fieldPath) => ({ entityId, fieldPath })));
|
|
1404
|
+
conflicts.push(...shared.conflicts);
|
|
1405
|
+
for (const conflict of shared.conflicts) {
|
|
1406
|
+
warnings.push(`Held shared field conflict for entity "${conflict.entityId}", store "${conflict.storeId}", field "${conflict.fieldPath}" (local ${conflict.localValueSummary}, remote ${conflict.remoteValueSummary}).`);
|
|
1407
|
+
}
|
|
1408
|
+
if (existingEntity && entityMapping) {
|
|
1409
|
+
const remoteMetadata = mergeMetadata(existingEntity.metadata, writable.metadata ?? {});
|
|
1410
|
+
const remoteStatus = ownerAllows(owners, "entity.status") && !blockedPaths.has("entity.status")
|
|
1411
|
+
? writable.status ?? (existingEntity.status === "archived" ? "active" : undefined)
|
|
1412
|
+
: undefined;
|
|
1413
|
+
const updateInput = {};
|
|
1414
|
+
if (ownerAllows(owners, "entity.slug") && !blockedPaths.has("entity.slug") && existingEntity.slug !== writable.slug) {
|
|
1415
|
+
updateInput.slug = writable.slug;
|
|
1416
|
+
}
|
|
1417
|
+
if (hash(remoteMetadata) !== hash(existingEntity.metadata ?? {}))
|
|
1418
|
+
updateInput.metadata = remoteMetadata;
|
|
1419
|
+
if (remoteStatus !== undefined && !blockedPaths.has("entity.status") && remoteStatus !== existingEntity.status) {
|
|
1420
|
+
updateInput.status = remoteStatus;
|
|
1421
|
+
updateInput.isVisible = remoteStatus === "active";
|
|
1422
|
+
}
|
|
1423
|
+
const shouldUpdate = force
|
|
1424
|
+
? Object.keys(updateInput).length > 0
|
|
1425
|
+
: remoteChanged || existingEntity.status === "archived";
|
|
1426
|
+
if (shouldUpdate) {
|
|
1427
|
+
converged += 1;
|
|
1428
|
+
if (Object.keys(updateInput).length > 0) {
|
|
1429
|
+
const updated = await this.catalog.update(entityMapping.entityId, updateInput, actor);
|
|
1430
|
+
if (!updated.ok)
|
|
1431
|
+
return PluginErr(updated.error.message);
|
|
1432
|
+
entityTouched = true;
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
const optionAxes = await this.upsertOptionAxes(entityId, writable, actor);
|
|
1437
|
+
if (!optionAxes.ok)
|
|
1438
|
+
return optionAxes;
|
|
1439
|
+
const attributes = await this.setCatalogAttributes(entityId, writable, actor, blockedPaths);
|
|
1440
|
+
if (!attributes.ok)
|
|
1441
|
+
return attributes;
|
|
1442
|
+
const variantIds = await this.upsertVariants(orgId, storeId, entityId, writable, optionAxes.value.value, actor, warnings, !heldSharedPaths.includes("options") && owners.get("options") !== "platform", item);
|
|
1443
|
+
if (!variantIds.ok)
|
|
1444
|
+
return variantIds;
|
|
1445
|
+
const taxonomy = await this.applyTaxonomy(orgId, entityId, writable, actor, warnings);
|
|
1446
|
+
if (!taxonomy.ok)
|
|
1447
|
+
return taxonomy;
|
|
1448
|
+
const media = await this.applyMedia(orgId, entityId, writable, variantIds.value.value, actor, warnings, owners);
|
|
1449
|
+
if (!media.ok)
|
|
1450
|
+
return media;
|
|
1451
|
+
attributesCreated += attributes.value.created;
|
|
1452
|
+
mediaImported += media.value.imported;
|
|
1453
|
+
variantsGivenOptionValues += variantIds.value.repaired;
|
|
1454
|
+
skipped.push(...media.value.skipped.map((fieldPath) => ({ entityId, fieldPath })));
|
|
1455
|
+
entityTouched = entityTouched || optionAxes.value.changed || variantIds.value.changed || media.value.changed || attributes.value.changed;
|
|
1456
|
+
if (entityTouched)
|
|
1457
|
+
entitiesTouched += 1;
|
|
1458
|
+
if (entityTouched) {
|
|
1459
|
+
const revision = await this.catalog.recordEntityRevision(entityId, actor, "import");
|
|
1460
|
+
if (!revision.ok)
|
|
1461
|
+
return PluginErr(revision.error.message);
|
|
1462
|
+
}
|
|
1463
|
+
const revisionMarkers = await this.catalog.repository.findRevisionMarkers(entityId);
|
|
1464
|
+
const latestRevisionAt = revisionMarkers.at(-1)?.createdAt;
|
|
1465
|
+
const lastSyncedAt = latestRevisionAt ?? entityMapping?.lastSyncedAt ?? new Date();
|
|
1466
|
+
if (isNew) {
|
|
320
1467
|
await this.db.insert(channelEntityMap).values({
|
|
321
1468
|
organizationId: orgId,
|
|
322
1469
|
storeId,
|
|
323
|
-
kind: "
|
|
324
|
-
externalId:
|
|
325
|
-
entityId
|
|
326
|
-
|
|
327
|
-
|
|
1470
|
+
kind: "entity",
|
|
1471
|
+
externalId: item.externalId,
|
|
1472
|
+
entityId,
|
|
1473
|
+
syncHash: remoteHash,
|
|
1474
|
+
lastSyncedAt,
|
|
1475
|
+
heldFieldPaths: heldSharedPaths,
|
|
328
1476
|
});
|
|
329
1477
|
}
|
|
330
|
-
|
|
1478
|
+
else if (entityMapping) {
|
|
1479
|
+
await this.db.update(channelEntityMap).set({
|
|
1480
|
+
syncHash: remoteHash,
|
|
1481
|
+
lastSyncedAt,
|
|
1482
|
+
heldFieldPaths: heldSharedPaths,
|
|
1483
|
+
}).where(eq(channelEntityMap.id, entityMapping.id));
|
|
1484
|
+
}
|
|
1485
|
+
await this.db.update(channelEntityMap).set({ lastSyncedAt }).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.entityId, entityId), eq(channelEntityMap.kind, "variant")));
|
|
331
1486
|
}
|
|
332
|
-
return Ok({
|
|
1487
|
+
return Ok({
|
|
1488
|
+
imported,
|
|
1489
|
+
converged,
|
|
1490
|
+
entitiesTouched,
|
|
1491
|
+
attributesCreated,
|
|
1492
|
+
mediaImported,
|
|
1493
|
+
variantsGivenOptionValues,
|
|
1494
|
+
skipped,
|
|
1495
|
+
conflicts,
|
|
1496
|
+
warnings,
|
|
1497
|
+
});
|
|
333
1498
|
}
|
|
334
1499
|
async reconcile(orgId, storeId, actor) {
|
|
335
1500
|
const store = await this.getStoreRecord(orgId, storeId);
|
|
@@ -354,11 +1519,17 @@ export class ChannelConnectorService {
|
|
|
354
1519
|
return converged;
|
|
355
1520
|
const present = new Set(items.map((item) => item.externalId));
|
|
356
1521
|
let archived = 0;
|
|
1522
|
+
const skipped = [...converged.value.skipped];
|
|
357
1523
|
for (const mapping of entityMappings) {
|
|
358
1524
|
if (present.has(mapping.externalId))
|
|
359
1525
|
continue;
|
|
360
1526
|
const [entity] = await this.db.select({ status: sellableEntities.status }).from(sellableEntities).where(and(eq(sellableEntities.organizationId, orgId), eq(sellableEntities.id, mapping.entityId)));
|
|
361
1527
|
if (entity?.status !== "archived") {
|
|
1528
|
+
const owners = await this.catalog.resolveFieldOwners(mapping.entityId, storeId);
|
|
1529
|
+
if (owners.get("entity.status") === "platform") {
|
|
1530
|
+
skipped.push({ entityId: mapping.entityId, fieldPath: "entity.status" });
|
|
1531
|
+
continue;
|
|
1532
|
+
}
|
|
362
1533
|
const result = await this.catalog.archive(mapping.entityId, actor);
|
|
363
1534
|
if (!result.ok)
|
|
364
1535
|
return PluginErr(result.error.message);
|
|
@@ -395,6 +1566,9 @@ export class ChannelConnectorService {
|
|
|
395
1566
|
archived,
|
|
396
1567
|
inventoryUpdated,
|
|
397
1568
|
driftAlert: converged.value.imported + converged.value.converged + archived > threshold,
|
|
1569
|
+
...(skipped.length > 0 ? { skipped: uniqueSkipped(skipped) } : {}),
|
|
1570
|
+
...(converged.value.conflicts.length > 0 ? { conflicts: converged.value.conflicts } : {}),
|
|
1571
|
+
...(converged.value.warnings.length > 0 ? { warnings: converged.value.warnings } : {}),
|
|
398
1572
|
};
|
|
399
1573
|
await this.db.update(connectedStores).set({
|
|
400
1574
|
lastReconcileAt: new Date(),
|
|
@@ -447,19 +1621,34 @@ export class ChannelConnectorService {
|
|
|
447
1621
|
return PluginErr("Connected store not found.", "NOT_FOUND");
|
|
448
1622
|
const actor = createSystemActor(orgId);
|
|
449
1623
|
const data = event.data;
|
|
1624
|
+
let skipped = [];
|
|
1625
|
+
let conflicts = [];
|
|
1626
|
+
let warnings = [];
|
|
450
1627
|
if (event.type === "products/update") {
|
|
451
1628
|
const productId = String(data.id ?? data.product_id ?? "");
|
|
452
1629
|
const mapping = await this.db.select().from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.kind, "entity"), eq(channelEntityMap.externalId, productId)));
|
|
453
|
-
if (mapping[0])
|
|
454
|
-
await this.convergeCatalogItem(orgId, storeId, mapping[0].entityId, data, actor);
|
|
1630
|
+
if (mapping[0]) {
|
|
1631
|
+
const converged = await this.convergeCatalogItem(orgId, storeId, mapping[0].entityId, data, actor);
|
|
1632
|
+
if (!converged.ok)
|
|
1633
|
+
return converged;
|
|
1634
|
+
skipped = converged.value.skipped;
|
|
1635
|
+
conflicts = converged.value.conflicts;
|
|
1636
|
+
warnings = converged.value.warnings;
|
|
1637
|
+
}
|
|
455
1638
|
}
|
|
456
1639
|
else if (event.type === "products/delete") {
|
|
457
1640
|
const productId = String(data.id ?? data.product_id ?? "");
|
|
458
1641
|
const mapping = await this.db.select().from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.kind, "entity"), eq(channelEntityMap.externalId, productId)));
|
|
459
1642
|
if (mapping[0]) {
|
|
460
|
-
const
|
|
461
|
-
if (
|
|
462
|
-
|
|
1643
|
+
const owners = await this.catalog.resolveFieldOwners(mapping[0].entityId, storeId);
|
|
1644
|
+
if (owners.get("entity.status") === "platform") {
|
|
1645
|
+
skipped.push({ entityId: mapping[0].entityId, fieldPath: "entity.status" });
|
|
1646
|
+
}
|
|
1647
|
+
else {
|
|
1648
|
+
const archived = await this.catalog.archive(mapping[0].entityId, actor);
|
|
1649
|
+
if (!archived.ok)
|
|
1650
|
+
return PluginErr(archived.error.message);
|
|
1651
|
+
}
|
|
463
1652
|
}
|
|
464
1653
|
}
|
|
465
1654
|
else if (event.type === "inventory_levels/update") {
|
|
@@ -513,6 +1702,15 @@ export class ChannelConnectorService {
|
|
|
513
1702
|
return disconnected;
|
|
514
1703
|
return Ok({ processed: true });
|
|
515
1704
|
}
|
|
1705
|
+
if (skipped.length > 0 || conflicts.length > 0 || warnings.length > 0) {
|
|
1706
|
+
const report = {
|
|
1707
|
+
...(store.lastReconcileReport ?? {}),
|
|
1708
|
+
...(skipped.length > 0 ? { skipped: uniqueSkipped(skipped) } : {}),
|
|
1709
|
+
...(conflicts.length > 0 ? { conflicts } : {}),
|
|
1710
|
+
...(warnings.length > 0 ? { warnings } : {}),
|
|
1711
|
+
};
|
|
1712
|
+
await this.db.update(connectedStores).set({ lastReconcileReport: report, updatedAt: new Date() }).where(and(eq(connectedStores.organizationId, orgId), eq(connectedStores.id, storeId)));
|
|
1713
|
+
}
|
|
516
1714
|
return Ok({ processed: true });
|
|
517
1715
|
}
|
|
518
1716
|
complianceEmail(data) {
|
|
@@ -571,6 +1769,88 @@ export class ChannelConnectorService {
|
|
|
571
1769
|
}
|
|
572
1770
|
async convergeCatalogItem(orgId, storeId, entityId, data, actor) {
|
|
573
1771
|
const product = data.product && typeof data.product === "object" ? data.product : data;
|
|
1772
|
+
const remoteMetadata = product.metadata && typeof product.metadata === "object" && !Array.isArray(product.metadata)
|
|
1773
|
+
? product.metadata
|
|
1774
|
+
: {};
|
|
1775
|
+
const [mapping] = await this.db.select().from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.kind, "entity"), eq(channelEntityMap.entityId, entityId)));
|
|
1776
|
+
const [entity] = await this.db.select().from(sellableEntities).where(and(eq(sellableEntities.organizationId, orgId), eq(sellableEntities.id, entityId)));
|
|
1777
|
+
if (!mapping || !entity)
|
|
1778
|
+
return Ok({ skipped: [], conflicts: [], warnings: [] });
|
|
1779
|
+
const [currentAttribute] = await this.db.select().from(sellableAttributes).where(and(eq(sellableAttributes.entityId, entityId), eq(sellableAttributes.locale, "en")));
|
|
1780
|
+
const title = typeof product.title === "string" ? product.title : currentAttribute?.title ?? entity.slug;
|
|
1781
|
+
const description = product.description !== undefined
|
|
1782
|
+
? String(product.description)
|
|
1783
|
+
: currentAttribute?.description ?? undefined;
|
|
1784
|
+
const status = typeof product.status === "string" && ["draft", "active", "archived", "discontinued"].includes(product.status)
|
|
1785
|
+
? product.status
|
|
1786
|
+
: undefined;
|
|
1787
|
+
const remoteItem = {
|
|
1788
|
+
externalId: mapping.externalId,
|
|
1789
|
+
slug: typeof product.slug === "string" ? product.slug : entity.slug,
|
|
1790
|
+
title,
|
|
1791
|
+
...(description !== undefined ? { description } : {}),
|
|
1792
|
+
...(status !== undefined ? { status } : {}),
|
|
1793
|
+
attributes: [{ locale: "en", title, ...(description !== undefined ? { description } : {}) }],
|
|
1794
|
+
...(Object.keys(remoteMetadata).length > 0 ? { metadata: remoteMetadata } : {}),
|
|
1795
|
+
variants: [],
|
|
1796
|
+
};
|
|
1797
|
+
const fieldPaths = [];
|
|
1798
|
+
if (typeof product.slug === "string")
|
|
1799
|
+
fieldPaths.push("entity.slug");
|
|
1800
|
+
if (status !== undefined)
|
|
1801
|
+
fieldPaths.push("entity.status");
|
|
1802
|
+
for (const key of Object.keys(remoteMetadata)) {
|
|
1803
|
+
const path = `entity.metadata.${key}`;
|
|
1804
|
+
if (isValidFieldPath(path))
|
|
1805
|
+
fieldPaths.push(path);
|
|
1806
|
+
}
|
|
1807
|
+
if (typeof product.title === "string")
|
|
1808
|
+
fieldPaths.push("attributes.en.title");
|
|
1809
|
+
if (product.description !== undefined)
|
|
1810
|
+
fieldPaths.push("attributes.en.description");
|
|
1811
|
+
const ownershipBeforeSeed = await this.catalog.resolveFieldOwners(entityId, storeId);
|
|
1812
|
+
const seedPaths = fieldPaths.filter((path) => !ownershipBeforeSeed.has(path));
|
|
1813
|
+
const seeded = await this.catalog.seedImportedFieldOwnership(entityId, storeId, seedPaths);
|
|
1814
|
+
if (!seeded.ok)
|
|
1815
|
+
return PluginErr(seeded.error.message);
|
|
1816
|
+
for (const path of seedPaths)
|
|
1817
|
+
ownershipBeforeSeed.set(path, "store");
|
|
1818
|
+
const owners = ownershipBeforeSeed;
|
|
1819
|
+
const remoteHash = hash(product);
|
|
1820
|
+
const shared = await this.detectSharedConflicts(entityId, storeId, entity, mapping, remoteItem, owners, fieldPaths, remoteHash);
|
|
1821
|
+
const owned = this.filterOwnedFieldsAtPaths(remoteItem, owners, fieldPaths);
|
|
1822
|
+
const heldPaths = [...new Set([...(mapping.heldFieldPaths ?? []), ...shared.paths])];
|
|
1823
|
+
const held = this.filterConflictingFields(owned.writable, heldPaths);
|
|
1824
|
+
const blockedPaths = new Set([
|
|
1825
|
+
...owned.skipped,
|
|
1826
|
+
...heldPaths,
|
|
1827
|
+
...(!fieldPaths.includes("attributes.en.title") ? ["attributes.en.title"] : []),
|
|
1828
|
+
]);
|
|
1829
|
+
const skipped = owned.skipped.map((fieldPath) => ({ entityId, fieldPath }));
|
|
1830
|
+
const conflicts = shared.conflicts;
|
|
1831
|
+
const warnings = conflicts.map((conflict) => `Held shared field conflict for entity "${conflict.entityId}", store "${conflict.storeId}", field "${conflict.fieldPath}" (local ${conflict.localValueSummary}, remote ${conflict.remoteValueSummary}).`);
|
|
1832
|
+
const writable = held.writable;
|
|
1833
|
+
const updateInput = {};
|
|
1834
|
+
if (fieldPaths.includes("entity.slug") && ownerAllows(owners, "entity.slug") && !blockedPaths.has("entity.slug") && entity.slug !== writable.slug) {
|
|
1835
|
+
updateInput.slug = writable.slug;
|
|
1836
|
+
}
|
|
1837
|
+
if (Object.keys(writable.metadata ?? {}).length > 0) {
|
|
1838
|
+
const remoteEntityMetadata = mergeMetadata(entity.metadata, writable.metadata ?? {});
|
|
1839
|
+
if (hash(remoteEntityMetadata) !== hash(entity.metadata ?? {}))
|
|
1840
|
+
updateInput.metadata = remoteEntityMetadata;
|
|
1841
|
+
}
|
|
1842
|
+
if (fieldPaths.includes("entity.status") && ownerAllows(owners, "entity.status") && !blockedPaths.has("entity.status") && typeof writable.status === "string" && writable.status !== entity.status) {
|
|
1843
|
+
updateInput.status = writable.status;
|
|
1844
|
+
updateInput.isVisible = writable.status === "active";
|
|
1845
|
+
}
|
|
1846
|
+
if (Object.keys(updateInput).length > 0) {
|
|
1847
|
+
const updated = await this.catalog.update(entityId, updateInput, actor);
|
|
1848
|
+
if (!updated.ok)
|
|
1849
|
+
return PluginErr(updated.error.message);
|
|
1850
|
+
}
|
|
1851
|
+
const attributes = await this.setCatalogAttributes(entityId, writable, actor, blockedPaths);
|
|
1852
|
+
if (!attributes.ok)
|
|
1853
|
+
return attributes;
|
|
574
1854
|
const levels = Array.isArray(product.variants) ? product.variants : [];
|
|
575
1855
|
for (const variant of levels) {
|
|
576
1856
|
const externalId = String(variant.id ?? variant.variation_id ?? "");
|
|
@@ -578,6 +1858,14 @@ export class ChannelConnectorService {
|
|
|
578
1858
|
if (externalId && available !== undefined)
|
|
579
1859
|
await this.setMappedInventory(orgId, storeId, externalId, Number(available), actor);
|
|
580
1860
|
}
|
|
1861
|
+
const revisionMarkers = await this.catalog.repository.findRevisionMarkers(entityId);
|
|
1862
|
+
const lastSyncedAt = revisionMarkers.at(-1)?.createdAt ?? mapping.lastSyncedAt;
|
|
1863
|
+
await this.db.update(channelEntityMap).set({
|
|
1864
|
+
syncHash: remoteHash,
|
|
1865
|
+
lastSyncedAt,
|
|
1866
|
+
heldFieldPaths: heldPaths,
|
|
1867
|
+
}).where(eq(channelEntityMap.id, mapping.id));
|
|
1868
|
+
return Ok({ skipped, conflicts, warnings });
|
|
581
1869
|
}
|
|
582
1870
|
async createRefundRequest(orgId, store, data, actor) {
|
|
583
1871
|
const remoteRefundId = String(data.id ?? data.refund_id ?? "");
|