@porulle/plugin-channel-connector 0.10.8 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/catalog-field-mapping.d.ts +32 -0
- package/dist/catalog-field-mapping.js +178 -0
- package/dist/catalog-push-trigger.d.ts +26 -0
- package/dist/catalog-push-trigger.js +70 -0
- package/dist/hooks.js +18 -3
- package/dist/index.d.ts +5 -3
- package/dist/index.js +159 -8
- package/dist/mock-connector.d.ts +31 -6
- package/dist/mock-connector.js +77 -2
- package/dist/schema.d.ts +876 -63
- package/dist/schema.js +76 -1
- package/dist/service.d.ts +167 -2
- package/dist/service.js +2402 -79
- package/package.json +4 -4
- package/src/catalog-field-mapping.ts +211 -0
- package/src/catalog-push-trigger.ts +99 -0
- package/src/hooks.ts +30 -6
- package/src/index.ts +222 -6
- package/src/mock-connector.ts +81 -2
- package/src/schema.ts +106 -0
- package/src/service.ts +3203 -184
package/src/index.ts
CHANGED
|
@@ -7,12 +7,18 @@ import {
|
|
|
7
7
|
defineCommercePlugin,
|
|
8
8
|
router,
|
|
9
9
|
createSystemActor,
|
|
10
|
+
isValidFieldPath,
|
|
11
|
+
requireUserId,
|
|
10
12
|
} from "@porulle/core";
|
|
11
|
-
import type { JobsAdapter, PluginResult, PluginRouteRegistration, TaskDefinition } from "@porulle/core";
|
|
13
|
+
import type { FieldPath, JobsAdapter, PluginResult, PluginRouteRegistration, TaskDefinition } from "@porulle/core";
|
|
12
14
|
import { z } from "@hono/zod-openapi";
|
|
13
15
|
import { and, eq } from "@porulle/core/drizzle";
|
|
14
16
|
import { processedWebhookEvents } from "@porulle/core/schema";
|
|
15
17
|
import {
|
|
18
|
+
channelCatalogPushEvents,
|
|
19
|
+
channelCatalogPushes,
|
|
20
|
+
channelCatalogConflicts,
|
|
21
|
+
channelCatalogConflictEvents,
|
|
16
22
|
channelEntityMap,
|
|
17
23
|
channelExportEvents,
|
|
18
24
|
channelOrderExports,
|
|
@@ -22,6 +28,8 @@ import {
|
|
|
22
28
|
} from "./schema.js";
|
|
23
29
|
import {
|
|
24
30
|
ChannelConnectorService,
|
|
31
|
+
catalogPushConcurrencyKey,
|
|
32
|
+
type CatalogConflictState,
|
|
25
33
|
type ChannelComplianceData,
|
|
26
34
|
type ChannelConnectorPluginOptions,
|
|
27
35
|
} from "./service.js";
|
|
@@ -30,19 +38,64 @@ import { oauthStateEventId, signState, verifyState } from "./oauth-state.js";
|
|
|
30
38
|
|
|
31
39
|
type ChannelRouteContext = {
|
|
32
40
|
input: unknown;
|
|
41
|
+
query?: unknown;
|
|
33
42
|
params: Record<string, string>;
|
|
34
43
|
orgId: string;
|
|
35
|
-
actor: { userId: string } | null;
|
|
44
|
+
actor: { userId: string | null } | null;
|
|
36
45
|
};
|
|
37
46
|
|
|
38
47
|
export { mockChannelConnector } from "./mock-connector.js";
|
|
39
48
|
export type { MockChannelConnectorOptions } from "./mock-connector.js";
|
|
40
49
|
export {
|
|
41
50
|
ChannelConnectorService,
|
|
51
|
+
CATALOG_OUTBOUND_SUPPRESSION_WINDOW_MS,
|
|
52
|
+
CATALOG_PUSH_BATCH_SIZES,
|
|
53
|
+
CATALOG_PUSH_MAX_ATTEMPTS,
|
|
54
|
+
canCatalogPushTransition,
|
|
42
55
|
canExportTransition,
|
|
56
|
+
catalogPushConcurrencyKey,
|
|
57
|
+
catalogPushRetryDelayMs,
|
|
58
|
+
isCatalogPushBreakerOpen,
|
|
43
59
|
} from "./service.js";
|
|
60
|
+
export {
|
|
61
|
+
isValidCatalogMappingFieldPath,
|
|
62
|
+
matchFieldPath,
|
|
63
|
+
mergeCatalogFieldMapping,
|
|
64
|
+
normalizeCatalogFieldMapping,
|
|
65
|
+
compareCatalogFieldMappingSpecificity,
|
|
66
|
+
providerCatalogFieldMappingDefaults,
|
|
67
|
+
selectCatalogFieldMapping,
|
|
68
|
+
validateCatalogMappingRow,
|
|
69
|
+
} from "./catalog-field-mapping.js";
|
|
70
|
+
export type {
|
|
71
|
+
CatalogFieldMapping,
|
|
72
|
+
CatalogFieldMappingInput,
|
|
73
|
+
CatalogFieldMappingRow,
|
|
74
|
+
CatalogFieldTarget,
|
|
75
|
+
} from "./catalog-field-mapping.js";
|
|
44
76
|
export { signState, verifyState } from "./oauth-state.js";
|
|
45
77
|
export type {
|
|
78
|
+
BackfillCatalogOptions,
|
|
79
|
+
BackfillCatalogReport,
|
|
80
|
+
BuildCatalogPushItemsOptions,
|
|
81
|
+
BuildCatalogPushItemsResult,
|
|
82
|
+
CatalogPushAssemblyField,
|
|
83
|
+
CatalogPushAssemblyImage,
|
|
84
|
+
CatalogPushAssemblyItem,
|
|
85
|
+
CatalogPushPreviewBefore,
|
|
86
|
+
CatalogPushPreviewBeforeStatus,
|
|
87
|
+
CatalogPushPreviewDiff,
|
|
88
|
+
CatalogPushPreviewItem,
|
|
89
|
+
CatalogPushPreviewResult,
|
|
90
|
+
CatalogPushPreviewUnavailable,
|
|
91
|
+
PushCatalogToStoreResult,
|
|
92
|
+
CatalogPushJobResult,
|
|
93
|
+
CatalogFieldConflict,
|
|
94
|
+
CatalogFieldSkip,
|
|
95
|
+
CatalogPushFieldSkip,
|
|
96
|
+
CatalogPushSkipReason,
|
|
97
|
+
CatalogConflictState,
|
|
98
|
+
CatalogWriteSettings,
|
|
46
99
|
ChannelComplianceData,
|
|
47
100
|
ChannelConnectorPluginOptions,
|
|
48
101
|
ChannelStockLine,
|
|
@@ -52,6 +105,10 @@ export type {
|
|
|
52
105
|
} from "./service.js";
|
|
53
106
|
export type { OAuthStatePayload, OAuthStateResult } from "./oauth-state.js";
|
|
54
107
|
export type {
|
|
108
|
+
ChannelCatalogPush,
|
|
109
|
+
ChannelCatalogPushEvent,
|
|
110
|
+
ChannelCatalogConflict,
|
|
111
|
+
ChannelCatalogConflictEvent,
|
|
55
112
|
ChannelEntityMapEntry,
|
|
56
113
|
ChannelExportEvent,
|
|
57
114
|
ChannelOrderExport,
|
|
@@ -138,7 +195,38 @@ export function channelConnectorPlugin(options: ChannelConnectorPluginOptions =
|
|
|
138
195
|
const service = new ChannelConnectorService(ctx.db, ctx.services, options);
|
|
139
196
|
const result = await service.importCatalog(String(input.orgId), String(input.storeId), createSystemActor(String(input.orgId)));
|
|
140
197
|
if (!result.ok) throw new Error(result.error);
|
|
141
|
-
return {
|
|
198
|
+
return {
|
|
199
|
+
output: {
|
|
200
|
+
imported: result.value.imported,
|
|
201
|
+
cursor: result.value.cursor,
|
|
202
|
+
...(result.value.warnings ? { warnings: result.value.warnings } : {}),
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
slug: "channel/backfill-catalog",
|
|
209
|
+
concurrency: { key: (input: Record<string, unknown>) => String(input.storeId) },
|
|
210
|
+
handler: async ({ input, ctx }: { input: Record<string, unknown>; ctx: import("@porulle/core").TaskContext }) => {
|
|
211
|
+
const service = new ChannelConnectorService(ctx.db, ctx.services, options);
|
|
212
|
+
const orgId = String(input.orgId);
|
|
213
|
+
const storeId = String(input.storeId);
|
|
214
|
+
const dryRun = input.dryRun === true;
|
|
215
|
+
const result = await service.backfillCatalog(orgId, storeId, createSystemActor(orgId), {
|
|
216
|
+
dryRun,
|
|
217
|
+
...(input.restart === true ? { resume: false } : {}),
|
|
218
|
+
...(!dryRun ? { maxPages: 1 } : {}),
|
|
219
|
+
});
|
|
220
|
+
if (!result.ok) throw new Error(result.error);
|
|
221
|
+
if (!result.value.complete && !dryRun) {
|
|
222
|
+
const jobs = ctx.services.jobs as JobsAdapter;
|
|
223
|
+
await jobs.enqueue("channel/backfill-catalog", { orgId, storeId, dryRun }, {
|
|
224
|
+
organizationId: orgId,
|
|
225
|
+
concurrencyKey: storeId,
|
|
226
|
+
supersedes: false,
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
return { output: result.value };
|
|
142
230
|
},
|
|
143
231
|
},
|
|
144
232
|
{
|
|
@@ -172,6 +260,37 @@ export function channelConnectorPlugin(options: ChannelConnectorPluginOptions =
|
|
|
172
260
|
return { output: { exportId: result.value.id, state: result.value.state } };
|
|
173
261
|
},
|
|
174
262
|
},
|
|
263
|
+
{
|
|
264
|
+
slug: "channel/push-catalog",
|
|
265
|
+
concurrency: { key: catalogPushConcurrencyKey, supersedes: true },
|
|
266
|
+
handler: async ({ input, ctx }: { input: Record<string, unknown>; ctx: import("@porulle/core").TaskContext }) => {
|
|
267
|
+
const service = new ChannelConnectorService(ctx.db, ctx.services, options);
|
|
268
|
+
const orgId = String(input.organizationId ?? input.orgId);
|
|
269
|
+
const storeId = String(input.storeId);
|
|
270
|
+
const entityIds = Array.isArray(input.entityIds)
|
|
271
|
+
? input.entityIds.map(String)
|
|
272
|
+
: undefined;
|
|
273
|
+
const forceFieldPaths = typeof input.forceFieldPaths === "object" && input.forceFieldPaths !== null
|
|
274
|
+
? Object.fromEntries(Object.entries(input.forceFieldPaths).flatMap(([entityId, paths]) => [
|
|
275
|
+
[entityId, Array.isArray(paths) ? paths.filter((path): path is FieldPath => typeof path === "string" && isValidFieldPath(path)) : []],
|
|
276
|
+
]))
|
|
277
|
+
: undefined;
|
|
278
|
+
const cursor = typeof input.cursor === "string" ? input.cursor : undefined;
|
|
279
|
+
const result = await service.executeCatalogPushJob(
|
|
280
|
+
orgId,
|
|
281
|
+
storeId,
|
|
282
|
+
{
|
|
283
|
+
...(entityIds ? { entityIds } : {}),
|
|
284
|
+
...(forceFieldPaths ? { forceFieldPaths } : {}),
|
|
285
|
+
...(cursor ? { cursor } : {}),
|
|
286
|
+
},
|
|
287
|
+
createSystemActor(orgId),
|
|
288
|
+
{ jobs: ctx.services.jobs as JobsAdapter },
|
|
289
|
+
);
|
|
290
|
+
if (!result.ok) throw new Error(result.error);
|
|
291
|
+
return { output: result.value };
|
|
292
|
+
},
|
|
293
|
+
},
|
|
175
294
|
{
|
|
176
295
|
slug: "channel/reap-exports",
|
|
177
296
|
handler: async ({ input, ctx }: { input: Record<string, unknown>; ctx: import("@porulle/core").TaskContext }) => {
|
|
@@ -194,6 +313,10 @@ export function channelConnectorPlugin(options: ChannelConnectorPluginOptions =
|
|
|
194
313
|
schema: () => ({
|
|
195
314
|
connectedStores,
|
|
196
315
|
channelEntityMap,
|
|
316
|
+
channelCatalogPushes,
|
|
317
|
+
channelCatalogPushEvents,
|
|
318
|
+
channelCatalogConflicts,
|
|
319
|
+
channelCatalogConflictEvents,
|
|
197
320
|
channelOrderExports,
|
|
198
321
|
channelExportEvents,
|
|
199
322
|
channelRefundRequests,
|
|
@@ -378,11 +501,104 @@ export function channelConnectorPlugin(options: ChannelConnectorPluginOptions =
|
|
|
378
501
|
.permission("channels:read")
|
|
379
502
|
.handler(async ({ params, orgId }: ChannelRouteContext) => unwrap(await service.getStore(orgId, params.id!)));
|
|
380
503
|
|
|
504
|
+
channels.get("/stores/{storeId}/catalog-write")
|
|
505
|
+
.summary("Get catalog write settings for a channel store")
|
|
506
|
+
.permission("channels:manage")
|
|
507
|
+
.handler(async ({ params, orgId }: ChannelRouteContext) => unwrap(await service.getCatalogWriteSettings(orgId, params.storeId!)));
|
|
508
|
+
|
|
509
|
+
channels.put("/stores/{storeId}/catalog-write")
|
|
510
|
+
.summary("Update catalog write settings for a channel store")
|
|
511
|
+
.permission("channels:manage")
|
|
512
|
+
.input(z.object({
|
|
513
|
+
enabled: z.boolean().optional(),
|
|
514
|
+
overrides: z.unknown().optional(),
|
|
515
|
+
}).refine((value) => value.enabled !== undefined || value.overrides !== undefined))
|
|
516
|
+
.handler(async ({ params, orgId, input }: ChannelRouteContext) => {
|
|
517
|
+
const values = input as { enabled?: boolean; overrides?: unknown };
|
|
518
|
+
if (values.overrides !== undefined) unwrap(await service.updateCatalogFieldMapping(orgId, params.storeId!, values.overrides));
|
|
519
|
+
if (values.enabled !== undefined) unwrap(await service.updateCatalogWriteEnabled(orgId, params.storeId!, values.enabled));
|
|
520
|
+
return unwrap(await service.getCatalogWriteSettings(orgId, params.storeId!));
|
|
521
|
+
});
|
|
522
|
+
|
|
381
523
|
channels.get("/stores/{storeId}/reconcile-status")
|
|
382
524
|
.summary("Get channel reconciliation status")
|
|
383
525
|
.permission("channels:read")
|
|
384
526
|
.handler(async ({ params, orgId }: ChannelRouteContext) => unwrap(await service.getReconcileStatus(orgId, params.storeId!)));
|
|
385
527
|
|
|
528
|
+
channels.get("/conflicts")
|
|
529
|
+
.summary("List channel catalog conflicts")
|
|
530
|
+
.permission("channels:read")
|
|
531
|
+
.query(z.object({ storeId: z.string().min(1).optional(), state: z.enum(["open", "resolved"]).optional() }))
|
|
532
|
+
.handler(async ({ query, orgId }: ChannelRouteContext) => {
|
|
533
|
+
const values = query as { storeId?: string; state?: CatalogConflictState };
|
|
534
|
+
return unwrap(await service.listCatalogConflicts(orgId, values.storeId, values.state));
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
channels.post("/conflicts/{id}/resolve")
|
|
538
|
+
.summary("Resolve a channel catalog conflict")
|
|
539
|
+
.permission("channels:manage")
|
|
540
|
+
.params(z.object({ id: z.string().min(1) }))
|
|
541
|
+
.input(z.object({ choose: z.enum(["platform", "store"]) }))
|
|
542
|
+
.handler(async ({ params, orgId, input, actor }: ChannelRouteContext) => {
|
|
543
|
+
const values = input as { choose: "platform" | "store" };
|
|
544
|
+
return unwrap(await service.resolveCatalogConflict(orgId, params.id!, values.choose, actor!));
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
channels.post("/stores/{storeId}/backfill")
|
|
548
|
+
.summary("Backfill a channel catalog into the PIM")
|
|
549
|
+
.permission("channels:manage")
|
|
550
|
+
.input(z.object({ dryRun: z.boolean().optional(), restart: z.boolean().optional() }))
|
|
551
|
+
.handler(async ({ params, orgId, input }: ChannelRouteContext) => {
|
|
552
|
+
const values = input as { dryRun?: boolean; restart?: boolean };
|
|
553
|
+
if (values.dryRun === true) {
|
|
554
|
+
return unwrap(await service.backfillCatalog(orgId, params.storeId!, createSystemActor(orgId), { dryRun: true }));
|
|
555
|
+
}
|
|
556
|
+
const jobs = ctx.services.jobs as JobsAdapter;
|
|
557
|
+
await jobs.enqueue("channel/backfill-catalog", {
|
|
558
|
+
orgId,
|
|
559
|
+
storeId: params.storeId!,
|
|
560
|
+
...(values.restart === true ? { restart: true } : {}),
|
|
561
|
+
}, {
|
|
562
|
+
organizationId: orgId,
|
|
563
|
+
concurrencyKey: params.storeId!,
|
|
564
|
+
supersedes: false,
|
|
565
|
+
});
|
|
566
|
+
return { enqueued: true, storeId: params.storeId! };
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
channels.post("/stores/{storeId}/push-catalog")
|
|
570
|
+
.summary("Enqueue a catalog push for a connected store")
|
|
571
|
+
.permission("channels:manage")
|
|
572
|
+
.input(z.object({ entityIds: z.array(z.string()).optional() }))
|
|
573
|
+
.handler(async ({ params, orgId, input }: ChannelRouteContext) => {
|
|
574
|
+
unwrap(await service.getStore(orgId, params.storeId!));
|
|
575
|
+
const values = input as { entityIds?: string[] };
|
|
576
|
+
const jobs = ctx.services.jobs as JobsAdapter;
|
|
577
|
+
await jobs.enqueue("channel/push-catalog", {
|
|
578
|
+
organizationId: orgId,
|
|
579
|
+
storeId: params.storeId!,
|
|
580
|
+
...(values.entityIds ? { entityIds: values.entityIds } : {}),
|
|
581
|
+
}, {
|
|
582
|
+
organizationId: orgId,
|
|
583
|
+
concurrencyKey: catalogPushConcurrencyKey({
|
|
584
|
+
storeId: params.storeId!,
|
|
585
|
+
...(values.entityIds ? { entityIds: values.entityIds } : {}),
|
|
586
|
+
}),
|
|
587
|
+
supersedes: true,
|
|
588
|
+
});
|
|
589
|
+
return { enqueued: true, storeId: params.storeId! };
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
channels.post("/stores/{storeId}/push-catalog/preview")
|
|
593
|
+
.summary("Preview a catalog push for a connected store")
|
|
594
|
+
.permission("channels:manage")
|
|
595
|
+
.input(z.object({ entityIds: z.array(z.string()).optional() }))
|
|
596
|
+
.handler(async ({ params, orgId, input }: ChannelRouteContext) => {
|
|
597
|
+
unwrap(await service.getStore(orgId, params.storeId!));
|
|
598
|
+
const values = input as { entityIds?: string[] };
|
|
599
|
+
return unwrap(await service.previewCatalogPush(orgId, params.storeId!, values.entityIds));
|
|
600
|
+
});
|
|
601
|
+
|
|
386
602
|
channels.post("/stores/{id}/disconnect")
|
|
387
603
|
.summary("Disconnect a channel store")
|
|
388
604
|
.permission("channels:manage")
|
|
@@ -401,12 +617,12 @@ export function channelConnectorPlugin(options: ChannelConnectorPluginOptions =
|
|
|
401
617
|
channels.post("/refund-requests/{id}/approve")
|
|
402
618
|
.summary("Approve a channel refund request")
|
|
403
619
|
.permission("channels:manage")
|
|
404
|
-
.handler(async ({ params, orgId, actor }: ChannelRouteContext) => unwrap(await service.approveRefund(orgId, params.id!, actor
|
|
620
|
+
.handler(async ({ params, orgId, actor }: ChannelRouteContext) => unwrap(await service.approveRefund(orgId, params.id!, { userId: requireUserId(actor) })));
|
|
405
621
|
|
|
406
622
|
channels.post("/refund-requests/{id}/reject")
|
|
407
623
|
.summary("Reject a channel refund request")
|
|
408
624
|
.permission("channels:manage")
|
|
409
|
-
.handler(async ({ params, orgId, actor }: ChannelRouteContext) => unwrap(await service.rejectRefund(orgId, params.id!, actor
|
|
625
|
+
.handler(async ({ params, orgId, actor }: ChannelRouteContext) => unwrap(await service.rejectRefund(orgId, params.id!, { userId: requireUserId(actor) })));
|
|
410
626
|
|
|
411
627
|
channels.post("/exports/{id}/retry")
|
|
412
628
|
.summary("Retry a failed channel order export")
|
|
@@ -414,7 +630,7 @@ export function channelConnectorPlugin(options: ChannelConnectorPluginOptions =
|
|
|
414
630
|
.handler(async ({ params, orgId, actor }: ChannelRouteContext) => unwrap(await service.retryExport(
|
|
415
631
|
orgId,
|
|
416
632
|
params.id!,
|
|
417
|
-
actor
|
|
633
|
+
requireUserId(actor),
|
|
418
634
|
)));
|
|
419
635
|
|
|
420
636
|
return channels.routes() as PluginRouteRegistration[];
|
package/src/mock-connector.ts
CHANGED
|
@@ -6,8 +6,12 @@ import {
|
|
|
6
6
|
} from "@porulle/core";
|
|
7
7
|
import type {
|
|
8
8
|
ChannelCatalogItem,
|
|
9
|
+
ChannelConnectorError,
|
|
9
10
|
ChannelInventoryLevel,
|
|
10
11
|
ChannelOrderSlice,
|
|
12
|
+
ChannelPushCatalogItem,
|
|
13
|
+
ChannelPushCatalogPreviousField,
|
|
14
|
+
ChannelStore,
|
|
11
15
|
} from "@porulle/core";
|
|
12
16
|
|
|
13
17
|
export interface MockChannelConnectorOptions {
|
|
@@ -17,10 +21,64 @@ export interface MockChannelConnectorOptions {
|
|
|
17
21
|
throwOnInventory?: boolean;
|
|
18
22
|
inventoryDelayMs?: number;
|
|
19
23
|
onFetchInventory?: (ids: string[]) => void;
|
|
24
|
+
pushCatalogFailures?: Record<string, ChannelConnectorError>;
|
|
25
|
+
pushCatalogTransportError?: ChannelConnectorError;
|
|
26
|
+
onPushCatalog?: (items: ChannelPushCatalogItem[]) => void;
|
|
20
27
|
}
|
|
21
28
|
|
|
29
|
+
const defaultCatalog: ChannelCatalogItem[] = [{
|
|
30
|
+
externalId: "mock-product-1",
|
|
31
|
+
slug: "mock-channel-product",
|
|
32
|
+
title: "Mock Channel Product",
|
|
33
|
+
description: "Imported through the mock connector.",
|
|
34
|
+
attributes: [{
|
|
35
|
+
locale: "en",
|
|
36
|
+
title: "Mock Channel Product",
|
|
37
|
+
subtitle: "A complete mock catalog item",
|
|
38
|
+
description: "Imported through the mock connector.",
|
|
39
|
+
richDescription: { blocks: [{ type: "paragraph", text: "Mock product details." }] },
|
|
40
|
+
seoTitle: "Mock Channel Product | Porulle",
|
|
41
|
+
seoDescription: "A mock product with the complete channel catalog shape.",
|
|
42
|
+
}],
|
|
43
|
+
images: [
|
|
44
|
+
{
|
|
45
|
+
externalId: "mock-image-primary",
|
|
46
|
+
url: "https://mock.channel.test/images/mock-product-1-primary.jpg",
|
|
47
|
+
alt: "Mock Channel Product",
|
|
48
|
+
role: "primary",
|
|
49
|
+
sortOrder: 0,
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
externalId: "mock-image-variant",
|
|
53
|
+
url: "https://mock.channel.test/images/mock-variant-1.jpg",
|
|
54
|
+
alt: "Mock Channel Product blue variant",
|
|
55
|
+
role: "gallery",
|
|
56
|
+
sortOrder: 1,
|
|
57
|
+
variantExternalIds: ["mock-variant-1"],
|
|
58
|
+
},
|
|
59
|
+
],
|
|
60
|
+
options: [{
|
|
61
|
+
name: "color",
|
|
62
|
+
displayName: "Color",
|
|
63
|
+
sortOrder: 0,
|
|
64
|
+
values: [{ value: "blue", displayValue: "Blue", sortOrder: 0 }],
|
|
65
|
+
}],
|
|
66
|
+
tags: ["mock", "featured"],
|
|
67
|
+
brand: "Porulle",
|
|
68
|
+
categories: ["mock-products"],
|
|
69
|
+
status: "active",
|
|
70
|
+
variants: [{
|
|
71
|
+
externalId: "mock-variant-1",
|
|
72
|
+
sku: "MOCK-SKU-1",
|
|
73
|
+
barcode: "0123456789012",
|
|
74
|
+
optionValues: { color: "blue" },
|
|
75
|
+
prices: [{ currency: "USD", amount: 2500 }],
|
|
76
|
+
}],
|
|
77
|
+
}];
|
|
78
|
+
|
|
22
79
|
export function mockChannelConnector(options: MockChannelConnectorOptions = {}) {
|
|
23
80
|
const orders = new Map<string, ChannelOrderSlice>();
|
|
81
|
+
const catalog = new Map<string, ChannelPushCatalogItem>();
|
|
24
82
|
|
|
25
83
|
return defineChannelConnector({
|
|
26
84
|
providerId: "mock",
|
|
@@ -28,10 +86,11 @@ export function mockChannelConnector(options: MockChannelConnectorOptions = {})
|
|
|
28
86
|
importCatalog: true,
|
|
29
87
|
importInventory: true,
|
|
30
88
|
pushOrder: true,
|
|
89
|
+
pushCatalog: true,
|
|
31
90
|
receiveWebhooks: true,
|
|
32
91
|
},
|
|
33
|
-
async importCatalog() {
|
|
34
|
-
return Ok({ items: options.catalog ??
|
|
92
|
+
async importCatalog(_store?: ChannelStore) {
|
|
93
|
+
return Ok({ items: options.catalog ?? defaultCatalog, nextCursor: null });
|
|
35
94
|
},
|
|
36
95
|
async fetchInventory(_store, ids) {
|
|
37
96
|
const requestedIds = ids ?? [];
|
|
@@ -52,6 +111,26 @@ export function mockChannelConnector(options: MockChannelConnectorOptions = {})
|
|
|
52
111
|
remoteUrl: `https://mock.channel.test/orders/${remoteOrderId}`,
|
|
53
112
|
});
|
|
54
113
|
},
|
|
114
|
+
async pushCatalog(_store, items, opts?: { dryRun?: boolean }) {
|
|
115
|
+
if (options.pushCatalogTransportError) return Err(options.pushCatalogTransportError);
|
|
116
|
+
const outcomes = items.map((item) => {
|
|
117
|
+
const error = options.pushCatalogFailures?.[item.externalId];
|
|
118
|
+
if (error) return { externalId: item.externalId, ok: false, error };
|
|
119
|
+
const previous = catalog.get(item.externalId);
|
|
120
|
+
const previousFields: ChannelPushCatalogPreviousField[] = previous
|
|
121
|
+
? [...previous.fields, ...(previous.variants ?? []).flatMap((variant) => variant.fields)]
|
|
122
|
+
.map((field) => ({ fieldPath: field.fieldPath, value: structuredClone(field.value) }))
|
|
123
|
+
: [];
|
|
124
|
+
if (opts?.dryRun !== true) catalog.set(item.externalId, structuredClone(item));
|
|
125
|
+
return {
|
|
126
|
+
externalId: item.externalId,
|
|
127
|
+
ok: true,
|
|
128
|
+
...(previousFields.length > 0 ? { previousFields } : {}),
|
|
129
|
+
};
|
|
130
|
+
});
|
|
131
|
+
if (opts?.dryRun !== true) options.onPushCatalog?.(structuredClone(items));
|
|
132
|
+
return Ok({ outcomes });
|
|
133
|
+
},
|
|
55
134
|
async fetchOrderStatus(_store, remoteId) {
|
|
56
135
|
if (!orders.has(remoteId)) {
|
|
57
136
|
return Err(new CommerceValidationError(`Mock order "${remoteId}" was not found.`));
|
package/src/schema.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
+
boolean,
|
|
2
3
|
index,
|
|
3
4
|
integer,
|
|
4
5
|
jsonb,
|
|
@@ -7,7 +8,10 @@ import {
|
|
|
7
8
|
timestamp,
|
|
8
9
|
uniqueIndex,
|
|
9
10
|
uuid,
|
|
11
|
+
sql,
|
|
10
12
|
} from "@porulle/core/drizzle";
|
|
13
|
+
import type { ChannelPushCatalogItem, FieldPath } from "@porulle/core";
|
|
14
|
+
import type { CatalogFieldMapping } from "./catalog-field-mapping.js";
|
|
11
15
|
|
|
12
16
|
export const connectedStores = pgTable(
|
|
13
17
|
"connected_stores",
|
|
@@ -20,6 +24,8 @@ export const connectedStores = pgTable(
|
|
|
20
24
|
status: text("status", { enum: ["connected", "disconnected", "error"] })
|
|
21
25
|
.notNull()
|
|
22
26
|
.default("connected"),
|
|
27
|
+
catalogWriteEnabled: boolean("catalog_write_enabled").notNull().default(false),
|
|
28
|
+
catalogFieldMapping: jsonb("catalog_field_mapping").$type<CatalogFieldMapping>().notNull().default([]),
|
|
23
29
|
catalogCursor: text("catalog_cursor"),
|
|
24
30
|
inventoryCursor: text("inventory_cursor"),
|
|
25
31
|
lastSyncAt: timestamp("last_sync_at", { withTimezone: true }),
|
|
@@ -48,6 +54,11 @@ export const channelEntityMap = pgTable(
|
|
|
48
54
|
variantId: uuid("variant_id"),
|
|
49
55
|
lastSyncedAt: timestamp("last_synced_at", { withTimezone: true }).defaultNow().notNull(),
|
|
50
56
|
syncHash: text("sync_hash").notNull(),
|
|
57
|
+
outboundHash: text("outbound_hash"),
|
|
58
|
+
outboundPushedAt: timestamp("outbound_pushed_at", { withTimezone: true }),
|
|
59
|
+
outboundFieldPaths: jsonb("outbound_field_paths").$type<FieldPath[]>().notNull().default([]),
|
|
60
|
+
heldFieldPaths: jsonb("held_field_paths").$type<FieldPath[]>().notNull().default([]),
|
|
61
|
+
forcedPushFieldPaths: jsonb("forced_push_field_paths").$type<FieldPath[]>().notNull().default([]),
|
|
51
62
|
},
|
|
52
63
|
(table) => ({
|
|
53
64
|
orgIdx: index("idx_channel_entity_map_org").on(table.organizationId),
|
|
@@ -60,6 +71,48 @@ export const channelEntityMap = pgTable(
|
|
|
60
71
|
}),
|
|
61
72
|
);
|
|
62
73
|
|
|
74
|
+
export const channelCatalogConflicts = pgTable(
|
|
75
|
+
"channel_catalog_conflicts",
|
|
76
|
+
{
|
|
77
|
+
id: uuid("id").defaultRandom().primaryKey(),
|
|
78
|
+
organizationId: text("organization_id").notNull(),
|
|
79
|
+
storeId: uuid("store_id").references(() => connectedStores.id, { onDelete: "cascade" }).notNull(),
|
|
80
|
+
entityId: uuid("entity_id").notNull(),
|
|
81
|
+
fieldPath: text("field_path").notNull(),
|
|
82
|
+
platformValue: jsonb("platform_value").$type<unknown>().notNull(),
|
|
83
|
+
storeValue: jsonb("store_value").$type<unknown>().notNull(),
|
|
84
|
+
state: text("state", { enum: ["open", "resolved"] }).notNull().default("open"),
|
|
85
|
+
resolvedBy: text("resolved_by"),
|
|
86
|
+
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
|
87
|
+
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
|
88
|
+
},
|
|
89
|
+
(table) => ({
|
|
90
|
+
orgIdx: index("idx_channel_catalog_conflicts_org").on(table.organizationId),
|
|
91
|
+
stateIdx: index("idx_channel_catalog_conflicts_org_state").on(table.organizationId, table.state),
|
|
92
|
+
openUnique: uniqueIndex("channel_catalog_conflicts_open_unique")
|
|
93
|
+
.on(table.storeId, table.entityId, table.fieldPath)
|
|
94
|
+
.where(sql`${table.state} = 'open'`),
|
|
95
|
+
}),
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
export const channelCatalogConflictEvents = pgTable(
|
|
99
|
+
"channel_catalog_conflict_events",
|
|
100
|
+
{
|
|
101
|
+
id: uuid("id").defaultRandom().primaryKey(),
|
|
102
|
+
organizationId: text("organization_id").notNull(),
|
|
103
|
+
conflictId: uuid("conflict_id").references(() => channelCatalogConflicts.id, { onDelete: "cascade" }).notNull(),
|
|
104
|
+
fromState: text("from_state"),
|
|
105
|
+
toState: text("to_state").notNull(),
|
|
106
|
+
reason: text("reason"),
|
|
107
|
+
changedBy: text("changed_by").notNull(),
|
|
108
|
+
changedAt: timestamp("changed_at", { withTimezone: true }).defaultNow().notNull(),
|
|
109
|
+
},
|
|
110
|
+
(table) => ({
|
|
111
|
+
orgIdx: index("idx_channel_catalog_conflict_events_org").on(table.organizationId),
|
|
112
|
+
conflictIdx: index("idx_channel_catalog_conflict_events_conflict").on(table.conflictId),
|
|
113
|
+
}),
|
|
114
|
+
);
|
|
115
|
+
|
|
63
116
|
export const channelOrderExports = pgTable(
|
|
64
117
|
"channel_order_exports",
|
|
65
118
|
{
|
|
@@ -91,6 +144,55 @@ export const channelOrderExports = pgTable(
|
|
|
91
144
|
}),
|
|
92
145
|
);
|
|
93
146
|
|
|
147
|
+
export const channelCatalogPushes = pgTable(
|
|
148
|
+
"channel_catalog_pushes",
|
|
149
|
+
{
|
|
150
|
+
id: uuid("id").defaultRandom().primaryKey(),
|
|
151
|
+
organizationId: text("organization_id").notNull(),
|
|
152
|
+
storeId: uuid("store_id").references(() => connectedStores.id, { onDelete: "cascade" }).notNull(),
|
|
153
|
+
entityId: uuid("entity_id").notNull(),
|
|
154
|
+
payloadSnapshot: jsonb("payload_snapshot").$type<ChannelPushCatalogItem | null>(),
|
|
155
|
+
state: text("state", { enum: ["pending", "exported", "confirmed", "failed", "abandoned"] })
|
|
156
|
+
.notNull()
|
|
157
|
+
.default("pending"),
|
|
158
|
+
failureKind: text("failure_kind", { enum: ["definitive", "transient"] }),
|
|
159
|
+
attempts: integer("attempts").notNull().default(0),
|
|
160
|
+
lastError: text("last_error"),
|
|
161
|
+
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
|
162
|
+
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
|
163
|
+
},
|
|
164
|
+
(table) => ({
|
|
165
|
+
orgIdx: index("idx_channel_catalog_pushes_org").on(table.organizationId),
|
|
166
|
+
storeIdx: index("idx_channel_catalog_pushes_store").on(table.storeId),
|
|
167
|
+
stateIdx: index("idx_channel_catalog_pushes_state").on(table.organizationId, table.state),
|
|
168
|
+
entityIdx: index("idx_channel_catalog_pushes_entity").on(table.organizationId, table.entityId),
|
|
169
|
+
storeEntityUnique: uniqueIndex("channel_catalog_pushes_store_entity_unique").on(
|
|
170
|
+
table.storeId,
|
|
171
|
+
table.entityId,
|
|
172
|
+
),
|
|
173
|
+
}),
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
export const channelCatalogPushEvents = pgTable(
|
|
177
|
+
"channel_catalog_push_events",
|
|
178
|
+
{
|
|
179
|
+
id: uuid("id").defaultRandom().primaryKey(),
|
|
180
|
+
organizationId: text("organization_id").notNull(),
|
|
181
|
+
pushId: uuid("push_id")
|
|
182
|
+
.references(() => channelCatalogPushes.id, { onDelete: "cascade" })
|
|
183
|
+
.notNull(),
|
|
184
|
+
fromState: text("from_state").notNull(),
|
|
185
|
+
toState: text("to_state").notNull(),
|
|
186
|
+
reason: text("reason"),
|
|
187
|
+
changedBy: text("changed_by").notNull(),
|
|
188
|
+
changedAt: timestamp("changed_at", { withTimezone: true }).defaultNow().notNull(),
|
|
189
|
+
},
|
|
190
|
+
(table) => ({
|
|
191
|
+
orgIdx: index("idx_channel_catalog_push_events_org").on(table.organizationId),
|
|
192
|
+
pushIdx: index("idx_channel_catalog_push_events_push").on(table.pushId),
|
|
193
|
+
}),
|
|
194
|
+
);
|
|
195
|
+
|
|
94
196
|
export const channelExportEvents = pgTable(
|
|
95
197
|
"channel_export_events",
|
|
96
198
|
{
|
|
@@ -152,6 +254,10 @@ export const channelRefundEvents = pgTable(
|
|
|
152
254
|
|
|
153
255
|
export type ConnectedStore = typeof connectedStores.$inferSelect;
|
|
154
256
|
export type ChannelEntityMapEntry = typeof channelEntityMap.$inferSelect;
|
|
257
|
+
export type ChannelCatalogConflict = typeof channelCatalogConflicts.$inferSelect;
|
|
258
|
+
export type ChannelCatalogConflictEvent = typeof channelCatalogConflictEvents.$inferSelect;
|
|
259
|
+
export type ChannelCatalogPush = typeof channelCatalogPushes.$inferSelect;
|
|
260
|
+
export type ChannelCatalogPushEvent = typeof channelCatalogPushEvents.$inferSelect;
|
|
155
261
|
export type ChannelOrderExport = typeof channelOrderExports.$inferSelect;
|
|
156
262
|
export type ChannelExportEvent = typeof channelExportEvents.$inferSelect;
|
|
157
263
|
export type ChannelRefundRequest = typeof channelRefundRequests.$inferSelect;
|