@porulle/plugin-channel-connector 0.65.1 → 0.67.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 +3 -2
- package/dist/index.js +99 -58
- package/dist/live-credentials.d.ts +24 -0
- package/dist/live-credentials.js +71 -0
- package/dist/oauth-state.d.ts +10 -1
- package/dist/oauth-state.js +10 -9
- package/dist/schema.d.ts +3 -3
- package/dist/service.d.ts +102 -10
- package/dist/service.js +209 -69
- package/package.json +2 -2
- package/src/index.ts +102 -51
- package/src/live-credentials.ts +70 -0
- package/src/oauth-state.ts +13 -7
- package/src/schema.ts +2 -2
- package/src/service.ts +257 -75
package/dist/service.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { Actor, ChannelCatalogItem, ChannelConnector, ChannelOrderSlice, ChannelPushCatalogField, ChannelPushCatalogImage, ChannelPushCatalogItem, ChannelPushCatalogItemOutcome, ChannelPushCatalogResult, ChannelStore, PluginDb, PluginResult, PluginTxFn } from "@porulle/core";
|
|
2
3
|
import type { ChannelCatalogImage } from "@porulle/core";
|
|
3
4
|
import type { FieldOwner, FieldPath } from "@porulle/core";
|
|
4
5
|
import type { JobsAdapter } from "@porulle/core";
|
|
@@ -91,18 +92,61 @@ export interface ChannelComplianceData {
|
|
|
91
92
|
}>;
|
|
92
93
|
}
|
|
93
94
|
/**
|
|
94
|
-
*
|
|
95
|
+
* Which stores the caller may see and act on, resolved per request.
|
|
95
96
|
*
|
|
96
97
|
* Returning `null` means "do not confine" and is the default — every existing consumer keeps the
|
|
97
|
-
* organization-wide behaviour. An array is the complete set of store ids this caller may
|
|
98
|
-
* **`[]` means none**, not "no filter".
|
|
98
|
+
* organization-wide behaviour. An array is the complete set of store ids this caller may reach, and
|
|
99
|
+
* **`[]` means none**, not "no filter". Applied to the list AND to every route that names one store,
|
|
100
|
+
* where a store outside the set answers NOT_FOUND — a refusal must not confirm the store exists.
|
|
99
101
|
*
|
|
100
102
|
* It takes IDS rather than a tenant, deliberately. `vendor`, `seller`, `team` are models a consumer
|
|
101
103
|
* owns; this package is generic commerce and acquiring one of them here would push a marketplace
|
|
102
104
|
* concept into every deployment that has no such thing. The consumer resolves the meaning and hands
|
|
103
105
|
* back the answer.
|
|
104
106
|
*/
|
|
105
|
-
export type
|
|
107
|
+
export type ConfineStores = (context: StoreReadContext) => Promise<readonly string[] | null> | readonly string[] | null;
|
|
108
|
+
/** Who connected a store: the signed-in user who started OAuth, or the caller of `POST /stores`. */
|
|
109
|
+
export interface StoreConnectActor {
|
|
110
|
+
orgId: string;
|
|
111
|
+
userId: string | null;
|
|
112
|
+
/**
|
|
113
|
+
* What the consumer's {@link ConnectClaims} resolved when the connection started — e.g. which of
|
|
114
|
+
* the user's vendors the store is for. Carried in the signed OAuth state, so the callback, which has
|
|
115
|
+
* no session or headers of its own, binds the store to exactly what was chosen at the start.
|
|
116
|
+
*/
|
|
117
|
+
claims: Readonly<Record<string, string>>;
|
|
118
|
+
/** Core's request escape hatch when the connect is a request; absent on the OAuth callback. */
|
|
119
|
+
raw?: unknown;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Resolves, from the request that STARTS a connection, the facts the consumer needs to bind the store
|
|
123
|
+
* later. Throw to refuse the start. Runs for OAuth start and for `POST /stores`.
|
|
124
|
+
*/
|
|
125
|
+
export type ConnectClaims = (context: StoreReadContext) => Promise<Record<string, string>> | Record<string, string>;
|
|
126
|
+
/**
|
|
127
|
+
* Binds a just-connected store to whatever the consumer means by an owner, INSIDE the transaction
|
|
128
|
+
* that wrote the store row — so a store and its binding commit together or not at all. Throw to
|
|
129
|
+
* refuse the connection; the store row is rolled back with it.
|
|
130
|
+
*/
|
|
131
|
+
export type BindConnectedStore = (input: {
|
|
132
|
+
db: PluginDb;
|
|
133
|
+
store: ConnectedStore;
|
|
134
|
+
actor: StoreConnectActor;
|
|
135
|
+
}) => Promise<void>;
|
|
136
|
+
/** Work that follows a committed connection: the first import, provider-attested facts, keys. */
|
|
137
|
+
export type AfterStoreConnected = (input: {
|
|
138
|
+
store: ConnectedStore;
|
|
139
|
+
actor: StoreConnectActor;
|
|
140
|
+
connector: ChannelConnector;
|
|
141
|
+
services: Record<string, unknown>;
|
|
142
|
+
}) => Promise<void>;
|
|
143
|
+
/** Entities a provider webhook just created or changed, converged; the host projects them. */
|
|
144
|
+
export type OnStoreCatalogChanged = (input: {
|
|
145
|
+
orgId: string;
|
|
146
|
+
storeId: string;
|
|
147
|
+
entityIds: string[];
|
|
148
|
+
convergence: CatalogPageConvergence;
|
|
149
|
+
}) => Promise<void>;
|
|
106
150
|
/** What a consumer needs to resolve the caller. `raw` is core's documented request escape hatch. */
|
|
107
151
|
export interface StoreReadContext {
|
|
108
152
|
orgId: string;
|
|
@@ -115,13 +159,30 @@ export interface StoreReadContext {
|
|
|
115
159
|
export interface ChannelConnectorPluginOptions {
|
|
116
160
|
connectors?: ChannelConnector[];
|
|
117
161
|
/**
|
|
118
|
-
* Confines
|
|
162
|
+
* Confines stores to a set the consumer chooses. See {@link ConfineStores}.
|
|
119
163
|
*
|
|
120
164
|
* Absent by default, because narrowing an existing read for every deployment would be a breaking
|
|
121
165
|
* change to a published package. A consumer that needs confinement opts in; one that does not is
|
|
122
166
|
* unaffected.
|
|
123
167
|
*/
|
|
124
|
-
|
|
168
|
+
confineStores?: ConfineStores;
|
|
169
|
+
/** See {@link ConnectClaims}. Absent, a connection carries no claims. */
|
|
170
|
+
connectClaims?: ConnectClaims;
|
|
171
|
+
/** See {@link BindConnectedStore}. */
|
|
172
|
+
bindConnectedStore?: BindConnectedStore;
|
|
173
|
+
/** See {@link AfterStoreConnected}. */
|
|
174
|
+
afterStoreConnected?: AfterStoreConnected;
|
|
175
|
+
/** See {@link OnStoreCatalogChanged}. Absent, a webhook's products converge and nothing else runs. */
|
|
176
|
+
onStoreCatalogChanged?: OnStoreCatalogChanged;
|
|
177
|
+
/**
|
|
178
|
+
* This deployment's public origin. Required for a connector that registers webhooks per store: a
|
|
179
|
+
* provider delivers to an ABSOLUTE address, and a relative one is refused at connect.
|
|
180
|
+
*/
|
|
181
|
+
publicUrl?: string;
|
|
182
|
+
/**
|
|
183
|
+
* `postConnectRedirect` is where the merchant's browser lands after OAuth, with `connected=<storeId>`
|
|
184
|
+
* or `connect_error=<code>` appended — a browser flow ends on a page, never on a JSON error.
|
|
185
|
+
*/
|
|
125
186
|
oauth?: {
|
|
126
187
|
stateSecret: string;
|
|
127
188
|
postConnectRedirect: string;
|
|
@@ -261,6 +322,18 @@ export declare function storeSlugSuffix(storeDomain: string): string;
|
|
|
261
322
|
*/
|
|
262
323
|
export declare function isSlugConflict(error: unknown): boolean;
|
|
263
324
|
export declare const CATALOG_OUTBOUND_SUPPRESSION_WINDOW_MS: number;
|
|
325
|
+
/** What a host writes as an order's `metadata.shippingAddress`, in {@link ChannelOrderAddress}'s spelling. */
|
|
326
|
+
export declare const channelOrderAddressSchema: z.ZodObject<{
|
|
327
|
+
firstName: z.ZodString;
|
|
328
|
+
lastName: z.ZodString;
|
|
329
|
+
line1: z.ZodString;
|
|
330
|
+
line2: z.ZodOptional<z.ZodString>;
|
|
331
|
+
city: z.ZodString;
|
|
332
|
+
region: z.ZodOptional<z.ZodString>;
|
|
333
|
+
postalCode: z.ZodOptional<z.ZodString>;
|
|
334
|
+
countryCode: z.ZodString;
|
|
335
|
+
phone: z.ZodOptional<z.ZodString>;
|
|
336
|
+
}, z.core.$strip>;
|
|
264
337
|
/**
|
|
265
338
|
* A hero is streamed inside the page's own invocation, so it is bounded: a 30 MB TIFF a merchant
|
|
266
339
|
* uploaded by mistake must not buffer into a 128 MiB isolate. Anything larger is reported, not
|
|
@@ -419,15 +492,32 @@ export declare class ChannelConnectorService {
|
|
|
419
492
|
getCatalogWriteSettings(orgId: string, storeId: string): Promise<PluginResult<CatalogWriteSettings>>;
|
|
420
493
|
updateCatalogWriteEnabled(orgId: string, storeId: string, enabled: boolean): Promise<PluginResult<CatalogWriteSettings>>;
|
|
421
494
|
updateCatalogFieldMapping(orgId: string, storeId: string, mapping: unknown): Promise<PluginResult<CatalogWriteSettings>>;
|
|
495
|
+
/**
|
|
496
|
+
* Connects a store, or refreshes the grant of one this organization already holds for the same
|
|
497
|
+
* provider and domain (a reconnect after an uninstall, or a re-authorization) — never a second row
|
|
498
|
+
* for the same shop, which would import it twice.
|
|
499
|
+
*
|
|
500
|
+
* The row and the consumer's binding of it ({@link BindConnectedStore}) are one transaction. A
|
|
501
|
+
* provider that subscribes per store is registered after commit, at an absolute address; the
|
|
502
|
+
* consumer's follow-on work ({@link AfterStoreConnected}) runs after that.
|
|
503
|
+
*/
|
|
422
504
|
connectStore(orgId: string, input: {
|
|
423
505
|
provider: string;
|
|
424
506
|
credentials: Record<string, unknown>;
|
|
425
507
|
storeDomain: string;
|
|
426
508
|
webhookSecret?: string;
|
|
427
|
-
}): Promise<PluginResult<PublicConnectedStore>>;
|
|
428
|
-
|
|
509
|
+
}, actor: StoreConnectActor): Promise<PluginResult<PublicConnectedStore>>;
|
|
510
|
+
/** The store with credentials good for a call the host makes itself, e.g. its own Admin API write. */
|
|
511
|
+
liveStore(orgId: string, storeId: string): Promise<PluginResult<ChannelStore>>;
|
|
512
|
+
/** The consumer's claims for a connection starting from this request. See {@link ConnectClaims}. */
|
|
513
|
+
connectClaims(context: StoreReadContext): Promise<PluginResult<Record<string, string>>>;
|
|
514
|
+
/** The caller's allow-list, or null for unconfined. See {@link ConfineStores}. */
|
|
515
|
+
private allowedStores;
|
|
516
|
+
/** NOT_FOUND for a store outside the caller's set, exactly as for one that does not exist. */
|
|
517
|
+
reachableStore(orgId: string, id: string, context: StoreReadContext | undefined): Promise<PluginResult<ConnectedStore>>;
|
|
518
|
+
disconnectStore(orgId: string, id: string, context?: StoreReadContext): Promise<PluginResult<PublicConnectedStore>>;
|
|
429
519
|
disconnectStoreSystem(orgId: string, id: string, redactDomain?: boolean): Promise<PluginResult<PublicConnectedStore>>;
|
|
430
|
-
getStore(orgId: string, id: string): Promise<PluginResult<PublicConnectedStore>>;
|
|
520
|
+
getStore(orgId: string, id: string, context?: StoreReadContext): Promise<PluginResult<PublicConnectedStore>>;
|
|
431
521
|
listStores(orgId: string, context?: StoreReadContext): Promise<PluginResult<PublicConnectedStore[]>>;
|
|
432
522
|
validateLineStock(orgId: string, lines: ChannelStockLine[], timeoutMs?: number): Promise<void>;
|
|
433
523
|
/**
|
|
@@ -537,6 +627,8 @@ export declare class ChannelConnectorService {
|
|
|
537
627
|
private redactCustomerData;
|
|
538
628
|
private redactShopData;
|
|
539
629
|
private resolveOrderId;
|
|
630
|
+
/** Archives this store's product mapped to `externalId`, unless the platform owns its status. */
|
|
631
|
+
private archiveMappedProduct;
|
|
540
632
|
private setMappedInventory;
|
|
541
633
|
private setInventoryLevel;
|
|
542
634
|
/** This store's mapped variant whose import recorded `metadata.inventoryItemId`; null when none, or when two claim it. */
|
package/dist/service.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import {
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { CommerceInvalidTransitionError, CommerceNotFoundError, CommerceValidationError, Ok, PluginErr, createTxContext, createSystemActor, linkFieldPaths, removeEntityLinks, writeEntityLinks, } from "@porulle/core";
|
|
3
4
|
import { isValidFieldPath, requireUserId } from "@porulle/core";
|
|
4
5
|
import { CHANNEL_CONVERGENCE_CTX } from "./catalog-push-trigger.js";
|
|
6
|
+
import { resolveLiveCredentials, withLiveCredentials } from "./live-credentials.js";
|
|
5
7
|
import { and, desc, eq, inArray, isNull, lte, or, sql } from "@porulle/core/drizzle";
|
|
6
8
|
import { brands, categories, customerAddresses, customers, entityBrands, entityCategories, entityMedia, entityTags, inventoryLevels, mediaAssets, optionTypes, optionValues, orderLineItems, orders, prices, sellableAttributes, sellableCustomFields, sellableEntities, sellableEntityRevisions, entityFieldDefinitions, tags, variants, variantOptionValues, } from "@porulle/core/schema";
|
|
7
9
|
import { planAbsentArchives } from "./deletion-policy.js";
|
|
@@ -444,6 +446,28 @@ async function withTimeout(promise, timeoutMs) {
|
|
|
444
446
|
clearTimeout(timer);
|
|
445
447
|
}
|
|
446
448
|
}
|
|
449
|
+
/** What a host writes as an order's `metadata.shippingAddress`, in {@link ChannelOrderAddress}'s spelling. */
|
|
450
|
+
export const channelOrderAddressSchema = z.object({
|
|
451
|
+
firstName: z.string(),
|
|
452
|
+
lastName: z.string(),
|
|
453
|
+
line1: z.string().min(1),
|
|
454
|
+
line2: z.string().optional(),
|
|
455
|
+
city: z.string().min(1),
|
|
456
|
+
region: z.string().optional(),
|
|
457
|
+
postalCode: z.string().optional(),
|
|
458
|
+
countryCode: z.string().regex(/^[A-Z]{2}$/, "countryCode must be ISO 3166-1 alpha-2"),
|
|
459
|
+
phone: z.string().optional(),
|
|
460
|
+
});
|
|
461
|
+
function withoutUndefined(address) {
|
|
462
|
+
const { line2, region, postalCode, phone, ...required } = address;
|
|
463
|
+
return {
|
|
464
|
+
...required,
|
|
465
|
+
...(line2 !== undefined ? { line2 } : {}),
|
|
466
|
+
...(region !== undefined ? { region } : {}),
|
|
467
|
+
...(postalCode !== undefined ? { postalCode } : {}),
|
|
468
|
+
...(phone !== undefined ? { phone } : {}),
|
|
469
|
+
};
|
|
470
|
+
}
|
|
447
471
|
function redactStore(store) {
|
|
448
472
|
return {
|
|
449
473
|
id: store.id,
|
|
@@ -605,7 +629,7 @@ export class ChannelConnectorService {
|
|
|
605
629
|
if (this.connectors.has(connector.providerId)) {
|
|
606
630
|
throw new Error(`Duplicate channel connector providerId: ${connector.providerId}`);
|
|
607
631
|
}
|
|
608
|
-
this.connectors.set(connector.providerId, connector);
|
|
632
|
+
this.connectors.set(connector.providerId, withLiveCredentials(connector, db));
|
|
609
633
|
}
|
|
610
634
|
this.jobs = options.jobs ?? services.jobs;
|
|
611
635
|
this.transact = transaction ?? ((fn) => this.db.transaction(fn));
|
|
@@ -2179,59 +2203,118 @@ export class ChannelConnectorService {
|
|
|
2179
2203
|
.where(and(eq(connectedStores.organizationId, orgId), eq(connectedStores.id, storeId)));
|
|
2180
2204
|
return this.getCatalogWriteSettings(orgId, storeId);
|
|
2181
2205
|
}
|
|
2182
|
-
|
|
2183
|
-
|
|
2206
|
+
/**
|
|
2207
|
+
* Connects a store, or refreshes the grant of one this organization already holds for the same
|
|
2208
|
+
* provider and domain (a reconnect after an uninstall, or a re-authorization) — never a second row
|
|
2209
|
+
* for the same shop, which would import it twice.
|
|
2210
|
+
*
|
|
2211
|
+
* The row and the consumer's binding of it ({@link BindConnectedStore}) are one transaction. A
|
|
2212
|
+
* provider that subscribes per store is registered after commit, at an absolute address; the
|
|
2213
|
+
* consumer's follow-on work ({@link AfterStoreConnected}) runs after that.
|
|
2214
|
+
*/
|
|
2215
|
+
async connectStore(orgId, input, actor) {
|
|
2216
|
+
const connector = this.connectors.get(input.provider);
|
|
2217
|
+
if (!connector)
|
|
2184
2218
|
return PluginErr(`No connector registered for provider "${input.provider}".`, "NOT_FOUND");
|
|
2219
|
+
const storeDomain = connector.normalizeStoreDomain ? connector.normalizeStoreDomain(input.storeDomain) : input.storeDomain;
|
|
2220
|
+
if (!storeDomain)
|
|
2221
|
+
return PluginErr(`"${input.storeDomain}" does not name a ${input.provider} store.`, "INVALID_STORE_DOMAIN");
|
|
2222
|
+
if (connector.registerWebhooks && !this.options.publicUrl) {
|
|
2223
|
+
return PluginErr(`Connector "${input.provider}" subscribes per store and needs the plugin's publicUrl to give it an absolute address.`, "PUBLIC_URL_REQUIRED");
|
|
2185
2224
|
}
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
.
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
if (connector.registerWebhooks) {
|
|
2225
|
+
let store;
|
|
2226
|
+
try {
|
|
2227
|
+
store = await this.transact(async (tx) => {
|
|
2228
|
+
const [existing] = await tx.select().from(connectedStores).where(and(eq(connectedStores.organizationId, orgId), eq(connectedStores.provider, input.provider), eq(connectedStores.storeDomain, storeDomain)));
|
|
2229
|
+
const rows = existing
|
|
2230
|
+
? await tx.update(connectedStores).set({
|
|
2231
|
+
credentials: input.credentials,
|
|
2232
|
+
status: "connected",
|
|
2233
|
+
...(existing.status !== "connected" ? { catalogWriteEnabled: false } : {}),
|
|
2234
|
+
webhookSecret: input.webhookSecret ?? existing.webhookSecret ?? crypto.randomUUID(),
|
|
2235
|
+
updatedAt: new Date(),
|
|
2236
|
+
}).where(eq(connectedStores.id, existing.id)).returning()
|
|
2237
|
+
: await tx.insert(connectedStores).values({
|
|
2238
|
+
organizationId: orgId,
|
|
2239
|
+
provider: input.provider,
|
|
2240
|
+
credentials: input.credentials,
|
|
2241
|
+
storeDomain,
|
|
2242
|
+
webhookSecret: input.webhookSecret ?? crypto.randomUUID(),
|
|
2243
|
+
}).returning();
|
|
2244
|
+
const written = rows[0];
|
|
2245
|
+
if (!written)
|
|
2246
|
+
throw new Error("The connected store row was not written.");
|
|
2247
|
+
await this.options.bindConnectedStore?.({ db: tx, store: written, actor });
|
|
2248
|
+
return written;
|
|
2249
|
+
});
|
|
2250
|
+
}
|
|
2251
|
+
catch (error) {
|
|
2252
|
+
return PluginErr(error instanceof Error ? error.message : "The store could not be connected.", error instanceof CommerceNotFoundError ? "NOT_FOUND" : "STORE_CONNECTION_REFUSED");
|
|
2253
|
+
}
|
|
2254
|
+
if (connector.registerWebhooks && this.options.publicUrl) {
|
|
2255
|
+
const callbackUrl = new URL(`/api/channels/webhooks/${store.id}`, this.options.publicUrl).toString();
|
|
2216
2256
|
const registration = await connector.registerWebhooks(store, [
|
|
2257
|
+
"products/create",
|
|
2217
2258
|
"products/update",
|
|
2218
2259
|
"products/delete",
|
|
2219
2260
|
"inventory_levels/update",
|
|
2220
2261
|
"orders/fulfilled",
|
|
2221
2262
|
"orders/cancelled",
|
|
2222
|
-
"refunds/create",
|
|
2223
2263
|
"app/uninstalled",
|
|
2224
|
-
],
|
|
2264
|
+
], callbackUrl);
|
|
2225
2265
|
if (!registration.ok) {
|
|
2226
2266
|
await this.db.update(connectedStores).set({ status: "error", updatedAt: new Date() }).where(eq(connectedStores.id, store.id));
|
|
2227
2267
|
return PluginErr(registration.error.message, "CONNECTOR_REGISTRATION_FAILED");
|
|
2228
2268
|
}
|
|
2229
2269
|
}
|
|
2230
|
-
|
|
2231
|
-
|
|
2270
|
+
if (this.options.afterStoreConnected) {
|
|
2271
|
+
try {
|
|
2272
|
+
await this.options.afterStoreConnected({ store, actor, connector, services: this.services });
|
|
2273
|
+
}
|
|
2274
|
+
catch (error) {
|
|
2275
|
+
return PluginErr(error instanceof Error ? error.message : "The store connected but its follow-on work failed.", "AFTER_CONNECT_FAILED");
|
|
2276
|
+
}
|
|
2277
|
+
}
|
|
2232
2278
|
return Ok(redactStore(store));
|
|
2233
2279
|
}
|
|
2234
|
-
|
|
2280
|
+
/** The store with credentials good for a call the host makes itself, e.g. its own Admin API write. */
|
|
2281
|
+
async liveStore(orgId, storeId) {
|
|
2282
|
+
const store = await this.getStoreRecord(orgId, storeId);
|
|
2283
|
+
if (!store || store.status !== "connected")
|
|
2284
|
+
return PluginErr("Connected store not found.", "NOT_FOUND");
|
|
2285
|
+
const connector = this.connectors.get(store.provider);
|
|
2286
|
+
if (!connector)
|
|
2287
|
+
return PluginErr(`No connector registered for provider "${store.provider}".`, "NOT_FOUND");
|
|
2288
|
+
const live = await resolveLiveCredentials(connector, this.db, store);
|
|
2289
|
+
return live.ok ? Ok(live.value) : PluginErr(live.error.message, live.error.code);
|
|
2290
|
+
}
|
|
2291
|
+
/** The consumer's claims for a connection starting from this request. See {@link ConnectClaims}. */
|
|
2292
|
+
async connectClaims(context) {
|
|
2293
|
+
if (!this.options.connectClaims)
|
|
2294
|
+
return Ok({});
|
|
2295
|
+
try {
|
|
2296
|
+
return Ok(await this.options.connectClaims(context));
|
|
2297
|
+
}
|
|
2298
|
+
catch (error) {
|
|
2299
|
+
return PluginErr(error instanceof Error ? error.message : "The connection could not be started.", error instanceof CommerceNotFoundError ? "NOT_FOUND" : "CONNECT_REFUSED");
|
|
2300
|
+
}
|
|
2301
|
+
}
|
|
2302
|
+
/** The caller's allow-list, or null for unconfined. See {@link ConfineStores}. */
|
|
2303
|
+
async allowedStores(orgId, context) {
|
|
2304
|
+
return this.options.confineStores ? await this.options.confineStores(context ?? { orgId, actor: null, raw: undefined }) : null;
|
|
2305
|
+
}
|
|
2306
|
+
/** NOT_FOUND for a store outside the caller's set, exactly as for one that does not exist. */
|
|
2307
|
+
async reachableStore(orgId, id, context) {
|
|
2308
|
+
const allowed = await this.allowedStores(orgId, context);
|
|
2309
|
+
if (allowed !== null && !allowed.includes(id))
|
|
2310
|
+
return PluginErr("Connected store not found.", "NOT_FOUND");
|
|
2311
|
+
const store = await this.getStoreRecord(orgId, id);
|
|
2312
|
+
return store ? Ok(store) : PluginErr("Connected store not found.", "NOT_FOUND");
|
|
2313
|
+
}
|
|
2314
|
+
async disconnectStore(orgId, id, context) {
|
|
2315
|
+
const store = await this.reachableStore(orgId, id, context);
|
|
2316
|
+
if (!store.ok)
|
|
2317
|
+
return store;
|
|
2235
2318
|
return this.disconnectStoreSystem(orgId, id);
|
|
2236
2319
|
}
|
|
2237
2320
|
async disconnectStoreSystem(orgId, id, redactDomain = false) {
|
|
@@ -2251,16 +2334,12 @@ export class ChannelConnectorService {
|
|
|
2251
2334
|
return PluginErr("Connected store not found.", "NOT_FOUND");
|
|
2252
2335
|
return Ok(redactStore(store));
|
|
2253
2336
|
}
|
|
2254
|
-
async getStore(orgId, id) {
|
|
2255
|
-
const store = await this.
|
|
2256
|
-
|
|
2257
|
-
return PluginErr("Connected store not found.", "NOT_FOUND");
|
|
2258
|
-
return Ok(redactStore(store));
|
|
2337
|
+
async getStore(orgId, id, context) {
|
|
2338
|
+
const store = await this.reachableStore(orgId, id, context);
|
|
2339
|
+
return store.ok ? Ok(redactStore(store.value)) : store;
|
|
2259
2340
|
}
|
|
2260
2341
|
async listStores(orgId, context) {
|
|
2261
|
-
const allowed = this.
|
|
2262
|
-
? await this.options.confineStoreReads(context ?? { orgId, actor: null, raw: undefined })
|
|
2263
|
-
: null;
|
|
2342
|
+
const allowed = await this.allowedStores(orgId, context);
|
|
2264
2343
|
// An empty allow-list means the caller may read NOTHING, stated here rather than left to the
|
|
2265
2344
|
// query builder.
|
|
2266
2345
|
//
|
|
@@ -3702,7 +3781,39 @@ export class ChannelConnectorService {
|
|
|
3702
3781
|
let skipped = [];
|
|
3703
3782
|
let conflicts = [];
|
|
3704
3783
|
let warnings = [];
|
|
3705
|
-
|
|
3784
|
+
const connector = this.connectors.get(store.provider);
|
|
3785
|
+
if ((event.type === "products/create" || event.type === "products/update") && connector?.fetchCatalogItems) {
|
|
3786
|
+
// A webhook is a notification, not a snapshot: its payload is the provider's wire spelling and
|
|
3787
|
+
// may be stale or out of order. The product is read fresh and converged exactly as an import
|
|
3788
|
+
// page would be — creating it when this store has never mapped it.
|
|
3789
|
+
const externalId = String(data.id ?? data.product_id ?? "");
|
|
3790
|
+
if (!externalId)
|
|
3791
|
+
return PluginErr(`A ${event.type} delivery named no product.`, "INVALID_WEBHOOK");
|
|
3792
|
+
const read = await connector.fetchCatalogItems(store, [externalId]);
|
|
3793
|
+
if (!read.ok)
|
|
3794
|
+
return PluginErr(read.error.message, read.error.code);
|
|
3795
|
+
const [item] = read.value;
|
|
3796
|
+
if (item === undefined) {
|
|
3797
|
+
// Gone between the delivery and the read: the same as a delete.
|
|
3798
|
+
const archived = await this.archiveMappedProduct(orgId, storeId, externalId, actor);
|
|
3799
|
+
if (!archived.ok)
|
|
3800
|
+
return archived;
|
|
3801
|
+
skipped = archived.value;
|
|
3802
|
+
}
|
|
3803
|
+
else {
|
|
3804
|
+
const converged = await this.convergeCatalogPage(orgId, storeId, [item], actor);
|
|
3805
|
+
if (!converged.ok)
|
|
3806
|
+
return converged;
|
|
3807
|
+
const [failure] = converged.value.failures;
|
|
3808
|
+
if (failure)
|
|
3809
|
+
return PluginErr(`Product ${failure.externalId} could not be converged: ${failure.error}`, "CONVERGENCE_FAILED");
|
|
3810
|
+
warnings = [...converged.value.warnings];
|
|
3811
|
+
if (converged.value.entityIds.length > 0) {
|
|
3812
|
+
await this.options.onStoreCatalogChanged?.({ orgId, storeId, entityIds: [...converged.value.entityIds], convergence: converged.value });
|
|
3813
|
+
}
|
|
3814
|
+
}
|
|
3815
|
+
}
|
|
3816
|
+
else if (event.type === "products/update") {
|
|
3706
3817
|
const productId = String(data.id ?? data.product_id ?? "");
|
|
3707
3818
|
const mapping = await this.db.select().from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.kind, "entity"), eq(channelEntityMap.externalId, productId)));
|
|
3708
3819
|
if (mapping[0]) {
|
|
@@ -3715,19 +3826,10 @@ export class ChannelConnectorService {
|
|
|
3715
3826
|
}
|
|
3716
3827
|
}
|
|
3717
3828
|
else if (event.type === "products/delete") {
|
|
3718
|
-
const
|
|
3719
|
-
|
|
3720
|
-
|
|
3721
|
-
|
|
3722
|
-
if (owners.get("entity.status") === "platform") {
|
|
3723
|
-
skipped.push({ entityId: mapping[0].entityId, fieldPath: "entity.status" });
|
|
3724
|
-
}
|
|
3725
|
-
else {
|
|
3726
|
-
const archived = await this.catalog.archive(mapping[0].entityId, actor);
|
|
3727
|
-
if (!archived.ok)
|
|
3728
|
-
return PluginErr(archived.error.message);
|
|
3729
|
-
}
|
|
3730
|
-
}
|
|
3829
|
+
const archived = await this.archiveMappedProduct(orgId, storeId, String(data.id ?? data.product_id ?? ""), actor);
|
|
3830
|
+
if (!archived.ok)
|
|
3831
|
+
return archived;
|
|
3832
|
+
skipped = archived.value;
|
|
3731
3833
|
}
|
|
3732
3834
|
else if (event.type === "inventory_levels/update") {
|
|
3733
3835
|
const available = Number(data.available ?? data.stock_quantity ?? 0);
|
|
@@ -3738,7 +3840,18 @@ export class ChannelConnectorService {
|
|
|
3738
3840
|
const inventoryItemId = data.inventory_item_id !== undefined && data.inventory_item_id !== null ? String(data.inventory_item_id) : null;
|
|
3739
3841
|
const byInventoryItem = inventoryItemId === null ? null : await this.variantForInventoryItem(orgId, storeId, inventoryItemId);
|
|
3740
3842
|
if (byInventoryItem !== null) {
|
|
3741
|
-
|
|
3843
|
+
// The delivery's `available` is ONE location's count. The variant's stock is the sum the
|
|
3844
|
+
// connector reads, so it is read fresh rather than taken from the payload.
|
|
3845
|
+
let quantity = available;
|
|
3846
|
+
if (connector) {
|
|
3847
|
+
const levels = await connector.fetchInventory(store, [byInventoryItem.externalId]);
|
|
3848
|
+
if (!levels.ok)
|
|
3849
|
+
return PluginErr(levels.error.message, levels.error.code);
|
|
3850
|
+
const fresh = levels.value.find((entry) => entry.externalId === byInventoryItem.externalId);
|
|
3851
|
+
if (fresh)
|
|
3852
|
+
quantity = fresh.available;
|
|
3853
|
+
}
|
|
3854
|
+
await this.setInventoryLevel(byInventoryItem.entityId, byInventoryItem.variantId, quantity, actor);
|
|
3742
3855
|
}
|
|
3743
3856
|
else {
|
|
3744
3857
|
const externalId = String(data.variation_id ?? data.product_id ?? inventoryItemId ?? "");
|
|
@@ -3845,10 +3958,22 @@ export class ChannelConnectorService {
|
|
|
3845
3958
|
}
|
|
3846
3959
|
async resolveOrderId(orgId, storeId, data) {
|
|
3847
3960
|
const nestedOrder = data.order && typeof data.order === "object" ? data.order : undefined;
|
|
3848
|
-
|
|
3961
|
+
// An `orders/*` payload IS the order, so its own `id` names it; a refund names its order in `order_id`.
|
|
3962
|
+
const remoteOrderId = String(data.order_id ?? data.orderId ?? nestedOrder?.id ?? data.id ?? "");
|
|
3849
3963
|
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)));
|
|
3850
3964
|
return rows[0]?.orderId;
|
|
3851
3965
|
}
|
|
3966
|
+
/** Archives this store's product mapped to `externalId`, unless the platform owns its status. */
|
|
3967
|
+
async archiveMappedProduct(orgId, storeId, externalId, actor) {
|
|
3968
|
+
const [mapping] = await this.db.select().from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.kind, "entity"), eq(channelEntityMap.externalId, externalId)));
|
|
3969
|
+
if (!mapping)
|
|
3970
|
+
return Ok([]);
|
|
3971
|
+
const owners = await this.catalog.resolveFieldOwners(mapping.entityId, storeId);
|
|
3972
|
+
if (owners.get("entity.status") === "platform")
|
|
3973
|
+
return Ok([{ entityId: mapping.entityId, fieldPath: "entity.status" }]);
|
|
3974
|
+
const archived = await this.catalog.archive(mapping.entityId, actor);
|
|
3975
|
+
return archived.ok ? Ok([]) : PluginErr(archived.error.message);
|
|
3976
|
+
}
|
|
3852
3977
|
async setMappedInventory(orgId, storeId, externalId, quantity, actor) {
|
|
3853
3978
|
const [mapping] = await this.db.select().from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.externalId, externalId)));
|
|
3854
3979
|
if (!mapping)
|
|
@@ -3861,12 +3986,12 @@ export class ChannelConnectorService {
|
|
|
3861
3986
|
}
|
|
3862
3987
|
/** This store's mapped variant whose import recorded `metadata.inventoryItemId`; null when none, or when two claim it. */
|
|
3863
3988
|
async variantForInventoryItem(orgId, storeId, inventoryItemId) {
|
|
3864
|
-
const rows = await this.db.select({ entityId: channelEntityMap.entityId, variantId: channelEntityMap.variantId }).from(channelEntityMap)
|
|
3989
|
+
const rows = await this.db.select({ entityId: channelEntityMap.entityId, variantId: channelEntityMap.variantId, externalId: channelEntityMap.externalId }).from(channelEntityMap)
|
|
3865
3990
|
.innerJoin(variants, eq(variants.id, channelEntityMap.variantId))
|
|
3866
3991
|
.where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.kind, "variant"), sql `${variants.metadata}->>'inventoryItemId' = ${inventoryItemId}`))
|
|
3867
3992
|
.limit(2);
|
|
3868
3993
|
const [only] = rows;
|
|
3869
|
-
return rows.length === 1 && only !== undefined && only.variantId !== null ? { entityId: only.entityId, variantId: only.variantId } : null;
|
|
3994
|
+
return rows.length === 1 && only !== undefined && only.variantId !== null ? { entityId: only.entityId, variantId: only.variantId, externalId: only.externalId } : null;
|
|
3870
3995
|
}
|
|
3871
3996
|
async convergeCatalogItem(orgId, storeId, entityId, data, actor) {
|
|
3872
3997
|
const product = data.product && typeof data.product === "object" ? data.product : data;
|
|
@@ -4217,8 +4342,19 @@ export class ChannelConnectorService {
|
|
|
4217
4342
|
// shipped to their default. The order's address is applied below and wins.
|
|
4218
4343
|
const addresses = await this.db.select().from(customerAddresses).where(and(eq(customerAddresses.customerId, customer.id), eq(customerAddresses.type, "shipping")));
|
|
4219
4344
|
const address = addresses.find((item) => item.isDefault) ?? addresses[0];
|
|
4220
|
-
if (address)
|
|
4221
|
-
shippingAddress = {
|
|
4345
|
+
if (address) {
|
|
4346
|
+
shippingAddress = {
|
|
4347
|
+
firstName: address.firstName ?? "",
|
|
4348
|
+
lastName: address.lastName ?? "",
|
|
4349
|
+
line1: address.line1,
|
|
4350
|
+
...(address.line2 ? { line2: address.line2 } : {}),
|
|
4351
|
+
city: address.city,
|
|
4352
|
+
...(address.state ? { region: address.state } : {}),
|
|
4353
|
+
...(address.postalCode ? { postalCode: address.postalCode } : {}),
|
|
4354
|
+
countryCode: address.country,
|
|
4355
|
+
...(address.phone ? { phone: address.phone } : {}),
|
|
4356
|
+
};
|
|
4357
|
+
}
|
|
4222
4358
|
}
|
|
4223
4359
|
}
|
|
4224
4360
|
const metadata = order.metadata ?? {};
|
|
@@ -4226,8 +4362,12 @@ export class ChannelConnectorService {
|
|
|
4226
4362
|
email ??= typeof guest.email === "string" ? guest.email : null;
|
|
4227
4363
|
name ||= typeof guest.name === "string" ? guest.name : `${typeof guest.firstName === "string" ? guest.firstName : ""} ${typeof guest.lastName === "string" ? guest.lastName : ""}`.trim();
|
|
4228
4364
|
const orderShipping = metadata.shippingAddress ?? metadata.guestShippingAddress ?? (typeof metadata.guestCustomer === "object" && metadata.guestCustomer ? metadata.guestCustomer.shippingAddress : undefined);
|
|
4229
|
-
if (orderShipping
|
|
4230
|
-
|
|
4365
|
+
if (orderShipping !== undefined) {
|
|
4366
|
+
const parsed = channelOrderAddressSchema.safeParse(orderShipping);
|
|
4367
|
+
if (!parsed.success)
|
|
4368
|
+
return PluginErr(`The order's shipping address is not a channel order address: ${parsed.error.issues[0]?.message ?? "invalid"}.`, "CUSTOMER_DATA_MISSING");
|
|
4369
|
+
shippingAddress = withoutUndefined(parsed.data);
|
|
4370
|
+
}
|
|
4231
4371
|
if (!email || !shippingAddress)
|
|
4232
4372
|
return PluginErr("Customer email and shipping address are required for channel order export.", "CUSTOMER_DATA_MISSING");
|
|
4233
4373
|
return Ok({ orderId, currency: order.currency, grandTotal: lines.reduce((sum, line) => sum + line.totalPrice, 0), lines, customer: { name, email, shippingAddress } });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@porulle/plugin-channel-connector",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.67.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"dependencies": {
|
|
23
23
|
"@hono/zod-openapi": "^1.2.2",
|
|
24
24
|
"hono": "^4.12.5",
|
|
25
|
-
"@porulle/core": "0.
|
|
25
|
+
"@porulle/core": "0.67.0"
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|
|
28
28
|
"@types/node": "^24.5.2",
|