@porulle/plugin-channel-connector 0.73.2 → 0.74.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/index.d.ts +1 -1
- package/dist/index.js +102 -29
- package/dist/live-credentials.d.ts +6 -3
- package/dist/live-credentials.js +22 -6
- package/dist/mock-connector.d.ts +62 -0
- package/dist/mock-connector.js +33 -0
- package/dist/schema.d.ts +82 -2
- package/dist/schema.js +9 -1
- package/dist/service.d.ts +72 -15
- package/dist/service.js +307 -365
- package/package.json +4 -4
- package/src/index.ts +100 -27
- package/src/live-credentials.ts +23 -5
- package/src/mock-connector.ts +34 -0
- package/src/schema.ts +20 -1
- package/src/service.ts +321 -363
package/src/service.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import {
|
|
4
|
+
CHANNEL_CREDENTIALS_REJECTED,
|
|
4
5
|
CHANNEL_OUT_OF_STOCK,
|
|
5
6
|
CommerceInvalidTransitionError,
|
|
6
7
|
CommerceNotFoundError,
|
|
@@ -19,6 +20,9 @@ import type {
|
|
|
19
20
|
EntityLinkRows,
|
|
20
21
|
ChannelCatalogItem,
|
|
21
22
|
ChannelConnector,
|
|
23
|
+
ChannelEvent,
|
|
24
|
+
ChannelShipment,
|
|
25
|
+
ChannelWebhookEvent,
|
|
22
26
|
ChannelOrderAddress,
|
|
23
27
|
ChannelOrderSlice,
|
|
24
28
|
ChannelPushCatalogField,
|
|
@@ -92,6 +96,7 @@ import {
|
|
|
92
96
|
type ChannelRefundRequest,
|
|
93
97
|
type ConnectedStore,
|
|
94
98
|
} from "./schema.js";
|
|
99
|
+
import type { StoreHealth } from "./schema.js";
|
|
95
100
|
import {
|
|
96
101
|
mergeCatalogFieldMapping,
|
|
97
102
|
normalizeCatalogFieldMapping,
|
|
@@ -132,26 +137,13 @@ export const CHANNEL_INVENTORY_MAX_ITEMS_PER_INVOCATION = 20;
|
|
|
132
137
|
* The reason a platform order is cancelled under when its STORE cancelled it. The cancel hook skips
|
|
133
138
|
* cancelling at the store for exactly this reason, so the two directions cannot loop.
|
|
134
139
|
*/
|
|
135
|
-
|
|
140
|
+
/** How stale a store order may be before a read point queues a refresh of it. */
|
|
141
|
+
export const REMOTE_ORDER_REFRESH_MS = 5 * 60 * 1000;
|
|
136
142
|
|
|
137
|
-
/**
|
|
138
|
-
const
|
|
139
|
-
"returns/approve": "approved",
|
|
140
|
-
"returns/decline": "declined",
|
|
141
|
-
"returns/close": "closed",
|
|
142
|
-
"returns/cancel": "cancelled",
|
|
143
|
-
"returns/reopen": "approved",
|
|
144
|
-
};
|
|
143
|
+
/** How often a merchant's visit may make the plugin call a store to check its health. */
|
|
144
|
+
export const STORE_HEALTH_INTERVAL_MS = 10 * 60 * 1000;
|
|
145
145
|
|
|
146
|
-
|
|
147
|
-
const channelFulfillmentsSchema = z.array(z.object({
|
|
148
|
-
id: z.union([z.string(), z.number()]),
|
|
149
|
-
status: z.string().nullish(),
|
|
150
|
-
tracking_company: z.string().nullish(),
|
|
151
|
-
tracking_number: z.string().nullish(),
|
|
152
|
-
tracking_url: z.string().nullish(),
|
|
153
|
-
line_items: z.array(z.object({ variant_id: z.union([z.string(), z.number()]).nullish(), quantity: z.number().int().positive() })).default([]),
|
|
154
|
-
}));
|
|
146
|
+
export const CHANNEL_ORDER_CANCELLED_REASON = "channel_order_cancelled";
|
|
155
147
|
|
|
156
148
|
const CATALOG_PUSH_RETRY_BASE_MS = 60_000;
|
|
157
149
|
const CATALOG_PUSH_RETRY_MAX_MS = 60 * 60 * 1000;
|
|
@@ -1117,6 +1109,9 @@ function redactStore(store: ConnectedStore): PublicConnectedStore {
|
|
|
1117
1109
|
credentials: "[REDACTED]",
|
|
1118
1110
|
storeDomain: store.storeDomain,
|
|
1119
1111
|
status: store.status,
|
|
1112
|
+
statusReason: store.statusReason,
|
|
1113
|
+
health: store.health,
|
|
1114
|
+
lastEventAt: store.lastEventAt,
|
|
1120
1115
|
catalogWriteEnabled: store.catalogWriteEnabled,
|
|
1121
1116
|
catalogFieldMapping: store.catalogFieldMapping,
|
|
1122
1117
|
catalogCursor: store.catalogCursor,
|
|
@@ -1165,6 +1160,10 @@ export interface CatalogPageConvergence extends Record<string, unknown> {
|
|
|
1165
1160
|
heroesImported: number;
|
|
1166
1161
|
mediaFailures: CatalogMediaFailure[];
|
|
1167
1162
|
deferredMedia: CatalogDeferredMedia[];
|
|
1163
|
+
/** Fields the store's value did not overwrite because the platform owns them. */
|
|
1164
|
+
skipped: CatalogFieldSkip[];
|
|
1165
|
+
/** Shared fields both sides changed, held for an operator. */
|
|
1166
|
+
conflicts: CatalogFieldConflict[];
|
|
1168
1167
|
warnings: string[];
|
|
1169
1168
|
}
|
|
1170
1169
|
|
|
@@ -1295,6 +1294,13 @@ function toImportProduct(item: ChannelCatalogItem): ImportProduct {
|
|
|
1295
1294
|
/** One item's link writes, planned before the transaction that commits them (`commitEntityLinks`). */
|
|
1296
1295
|
type PlannedLinks = { [K in keyof EntityLinkRows]-?: Array<NonNullable<EntityLinkRows[K]>[number]> };
|
|
1297
1296
|
|
|
1297
|
+
/** What applying one delivery's events left for the store's reconcile report. */
|
|
1298
|
+
interface ChannelEventReport {
|
|
1299
|
+
skipped: CatalogFieldSkip[];
|
|
1300
|
+
conflicts: CatalogFieldConflict[];
|
|
1301
|
+
warnings: string[];
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1298
1304
|
export class ChannelConnectorService {
|
|
1299
1305
|
private readonly connectors = new Map<string, ChannelConnector>();
|
|
1300
1306
|
private readonly transact: PluginTxFn;
|
|
@@ -2654,6 +2660,12 @@ export class ChannelConnectorService {
|
|
|
2654
2660
|
|
|
2655
2661
|
// A shop_domain can map to more than one connected store (reconnect, or the same
|
|
2656
2662
|
// shop under two orgs). Compliance webhooks must fan out to all of them.
|
|
2663
|
+
/** This organization's store of `provider` at `storeDomain`, whatever its status. */
|
|
2664
|
+
async storeByDomain(orgId: string, provider: string, storeDomain: string): Promise<PublicConnectedStore | undefined> {
|
|
2665
|
+
const [row] = await this.db.select().from(connectedStores).where(and(eq(connectedStores.organizationId, orgId), eq(connectedStores.provider, provider), eq(connectedStores.storeDomain, storeDomain)));
|
|
2666
|
+
return row ? redactStore(row as ConnectedStore) : undefined;
|
|
2667
|
+
}
|
|
2668
|
+
|
|
2657
2669
|
async getStoresByDomain(shopDomain: string): Promise<ConnectedStore[]> {
|
|
2658
2670
|
const rows = await this.db
|
|
2659
2671
|
.select()
|
|
@@ -3156,14 +3168,29 @@ export class ChannelConnectorService {
|
|
|
3156
3168
|
* provider that subscribes per store is registered after commit, at an absolute address; the
|
|
3157
3169
|
* consumer's follow-on work ({@link AfterStoreConnected}) runs after that.
|
|
3158
3170
|
*/
|
|
3171
|
+
/**
|
|
3172
|
+
* Connects a store and runs its follow-on work (subscribe, first import) before answering. For a
|
|
3173
|
+
* provider whose callback must be answered at once, see {@link saveConnectingStore} and
|
|
3174
|
+
* {@link completeConnect}, which this is the two halves of.
|
|
3175
|
+
*/
|
|
3159
3176
|
async connectStore(
|
|
3160
3177
|
orgId: string,
|
|
3161
|
-
input: {
|
|
3162
|
-
|
|
3163
|
-
|
|
3164
|
-
|
|
3165
|
-
|
|
3166
|
-
|
|
3178
|
+
input: { provider: string; credentials: Record<string, unknown>; storeDomain: string; webhookSecret?: string },
|
|
3179
|
+
actor: StoreConnectActor,
|
|
3180
|
+
): Promise<PluginResult<PublicConnectedStore>> {
|
|
3181
|
+
const saved = await this.saveConnectingStore(orgId, input, actor);
|
|
3182
|
+
if (!saved.ok) return saved;
|
|
3183
|
+
return this.completeConnect(orgId, saved.value.id, actor);
|
|
3184
|
+
}
|
|
3185
|
+
|
|
3186
|
+
/**
|
|
3187
|
+
* Writes the store with its credentials in status `connecting`, bound to the actor's vendor, and
|
|
3188
|
+
* nothing else: no call to the store. Reconnecting a store this organization already has refreshes
|
|
3189
|
+
* its row instead of adding one.
|
|
3190
|
+
*/
|
|
3191
|
+
async saveConnectingStore(
|
|
3192
|
+
orgId: string,
|
|
3193
|
+
input: { provider: string; credentials: Record<string, unknown>; storeDomain: string; webhookSecret?: string },
|
|
3167
3194
|
actor: StoreConnectActor,
|
|
3168
3195
|
): Promise<PluginResult<PublicConnectedStore>> {
|
|
3169
3196
|
const connector = this.connectors.get(input.provider);
|
|
@@ -3173,9 +3200,8 @@ export class ChannelConnectorService {
|
|
|
3173
3200
|
if (connector.registerWebhooks && !this.options.publicUrl) {
|
|
3174
3201
|
return PluginErr(`Connector "${input.provider}" subscribes per store and needs the plugin's publicUrl to give it an absolute address.`, "PUBLIC_URL_REQUIRED");
|
|
3175
3202
|
}
|
|
3176
|
-
let store: ConnectedStore;
|
|
3177
3203
|
try {
|
|
3178
|
-
store = await this.transact(async (tx) => {
|
|
3204
|
+
const store = await this.transact(async (tx) => {
|
|
3179
3205
|
const [existing] = await tx.select().from(connectedStores).where(and(
|
|
3180
3206
|
eq(connectedStores.organizationId, orgId),
|
|
3181
3207
|
eq(connectedStores.provider, input.provider),
|
|
@@ -3184,7 +3210,8 @@ export class ChannelConnectorService {
|
|
|
3184
3210
|
const rows = existing
|
|
3185
3211
|
? await tx.update(connectedStores).set({
|
|
3186
3212
|
credentials: input.credentials,
|
|
3187
|
-
status: "
|
|
3213
|
+
status: "connecting",
|
|
3214
|
+
statusReason: null,
|
|
3188
3215
|
...(existing.status !== "connected" ? { catalogWriteEnabled: false } : {}),
|
|
3189
3216
|
webhookSecret: input.webhookSecret ?? existing.webhookSecret ?? crypto.randomUUID(),
|
|
3190
3217
|
updatedAt: new Date(),
|
|
@@ -3194,6 +3221,7 @@ export class ChannelConnectorService {
|
|
|
3194
3221
|
provider: input.provider,
|
|
3195
3222
|
credentials: input.credentials,
|
|
3196
3223
|
storeDomain,
|
|
3224
|
+
status: "connecting",
|
|
3197
3225
|
webhookSecret: input.webhookSecret ?? crypto.randomUUID(),
|
|
3198
3226
|
}).returning();
|
|
3199
3227
|
const written = rows[0] as ConnectedStore | undefined;
|
|
@@ -3201,39 +3229,53 @@ export class ChannelConnectorService {
|
|
|
3201
3229
|
await this.options.bindConnectedStore?.({ db: tx, store: written, actor });
|
|
3202
3230
|
return written;
|
|
3203
3231
|
});
|
|
3232
|
+
return Ok(redactStore(store));
|
|
3204
3233
|
} catch (error) {
|
|
3205
3234
|
return PluginErr(error instanceof Error ? error.message : "The store could not be connected.", error instanceof CommerceNotFoundError ? "NOT_FOUND" : "STORE_CONNECTION_REFUSED");
|
|
3206
3235
|
}
|
|
3236
|
+
}
|
|
3237
|
+
|
|
3238
|
+
/**
|
|
3239
|
+
* The work after the credentials are saved: subscribe the store to its connector's topics (all or
|
|
3240
|
+
* none — a partial subscription is removed), mark it `connected`, then the host's follow-on work.
|
|
3241
|
+
* A failure leaves the store in `error` with the reason the merchant will read, unless the host
|
|
3242
|
+
* already moved it (it disconnects a store it refuses).
|
|
3243
|
+
*/
|
|
3244
|
+
async completeConnect(orgId: string, storeId: string, actor: StoreConnectActor): Promise<PluginResult<PublicConnectedStore>> {
|
|
3245
|
+
const store = await this.getStoreRecord(orgId, storeId);
|
|
3246
|
+
if (!store) return PluginErr("Connected store not found.", "NOT_FOUND");
|
|
3247
|
+
const connector = this.connectors.get(store.provider);
|
|
3248
|
+
if (!connector) return PluginErr(`No connector registered for provider "${store.provider}".`, "NOT_FOUND");
|
|
3249
|
+
const fail = async (message: string, code: string): Promise<PluginResult<never>> => {
|
|
3250
|
+
await this.db.update(connectedStores).set({ statusReason: message, updatedAt: new Date() }).where(eq(connectedStores.id, storeId));
|
|
3251
|
+
await this.db.update(connectedStores).set({ status: "error" }).where(and(eq(connectedStores.id, storeId), inArray(connectedStores.status, ["connecting", "connected"])));
|
|
3252
|
+
return PluginErr(message, code);
|
|
3253
|
+
};
|
|
3207
3254
|
if (connector.registerWebhooks && this.options.publicUrl) {
|
|
3208
|
-
const callbackUrl =
|
|
3209
|
-
const registration = await connector.registerWebhooks(store as ChannelStore, [
|
|
3210
|
-
"products/create",
|
|
3211
|
-
"products/update",
|
|
3212
|
-
"products/delete",
|
|
3213
|
-
"inventory_levels/update",
|
|
3214
|
-
"orders/fulfilled",
|
|
3215
|
-
"orders/partially_fulfilled",
|
|
3216
|
-
"orders/cancelled",
|
|
3217
|
-
"refunds/create",
|
|
3218
|
-
"returns/approve",
|
|
3219
|
-
"returns/decline",
|
|
3220
|
-
"returns/close",
|
|
3221
|
-
"returns/cancel",
|
|
3222
|
-
"app/uninstalled",
|
|
3223
|
-
], callbackUrl);
|
|
3255
|
+
const callbackUrl = this.webhookCallbackUrl(storeId);
|
|
3256
|
+
const registration = await connector.registerWebhooks(store as ChannelStore, [...(connector.webhookTopics ?? [])], callbackUrl);
|
|
3224
3257
|
if (!registration.ok) {
|
|
3225
|
-
await
|
|
3226
|
-
return
|
|
3258
|
+
await connector.unregisterWebhooks?.(store as ChannelStore, callbackUrl);
|
|
3259
|
+
return fail(registration.error.message, registration.error.code === CHANNEL_CREDENTIALS_REJECTED ? CHANNEL_CREDENTIALS_REJECTED : "CONNECTOR_REGISTRATION_FAILED");
|
|
3227
3260
|
}
|
|
3228
3261
|
}
|
|
3262
|
+
const [connected] = await this.db.update(connectedStores).set({ status: "connected", statusReason: null, updatedAt: new Date() })
|
|
3263
|
+
.where(and(eq(connectedStores.id, storeId), eq(connectedStores.status, "connecting"))).returning();
|
|
3264
|
+
const current = (connected ?? store) as ConnectedStore;
|
|
3229
3265
|
if (this.options.afterStoreConnected) {
|
|
3230
3266
|
try {
|
|
3231
|
-
await this.options.afterStoreConnected({ store, actor, connector, services: this.services });
|
|
3267
|
+
await this.options.afterStoreConnected({ store: current, actor, connector, services: this.services });
|
|
3232
3268
|
} catch (error) {
|
|
3233
|
-
return
|
|
3269
|
+
return fail(error instanceof Error ? error.message : "The store connected but its follow-on work failed.", "AFTER_CONNECT_FAILED");
|
|
3234
3270
|
}
|
|
3235
3271
|
}
|
|
3236
|
-
return Ok(redactStore(
|
|
3272
|
+
return Ok(redactStore(current));
|
|
3273
|
+
}
|
|
3274
|
+
|
|
3275
|
+
/** Where a store that subscribes per store delivers its webhooks: absolute, on this deployment's public origin. */
|
|
3276
|
+
webhookCallbackUrl(storeId: string): string {
|
|
3277
|
+
if (!this.options.publicUrl) throw new Error("The channel plugin has no publicUrl to build a webhook address on.");
|
|
3278
|
+
return new URL(`/api/channels/webhooks/${storeId}`, this.options.publicUrl).toString();
|
|
3237
3279
|
}
|
|
3238
3280
|
|
|
3239
3281
|
/** The store with credentials good for a call the host makes itself, e.g. its own Admin API write. */
|
|
@@ -3276,6 +3318,13 @@ export class ChannelConnectorService {
|
|
|
3276
3318
|
}
|
|
3277
3319
|
|
|
3278
3320
|
async disconnectStoreSystem(orgId: string, id: string, redactDomain = false): Promise<PluginResult<PublicConnectedStore>> {
|
|
3321
|
+
const before = await this.getStoreRecord(orgId, id);
|
|
3322
|
+
const connector = before ? this.connectors.get(before.provider) : undefined;
|
|
3323
|
+
if (before && connector?.unregisterWebhooks && this.options.publicUrl && before.status !== "disconnected") {
|
|
3324
|
+
// Best effort: the store is disconnected here whatever the provider answers.
|
|
3325
|
+
const removed = await connector.unregisterWebhooks(before as ChannelStore, this.webhookCallbackUrl(id));
|
|
3326
|
+
if (!removed.ok) console.warn(JSON.stringify({ event: "channel_unregister_webhooks_failed", provider: before.provider, storeId: id, code: removed.error.code, message: removed.error.message }));
|
|
3327
|
+
}
|
|
3279
3328
|
const rows = await this.db
|
|
3280
3329
|
.update(connectedStores)
|
|
3281
3330
|
.set({
|
|
@@ -3292,6 +3341,39 @@ export class ChannelConnectorService {
|
|
|
3292
3341
|
return Ok(redactStore(store));
|
|
3293
3342
|
}
|
|
3294
3343
|
|
|
3344
|
+
/**
|
|
3345
|
+
* Checks the store's webhook subscriptions and key and repairs what it can, when a merchant looks:
|
|
3346
|
+
* there is no scheduled check. At most once per {@link STORE_HEALTH_INTERVAL_MS} per store; inside
|
|
3347
|
+
* that window the last result is answered without calling the store.
|
|
3348
|
+
*/
|
|
3349
|
+
async checkStoreHealth(orgId: string, id: string, context?: StoreReadContext): Promise<PluginResult<StoreHealth & { status: ConnectedStore["status"]; statusReason: string | null; lastEventAt: Date | null; cached: boolean }>> {
|
|
3350
|
+
const reached = await this.reachableStore(orgId, id, context);
|
|
3351
|
+
if (!reached.ok) return reached;
|
|
3352
|
+
const store = reached.value;
|
|
3353
|
+
const answer = (health: StoreHealth, current: ConnectedStore, cached: boolean) => Ok({ ...health, status: current.status, statusReason: current.statusReason, lastEventAt: current.lastEventAt, cached });
|
|
3354
|
+
if (store.health && Date.now() - Date.parse(store.health.checkedAt) < STORE_HEALTH_INTERVAL_MS) return answer(store.health, store, true);
|
|
3355
|
+
const connector = this.connectors.get(store.provider);
|
|
3356
|
+
if (!connector) return PluginErr(`No connector registered for provider "${store.provider}".`, "NOT_FOUND");
|
|
3357
|
+
let health: StoreHealth;
|
|
3358
|
+
const checkedAt = new Date().toISOString();
|
|
3359
|
+
if (store.status !== "connected" && store.status !== "error") {
|
|
3360
|
+
health = { checkedAt, webhooks: "not_applicable", repaired: 0, missing: [], keyValid: false };
|
|
3361
|
+
} else if (connector.webhookHealth && this.options.publicUrl) {
|
|
3362
|
+
const checked = await connector.webhookHealth(store as ChannelStore, this.webhookCallbackUrl(id));
|
|
3363
|
+
health = checked.ok
|
|
3364
|
+
? { checkedAt, webhooks: checked.value.healthy ? (checked.value.repaired > 0 ? "repaired" : "ok") : "failing", repaired: checked.value.repaired, missing: checked.value.missing, keyValid: true }
|
|
3365
|
+
: { checkedAt, webhooks: "failing", repaired: 0, missing: [...(connector.webhookTopics ?? [])], keyValid: checked.error.code !== CHANNEL_CREDENTIALS_REJECTED, error: checked.error.message };
|
|
3366
|
+
} else if (connector.fetchStoreProfile) {
|
|
3367
|
+
// Subscribed per app (Shopify): nothing per store to repair, only the grant to check.
|
|
3368
|
+
const profile = await connector.fetchStoreProfile(store as ChannelStore);
|
|
3369
|
+
health = { checkedAt, webhooks: "not_applicable", repaired: 0, missing: [], keyValid: profile.ok || profile.error.code !== CHANNEL_CREDENTIALS_REJECTED, ...(profile.ok ? {} : { error: profile.error.message }) };
|
|
3370
|
+
} else {
|
|
3371
|
+
health = { checkedAt, webhooks: "not_applicable", repaired: 0, missing: [], keyValid: true };
|
|
3372
|
+
}
|
|
3373
|
+
const [updated] = await this.db.update(connectedStores).set({ health, updatedAt: new Date() }).where(and(eq(connectedStores.organizationId, orgId), eq(connectedStores.id, id))).returning();
|
|
3374
|
+
return answer(health, (updated ?? store) as ConnectedStore, false);
|
|
3375
|
+
}
|
|
3376
|
+
|
|
3295
3377
|
async getStore(orgId: string, id: string, context?: StoreReadContext): Promise<PluginResult<PublicConnectedStore>> {
|
|
3296
3378
|
const store = await this.reachableStore(orgId, id, context);
|
|
3297
3379
|
return store.ok ? Ok(redactStore(store.value)) : store;
|
|
@@ -3426,6 +3508,8 @@ export class ChannelConnectorService {
|
|
|
3426
3508
|
const items = rawItems.map(withDistinctVariantSkus);
|
|
3427
3509
|
const failures: CatalogConvergenceFailure[] = [];
|
|
3428
3510
|
const warnings: string[] = [];
|
|
3511
|
+
const skipped: CatalogFieldSkip[] = [];
|
|
3512
|
+
const conflicts: CatalogFieldConflict[] = [];
|
|
3429
3513
|
const entityByExternalId = new Map<string, string>();
|
|
3430
3514
|
let unchanged = 0;
|
|
3431
3515
|
let updated = 0;
|
|
@@ -3473,6 +3557,8 @@ export class ChannelConnectorService {
|
|
|
3473
3557
|
if (!result.ok) return result;
|
|
3474
3558
|
failures.push(...result.value.failures);
|
|
3475
3559
|
warnings.push(...result.value.warnings);
|
|
3560
|
+
skipped.push(...result.value.skipped);
|
|
3561
|
+
conflicts.push(...result.value.conflicts);
|
|
3476
3562
|
const failedIds = new Set(result.value.failures.map((failure) => failure.externalId));
|
|
3477
3563
|
const survivors = editor.filter((item) => !failedIds.has(item.externalId));
|
|
3478
3564
|
survivors.forEach((item, index) => {
|
|
@@ -3563,6 +3649,8 @@ export class ChannelConnectorService {
|
|
|
3563
3649
|
heroesImported: media.heroesImported,
|
|
3564
3650
|
mediaFailures: media.mediaFailures,
|
|
3565
3651
|
deferredMedia: media.deferredMedia,
|
|
3652
|
+
skipped,
|
|
3653
|
+
conflicts,
|
|
3566
3654
|
warnings,
|
|
3567
3655
|
});
|
|
3568
3656
|
}
|
|
@@ -4944,83 +5032,103 @@ export class ChannelConnectorService {
|
|
|
4944
5032
|
return Ok({ synced, exhausted });
|
|
4945
5033
|
}
|
|
4946
5034
|
|
|
4947
|
-
|
|
5035
|
+
/**
|
|
5036
|
+
* One verified store delivery. The store's connector says what it means ({@link ChannelEvent}); this
|
|
5037
|
+
* acts on the meaning and never on a topic or payload field. A delivery the connector does not act
|
|
5038
|
+
* on is logged as unmapped and answered `processed: false`, never reported as applied.
|
|
5039
|
+
*/
|
|
5040
|
+
async handleWebhook(orgId: string, storeId: string, delivery: ChannelWebhookEvent): Promise<PluginResult<{ processed: boolean; data?: ChannelComplianceData; redacted?: number }>> {
|
|
4948
5041
|
const store = await this.getStoreRecord(orgId, storeId);
|
|
4949
5042
|
if (!store) return PluginErr("Connected store not found.", "NOT_FOUND");
|
|
4950
|
-
const actor = createSystemActor(orgId);
|
|
4951
|
-
const data = event.data as Record<string, unknown>;
|
|
4952
|
-
let skipped: CatalogFieldSkip[] = [];
|
|
4953
|
-
let conflicts: CatalogFieldConflict[] = [];
|
|
4954
|
-
let warnings: string[] = [];
|
|
4955
5043
|
const connector = this.connectors.get(store.provider);
|
|
4956
|
-
|
|
4957
|
-
|
|
4958
|
-
|
|
4959
|
-
|
|
4960
|
-
|
|
4961
|
-
|
|
4962
|
-
|
|
4963
|
-
|
|
4964
|
-
|
|
4965
|
-
|
|
4966
|
-
|
|
4967
|
-
|
|
4968
|
-
|
|
4969
|
-
|
|
4970
|
-
|
|
4971
|
-
|
|
5044
|
+
const decoded = connector?.decodeWebhook ? await connector.decodeWebhook(store as ChannelStore, delivery) : Ok<ChannelEvent[]>([]);
|
|
5045
|
+
if (!decoded.ok) return PluginErr(decoded.error.message, decoded.error.code);
|
|
5046
|
+
await this.db.update(connectedStores).set({ lastEventAt: new Date() }).where(and(eq(connectedStores.organizationId, orgId), eq(connectedStores.id, storeId)));
|
|
5047
|
+
if (decoded.value.length === 0) {
|
|
5048
|
+
console.warn(JSON.stringify({ event: "channel_webhook_unmapped", provider: store.provider, storeId, topic: delivery.type, deliveryId: delivery.id }));
|
|
5049
|
+
return Ok({ processed: false });
|
|
5050
|
+
}
|
|
5051
|
+
const actor = createSystemActor(orgId);
|
|
5052
|
+
const outcome: { processed: boolean; data?: ChannelComplianceData; redacted?: number } = { processed: true };
|
|
5053
|
+
const report: ChannelEventReport = { skipped: [], conflicts: [], warnings: [] };
|
|
5054
|
+
for (const event of decoded.value) {
|
|
5055
|
+
const applied = await this.applyChannelEvent(orgId, store, connector, event, actor, report);
|
|
5056
|
+
if (!applied.ok) return applied;
|
|
5057
|
+
if (applied.value.data) outcome.data = applied.value.data;
|
|
5058
|
+
if (applied.value.redacted !== undefined) outcome.redacted = applied.value.redacted;
|
|
5059
|
+
}
|
|
5060
|
+
if (report.skipped.length > 0 || report.conflicts.length > 0 || report.warnings.length > 0) {
|
|
5061
|
+
const merged = {
|
|
5062
|
+
...(store.lastReconcileReport ?? {}),
|
|
5063
|
+
...(report.skipped.length > 0 ? { skipped: uniqueSkipped(report.skipped) } : {}),
|
|
5064
|
+
...(report.conflicts.length > 0 ? { conflicts: report.conflicts } : {}),
|
|
5065
|
+
...(report.warnings.length > 0 ? { warnings: report.warnings } : {}),
|
|
5066
|
+
};
|
|
5067
|
+
await this.db.update(connectedStores).set({ lastReconcileReport: merged, updatedAt: new Date() }).where(and(
|
|
5068
|
+
eq(connectedStores.organizationId, orgId),
|
|
5069
|
+
eq(connectedStores.id, storeId),
|
|
5070
|
+
));
|
|
5071
|
+
}
|
|
5072
|
+
return Ok(outcome);
|
|
5073
|
+
}
|
|
5074
|
+
|
|
5075
|
+
private async applyChannelEvent(
|
|
5076
|
+
orgId: string,
|
|
5077
|
+
store: ConnectedStore,
|
|
5078
|
+
connector: ChannelConnector | undefined,
|
|
5079
|
+
event: ChannelEvent,
|
|
5080
|
+
actor: Actor,
|
|
5081
|
+
report: ChannelEventReport,
|
|
5082
|
+
): Promise<PluginResult<{ data?: ChannelComplianceData; redacted?: number }>> {
|
|
5083
|
+
const storeId = store.id;
|
|
5084
|
+
switch (event.kind) {
|
|
5085
|
+
case "product.changed": {
|
|
5086
|
+
// A webhook is a notification, not a snapshot. The products are read fresh and converged
|
|
5087
|
+
// exactly as an import page would be, creating one this store has never mapped.
|
|
5088
|
+
if (!connector?.fetchCatalogItems) return PluginErr(`Connector "${store.provider}" cannot re-read products, so a product change cannot be applied.`, "CONNECTOR_CANNOT_REREAD");
|
|
5089
|
+
const read = await connector.fetchCatalogItems(store as ChannelStore, event.externalIds);
|
|
5090
|
+
if (!read.ok) return PluginErr(read.error.message, read.error.code);
|
|
5091
|
+
const found = new Set(read.value.map((item) => item.externalId));
|
|
5092
|
+
for (const externalId of event.externalIds.filter((id) => !found.has(id))) {
|
|
5093
|
+
// Gone between the delivery and the read: the same as a delete.
|
|
5094
|
+
const archived = await this.archiveMappedProduct(orgId, storeId, externalId, actor);
|
|
5095
|
+
if (!archived.ok) return archived;
|
|
5096
|
+
report.skipped.push(...archived.value);
|
|
5097
|
+
}
|
|
5098
|
+
if (read.value.length === 0) return Ok({});
|
|
5099
|
+
const converged = await this.convergeCatalogPage(orgId, storeId, read.value, actor);
|
|
4972
5100
|
if (!converged.ok) return converged;
|
|
4973
5101
|
const [failure] = converged.value.failures;
|
|
4974
5102
|
if (failure) return PluginErr(`Product ${failure.externalId} could not be converged: ${failure.error}`, "CONVERGENCE_FAILED");
|
|
4975
|
-
|
|
5103
|
+
report.skipped.push(...converged.value.skipped);
|
|
5104
|
+
report.conflicts.push(...converged.value.conflicts);
|
|
5105
|
+
report.warnings.push(...converged.value.warnings);
|
|
4976
5106
|
if (converged.value.entityIds.length > 0) {
|
|
4977
5107
|
await this.options.onStoreCatalogChanged?.({ orgId, storeId, entityIds: [...converged.value.entityIds], convergence: converged.value });
|
|
4978
5108
|
}
|
|
5109
|
+
return Ok({});
|
|
4979
5110
|
}
|
|
4980
|
-
|
|
4981
|
-
|
|
4982
|
-
|
|
4983
|
-
|
|
4984
|
-
|
|
4985
|
-
if (!converged.ok) return converged;
|
|
4986
|
-
skipped = converged.value.skipped;
|
|
4987
|
-
conflicts = converged.value.conflicts;
|
|
4988
|
-
warnings = converged.value.warnings;
|
|
4989
|
-
}
|
|
4990
|
-
} else if (event.type === "products/delete") {
|
|
4991
|
-
const archived = await this.archiveMappedProduct(orgId, storeId, String(data.id ?? data.product_id ?? ""), actor);
|
|
4992
|
-
if (!archived.ok) return archived;
|
|
4993
|
-
skipped = archived.value;
|
|
4994
|
-
} else if (event.type === "inventory_levels/update") {
|
|
4995
|
-
const available = Number(data.available ?? data.stock_quantity ?? 0);
|
|
4996
|
-
// Shopify names an INVENTORY ITEM, whose id is not the variant id the channel map is keyed by;
|
|
4997
|
-
// looked up by it, every Shopify stock webhook found no mapping and was dropped. The variant is
|
|
4998
|
-
// resolved through the inventory item id its import recorded; a provider that sends a variant
|
|
4999
|
-
// id keeps the plain lookup.
|
|
5000
|
-
const inventoryItemId = data.inventory_item_id !== undefined && data.inventory_item_id !== null ? String(data.inventory_item_id) : null;
|
|
5001
|
-
const byInventoryItem = inventoryItemId === null ? null : await this.variantForInventoryItem(orgId, storeId, inventoryItemId);
|
|
5002
|
-
if (byInventoryItem !== null) {
|
|
5003
|
-
// The delivery's `available` is ONE location's count. The variant's stock is the sum the
|
|
5004
|
-
// connector reads, so it is read fresh rather than taken from the payload.
|
|
5005
|
-
let quantity = available;
|
|
5006
|
-
if (connector) {
|
|
5007
|
-
const levels = await connector.fetchInventory(store as ChannelStore, [byInventoryItem.externalId]);
|
|
5008
|
-
if (!levels.ok) return PluginErr(levels.error.message, levels.error.code);
|
|
5009
|
-
const fresh = levels.value.find((entry) => entry.externalId === byInventoryItem.externalId);
|
|
5010
|
-
if (fresh) quantity = fresh.available;
|
|
5111
|
+
case "product.deleted": {
|
|
5112
|
+
for (const externalId of event.externalIds) {
|
|
5113
|
+
const archived = await this.archiveMappedProduct(orgId, storeId, externalId, actor);
|
|
5114
|
+
if (!archived.ok) return archived;
|
|
5115
|
+
report.skipped.push(...archived.value);
|
|
5011
5116
|
}
|
|
5012
|
-
|
|
5013
|
-
} else {
|
|
5014
|
-
const externalId = String(data.variation_id ?? data.product_id ?? inventoryItemId ?? "");
|
|
5015
|
-
await this.setMappedInventory(orgId, storeId, externalId, available, actor);
|
|
5117
|
+
return Ok({});
|
|
5016
5118
|
}
|
|
5017
|
-
|
|
5018
|
-
|
|
5019
|
-
|
|
5119
|
+
case "inventory.changed": {
|
|
5120
|
+
for (const level of event.levels) await this.setMappedInventory(orgId, storeId, level.externalId, level.available, actor);
|
|
5121
|
+
return Ok({});
|
|
5122
|
+
}
|
|
5123
|
+
case "order.cancelled":
|
|
5124
|
+
case "order.fulfilled": {
|
|
5125
|
+
const orderId = await this.orderForRemote(orgId, storeId, event.remoteOrderId);
|
|
5126
|
+
if (!orderId) return Ok({});
|
|
5020
5127
|
const ordersService = this.services.orders as { addNote(orderId: string, input: { body: string }, actor: Actor): Promise<{ ok: boolean; error?: { message: string } }>; changeStatus(input: { orderId: string; newStatus: "processing" | "fulfilled" | "partially_fulfilled" | "cancelled"; reason: string }, actor: Actor): Promise<{ ok: boolean }> };
|
|
5021
|
-
const
|
|
5128
|
+
const what = event.kind === "order.cancelled" ? "cancelled" : event.partial ? "partially fulfilled" : "fulfilled";
|
|
5129
|
+
const note = await ordersService.addNote(orderId, { body: `The store ${what} its order ${event.remoteOrderId}.` }, actor);
|
|
5022
5130
|
if (!note.ok) return PluginErr(note.error?.message ?? "Could not add channel order note.");
|
|
5023
|
-
if (event.
|
|
5131
|
+
if (event.kind === "order.cancelled") {
|
|
5024
5132
|
// The store cancelled: the platform follows, under a reason the cancel hook recognises, so
|
|
5025
5133
|
// it does not turn round and cancel at the store again. An order already closed, or one the
|
|
5026
5134
|
// machine cannot cancel (shipped), keeps its status; the note above records the delivery.
|
|
@@ -5028,60 +5136,42 @@ export class ChannelConnectorService {
|
|
|
5028
5136
|
if (order && !["cancelled", "refunded"].includes(order.status)) {
|
|
5029
5137
|
await ordersService.changeStatus({ orderId, newStatus: "cancelled", reason: CHANNEL_ORDER_CANCELLED_REASON }, actor);
|
|
5030
5138
|
}
|
|
5139
|
+
return Ok({});
|
|
5031
5140
|
}
|
|
5032
|
-
|
|
5033
|
-
|
|
5034
|
-
|
|
5035
|
-
|
|
5036
|
-
|
|
5037
|
-
|
|
5038
|
-
|
|
5039
|
-
|
|
5040
|
-
|
|
5041
|
-
await ordersService.changeStatus({ orderId, newStatus: target, reason: "channel_order_fulfilled" }, actor);
|
|
5042
|
-
}
|
|
5141
|
+
// The parcels first, so whatever the status move announces (a shipped email) can read them.
|
|
5142
|
+
const recorded = await this.recordChannelFulfillments(orgId, storeId, orderId, event.shipments, actor);
|
|
5143
|
+
if (!recorded.ok) return recorded;
|
|
5144
|
+
const target = event.partial ? "partially_fulfilled" : "fulfilled";
|
|
5145
|
+
const [order] = await this.db.select({ status: orders.status }).from(orders).where(and(eq(orders.organizationId, orgId), eq(orders.id, orderId)));
|
|
5146
|
+
if (order?.status === "confirmed") await ordersService.changeStatus({ orderId, newStatus: "processing", reason: "channel_order_fulfilled" }, actor);
|
|
5147
|
+
const [after] = await this.db.select({ status: orders.status }).from(orders).where(and(eq(orders.organizationId, orgId), eq(orders.id, orderId)));
|
|
5148
|
+
if (after?.status === "processing" || (target === "fulfilled" && after?.status === "partially_fulfilled")) {
|
|
5149
|
+
await ordersService.changeStatus({ orderId, newStatus: target, reason: "channel_order_fulfilled" }, actor);
|
|
5043
5150
|
}
|
|
5151
|
+
return Ok({});
|
|
5044
5152
|
}
|
|
5045
|
-
|
|
5046
|
-
|
|
5047
|
-
|
|
5048
|
-
|
|
5049
|
-
|
|
5050
|
-
|
|
5153
|
+
case "refund.created": {
|
|
5154
|
+
const refund = await this.createRefundRequest(orgId, store, event, actor);
|
|
5155
|
+
return refund.ok ? Ok({}) : refund;
|
|
5156
|
+
}
|
|
5157
|
+
case "return.updated": {
|
|
5158
|
+
// The store's answer to a return the marketplace asked for. A return it never asked for is ignored.
|
|
5159
|
+
await this.db.update(channelReturns).set({ status: event.status, updatedAt: new Date() }).where(and(eq(channelReturns.organizationId, orgId), eq(channelReturns.storeId, storeId), eq(channelReturns.remoteReturnId, event.remoteReturnId)));
|
|
5160
|
+
return Ok({});
|
|
5161
|
+
}
|
|
5162
|
+
case "connection.revoked": {
|
|
5163
|
+
const disconnected = await this.disconnectStoreSystem(orgId, storeId);
|
|
5164
|
+
return disconnected.ok ? Ok({}) : disconnected;
|
|
5165
|
+
}
|
|
5166
|
+
case "compliance.request": {
|
|
5167
|
+
if (event.request === "customer_data") {
|
|
5168
|
+
const dataRequest = await this.channelCustomerDataRequest(orgId, storeId, event.data);
|
|
5169
|
+
return dataRequest.ok ? Ok({ data: dataRequest.value }) : dataRequest;
|
|
5170
|
+
}
|
|
5171
|
+
const redacted = event.request === "customer_redact" ? await this.redactCustomerData(orgId, storeId, event.data) : await this.redactShopData(orgId, storeId);
|
|
5172
|
+
return redacted.ok ? Ok({ redacted: redacted.value }) : redacted;
|
|
5051
5173
|
}
|
|
5052
|
-
} else if (event.type === "refunds/create") {
|
|
5053
|
-
const refund = await this.createRefundRequest(orgId, store, data, actor);
|
|
5054
|
-
if (!refund.ok) return refund;
|
|
5055
|
-
} else if (event.type === "customers/data_request") {
|
|
5056
|
-
const dataRequest = await this.channelCustomerDataRequest(orgId, storeId, data);
|
|
5057
|
-
if (!dataRequest.ok) return dataRequest;
|
|
5058
|
-
return Ok({ processed: true, data: dataRequest.value });
|
|
5059
|
-
} else if (event.type === "customers/redact") {
|
|
5060
|
-
const redacted = await this.redactCustomerData(orgId, storeId, data);
|
|
5061
|
-
if (!redacted.ok) return redacted;
|
|
5062
|
-
return Ok({ processed: true, redacted: redacted.value });
|
|
5063
|
-
} else if (event.type === "shop/redact") {
|
|
5064
|
-
const redacted = await this.redactShopData(orgId, storeId);
|
|
5065
|
-
if (!redacted.ok) return redacted;
|
|
5066
|
-
return Ok({ processed: true, redacted: redacted.value });
|
|
5067
|
-
} else if (event.type === "app/uninstalled") {
|
|
5068
|
-
const disconnected = await this.disconnectStoreSystem(orgId, storeId);
|
|
5069
|
-
if (!disconnected.ok) return disconnected;
|
|
5070
|
-
return Ok({ processed: true });
|
|
5071
|
-
}
|
|
5072
|
-
if (skipped.length > 0 || conflicts.length > 0 || warnings.length > 0) {
|
|
5073
|
-
const report = {
|
|
5074
|
-
...(store.lastReconcileReport ?? {}),
|
|
5075
|
-
...(skipped.length > 0 ? { skipped: uniqueSkipped(skipped) } : {}),
|
|
5076
|
-
...(conflicts.length > 0 ? { conflicts } : {}),
|
|
5077
|
-
...(warnings.length > 0 ? { warnings } : {}),
|
|
5078
|
-
};
|
|
5079
|
-
await this.db.update(connectedStores).set({ lastReconcileReport: report, updatedAt: new Date() }).where(and(
|
|
5080
|
-
eq(connectedStores.organizationId, orgId),
|
|
5081
|
-
eq(connectedStores.id, storeId),
|
|
5082
|
-
));
|
|
5083
5174
|
}
|
|
5084
|
-
return Ok({ processed: true });
|
|
5085
5175
|
}
|
|
5086
5176
|
|
|
5087
5177
|
private complianceEmail(data: Record<string, unknown>): string | undefined {
|
|
@@ -5138,10 +5228,53 @@ export class ChannelConnectorService {
|
|
|
5138
5228
|
return Ok(rows.filter((row) => row.customerData !== null).length);
|
|
5139
5229
|
}
|
|
5140
5230
|
|
|
5141
|
-
|
|
5142
|
-
|
|
5143
|
-
|
|
5144
|
-
|
|
5231
|
+
/**
|
|
5232
|
+
* For a read point (a shopper opening their order): each of the order's store orders not read for
|
|
5233
|
+
* {@link REMOTE_ORDER_REFRESH_MS} is queued for one refresh, so a store-side cancel, shipment or
|
|
5234
|
+
* refund whose delivery never arrived still reaches the platform. No schedule: someone looked.
|
|
5235
|
+
* Answers how many were queued.
|
|
5236
|
+
*/
|
|
5237
|
+
async refreshStaleRemoteOrders(orgId: string, orderId: string, jobs: JobsAdapter): Promise<number> {
|
|
5238
|
+
const [order] = await this.db.select({ status: orders.status }).from(orders).where(and(eq(orders.organizationId, orgId), eq(orders.id, orderId)));
|
|
5239
|
+
if (!order || ["cancelled", "refunded"].includes(order.status)) return 0;
|
|
5240
|
+
const cutoff = new Date(Date.now() - REMOTE_ORDER_REFRESH_MS);
|
|
5241
|
+
// The claim and the window in one statement: two readers at once queue one refresh.
|
|
5242
|
+
const claimed = await this.db.update(channelOrderExports).set({ remoteCheckedAt: new Date() }).where(and(
|
|
5243
|
+
eq(channelOrderExports.organizationId, orgId),
|
|
5244
|
+
eq(channelOrderExports.orderId, orderId),
|
|
5245
|
+
inArray(channelOrderExports.state, ["exported", "confirmed"]),
|
|
5246
|
+
sql`${channelOrderExports.remoteOrderId} is not null`,
|
|
5247
|
+
or(isNull(channelOrderExports.remoteCheckedAt), lte(channelOrderExports.remoteCheckedAt, cutoff)),
|
|
5248
|
+
)).returning({ storeId: channelOrderExports.storeId, remoteOrderId: channelOrderExports.remoteOrderId });
|
|
5249
|
+
let queued = 0;
|
|
5250
|
+
for (const row of claimed) {
|
|
5251
|
+
if (!row.remoteOrderId) continue;
|
|
5252
|
+
const store = await this.getStoreRecord(orgId, row.storeId);
|
|
5253
|
+
if (!store || store.status !== "connected" || !this.connectors.get(store.provider)?.orderEvents) continue;
|
|
5254
|
+
await jobs.enqueue("channel/refresh-order", { orgId, storeId: row.storeId, remoteOrderId: row.remoteOrderId }, { organizationId: orgId, concurrencyKey: `webhook:${row.storeId}`, supersedes: false });
|
|
5255
|
+
queued += 1;
|
|
5256
|
+
}
|
|
5257
|
+
return queued;
|
|
5258
|
+
}
|
|
5259
|
+
|
|
5260
|
+
/** The store's current state of one order we pushed, applied as if its delivery had arrived. */
|
|
5261
|
+
async refreshRemoteOrder(orgId: string, storeId: string, remoteOrderId: string): Promise<PluginResult<{ events: number }>> {
|
|
5262
|
+
const store = await this.getStoreRecord(orgId, storeId);
|
|
5263
|
+
if (!store) return PluginErr("Connected store not found.", "NOT_FOUND");
|
|
5264
|
+
const connector = this.connectors.get(store.provider);
|
|
5265
|
+
if (!connector?.orderEvents) return Ok({ events: 0 });
|
|
5266
|
+
const events = await connector.orderEvents(store as ChannelStore, remoteOrderId);
|
|
5267
|
+
if (!events.ok) return PluginErr(events.error.message, events.error.code);
|
|
5268
|
+
const report: ChannelEventReport = { skipped: [], conflicts: [], warnings: [] };
|
|
5269
|
+
const actor = createSystemActor(orgId);
|
|
5270
|
+
for (const event of events.value) {
|
|
5271
|
+
const applied = await this.applyChannelEvent(orgId, store, connector, event, actor, report);
|
|
5272
|
+
if (!applied.ok) return applied;
|
|
5273
|
+
}
|
|
5274
|
+
return Ok({ events: events.value.length });
|
|
5275
|
+
}
|
|
5276
|
+
|
|
5277
|
+
private async orderForRemote(orgId: string, storeId: string, remoteOrderId: string): Promise<string | undefined> {
|
|
5145
5278
|
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)));
|
|
5146
5279
|
return rows[0]?.orderId;
|
|
5147
5280
|
}
|
|
@@ -5167,192 +5300,20 @@ export class ChannelConnectorService {
|
|
|
5167
5300
|
await inventory.setAbsolute({ entityId, ...(variantId ? { variantId } : {}), quantity: Math.max(0, Math.floor(quantity)), reason: "Inventory webhook sync" }, actor);
|
|
5168
5301
|
}
|
|
5169
5302
|
|
|
5170
|
-
|
|
5171
|
-
|
|
5172
|
-
const
|
|
5173
|
-
|
|
5174
|
-
|
|
5175
|
-
eq(channelEntityMap.organizationId, orgId),
|
|
5176
|
-
eq(channelEntityMap.storeId, storeId),
|
|
5177
|
-
eq(channelEntityMap.kind, "variant"),
|
|
5178
|
-
sql`${variants.metadata}->>'inventoryItemId' = ${inventoryItemId}`,
|
|
5179
|
-
))
|
|
5180
|
-
.limit(2);
|
|
5181
|
-
const [only] = rows;
|
|
5182
|
-
return rows.length === 1 && only !== undefined && only.variantId !== null ? { entityId: only.entityId, variantId: only.variantId, externalId: only.externalId } : null;
|
|
5183
|
-
}
|
|
5184
|
-
|
|
5185
|
-
private async convergeCatalogItem(
|
|
5186
|
-
orgId: string,
|
|
5187
|
-
storeId: string,
|
|
5188
|
-
entityId: string,
|
|
5189
|
-
data: Record<string, unknown>,
|
|
5190
|
-
actor: Actor,
|
|
5191
|
-
): Promise<PluginResult<{ skipped: CatalogFieldSkip[]; conflicts: CatalogFieldConflict[]; warnings: string[] }>> {
|
|
5192
|
-
const product = data.product && typeof data.product === "object" ? data.product as Record<string, unknown> : data;
|
|
5193
|
-
const remoteMetadata = product.metadata && typeof product.metadata === "object" && !Array.isArray(product.metadata)
|
|
5194
|
-
? product.metadata as Record<string, unknown>
|
|
5195
|
-
: {};
|
|
5196
|
-
const [mapping] = await this.db.select().from(channelEntityMap).where(and(
|
|
5197
|
-
eq(channelEntityMap.organizationId, orgId),
|
|
5198
|
-
eq(channelEntityMap.storeId, storeId),
|
|
5199
|
-
eq(channelEntityMap.kind, "entity"),
|
|
5200
|
-
eq(channelEntityMap.entityId, entityId),
|
|
5201
|
-
));
|
|
5202
|
-
const [entity] = await this.db.select().from(sellableEntities).where(and(
|
|
5203
|
-
eq(sellableEntities.organizationId, orgId),
|
|
5204
|
-
eq(sellableEntities.id, entityId),
|
|
5205
|
-
));
|
|
5206
|
-
if (!mapping || !entity) return Ok({ skipped: [], conflicts: [], warnings: [] });
|
|
5207
|
-
const [currentAttribute] = await this.db.select().from(sellableAttributes).where(and(
|
|
5208
|
-
eq(sellableAttributes.entityId, entityId),
|
|
5209
|
-
eq(sellableAttributes.locale, "en"),
|
|
5210
|
-
));
|
|
5211
|
-
const title = typeof product.title === "string" ? product.title : currentAttribute?.title ?? entity.slug;
|
|
5212
|
-
const description = product.description !== undefined
|
|
5213
|
-
? String(product.description)
|
|
5214
|
-
: currentAttribute?.description ?? undefined;
|
|
5215
|
-
const status = typeof product.status === "string" && ["draft", "active", "archived", "discontinued"].includes(product.status)
|
|
5216
|
-
? product.status as NonNullable<ChannelCatalogItem["status"]>
|
|
5217
|
-
: undefined;
|
|
5218
|
-
const customFields = product.customFields && typeof product.customFields === "object" && !Array.isArray(product.customFields)
|
|
5219
|
-
? product.customFields as Record<string, unknown>
|
|
5220
|
-
: undefined;
|
|
5221
|
-
const images = Array.isArray(product.images)
|
|
5222
|
-
? product.images.flatMap((raw) => {
|
|
5223
|
-
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return [];
|
|
5224
|
-
const image = raw as Record<string, unknown>;
|
|
5225
|
-
const role = typeof image.role === "string" ? pushCatalogImageRole(image.role) : undefined;
|
|
5226
|
-
const url = typeof image.url === "string" ? image.url : typeof image.src === "string" ? image.src : undefined;
|
|
5227
|
-
return role && url ? [{ role, url }] : [];
|
|
5228
|
-
})
|
|
5229
|
-
: [];
|
|
5230
|
-
const remoteItem = {
|
|
5231
|
-
externalId: mapping.externalId,
|
|
5232
|
-
slug: typeof product.slug === "string" ? product.slug : entity.slug,
|
|
5233
|
-
title,
|
|
5234
|
-
...(description !== undefined ? { description } : {}),
|
|
5235
|
-
...(status !== undefined ? { status } : {}),
|
|
5236
|
-
attributes: [{ locale: "en", title, ...(description !== undefined ? { description } : {}) }],
|
|
5237
|
-
...(Object.keys(remoteMetadata).length > 0 ? { metadata: remoteMetadata } : {}),
|
|
5238
|
-
...(customFields !== undefined ? { customFields } : {}),
|
|
5239
|
-
...(images.length > 0 ? { images } : {}),
|
|
5240
|
-
variants: [],
|
|
5241
|
-
} as ChannelCatalogItem & { customFields?: Record<string, unknown> };
|
|
5242
|
-
const fieldPaths: FieldPath[] = [];
|
|
5243
|
-
if (typeof product.slug === "string") fieldPaths.push("entity.slug");
|
|
5244
|
-
if (status !== undefined) fieldPaths.push("entity.status");
|
|
5245
|
-
for (const key of Object.keys(remoteMetadata)) {
|
|
5246
|
-
const path = `entity.metadata.${key}`;
|
|
5247
|
-
if (isValidFieldPath(path)) fieldPaths.push(path);
|
|
5248
|
-
}
|
|
5249
|
-
if (typeof product.title === "string") fieldPaths.push("attributes.en.title");
|
|
5250
|
-
if (product.description !== undefined) fieldPaths.push("attributes.en.description");
|
|
5251
|
-
for (const [name, locales] of Object.entries(customFields ?? {})) {
|
|
5252
|
-
if (!locales || typeof locales !== "object" || Array.isArray(locales)) continue;
|
|
5253
|
-
for (const locale of Object.keys(locales as Record<string, unknown>)) {
|
|
5254
|
-
const path = `customFields.${name}.${locale}`;
|
|
5255
|
-
if (isValidFieldPath(path)) fieldPaths.push(path);
|
|
5256
|
-
}
|
|
5257
|
-
}
|
|
5258
|
-
for (const image of images) fieldPaths.push(`media.${image.role}`);
|
|
5259
|
-
const ownershipBeforeSeed = await this.catalog.resolveFieldOwners(entityId, storeId);
|
|
5260
|
-
const seedPaths = fieldPaths.filter((path) => !ownershipBeforeSeed.has(path));
|
|
5261
|
-
const seeded = await this.catalog.seedImportedFieldOwnership(entityId, storeId, seedPaths);
|
|
5262
|
-
if (!seeded.ok) return PluginErr(seeded.error.message);
|
|
5263
|
-
for (const path of seedPaths) ownershipBeforeSeed.set(path, "store");
|
|
5264
|
-
const owners = ownershipBeforeSeed;
|
|
5265
|
-
const remoteHash = hash(product);
|
|
5266
|
-
const outboundEcho = this.isOutboundEcho(mapping, remoteItem);
|
|
5267
|
-
const shared = await this.detectSharedConflicts(
|
|
5268
|
-
entityId, storeId, entity, mapping, remoteItem, owners, fieldPaths, remoteHash,
|
|
5269
|
-
outboundEcho ? { certifiedPaths: new Set(mapping.outboundFieldPaths ?? []) } : undefined,
|
|
5270
|
-
);
|
|
5271
|
-
const persistedConflicts = await this.persistCatalogConflicts(orgId, shared.conflicts, requireUserId(actor));
|
|
5272
|
-
if (!persistedConflicts.ok) return persistedConflicts;
|
|
5273
|
-
const owned = this.filterOwnedFieldsAtPaths(remoteItem, owners, fieldPaths);
|
|
5274
|
-
const heldPaths = [...new Set([...(mapping.heldFieldPaths ?? []), ...shared.paths])];
|
|
5275
|
-
// Same revocation as the reconcile path: a newly held path cancels any force
|
|
5276
|
-
// left from an earlier resolution, so a webhook-raised conflict cannot be
|
|
5277
|
-
// pre-empted by an operator's answer to a previous one.
|
|
5278
|
-
const survivingForcedPaths = (mapping.forcedPushFieldPaths ?? []).filter(
|
|
5279
|
-
(path) => !heldPaths.includes(path),
|
|
5280
|
-
);
|
|
5281
|
-
const held = this.filterConflictingFields(owned.writable, heldPaths);
|
|
5282
|
-
const blockedPaths = new Set<FieldPath>([
|
|
5283
|
-
...owned.skipped,
|
|
5284
|
-
...heldPaths,
|
|
5285
|
-
...(!fieldPaths.includes("attributes.en.title") ? ["attributes.en.title" as FieldPath] : []),
|
|
5286
|
-
]);
|
|
5287
|
-
const skipped = owned.skipped.map((fieldPath) => ({ entityId, fieldPath }));
|
|
5288
|
-
const conflicts = shared.conflicts.map(({ platformValue: _platformValue, storeValue: _storeValue, ...conflict }) => conflict);
|
|
5289
|
-
const warnings = conflicts.map((conflict) => `Held shared field conflict for entity "${conflict.entityId}", store "${conflict.storeId}", field "${conflict.fieldPath}" (local ${conflict.localValueSummary}, remote ${conflict.remoteValueSummary}).`);
|
|
5290
|
-
const writable = held.writable;
|
|
5291
|
-
const updateInput: {
|
|
5292
|
-
slug?: string;
|
|
5293
|
-
metadata?: Record<string, unknown>;
|
|
5294
|
-
status?: string;
|
|
5295
|
-
isVisible?: boolean;
|
|
5296
|
-
} = {};
|
|
5297
|
-
if (fieldPaths.includes("entity.slug") && ownerAllows(owners, "entity.slug") && !blockedPaths.has("entity.slug") && typeof writable.slug === "string") {
|
|
5298
|
-
const resolved = (await this.resolveStoreSlugs(orgId, storeId, [writable.slug])).get(writable.slug);
|
|
5299
|
-
const slug = resolved ? this.slugToKeep(entity.slug, resolved) : writable.slug;
|
|
5300
|
-
if (entity.slug !== slug) updateInput.slug = slug;
|
|
5301
|
-
}
|
|
5302
|
-
if (Object.keys(writable.metadata ?? {}).length > 0) {
|
|
5303
|
-
const remoteEntityMetadata = mergeMetadata(entity.metadata, writable.metadata ?? {});
|
|
5304
|
-
if (hash(remoteEntityMetadata) !== hash(entity.metadata ?? {})) updateInput.metadata = remoteEntityMetadata;
|
|
5305
|
-
}
|
|
5306
|
-
if (fieldPaths.includes("entity.status") && ownerAllows(owners, "entity.status") && !blockedPaths.has("entity.status") && typeof writable.status === "string" && writable.status !== entity.status) {
|
|
5307
|
-
updateInput.status = writable.status;
|
|
5308
|
-
updateInput.isVisible = writable.status === "active";
|
|
5309
|
-
}
|
|
5310
|
-
if (Object.keys(updateInput).length > 0) {
|
|
5311
|
-
const updated = await this.catalog.update(entityId, updateInput, actor, CHANNEL_CONVERGENCE_CTX);
|
|
5312
|
-
if (!updated.ok) return PluginErr(updated.error.message);
|
|
5313
|
-
}
|
|
5314
|
-
const attributes = await this.setCatalogAttributesIfWritable(entityId, writable, actor, blockedPaths, CHANNEL_CONVERGENCE_CTX);
|
|
5315
|
-
if (!attributes.ok) return attributes;
|
|
5316
|
-
const levels = Array.isArray(product.variants) ? product.variants as Array<Record<string, unknown>> : [];
|
|
5317
|
-
for (const variant of levels) {
|
|
5318
|
-
const externalId = String(variant.id ?? variant.variation_id ?? "");
|
|
5319
|
-
const available = variant.inventory_quantity ?? variant.stock_quantity;
|
|
5320
|
-
if (externalId && available !== undefined) await this.setMappedInventory(orgId, storeId, externalId, Number(available), actor);
|
|
5321
|
-
}
|
|
5322
|
-
const revisionMarkers = await this.catalog.repository.findRevisionMarkers(entityId);
|
|
5323
|
-
const lastSyncedAt = revisionMarkers.at(-1)?.createdAt ?? mapping.lastSyncedAt;
|
|
5324
|
-
await this.db.update(channelEntityMap).set({
|
|
5325
|
-
syncHash: remoteHash,
|
|
5326
|
-
lastSyncedAt,
|
|
5327
|
-
heldFieldPaths: heldPaths,
|
|
5328
|
-
forcedPushFieldPaths: survivingForcedPaths,
|
|
5329
|
-
}).where(eq(channelEntityMap.id, mapping.id));
|
|
5330
|
-
return Ok({ skipped, conflicts, warnings });
|
|
5331
|
-
}
|
|
5332
|
-
|
|
5333
|
-
private async createRefundRequest(orgId: string, store: ConnectedStore, data: Record<string, unknown>, actor: Actor): Promise<PluginResult<ChannelRefundRequest>> {
|
|
5334
|
-
const remoteRefundId = String(data.id ?? data.refund_id ?? "");
|
|
5335
|
-
const orderId = await this.resolveOrderId(orgId, store.id, data);
|
|
5336
|
-
if (!remoteRefundId || !orderId) return PluginErr("Refund webhook is missing a mapped order or refund id.", "REFUND_MAPPING_MISSING");
|
|
5303
|
+
private async createRefundRequest(orgId: string, store: ConnectedStore, event: Extract<ChannelEvent, { kind: "refund.created" }>, actor: Actor): Promise<PluginResult<ChannelRefundRequest | null>> {
|
|
5304
|
+
const { remoteRefundId } = event;
|
|
5305
|
+
const orderId = await this.orderForRemote(orgId, store.id, event.remoteOrderId);
|
|
5306
|
+
// A refund on an order this store never received from us is the store's own business.
|
|
5307
|
+
if (!orderId) return Ok(null);
|
|
5337
5308
|
const existing = await this.db.select().from(channelRefundRequests).where(and(eq(channelRefundRequests.storeId, store.id), eq(channelRefundRequests.remoteRefundId, remoteRefundId)));
|
|
5338
5309
|
if (existing[0]) return Ok(existing[0] as ChannelRefundRequest);
|
|
5339
|
-
// Shopify names the refunded lines in `refund_line_items`, each with its order line under
|
|
5340
|
-
// `line_item`; other providers send a flat `line_items`. Both reduce to { variant_id, quantity }.
|
|
5341
|
-
const lineData = Array.isArray(data.refund_line_items)
|
|
5342
|
-
? data.refund_line_items.map((raw) => {
|
|
5343
|
-
const entry = (raw ?? {}) as Record<string, unknown>;
|
|
5344
|
-
const orderLine = (entry.line_item ?? {}) as Record<string, unknown>;
|
|
5345
|
-
return { variant_id: orderLine.variant_id, product_id: orderLine.product_id, quantity: entry.quantity };
|
|
5346
|
-
})
|
|
5347
|
-
: Array.isArray(data.line_items) ? data.line_items : Array.isArray(data.lineItems) ? data.lineItems : [];
|
|
5348
5310
|
const orderLines = await this.db.select().from(orderLineItems).where(eq(orderLineItems.orderId, orderId));
|
|
5349
5311
|
const mappings = await this.db.select().from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, store.id)));
|
|
5350
5312
|
const refundLines: Array<{ lineItemId: string; quantity: number }> = [];
|
|
5351
|
-
let clean =
|
|
5352
|
-
for (const
|
|
5353
|
-
const
|
|
5354
|
-
const
|
|
5355
|
-
const quantity = Number(line.quantity ?? 0);
|
|
5313
|
+
let clean = event.lines.length > 0;
|
|
5314
|
+
for (const line of event.lines) {
|
|
5315
|
+
const externalId = line.externalVariantId;
|
|
5316
|
+
const quantity = line.quantity;
|
|
5356
5317
|
const mapping = mappings.find((item) => item.externalId === externalId);
|
|
5357
5318
|
const orderLine = mapping ? orderLines.find((item) => item.variantId === mapping.variantId || item.entityId === mapping.entityId) : undefined;
|
|
5358
5319
|
if (!orderLine || !Number.isInteger(quantity) || quantity < 1 || quantity > orderLine.quantity - orderLine.refundedQuantity) clean = false;
|
|
@@ -5532,30 +5493,27 @@ export class ChannelConnectorService {
|
|
|
5532
5493
|
}
|
|
5533
5494
|
|
|
5534
5495
|
/**
|
|
5535
|
-
* One core fulfilment record per store
|
|
5536
|
-
*
|
|
5537
|
-
*
|
|
5538
|
-
* records every line not yet fulfilled.
|
|
5496
|
+
* One core fulfilment record per store parcel, keyed on the store's parcel id
|
|
5497
|
+
* (`metadata.channelFulfillmentId`) so a replay records nothing twice. Each records the lines it
|
|
5498
|
+
* shipped, matched by the store's variant id; one whose lines cannot be matched (or that names
|
|
5499
|
+
* none) records every line not yet fulfilled.
|
|
5539
5500
|
*/
|
|
5540
|
-
private async recordChannelFulfillments(orgId: string, storeId: string, orderId: string,
|
|
5541
|
-
const parsed = channelFulfillmentsSchema.safeParse(data.fulfillments ?? []);
|
|
5542
|
-
if (!parsed.success) return PluginErr(`Channel order fulfilments did not parse: ${parsed.error.message}`);
|
|
5501
|
+
private async recordChannelFulfillments(orgId: string, storeId: string, orderId: string, shipments: ChannelShipment[], actor: Actor): Promise<PluginResult<number>> {
|
|
5543
5502
|
const existing = await this.db.select({ metadata: fulfillmentRecords.metadata }).from(fulfillmentRecords).where(eq(fulfillmentRecords.orderId, orderId));
|
|
5544
5503
|
const recorded = new Set(existing.map((row) => String(row.metadata?.channelFulfillmentId ?? "")));
|
|
5545
5504
|
const lines = await this.db.select({ id: orderLineItems.id, variantId: orderLineItems.variantId, quantity: orderLineItems.quantity }).from(orderLineItems).where(eq(orderLineItems.orderId, orderId));
|
|
5546
5505
|
const fulfillment = this.services.fulfillment as { createFulfillment(input: { orderId: string; lineItems: Array<{ orderLineItemId: string; quantity: number }>; carrier?: string; trackingNumber?: string; trackingUrl?: string; status?: string; metadata?: Record<string, unknown> }, actor: Actor): Promise<{ ok: boolean; error?: { message: string } }> };
|
|
5547
5506
|
let created = 0;
|
|
5548
|
-
for (const parcel of
|
|
5549
|
-
|
|
5550
|
-
|
|
5551
|
-
const externalIds = parcel.line_items.flatMap((line) => (line.variant_id == null ? [] : [String(line.variant_id)]));
|
|
5507
|
+
for (const parcel of shipments) {
|
|
5508
|
+
if (recorded.has(parcel.remoteId)) continue;
|
|
5509
|
+
const externalIds = parcel.lines.map((line) => line.externalVariantId);
|
|
5552
5510
|
const mapped = externalIds.length === 0 ? [] : await this.db
|
|
5553
5511
|
.select({ externalId: channelEntityMap.externalId, variantId: channelEntityMap.variantId })
|
|
5554
5512
|
.from(channelEntityMap)
|
|
5555
5513
|
.where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.kind, "variant"), inArray(channelEntityMap.externalId, externalIds)));
|
|
5556
5514
|
const variantFor = new Map(mapped.map((row) => [row.externalId, row.variantId]));
|
|
5557
|
-
const matched = parcel.
|
|
5558
|
-
const variantId =
|
|
5515
|
+
const matched = parcel.lines.flatMap((line) => {
|
|
5516
|
+
const variantId = variantFor.get(line.externalVariantId);
|
|
5559
5517
|
const orderLine = variantId == null ? undefined : lines.find((candidate) => candidate.variantId === variantId);
|
|
5560
5518
|
return orderLine ? [{ orderLineItemId: orderLine.id, quantity: line.quantity }] : [];
|
|
5561
5519
|
});
|
|
@@ -5564,14 +5522,14 @@ export class ChannelConnectorService {
|
|
|
5564
5522
|
const result = await fulfillment.createFulfillment({
|
|
5565
5523
|
orderId,
|
|
5566
5524
|
lineItems,
|
|
5567
|
-
...(parcel.
|
|
5568
|
-
...(parcel.
|
|
5569
|
-
...(parcel.
|
|
5525
|
+
...(parcel.carrier ? { carrier: parcel.carrier } : {}),
|
|
5526
|
+
...(parcel.trackingNumber ? { trackingNumber: parcel.trackingNumber } : {}),
|
|
5527
|
+
...(parcel.trackingUrl ? { trackingUrl: parcel.trackingUrl } : {}),
|
|
5570
5528
|
status: "shipped",
|
|
5571
|
-
metadata: { channelFulfillmentId:
|
|
5529
|
+
metadata: { channelFulfillmentId: parcel.remoteId, storeId, ...(parcel.source ? { trackingSource: parcel.source } : {}) },
|
|
5572
5530
|
}, actor);
|
|
5573
5531
|
if (!result.ok) return PluginErr(result.error?.message ?? "Could not record the store's fulfilment.");
|
|
5574
|
-
recorded.add(
|
|
5532
|
+
recorded.add(parcel.remoteId);
|
|
5575
5533
|
created += 1;
|
|
5576
5534
|
}
|
|
5577
5535
|
return Ok(created);
|