@porulle/plugin-channel-connector 0.65.0 → 0.66.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/src/service.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { z } from "zod";
2
3
  import {
3
4
  CommerceInvalidTransitionError,
5
+ CommerceNotFoundError,
4
6
  CommerceValidationError,
5
7
  Ok,
6
8
  PluginErr,
@@ -15,6 +17,7 @@ import type {
15
17
  EntityLinkRows,
16
18
  ChannelCatalogItem,
17
19
  ChannelConnector,
20
+ ChannelOrderAddress,
18
21
  ChannelOrderSlice,
19
22
  ChannelPushCatalogField,
20
23
  ChannelPushCatalogImage,
@@ -37,6 +40,7 @@ import { isValidFieldPath, requireUserId } from "@porulle/core";
37
40
  import type { FieldOwner, FieldPath } from "@porulle/core";
38
41
  import type { JobsAdapter } from "@porulle/core";
39
42
  import { CHANNEL_CONVERGENCE_CTX } from "./catalog-push-trigger.js";
43
+ import { resolveLiveCredentials, withLiveCredentials } from "./live-credentials.js";
40
44
  import { and, desc, eq, inArray, isNull, lte, or, sql, type SQL } from "@porulle/core/drizzle";
41
45
  import {
42
46
  brands,
@@ -232,18 +236,40 @@ export interface ChannelComplianceData {
232
236
  }
233
237
 
234
238
  /**
235
- * What a consumer is allowed to read, resolved per request.
239
+ * Which stores the caller may see and act on, resolved per request.
236
240
  *
237
241
  * Returning `null` means "do not confine" and is the default — every existing consumer keeps the
238
- * organization-wide behaviour. An array is the complete set of store ids this caller may see, and
239
- * **`[]` means none**, not "no filter".
242
+ * organization-wide behaviour. An array is the complete set of store ids this caller may reach, and
243
+ * **`[]` means none**, not "no filter". Applied to the list AND to every route that names one store,
244
+ * where a store outside the set answers NOT_FOUND — a refusal must not confirm the store exists.
240
245
  *
241
246
  * It takes IDS rather than a tenant, deliberately. `vendor`, `seller`, `team` are models a consumer
242
247
  * owns; this package is generic commerce and acquiring one of them here would push a marketplace
243
248
  * concept into every deployment that has no such thing. The consumer resolves the meaning and hands
244
249
  * back the answer.
245
250
  */
246
- export type ConfineStoreReads = (context: StoreReadContext) => Promise<readonly string[] | null> | readonly string[] | null;
251
+ export type ConfineStores = (context: StoreReadContext) => Promise<readonly string[] | null> | readonly string[] | null;
252
+
253
+ /** Who connected a store: the signed-in user who started OAuth, or the caller of `POST /stores`. */
254
+ export interface StoreConnectActor {
255
+ orgId: string;
256
+ userId: string | null;
257
+ /** Core's request escape hatch when the connect is a request; absent on the OAuth callback. */
258
+ raw?: unknown;
259
+ }
260
+
261
+ /**
262
+ * Binds a just-connected store to whatever the consumer means by an owner, INSIDE the transaction
263
+ * that wrote the store row — so a store and its binding commit together or not at all. Throw to
264
+ * refuse the connection; the store row is rolled back with it.
265
+ */
266
+ export type BindConnectedStore = (input: { db: PluginDb; store: ConnectedStore; actor: StoreConnectActor }) => Promise<void>;
267
+
268
+ /** Work that follows a committed connection: the first import, provider-attested facts, keys. */
269
+ export type AfterStoreConnected = (input: { store: ConnectedStore; actor: StoreConnectActor; connector: ChannelConnector }) => Promise<void>;
270
+
271
+ /** Entities a provider webhook just created or changed, converged; the host projects them. */
272
+ export type OnStoreCatalogChanged = (input: { orgId: string; storeId: string; entityIds: string[]; convergence: CatalogPageConvergence }) => Promise<void>;
247
273
 
248
274
  /** What a consumer needs to resolve the caller. `raw` is core's documented request escape hatch. */
249
275
  export interface StoreReadContext {
@@ -255,13 +281,28 @@ export interface StoreReadContext {
255
281
  export interface ChannelConnectorPluginOptions {
256
282
  connectors?: ChannelConnector[];
257
283
  /**
258
- * Confines store reads to a set the consumer chooses. See {@link ConfineStoreReads}.
284
+ * Confines stores to a set the consumer chooses. See {@link ConfineStores}.
259
285
  *
260
286
  * Absent by default, because narrowing an existing read for every deployment would be a breaking
261
287
  * change to a published package. A consumer that needs confinement opts in; one that does not is
262
288
  * unaffected.
263
289
  */
264
- confineStoreReads?: ConfineStoreReads;
290
+ confineStores?: ConfineStores;
291
+ /** See {@link BindConnectedStore}. */
292
+ bindConnectedStore?: BindConnectedStore;
293
+ /** See {@link AfterStoreConnected}. */
294
+ afterStoreConnected?: AfterStoreConnected;
295
+ /** See {@link OnStoreCatalogChanged}. Absent, a webhook's products converge and nothing else runs. */
296
+ onStoreCatalogChanged?: OnStoreCatalogChanged;
297
+ /**
298
+ * This deployment's public origin. Required for a connector that registers webhooks per store: a
299
+ * provider delivers to an ABSOLUTE address, and a relative one is refused at connect.
300
+ */
301
+ publicUrl?: string;
302
+ /**
303
+ * `postConnectRedirect` is where the merchant's browser lands after OAuth, with `connected=<storeId>`
304
+ * or `connect_error=<code>` appended — a browser flow ends on a page, never on a JSON error.
305
+ */
265
306
  oauth?: { stateSecret: string; postConnectRedirect: string };
266
307
  inventoryTimeoutMs?: number;
267
308
  jobs?: JobsAdapter;
@@ -997,6 +1038,30 @@ async function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T
997
1038
  }
998
1039
  }
999
1040
 
1041
+ /** What a host writes as an order's `metadata.shippingAddress`, in {@link ChannelOrderAddress}'s spelling. */
1042
+ export const channelOrderAddressSchema = z.object({
1043
+ firstName: z.string(),
1044
+ lastName: z.string(),
1045
+ line1: z.string().min(1),
1046
+ line2: z.string().optional(),
1047
+ city: z.string().min(1),
1048
+ region: z.string().optional(),
1049
+ postalCode: z.string().optional(),
1050
+ countryCode: z.string().regex(/^[A-Z]{2}$/, "countryCode must be ISO 3166-1 alpha-2"),
1051
+ phone: z.string().optional(),
1052
+ });
1053
+
1054
+ function withoutUndefined(address: z.infer<typeof channelOrderAddressSchema>): ChannelOrderAddress {
1055
+ const { line2, region, postalCode, phone, ...required } = address;
1056
+ return {
1057
+ ...required,
1058
+ ...(line2 !== undefined ? { line2 } : {}),
1059
+ ...(region !== undefined ? { region } : {}),
1060
+ ...(postalCode !== undefined ? { postalCode } : {}),
1061
+ ...(phone !== undefined ? { phone } : {}),
1062
+ };
1063
+ }
1064
+
1000
1065
  function redactStore(store: ConnectedStore): PublicConnectedStore {
1001
1066
  return {
1002
1067
  id: store.id,
@@ -1200,7 +1265,7 @@ export class ChannelConnectorService {
1200
1265
  if (this.connectors.has(connector.providerId)) {
1201
1266
  throw new Error(`Duplicate channel connector providerId: ${connector.providerId}`);
1202
1267
  }
1203
- this.connectors.set(connector.providerId, connector);
1268
+ this.connectors.set(connector.providerId, withLiveCredentials(connector, db));
1204
1269
  }
1205
1270
  this.jobs = options.jobs ?? (services.jobs as JobsAdapter | undefined);
1206
1271
  this.transact = transaction ?? ((fn) => this.db.transaction(fn));
@@ -3025,6 +3090,15 @@ export class ChannelConnectorService {
3025
3090
  return this.getCatalogWriteSettings(orgId, storeId);
3026
3091
  }
3027
3092
 
3093
+ /**
3094
+ * Connects a store, or refreshes the grant of one this organization already holds for the same
3095
+ * provider and domain (a reconnect after an uninstall, or a re-authorization) — never a second row
3096
+ * for the same shop, which would import it twice.
3097
+ *
3098
+ * The row and the consumer's binding of it ({@link BindConnectedStore}) are one transaction. A
3099
+ * provider that subscribes per store is registered after commit, at an absolute address; the
3100
+ * consumer's follow-on work ({@link AfterStoreConnected}) runs after that.
3101
+ */
3028
3102
  async connectStore(
3029
3103
  orgId: string,
3030
3104
  input: {
@@ -3033,64 +3107,98 @@ export class ChannelConnectorService {
3033
3107
  storeDomain: string;
3034
3108
  webhookSecret?: string;
3035
3109
  },
3110
+ actor: StoreConnectActor,
3036
3111
  ): Promise<PluginResult<PublicConnectedStore>> {
3037
- if (!this.connectors.has(input.provider)) {
3038
- return PluginErr(`No connector registered for provider "${input.provider}".`, "NOT_FOUND");
3112
+ const connector = this.connectors.get(input.provider);
3113
+ if (!connector) return PluginErr(`No connector registered for provider "${input.provider}".`, "NOT_FOUND");
3114
+ const storeDomain = connector.normalizeStoreDomain ? connector.normalizeStoreDomain(input.storeDomain) : input.storeDomain;
3115
+ if (!storeDomain) return PluginErr(`"${input.storeDomain}" does not name a ${input.provider} store.`, "INVALID_STORE_DOMAIN");
3116
+ if (connector.registerWebhooks && !this.options.publicUrl) {
3117
+ return PluginErr(`Connector "${input.provider}" subscribes per store and needs the plugin's publicUrl to give it an absolute address.`, "PUBLIC_URL_REQUIRED");
3039
3118
  }
3040
- const existingRows = await this.db
3041
- .select()
3042
- .from(connectedStores)
3043
- .where(and(
3044
- eq(connectedStores.organizationId, orgId),
3045
- eq(connectedStores.provider, input.provider),
3046
- eq(connectedStores.storeDomain, input.storeDomain),
3047
- ));
3048
- const reconnect = existingRows.find((row) => row.status !== "connected");
3049
- const rows = reconnect
3050
- ? await this.db
3051
- .update(connectedStores)
3052
- .set({
3053
- credentials: input.credentials,
3054
- status: "connected",
3055
- catalogWriteEnabled: false,
3056
- webhookSecret: input.webhookSecret ?? crypto.randomUUID(),
3057
- updatedAt: new Date(),
3058
- })
3059
- .where(eq(connectedStores.id, reconnect.id))
3060
- .returning()
3061
- : await this.db
3062
- .insert(connectedStores)
3063
- .values({
3064
- organizationId: orgId,
3065
- provider: input.provider,
3066
- credentials: input.credentials,
3067
- storeDomain: input.storeDomain,
3068
- webhookSecret: input.webhookSecret ?? crypto.randomUUID(),
3069
- })
3070
- .returning();
3071
- const connector = this.connectors.get(input.provider)!;
3072
- const store = rows[0] as ConnectedStore;
3073
- if (connector.registerWebhooks) {
3119
+ let store: ConnectedStore;
3120
+ try {
3121
+ store = await this.transact(async (tx) => {
3122
+ const [existing] = await tx.select().from(connectedStores).where(and(
3123
+ eq(connectedStores.organizationId, orgId),
3124
+ eq(connectedStores.provider, input.provider),
3125
+ eq(connectedStores.storeDomain, storeDomain),
3126
+ ));
3127
+ const rows = existing
3128
+ ? await tx.update(connectedStores).set({
3129
+ credentials: input.credentials,
3130
+ status: "connected",
3131
+ ...(existing.status !== "connected" ? { catalogWriteEnabled: false } : {}),
3132
+ webhookSecret: input.webhookSecret ?? existing.webhookSecret ?? crypto.randomUUID(),
3133
+ updatedAt: new Date(),
3134
+ }).where(eq(connectedStores.id, existing.id)).returning()
3135
+ : await tx.insert(connectedStores).values({
3136
+ organizationId: orgId,
3137
+ provider: input.provider,
3138
+ credentials: input.credentials,
3139
+ storeDomain,
3140
+ webhookSecret: input.webhookSecret ?? crypto.randomUUID(),
3141
+ }).returning();
3142
+ const written = rows[0] as ConnectedStore | undefined;
3143
+ if (!written) throw new Error("The connected store row was not written.");
3144
+ await this.options.bindConnectedStore?.({ db: tx, store: written, actor });
3145
+ return written;
3146
+ });
3147
+ } catch (error) {
3148
+ return PluginErr(error instanceof Error ? error.message : "The store could not be connected.", error instanceof CommerceNotFoundError ? "NOT_FOUND" : "STORE_CONNECTION_REFUSED");
3149
+ }
3150
+ if (connector.registerWebhooks && this.options.publicUrl) {
3151
+ const callbackUrl = new URL(`/api/channels/webhooks/${store.id}`, this.options.publicUrl).toString();
3074
3152
  const registration = await connector.registerWebhooks(store as ChannelStore, [
3153
+ "products/create",
3075
3154
  "products/update",
3076
3155
  "products/delete",
3077
3156
  "inventory_levels/update",
3078
3157
  "orders/fulfilled",
3079
3158
  "orders/cancelled",
3080
- "refunds/create",
3081
3159
  "app/uninstalled",
3082
- ], `/api/channels/webhooks/${store.id}`);
3160
+ ], callbackUrl);
3083
3161
  if (!registration.ok) {
3084
3162
  await this.db.update(connectedStores).set({ status: "error", updatedAt: new Date() }).where(eq(connectedStores.id, store.id));
3085
3163
  return PluginErr(registration.error.message, "CONNECTOR_REGISTRATION_FAILED");
3086
3164
  }
3087
3165
  }
3088
- // Connecting starts no import. The host's operator route starts one (and levels inventory after
3089
- // it); connect used to enqueue a second, sequential walk that ran beside the host's own.
3166
+ if (this.options.afterStoreConnected) {
3167
+ try {
3168
+ await this.options.afterStoreConnected({ store, actor, connector });
3169
+ } catch (error) {
3170
+ return PluginErr(error instanceof Error ? error.message : "The store connected but its follow-on work failed.", "AFTER_CONNECT_FAILED");
3171
+ }
3172
+ }
3090
3173
  return Ok(redactStore(store));
3091
3174
  }
3092
3175
 
3093
- async disconnectStore(orgId: string, id: string): Promise<PluginResult<PublicConnectedStore>> {
3176
+ /** The store with credentials good for a call the host makes itself, e.g. its own Admin API write. */
3177
+ async liveStore(orgId: string, storeId: string): Promise<PluginResult<ChannelStore>> {
3178
+ const store = await this.getStoreRecord(orgId, storeId);
3179
+ if (!store || store.status !== "connected") return PluginErr("Connected store not found.", "NOT_FOUND");
3180
+ const connector = this.connectors.get(store.provider);
3181
+ if (!connector) return PluginErr(`No connector registered for provider "${store.provider}".`, "NOT_FOUND");
3182
+ const live = await resolveLiveCredentials(connector, this.db, store as ChannelStore);
3183
+ return live.ok ? Ok(live.value) : PluginErr(live.error.message, live.error.code);
3184
+ }
3185
+
3186
+ /** The caller's allow-list, or null for unconfined. See {@link ConfineStores}. */
3187
+ private async allowedStores(orgId: string, context: StoreReadContext | undefined): Promise<readonly string[] | null> {
3188
+ return this.options.confineStores ? await this.options.confineStores(context ?? { orgId, actor: null, raw: undefined }) : null;
3189
+ }
3190
+
3191
+ /** NOT_FOUND for a store outside the caller's set, exactly as for one that does not exist. */
3192
+ async reachableStore(orgId: string, id: string, context: StoreReadContext | undefined): Promise<PluginResult<ConnectedStore>> {
3193
+ const allowed = await this.allowedStores(orgId, context);
3194
+ if (allowed !== null && !allowed.includes(id)) return PluginErr("Connected store not found.", "NOT_FOUND");
3195
+ const store = await this.getStoreRecord(orgId, id);
3196
+ return store ? Ok(store) : PluginErr("Connected store not found.", "NOT_FOUND");
3197
+ }
3198
+
3199
+ async disconnectStore(orgId: string, id: string, context?: StoreReadContext): Promise<PluginResult<PublicConnectedStore>> {
3200
+ const store = await this.reachableStore(orgId, id, context);
3201
+ if (!store.ok) return store;
3094
3202
  return this.disconnectStoreSystem(orgId, id);
3095
3203
  }
3096
3204
 
@@ -3111,16 +3219,13 @@ export class ChannelConnectorService {
3111
3219
  return Ok(redactStore(store));
3112
3220
  }
3113
3221
 
3114
- async getStore(orgId: string, id: string): Promise<PluginResult<PublicConnectedStore>> {
3115
- const store = await this.getStoreRecord(orgId, id);
3116
- if (!store) return PluginErr("Connected store not found.", "NOT_FOUND");
3117
- return Ok(redactStore(store));
3222
+ async getStore(orgId: string, id: string, context?: StoreReadContext): Promise<PluginResult<PublicConnectedStore>> {
3223
+ const store = await this.reachableStore(orgId, id, context);
3224
+ return store.ok ? Ok(redactStore(store.value)) : store;
3118
3225
  }
3119
3226
 
3120
3227
  async listStores(orgId: string, context?: StoreReadContext): Promise<PluginResult<PublicConnectedStore[]>> {
3121
- const allowed = this.options.confineStoreReads
3122
- ? await this.options.confineStoreReads(context ?? { orgId, actor: null, raw: undefined })
3123
- : null;
3228
+ const allowed = await this.allowedStores(orgId, context);
3124
3229
  // An empty allow-list means the caller may read NOTHING, stated here rather than left to the
3125
3230
  // query builder.
3126
3231
  //
@@ -4767,7 +4872,32 @@ export class ChannelConnectorService {
4767
4872
  let skipped: CatalogFieldSkip[] = [];
4768
4873
  let conflicts: CatalogFieldConflict[] = [];
4769
4874
  let warnings: string[] = [];
4770
- if (event.type === "products/update") {
4875
+ const connector = this.connectors.get(store.provider);
4876
+ if ((event.type === "products/create" || event.type === "products/update") && connector?.fetchCatalogItems) {
4877
+ // A webhook is a notification, not a snapshot: its payload is the provider's wire spelling and
4878
+ // may be stale or out of order. The product is read fresh and converged exactly as an import
4879
+ // page would be — creating it when this store has never mapped it.
4880
+ const externalId = String(data.id ?? data.product_id ?? "");
4881
+ if (!externalId) return PluginErr(`A ${event.type} delivery named no product.`, "INVALID_WEBHOOK");
4882
+ const read = await connector.fetchCatalogItems(store as ChannelStore, [externalId]);
4883
+ if (!read.ok) return PluginErr(read.error.message, read.error.code);
4884
+ const [item] = read.value;
4885
+ if (item === undefined) {
4886
+ // Gone between the delivery and the read: the same as a delete.
4887
+ const archived = await this.archiveMappedProduct(orgId, storeId, externalId, actor);
4888
+ if (!archived.ok) return archived;
4889
+ skipped = archived.value;
4890
+ } else {
4891
+ const converged = await this.convergeCatalogPage(orgId, storeId, [item], actor);
4892
+ if (!converged.ok) return converged;
4893
+ const [failure] = converged.value.failures;
4894
+ if (failure) return PluginErr(`Product ${failure.externalId} could not be converged: ${failure.error}`, "CONVERGENCE_FAILED");
4895
+ warnings = [...converged.value.warnings];
4896
+ if (converged.value.entityIds.length > 0) {
4897
+ await this.options.onStoreCatalogChanged?.({ orgId, storeId, entityIds: [...converged.value.entityIds], convergence: converged.value });
4898
+ }
4899
+ }
4900
+ } else if (event.type === "products/update") {
4771
4901
  const productId = String(data.id ?? data.product_id ?? "");
4772
4902
  const mapping = await this.db.select().from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.kind, "entity"), eq(channelEntityMap.externalId, productId)));
4773
4903
  if (mapping[0]) {
@@ -4778,17 +4908,9 @@ export class ChannelConnectorService {
4778
4908
  warnings = converged.value.warnings;
4779
4909
  }
4780
4910
  } else if (event.type === "products/delete") {
4781
- const productId = String(data.id ?? data.product_id ?? "");
4782
- const mapping = await this.db.select().from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.kind, "entity"), eq(channelEntityMap.externalId, productId)));
4783
- if (mapping[0]) {
4784
- const owners = await this.catalog.resolveFieldOwners(mapping[0].entityId, storeId);
4785
- if (owners.get("entity.status") === "platform") {
4786
- skipped.push({ entityId: mapping[0].entityId, fieldPath: "entity.status" });
4787
- } else {
4788
- const archived = await this.catalog.archive(mapping[0].entityId, actor);
4789
- if (!archived.ok) return PluginErr(archived.error.message);
4790
- }
4791
- }
4911
+ const archived = await this.archiveMappedProduct(orgId, storeId, String(data.id ?? data.product_id ?? ""), actor);
4912
+ if (!archived.ok) return archived;
4913
+ skipped = archived.value;
4792
4914
  } else if (event.type === "inventory_levels/update") {
4793
4915
  const available = Number(data.available ?? data.stock_quantity ?? 0);
4794
4916
  // Shopify names an INVENTORY ITEM, whose id is not the variant id the channel map is keyed by;
@@ -4798,7 +4920,16 @@ export class ChannelConnectorService {
4798
4920
  const inventoryItemId = data.inventory_item_id !== undefined && data.inventory_item_id !== null ? String(data.inventory_item_id) : null;
4799
4921
  const byInventoryItem = inventoryItemId === null ? null : await this.variantForInventoryItem(orgId, storeId, inventoryItemId);
4800
4922
  if (byInventoryItem !== null) {
4801
- await this.setInventoryLevel(byInventoryItem.entityId, byInventoryItem.variantId, available, actor);
4923
+ // The delivery's `available` is ONE location's count. The variant's stock is the sum the
4924
+ // connector reads, so it is read fresh rather than taken from the payload.
4925
+ let quantity = available;
4926
+ if (connector) {
4927
+ const levels = await connector.fetchInventory(store as ChannelStore, [byInventoryItem.externalId]);
4928
+ if (!levels.ok) return PluginErr(levels.error.message, levels.error.code);
4929
+ const fresh = levels.value.find((entry) => entry.externalId === byInventoryItem.externalId);
4930
+ if (fresh) quantity = fresh.available;
4931
+ }
4932
+ await this.setInventoryLevel(byInventoryItem.entityId, byInventoryItem.variantId, quantity, actor);
4802
4933
  } else {
4803
4934
  const externalId = String(data.variation_id ?? data.product_id ?? inventoryItemId ?? "");
4804
4935
  await this.setMappedInventory(orgId, storeId, externalId, available, actor);
@@ -4907,11 +5038,22 @@ export class ChannelConnectorService {
4907
5038
 
4908
5039
  private async resolveOrderId(orgId: string, storeId: string, data: Record<string, unknown>): Promise<string | undefined> {
4909
5040
  const nestedOrder = data.order && typeof data.order === "object" ? data.order as Record<string, unknown> : undefined;
4910
- const remoteOrderId = String(data.order_id ?? data.orderId ?? nestedOrder?.id ?? "");
5041
+ // An `orders/*` payload IS the order, so its own `id` names it; a refund names its order in `order_id`.
5042
+ const remoteOrderId = String(data.order_id ?? data.orderId ?? nestedOrder?.id ?? data.id ?? "");
4911
5043
  const rows = await this.db.select({ orderId: channelOrderExports.orderId }).from(channelOrderExports).where(and(eq(channelOrderExports.organizationId, orgId), eq(channelOrderExports.storeId, storeId), eq(channelOrderExports.remoteOrderId, remoteOrderId)));
4912
5044
  return rows[0]?.orderId;
4913
5045
  }
4914
5046
 
5047
+ /** Archives this store's product mapped to `externalId`, unless the platform owns its status. */
5048
+ private async archiveMappedProduct(orgId: string, storeId: string, externalId: string, actor: Actor): Promise<PluginResult<CatalogFieldSkip[]>> {
5049
+ const [mapping] = await this.db.select().from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.kind, "entity"), eq(channelEntityMap.externalId, externalId)));
5050
+ if (!mapping) return Ok([]);
5051
+ const owners = await this.catalog.resolveFieldOwners(mapping.entityId, storeId);
5052
+ if (owners.get("entity.status") === "platform") return Ok([{ entityId: mapping.entityId, fieldPath: "entity.status" }]);
5053
+ const archived = await this.catalog.archive(mapping.entityId, actor);
5054
+ return archived.ok ? Ok([]) : PluginErr(archived.error.message);
5055
+ }
5056
+
4915
5057
  private async setMappedInventory(orgId: string, storeId: string, externalId: string, quantity: number, actor: Actor): Promise<void> {
4916
5058
  const [mapping] = await this.db.select().from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.externalId, externalId)));
4917
5059
  if (!mapping) return;
@@ -4924,8 +5066,8 @@ export class ChannelConnectorService {
4924
5066
  }
4925
5067
 
4926
5068
  /** This store's mapped variant whose import recorded `metadata.inventoryItemId`; null when none, or when two claim it. */
4927
- private async variantForInventoryItem(orgId: string, storeId: string, inventoryItemId: string): Promise<{ entityId: string; variantId: string } | null> {
4928
- const rows = await this.db.select({ entityId: channelEntityMap.entityId, variantId: channelEntityMap.variantId }).from(channelEntityMap)
5069
+ private async variantForInventoryItem(orgId: string, storeId: string, inventoryItemId: string): Promise<{ entityId: string; variantId: string; externalId: string } | null> {
5070
+ const rows = await this.db.select({ entityId: channelEntityMap.entityId, variantId: channelEntityMap.variantId, externalId: channelEntityMap.externalId }).from(channelEntityMap)
4929
5071
  .innerJoin(variants, eq(variants.id, channelEntityMap.variantId))
4930
5072
  .where(and(
4931
5073
  eq(channelEntityMap.organizationId, orgId),
@@ -4935,7 +5077,7 @@ export class ChannelConnectorService {
4935
5077
  ))
4936
5078
  .limit(2);
4937
5079
  const [only] = rows;
4938
- return rows.length === 1 && only !== undefined && only.variantId !== null ? { entityId: only.entityId, variantId: only.variantId } : null;
5080
+ return rows.length === 1 && only !== undefined && only.variantId !== null ? { entityId: only.entityId, variantId: only.variantId, externalId: only.externalId } : null;
4939
5081
  }
4940
5082
 
4941
5083
  private async convergeCatalogItem(
@@ -5360,7 +5502,7 @@ export class ChannelConnectorService {
5360
5502
 
5361
5503
  let email: string | null = null;
5362
5504
  let name = "";
5363
- let shippingAddress: Record<string, unknown> | null = null;
5505
+ let shippingAddress: ChannelOrderAddress | null = null;
5364
5506
  if (order.customerId) {
5365
5507
  const [customer] = await this.db.select().from(customers).where(and(eq(customers.organizationId, orgId), eq(customers.id, order.customerId)));
5366
5508
  if (customer) {
@@ -5371,7 +5513,19 @@ export class ChannelConnectorService {
5371
5513
  // shipped to their default. The order's address is applied below and wins.
5372
5514
  const addresses = await this.db.select().from(customerAddresses).where(and(eq(customerAddresses.customerId, customer.id), eq(customerAddresses.type, "shipping")));
5373
5515
  const address = addresses.find((item) => item.isDefault) ?? addresses[0];
5374
- if (address) shippingAddress = { first_name: address.firstName, last_name: address.lastName, address1: address.line1, ...(address.line2 ? { address2: address.line2 } : {}), city: address.city, ...(address.state ? { state: address.state } : {}), ...(address.postalCode ? { zip: address.postalCode } : {}), country: address.country, ...(address.phone ? { phone: address.phone } : {}) };
5516
+ if (address) {
5517
+ shippingAddress = {
5518
+ firstName: address.firstName ?? "",
5519
+ lastName: address.lastName ?? "",
5520
+ line1: address.line1,
5521
+ ...(address.line2 ? { line2: address.line2 } : {}),
5522
+ city: address.city,
5523
+ ...(address.state ? { region: address.state } : {}),
5524
+ ...(address.postalCode ? { postalCode: address.postalCode } : {}),
5525
+ countryCode: address.country,
5526
+ ...(address.phone ? { phone: address.phone } : {}),
5527
+ };
5528
+ }
5375
5529
  }
5376
5530
  }
5377
5531
  const metadata = order.metadata ?? {};
@@ -5379,7 +5533,11 @@ export class ChannelConnectorService {
5379
5533
  email ??= typeof guest.email === "string" ? guest.email : null;
5380
5534
  name ||= typeof guest.name === "string" ? guest.name : `${typeof guest.firstName === "string" ? guest.firstName : ""} ${typeof guest.lastName === "string" ? guest.lastName : ""}`.trim();
5381
5535
  const orderShipping = metadata.shippingAddress ?? metadata.guestShippingAddress ?? (typeof metadata.guestCustomer === "object" && metadata.guestCustomer ? (metadata.guestCustomer as Record<string, unknown>).shippingAddress : undefined);
5382
- if (orderShipping && typeof orderShipping === "object") shippingAddress = orderShipping as Record<string, unknown>;
5536
+ if (orderShipping !== undefined) {
5537
+ const parsed = channelOrderAddressSchema.safeParse(orderShipping);
5538
+ if (!parsed.success) return PluginErr(`The order's shipping address is not a channel order address: ${parsed.error.issues[0]?.message ?? "invalid"}.`, "CUSTOMER_DATA_MISSING");
5539
+ shippingAddress = withoutUndefined(parsed.data);
5540
+ }
5383
5541
  if (!email || !shippingAddress) return PluginErr("Customer email and shipping address are required for channel order export.", "CUSTOMER_DATA_MISSING");
5384
5542
  return Ok({ orderId, currency: order.currency, grandTotal: lines.reduce((sum, line) => sum + line.totalPrice, 0), lines, customer: { name, email, shippingAddress } });
5385
5543
  }