@porulle/plugin-channel-connector 0.10.6 → 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 +4 -4
- 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
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { FieldPath } from "@porulle/core";
|
|
2
|
+
export declare const catalogFieldTargets: readonly ["native", "attribute", "meta"];
|
|
3
|
+
export type CatalogFieldTarget = (typeof catalogFieldTargets)[number];
|
|
4
|
+
export interface CatalogFieldMappingRow {
|
|
5
|
+
fieldPath: FieldPath;
|
|
6
|
+
provider: string;
|
|
7
|
+
target: CatalogFieldTarget;
|
|
8
|
+
remoteKey: string;
|
|
9
|
+
}
|
|
10
|
+
export type CatalogFieldMapping = CatalogFieldMappingRow[];
|
|
11
|
+
export type CatalogFieldMappingInput = Array<{
|
|
12
|
+
fieldPath: string;
|
|
13
|
+
provider?: string;
|
|
14
|
+
target: CatalogFieldTarget;
|
|
15
|
+
remoteKey: string;
|
|
16
|
+
}> | Record<string, {
|
|
17
|
+
provider?: string;
|
|
18
|
+
target: CatalogFieldTarget;
|
|
19
|
+
remoteKey: string;
|
|
20
|
+
}>;
|
|
21
|
+
export declare const providerCatalogFieldMappingDefaults: Record<string, CatalogFieldMapping>;
|
|
22
|
+
export declare function matchFieldPath(pattern: string, path: string): boolean;
|
|
23
|
+
export declare function isValidCatalogMappingFieldPath(value: string): boolean;
|
|
24
|
+
export declare function validateCatalogMappingRow(input: Partial<CatalogFieldMappingRow> & {
|
|
25
|
+
fieldPath: string;
|
|
26
|
+
target: CatalogFieldTarget;
|
|
27
|
+
remoteKey: string;
|
|
28
|
+
}, provider: string): CatalogFieldMappingRow;
|
|
29
|
+
export declare function normalizeCatalogFieldMapping(input: unknown, provider: string): CatalogFieldMapping;
|
|
30
|
+
export declare function mergeCatalogFieldMapping(provider: string, overrides: unknown, filterableCustomFields?: ReadonlySet<string> | Readonly<Record<string, boolean>>, warnings?: string[]): CatalogFieldMapping;
|
|
31
|
+
export declare function compareCatalogFieldMappingSpecificity(left: CatalogFieldMappingRow, right: CatalogFieldMappingRow): number;
|
|
32
|
+
export declare function selectCatalogFieldMapping(mapping: CatalogFieldMapping, path: string): CatalogFieldMappingRow | undefined;
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
export const catalogFieldTargets = ["native", "attribute", "meta"];
|
|
2
|
+
const fieldSegment = /^[A-Za-z0-9_-]+$/;
|
|
3
|
+
const forbiddenFieldPaths = ["variants.sku", "variants.barcode", "options", "prices", "entity.status"];
|
|
4
|
+
export const providerCatalogFieldMappingDefaults = {
|
|
5
|
+
shopify: [
|
|
6
|
+
{ fieldPath: "attributes.*.title", provider: "shopify", target: "native", remoteKey: "title" },
|
|
7
|
+
{ fieldPath: "attributes.*.description", provider: "shopify", target: "native", remoteKey: "body_html" },
|
|
8
|
+
{ fieldPath: "attributes.*.seoTitle", provider: "shopify", target: "meta", remoteKey: "seo_title" },
|
|
9
|
+
{ fieldPath: "attributes.*.seoDescription", provider: "shopify", target: "meta", remoteKey: "seo_description" },
|
|
10
|
+
{ fieldPath: "customFields.*.*", provider: "shopify", target: "meta", remoteKey: "metafields" },
|
|
11
|
+
{ fieldPath: "media.*", provider: "shopify", target: "native", remoteKey: "images" },
|
|
12
|
+
{ fieldPath: "entity.metadata.*", provider: "shopify", target: "meta", remoteKey: "metafields" },
|
|
13
|
+
],
|
|
14
|
+
woocommerce: [
|
|
15
|
+
{ fieldPath: "attributes.*.title", provider: "woocommerce", target: "native", remoteKey: "name" },
|
|
16
|
+
{ fieldPath: "attributes.*.description", provider: "woocommerce", target: "native", remoteKey: "description" },
|
|
17
|
+
{ fieldPath: "attributes.*.seoTitle", provider: "woocommerce", target: "meta", remoteKey: "yoast_wpseo_title" },
|
|
18
|
+
{ fieldPath: "attributes.*.seoDescription", provider: "woocommerce", target: "meta", remoteKey: "yoast_wpseo_metadesc" },
|
|
19
|
+
{ fieldPath: "customFields.*.*", provider: "woocommerce", target: "meta", remoteKey: "porulle_meta_data" },
|
|
20
|
+
{ fieldPath: "media.*", provider: "woocommerce", target: "native", remoteKey: "images" },
|
|
21
|
+
{ fieldPath: "entity.metadata.*", provider: "woocommerce", target: "meta", remoteKey: "porulle_meta_data" },
|
|
22
|
+
],
|
|
23
|
+
};
|
|
24
|
+
export function matchFieldPath(pattern, path) {
|
|
25
|
+
const patternSegments = pattern.split(".");
|
|
26
|
+
const pathSegments = path.split(".");
|
|
27
|
+
return patternSegments.length === pathSegments.length
|
|
28
|
+
&& patternSegments.every((segment, index) => pathSegments[index] !== "" && (segment === "*" || segment === pathSegments[index]));
|
|
29
|
+
}
|
|
30
|
+
export function isValidCatalogMappingFieldPath(value) {
|
|
31
|
+
const segments = value.split(".");
|
|
32
|
+
return segments.length > 0 && segments.every((segment) => segment === "*" || fieldSegment.test(segment));
|
|
33
|
+
}
|
|
34
|
+
function couldCoverForbiddenSubtree(fieldPath, root) {
|
|
35
|
+
const fieldSegments = fieldPath.split(".");
|
|
36
|
+
const rootSegments = root.split(".");
|
|
37
|
+
return fieldSegments.length >= rootSegments.length
|
|
38
|
+
&& rootSegments.every((segment, index) => fieldSegments[index] === "*" || fieldSegments[index] === segment);
|
|
39
|
+
}
|
|
40
|
+
function isForbiddenMappingFieldPath(fieldPath) {
|
|
41
|
+
return forbiddenFieldPaths.some((root) => couldCoverForbiddenSubtree(fieldPath, root));
|
|
42
|
+
}
|
|
43
|
+
export function validateCatalogMappingRow(input, provider) {
|
|
44
|
+
if (!isValidCatalogMappingFieldPath(input.fieldPath)) {
|
|
45
|
+
throw new Error("Catalog mapping field paths must contain dot-separated alphanumeric, underscore, hyphen, or wildcard segments.");
|
|
46
|
+
}
|
|
47
|
+
if (isForbiddenMappingFieldPath(input.fieldPath)) {
|
|
48
|
+
throw new Error(`Catalog mapping cannot write the forbidden field path "${input.fieldPath}".`);
|
|
49
|
+
}
|
|
50
|
+
if (!catalogFieldTargets.includes(input.target)) {
|
|
51
|
+
throw new Error(`Catalog mapping target "${input.target}" is invalid.`);
|
|
52
|
+
}
|
|
53
|
+
const remoteKey = input.remoteKey.trim();
|
|
54
|
+
if (remoteKey.length === 0) {
|
|
55
|
+
throw new Error("Catalog mapping remoteKey must not be empty.");
|
|
56
|
+
}
|
|
57
|
+
const rowProvider = input.provider ?? provider;
|
|
58
|
+
if (rowProvider !== provider) {
|
|
59
|
+
throw new Error(`Catalog mapping provider must be "${provider}" for this store.`);
|
|
60
|
+
}
|
|
61
|
+
if (rowProvider === "woocommerce" && input.target === "meta" && remoteKey.startsWith("_")) {
|
|
62
|
+
throw new Error("WooCommerce meta keys must not start with an underscore.");
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
fieldPath: input.fieldPath,
|
|
66
|
+
provider: rowProvider,
|
|
67
|
+
target: input.target,
|
|
68
|
+
remoteKey,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
function mappingEntries(input) {
|
|
72
|
+
if (Array.isArray(input))
|
|
73
|
+
return input;
|
|
74
|
+
if (!input || typeof input !== "object")
|
|
75
|
+
throw new Error("Catalog mapping must be an array or an object.");
|
|
76
|
+
return Object.entries(input).map(([fieldPath, value]) => {
|
|
77
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
78
|
+
throw new Error("Catalog mapping values must be objects.");
|
|
79
|
+
return { ...value, fieldPath };
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
function normalizeCatalogMappingRow(row, provider) {
|
|
83
|
+
if (!row || typeof row !== "object" || Array.isArray(row))
|
|
84
|
+
throw new Error("Catalog mapping rows must be objects.");
|
|
85
|
+
const value = row;
|
|
86
|
+
if (typeof value.fieldPath !== "string" || typeof value.target !== "string" || typeof value.remoteKey !== "string") {
|
|
87
|
+
throw new Error("Catalog mapping rows require fieldPath, target, and remoteKey.");
|
|
88
|
+
}
|
|
89
|
+
return validateCatalogMappingRow({
|
|
90
|
+
fieldPath: value.fieldPath,
|
|
91
|
+
...(typeof value.provider === "string" ? { provider: value.provider } : {}),
|
|
92
|
+
target: value.target,
|
|
93
|
+
remoteKey: value.remoteKey,
|
|
94
|
+
}, provider);
|
|
95
|
+
}
|
|
96
|
+
export function normalizeCatalogFieldMapping(input, provider) {
|
|
97
|
+
return mappingEntries(input).map((row) => normalizeCatalogMappingRow(row, provider));
|
|
98
|
+
}
|
|
99
|
+
function normalizeStoredCatalogFieldMapping(input, provider, warnings) {
|
|
100
|
+
let entries;
|
|
101
|
+
try {
|
|
102
|
+
entries = mappingEntries(input ?? []);
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
warnings.push(error instanceof Error ? error.message : "Stored catalog mapping is invalid.");
|
|
106
|
+
return [];
|
|
107
|
+
}
|
|
108
|
+
return entries.flatMap((row, index) => {
|
|
109
|
+
try {
|
|
110
|
+
return [normalizeCatalogMappingRow(row, provider)];
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
const message = error instanceof Error ? error.message : "Stored catalog mapping row is invalid.";
|
|
114
|
+
warnings.push(`Skipped catalog mapping row ${index}: ${message}`);
|
|
115
|
+
return [];
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
function filterableEntries(hint) {
|
|
120
|
+
if (hint instanceof Set)
|
|
121
|
+
return [...hint].map((path) => [path, true]);
|
|
122
|
+
return Object.entries(hint);
|
|
123
|
+
}
|
|
124
|
+
export function mergeCatalogFieldMapping(provider, overrides, filterableCustomFields, warnings = []) {
|
|
125
|
+
const defaults = (providerCatalogFieldMappingDefaults[provider] ?? []).map((row) => ({ ...row }));
|
|
126
|
+
const rows = normalizeStoredCatalogFieldMapping(overrides, provider, warnings);
|
|
127
|
+
const merged = [...defaults];
|
|
128
|
+
for (const row of rows) {
|
|
129
|
+
const index = merged.findIndex((defaultRow) => defaultRow.fieldPath === row.fieldPath && defaultRow.provider === row.provider);
|
|
130
|
+
if (index === -1)
|
|
131
|
+
merged.push(row);
|
|
132
|
+
else
|
|
133
|
+
merged[index] = row;
|
|
134
|
+
}
|
|
135
|
+
if (!filterableCustomFields)
|
|
136
|
+
return merged;
|
|
137
|
+
const hints = filterableEntries(filterableCustomFields).filter(([path]) => isValidCatalogMappingFieldPath(path));
|
|
138
|
+
if (hints.length === 0)
|
|
139
|
+
return merged;
|
|
140
|
+
const customFieldPattern = "customFields.*.*";
|
|
141
|
+
const customFieldDefault = merged.find((row) => row.fieldPath === customFieldPattern);
|
|
142
|
+
if (!customFieldDefault)
|
|
143
|
+
return merged;
|
|
144
|
+
const expanded = hints
|
|
145
|
+
.filter(([path]) => matchFieldPath(customFieldPattern, path))
|
|
146
|
+
.map(([fieldPath, filterable]) => ({
|
|
147
|
+
...customFieldDefault,
|
|
148
|
+
fieldPath,
|
|
149
|
+
target: filterable ? "attribute" : "meta",
|
|
150
|
+
remoteKey: fieldPath.split(".")[1] ?? customFieldDefault.remoteKey,
|
|
151
|
+
}));
|
|
152
|
+
return [...merged.filter((row) => row.fieldPath !== customFieldPattern), ...expanded];
|
|
153
|
+
}
|
|
154
|
+
function wildcardCount(fieldPath) {
|
|
155
|
+
return fieldPath.split(".").filter((segment) => segment === "*").length;
|
|
156
|
+
}
|
|
157
|
+
function compareStrings(left, right) {
|
|
158
|
+
if (left < right)
|
|
159
|
+
return -1;
|
|
160
|
+
if (left > right)
|
|
161
|
+
return 1;
|
|
162
|
+
return 0;
|
|
163
|
+
}
|
|
164
|
+
export function compareCatalogFieldMappingSpecificity(left, right) {
|
|
165
|
+
const wildcardDifference = wildcardCount(left.fieldPath) - wildcardCount(right.fieldPath);
|
|
166
|
+
if (wildcardDifference !== 0)
|
|
167
|
+
return wildcardDifference;
|
|
168
|
+
const fieldPathDifference = compareStrings(left.fieldPath, right.fieldPath);
|
|
169
|
+
if (fieldPathDifference !== 0)
|
|
170
|
+
return fieldPathDifference;
|
|
171
|
+
const providerDifference = compareStrings(left.provider, right.provider);
|
|
172
|
+
if (providerDifference !== 0)
|
|
173
|
+
return providerDifference;
|
|
174
|
+
return compareStrings(left.remoteKey, right.remoteKey);
|
|
175
|
+
}
|
|
176
|
+
export function selectCatalogFieldMapping(mapping, path) {
|
|
177
|
+
return mapping.filter((row) => matchFieldPath(row.fieldPath, path)).sort(compareCatalogFieldMappingSpecificity)[0];
|
|
178
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -2,8 +2,10 @@ import { type ChannelConnectorPluginOptions } from "./service.js";
|
|
|
2
2
|
export { mockChannelConnector } from "./mock-connector.js";
|
|
3
3
|
export type { MockChannelConnectorOptions } from "./mock-connector.js";
|
|
4
4
|
export { ChannelConnectorService, canExportTransition, } from "./service.js";
|
|
5
|
+
export { isValidCatalogMappingFieldPath, matchFieldPath, mergeCatalogFieldMapping, normalizeCatalogFieldMapping, compareCatalogFieldMappingSpecificity, providerCatalogFieldMappingDefaults, selectCatalogFieldMapping, validateCatalogMappingRow, } from "./catalog-field-mapping.js";
|
|
6
|
+
export type { CatalogFieldMapping, CatalogFieldMappingInput, CatalogFieldMappingRow, CatalogFieldTarget, } from "./catalog-field-mapping.js";
|
|
5
7
|
export { signState, verifyState } from "./oauth-state.js";
|
|
6
|
-
export type { ChannelComplianceData, ChannelConnectorPluginOptions, ChannelStockLine, ExportState, PublicConnectedStore, ReconcileReport, } from "./service.js";
|
|
8
|
+
export type { BackfillCatalogOptions, BackfillCatalogReport, BuildCatalogPushItemsOptions, BuildCatalogPushItemsResult, CatalogFieldConflict, CatalogFieldSkip, CatalogPushFieldSkip, CatalogPushSkipReason, CatalogWriteSettings, ChannelComplianceData, ChannelConnectorPluginOptions, ChannelStockLine, ExportState, PublicConnectedStore, ReconcileReport, } from "./service.js";
|
|
7
9
|
export type { OAuthStatePayload, OAuthStateResult } from "./oauth-state.js";
|
|
8
10
|
export type { ChannelEntityMapEntry, ChannelExportEvent, ChannelOrderExport, ChannelRefundEvent, ChannelRefundRequest, ConnectedStore, } from "./schema.js";
|
|
9
11
|
export declare function channelConnectorPlugin(options?: ChannelConnectorPluginOptions): import("@porulle/core").CommercePlugin;
|
package/dist/index.js
CHANGED
|
@@ -9,6 +9,7 @@ import { buildHooks } from "./hooks.js";
|
|
|
9
9
|
import { oauthStateEventId, signState, verifyState } from "./oauth-state.js";
|
|
10
10
|
export { mockChannelConnector } from "./mock-connector.js";
|
|
11
11
|
export { ChannelConnectorService, canExportTransition, } from "./service.js";
|
|
12
|
+
export { isValidCatalogMappingFieldPath, matchFieldPath, mergeCatalogFieldMapping, normalizeCatalogFieldMapping, compareCatalogFieldMappingSpecificity, providerCatalogFieldMappingDefaults, selectCatalogFieldMapping, validateCatalogMappingRow, } from "./catalog-field-mapping.js";
|
|
12
13
|
export { signState, verifyState } from "./oauth-state.js";
|
|
13
14
|
function unwrap(result) {
|
|
14
15
|
if (result.ok)
|
|
@@ -90,7 +91,39 @@ export function channelConnectorPlugin(options = {}) {
|
|
|
90
91
|
const result = await service.importCatalog(String(input.orgId), String(input.storeId), createSystemActor(String(input.orgId)));
|
|
91
92
|
if (!result.ok)
|
|
92
93
|
throw new Error(result.error);
|
|
93
|
-
return {
|
|
94
|
+
return {
|
|
95
|
+
output: {
|
|
96
|
+
imported: result.value.imported,
|
|
97
|
+
cursor: result.value.cursor,
|
|
98
|
+
...(result.value.warnings ? { warnings: result.value.warnings } : {}),
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
slug: "channel/backfill-catalog",
|
|
105
|
+
concurrency: { key: (input) => String(input.storeId) },
|
|
106
|
+
handler: async ({ input, ctx }) => {
|
|
107
|
+
const service = new ChannelConnectorService(ctx.db, ctx.services, options);
|
|
108
|
+
const orgId = String(input.orgId);
|
|
109
|
+
const storeId = String(input.storeId);
|
|
110
|
+
const dryRun = input.dryRun === true;
|
|
111
|
+
const result = await service.backfillCatalog(orgId, storeId, createSystemActor(orgId), {
|
|
112
|
+
dryRun,
|
|
113
|
+
...(input.restart === true ? { resume: false } : {}),
|
|
114
|
+
...(!dryRun ? { maxPages: 1 } : {}),
|
|
115
|
+
});
|
|
116
|
+
if (!result.ok)
|
|
117
|
+
throw new Error(result.error);
|
|
118
|
+
if (!result.value.complete && !dryRun) {
|
|
119
|
+
const jobs = ctx.services.jobs;
|
|
120
|
+
await jobs.enqueue("channel/backfill-catalog", { orgId, storeId, dryRun }, {
|
|
121
|
+
organizationId: orgId,
|
|
122
|
+
concurrencyKey: storeId,
|
|
123
|
+
supersedes: false,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
return { output: result.value };
|
|
94
127
|
},
|
|
95
128
|
},
|
|
96
129
|
{
|
|
@@ -341,10 +374,50 @@ export function channelConnectorPlugin(options = {}) {
|
|
|
341
374
|
.summary("Get a connected channel store")
|
|
342
375
|
.permission("channels:read")
|
|
343
376
|
.handler(async ({ params, orgId }) => unwrap(await service.getStore(orgId, params.id)));
|
|
377
|
+
channels.get("/stores/{storeId}/catalog-write")
|
|
378
|
+
.summary("Get catalog write settings for a channel store")
|
|
379
|
+
.permission("channels:manage")
|
|
380
|
+
.handler(async ({ params, orgId }) => unwrap(await service.getCatalogWriteSettings(orgId, params.storeId)));
|
|
381
|
+
channels.put("/stores/{storeId}/catalog-write")
|
|
382
|
+
.summary("Update catalog write settings for a channel store")
|
|
383
|
+
.permission("channels:manage")
|
|
384
|
+
.input(z.object({
|
|
385
|
+
enabled: z.boolean().optional(),
|
|
386
|
+
overrides: z.unknown().optional(),
|
|
387
|
+
}).refine((value) => value.enabled !== undefined || value.overrides !== undefined))
|
|
388
|
+
.handler(async ({ params, orgId, input }) => {
|
|
389
|
+
const values = input;
|
|
390
|
+
if (values.overrides !== undefined)
|
|
391
|
+
unwrap(await service.updateCatalogFieldMapping(orgId, params.storeId, values.overrides));
|
|
392
|
+
if (values.enabled !== undefined)
|
|
393
|
+
unwrap(await service.updateCatalogWriteEnabled(orgId, params.storeId, values.enabled));
|
|
394
|
+
return unwrap(await service.getCatalogWriteSettings(orgId, params.storeId));
|
|
395
|
+
});
|
|
344
396
|
channels.get("/stores/{storeId}/reconcile-status")
|
|
345
397
|
.summary("Get channel reconciliation status")
|
|
346
398
|
.permission("channels:read")
|
|
347
399
|
.handler(async ({ params, orgId }) => unwrap(await service.getReconcileStatus(orgId, params.storeId)));
|
|
400
|
+
channels.post("/stores/{storeId}/backfill")
|
|
401
|
+
.summary("Backfill a channel catalog into the PIM")
|
|
402
|
+
.permission("channels:manage")
|
|
403
|
+
.input(z.object({ dryRun: z.boolean().optional(), restart: z.boolean().optional() }))
|
|
404
|
+
.handler(async ({ params, orgId, input }) => {
|
|
405
|
+
const values = input;
|
|
406
|
+
if (values.dryRun === true) {
|
|
407
|
+
return unwrap(await service.backfillCatalog(orgId, params.storeId, createSystemActor(orgId), { dryRun: true }));
|
|
408
|
+
}
|
|
409
|
+
const jobs = ctx.services.jobs;
|
|
410
|
+
await jobs.enqueue("channel/backfill-catalog", {
|
|
411
|
+
orgId,
|
|
412
|
+
storeId: params.storeId,
|
|
413
|
+
...(values.restart === true ? { restart: true } : {}),
|
|
414
|
+
}, {
|
|
415
|
+
organizationId: orgId,
|
|
416
|
+
concurrencyKey: params.storeId,
|
|
417
|
+
supersedes: false,
|
|
418
|
+
});
|
|
419
|
+
return { enqueued: true, storeId: params.storeId };
|
|
420
|
+
});
|
|
348
421
|
channels.post("/stores/{id}/disconnect")
|
|
349
422
|
.summary("Disconnect a channel store")
|
|
350
423
|
.permission("channels:manage")
|
package/dist/mock-connector.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { CommerceValidationError } from "@porulle/core";
|
|
2
|
-
import type { ChannelCatalogItem, ChannelInventoryLevel, ChannelOrderSlice } from "@porulle/core";
|
|
2
|
+
import type { ChannelCatalogItem, ChannelConnectorError, ChannelInventoryLevel, ChannelOrderSlice, ChannelPushCatalogItem, ChannelPushCatalogPreviousField, ChannelStore } from "@porulle/core";
|
|
3
3
|
export interface MockChannelConnectorOptions {
|
|
4
4
|
catalog?: ChannelCatalogItem[];
|
|
5
5
|
inventory?: ChannelInventoryLevel[];
|
|
@@ -7,6 +7,9 @@ export interface MockChannelConnectorOptions {
|
|
|
7
7
|
throwOnInventory?: boolean;
|
|
8
8
|
inventoryDelayMs?: number;
|
|
9
9
|
onFetchInventory?: (ids: string[]) => void;
|
|
10
|
+
pushCatalogFailures?: Record<string, ChannelConnectorError>;
|
|
11
|
+
pushCatalogTransportError?: ChannelConnectorError;
|
|
12
|
+
onPushCatalog?: (items: ChannelPushCatalogItem[]) => void;
|
|
10
13
|
}
|
|
11
14
|
export declare function mockChannelConnector(options?: MockChannelConnectorOptions): {
|
|
12
15
|
providerId: string;
|
|
@@ -14,13 +17,14 @@ export declare function mockChannelConnector(options?: MockChannelConnectorOptio
|
|
|
14
17
|
importCatalog: true;
|
|
15
18
|
importInventory: true;
|
|
16
19
|
pushOrder: true;
|
|
20
|
+
pushCatalog: true;
|
|
17
21
|
receiveWebhooks: true;
|
|
18
22
|
};
|
|
19
|
-
importCatalog(): Promise<import("@porulle/core").Result<{
|
|
23
|
+
importCatalog(_store?: ChannelStore): Promise<import("@porulle/core").Result<{
|
|
20
24
|
items: ChannelCatalogItem[];
|
|
21
25
|
nextCursor: null;
|
|
22
26
|
}, never>>;
|
|
23
|
-
fetchInventory(_store:
|
|
27
|
+
fetchInventory(_store: ChannelStore, ids: string[] | undefined): Promise<{
|
|
24
28
|
ok: false;
|
|
25
29
|
error: CommerceValidationError;
|
|
26
30
|
} | {
|
|
@@ -28,11 +32,32 @@ export declare function mockChannelConnector(options?: MockChannelConnectorOptio
|
|
|
28
32
|
value: ChannelInventoryLevel[];
|
|
29
33
|
meta?: Record<string, unknown>;
|
|
30
34
|
}>;
|
|
31
|
-
pushOrder(_store:
|
|
35
|
+
pushOrder(_store: ChannelStore, slice: ChannelOrderSlice): Promise<import("@porulle/core").Result<{
|
|
32
36
|
remoteOrderId: string;
|
|
33
37
|
remoteUrl: string;
|
|
34
38
|
}, never>>;
|
|
35
|
-
|
|
39
|
+
pushCatalog(_store: ChannelStore, items: ChannelPushCatalogItem[], opts?: {
|
|
40
|
+
dryRun?: boolean;
|
|
41
|
+
}): Promise<{
|
|
42
|
+
ok: false;
|
|
43
|
+
error: ChannelConnectorError;
|
|
44
|
+
} | {
|
|
45
|
+
ok: true;
|
|
46
|
+
value: {
|
|
47
|
+
outcomes: ({
|
|
48
|
+
externalId: string;
|
|
49
|
+
ok: boolean;
|
|
50
|
+
error: ChannelConnectorError;
|
|
51
|
+
} | {
|
|
52
|
+
previousFields?: ChannelPushCatalogPreviousField[];
|
|
53
|
+
externalId: string;
|
|
54
|
+
ok: boolean;
|
|
55
|
+
error?: never;
|
|
56
|
+
})[];
|
|
57
|
+
};
|
|
58
|
+
meta?: Record<string, unknown>;
|
|
59
|
+
}>;
|
|
60
|
+
fetchOrderStatus(_store: ChannelStore, remoteId: string): Promise<{
|
|
36
61
|
ok: false;
|
|
37
62
|
error: CommerceValidationError;
|
|
38
63
|
} | {
|
|
@@ -42,7 +67,7 @@ export declare function mockChannelConnector(options?: MockChannelConnectorOptio
|
|
|
42
67
|
};
|
|
43
68
|
meta?: Record<string, unknown>;
|
|
44
69
|
}>;
|
|
45
|
-
verifyWebhook(store:
|
|
70
|
+
verifyWebhook(store: ChannelStore, request: Request): Promise<{
|
|
46
71
|
ok: false;
|
|
47
72
|
error: CommerceValidationError;
|
|
48
73
|
} | {
|
package/dist/mock-connector.js
CHANGED
|
@@ -1,16 +1,67 @@
|
|
|
1
1
|
import { CommerceValidationError, Err, Ok, defineChannelConnector, } from "@porulle/core";
|
|
2
|
+
const defaultCatalog = [{
|
|
3
|
+
externalId: "mock-product-1",
|
|
4
|
+
slug: "mock-channel-product",
|
|
5
|
+
title: "Mock Channel Product",
|
|
6
|
+
description: "Imported through the mock connector.",
|
|
7
|
+
attributes: [{
|
|
8
|
+
locale: "en",
|
|
9
|
+
title: "Mock Channel Product",
|
|
10
|
+
subtitle: "A complete mock catalog item",
|
|
11
|
+
description: "Imported through the mock connector.",
|
|
12
|
+
richDescription: { blocks: [{ type: "paragraph", text: "Mock product details." }] },
|
|
13
|
+
seoTitle: "Mock Channel Product | Porulle",
|
|
14
|
+
seoDescription: "A mock product with the complete channel catalog shape.",
|
|
15
|
+
}],
|
|
16
|
+
images: [
|
|
17
|
+
{
|
|
18
|
+
externalId: "mock-image-primary",
|
|
19
|
+
url: "https://mock.channel.test/images/mock-product-1-primary.jpg",
|
|
20
|
+
alt: "Mock Channel Product",
|
|
21
|
+
role: "primary",
|
|
22
|
+
sortOrder: 0,
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
externalId: "mock-image-variant",
|
|
26
|
+
url: "https://mock.channel.test/images/mock-variant-1.jpg",
|
|
27
|
+
alt: "Mock Channel Product blue variant",
|
|
28
|
+
role: "gallery",
|
|
29
|
+
sortOrder: 1,
|
|
30
|
+
variantExternalIds: ["mock-variant-1"],
|
|
31
|
+
},
|
|
32
|
+
],
|
|
33
|
+
options: [{
|
|
34
|
+
name: "color",
|
|
35
|
+
displayName: "Color",
|
|
36
|
+
sortOrder: 0,
|
|
37
|
+
values: [{ value: "blue", displayValue: "Blue", sortOrder: 0 }],
|
|
38
|
+
}],
|
|
39
|
+
tags: ["mock", "featured"],
|
|
40
|
+
brand: "Porulle",
|
|
41
|
+
categories: ["mock-products"],
|
|
42
|
+
status: "active",
|
|
43
|
+
variants: [{
|
|
44
|
+
externalId: "mock-variant-1",
|
|
45
|
+
sku: "MOCK-SKU-1",
|
|
46
|
+
barcode: "0123456789012",
|
|
47
|
+
optionValues: { color: "blue" },
|
|
48
|
+
prices: [{ currency: "USD", amount: 2500 }],
|
|
49
|
+
}],
|
|
50
|
+
}];
|
|
2
51
|
export function mockChannelConnector(options = {}) {
|
|
3
52
|
const orders = new Map();
|
|
53
|
+
const catalog = new Map();
|
|
4
54
|
return defineChannelConnector({
|
|
5
55
|
providerId: "mock",
|
|
6
56
|
capabilities: {
|
|
7
57
|
importCatalog: true,
|
|
8
58
|
importInventory: true,
|
|
9
59
|
pushOrder: true,
|
|
60
|
+
pushCatalog: true,
|
|
10
61
|
receiveWebhooks: true,
|
|
11
62
|
},
|
|
12
|
-
async importCatalog() {
|
|
13
|
-
return Ok({ items: options.catalog ??
|
|
63
|
+
async importCatalog(_store) {
|
|
64
|
+
return Ok({ items: options.catalog ?? defaultCatalog, nextCursor: null });
|
|
14
65
|
},
|
|
15
66
|
async fetchInventory(_store, ids) {
|
|
16
67
|
const requestedIds = ids ?? [];
|
|
@@ -33,6 +84,30 @@ export function mockChannelConnector(options = {}) {
|
|
|
33
84
|
remoteUrl: `https://mock.channel.test/orders/${remoteOrderId}`,
|
|
34
85
|
});
|
|
35
86
|
},
|
|
87
|
+
async pushCatalog(_store, items, opts) {
|
|
88
|
+
if (options.pushCatalogTransportError)
|
|
89
|
+
return Err(options.pushCatalogTransportError);
|
|
90
|
+
const outcomes = items.map((item) => {
|
|
91
|
+
const error = options.pushCatalogFailures?.[item.externalId];
|
|
92
|
+
if (error)
|
|
93
|
+
return { externalId: item.externalId, ok: false, error };
|
|
94
|
+
const previous = catalog.get(item.externalId);
|
|
95
|
+
const previousFields = previous
|
|
96
|
+
? [...previous.fields, ...(previous.variants ?? []).flatMap((variant) => variant.fields)]
|
|
97
|
+
.map((field) => ({ fieldPath: field.fieldPath, value: structuredClone(field.value) }))
|
|
98
|
+
: [];
|
|
99
|
+
if (opts?.dryRun !== true)
|
|
100
|
+
catalog.set(item.externalId, structuredClone(item));
|
|
101
|
+
return {
|
|
102
|
+
externalId: item.externalId,
|
|
103
|
+
ok: true,
|
|
104
|
+
...(previousFields.length > 0 ? { previousFields } : {}),
|
|
105
|
+
};
|
|
106
|
+
});
|
|
107
|
+
if (opts?.dryRun !== true)
|
|
108
|
+
options.onPushCatalog?.(structuredClone(items));
|
|
109
|
+
return Ok({ outcomes });
|
|
110
|
+
},
|
|
36
111
|
async fetchOrderStatus(_store, remoteId) {
|
|
37
112
|
if (!orders.has(remoteId)) {
|
|
38
113
|
return Err(new CommerceValidationError(`Mock order "${remoteId}" was not found.`));
|
package/dist/schema.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { CatalogFieldMapping } from "./catalog-field-mapping.js";
|
|
1
2
|
export declare const connectedStores: import("drizzle-orm/pg-core/table").PgTableWithColumns<{
|
|
2
3
|
name: "connected_stores";
|
|
3
4
|
schema: undefined;
|
|
@@ -106,6 +107,42 @@ export declare const connectedStores: import("drizzle-orm/pg-core/table").PgTabl
|
|
|
106
107
|
identity: undefined;
|
|
107
108
|
generated: undefined;
|
|
108
109
|
}, {}, {}>;
|
|
110
|
+
catalogWriteEnabled: import("@porulle/core/drizzle").PgColumn<{
|
|
111
|
+
name: "catalog_write_enabled";
|
|
112
|
+
tableName: "connected_stores";
|
|
113
|
+
dataType: "boolean";
|
|
114
|
+
columnType: "PgBoolean";
|
|
115
|
+
data: boolean;
|
|
116
|
+
driverParam: boolean;
|
|
117
|
+
notNull: true;
|
|
118
|
+
hasDefault: true;
|
|
119
|
+
isPrimaryKey: false;
|
|
120
|
+
isAutoincrement: false;
|
|
121
|
+
hasRuntimeDefault: false;
|
|
122
|
+
enumValues: undefined;
|
|
123
|
+
baseColumn: never;
|
|
124
|
+
identity: undefined;
|
|
125
|
+
generated: undefined;
|
|
126
|
+
}, {}, {}>;
|
|
127
|
+
catalogFieldMapping: import("@porulle/core/drizzle").PgColumn<{
|
|
128
|
+
name: "catalog_field_mapping";
|
|
129
|
+
tableName: "connected_stores";
|
|
130
|
+
dataType: "json";
|
|
131
|
+
columnType: "PgJsonb";
|
|
132
|
+
data: CatalogFieldMapping;
|
|
133
|
+
driverParam: unknown;
|
|
134
|
+
notNull: true;
|
|
135
|
+
hasDefault: true;
|
|
136
|
+
isPrimaryKey: false;
|
|
137
|
+
isAutoincrement: false;
|
|
138
|
+
hasRuntimeDefault: false;
|
|
139
|
+
enumValues: undefined;
|
|
140
|
+
baseColumn: never;
|
|
141
|
+
identity: undefined;
|
|
142
|
+
generated: undefined;
|
|
143
|
+
}, {}, {
|
|
144
|
+
$type: CatalogFieldMapping;
|
|
145
|
+
}>;
|
|
109
146
|
catalogCursor: import("@porulle/core/drizzle").PgColumn<{
|
|
110
147
|
name: "catalog_cursor";
|
|
111
148
|
tableName: "connected_stores";
|
|
@@ -423,6 +460,25 @@ export declare const channelEntityMap: import("drizzle-orm/pg-core/table").PgTab
|
|
|
423
460
|
identity: undefined;
|
|
424
461
|
generated: undefined;
|
|
425
462
|
}, {}, {}>;
|
|
463
|
+
heldFieldPaths: import("@porulle/core/drizzle").PgColumn<{
|
|
464
|
+
name: "held_field_paths";
|
|
465
|
+
tableName: "channel_entity_map";
|
|
466
|
+
dataType: "json";
|
|
467
|
+
columnType: "PgJsonb";
|
|
468
|
+
data: string[];
|
|
469
|
+
driverParam: unknown;
|
|
470
|
+
notNull: true;
|
|
471
|
+
hasDefault: true;
|
|
472
|
+
isPrimaryKey: false;
|
|
473
|
+
isAutoincrement: false;
|
|
474
|
+
hasRuntimeDefault: false;
|
|
475
|
+
enumValues: undefined;
|
|
476
|
+
baseColumn: never;
|
|
477
|
+
identity: undefined;
|
|
478
|
+
generated: undefined;
|
|
479
|
+
}, {}, {
|
|
480
|
+
$type: string[];
|
|
481
|
+
}>;
|
|
426
482
|
};
|
|
427
483
|
dialect: "pg";
|
|
428
484
|
}>;
|
|
@@ -918,7 +974,7 @@ export declare const channelRefundRequests: import("drizzle-orm/pg-core/table").
|
|
|
918
974
|
tableName: "channel_refund_requests";
|
|
919
975
|
dataType: "string";
|
|
920
976
|
columnType: "PgText";
|
|
921
|
-
data: "
|
|
977
|
+
data: "approved" | "rejected" | "requested" | "executed";
|
|
922
978
|
driverParam: string;
|
|
923
979
|
notNull: true;
|
|
924
980
|
hasDefault: true;
|
package/dist/schema.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { index, integer, jsonb, pgTable, text, timestamp, uniqueIndex, uuid, } from "@porulle/core/drizzle";
|
|
1
|
+
import { boolean, index, integer, jsonb, pgTable, text, timestamp, uniqueIndex, uuid, } from "@porulle/core/drizzle";
|
|
2
2
|
export const connectedStores = pgTable("connected_stores", {
|
|
3
3
|
id: uuid("id").defaultRandom().primaryKey(),
|
|
4
4
|
organizationId: text("organization_id").notNull(),
|
|
@@ -8,6 +8,8 @@ export const connectedStores = pgTable("connected_stores", {
|
|
|
8
8
|
status: text("status", { enum: ["connected", "disconnected", "error"] })
|
|
9
9
|
.notNull()
|
|
10
10
|
.default("connected"),
|
|
11
|
+
catalogWriteEnabled: boolean("catalog_write_enabled").notNull().default(false),
|
|
12
|
+
catalogFieldMapping: jsonb("catalog_field_mapping").$type().notNull().default([]),
|
|
11
13
|
catalogCursor: text("catalog_cursor"),
|
|
12
14
|
inventoryCursor: text("inventory_cursor"),
|
|
13
15
|
lastSyncAt: timestamp("last_sync_at", { withTimezone: true }),
|
|
@@ -31,6 +33,7 @@ export const channelEntityMap = pgTable("channel_entity_map", {
|
|
|
31
33
|
variantId: uuid("variant_id"),
|
|
32
34
|
lastSyncedAt: timestamp("last_synced_at", { withTimezone: true }).defaultNow().notNull(),
|
|
33
35
|
syncHash: text("sync_hash").notNull(),
|
|
36
|
+
heldFieldPaths: jsonb("held_field_paths").$type().notNull().default([]),
|
|
34
37
|
}, (table) => ({
|
|
35
38
|
orgIdx: index("idx_channel_entity_map_org").on(table.organizationId),
|
|
36
39
|
storeIdx: index("idx_channel_entity_map_store").on(table.storeId),
|