@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/dist/service.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
import { CHANNEL_OUT_OF_STOCK, CommerceInvalidTransitionError, CommerceNotFoundError, CommerceValidationError, Ok, PluginErr, createTxContext, createSystemActor, linkFieldPaths, removeEntityLinks, writeEntityLinks, } from "@porulle/core";
|
|
3
|
+
import { CHANNEL_CREDENTIALS_REJECTED, CHANNEL_OUT_OF_STOCK, CommerceInvalidTransitionError, CommerceNotFoundError, CommerceValidationError, Ok, PluginErr, createTxContext, createSystemActor, linkFieldPaths, removeEntityLinks, writeEntityLinks, } from "@porulle/core";
|
|
4
4
|
import { isValidFieldPath, requireUserId } from "@porulle/core";
|
|
5
5
|
import { CHANNEL_CONVERGENCE_CTX } from "./catalog-push-trigger.js";
|
|
6
6
|
import { resolveLiveCredentials, withLiveCredentials } from "./live-credentials.js";
|
|
@@ -33,24 +33,11 @@ export const CHANNEL_INVENTORY_MAX_ITEMS_PER_INVOCATION = 20;
|
|
|
33
33
|
* The reason a platform order is cancelled under when its STORE cancelled it. The cancel hook skips
|
|
34
34
|
* cancelling at the store for exactly this reason, so the two directions cannot loop.
|
|
35
35
|
*/
|
|
36
|
+
/** How stale a store order may be before a read point queues a refresh of it. */
|
|
37
|
+
export const REMOTE_ORDER_REFRESH_MS = 5 * 60 * 1000;
|
|
38
|
+
/** How often a merchant's visit may make the plugin call a store to check its health. */
|
|
39
|
+
export const STORE_HEALTH_INTERVAL_MS = 10 * 60 * 1000;
|
|
36
40
|
export const CHANNEL_ORDER_CANCELLED_REASON = "channel_order_cancelled";
|
|
37
|
-
/** What each store return webhook says about the return. */
|
|
38
|
-
const RETURN_WEBHOOK_STATUS = {
|
|
39
|
-
"returns/approve": "approved",
|
|
40
|
-
"returns/decline": "declined",
|
|
41
|
-
"returns/close": "closed",
|
|
42
|
-
"returns/cancel": "cancelled",
|
|
43
|
-
"returns/reopen": "approved",
|
|
44
|
-
};
|
|
45
|
-
/** The slice of a store order body's `fulfillments` (Shopify's REST spelling) a parcel is read from. */
|
|
46
|
-
const channelFulfillmentsSchema = z.array(z.object({
|
|
47
|
-
id: z.union([z.string(), z.number()]),
|
|
48
|
-
status: z.string().nullish(),
|
|
49
|
-
tracking_company: z.string().nullish(),
|
|
50
|
-
tracking_number: z.string().nullish(),
|
|
51
|
-
tracking_url: z.string().nullish(),
|
|
52
|
-
line_items: z.array(z.object({ variant_id: z.union([z.string(), z.number()]).nullish(), quantity: z.number().int().positive() })).default([]),
|
|
53
|
-
}));
|
|
54
41
|
const CATALOG_PUSH_RETRY_BASE_MS = 60_000;
|
|
55
42
|
const CATALOG_PUSH_RETRY_MAX_MS = 60 * 60 * 1000;
|
|
56
43
|
export function catalogPushRetryDelayMs(attempts) {
|
|
@@ -498,6 +485,9 @@ function redactStore(store) {
|
|
|
498
485
|
credentials: "[REDACTED]",
|
|
499
486
|
storeDomain: store.storeDomain,
|
|
500
487
|
status: store.status,
|
|
488
|
+
statusReason: store.statusReason,
|
|
489
|
+
health: store.health,
|
|
490
|
+
lastEventAt: store.lastEventAt,
|
|
501
491
|
catalogWriteEnabled: store.catalogWriteEnabled,
|
|
502
492
|
catalogFieldMapping: store.catalogFieldMapping,
|
|
503
493
|
catalogCursor: store.catalogCursor,
|
|
@@ -1807,6 +1797,11 @@ export class ChannelConnectorService {
|
|
|
1807
1797
|
}
|
|
1808
1798
|
// A shop_domain can map to more than one connected store (reconnect, or the same
|
|
1809
1799
|
// shop under two orgs). Compliance webhooks must fan out to all of them.
|
|
1800
|
+
/** This organization's store of `provider` at `storeDomain`, whatever its status. */
|
|
1801
|
+
async storeByDomain(orgId, provider, storeDomain) {
|
|
1802
|
+
const [row] = await this.db.select().from(connectedStores).where(and(eq(connectedStores.organizationId, orgId), eq(connectedStores.provider, provider), eq(connectedStores.storeDomain, storeDomain)));
|
|
1803
|
+
return row ? redactStore(row) : undefined;
|
|
1804
|
+
}
|
|
1810
1805
|
async getStoresByDomain(shopDomain) {
|
|
1811
1806
|
const rows = await this.db
|
|
1812
1807
|
.select()
|
|
@@ -2244,7 +2239,23 @@ export class ChannelConnectorService {
|
|
|
2244
2239
|
* provider that subscribes per store is registered after commit, at an absolute address; the
|
|
2245
2240
|
* consumer's follow-on work ({@link AfterStoreConnected}) runs after that.
|
|
2246
2241
|
*/
|
|
2242
|
+
/**
|
|
2243
|
+
* Connects a store and runs its follow-on work (subscribe, first import) before answering. For a
|
|
2244
|
+
* provider whose callback must be answered at once, see {@link saveConnectingStore} and
|
|
2245
|
+
* {@link completeConnect}, which this is the two halves of.
|
|
2246
|
+
*/
|
|
2247
2247
|
async connectStore(orgId, input, actor) {
|
|
2248
|
+
const saved = await this.saveConnectingStore(orgId, input, actor);
|
|
2249
|
+
if (!saved.ok)
|
|
2250
|
+
return saved;
|
|
2251
|
+
return this.completeConnect(orgId, saved.value.id, actor);
|
|
2252
|
+
}
|
|
2253
|
+
/**
|
|
2254
|
+
* Writes the store with its credentials in status `connecting`, bound to the actor's vendor, and
|
|
2255
|
+
* nothing else: no call to the store. Reconnecting a store this organization already has refreshes
|
|
2256
|
+
* its row instead of adding one.
|
|
2257
|
+
*/
|
|
2258
|
+
async saveConnectingStore(orgId, input, actor) {
|
|
2248
2259
|
const connector = this.connectors.get(input.provider);
|
|
2249
2260
|
if (!connector)
|
|
2250
2261
|
return PluginErr(`No connector registered for provider "${input.provider}".`, "NOT_FOUND");
|
|
@@ -2254,14 +2265,14 @@ export class ChannelConnectorService {
|
|
|
2254
2265
|
if (connector.registerWebhooks && !this.options.publicUrl) {
|
|
2255
2266
|
return PluginErr(`Connector "${input.provider}" subscribes per store and needs the plugin's publicUrl to give it an absolute address.`, "PUBLIC_URL_REQUIRED");
|
|
2256
2267
|
}
|
|
2257
|
-
let store;
|
|
2258
2268
|
try {
|
|
2259
|
-
store = await this.transact(async (tx) => {
|
|
2269
|
+
const store = await this.transact(async (tx) => {
|
|
2260
2270
|
const [existing] = await tx.select().from(connectedStores).where(and(eq(connectedStores.organizationId, orgId), eq(connectedStores.provider, input.provider), eq(connectedStores.storeDomain, storeDomain)));
|
|
2261
2271
|
const rows = existing
|
|
2262
2272
|
? await tx.update(connectedStores).set({
|
|
2263
2273
|
credentials: input.credentials,
|
|
2264
|
-
status: "
|
|
2274
|
+
status: "connecting",
|
|
2275
|
+
statusReason: null,
|
|
2265
2276
|
...(existing.status !== "connected" ? { catalogWriteEnabled: false } : {}),
|
|
2266
2277
|
webhookSecret: input.webhookSecret ?? existing.webhookSecret ?? crypto.randomUUID(),
|
|
2267
2278
|
updatedAt: new Date(),
|
|
@@ -2271,6 +2282,7 @@ export class ChannelConnectorService {
|
|
|
2271
2282
|
provider: input.provider,
|
|
2272
2283
|
credentials: input.credentials,
|
|
2273
2284
|
storeDomain,
|
|
2285
|
+
status: "connecting",
|
|
2274
2286
|
webhookSecret: input.webhookSecret ?? crypto.randomUUID(),
|
|
2275
2287
|
}).returning();
|
|
2276
2288
|
const written = rows[0];
|
|
@@ -2279,41 +2291,56 @@ export class ChannelConnectorService {
|
|
|
2279
2291
|
await this.options.bindConnectedStore?.({ db: tx, store: written, actor });
|
|
2280
2292
|
return written;
|
|
2281
2293
|
});
|
|
2294
|
+
return Ok(redactStore(store));
|
|
2282
2295
|
}
|
|
2283
2296
|
catch (error) {
|
|
2284
2297
|
return PluginErr(error instanceof Error ? error.message : "The store could not be connected.", error instanceof CommerceNotFoundError ? "NOT_FOUND" : "STORE_CONNECTION_REFUSED");
|
|
2285
2298
|
}
|
|
2299
|
+
}
|
|
2300
|
+
/**
|
|
2301
|
+
* The work after the credentials are saved: subscribe the store to its connector's topics (all or
|
|
2302
|
+
* none — a partial subscription is removed), mark it `connected`, then the host's follow-on work.
|
|
2303
|
+
* A failure leaves the store in `error` with the reason the merchant will read, unless the host
|
|
2304
|
+
* already moved it (it disconnects a store it refuses).
|
|
2305
|
+
*/
|
|
2306
|
+
async completeConnect(orgId, storeId, actor) {
|
|
2307
|
+
const store = await this.getStoreRecord(orgId, storeId);
|
|
2308
|
+
if (!store)
|
|
2309
|
+
return PluginErr("Connected store not found.", "NOT_FOUND");
|
|
2310
|
+
const connector = this.connectors.get(store.provider);
|
|
2311
|
+
if (!connector)
|
|
2312
|
+
return PluginErr(`No connector registered for provider "${store.provider}".`, "NOT_FOUND");
|
|
2313
|
+
const fail = async (message, code) => {
|
|
2314
|
+
await this.db.update(connectedStores).set({ statusReason: message, updatedAt: new Date() }).where(eq(connectedStores.id, storeId));
|
|
2315
|
+
await this.db.update(connectedStores).set({ status: "error" }).where(and(eq(connectedStores.id, storeId), inArray(connectedStores.status, ["connecting", "connected"])));
|
|
2316
|
+
return PluginErr(message, code);
|
|
2317
|
+
};
|
|
2286
2318
|
if (connector.registerWebhooks && this.options.publicUrl) {
|
|
2287
|
-
const callbackUrl =
|
|
2288
|
-
const registration = await connector.registerWebhooks(store, [
|
|
2289
|
-
"products/create",
|
|
2290
|
-
"products/update",
|
|
2291
|
-
"products/delete",
|
|
2292
|
-
"inventory_levels/update",
|
|
2293
|
-
"orders/fulfilled",
|
|
2294
|
-
"orders/partially_fulfilled",
|
|
2295
|
-
"orders/cancelled",
|
|
2296
|
-
"refunds/create",
|
|
2297
|
-
"returns/approve",
|
|
2298
|
-
"returns/decline",
|
|
2299
|
-
"returns/close",
|
|
2300
|
-
"returns/cancel",
|
|
2301
|
-
"app/uninstalled",
|
|
2302
|
-
], callbackUrl);
|
|
2319
|
+
const callbackUrl = this.webhookCallbackUrl(storeId);
|
|
2320
|
+
const registration = await connector.registerWebhooks(store, [...(connector.webhookTopics ?? [])], callbackUrl);
|
|
2303
2321
|
if (!registration.ok) {
|
|
2304
|
-
await
|
|
2305
|
-
return
|
|
2322
|
+
await connector.unregisterWebhooks?.(store, callbackUrl);
|
|
2323
|
+
return fail(registration.error.message, registration.error.code === CHANNEL_CREDENTIALS_REJECTED ? CHANNEL_CREDENTIALS_REJECTED : "CONNECTOR_REGISTRATION_FAILED");
|
|
2306
2324
|
}
|
|
2307
2325
|
}
|
|
2326
|
+
const [connected] = await this.db.update(connectedStores).set({ status: "connected", statusReason: null, updatedAt: new Date() })
|
|
2327
|
+
.where(and(eq(connectedStores.id, storeId), eq(connectedStores.status, "connecting"))).returning();
|
|
2328
|
+
const current = (connected ?? store);
|
|
2308
2329
|
if (this.options.afterStoreConnected) {
|
|
2309
2330
|
try {
|
|
2310
|
-
await this.options.afterStoreConnected({ store, actor, connector, services: this.services });
|
|
2331
|
+
await this.options.afterStoreConnected({ store: current, actor, connector, services: this.services });
|
|
2311
2332
|
}
|
|
2312
2333
|
catch (error) {
|
|
2313
|
-
return
|
|
2334
|
+
return fail(error instanceof Error ? error.message : "The store connected but its follow-on work failed.", "AFTER_CONNECT_FAILED");
|
|
2314
2335
|
}
|
|
2315
2336
|
}
|
|
2316
|
-
return Ok(redactStore(
|
|
2337
|
+
return Ok(redactStore(current));
|
|
2338
|
+
}
|
|
2339
|
+
/** Where a store that subscribes per store delivers its webhooks: absolute, on this deployment's public origin. */
|
|
2340
|
+
webhookCallbackUrl(storeId) {
|
|
2341
|
+
if (!this.options.publicUrl)
|
|
2342
|
+
throw new Error("The channel plugin has no publicUrl to build a webhook address on.");
|
|
2343
|
+
return new URL(`/api/channels/webhooks/${storeId}`, this.options.publicUrl).toString();
|
|
2317
2344
|
}
|
|
2318
2345
|
/** The store with credentials good for a call the host makes itself, e.g. its own Admin API write. */
|
|
2319
2346
|
async liveStore(orgId, storeId) {
|
|
@@ -2356,6 +2383,14 @@ export class ChannelConnectorService {
|
|
|
2356
2383
|
return this.disconnectStoreSystem(orgId, id);
|
|
2357
2384
|
}
|
|
2358
2385
|
async disconnectStoreSystem(orgId, id, redactDomain = false) {
|
|
2386
|
+
const before = await this.getStoreRecord(orgId, id);
|
|
2387
|
+
const connector = before ? this.connectors.get(before.provider) : undefined;
|
|
2388
|
+
if (before && connector?.unregisterWebhooks && this.options.publicUrl && before.status !== "disconnected") {
|
|
2389
|
+
// Best effort: the store is disconnected here whatever the provider answers.
|
|
2390
|
+
const removed = await connector.unregisterWebhooks(before, this.webhookCallbackUrl(id));
|
|
2391
|
+
if (!removed.ok)
|
|
2392
|
+
console.warn(JSON.stringify({ event: "channel_unregister_webhooks_failed", provider: before.provider, storeId: id, code: removed.error.code, message: removed.error.message }));
|
|
2393
|
+
}
|
|
2359
2394
|
const rows = await this.db
|
|
2360
2395
|
.update(connectedStores)
|
|
2361
2396
|
.set({
|
|
@@ -2372,6 +2407,44 @@ export class ChannelConnectorService {
|
|
|
2372
2407
|
return PluginErr("Connected store not found.", "NOT_FOUND");
|
|
2373
2408
|
return Ok(redactStore(store));
|
|
2374
2409
|
}
|
|
2410
|
+
/**
|
|
2411
|
+
* Checks the store's webhook subscriptions and key and repairs what it can, when a merchant looks:
|
|
2412
|
+
* there is no scheduled check. At most once per {@link STORE_HEALTH_INTERVAL_MS} per store; inside
|
|
2413
|
+
* that window the last result is answered without calling the store.
|
|
2414
|
+
*/
|
|
2415
|
+
async checkStoreHealth(orgId, id, context) {
|
|
2416
|
+
const reached = await this.reachableStore(orgId, id, context);
|
|
2417
|
+
if (!reached.ok)
|
|
2418
|
+
return reached;
|
|
2419
|
+
const store = reached.value;
|
|
2420
|
+
const answer = (health, current, cached) => Ok({ ...health, status: current.status, statusReason: current.statusReason, lastEventAt: current.lastEventAt, cached });
|
|
2421
|
+
if (store.health && Date.now() - Date.parse(store.health.checkedAt) < STORE_HEALTH_INTERVAL_MS)
|
|
2422
|
+
return answer(store.health, store, true);
|
|
2423
|
+
const connector = this.connectors.get(store.provider);
|
|
2424
|
+
if (!connector)
|
|
2425
|
+
return PluginErr(`No connector registered for provider "${store.provider}".`, "NOT_FOUND");
|
|
2426
|
+
let health;
|
|
2427
|
+
const checkedAt = new Date().toISOString();
|
|
2428
|
+
if (store.status !== "connected" && store.status !== "error") {
|
|
2429
|
+
health = { checkedAt, webhooks: "not_applicable", repaired: 0, missing: [], keyValid: false };
|
|
2430
|
+
}
|
|
2431
|
+
else if (connector.webhookHealth && this.options.publicUrl) {
|
|
2432
|
+
const checked = await connector.webhookHealth(store, this.webhookCallbackUrl(id));
|
|
2433
|
+
health = checked.ok
|
|
2434
|
+
? { checkedAt, webhooks: checked.value.healthy ? (checked.value.repaired > 0 ? "repaired" : "ok") : "failing", repaired: checked.value.repaired, missing: checked.value.missing, keyValid: true }
|
|
2435
|
+
: { checkedAt, webhooks: "failing", repaired: 0, missing: [...(connector.webhookTopics ?? [])], keyValid: checked.error.code !== CHANNEL_CREDENTIALS_REJECTED, error: checked.error.message };
|
|
2436
|
+
}
|
|
2437
|
+
else if (connector.fetchStoreProfile) {
|
|
2438
|
+
// Subscribed per app (Shopify): nothing per store to repair, only the grant to check.
|
|
2439
|
+
const profile = await connector.fetchStoreProfile(store);
|
|
2440
|
+
health = { checkedAt, webhooks: "not_applicable", repaired: 0, missing: [], keyValid: profile.ok || profile.error.code !== CHANNEL_CREDENTIALS_REJECTED, ...(profile.ok ? {} : { error: profile.error.message }) };
|
|
2441
|
+
}
|
|
2442
|
+
else {
|
|
2443
|
+
health = { checkedAt, webhooks: "not_applicable", repaired: 0, missing: [], keyValid: true };
|
|
2444
|
+
}
|
|
2445
|
+
const [updated] = await this.db.update(connectedStores).set({ health, updatedAt: new Date() }).where(and(eq(connectedStores.organizationId, orgId), eq(connectedStores.id, id))).returning();
|
|
2446
|
+
return answer(health, (updated ?? store), false);
|
|
2447
|
+
}
|
|
2375
2448
|
async getStore(orgId, id, context) {
|
|
2376
2449
|
const store = await this.reachableStore(orgId, id, context);
|
|
2377
2450
|
return store.ok ? Ok(redactStore(store.value)) : store;
|
|
@@ -2482,6 +2555,8 @@ export class ChannelConnectorService {
|
|
|
2482
2555
|
const items = rawItems.map(withDistinctVariantSkus);
|
|
2483
2556
|
const failures = [];
|
|
2484
2557
|
const warnings = [];
|
|
2558
|
+
const skipped = [];
|
|
2559
|
+
const conflicts = [];
|
|
2485
2560
|
const entityByExternalId = new Map();
|
|
2486
2561
|
let unchanged = 0;
|
|
2487
2562
|
let updated = 0;
|
|
@@ -2520,6 +2595,8 @@ export class ChannelConnectorService {
|
|
|
2520
2595
|
return result;
|
|
2521
2596
|
failures.push(...result.value.failures);
|
|
2522
2597
|
warnings.push(...result.value.warnings);
|
|
2598
|
+
skipped.push(...result.value.skipped);
|
|
2599
|
+
conflicts.push(...result.value.conflicts);
|
|
2523
2600
|
const failedIds = new Set(result.value.failures.map((failure) => failure.externalId));
|
|
2524
2601
|
const survivors = editor.filter((item) => !failedIds.has(item.externalId));
|
|
2525
2602
|
survivors.forEach((item, index) => {
|
|
@@ -2613,6 +2690,8 @@ export class ChannelConnectorService {
|
|
|
2613
2690
|
heroesImported: media.heroesImported,
|
|
2614
2691
|
mediaFailures: media.mediaFailures,
|
|
2615
2692
|
deferredMedia: media.deferredMedia,
|
|
2693
|
+
skipped,
|
|
2694
|
+
conflicts,
|
|
2616
2695
|
warnings,
|
|
2617
2696
|
});
|
|
2618
2697
|
}
|
|
@@ -3817,100 +3896,107 @@ export class ChannelConnectorService {
|
|
|
3817
3896
|
}).where(and(eq(connectedStores.organizationId, orgId), eq(connectedStores.id, storeId)));
|
|
3818
3897
|
return Ok({ synced, exhausted });
|
|
3819
3898
|
}
|
|
3820
|
-
|
|
3899
|
+
/**
|
|
3900
|
+
* One verified store delivery. The store's connector says what it means ({@link ChannelEvent}); this
|
|
3901
|
+
* acts on the meaning and never on a topic or payload field. A delivery the connector does not act
|
|
3902
|
+
* on is logged as unmapped and answered `processed: false`, never reported as applied.
|
|
3903
|
+
*/
|
|
3904
|
+
async handleWebhook(orgId, storeId, delivery) {
|
|
3821
3905
|
const store = await this.getStoreRecord(orgId, storeId);
|
|
3822
3906
|
if (!store)
|
|
3823
3907
|
return PluginErr("Connected store not found.", "NOT_FOUND");
|
|
3824
|
-
const actor = createSystemActor(orgId);
|
|
3825
|
-
const data = event.data;
|
|
3826
|
-
let skipped = [];
|
|
3827
|
-
let conflicts = [];
|
|
3828
|
-
let warnings = [];
|
|
3829
3908
|
const connector = this.connectors.get(store.provider);
|
|
3830
|
-
|
|
3831
|
-
|
|
3832
|
-
|
|
3833
|
-
|
|
3834
|
-
|
|
3835
|
-
|
|
3836
|
-
|
|
3837
|
-
|
|
3838
|
-
|
|
3839
|
-
|
|
3840
|
-
|
|
3841
|
-
|
|
3842
|
-
|
|
3843
|
-
|
|
3844
|
-
|
|
3845
|
-
|
|
3846
|
-
|
|
3847
|
-
|
|
3848
|
-
|
|
3849
|
-
|
|
3909
|
+
const decoded = connector?.decodeWebhook ? await connector.decodeWebhook(store, delivery) : Ok([]);
|
|
3910
|
+
if (!decoded.ok)
|
|
3911
|
+
return PluginErr(decoded.error.message, decoded.error.code);
|
|
3912
|
+
await this.db.update(connectedStores).set({ lastEventAt: new Date() }).where(and(eq(connectedStores.organizationId, orgId), eq(connectedStores.id, storeId)));
|
|
3913
|
+
if (decoded.value.length === 0) {
|
|
3914
|
+
console.warn(JSON.stringify({ event: "channel_webhook_unmapped", provider: store.provider, storeId, topic: delivery.type, deliveryId: delivery.id }));
|
|
3915
|
+
return Ok({ processed: false });
|
|
3916
|
+
}
|
|
3917
|
+
const actor = createSystemActor(orgId);
|
|
3918
|
+
const outcome = { processed: true };
|
|
3919
|
+
const report = { skipped: [], conflicts: [], warnings: [] };
|
|
3920
|
+
for (const event of decoded.value) {
|
|
3921
|
+
const applied = await this.applyChannelEvent(orgId, store, connector, event, actor, report);
|
|
3922
|
+
if (!applied.ok)
|
|
3923
|
+
return applied;
|
|
3924
|
+
if (applied.value.data)
|
|
3925
|
+
outcome.data = applied.value.data;
|
|
3926
|
+
if (applied.value.redacted !== undefined)
|
|
3927
|
+
outcome.redacted = applied.value.redacted;
|
|
3928
|
+
}
|
|
3929
|
+
if (report.skipped.length > 0 || report.conflicts.length > 0 || report.warnings.length > 0) {
|
|
3930
|
+
const merged = {
|
|
3931
|
+
...(store.lastReconcileReport ?? {}),
|
|
3932
|
+
...(report.skipped.length > 0 ? { skipped: uniqueSkipped(report.skipped) } : {}),
|
|
3933
|
+
...(report.conflicts.length > 0 ? { conflicts: report.conflicts } : {}),
|
|
3934
|
+
...(report.warnings.length > 0 ? { warnings: report.warnings } : {}),
|
|
3935
|
+
};
|
|
3936
|
+
await this.db.update(connectedStores).set({ lastReconcileReport: merged, updatedAt: new Date() }).where(and(eq(connectedStores.organizationId, orgId), eq(connectedStores.id, storeId)));
|
|
3937
|
+
}
|
|
3938
|
+
return Ok(outcome);
|
|
3939
|
+
}
|
|
3940
|
+
async applyChannelEvent(orgId, store, connector, event, actor, report) {
|
|
3941
|
+
const storeId = store.id;
|
|
3942
|
+
switch (event.kind) {
|
|
3943
|
+
case "product.changed": {
|
|
3944
|
+
// A webhook is a notification, not a snapshot. The products are read fresh and converged
|
|
3945
|
+
// exactly as an import page would be, creating one this store has never mapped.
|
|
3946
|
+
if (!connector?.fetchCatalogItems)
|
|
3947
|
+
return PluginErr(`Connector "${store.provider}" cannot re-read products, so a product change cannot be applied.`, "CONNECTOR_CANNOT_REREAD");
|
|
3948
|
+
const read = await connector.fetchCatalogItems(store, event.externalIds);
|
|
3949
|
+
if (!read.ok)
|
|
3950
|
+
return PluginErr(read.error.message, read.error.code);
|
|
3951
|
+
const found = new Set(read.value.map((item) => item.externalId));
|
|
3952
|
+
for (const externalId of event.externalIds.filter((id) => !found.has(id))) {
|
|
3953
|
+
// Gone between the delivery and the read: the same as a delete.
|
|
3954
|
+
const archived = await this.archiveMappedProduct(orgId, storeId, externalId, actor);
|
|
3955
|
+
if (!archived.ok)
|
|
3956
|
+
return archived;
|
|
3957
|
+
report.skipped.push(...archived.value);
|
|
3958
|
+
}
|
|
3959
|
+
if (read.value.length === 0)
|
|
3960
|
+
return Ok({});
|
|
3961
|
+
const converged = await this.convergeCatalogPage(orgId, storeId, read.value, actor);
|
|
3850
3962
|
if (!converged.ok)
|
|
3851
3963
|
return converged;
|
|
3852
3964
|
const [failure] = converged.value.failures;
|
|
3853
3965
|
if (failure)
|
|
3854
3966
|
return PluginErr(`Product ${failure.externalId} could not be converged: ${failure.error}`, "CONVERGENCE_FAILED");
|
|
3855
|
-
|
|
3967
|
+
report.skipped.push(...converged.value.skipped);
|
|
3968
|
+
report.conflicts.push(...converged.value.conflicts);
|
|
3969
|
+
report.warnings.push(...converged.value.warnings);
|
|
3856
3970
|
if (converged.value.entityIds.length > 0) {
|
|
3857
3971
|
await this.options.onStoreCatalogChanged?.({ orgId, storeId, entityIds: [...converged.value.entityIds], convergence: converged.value });
|
|
3858
3972
|
}
|
|
3973
|
+
return Ok({});
|
|
3859
3974
|
}
|
|
3860
|
-
|
|
3861
|
-
|
|
3862
|
-
|
|
3863
|
-
|
|
3864
|
-
|
|
3865
|
-
|
|
3866
|
-
if (!converged.ok)
|
|
3867
|
-
return converged;
|
|
3868
|
-
skipped = converged.value.skipped;
|
|
3869
|
-
conflicts = converged.value.conflicts;
|
|
3870
|
-
warnings = converged.value.warnings;
|
|
3871
|
-
}
|
|
3872
|
-
}
|
|
3873
|
-
else if (event.type === "products/delete") {
|
|
3874
|
-
const archived = await this.archiveMappedProduct(orgId, storeId, String(data.id ?? data.product_id ?? ""), actor);
|
|
3875
|
-
if (!archived.ok)
|
|
3876
|
-
return archived;
|
|
3877
|
-
skipped = archived.value;
|
|
3878
|
-
}
|
|
3879
|
-
else if (event.type === "inventory_levels/update") {
|
|
3880
|
-
const available = Number(data.available ?? data.stock_quantity ?? 0);
|
|
3881
|
-
// Shopify names an INVENTORY ITEM, whose id is not the variant id the channel map is keyed by;
|
|
3882
|
-
// looked up by it, every Shopify stock webhook found no mapping and was dropped. The variant is
|
|
3883
|
-
// resolved through the inventory item id its import recorded; a provider that sends a variant
|
|
3884
|
-
// id keeps the plain lookup.
|
|
3885
|
-
const inventoryItemId = data.inventory_item_id !== undefined && data.inventory_item_id !== null ? String(data.inventory_item_id) : null;
|
|
3886
|
-
const byInventoryItem = inventoryItemId === null ? null : await this.variantForInventoryItem(orgId, storeId, inventoryItemId);
|
|
3887
|
-
if (byInventoryItem !== null) {
|
|
3888
|
-
// The delivery's `available` is ONE location's count. The variant's stock is the sum the
|
|
3889
|
-
// connector reads, so it is read fresh rather than taken from the payload.
|
|
3890
|
-
let quantity = available;
|
|
3891
|
-
if (connector) {
|
|
3892
|
-
const levels = await connector.fetchInventory(store, [byInventoryItem.externalId]);
|
|
3893
|
-
if (!levels.ok)
|
|
3894
|
-
return PluginErr(levels.error.message, levels.error.code);
|
|
3895
|
-
const fresh = levels.value.find((entry) => entry.externalId === byInventoryItem.externalId);
|
|
3896
|
-
if (fresh)
|
|
3897
|
-
quantity = fresh.available;
|
|
3975
|
+
case "product.deleted": {
|
|
3976
|
+
for (const externalId of event.externalIds) {
|
|
3977
|
+
const archived = await this.archiveMappedProduct(orgId, storeId, externalId, actor);
|
|
3978
|
+
if (!archived.ok)
|
|
3979
|
+
return archived;
|
|
3980
|
+
report.skipped.push(...archived.value);
|
|
3898
3981
|
}
|
|
3899
|
-
|
|
3982
|
+
return Ok({});
|
|
3900
3983
|
}
|
|
3901
|
-
|
|
3902
|
-
const
|
|
3903
|
-
|
|
3984
|
+
case "inventory.changed": {
|
|
3985
|
+
for (const level of event.levels)
|
|
3986
|
+
await this.setMappedInventory(orgId, storeId, level.externalId, level.available, actor);
|
|
3987
|
+
return Ok({});
|
|
3904
3988
|
}
|
|
3905
|
-
|
|
3906
|
-
|
|
3907
|
-
|
|
3908
|
-
|
|
3989
|
+
case "order.cancelled":
|
|
3990
|
+
case "order.fulfilled": {
|
|
3991
|
+
const orderId = await this.orderForRemote(orgId, storeId, event.remoteOrderId);
|
|
3992
|
+
if (!orderId)
|
|
3993
|
+
return Ok({});
|
|
3909
3994
|
const ordersService = this.services.orders;
|
|
3910
|
-
const
|
|
3995
|
+
const what = event.kind === "order.cancelled" ? "cancelled" : event.partial ? "partially fulfilled" : "fulfilled";
|
|
3996
|
+
const note = await ordersService.addNote(orderId, { body: `The store ${what} its order ${event.remoteOrderId}.` }, actor);
|
|
3911
3997
|
if (!note.ok)
|
|
3912
3998
|
return PluginErr(note.error?.message ?? "Could not add channel order note.");
|
|
3913
|
-
if (event.
|
|
3999
|
+
if (event.kind === "order.cancelled") {
|
|
3914
4000
|
// The store cancelled: the platform follows, under a reason the cancel hook recognises, so
|
|
3915
4001
|
// it does not turn round and cancel at the store again. An order already closed, or one the
|
|
3916
4002
|
// machine cannot cancel (shipped), keeps its status; the note above records the delivery.
|
|
@@ -3918,70 +4004,44 @@ export class ChannelConnectorService {
|
|
|
3918
4004
|
if (order && !["cancelled", "refunded"].includes(order.status)) {
|
|
3919
4005
|
await ordersService.changeStatus({ orderId, newStatus: "cancelled", reason: CHANNEL_ORDER_CANCELLED_REASON }, actor);
|
|
3920
4006
|
}
|
|
4007
|
+
return Ok({});
|
|
3921
4008
|
}
|
|
3922
|
-
|
|
3923
|
-
|
|
3924
|
-
|
|
3925
|
-
|
|
3926
|
-
|
|
3927
|
-
|
|
3928
|
-
|
|
3929
|
-
|
|
3930
|
-
|
|
3931
|
-
|
|
3932
|
-
|
|
3933
|
-
await ordersService.changeStatus({ orderId, newStatus: target, reason: "channel_order_fulfilled" }, actor);
|
|
3934
|
-
}
|
|
4009
|
+
// The parcels first, so whatever the status move announces (a shipped email) can read them.
|
|
4010
|
+
const recorded = await this.recordChannelFulfillments(orgId, storeId, orderId, event.shipments, actor);
|
|
4011
|
+
if (!recorded.ok)
|
|
4012
|
+
return recorded;
|
|
4013
|
+
const target = event.partial ? "partially_fulfilled" : "fulfilled";
|
|
4014
|
+
const [order] = await this.db.select({ status: orders.status }).from(orders).where(and(eq(orders.organizationId, orgId), eq(orders.id, orderId)));
|
|
4015
|
+
if (order?.status === "confirmed")
|
|
4016
|
+
await ordersService.changeStatus({ orderId, newStatus: "processing", reason: "channel_order_fulfilled" }, actor);
|
|
4017
|
+
const [after] = await this.db.select({ status: orders.status }).from(orders).where(and(eq(orders.organizationId, orgId), eq(orders.id, orderId)));
|
|
4018
|
+
if (after?.status === "processing" || (target === "fulfilled" && after?.status === "partially_fulfilled")) {
|
|
4019
|
+
await ordersService.changeStatus({ orderId, newStatus: target, reason: "channel_order_fulfilled" }, actor);
|
|
3935
4020
|
}
|
|
4021
|
+
return Ok({});
|
|
3936
4022
|
}
|
|
3937
|
-
|
|
3938
|
-
|
|
3939
|
-
|
|
3940
|
-
|
|
3941
|
-
|
|
3942
|
-
|
|
3943
|
-
await this.db.update(channelReturns).set({ status, updatedAt: new Date() }).where(and(eq(channelReturns.organizationId, orgId), eq(channelReturns.storeId, storeId), eq(channelReturns.remoteReturnId, remoteReturnId)));
|
|
4023
|
+
case "refund.created": {
|
|
4024
|
+
const refund = await this.createRefundRequest(orgId, store, event, actor);
|
|
4025
|
+
return refund.ok ? Ok({}) : refund;
|
|
4026
|
+
}
|
|
4027
|
+
case "return.updated": {
|
|
4028
|
+
// The store's answer to a return the marketplace asked for. A return it never asked for is ignored.
|
|
4029
|
+
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)));
|
|
4030
|
+
return Ok({});
|
|
4031
|
+
}
|
|
4032
|
+
case "connection.revoked": {
|
|
4033
|
+
const disconnected = await this.disconnectStoreSystem(orgId, storeId);
|
|
4034
|
+
return disconnected.ok ? Ok({}) : disconnected;
|
|
4035
|
+
}
|
|
4036
|
+
case "compliance.request": {
|
|
4037
|
+
if (event.request === "customer_data") {
|
|
4038
|
+
const dataRequest = await this.channelCustomerDataRequest(orgId, storeId, event.data);
|
|
4039
|
+
return dataRequest.ok ? Ok({ data: dataRequest.value }) : dataRequest;
|
|
4040
|
+
}
|
|
4041
|
+
const redacted = event.request === "customer_redact" ? await this.redactCustomerData(orgId, storeId, event.data) : await this.redactShopData(orgId, storeId);
|
|
4042
|
+
return redacted.ok ? Ok({ redacted: redacted.value }) : redacted;
|
|
3944
4043
|
}
|
|
3945
4044
|
}
|
|
3946
|
-
else if (event.type === "refunds/create") {
|
|
3947
|
-
const refund = await this.createRefundRequest(orgId, store, data, actor);
|
|
3948
|
-
if (!refund.ok)
|
|
3949
|
-
return refund;
|
|
3950
|
-
}
|
|
3951
|
-
else if (event.type === "customers/data_request") {
|
|
3952
|
-
const dataRequest = await this.channelCustomerDataRequest(orgId, storeId, data);
|
|
3953
|
-
if (!dataRequest.ok)
|
|
3954
|
-
return dataRequest;
|
|
3955
|
-
return Ok({ processed: true, data: dataRequest.value });
|
|
3956
|
-
}
|
|
3957
|
-
else if (event.type === "customers/redact") {
|
|
3958
|
-
const redacted = await this.redactCustomerData(orgId, storeId, data);
|
|
3959
|
-
if (!redacted.ok)
|
|
3960
|
-
return redacted;
|
|
3961
|
-
return Ok({ processed: true, redacted: redacted.value });
|
|
3962
|
-
}
|
|
3963
|
-
else if (event.type === "shop/redact") {
|
|
3964
|
-
const redacted = await this.redactShopData(orgId, storeId);
|
|
3965
|
-
if (!redacted.ok)
|
|
3966
|
-
return redacted;
|
|
3967
|
-
return Ok({ processed: true, redacted: redacted.value });
|
|
3968
|
-
}
|
|
3969
|
-
else if (event.type === "app/uninstalled") {
|
|
3970
|
-
const disconnected = await this.disconnectStoreSystem(orgId, storeId);
|
|
3971
|
-
if (!disconnected.ok)
|
|
3972
|
-
return disconnected;
|
|
3973
|
-
return Ok({ processed: true });
|
|
3974
|
-
}
|
|
3975
|
-
if (skipped.length > 0 || conflicts.length > 0 || warnings.length > 0) {
|
|
3976
|
-
const report = {
|
|
3977
|
-
...(store.lastReconcileReport ?? {}),
|
|
3978
|
-
...(skipped.length > 0 ? { skipped: uniqueSkipped(skipped) } : {}),
|
|
3979
|
-
...(conflicts.length > 0 ? { conflicts } : {}),
|
|
3980
|
-
...(warnings.length > 0 ? { warnings } : {}),
|
|
3981
|
-
};
|
|
3982
|
-
await this.db.update(connectedStores).set({ lastReconcileReport: report, updatedAt: new Date() }).where(and(eq(connectedStores.organizationId, orgId), eq(connectedStores.id, storeId)));
|
|
3983
|
-
}
|
|
3984
|
-
return Ok({ processed: true });
|
|
3985
4045
|
}
|
|
3986
4046
|
complianceEmail(data) {
|
|
3987
4047
|
const customer = data.customer && typeof data.customer === "object" ? data.customer : undefined;
|
|
@@ -4024,10 +4084,52 @@ export class ChannelConnectorService {
|
|
|
4024
4084
|
return PluginErr(disconnected.error, disconnected.code);
|
|
4025
4085
|
return Ok(rows.filter((row) => row.customerData !== null).length);
|
|
4026
4086
|
}
|
|
4027
|
-
|
|
4028
|
-
|
|
4029
|
-
|
|
4030
|
-
|
|
4087
|
+
/**
|
|
4088
|
+
* For a read point (a shopper opening their order): each of the order's store orders not read for
|
|
4089
|
+
* {@link REMOTE_ORDER_REFRESH_MS} is queued for one refresh, so a store-side cancel, shipment or
|
|
4090
|
+
* refund whose delivery never arrived still reaches the platform. No schedule: someone looked.
|
|
4091
|
+
* Answers how many were queued.
|
|
4092
|
+
*/
|
|
4093
|
+
async refreshStaleRemoteOrders(orgId, orderId, jobs) {
|
|
4094
|
+
const [order] = await this.db.select({ status: orders.status }).from(orders).where(and(eq(orders.organizationId, orgId), eq(orders.id, orderId)));
|
|
4095
|
+
if (!order || ["cancelled", "refunded"].includes(order.status))
|
|
4096
|
+
return 0;
|
|
4097
|
+
const cutoff = new Date(Date.now() - REMOTE_ORDER_REFRESH_MS);
|
|
4098
|
+
// The claim and the window in one statement: two readers at once queue one refresh.
|
|
4099
|
+
const claimed = await this.db.update(channelOrderExports).set({ remoteCheckedAt: new Date() }).where(and(eq(channelOrderExports.organizationId, orgId), eq(channelOrderExports.orderId, orderId), inArray(channelOrderExports.state, ["exported", "confirmed"]), sql `${channelOrderExports.remoteOrderId} is not null`, or(isNull(channelOrderExports.remoteCheckedAt), lte(channelOrderExports.remoteCheckedAt, cutoff)))).returning({ storeId: channelOrderExports.storeId, remoteOrderId: channelOrderExports.remoteOrderId });
|
|
4100
|
+
let queued = 0;
|
|
4101
|
+
for (const row of claimed) {
|
|
4102
|
+
if (!row.remoteOrderId)
|
|
4103
|
+
continue;
|
|
4104
|
+
const store = await this.getStoreRecord(orgId, row.storeId);
|
|
4105
|
+
if (!store || store.status !== "connected" || !this.connectors.get(store.provider)?.orderEvents)
|
|
4106
|
+
continue;
|
|
4107
|
+
await jobs.enqueue("channel/refresh-order", { orgId, storeId: row.storeId, remoteOrderId: row.remoteOrderId }, { organizationId: orgId, concurrencyKey: `webhook:${row.storeId}`, supersedes: false });
|
|
4108
|
+
queued += 1;
|
|
4109
|
+
}
|
|
4110
|
+
return queued;
|
|
4111
|
+
}
|
|
4112
|
+
/** The store's current state of one order we pushed, applied as if its delivery had arrived. */
|
|
4113
|
+
async refreshRemoteOrder(orgId, storeId, remoteOrderId) {
|
|
4114
|
+
const store = await this.getStoreRecord(orgId, storeId);
|
|
4115
|
+
if (!store)
|
|
4116
|
+
return PluginErr("Connected store not found.", "NOT_FOUND");
|
|
4117
|
+
const connector = this.connectors.get(store.provider);
|
|
4118
|
+
if (!connector?.orderEvents)
|
|
4119
|
+
return Ok({ events: 0 });
|
|
4120
|
+
const events = await connector.orderEvents(store, remoteOrderId);
|
|
4121
|
+
if (!events.ok)
|
|
4122
|
+
return PluginErr(events.error.message, events.error.code);
|
|
4123
|
+
const report = { skipped: [], conflicts: [], warnings: [] };
|
|
4124
|
+
const actor = createSystemActor(orgId);
|
|
4125
|
+
for (const event of events.value) {
|
|
4126
|
+
const applied = await this.applyChannelEvent(orgId, store, connector, event, actor, report);
|
|
4127
|
+
if (!applied.ok)
|
|
4128
|
+
return applied;
|
|
4129
|
+
}
|
|
4130
|
+
return Ok({ events: events.value.length });
|
|
4131
|
+
}
|
|
4132
|
+
async orderForRemote(orgId, storeId, remoteOrderId) {
|
|
4031
4133
|
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)));
|
|
4032
4134
|
return rows[0]?.orderId;
|
|
4033
4135
|
}
|
|
@@ -4052,178 +4154,22 @@ export class ChannelConnectorService {
|
|
|
4052
4154
|
const inventory = this.services.inventory;
|
|
4053
4155
|
await inventory.setAbsolute({ entityId, ...(variantId ? { variantId } : {}), quantity: Math.max(0, Math.floor(quantity)), reason: "Inventory webhook sync" }, actor);
|
|
4054
4156
|
}
|
|
4055
|
-
|
|
4056
|
-
|
|
4057
|
-
const
|
|
4058
|
-
|
|
4059
|
-
|
|
4060
|
-
|
|
4061
|
-
const [only] = rows;
|
|
4062
|
-
return rows.length === 1 && only !== undefined && only.variantId !== null ? { entityId: only.entityId, variantId: only.variantId, externalId: only.externalId } : null;
|
|
4063
|
-
}
|
|
4064
|
-
async convergeCatalogItem(orgId, storeId, entityId, data, actor) {
|
|
4065
|
-
const product = data.product && typeof data.product === "object" ? data.product : data;
|
|
4066
|
-
const remoteMetadata = product.metadata && typeof product.metadata === "object" && !Array.isArray(product.metadata)
|
|
4067
|
-
? product.metadata
|
|
4068
|
-
: {};
|
|
4069
|
-
const [mapping] = await this.db.select().from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.kind, "entity"), eq(channelEntityMap.entityId, entityId)));
|
|
4070
|
-
const [entity] = await this.db.select().from(sellableEntities).where(and(eq(sellableEntities.organizationId, orgId), eq(sellableEntities.id, entityId)));
|
|
4071
|
-
if (!mapping || !entity)
|
|
4072
|
-
return Ok({ skipped: [], conflicts: [], warnings: [] });
|
|
4073
|
-
const [currentAttribute] = await this.db.select().from(sellableAttributes).where(and(eq(sellableAttributes.entityId, entityId), eq(sellableAttributes.locale, "en")));
|
|
4074
|
-
const title = typeof product.title === "string" ? product.title : currentAttribute?.title ?? entity.slug;
|
|
4075
|
-
const description = product.description !== undefined
|
|
4076
|
-
? String(product.description)
|
|
4077
|
-
: currentAttribute?.description ?? undefined;
|
|
4078
|
-
const status = typeof product.status === "string" && ["draft", "active", "archived", "discontinued"].includes(product.status)
|
|
4079
|
-
? product.status
|
|
4080
|
-
: undefined;
|
|
4081
|
-
const customFields = product.customFields && typeof product.customFields === "object" && !Array.isArray(product.customFields)
|
|
4082
|
-
? product.customFields
|
|
4083
|
-
: undefined;
|
|
4084
|
-
const images = Array.isArray(product.images)
|
|
4085
|
-
? product.images.flatMap((raw) => {
|
|
4086
|
-
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
4087
|
-
return [];
|
|
4088
|
-
const image = raw;
|
|
4089
|
-
const role = typeof image.role === "string" ? pushCatalogImageRole(image.role) : undefined;
|
|
4090
|
-
const url = typeof image.url === "string" ? image.url : typeof image.src === "string" ? image.src : undefined;
|
|
4091
|
-
return role && url ? [{ role, url }] : [];
|
|
4092
|
-
})
|
|
4093
|
-
: [];
|
|
4094
|
-
const remoteItem = {
|
|
4095
|
-
externalId: mapping.externalId,
|
|
4096
|
-
slug: typeof product.slug === "string" ? product.slug : entity.slug,
|
|
4097
|
-
title,
|
|
4098
|
-
...(description !== undefined ? { description } : {}),
|
|
4099
|
-
...(status !== undefined ? { status } : {}),
|
|
4100
|
-
attributes: [{ locale: "en", title, ...(description !== undefined ? { description } : {}) }],
|
|
4101
|
-
...(Object.keys(remoteMetadata).length > 0 ? { metadata: remoteMetadata } : {}),
|
|
4102
|
-
...(customFields !== undefined ? { customFields } : {}),
|
|
4103
|
-
...(images.length > 0 ? { images } : {}),
|
|
4104
|
-
variants: [],
|
|
4105
|
-
};
|
|
4106
|
-
const fieldPaths = [];
|
|
4107
|
-
if (typeof product.slug === "string")
|
|
4108
|
-
fieldPaths.push("entity.slug");
|
|
4109
|
-
if (status !== undefined)
|
|
4110
|
-
fieldPaths.push("entity.status");
|
|
4111
|
-
for (const key of Object.keys(remoteMetadata)) {
|
|
4112
|
-
const path = `entity.metadata.${key}`;
|
|
4113
|
-
if (isValidFieldPath(path))
|
|
4114
|
-
fieldPaths.push(path);
|
|
4115
|
-
}
|
|
4116
|
-
if (typeof product.title === "string")
|
|
4117
|
-
fieldPaths.push("attributes.en.title");
|
|
4118
|
-
if (product.description !== undefined)
|
|
4119
|
-
fieldPaths.push("attributes.en.description");
|
|
4120
|
-
for (const [name, locales] of Object.entries(customFields ?? {})) {
|
|
4121
|
-
if (!locales || typeof locales !== "object" || Array.isArray(locales))
|
|
4122
|
-
continue;
|
|
4123
|
-
for (const locale of Object.keys(locales)) {
|
|
4124
|
-
const path = `customFields.${name}.${locale}`;
|
|
4125
|
-
if (isValidFieldPath(path))
|
|
4126
|
-
fieldPaths.push(path);
|
|
4127
|
-
}
|
|
4128
|
-
}
|
|
4129
|
-
for (const image of images)
|
|
4130
|
-
fieldPaths.push(`media.${image.role}`);
|
|
4131
|
-
const ownershipBeforeSeed = await this.catalog.resolveFieldOwners(entityId, storeId);
|
|
4132
|
-
const seedPaths = fieldPaths.filter((path) => !ownershipBeforeSeed.has(path));
|
|
4133
|
-
const seeded = await this.catalog.seedImportedFieldOwnership(entityId, storeId, seedPaths);
|
|
4134
|
-
if (!seeded.ok)
|
|
4135
|
-
return PluginErr(seeded.error.message);
|
|
4136
|
-
for (const path of seedPaths)
|
|
4137
|
-
ownershipBeforeSeed.set(path, "store");
|
|
4138
|
-
const owners = ownershipBeforeSeed;
|
|
4139
|
-
const remoteHash = hash(product);
|
|
4140
|
-
const outboundEcho = this.isOutboundEcho(mapping, remoteItem);
|
|
4141
|
-
const shared = await this.detectSharedConflicts(entityId, storeId, entity, mapping, remoteItem, owners, fieldPaths, remoteHash, outboundEcho ? { certifiedPaths: new Set(mapping.outboundFieldPaths ?? []) } : undefined);
|
|
4142
|
-
const persistedConflicts = await this.persistCatalogConflicts(orgId, shared.conflicts, requireUserId(actor));
|
|
4143
|
-
if (!persistedConflicts.ok)
|
|
4144
|
-
return persistedConflicts;
|
|
4145
|
-
const owned = this.filterOwnedFieldsAtPaths(remoteItem, owners, fieldPaths);
|
|
4146
|
-
const heldPaths = [...new Set([...(mapping.heldFieldPaths ?? []), ...shared.paths])];
|
|
4147
|
-
// Same revocation as the reconcile path: a newly held path cancels any force
|
|
4148
|
-
// left from an earlier resolution, so a webhook-raised conflict cannot be
|
|
4149
|
-
// pre-empted by an operator's answer to a previous one.
|
|
4150
|
-
const survivingForcedPaths = (mapping.forcedPushFieldPaths ?? []).filter((path) => !heldPaths.includes(path));
|
|
4151
|
-
const held = this.filterConflictingFields(owned.writable, heldPaths);
|
|
4152
|
-
const blockedPaths = new Set([
|
|
4153
|
-
...owned.skipped,
|
|
4154
|
-
...heldPaths,
|
|
4155
|
-
...(!fieldPaths.includes("attributes.en.title") ? ["attributes.en.title"] : []),
|
|
4156
|
-
]);
|
|
4157
|
-
const skipped = owned.skipped.map((fieldPath) => ({ entityId, fieldPath }));
|
|
4158
|
-
const conflicts = shared.conflicts.map(({ platformValue: _platformValue, storeValue: _storeValue, ...conflict }) => conflict);
|
|
4159
|
-
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}).`);
|
|
4160
|
-
const writable = held.writable;
|
|
4161
|
-
const updateInput = {};
|
|
4162
|
-
if (fieldPaths.includes("entity.slug") && ownerAllows(owners, "entity.slug") && !blockedPaths.has("entity.slug") && typeof writable.slug === "string") {
|
|
4163
|
-
const resolved = (await this.resolveStoreSlugs(orgId, storeId, [writable.slug])).get(writable.slug);
|
|
4164
|
-
const slug = resolved ? this.slugToKeep(entity.slug, resolved) : writable.slug;
|
|
4165
|
-
if (entity.slug !== slug)
|
|
4166
|
-
updateInput.slug = slug;
|
|
4167
|
-
}
|
|
4168
|
-
if (Object.keys(writable.metadata ?? {}).length > 0) {
|
|
4169
|
-
const remoteEntityMetadata = mergeMetadata(entity.metadata, writable.metadata ?? {});
|
|
4170
|
-
if (hash(remoteEntityMetadata) !== hash(entity.metadata ?? {}))
|
|
4171
|
-
updateInput.metadata = remoteEntityMetadata;
|
|
4172
|
-
}
|
|
4173
|
-
if (fieldPaths.includes("entity.status") && ownerAllows(owners, "entity.status") && !blockedPaths.has("entity.status") && typeof writable.status === "string" && writable.status !== entity.status) {
|
|
4174
|
-
updateInput.status = writable.status;
|
|
4175
|
-
updateInput.isVisible = writable.status === "active";
|
|
4176
|
-
}
|
|
4177
|
-
if (Object.keys(updateInput).length > 0) {
|
|
4178
|
-
const updated = await this.catalog.update(entityId, updateInput, actor, CHANNEL_CONVERGENCE_CTX);
|
|
4179
|
-
if (!updated.ok)
|
|
4180
|
-
return PluginErr(updated.error.message);
|
|
4181
|
-
}
|
|
4182
|
-
const attributes = await this.setCatalogAttributesIfWritable(entityId, writable, actor, blockedPaths, CHANNEL_CONVERGENCE_CTX);
|
|
4183
|
-
if (!attributes.ok)
|
|
4184
|
-
return attributes;
|
|
4185
|
-
const levels = Array.isArray(product.variants) ? product.variants : [];
|
|
4186
|
-
for (const variant of levels) {
|
|
4187
|
-
const externalId = String(variant.id ?? variant.variation_id ?? "");
|
|
4188
|
-
const available = variant.inventory_quantity ?? variant.stock_quantity;
|
|
4189
|
-
if (externalId && available !== undefined)
|
|
4190
|
-
await this.setMappedInventory(orgId, storeId, externalId, Number(available), actor);
|
|
4191
|
-
}
|
|
4192
|
-
const revisionMarkers = await this.catalog.repository.findRevisionMarkers(entityId);
|
|
4193
|
-
const lastSyncedAt = revisionMarkers.at(-1)?.createdAt ?? mapping.lastSyncedAt;
|
|
4194
|
-
await this.db.update(channelEntityMap).set({
|
|
4195
|
-
syncHash: remoteHash,
|
|
4196
|
-
lastSyncedAt,
|
|
4197
|
-
heldFieldPaths: heldPaths,
|
|
4198
|
-
forcedPushFieldPaths: survivingForcedPaths,
|
|
4199
|
-
}).where(eq(channelEntityMap.id, mapping.id));
|
|
4200
|
-
return Ok({ skipped, conflicts, warnings });
|
|
4201
|
-
}
|
|
4202
|
-
async createRefundRequest(orgId, store, data, actor) {
|
|
4203
|
-
const remoteRefundId = String(data.id ?? data.refund_id ?? "");
|
|
4204
|
-
const orderId = await this.resolveOrderId(orgId, store.id, data);
|
|
4205
|
-
if (!remoteRefundId || !orderId)
|
|
4206
|
-
return PluginErr("Refund webhook is missing a mapped order or refund id.", "REFUND_MAPPING_MISSING");
|
|
4157
|
+
async createRefundRequest(orgId, store, event, actor) {
|
|
4158
|
+
const { remoteRefundId } = event;
|
|
4159
|
+
const orderId = await this.orderForRemote(orgId, store.id, event.remoteOrderId);
|
|
4160
|
+
// A refund on an order this store never received from us is the store's own business.
|
|
4161
|
+
if (!orderId)
|
|
4162
|
+
return Ok(null);
|
|
4207
4163
|
const existing = await this.db.select().from(channelRefundRequests).where(and(eq(channelRefundRequests.storeId, store.id), eq(channelRefundRequests.remoteRefundId, remoteRefundId)));
|
|
4208
4164
|
if (existing[0])
|
|
4209
4165
|
return Ok(existing[0]);
|
|
4210
|
-
// Shopify names the refunded lines in `refund_line_items`, each with its order line under
|
|
4211
|
-
// `line_item`; other providers send a flat `line_items`. Both reduce to { variant_id, quantity }.
|
|
4212
|
-
const lineData = Array.isArray(data.refund_line_items)
|
|
4213
|
-
? data.refund_line_items.map((raw) => {
|
|
4214
|
-
const entry = (raw ?? {});
|
|
4215
|
-
const orderLine = (entry.line_item ?? {});
|
|
4216
|
-
return { variant_id: orderLine.variant_id, product_id: orderLine.product_id, quantity: entry.quantity };
|
|
4217
|
-
})
|
|
4218
|
-
: Array.isArray(data.line_items) ? data.line_items : Array.isArray(data.lineItems) ? data.lineItems : [];
|
|
4219
4166
|
const orderLines = await this.db.select().from(orderLineItems).where(eq(orderLineItems.orderId, orderId));
|
|
4220
4167
|
const mappings = await this.db.select().from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, store.id)));
|
|
4221
4168
|
const refundLines = [];
|
|
4222
|
-
let clean =
|
|
4223
|
-
for (const
|
|
4224
|
-
const
|
|
4225
|
-
const
|
|
4226
|
-
const quantity = Number(line.quantity ?? 0);
|
|
4169
|
+
let clean = event.lines.length > 0;
|
|
4170
|
+
for (const line of event.lines) {
|
|
4171
|
+
const externalId = line.externalVariantId;
|
|
4172
|
+
const quantity = line.quantity;
|
|
4227
4173
|
const mapping = mappings.find((item) => item.externalId === externalId);
|
|
4228
4174
|
const orderLine = mapping ? orderLines.find((item) => item.variantId === mapping.variantId || item.entityId === mapping.entityId) : undefined;
|
|
4229
4175
|
if (!orderLine || !Number.isInteger(quantity) || quantity < 1 || quantity > orderLine.quantity - orderLine.refundedQuantity)
|
|
@@ -4381,32 +4327,28 @@ export class ChannelConnectorService {
|
|
|
4381
4327
|
return Ok(cancelled);
|
|
4382
4328
|
}
|
|
4383
4329
|
/**
|
|
4384
|
-
* One core fulfilment record per store
|
|
4385
|
-
*
|
|
4386
|
-
*
|
|
4387
|
-
* records every line not yet fulfilled.
|
|
4330
|
+
* One core fulfilment record per store parcel, keyed on the store's parcel id
|
|
4331
|
+
* (`metadata.channelFulfillmentId`) so a replay records nothing twice. Each records the lines it
|
|
4332
|
+
* shipped, matched by the store's variant id; one whose lines cannot be matched (or that names
|
|
4333
|
+
* none) records every line not yet fulfilled.
|
|
4388
4334
|
*/
|
|
4389
|
-
async recordChannelFulfillments(orgId, storeId, orderId,
|
|
4390
|
-
const parsed = channelFulfillmentsSchema.safeParse(data.fulfillments ?? []);
|
|
4391
|
-
if (!parsed.success)
|
|
4392
|
-
return PluginErr(`Channel order fulfilments did not parse: ${parsed.error.message}`);
|
|
4335
|
+
async recordChannelFulfillments(orgId, storeId, orderId, shipments, actor) {
|
|
4393
4336
|
const existing = await this.db.select({ metadata: fulfillmentRecords.metadata }).from(fulfillmentRecords).where(eq(fulfillmentRecords.orderId, orderId));
|
|
4394
4337
|
const recorded = new Set(existing.map((row) => String(row.metadata?.channelFulfillmentId ?? "")));
|
|
4395
4338
|
const lines = await this.db.select({ id: orderLineItems.id, variantId: orderLineItems.variantId, quantity: orderLineItems.quantity }).from(orderLineItems).where(eq(orderLineItems.orderId, orderId));
|
|
4396
4339
|
const fulfillment = this.services.fulfillment;
|
|
4397
4340
|
let created = 0;
|
|
4398
|
-
for (const parcel of
|
|
4399
|
-
|
|
4400
|
-
if (recorded.has(parcelId) || parcel.status === "cancelled" || parcel.status === "error" || parcel.status === "failure")
|
|
4341
|
+
for (const parcel of shipments) {
|
|
4342
|
+
if (recorded.has(parcel.remoteId))
|
|
4401
4343
|
continue;
|
|
4402
|
-
const externalIds = parcel.
|
|
4344
|
+
const externalIds = parcel.lines.map((line) => line.externalVariantId);
|
|
4403
4345
|
const mapped = externalIds.length === 0 ? [] : await this.db
|
|
4404
4346
|
.select({ externalId: channelEntityMap.externalId, variantId: channelEntityMap.variantId })
|
|
4405
4347
|
.from(channelEntityMap)
|
|
4406
4348
|
.where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.kind, "variant"), inArray(channelEntityMap.externalId, externalIds)));
|
|
4407
4349
|
const variantFor = new Map(mapped.map((row) => [row.externalId, row.variantId]));
|
|
4408
|
-
const matched = parcel.
|
|
4409
|
-
const variantId =
|
|
4350
|
+
const matched = parcel.lines.flatMap((line) => {
|
|
4351
|
+
const variantId = variantFor.get(line.externalVariantId);
|
|
4410
4352
|
const orderLine = variantId == null ? undefined : lines.find((candidate) => candidate.variantId === variantId);
|
|
4411
4353
|
return orderLine ? [{ orderLineItemId: orderLine.id, quantity: line.quantity }] : [];
|
|
4412
4354
|
});
|
|
@@ -4416,15 +4358,15 @@ export class ChannelConnectorService {
|
|
|
4416
4358
|
const result = await fulfillment.createFulfillment({
|
|
4417
4359
|
orderId,
|
|
4418
4360
|
lineItems,
|
|
4419
|
-
...(parcel.
|
|
4420
|
-
...(parcel.
|
|
4421
|
-
...(parcel.
|
|
4361
|
+
...(parcel.carrier ? { carrier: parcel.carrier } : {}),
|
|
4362
|
+
...(parcel.trackingNumber ? { trackingNumber: parcel.trackingNumber } : {}),
|
|
4363
|
+
...(parcel.trackingUrl ? { trackingUrl: parcel.trackingUrl } : {}),
|
|
4422
4364
|
status: "shipped",
|
|
4423
|
-
metadata: { channelFulfillmentId:
|
|
4365
|
+
metadata: { channelFulfillmentId: parcel.remoteId, storeId, ...(parcel.source ? { trackingSource: parcel.source } : {}) },
|
|
4424
4366
|
}, actor);
|
|
4425
4367
|
if (!result.ok)
|
|
4426
4368
|
return PluginErr(result.error?.message ?? "Could not record the store's fulfilment.");
|
|
4427
|
-
recorded.add(
|
|
4369
|
+
recorded.add(parcel.remoteId);
|
|
4428
4370
|
created += 1;
|
|
4429
4371
|
}
|
|
4430
4372
|
return Ok(created);
|