@porulle/plugin-channel-connector 0.10.8 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/catalog-field-mapping.d.ts +32 -0
- package/dist/catalog-field-mapping.js +178 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +74 -1
- package/dist/mock-connector.d.ts +31 -6
- package/dist/mock-connector.js +77 -2
- package/dist/schema.d.ts +57 -1
- package/dist/schema.js +4 -1
- package/dist/service.d.ts +81 -1
- package/dist/service.js +1358 -70
- package/package.json +2 -2
- package/src/catalog-field-mapping.ts +211 -0
- package/src/index.ts +98 -1
- package/src/mock-connector.ts +81 -2
- package/src/schema.ts +6 -0
- package/src/service.ts +1722 -71
package/src/service.ts
CHANGED
|
@@ -4,22 +4,50 @@ import {
|
|
|
4
4
|
CommerceValidationError,
|
|
5
5
|
Ok,
|
|
6
6
|
PluginErr,
|
|
7
|
+
createTxContext,
|
|
7
8
|
createSystemActor,
|
|
8
9
|
} from "@porulle/core";
|
|
9
10
|
import type {
|
|
10
11
|
Actor,
|
|
11
12
|
ChannelCatalogItem,
|
|
12
13
|
ChannelConnector,
|
|
13
|
-
ChannelInventoryLevel,
|
|
14
14
|
ChannelOrderSlice,
|
|
15
|
+
ChannelPushCatalogField,
|
|
16
|
+
ChannelPushCatalogImage,
|
|
17
|
+
ChannelPushCatalogIntent,
|
|
18
|
+
ChannelPushCatalogItem,
|
|
15
19
|
ChannelStore,
|
|
16
20
|
PluginDb,
|
|
17
21
|
PluginResult,
|
|
18
22
|
PluginTxFn,
|
|
23
|
+
TxContext,
|
|
19
24
|
} from "@porulle/core";
|
|
25
|
+
import { isValidFieldPath } from "@porulle/core";
|
|
26
|
+
import type { FieldOwner, FieldPath } from "@porulle/core";
|
|
20
27
|
import type { JobsAdapter } from "@porulle/core";
|
|
21
|
-
import { and, eq, inArray } from "@porulle/core/drizzle";
|
|
22
|
-
import {
|
|
28
|
+
import { and, eq, inArray, isNull } from "@porulle/core/drizzle";
|
|
29
|
+
import {
|
|
30
|
+
brands,
|
|
31
|
+
categories,
|
|
32
|
+
customerAddresses,
|
|
33
|
+
customers,
|
|
34
|
+
entityMedia,
|
|
35
|
+
entityTags,
|
|
36
|
+
inventoryLevels,
|
|
37
|
+
mediaAssets,
|
|
38
|
+
optionTypes,
|
|
39
|
+
optionValues,
|
|
40
|
+
orderLineItems,
|
|
41
|
+
orders,
|
|
42
|
+
prices,
|
|
43
|
+
sellableAttributes,
|
|
44
|
+
sellableCustomFields,
|
|
45
|
+
sellableEntities,
|
|
46
|
+
entityFieldDefinitions,
|
|
47
|
+
tags,
|
|
48
|
+
variants,
|
|
49
|
+
variantOptionValues,
|
|
50
|
+
} from "@porulle/core/schema";
|
|
23
51
|
import {
|
|
24
52
|
channelEntityMap,
|
|
25
53
|
channelExportEvents,
|
|
@@ -31,6 +59,13 @@ import {
|
|
|
31
59
|
type ChannelRefundRequest,
|
|
32
60
|
type ConnectedStore,
|
|
33
61
|
} from "./schema.js";
|
|
62
|
+
import {
|
|
63
|
+
mergeCatalogFieldMapping,
|
|
64
|
+
normalizeCatalogFieldMapping,
|
|
65
|
+
selectCatalogFieldMapping,
|
|
66
|
+
type CatalogFieldMapping,
|
|
67
|
+
type CatalogFieldMappingInput,
|
|
68
|
+
} from "./catalog-field-mapping.js";
|
|
34
69
|
|
|
35
70
|
export type ExportState = ChannelOrderExport["state"];
|
|
36
71
|
|
|
@@ -40,6 +75,30 @@ export interface ReconcileReport extends Record<string, unknown> {
|
|
|
40
75
|
archived: number;
|
|
41
76
|
inventoryUpdated: number;
|
|
42
77
|
driftAlert: boolean;
|
|
78
|
+
skipped?: CatalogFieldSkip[];
|
|
79
|
+
conflicts?: CatalogFieldConflict[];
|
|
80
|
+
warnings?: string[];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface CatalogFieldConflict {
|
|
84
|
+
entityId: string;
|
|
85
|
+
storeId: string;
|
|
86
|
+
fieldPath: FieldPath;
|
|
87
|
+
localValueSummary: string;
|
|
88
|
+
remoteValueSummary: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface CatalogFieldSkip {
|
|
92
|
+
entityId: string;
|
|
93
|
+
fieldPath: FieldPath;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export type CatalogPushSkipReason = "no_mapping" | "held" | "entity_not_active" | "unmapped_entity";
|
|
97
|
+
|
|
98
|
+
export interface CatalogPushFieldSkip {
|
|
99
|
+
entityId: string;
|
|
100
|
+
fieldPath: FieldPath;
|
|
101
|
+
reason: CatalogPushSkipReason;
|
|
43
102
|
}
|
|
44
103
|
|
|
45
104
|
export type PublicConnectedStore = Omit<ConnectedStore, "credentials" | "webhookSecret"> & {
|
|
@@ -47,6 +106,13 @@ export type PublicConnectedStore = Omit<ConnectedStore, "credentials" | "webhook
|
|
|
47
106
|
webhookSecret: "[REDACTED]";
|
|
48
107
|
};
|
|
49
108
|
|
|
109
|
+
export interface CatalogWriteSettings {
|
|
110
|
+
enabled: boolean;
|
|
111
|
+
overrides: CatalogFieldMapping;
|
|
112
|
+
merged: CatalogFieldMapping;
|
|
113
|
+
warnings?: string[];
|
|
114
|
+
}
|
|
115
|
+
|
|
50
116
|
export interface ChannelComplianceData {
|
|
51
117
|
customer: { id?: string; email?: string };
|
|
52
118
|
exports: Array<{
|
|
@@ -75,7 +141,62 @@ export interface ChannelStockLine {
|
|
|
75
141
|
quantity: number;
|
|
76
142
|
}
|
|
77
143
|
|
|
144
|
+
interface BackfillCounts {
|
|
145
|
+
entitiesTouched: number;
|
|
146
|
+
attributesCreated: number;
|
|
147
|
+
mediaImported: number;
|
|
148
|
+
variantsGivenOptionValues: number;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export interface BackfillCatalogReport extends BackfillCounts, Record<string, unknown> {
|
|
152
|
+
cursor: string | null;
|
|
153
|
+
complete: boolean;
|
|
154
|
+
skipped?: CatalogFieldSkip[];
|
|
155
|
+
conflicts?: CatalogFieldConflict[];
|
|
156
|
+
warnings?: string[];
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
interface CatalogConvergenceStats {
|
|
160
|
+
imported: number;
|
|
161
|
+
converged: number;
|
|
162
|
+
entitiesTouched: number;
|
|
163
|
+
attributesCreated: number;
|
|
164
|
+
mediaImported: number;
|
|
165
|
+
variantsGivenOptionValues: number;
|
|
166
|
+
skipped: CatalogFieldSkip[];
|
|
167
|
+
conflicts: CatalogFieldConflict[];
|
|
168
|
+
warnings: string[];
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export interface BackfillCatalogOptions {
|
|
172
|
+
dryRun?: boolean;
|
|
173
|
+
resume?: boolean;
|
|
174
|
+
maxPages?: number;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export interface BuildCatalogPushItemsOptions {
|
|
178
|
+
recordRevision?: boolean;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export interface BuildCatalogPushItemsResult {
|
|
182
|
+
items: ChannelPushCatalogItem[];
|
|
183
|
+
skipped: CatalogPushFieldSkip[];
|
|
184
|
+
warnings: string[];
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
interface BackfillState {
|
|
188
|
+
cursor: string | null;
|
|
189
|
+
report: BackfillCounts;
|
|
190
|
+
skipped?: CatalogFieldSkip[];
|
|
191
|
+
conflicts?: CatalogFieldConflict[];
|
|
192
|
+
warnings?: string[];
|
|
193
|
+
completedAt?: string;
|
|
194
|
+
}
|
|
195
|
+
|
|
78
196
|
interface CatalogService {
|
|
197
|
+
repository: {
|
|
198
|
+
findRevisionMarkers(entityId: string, since?: Date): Promise<Array<{ createdAt: Date; reason: string }>>;
|
|
199
|
+
};
|
|
79
200
|
update(
|
|
80
201
|
id: string,
|
|
81
202
|
input: { slug?: string; status?: string; metadata?: Record<string, unknown>; isVisible?: boolean },
|
|
@@ -88,6 +209,8 @@ interface CatalogService {
|
|
|
88
209
|
slug: string;
|
|
89
210
|
sourceStoreId: string;
|
|
90
211
|
metadata: Record<string, unknown>;
|
|
212
|
+
status?: string;
|
|
213
|
+
isVisible?: boolean;
|
|
91
214
|
},
|
|
92
215
|
actor: Actor,
|
|
93
216
|
): Promise<{ ok: true; value: { id: string } } | { ok: false; error: { message: string } }>;
|
|
@@ -95,6 +218,100 @@ interface CatalogService {
|
|
|
95
218
|
input: { entityId: string; options: Record<string, string>; sku?: string; barcode?: string },
|
|
96
219
|
actor: Actor,
|
|
97
220
|
): Promise<{ ok: true; value: { id: string } } | { ok: false; error: { message: string } }>;
|
|
221
|
+
setAttributes(
|
|
222
|
+
entityId: string,
|
|
223
|
+
locale: string,
|
|
224
|
+
attrs: {
|
|
225
|
+
title: string;
|
|
226
|
+
subtitle?: string;
|
|
227
|
+
description?: string;
|
|
228
|
+
richDescription?: unknown;
|
|
229
|
+
seoTitle?: string;
|
|
230
|
+
seoDescription?: string;
|
|
231
|
+
},
|
|
232
|
+
actor: Actor,
|
|
233
|
+
): Promise<{ ok: true; value: undefined } | { ok: false; error: { message: string } }>;
|
|
234
|
+
recordEntityRevision(
|
|
235
|
+
entityId: string,
|
|
236
|
+
actor: Actor,
|
|
237
|
+
reason: "import" | "push",
|
|
238
|
+
ctx?: TxContext<PluginDb>,
|
|
239
|
+
): Promise<{ ok: true; value: unknown } | { ok: false; error: { message: string } }>;
|
|
240
|
+
resolveFieldOwners(entityId: string, storeId: string): Promise<Map<FieldPath, FieldOwner>>;
|
|
241
|
+
seedImportedFieldOwnership(entityId: string, storeId: string, fieldPaths: FieldPath[]): Promise<{ ok: true; value: undefined } | { ok: false; error: { message: string } }>;
|
|
242
|
+
createOptionType(
|
|
243
|
+
input: { entityId: string; name: string; values?: string[] },
|
|
244
|
+
actor: Actor,
|
|
245
|
+
): Promise<{ ok: true; value: { id: string } } | { ok: false; error: { message: string } }>;
|
|
246
|
+
createOptionValue(
|
|
247
|
+
input: { optionTypeId: string; value: string },
|
|
248
|
+
actor: Actor,
|
|
249
|
+
): Promise<{ ok: true; value: { id: string } } | { ok: false; error: { message: string } }>;
|
|
250
|
+
createCategory(
|
|
251
|
+
input: { slug: string },
|
|
252
|
+
actor: Actor,
|
|
253
|
+
): Promise<{ ok: true; value: { id: string } } | { ok: false; error: { message: string } }>;
|
|
254
|
+
addToCategory(
|
|
255
|
+
entityId: string,
|
|
256
|
+
categoryId: string,
|
|
257
|
+
actor: Actor,
|
|
258
|
+
): Promise<{ ok: true; value: undefined } | { ok: false; error: { message: string } }>;
|
|
259
|
+
createBrand(
|
|
260
|
+
input: { slug: string; displayName: string },
|
|
261
|
+
actor: Actor,
|
|
262
|
+
): Promise<{ ok: true; value: { id: string } } | { ok: false; error: { message: string } }>;
|
|
263
|
+
addToBrand(
|
|
264
|
+
entityId: string,
|
|
265
|
+
brandId: string,
|
|
266
|
+
actor: Actor,
|
|
267
|
+
): Promise<{ ok: true; value: undefined } | { ok: false; error: { message: string } }>;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
type ServiceResult<T> =
|
|
271
|
+
| { ok: true; value: T }
|
|
272
|
+
| { ok: false; error: { message: string; code?: string } };
|
|
273
|
+
|
|
274
|
+
interface MediaService {
|
|
275
|
+
upload(
|
|
276
|
+
input: {
|
|
277
|
+
filename: string;
|
|
278
|
+
contentType: string;
|
|
279
|
+
data: ArrayBuffer;
|
|
280
|
+
alt?: string;
|
|
281
|
+
metadata?: Record<string, unknown>;
|
|
282
|
+
origin?: "merchant" | "generated" | "imported";
|
|
283
|
+
},
|
|
284
|
+
actor: Actor,
|
|
285
|
+
): Promise<ServiceResult<{ id: string; url: string }>>;
|
|
286
|
+
attachToEntity(
|
|
287
|
+
input: {
|
|
288
|
+
entityId: string;
|
|
289
|
+
mediaAssetId: string;
|
|
290
|
+
role: "primary" | "gallery" | "thumbnail" | "video" | "document";
|
|
291
|
+
variantId?: string;
|
|
292
|
+
sortOrder?: number;
|
|
293
|
+
},
|
|
294
|
+
actor: Actor,
|
|
295
|
+
): Promise<ServiceResult<undefined>>;
|
|
296
|
+
listEntityMedia(
|
|
297
|
+
entityId: string,
|
|
298
|
+
opts?: { variantId?: string; orgId?: string },
|
|
299
|
+
): Promise<ServiceResult<Array<{
|
|
300
|
+
mediaAssetId: string;
|
|
301
|
+
role: string;
|
|
302
|
+
sortOrder: number;
|
|
303
|
+
variantId: string | null;
|
|
304
|
+
url: string;
|
|
305
|
+
alt: string | null;
|
|
306
|
+
contentType: string;
|
|
307
|
+
}>>>;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
interface PricingService {
|
|
311
|
+
setBasePrice(
|
|
312
|
+
input: { entityId: string; variantId?: string; currency: string; amount: number; compareAtAmount?: number | null },
|
|
313
|
+
actor: Actor,
|
|
314
|
+
): Promise<ServiceResult<unknown>>;
|
|
98
315
|
}
|
|
99
316
|
|
|
100
317
|
const exportTransitions: Record<ExportState, readonly ExportState[]> = {
|
|
@@ -113,6 +330,114 @@ function hash(value: unknown): string {
|
|
|
113
330
|
return createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
|
114
331
|
}
|
|
115
332
|
|
|
333
|
+
function mergeMetadata(
|
|
334
|
+
existing: Record<string, unknown> | null | undefined,
|
|
335
|
+
remote: Record<string, unknown>,
|
|
336
|
+
): Record<string, unknown> {
|
|
337
|
+
return { ...(existing ?? {}), ...remote };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const attributeFields = ["title", "subtitle", "description", "richDescription", "seoTitle", "seoDescription"] as const;
|
|
341
|
+
const pushImageRoles = ["primary", "gallery", "thumbnail", "video", "document"] as const;
|
|
342
|
+
|
|
343
|
+
function customFieldValue(field: typeof sellableCustomFields.$inferSelect): unknown {
|
|
344
|
+
switch (field.fieldType) {
|
|
345
|
+
case "text":
|
|
346
|
+
case "relation":
|
|
347
|
+
case "select":
|
|
348
|
+
return field.textValue;
|
|
349
|
+
case "number":
|
|
350
|
+
return field.numberValue;
|
|
351
|
+
case "boolean":
|
|
352
|
+
return field.booleanValue;
|
|
353
|
+
case "date":
|
|
354
|
+
return field.dateValue;
|
|
355
|
+
case "json":
|
|
356
|
+
return field.jsonValue;
|
|
357
|
+
default:
|
|
358
|
+
return null;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function pushCatalogIntent(
|
|
363
|
+
fieldPath: string,
|
|
364
|
+
target: "native" | "attribute" | "meta",
|
|
365
|
+
): ChannelPushCatalogIntent {
|
|
366
|
+
if (fieldPath.startsWith("customFields.") && target === "attribute") return "filterable";
|
|
367
|
+
if (fieldPath.startsWith("customFields.") || fieldPath.startsWith("entity.metadata.")) return "tag";
|
|
368
|
+
return "display";
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function pushCatalogField(
|
|
372
|
+
fieldPath: FieldPath,
|
|
373
|
+
value: unknown,
|
|
374
|
+
mapping: { target: "native" | "attribute" | "meta"; remoteKey: string },
|
|
375
|
+
): ChannelPushCatalogField {
|
|
376
|
+
const segments = fieldPath.split(".");
|
|
377
|
+
const locale = fieldPath.startsWith("attributes.")
|
|
378
|
+
? segments[1]
|
|
379
|
+
: fieldPath.startsWith("customFields.")
|
|
380
|
+
? segments[2]
|
|
381
|
+
: undefined;
|
|
382
|
+
return {
|
|
383
|
+
fieldPath,
|
|
384
|
+
intent: pushCatalogIntent(fieldPath, mapping.target),
|
|
385
|
+
value,
|
|
386
|
+
...(locale !== undefined ? { locale } : {}),
|
|
387
|
+
remoteKey: mapping.remoteKey,
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function pushCatalogImageRole(value: string): ChannelPushCatalogImage["role"] | undefined {
|
|
392
|
+
return pushImageRoles.find((role) => role === value);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function importedFieldPaths(item: ChannelCatalogItem): FieldPath[] {
|
|
396
|
+
const paths = new Set<FieldPath>(["entity.slug"]);
|
|
397
|
+
if (item.status !== undefined) paths.add("entity.status");
|
|
398
|
+
for (const key of Object.keys(item.metadata ?? {})) {
|
|
399
|
+
const path = `entity.metadata.${key}`;
|
|
400
|
+
if (isValidFieldPath(path)) paths.add(path);
|
|
401
|
+
}
|
|
402
|
+
const attributes = item.attributes?.length
|
|
403
|
+
? item.attributes
|
|
404
|
+
: [{ locale: "en", title: item.title, ...(item.description !== undefined ? { description: item.description } : {}) }];
|
|
405
|
+
for (const attribute of attributes) {
|
|
406
|
+
for (const field of attributeFields) {
|
|
407
|
+
if (attribute[field] !== undefined) paths.add(`attributes.${attribute.locale}.${field}`);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
for (const image of item.images ?? []) paths.add(`media.${image.role}`);
|
|
411
|
+
if (item.options?.length) paths.add("options");
|
|
412
|
+
if (item.variants.some((variant) => variant.sku !== undefined)) paths.add("variants.sku");
|
|
413
|
+
if (item.variants.some((variant) => variant.barcode !== undefined)) paths.add("variants.barcode");
|
|
414
|
+
for (const currency of item.variants.flatMap((variant) => variant.prices ?? []).map((price) => price.currency)) {
|
|
415
|
+
const path = `prices.${currency}`;
|
|
416
|
+
if (isValidFieldPath(path)) paths.add(path);
|
|
417
|
+
}
|
|
418
|
+
return [...paths];
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function summarizeValue(value: unknown): string {
|
|
422
|
+
const serialized = JSON.stringify(value);
|
|
423
|
+
if (serialized === undefined) return String(value);
|
|
424
|
+
return serialized.length > 256 ? `${serialized.slice(0, 253)}...` : serialized;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function uniqueSkipped(skipped: CatalogFieldSkip[]): CatalogFieldSkip[] {
|
|
428
|
+
const seen = new Set<string>();
|
|
429
|
+
return skipped.filter((entry) => {
|
|
430
|
+
const key = `${entry.entityId}:${entry.fieldPath}`;
|
|
431
|
+
if (seen.has(key)) return false;
|
|
432
|
+
seen.add(key);
|
|
433
|
+
return true;
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function ownerAllows(owners: Map<FieldPath, FieldOwner>, path: FieldPath): boolean {
|
|
438
|
+
return owners.get(path) !== "platform";
|
|
439
|
+
}
|
|
440
|
+
|
|
116
441
|
function stockFailure(line: ChannelStockLine, reason: string): string {
|
|
117
442
|
return `Cannot checkout line "${line.title ?? line.entityId}": ${reason}.`;
|
|
118
443
|
}
|
|
@@ -139,6 +464,8 @@ function redactStore(store: ConnectedStore): PublicConnectedStore {
|
|
|
139
464
|
credentials: "[REDACTED]",
|
|
140
465
|
storeDomain: store.storeDomain,
|
|
141
466
|
status: store.status,
|
|
467
|
+
catalogWriteEnabled: store.catalogWriteEnabled,
|
|
468
|
+
catalogFieldMapping: store.catalogFieldMapping,
|
|
142
469
|
catalogCursor: store.catalogCursor,
|
|
143
470
|
inventoryCursor: store.inventoryCursor,
|
|
144
471
|
lastSyncAt: store.lastSyncAt,
|
|
@@ -182,6 +509,557 @@ export class ChannelConnectorService {
|
|
|
182
509
|
return this.services.catalog as CatalogService;
|
|
183
510
|
}
|
|
184
511
|
|
|
512
|
+
private get media(): MediaService {
|
|
513
|
+
return this.services.media as MediaService;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
private get pricing(): PricingService {
|
|
517
|
+
return this.services.pricing as PricingService;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
private filterOwnedFields(
|
|
521
|
+
item: ChannelCatalogItem,
|
|
522
|
+
owners: Map<FieldPath, FieldOwner>,
|
|
523
|
+
): { writable: ChannelCatalogItem; skipped: FieldPath[]; conflicts: FieldPath[] } {
|
|
524
|
+
return this.filterOwnedFieldsAtPaths(item, owners, importedFieldPaths(item));
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
private filterOwnedFieldsAtPaths(
|
|
528
|
+
item: ChannelCatalogItem,
|
|
529
|
+
owners: Map<FieldPath, FieldOwner>,
|
|
530
|
+
fieldPaths: FieldPath[],
|
|
531
|
+
): { writable: ChannelCatalogItem; skipped: FieldPath[]; conflicts: FieldPath[] } {
|
|
532
|
+
const populated = new Set(fieldPaths);
|
|
533
|
+
const skipped = fieldPaths.filter((path) => owners.get(path) === "platform");
|
|
534
|
+
const blocked = new Set(skipped);
|
|
535
|
+
const attributes = (item.attributes?.length
|
|
536
|
+
? item.attributes
|
|
537
|
+
: [{ locale: "en", title: item.title, ...(item.description !== undefined ? { description: item.description } : {}) }])
|
|
538
|
+
.flatMap((attribute) => {
|
|
539
|
+
return [{
|
|
540
|
+
locale: attribute.locale,
|
|
541
|
+
title: attribute.title,
|
|
542
|
+
...Object.fromEntries(attributeFields.slice(1)
|
|
543
|
+
.filter((field) => attribute[field] !== undefined
|
|
544
|
+
&& populated.has(`attributes.${attribute.locale}.${field}`)
|
|
545
|
+
&& !blocked.has(`attributes.${attribute.locale}.${field}`))
|
|
546
|
+
.map((field) => [field, attribute[field]])),
|
|
547
|
+
}];
|
|
548
|
+
});
|
|
549
|
+
const writable: ChannelCatalogItem = {
|
|
550
|
+
...item,
|
|
551
|
+
attributes,
|
|
552
|
+
metadata: Object.fromEntries(Object.entries(item.metadata ?? {}).filter(([key]) => populated.has(`entity.metadata.${key}`) && !blocked.has(`entity.metadata.${key}`))),
|
|
553
|
+
...(item.images !== undefined ? { images: item.images.filter((image) => populated.has(`media.${image.role}`) && !blocked.has(`media.${image.role}`)) } : {}),
|
|
554
|
+
...(item.options !== undefined ? { options: blocked.has("options") ? [] : item.options } : {}),
|
|
555
|
+
variants: item.variants.map((variant) => ({
|
|
556
|
+
externalId: variant.externalId,
|
|
557
|
+
...(variant.sku !== undefined && populated.has("variants.sku") && !blocked.has("variants.sku") ? { sku: variant.sku } : {}),
|
|
558
|
+
...(variant.barcode !== undefined && populated.has("variants.barcode") && !blocked.has("variants.barcode") ? { barcode: variant.barcode } : {}),
|
|
559
|
+
...(variant.metadata !== undefined ? { metadata: variant.metadata } : {}),
|
|
560
|
+
...(variant.optionValues !== undefined && populated.has("options") && !blocked.has("options") ? { optionValues: variant.optionValues } : {}),
|
|
561
|
+
...(variant.prices !== undefined
|
|
562
|
+
? { prices: variant.prices.filter((price) => populated.has(`prices.${price.currency}`) && !blocked.has(`prices.${price.currency}`)) }
|
|
563
|
+
: {}),
|
|
564
|
+
})),
|
|
565
|
+
};
|
|
566
|
+
return { writable, skipped, conflicts: [] };
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
private filterConflictingFields(
|
|
570
|
+
item: ChannelCatalogItem,
|
|
571
|
+
conflicts: FieldPath[],
|
|
572
|
+
): { writable: ChannelCatalogItem; conflicts: FieldPath[] } {
|
|
573
|
+
if (conflicts.length === 0) return { writable: item, conflicts: [] };
|
|
574
|
+
const blocked = new Set(conflicts);
|
|
575
|
+
const attributes = (item.attributes ?? []).flatMap((attribute) => {
|
|
576
|
+
return [{
|
|
577
|
+
locale: attribute.locale,
|
|
578
|
+
title: attribute.title,
|
|
579
|
+
...Object.fromEntries(attributeFields.slice(1)
|
|
580
|
+
.filter((field) => attribute[field] !== undefined && !blocked.has(`attributes.${attribute.locale}.${field}`))
|
|
581
|
+
.map((field) => [field, attribute[field]])),
|
|
582
|
+
}];
|
|
583
|
+
});
|
|
584
|
+
return {
|
|
585
|
+
writable: {
|
|
586
|
+
...item,
|
|
587
|
+
attributes,
|
|
588
|
+
metadata: Object.fromEntries(Object.entries(item.metadata ?? {}).filter(([key]) => !blocked.has(`entity.metadata.${key}`))),
|
|
589
|
+
...(item.images !== undefined ? { images: item.images.filter((image) => !blocked.has(`media.${image.role}`)) } : {}),
|
|
590
|
+
...(item.options !== undefined ? { options: blocked.has("options") ? [] : item.options } : {}),
|
|
591
|
+
variants: item.variants.map((variant) => ({
|
|
592
|
+
externalId: variant.externalId,
|
|
593
|
+
...(variant.sku !== undefined && !blocked.has("variants.sku") ? { sku: variant.sku } : {}),
|
|
594
|
+
...(variant.barcode !== undefined && !blocked.has("variants.barcode") ? { barcode: variant.barcode } : {}),
|
|
595
|
+
...(variant.metadata !== undefined ? { metadata: variant.metadata } : {}),
|
|
596
|
+
...(variant.optionValues !== undefined && !blocked.has("options") ? { optionValues: variant.optionValues } : {}),
|
|
597
|
+
...(variant.prices !== undefined
|
|
598
|
+
? { prices: variant.prices.filter((price) => !blocked.has(`prices.${price.currency}`)) }
|
|
599
|
+
: {}),
|
|
600
|
+
})),
|
|
601
|
+
},
|
|
602
|
+
conflicts,
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
private remoteFieldValue(item: ChannelCatalogItem, path: FieldPath): unknown {
|
|
607
|
+
const [root, segment, field] = path.split(".");
|
|
608
|
+
if (root === "entity" && segment === "slug") return item.slug;
|
|
609
|
+
if (root === "entity" && segment === "status") return item.status;
|
|
610
|
+
if (root === "entity" && segment === "metadata") return item.metadata?.[field ?? ""];
|
|
611
|
+
if (root === "attributes" && segment && field) {
|
|
612
|
+
const attributes = item.attributes?.length
|
|
613
|
+
? item.attributes
|
|
614
|
+
: [{ locale: "en", title: item.title, ...(item.description !== undefined ? { description: item.description } : {}) }];
|
|
615
|
+
const attribute = attributes.find((row) => row.locale === segment);
|
|
616
|
+
return attribute?.[field as keyof typeof attribute];
|
|
617
|
+
}
|
|
618
|
+
if (root === "media" && segment) return (item.images ?? []).filter((image) => image.role === segment).map((image) => image.externalId ?? image.url);
|
|
619
|
+
if (path === "options") return item.options;
|
|
620
|
+
if (path === "variants.sku") return item.variants.map((variant) => variant.sku);
|
|
621
|
+
if (path === "variants.barcode") return item.variants.map((variant) => variant.barcode);
|
|
622
|
+
if (root === "prices" && segment) return item.variants.flatMap((variant) => variant.prices ?? []).filter((price) => price.currency === segment);
|
|
623
|
+
return undefined;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
private async localFieldValue(
|
|
627
|
+
entityId: string,
|
|
628
|
+
entity: typeof sellableEntities.$inferSelect,
|
|
629
|
+
path: FieldPath,
|
|
630
|
+
): Promise<unknown> {
|
|
631
|
+
const [root, segment, field] = path.split(".");
|
|
632
|
+
if (root === "entity" && segment === "slug") return entity.slug;
|
|
633
|
+
if (root === "entity" && segment === "status") return entity.status;
|
|
634
|
+
if (root === "entity" && segment === "metadata") return entity.metadata?.[field ?? ""];
|
|
635
|
+
if (root === "attributes" && segment && field) {
|
|
636
|
+
const [attribute] = await this.db.select().from(sellableAttributes).where(and(
|
|
637
|
+
eq(sellableAttributes.entityId, entityId),
|
|
638
|
+
eq(sellableAttributes.locale, segment),
|
|
639
|
+
));
|
|
640
|
+
const values: Record<string, unknown> = attribute
|
|
641
|
+
? {
|
|
642
|
+
title: attribute.title,
|
|
643
|
+
subtitle: attribute.subtitle,
|
|
644
|
+
description: attribute.description,
|
|
645
|
+
richDescription: attribute.richDescription,
|
|
646
|
+
seoTitle: attribute.seoTitle,
|
|
647
|
+
seoDescription: attribute.seoDescription,
|
|
648
|
+
}
|
|
649
|
+
: {};
|
|
650
|
+
return values[field];
|
|
651
|
+
}
|
|
652
|
+
if (root === "media" && segment) {
|
|
653
|
+
const links = await this.db.select({ id: entityMedia.mediaAssetId }).from(entityMedia).where(and(
|
|
654
|
+
eq(entityMedia.entityId, entityId),
|
|
655
|
+
eq(entityMedia.role, segment as "primary" | "gallery" | "thumbnail" | "video" | "document"),
|
|
656
|
+
));
|
|
657
|
+
return links.map((link) => link.id);
|
|
658
|
+
}
|
|
659
|
+
if (path === "options") {
|
|
660
|
+
const types = await this.db.select().from(optionTypes).where(eq(optionTypes.entityId, entityId));
|
|
661
|
+
const values = await Promise.all(types.map(async (type) => ({
|
|
662
|
+
name: type.name,
|
|
663
|
+
values: await this.db.select().from(optionValues).where(eq(optionValues.optionTypeId, type.id)),
|
|
664
|
+
})));
|
|
665
|
+
return values;
|
|
666
|
+
}
|
|
667
|
+
if (path === "variants.sku" || path === "variants.barcode") {
|
|
668
|
+
const rows = await this.db.select().from(variants).where(eq(variants.entityId, entityId));
|
|
669
|
+
return rows.map((variant) => path === "variants.sku" ? variant.sku : variant.barcode);
|
|
670
|
+
}
|
|
671
|
+
if (root === "prices" && segment) {
|
|
672
|
+
const rows = await this.db.select().from(prices).where(and(
|
|
673
|
+
eq(prices.entityId, entityId),
|
|
674
|
+
eq(prices.currency, segment),
|
|
675
|
+
));
|
|
676
|
+
return rows.map((price) => ({ amount: price.amount, compareAtAmount: price.compareAtAmount }));
|
|
677
|
+
}
|
|
678
|
+
return undefined;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
private async detectSharedConflicts(
|
|
682
|
+
entityId: string,
|
|
683
|
+
storeId: string,
|
|
684
|
+
entity: typeof sellableEntities.$inferSelect,
|
|
685
|
+
mapping: typeof channelEntityMap.$inferSelect | undefined,
|
|
686
|
+
item: ChannelCatalogItem,
|
|
687
|
+
owners: Map<FieldPath, FieldOwner>,
|
|
688
|
+
fieldPaths: FieldPath[] = importedFieldPaths(item),
|
|
689
|
+
remoteHash = hash(item),
|
|
690
|
+
): Promise<{ paths: FieldPath[]; conflicts: CatalogFieldConflict[] }> {
|
|
691
|
+
if (!mapping || mapping.syncHash === remoteHash) return { paths: [], conflicts: [] };
|
|
692
|
+
const revisions = await this.catalog.repository.findRevisionMarkers(entityId, mapping.lastSyncedAt);
|
|
693
|
+
const localChanged = revisions.some((revision) => revision.reason !== "import");
|
|
694
|
+
if (!localChanged) return { paths: [], conflicts: [] };
|
|
695
|
+
const paths = fieldPaths.filter((path) => owners.get(path) === "shared");
|
|
696
|
+
const conflicts = await Promise.all(paths.map(async (fieldPath) => ({
|
|
697
|
+
entityId,
|
|
698
|
+
storeId,
|
|
699
|
+
fieldPath,
|
|
700
|
+
localValueSummary: summarizeValue(await this.localFieldValue(entityId, entity, fieldPath)),
|
|
701
|
+
remoteValueSummary: summarizeValue(this.remoteFieldValue(item, fieldPath)),
|
|
702
|
+
})));
|
|
703
|
+
return { paths, conflicts };
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
private async setCatalogAttributes(
|
|
707
|
+
entityId: string,
|
|
708
|
+
item: ChannelCatalogItem,
|
|
709
|
+
actor: Actor,
|
|
710
|
+
blockedPaths: ReadonlySet<FieldPath> = new Set<FieldPath>(),
|
|
711
|
+
): Promise<PluginResult<{ created: number; changed: boolean }>> {
|
|
712
|
+
const attributes = item.attributes ?? [{
|
|
713
|
+
locale: "en",
|
|
714
|
+
title: item.title,
|
|
715
|
+
...(item.description !== undefined ? { description: item.description } : {}),
|
|
716
|
+
}];
|
|
717
|
+
const existing = await this.db.select().from(sellableAttributes).where(eq(sellableAttributes.entityId, entityId));
|
|
718
|
+
let created = 0;
|
|
719
|
+
let changed = false;
|
|
720
|
+
for (const attribute of attributes) {
|
|
721
|
+
const current = existing.find((row) => row.locale === attribute.locale);
|
|
722
|
+
const titlePath = `attributes.${attribute.locale}.title` as FieldPath;
|
|
723
|
+
if (!current && blockedPaths.has(titlePath)) continue;
|
|
724
|
+
const title = blockedPaths.has(titlePath) ? current?.title : attribute.title;
|
|
725
|
+
if (title === undefined) continue;
|
|
726
|
+
const writeAttribute = {
|
|
727
|
+
title,
|
|
728
|
+
...(attribute.subtitle !== undefined && !blockedPaths.has(`attributes.${attribute.locale}.subtitle`) ? { subtitle: attribute.subtitle } : current?.subtitle != null ? { subtitle: current.subtitle } : {}),
|
|
729
|
+
...(attribute.description !== undefined && !blockedPaths.has(`attributes.${attribute.locale}.description`) ? { description: attribute.description } : current?.description != null ? { description: current.description } : {}),
|
|
730
|
+
...(attribute.richDescription !== undefined && !blockedPaths.has(`attributes.${attribute.locale}.richDescription`) ? { richDescription: attribute.richDescription } : current?.richDescription != null ? { richDescription: current.richDescription } : {}),
|
|
731
|
+
...(attribute.seoTitle !== undefined && !blockedPaths.has(`attributes.${attribute.locale}.seoTitle`) ? { seoTitle: attribute.seoTitle } : current?.seoTitle != null ? { seoTitle: current.seoTitle } : {}),
|
|
732
|
+
...(attribute.seoDescription !== undefined && !blockedPaths.has(`attributes.${attribute.locale}.seoDescription`) ? { seoDescription: attribute.seoDescription } : current?.seoDescription != null ? { seoDescription: current.seoDescription } : {}),
|
|
733
|
+
};
|
|
734
|
+
if (!current) {
|
|
735
|
+
created += 1;
|
|
736
|
+
changed = true;
|
|
737
|
+
} else if (attributeFields.some((field) => (current[field] == null ? null : current[field]) !== (writeAttribute[field] == null ? null : writeAttribute[field]))) {
|
|
738
|
+
changed = true;
|
|
739
|
+
}
|
|
740
|
+
const result = await this.catalog.setAttributes(entityId, attribute.locale, writeAttribute, actor);
|
|
741
|
+
if (!result.ok) return PluginErr(result.error.message);
|
|
742
|
+
}
|
|
743
|
+
return Ok({ created, changed });
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
private async upsertOptionAxes(
|
|
747
|
+
entityId: string,
|
|
748
|
+
item: ChannelCatalogItem,
|
|
749
|
+
actor: Actor,
|
|
750
|
+
): Promise<PluginResult<{ value: Map<string, Map<string, string>>; changed: boolean }>> {
|
|
751
|
+
const optionValueIds = new Map<string, Map<string, string>>();
|
|
752
|
+
const existingTypes = await this.db.select().from(optionTypes).where(eq(optionTypes.entityId, entityId));
|
|
753
|
+
let changed = false;
|
|
754
|
+
for (const [typeIndex, sourceType] of (item.options ?? []).entries()) {
|
|
755
|
+
let optionType = existingTypes.find((row) => row.name === sourceType.name);
|
|
756
|
+
if (!optionType) {
|
|
757
|
+
const created = await this.catalog.createOptionType({ entityId, name: sourceType.name, values: [] }, actor);
|
|
758
|
+
if (!created.ok) return PluginErr(created.error.message);
|
|
759
|
+
const [createdType] = await this.db.select().from(optionTypes).where(eq(optionTypes.id, created.value.id));
|
|
760
|
+
if (!createdType) return PluginErr(`Option type "${sourceType.name}" was not persisted.`);
|
|
761
|
+
optionType = createdType;
|
|
762
|
+
existingTypes.push(optionType);
|
|
763
|
+
changed = true;
|
|
764
|
+
}
|
|
765
|
+
await this.db.update(optionTypes).set({
|
|
766
|
+
displayName: sourceType.displayName,
|
|
767
|
+
sortOrder: sourceType.sortOrder ?? typeIndex,
|
|
768
|
+
}).where(eq(optionTypes.id, optionType.id));
|
|
769
|
+
|
|
770
|
+
const existingValues = await this.db.select().from(optionValues).where(eq(optionValues.optionTypeId, optionType.id));
|
|
771
|
+
const valueIds = new Map<string, string>();
|
|
772
|
+
for (const [valueIndex, sourceValue] of sourceType.values.entries()) {
|
|
773
|
+
let optionValue = existingValues.find((row) => row.value === sourceValue.value);
|
|
774
|
+
if (!optionValue) {
|
|
775
|
+
const created = await this.catalog.createOptionValue({ optionTypeId: optionType.id, value: sourceValue.value }, actor);
|
|
776
|
+
if (!created.ok) return PluginErr(created.error.message);
|
|
777
|
+
const [createdValue] = await this.db.select().from(optionValues).where(eq(optionValues.id, created.value.id));
|
|
778
|
+
if (!createdValue) return PluginErr(`Option value "${sourceValue.value}" was not persisted.`);
|
|
779
|
+
optionValue = createdValue;
|
|
780
|
+
existingValues.push(optionValue);
|
|
781
|
+
changed = true;
|
|
782
|
+
}
|
|
783
|
+
await this.db.update(optionValues).set({
|
|
784
|
+
displayValue: sourceValue.displayValue,
|
|
785
|
+
sortOrder: sourceValue.sortOrder ?? valueIndex,
|
|
786
|
+
}).where(eq(optionValues.id, optionValue.id));
|
|
787
|
+
valueIds.set(sourceValue.value, optionValue.id);
|
|
788
|
+
}
|
|
789
|
+
optionValueIds.set(sourceType.name, valueIds);
|
|
790
|
+
}
|
|
791
|
+
return Ok({ value: optionValueIds, changed });
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
private async upsertVariants(
|
|
795
|
+
orgId: string,
|
|
796
|
+
storeId: string,
|
|
797
|
+
entityId: string,
|
|
798
|
+
item: ChannelCatalogItem,
|
|
799
|
+
optionValueIds: Map<string, Map<string, string>>,
|
|
800
|
+
actor: Actor,
|
|
801
|
+
warnings: string[],
|
|
802
|
+
applyOptionValues: boolean,
|
|
803
|
+
fullItem: ChannelCatalogItem,
|
|
804
|
+
): Promise<PluginResult<{ value: Map<string, string>; repaired: number; changed: boolean }>> {
|
|
805
|
+
const variantIds = new Map<string, string>();
|
|
806
|
+
let repaired = 0;
|
|
807
|
+
let changed = false;
|
|
808
|
+
const mappings = await this.db.select().from(channelEntityMap).where(and(
|
|
809
|
+
eq(channelEntityMap.organizationId, orgId),
|
|
810
|
+
eq(channelEntityMap.storeId, storeId),
|
|
811
|
+
eq(channelEntityMap.kind, "variant"),
|
|
812
|
+
eq(channelEntityMap.entityId, entityId),
|
|
813
|
+
));
|
|
814
|
+
for (const sourceVariant of item.variants) {
|
|
815
|
+
const fullSourceVariant = fullItem.variants.find((variant) => variant.externalId === sourceVariant.externalId) ?? sourceVariant;
|
|
816
|
+
let mapping = mappings.find((row) => row.externalId === sourceVariant.externalId);
|
|
817
|
+
let variantId = mapping?.variantId;
|
|
818
|
+
const createdVariant = !variantId;
|
|
819
|
+
if (!variantId) {
|
|
820
|
+
const options: Record<string, string> = {};
|
|
821
|
+
for (const [name, value] of Object.entries(sourceVariant.optionValues ?? {})) {
|
|
822
|
+
const optionValueId = optionValueIds.get(name)?.get(value);
|
|
823
|
+
if (!optionValueId) {
|
|
824
|
+
warnings.push(`Skipped unmapped option "${name}=${value}" on variant "${sourceVariant.externalId}".`);
|
|
825
|
+
continue;
|
|
826
|
+
}
|
|
827
|
+
options[name] = value;
|
|
828
|
+
}
|
|
829
|
+
const created = await this.catalog.createVariant({
|
|
830
|
+
entityId,
|
|
831
|
+
options,
|
|
832
|
+
...(sourceVariant.sku !== undefined ? { sku: sourceVariant.sku } : {}),
|
|
833
|
+
...(sourceVariant.barcode !== undefined ? { barcode: sourceVariant.barcode } : {}),
|
|
834
|
+
}, actor);
|
|
835
|
+
if (!created.ok) return PluginErr(created.error.message);
|
|
836
|
+
variantId = created.value.id;
|
|
837
|
+
const [createdMapping] = await this.db.insert(channelEntityMap).values({
|
|
838
|
+
organizationId: orgId,
|
|
839
|
+
storeId,
|
|
840
|
+
kind: "variant",
|
|
841
|
+
externalId: sourceVariant.externalId,
|
|
842
|
+
entityId,
|
|
843
|
+
variantId,
|
|
844
|
+
syncHash: hash(fullSourceVariant),
|
|
845
|
+
}).returning();
|
|
846
|
+
mapping = createdMapping;
|
|
847
|
+
if (mapping) mappings.push(mapping);
|
|
848
|
+
}
|
|
849
|
+
if (!variantId) {
|
|
850
|
+
warnings.push(`Skipped variant "${sourceVariant.externalId}": no local variant mapping exists.`);
|
|
851
|
+
continue;
|
|
852
|
+
}
|
|
853
|
+
variantIds.set(sourceVariant.externalId, variantId);
|
|
854
|
+
if (applyOptionValues) {
|
|
855
|
+
const desiredOptionValueIds = Object.entries(sourceVariant.optionValues ?? {})
|
|
856
|
+
.map(([name, value]) => optionValueIds.get(name)?.get(value))
|
|
857
|
+
.filter((optionValueId): optionValueId is string => optionValueId !== undefined);
|
|
858
|
+
const currentOptionValues = await this.db.select().from(variantOptionValues).where(eq(variantOptionValues.variantId, variantId));
|
|
859
|
+
const currentIds = currentOptionValues.map((row) => row.optionValueId).sort();
|
|
860
|
+
const desiredIds = [...new Set(desiredOptionValueIds)].sort();
|
|
861
|
+
if (currentIds.length !== desiredIds.length || currentIds.some((id, index) => id !== desiredIds[index])) {
|
|
862
|
+
await this.db.delete(variantOptionValues).where(eq(variantOptionValues.variantId, variantId));
|
|
863
|
+
if (desiredIds.length > 0) {
|
|
864
|
+
await this.db.insert(variantOptionValues).values(desiredIds.map((optionValueId) => ({ variantId, optionValueId }))).onConflictDoNothing();
|
|
865
|
+
repaired += 1;
|
|
866
|
+
}
|
|
867
|
+
changed = true;
|
|
868
|
+
}
|
|
869
|
+
if (createdVariant && desiredIds.length > 0) {
|
|
870
|
+
repaired += 1;
|
|
871
|
+
changed = true;
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
for (const price of sourceVariant.prices ?? []) {
|
|
875
|
+
const priced = await this.pricing.setBasePrice({
|
|
876
|
+
entityId,
|
|
877
|
+
variantId,
|
|
878
|
+
currency: price.currency,
|
|
879
|
+
amount: price.amount,
|
|
880
|
+
compareAtAmount: price.compareAtAmount ?? null,
|
|
881
|
+
}, actor);
|
|
882
|
+
if (!priced.ok) return PluginErr(priced.error.message);
|
|
883
|
+
}
|
|
884
|
+
if (mapping) {
|
|
885
|
+
await this.db.update(channelEntityMap).set({
|
|
886
|
+
syncHash: hash(fullSourceVariant),
|
|
887
|
+
}).where(eq(channelEntityMap.id, mapping.id));
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
return Ok({ value: variantIds, repaired, changed });
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
private async applyTaxonomy(
|
|
894
|
+
orgId: string,
|
|
895
|
+
entityId: string,
|
|
896
|
+
item: ChannelCatalogItem,
|
|
897
|
+
actor: Actor,
|
|
898
|
+
warnings: string[],
|
|
899
|
+
): Promise<PluginResult<void>> {
|
|
900
|
+
const categoryRows = await this.db.select().from(categories).where(eq(categories.organizationId, orgId));
|
|
901
|
+
for (const slug of new Set(item.categories ?? [])) {
|
|
902
|
+
let category = categoryRows.find((row) => row.slug === slug);
|
|
903
|
+
if (category?.status === "archived") {
|
|
904
|
+
warnings.push(`Skipped archived category "${slug}".`);
|
|
905
|
+
continue;
|
|
906
|
+
}
|
|
907
|
+
if (!category) {
|
|
908
|
+
const created = await this.catalog.createCategory({ slug }, actor);
|
|
909
|
+
if (!created.ok) return PluginErr(created.error.message);
|
|
910
|
+
const [createdCategory] = await this.db.select().from(categories).where(eq(categories.id, created.value.id));
|
|
911
|
+
if (!createdCategory) return PluginErr(`Category "${slug}" was not persisted.`);
|
|
912
|
+
category = createdCategory;
|
|
913
|
+
categoryRows.push(category);
|
|
914
|
+
}
|
|
915
|
+
const linked = await this.catalog.addToCategory(entityId, category.id, actor);
|
|
916
|
+
if (!linked.ok) return PluginErr(linked.error.message);
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
const brandRows = await this.db.select().from(brands).where(eq(brands.organizationId, orgId));
|
|
920
|
+
if (item.brand) {
|
|
921
|
+
let brand = brandRows.find((row) => row.slug === item.brand);
|
|
922
|
+
if (!brand) {
|
|
923
|
+
const created = await this.catalog.createBrand({ slug: item.brand, displayName: item.brand }, actor);
|
|
924
|
+
if (!created.ok) return PluginErr(created.error.message);
|
|
925
|
+
const [createdBrand] = await this.db.select().from(brands).where(eq(brands.id, created.value.id));
|
|
926
|
+
if (!createdBrand) return PluginErr(`Brand "${item.brand}" was not persisted.`);
|
|
927
|
+
brand = createdBrand;
|
|
928
|
+
brandRows.push(brand);
|
|
929
|
+
}
|
|
930
|
+
const linked = await this.catalog.addToBrand(entityId, brand.id, actor);
|
|
931
|
+
if (!linked.ok) return PluginErr(linked.error.message);
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
const tagRows = await this.db.select().from(tags).where(eq(tags.organizationId, orgId));
|
|
935
|
+
for (const slug of new Set(item.tags ?? [])) {
|
|
936
|
+
let tag = tagRows.find((row) => row.slug === slug);
|
|
937
|
+
if (!tag) {
|
|
938
|
+
const [createdTag] = await this.db.insert(tags).values({ organizationId: orgId, slug, displayName: slug }).onConflictDoNothing().returning();
|
|
939
|
+
tag = createdTag ?? (await this.db.select().from(tags).where(and(
|
|
940
|
+
eq(tags.organizationId, orgId),
|
|
941
|
+
eq(tags.slug, slug),
|
|
942
|
+
)))[0];
|
|
943
|
+
if (!tag) return PluginErr(`Tag "${slug}" was not persisted.`);
|
|
944
|
+
tagRows.push(tag);
|
|
945
|
+
}
|
|
946
|
+
await this.db.insert(entityTags).values({ entityId, tagId: tag.id }).onConflictDoNothing();
|
|
947
|
+
}
|
|
948
|
+
return Ok(undefined);
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
private async applyMedia(
|
|
952
|
+
orgId: string,
|
|
953
|
+
entityId: string,
|
|
954
|
+
item: ChannelCatalogItem,
|
|
955
|
+
variantIds: Map<string, string>,
|
|
956
|
+
actor: Actor,
|
|
957
|
+
warnings: string[],
|
|
958
|
+
owners: Map<FieldPath, FieldOwner>,
|
|
959
|
+
): Promise<PluginResult<{ imported: number; changed: boolean; skipped: FieldPath[] }>> {
|
|
960
|
+
const assets = await this.db.select().from(mediaAssets).where(eq(mediaAssets.organizationId, orgId));
|
|
961
|
+
const links = await this.db.select().from(entityMedia).where(eq(entityMedia.entityId, entityId));
|
|
962
|
+
let imported = 0;
|
|
963
|
+
let changed = false;
|
|
964
|
+
const skipped: FieldPath[] = [];
|
|
965
|
+
for (const image of item.images ?? []) {
|
|
966
|
+
const urlHash = hash(image.url);
|
|
967
|
+
const asset = assets.find((row) => {
|
|
968
|
+
const metadata = row.metadata ?? {};
|
|
969
|
+
return (image.externalId != null && metadata.channelImageExternalId === image.externalId)
|
|
970
|
+
|| metadata.channelImageUrlHash === urlHash;
|
|
971
|
+
});
|
|
972
|
+
let mediaAssetId = asset?.id;
|
|
973
|
+
if (!mediaAssetId) {
|
|
974
|
+
let response: Response;
|
|
975
|
+
try {
|
|
976
|
+
response = await fetch(image.url);
|
|
977
|
+
} catch (error) {
|
|
978
|
+
warnings.push(`Skipped image "${image.externalId ?? image.url}": ${error instanceof Error ? error.message : "download failed"}.`);
|
|
979
|
+
continue;
|
|
980
|
+
}
|
|
981
|
+
if (!response.ok) {
|
|
982
|
+
warnings.push(`Skipped image "${image.externalId ?? image.url}": download returned ${response.status}.`);
|
|
983
|
+
continue;
|
|
984
|
+
}
|
|
985
|
+
const contentType = response.headers.get("content-type")?.split(";", 1)[0] ?? "image/jpeg";
|
|
986
|
+
const extension = contentType.split("/", 2)[1] ?? "jpg";
|
|
987
|
+
const uploaded = await this.media.upload({
|
|
988
|
+
filename: `${image.externalId ?? urlHash}.${extension}`,
|
|
989
|
+
contentType,
|
|
990
|
+
data: await response.arrayBuffer(),
|
|
991
|
+
...(image.alt !== undefined ? { alt: image.alt } : {}),
|
|
992
|
+
metadata: {
|
|
993
|
+
channelImageUrlHash: urlHash,
|
|
994
|
+
...(image.externalId !== undefined ? { channelImageExternalId: image.externalId } : {}),
|
|
995
|
+
},
|
|
996
|
+
origin: "imported",
|
|
997
|
+
}, actor);
|
|
998
|
+
if (!uploaded.ok) {
|
|
999
|
+
warnings.push(`Skipped image "${image.externalId ?? image.url}": ${uploaded.error.code === "STORAGE_NOT_SUPPORTED" ? "storage adapter is not configured" : uploaded.error.message}.`);
|
|
1000
|
+
continue;
|
|
1001
|
+
}
|
|
1002
|
+
mediaAssetId = uploaded.value.id;
|
|
1003
|
+
imported += 1;
|
|
1004
|
+
changed = true;
|
|
1005
|
+
const [createdAsset] = await this.db.select().from(mediaAssets).where(eq(mediaAssets.id, mediaAssetId));
|
|
1006
|
+
if (createdAsset) assets.push(createdAsset);
|
|
1007
|
+
}
|
|
1008
|
+
if (!mediaAssetId) continue;
|
|
1009
|
+
|
|
1010
|
+
const targets = image.variantExternalIds?.length
|
|
1011
|
+
? image.variantExternalIds.map((externalId) => ({ externalId, variantId: variantIds.get(externalId) }))
|
|
1012
|
+
: [{ externalId: undefined, variantId: undefined }];
|
|
1013
|
+
for (const target of targets) {
|
|
1014
|
+
if (image.variantExternalIds?.length && !target.variantId) {
|
|
1015
|
+
warnings.push(`Skipped image "${image.externalId ?? image.url}" for unmapped variant "${target.externalId}".`);
|
|
1016
|
+
continue;
|
|
1017
|
+
}
|
|
1018
|
+
const existingLink = links.find((link) =>
|
|
1019
|
+
link.mediaAssetId === mediaAssetId
|
|
1020
|
+
&& (target.variantId === undefined ? link.variantId === null : link.variantId === target.variantId),
|
|
1021
|
+
);
|
|
1022
|
+
if (existingLink) {
|
|
1023
|
+
if (existingLink.role !== image.role) {
|
|
1024
|
+
const currentRolePath = `media.${existingLink.role}` as FieldPath;
|
|
1025
|
+
const incomingRolePath = `media.${image.role}` as FieldPath;
|
|
1026
|
+
for (const path of [currentRolePath, incomingRolePath]) {
|
|
1027
|
+
if (owners.get(path) === "platform" && !skipped.includes(path)) skipped.push(path);
|
|
1028
|
+
}
|
|
1029
|
+
if (skipped.includes(currentRolePath) || skipped.includes(incomingRolePath)) continue;
|
|
1030
|
+
}
|
|
1031
|
+
if (existingLink.role !== image.role || existingLink.sortOrder !== (image.sortOrder ?? 0)) {
|
|
1032
|
+
await this.db.update(entityMedia).set({ role: image.role, sortOrder: image.sortOrder ?? 0 }).where(and(
|
|
1033
|
+
eq(entityMedia.entityId, entityId),
|
|
1034
|
+
eq(entityMedia.mediaAssetId, mediaAssetId),
|
|
1035
|
+
target.variantId === undefined ? isNull(entityMedia.variantId) : eq(entityMedia.variantId, target.variantId),
|
|
1036
|
+
));
|
|
1037
|
+
changed = true;
|
|
1038
|
+
}
|
|
1039
|
+
continue;
|
|
1040
|
+
}
|
|
1041
|
+
const attached = await this.media.attachToEntity({
|
|
1042
|
+
entityId,
|
|
1043
|
+
mediaAssetId,
|
|
1044
|
+
role: image.role,
|
|
1045
|
+
sortOrder: image.sortOrder ?? 0,
|
|
1046
|
+
...(target.variantId !== undefined ? { variantId: target.variantId } : {}),
|
|
1047
|
+
}, actor);
|
|
1048
|
+
if (!attached.ok) return PluginErr(attached.error.message);
|
|
1049
|
+
changed = true;
|
|
1050
|
+
links.push({
|
|
1051
|
+
entityId,
|
|
1052
|
+
mediaAssetId,
|
|
1053
|
+
role: image.role,
|
|
1054
|
+
sortOrder: image.sortOrder ?? 0,
|
|
1055
|
+
variantId: target.variantId ?? null,
|
|
1056
|
+
createdAt: new Date(),
|
|
1057
|
+
});
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
return Ok({ imported, changed, skipped });
|
|
1061
|
+
}
|
|
1062
|
+
|
|
185
1063
|
private async getStoreRecord(orgId: string, id: string): Promise<ConnectedStore | undefined> {
|
|
186
1064
|
const rows = await this.db
|
|
187
1065
|
.select()
|
|
@@ -208,6 +1086,203 @@ export class ChannelConnectorService {
|
|
|
208
1086
|
return rows as ConnectedStore[];
|
|
209
1087
|
}
|
|
210
1088
|
|
|
1089
|
+
resolveCatalogFieldMapping(
|
|
1090
|
+
store: Pick<ConnectedStore, "provider" | "catalogFieldMapping">,
|
|
1091
|
+
filterableCustomFields?: ReadonlySet<string> | Readonly<Record<string, boolean>>,
|
|
1092
|
+
warnings: string[] = [],
|
|
1093
|
+
): CatalogFieldMapping {
|
|
1094
|
+
return mergeCatalogFieldMapping(store.provider, store.catalogFieldMapping, filterableCustomFields, warnings);
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
async buildCatalogPushItems(
|
|
1098
|
+
orgId: string,
|
|
1099
|
+
storeId: string,
|
|
1100
|
+
entityIds: string[],
|
|
1101
|
+
options: BuildCatalogPushItemsOptions = {},
|
|
1102
|
+
): Promise<PluginResult<BuildCatalogPushItemsResult>> {
|
|
1103
|
+
const store = await this.getStoreRecord(orgId, storeId);
|
|
1104
|
+
if (!store || store.status !== "connected") return PluginErr("Connected store not found.", "NOT_FOUND");
|
|
1105
|
+
if (!store.catalogWriteEnabled) return PluginErr("Catalog writes are disabled for this store.", "CATALOG_WRITE_DISABLED");
|
|
1106
|
+
if (entityIds.length === 0) return Ok({ items: [], skipped: [], warnings: [] });
|
|
1107
|
+
|
|
1108
|
+
const entities = await this.db.select().from(sellableEntities).where(and(
|
|
1109
|
+
eq(sellableEntities.organizationId, orgId),
|
|
1110
|
+
inArray(sellableEntities.id, entityIds),
|
|
1111
|
+
));
|
|
1112
|
+
const entityById = new Map(entities.map((entity) => [entity.id, entity]));
|
|
1113
|
+
const mappings = await this.db.select().from(channelEntityMap).where(and(
|
|
1114
|
+
eq(channelEntityMap.organizationId, orgId),
|
|
1115
|
+
eq(channelEntityMap.storeId, storeId),
|
|
1116
|
+
eq(channelEntityMap.kind, "entity"),
|
|
1117
|
+
inArray(channelEntityMap.entityId, entityIds),
|
|
1118
|
+
));
|
|
1119
|
+
const mappingByEntity = new Map(mappings.map((mapping) => [mapping.entityId, mapping]));
|
|
1120
|
+
const items: ChannelPushCatalogItem[] = [];
|
|
1121
|
+
const skipped: CatalogPushFieldSkip[] = [];
|
|
1122
|
+
const warnings: string[] = [];
|
|
1123
|
+
const revisionEntityIds: string[] = [];
|
|
1124
|
+
|
|
1125
|
+
for (const entityId of entityIds) {
|
|
1126
|
+
const entity = entityById.get(entityId);
|
|
1127
|
+
if (!entity) return PluginErr("Catalog entity not found.", "NOT_FOUND");
|
|
1128
|
+
if (entity.status !== "active") {
|
|
1129
|
+
skipped.push({ entityId, fieldPath: "entity.status", reason: "entity_not_active" });
|
|
1130
|
+
continue;
|
|
1131
|
+
}
|
|
1132
|
+
const entityMapping = mappingByEntity.get(entity.id);
|
|
1133
|
+
if (!entityMapping) {
|
|
1134
|
+
skipped.push({ entityId, fieldPath: "entity", reason: "unmapped_entity" });
|
|
1135
|
+
continue;
|
|
1136
|
+
}
|
|
1137
|
+
const owners = await this.catalog.resolveFieldOwners(entity.id, storeId);
|
|
1138
|
+
const attributes = await this.db.select().from(sellableAttributes).where(eq(sellableAttributes.entityId, entity.id));
|
|
1139
|
+
const customFields = await this.db.select().from(sellableCustomFields).where(and(
|
|
1140
|
+
eq(sellableCustomFields.entityId, entity.id),
|
|
1141
|
+
eq(sellableCustomFields.status, "approved"),
|
|
1142
|
+
));
|
|
1143
|
+
const customFieldNames = [...new Set(customFields.map((field) => field.fieldName))];
|
|
1144
|
+
const definitions = customFieldNames.length > 0
|
|
1145
|
+
? await this.db.select({ name: entityFieldDefinitions.name, filterable: entityFieldDefinitions.filterable }).from(entityFieldDefinitions).where(and(
|
|
1146
|
+
eq(entityFieldDefinitions.organizationId, orgId),
|
|
1147
|
+
eq(entityFieldDefinitions.entityType, entity.type),
|
|
1148
|
+
inArray(entityFieldDefinitions.name, customFieldNames),
|
|
1149
|
+
))
|
|
1150
|
+
: [];
|
|
1151
|
+
const filterableCustomFields = Object.fromEntries(definitions.map((definition) => [
|
|
1152
|
+
`customFields.${definition.name}.en`,
|
|
1153
|
+
definition.filterable,
|
|
1154
|
+
]));
|
|
1155
|
+
for (const field of customFields) {
|
|
1156
|
+
filterableCustomFields[`customFields.${field.fieldName}.${field.locale}`] = definitions.find(
|
|
1157
|
+
(definition) => definition.name === field.fieldName,
|
|
1158
|
+
)?.filterable ?? false;
|
|
1159
|
+
}
|
|
1160
|
+
const fieldMapping = this.resolveCatalogFieldMapping(store, filterableCustomFields, warnings);
|
|
1161
|
+
const heldPaths = new Set(entityMapping.heldFieldPaths ?? []);
|
|
1162
|
+
const fields: ChannelPushCatalogField[] = [];
|
|
1163
|
+
const appendField = (fieldPath: FieldPath, value: unknown) => {
|
|
1164
|
+
if (value === undefined || owners.get(fieldPath) !== "platform") return;
|
|
1165
|
+
if (heldPaths.has(fieldPath)) {
|
|
1166
|
+
skipped.push({ entityId, fieldPath, reason: "held" });
|
|
1167
|
+
return;
|
|
1168
|
+
}
|
|
1169
|
+
const mapping = selectCatalogFieldMapping(fieldMapping, fieldPath);
|
|
1170
|
+
if (!mapping) {
|
|
1171
|
+
skipped.push({ entityId, fieldPath, reason: "no_mapping" });
|
|
1172
|
+
return;
|
|
1173
|
+
}
|
|
1174
|
+
fields.push(pushCatalogField(fieldPath, value, mapping));
|
|
1175
|
+
};
|
|
1176
|
+
|
|
1177
|
+
for (const attribute of attributes) {
|
|
1178
|
+
for (const field of attributeFields) {
|
|
1179
|
+
appendField(`attributes.${attribute.locale}.${field}`, attribute[field]);
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
for (const [key, value] of Object.entries(entity.metadata ?? {})) {
|
|
1183
|
+
const fieldPath = `entity.metadata.${key}`;
|
|
1184
|
+
if (isValidFieldPath(fieldPath)) appendField(fieldPath, value);
|
|
1185
|
+
}
|
|
1186
|
+
for (const customField of customFields) {
|
|
1187
|
+
const fieldPath = `customFields.${customField.fieldName}.${customField.locale}`;
|
|
1188
|
+
if (isValidFieldPath(fieldPath)) appendField(fieldPath, customFieldValue(customField));
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
const media = await this.media.listEntityMedia(entity.id, { orgId });
|
|
1192
|
+
if (!media.ok) return PluginErr(media.error.message);
|
|
1193
|
+
const images: ChannelPushCatalogImage[] = [];
|
|
1194
|
+
for (const attached of media.value) {
|
|
1195
|
+
const role = pushCatalogImageRole(attached.role);
|
|
1196
|
+
if (!role) continue;
|
|
1197
|
+
const fieldPath = `media.${role}` as FieldPath;
|
|
1198
|
+
if (owners.get(fieldPath) !== "platform") continue;
|
|
1199
|
+
if (heldPaths.has(fieldPath)) {
|
|
1200
|
+
skipped.push({ entityId, fieldPath, reason: "held" });
|
|
1201
|
+
continue;
|
|
1202
|
+
}
|
|
1203
|
+
if (!selectCatalogFieldMapping(fieldMapping, fieldPath)) {
|
|
1204
|
+
skipped.push({ entityId, fieldPath, reason: "no_mapping" });
|
|
1205
|
+
continue;
|
|
1206
|
+
}
|
|
1207
|
+
images.push({
|
|
1208
|
+
url: attached.url,
|
|
1209
|
+
role,
|
|
1210
|
+
sortOrder: attached.sortOrder,
|
|
1211
|
+
...(attached.alt !== null ? { alt: attached.alt } : {}),
|
|
1212
|
+
});
|
|
1213
|
+
}
|
|
1214
|
+
fields.sort((left, right) => left.fieldPath.localeCompare(right.fieldPath));
|
|
1215
|
+
const item: ChannelPushCatalogItem = {
|
|
1216
|
+
externalId: entityMapping.externalId,
|
|
1217
|
+
fields,
|
|
1218
|
+
...(images.length > 0 ? { images } : {}),
|
|
1219
|
+
};
|
|
1220
|
+
items.push(item);
|
|
1221
|
+
if (options.recordRevision === true) revisionEntityIds.push(entity.id);
|
|
1222
|
+
}
|
|
1223
|
+
if (options.recordRevision === true && revisionEntityIds.length > 0) {
|
|
1224
|
+
const actor = createSystemActor(orgId);
|
|
1225
|
+
try {
|
|
1226
|
+
await this.transact(async (tx) => {
|
|
1227
|
+
const txContext = createTxContext(tx, { actor });
|
|
1228
|
+
for (const entityId of revisionEntityIds) {
|
|
1229
|
+
const revision = await this.catalog.recordEntityRevision(entityId, actor, "push", txContext);
|
|
1230
|
+
if (!revision.ok) throw new Error(revision.error.message);
|
|
1231
|
+
}
|
|
1232
|
+
});
|
|
1233
|
+
} catch (error) {
|
|
1234
|
+
return PluginErr(error instanceof Error ? error.message : "Failed to record catalog push revisions.");
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
return Ok({ items, skipped, warnings: [...new Set(warnings)] });
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
async getCatalogWriteSettings(orgId: string, storeId: string): Promise<PluginResult<CatalogWriteSettings>> {
|
|
1241
|
+
const store = await this.getStoreRecord(orgId, storeId);
|
|
1242
|
+
if (!store) return PluginErr("Connected store not found.", "NOT_FOUND");
|
|
1243
|
+
const warnings: string[] = [];
|
|
1244
|
+
return Ok({
|
|
1245
|
+
enabled: store.catalogWriteEnabled === true,
|
|
1246
|
+
overrides: store.catalogFieldMapping,
|
|
1247
|
+
merged: this.resolveCatalogFieldMapping(store, undefined, warnings),
|
|
1248
|
+
...(warnings.length > 0 ? { warnings } : {}),
|
|
1249
|
+
});
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
async updateCatalogWriteEnabled(
|
|
1253
|
+
orgId: string,
|
|
1254
|
+
storeId: string,
|
|
1255
|
+
enabled: boolean,
|
|
1256
|
+
): Promise<PluginResult<CatalogWriteSettings>> {
|
|
1257
|
+
const rows = await this.db
|
|
1258
|
+
.update(connectedStores)
|
|
1259
|
+
.set({ catalogWriteEnabled: enabled, updatedAt: new Date() })
|
|
1260
|
+
.where(and(eq(connectedStores.organizationId, orgId), eq(connectedStores.id, storeId)))
|
|
1261
|
+
.returning();
|
|
1262
|
+
if (!rows[0]) return PluginErr("Connected store not found.", "NOT_FOUND");
|
|
1263
|
+
return this.getCatalogWriteSettings(orgId, storeId);
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
async updateCatalogFieldMapping(
|
|
1267
|
+
orgId: string,
|
|
1268
|
+
storeId: string,
|
|
1269
|
+
mapping: unknown,
|
|
1270
|
+
): Promise<PluginResult<CatalogWriteSettings>> {
|
|
1271
|
+
const store = await this.getStoreRecord(orgId, storeId);
|
|
1272
|
+
if (!store) return PluginErr("Connected store not found.", "NOT_FOUND");
|
|
1273
|
+
let normalized: CatalogFieldMapping;
|
|
1274
|
+
try {
|
|
1275
|
+
normalized = normalizeCatalogFieldMapping(mapping as CatalogFieldMappingInput, store.provider);
|
|
1276
|
+
} catch (error) {
|
|
1277
|
+
return PluginErr(error instanceof Error ? error.message : "Catalog mapping is invalid.", "INVALID_MAPPING");
|
|
1278
|
+
}
|
|
1279
|
+
await this.db
|
|
1280
|
+
.update(connectedStores)
|
|
1281
|
+
.set({ catalogFieldMapping: normalized, updatedAt: new Date() })
|
|
1282
|
+
.where(and(eq(connectedStores.organizationId, orgId), eq(connectedStores.id, storeId)));
|
|
1283
|
+
return this.getCatalogWriteSettings(orgId, storeId);
|
|
1284
|
+
}
|
|
1285
|
+
|
|
211
1286
|
async connectStore(
|
|
212
1287
|
orgId: string,
|
|
213
1288
|
input: {
|
|
@@ -220,16 +1295,37 @@ export class ChannelConnectorService {
|
|
|
220
1295
|
if (!this.connectors.has(input.provider)) {
|
|
221
1296
|
return PluginErr(`No connector registered for provider "${input.provider}".`, "NOT_FOUND");
|
|
222
1297
|
}
|
|
223
|
-
const
|
|
224
|
-
.
|
|
225
|
-
.
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
storeDomain
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
1298
|
+
const existingRows = await this.db
|
|
1299
|
+
.select()
|
|
1300
|
+
.from(connectedStores)
|
|
1301
|
+
.where(and(
|
|
1302
|
+
eq(connectedStores.organizationId, orgId),
|
|
1303
|
+
eq(connectedStores.provider, input.provider),
|
|
1304
|
+
eq(connectedStores.storeDomain, input.storeDomain),
|
|
1305
|
+
));
|
|
1306
|
+
const reconnect = existingRows.find((row) => row.status !== "connected");
|
|
1307
|
+
const rows = reconnect
|
|
1308
|
+
? await this.db
|
|
1309
|
+
.update(connectedStores)
|
|
1310
|
+
.set({
|
|
1311
|
+
credentials: input.credentials,
|
|
1312
|
+
status: "connected",
|
|
1313
|
+
catalogWriteEnabled: false,
|
|
1314
|
+
webhookSecret: input.webhookSecret ?? crypto.randomUUID(),
|
|
1315
|
+
updatedAt: new Date(),
|
|
1316
|
+
})
|
|
1317
|
+
.where(eq(connectedStores.id, reconnect.id))
|
|
1318
|
+
.returning()
|
|
1319
|
+
: await this.db
|
|
1320
|
+
.insert(connectedStores)
|
|
1321
|
+
.values({
|
|
1322
|
+
organizationId: orgId,
|
|
1323
|
+
provider: input.provider,
|
|
1324
|
+
credentials: input.credentials,
|
|
1325
|
+
storeDomain: input.storeDomain,
|
|
1326
|
+
webhookSecret: input.webhookSecret ?? crypto.randomUUID(),
|
|
1327
|
+
})
|
|
1328
|
+
.returning();
|
|
233
1329
|
const connector = this.connectors.get(input.provider)!;
|
|
234
1330
|
const store = rows[0] as ConnectedStore;
|
|
235
1331
|
if (connector.registerWebhooks) {
|
|
@@ -372,7 +1468,7 @@ export class ChannelConnectorService {
|
|
|
372
1468
|
orgId: string,
|
|
373
1469
|
storeId: string,
|
|
374
1470
|
actor: Actor,
|
|
375
|
-
): Promise<PluginResult<{ imported: number; cursor: string | null }>> {
|
|
1471
|
+
): Promise<PluginResult<{ imported: number; cursor: string | null; skipped?: CatalogFieldSkip[]; conflicts?: CatalogFieldConflict[]; warnings?: string[] }>> {
|
|
376
1472
|
const store = await this.getStoreRecord(orgId, storeId);
|
|
377
1473
|
if (!store || store.status !== "connected") {
|
|
378
1474
|
return PluginErr("Connected store not found.", "NOT_FOUND");
|
|
@@ -396,7 +1492,323 @@ export class ChannelConnectorService {
|
|
|
396
1492
|
.update(connectedStores)
|
|
397
1493
|
.set({ catalogCursor: null, lastSyncAt: new Date(), updatedAt: new Date() })
|
|
398
1494
|
.where(and(eq(connectedStores.organizationId, orgId), eq(connectedStores.id, storeId)));
|
|
399
|
-
return Ok({
|
|
1495
|
+
return Ok({
|
|
1496
|
+
imported: result.value.imported,
|
|
1497
|
+
cursor: null,
|
|
1498
|
+
...(result.value.skipped.length > 0 ? { skipped: uniqueSkipped(result.value.skipped) } : {}),
|
|
1499
|
+
...(result.value.conflicts.length > 0 ? { conflicts: result.value.conflicts } : {}),
|
|
1500
|
+
...(result.value.warnings.length > 0 ? { warnings: result.value.warnings } : {}),
|
|
1501
|
+
});
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
private async promoteLegacyAttributes(
|
|
1505
|
+
orgId: string,
|
|
1506
|
+
storeId: string,
|
|
1507
|
+
actor: Actor,
|
|
1508
|
+
dryRun: boolean,
|
|
1509
|
+
): Promise<PluginResult<number>> {
|
|
1510
|
+
const mappings = await this.db.select().from(channelEntityMap).where(and(
|
|
1511
|
+
eq(channelEntityMap.organizationId, orgId),
|
|
1512
|
+
eq(channelEntityMap.storeId, storeId),
|
|
1513
|
+
eq(channelEntityMap.kind, "entity"),
|
|
1514
|
+
));
|
|
1515
|
+
let created = 0;
|
|
1516
|
+
for (const entityId of new Set(mappings.map((mapping) => mapping.entityId))) {
|
|
1517
|
+
const [entity] = await this.db.select().from(sellableEntities).where(and(
|
|
1518
|
+
eq(sellableEntities.organizationId, orgId),
|
|
1519
|
+
eq(sellableEntities.id, entityId),
|
|
1520
|
+
));
|
|
1521
|
+
if (!entity) continue;
|
|
1522
|
+
const attributes = await this.db.select().from(sellableAttributes).where(eq(sellableAttributes.entityId, entity.id));
|
|
1523
|
+
if (attributes.length > 0) continue;
|
|
1524
|
+
const metadata = entity.metadata ?? {};
|
|
1525
|
+
if (typeof metadata.title !== "string") continue;
|
|
1526
|
+
const owners = await this.catalog.resolveFieldOwners(entity.id, storeId);
|
|
1527
|
+
if (owners.get("attributes.en.title") === "platform") continue;
|
|
1528
|
+
if (dryRun) {
|
|
1529
|
+
created += 1;
|
|
1530
|
+
continue;
|
|
1531
|
+
}
|
|
1532
|
+
const promoted = await this.catalog.setAttributes(entity.id, "en", {
|
|
1533
|
+
title: metadata.title,
|
|
1534
|
+
...(typeof metadata.description === "string" ? { description: metadata.description } : {}),
|
|
1535
|
+
}, actor);
|
|
1536
|
+
if (!promoted.ok) return PluginErr(promoted.error.message);
|
|
1537
|
+
const [confirmed] = await this.db.select({ id: sellableAttributes.id, title: sellableAttributes.title, description: sellableAttributes.description }).from(sellableAttributes).where(and(
|
|
1538
|
+
eq(sellableAttributes.entityId, entity.id),
|
|
1539
|
+
eq(sellableAttributes.locale, "en"),
|
|
1540
|
+
));
|
|
1541
|
+
if (!confirmed || confirmed.title !== metadata.title || (typeof metadata.description === "string" && confirmed.description !== metadata.description)) {
|
|
1542
|
+
return PluginErr(`Legacy attributes for entity "${entity.id}" were not persisted.`);
|
|
1543
|
+
}
|
|
1544
|
+
const nextMetadata = { ...metadata };
|
|
1545
|
+
delete nextMetadata.title;
|
|
1546
|
+
if (typeof metadata.description === "string") delete nextMetadata.description;
|
|
1547
|
+
await this.db.update(sellableEntities).set({ metadata: nextMetadata, updatedAt: new Date() }).where(and(
|
|
1548
|
+
eq(sellableEntities.organizationId, orgId),
|
|
1549
|
+
eq(sellableEntities.id, entity.id),
|
|
1550
|
+
));
|
|
1551
|
+
created += 1;
|
|
1552
|
+
}
|
|
1553
|
+
return Ok(created);
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
private async saveBackfillState(orgId: string, storeId: string, state: BackfillState): Promise<void> {
|
|
1557
|
+
const [store] = await this.db.select({ breakerState: connectedStores.breakerState }).from(connectedStores).where(and(
|
|
1558
|
+
eq(connectedStores.organizationId, orgId),
|
|
1559
|
+
eq(connectedStores.id, storeId),
|
|
1560
|
+
));
|
|
1561
|
+
await this.db.update(connectedStores).set({
|
|
1562
|
+
breakerState: { ...(store?.breakerState ?? {}), catalogBackfill: state },
|
|
1563
|
+
updatedAt: new Date(),
|
|
1564
|
+
}).where(and(eq(connectedStores.organizationId, orgId), eq(connectedStores.id, storeId)));
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
async backfillCatalog(
|
|
1568
|
+
orgId: string,
|
|
1569
|
+
storeId: string,
|
|
1570
|
+
actor: Actor,
|
|
1571
|
+
options: BackfillCatalogOptions = {},
|
|
1572
|
+
): Promise<PluginResult<BackfillCatalogReport>> {
|
|
1573
|
+
const store = await this.getStoreRecord(orgId, storeId);
|
|
1574
|
+
if (!store || store.status !== "connected") return PluginErr("Connected store not found.", "NOT_FOUND");
|
|
1575
|
+
const connector = this.connectors.get(store.provider);
|
|
1576
|
+
if (!connector) return PluginErr(`No connector registered for provider "${store.provider}".`);
|
|
1577
|
+
const dryRun = options.dryRun === true;
|
|
1578
|
+
const saved = store.breakerState.catalogBackfill;
|
|
1579
|
+
const savedState = saved && typeof saved === "object" ? saved as unknown as BackfillState : undefined;
|
|
1580
|
+
// Undefined resume derives from persisted state, so a retried job or a
|
|
1581
|
+
// re-triggered run continues an unfinished backfill instead of restarting.
|
|
1582
|
+
const resume = options.resume ?? (savedState !== undefined && !savedState.completedAt);
|
|
1583
|
+
if (resume && savedState?.completedAt && savedState.cursor === null) {
|
|
1584
|
+
return Ok({
|
|
1585
|
+
...savedState.report,
|
|
1586
|
+
cursor: null,
|
|
1587
|
+
complete: true,
|
|
1588
|
+
...(savedState.skipped?.length ? { skipped: savedState.skipped } : {}),
|
|
1589
|
+
...(savedState.conflicts?.length ? { conflicts: savedState.conflicts } : {}),
|
|
1590
|
+
...(savedState.warnings?.length ? { warnings: savedState.warnings } : {}),
|
|
1591
|
+
});
|
|
1592
|
+
}
|
|
1593
|
+
const report = resume && savedState ? { ...savedState.report } : {
|
|
1594
|
+
entitiesTouched: 0,
|
|
1595
|
+
attributesCreated: 0,
|
|
1596
|
+
mediaImported: 0,
|
|
1597
|
+
variantsGivenOptionValues: 0,
|
|
1598
|
+
};
|
|
1599
|
+
const skipped = resume && savedState?.skipped ? [...savedState.skipped] : [];
|
|
1600
|
+
const conflicts = resume && savedState?.conflicts ? [...savedState.conflicts] : [];
|
|
1601
|
+
const warnings = resume && savedState?.warnings ? [...savedState.warnings] : [];
|
|
1602
|
+
const promoted = await this.promoteLegacyAttributes(orgId, storeId, actor, dryRun);
|
|
1603
|
+
if (!promoted.ok) return promoted;
|
|
1604
|
+
report.attributesCreated += promoted.value;
|
|
1605
|
+
let cursor = resume && savedState?.cursor ? savedState.cursor : undefined;
|
|
1606
|
+
let pages = 0;
|
|
1607
|
+
if (!dryRun) {
|
|
1608
|
+
await this.saveBackfillState(orgId, storeId, {
|
|
1609
|
+
cursor: cursor ?? null,
|
|
1610
|
+
report,
|
|
1611
|
+
...(skipped.length > 0 ? { skipped: uniqueSkipped(skipped) } : {}),
|
|
1612
|
+
...(conflicts.length > 0 ? { conflicts } : {}),
|
|
1613
|
+
...(warnings.length > 0 ? { warnings } : {}),
|
|
1614
|
+
});
|
|
1615
|
+
}
|
|
1616
|
+
do {
|
|
1617
|
+
const page = await connector.importCatalog(store as ChannelStore, cursor);
|
|
1618
|
+
if (!page.ok) return PluginErr(page.error.message);
|
|
1619
|
+
const converged = await this.convergeCatalogItems(orgId, storeId, page.value.items, actor, true, dryRun);
|
|
1620
|
+
if (!converged.ok) return converged;
|
|
1621
|
+
report.entitiesTouched += converged.value.entitiesTouched;
|
|
1622
|
+
report.attributesCreated += converged.value.attributesCreated;
|
|
1623
|
+
report.mediaImported += converged.value.mediaImported;
|
|
1624
|
+
report.variantsGivenOptionValues += converged.value.variantsGivenOptionValues;
|
|
1625
|
+
skipped.push(...converged.value.skipped);
|
|
1626
|
+
conflicts.push(...converged.value.conflicts);
|
|
1627
|
+
warnings.push(...converged.value.warnings);
|
|
1628
|
+
cursor = page.value.nextCursor ?? undefined;
|
|
1629
|
+
pages += 1;
|
|
1630
|
+
// The final state is written once with completedAt below; a cursor-null
|
|
1631
|
+
// checkpoint without it would read as a fresh start after a crash.
|
|
1632
|
+
if (!dryRun && cursor) {
|
|
1633
|
+
await this.saveBackfillState(orgId, storeId, {
|
|
1634
|
+
cursor,
|
|
1635
|
+
report,
|
|
1636
|
+
...(skipped.length > 0 ? { skipped: uniqueSkipped(skipped) } : {}),
|
|
1637
|
+
...(conflicts.length > 0 ? { conflicts } : {}),
|
|
1638
|
+
...(warnings.length > 0 ? { warnings } : {}),
|
|
1639
|
+
});
|
|
1640
|
+
}
|
|
1641
|
+
if (options.maxPages !== undefined && pages >= options.maxPages && cursor) {
|
|
1642
|
+
return Ok({
|
|
1643
|
+
...report,
|
|
1644
|
+
cursor,
|
|
1645
|
+
complete: false,
|
|
1646
|
+
...(skipped.length > 0 ? { skipped: uniqueSkipped(skipped) } : {}),
|
|
1647
|
+
...(conflicts.length > 0 ? { conflicts } : {}),
|
|
1648
|
+
...(warnings.length > 0 ? { warnings } : {}),
|
|
1649
|
+
});
|
|
1650
|
+
}
|
|
1651
|
+
} while (cursor);
|
|
1652
|
+
if (!dryRun) {
|
|
1653
|
+
await this.saveBackfillState(orgId, storeId, {
|
|
1654
|
+
cursor: null,
|
|
1655
|
+
report,
|
|
1656
|
+
...(skipped.length > 0 ? { skipped: uniqueSkipped(skipped) } : {}),
|
|
1657
|
+
...(conflicts.length > 0 ? { conflicts } : {}),
|
|
1658
|
+
...(warnings.length > 0 ? { warnings } : {}),
|
|
1659
|
+
completedAt: new Date().toISOString(),
|
|
1660
|
+
});
|
|
1661
|
+
}
|
|
1662
|
+
return Ok({
|
|
1663
|
+
...report,
|
|
1664
|
+
cursor: null,
|
|
1665
|
+
complete: true,
|
|
1666
|
+
...(skipped.length > 0 ? { skipped: uniqueSkipped(skipped) } : {}),
|
|
1667
|
+
...(conflicts.length > 0 ? { conflicts } : {}),
|
|
1668
|
+
...(warnings.length > 0 ? { warnings } : {}),
|
|
1669
|
+
});
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
private async estimateCatalogItems(
|
|
1673
|
+
orgId: string,
|
|
1674
|
+
storeId: string,
|
|
1675
|
+
items: ChannelCatalogItem[],
|
|
1676
|
+
): Promise<PluginResult<CatalogConvergenceStats>> {
|
|
1677
|
+
const stats: CatalogConvergenceStats = {
|
|
1678
|
+
imported: 0,
|
|
1679
|
+
converged: 0,
|
|
1680
|
+
entitiesTouched: 0,
|
|
1681
|
+
attributesCreated: 0,
|
|
1682
|
+
mediaImported: 0,
|
|
1683
|
+
variantsGivenOptionValues: 0,
|
|
1684
|
+
skipped: [],
|
|
1685
|
+
conflicts: [],
|
|
1686
|
+
warnings: [],
|
|
1687
|
+
};
|
|
1688
|
+
const assets = await this.db.select().from(mediaAssets).where(eq(mediaAssets.organizationId, orgId));
|
|
1689
|
+
for (const item of items) {
|
|
1690
|
+
const [entityMapping] = await this.db.select().from(channelEntityMap).where(and(
|
|
1691
|
+
eq(channelEntityMap.organizationId, orgId),
|
|
1692
|
+
eq(channelEntityMap.storeId, storeId),
|
|
1693
|
+
eq(channelEntityMap.kind, "entity"),
|
|
1694
|
+
eq(channelEntityMap.externalId, item.externalId),
|
|
1695
|
+
));
|
|
1696
|
+
if (!entityMapping) {
|
|
1697
|
+
stats.imported += 1;
|
|
1698
|
+
stats.entitiesTouched += 1;
|
|
1699
|
+
stats.attributesCreated += item.attributes?.length || 1;
|
|
1700
|
+
stats.variantsGivenOptionValues += item.variants.filter((variant) => Object.keys(variant.optionValues ?? {}).some((name) => item.options?.some((option) => option.name === name))).length;
|
|
1701
|
+
stats.mediaImported += item.images?.length ?? 0;
|
|
1702
|
+
continue;
|
|
1703
|
+
}
|
|
1704
|
+
const [entity] = await this.db.select().from(sellableEntities).where(and(
|
|
1705
|
+
eq(sellableEntities.organizationId, orgId),
|
|
1706
|
+
eq(sellableEntities.id, entityMapping.entityId),
|
|
1707
|
+
));
|
|
1708
|
+
if (!entity) continue;
|
|
1709
|
+
const owners = await this.catalog.resolveFieldOwners(entity.id, storeId);
|
|
1710
|
+
stats.skipped.push(...importedFieldPaths(item)
|
|
1711
|
+
.filter((path) => owners.get(path) === "platform")
|
|
1712
|
+
.map((fieldPath) => ({ entityId: entity.id, fieldPath })));
|
|
1713
|
+
let touched = false;
|
|
1714
|
+
const attributes = await this.db.select().from(sellableAttributes).where(eq(sellableAttributes.entityId, entity.id));
|
|
1715
|
+
const locales = new Set(attributes.map((attribute) => attribute.locale));
|
|
1716
|
+
const metadata = entity.metadata ?? {};
|
|
1717
|
+
if (attributes.length === 0 && typeof metadata.title === "string") {
|
|
1718
|
+
locales.add("en");
|
|
1719
|
+
touched = true;
|
|
1720
|
+
}
|
|
1721
|
+
const sourceAttributes = item.attributes?.length
|
|
1722
|
+
? item.attributes
|
|
1723
|
+
: [{ locale: "en", title: item.title, ...(item.description !== undefined ? { description: item.description } : {}) }];
|
|
1724
|
+
for (const attribute of sourceAttributes) {
|
|
1725
|
+
if (!locales.has(attribute.locale)) {
|
|
1726
|
+
stats.attributesCreated += 1;
|
|
1727
|
+
locales.add(attribute.locale);
|
|
1728
|
+
touched = true;
|
|
1729
|
+
}
|
|
1730
|
+
}
|
|
1731
|
+
const remoteMetadata = mergeMetadata(entity.metadata, item.metadata ?? {});
|
|
1732
|
+
const remoteStatus = item.status ?? (entity.status === "archived" ? "active" : undefined);
|
|
1733
|
+
const entityChanged = entity.slug !== item.slug
|
|
1734
|
+
|| hash(remoteMetadata) !== hash(entity.metadata ?? {})
|
|
1735
|
+
|| (remoteStatus !== undefined && remoteStatus !== entity.status);
|
|
1736
|
+
if (entityChanged) {
|
|
1737
|
+
stats.converged += 1;
|
|
1738
|
+
touched = true;
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
const optionValueIds = new Map<string, Map<string, string>>();
|
|
1742
|
+
const existingTypes = await this.db.select().from(optionTypes).where(eq(optionTypes.entityId, entity.id));
|
|
1743
|
+
for (const sourceType of item.options ?? []) {
|
|
1744
|
+
const existingType = existingTypes.find((optionType) => optionType.name === sourceType.name);
|
|
1745
|
+
if (!existingType) {
|
|
1746
|
+
touched = true;
|
|
1747
|
+
optionValueIds.set(sourceType.name, new Map(sourceType.values.map((value) => [value.value, `new:${sourceType.name}:${value.value}`])));
|
|
1748
|
+
continue;
|
|
1749
|
+
}
|
|
1750
|
+
const existingValues = await this.db.select().from(optionValues).where(eq(optionValues.optionTypeId, existingType.id));
|
|
1751
|
+
const valueIds = new Map<string, string>();
|
|
1752
|
+
for (const sourceValue of sourceType.values) {
|
|
1753
|
+
const existingValue = existingValues.find((value) => value.value === sourceValue.value);
|
|
1754
|
+
if (!existingValue) touched = true;
|
|
1755
|
+
valueIds.set(sourceValue.value, existingValue?.id ?? `new:${sourceType.name}:${sourceValue.value}`);
|
|
1756
|
+
}
|
|
1757
|
+
optionValueIds.set(sourceType.name, valueIds);
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
const variantMappings = await this.db.select().from(channelEntityMap).where(and(
|
|
1761
|
+
eq(channelEntityMap.organizationId, orgId),
|
|
1762
|
+
eq(channelEntityMap.storeId, storeId),
|
|
1763
|
+
eq(channelEntityMap.kind, "variant"),
|
|
1764
|
+
eq(channelEntityMap.entityId, entity.id),
|
|
1765
|
+
));
|
|
1766
|
+
const variantIds = new Map<string, string>();
|
|
1767
|
+
for (const sourceVariant of item.variants) {
|
|
1768
|
+
const mapping = variantMappings.find((row) => row.externalId === sourceVariant.externalId);
|
|
1769
|
+
const variantId = mapping?.variantId ?? `new:${sourceVariant.externalId}`;
|
|
1770
|
+
variantIds.set(sourceVariant.externalId, variantId);
|
|
1771
|
+
const desiredIds = [...new Set(Object.entries(sourceVariant.optionValues ?? {})
|
|
1772
|
+
.map(([name, value]) => optionValueIds.get(name)?.get(value))
|
|
1773
|
+
.filter((optionValueId): optionValueId is string => optionValueId !== undefined))].sort();
|
|
1774
|
+
if (!mapping?.variantId) {
|
|
1775
|
+
if (desiredIds.length > 0) stats.variantsGivenOptionValues += 1;
|
|
1776
|
+
touched = true;
|
|
1777
|
+
continue;
|
|
1778
|
+
}
|
|
1779
|
+
const current = await this.db.select().from(variantOptionValues).where(eq(variantOptionValues.variantId, mapping.variantId));
|
|
1780
|
+
const currentIds = current.map((row) => row.optionValueId).sort();
|
|
1781
|
+
if (currentIds.length !== desiredIds.length || currentIds.some((id, index) => id !== desiredIds[index])) {
|
|
1782
|
+
if (desiredIds.length > 0) stats.variantsGivenOptionValues += 1;
|
|
1783
|
+
touched = true;
|
|
1784
|
+
}
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1787
|
+
const links = await this.db.select().from(entityMedia).where(eq(entityMedia.entityId, entity.id));
|
|
1788
|
+
for (const image of item.images ?? []) {
|
|
1789
|
+
const urlHash = hash(image.url);
|
|
1790
|
+
const asset = assets.find((row) => {
|
|
1791
|
+
const assetMetadata = row.metadata ?? {};
|
|
1792
|
+
return (image.externalId != null && assetMetadata.channelImageExternalId === image.externalId)
|
|
1793
|
+
|| assetMetadata.channelImageUrlHash === urlHash;
|
|
1794
|
+
});
|
|
1795
|
+
const mediaAssetId = asset?.id ?? `new:${urlHash}`;
|
|
1796
|
+
if (!asset) stats.mediaImported += 1;
|
|
1797
|
+
const targets = image.variantExternalIds?.length
|
|
1798
|
+
? image.variantExternalIds.map((externalId) => ({ externalId, variantId: variantIds.get(externalId) }))
|
|
1799
|
+
: [{ externalId: undefined, variantId: undefined }];
|
|
1800
|
+
for (const target of targets) {
|
|
1801
|
+
if (image.variantExternalIds?.length && !target.variantId) {
|
|
1802
|
+
stats.warnings.push(`Skipped image "${image.externalId ?? image.url}" for unmapped variant "${target.externalId}".`);
|
|
1803
|
+
continue;
|
|
1804
|
+
}
|
|
1805
|
+
const existingLink = links.find((link) => link.mediaAssetId === mediaAssetId && (target.variantId === undefined ? link.variantId === null : link.variantId === target.variantId));
|
|
1806
|
+
if (!existingLink) touched = true;
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
1809
|
+
if (touched) stats.entitiesTouched += 1;
|
|
1810
|
+
}
|
|
1811
|
+
return Ok(stats);
|
|
400
1812
|
}
|
|
401
1813
|
|
|
402
1814
|
private async convergeCatalogItems(
|
|
@@ -404,10 +1816,21 @@ export class ChannelConnectorService {
|
|
|
404
1816
|
storeId: string,
|
|
405
1817
|
items: ChannelCatalogItem[],
|
|
406
1818
|
actor: Actor,
|
|
407
|
-
|
|
1819
|
+
force = false,
|
|
1820
|
+
dryRun = false,
|
|
1821
|
+
): Promise<PluginResult<CatalogConvergenceStats>> {
|
|
1822
|
+
if (dryRun) return this.estimateCatalogItems(orgId, storeId, items);
|
|
408
1823
|
let imported = 0;
|
|
409
1824
|
let converged = 0;
|
|
1825
|
+
let entitiesTouched = 0;
|
|
1826
|
+
let attributesCreated = 0;
|
|
1827
|
+
let mediaImported = 0;
|
|
1828
|
+
let variantsGivenOptionValues = 0;
|
|
1829
|
+
const skipped: CatalogFieldSkip[] = [];
|
|
1830
|
+
const conflicts: CatalogFieldConflict[] = [];
|
|
1831
|
+
const warnings: string[] = [];
|
|
410
1832
|
for (const item of items) {
|
|
1833
|
+
const remoteHash = hash(item);
|
|
411
1834
|
const existing = await this.db
|
|
412
1835
|
.select()
|
|
413
1836
|
.from(channelEntityMap)
|
|
@@ -418,76 +1841,168 @@ export class ChannelConnectorService {
|
|
|
418
1841
|
eq(channelEntityMap.externalId, item.externalId),
|
|
419
1842
|
));
|
|
420
1843
|
const entityMapping = existing.find((entry) => entry.kind === "entity");
|
|
1844
|
+
let entityId: string;
|
|
1845
|
+
let isNew = false;
|
|
1846
|
+
let entityTouched = false;
|
|
1847
|
+
let existingEntity: typeof sellableEntities.$inferSelect | undefined;
|
|
421
1848
|
if (entityMapping) {
|
|
422
1849
|
const [entity] = await this.db.select().from(sellableEntities).where(and(
|
|
423
1850
|
eq(sellableEntities.organizationId, orgId),
|
|
424
1851
|
eq(sellableEntities.id, entityMapping.entityId),
|
|
425
1852
|
));
|
|
426
|
-
if (
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
metadata: {
|
|
430
|
-
...(item.metadata ?? {}),
|
|
431
|
-
title: item.title,
|
|
432
|
-
...(item.description !== undefined ? { description: item.description } : {}),
|
|
433
|
-
},
|
|
434
|
-
...(entity?.status === "archived" ? { status: "active", isVisible: true } : {}),
|
|
435
|
-
}, actor);
|
|
436
|
-
if (!updated.ok) return PluginErr(updated.error.message);
|
|
437
|
-
await this.db.update(channelEntityMap).set({ syncHash: hash(item), lastSyncedAt: new Date() }).where(eq(channelEntityMap.id, entityMapping.id));
|
|
438
|
-
converged += 1;
|
|
1853
|
+
if (!entity) {
|
|
1854
|
+
warnings.push(`Skipped "${item.externalId}": mapped entity ${entityMapping.entityId} no longer exists.`);
|
|
1855
|
+
continue;
|
|
439
1856
|
}
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
{
|
|
1857
|
+
entityId = entityMapping.entityId;
|
|
1858
|
+
existingEntity = entity;
|
|
1859
|
+
} else {
|
|
1860
|
+
const status = item.status;
|
|
1861
|
+
const entity = await this.catalog.create({
|
|
445
1862
|
type: "product",
|
|
446
1863
|
slug: item.slug,
|
|
447
1864
|
sourceStoreId: storeId,
|
|
448
|
-
metadata: {
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
1865
|
+
metadata: mergeMetadata(undefined, item.metadata ?? {}),
|
|
1866
|
+
...(status !== undefined ? { status, isVisible: status === "active" } : {}),
|
|
1867
|
+
}, actor);
|
|
1868
|
+
if (!entity.ok) return PluginErr(entity.error.message);
|
|
1869
|
+
entityId = entity.value.id;
|
|
1870
|
+
isNew = true;
|
|
1871
|
+
imported += 1;
|
|
1872
|
+
entityTouched = true;
|
|
1873
|
+
}
|
|
1874
|
+
|
|
1875
|
+
const ownershipBeforeSeed = await this.catalog.resolveFieldOwners(entityId, storeId);
|
|
1876
|
+
const seedPaths = importedFieldPaths(item).filter((path) => !ownershipBeforeSeed.has(path));
|
|
1877
|
+
const seeded = await this.catalog.seedImportedFieldOwnership(entityId, storeId, seedPaths);
|
|
1878
|
+
if (!seeded.ok) return PluginErr(seeded.error.message);
|
|
1879
|
+
for (const path of seedPaths) ownershipBeforeSeed.set(path, "store");
|
|
1880
|
+
const owners = ownershipBeforeSeed;
|
|
1881
|
+
const remoteChanged = entityMapping === undefined || entityMapping.syncHash !== remoteHash;
|
|
1882
|
+
// An unchanged remote item writes nothing and advances no baseline:
|
|
1883
|
+
// converging a stale replay would revert local edits to shared and
|
|
1884
|
+
// unowned fields that the store never actually changed.
|
|
1885
|
+
if (!force && !remoteChanged && existingEntity && existingEntity.status !== "archived") {
|
|
1886
|
+
continue;
|
|
1887
|
+
}
|
|
1888
|
+
const shared = existingEntity
|
|
1889
|
+
? await this.detectSharedConflicts(entityId, storeId, existingEntity, entityMapping, item, owners)
|
|
1890
|
+
: { paths: [], conflicts: [] };
|
|
1891
|
+
const owned = this.filterOwnedFields(item, owners);
|
|
1892
|
+
const heldSharedPaths = [...new Set([...(entityMapping?.heldFieldPaths ?? []), ...shared.paths])];
|
|
1893
|
+
const held = this.filterConflictingFields(owned.writable, heldSharedPaths);
|
|
1894
|
+
const writable = held.writable;
|
|
1895
|
+
const blockedPaths = new Set<FieldPath>([...owned.skipped, ...heldSharedPaths]);
|
|
1896
|
+
skipped.push(...owned.skipped.map((fieldPath) => ({ entityId, fieldPath })));
|
|
1897
|
+
conflicts.push(...shared.conflicts);
|
|
1898
|
+
for (const conflict of shared.conflicts) {
|
|
1899
|
+
warnings.push(`Held shared field conflict for entity "${conflict.entityId}", store "${conflict.storeId}", field "${conflict.fieldPath}" (local ${conflict.localValueSummary}, remote ${conflict.remoteValueSummary}).`);
|
|
1900
|
+
}
|
|
1901
|
+
|
|
1902
|
+
if (existingEntity && entityMapping) {
|
|
1903
|
+
const remoteMetadata = mergeMetadata(existingEntity.metadata, writable.metadata ?? {});
|
|
1904
|
+
const remoteStatus = ownerAllows(owners, "entity.status") && !blockedPaths.has("entity.status")
|
|
1905
|
+
? writable.status ?? (existingEntity.status === "archived" ? "active" : undefined)
|
|
1906
|
+
: undefined;
|
|
1907
|
+
const updateInput: {
|
|
1908
|
+
slug?: string;
|
|
1909
|
+
metadata?: Record<string, unknown>;
|
|
1910
|
+
status?: string;
|
|
1911
|
+
isVisible?: boolean;
|
|
1912
|
+
} = {};
|
|
1913
|
+
if (ownerAllows(owners, "entity.slug") && !blockedPaths.has("entity.slug") && existingEntity.slug !== writable.slug) {
|
|
1914
|
+
updateInput.slug = writable.slug;
|
|
1915
|
+
}
|
|
1916
|
+
if (hash(remoteMetadata) !== hash(existingEntity.metadata ?? {})) updateInput.metadata = remoteMetadata;
|
|
1917
|
+
if (remoteStatus !== undefined && !blockedPaths.has("entity.status") && remoteStatus !== existingEntity.status) {
|
|
1918
|
+
updateInput.status = remoteStatus;
|
|
1919
|
+
updateInput.isVisible = remoteStatus === "active";
|
|
1920
|
+
}
|
|
1921
|
+
const shouldUpdate = force
|
|
1922
|
+
? Object.keys(updateInput).length > 0
|
|
1923
|
+
: remoteChanged || existingEntity.status === "archived";
|
|
1924
|
+
if (shouldUpdate) {
|
|
1925
|
+
converged += 1;
|
|
1926
|
+
if (Object.keys(updateInput).length > 0) {
|
|
1927
|
+
const updated = await this.catalog.update(entityMapping.entityId, updateInput, actor);
|
|
1928
|
+
if (!updated.ok) return PluginErr(updated.error.message);
|
|
1929
|
+
entityTouched = true;
|
|
1930
|
+
}
|
|
1931
|
+
}
|
|
1932
|
+
}
|
|
1933
|
+
|
|
1934
|
+
const optionAxes = await this.upsertOptionAxes(entityId, writable, actor);
|
|
1935
|
+
if (!optionAxes.ok) return optionAxes;
|
|
1936
|
+
const attributes = await this.setCatalogAttributes(entityId, writable, actor, blockedPaths);
|
|
1937
|
+
if (!attributes.ok) return attributes;
|
|
1938
|
+
const variantIds = await this.upsertVariants(
|
|
1939
|
+
orgId,
|
|
1940
|
+
storeId,
|
|
1941
|
+
entityId,
|
|
1942
|
+
writable,
|
|
1943
|
+
optionAxes.value.value,
|
|
454
1944
|
actor,
|
|
1945
|
+
warnings,
|
|
1946
|
+
!heldSharedPaths.includes("options") && owners.get("options") !== "platform",
|
|
1947
|
+
item,
|
|
455
1948
|
);
|
|
456
|
-
if (!
|
|
1949
|
+
if (!variantIds.ok) return variantIds;
|
|
1950
|
+
const taxonomy = await this.applyTaxonomy(orgId, entityId, writable, actor, warnings);
|
|
1951
|
+
if (!taxonomy.ok) return taxonomy;
|
|
1952
|
+
const media = await this.applyMedia(orgId, entityId, writable, variantIds.value.value, actor, warnings, owners);
|
|
1953
|
+
if (!media.ok) return media;
|
|
1954
|
+
attributesCreated += attributes.value.created;
|
|
1955
|
+
mediaImported += media.value.imported;
|
|
1956
|
+
variantsGivenOptionValues += variantIds.value.repaired;
|
|
1957
|
+
skipped.push(...media.value.skipped.map((fieldPath) => ({ entityId, fieldPath })));
|
|
1958
|
+
entityTouched = entityTouched || optionAxes.value.changed || variantIds.value.changed || media.value.changed || attributes.value.changed;
|
|
1959
|
+
if (entityTouched) entitiesTouched += 1;
|
|
1960
|
+
|
|
1961
|
+
if (entityTouched) {
|
|
1962
|
+
const revision = await this.catalog.recordEntityRevision(entityId, actor, "import");
|
|
1963
|
+
if (!revision.ok) return PluginErr(revision.error.message);
|
|
1964
|
+
}
|
|
457
1965
|
|
|
458
|
-
await this.
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
kind: "entity",
|
|
462
|
-
externalId: item.externalId,
|
|
463
|
-
entityId: entity.value.id,
|
|
464
|
-
syncHash: hash(item),
|
|
465
|
-
});
|
|
1966
|
+
const revisionMarkers = await this.catalog.repository.findRevisionMarkers(entityId);
|
|
1967
|
+
const latestRevisionAt = revisionMarkers.at(-1)?.createdAt;
|
|
1968
|
+
const lastSyncedAt = latestRevisionAt ?? entityMapping?.lastSyncedAt ?? new Date();
|
|
466
1969
|
|
|
467
|
-
|
|
468
|
-
const variant = await this.catalog.createVariant(
|
|
469
|
-
{
|
|
470
|
-
entityId: entity.value.id,
|
|
471
|
-
options: {},
|
|
472
|
-
...(sourceVariant.sku !== undefined ? { sku: sourceVariant.sku } : {}),
|
|
473
|
-
...(sourceVariant.barcode !== undefined ? { barcode: sourceVariant.barcode } : {}),
|
|
474
|
-
},
|
|
475
|
-
actor,
|
|
476
|
-
);
|
|
477
|
-
if (!variant.ok) return PluginErr(variant.error.message);
|
|
1970
|
+
if (isNew) {
|
|
478
1971
|
await this.db.insert(channelEntityMap).values({
|
|
479
1972
|
organizationId: orgId,
|
|
480
1973
|
storeId,
|
|
481
|
-
kind: "
|
|
482
|
-
externalId:
|
|
483
|
-
entityId
|
|
484
|
-
|
|
485
|
-
|
|
1974
|
+
kind: "entity",
|
|
1975
|
+
externalId: item.externalId,
|
|
1976
|
+
entityId,
|
|
1977
|
+
syncHash: remoteHash,
|
|
1978
|
+
lastSyncedAt,
|
|
1979
|
+
heldFieldPaths: heldSharedPaths,
|
|
486
1980
|
});
|
|
1981
|
+
} else if (entityMapping) {
|
|
1982
|
+
await this.db.update(channelEntityMap).set({
|
|
1983
|
+
syncHash: remoteHash,
|
|
1984
|
+
lastSyncedAt,
|
|
1985
|
+
heldFieldPaths: heldSharedPaths,
|
|
1986
|
+
}).where(eq(channelEntityMap.id, entityMapping.id));
|
|
487
1987
|
}
|
|
488
|
-
|
|
1988
|
+
await this.db.update(channelEntityMap).set({ lastSyncedAt }).where(and(
|
|
1989
|
+
eq(channelEntityMap.organizationId, orgId),
|
|
1990
|
+
eq(channelEntityMap.storeId, storeId),
|
|
1991
|
+
eq(channelEntityMap.entityId, entityId),
|
|
1992
|
+
eq(channelEntityMap.kind, "variant"),
|
|
1993
|
+
));
|
|
489
1994
|
}
|
|
490
|
-
return Ok({
|
|
1995
|
+
return Ok({
|
|
1996
|
+
imported,
|
|
1997
|
+
converged,
|
|
1998
|
+
entitiesTouched,
|
|
1999
|
+
attributesCreated,
|
|
2000
|
+
mediaImported,
|
|
2001
|
+
variantsGivenOptionValues,
|
|
2002
|
+
skipped,
|
|
2003
|
+
conflicts,
|
|
2004
|
+
warnings,
|
|
2005
|
+
});
|
|
491
2006
|
}
|
|
492
2007
|
|
|
493
2008
|
async reconcile(
|
|
@@ -515,6 +2030,7 @@ export class ChannelConnectorService {
|
|
|
515
2030
|
if (!converged.ok) return converged;
|
|
516
2031
|
const present = new Set(items.map((item) => item.externalId));
|
|
517
2032
|
let archived = 0;
|
|
2033
|
+
const skipped = [...converged.value.skipped];
|
|
518
2034
|
for (const mapping of entityMappings) {
|
|
519
2035
|
if (present.has(mapping.externalId)) continue;
|
|
520
2036
|
const [entity] = await this.db.select({ status: sellableEntities.status }).from(sellableEntities).where(and(
|
|
@@ -522,6 +2038,11 @@ export class ChannelConnectorService {
|
|
|
522
2038
|
eq(sellableEntities.id, mapping.entityId),
|
|
523
2039
|
));
|
|
524
2040
|
if (entity?.status !== "archived") {
|
|
2041
|
+
const owners = await this.catalog.resolveFieldOwners(mapping.entityId, storeId);
|
|
2042
|
+
if (owners.get("entity.status") === "platform") {
|
|
2043
|
+
skipped.push({ entityId: mapping.entityId, fieldPath: "entity.status" });
|
|
2044
|
+
continue;
|
|
2045
|
+
}
|
|
525
2046
|
const result = await this.catalog.archive(mapping.entityId, actor);
|
|
526
2047
|
if (!result.ok) return PluginErr(result.error.message);
|
|
527
2048
|
archived += 1;
|
|
@@ -556,6 +2077,9 @@ export class ChannelConnectorService {
|
|
|
556
2077
|
archived,
|
|
557
2078
|
inventoryUpdated,
|
|
558
2079
|
driftAlert: converged.value.imported + converged.value.converged + archived > threshold,
|
|
2080
|
+
...(skipped.length > 0 ? { skipped: uniqueSkipped(skipped) } : {}),
|
|
2081
|
+
...(converged.value.conflicts.length > 0 ? { conflicts: converged.value.conflicts } : {}),
|
|
2082
|
+
...(converged.value.warnings.length > 0 ? { warnings: converged.value.warnings } : {}),
|
|
559
2083
|
};
|
|
560
2084
|
await this.db.update(connectedStores).set({
|
|
561
2085
|
lastReconcileAt: new Date(),
|
|
@@ -616,16 +2140,30 @@ export class ChannelConnectorService {
|
|
|
616
2140
|
if (!store) return PluginErr("Connected store not found.", "NOT_FOUND");
|
|
617
2141
|
const actor = createSystemActor(orgId);
|
|
618
2142
|
const data = event.data as Record<string, unknown>;
|
|
2143
|
+
let skipped: CatalogFieldSkip[] = [];
|
|
2144
|
+
let conflicts: CatalogFieldConflict[] = [];
|
|
2145
|
+
let warnings: string[] = [];
|
|
619
2146
|
if (event.type === "products/update") {
|
|
620
2147
|
const productId = String(data.id ?? data.product_id ?? "");
|
|
621
2148
|
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)));
|
|
622
|
-
if (mapping[0])
|
|
2149
|
+
if (mapping[0]) {
|
|
2150
|
+
const converged = await this.convergeCatalogItem(orgId, storeId, mapping[0].entityId, data, actor);
|
|
2151
|
+
if (!converged.ok) return converged;
|
|
2152
|
+
skipped = converged.value.skipped;
|
|
2153
|
+
conflicts = converged.value.conflicts;
|
|
2154
|
+
warnings = converged.value.warnings;
|
|
2155
|
+
}
|
|
623
2156
|
} else if (event.type === "products/delete") {
|
|
624
2157
|
const productId = String(data.id ?? data.product_id ?? "");
|
|
625
2158
|
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)));
|
|
626
2159
|
if (mapping[0]) {
|
|
627
|
-
const
|
|
628
|
-
if (
|
|
2160
|
+
const owners = await this.catalog.resolveFieldOwners(mapping[0].entityId, storeId);
|
|
2161
|
+
if (owners.get("entity.status") === "platform") {
|
|
2162
|
+
skipped.push({ entityId: mapping[0].entityId, fieldPath: "entity.status" });
|
|
2163
|
+
} else {
|
|
2164
|
+
const archived = await this.catalog.archive(mapping[0].entityId, actor);
|
|
2165
|
+
if (!archived.ok) return PluginErr(archived.error.message);
|
|
2166
|
+
}
|
|
629
2167
|
}
|
|
630
2168
|
} else if (event.type === "inventory_levels/update") {
|
|
631
2169
|
const externalId = String(data.inventory_item_id ?? data.variation_id ?? data.product_id ?? "");
|
|
@@ -664,6 +2202,18 @@ export class ChannelConnectorService {
|
|
|
664
2202
|
if (!disconnected.ok) return disconnected;
|
|
665
2203
|
return Ok({ processed: true });
|
|
666
2204
|
}
|
|
2205
|
+
if (skipped.length > 0 || conflicts.length > 0 || warnings.length > 0) {
|
|
2206
|
+
const report = {
|
|
2207
|
+
...(store.lastReconcileReport ?? {}),
|
|
2208
|
+
...(skipped.length > 0 ? { skipped: uniqueSkipped(skipped) } : {}),
|
|
2209
|
+
...(conflicts.length > 0 ? { conflicts } : {}),
|
|
2210
|
+
...(warnings.length > 0 ? { warnings } : {}),
|
|
2211
|
+
};
|
|
2212
|
+
await this.db.update(connectedStores).set({ lastReconcileReport: report, updatedAt: new Date() }).where(and(
|
|
2213
|
+
eq(connectedStores.organizationId, orgId),
|
|
2214
|
+
eq(connectedStores.id, storeId),
|
|
2215
|
+
));
|
|
2216
|
+
}
|
|
667
2217
|
return Ok({ processed: true });
|
|
668
2218
|
}
|
|
669
2219
|
|
|
@@ -735,14 +2285,115 @@ export class ChannelConnectorService {
|
|
|
735
2285
|
await inventory.setAbsolute({ entityId: mapping.entityId, ...(mapping.variantId ? { variantId: mapping.variantId } : {}), quantity: Math.max(0, Math.floor(quantity)), reason: "Inventory webhook sync" }, actor);
|
|
736
2286
|
}
|
|
737
2287
|
|
|
738
|
-
private async convergeCatalogItem(
|
|
2288
|
+
private async convergeCatalogItem(
|
|
2289
|
+
orgId: string,
|
|
2290
|
+
storeId: string,
|
|
2291
|
+
entityId: string,
|
|
2292
|
+
data: Record<string, unknown>,
|
|
2293
|
+
actor: Actor,
|
|
2294
|
+
): Promise<PluginResult<{ skipped: CatalogFieldSkip[]; conflicts: CatalogFieldConflict[]; warnings: string[] }>> {
|
|
739
2295
|
const product = data.product && typeof data.product === "object" ? data.product as Record<string, unknown> : data;
|
|
2296
|
+
const remoteMetadata = product.metadata && typeof product.metadata === "object" && !Array.isArray(product.metadata)
|
|
2297
|
+
? product.metadata as Record<string, unknown>
|
|
2298
|
+
: {};
|
|
2299
|
+
const [mapping] = await this.db.select().from(channelEntityMap).where(and(
|
|
2300
|
+
eq(channelEntityMap.organizationId, orgId),
|
|
2301
|
+
eq(channelEntityMap.storeId, storeId),
|
|
2302
|
+
eq(channelEntityMap.kind, "entity"),
|
|
2303
|
+
eq(channelEntityMap.entityId, entityId),
|
|
2304
|
+
));
|
|
2305
|
+
const [entity] = await this.db.select().from(sellableEntities).where(and(
|
|
2306
|
+
eq(sellableEntities.organizationId, orgId),
|
|
2307
|
+
eq(sellableEntities.id, entityId),
|
|
2308
|
+
));
|
|
2309
|
+
if (!mapping || !entity) return Ok({ skipped: [], conflicts: [], warnings: [] });
|
|
2310
|
+
const [currentAttribute] = await this.db.select().from(sellableAttributes).where(and(
|
|
2311
|
+
eq(sellableAttributes.entityId, entityId),
|
|
2312
|
+
eq(sellableAttributes.locale, "en"),
|
|
2313
|
+
));
|
|
2314
|
+
const title = typeof product.title === "string" ? product.title : currentAttribute?.title ?? entity.slug;
|
|
2315
|
+
const description = product.description !== undefined
|
|
2316
|
+
? String(product.description)
|
|
2317
|
+
: currentAttribute?.description ?? undefined;
|
|
2318
|
+
const status = typeof product.status === "string" && ["draft", "active", "archived", "discontinued"].includes(product.status)
|
|
2319
|
+
? product.status as NonNullable<ChannelCatalogItem["status"]>
|
|
2320
|
+
: undefined;
|
|
2321
|
+
const remoteItem: ChannelCatalogItem = {
|
|
2322
|
+
externalId: mapping.externalId,
|
|
2323
|
+
slug: typeof product.slug === "string" ? product.slug : entity.slug,
|
|
2324
|
+
title,
|
|
2325
|
+
...(description !== undefined ? { description } : {}),
|
|
2326
|
+
...(status !== undefined ? { status } : {}),
|
|
2327
|
+
attributes: [{ locale: "en", title, ...(description !== undefined ? { description } : {}) }],
|
|
2328
|
+
...(Object.keys(remoteMetadata).length > 0 ? { metadata: remoteMetadata } : {}),
|
|
2329
|
+
variants: [],
|
|
2330
|
+
};
|
|
2331
|
+
const fieldPaths: FieldPath[] = [];
|
|
2332
|
+
if (typeof product.slug === "string") fieldPaths.push("entity.slug");
|
|
2333
|
+
if (status !== undefined) fieldPaths.push("entity.status");
|
|
2334
|
+
for (const key of Object.keys(remoteMetadata)) {
|
|
2335
|
+
const path = `entity.metadata.${key}`;
|
|
2336
|
+
if (isValidFieldPath(path)) fieldPaths.push(path);
|
|
2337
|
+
}
|
|
2338
|
+
if (typeof product.title === "string") fieldPaths.push("attributes.en.title");
|
|
2339
|
+
if (product.description !== undefined) fieldPaths.push("attributes.en.description");
|
|
2340
|
+
const ownershipBeforeSeed = await this.catalog.resolveFieldOwners(entityId, storeId);
|
|
2341
|
+
const seedPaths = fieldPaths.filter((path) => !ownershipBeforeSeed.has(path));
|
|
2342
|
+
const seeded = await this.catalog.seedImportedFieldOwnership(entityId, storeId, seedPaths);
|
|
2343
|
+
if (!seeded.ok) return PluginErr(seeded.error.message);
|
|
2344
|
+
for (const path of seedPaths) ownershipBeforeSeed.set(path, "store");
|
|
2345
|
+
const owners = ownershipBeforeSeed;
|
|
2346
|
+
const remoteHash = hash(product);
|
|
2347
|
+
const shared = await this.detectSharedConflicts(entityId, storeId, entity, mapping, remoteItem, owners, fieldPaths, remoteHash);
|
|
2348
|
+
const owned = this.filterOwnedFieldsAtPaths(remoteItem, owners, fieldPaths);
|
|
2349
|
+
const heldPaths = [...new Set([...(mapping.heldFieldPaths ?? []), ...shared.paths])];
|
|
2350
|
+
const held = this.filterConflictingFields(owned.writable, heldPaths);
|
|
2351
|
+
const blockedPaths = new Set<FieldPath>([
|
|
2352
|
+
...owned.skipped,
|
|
2353
|
+
...heldPaths,
|
|
2354
|
+
...(!fieldPaths.includes("attributes.en.title") ? ["attributes.en.title" as FieldPath] : []),
|
|
2355
|
+
]);
|
|
2356
|
+
const skipped = owned.skipped.map((fieldPath) => ({ entityId, fieldPath }));
|
|
2357
|
+
const conflicts = shared.conflicts;
|
|
2358
|
+
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}).`);
|
|
2359
|
+
const writable = held.writable;
|
|
2360
|
+
const updateInput: {
|
|
2361
|
+
slug?: string;
|
|
2362
|
+
metadata?: Record<string, unknown>;
|
|
2363
|
+
status?: string;
|
|
2364
|
+
isVisible?: boolean;
|
|
2365
|
+
} = {};
|
|
2366
|
+
if (fieldPaths.includes("entity.slug") && ownerAllows(owners, "entity.slug") && !blockedPaths.has("entity.slug") && entity.slug !== writable.slug) {
|
|
2367
|
+
updateInput.slug = writable.slug;
|
|
2368
|
+
}
|
|
2369
|
+
if (Object.keys(writable.metadata ?? {}).length > 0) {
|
|
2370
|
+
const remoteEntityMetadata = mergeMetadata(entity.metadata, writable.metadata ?? {});
|
|
2371
|
+
if (hash(remoteEntityMetadata) !== hash(entity.metadata ?? {})) updateInput.metadata = remoteEntityMetadata;
|
|
2372
|
+
}
|
|
2373
|
+
if (fieldPaths.includes("entity.status") && ownerAllows(owners, "entity.status") && !blockedPaths.has("entity.status") && typeof writable.status === "string" && writable.status !== entity.status) {
|
|
2374
|
+
updateInput.status = writable.status;
|
|
2375
|
+
updateInput.isVisible = writable.status === "active";
|
|
2376
|
+
}
|
|
2377
|
+
if (Object.keys(updateInput).length > 0) {
|
|
2378
|
+
const updated = await this.catalog.update(entityId, updateInput, actor);
|
|
2379
|
+
if (!updated.ok) return PluginErr(updated.error.message);
|
|
2380
|
+
}
|
|
2381
|
+
const attributes = await this.setCatalogAttributes(entityId, writable, actor, blockedPaths);
|
|
2382
|
+
if (!attributes.ok) return attributes;
|
|
740
2383
|
const levels = Array.isArray(product.variants) ? product.variants as Array<Record<string, unknown>> : [];
|
|
741
2384
|
for (const variant of levels) {
|
|
742
2385
|
const externalId = String(variant.id ?? variant.variation_id ?? "");
|
|
743
2386
|
const available = variant.inventory_quantity ?? variant.stock_quantity;
|
|
744
2387
|
if (externalId && available !== undefined) await this.setMappedInventory(orgId, storeId, externalId, Number(available), actor);
|
|
745
2388
|
}
|
|
2389
|
+
const revisionMarkers = await this.catalog.repository.findRevisionMarkers(entityId);
|
|
2390
|
+
const lastSyncedAt = revisionMarkers.at(-1)?.createdAt ?? mapping.lastSyncedAt;
|
|
2391
|
+
await this.db.update(channelEntityMap).set({
|
|
2392
|
+
syncHash: remoteHash,
|
|
2393
|
+
lastSyncedAt,
|
|
2394
|
+
heldFieldPaths: heldPaths,
|
|
2395
|
+
}).where(eq(channelEntityMap.id, mapping.id));
|
|
2396
|
+
return Ok({ skipped, conflicts, warnings });
|
|
746
2397
|
}
|
|
747
2398
|
|
|
748
2399
|
private async createRefundRequest(orgId: string, store: ConnectedStore, data: Record<string, unknown>, actor: Actor): Promise<PluginResult<ChannelRefundRequest>> {
|