@porulle/plugin-channel-connector 0.11.0 → 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,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,11 +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
5
  export { isValidCatalogMappingFieldPath, matchFieldPath, mergeCatalogFieldMapping, normalizeCatalogFieldMapping, compareCatalogFieldMappingSpecificity, providerCatalogFieldMappingDefaults, selectCatalogFieldMapping, validateCatalogMappingRow, } from "./catalog-field-mapping.js";
6
6
  export type { CatalogFieldMapping, CatalogFieldMappingInput, CatalogFieldMappingRow, CatalogFieldTarget, } from "./catalog-field-mapping.js";
7
7
  export { signState, verifyState } from "./oauth-state.js";
8
- export type { BackfillCatalogOptions, BackfillCatalogReport, BuildCatalogPushItemsOptions, BuildCatalogPushItemsResult, CatalogFieldConflict, CatalogFieldSkip, CatalogPushFieldSkip, CatalogPushSkipReason, CatalogWriteSettings, 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";
9
9
  export type { OAuthStatePayload, OAuthStateResult } from "./oauth-state.js";
10
- 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";
11
11
  export declare function channelConnectorPlugin(options?: ChannelConnectorPluginOptions): import("@porulle/core").CommercePlugin;
package/dist/index.js CHANGED
@@ -1,14 +1,14 @@
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
12
  export { isValidCatalogMappingFieldPath, matchFieldPath, mergeCatalogFieldMapping, normalizeCatalogFieldMapping, compareCatalogFieldMappingSpecificity, providerCatalogFieldMappingDefaults, selectCatalogFieldMapping, validateCatalogMappingRow, } from "./catalog-field-mapping.js";
13
13
  export { signState, verifyState } from "./oauth-state.js";
14
14
  function unwrap(result) {
@@ -161,6 +161,32 @@ export function channelConnectorPlugin(options = {}) {
161
161
  return { output: { exportId: result.value.id, state: result.value.state } };
162
162
  },
163
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
+ },
164
190
  {
165
191
  slug: "channel/reap-exports",
166
192
  handler: async ({ input, ctx }) => {
@@ -183,6 +209,10 @@ export function channelConnectorPlugin(options = {}) {
183
209
  schema: () => ({
184
210
  connectedStores,
185
211
  channelEntityMap,
212
+ channelCatalogPushes,
213
+ channelCatalogPushEvents,
214
+ channelCatalogConflicts,
215
+ channelCatalogConflictEvents,
186
216
  channelOrderExports,
187
217
  channelExportEvents,
188
218
  channelRefundRequests,
@@ -397,6 +427,23 @@ export function channelConnectorPlugin(options = {}) {
397
427
  .summary("Get channel reconciliation status")
398
428
  .permission("channels:read")
399
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
+ });
400
447
  channels.post("/stores/{storeId}/backfill")
401
448
  .summary("Backfill a channel catalog into the PIM")
402
449
  .permission("channels:manage")
@@ -418,6 +465,37 @@ export function channelConnectorPlugin(options = {}) {
418
465
  });
419
466
  return { enqueued: true, storeId: params.storeId };
420
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
+ });
421
499
  channels.post("/stores/{id}/disconnect")
422
500
  .summary("Disconnect a channel store")
423
501
  .permission("channels:manage")
@@ -433,15 +511,15 @@ export function channelConnectorPlugin(options = {}) {
433
511
  channels.post("/refund-requests/{id}/approve")
434
512
  .summary("Approve a channel refund request")
435
513
  .permission("channels:manage")
436
- .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) })));
437
515
  channels.post("/refund-requests/{id}/reject")
438
516
  .summary("Reject a channel refund request")
439
517
  .permission("channels:manage")
440
- .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) })));
441
519
  channels.post("/exports/{id}/retry")
442
520
  .summary("Retry a failed channel order export")
443
521
  .permission("channels:manage")
444
- .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))));
445
523
  return channels.routes();
446
524
  },
447
525
  });