@porulle/plugin-channel-connector 0.10.8 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/catalog-field-mapping.d.ts +32 -0
- package/dist/catalog-field-mapping.js +178 -0
- package/dist/catalog-push-trigger.d.ts +26 -0
- package/dist/catalog-push-trigger.js +70 -0
- package/dist/hooks.js +18 -3
- package/dist/index.d.ts +5 -3
- package/dist/index.js +159 -8
- package/dist/mock-connector.d.ts +31 -6
- package/dist/mock-connector.js +77 -2
- package/dist/schema.d.ts +876 -63
- package/dist/schema.js +76 -1
- package/dist/service.d.ts +167 -2
- package/dist/service.js +2402 -79
- package/package.json +4 -4
- package/src/catalog-field-mapping.ts +211 -0
- package/src/catalog-push-trigger.ts +99 -0
- package/src/hooks.ts +30 -6
- package/src/index.ts +222 -6
- package/src/mock-connector.ts +81 -2
- package/src/schema.ts +106 -0
- package/src/service.ts +3203 -184
package/dist/service.js
CHANGED
|
@@ -1,8 +1,53 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import { CommerceInvalidTransitionError, CommerceValidationError, Ok, PluginErr, createSystemActor, } from "@porulle/core";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
2
|
+
import { CommerceInvalidTransitionError, CommerceValidationError, Ok, PluginErr, createTxContext, createSystemActor, } from "@porulle/core";
|
|
3
|
+
import { isValidFieldPath, requireUserId } from "@porulle/core";
|
|
4
|
+
import { CHANNEL_CONVERGENCE_CTX } from "./catalog-push-trigger.js";
|
|
5
|
+
import { and, desc, eq, inArray, isNull, lte } from "@porulle/core/drizzle";
|
|
6
|
+
import { brands, categories, customerAddresses, customers, entityMedia, entityTags, inventoryLevels, mediaAssets, optionTypes, optionValues, orderLineItems, orders, prices, sellableAttributes, sellableCustomFields, sellableEntities, sellableEntityRevisions, entityFieldDefinitions, tags, variants, variantOptionValues, } from "@porulle/core/schema";
|
|
7
|
+
import { channelCatalogPushEvents, channelCatalogPushes, channelCatalogConflicts, channelCatalogConflictEvents, channelEntityMap, channelExportEvents, channelOrderExports, connectedStores, channelRefundEvents, channelRefundRequests, } from "./schema.js";
|
|
8
|
+
import { mergeCatalogFieldMapping, normalizeCatalogFieldMapping, selectCatalogFieldMapping, } from "./catalog-field-mapping.js";
|
|
9
|
+
export const CATALOG_PUSH_BATCH_SIZES = {
|
|
10
|
+
mock: 100,
|
|
11
|
+
shopify: 50,
|
|
12
|
+
woocommerce: 100,
|
|
13
|
+
};
|
|
14
|
+
const DEFAULT_CATALOG_PUSH_BATCH_SIZE = 50;
|
|
15
|
+
const CATALOG_PUSH_BREAKER_RETRY_MS = 60_000;
|
|
16
|
+
export const CATALOG_PUSH_MAX_ATTEMPTS = 8;
|
|
17
|
+
const CATALOG_PUSH_RETRY_BASE_MS = 60_000;
|
|
18
|
+
const CATALOG_PUSH_RETRY_MAX_MS = 60 * 60 * 1000;
|
|
19
|
+
export function catalogPushRetryDelayMs(attempts) {
|
|
20
|
+
const exponent = Math.max(0, attempts - 1);
|
|
21
|
+
return Math.min(CATALOG_PUSH_RETRY_BASE_MS * (2 ** exponent), CATALOG_PUSH_RETRY_MAX_MS);
|
|
22
|
+
}
|
|
23
|
+
export function catalogPushConcurrencyKey(input) {
|
|
24
|
+
const storeId = String(input.storeId);
|
|
25
|
+
const entityIds = input.entityIds;
|
|
26
|
+
if (Array.isArray(entityIds) && entityIds.length === 1) {
|
|
27
|
+
return `push:${String(entityIds[0])}:${storeId}`;
|
|
28
|
+
}
|
|
29
|
+
return `push-catalog:${storeId}`;
|
|
30
|
+
}
|
|
31
|
+
export function isCatalogPushBreakerOpen(breakerState) {
|
|
32
|
+
if (breakerState.open === true) {
|
|
33
|
+
const openUntil = breakerState.openUntil;
|
|
34
|
+
if (typeof openUntil === "string" && new Date(openUntil) <= new Date())
|
|
35
|
+
return false;
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
const catalogPush = breakerState.catalogPush;
|
|
39
|
+
if (!catalogPush || typeof catalogPush !== "object")
|
|
40
|
+
return false;
|
|
41
|
+
const state = catalogPush;
|
|
42
|
+
if (state.open !== true)
|
|
43
|
+
return false;
|
|
44
|
+
if (typeof state.openUntil === "string" && new Date(state.openUntil) <= new Date())
|
|
45
|
+
return false;
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
function catalogPushBatchSize(provider) {
|
|
49
|
+
return CATALOG_PUSH_BATCH_SIZES[provider] ?? DEFAULT_CATALOG_PUSH_BATCH_SIZE;
|
|
50
|
+
}
|
|
6
51
|
const exportTransitions = {
|
|
7
52
|
pending: ["exported", "abandoned"],
|
|
8
53
|
exported: ["confirmed", "failed", "abandoned"],
|
|
@@ -13,9 +58,218 @@ const exportTransitions = {
|
|
|
13
58
|
export function canExportTransition(from, to) {
|
|
14
59
|
return exportTransitions[from].includes(to);
|
|
15
60
|
}
|
|
61
|
+
// Catalog pushes recur; confirmed/failed rows re-arm through exported, and rows with nothing to push resolve directly.
|
|
62
|
+
const catalogPushTransitions = {
|
|
63
|
+
pending: ["exported", "confirmed", "abandoned"],
|
|
64
|
+
exported: ["confirmed", "failed", "abandoned"],
|
|
65
|
+
confirmed: ["exported", "abandoned"],
|
|
66
|
+
failed: ["exported", "confirmed", "abandoned"],
|
|
67
|
+
abandoned: [],
|
|
68
|
+
};
|
|
69
|
+
export function canCatalogPushTransition(from, to) {
|
|
70
|
+
return catalogPushTransitions[from].includes(to);
|
|
71
|
+
}
|
|
16
72
|
function hash(value) {
|
|
17
73
|
return createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
|
18
74
|
}
|
|
75
|
+
export const CATALOG_OUTBOUND_SUPPRESSION_WINDOW_MS = 15 * 60 * 1000;
|
|
76
|
+
function normalizeCanonicalValue(value) {
|
|
77
|
+
if (typeof value === "string")
|
|
78
|
+
return value.replace(/\r\n?/g, "\n").replace(/\s+/g, " ").trim();
|
|
79
|
+
if (Array.isArray(value))
|
|
80
|
+
return value.map(normalizeCanonicalValue).sort((left, right) => (JSON.stringify(left) ?? "").localeCompare(JSON.stringify(right) ?? ""));
|
|
81
|
+
if (value && typeof value === "object") {
|
|
82
|
+
return Object.fromEntries(Object.entries(value)
|
|
83
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
84
|
+
.map(([key, nested]) => [key, normalizeCanonicalValue(nested)]));
|
|
85
|
+
}
|
|
86
|
+
return value;
|
|
87
|
+
}
|
|
88
|
+
function normalizedValuesEqual(left, right) {
|
|
89
|
+
return JSON.stringify(normalizeCanonicalValue(left)) === JSON.stringify(normalizeCanonicalValue(right));
|
|
90
|
+
}
|
|
91
|
+
function snapshotCustomFieldValue(field) {
|
|
92
|
+
if (field.textValue !== null && field.textValue !== undefined)
|
|
93
|
+
return field.textValue;
|
|
94
|
+
if (field.numberValue !== null && field.numberValue !== undefined)
|
|
95
|
+
return field.numberValue;
|
|
96
|
+
if (field.booleanValue !== null && field.booleanValue !== undefined)
|
|
97
|
+
return field.booleanValue;
|
|
98
|
+
if (field.dateValue !== null && field.dateValue !== undefined)
|
|
99
|
+
return field.dateValue;
|
|
100
|
+
return field.jsonValue;
|
|
101
|
+
}
|
|
102
|
+
function snapshotFieldValue(snapshot, path) {
|
|
103
|
+
const [root, segment, field] = path.split(".");
|
|
104
|
+
if (root === "entity" && segment === "slug")
|
|
105
|
+
return { found: true, value: snapshot.entity.slug };
|
|
106
|
+
if (root === "entity" && segment === "status")
|
|
107
|
+
return { found: true, value: snapshot.entity.status };
|
|
108
|
+
if (root === "entity" && segment === "metadata") {
|
|
109
|
+
const metadata = snapshot.entity.metadata;
|
|
110
|
+
return {
|
|
111
|
+
found: true,
|
|
112
|
+
value: metadata && typeof metadata === "object" ? metadata[field ?? ""] : undefined,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
if (root === "attributes" && segment && field) {
|
|
116
|
+
const attribute = snapshot.attributes.find((row) => row.locale === segment);
|
|
117
|
+
return { found: true, value: attribute?.[field] };
|
|
118
|
+
}
|
|
119
|
+
if (root === "customFields" && segment && field) {
|
|
120
|
+
const customField = snapshot.customFields.find((row) => row.fieldName === segment && row.locale === field && row.status === "approved");
|
|
121
|
+
return { found: true, value: customField ? snapshotCustomFieldValue(customField) : undefined };
|
|
122
|
+
}
|
|
123
|
+
if (root === "media" && segment) {
|
|
124
|
+
return {
|
|
125
|
+
found: true,
|
|
126
|
+
value: snapshot.media.filter((row) => row.role === segment).map((row) => row.mediaAssetId),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
return { found: false, value: undefined };
|
|
130
|
+
}
|
|
131
|
+
function canonicalHash(externalId, fieldPaths, valueAtPath) {
|
|
132
|
+
const fields = fieldPaths.flatMap((fieldPath) => {
|
|
133
|
+
const value = valueAtPath(fieldPath);
|
|
134
|
+
return value === undefined ? [] : [{ fieldPath, value: normalizeCanonicalValue(value) }];
|
|
135
|
+
});
|
|
136
|
+
return hash({
|
|
137
|
+
externalId,
|
|
138
|
+
fields: fields.sort((left, right) => left.fieldPath.localeCompare(right.fieldPath)),
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
function outboundFieldPaths(item) {
|
|
142
|
+
const paths = new Set(item.fields.flatMap((field) => isValidFieldPath(field.fieldPath) ? [field.fieldPath] : []));
|
|
143
|
+
for (const image of item.images ?? [])
|
|
144
|
+
paths.add(`media.${image.role}`);
|
|
145
|
+
return [...paths].sort();
|
|
146
|
+
}
|
|
147
|
+
function pushFieldValue(item, fieldPath) {
|
|
148
|
+
if (fieldPath.startsWith("media.")) {
|
|
149
|
+
const role = fieldPath.slice("media.".length);
|
|
150
|
+
return (item.images ?? [])
|
|
151
|
+
.filter((image) => image.role === role)
|
|
152
|
+
.map((image) => ({ url: image.url, role: image.role }));
|
|
153
|
+
}
|
|
154
|
+
return item.fields.find((field) => field.fieldPath === fieldPath)?.value;
|
|
155
|
+
}
|
|
156
|
+
function canonicalOutboundHash(externalId, item, fieldPaths) {
|
|
157
|
+
return canonicalHash(externalId, fieldPaths, (fieldPath) => pushFieldValue(item, fieldPath));
|
|
158
|
+
}
|
|
159
|
+
function canonicalInboundHash(externalId, fieldPaths, remoteFieldValue) {
|
|
160
|
+
return canonicalHash(externalId, fieldPaths, remoteFieldValue);
|
|
161
|
+
}
|
|
162
|
+
function mergeMetadata(existing, remote) {
|
|
163
|
+
return { ...(existing ?? {}), ...remote };
|
|
164
|
+
}
|
|
165
|
+
const attributeFields = ["title", "subtitle", "description", "richDescription", "seoTitle", "seoDescription"];
|
|
166
|
+
const pushImageRoles = ["primary", "gallery", "thumbnail", "video", "document"];
|
|
167
|
+
function customFieldValue(field) {
|
|
168
|
+
switch (field.fieldType) {
|
|
169
|
+
case "text":
|
|
170
|
+
case "relation":
|
|
171
|
+
case "select":
|
|
172
|
+
return field.textValue;
|
|
173
|
+
case "number":
|
|
174
|
+
return field.numberValue;
|
|
175
|
+
case "boolean":
|
|
176
|
+
return field.booleanValue;
|
|
177
|
+
case "date":
|
|
178
|
+
return field.dateValue;
|
|
179
|
+
case "json":
|
|
180
|
+
return field.jsonValue;
|
|
181
|
+
default:
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
function pushCatalogIntent(fieldPath, target) {
|
|
186
|
+
if (fieldPath.startsWith("customFields.") && target === "attribute")
|
|
187
|
+
return "filterable";
|
|
188
|
+
if (fieldPath.startsWith("customFields.") || fieldPath.startsWith("entity.metadata."))
|
|
189
|
+
return "tag";
|
|
190
|
+
return "display";
|
|
191
|
+
}
|
|
192
|
+
function pushCatalogField(fieldPath, value, mapping) {
|
|
193
|
+
const segments = fieldPath.split(".");
|
|
194
|
+
const locale = fieldPath.startsWith("attributes.")
|
|
195
|
+
? segments[1]
|
|
196
|
+
: fieldPath.startsWith("customFields.")
|
|
197
|
+
? segments[2]
|
|
198
|
+
: undefined;
|
|
199
|
+
return {
|
|
200
|
+
fieldPath,
|
|
201
|
+
intent: pushCatalogIntent(fieldPath, mapping.target),
|
|
202
|
+
value,
|
|
203
|
+
...(locale !== undefined ? { locale } : {}),
|
|
204
|
+
remoteKey: mapping.remoteKey,
|
|
205
|
+
target: mapping.target,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
function pushCatalogImageRole(value) {
|
|
209
|
+
return pushImageRoles.find((role) => role === value);
|
|
210
|
+
}
|
|
211
|
+
function importedFieldPaths(item) {
|
|
212
|
+
const paths = new Set(["entity.slug"]);
|
|
213
|
+
if (item.status !== undefined)
|
|
214
|
+
paths.add("entity.status");
|
|
215
|
+
for (const key of Object.keys(item.metadata ?? {})) {
|
|
216
|
+
const path = `entity.metadata.${key}`;
|
|
217
|
+
if (isValidFieldPath(path))
|
|
218
|
+
paths.add(path);
|
|
219
|
+
}
|
|
220
|
+
const attributes = item.attributes?.length
|
|
221
|
+
? item.attributes
|
|
222
|
+
: [{ locale: "en", title: item.title, ...(item.description !== undefined ? { description: item.description } : {}) }];
|
|
223
|
+
for (const attribute of attributes) {
|
|
224
|
+
for (const field of attributeFields) {
|
|
225
|
+
if (attribute[field] !== undefined)
|
|
226
|
+
paths.add(`attributes.${attribute.locale}.${field}`);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
const customFields = item.customFields;
|
|
230
|
+
for (const [name, locales] of Object.entries(customFields ?? {})) {
|
|
231
|
+
if (!locales || typeof locales !== "object" || Array.isArray(locales))
|
|
232
|
+
continue;
|
|
233
|
+
for (const locale of Object.keys(locales)) {
|
|
234
|
+
const path = `customFields.${name}.${locale}`;
|
|
235
|
+
if (isValidFieldPath(path))
|
|
236
|
+
paths.add(path);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
for (const image of item.images ?? [])
|
|
240
|
+
paths.add(`media.${image.role}`);
|
|
241
|
+
if (item.options?.length)
|
|
242
|
+
paths.add("options");
|
|
243
|
+
if (item.variants.some((variant) => variant.sku !== undefined))
|
|
244
|
+
paths.add("variants.sku");
|
|
245
|
+
if (item.variants.some((variant) => variant.barcode !== undefined))
|
|
246
|
+
paths.add("variants.barcode");
|
|
247
|
+
for (const currency of item.variants.flatMap((variant) => variant.prices ?? []).map((price) => price.currency)) {
|
|
248
|
+
const path = `prices.${currency}`;
|
|
249
|
+
if (isValidFieldPath(path))
|
|
250
|
+
paths.add(path);
|
|
251
|
+
}
|
|
252
|
+
return [...paths];
|
|
253
|
+
}
|
|
254
|
+
function summarizeValue(value) {
|
|
255
|
+
const serialized = JSON.stringify(value);
|
|
256
|
+
if (serialized === undefined)
|
|
257
|
+
return String(value);
|
|
258
|
+
return serialized.length > 256 ? `${serialized.slice(0, 253)}...` : serialized;
|
|
259
|
+
}
|
|
260
|
+
function uniqueSkipped(skipped) {
|
|
261
|
+
const seen = new Set();
|
|
262
|
+
return skipped.filter((entry) => {
|
|
263
|
+
const key = `${entry.entityId}:${entry.fieldPath}`;
|
|
264
|
+
if (seen.has(key))
|
|
265
|
+
return false;
|
|
266
|
+
seen.add(key);
|
|
267
|
+
return true;
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
function ownerAllows(owners, path) {
|
|
271
|
+
return owners.get(path) !== "platform";
|
|
272
|
+
}
|
|
19
273
|
function stockFailure(line, reason) {
|
|
20
274
|
return `Cannot checkout line "${line.title ?? line.entityId}": ${reason}.`;
|
|
21
275
|
}
|
|
@@ -42,6 +296,8 @@ function redactStore(store) {
|
|
|
42
296
|
credentials: "[REDACTED]",
|
|
43
297
|
storeDomain: store.storeDomain,
|
|
44
298
|
status: store.status,
|
|
299
|
+
catalogWriteEnabled: store.catalogWriteEnabled,
|
|
300
|
+
catalogFieldMapping: store.catalogFieldMapping,
|
|
45
301
|
catalogCursor: store.catalogCursor,
|
|
46
302
|
inventoryCursor: store.inventoryCursor,
|
|
47
303
|
lastSyncAt: store.lastSyncAt,
|
|
@@ -79,6 +335,614 @@ export class ChannelConnectorService {
|
|
|
79
335
|
get catalog() {
|
|
80
336
|
return this.services.catalog;
|
|
81
337
|
}
|
|
338
|
+
get media() {
|
|
339
|
+
return this.services.media;
|
|
340
|
+
}
|
|
341
|
+
get pricing() {
|
|
342
|
+
return this.services.pricing;
|
|
343
|
+
}
|
|
344
|
+
filterOwnedFields(item, owners) {
|
|
345
|
+
return this.filterOwnedFieldsAtPaths(item, owners, importedFieldPaths(item));
|
|
346
|
+
}
|
|
347
|
+
filterOwnedFieldsAtPaths(item, owners, fieldPaths) {
|
|
348
|
+
const populated = new Set(fieldPaths);
|
|
349
|
+
const skipped = fieldPaths.filter((path) => owners.get(path) === "platform");
|
|
350
|
+
const blocked = new Set(skipped);
|
|
351
|
+
const attributes = (item.attributes?.length
|
|
352
|
+
? item.attributes
|
|
353
|
+
: [{ locale: "en", title: item.title, ...(item.description !== undefined ? { description: item.description } : {}) }])
|
|
354
|
+
.flatMap((attribute) => {
|
|
355
|
+
return [{
|
|
356
|
+
locale: attribute.locale,
|
|
357
|
+
title: attribute.title,
|
|
358
|
+
...Object.fromEntries(attributeFields.slice(1)
|
|
359
|
+
.filter((field) => attribute[field] !== undefined
|
|
360
|
+
&& populated.has(`attributes.${attribute.locale}.${field}`)
|
|
361
|
+
&& !blocked.has(`attributes.${attribute.locale}.${field}`))
|
|
362
|
+
.map((field) => [field, attribute[field]])),
|
|
363
|
+
}];
|
|
364
|
+
});
|
|
365
|
+
const writable = {
|
|
366
|
+
...item,
|
|
367
|
+
attributes,
|
|
368
|
+
metadata: Object.fromEntries(Object.entries(item.metadata ?? {}).filter(([key]) => populated.has(`entity.metadata.${key}`) && !blocked.has(`entity.metadata.${key}`))),
|
|
369
|
+
...(item.images !== undefined ? { images: item.images.filter((image) => populated.has(`media.${image.role}`) && !blocked.has(`media.${image.role}`)) } : {}),
|
|
370
|
+
...(item.options !== undefined ? { options: blocked.has("options") ? [] : item.options } : {}),
|
|
371
|
+
variants: item.variants.map((variant) => ({
|
|
372
|
+
externalId: variant.externalId,
|
|
373
|
+
...(variant.sku !== undefined && populated.has("variants.sku") && !blocked.has("variants.sku") ? { sku: variant.sku } : {}),
|
|
374
|
+
...(variant.barcode !== undefined && populated.has("variants.barcode") && !blocked.has("variants.barcode") ? { barcode: variant.barcode } : {}),
|
|
375
|
+
...(variant.metadata !== undefined ? { metadata: variant.metadata } : {}),
|
|
376
|
+
...(variant.optionValues !== undefined && populated.has("options") && !blocked.has("options") ? { optionValues: variant.optionValues } : {}),
|
|
377
|
+
...(variant.prices !== undefined
|
|
378
|
+
? { prices: variant.prices.filter((price) => populated.has(`prices.${price.currency}`) && !blocked.has(`prices.${price.currency}`)) }
|
|
379
|
+
: {}),
|
|
380
|
+
})),
|
|
381
|
+
};
|
|
382
|
+
return { writable, skipped, conflicts: [] };
|
|
383
|
+
}
|
|
384
|
+
filterConflictingFields(item, conflicts) {
|
|
385
|
+
if (conflicts.length === 0)
|
|
386
|
+
return { writable: item, conflicts: [] };
|
|
387
|
+
const blocked = new Set(conflicts);
|
|
388
|
+
const attributes = (item.attributes ?? []).flatMap((attribute) => {
|
|
389
|
+
return [{
|
|
390
|
+
locale: attribute.locale,
|
|
391
|
+
title: attribute.title,
|
|
392
|
+
...Object.fromEntries(attributeFields.slice(1)
|
|
393
|
+
.filter((field) => attribute[field] !== undefined && !blocked.has(`attributes.${attribute.locale}.${field}`))
|
|
394
|
+
.map((field) => [field, attribute[field]])),
|
|
395
|
+
}];
|
|
396
|
+
});
|
|
397
|
+
return {
|
|
398
|
+
writable: {
|
|
399
|
+
...item,
|
|
400
|
+
attributes,
|
|
401
|
+
metadata: Object.fromEntries(Object.entries(item.metadata ?? {}).filter(([key]) => !blocked.has(`entity.metadata.${key}`))),
|
|
402
|
+
...(item.images !== undefined ? { images: item.images.filter((image) => !blocked.has(`media.${image.role}`)) } : {}),
|
|
403
|
+
...(item.options !== undefined ? { options: blocked.has("options") ? [] : item.options } : {}),
|
|
404
|
+
variants: item.variants.map((variant) => ({
|
|
405
|
+
externalId: variant.externalId,
|
|
406
|
+
...(variant.sku !== undefined && !blocked.has("variants.sku") ? { sku: variant.sku } : {}),
|
|
407
|
+
...(variant.barcode !== undefined && !blocked.has("variants.barcode") ? { barcode: variant.barcode } : {}),
|
|
408
|
+
...(variant.metadata !== undefined ? { metadata: variant.metadata } : {}),
|
|
409
|
+
...(variant.optionValues !== undefined && !blocked.has("options") ? { optionValues: variant.optionValues } : {}),
|
|
410
|
+
...(variant.prices !== undefined
|
|
411
|
+
? { prices: variant.prices.filter((price) => !blocked.has(`prices.${price.currency}`)) }
|
|
412
|
+
: {}),
|
|
413
|
+
})),
|
|
414
|
+
},
|
|
415
|
+
conflicts,
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
remoteFieldValue(item, path) {
|
|
419
|
+
const [root, segment, field] = path.split(".");
|
|
420
|
+
if (root === "entity" && segment === "slug")
|
|
421
|
+
return item.slug;
|
|
422
|
+
if (root === "entity" && segment === "status")
|
|
423
|
+
return item.status;
|
|
424
|
+
if (root === "entity" && segment === "metadata")
|
|
425
|
+
return item.metadata?.[field ?? ""];
|
|
426
|
+
if (root === "customFields" && segment && field) {
|
|
427
|
+
const customFields = item.customFields;
|
|
428
|
+
const customField = customFields?.[segment];
|
|
429
|
+
if (customField && typeof customField === "object" && !Array.isArray(customField)) {
|
|
430
|
+
return customField[field];
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
if (root === "attributes" && segment && field) {
|
|
434
|
+
const attributes = item.attributes?.length
|
|
435
|
+
? item.attributes
|
|
436
|
+
: [{ locale: "en", title: item.title, ...(item.description !== undefined ? { description: item.description } : {}) }];
|
|
437
|
+
const attribute = attributes.find((row) => row.locale === segment);
|
|
438
|
+
return attribute?.[field];
|
|
439
|
+
}
|
|
440
|
+
if (root === "media" && segment) {
|
|
441
|
+
return (item.images ?? [])
|
|
442
|
+
.filter((image) => image.role === segment)
|
|
443
|
+
.map((image) => ({ url: image.url, role: image.role }));
|
|
444
|
+
}
|
|
445
|
+
if (path === "options")
|
|
446
|
+
return item.options;
|
|
447
|
+
if (path === "variants.sku")
|
|
448
|
+
return item.variants.map((variant) => variant.sku);
|
|
449
|
+
if (path === "variants.barcode")
|
|
450
|
+
return item.variants.map((variant) => variant.barcode);
|
|
451
|
+
if (root === "prices" && segment)
|
|
452
|
+
return item.variants.flatMap((variant) => variant.prices ?? []).filter((price) => price.currency === segment);
|
|
453
|
+
return undefined;
|
|
454
|
+
}
|
|
455
|
+
isOutboundEcho(mapping, item) {
|
|
456
|
+
if (!mapping.outboundHash || !mapping.outboundPushedAt || mapping.outboundFieldPaths.length === 0)
|
|
457
|
+
return false;
|
|
458
|
+
const age = Date.now() - mapping.outboundPushedAt.getTime();
|
|
459
|
+
if (age < 0 || age > CATALOG_OUTBOUND_SUPPRESSION_WINDOW_MS)
|
|
460
|
+
return false;
|
|
461
|
+
const inboundHash = canonicalInboundHash(mapping.externalId, mapping.outboundFieldPaths, (fieldPath) => this.remoteFieldValue(item, fieldPath));
|
|
462
|
+
return inboundHash === mapping.outboundHash;
|
|
463
|
+
}
|
|
464
|
+
async localFieldValue(entityId, entity, path) {
|
|
465
|
+
const [root, segment, field] = path.split(".");
|
|
466
|
+
if (root === "entity" && segment === "slug")
|
|
467
|
+
return entity.slug;
|
|
468
|
+
if (root === "entity" && segment === "status")
|
|
469
|
+
return entity.status;
|
|
470
|
+
if (root === "entity" && segment === "metadata")
|
|
471
|
+
return entity.metadata?.[field ?? ""];
|
|
472
|
+
if (root === "attributes" && segment && field) {
|
|
473
|
+
const [attribute] = await this.db.select().from(sellableAttributes).where(and(eq(sellableAttributes.entityId, entityId), eq(sellableAttributes.locale, segment)));
|
|
474
|
+
const values = attribute
|
|
475
|
+
? {
|
|
476
|
+
title: attribute.title,
|
|
477
|
+
subtitle: attribute.subtitle,
|
|
478
|
+
description: attribute.description,
|
|
479
|
+
richDescription: attribute.richDescription,
|
|
480
|
+
seoTitle: attribute.seoTitle,
|
|
481
|
+
seoDescription: attribute.seoDescription,
|
|
482
|
+
}
|
|
483
|
+
: {};
|
|
484
|
+
return values[field];
|
|
485
|
+
}
|
|
486
|
+
if (root === "customFields" && segment && field) {
|
|
487
|
+
const [customField] = await this.db.select().from(sellableCustomFields).where(and(eq(sellableCustomFields.entityId, entityId), eq(sellableCustomFields.fieldName, segment), eq(sellableCustomFields.locale, field), eq(sellableCustomFields.status, "approved")));
|
|
488
|
+
return customField ? customFieldValue(customField) : undefined;
|
|
489
|
+
}
|
|
490
|
+
if (root === "media" && segment) {
|
|
491
|
+
const links = await this.db.select({ id: entityMedia.mediaAssetId }).from(entityMedia).where(and(eq(entityMedia.entityId, entityId), eq(entityMedia.role, segment)));
|
|
492
|
+
return links.map((link) => link.id);
|
|
493
|
+
}
|
|
494
|
+
if (path === "options") {
|
|
495
|
+
const types = await this.db.select().from(optionTypes).where(eq(optionTypes.entityId, entityId));
|
|
496
|
+
const values = await Promise.all(types.map(async (type) => ({
|
|
497
|
+
name: type.name,
|
|
498
|
+
values: await this.db.select().from(optionValues).where(eq(optionValues.optionTypeId, type.id)),
|
|
499
|
+
})));
|
|
500
|
+
return values;
|
|
501
|
+
}
|
|
502
|
+
if (path === "variants.sku" || path === "variants.barcode") {
|
|
503
|
+
const rows = await this.db.select().from(variants).where(eq(variants.entityId, entityId));
|
|
504
|
+
return rows.map((variant) => path === "variants.sku" ? variant.sku : variant.barcode);
|
|
505
|
+
}
|
|
506
|
+
if (root === "prices" && segment) {
|
|
507
|
+
const rows = await this.db.select().from(prices).where(and(eq(prices.entityId, entityId), eq(prices.currency, segment)));
|
|
508
|
+
return rows.map((price) => ({ amount: price.amount, compareAtAmount: price.compareAtAmount }));
|
|
509
|
+
}
|
|
510
|
+
return undefined;
|
|
511
|
+
}
|
|
512
|
+
async lastSyncedSnapshot(entityId, lastSyncedAt) {
|
|
513
|
+
const [revision] = await this.db.select({ snapshot: sellableEntityRevisions.snapshot }).from(sellableEntityRevisions).where(and(eq(sellableEntityRevisions.entityId, entityId), lte(sellableEntityRevisions.createdAt, lastSyncedAt))).orderBy(desc(sellableEntityRevisions.createdAt)).limit(1);
|
|
514
|
+
return revision?.snapshot;
|
|
515
|
+
}
|
|
516
|
+
async detectSharedConflicts(entityId, storeId, entity, mapping, item, owners, fieldPaths = importedFieldPaths(item), remoteHash = hash(item), echo) {
|
|
517
|
+
if (!mapping || mapping.syncHash === remoteHash)
|
|
518
|
+
return { paths: [], conflicts: [] };
|
|
519
|
+
const revisions = await this.catalog.repository.findRevisionMarkers(entityId, mapping.lastSyncedAt);
|
|
520
|
+
const localChanged = revisions.some((revision) => revision.reason !== "import");
|
|
521
|
+
const paths = fieldPaths.filter((path) => owners.get(path) === "shared");
|
|
522
|
+
const openRows = await this.db.select({
|
|
523
|
+
fieldPath: channelCatalogConflicts.fieldPath,
|
|
524
|
+
storeValue: channelCatalogConflicts.storeValue,
|
|
525
|
+
}).from(channelCatalogConflicts).where(and(eq(channelCatalogConflicts.storeId, storeId), eq(channelCatalogConflicts.entityId, entityId), eq(channelCatalogConflicts.state, "open")));
|
|
526
|
+
if (!localChanged && openRows.length === 0)
|
|
527
|
+
return { paths: [], conflicts: [] };
|
|
528
|
+
const openByPath = new Map(openRows.map((row) => [row.fieldPath, row.storeValue]));
|
|
529
|
+
const baseline = await this.lastSyncedSnapshot(entityId, mapping.lastSyncedAt);
|
|
530
|
+
const changed = [];
|
|
531
|
+
for (const path of paths) {
|
|
532
|
+
const localValue = await this.localFieldValue(entityId, entity, path);
|
|
533
|
+
const remoteValue = this.remoteFieldValue(item, path);
|
|
534
|
+
let diverged = false;
|
|
535
|
+
if (echo) {
|
|
536
|
+
// The outbound hash certifies only the pushed paths; a shared path
|
|
537
|
+
// outside that set carrying a genuinely different remote value is a
|
|
538
|
+
// real store edit even inside an echo payload.
|
|
539
|
+
diverged = !echo.certifiedPaths.has(path) && !normalizedValuesEqual(remoteValue, localValue);
|
|
540
|
+
}
|
|
541
|
+
else if (openByPath.has(path)) {
|
|
542
|
+
diverged = !normalizedValuesEqual(remoteValue, openByPath.get(path));
|
|
543
|
+
}
|
|
544
|
+
else if (baseline) {
|
|
545
|
+
const baselineValue = snapshotFieldValue(baseline, path);
|
|
546
|
+
diverged = baselineValue.found
|
|
547
|
+
&& !normalizedValuesEqual(localValue, baselineValue.value)
|
|
548
|
+
&& !normalizedValuesEqual(remoteValue, baselineValue.value)
|
|
549
|
+
&& !normalizedValuesEqual(localValue, remoteValue);
|
|
550
|
+
}
|
|
551
|
+
else {
|
|
552
|
+
diverged = !normalizedValuesEqual(remoteValue, localValue);
|
|
553
|
+
}
|
|
554
|
+
if (diverged)
|
|
555
|
+
changed.push(path);
|
|
556
|
+
}
|
|
557
|
+
const conflicts = await Promise.all(changed.map(async (fieldPath) => {
|
|
558
|
+
const platformValue = await this.localFieldValue(entityId, entity, fieldPath);
|
|
559
|
+
const storeValue = this.remoteFieldValue(item, fieldPath);
|
|
560
|
+
return {
|
|
561
|
+
entityId,
|
|
562
|
+
storeId,
|
|
563
|
+
fieldPath,
|
|
564
|
+
platformValue: platformValue === undefined ? null : platformValue,
|
|
565
|
+
storeValue: storeValue === undefined ? null : storeValue,
|
|
566
|
+
localValueSummary: summarizeValue(platformValue),
|
|
567
|
+
remoteValueSummary: summarizeValue(storeValue),
|
|
568
|
+
};
|
|
569
|
+
}));
|
|
570
|
+
return { paths: changed, conflicts };
|
|
571
|
+
}
|
|
572
|
+
async persistCatalogConflicts(orgId, conflicts, changedBy) {
|
|
573
|
+
for (const conflict of conflicts) {
|
|
574
|
+
const [inserted] = await this.db.insert(channelCatalogConflicts).values({
|
|
575
|
+
organizationId: orgId,
|
|
576
|
+
storeId: conflict.storeId,
|
|
577
|
+
entityId: conflict.entityId,
|
|
578
|
+
fieldPath: conflict.fieldPath,
|
|
579
|
+
platformValue: conflict.platformValue,
|
|
580
|
+
storeValue: conflict.storeValue,
|
|
581
|
+
}).onConflictDoNothing().returning();
|
|
582
|
+
if (!inserted) {
|
|
583
|
+
const [existing] = await this.db.select({
|
|
584
|
+
id: channelCatalogConflicts.id,
|
|
585
|
+
storeValue: channelCatalogConflicts.storeValue,
|
|
586
|
+
platformValue: channelCatalogConflicts.platformValue,
|
|
587
|
+
}).from(channelCatalogConflicts).where(and(eq(channelCatalogConflicts.organizationId, orgId), eq(channelCatalogConflicts.storeId, conflict.storeId), eq(channelCatalogConflicts.entityId, conflict.entityId), eq(channelCatalogConflicts.fieldPath, conflict.fieldPath), eq(channelCatalogConflicts.state, "open")));
|
|
588
|
+
const storeMoved = existing !== undefined && !normalizedValuesEqual(existing.storeValue, conflict.storeValue);
|
|
589
|
+
const platformMoved = existing !== undefined && !normalizedValuesEqual(existing.platformValue, conflict.platformValue);
|
|
590
|
+
if (existing && (storeMoved || platformMoved)) {
|
|
591
|
+
await this.db.update(channelCatalogConflicts).set({
|
|
592
|
+
storeValue: conflict.storeValue,
|
|
593
|
+
platformValue: conflict.platformValue,
|
|
594
|
+
updatedAt: new Date(),
|
|
595
|
+
}).where(eq(channelCatalogConflicts.id, existing.id));
|
|
596
|
+
}
|
|
597
|
+
continue;
|
|
598
|
+
}
|
|
599
|
+
await this.db.insert(channelCatalogConflictEvents).values({
|
|
600
|
+
organizationId: orgId,
|
|
601
|
+
conflictId: inserted.id,
|
|
602
|
+
fromState: null,
|
|
603
|
+
toState: "open",
|
|
604
|
+
reason: "Shared catalog field changed on both sides.",
|
|
605
|
+
changedBy,
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
return Ok(undefined);
|
|
609
|
+
}
|
|
610
|
+
async setCatalogAttributes(entityId, item, actor, blockedPaths = new Set(), catalogCtx) {
|
|
611
|
+
const attributes = item.attributes ?? [{
|
|
612
|
+
locale: "en",
|
|
613
|
+
title: item.title,
|
|
614
|
+
...(item.description !== undefined ? { description: item.description } : {}),
|
|
615
|
+
}];
|
|
616
|
+
const existing = await this.db.select().from(sellableAttributes).where(eq(sellableAttributes.entityId, entityId));
|
|
617
|
+
let created = 0;
|
|
618
|
+
let changed = false;
|
|
619
|
+
for (const attribute of attributes) {
|
|
620
|
+
const current = existing.find((row) => row.locale === attribute.locale);
|
|
621
|
+
const titlePath = `attributes.${attribute.locale}.title`;
|
|
622
|
+
if (!current && blockedPaths.has(titlePath))
|
|
623
|
+
continue;
|
|
624
|
+
const title = blockedPaths.has(titlePath) ? current?.title : attribute.title;
|
|
625
|
+
if (title === undefined)
|
|
626
|
+
continue;
|
|
627
|
+
const writeAttribute = {
|
|
628
|
+
title,
|
|
629
|
+
...(attribute.subtitle !== undefined && !blockedPaths.has(`attributes.${attribute.locale}.subtitle`) ? { subtitle: attribute.subtitle } : current?.subtitle != null ? { subtitle: current.subtitle } : {}),
|
|
630
|
+
...(attribute.description !== undefined && !blockedPaths.has(`attributes.${attribute.locale}.description`) ? { description: attribute.description } : current?.description != null ? { description: current.description } : {}),
|
|
631
|
+
...(attribute.richDescription !== undefined && !blockedPaths.has(`attributes.${attribute.locale}.richDescription`) ? { richDescription: attribute.richDescription } : current?.richDescription != null ? { richDescription: current.richDescription } : {}),
|
|
632
|
+
...(attribute.seoTitle !== undefined && !blockedPaths.has(`attributes.${attribute.locale}.seoTitle`) ? { seoTitle: attribute.seoTitle } : current?.seoTitle != null ? { seoTitle: current.seoTitle } : {}),
|
|
633
|
+
...(attribute.seoDescription !== undefined && !blockedPaths.has(`attributes.${attribute.locale}.seoDescription`) ? { seoDescription: attribute.seoDescription } : current?.seoDescription != null ? { seoDescription: current.seoDescription } : {}),
|
|
634
|
+
};
|
|
635
|
+
if (!current) {
|
|
636
|
+
created += 1;
|
|
637
|
+
changed = true;
|
|
638
|
+
}
|
|
639
|
+
else if (attributeFields.some((field) => (current[field] == null ? null : current[field]) !== (writeAttribute[field] == null ? null : writeAttribute[field]))) {
|
|
640
|
+
changed = true;
|
|
641
|
+
}
|
|
642
|
+
const result = await this.catalog.setAttributes(entityId, attribute.locale, writeAttribute, actor, catalogCtx);
|
|
643
|
+
if (!result.ok)
|
|
644
|
+
return PluginErr(result.error.message);
|
|
645
|
+
}
|
|
646
|
+
return Ok({ created, changed });
|
|
647
|
+
}
|
|
648
|
+
async setCatalogAttributesIfWritable(entityId, item, actor, blockedPaths, catalogCtx) {
|
|
649
|
+
const attributes = item.attributes ?? [{
|
|
650
|
+
locale: "en",
|
|
651
|
+
title: item.title,
|
|
652
|
+
...(item.description !== undefined ? { description: item.description } : {}),
|
|
653
|
+
}];
|
|
654
|
+
const writable = attributes.some((attribute) => attributeFields.some((field) => (attribute[field] !== undefined && !blockedPaths.has(`attributes.${attribute.locale}.${field}`))));
|
|
655
|
+
if (!writable)
|
|
656
|
+
return Ok({ created: 0, changed: false });
|
|
657
|
+
return this.setCatalogAttributes(entityId, item, actor, blockedPaths, catalogCtx);
|
|
658
|
+
}
|
|
659
|
+
async upsertOptionAxes(entityId, item, actor) {
|
|
660
|
+
const optionValueIds = new Map();
|
|
661
|
+
const existingTypes = await this.db.select().from(optionTypes).where(eq(optionTypes.entityId, entityId));
|
|
662
|
+
let changed = false;
|
|
663
|
+
for (const [typeIndex, sourceType] of (item.options ?? []).entries()) {
|
|
664
|
+
let optionType = existingTypes.find((row) => row.name === sourceType.name);
|
|
665
|
+
if (!optionType) {
|
|
666
|
+
const created = await this.catalog.createOptionType({ entityId, name: sourceType.name, values: [] }, actor);
|
|
667
|
+
if (!created.ok)
|
|
668
|
+
return PluginErr(created.error.message);
|
|
669
|
+
const [createdType] = await this.db.select().from(optionTypes).where(eq(optionTypes.id, created.value.id));
|
|
670
|
+
if (!createdType)
|
|
671
|
+
return PluginErr(`Option type "${sourceType.name}" was not persisted.`);
|
|
672
|
+
optionType = createdType;
|
|
673
|
+
existingTypes.push(optionType);
|
|
674
|
+
changed = true;
|
|
675
|
+
}
|
|
676
|
+
await this.db.update(optionTypes).set({
|
|
677
|
+
displayName: sourceType.displayName,
|
|
678
|
+
sortOrder: sourceType.sortOrder ?? typeIndex,
|
|
679
|
+
}).where(eq(optionTypes.id, optionType.id));
|
|
680
|
+
const existingValues = await this.db.select().from(optionValues).where(eq(optionValues.optionTypeId, optionType.id));
|
|
681
|
+
const valueIds = new Map();
|
|
682
|
+
for (const [valueIndex, sourceValue] of sourceType.values.entries()) {
|
|
683
|
+
let optionValue = existingValues.find((row) => row.value === sourceValue.value);
|
|
684
|
+
if (!optionValue) {
|
|
685
|
+
const created = await this.catalog.createOptionValue({ optionTypeId: optionType.id, value: sourceValue.value }, actor);
|
|
686
|
+
if (!created.ok)
|
|
687
|
+
return PluginErr(created.error.message);
|
|
688
|
+
const [createdValue] = await this.db.select().from(optionValues).where(eq(optionValues.id, created.value.id));
|
|
689
|
+
if (!createdValue)
|
|
690
|
+
return PluginErr(`Option value "${sourceValue.value}" was not persisted.`);
|
|
691
|
+
optionValue = createdValue;
|
|
692
|
+
existingValues.push(optionValue);
|
|
693
|
+
changed = true;
|
|
694
|
+
}
|
|
695
|
+
await this.db.update(optionValues).set({
|
|
696
|
+
displayValue: sourceValue.displayValue,
|
|
697
|
+
sortOrder: sourceValue.sortOrder ?? valueIndex,
|
|
698
|
+
}).where(eq(optionValues.id, optionValue.id));
|
|
699
|
+
valueIds.set(sourceValue.value, optionValue.id);
|
|
700
|
+
}
|
|
701
|
+
optionValueIds.set(sourceType.name, valueIds);
|
|
702
|
+
}
|
|
703
|
+
return Ok({ value: optionValueIds, changed });
|
|
704
|
+
}
|
|
705
|
+
async upsertVariants(orgId, storeId, entityId, item, optionValueIds, actor, warnings, applyOptionValues, fullItem) {
|
|
706
|
+
const variantIds = new Map();
|
|
707
|
+
let repaired = 0;
|
|
708
|
+
let changed = false;
|
|
709
|
+
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)));
|
|
710
|
+
for (const sourceVariant of item.variants) {
|
|
711
|
+
const fullSourceVariant = fullItem.variants.find((variant) => variant.externalId === sourceVariant.externalId) ?? sourceVariant;
|
|
712
|
+
let mapping = mappings.find((row) => row.externalId === sourceVariant.externalId);
|
|
713
|
+
let variantId = mapping?.variantId;
|
|
714
|
+
const createdVariant = !variantId;
|
|
715
|
+
if (!variantId) {
|
|
716
|
+
const options = {};
|
|
717
|
+
for (const [name, value] of Object.entries(sourceVariant.optionValues ?? {})) {
|
|
718
|
+
const optionValueId = optionValueIds.get(name)?.get(value);
|
|
719
|
+
if (!optionValueId) {
|
|
720
|
+
warnings.push(`Skipped unmapped option "${name}=${value}" on variant "${sourceVariant.externalId}".`);
|
|
721
|
+
continue;
|
|
722
|
+
}
|
|
723
|
+
options[name] = value;
|
|
724
|
+
}
|
|
725
|
+
const created = await this.catalog.createVariant({
|
|
726
|
+
entityId,
|
|
727
|
+
options,
|
|
728
|
+
...(sourceVariant.sku !== undefined ? { sku: sourceVariant.sku } : {}),
|
|
729
|
+
...(sourceVariant.barcode !== undefined ? { barcode: sourceVariant.barcode } : {}),
|
|
730
|
+
}, actor);
|
|
731
|
+
if (!created.ok)
|
|
732
|
+
return PluginErr(created.error.message);
|
|
733
|
+
variantId = created.value.id;
|
|
734
|
+
const [createdMapping] = await this.db.insert(channelEntityMap).values({
|
|
735
|
+
organizationId: orgId,
|
|
736
|
+
storeId,
|
|
737
|
+
kind: "variant",
|
|
738
|
+
externalId: sourceVariant.externalId,
|
|
739
|
+
entityId,
|
|
740
|
+
variantId,
|
|
741
|
+
syncHash: hash(fullSourceVariant),
|
|
742
|
+
}).returning();
|
|
743
|
+
mapping = createdMapping;
|
|
744
|
+
if (mapping)
|
|
745
|
+
mappings.push(mapping);
|
|
746
|
+
}
|
|
747
|
+
if (!variantId) {
|
|
748
|
+
warnings.push(`Skipped variant "${sourceVariant.externalId}": no local variant mapping exists.`);
|
|
749
|
+
continue;
|
|
750
|
+
}
|
|
751
|
+
variantIds.set(sourceVariant.externalId, variantId);
|
|
752
|
+
if (applyOptionValues) {
|
|
753
|
+
const desiredOptionValueIds = Object.entries(sourceVariant.optionValues ?? {})
|
|
754
|
+
.map(([name, value]) => optionValueIds.get(name)?.get(value))
|
|
755
|
+
.filter((optionValueId) => optionValueId !== undefined);
|
|
756
|
+
const currentOptionValues = await this.db.select().from(variantOptionValues).where(eq(variantOptionValues.variantId, variantId));
|
|
757
|
+
const currentIds = currentOptionValues.map((row) => row.optionValueId).sort();
|
|
758
|
+
const desiredIds = [...new Set(desiredOptionValueIds)].sort();
|
|
759
|
+
if (currentIds.length !== desiredIds.length || currentIds.some((id, index) => id !== desiredIds[index])) {
|
|
760
|
+
await this.db.delete(variantOptionValues).where(eq(variantOptionValues.variantId, variantId));
|
|
761
|
+
if (desiredIds.length > 0) {
|
|
762
|
+
await this.db.insert(variantOptionValues).values(desiredIds.map((optionValueId) => ({ variantId, optionValueId }))).onConflictDoNothing();
|
|
763
|
+
repaired += 1;
|
|
764
|
+
}
|
|
765
|
+
changed = true;
|
|
766
|
+
}
|
|
767
|
+
if (createdVariant && desiredIds.length > 0) {
|
|
768
|
+
repaired += 1;
|
|
769
|
+
changed = true;
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
for (const price of sourceVariant.prices ?? []) {
|
|
773
|
+
const priced = await this.pricing.setBasePrice({
|
|
774
|
+
entityId,
|
|
775
|
+
variantId,
|
|
776
|
+
currency: price.currency,
|
|
777
|
+
amount: price.amount,
|
|
778
|
+
compareAtAmount: price.compareAtAmount ?? null,
|
|
779
|
+
}, actor);
|
|
780
|
+
if (!priced.ok)
|
|
781
|
+
return PluginErr(priced.error.message);
|
|
782
|
+
}
|
|
783
|
+
if (mapping) {
|
|
784
|
+
await this.db.update(channelEntityMap).set({
|
|
785
|
+
syncHash: hash(fullSourceVariant),
|
|
786
|
+
}).where(eq(channelEntityMap.id, mapping.id));
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
return Ok({ value: variantIds, repaired, changed });
|
|
790
|
+
}
|
|
791
|
+
async applyTaxonomy(orgId, entityId, item, actor, warnings) {
|
|
792
|
+
const categoryRows = await this.db.select().from(categories).where(eq(categories.organizationId, orgId));
|
|
793
|
+
for (const slug of new Set(item.categories ?? [])) {
|
|
794
|
+
let category = categoryRows.find((row) => row.slug === slug);
|
|
795
|
+
if (category?.status === "archived") {
|
|
796
|
+
warnings.push(`Skipped archived category "${slug}".`);
|
|
797
|
+
continue;
|
|
798
|
+
}
|
|
799
|
+
if (!category) {
|
|
800
|
+
const created = await this.catalog.createCategory({ slug }, actor);
|
|
801
|
+
if (!created.ok)
|
|
802
|
+
return PluginErr(created.error.message);
|
|
803
|
+
const [createdCategory] = await this.db.select().from(categories).where(eq(categories.id, created.value.id));
|
|
804
|
+
if (!createdCategory)
|
|
805
|
+
return PluginErr(`Category "${slug}" was not persisted.`);
|
|
806
|
+
category = createdCategory;
|
|
807
|
+
categoryRows.push(category);
|
|
808
|
+
}
|
|
809
|
+
const linked = await this.catalog.addToCategory(entityId, category.id, actor);
|
|
810
|
+
if (!linked.ok)
|
|
811
|
+
return PluginErr(linked.error.message);
|
|
812
|
+
}
|
|
813
|
+
const brandRows = await this.db.select().from(brands).where(eq(brands.organizationId, orgId));
|
|
814
|
+
if (item.brand) {
|
|
815
|
+
let brand = brandRows.find((row) => row.slug === item.brand);
|
|
816
|
+
if (!brand) {
|
|
817
|
+
const created = await this.catalog.createBrand({ slug: item.brand, displayName: item.brand }, actor);
|
|
818
|
+
if (!created.ok)
|
|
819
|
+
return PluginErr(created.error.message);
|
|
820
|
+
const [createdBrand] = await this.db.select().from(brands).where(eq(brands.id, created.value.id));
|
|
821
|
+
if (!createdBrand)
|
|
822
|
+
return PluginErr(`Brand "${item.brand}" was not persisted.`);
|
|
823
|
+
brand = createdBrand;
|
|
824
|
+
brandRows.push(brand);
|
|
825
|
+
}
|
|
826
|
+
const linked = await this.catalog.addToBrand(entityId, brand.id, actor);
|
|
827
|
+
if (!linked.ok)
|
|
828
|
+
return PluginErr(linked.error.message);
|
|
829
|
+
}
|
|
830
|
+
const tagRows = await this.db.select().from(tags).where(eq(tags.organizationId, orgId));
|
|
831
|
+
for (const slug of new Set(item.tags ?? [])) {
|
|
832
|
+
let tag = tagRows.find((row) => row.slug === slug);
|
|
833
|
+
if (!tag) {
|
|
834
|
+
const [createdTag] = await this.db.insert(tags).values({ organizationId: orgId, slug, displayName: slug }).onConflictDoNothing().returning();
|
|
835
|
+
tag = createdTag ?? (await this.db.select().from(tags).where(and(eq(tags.organizationId, orgId), eq(tags.slug, slug))))[0];
|
|
836
|
+
if (!tag)
|
|
837
|
+
return PluginErr(`Tag "${slug}" was not persisted.`);
|
|
838
|
+
tagRows.push(tag);
|
|
839
|
+
}
|
|
840
|
+
await this.db.insert(entityTags).values({ entityId, tagId: tag.id }).onConflictDoNothing();
|
|
841
|
+
}
|
|
842
|
+
return Ok(undefined);
|
|
843
|
+
}
|
|
844
|
+
async applyMedia(orgId, entityId, item, variantIds, actor, warnings, owners) {
|
|
845
|
+
const assets = await this.db.select().from(mediaAssets).where(eq(mediaAssets.organizationId, orgId));
|
|
846
|
+
const links = await this.db.select().from(entityMedia).where(eq(entityMedia.entityId, entityId));
|
|
847
|
+
let imported = 0;
|
|
848
|
+
let changed = false;
|
|
849
|
+
const skipped = [];
|
|
850
|
+
for (const image of item.images ?? []) {
|
|
851
|
+
const urlHash = hash(image.url);
|
|
852
|
+
const asset = assets.find((row) => {
|
|
853
|
+
const metadata = row.metadata ?? {};
|
|
854
|
+
return (image.externalId != null && metadata.channelImageExternalId === image.externalId)
|
|
855
|
+
|| metadata.channelImageUrlHash === urlHash;
|
|
856
|
+
});
|
|
857
|
+
let mediaAssetId = asset?.id;
|
|
858
|
+
if (!mediaAssetId) {
|
|
859
|
+
let response;
|
|
860
|
+
try {
|
|
861
|
+
response = await fetch(image.url);
|
|
862
|
+
}
|
|
863
|
+
catch (error) {
|
|
864
|
+
warnings.push(`Skipped image "${image.externalId ?? image.url}": ${error instanceof Error ? error.message : "download failed"}.`);
|
|
865
|
+
continue;
|
|
866
|
+
}
|
|
867
|
+
if (!response.ok) {
|
|
868
|
+
warnings.push(`Skipped image "${image.externalId ?? image.url}": download returned ${response.status}.`);
|
|
869
|
+
continue;
|
|
870
|
+
}
|
|
871
|
+
const contentType = response.headers.get("content-type")?.split(";", 1)[0] ?? "image/jpeg";
|
|
872
|
+
const extension = contentType.split("/", 2)[1] ?? "jpg";
|
|
873
|
+
const uploaded = await this.media.upload({
|
|
874
|
+
filename: `${image.externalId ?? urlHash}.${extension}`,
|
|
875
|
+
contentType,
|
|
876
|
+
data: await response.arrayBuffer(),
|
|
877
|
+
...(image.alt !== undefined ? { alt: image.alt } : {}),
|
|
878
|
+
metadata: {
|
|
879
|
+
channelImageUrlHash: urlHash,
|
|
880
|
+
...(image.externalId !== undefined ? { channelImageExternalId: image.externalId } : {}),
|
|
881
|
+
},
|
|
882
|
+
origin: "imported",
|
|
883
|
+
}, actor);
|
|
884
|
+
if (!uploaded.ok) {
|
|
885
|
+
warnings.push(`Skipped image "${image.externalId ?? image.url}": ${uploaded.error.code === "STORAGE_NOT_SUPPORTED" ? "storage adapter is not configured" : uploaded.error.message}.`);
|
|
886
|
+
continue;
|
|
887
|
+
}
|
|
888
|
+
mediaAssetId = uploaded.value.id;
|
|
889
|
+
imported += 1;
|
|
890
|
+
changed = true;
|
|
891
|
+
const [createdAsset] = await this.db.select().from(mediaAssets).where(eq(mediaAssets.id, mediaAssetId));
|
|
892
|
+
if (createdAsset)
|
|
893
|
+
assets.push(createdAsset);
|
|
894
|
+
}
|
|
895
|
+
if (!mediaAssetId)
|
|
896
|
+
continue;
|
|
897
|
+
const targets = image.variantExternalIds?.length
|
|
898
|
+
? image.variantExternalIds.map((externalId) => ({ externalId, variantId: variantIds.get(externalId) }))
|
|
899
|
+
: [{ externalId: undefined, variantId: undefined }];
|
|
900
|
+
for (const target of targets) {
|
|
901
|
+
if (image.variantExternalIds?.length && !target.variantId) {
|
|
902
|
+
warnings.push(`Skipped image "${image.externalId ?? image.url}" for unmapped variant "${target.externalId}".`);
|
|
903
|
+
continue;
|
|
904
|
+
}
|
|
905
|
+
const existingLink = links.find((link) => link.mediaAssetId === mediaAssetId
|
|
906
|
+
&& (target.variantId === undefined ? link.variantId === null : link.variantId === target.variantId));
|
|
907
|
+
if (existingLink) {
|
|
908
|
+
if (existingLink.role !== image.role) {
|
|
909
|
+
const currentRolePath = `media.${existingLink.role}`;
|
|
910
|
+
const incomingRolePath = `media.${image.role}`;
|
|
911
|
+
for (const path of [currentRolePath, incomingRolePath]) {
|
|
912
|
+
if (owners.get(path) === "platform" && !skipped.includes(path))
|
|
913
|
+
skipped.push(path);
|
|
914
|
+
}
|
|
915
|
+
if (skipped.includes(currentRolePath) || skipped.includes(incomingRolePath))
|
|
916
|
+
continue;
|
|
917
|
+
}
|
|
918
|
+
if (existingLink.role !== image.role || existingLink.sortOrder !== (image.sortOrder ?? 0)) {
|
|
919
|
+
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)));
|
|
920
|
+
changed = true;
|
|
921
|
+
}
|
|
922
|
+
continue;
|
|
923
|
+
}
|
|
924
|
+
const attached = await this.media.attachToEntity({
|
|
925
|
+
entityId,
|
|
926
|
+
mediaAssetId,
|
|
927
|
+
role: image.role,
|
|
928
|
+
sortOrder: image.sortOrder ?? 0,
|
|
929
|
+
...(target.variantId !== undefined ? { variantId: target.variantId } : {}),
|
|
930
|
+
}, actor);
|
|
931
|
+
if (!attached.ok)
|
|
932
|
+
return PluginErr(attached.error.message);
|
|
933
|
+
changed = true;
|
|
934
|
+
links.push({
|
|
935
|
+
entityId,
|
|
936
|
+
mediaAssetId,
|
|
937
|
+
role: image.role,
|
|
938
|
+
sortOrder: image.sortOrder ?? 0,
|
|
939
|
+
variantId: target.variantId ?? null,
|
|
940
|
+
createdAt: new Date(),
|
|
941
|
+
});
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
return Ok({ imported, changed, skipped });
|
|
945
|
+
}
|
|
82
946
|
async getStoreRecord(orgId, id) {
|
|
83
947
|
const rows = await this.db
|
|
84
948
|
.select()
|
|
@@ -102,20 +966,458 @@ export class ChannelConnectorService {
|
|
|
102
966
|
.where(eq(connectedStores.storeDomain, shopDomain));
|
|
103
967
|
return rows;
|
|
104
968
|
}
|
|
969
|
+
resolveCatalogFieldMapping(store, filterableCustomFields, warnings = []) {
|
|
970
|
+
return mergeCatalogFieldMapping(store.provider, store.catalogFieldMapping, filterableCustomFields, warnings);
|
|
971
|
+
}
|
|
972
|
+
async buildCatalogPushItems(orgId, storeId, entityIds, options = {}) {
|
|
973
|
+
const store = await this.getStoreRecord(orgId, storeId);
|
|
974
|
+
if (!store || store.status !== "connected")
|
|
975
|
+
return PluginErr("Connected store not found.", "NOT_FOUND");
|
|
976
|
+
if (!store.catalogWriteEnabled)
|
|
977
|
+
return PluginErr("Catalog writes are disabled for this store.", "CATALOG_WRITE_DISABLED");
|
|
978
|
+
if (entityIds.length === 0)
|
|
979
|
+
return Ok({ items: [], skipped: [], warnings: [] });
|
|
980
|
+
const entities = await this.db.select().from(sellableEntities).where(and(eq(sellableEntities.organizationId, orgId), inArray(sellableEntities.id, entityIds)));
|
|
981
|
+
const entityById = new Map(entities.map((entity) => [entity.id, entity]));
|
|
982
|
+
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)));
|
|
983
|
+
const mappingByEntity = new Map(mappings.map((mapping) => [mapping.entityId, mapping]));
|
|
984
|
+
const items = [];
|
|
985
|
+
const skipped = [];
|
|
986
|
+
const warnings = [];
|
|
987
|
+
const revisionEntityIds = [];
|
|
988
|
+
for (const entityId of entityIds) {
|
|
989
|
+
const entity = entityById.get(entityId);
|
|
990
|
+
if (!entity)
|
|
991
|
+
return PluginErr("Catalog entity not found.", "NOT_FOUND");
|
|
992
|
+
if (entity.status !== "active") {
|
|
993
|
+
skipped.push({ entityId, fieldPath: "entity.status", reason: "entity_not_active" });
|
|
994
|
+
continue;
|
|
995
|
+
}
|
|
996
|
+
const entityMapping = mappingByEntity.get(entity.id);
|
|
997
|
+
if (!entityMapping) {
|
|
998
|
+
skipped.push({ entityId, fieldPath: "entity", reason: "unmapped_entity" });
|
|
999
|
+
continue;
|
|
1000
|
+
}
|
|
1001
|
+
const owners = await this.catalog.resolveFieldOwners(entity.id, storeId);
|
|
1002
|
+
const attributes = await this.db.select().from(sellableAttributes).where(eq(sellableAttributes.entityId, entity.id));
|
|
1003
|
+
const customFields = await this.db.select().from(sellableCustomFields).where(and(eq(sellableCustomFields.entityId, entity.id), eq(sellableCustomFields.status, "approved")));
|
|
1004
|
+
const customFieldNames = [...new Set(customFields.map((field) => field.fieldName))];
|
|
1005
|
+
const definitions = customFieldNames.length > 0
|
|
1006
|
+
? 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)))
|
|
1007
|
+
: [];
|
|
1008
|
+
const filterableCustomFields = Object.fromEntries(definitions.map((definition) => [
|
|
1009
|
+
`customFields.${definition.name}.en`,
|
|
1010
|
+
definition.filterable,
|
|
1011
|
+
]));
|
|
1012
|
+
for (const field of customFields) {
|
|
1013
|
+
filterableCustomFields[`customFields.${field.fieldName}.${field.locale}`] = definitions.find((definition) => definition.name === field.fieldName)?.filterable ?? false;
|
|
1014
|
+
}
|
|
1015
|
+
const fieldMapping = this.resolveCatalogFieldMapping(store, filterableCustomFields, warnings);
|
|
1016
|
+
const heldPaths = new Set(entityMapping.heldFieldPaths ?? []);
|
|
1017
|
+
const forcedPushPaths = new Set([
|
|
1018
|
+
...(entityMapping.forcedPushFieldPaths ?? []),
|
|
1019
|
+
...(options.forceFieldPaths?.[entity.id] ?? []),
|
|
1020
|
+
]);
|
|
1021
|
+
const fields = [];
|
|
1022
|
+
const appendField = (fieldPath, value) => {
|
|
1023
|
+
if (value === undefined)
|
|
1024
|
+
return;
|
|
1025
|
+
const owner = owners.get(fieldPath);
|
|
1026
|
+
const mapping = selectCatalogFieldMapping(fieldMapping, fieldPath);
|
|
1027
|
+
if (owner === "store") {
|
|
1028
|
+
skipped.push({
|
|
1029
|
+
entityId,
|
|
1030
|
+
fieldPath,
|
|
1031
|
+
reason: "store_owned",
|
|
1032
|
+
value,
|
|
1033
|
+
owner,
|
|
1034
|
+
...(mapping ? { target: mapping.target, remoteKey: mapping.remoteKey } : {}),
|
|
1035
|
+
});
|
|
1036
|
+
return;
|
|
1037
|
+
}
|
|
1038
|
+
if (owner === undefined)
|
|
1039
|
+
return;
|
|
1040
|
+
const forced = forcedPushPaths.has(fieldPath);
|
|
1041
|
+
if (owner !== "platform" && !forced)
|
|
1042
|
+
return;
|
|
1043
|
+
if (heldPaths.has(fieldPath)) {
|
|
1044
|
+
skipped.push({
|
|
1045
|
+
entityId,
|
|
1046
|
+
fieldPath,
|
|
1047
|
+
reason: "held",
|
|
1048
|
+
value,
|
|
1049
|
+
owner,
|
|
1050
|
+
...(mapping ? { target: mapping.target, remoteKey: mapping.remoteKey } : {}),
|
|
1051
|
+
});
|
|
1052
|
+
return;
|
|
1053
|
+
}
|
|
1054
|
+
if (!mapping) {
|
|
1055
|
+
skipped.push({ entityId, fieldPath, reason: "no_mapping", value, owner });
|
|
1056
|
+
return;
|
|
1057
|
+
}
|
|
1058
|
+
fields.push(pushCatalogField(fieldPath, value, mapping));
|
|
1059
|
+
};
|
|
1060
|
+
for (const attribute of attributes) {
|
|
1061
|
+
for (const field of attributeFields) {
|
|
1062
|
+
appendField(`attributes.${attribute.locale}.${field}`, attribute[field]);
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
for (const [key, value] of Object.entries(entity.metadata ?? {})) {
|
|
1066
|
+
const fieldPath = `entity.metadata.${key}`;
|
|
1067
|
+
if (isValidFieldPath(fieldPath))
|
|
1068
|
+
appendField(fieldPath, value);
|
|
1069
|
+
}
|
|
1070
|
+
for (const customField of customFields) {
|
|
1071
|
+
const fieldPath = `customFields.${customField.fieldName}.${customField.locale}`;
|
|
1072
|
+
if (isValidFieldPath(fieldPath))
|
|
1073
|
+
appendField(fieldPath, customFieldValue(customField));
|
|
1074
|
+
}
|
|
1075
|
+
const media = await this.media.listEntityMedia(entity.id, { orgId });
|
|
1076
|
+
if (!media.ok)
|
|
1077
|
+
return PluginErr(media.error.message);
|
|
1078
|
+
const images = [];
|
|
1079
|
+
for (const attached of media.value) {
|
|
1080
|
+
const role = pushCatalogImageRole(attached.role);
|
|
1081
|
+
if (!role)
|
|
1082
|
+
continue;
|
|
1083
|
+
const fieldPath = `media.${role}`;
|
|
1084
|
+
const owner = owners.get(fieldPath);
|
|
1085
|
+
const mapping = selectCatalogFieldMapping(fieldMapping, fieldPath);
|
|
1086
|
+
const imageValue = [{ url: attached.url, role }];
|
|
1087
|
+
if (owner === "store") {
|
|
1088
|
+
skipped.push({
|
|
1089
|
+
entityId,
|
|
1090
|
+
fieldPath,
|
|
1091
|
+
reason: "store_owned",
|
|
1092
|
+
value: imageValue,
|
|
1093
|
+
owner,
|
|
1094
|
+
...(mapping ? { target: mapping.target, remoteKey: mapping.remoteKey } : {}),
|
|
1095
|
+
});
|
|
1096
|
+
continue;
|
|
1097
|
+
}
|
|
1098
|
+
if (owner === undefined)
|
|
1099
|
+
continue;
|
|
1100
|
+
const forced = forcedPushPaths.has(fieldPath);
|
|
1101
|
+
if (owner !== "platform" && !forced)
|
|
1102
|
+
continue;
|
|
1103
|
+
if (heldPaths.has(fieldPath)) {
|
|
1104
|
+
skipped.push({
|
|
1105
|
+
entityId,
|
|
1106
|
+
fieldPath,
|
|
1107
|
+
reason: "held",
|
|
1108
|
+
value: imageValue,
|
|
1109
|
+
owner,
|
|
1110
|
+
...(mapping ? { target: mapping.target, remoteKey: mapping.remoteKey } : {}),
|
|
1111
|
+
});
|
|
1112
|
+
continue;
|
|
1113
|
+
}
|
|
1114
|
+
if (!mapping) {
|
|
1115
|
+
skipped.push({ entityId, fieldPath, reason: "no_mapping", value: imageValue, owner });
|
|
1116
|
+
continue;
|
|
1117
|
+
}
|
|
1118
|
+
images.push({
|
|
1119
|
+
fieldPath,
|
|
1120
|
+
target: mapping.target,
|
|
1121
|
+
remoteKey: mapping.remoteKey,
|
|
1122
|
+
url: attached.url,
|
|
1123
|
+
role,
|
|
1124
|
+
sortOrder: attached.sortOrder,
|
|
1125
|
+
...(attached.alt !== null ? { alt: attached.alt } : {}),
|
|
1126
|
+
});
|
|
1127
|
+
}
|
|
1128
|
+
fields.sort((left, right) => left.fieldPath.localeCompare(right.fieldPath));
|
|
1129
|
+
const item = {
|
|
1130
|
+
externalId: entityMapping.externalId,
|
|
1131
|
+
fields,
|
|
1132
|
+
...(images.length > 0 ? { images } : {}),
|
|
1133
|
+
};
|
|
1134
|
+
items.push(item);
|
|
1135
|
+
if (options.recordRevision === true)
|
|
1136
|
+
revisionEntityIds.push(entity.id);
|
|
1137
|
+
}
|
|
1138
|
+
if (options.recordRevision === true && revisionEntityIds.length > 0) {
|
|
1139
|
+
const actor = createSystemActor(orgId);
|
|
1140
|
+
try {
|
|
1141
|
+
await this.transact(async (tx) => {
|
|
1142
|
+
const txContext = createTxContext(tx, { actor });
|
|
1143
|
+
for (const entityId of revisionEntityIds) {
|
|
1144
|
+
const revision = await this.catalog.recordEntityRevision(entityId, actor, "push", txContext);
|
|
1145
|
+
if (!revision.ok)
|
|
1146
|
+
throw new Error(revision.error.message);
|
|
1147
|
+
}
|
|
1148
|
+
});
|
|
1149
|
+
}
|
|
1150
|
+
catch (error) {
|
|
1151
|
+
return PluginErr(error instanceof Error ? error.message : "Failed to record catalog push revisions.");
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
return Ok({ items, skipped, warnings: [...new Set(warnings)] });
|
|
1155
|
+
}
|
|
1156
|
+
async recordOutboundPush(orgId, storeId, outcomes, items, phase = "settle") {
|
|
1157
|
+
const outcomeByExternalId = new Map(outcomes.map((outcome) => [outcome.externalId, outcome]));
|
|
1158
|
+
const now = new Date();
|
|
1159
|
+
for (const item of items) {
|
|
1160
|
+
const outcome = outcomeByExternalId.get(item.externalId);
|
|
1161
|
+
const mapping = await this.db.select({
|
|
1162
|
+
id: channelEntityMap.id,
|
|
1163
|
+
externalId: channelEntityMap.externalId,
|
|
1164
|
+
forcedPushFieldPaths: channelEntityMap.forcedPushFieldPaths,
|
|
1165
|
+
}).from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.kind, "entity"), eq(channelEntityMap.externalId, item.externalId)));
|
|
1166
|
+
if (!mapping[0])
|
|
1167
|
+
continue;
|
|
1168
|
+
if (outcome?.ok === true) {
|
|
1169
|
+
const fieldPaths = outboundFieldPaths(item);
|
|
1170
|
+
await this.db.update(channelEntityMap).set({
|
|
1171
|
+
outboundHash: canonicalOutboundHash(mapping[0].externalId, item, fieldPaths),
|
|
1172
|
+
outboundPushedAt: now,
|
|
1173
|
+
outboundFieldPaths: fieldPaths,
|
|
1174
|
+
// A force is an operator's conflict resolution. The write-ahead runs
|
|
1175
|
+
// before the connector is called and its outcomes are optimistic, so
|
|
1176
|
+
// consuming the force there would discard the resolution on a failed
|
|
1177
|
+
// push and the retry would silently omit the field.
|
|
1178
|
+
...(phase === "settle"
|
|
1179
|
+
? { forcedPushFieldPaths: (mapping[0].forcedPushFieldPaths ?? []).filter((path) => !fieldPaths.includes(path)) }
|
|
1180
|
+
: {}),
|
|
1181
|
+
}).where(eq(channelEntityMap.id, mapping[0].id));
|
|
1182
|
+
}
|
|
1183
|
+
else {
|
|
1184
|
+
await this.db.update(channelEntityMap).set({
|
|
1185
|
+
outboundHash: null,
|
|
1186
|
+
outboundPushedAt: null,
|
|
1187
|
+
outboundFieldPaths: [],
|
|
1188
|
+
syncHash: "",
|
|
1189
|
+
}).where(eq(channelEntityMap.id, mapping[0].id));
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
return Ok(undefined);
|
|
1193
|
+
}
|
|
1194
|
+
async pushCatalogToStore(orgId, storeId, entityIds) {
|
|
1195
|
+
const assembled = await this.buildCatalogPushItems(orgId, storeId, entityIds);
|
|
1196
|
+
if (!assembled.ok)
|
|
1197
|
+
return assembled;
|
|
1198
|
+
const store = await this.getStoreRecord(orgId, storeId);
|
|
1199
|
+
if (!store || store.status !== "connected")
|
|
1200
|
+
return PluginErr("Connected store not found.", "NOT_FOUND");
|
|
1201
|
+
const connector = this.connectors.get(store.provider);
|
|
1202
|
+
if (!connector?.pushCatalog)
|
|
1203
|
+
return PluginErr(`Catalog push is not supported by provider "${store.provider}".`);
|
|
1204
|
+
if (assembled.value.items.length === 0)
|
|
1205
|
+
return Ok({
|
|
1206
|
+
outcomes: [],
|
|
1207
|
+
skipped: assembled.value.skipped,
|
|
1208
|
+
warnings: assembled.value.warnings,
|
|
1209
|
+
});
|
|
1210
|
+
const writeAhead = await this.recordOutboundPush(orgId, storeId, assembled.value.items.map((item) => ({ externalId: item.externalId, ok: true })), assembled.value.items, "write-ahead");
|
|
1211
|
+
if (!writeAhead.ok)
|
|
1212
|
+
return writeAhead;
|
|
1213
|
+
let result;
|
|
1214
|
+
try {
|
|
1215
|
+
result = await connector.pushCatalog(store, assembled.value.items);
|
|
1216
|
+
}
|
|
1217
|
+
catch (error) {
|
|
1218
|
+
const connectorError = {
|
|
1219
|
+
code: "CATALOG_PUSH_THROWN",
|
|
1220
|
+
message: error instanceof Error ? error.message : "Catalog push failed.",
|
|
1221
|
+
};
|
|
1222
|
+
const cleared = await this.recordOutboundPush(orgId, storeId, assembled.value.items.map((item) => ({ externalId: item.externalId, ok: false, error: connectorError })), assembled.value.items);
|
|
1223
|
+
if (!cleared.ok)
|
|
1224
|
+
return cleared;
|
|
1225
|
+
return PluginErr(connectorError.message, connectorError.code);
|
|
1226
|
+
}
|
|
1227
|
+
if (!result.ok) {
|
|
1228
|
+
const cleared = await this.recordOutboundPush(orgId, storeId, assembled.value.items.map((item) => ({ externalId: item.externalId, ok: false, error: result.error })), assembled.value.items);
|
|
1229
|
+
if (!cleared.ok)
|
|
1230
|
+
return cleared;
|
|
1231
|
+
return PluginErr(result.error.message, result.error.code);
|
|
1232
|
+
}
|
|
1233
|
+
const recorded = await this.recordOutboundPush(orgId, storeId, result.value.outcomes, assembled.value.items);
|
|
1234
|
+
if (!recorded.ok)
|
|
1235
|
+
return recorded;
|
|
1236
|
+
const successfulEntityIds = assembled.value.items
|
|
1237
|
+
.filter((item) => result.value.outcomes.some((outcome) => outcome.externalId === item.externalId && outcome.ok))
|
|
1238
|
+
.map((item) => item.externalId);
|
|
1239
|
+
if (successfulEntityIds.length > 0) {
|
|
1240
|
+
const mappings = await this.db.select({ entityId: channelEntityMap.entityId }).from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.kind, "entity"), inArray(channelEntityMap.externalId, successfulEntityIds)));
|
|
1241
|
+
const actor = createSystemActor(orgId);
|
|
1242
|
+
try {
|
|
1243
|
+
await this.transact(async (tx) => {
|
|
1244
|
+
const txContext = createTxContext(tx, { actor });
|
|
1245
|
+
for (const entityId of [...new Set(mappings.map((mapping) => mapping.entityId))]) {
|
|
1246
|
+
const revision = await this.catalog.recordEntityRevision(entityId, actor, "push", txContext);
|
|
1247
|
+
if (!revision.ok)
|
|
1248
|
+
throw new Error(revision.error.message);
|
|
1249
|
+
}
|
|
1250
|
+
});
|
|
1251
|
+
}
|
|
1252
|
+
catch (error) {
|
|
1253
|
+
return PluginErr(error instanceof Error ? error.message : "Failed to record catalog push revisions.");
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
return Ok({
|
|
1257
|
+
...result.value,
|
|
1258
|
+
skipped: assembled.value.skipped,
|
|
1259
|
+
warnings: assembled.value.warnings,
|
|
1260
|
+
});
|
|
1261
|
+
}
|
|
1262
|
+
async previewCatalogPush(orgId, storeId, entityIds) {
|
|
1263
|
+
const assembledEntityIds = await this.resolveCatalogPushEntityIds(orgId, storeId, entityIds);
|
|
1264
|
+
const assembled = await this.buildCatalogPushItems(orgId, storeId, assembledEntityIds);
|
|
1265
|
+
if (!assembled.ok)
|
|
1266
|
+
return assembled;
|
|
1267
|
+
const store = await this.getStoreRecord(orgId, storeId);
|
|
1268
|
+
if (!store || store.status !== "connected")
|
|
1269
|
+
return PluginErr("Connected store not found.", "NOT_FOUND");
|
|
1270
|
+
const connector = this.connectors.get(store.provider);
|
|
1271
|
+
if (!connector?.pushCatalog)
|
|
1272
|
+
return PluginErr(`Catalog push is not supported by provider "${store.provider}".`);
|
|
1273
|
+
if (assembled.value.items.length === 0) {
|
|
1274
|
+
return Ok({ items: [], skipped: assembled.value.skipped, warnings: assembled.value.warnings });
|
|
1275
|
+
}
|
|
1276
|
+
let result;
|
|
1277
|
+
try {
|
|
1278
|
+
result = await connector.pushCatalog(store, assembled.value.items, { dryRun: true });
|
|
1279
|
+
}
|
|
1280
|
+
catch (error) {
|
|
1281
|
+
return PluginErr(error instanceof Error ? error.message : "Catalog push preview failed.", "CATALOG_PREVIEW_THROWN");
|
|
1282
|
+
}
|
|
1283
|
+
if (!result.ok)
|
|
1284
|
+
return PluginErr(result.error.message, result.error.code);
|
|
1285
|
+
const failed = result.value.outcomes.find((outcome) => !outcome.ok);
|
|
1286
|
+
if (failed)
|
|
1287
|
+
return PluginErr(failed.error?.message ?? `Catalog push preview failed for item "${failed.externalId}".`, failed.error?.code ?? "CATALOG_PREVIEW_FAILED");
|
|
1288
|
+
const mappings = assembledEntityIds.length === 0
|
|
1289
|
+
? []
|
|
1290
|
+
: await this.db.select({ entityId: channelEntityMap.entityId, externalId: channelEntityMap.externalId }).from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.kind, "entity"), inArray(channelEntityMap.entityId, assembledEntityIds)));
|
|
1291
|
+
const externalByEntity = new Map(mappings.map((mapping) => [mapping.entityId, mapping.externalId]));
|
|
1292
|
+
const skippedByExternal = new Map();
|
|
1293
|
+
for (const skipped of assembled.value.skipped) {
|
|
1294
|
+
const externalId = externalByEntity.get(skipped.entityId);
|
|
1295
|
+
if (!externalId)
|
|
1296
|
+
continue;
|
|
1297
|
+
const existing = skippedByExternal.get(externalId) ?? [];
|
|
1298
|
+
existing.push(skipped);
|
|
1299
|
+
skippedByExternal.set(externalId, existing);
|
|
1300
|
+
}
|
|
1301
|
+
const outcomeByExternalId = new Map(result.value.outcomes.map((outcome) => [outcome.externalId, outcome]));
|
|
1302
|
+
const beforeFor = (externalId, fieldPath) => {
|
|
1303
|
+
const previousFields = outcomeByExternalId.get(externalId)?.previousFields;
|
|
1304
|
+
if (previousFields === undefined) {
|
|
1305
|
+
return { before: { status: "unavailable" }, beforeStatus: "unavailable" };
|
|
1306
|
+
}
|
|
1307
|
+
const previous = previousFields.find((field) => field.fieldPath === fieldPath);
|
|
1308
|
+
if (!previous)
|
|
1309
|
+
return { before: null, beforeStatus: "missing" };
|
|
1310
|
+
return { before: previous.value, beforeStatus: "value" };
|
|
1311
|
+
};
|
|
1312
|
+
const items = assembled.value.items.map((item) => {
|
|
1313
|
+
const diffs = item.fields.map((field) => ({
|
|
1314
|
+
fieldPath: field.fieldPath,
|
|
1315
|
+
target: field.target,
|
|
1316
|
+
remoteKey: field.remoteKey ?? null,
|
|
1317
|
+
...beforeFor(item.externalId, field.fieldPath),
|
|
1318
|
+
after: field.value,
|
|
1319
|
+
owner: "platform",
|
|
1320
|
+
willWrite: true,
|
|
1321
|
+
}));
|
|
1322
|
+
for (const image of item.images ?? []) {
|
|
1323
|
+
diffs.push({
|
|
1324
|
+
fieldPath: image.fieldPath,
|
|
1325
|
+
target: image.target,
|
|
1326
|
+
remoteKey: image.remoteKey,
|
|
1327
|
+
...beforeFor(item.externalId, image.fieldPath),
|
|
1328
|
+
after: pushFieldValue(item, image.fieldPath),
|
|
1329
|
+
owner: "platform",
|
|
1330
|
+
willWrite: true,
|
|
1331
|
+
});
|
|
1332
|
+
}
|
|
1333
|
+
for (const skipped of skippedByExternal.get(item.externalId) ?? []) {
|
|
1334
|
+
if (skipped.value === undefined || skipped.owner === undefined)
|
|
1335
|
+
continue;
|
|
1336
|
+
diffs.push({
|
|
1337
|
+
fieldPath: skipped.fieldPath,
|
|
1338
|
+
target: skipped.target ?? null,
|
|
1339
|
+
remoteKey: skipped.remoteKey ?? null,
|
|
1340
|
+
...beforeFor(item.externalId, skipped.fieldPath),
|
|
1341
|
+
after: skipped.value,
|
|
1342
|
+
owner: skipped.owner,
|
|
1343
|
+
willWrite: false,
|
|
1344
|
+
reason: skipped.reason,
|
|
1345
|
+
});
|
|
1346
|
+
}
|
|
1347
|
+
return { externalId: item.externalId, diffs };
|
|
1348
|
+
});
|
|
1349
|
+
return Ok({ items, skipped: assembled.value.skipped, warnings: assembled.value.warnings });
|
|
1350
|
+
}
|
|
1351
|
+
async getCatalogWriteSettings(orgId, storeId) {
|
|
1352
|
+
const store = await this.getStoreRecord(orgId, storeId);
|
|
1353
|
+
if (!store)
|
|
1354
|
+
return PluginErr("Connected store not found.", "NOT_FOUND");
|
|
1355
|
+
const warnings = [];
|
|
1356
|
+
return Ok({
|
|
1357
|
+
enabled: store.catalogWriteEnabled === true,
|
|
1358
|
+
overrides: store.catalogFieldMapping,
|
|
1359
|
+
merged: this.resolveCatalogFieldMapping(store, undefined, warnings),
|
|
1360
|
+
...(warnings.length > 0 ? { warnings } : {}),
|
|
1361
|
+
});
|
|
1362
|
+
}
|
|
1363
|
+
async updateCatalogWriteEnabled(orgId, storeId, enabled) {
|
|
1364
|
+
const rows = await this.db
|
|
1365
|
+
.update(connectedStores)
|
|
1366
|
+
.set({ catalogWriteEnabled: enabled, updatedAt: new Date() })
|
|
1367
|
+
.where(and(eq(connectedStores.organizationId, orgId), eq(connectedStores.id, storeId)))
|
|
1368
|
+
.returning();
|
|
1369
|
+
if (!rows[0])
|
|
1370
|
+
return PluginErr("Connected store not found.", "NOT_FOUND");
|
|
1371
|
+
return this.getCatalogWriteSettings(orgId, storeId);
|
|
1372
|
+
}
|
|
1373
|
+
async updateCatalogFieldMapping(orgId, storeId, mapping) {
|
|
1374
|
+
const store = await this.getStoreRecord(orgId, storeId);
|
|
1375
|
+
if (!store)
|
|
1376
|
+
return PluginErr("Connected store not found.", "NOT_FOUND");
|
|
1377
|
+
let normalized;
|
|
1378
|
+
try {
|
|
1379
|
+
normalized = normalizeCatalogFieldMapping(mapping, store.provider);
|
|
1380
|
+
}
|
|
1381
|
+
catch (error) {
|
|
1382
|
+
return PluginErr(error instanceof Error ? error.message : "Catalog mapping is invalid.", "INVALID_MAPPING");
|
|
1383
|
+
}
|
|
1384
|
+
await this.db
|
|
1385
|
+
.update(connectedStores)
|
|
1386
|
+
.set({ catalogFieldMapping: normalized, updatedAt: new Date() })
|
|
1387
|
+
.where(and(eq(connectedStores.organizationId, orgId), eq(connectedStores.id, storeId)));
|
|
1388
|
+
return this.getCatalogWriteSettings(orgId, storeId);
|
|
1389
|
+
}
|
|
105
1390
|
async connectStore(orgId, input) {
|
|
106
1391
|
if (!this.connectors.has(input.provider)) {
|
|
107
1392
|
return PluginErr(`No connector registered for provider "${input.provider}".`, "NOT_FOUND");
|
|
108
1393
|
}
|
|
109
|
-
const
|
|
110
|
-
.
|
|
111
|
-
.
|
|
112
|
-
organizationId
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
1394
|
+
const existingRows = await this.db
|
|
1395
|
+
.select()
|
|
1396
|
+
.from(connectedStores)
|
|
1397
|
+
.where(and(eq(connectedStores.organizationId, orgId), eq(connectedStores.provider, input.provider), eq(connectedStores.storeDomain, input.storeDomain)));
|
|
1398
|
+
const reconnect = existingRows.find((row) => row.status !== "connected");
|
|
1399
|
+
const rows = reconnect
|
|
1400
|
+
? await this.db
|
|
1401
|
+
.update(connectedStores)
|
|
1402
|
+
.set({
|
|
1403
|
+
credentials: input.credentials,
|
|
1404
|
+
status: "connected",
|
|
1405
|
+
catalogWriteEnabled: false,
|
|
1406
|
+
webhookSecret: input.webhookSecret ?? crypto.randomUUID(),
|
|
1407
|
+
updatedAt: new Date(),
|
|
1408
|
+
})
|
|
1409
|
+
.where(eq(connectedStores.id, reconnect.id))
|
|
1410
|
+
.returning()
|
|
1411
|
+
: await this.db
|
|
1412
|
+
.insert(connectedStores)
|
|
1413
|
+
.values({
|
|
1414
|
+
organizationId: orgId,
|
|
1415
|
+
provider: input.provider,
|
|
1416
|
+
credentials: input.credentials,
|
|
1417
|
+
storeDomain: input.storeDomain,
|
|
1418
|
+
webhookSecret: input.webhookSecret ?? crypto.randomUUID(),
|
|
1419
|
+
})
|
|
1420
|
+
.returning();
|
|
119
1421
|
const connector = this.connectors.get(input.provider);
|
|
120
1422
|
const store = rows[0];
|
|
121
1423
|
if (connector.registerWebhooks) {
|
|
@@ -258,78 +1560,468 @@ export class ChannelConnectorService {
|
|
|
258
1560
|
.update(connectedStores)
|
|
259
1561
|
.set({ catalogCursor: null, lastSyncAt: new Date(), updatedAt: new Date() })
|
|
260
1562
|
.where(and(eq(connectedStores.organizationId, orgId), eq(connectedStores.id, storeId)));
|
|
261
|
-
return Ok({
|
|
1563
|
+
return Ok({
|
|
1564
|
+
imported: result.value.imported,
|
|
1565
|
+
cursor: null,
|
|
1566
|
+
...(result.value.skipped.length > 0 ? { skipped: uniqueSkipped(result.value.skipped) } : {}),
|
|
1567
|
+
...(result.value.conflicts.length > 0 ? { conflicts: result.value.conflicts } : {}),
|
|
1568
|
+
...(result.value.warnings.length > 0 ? { warnings: result.value.warnings } : {}),
|
|
1569
|
+
});
|
|
1570
|
+
}
|
|
1571
|
+
async promoteLegacyAttributes(orgId, storeId, actor, dryRun) {
|
|
1572
|
+
const mappings = await this.db.select().from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.kind, "entity")));
|
|
1573
|
+
let created = 0;
|
|
1574
|
+
for (const entityId of new Set(mappings.map((mapping) => mapping.entityId))) {
|
|
1575
|
+
const [entity] = await this.db.select().from(sellableEntities).where(and(eq(sellableEntities.organizationId, orgId), eq(sellableEntities.id, entityId)));
|
|
1576
|
+
if (!entity)
|
|
1577
|
+
continue;
|
|
1578
|
+
const attributes = await this.db.select().from(sellableAttributes).where(eq(sellableAttributes.entityId, entity.id));
|
|
1579
|
+
if (attributes.length > 0)
|
|
1580
|
+
continue;
|
|
1581
|
+
const metadata = entity.metadata ?? {};
|
|
1582
|
+
if (typeof metadata.title !== "string")
|
|
1583
|
+
continue;
|
|
1584
|
+
const owners = await this.catalog.resolveFieldOwners(entity.id, storeId);
|
|
1585
|
+
if (owners.get("attributes.en.title") === "platform")
|
|
1586
|
+
continue;
|
|
1587
|
+
if (dryRun) {
|
|
1588
|
+
created += 1;
|
|
1589
|
+
continue;
|
|
1590
|
+
}
|
|
1591
|
+
const promoted = await this.catalog.setAttributes(entity.id, "en", {
|
|
1592
|
+
title: metadata.title,
|
|
1593
|
+
...(typeof metadata.description === "string" ? { description: metadata.description } : {}),
|
|
1594
|
+
}, actor, CHANNEL_CONVERGENCE_CTX);
|
|
1595
|
+
if (!promoted.ok)
|
|
1596
|
+
return PluginErr(promoted.error.message);
|
|
1597
|
+
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")));
|
|
1598
|
+
if (!confirmed || confirmed.title !== metadata.title || (typeof metadata.description === "string" && confirmed.description !== metadata.description)) {
|
|
1599
|
+
return PluginErr(`Legacy attributes for entity "${entity.id}" were not persisted.`);
|
|
1600
|
+
}
|
|
1601
|
+
const nextMetadata = { ...metadata };
|
|
1602
|
+
delete nextMetadata.title;
|
|
1603
|
+
if (typeof metadata.description === "string")
|
|
1604
|
+
delete nextMetadata.description;
|
|
1605
|
+
await this.db.update(sellableEntities).set({ metadata: nextMetadata, updatedAt: new Date() }).where(and(eq(sellableEntities.organizationId, orgId), eq(sellableEntities.id, entity.id)));
|
|
1606
|
+
created += 1;
|
|
1607
|
+
}
|
|
1608
|
+
return Ok(created);
|
|
1609
|
+
}
|
|
1610
|
+
async saveBackfillState(orgId, storeId, state) {
|
|
1611
|
+
const [store] = await this.db.select({ breakerState: connectedStores.breakerState }).from(connectedStores).where(and(eq(connectedStores.organizationId, orgId), eq(connectedStores.id, storeId)));
|
|
1612
|
+
await this.db.update(connectedStores).set({
|
|
1613
|
+
breakerState: { ...(store?.breakerState ?? {}), catalogBackfill: state },
|
|
1614
|
+
updatedAt: new Date(),
|
|
1615
|
+
}).where(and(eq(connectedStores.organizationId, orgId), eq(connectedStores.id, storeId)));
|
|
1616
|
+
}
|
|
1617
|
+
async backfillCatalog(orgId, storeId, actor, options = {}) {
|
|
1618
|
+
const store = await this.getStoreRecord(orgId, storeId);
|
|
1619
|
+
if (!store || store.status !== "connected")
|
|
1620
|
+
return PluginErr("Connected store not found.", "NOT_FOUND");
|
|
1621
|
+
const connector = this.connectors.get(store.provider);
|
|
1622
|
+
if (!connector)
|
|
1623
|
+
return PluginErr(`No connector registered for provider "${store.provider}".`);
|
|
1624
|
+
const dryRun = options.dryRun === true;
|
|
1625
|
+
const saved = store.breakerState.catalogBackfill;
|
|
1626
|
+
const savedState = saved && typeof saved === "object" ? saved : undefined;
|
|
1627
|
+
// Undefined resume derives from persisted state, so a retried job or a
|
|
1628
|
+
// re-triggered run continues an unfinished backfill instead of restarting.
|
|
1629
|
+
const resume = options.resume ?? (savedState !== undefined && !savedState.completedAt);
|
|
1630
|
+
if (resume && savedState?.completedAt && savedState.cursor === null) {
|
|
1631
|
+
return Ok({
|
|
1632
|
+
...savedState.report,
|
|
1633
|
+
cursor: null,
|
|
1634
|
+
complete: true,
|
|
1635
|
+
...(savedState.skipped?.length ? { skipped: savedState.skipped } : {}),
|
|
1636
|
+
...(savedState.conflicts?.length ? { conflicts: savedState.conflicts } : {}),
|
|
1637
|
+
...(savedState.warnings?.length ? { warnings: savedState.warnings } : {}),
|
|
1638
|
+
});
|
|
1639
|
+
}
|
|
1640
|
+
const report = resume && savedState ? { ...savedState.report } : {
|
|
1641
|
+
entitiesTouched: 0,
|
|
1642
|
+
attributesCreated: 0,
|
|
1643
|
+
mediaImported: 0,
|
|
1644
|
+
variantsGivenOptionValues: 0,
|
|
1645
|
+
};
|
|
1646
|
+
const skipped = resume && savedState?.skipped ? [...savedState.skipped] : [];
|
|
1647
|
+
const conflicts = resume && savedState?.conflicts ? [...savedState.conflicts] : [];
|
|
1648
|
+
const warnings = resume && savedState?.warnings ? [...savedState.warnings] : [];
|
|
1649
|
+
const promoted = await this.promoteLegacyAttributes(orgId, storeId, actor, dryRun);
|
|
1650
|
+
if (!promoted.ok)
|
|
1651
|
+
return promoted;
|
|
1652
|
+
report.attributesCreated += promoted.value;
|
|
1653
|
+
let cursor = resume && savedState?.cursor ? savedState.cursor : undefined;
|
|
1654
|
+
let pages = 0;
|
|
1655
|
+
if (!dryRun) {
|
|
1656
|
+
await this.saveBackfillState(orgId, storeId, {
|
|
1657
|
+
cursor: cursor ?? null,
|
|
1658
|
+
report,
|
|
1659
|
+
...(skipped.length > 0 ? { skipped: uniqueSkipped(skipped) } : {}),
|
|
1660
|
+
...(conflicts.length > 0 ? { conflicts } : {}),
|
|
1661
|
+
...(warnings.length > 0 ? { warnings } : {}),
|
|
1662
|
+
});
|
|
1663
|
+
}
|
|
1664
|
+
do {
|
|
1665
|
+
const page = await connector.importCatalog(store, cursor);
|
|
1666
|
+
if (!page.ok)
|
|
1667
|
+
return PluginErr(page.error.message);
|
|
1668
|
+
const converged = await this.convergeCatalogItems(orgId, storeId, page.value.items, actor, true, dryRun);
|
|
1669
|
+
if (!converged.ok)
|
|
1670
|
+
return converged;
|
|
1671
|
+
report.entitiesTouched += converged.value.entitiesTouched;
|
|
1672
|
+
report.attributesCreated += converged.value.attributesCreated;
|
|
1673
|
+
report.mediaImported += converged.value.mediaImported;
|
|
1674
|
+
report.variantsGivenOptionValues += converged.value.variantsGivenOptionValues;
|
|
1675
|
+
skipped.push(...converged.value.skipped);
|
|
1676
|
+
conflicts.push(...converged.value.conflicts);
|
|
1677
|
+
warnings.push(...converged.value.warnings);
|
|
1678
|
+
cursor = page.value.nextCursor ?? undefined;
|
|
1679
|
+
pages += 1;
|
|
1680
|
+
// The final state is written once with completedAt below; a cursor-null
|
|
1681
|
+
// checkpoint without it would read as a fresh start after a crash.
|
|
1682
|
+
if (!dryRun && cursor) {
|
|
1683
|
+
await this.saveBackfillState(orgId, storeId, {
|
|
1684
|
+
cursor,
|
|
1685
|
+
report,
|
|
1686
|
+
...(skipped.length > 0 ? { skipped: uniqueSkipped(skipped) } : {}),
|
|
1687
|
+
...(conflicts.length > 0 ? { conflicts } : {}),
|
|
1688
|
+
...(warnings.length > 0 ? { warnings } : {}),
|
|
1689
|
+
});
|
|
1690
|
+
}
|
|
1691
|
+
if (options.maxPages !== undefined && pages >= options.maxPages && cursor) {
|
|
1692
|
+
return Ok({
|
|
1693
|
+
...report,
|
|
1694
|
+
cursor,
|
|
1695
|
+
complete: false,
|
|
1696
|
+
...(skipped.length > 0 ? { skipped: uniqueSkipped(skipped) } : {}),
|
|
1697
|
+
...(conflicts.length > 0 ? { conflicts } : {}),
|
|
1698
|
+
...(warnings.length > 0 ? { warnings } : {}),
|
|
1699
|
+
});
|
|
1700
|
+
}
|
|
1701
|
+
} while (cursor);
|
|
1702
|
+
if (!dryRun) {
|
|
1703
|
+
await this.saveBackfillState(orgId, storeId, {
|
|
1704
|
+
cursor: null,
|
|
1705
|
+
report,
|
|
1706
|
+
...(skipped.length > 0 ? { skipped: uniqueSkipped(skipped) } : {}),
|
|
1707
|
+
...(conflicts.length > 0 ? { conflicts } : {}),
|
|
1708
|
+
...(warnings.length > 0 ? { warnings } : {}),
|
|
1709
|
+
completedAt: new Date().toISOString(),
|
|
1710
|
+
});
|
|
1711
|
+
}
|
|
1712
|
+
return Ok({
|
|
1713
|
+
...report,
|
|
1714
|
+
cursor: null,
|
|
1715
|
+
complete: true,
|
|
1716
|
+
...(skipped.length > 0 ? { skipped: uniqueSkipped(skipped) } : {}),
|
|
1717
|
+
...(conflicts.length > 0 ? { conflicts } : {}),
|
|
1718
|
+
...(warnings.length > 0 ? { warnings } : {}),
|
|
1719
|
+
});
|
|
262
1720
|
}
|
|
263
|
-
async
|
|
1721
|
+
async estimateCatalogItems(orgId, storeId, items) {
|
|
1722
|
+
const stats = {
|
|
1723
|
+
imported: 0,
|
|
1724
|
+
converged: 0,
|
|
1725
|
+
entitiesTouched: 0,
|
|
1726
|
+
attributesCreated: 0,
|
|
1727
|
+
mediaImported: 0,
|
|
1728
|
+
variantsGivenOptionValues: 0,
|
|
1729
|
+
skipped: [],
|
|
1730
|
+
conflicts: [],
|
|
1731
|
+
warnings: [],
|
|
1732
|
+
};
|
|
1733
|
+
const assets = await this.db.select().from(mediaAssets).where(eq(mediaAssets.organizationId, orgId));
|
|
1734
|
+
for (const item of items) {
|
|
1735
|
+
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)));
|
|
1736
|
+
if (!entityMapping) {
|
|
1737
|
+
stats.imported += 1;
|
|
1738
|
+
stats.entitiesTouched += 1;
|
|
1739
|
+
stats.attributesCreated += item.attributes?.length || 1;
|
|
1740
|
+
stats.variantsGivenOptionValues += item.variants.filter((variant) => Object.keys(variant.optionValues ?? {}).some((name) => item.options?.some((option) => option.name === name))).length;
|
|
1741
|
+
stats.mediaImported += item.images?.length ?? 0;
|
|
1742
|
+
continue;
|
|
1743
|
+
}
|
|
1744
|
+
const [entity] = await this.db.select().from(sellableEntities).where(and(eq(sellableEntities.organizationId, orgId), eq(sellableEntities.id, entityMapping.entityId)));
|
|
1745
|
+
if (!entity)
|
|
1746
|
+
continue;
|
|
1747
|
+
const owners = await this.catalog.resolveFieldOwners(entity.id, storeId);
|
|
1748
|
+
stats.skipped.push(...importedFieldPaths(item)
|
|
1749
|
+
.filter((path) => owners.get(path) === "platform")
|
|
1750
|
+
.map((fieldPath) => ({ entityId: entity.id, fieldPath })));
|
|
1751
|
+
let touched = false;
|
|
1752
|
+
const attributes = await this.db.select().from(sellableAttributes).where(eq(sellableAttributes.entityId, entity.id));
|
|
1753
|
+
const locales = new Set(attributes.map((attribute) => attribute.locale));
|
|
1754
|
+
const metadata = entity.metadata ?? {};
|
|
1755
|
+
if (attributes.length === 0 && typeof metadata.title === "string") {
|
|
1756
|
+
locales.add("en");
|
|
1757
|
+
touched = true;
|
|
1758
|
+
}
|
|
1759
|
+
const sourceAttributes = item.attributes?.length
|
|
1760
|
+
? item.attributes
|
|
1761
|
+
: [{ locale: "en", title: item.title, ...(item.description !== undefined ? { description: item.description } : {}) }];
|
|
1762
|
+
for (const attribute of sourceAttributes) {
|
|
1763
|
+
if (!locales.has(attribute.locale)) {
|
|
1764
|
+
stats.attributesCreated += 1;
|
|
1765
|
+
locales.add(attribute.locale);
|
|
1766
|
+
touched = true;
|
|
1767
|
+
}
|
|
1768
|
+
}
|
|
1769
|
+
const remoteMetadata = mergeMetadata(entity.metadata, item.metadata ?? {});
|
|
1770
|
+
const remoteStatus = item.status ?? (entity.status === "archived" ? "active" : undefined);
|
|
1771
|
+
const entityChanged = entity.slug !== item.slug
|
|
1772
|
+
|| hash(remoteMetadata) !== hash(entity.metadata ?? {})
|
|
1773
|
+
|| (remoteStatus !== undefined && remoteStatus !== entity.status);
|
|
1774
|
+
if (entityChanged) {
|
|
1775
|
+
stats.converged += 1;
|
|
1776
|
+
touched = true;
|
|
1777
|
+
}
|
|
1778
|
+
const optionValueIds = new Map();
|
|
1779
|
+
const existingTypes = await this.db.select().from(optionTypes).where(eq(optionTypes.entityId, entity.id));
|
|
1780
|
+
for (const sourceType of item.options ?? []) {
|
|
1781
|
+
const existingType = existingTypes.find((optionType) => optionType.name === sourceType.name);
|
|
1782
|
+
if (!existingType) {
|
|
1783
|
+
touched = true;
|
|
1784
|
+
optionValueIds.set(sourceType.name, new Map(sourceType.values.map((value) => [value.value, `new:${sourceType.name}:${value.value}`])));
|
|
1785
|
+
continue;
|
|
1786
|
+
}
|
|
1787
|
+
const existingValues = await this.db.select().from(optionValues).where(eq(optionValues.optionTypeId, existingType.id));
|
|
1788
|
+
const valueIds = new Map();
|
|
1789
|
+
for (const sourceValue of sourceType.values) {
|
|
1790
|
+
const existingValue = existingValues.find((value) => value.value === sourceValue.value);
|
|
1791
|
+
if (!existingValue)
|
|
1792
|
+
touched = true;
|
|
1793
|
+
valueIds.set(sourceValue.value, existingValue?.id ?? `new:${sourceType.name}:${sourceValue.value}`);
|
|
1794
|
+
}
|
|
1795
|
+
optionValueIds.set(sourceType.name, valueIds);
|
|
1796
|
+
}
|
|
1797
|
+
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)));
|
|
1798
|
+
const variantIds = new Map();
|
|
1799
|
+
for (const sourceVariant of item.variants) {
|
|
1800
|
+
const mapping = variantMappings.find((row) => row.externalId === sourceVariant.externalId);
|
|
1801
|
+
const variantId = mapping?.variantId ?? `new:${sourceVariant.externalId}`;
|
|
1802
|
+
variantIds.set(sourceVariant.externalId, variantId);
|
|
1803
|
+
const desiredIds = [...new Set(Object.entries(sourceVariant.optionValues ?? {})
|
|
1804
|
+
.map(([name, value]) => optionValueIds.get(name)?.get(value))
|
|
1805
|
+
.filter((optionValueId) => optionValueId !== undefined))].sort();
|
|
1806
|
+
if (!mapping?.variantId) {
|
|
1807
|
+
if (desiredIds.length > 0)
|
|
1808
|
+
stats.variantsGivenOptionValues += 1;
|
|
1809
|
+
touched = true;
|
|
1810
|
+
continue;
|
|
1811
|
+
}
|
|
1812
|
+
const current = await this.db.select().from(variantOptionValues).where(eq(variantOptionValues.variantId, mapping.variantId));
|
|
1813
|
+
const currentIds = current.map((row) => row.optionValueId).sort();
|
|
1814
|
+
if (currentIds.length !== desiredIds.length || currentIds.some((id, index) => id !== desiredIds[index])) {
|
|
1815
|
+
if (desiredIds.length > 0)
|
|
1816
|
+
stats.variantsGivenOptionValues += 1;
|
|
1817
|
+
touched = true;
|
|
1818
|
+
}
|
|
1819
|
+
}
|
|
1820
|
+
const links = await this.db.select().from(entityMedia).where(eq(entityMedia.entityId, entity.id));
|
|
1821
|
+
for (const image of item.images ?? []) {
|
|
1822
|
+
const urlHash = hash(image.url);
|
|
1823
|
+
const asset = assets.find((row) => {
|
|
1824
|
+
const assetMetadata = row.metadata ?? {};
|
|
1825
|
+
return (image.externalId != null && assetMetadata.channelImageExternalId === image.externalId)
|
|
1826
|
+
|| assetMetadata.channelImageUrlHash === urlHash;
|
|
1827
|
+
});
|
|
1828
|
+
const mediaAssetId = asset?.id ?? `new:${urlHash}`;
|
|
1829
|
+
if (!asset)
|
|
1830
|
+
stats.mediaImported += 1;
|
|
1831
|
+
const targets = image.variantExternalIds?.length
|
|
1832
|
+
? image.variantExternalIds.map((externalId) => ({ externalId, variantId: variantIds.get(externalId) }))
|
|
1833
|
+
: [{ externalId: undefined, variantId: undefined }];
|
|
1834
|
+
for (const target of targets) {
|
|
1835
|
+
if (image.variantExternalIds?.length && !target.variantId) {
|
|
1836
|
+
stats.warnings.push(`Skipped image "${image.externalId ?? image.url}" for unmapped variant "${target.externalId}".`);
|
|
1837
|
+
continue;
|
|
1838
|
+
}
|
|
1839
|
+
const existingLink = links.find((link) => link.mediaAssetId === mediaAssetId && (target.variantId === undefined ? link.variantId === null : link.variantId === target.variantId));
|
|
1840
|
+
if (!existingLink)
|
|
1841
|
+
touched = true;
|
|
1842
|
+
}
|
|
1843
|
+
}
|
|
1844
|
+
if (touched)
|
|
1845
|
+
stats.entitiesTouched += 1;
|
|
1846
|
+
}
|
|
1847
|
+
return Ok(stats);
|
|
1848
|
+
}
|
|
1849
|
+
async convergeCatalogItems(orgId, storeId, items, actor, force = false, dryRun = false) {
|
|
1850
|
+
if (dryRun)
|
|
1851
|
+
return this.estimateCatalogItems(orgId, storeId, items);
|
|
264
1852
|
let imported = 0;
|
|
265
1853
|
let converged = 0;
|
|
1854
|
+
let entitiesTouched = 0;
|
|
1855
|
+
let attributesCreated = 0;
|
|
1856
|
+
let mediaImported = 0;
|
|
1857
|
+
let variantsGivenOptionValues = 0;
|
|
1858
|
+
const skipped = [];
|
|
1859
|
+
const conflicts = [];
|
|
1860
|
+
const warnings = [];
|
|
266
1861
|
for (const item of items) {
|
|
1862
|
+
const remoteHash = hash(item);
|
|
267
1863
|
const existing = await this.db
|
|
268
1864
|
.select()
|
|
269
1865
|
.from(channelEntityMap)
|
|
270
1866
|
.where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.kind, "entity"), eq(channelEntityMap.externalId, item.externalId)));
|
|
271
1867
|
const entityMapping = existing.find((entry) => entry.kind === "entity");
|
|
1868
|
+
let entityId;
|
|
1869
|
+
let isNew = false;
|
|
1870
|
+
let entityTouched = false;
|
|
1871
|
+
let existingEntity;
|
|
272
1872
|
if (entityMapping) {
|
|
273
1873
|
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;
|
|
1874
|
+
if (!entity) {
|
|
1875
|
+
warnings.push(`Skipped "${item.externalId}": mapped entity ${entityMapping.entityId} no longer exists.`);
|
|
1876
|
+
continue;
|
|
288
1877
|
}
|
|
289
|
-
|
|
1878
|
+
entityId = entityMapping.entityId;
|
|
1879
|
+
existingEntity = entity;
|
|
290
1880
|
}
|
|
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 } : {}),
|
|
1881
|
+
else {
|
|
1882
|
+
const status = item.status;
|
|
1883
|
+
const entity = await this.catalog.create({
|
|
1884
|
+
type: "product",
|
|
1885
|
+
slug: item.slug,
|
|
1886
|
+
sourceStoreId: storeId,
|
|
1887
|
+
metadata: mergeMetadata(undefined, item.metadata ?? {}),
|
|
1888
|
+
...(status !== undefined ? { status, isVisible: status === "active" } : {}),
|
|
317
1889
|
}, actor);
|
|
318
|
-
if (!
|
|
319
|
-
return PluginErr(
|
|
1890
|
+
if (!entity.ok)
|
|
1891
|
+
return PluginErr(entity.error.message);
|
|
1892
|
+
entityId = entity.value.id;
|
|
1893
|
+
isNew = true;
|
|
1894
|
+
imported += 1;
|
|
1895
|
+
entityTouched = true;
|
|
1896
|
+
}
|
|
1897
|
+
const ownershipBeforeSeed = await this.catalog.resolveFieldOwners(entityId, storeId);
|
|
1898
|
+
const seedPaths = importedFieldPaths(item).filter((path) => !ownershipBeforeSeed.has(path));
|
|
1899
|
+
const seeded = await this.catalog.seedImportedFieldOwnership(entityId, storeId, seedPaths);
|
|
1900
|
+
if (!seeded.ok)
|
|
1901
|
+
return PluginErr(seeded.error.message);
|
|
1902
|
+
for (const path of seedPaths)
|
|
1903
|
+
ownershipBeforeSeed.set(path, "store");
|
|
1904
|
+
const owners = ownershipBeforeSeed;
|
|
1905
|
+
const outboundEcho = entityMapping ? this.isOutboundEcho(entityMapping, item) : false;
|
|
1906
|
+
const remoteChanged = entityMapping === undefined || entityMapping.syncHash !== remoteHash;
|
|
1907
|
+
// An unchanged remote item writes nothing and advances no baseline:
|
|
1908
|
+
// converging a stale replay would revert local edits to shared and
|
|
1909
|
+
// unowned fields that the store never actually changed.
|
|
1910
|
+
if (!force && !remoteChanged && existingEntity && existingEntity.status !== "archived") {
|
|
1911
|
+
continue;
|
|
1912
|
+
}
|
|
1913
|
+
const shared = existingEntity
|
|
1914
|
+
? await this.detectSharedConflicts(entityId, storeId, existingEntity, entityMapping, item, owners, importedFieldPaths(item), remoteHash, outboundEcho ? { certifiedPaths: new Set(entityMapping?.outboundFieldPaths ?? []) } : undefined)
|
|
1915
|
+
: { paths: [], conflicts: [] };
|
|
1916
|
+
const persistedConflicts = await this.persistCatalogConflicts(orgId, shared.conflicts, requireUserId(actor));
|
|
1917
|
+
if (!persistedConflicts.ok)
|
|
1918
|
+
return persistedConflicts;
|
|
1919
|
+
const owned = this.filterOwnedFields(item, owners);
|
|
1920
|
+
const heldSharedPaths = [...new Set([...(entityMapping?.heldFieldPaths ?? []), ...shared.paths])];
|
|
1921
|
+
// A newly held path revokes any force left from an earlier resolution of
|
|
1922
|
+
// that same path: the force was the operator's answer to a question that
|
|
1923
|
+
// has since been asked again, and it must not pre-empt the new one.
|
|
1924
|
+
const survivingForcedPaths = (entityMapping?.forcedPushFieldPaths ?? []).filter((path) => !heldSharedPaths.includes(path));
|
|
1925
|
+
const held = this.filterConflictingFields(owned.writable, heldSharedPaths);
|
|
1926
|
+
const writable = held.writable;
|
|
1927
|
+
const blockedPaths = new Set([...owned.skipped, ...heldSharedPaths]);
|
|
1928
|
+
skipped.push(...owned.skipped.map((fieldPath) => ({ entityId, fieldPath })));
|
|
1929
|
+
conflicts.push(...shared.conflicts.map(({ platformValue: _platformValue, storeValue: _storeValue, ...conflict }) => conflict));
|
|
1930
|
+
for (const conflict of shared.conflicts) {
|
|
1931
|
+
warnings.push(`Held shared field conflict for entity "${conflict.entityId}", store "${conflict.storeId}", field "${conflict.fieldPath}" (local ${conflict.localValueSummary}, remote ${conflict.remoteValueSummary}).`);
|
|
1932
|
+
}
|
|
1933
|
+
if (existingEntity && entityMapping) {
|
|
1934
|
+
const remoteMetadata = mergeMetadata(existingEntity.metadata, writable.metadata ?? {});
|
|
1935
|
+
const remoteStatus = ownerAllows(owners, "entity.status") && !blockedPaths.has("entity.status")
|
|
1936
|
+
? writable.status ?? (existingEntity.status === "archived" ? "active" : undefined)
|
|
1937
|
+
: undefined;
|
|
1938
|
+
const updateInput = {};
|
|
1939
|
+
if (ownerAllows(owners, "entity.slug") && !blockedPaths.has("entity.slug") && existingEntity.slug !== writable.slug) {
|
|
1940
|
+
updateInput.slug = writable.slug;
|
|
1941
|
+
}
|
|
1942
|
+
if (hash(remoteMetadata) !== hash(existingEntity.metadata ?? {}))
|
|
1943
|
+
updateInput.metadata = remoteMetadata;
|
|
1944
|
+
if (remoteStatus !== undefined && !blockedPaths.has("entity.status") && remoteStatus !== existingEntity.status) {
|
|
1945
|
+
updateInput.status = remoteStatus;
|
|
1946
|
+
updateInput.isVisible = remoteStatus === "active";
|
|
1947
|
+
}
|
|
1948
|
+
const shouldUpdate = force
|
|
1949
|
+
? Object.keys(updateInput).length > 0
|
|
1950
|
+
: remoteChanged || existingEntity.status === "archived";
|
|
1951
|
+
if (shouldUpdate) {
|
|
1952
|
+
converged += 1;
|
|
1953
|
+
if (Object.keys(updateInput).length > 0) {
|
|
1954
|
+
const updated = await this.catalog.update(entityMapping.entityId, updateInput, actor, CHANNEL_CONVERGENCE_CTX);
|
|
1955
|
+
if (!updated.ok)
|
|
1956
|
+
return PluginErr(updated.error.message);
|
|
1957
|
+
entityTouched = true;
|
|
1958
|
+
}
|
|
1959
|
+
}
|
|
1960
|
+
}
|
|
1961
|
+
const optionAxes = await this.upsertOptionAxes(entityId, writable, actor);
|
|
1962
|
+
if (!optionAxes.ok)
|
|
1963
|
+
return optionAxes;
|
|
1964
|
+
const attributes = await this.setCatalogAttributesIfWritable(entityId, writable, actor, blockedPaths, CHANNEL_CONVERGENCE_CTX);
|
|
1965
|
+
if (!attributes.ok)
|
|
1966
|
+
return attributes;
|
|
1967
|
+
const variantIds = await this.upsertVariants(orgId, storeId, entityId, writable, optionAxes.value.value, actor, warnings, !heldSharedPaths.includes("options") && owners.get("options") !== "platform", item);
|
|
1968
|
+
if (!variantIds.ok)
|
|
1969
|
+
return variantIds;
|
|
1970
|
+
const taxonomy = await this.applyTaxonomy(orgId, entityId, writable, actor, warnings);
|
|
1971
|
+
if (!taxonomy.ok)
|
|
1972
|
+
return taxonomy;
|
|
1973
|
+
const media = await this.applyMedia(orgId, entityId, writable, variantIds.value.value, actor, warnings, owners);
|
|
1974
|
+
if (!media.ok)
|
|
1975
|
+
return media;
|
|
1976
|
+
attributesCreated += attributes.value.created;
|
|
1977
|
+
mediaImported += media.value.imported;
|
|
1978
|
+
variantsGivenOptionValues += variantIds.value.repaired;
|
|
1979
|
+
skipped.push(...media.value.skipped.map((fieldPath) => ({ entityId, fieldPath })));
|
|
1980
|
+
entityTouched = entityTouched || optionAxes.value.changed || variantIds.value.changed || media.value.changed || attributes.value.changed;
|
|
1981
|
+
if (entityTouched)
|
|
1982
|
+
entitiesTouched += 1;
|
|
1983
|
+
if (entityTouched) {
|
|
1984
|
+
const revision = await this.catalog.recordEntityRevision(entityId, actor, "import");
|
|
1985
|
+
if (!revision.ok)
|
|
1986
|
+
return PluginErr(revision.error.message);
|
|
1987
|
+
}
|
|
1988
|
+
const revisionMarkers = await this.catalog.repository.findRevisionMarkers(entityId);
|
|
1989
|
+
const latestRevisionAt = revisionMarkers.at(-1)?.createdAt;
|
|
1990
|
+
const lastSyncedAt = latestRevisionAt ?? entityMapping?.lastSyncedAt ?? new Date();
|
|
1991
|
+
if (isNew) {
|
|
320
1992
|
await this.db.insert(channelEntityMap).values({
|
|
321
1993
|
organizationId: orgId,
|
|
322
1994
|
storeId,
|
|
323
|
-
kind: "
|
|
324
|
-
externalId:
|
|
325
|
-
entityId
|
|
326
|
-
|
|
327
|
-
|
|
1995
|
+
kind: "entity",
|
|
1996
|
+
externalId: item.externalId,
|
|
1997
|
+
entityId,
|
|
1998
|
+
syncHash: remoteHash,
|
|
1999
|
+
lastSyncedAt,
|
|
2000
|
+
heldFieldPaths: heldSharedPaths,
|
|
2001
|
+
forcedPushFieldPaths: survivingForcedPaths,
|
|
328
2002
|
});
|
|
329
2003
|
}
|
|
330
|
-
|
|
2004
|
+
else if (entityMapping) {
|
|
2005
|
+
await this.db.update(channelEntityMap).set({
|
|
2006
|
+
syncHash: remoteHash,
|
|
2007
|
+
lastSyncedAt,
|
|
2008
|
+
heldFieldPaths: heldSharedPaths,
|
|
2009
|
+
forcedPushFieldPaths: survivingForcedPaths,
|
|
2010
|
+
}).where(eq(channelEntityMap.id, entityMapping.id));
|
|
2011
|
+
}
|
|
2012
|
+
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
2013
|
}
|
|
332
|
-
return Ok({
|
|
2014
|
+
return Ok({
|
|
2015
|
+
imported,
|
|
2016
|
+
converged,
|
|
2017
|
+
entitiesTouched,
|
|
2018
|
+
attributesCreated,
|
|
2019
|
+
mediaImported,
|
|
2020
|
+
variantsGivenOptionValues,
|
|
2021
|
+
skipped,
|
|
2022
|
+
conflicts,
|
|
2023
|
+
warnings,
|
|
2024
|
+
});
|
|
333
2025
|
}
|
|
334
2026
|
async reconcile(orgId, storeId, actor) {
|
|
335
2027
|
const store = await this.getStoreRecord(orgId, storeId);
|
|
@@ -354,11 +2046,17 @@ export class ChannelConnectorService {
|
|
|
354
2046
|
return converged;
|
|
355
2047
|
const present = new Set(items.map((item) => item.externalId));
|
|
356
2048
|
let archived = 0;
|
|
2049
|
+
const skipped = [...converged.value.skipped];
|
|
357
2050
|
for (const mapping of entityMappings) {
|
|
358
2051
|
if (present.has(mapping.externalId))
|
|
359
2052
|
continue;
|
|
360
2053
|
const [entity] = await this.db.select({ status: sellableEntities.status }).from(sellableEntities).where(and(eq(sellableEntities.organizationId, orgId), eq(sellableEntities.id, mapping.entityId)));
|
|
361
2054
|
if (entity?.status !== "archived") {
|
|
2055
|
+
const owners = await this.catalog.resolveFieldOwners(mapping.entityId, storeId);
|
|
2056
|
+
if (owners.get("entity.status") === "platform") {
|
|
2057
|
+
skipped.push({ entityId: mapping.entityId, fieldPath: "entity.status" });
|
|
2058
|
+
continue;
|
|
2059
|
+
}
|
|
362
2060
|
const result = await this.catalog.archive(mapping.entityId, actor);
|
|
363
2061
|
if (!result.ok)
|
|
364
2062
|
return PluginErr(result.error.message);
|
|
@@ -389,12 +2087,17 @@ export class ChannelConnectorService {
|
|
|
389
2087
|
inventoryUpdated += 1;
|
|
390
2088
|
}
|
|
391
2089
|
const threshold = this.options.driftAlertThreshold ?? 25;
|
|
2090
|
+
const openConflictRows = await this.db.select({ id: channelCatalogConflicts.id }).from(channelCatalogConflicts).where(and(eq(channelCatalogConflicts.organizationId, orgId), eq(channelCatalogConflicts.storeId, storeId), eq(channelCatalogConflicts.state, "open")));
|
|
392
2091
|
const report = {
|
|
393
2092
|
imported: converged.value.imported,
|
|
394
2093
|
converged: converged.value.converged,
|
|
395
2094
|
archived,
|
|
396
2095
|
inventoryUpdated,
|
|
2096
|
+
openConflicts: openConflictRows.length,
|
|
397
2097
|
driftAlert: converged.value.imported + converged.value.converged + archived > threshold,
|
|
2098
|
+
...(skipped.length > 0 ? { skipped: uniqueSkipped(skipped) } : {}),
|
|
2099
|
+
...(converged.value.conflicts.length > 0 ? { conflicts: converged.value.conflicts } : {}),
|
|
2100
|
+
...(converged.value.warnings.length > 0 ? { warnings: converged.value.warnings } : {}),
|
|
398
2101
|
};
|
|
399
2102
|
await this.db.update(connectedStores).set({
|
|
400
2103
|
lastReconcileAt: new Date(),
|
|
@@ -411,6 +2114,134 @@ export class ChannelConnectorService {
|
|
|
411
2114
|
const report = store.lastReconcileReport;
|
|
412
2115
|
return Ok({ lastReconcileAt: store.lastReconcileAt, report, driftAlert: report?.driftAlert ?? false });
|
|
413
2116
|
}
|
|
2117
|
+
async listCatalogConflicts(orgId, storeId, state = "open") {
|
|
2118
|
+
const conditions = [eq(channelCatalogConflicts.organizationId, orgId), eq(channelCatalogConflicts.state, state)];
|
|
2119
|
+
if (storeId !== undefined)
|
|
2120
|
+
conditions.push(eq(channelCatalogConflicts.storeId, storeId));
|
|
2121
|
+
return Ok(await this.db.select().from(channelCatalogConflicts).where(and(...conditions)));
|
|
2122
|
+
}
|
|
2123
|
+
async resolveCatalogConflict(orgId, id, choose, actor) {
|
|
2124
|
+
const [conflict] = await this.db.select().from(channelCatalogConflicts).where(and(eq(channelCatalogConflicts.organizationId, orgId), eq(channelCatalogConflicts.id, id), eq(channelCatalogConflicts.state, "open")));
|
|
2125
|
+
if (!conflict)
|
|
2126
|
+
return PluginErr("Catalog conflict not found or already resolved.", "NOT_FOUND");
|
|
2127
|
+
if (choose === "platform" && !this.jobs)
|
|
2128
|
+
return PluginErr("Jobs are not configured.", "JOBS_UNAVAILABLE");
|
|
2129
|
+
const [mapping] = await this.db.select().from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, conflict.storeId), eq(channelEntityMap.kind, "entity"), eq(channelEntityMap.entityId, conflict.entityId)));
|
|
2130
|
+
if (!mapping)
|
|
2131
|
+
return PluginErr("Catalog conflict mapping not found.", "NOT_FOUND");
|
|
2132
|
+
const systemActor = createSystemActor(orgId);
|
|
2133
|
+
let resolutionBaselineAt;
|
|
2134
|
+
if (choose === "store") {
|
|
2135
|
+
const applied = await this.applyStoreConflictValue(orgId, conflict, systemActor);
|
|
2136
|
+
if (!applied.ok)
|
|
2137
|
+
return PluginErr(applied.error, applied.code);
|
|
2138
|
+
const revisions = await this.catalog.repository.findRevisionMarkers(conflict.entityId);
|
|
2139
|
+
resolutionBaselineAt = revisions.at(-1)?.createdAt;
|
|
2140
|
+
}
|
|
2141
|
+
const heldFieldPaths = (mapping.heldFieldPaths ?? []).filter((path) => path !== conflict.fieldPath);
|
|
2142
|
+
const forcedPushFieldPaths = choose === "platform"
|
|
2143
|
+
? [...new Set([...(mapping.forcedPushFieldPaths ?? []), conflict.fieldPath])]
|
|
2144
|
+
: mapping.forcedPushFieldPaths ?? [];
|
|
2145
|
+
const [resolved] = await this.db.update(channelCatalogConflicts).set({
|
|
2146
|
+
state: "resolved",
|
|
2147
|
+
resolvedBy: requireUserId(actor),
|
|
2148
|
+
updatedAt: new Date(),
|
|
2149
|
+
}).where(and(eq(channelCatalogConflicts.organizationId, orgId), eq(channelCatalogConflicts.id, id), eq(channelCatalogConflicts.state, "open"))).returning();
|
|
2150
|
+
if (!resolved)
|
|
2151
|
+
return PluginErr("Catalog conflict not found or already resolved.", "NOT_FOUND");
|
|
2152
|
+
await this.db.update(channelEntityMap).set({
|
|
2153
|
+
heldFieldPaths,
|
|
2154
|
+
forcedPushFieldPaths,
|
|
2155
|
+
...(resolutionBaselineAt ? { lastSyncedAt: resolutionBaselineAt } : {}),
|
|
2156
|
+
}).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.id, mapping.id)));
|
|
2157
|
+
await this.db.insert(channelCatalogConflictEvents).values({
|
|
2158
|
+
organizationId: orgId,
|
|
2159
|
+
conflictId: conflict.id,
|
|
2160
|
+
fromState: "open",
|
|
2161
|
+
toState: "resolved",
|
|
2162
|
+
reason: `Operator chose the ${choose} value.`,
|
|
2163
|
+
changedBy: requireUserId(actor),
|
|
2164
|
+
});
|
|
2165
|
+
if (choose === "platform") {
|
|
2166
|
+
await this.jobs.enqueue("channel/push-catalog", {
|
|
2167
|
+
organizationId: orgId,
|
|
2168
|
+
storeId: conflict.storeId,
|
|
2169
|
+
entityIds: [conflict.entityId],
|
|
2170
|
+
}, {
|
|
2171
|
+
organizationId: orgId,
|
|
2172
|
+
concurrencyKey: catalogPushConcurrencyKey({ storeId: conflict.storeId, entityIds: [conflict.entityId] }),
|
|
2173
|
+
supersedes: true,
|
|
2174
|
+
});
|
|
2175
|
+
}
|
|
2176
|
+
return Ok(resolved);
|
|
2177
|
+
}
|
|
2178
|
+
async applyStoreConflictValue(orgId, conflict, actor) {
|
|
2179
|
+
const [root, segment, field] = conflict.fieldPath.split(".");
|
|
2180
|
+
if (root === "entity" && segment === "slug") {
|
|
2181
|
+
if (typeof conflict.storeValue !== "string")
|
|
2182
|
+
return PluginErr("The stored catalog value is not a valid slug.", "INVALID_CONFLICT_VALUE");
|
|
2183
|
+
const updated = await this.catalog.update(conflict.entityId, { slug: conflict.storeValue }, actor, CHANNEL_CONVERGENCE_CTX);
|
|
2184
|
+
return updated.ok ? Ok(undefined) : PluginErr(updated.error.message);
|
|
2185
|
+
}
|
|
2186
|
+
if (root === "entity" && segment === "status") {
|
|
2187
|
+
if (typeof conflict.storeValue !== "string")
|
|
2188
|
+
return PluginErr("The stored catalog value is not a valid status.", "INVALID_CONFLICT_VALUE");
|
|
2189
|
+
const status = ["draft", "active", "archived", "discontinued"].find((value) => value === conflict.storeValue);
|
|
2190
|
+
if (!status)
|
|
2191
|
+
return PluginErr("The stored catalog value is not a valid status.", "INVALID_CONFLICT_VALUE");
|
|
2192
|
+
const updated = await this.catalog.update(conflict.entityId, { status, isVisible: status === "active" }, actor, CHANNEL_CONVERGENCE_CTX);
|
|
2193
|
+
return updated.ok ? Ok(undefined) : PluginErr(updated.error.message);
|
|
2194
|
+
}
|
|
2195
|
+
if (root === "entity" && segment === "metadata" && field) {
|
|
2196
|
+
const [entity] = await this.db.select().from(sellableEntities).where(and(eq(sellableEntities.organizationId, orgId), eq(sellableEntities.id, conflict.entityId)));
|
|
2197
|
+
if (!entity)
|
|
2198
|
+
return PluginErr("Catalog entity not found.", "NOT_FOUND");
|
|
2199
|
+
const updated = await this.catalog.update(conflict.entityId, {
|
|
2200
|
+
metadata: { ...(entity.metadata ?? {}), [field]: conflict.storeValue },
|
|
2201
|
+
}, actor, CHANNEL_CONVERGENCE_CTX);
|
|
2202
|
+
return updated.ok ? Ok(undefined) : PluginErr(updated.error.message);
|
|
2203
|
+
}
|
|
2204
|
+
if (root === "attributes" && segment && field && attributeFields.some((attributeField) => attributeField === field)) {
|
|
2205
|
+
const [attribute] = await this.db.select().from(sellableAttributes).where(and(eq(sellableAttributes.entityId, conflict.entityId), eq(sellableAttributes.locale, segment)));
|
|
2206
|
+
const title = attribute?.title ?? "";
|
|
2207
|
+
const attrs = { title };
|
|
2208
|
+
if (field === "title") {
|
|
2209
|
+
if (typeof conflict.storeValue !== "string")
|
|
2210
|
+
return PluginErr("The stored catalog value is not valid text.", "INVALID_CONFLICT_VALUE");
|
|
2211
|
+
attrs.title = conflict.storeValue;
|
|
2212
|
+
}
|
|
2213
|
+
else if (field === "subtitle") {
|
|
2214
|
+
if (typeof conflict.storeValue !== "string")
|
|
2215
|
+
return PluginErr("The stored catalog value is not valid text.", "INVALID_CONFLICT_VALUE");
|
|
2216
|
+
attrs.subtitle = conflict.storeValue;
|
|
2217
|
+
}
|
|
2218
|
+
else if (field === "description") {
|
|
2219
|
+
if (typeof conflict.storeValue !== "string")
|
|
2220
|
+
return PluginErr("The stored catalog value is not valid text.", "INVALID_CONFLICT_VALUE");
|
|
2221
|
+
attrs.description = conflict.storeValue;
|
|
2222
|
+
}
|
|
2223
|
+
else if (field === "richDescription") {
|
|
2224
|
+
attrs.richDescription = conflict.storeValue;
|
|
2225
|
+
}
|
|
2226
|
+
else if (field === "seoTitle") {
|
|
2227
|
+
if (typeof conflict.storeValue !== "string")
|
|
2228
|
+
return PluginErr("The stored catalog value is not valid text.", "INVALID_CONFLICT_VALUE");
|
|
2229
|
+
attrs.seoTitle = conflict.storeValue;
|
|
2230
|
+
}
|
|
2231
|
+
else if (field === "seoDescription") {
|
|
2232
|
+
if (typeof conflict.storeValue !== "string")
|
|
2233
|
+
return PluginErr("The stored catalog value is not valid text.", "INVALID_CONFLICT_VALUE");
|
|
2234
|
+
attrs.seoDescription = conflict.storeValue;
|
|
2235
|
+
}
|
|
2236
|
+
const updated = await this.catalog.setAttributes(conflict.entityId, segment, attrs, actor, CHANNEL_CONVERGENCE_CTX);
|
|
2237
|
+
return updated.ok ? Ok(undefined) : PluginErr(updated.error.message);
|
|
2238
|
+
}
|
|
2239
|
+
if (root === "customFields" && segment && field === "en") {
|
|
2240
|
+
const updated = await this.catalog.update(conflict.entityId, { customFields: { [segment]: conflict.storeValue } }, actor, CHANNEL_CONVERGENCE_CTX);
|
|
2241
|
+
return updated.ok ? Ok(undefined) : PluginErr(updated.error.message);
|
|
2242
|
+
}
|
|
2243
|
+
return PluginErr(`Conflict field path "${conflict.fieldPath}" cannot be resolved to the store value.`, "UNSUPPORTED_CONFLICT_FIELD");
|
|
2244
|
+
}
|
|
414
2245
|
async syncInventory(orgId, storeId, actor) {
|
|
415
2246
|
const store = await this.getStoreRecord(orgId, storeId);
|
|
416
2247
|
if (!store || store.status !== "connected")
|
|
@@ -447,19 +2278,34 @@ export class ChannelConnectorService {
|
|
|
447
2278
|
return PluginErr("Connected store not found.", "NOT_FOUND");
|
|
448
2279
|
const actor = createSystemActor(orgId);
|
|
449
2280
|
const data = event.data;
|
|
2281
|
+
let skipped = [];
|
|
2282
|
+
let conflicts = [];
|
|
2283
|
+
let warnings = [];
|
|
450
2284
|
if (event.type === "products/update") {
|
|
451
2285
|
const productId = String(data.id ?? data.product_id ?? "");
|
|
452
2286
|
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);
|
|
2287
|
+
if (mapping[0]) {
|
|
2288
|
+
const converged = await this.convergeCatalogItem(orgId, storeId, mapping[0].entityId, data, actor);
|
|
2289
|
+
if (!converged.ok)
|
|
2290
|
+
return converged;
|
|
2291
|
+
skipped = converged.value.skipped;
|
|
2292
|
+
conflicts = converged.value.conflicts;
|
|
2293
|
+
warnings = converged.value.warnings;
|
|
2294
|
+
}
|
|
455
2295
|
}
|
|
456
2296
|
else if (event.type === "products/delete") {
|
|
457
2297
|
const productId = String(data.id ?? data.product_id ?? "");
|
|
458
2298
|
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
2299
|
if (mapping[0]) {
|
|
460
|
-
const
|
|
461
|
-
if (
|
|
462
|
-
|
|
2300
|
+
const owners = await this.catalog.resolveFieldOwners(mapping[0].entityId, storeId);
|
|
2301
|
+
if (owners.get("entity.status") === "platform") {
|
|
2302
|
+
skipped.push({ entityId: mapping[0].entityId, fieldPath: "entity.status" });
|
|
2303
|
+
}
|
|
2304
|
+
else {
|
|
2305
|
+
const archived = await this.catalog.archive(mapping[0].entityId, actor);
|
|
2306
|
+
if (!archived.ok)
|
|
2307
|
+
return PluginErr(archived.error.message);
|
|
2308
|
+
}
|
|
463
2309
|
}
|
|
464
2310
|
}
|
|
465
2311
|
else if (event.type === "inventory_levels/update") {
|
|
@@ -513,6 +2359,15 @@ export class ChannelConnectorService {
|
|
|
513
2359
|
return disconnected;
|
|
514
2360
|
return Ok({ processed: true });
|
|
515
2361
|
}
|
|
2362
|
+
if (skipped.length > 0 || conflicts.length > 0 || warnings.length > 0) {
|
|
2363
|
+
const report = {
|
|
2364
|
+
...(store.lastReconcileReport ?? {}),
|
|
2365
|
+
...(skipped.length > 0 ? { skipped: uniqueSkipped(skipped) } : {}),
|
|
2366
|
+
...(conflicts.length > 0 ? { conflicts } : {}),
|
|
2367
|
+
...(warnings.length > 0 ? { warnings } : {}),
|
|
2368
|
+
};
|
|
2369
|
+
await this.db.update(connectedStores).set({ lastReconcileReport: report, updatedAt: new Date() }).where(and(eq(connectedStores.organizationId, orgId), eq(connectedStores.id, storeId)));
|
|
2370
|
+
}
|
|
516
2371
|
return Ok({ processed: true });
|
|
517
2372
|
}
|
|
518
2373
|
complianceEmail(data) {
|
|
@@ -571,6 +2426,122 @@ export class ChannelConnectorService {
|
|
|
571
2426
|
}
|
|
572
2427
|
async convergeCatalogItem(orgId, storeId, entityId, data, actor) {
|
|
573
2428
|
const product = data.product && typeof data.product === "object" ? data.product : data;
|
|
2429
|
+
const remoteMetadata = product.metadata && typeof product.metadata === "object" && !Array.isArray(product.metadata)
|
|
2430
|
+
? product.metadata
|
|
2431
|
+
: {};
|
|
2432
|
+
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)));
|
|
2433
|
+
const [entity] = await this.db.select().from(sellableEntities).where(and(eq(sellableEntities.organizationId, orgId), eq(sellableEntities.id, entityId)));
|
|
2434
|
+
if (!mapping || !entity)
|
|
2435
|
+
return Ok({ skipped: [], conflicts: [], warnings: [] });
|
|
2436
|
+
const [currentAttribute] = await this.db.select().from(sellableAttributes).where(and(eq(sellableAttributes.entityId, entityId), eq(sellableAttributes.locale, "en")));
|
|
2437
|
+
const title = typeof product.title === "string" ? product.title : currentAttribute?.title ?? entity.slug;
|
|
2438
|
+
const description = product.description !== undefined
|
|
2439
|
+
? String(product.description)
|
|
2440
|
+
: currentAttribute?.description ?? undefined;
|
|
2441
|
+
const status = typeof product.status === "string" && ["draft", "active", "archived", "discontinued"].includes(product.status)
|
|
2442
|
+
? product.status
|
|
2443
|
+
: undefined;
|
|
2444
|
+
const customFields = product.customFields && typeof product.customFields === "object" && !Array.isArray(product.customFields)
|
|
2445
|
+
? product.customFields
|
|
2446
|
+
: undefined;
|
|
2447
|
+
const images = Array.isArray(product.images)
|
|
2448
|
+
? product.images.flatMap((raw) => {
|
|
2449
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
2450
|
+
return [];
|
|
2451
|
+
const image = raw;
|
|
2452
|
+
const role = typeof image.role === "string" ? pushCatalogImageRole(image.role) : undefined;
|
|
2453
|
+
const url = typeof image.url === "string" ? image.url : typeof image.src === "string" ? image.src : undefined;
|
|
2454
|
+
return role && url ? [{ role, url }] : [];
|
|
2455
|
+
})
|
|
2456
|
+
: [];
|
|
2457
|
+
const remoteItem = {
|
|
2458
|
+
externalId: mapping.externalId,
|
|
2459
|
+
slug: typeof product.slug === "string" ? product.slug : entity.slug,
|
|
2460
|
+
title,
|
|
2461
|
+
...(description !== undefined ? { description } : {}),
|
|
2462
|
+
...(status !== undefined ? { status } : {}),
|
|
2463
|
+
attributes: [{ locale: "en", title, ...(description !== undefined ? { description } : {}) }],
|
|
2464
|
+
...(Object.keys(remoteMetadata).length > 0 ? { metadata: remoteMetadata } : {}),
|
|
2465
|
+
...(customFields !== undefined ? { customFields } : {}),
|
|
2466
|
+
...(images.length > 0 ? { images } : {}),
|
|
2467
|
+
variants: [],
|
|
2468
|
+
};
|
|
2469
|
+
const fieldPaths = [];
|
|
2470
|
+
if (typeof product.slug === "string")
|
|
2471
|
+
fieldPaths.push("entity.slug");
|
|
2472
|
+
if (status !== undefined)
|
|
2473
|
+
fieldPaths.push("entity.status");
|
|
2474
|
+
for (const key of Object.keys(remoteMetadata)) {
|
|
2475
|
+
const path = `entity.metadata.${key}`;
|
|
2476
|
+
if (isValidFieldPath(path))
|
|
2477
|
+
fieldPaths.push(path);
|
|
2478
|
+
}
|
|
2479
|
+
if (typeof product.title === "string")
|
|
2480
|
+
fieldPaths.push("attributes.en.title");
|
|
2481
|
+
if (product.description !== undefined)
|
|
2482
|
+
fieldPaths.push("attributes.en.description");
|
|
2483
|
+
for (const [name, locales] of Object.entries(customFields ?? {})) {
|
|
2484
|
+
if (!locales || typeof locales !== "object" || Array.isArray(locales))
|
|
2485
|
+
continue;
|
|
2486
|
+
for (const locale of Object.keys(locales)) {
|
|
2487
|
+
const path = `customFields.${name}.${locale}`;
|
|
2488
|
+
if (isValidFieldPath(path))
|
|
2489
|
+
fieldPaths.push(path);
|
|
2490
|
+
}
|
|
2491
|
+
}
|
|
2492
|
+
for (const image of images)
|
|
2493
|
+
fieldPaths.push(`media.${image.role}`);
|
|
2494
|
+
const ownershipBeforeSeed = await this.catalog.resolveFieldOwners(entityId, storeId);
|
|
2495
|
+
const seedPaths = fieldPaths.filter((path) => !ownershipBeforeSeed.has(path));
|
|
2496
|
+
const seeded = await this.catalog.seedImportedFieldOwnership(entityId, storeId, seedPaths);
|
|
2497
|
+
if (!seeded.ok)
|
|
2498
|
+
return PluginErr(seeded.error.message);
|
|
2499
|
+
for (const path of seedPaths)
|
|
2500
|
+
ownershipBeforeSeed.set(path, "store");
|
|
2501
|
+
const owners = ownershipBeforeSeed;
|
|
2502
|
+
const remoteHash = hash(product);
|
|
2503
|
+
const outboundEcho = this.isOutboundEcho(mapping, remoteItem);
|
|
2504
|
+
const shared = await this.detectSharedConflicts(entityId, storeId, entity, mapping, remoteItem, owners, fieldPaths, remoteHash, outboundEcho ? { certifiedPaths: new Set(mapping.outboundFieldPaths ?? []) } : undefined);
|
|
2505
|
+
const persistedConflicts = await this.persistCatalogConflicts(orgId, shared.conflicts, requireUserId(actor));
|
|
2506
|
+
if (!persistedConflicts.ok)
|
|
2507
|
+
return persistedConflicts;
|
|
2508
|
+
const owned = this.filterOwnedFieldsAtPaths(remoteItem, owners, fieldPaths);
|
|
2509
|
+
const heldPaths = [...new Set([...(mapping.heldFieldPaths ?? []), ...shared.paths])];
|
|
2510
|
+
// Same revocation as the reconcile path: a newly held path cancels any force
|
|
2511
|
+
// left from an earlier resolution, so a webhook-raised conflict cannot be
|
|
2512
|
+
// pre-empted by an operator's answer to a previous one.
|
|
2513
|
+
const survivingForcedPaths = (mapping.forcedPushFieldPaths ?? []).filter((path) => !heldPaths.includes(path));
|
|
2514
|
+
const held = this.filterConflictingFields(owned.writable, heldPaths);
|
|
2515
|
+
const blockedPaths = new Set([
|
|
2516
|
+
...owned.skipped,
|
|
2517
|
+
...heldPaths,
|
|
2518
|
+
...(!fieldPaths.includes("attributes.en.title") ? ["attributes.en.title"] : []),
|
|
2519
|
+
]);
|
|
2520
|
+
const skipped = owned.skipped.map((fieldPath) => ({ entityId, fieldPath }));
|
|
2521
|
+
const conflicts = shared.conflicts.map(({ platformValue: _platformValue, storeValue: _storeValue, ...conflict }) => conflict);
|
|
2522
|
+
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}).`);
|
|
2523
|
+
const writable = held.writable;
|
|
2524
|
+
const updateInput = {};
|
|
2525
|
+
if (fieldPaths.includes("entity.slug") && ownerAllows(owners, "entity.slug") && !blockedPaths.has("entity.slug") && entity.slug !== writable.slug) {
|
|
2526
|
+
updateInput.slug = writable.slug;
|
|
2527
|
+
}
|
|
2528
|
+
if (Object.keys(writable.metadata ?? {}).length > 0) {
|
|
2529
|
+
const remoteEntityMetadata = mergeMetadata(entity.metadata, writable.metadata ?? {});
|
|
2530
|
+
if (hash(remoteEntityMetadata) !== hash(entity.metadata ?? {}))
|
|
2531
|
+
updateInput.metadata = remoteEntityMetadata;
|
|
2532
|
+
}
|
|
2533
|
+
if (fieldPaths.includes("entity.status") && ownerAllows(owners, "entity.status") && !blockedPaths.has("entity.status") && typeof writable.status === "string" && writable.status !== entity.status) {
|
|
2534
|
+
updateInput.status = writable.status;
|
|
2535
|
+
updateInput.isVisible = writable.status === "active";
|
|
2536
|
+
}
|
|
2537
|
+
if (Object.keys(updateInput).length > 0) {
|
|
2538
|
+
const updated = await this.catalog.update(entityId, updateInput, actor, CHANNEL_CONVERGENCE_CTX);
|
|
2539
|
+
if (!updated.ok)
|
|
2540
|
+
return PluginErr(updated.error.message);
|
|
2541
|
+
}
|
|
2542
|
+
const attributes = await this.setCatalogAttributesIfWritable(entityId, writable, actor, blockedPaths, CHANNEL_CONVERGENCE_CTX);
|
|
2543
|
+
if (!attributes.ok)
|
|
2544
|
+
return attributes;
|
|
574
2545
|
const levels = Array.isArray(product.variants) ? product.variants : [];
|
|
575
2546
|
for (const variant of levels) {
|
|
576
2547
|
const externalId = String(variant.id ?? variant.variation_id ?? "");
|
|
@@ -578,6 +2549,15 @@ export class ChannelConnectorService {
|
|
|
578
2549
|
if (externalId && available !== undefined)
|
|
579
2550
|
await this.setMappedInventory(orgId, storeId, externalId, Number(available), actor);
|
|
580
2551
|
}
|
|
2552
|
+
const revisionMarkers = await this.catalog.repository.findRevisionMarkers(entityId);
|
|
2553
|
+
const lastSyncedAt = revisionMarkers.at(-1)?.createdAt ?? mapping.lastSyncedAt;
|
|
2554
|
+
await this.db.update(channelEntityMap).set({
|
|
2555
|
+
syncHash: remoteHash,
|
|
2556
|
+
lastSyncedAt,
|
|
2557
|
+
heldFieldPaths: heldPaths,
|
|
2558
|
+
forcedPushFieldPaths: survivingForcedPaths,
|
|
2559
|
+
}).where(eq(channelEntityMap.id, mapping.id));
|
|
2560
|
+
return Ok({ skipped, conflicts, warnings });
|
|
581
2561
|
}
|
|
582
2562
|
async createRefundRequest(orgId, store, data, actor) {
|
|
583
2563
|
const remoteRefundId = String(data.id ?? data.refund_id ?? "");
|
|
@@ -613,9 +2593,9 @@ export class ChannelConnectorService {
|
|
|
613
2593
|
const max = this.options.refundAutoMax ?? order.amountCaptured ?? order.grandTotal;
|
|
614
2594
|
const ageOk = Date.now() - store.createdAt.getTime() >= (this.options.newStoreDays ?? 7) * 86_400_000;
|
|
615
2595
|
const auto = clean && amount > 0 && ageOk && amount <= max;
|
|
616
|
-
const rows = await this.db.insert(channelRefundRequests).values({ organizationId: orgId, storeId: store.id, orderId, remoteRefundId, amount, state: auto ? "approved" : "requested", approvedBy: auto ? actor
|
|
2596
|
+
const rows = await this.db.insert(channelRefundRequests).values({ organizationId: orgId, storeId: store.id, orderId, remoteRefundId, amount, state: auto ? "approved" : "requested", approvedBy: auto ? requireUserId(actor) : null }).returning();
|
|
617
2597
|
const request = rows[0];
|
|
618
|
-
await this.db.insert(channelRefundEvents).values({ organizationId: orgId, requestId: request.id, fromState: null, toState: request.state, reason: auto ? "Automatic guarded refund" : "Operator approval required", changedBy: actor
|
|
2598
|
+
await this.db.insert(channelRefundEvents).values({ organizationId: orgId, requestId: request.id, fromState: null, toState: request.state, reason: auto ? "Automatic guarded refund" : "Operator approval required", changedBy: requireUserId(actor) });
|
|
619
2599
|
if (auto) {
|
|
620
2600
|
const result = await this.executeRefund(request, refundLines, actor);
|
|
621
2601
|
if (!result.ok)
|
|
@@ -629,7 +2609,7 @@ export class ChannelConnectorService {
|
|
|
629
2609
|
if (!result.ok)
|
|
630
2610
|
return PluginErr(result.error?.message ?? "Refund execution failed.");
|
|
631
2611
|
const [updated] = await this.db.update(channelRefundRequests).set({ state: "executed", updatedAt: new Date() }).where(and(eq(channelRefundRequests.organizationId, request.organizationId), eq(channelRefundRequests.id, request.id), eq(channelRefundRequests.state, "approved"))).returning();
|
|
632
|
-
await this.db.insert(channelRefundEvents).values({ organizationId: request.organizationId, requestId: request.id, fromState: "approved", toState: "executed", reason: "Platform refund executed", changedBy: actor
|
|
2612
|
+
await this.db.insert(channelRefundEvents).values({ organizationId: request.organizationId, requestId: request.id, fromState: "approved", toState: "executed", reason: "Platform refund executed", changedBy: requireUserId(actor) });
|
|
633
2613
|
return Ok(updated);
|
|
634
2614
|
}
|
|
635
2615
|
async listRefundRequests(orgId) {
|
|
@@ -728,7 +2708,7 @@ export class ChannelConnectorService {
|
|
|
728
2708
|
if (created.value.state === "confirmed")
|
|
729
2709
|
return created;
|
|
730
2710
|
if (created.value.state !== "exported") {
|
|
731
|
-
const exported = await this.transitionExport(orgId, created.value.id, "exported", actor
|
|
2711
|
+
const exported = await this.transitionExport(orgId, created.value.id, "exported", requireUserId(actor), "Export attempt started.");
|
|
732
2712
|
if (!exported.ok)
|
|
733
2713
|
return exported;
|
|
734
2714
|
}
|
|
@@ -738,7 +2718,7 @@ export class ChannelConnectorService {
|
|
|
738
2718
|
.where(and(eq(channelOrderExports.organizationId, orgId), eq(channelOrderExports.id, created.value.id)));
|
|
739
2719
|
const pushed = await connector.pushOrder(store, slice);
|
|
740
2720
|
if (!pushed.ok) {
|
|
741
|
-
return this.transitionExport(orgId, created.value.id, "failed", actor
|
|
2721
|
+
return this.transitionExport(orgId, created.value.id, "failed", requireUserId(actor), pushed.error.message, pushed.error.retriable === true ? "transient" : "definitive");
|
|
742
2722
|
}
|
|
743
2723
|
await this.db
|
|
744
2724
|
.update(channelOrderExports)
|
|
@@ -750,13 +2730,13 @@ export class ChannelConnectorService {
|
|
|
750
2730
|
.where(and(eq(channelOrderExports.organizationId, orgId), eq(channelOrderExports.id, created.value.id)));
|
|
751
2731
|
const remoteStatus = await connector.fetchOrderStatus(store, pushed.value.remoteOrderId);
|
|
752
2732
|
if (!remoteStatus.ok) {
|
|
753
|
-
return this.transitionExport(orgId, created.value.id, "failed", actor
|
|
2733
|
+
return this.transitionExport(orgId, created.value.id, "failed", requireUserId(actor), remoteStatus.error.message, remoteStatus.error.retriable === true ? "transient" : "definitive");
|
|
754
2734
|
}
|
|
755
2735
|
if (remoteStatus.value.status === "confirmed") {
|
|
756
|
-
return this.transitionExport(orgId, created.value.id, "confirmed", actor
|
|
2736
|
+
return this.transitionExport(orgId, created.value.id, "confirmed", requireUserId(actor), "Remote order confirmed.");
|
|
757
2737
|
}
|
|
758
2738
|
if (remoteStatus.value.status === "failed" || remoteStatus.value.status === "cancelled") {
|
|
759
|
-
return this.transitionExport(orgId, created.value.id, "failed", actor
|
|
2739
|
+
return this.transitionExport(orgId, created.value.id, "failed", requireUserId(actor), `Remote order status: ${remoteStatus.value.status}.`);
|
|
760
2740
|
}
|
|
761
2741
|
const refreshed = await this.getExport(orgId, created.value.id);
|
|
762
2742
|
return refreshed;
|
|
@@ -845,4 +2825,347 @@ export class ChannelConnectorService {
|
|
|
845
2825
|
abandonExport(orgId, exportId, changedBy, reason) {
|
|
846
2826
|
return this.transitionExport(orgId, exportId, "abandoned", changedBy, reason);
|
|
847
2827
|
}
|
|
2828
|
+
async resolveCatalogPushEntityIds(orgId, storeId, entityIds) {
|
|
2829
|
+
if (entityIds !== undefined)
|
|
2830
|
+
return [...new Set(entityIds)].sort();
|
|
2831
|
+
const mappings = await this.db.select({ entityId: channelEntityMap.entityId }).from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.kind, "entity")));
|
|
2832
|
+
return [...new Set(mappings.map((mapping) => mapping.entityId))].sort();
|
|
2833
|
+
}
|
|
2834
|
+
async createCatalogPush(orgId, storeId, entityId) {
|
|
2835
|
+
const store = await this.getStoreRecord(orgId, storeId);
|
|
2836
|
+
if (!store || store.status !== "connected") {
|
|
2837
|
+
return PluginErr("Connected store not found.", "NOT_FOUND");
|
|
2838
|
+
}
|
|
2839
|
+
const rows = await this.db
|
|
2840
|
+
.insert(channelCatalogPushes)
|
|
2841
|
+
.values({ organizationId: orgId, storeId, entityId })
|
|
2842
|
+
.onConflictDoNothing({ target: [channelCatalogPushes.storeId, channelCatalogPushes.entityId] })
|
|
2843
|
+
.returning();
|
|
2844
|
+
if (rows[0])
|
|
2845
|
+
return Ok(rows[0]);
|
|
2846
|
+
const existing = await this.db
|
|
2847
|
+
.select()
|
|
2848
|
+
.from(channelCatalogPushes)
|
|
2849
|
+
.where(and(eq(channelCatalogPushes.organizationId, orgId), eq(channelCatalogPushes.storeId, storeId), eq(channelCatalogPushes.entityId, entityId)));
|
|
2850
|
+
if (!existing[0])
|
|
2851
|
+
return PluginErr("Failed to create channel catalog push.");
|
|
2852
|
+
return Ok(existing[0]);
|
|
2853
|
+
}
|
|
2854
|
+
async transitionCatalogPush(orgId, pushId, toState, changedBy, reason, failureKind, payloadSnapshot) {
|
|
2855
|
+
return this.transact(async (tx) => {
|
|
2856
|
+
const currentRows = await tx
|
|
2857
|
+
.select()
|
|
2858
|
+
.from(channelCatalogPushes)
|
|
2859
|
+
.where(and(eq(channelCatalogPushes.organizationId, orgId), eq(channelCatalogPushes.id, pushId)));
|
|
2860
|
+
const current = currentRows[0];
|
|
2861
|
+
if (!current)
|
|
2862
|
+
return PluginErr("Channel catalog push not found.", "NOT_FOUND");
|
|
2863
|
+
if (!canCatalogPushTransition(current.state, toState)) {
|
|
2864
|
+
const error = new CommerceInvalidTransitionError(`Cannot transition channel catalog push from ${current.state} to ${toState}.`);
|
|
2865
|
+
return PluginErr(error.message, error.code);
|
|
2866
|
+
}
|
|
2867
|
+
const updatedRows = await tx
|
|
2868
|
+
.update(channelCatalogPushes)
|
|
2869
|
+
.set({
|
|
2870
|
+
state: toState,
|
|
2871
|
+
updatedAt: new Date(),
|
|
2872
|
+
...(payloadSnapshot !== undefined ? { payloadSnapshot } : {}),
|
|
2873
|
+
...(toState === "exported" ? { attempts: current.attempts + 1, lastError: null, failureKind: null } : {}),
|
|
2874
|
+
...(toState === "failed" ? { lastError: reason ?? "Catalog push failed." } : {}),
|
|
2875
|
+
...(toState === "failed" ? { failureKind: failureKind ?? "definitive" } : {}),
|
|
2876
|
+
...(toState === "confirmed" ? { lastError: null, failureKind: null } : {}),
|
|
2877
|
+
})
|
|
2878
|
+
.where(and(eq(channelCatalogPushes.organizationId, orgId), eq(channelCatalogPushes.id, pushId), eq(channelCatalogPushes.state, current.state)))
|
|
2879
|
+
.returning();
|
|
2880
|
+
const updated = updatedRows[0];
|
|
2881
|
+
if (!updated)
|
|
2882
|
+
return PluginErr("Channel catalog push changed concurrently.", "CONFLICT");
|
|
2883
|
+
await tx.insert(channelCatalogPushEvents).values({
|
|
2884
|
+
organizationId: orgId,
|
|
2885
|
+
pushId,
|
|
2886
|
+
fromState: current.state,
|
|
2887
|
+
toState,
|
|
2888
|
+
reason: reason ?? null,
|
|
2889
|
+
changedBy,
|
|
2890
|
+
});
|
|
2891
|
+
return Ok(updated);
|
|
2892
|
+
});
|
|
2893
|
+
}
|
|
2894
|
+
async recordCatalogPushRevisions(orgId, entityIds, actor) {
|
|
2895
|
+
if (entityIds.length === 0)
|
|
2896
|
+
return Ok(undefined);
|
|
2897
|
+
try {
|
|
2898
|
+
await this.transact(async (tx) => {
|
|
2899
|
+
const txContext = createTxContext(tx, { actor });
|
|
2900
|
+
for (const entityId of [...new Set(entityIds)]) {
|
|
2901
|
+
const revision = await this.catalog.recordEntityRevision(entityId, actor, "push", txContext);
|
|
2902
|
+
if (!revision.ok)
|
|
2903
|
+
throw new Error(revision.error.message);
|
|
2904
|
+
}
|
|
2905
|
+
});
|
|
2906
|
+
}
|
|
2907
|
+
catch (error) {
|
|
2908
|
+
return PluginErr(error instanceof Error ? error.message : "Failed to record catalog push revisions.");
|
|
2909
|
+
}
|
|
2910
|
+
return Ok(undefined);
|
|
2911
|
+
}
|
|
2912
|
+
async executeCatalogPushJob(orgId, storeId, options, actor, runtime) {
|
|
2913
|
+
const store = await this.getStoreRecord(orgId, storeId);
|
|
2914
|
+
if (!store || store.status !== "connected")
|
|
2915
|
+
return PluginErr("Connected store not found.", "NOT_FOUND");
|
|
2916
|
+
if (!store.catalogWriteEnabled)
|
|
2917
|
+
return Ok({ noop: true });
|
|
2918
|
+
const connector = this.connectors.get(store.provider);
|
|
2919
|
+
if (!connector?.pushCatalog)
|
|
2920
|
+
return Ok({ noop: true });
|
|
2921
|
+
if (isCatalogPushBreakerOpen(store.breakerState)) {
|
|
2922
|
+
await runtime.jobs.enqueue("channel/push-catalog", {
|
|
2923
|
+
organizationId: orgId,
|
|
2924
|
+
storeId,
|
|
2925
|
+
...(options.entityIds ? { entityIds: options.entityIds } : {}),
|
|
2926
|
+
...(options.forceFieldPaths ? { forceFieldPaths: options.forceFieldPaths } : {}),
|
|
2927
|
+
...(options.cursor ? { cursor: options.cursor } : {}),
|
|
2928
|
+
}, {
|
|
2929
|
+
organizationId: orgId,
|
|
2930
|
+
concurrencyKey: catalogPushConcurrencyKey({ storeId, entityIds: options.entityIds }),
|
|
2931
|
+
supersedes: false,
|
|
2932
|
+
delayMs: CATALOG_PUSH_BREAKER_RETRY_MS,
|
|
2933
|
+
});
|
|
2934
|
+
return Ok({ rescheduled: true });
|
|
2935
|
+
}
|
|
2936
|
+
const allEntityIds = await this.resolveCatalogPushEntityIds(orgId, storeId, options.entityIds);
|
|
2937
|
+
const batchSize = catalogPushBatchSize(store.provider);
|
|
2938
|
+
const pageEntityIds = options.cursor
|
|
2939
|
+
? allEntityIds.filter((entityId) => entityId > options.cursor).slice(0, batchSize)
|
|
2940
|
+
: allEntityIds.slice(0, batchSize);
|
|
2941
|
+
if (pageEntityIds.length === 0)
|
|
2942
|
+
return Ok({ complete: true, pushed: 0, failed: 0 });
|
|
2943
|
+
// Abandoned is terminal: a row that exhausted its attempts stays out of
|
|
2944
|
+
// every later sweep until an operator re-arms it.
|
|
2945
|
+
const abandonedRows = await this.db.select({ entityId: channelCatalogPushes.entityId }).from(channelCatalogPushes).where(and(eq(channelCatalogPushes.organizationId, orgId), eq(channelCatalogPushes.storeId, storeId), eq(channelCatalogPushes.state, "abandoned"), inArray(channelCatalogPushes.entityId, pageEntityIds)));
|
|
2946
|
+
const abandonedEntityIds = new Set(abandonedRows.map((row) => row.entityId));
|
|
2947
|
+
const batchEntityIds = pageEntityIds.filter((entityId) => !abandonedEntityIds.has(entityId));
|
|
2948
|
+
if (batchEntityIds.length === 0) {
|
|
2949
|
+
const batchCursor = pageEntityIds[pageEntityIds.length - 1];
|
|
2950
|
+
const hasMore = allEntityIds.some((entityId) => entityId > batchCursor);
|
|
2951
|
+
if (!hasMore)
|
|
2952
|
+
return Ok({ complete: true, pushed: 0, failed: 0 });
|
|
2953
|
+
await runtime.jobs.enqueue("channel/push-catalog", {
|
|
2954
|
+
organizationId: orgId,
|
|
2955
|
+
storeId,
|
|
2956
|
+
...(options.entityIds ? { entityIds: options.entityIds } : {}),
|
|
2957
|
+
...(options.forceFieldPaths ? { forceFieldPaths: options.forceFieldPaths } : {}),
|
|
2958
|
+
cursor: batchCursor,
|
|
2959
|
+
}, {
|
|
2960
|
+
organizationId: orgId,
|
|
2961
|
+
concurrencyKey: catalogPushConcurrencyKey({ storeId, entityIds: options.entityIds }),
|
|
2962
|
+
supersedes: false,
|
|
2963
|
+
});
|
|
2964
|
+
return Ok({ complete: false, cursor: batchCursor, pushed: 0, failed: 0 });
|
|
2965
|
+
}
|
|
2966
|
+
const assembled = await this.buildCatalogPushItems(orgId, storeId, batchEntityIds, {
|
|
2967
|
+
...(options.forceFieldPaths ? { forceFieldPaths: options.forceFieldPaths } : {}),
|
|
2968
|
+
});
|
|
2969
|
+
if (!assembled.ok)
|
|
2970
|
+
return assembled;
|
|
2971
|
+
const mappings = await this.db.select({
|
|
2972
|
+
entityId: channelEntityMap.entityId,
|
|
2973
|
+
externalId: channelEntityMap.externalId,
|
|
2974
|
+
}).from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.kind, "entity"), inArray(channelEntityMap.entityId, batchEntityIds)));
|
|
2975
|
+
const externalByEntity = new Map(mappings.map((mapping) => [mapping.entityId, mapping.externalId]));
|
|
2976
|
+
const entityByExternal = new Map(mappings.map((mapping) => [mapping.externalId, mapping.entityId]));
|
|
2977
|
+
const itemByExternal = new Map(assembled.value.items.map((item) => [item.externalId, item]));
|
|
2978
|
+
let pushed = 0;
|
|
2979
|
+
let failed = 0;
|
|
2980
|
+
if (assembled.value.items.length === 0) {
|
|
2981
|
+
for (const entityId of batchEntityIds) {
|
|
2982
|
+
const created = await this.createCatalogPush(orgId, storeId, entityId);
|
|
2983
|
+
if (!created.ok)
|
|
2984
|
+
return created;
|
|
2985
|
+
if (created.value.state === "confirmed" || created.value.state === "abandoned") {
|
|
2986
|
+
if (created.value.state === "confirmed")
|
|
2987
|
+
pushed += 1;
|
|
2988
|
+
continue;
|
|
2989
|
+
}
|
|
2990
|
+
const confirmed = await this.transitionCatalogPush(orgId, created.value.id, "confirmed", requireUserId(actor), "No platform-owned fields to push.", undefined, null);
|
|
2991
|
+
if (!confirmed.ok)
|
|
2992
|
+
return confirmed;
|
|
2993
|
+
pushed += 1;
|
|
2994
|
+
}
|
|
2995
|
+
}
|
|
2996
|
+
else {
|
|
2997
|
+
const pushIds = new Map();
|
|
2998
|
+
const pushAttempts = new Map();
|
|
2999
|
+
for (const entityId of batchEntityIds) {
|
|
3000
|
+
const externalId = externalByEntity.get(entityId);
|
|
3001
|
+
const item = externalId ? itemByExternal.get(externalId) : undefined;
|
|
3002
|
+
if (!item)
|
|
3003
|
+
continue;
|
|
3004
|
+
const created = await this.createCatalogPush(orgId, storeId, entityId);
|
|
3005
|
+
if (!created.ok)
|
|
3006
|
+
return created;
|
|
3007
|
+
pushIds.set(item.externalId, created.value.id);
|
|
3008
|
+
pushAttempts.set(item.externalId, created.value.attempts);
|
|
3009
|
+
if (created.value.state === "pending" || created.value.state === "confirmed" || created.value.state === "failed") {
|
|
3010
|
+
const exported = await this.transitionCatalogPush(orgId, created.value.id, "exported", requireUserId(actor), "Catalog push attempt started.", undefined, item);
|
|
3011
|
+
if (!exported.ok)
|
|
3012
|
+
return exported;
|
|
3013
|
+
pushAttempts.set(item.externalId, exported.value.attempts);
|
|
3014
|
+
}
|
|
3015
|
+
}
|
|
3016
|
+
const items = assembled.value.items;
|
|
3017
|
+
const writeAhead = await this.recordOutboundPush(orgId, storeId, items.map((item) => ({ externalId: item.externalId, ok: true })), items, "write-ahead");
|
|
3018
|
+
if (!writeAhead.ok)
|
|
3019
|
+
return writeAhead;
|
|
3020
|
+
let result;
|
|
3021
|
+
try {
|
|
3022
|
+
result = await connector.pushCatalog(store, items);
|
|
3023
|
+
}
|
|
3024
|
+
catch (error) {
|
|
3025
|
+
const connectorError = {
|
|
3026
|
+
code: "CATALOG_PUSH_THROWN",
|
|
3027
|
+
message: error instanceof Error ? error.message : "Catalog push failed.",
|
|
3028
|
+
};
|
|
3029
|
+
const cleared = await this.recordOutboundPush(orgId, storeId, items.map((item) => ({ externalId: item.externalId, ok: false, error: connectorError })), items);
|
|
3030
|
+
if (!cleared.ok)
|
|
3031
|
+
return cleared;
|
|
3032
|
+
for (const item of items) {
|
|
3033
|
+
const pushId = pushIds.get(item.externalId);
|
|
3034
|
+
if (!pushId)
|
|
3035
|
+
continue;
|
|
3036
|
+
const attempts = pushAttempts.get(item.externalId) ?? 0;
|
|
3037
|
+
if (attempts >= CATALOG_PUSH_MAX_ATTEMPTS) {
|
|
3038
|
+
await this.transitionCatalogPush(orgId, pushId, "abandoned", requireUserId(actor), connectorError.message);
|
|
3039
|
+
}
|
|
3040
|
+
else {
|
|
3041
|
+
await this.transitionCatalogPush(orgId, pushId, "failed", requireUserId(actor), connectorError.message, "transient");
|
|
3042
|
+
}
|
|
3043
|
+
failed += 1;
|
|
3044
|
+
}
|
|
3045
|
+
const maxAttempts = Math.max(0, ...items.map((item) => pushAttempts.get(item.externalId) ?? 0));
|
|
3046
|
+
if (maxAttempts < CATALOG_PUSH_MAX_ATTEMPTS) {
|
|
3047
|
+
await runtime.jobs.enqueue("channel/push-catalog", {
|
|
3048
|
+
organizationId: orgId,
|
|
3049
|
+
storeId,
|
|
3050
|
+
...(options.entityIds ? { entityIds: options.entityIds } : {}),
|
|
3051
|
+
...(options.forceFieldPaths ? { forceFieldPaths: options.forceFieldPaths } : {}),
|
|
3052
|
+
...(options.cursor ? { cursor: options.cursor } : {}),
|
|
3053
|
+
}, {
|
|
3054
|
+
organizationId: orgId,
|
|
3055
|
+
concurrencyKey: catalogPushConcurrencyKey({ storeId, entityIds: options.entityIds }),
|
|
3056
|
+
supersedes: false,
|
|
3057
|
+
delayMs: catalogPushRetryDelayMs(maxAttempts),
|
|
3058
|
+
});
|
|
3059
|
+
}
|
|
3060
|
+
return Ok({ rescheduled: true, pushed, failed });
|
|
3061
|
+
}
|
|
3062
|
+
if (!result.ok) {
|
|
3063
|
+
const cleared = await this.recordOutboundPush(orgId, storeId, items.map((item) => ({ externalId: item.externalId, ok: false, error: result.error })), items);
|
|
3064
|
+
if (!cleared.ok)
|
|
3065
|
+
return cleared;
|
|
3066
|
+
for (const item of items) {
|
|
3067
|
+
const pushId = pushIds.get(item.externalId);
|
|
3068
|
+
if (!pushId)
|
|
3069
|
+
continue;
|
|
3070
|
+
const attempts = pushAttempts.get(item.externalId) ?? 0;
|
|
3071
|
+
const failureKind = result.error.retriable === true ? "transient" : "definitive";
|
|
3072
|
+
if (failureKind === "transient" && attempts >= CATALOG_PUSH_MAX_ATTEMPTS) {
|
|
3073
|
+
await this.transitionCatalogPush(orgId, pushId, "abandoned", requireUserId(actor), result.error.message);
|
|
3074
|
+
}
|
|
3075
|
+
else {
|
|
3076
|
+
await this.transitionCatalogPush(orgId, pushId, "failed", requireUserId(actor), result.error.message, failureKind);
|
|
3077
|
+
}
|
|
3078
|
+
failed += 1;
|
|
3079
|
+
}
|
|
3080
|
+
if (result.error.retriable === true) {
|
|
3081
|
+
const maxAttempts = Math.max(0, ...items.map((item) => pushAttempts.get(item.externalId) ?? 0));
|
|
3082
|
+
if (maxAttempts < CATALOG_PUSH_MAX_ATTEMPTS) {
|
|
3083
|
+
await runtime.jobs.enqueue("channel/push-catalog", {
|
|
3084
|
+
organizationId: orgId,
|
|
3085
|
+
storeId,
|
|
3086
|
+
...(options.entityIds ? { entityIds: options.entityIds } : {}),
|
|
3087
|
+
...(options.forceFieldPaths ? { forceFieldPaths: options.forceFieldPaths } : {}),
|
|
3088
|
+
...(options.cursor ? { cursor: options.cursor } : {}),
|
|
3089
|
+
}, {
|
|
3090
|
+
organizationId: orgId,
|
|
3091
|
+
concurrencyKey: catalogPushConcurrencyKey({ storeId, entityIds: options.entityIds }),
|
|
3092
|
+
supersedes: false,
|
|
3093
|
+
delayMs: catalogPushRetryDelayMs(maxAttempts),
|
|
3094
|
+
});
|
|
3095
|
+
}
|
|
3096
|
+
return Ok({ rescheduled: true, pushed, failed });
|
|
3097
|
+
}
|
|
3098
|
+
const batchCursor = pageEntityIds[pageEntityIds.length - 1];
|
|
3099
|
+
const hasMore = allEntityIds.some((entityId) => entityId > batchCursor);
|
|
3100
|
+
return Ok({ complete: !hasMore, pushed, failed });
|
|
3101
|
+
}
|
|
3102
|
+
const recorded = await this.recordOutboundPush(orgId, storeId, result.value.outcomes, items);
|
|
3103
|
+
if (!recorded.ok)
|
|
3104
|
+
return recorded;
|
|
3105
|
+
const successfulEntityIds = [];
|
|
3106
|
+
for (const outcome of result.value.outcomes) {
|
|
3107
|
+
const pushId = pushIds.get(outcome.externalId);
|
|
3108
|
+
const entityId = entityByExternal.get(outcome.externalId);
|
|
3109
|
+
if (!pushId || !entityId)
|
|
3110
|
+
continue;
|
|
3111
|
+
const item = itemByExternal.get(outcome.externalId);
|
|
3112
|
+
if (outcome.ok) {
|
|
3113
|
+
const confirmed = await this.transitionCatalogPush(orgId, pushId, "confirmed", requireUserId(actor), "Remote catalog confirmed item.", undefined, item ?? null);
|
|
3114
|
+
if (!confirmed.ok)
|
|
3115
|
+
return confirmed;
|
|
3116
|
+
successfulEntityIds.push(entityId);
|
|
3117
|
+
pushed += 1;
|
|
3118
|
+
continue;
|
|
3119
|
+
}
|
|
3120
|
+
const failureKind = outcome.error?.retriable === true ? "transient" : "definitive";
|
|
3121
|
+
const attempts = pushAttempts.get(outcome.externalId) ?? 0;
|
|
3122
|
+
if (failureKind === "transient" && attempts >= CATALOG_PUSH_MAX_ATTEMPTS) {
|
|
3123
|
+
const abandoned = await this.transitionCatalogPush(orgId, pushId, "abandoned", requireUserId(actor), outcome.error?.message ?? "Catalog push failed.", undefined, item ?? null);
|
|
3124
|
+
if (!abandoned.ok)
|
|
3125
|
+
return abandoned;
|
|
3126
|
+
}
|
|
3127
|
+
else {
|
|
3128
|
+
const failedPush = await this.transitionCatalogPush(orgId, pushId, "failed", requireUserId(actor), outcome.error?.message ?? "Catalog push failed.", failureKind, item ?? null);
|
|
3129
|
+
if (!failedPush.ok)
|
|
3130
|
+
return failedPush;
|
|
3131
|
+
if (failureKind === "transient") {
|
|
3132
|
+
await runtime.jobs.enqueue("channel/push-catalog", {
|
|
3133
|
+
organizationId: orgId,
|
|
3134
|
+
storeId,
|
|
3135
|
+
entityIds: [entityId],
|
|
3136
|
+
...(options.forceFieldPaths?.[entityId]
|
|
3137
|
+
? { forceFieldPaths: { [entityId]: options.forceFieldPaths[entityId] } }
|
|
3138
|
+
: {}),
|
|
3139
|
+
}, {
|
|
3140
|
+
organizationId: orgId,
|
|
3141
|
+
concurrencyKey: catalogPushConcurrencyKey({ storeId, entityIds: [entityId] }),
|
|
3142
|
+
supersedes: true,
|
|
3143
|
+
delayMs: catalogPushRetryDelayMs(attempts),
|
|
3144
|
+
});
|
|
3145
|
+
}
|
|
3146
|
+
}
|
|
3147
|
+
failed += 1;
|
|
3148
|
+
}
|
|
3149
|
+
const revisions = await this.recordCatalogPushRevisions(orgId, successfulEntityIds, actor);
|
|
3150
|
+
if (!revisions.ok)
|
|
3151
|
+
return revisions;
|
|
3152
|
+
}
|
|
3153
|
+
const batchCursor = pageEntityIds[pageEntityIds.length - 1];
|
|
3154
|
+
const hasMore = allEntityIds.some((entityId) => entityId > batchCursor);
|
|
3155
|
+
if (hasMore) {
|
|
3156
|
+
await runtime.jobs.enqueue("channel/push-catalog", {
|
|
3157
|
+
organizationId: orgId,
|
|
3158
|
+
storeId,
|
|
3159
|
+
...(options.entityIds ? { entityIds: options.entityIds } : {}),
|
|
3160
|
+
...(options.forceFieldPaths ? { forceFieldPaths: options.forceFieldPaths } : {}),
|
|
3161
|
+
cursor: batchCursor,
|
|
3162
|
+
}, {
|
|
3163
|
+
organizationId: orgId,
|
|
3164
|
+
concurrencyKey: catalogPushConcurrencyKey({ storeId, entityIds: options.entityIds }),
|
|
3165
|
+
supersedes: false,
|
|
3166
|
+
});
|
|
3167
|
+
return Ok({ complete: false, cursor: batchCursor, pushed, failed });
|
|
3168
|
+
}
|
|
3169
|
+
return Ok({ complete: true, pushed, failed });
|
|
3170
|
+
}
|
|
848
3171
|
}
|