@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.
@@ -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
+ }
@@ -0,0 +1,26 @@
1
+ import type { HookContext } from "@porulle/core";
2
+ type CatalogUpdateInput = {
3
+ slug?: string;
4
+ status?: string;
5
+ metadata?: Record<string, unknown>;
6
+ customFields?: Record<string, unknown | null>;
7
+ };
8
+ export declare const CHANNEL_CONVERGENCE_ORIGIN = "channel-convergence";
9
+ export declare const CHANNEL_CONVERGENCE_CTX: {
10
+ hookContext: {
11
+ origin: string;
12
+ };
13
+ };
14
+ export declare function maybeEnqueueCatalogPush(args: {
15
+ entityId: string;
16
+ changedFieldPaths: string[];
17
+ context: HookContext;
18
+ }): Promise<void>;
19
+ export declare function recordUpdateFieldPaths(input: CatalogUpdateInput, context: HookContext): CatalogUpdateInput;
20
+ export declare function handleCatalogAfterUpdate(args: {
21
+ result: {
22
+ id: string;
23
+ };
24
+ context: HookContext;
25
+ }): Promise<void>;
26
+ export {};
@@ -0,0 +1,70 @@
1
+ import { resolveOrgIdForCommerce, isValidFieldPath } from "@porulle/core";
2
+ import { and, eq } from "@porulle/core/drizzle";
3
+ import { catalogPushConcurrencyKey } from "./service.js";
4
+ import { channelEntityMap } from "./schema.js";
5
+ export const CHANNEL_CONVERGENCE_ORIGIN = "channel-convergence";
6
+ export const CHANNEL_CONVERGENCE_CTX = {
7
+ hookContext: { origin: CHANNEL_CONVERGENCE_ORIGIN },
8
+ };
9
+ function updateInputFieldPaths(input) {
10
+ const paths = [];
11
+ if (input.slug !== undefined)
12
+ paths.push("entity.slug");
13
+ if (input.status !== undefined)
14
+ paths.push("entity.status");
15
+ if (input.metadata) {
16
+ for (const key of Object.keys(input.metadata))
17
+ paths.push(`entity.metadata.${key}`);
18
+ }
19
+ if (input.customFields) {
20
+ for (const name of Object.keys(input.customFields))
21
+ paths.push(`customFields.${name}.en`);
22
+ }
23
+ return paths.filter(isValidFieldPath);
24
+ }
25
+ export async function maybeEnqueueCatalogPush(args) {
26
+ if (args.context.context.origin === CHANNEL_CONVERGENCE_ORIGIN)
27
+ return;
28
+ if (args.changedFieldPaths.length === 0)
29
+ return;
30
+ const orgId = resolveOrgIdForCommerce(args.context.actor, args.context.commerceConfig);
31
+ const catalog = args.context.services.catalog;
32
+ const mappings = await args.context.db
33
+ .select({ storeId: channelEntityMap.storeId })
34
+ .from(channelEntityMap)
35
+ .where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.entityId, args.entityId), eq(channelEntityMap.kind, "entity")));
36
+ const forceFieldPathsByStore = new Map();
37
+ for (const mapping of mappings) {
38
+ const owners = await catalog.resolveFieldOwners(args.entityId, mapping.storeId);
39
+ const changedPaths = args.changedFieldPaths.filter((path) => {
40
+ const owner = owners.get(path);
41
+ return owner === "platform" || owner === "shared";
42
+ });
43
+ if (changedPaths.length > 0)
44
+ forceFieldPathsByStore.set(mapping.storeId, changedPaths);
45
+ }
46
+ await Promise.all([...forceFieldPathsByStore].map(([storeId, forceFieldPaths]) => args.context.jobs.enqueue("channel/push-catalog", {
47
+ organizationId: orgId,
48
+ storeId,
49
+ entityIds: [args.entityId],
50
+ forceFieldPaths: { [args.entityId]: forceFieldPaths },
51
+ }, {
52
+ organizationId: orgId,
53
+ concurrencyKey: catalogPushConcurrencyKey({ storeId, entityIds: [args.entityId] }),
54
+ supersedes: true,
55
+ })));
56
+ }
57
+ export function recordUpdateFieldPaths(input, context) {
58
+ context.context.changedFieldPaths = updateInputFieldPaths(input);
59
+ return input;
60
+ }
61
+ export async function handleCatalogAfterUpdate(args) {
62
+ const changedFieldPaths = Array.isArray(args.context.context.changedFieldPaths)
63
+ ? args.context.context.changedFieldPaths.map(String)
64
+ : [];
65
+ await maybeEnqueueCatalogPush({
66
+ entityId: args.result.id,
67
+ changedFieldPaths,
68
+ context: args.context,
69
+ });
70
+ }
package/dist/hooks.js CHANGED
@@ -1,24 +1,39 @@
1
- import { resolveOrgId } from "@porulle/core";
1
+ import { resolveOrgIdForCommerce } from "@porulle/core";
2
2
  import { and, eq, inArray } from "@porulle/core/drizzle";
3
3
  import { sellableEntities } from "@porulle/core/schema";
4
4
  import { ChannelConnectorService, } from "./service.js";
5
+ import { handleCatalogAfterUpdate, recordUpdateFieldPaths, } from "./catalog-push-trigger.js";
5
6
  export function buildHooks(options) {
6
7
  return [{
7
8
  key: "checkout.beforePayment",
8
9
  async handler(args) {
9
10
  const { data, context } = args;
11
+ if (data.lineItems.length === 0)
12
+ return data;
10
13
  const service = new ChannelConnectorService(context.db, context.services, options);
11
- await service.validateLineStock(resolveOrgId(context.actor), data.lineItems, options.inventoryTimeoutMs);
14
+ await service.validateLineStock(resolveOrgIdForCommerce(context.actor, context.commerceConfig), data.lineItems, options.inventoryTimeoutMs);
12
15
  return data;
13
16
  },
14
17
  }, {
15
18
  key: "orders.afterCreate",
16
19
  async handler(args) {
17
20
  const { result, context } = args;
18
- const orgId = resolveOrgId(context.actor);
21
+ const orgId = resolveOrgIdForCommerce(context.actor, context.commerceConfig);
19
22
  const entities = await context.db.select({ id: sellableEntities.id, sourceStoreId: sellableEntities.sourceStoreId }).from(sellableEntities).where(and(eq(sellableEntities.organizationId, orgId), inArray(sellableEntities.id, (result.lineItems ?? []).map((line) => line.entityId))));
20
23
  const stores = new Set(entities.map((entity) => entity.sourceStoreId).filter((storeId) => storeId !== null));
21
24
  await Promise.all([...stores].map((storeId) => context.jobs.enqueue("channel/push-order", { orgId, storeId, orderId: result.id }, { organizationId: orgId, concurrencyKey: `push:${result.id}:${storeId}`, supersedes: true })));
22
25
  },
26
+ }, {
27
+ key: "catalog.beforeUpdate",
28
+ handler(args) {
29
+ const { data, context } = args;
30
+ return recordUpdateFieldPaths(data, context);
31
+ },
32
+ }, {
33
+ key: "catalog.afterUpdate",
34
+ async handler(args) {
35
+ const { result, context } = args;
36
+ await handleCatalogAfterUpdate({ result, context });
37
+ },
23
38
  }];
24
39
  }
package/dist/index.d.ts CHANGED
@@ -1,9 +1,11 @@
1
1
  import { type ChannelConnectorPluginOptions } from "./service.js";
2
2
  export { mockChannelConnector } from "./mock-connector.js";
3
3
  export type { MockChannelConnectorOptions } from "./mock-connector.js";
4
- export { ChannelConnectorService, canExportTransition, } from "./service.js";
4
+ export { ChannelConnectorService, CATALOG_OUTBOUND_SUPPRESSION_WINDOW_MS, CATALOG_PUSH_BATCH_SIZES, CATALOG_PUSH_MAX_ATTEMPTS, canCatalogPushTransition, canExportTransition, catalogPushConcurrencyKey, catalogPushRetryDelayMs, isCatalogPushBreakerOpen, } 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, CatalogPushAssemblyField, CatalogPushAssemblyImage, CatalogPushAssemblyItem, CatalogPushPreviewBefore, CatalogPushPreviewBeforeStatus, CatalogPushPreviewDiff, CatalogPushPreviewItem, CatalogPushPreviewResult, CatalogPushPreviewUnavailable, PushCatalogToStoreResult, CatalogPushJobResult, CatalogFieldConflict, CatalogFieldSkip, CatalogPushFieldSkip, CatalogPushSkipReason, CatalogConflictState, CatalogWriteSettings, ChannelComplianceData, ChannelConnectorPluginOptions, ChannelStockLine, ExportState, PublicConnectedStore, ReconcileReport, } from "./service.js";
7
9
  export type { OAuthStatePayload, OAuthStateResult } from "./oauth-state.js";
8
- export type { ChannelEntityMapEntry, ChannelExportEvent, ChannelOrderExport, ChannelRefundEvent, ChannelRefundRequest, ConnectedStore, } from "./schema.js";
10
+ export type { ChannelCatalogPush, ChannelCatalogPushEvent, ChannelCatalogConflict, ChannelCatalogConflictEvent, 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
@@ -1,14 +1,15 @@
1
1
  import { createHash } from "node:crypto";
2
- import { CommerceConflictError, CommerceInvalidTransitionError, CommerceNotFoundError, CommerceValidationError, defineCommercePlugin, router, createSystemActor, } from "@porulle/core";
2
+ import { CommerceConflictError, CommerceInvalidTransitionError, CommerceNotFoundError, CommerceValidationError, defineCommercePlugin, router, createSystemActor, isValidFieldPath, requireUserId, } from "@porulle/core";
3
3
  import { z } from "@hono/zod-openapi";
4
4
  import { and, eq } from "@porulle/core/drizzle";
5
5
  import { processedWebhookEvents } from "@porulle/core/schema";
6
- import { channelEntityMap, channelExportEvents, channelOrderExports, channelRefundEvents, channelRefundRequests, connectedStores, } from "./schema.js";
7
- import { ChannelConnectorService, } from "./service.js";
6
+ import { channelCatalogPushEvents, channelCatalogPushes, channelCatalogConflicts, channelCatalogConflictEvents, channelEntityMap, channelExportEvents, channelOrderExports, channelRefundEvents, channelRefundRequests, connectedStores, } from "./schema.js";
7
+ import { ChannelConnectorService, catalogPushConcurrencyKey, } from "./service.js";
8
8
  import { buildHooks } from "./hooks.js";
9
9
  import { oauthStateEventId, signState, verifyState } from "./oauth-state.js";
10
10
  export { mockChannelConnector } from "./mock-connector.js";
11
- export { ChannelConnectorService, canExportTransition, } from "./service.js";
11
+ export { ChannelConnectorService, CATALOG_OUTBOUND_SUPPRESSION_WINDOW_MS, CATALOG_PUSH_BATCH_SIZES, CATALOG_PUSH_MAX_ATTEMPTS, canCatalogPushTransition, canExportTransition, catalogPushConcurrencyKey, catalogPushRetryDelayMs, isCatalogPushBreakerOpen, } 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 { output: { imported: result.value.imported, cursor: result.value.cursor } };
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
  {
@@ -128,6 +161,32 @@ export function channelConnectorPlugin(options = {}) {
128
161
  return { output: { exportId: result.value.id, state: result.value.state } };
129
162
  },
130
163
  },
164
+ {
165
+ slug: "channel/push-catalog",
166
+ concurrency: { key: catalogPushConcurrencyKey, supersedes: true },
167
+ handler: async ({ input, ctx }) => {
168
+ const service = new ChannelConnectorService(ctx.db, ctx.services, options);
169
+ const orgId = String(input.organizationId ?? input.orgId);
170
+ const storeId = String(input.storeId);
171
+ const entityIds = Array.isArray(input.entityIds)
172
+ ? input.entityIds.map(String)
173
+ : undefined;
174
+ const forceFieldPaths = typeof input.forceFieldPaths === "object" && input.forceFieldPaths !== null
175
+ ? Object.fromEntries(Object.entries(input.forceFieldPaths).flatMap(([entityId, paths]) => [
176
+ [entityId, Array.isArray(paths) ? paths.filter((path) => typeof path === "string" && isValidFieldPath(path)) : []],
177
+ ]))
178
+ : undefined;
179
+ const cursor = typeof input.cursor === "string" ? input.cursor : undefined;
180
+ const result = await service.executeCatalogPushJob(orgId, storeId, {
181
+ ...(entityIds ? { entityIds } : {}),
182
+ ...(forceFieldPaths ? { forceFieldPaths } : {}),
183
+ ...(cursor ? { cursor } : {}),
184
+ }, createSystemActor(orgId), { jobs: ctx.services.jobs });
185
+ if (!result.ok)
186
+ throw new Error(result.error);
187
+ return { output: result.value };
188
+ },
189
+ },
131
190
  {
132
191
  slug: "channel/reap-exports",
133
192
  handler: async ({ input, ctx }) => {
@@ -150,6 +209,10 @@ export function channelConnectorPlugin(options = {}) {
150
209
  schema: () => ({
151
210
  connectedStores,
152
211
  channelEntityMap,
212
+ channelCatalogPushes,
213
+ channelCatalogPushEvents,
214
+ channelCatalogConflicts,
215
+ channelCatalogConflictEvents,
153
216
  channelOrderExports,
154
217
  channelExportEvents,
155
218
  channelRefundRequests,
@@ -341,10 +404,98 @@ export function channelConnectorPlugin(options = {}) {
341
404
  .summary("Get a connected channel store")
342
405
  .permission("channels:read")
343
406
  .handler(async ({ params, orgId }) => unwrap(await service.getStore(orgId, params.id)));
407
+ channels.get("/stores/{storeId}/catalog-write")
408
+ .summary("Get catalog write settings for a channel store")
409
+ .permission("channels:manage")
410
+ .handler(async ({ params, orgId }) => unwrap(await service.getCatalogWriteSettings(orgId, params.storeId)));
411
+ channels.put("/stores/{storeId}/catalog-write")
412
+ .summary("Update catalog write settings for a channel store")
413
+ .permission("channels:manage")
414
+ .input(z.object({
415
+ enabled: z.boolean().optional(),
416
+ overrides: z.unknown().optional(),
417
+ }).refine((value) => value.enabled !== undefined || value.overrides !== undefined))
418
+ .handler(async ({ params, orgId, input }) => {
419
+ const values = input;
420
+ if (values.overrides !== undefined)
421
+ unwrap(await service.updateCatalogFieldMapping(orgId, params.storeId, values.overrides));
422
+ if (values.enabled !== undefined)
423
+ unwrap(await service.updateCatalogWriteEnabled(orgId, params.storeId, values.enabled));
424
+ return unwrap(await service.getCatalogWriteSettings(orgId, params.storeId));
425
+ });
344
426
  channels.get("/stores/{storeId}/reconcile-status")
345
427
  .summary("Get channel reconciliation status")
346
428
  .permission("channels:read")
347
429
  .handler(async ({ params, orgId }) => unwrap(await service.getReconcileStatus(orgId, params.storeId)));
430
+ channels.get("/conflicts")
431
+ .summary("List channel catalog conflicts")
432
+ .permission("channels:read")
433
+ .query(z.object({ storeId: z.string().min(1).optional(), state: z.enum(["open", "resolved"]).optional() }))
434
+ .handler(async ({ query, orgId }) => {
435
+ const values = query;
436
+ return unwrap(await service.listCatalogConflicts(orgId, values.storeId, values.state));
437
+ });
438
+ channels.post("/conflicts/{id}/resolve")
439
+ .summary("Resolve a channel catalog conflict")
440
+ .permission("channels:manage")
441
+ .params(z.object({ id: z.string().min(1) }))
442
+ .input(z.object({ choose: z.enum(["platform", "store"]) }))
443
+ .handler(async ({ params, orgId, input, actor }) => {
444
+ const values = input;
445
+ return unwrap(await service.resolveCatalogConflict(orgId, params.id, values.choose, actor));
446
+ });
447
+ channels.post("/stores/{storeId}/backfill")
448
+ .summary("Backfill a channel catalog into the PIM")
449
+ .permission("channels:manage")
450
+ .input(z.object({ dryRun: z.boolean().optional(), restart: z.boolean().optional() }))
451
+ .handler(async ({ params, orgId, input }) => {
452
+ const values = input;
453
+ if (values.dryRun === true) {
454
+ return unwrap(await service.backfillCatalog(orgId, params.storeId, createSystemActor(orgId), { dryRun: true }));
455
+ }
456
+ const jobs = ctx.services.jobs;
457
+ await jobs.enqueue("channel/backfill-catalog", {
458
+ orgId,
459
+ storeId: params.storeId,
460
+ ...(values.restart === true ? { restart: true } : {}),
461
+ }, {
462
+ organizationId: orgId,
463
+ concurrencyKey: params.storeId,
464
+ supersedes: false,
465
+ });
466
+ return { enqueued: true, storeId: params.storeId };
467
+ });
468
+ channels.post("/stores/{storeId}/push-catalog")
469
+ .summary("Enqueue a catalog push for a connected store")
470
+ .permission("channels:manage")
471
+ .input(z.object({ entityIds: z.array(z.string()).optional() }))
472
+ .handler(async ({ params, orgId, input }) => {
473
+ unwrap(await service.getStore(orgId, params.storeId));
474
+ const values = input;
475
+ const jobs = ctx.services.jobs;
476
+ await jobs.enqueue("channel/push-catalog", {
477
+ organizationId: orgId,
478
+ storeId: params.storeId,
479
+ ...(values.entityIds ? { entityIds: values.entityIds } : {}),
480
+ }, {
481
+ organizationId: orgId,
482
+ concurrencyKey: catalogPushConcurrencyKey({
483
+ storeId: params.storeId,
484
+ ...(values.entityIds ? { entityIds: values.entityIds } : {}),
485
+ }),
486
+ supersedes: true,
487
+ });
488
+ return { enqueued: true, storeId: params.storeId };
489
+ });
490
+ channels.post("/stores/{storeId}/push-catalog/preview")
491
+ .summary("Preview a catalog push for a connected store")
492
+ .permission("channels:manage")
493
+ .input(z.object({ entityIds: z.array(z.string()).optional() }))
494
+ .handler(async ({ params, orgId, input }) => {
495
+ unwrap(await service.getStore(orgId, params.storeId));
496
+ const values = input;
497
+ return unwrap(await service.previewCatalogPush(orgId, params.storeId, values.entityIds));
498
+ });
348
499
  channels.post("/stores/{id}/disconnect")
349
500
  .summary("Disconnect a channel store")
350
501
  .permission("channels:manage")
@@ -360,15 +511,15 @@ export function channelConnectorPlugin(options = {}) {
360
511
  channels.post("/refund-requests/{id}/approve")
361
512
  .summary("Approve a channel refund request")
362
513
  .permission("channels:manage")
363
- .handler(async ({ params, orgId, actor }) => unwrap(await service.approveRefund(orgId, params.id, actor)));
514
+ .handler(async ({ params, orgId, actor }) => unwrap(await service.approveRefund(orgId, params.id, { userId: requireUserId(actor) })));
364
515
  channels.post("/refund-requests/{id}/reject")
365
516
  .summary("Reject a channel refund request")
366
517
  .permission("channels:manage")
367
- .handler(async ({ params, orgId, actor }) => unwrap(await service.rejectRefund(orgId, params.id, actor)));
518
+ .handler(async ({ params, orgId, actor }) => unwrap(await service.rejectRefund(orgId, params.id, { userId: requireUserId(actor) })));
368
519
  channels.post("/exports/{id}/retry")
369
520
  .summary("Retry a failed channel order export")
370
521
  .permission("channels:manage")
371
- .handler(async ({ params, orgId, actor }) => unwrap(await service.retryExport(orgId, params.id, actor.userId)));
522
+ .handler(async ({ params, orgId, actor }) => unwrap(await service.retryExport(orgId, params.id, requireUserId(actor))));
372
523
  return channels.routes();
373
524
  },
374
525
  });