@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/src/service.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
+
import { z } from "zod";
|
|
2
3
|
import {
|
|
3
4
|
CommerceInvalidTransitionError,
|
|
5
|
+
CommerceNotFoundError,
|
|
4
6
|
CommerceValidationError,
|
|
5
7
|
Ok,
|
|
6
8
|
PluginErr,
|
|
@@ -15,6 +17,7 @@ import type {
|
|
|
15
17
|
EntityLinkRows,
|
|
16
18
|
ChannelCatalogItem,
|
|
17
19
|
ChannelConnector,
|
|
20
|
+
ChannelOrderAddress,
|
|
18
21
|
ChannelOrderSlice,
|
|
19
22
|
ChannelPushCatalogField,
|
|
20
23
|
ChannelPushCatalogImage,
|
|
@@ -37,6 +40,7 @@ import { isValidFieldPath, requireUserId } from "@porulle/core";
|
|
|
37
40
|
import type { FieldOwner, FieldPath } from "@porulle/core";
|
|
38
41
|
import type { JobsAdapter } from "@porulle/core";
|
|
39
42
|
import { CHANNEL_CONVERGENCE_CTX } from "./catalog-push-trigger.js";
|
|
43
|
+
import { resolveLiveCredentials, withLiveCredentials } from "./live-credentials.js";
|
|
40
44
|
import { and, desc, eq, inArray, isNull, lte, or, sql, type SQL } from "@porulle/core/drizzle";
|
|
41
45
|
import {
|
|
42
46
|
brands,
|
|
@@ -232,18 +236,52 @@ export interface ChannelComplianceData {
|
|
|
232
236
|
}
|
|
233
237
|
|
|
234
238
|
/**
|
|
235
|
-
*
|
|
239
|
+
* Which stores the caller may see and act on, resolved per request.
|
|
236
240
|
*
|
|
237
241
|
* Returning `null` means "do not confine" and is the default — every existing consumer keeps the
|
|
238
|
-
* organization-wide behaviour. An array is the complete set of store ids this caller may
|
|
239
|
-
* **`[]` means none**, not "no filter".
|
|
242
|
+
* organization-wide behaviour. An array is the complete set of store ids this caller may reach, and
|
|
243
|
+
* **`[]` means none**, not "no filter". Applied to the list AND to every route that names one store,
|
|
244
|
+
* where a store outside the set answers NOT_FOUND — a refusal must not confirm the store exists.
|
|
240
245
|
*
|
|
241
246
|
* It takes IDS rather than a tenant, deliberately. `vendor`, `seller`, `team` are models a consumer
|
|
242
247
|
* owns; this package is generic commerce and acquiring one of them here would push a marketplace
|
|
243
248
|
* concept into every deployment that has no such thing. The consumer resolves the meaning and hands
|
|
244
249
|
* back the answer.
|
|
245
250
|
*/
|
|
246
|
-
export type
|
|
251
|
+
export type ConfineStores = (context: StoreReadContext) => Promise<readonly string[] | null> | readonly string[] | null;
|
|
252
|
+
|
|
253
|
+
/** Who connected a store: the signed-in user who started OAuth, or the caller of `POST /stores`. */
|
|
254
|
+
export interface StoreConnectActor {
|
|
255
|
+
orgId: string;
|
|
256
|
+
userId: string | null;
|
|
257
|
+
/**
|
|
258
|
+
* What the consumer's {@link ConnectClaims} resolved when the connection started — e.g. which of
|
|
259
|
+
* the user's vendors the store is for. Carried in the signed OAuth state, so the callback, which has
|
|
260
|
+
* no session or headers of its own, binds the store to exactly what was chosen at the start.
|
|
261
|
+
*/
|
|
262
|
+
claims: Readonly<Record<string, string>>;
|
|
263
|
+
/** Core's request escape hatch when the connect is a request; absent on the OAuth callback. */
|
|
264
|
+
raw?: unknown;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Resolves, from the request that STARTS a connection, the facts the consumer needs to bind the store
|
|
269
|
+
* later. Throw to refuse the start. Runs for OAuth start and for `POST /stores`.
|
|
270
|
+
*/
|
|
271
|
+
export type ConnectClaims = (context: StoreReadContext) => Promise<Record<string, string>> | Record<string, string>;
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Binds a just-connected store to whatever the consumer means by an owner, INSIDE the transaction
|
|
275
|
+
* that wrote the store row — so a store and its binding commit together or not at all. Throw to
|
|
276
|
+
* refuse the connection; the store row is rolled back with it.
|
|
277
|
+
*/
|
|
278
|
+
export type BindConnectedStore = (input: { db: PluginDb; store: ConnectedStore; actor: StoreConnectActor }) => Promise<void>;
|
|
279
|
+
|
|
280
|
+
/** Work that follows a committed connection: the first import, provider-attested facts, keys. */
|
|
281
|
+
export type AfterStoreConnected = (input: { store: ConnectedStore; actor: StoreConnectActor; connector: ChannelConnector; services: Record<string, unknown> }) => Promise<void>;
|
|
282
|
+
|
|
283
|
+
/** Entities a provider webhook just created or changed, converged; the host projects them. */
|
|
284
|
+
export type OnStoreCatalogChanged = (input: { orgId: string; storeId: string; entityIds: string[]; convergence: CatalogPageConvergence }) => Promise<void>;
|
|
247
285
|
|
|
248
286
|
/** What a consumer needs to resolve the caller. `raw` is core's documented request escape hatch. */
|
|
249
287
|
export interface StoreReadContext {
|
|
@@ -255,13 +293,30 @@ export interface StoreReadContext {
|
|
|
255
293
|
export interface ChannelConnectorPluginOptions {
|
|
256
294
|
connectors?: ChannelConnector[];
|
|
257
295
|
/**
|
|
258
|
-
* Confines
|
|
296
|
+
* Confines stores to a set the consumer chooses. See {@link ConfineStores}.
|
|
259
297
|
*
|
|
260
298
|
* Absent by default, because narrowing an existing read for every deployment would be a breaking
|
|
261
299
|
* change to a published package. A consumer that needs confinement opts in; one that does not is
|
|
262
300
|
* unaffected.
|
|
263
301
|
*/
|
|
264
|
-
|
|
302
|
+
confineStores?: ConfineStores;
|
|
303
|
+
/** See {@link ConnectClaims}. Absent, a connection carries no claims. */
|
|
304
|
+
connectClaims?: ConnectClaims;
|
|
305
|
+
/** See {@link BindConnectedStore}. */
|
|
306
|
+
bindConnectedStore?: BindConnectedStore;
|
|
307
|
+
/** See {@link AfterStoreConnected}. */
|
|
308
|
+
afterStoreConnected?: AfterStoreConnected;
|
|
309
|
+
/** See {@link OnStoreCatalogChanged}. Absent, a webhook's products converge and nothing else runs. */
|
|
310
|
+
onStoreCatalogChanged?: OnStoreCatalogChanged;
|
|
311
|
+
/**
|
|
312
|
+
* This deployment's public origin. Required for a connector that registers webhooks per store: a
|
|
313
|
+
* provider delivers to an ABSOLUTE address, and a relative one is refused at connect.
|
|
314
|
+
*/
|
|
315
|
+
publicUrl?: string;
|
|
316
|
+
/**
|
|
317
|
+
* `postConnectRedirect` is where the merchant's browser lands after OAuth, with `connected=<storeId>`
|
|
318
|
+
* or `connect_error=<code>` appended — a browser flow ends on a page, never on a JSON error.
|
|
319
|
+
*/
|
|
265
320
|
oauth?: { stateSecret: string; postConnectRedirect: string };
|
|
266
321
|
inventoryTimeoutMs?: number;
|
|
267
322
|
jobs?: JobsAdapter;
|
|
@@ -997,6 +1052,30 @@ async function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T
|
|
|
997
1052
|
}
|
|
998
1053
|
}
|
|
999
1054
|
|
|
1055
|
+
/** What a host writes as an order's `metadata.shippingAddress`, in {@link ChannelOrderAddress}'s spelling. */
|
|
1056
|
+
export const channelOrderAddressSchema = z.object({
|
|
1057
|
+
firstName: z.string(),
|
|
1058
|
+
lastName: z.string(),
|
|
1059
|
+
line1: z.string().min(1),
|
|
1060
|
+
line2: z.string().optional(),
|
|
1061
|
+
city: z.string().min(1),
|
|
1062
|
+
region: z.string().optional(),
|
|
1063
|
+
postalCode: z.string().optional(),
|
|
1064
|
+
countryCode: z.string().regex(/^[A-Z]{2}$/, "countryCode must be ISO 3166-1 alpha-2"),
|
|
1065
|
+
phone: z.string().optional(),
|
|
1066
|
+
});
|
|
1067
|
+
|
|
1068
|
+
function withoutUndefined(address: z.infer<typeof channelOrderAddressSchema>): ChannelOrderAddress {
|
|
1069
|
+
const { line2, region, postalCode, phone, ...required } = address;
|
|
1070
|
+
return {
|
|
1071
|
+
...required,
|
|
1072
|
+
...(line2 !== undefined ? { line2 } : {}),
|
|
1073
|
+
...(region !== undefined ? { region } : {}),
|
|
1074
|
+
...(postalCode !== undefined ? { postalCode } : {}),
|
|
1075
|
+
...(phone !== undefined ? { phone } : {}),
|
|
1076
|
+
};
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1000
1079
|
function redactStore(store: ConnectedStore): PublicConnectedStore {
|
|
1001
1080
|
return {
|
|
1002
1081
|
id: store.id,
|
|
@@ -1200,7 +1279,7 @@ export class ChannelConnectorService {
|
|
|
1200
1279
|
if (this.connectors.has(connector.providerId)) {
|
|
1201
1280
|
throw new Error(`Duplicate channel connector providerId: ${connector.providerId}`);
|
|
1202
1281
|
}
|
|
1203
|
-
this.connectors.set(connector.providerId, connector);
|
|
1282
|
+
this.connectors.set(connector.providerId, withLiveCredentials(connector, db));
|
|
1204
1283
|
}
|
|
1205
1284
|
this.jobs = options.jobs ?? (services.jobs as JobsAdapter | undefined);
|
|
1206
1285
|
this.transact = transaction ?? ((fn) => this.db.transaction(fn));
|
|
@@ -3025,6 +3104,15 @@ export class ChannelConnectorService {
|
|
|
3025
3104
|
return this.getCatalogWriteSettings(orgId, storeId);
|
|
3026
3105
|
}
|
|
3027
3106
|
|
|
3107
|
+
/**
|
|
3108
|
+
* Connects a store, or refreshes the grant of one this organization already holds for the same
|
|
3109
|
+
* provider and domain (a reconnect after an uninstall, or a re-authorization) — never a second row
|
|
3110
|
+
* for the same shop, which would import it twice.
|
|
3111
|
+
*
|
|
3112
|
+
* The row and the consumer's binding of it ({@link BindConnectedStore}) are one transaction. A
|
|
3113
|
+
* provider that subscribes per store is registered after commit, at an absolute address; the
|
|
3114
|
+
* consumer's follow-on work ({@link AfterStoreConnected}) runs after that.
|
|
3115
|
+
*/
|
|
3028
3116
|
async connectStore(
|
|
3029
3117
|
orgId: string,
|
|
3030
3118
|
input: {
|
|
@@ -3033,64 +3121,108 @@ export class ChannelConnectorService {
|
|
|
3033
3121
|
storeDomain: string;
|
|
3034
3122
|
webhookSecret?: string;
|
|
3035
3123
|
},
|
|
3124
|
+
actor: StoreConnectActor,
|
|
3036
3125
|
): Promise<PluginResult<PublicConnectedStore>> {
|
|
3037
|
-
|
|
3038
|
-
|
|
3126
|
+
const connector = this.connectors.get(input.provider);
|
|
3127
|
+
if (!connector) return PluginErr(`No connector registered for provider "${input.provider}".`, "NOT_FOUND");
|
|
3128
|
+
const storeDomain = connector.normalizeStoreDomain ? connector.normalizeStoreDomain(input.storeDomain) : input.storeDomain;
|
|
3129
|
+
if (!storeDomain) return PluginErr(`"${input.storeDomain}" does not name a ${input.provider} store.`, "INVALID_STORE_DOMAIN");
|
|
3130
|
+
if (connector.registerWebhooks && !this.options.publicUrl) {
|
|
3131
|
+
return PluginErr(`Connector "${input.provider}" subscribes per store and needs the plugin's publicUrl to give it an absolute address.`, "PUBLIC_URL_REQUIRED");
|
|
3039
3132
|
}
|
|
3040
|
-
|
|
3041
|
-
|
|
3042
|
-
.
|
|
3043
|
-
|
|
3044
|
-
|
|
3045
|
-
|
|
3046
|
-
|
|
3047
|
-
|
|
3048
|
-
|
|
3049
|
-
|
|
3050
|
-
|
|
3051
|
-
|
|
3052
|
-
|
|
3053
|
-
|
|
3054
|
-
|
|
3055
|
-
|
|
3056
|
-
|
|
3057
|
-
|
|
3058
|
-
|
|
3059
|
-
|
|
3060
|
-
|
|
3061
|
-
|
|
3062
|
-
|
|
3063
|
-
|
|
3064
|
-
|
|
3065
|
-
|
|
3066
|
-
|
|
3067
|
-
|
|
3068
|
-
|
|
3069
|
-
|
|
3070
|
-
|
|
3071
|
-
|
|
3072
|
-
|
|
3073
|
-
if (connector.registerWebhooks) {
|
|
3133
|
+
let store: ConnectedStore;
|
|
3134
|
+
try {
|
|
3135
|
+
store = await this.transact(async (tx) => {
|
|
3136
|
+
const [existing] = await tx.select().from(connectedStores).where(and(
|
|
3137
|
+
eq(connectedStores.organizationId, orgId),
|
|
3138
|
+
eq(connectedStores.provider, input.provider),
|
|
3139
|
+
eq(connectedStores.storeDomain, storeDomain),
|
|
3140
|
+
));
|
|
3141
|
+
const rows = existing
|
|
3142
|
+
? await tx.update(connectedStores).set({
|
|
3143
|
+
credentials: input.credentials,
|
|
3144
|
+
status: "connected",
|
|
3145
|
+
...(existing.status !== "connected" ? { catalogWriteEnabled: false } : {}),
|
|
3146
|
+
webhookSecret: input.webhookSecret ?? existing.webhookSecret ?? crypto.randomUUID(),
|
|
3147
|
+
updatedAt: new Date(),
|
|
3148
|
+
}).where(eq(connectedStores.id, existing.id)).returning()
|
|
3149
|
+
: await tx.insert(connectedStores).values({
|
|
3150
|
+
organizationId: orgId,
|
|
3151
|
+
provider: input.provider,
|
|
3152
|
+
credentials: input.credentials,
|
|
3153
|
+
storeDomain,
|
|
3154
|
+
webhookSecret: input.webhookSecret ?? crypto.randomUUID(),
|
|
3155
|
+
}).returning();
|
|
3156
|
+
const written = rows[0] as ConnectedStore | undefined;
|
|
3157
|
+
if (!written) throw new Error("The connected store row was not written.");
|
|
3158
|
+
await this.options.bindConnectedStore?.({ db: tx, store: written, actor });
|
|
3159
|
+
return written;
|
|
3160
|
+
});
|
|
3161
|
+
} catch (error) {
|
|
3162
|
+
return PluginErr(error instanceof Error ? error.message : "The store could not be connected.", error instanceof CommerceNotFoundError ? "NOT_FOUND" : "STORE_CONNECTION_REFUSED");
|
|
3163
|
+
}
|
|
3164
|
+
if (connector.registerWebhooks && this.options.publicUrl) {
|
|
3165
|
+
const callbackUrl = new URL(`/api/channels/webhooks/${store.id}`, this.options.publicUrl).toString();
|
|
3074
3166
|
const registration = await connector.registerWebhooks(store as ChannelStore, [
|
|
3167
|
+
"products/create",
|
|
3075
3168
|
"products/update",
|
|
3076
3169
|
"products/delete",
|
|
3077
3170
|
"inventory_levels/update",
|
|
3078
3171
|
"orders/fulfilled",
|
|
3079
3172
|
"orders/cancelled",
|
|
3080
|
-
"refunds/create",
|
|
3081
3173
|
"app/uninstalled",
|
|
3082
|
-
],
|
|
3174
|
+
], callbackUrl);
|
|
3083
3175
|
if (!registration.ok) {
|
|
3084
3176
|
await this.db.update(connectedStores).set({ status: "error", updatedAt: new Date() }).where(eq(connectedStores.id, store.id));
|
|
3085
3177
|
return PluginErr(registration.error.message, "CONNECTOR_REGISTRATION_FAILED");
|
|
3086
3178
|
}
|
|
3087
3179
|
}
|
|
3088
|
-
|
|
3089
|
-
|
|
3180
|
+
if (this.options.afterStoreConnected) {
|
|
3181
|
+
try {
|
|
3182
|
+
await this.options.afterStoreConnected({ store, actor, connector, services: this.services });
|
|
3183
|
+
} catch (error) {
|
|
3184
|
+
return PluginErr(error instanceof Error ? error.message : "The store connected but its follow-on work failed.", "AFTER_CONNECT_FAILED");
|
|
3185
|
+
}
|
|
3186
|
+
}
|
|
3090
3187
|
return Ok(redactStore(store));
|
|
3091
3188
|
}
|
|
3092
3189
|
|
|
3093
|
-
|
|
3190
|
+
/** The store with credentials good for a call the host makes itself, e.g. its own Admin API write. */
|
|
3191
|
+
async liveStore(orgId: string, storeId: string): Promise<PluginResult<ChannelStore>> {
|
|
3192
|
+
const store = await this.getStoreRecord(orgId, storeId);
|
|
3193
|
+
if (!store || store.status !== "connected") return PluginErr("Connected store not found.", "NOT_FOUND");
|
|
3194
|
+
const connector = this.connectors.get(store.provider);
|
|
3195
|
+
if (!connector) return PluginErr(`No connector registered for provider "${store.provider}".`, "NOT_FOUND");
|
|
3196
|
+
const live = await resolveLiveCredentials(connector, this.db, store as ChannelStore);
|
|
3197
|
+
return live.ok ? Ok(live.value) : PluginErr(live.error.message, live.error.code);
|
|
3198
|
+
}
|
|
3199
|
+
|
|
3200
|
+
/** The consumer's claims for a connection starting from this request. See {@link ConnectClaims}. */
|
|
3201
|
+
async connectClaims(context: StoreReadContext): Promise<PluginResult<Record<string, string>>> {
|
|
3202
|
+
if (!this.options.connectClaims) return Ok({});
|
|
3203
|
+
try {
|
|
3204
|
+
return Ok(await this.options.connectClaims(context));
|
|
3205
|
+
} catch (error) {
|
|
3206
|
+
return PluginErr(error instanceof Error ? error.message : "The connection could not be started.", error instanceof CommerceNotFoundError ? "NOT_FOUND" : "CONNECT_REFUSED");
|
|
3207
|
+
}
|
|
3208
|
+
}
|
|
3209
|
+
|
|
3210
|
+
/** The caller's allow-list, or null for unconfined. See {@link ConfineStores}. */
|
|
3211
|
+
private async allowedStores(orgId: string, context: StoreReadContext | undefined): Promise<readonly string[] | null> {
|
|
3212
|
+
return this.options.confineStores ? await this.options.confineStores(context ?? { orgId, actor: null, raw: undefined }) : null;
|
|
3213
|
+
}
|
|
3214
|
+
|
|
3215
|
+
/** NOT_FOUND for a store outside the caller's set, exactly as for one that does not exist. */
|
|
3216
|
+
async reachableStore(orgId: string, id: string, context: StoreReadContext | undefined): Promise<PluginResult<ConnectedStore>> {
|
|
3217
|
+
const allowed = await this.allowedStores(orgId, context);
|
|
3218
|
+
if (allowed !== null && !allowed.includes(id)) return PluginErr("Connected store not found.", "NOT_FOUND");
|
|
3219
|
+
const store = await this.getStoreRecord(orgId, id);
|
|
3220
|
+
return store ? Ok(store) : PluginErr("Connected store not found.", "NOT_FOUND");
|
|
3221
|
+
}
|
|
3222
|
+
|
|
3223
|
+
async disconnectStore(orgId: string, id: string, context?: StoreReadContext): Promise<PluginResult<PublicConnectedStore>> {
|
|
3224
|
+
const store = await this.reachableStore(orgId, id, context);
|
|
3225
|
+
if (!store.ok) return store;
|
|
3094
3226
|
return this.disconnectStoreSystem(orgId, id);
|
|
3095
3227
|
}
|
|
3096
3228
|
|
|
@@ -3111,16 +3243,13 @@ export class ChannelConnectorService {
|
|
|
3111
3243
|
return Ok(redactStore(store));
|
|
3112
3244
|
}
|
|
3113
3245
|
|
|
3114
|
-
async getStore(orgId: string, id: string): Promise<PluginResult<PublicConnectedStore>> {
|
|
3115
|
-
const store = await this.
|
|
3116
|
-
|
|
3117
|
-
return Ok(redactStore(store));
|
|
3246
|
+
async getStore(orgId: string, id: string, context?: StoreReadContext): Promise<PluginResult<PublicConnectedStore>> {
|
|
3247
|
+
const store = await this.reachableStore(orgId, id, context);
|
|
3248
|
+
return store.ok ? Ok(redactStore(store.value)) : store;
|
|
3118
3249
|
}
|
|
3119
3250
|
|
|
3120
3251
|
async listStores(orgId: string, context?: StoreReadContext): Promise<PluginResult<PublicConnectedStore[]>> {
|
|
3121
|
-
const allowed = this.
|
|
3122
|
-
? await this.options.confineStoreReads(context ?? { orgId, actor: null, raw: undefined })
|
|
3123
|
-
: null;
|
|
3252
|
+
const allowed = await this.allowedStores(orgId, context);
|
|
3124
3253
|
// An empty allow-list means the caller may read NOTHING, stated here rather than left to the
|
|
3125
3254
|
// query builder.
|
|
3126
3255
|
//
|
|
@@ -4767,7 +4896,32 @@ export class ChannelConnectorService {
|
|
|
4767
4896
|
let skipped: CatalogFieldSkip[] = [];
|
|
4768
4897
|
let conflicts: CatalogFieldConflict[] = [];
|
|
4769
4898
|
let warnings: string[] = [];
|
|
4770
|
-
|
|
4899
|
+
const connector = this.connectors.get(store.provider);
|
|
4900
|
+
if ((event.type === "products/create" || event.type === "products/update") && connector?.fetchCatalogItems) {
|
|
4901
|
+
// A webhook is a notification, not a snapshot: its payload is the provider's wire spelling and
|
|
4902
|
+
// may be stale or out of order. The product is read fresh and converged exactly as an import
|
|
4903
|
+
// page would be — creating it when this store has never mapped it.
|
|
4904
|
+
const externalId = String(data.id ?? data.product_id ?? "");
|
|
4905
|
+
if (!externalId) return PluginErr(`A ${event.type} delivery named no product.`, "INVALID_WEBHOOK");
|
|
4906
|
+
const read = await connector.fetchCatalogItems(store as ChannelStore, [externalId]);
|
|
4907
|
+
if (!read.ok) return PluginErr(read.error.message, read.error.code);
|
|
4908
|
+
const [item] = read.value;
|
|
4909
|
+
if (item === undefined) {
|
|
4910
|
+
// Gone between the delivery and the read: the same as a delete.
|
|
4911
|
+
const archived = await this.archiveMappedProduct(orgId, storeId, externalId, actor);
|
|
4912
|
+
if (!archived.ok) return archived;
|
|
4913
|
+
skipped = archived.value;
|
|
4914
|
+
} else {
|
|
4915
|
+
const converged = await this.convergeCatalogPage(orgId, storeId, [item], actor);
|
|
4916
|
+
if (!converged.ok) return converged;
|
|
4917
|
+
const [failure] = converged.value.failures;
|
|
4918
|
+
if (failure) return PluginErr(`Product ${failure.externalId} could not be converged: ${failure.error}`, "CONVERGENCE_FAILED");
|
|
4919
|
+
warnings = [...converged.value.warnings];
|
|
4920
|
+
if (converged.value.entityIds.length > 0) {
|
|
4921
|
+
await this.options.onStoreCatalogChanged?.({ orgId, storeId, entityIds: [...converged.value.entityIds], convergence: converged.value });
|
|
4922
|
+
}
|
|
4923
|
+
}
|
|
4924
|
+
} else if (event.type === "products/update") {
|
|
4771
4925
|
const productId = String(data.id ?? data.product_id ?? "");
|
|
4772
4926
|
const mapping = await this.db.select().from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.kind, "entity"), eq(channelEntityMap.externalId, productId)));
|
|
4773
4927
|
if (mapping[0]) {
|
|
@@ -4778,17 +4932,9 @@ export class ChannelConnectorService {
|
|
|
4778
4932
|
warnings = converged.value.warnings;
|
|
4779
4933
|
}
|
|
4780
4934
|
} else if (event.type === "products/delete") {
|
|
4781
|
-
const
|
|
4782
|
-
|
|
4783
|
-
|
|
4784
|
-
const owners = await this.catalog.resolveFieldOwners(mapping[0].entityId, storeId);
|
|
4785
|
-
if (owners.get("entity.status") === "platform") {
|
|
4786
|
-
skipped.push({ entityId: mapping[0].entityId, fieldPath: "entity.status" });
|
|
4787
|
-
} else {
|
|
4788
|
-
const archived = await this.catalog.archive(mapping[0].entityId, actor);
|
|
4789
|
-
if (!archived.ok) return PluginErr(archived.error.message);
|
|
4790
|
-
}
|
|
4791
|
-
}
|
|
4935
|
+
const archived = await this.archiveMappedProduct(orgId, storeId, String(data.id ?? data.product_id ?? ""), actor);
|
|
4936
|
+
if (!archived.ok) return archived;
|
|
4937
|
+
skipped = archived.value;
|
|
4792
4938
|
} else if (event.type === "inventory_levels/update") {
|
|
4793
4939
|
const available = Number(data.available ?? data.stock_quantity ?? 0);
|
|
4794
4940
|
// Shopify names an INVENTORY ITEM, whose id is not the variant id the channel map is keyed by;
|
|
@@ -4798,7 +4944,16 @@ export class ChannelConnectorService {
|
|
|
4798
4944
|
const inventoryItemId = data.inventory_item_id !== undefined && data.inventory_item_id !== null ? String(data.inventory_item_id) : null;
|
|
4799
4945
|
const byInventoryItem = inventoryItemId === null ? null : await this.variantForInventoryItem(orgId, storeId, inventoryItemId);
|
|
4800
4946
|
if (byInventoryItem !== null) {
|
|
4801
|
-
|
|
4947
|
+
// The delivery's `available` is ONE location's count. The variant's stock is the sum the
|
|
4948
|
+
// connector reads, so it is read fresh rather than taken from the payload.
|
|
4949
|
+
let quantity = available;
|
|
4950
|
+
if (connector) {
|
|
4951
|
+
const levels = await connector.fetchInventory(store as ChannelStore, [byInventoryItem.externalId]);
|
|
4952
|
+
if (!levels.ok) return PluginErr(levels.error.message, levels.error.code);
|
|
4953
|
+
const fresh = levels.value.find((entry) => entry.externalId === byInventoryItem.externalId);
|
|
4954
|
+
if (fresh) quantity = fresh.available;
|
|
4955
|
+
}
|
|
4956
|
+
await this.setInventoryLevel(byInventoryItem.entityId, byInventoryItem.variantId, quantity, actor);
|
|
4802
4957
|
} else {
|
|
4803
4958
|
const externalId = String(data.variation_id ?? data.product_id ?? inventoryItemId ?? "");
|
|
4804
4959
|
await this.setMappedInventory(orgId, storeId, externalId, available, actor);
|
|
@@ -4907,11 +5062,22 @@ export class ChannelConnectorService {
|
|
|
4907
5062
|
|
|
4908
5063
|
private async resolveOrderId(orgId: string, storeId: string, data: Record<string, unknown>): Promise<string | undefined> {
|
|
4909
5064
|
const nestedOrder = data.order && typeof data.order === "object" ? data.order as Record<string, unknown> : undefined;
|
|
4910
|
-
|
|
5065
|
+
// An `orders/*` payload IS the order, so its own `id` names it; a refund names its order in `order_id`.
|
|
5066
|
+
const remoteOrderId = String(data.order_id ?? data.orderId ?? nestedOrder?.id ?? data.id ?? "");
|
|
4911
5067
|
const rows = await this.db.select({ orderId: channelOrderExports.orderId }).from(channelOrderExports).where(and(eq(channelOrderExports.organizationId, orgId), eq(channelOrderExports.storeId, storeId), eq(channelOrderExports.remoteOrderId, remoteOrderId)));
|
|
4912
5068
|
return rows[0]?.orderId;
|
|
4913
5069
|
}
|
|
4914
5070
|
|
|
5071
|
+
/** Archives this store's product mapped to `externalId`, unless the platform owns its status. */
|
|
5072
|
+
private async archiveMappedProduct(orgId: string, storeId: string, externalId: string, actor: Actor): Promise<PluginResult<CatalogFieldSkip[]>> {
|
|
5073
|
+
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)));
|
|
5074
|
+
if (!mapping) return Ok([]);
|
|
5075
|
+
const owners = await this.catalog.resolveFieldOwners(mapping.entityId, storeId);
|
|
5076
|
+
if (owners.get("entity.status") === "platform") return Ok([{ entityId: mapping.entityId, fieldPath: "entity.status" }]);
|
|
5077
|
+
const archived = await this.catalog.archive(mapping.entityId, actor);
|
|
5078
|
+
return archived.ok ? Ok([]) : PluginErr(archived.error.message);
|
|
5079
|
+
}
|
|
5080
|
+
|
|
4915
5081
|
private async setMappedInventory(orgId: string, storeId: string, externalId: string, quantity: number, actor: Actor): Promise<void> {
|
|
4916
5082
|
const [mapping] = await this.db.select().from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.externalId, externalId)));
|
|
4917
5083
|
if (!mapping) return;
|
|
@@ -4924,8 +5090,8 @@ export class ChannelConnectorService {
|
|
|
4924
5090
|
}
|
|
4925
5091
|
|
|
4926
5092
|
/** This store's mapped variant whose import recorded `metadata.inventoryItemId`; null when none, or when two claim it. */
|
|
4927
|
-
private async variantForInventoryItem(orgId: string, storeId: string, inventoryItemId: string): Promise<{ entityId: string; variantId: string } | null> {
|
|
4928
|
-
const rows = await this.db.select({ entityId: channelEntityMap.entityId, variantId: channelEntityMap.variantId }).from(channelEntityMap)
|
|
5093
|
+
private async variantForInventoryItem(orgId: string, storeId: string, inventoryItemId: string): Promise<{ entityId: string; variantId: string; externalId: string } | null> {
|
|
5094
|
+
const rows = await this.db.select({ entityId: channelEntityMap.entityId, variantId: channelEntityMap.variantId, externalId: channelEntityMap.externalId }).from(channelEntityMap)
|
|
4929
5095
|
.innerJoin(variants, eq(variants.id, channelEntityMap.variantId))
|
|
4930
5096
|
.where(and(
|
|
4931
5097
|
eq(channelEntityMap.organizationId, orgId),
|
|
@@ -4935,7 +5101,7 @@ export class ChannelConnectorService {
|
|
|
4935
5101
|
))
|
|
4936
5102
|
.limit(2);
|
|
4937
5103
|
const [only] = rows;
|
|
4938
|
-
return rows.length === 1 && only !== undefined && only.variantId !== null ? { entityId: only.entityId, variantId: only.variantId } : null;
|
|
5104
|
+
return rows.length === 1 && only !== undefined && only.variantId !== null ? { entityId: only.entityId, variantId: only.variantId, externalId: only.externalId } : null;
|
|
4939
5105
|
}
|
|
4940
5106
|
|
|
4941
5107
|
private async convergeCatalogItem(
|
|
@@ -5360,7 +5526,7 @@ export class ChannelConnectorService {
|
|
|
5360
5526
|
|
|
5361
5527
|
let email: string | null = null;
|
|
5362
5528
|
let name = "";
|
|
5363
|
-
let shippingAddress:
|
|
5529
|
+
let shippingAddress: ChannelOrderAddress | null = null;
|
|
5364
5530
|
if (order.customerId) {
|
|
5365
5531
|
const [customer] = await this.db.select().from(customers).where(and(eq(customers.organizationId, orgId), eq(customers.id, order.customerId)));
|
|
5366
5532
|
if (customer) {
|
|
@@ -5371,7 +5537,19 @@ export class ChannelConnectorService {
|
|
|
5371
5537
|
// shipped to their default. The order's address is applied below and wins.
|
|
5372
5538
|
const addresses = await this.db.select().from(customerAddresses).where(and(eq(customerAddresses.customerId, customer.id), eq(customerAddresses.type, "shipping")));
|
|
5373
5539
|
const address = addresses.find((item) => item.isDefault) ?? addresses[0];
|
|
5374
|
-
if (address)
|
|
5540
|
+
if (address) {
|
|
5541
|
+
shippingAddress = {
|
|
5542
|
+
firstName: address.firstName ?? "",
|
|
5543
|
+
lastName: address.lastName ?? "",
|
|
5544
|
+
line1: address.line1,
|
|
5545
|
+
...(address.line2 ? { line2: address.line2 } : {}),
|
|
5546
|
+
city: address.city,
|
|
5547
|
+
...(address.state ? { region: address.state } : {}),
|
|
5548
|
+
...(address.postalCode ? { postalCode: address.postalCode } : {}),
|
|
5549
|
+
countryCode: address.country,
|
|
5550
|
+
...(address.phone ? { phone: address.phone } : {}),
|
|
5551
|
+
};
|
|
5552
|
+
}
|
|
5375
5553
|
}
|
|
5376
5554
|
}
|
|
5377
5555
|
const metadata = order.metadata ?? {};
|
|
@@ -5379,7 +5557,11 @@ export class ChannelConnectorService {
|
|
|
5379
5557
|
email ??= typeof guest.email === "string" ? guest.email : null;
|
|
5380
5558
|
name ||= typeof guest.name === "string" ? guest.name : `${typeof guest.firstName === "string" ? guest.firstName : ""} ${typeof guest.lastName === "string" ? guest.lastName : ""}`.trim();
|
|
5381
5559
|
const orderShipping = metadata.shippingAddress ?? metadata.guestShippingAddress ?? (typeof metadata.guestCustomer === "object" && metadata.guestCustomer ? (metadata.guestCustomer as Record<string, unknown>).shippingAddress : undefined);
|
|
5382
|
-
if (orderShipping
|
|
5560
|
+
if (orderShipping !== undefined) {
|
|
5561
|
+
const parsed = channelOrderAddressSchema.safeParse(orderShipping);
|
|
5562
|
+
if (!parsed.success) return PluginErr(`The order's shipping address is not a channel order address: ${parsed.error.issues[0]?.message ?? "invalid"}.`, "CUSTOMER_DATA_MISSING");
|
|
5563
|
+
shippingAddress = withoutUndefined(parsed.data);
|
|
5564
|
+
}
|
|
5383
5565
|
if (!email || !shippingAddress) return PluginErr("Customer email and shipping address are required for channel order export.", "CUSTOMER_DATA_MISSING");
|
|
5384
5566
|
return Ok({ orderId, currency: order.currency, grandTotal: lines.reduce((sum, line) => sum + line.totalPrice, 0), lines, customer: { name, email, shippingAddress } });
|
|
5385
5567
|
}
|