@porulle/plugin-channel-connector 0.73.3 → 0.74.1
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 +114 -30
- 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 +110 -28
- 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/index.d.ts
CHANGED
|
@@ -27,5 +27,5 @@ export declare const CHANNEL_MAX_BATCHES_PER_SWEEP = 5000;
|
|
|
27
27
|
export { signState, verifyState } from "./oauth-state.js";
|
|
28
28
|
export type { BackfillCatalogOptions, BackfillCatalogReport, BuildCatalogPushItemsOptions, BuildCatalogPushItemsResult, CatalogPushAssemblyField, CatalogPushAssemblyImage, CatalogPushAssemblyItem, CatalogPushPreviewBefore, CatalogPushPreviewBeforeStatus, CatalogPushPreviewDiff, CatalogPushPreviewItem, CatalogPushPreviewResult, CatalogPushPreviewUnavailable, PushCatalogToStoreResult, CatalogPushJobResult, CatalogConvergenceFailure, CatalogFieldConflict, CatalogFieldSkip, CatalogPushFieldSkip, CatalogPushSkipReason, CatalogConflictState, CatalogWriteSettings, AfterStoreConnected, BindConnectedStore, ChannelComplianceData, ChannelConnectorPluginOptions, ConfineStores, ConnectClaims, OnStoreCatalogChanged, StoreConnectActor, StoreReadContext, ChannelStockLine, ExportState, PublicConnectedStore, ReconcileReport, } from "./service.js";
|
|
29
29
|
export type { OAuthStatePayload, OAuthStateResult } from "./oauth-state.js";
|
|
30
|
-
export type { ChannelCatalogPush, ChannelCatalogPushEvent, ChannelCatalogConflict, ChannelCatalogConflictEvent, ChannelEntityMapEntry, ChannelExportEvent, ChannelOrderExport, ChannelRefundEvent, ChannelRefundRequest, ConnectedStore, } from "./schema.js";
|
|
30
|
+
export type { ChannelCatalogPush, ChannelCatalogPushEvent, ChannelCatalogConflict, ChannelCatalogConflictEvent, ChannelEntityMapEntry, ChannelExportEvent, ChannelOrderExport, ChannelRefundEvent, ChannelRefundRequest, ConnectedStore, StoreHealth, } from "./schema.js";
|
|
31
31
|
export declare function channelConnectorPlugin(options?: ChannelConnectorPluginOptions): import("@porulle/core").CommercePlugin;
|
package/dist/index.js
CHANGED
|
@@ -143,6 +143,12 @@ function callbackUri(raw, redirect, provider) {
|
|
|
143
143
|
}
|
|
144
144
|
return new URL(`/api/channels/oauth/${provider}/callback`, origin).toString();
|
|
145
145
|
}
|
|
146
|
+
const refreshOrderInput = z.object({ orgId: z.string(), storeId: z.string(), remoteOrderId: z.string() });
|
|
147
|
+
const completeConnectInput = z.object({
|
|
148
|
+
orgId: z.string(),
|
|
149
|
+
storeId: z.string(),
|
|
150
|
+
actor: z.object({ orgId: z.string(), userId: z.string(), claims: z.record(z.string(), z.string()) }),
|
|
151
|
+
});
|
|
146
152
|
export function channelConnectorPlugin(options = {}) {
|
|
147
153
|
const jobs = [
|
|
148
154
|
{
|
|
@@ -300,7 +306,34 @@ export function channelConnectorPlugin(options = {}) {
|
|
|
300
306
|
});
|
|
301
307
|
if (!result.ok)
|
|
302
308
|
throw new Error(result.error);
|
|
303
|
-
return { output: { processed:
|
|
309
|
+
return { output: { processed: result.value.processed } };
|
|
310
|
+
},
|
|
311
|
+
},
|
|
312
|
+
{
|
|
313
|
+
// A read point found a store order not read for a while; its current state is applied as if
|
|
314
|
+
// the delivery for it had arrived. Serialized with that store's deliveries.
|
|
315
|
+
slug: "channel/refresh-order",
|
|
316
|
+
concurrency: { key: (input) => `webhook:${String(input.storeId)}` },
|
|
317
|
+
handler: async ({ input, ctx }) => {
|
|
318
|
+
const parsed = refreshOrderInput.parse(input);
|
|
319
|
+
const service = new ChannelConnectorService(ctx.db, ctx.services, options);
|
|
320
|
+
const result = await service.refreshRemoteOrder(parsed.orgId, parsed.storeId, parsed.remoteOrderId);
|
|
321
|
+
if (!result.ok)
|
|
322
|
+
throw new Error(result.error);
|
|
323
|
+
return { output: result.value };
|
|
324
|
+
},
|
|
325
|
+
},
|
|
326
|
+
{
|
|
327
|
+
// The second half of a connect whose callback had to be answered at once (WooCommerce's): subscribe
|
|
328
|
+
// the store and start its first import. Not retried: a failure leaves the store in `error` with
|
|
329
|
+
// the reason the merchant reads, and reconnecting is the retry.
|
|
330
|
+
slug: "channel/complete-connect",
|
|
331
|
+
concurrency: { key: (input) => `connect:${String(input.storeId)}` },
|
|
332
|
+
handler: async ({ input, ctx }) => {
|
|
333
|
+
const parsed = completeConnectInput.parse(input);
|
|
334
|
+
const service = new ChannelConnectorService(ctx.db, ctx.services, options);
|
|
335
|
+
const result = await service.completeConnect(parsed.orgId, parsed.storeId, parsed.actor);
|
|
336
|
+
return { output: result.ok ? { status: result.value.status } : { error: result.code ?? "COMPLETE_CONNECT_FAILED", message: result.error } };
|
|
304
337
|
},
|
|
305
338
|
},
|
|
306
339
|
{
|
|
@@ -366,7 +399,18 @@ export function channelConnectorPlugin(options = {}) {
|
|
|
366
399
|
if (!userId)
|
|
367
400
|
return oauthError(403, "USER_REQUIRED", "Connecting a store needs a signed-in user.");
|
|
368
401
|
const typed = String(query.shop ?? query.store ?? "");
|
|
369
|
-
|
|
402
|
+
// A connector that can look at the store first does, so the merchant is told what is wrong
|
|
403
|
+
// here rather than landing on a broken page at their own site.
|
|
404
|
+
let storeDomain;
|
|
405
|
+
if (connector.probeStore) {
|
|
406
|
+
const probed = await connector.probeStore(typed);
|
|
407
|
+
if (!probed.ok)
|
|
408
|
+
return connectOutcome(oauth.postConnectRedirect, { error: probed.error.code, message: probed.error.message });
|
|
409
|
+
storeDomain = probed.value.storeDomain;
|
|
410
|
+
}
|
|
411
|
+
else {
|
|
412
|
+
storeDomain = connector.normalizeStoreDomain ? connector.normalizeStoreDomain(typed) : typed;
|
|
413
|
+
}
|
|
370
414
|
if (!storeDomain)
|
|
371
415
|
return connectOutcome(oauth.postConnectRedirect, { error: "INVALID_STORE_DOMAIN", message: `"${typed}" does not name a ${provider} store.` });
|
|
372
416
|
// Refused HERE, before the merchant reaches the provider: a grant issued for a shop retires
|
|
@@ -395,6 +439,11 @@ export function channelConnectorPlugin(options = {}) {
|
|
|
395
439
|
return connectOutcome(oauth.postConnectRedirect, { error: authUrl.error.code, message: authUrl.error.message });
|
|
396
440
|
return oauthRedirect(authUrl.value);
|
|
397
441
|
});
|
|
442
|
+
// Two shapes of callback reach here. Shopify's is the merchant's BROWSER (GET): the connection
|
|
443
|
+
// completes in the request and the browser lands on the outcome. WooCommerce's is the STORE
|
|
444
|
+
// posting the keys (POST), and it deletes them on anything but a 200 or after 60 seconds, so
|
|
445
|
+
// the keys are saved, the answer is immediate, and the rest runs as `channel/complete-connect`.
|
|
446
|
+
// Its browser then returns separately (GET with `return=1`) and lands on the store's real state.
|
|
398
447
|
const handleOAuthCallback = async ({ params, raw }) => {
|
|
399
448
|
const oauth = options.oauth;
|
|
400
449
|
if (!oauth?.stateSecret || !oauth.postConnectRedirect)
|
|
@@ -407,36 +456,54 @@ export function channelConnectorPlugin(options = {}) {
|
|
|
407
456
|
return oauthError(501, "OAUTH_UNSUPPORTED", `Connector "${provider}" does not support OAuth onboarding.`);
|
|
408
457
|
const request = raw.req.raw;
|
|
409
458
|
const requestUrl = new URL(request.url);
|
|
459
|
+
const posted = request.method === "POST";
|
|
410
460
|
const state = requestUrl.searchParams.get("state");
|
|
411
|
-
|
|
461
|
+
// A store posting keys is answered with a status it understands; a browser is sent to the outcome.
|
|
462
|
+
const refused = (error, message, status = 400) => posted
|
|
463
|
+
? oauthError(status, error, message)
|
|
464
|
+
: connectOutcome(oauth.postConnectRedirect, { error, message });
|
|
412
465
|
if (!state)
|
|
413
466
|
return refused("INVALID_OAUTH_STATE", "The connection was not started here, or its link was altered.");
|
|
414
|
-
const landing = provider === "woocommerce" && request.method === "GET" && requestUrl.searchParams.get("return") === "1";
|
|
415
467
|
const verified = verifyState(state, oauth.stateSecret, Math.floor(Date.now() / 1000));
|
|
416
468
|
if (!verified.ok || verified.value.provider !== provider)
|
|
417
469
|
return refused("INVALID_OAUTH_STATE", "The connection link expired; start again.");
|
|
418
|
-
if (
|
|
419
|
-
|
|
470
|
+
if (!posted && requestUrl.searchParams.get("return") === "1") {
|
|
471
|
+
if (requestUrl.searchParams.get("success") === "0")
|
|
472
|
+
return refused("CONNECT_DECLINED", "You declined the connection in your store, so nothing was connected.");
|
|
473
|
+
const landed = await service.storeByDomain(verified.value.orgId, provider, verified.value.shopDomain);
|
|
474
|
+
if (!landed)
|
|
475
|
+
return refused("CONNECT_NOT_RECEIVED", "Your store did not send its keys. Approve the connection again.");
|
|
476
|
+
return connectOutcome(oauth.postConnectRedirect, { connected: landed.id });
|
|
477
|
+
}
|
|
420
478
|
const [consumed] = await db.insert(processedWebhookEvents).values({
|
|
421
479
|
eventId: oauthStateEventId(verified.value.jti),
|
|
422
480
|
provider: `oauth:${provider}`,
|
|
423
481
|
eventType: "oauth_state",
|
|
424
482
|
}).onConflictDoNothing().returning({ id: processedWebhookEvents.id });
|
|
425
483
|
if (!consumed)
|
|
426
|
-
return refused("OAUTH_STATE_REPLAYED", "This connection link was already used; start again.");
|
|
427
|
-
const completed = await connector.completeAuth(request, { storeDomain: verified.value.shopDomain });
|
|
484
|
+
return refused("OAUTH_STATE_REPLAYED", "This connection link was already used; start again.", 409);
|
|
485
|
+
const completed = await connector.completeAuth(request, { storeDomain: verified.value.shopDomain, state });
|
|
428
486
|
if (!completed.ok)
|
|
429
487
|
return refused(completed.error.code, completed.error.message);
|
|
430
488
|
if (completed.value.storeDomain !== verified.value.shopDomain)
|
|
431
489
|
return refused("OAUTH_STORE_MISMATCH", "The store that answered is not the one the connection was started for.");
|
|
432
|
-
const
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
return
|
|
439
|
-
|
|
490
|
+
const actor = { orgId: verified.value.orgId, userId: verified.value.userId, claims: verified.value.claims };
|
|
491
|
+
const input = { provider, storeDomain: verified.value.shopDomain, credentials: completed.value.credentials };
|
|
492
|
+
if (!posted) {
|
|
493
|
+
const connected = await service.connectStore(verified.value.orgId, input, actor);
|
|
494
|
+
if (!connected.ok)
|
|
495
|
+
return refused(connected.code ?? "STORE_CONNECTION_FAILED", connected.error);
|
|
496
|
+
return connectOutcome(oauth.postConnectRedirect, { connected: connected.value.id });
|
|
497
|
+
}
|
|
498
|
+
const saved = await service.saveConnectingStore(verified.value.orgId, input, actor);
|
|
499
|
+
if (!saved.ok)
|
|
500
|
+
return refused(saved.code ?? "STORE_CONNECTION_FAILED", saved.error, 409);
|
|
501
|
+
await ctx.services.jobs.enqueue("channel/complete-connect", { orgId: verified.value.orgId, storeId: saved.value.id, actor }, {
|
|
502
|
+
organizationId: verified.value.orgId,
|
|
503
|
+
concurrencyKey: `connect:${saved.value.id}`,
|
|
504
|
+
supersedes: false,
|
|
505
|
+
});
|
|
506
|
+
return new Response(JSON.stringify({ data: { received: true } }), { status: 200, headers: { "content-type": "application/json" } });
|
|
440
507
|
};
|
|
441
508
|
channels.get("/oauth/{provider}/callback")
|
|
442
509
|
.summary("Complete channel OAuth onboarding")
|
|
@@ -446,31 +513,44 @@ export function channelConnectorPlugin(options = {}) {
|
|
|
446
513
|
.summary("Receive channel OAuth credentials")
|
|
447
514
|
.params(z.object({ provider: z.string().min(1) }))
|
|
448
515
|
.handler(handleOAuthCallback);
|
|
516
|
+
// Every delivery for a provider that signs per STORE (WooCommerce) arrives here. The answer is
|
|
517
|
+
// 200 for anything verified, before any work: WooCommerce never retries a delivery, counts every
|
|
518
|
+
// non-2xx (and every redirect) as a failure, and silently disables a subscription after repeated
|
|
519
|
+
// failures. The work runs as a job, which retries through the queue, never through the store.
|
|
449
520
|
channels.post("/webhooks/{storeId}")
|
|
450
521
|
.summary("Receive a channel webhook")
|
|
522
|
+
.params(z.object({ storeId: z.string().uuid() }))
|
|
451
523
|
.handler(async ({ params, raw }) => {
|
|
452
524
|
const context = raw;
|
|
453
|
-
const
|
|
454
|
-
const [store] = await db.select().from(connectedStores).where(eq(connectedStores.id, storeId));
|
|
525
|
+
const [store] = await db.select().from(connectedStores).where(eq(connectedStores.id, params.storeId));
|
|
455
526
|
if (!store || !store.webhookSecret)
|
|
456
|
-
return context.json({ error: { code: "
|
|
527
|
+
return context.json({ error: { code: "NOT_FOUND", message: "No store receives webhooks here." } }, 404);
|
|
457
528
|
const connector = service.getConnector(store.provider);
|
|
458
529
|
if (!connector?.verifyWebhook)
|
|
459
|
-
return context.json({ error: { code: "
|
|
530
|
+
return context.json({ error: { code: "NOT_FOUND", message: "This store's provider does not deliver per-store webhooks." } }, 404);
|
|
460
531
|
const verified = await connector.verifyWebhook(store, context.req.raw);
|
|
461
532
|
if (!verified.ok)
|
|
462
533
|
return context.json({ error: { code: "UNAUTHORIZED", message: "Invalid webhook signature." } }, 401);
|
|
463
|
-
|
|
464
|
-
if (
|
|
534
|
+
// The unsigned ping a provider sends when a subscription is created carries nothing to do.
|
|
535
|
+
if (verified.value === null)
|
|
536
|
+
return context.json({ data: { received: true } });
|
|
537
|
+
const delivery = verified.value;
|
|
538
|
+
// The connector's key is unique within one store; the store id makes it unique here.
|
|
539
|
+
const [marked] = await db.insert(processedWebhookEvents).values({ eventId: `${store.id}:${delivery.id}`, provider: store.provider, eventType: delivery.type }).onConflictDoNothing().returning({ id: processedWebhookEvents.id });
|
|
540
|
+
if (!marked)
|
|
465
541
|
return context.json({ data: { received: true, duplicate: true } });
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
542
|
+
try {
|
|
543
|
+
await ctx.services.jobs.enqueue("channel/apply-webhook", { orgId: store.organizationId, storeId: store.id, id: delivery.id, topic: delivery.type, data: delivery.data }, {
|
|
544
|
+
organizationId: store.organizationId,
|
|
545
|
+
concurrencyKey: `webhook:${store.id}`,
|
|
546
|
+
supersedes: false,
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
catch (error) {
|
|
550
|
+
await db.delete(processedWebhookEvents).where(eq(processedWebhookEvents.id, marked.id));
|
|
551
|
+
return context.json({ error: { code: "WEBHOOK_NOT_ACCEPTED", message: error instanceof Error ? error.message : "The delivery could not be queued." } }, 503);
|
|
552
|
+
}
|
|
553
|
+
return context.json({ data: { received: true } });
|
|
474
554
|
});
|
|
475
555
|
// Every delivery for a provider that signs per APP — Shopify's catalogue, stock, order, uninstall
|
|
476
556
|
// and mandatory compliance topics alike — arrives here, at the one address its app configuration
|
|
@@ -628,6 +708,10 @@ export function channelConnectorPlugin(options = {}) {
|
|
|
628
708
|
const values = input;
|
|
629
709
|
return unwrap(await service.previewCatalogPush(orgId, params.storeId, values.entityIds));
|
|
630
710
|
});
|
|
711
|
+
channels.post("/stores/{id}/health")
|
|
712
|
+
.summary("Check a store's webhooks and key, repairing what can be repaired")
|
|
713
|
+
.permission("channels:connect")
|
|
714
|
+
.handler(async ({ params, orgId, actor, raw }) => unwrap(await service.checkStoreHealth(orgId, params.id, { orgId, actor, raw })));
|
|
631
715
|
channels.post("/stores/{id}/disconnect")
|
|
632
716
|
.summary("Disconnect a channel store")
|
|
633
717
|
.permission("channels:connect")
|
|
@@ -18,12 +18,15 @@ import type { ChannelConnector, ChannelConnectorError, ChannelStore, PluginDb, R
|
|
|
18
18
|
export declare function resolveLiveCredentials(connector: ChannelConnector, db: PluginDb, store: ChannelStore, options?: {
|
|
19
19
|
force?: boolean;
|
|
20
20
|
}): Promise<Result<ChannelStore, ChannelConnectorError>>;
|
|
21
|
+
/** Why a store whose key the provider refused is in `error`. */
|
|
22
|
+
export declare const CREDENTIALS_REJECTED_REASON = "The store no longer accepts the key it gave us (it was revoked, or the user who approved it was removed). Reconnect the store.";
|
|
21
23
|
/**
|
|
22
24
|
* The connector with every store-taking method routed through {@link resolveLiveCredentials}, so no
|
|
23
|
-
* call site can start on a lapsed token by forgetting to ask.
|
|
24
|
-
* credentials never expire.
|
|
25
|
+
* call site can start on a lapsed token by forgetting to ask.
|
|
25
26
|
*
|
|
26
27
|
* A call the provider answers with {@link CHANNEL_CREDENTIALS_REJECTED} — a token retired before its
|
|
27
|
-
* stated expiry — is retried ONCE on credentials refreshed by force
|
|
28
|
+
* stated expiry — is retried ONCE on credentials refreshed by force, for a connector that can refresh.
|
|
29
|
+
* A rejection that cannot be refreshed away (a second one, or a connector whose keys never expire,
|
|
30
|
+
* such as WooCommerce's) marks the store `error` so it reads as "reconnect".
|
|
28
31
|
*/
|
|
29
32
|
export declare function withLiveCredentials(connector: ChannelConnector, db: PluginDb): ChannelConnector;
|
package/dist/live-credentials.js
CHANGED
|
@@ -40,17 +40,21 @@ export async function resolveLiveCredentials(connector, db, store, options = {})
|
|
|
40
40
|
return Err({ code: "STORE_NOT_FOUND", message: `Connected store ${store.id} no longer exists.`, retriable: false });
|
|
41
41
|
return Ok({ ...store, credentials: current.credentials });
|
|
42
42
|
}
|
|
43
|
+
/** Why a store whose key the provider refused is in `error`. */
|
|
44
|
+
export const CREDENTIALS_REJECTED_REASON = "The store no longer accepts the key it gave us (it was revoked, or the user who approved it was removed). Reconnect the store.";
|
|
45
|
+
async function markCredentialsRejected(db, storeId) {
|
|
46
|
+
await db.update(connectedStores).set({ status: "error", statusReason: CREDENTIALS_REJECTED_REASON, updatedAt: new Date() }).where(eq(connectedStores.id, storeId));
|
|
47
|
+
}
|
|
43
48
|
/**
|
|
44
49
|
* The connector with every store-taking method routed through {@link resolveLiveCredentials}, so no
|
|
45
|
-
* call site can start on a lapsed token by forgetting to ask.
|
|
46
|
-
* credentials never expire.
|
|
50
|
+
* call site can start on a lapsed token by forgetting to ask.
|
|
47
51
|
*
|
|
48
52
|
* A call the provider answers with {@link CHANNEL_CREDENTIALS_REJECTED} — a token retired before its
|
|
49
|
-
* stated expiry — is retried ONCE on credentials refreshed by force
|
|
53
|
+
* stated expiry — is retried ONCE on credentials refreshed by force, for a connector that can refresh.
|
|
54
|
+
* A rejection that cannot be refreshed away (a second one, or a connector whose keys never expire,
|
|
55
|
+
* such as WooCommerce's) marks the store `error` so it reads as "reconnect".
|
|
50
56
|
*/
|
|
51
57
|
export function withLiveCredentials(connector, db) {
|
|
52
|
-
if (!connector.liveCredentials)
|
|
53
|
-
return connector;
|
|
54
58
|
const around = (call) => async (store, ...args) => {
|
|
55
59
|
const current = await resolveLiveCredentials(connector, db, store);
|
|
56
60
|
if (!current.ok)
|
|
@@ -58,10 +62,17 @@ export function withLiveCredentials(connector, db) {
|
|
|
58
62
|
const first = await call.call(connector, current.value, ...args);
|
|
59
63
|
if (first.ok || first.error.code !== CHANNEL_CREDENTIALS_REJECTED)
|
|
60
64
|
return first;
|
|
65
|
+
if (!connector.liveCredentials) {
|
|
66
|
+
await markCredentialsRejected(db, store.id);
|
|
67
|
+
return first;
|
|
68
|
+
}
|
|
61
69
|
const refreshed = await resolveLiveCredentials(connector, db, current.value, { force: true });
|
|
62
70
|
if (!refreshed.ok)
|
|
63
71
|
return refreshed;
|
|
64
|
-
|
|
72
|
+
const second = await call.call(connector, refreshed.value, ...args);
|
|
73
|
+
if (!second.ok && second.error.code === CHANNEL_CREDENTIALS_REJECTED)
|
|
74
|
+
await markCredentialsRejected(db, store.id);
|
|
75
|
+
return second;
|
|
65
76
|
};
|
|
66
77
|
return {
|
|
67
78
|
...connector,
|
|
@@ -76,6 +87,11 @@ export function withLiveCredentials(connector, db) {
|
|
|
76
87
|
...(connector.pushCatalog ? { pushCatalog: around(connector.pushCatalog) } : {}),
|
|
77
88
|
...(connector.reserve ? { reserve: around(connector.reserve) } : {}),
|
|
78
89
|
...(connector.registerWebhooks ? { registerWebhooks: around(connector.registerWebhooks) } : {}),
|
|
90
|
+
...(connector.unregisterWebhooks ? { unregisterWebhooks: around(connector.unregisterWebhooks) } : {}),
|
|
91
|
+
...(connector.webhookHealth ? { webhookHealth: around(connector.webhookHealth) } : {}),
|
|
92
|
+
...(connector.decodeWebhook ? { decodeWebhook: around(connector.decodeWebhook) } : {}),
|
|
93
|
+
...(connector.orderEvents ? { orderEvents: around(connector.orderEvents) } : {}),
|
|
79
94
|
...(connector.cancelOrder ? { cancelOrder: around(connector.cancelOrder) } : {}),
|
|
95
|
+
...(connector.requestReturn ? { requestReturn: around(connector.requestReturn) } : {}),
|
|
80
96
|
};
|
|
81
97
|
}
|
package/dist/mock-connector.d.ts
CHANGED
|
@@ -24,6 +24,68 @@ export declare function mockChannelConnector(options?: MockChannelConnectorOptio
|
|
|
24
24
|
items: ChannelCatalogItem[];
|
|
25
25
|
nextCursor: null;
|
|
26
26
|
}, never>>;
|
|
27
|
+
/** The catalogue as it stands when asked: a test changes `options.catalog` to change "the store". */
|
|
28
|
+
fetchCatalogItems(_store: ChannelStore, externalIds: string[]): Promise<import("@porulle/core").Result<ChannelCatalogItem[], never>>;
|
|
29
|
+
decodeWebhook(_store: ChannelStore, event: import("@porulle/core").ChannelWebhookEvent): Promise<{
|
|
30
|
+
ok: false;
|
|
31
|
+
error: {
|
|
32
|
+
code: string;
|
|
33
|
+
message: string;
|
|
34
|
+
retriable: false;
|
|
35
|
+
};
|
|
36
|
+
} | {
|
|
37
|
+
ok: true;
|
|
38
|
+
value: ({
|
|
39
|
+
kind: "product.changed";
|
|
40
|
+
externalIds: string[];
|
|
41
|
+
} | {
|
|
42
|
+
kind: "product.deleted";
|
|
43
|
+
externalIds: string[];
|
|
44
|
+
} | {
|
|
45
|
+
kind: "inventory.changed";
|
|
46
|
+
levels: {
|
|
47
|
+
externalId: string;
|
|
48
|
+
available: number;
|
|
49
|
+
}[];
|
|
50
|
+
} | {
|
|
51
|
+
kind: "order.cancelled";
|
|
52
|
+
remoteOrderId: string;
|
|
53
|
+
} | {
|
|
54
|
+
kind: "order.fulfilled";
|
|
55
|
+
remoteOrderId: string;
|
|
56
|
+
partial: boolean;
|
|
57
|
+
shipments: {
|
|
58
|
+
remoteId: string;
|
|
59
|
+
lines: {
|
|
60
|
+
externalVariantId: string;
|
|
61
|
+
quantity: number;
|
|
62
|
+
}[];
|
|
63
|
+
carrier?: string;
|
|
64
|
+
trackingNumber?: string;
|
|
65
|
+
trackingUrl?: string;
|
|
66
|
+
source?: string;
|
|
67
|
+
}[];
|
|
68
|
+
} | {
|
|
69
|
+
kind: "refund.created";
|
|
70
|
+
remoteOrderId: string;
|
|
71
|
+
remoteRefundId: string;
|
|
72
|
+
lines: {
|
|
73
|
+
externalVariantId: string;
|
|
74
|
+
quantity: number;
|
|
75
|
+
}[];
|
|
76
|
+
} | {
|
|
77
|
+
kind: "return.updated";
|
|
78
|
+
remoteReturnId: string;
|
|
79
|
+
status: "cancelled" | "approved" | "declined" | "closed";
|
|
80
|
+
} | {
|
|
81
|
+
kind: "connection.revoked";
|
|
82
|
+
} | {
|
|
83
|
+
kind: "compliance.request";
|
|
84
|
+
request: "customer_data" | "customer_redact" | "shop_redact";
|
|
85
|
+
data: Record<string, unknown>;
|
|
86
|
+
})[];
|
|
87
|
+
meta?: Record<string, unknown>;
|
|
88
|
+
}>;
|
|
27
89
|
fetchInventory(_store: ChannelStore, ids: string[] | undefined): Promise<{
|
|
28
90
|
ok: false;
|
|
29
91
|
error: CommerceValidationError;
|
package/dist/mock-connector.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { CommerceValidationError, Err, Ok, defineChannelConnector, } from "@porulle/core";
|
|
2
|
+
import { z } from "zod";
|
|
2
3
|
const defaultCatalog = [{
|
|
3
4
|
externalId: "mock-product-1",
|
|
4
5
|
slug: "mock-channel-product",
|
|
@@ -48,6 +49,27 @@ const defaultCatalog = [{
|
|
|
48
49
|
prices: [{ currency: "USD", amount: 2500 }],
|
|
49
50
|
}],
|
|
50
51
|
}];
|
|
52
|
+
const level = z.object({ externalId: z.string(), available: z.number() });
|
|
53
|
+
const shipment = z.object({
|
|
54
|
+
remoteId: z.string(),
|
|
55
|
+
carrier: z.string().exactOptional(),
|
|
56
|
+
trackingNumber: z.string().exactOptional(),
|
|
57
|
+
trackingUrl: z.string().exactOptional(),
|
|
58
|
+
lines: z.array(z.object({ externalVariantId: z.string(), quantity: z.number().int() })),
|
|
59
|
+
source: z.string().exactOptional(),
|
|
60
|
+
});
|
|
61
|
+
/** A mock delivery's body IS what it means: one {@link ChannelEvent} or a list of them. */
|
|
62
|
+
const channelEventSchema = z.discriminatedUnion("kind", [
|
|
63
|
+
z.object({ kind: z.literal("product.changed"), externalIds: z.array(z.string()) }),
|
|
64
|
+
z.object({ kind: z.literal("product.deleted"), externalIds: z.array(z.string()) }),
|
|
65
|
+
z.object({ kind: z.literal("inventory.changed"), levels: z.array(level) }),
|
|
66
|
+
z.object({ kind: z.literal("order.cancelled"), remoteOrderId: z.string() }),
|
|
67
|
+
z.object({ kind: z.literal("order.fulfilled"), remoteOrderId: z.string(), partial: z.boolean(), shipments: z.array(shipment) }),
|
|
68
|
+
z.object({ kind: z.literal("refund.created"), remoteOrderId: z.string(), remoteRefundId: z.string(), lines: z.array(z.object({ externalVariantId: z.string(), quantity: z.number().int() })) }),
|
|
69
|
+
z.object({ kind: z.literal("return.updated"), remoteReturnId: z.string(), status: z.enum(["approved", "declined", "closed", "cancelled"]) }),
|
|
70
|
+
z.object({ kind: z.literal("connection.revoked") }),
|
|
71
|
+
z.object({ kind: z.literal("compliance.request"), request: z.enum(["customer_data", "customer_redact", "shop_redact"]), data: z.record(z.string(), z.unknown()) }),
|
|
72
|
+
]);
|
|
51
73
|
export function mockChannelConnector(options = {}) {
|
|
52
74
|
const orders = new Map();
|
|
53
75
|
const catalog = new Map();
|
|
@@ -63,6 +85,17 @@ export function mockChannelConnector(options = {}) {
|
|
|
63
85
|
async importCatalog(_store) {
|
|
64
86
|
return Ok({ items: options.catalog ?? defaultCatalog, nextCursor: null });
|
|
65
87
|
},
|
|
88
|
+
/** The catalogue as it stands when asked: a test changes `options.catalog` to change "the store". */
|
|
89
|
+
async fetchCatalogItems(_store, externalIds) {
|
|
90
|
+
const wanted = new Set(externalIds);
|
|
91
|
+
return Ok((options.catalog ?? defaultCatalog).filter((item) => wanted.has(item.externalId)));
|
|
92
|
+
},
|
|
93
|
+
async decodeWebhook(_store, event) {
|
|
94
|
+
const parsed = z.union([channelEventSchema, z.array(channelEventSchema)]).safeParse(event.data);
|
|
95
|
+
if (!parsed.success)
|
|
96
|
+
return Err({ code: "MOCK_WEBHOOK_MALFORMED", message: parsed.error.message, retriable: false });
|
|
97
|
+
return Ok(Array.isArray(parsed.data) ? parsed.data : [parsed.data]);
|
|
98
|
+
},
|
|
66
99
|
async fetchInventory(_store, ids) {
|
|
67
100
|
const requestedIds = ids ?? [];
|
|
68
101
|
options.onFetchInventory?.(requestedIds);
|
package/dist/schema.d.ts
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
import type { ChannelOrderAddress, ChannelPushCatalogItem } from "@porulle/core";
|
|
2
2
|
import type { CatalogFieldMapping } from "./catalog-field-mapping.js";
|
|
3
|
+
/** What the last on-visit check found. `webhooks`: subscriptions all active, recreated, or still failing. */
|
|
4
|
+
export interface StoreHealth {
|
|
5
|
+
checkedAt: string;
|
|
6
|
+
webhooks: "ok" | "repaired" | "failing" | "not_applicable";
|
|
7
|
+
repaired: number;
|
|
8
|
+
missing: string[];
|
|
9
|
+
keyValid: boolean;
|
|
10
|
+
/** Why the check could not finish, when it could not. */
|
|
11
|
+
error?: string;
|
|
12
|
+
}
|
|
3
13
|
export declare const connectedStores: import("drizzle-orm/pg-core/table").PgTableWithColumns<{
|
|
4
14
|
name: "connected_stores";
|
|
5
15
|
schema: undefined;
|
|
@@ -96,14 +106,67 @@ export declare const connectedStores: import("drizzle-orm/pg-core/table").PgTabl
|
|
|
96
106
|
tableName: "connected_stores";
|
|
97
107
|
dataType: "string";
|
|
98
108
|
columnType: "PgText";
|
|
99
|
-
data: "error" | "connected" | "disconnected";
|
|
109
|
+
data: "error" | "connecting" | "connected" | "disconnected";
|
|
100
110
|
driverParam: string;
|
|
101
111
|
notNull: true;
|
|
102
112
|
hasDefault: true;
|
|
103
113
|
isPrimaryKey: false;
|
|
104
114
|
isAutoincrement: false;
|
|
105
115
|
hasRuntimeDefault: false;
|
|
106
|
-
enumValues: ["connected", "disconnected", "error"];
|
|
116
|
+
enumValues: ["connecting", "connected", "disconnected", "error"];
|
|
117
|
+
baseColumn: never;
|
|
118
|
+
identity: undefined;
|
|
119
|
+
generated: undefined;
|
|
120
|
+
}, {}, {}>;
|
|
121
|
+
statusReason: import("@porulle/core/drizzle").PgColumn<{
|
|
122
|
+
name: "status_reason";
|
|
123
|
+
tableName: "connected_stores";
|
|
124
|
+
dataType: "string";
|
|
125
|
+
columnType: "PgText";
|
|
126
|
+
data: string;
|
|
127
|
+
driverParam: string;
|
|
128
|
+
notNull: false;
|
|
129
|
+
hasDefault: false;
|
|
130
|
+
isPrimaryKey: false;
|
|
131
|
+
isAutoincrement: false;
|
|
132
|
+
hasRuntimeDefault: false;
|
|
133
|
+
enumValues: [string, ...string[]];
|
|
134
|
+
baseColumn: never;
|
|
135
|
+
identity: undefined;
|
|
136
|
+
generated: undefined;
|
|
137
|
+
}, {}, {}>;
|
|
138
|
+
health: import("@porulle/core/drizzle").PgColumn<{
|
|
139
|
+
name: "health";
|
|
140
|
+
tableName: "connected_stores";
|
|
141
|
+
dataType: "json";
|
|
142
|
+
columnType: "PgJsonb";
|
|
143
|
+
data: StoreHealth;
|
|
144
|
+
driverParam: unknown;
|
|
145
|
+
notNull: false;
|
|
146
|
+
hasDefault: false;
|
|
147
|
+
isPrimaryKey: false;
|
|
148
|
+
isAutoincrement: false;
|
|
149
|
+
hasRuntimeDefault: false;
|
|
150
|
+
enumValues: undefined;
|
|
151
|
+
baseColumn: never;
|
|
152
|
+
identity: undefined;
|
|
153
|
+
generated: undefined;
|
|
154
|
+
}, {}, {
|
|
155
|
+
$type: StoreHealth;
|
|
156
|
+
}>;
|
|
157
|
+
lastEventAt: import("@porulle/core/drizzle").PgColumn<{
|
|
158
|
+
name: "last_event_at";
|
|
159
|
+
tableName: "connected_stores";
|
|
160
|
+
dataType: "date";
|
|
161
|
+
columnType: "PgTimestamp";
|
|
162
|
+
data: Date;
|
|
163
|
+
driverParam: string;
|
|
164
|
+
notNull: false;
|
|
165
|
+
hasDefault: false;
|
|
166
|
+
isPrimaryKey: false;
|
|
167
|
+
isAutoincrement: false;
|
|
168
|
+
hasRuntimeDefault: false;
|
|
169
|
+
enumValues: undefined;
|
|
107
170
|
baseColumn: never;
|
|
108
171
|
identity: undefined;
|
|
109
172
|
generated: undefined;
|
|
@@ -1161,6 +1224,23 @@ export declare const channelOrderExports: import("drizzle-orm/pg-core/table").Pg
|
|
|
1161
1224
|
identity: undefined;
|
|
1162
1225
|
generated: undefined;
|
|
1163
1226
|
}, {}, {}>;
|
|
1227
|
+
remoteCheckedAt: import("@porulle/core/drizzle").PgColumn<{
|
|
1228
|
+
name: "remote_checked_at";
|
|
1229
|
+
tableName: "channel_order_exports";
|
|
1230
|
+
dataType: "date";
|
|
1231
|
+
columnType: "PgTimestamp";
|
|
1232
|
+
data: Date;
|
|
1233
|
+
driverParam: string;
|
|
1234
|
+
notNull: false;
|
|
1235
|
+
hasDefault: false;
|
|
1236
|
+
isPrimaryKey: false;
|
|
1237
|
+
isAutoincrement: false;
|
|
1238
|
+
hasRuntimeDefault: false;
|
|
1239
|
+
enumValues: undefined;
|
|
1240
|
+
baseColumn: never;
|
|
1241
|
+
identity: undefined;
|
|
1242
|
+
generated: undefined;
|
|
1243
|
+
}, {}, {}>;
|
|
1164
1244
|
attempts: import("@porulle/core/drizzle").PgColumn<{
|
|
1165
1245
|
name: "attempts";
|
|
1166
1246
|
tableName: "channel_order_exports";
|
package/dist/schema.js
CHANGED
|
@@ -6,9 +6,15 @@ export const connectedStores = pgTable("connected_stores", {
|
|
|
6
6
|
provider: text("provider").notNull(),
|
|
7
7
|
credentials: jsonb("credentials").$type().notNull(),
|
|
8
8
|
storeDomain: text("store_domain").notNull(),
|
|
9
|
-
status: text("status", { enum: ["connected", "disconnected", "error"] })
|
|
9
|
+
status: text("status", { enum: ["connecting", "connected", "disconnected", "error"] })
|
|
10
10
|
.notNull()
|
|
11
11
|
.default("connected"),
|
|
12
|
+
/** Why the store is in `error`, in words the merchant can act on. Null in every other status. */
|
|
13
|
+
statusReason: text("status_reason"),
|
|
14
|
+
/** The last check of the store's webhooks and key, made on a merchant's visit (never on a schedule). */
|
|
15
|
+
health: jsonb("health").$type(),
|
|
16
|
+
/** When the store last delivered a webhook we accepted. */
|
|
17
|
+
lastEventAt: timestamp("last_event_at", { withTimezone: true }),
|
|
12
18
|
catalogWriteEnabled: boolean("catalog_write_enabled").notNull().default(false),
|
|
13
19
|
catalogFieldMapping: jsonb("catalog_field_mapping").$type().notNull().default([]),
|
|
14
20
|
catalogCursor: text("catalog_cursor"),
|
|
@@ -103,6 +109,8 @@ export const channelOrderExports = pgTable("channel_order_exports", {
|
|
|
103
109
|
failureKind: text("failure_kind", { enum: ["definitive", "transient"] }),
|
|
104
110
|
remoteOrderId: text("remote_order_id"),
|
|
105
111
|
remoteUrl: text("remote_url"),
|
|
112
|
+
/** When the store's side of the order was last read by a read point catching up a missed delivery. */
|
|
113
|
+
remoteCheckedAt: timestamp("remote_checked_at", { withTimezone: true }),
|
|
106
114
|
attempts: integer("attempts").notNull().default(0),
|
|
107
115
|
lastError: text("last_error"),
|
|
108
116
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|