@porulle/plugin-channel-connector 0.74.0 → 0.74.2
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.js +12 -1
- package/dist/live-credentials.d.ts +2 -2
- package/dist/live-credentials.js +4 -3
- package/dist/service.d.ts +10 -0
- package/dist/service.js +25 -4
- package/package.json +2 -2
- package/src/index.ts +10 -1
- package/src/live-credentials.ts +5 -4
- package/src/service.ts +23 -4
package/dist/index.js
CHANGED
|
@@ -399,7 +399,18 @@ export function channelConnectorPlugin(options = {}) {
|
|
|
399
399
|
if (!userId)
|
|
400
400
|
return oauthError(403, "USER_REQUIRED", "Connecting a store needs a signed-in user.");
|
|
401
401
|
const typed = String(query.shop ?? query.store ?? "");
|
|
402
|
-
|
|
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
|
+
}
|
|
403
414
|
if (!storeDomain)
|
|
404
415
|
return connectOutcome(oauth.postConnectRedirect, { error: "INVALID_STORE_DOMAIN", message: `"${typed}" does not name a ${provider} store.` });
|
|
405
416
|
// Refused HERE, before the merchant reaches the provider: a grant issued for a shop retires
|
|
@@ -15,11 +15,11 @@ import type { ChannelConnector, ChannelConnectorError, ChannelStore, PluginDb, R
|
|
|
15
15
|
* token lapsed. The store is then marked `error`, so it reads as "reconnect" instead of failing every
|
|
16
16
|
* later call with a credential error nobody is shown.
|
|
17
17
|
*/
|
|
18
|
+
/** Why a store whose key the provider refused is in `error`. */
|
|
19
|
+
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.";
|
|
18
20
|
export declare function resolveLiveCredentials(connector: ChannelConnector, db: PluginDb, store: ChannelStore, options?: {
|
|
19
21
|
force?: boolean;
|
|
20
22
|
}): 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.";
|
|
23
23
|
/**
|
|
24
24
|
* The connector with every store-taking method routed through {@link resolveLiveCredentials}, so no
|
|
25
25
|
* call site can start on a lapsed token by forgetting to ask.
|
package/dist/live-credentials.js
CHANGED
|
@@ -17,13 +17,16 @@ import { connectedStores } from "./schema.js";
|
|
|
17
17
|
* token lapsed. The store is then marked `error`, so it reads as "reconnect" instead of failing every
|
|
18
18
|
* later call with a credential error nobody is shown.
|
|
19
19
|
*/
|
|
20
|
+
/** Why a store whose key the provider refused is in `error`. */
|
|
21
|
+
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.";
|
|
20
22
|
export async function resolveLiveCredentials(connector, db, store, options = {}) {
|
|
21
23
|
if (!connector.liveCredentials)
|
|
22
24
|
return Ok(store);
|
|
23
25
|
const answer = await connector.liveCredentials(store, options);
|
|
24
26
|
if (!answer.ok) {
|
|
25
27
|
if (answer.error.retriable !== true) {
|
|
26
|
-
|
|
28
|
+
const statusReason = answer.error.code === CHANNEL_CREDENTIALS_REJECTED ? CREDENTIALS_REJECTED_REASON : answer.error.message;
|
|
29
|
+
await db.update(connectedStores).set({ status: "error", statusReason, updatedAt: new Date() }).where(eq(connectedStores.id, store.id));
|
|
27
30
|
}
|
|
28
31
|
return answer;
|
|
29
32
|
}
|
|
@@ -40,8 +43,6 @@ export async function resolveLiveCredentials(connector, db, store, options = {})
|
|
|
40
43
|
return Err({ code: "STORE_NOT_FOUND", message: `Connected store ${store.id} no longer exists.`, retriable: false });
|
|
41
44
|
return Ok({ ...store, credentials: current.credentials });
|
|
42
45
|
}
|
|
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
46
|
async function markCredentialsRejected(db, storeId) {
|
|
46
47
|
await db.update(connectedStores).set({ status: "error", statusReason: CREDENTIALS_REJECTED_REASON, updatedAt: new Date() }).where(eq(connectedStores.id, storeId));
|
|
47
48
|
}
|
package/dist/service.d.ts
CHANGED
|
@@ -701,6 +701,11 @@ export declare class ChannelConnectorService {
|
|
|
701
701
|
private orderForRemote;
|
|
702
702
|
/** Archives this store's product mapped to `externalId`, unless the platform owns its status. */
|
|
703
703
|
private archiveMappedProduct;
|
|
704
|
+
/**
|
|
705
|
+
* Stock is a variant's. A provider can name a product and its only variant by the same id (a
|
|
706
|
+
* WooCommerce simple product is both), so the variant mapping wins over the entity one, as the
|
|
707
|
+
* paged sync's does; an entity mapping alone (a product imported with no variants) still takes it.
|
|
708
|
+
*/
|
|
704
709
|
private setMappedInventory;
|
|
705
710
|
private setInventoryLevel;
|
|
706
711
|
private createRefundRequest;
|
|
@@ -760,6 +765,11 @@ export declare class ChannelConnectorService {
|
|
|
760
765
|
}>;
|
|
761
766
|
getExport(orgId: string, id: string): Promise<PluginResult<ChannelOrderExport>>;
|
|
762
767
|
listFailedExports(orgId: string): Promise<PluginResult<ChannelOrderExport[]>>;
|
|
768
|
+
/**
|
|
769
|
+
* The operator's retry of a failed export: it runs the push again, as a job. The connector looks for
|
|
770
|
+
* an order it may already have created before creating one (an unclear first answer is exactly what
|
|
771
|
+
* left the export failed), so a retry never puts a second order in the store.
|
|
772
|
+
*/
|
|
763
773
|
retryExport(orgId: string, exportId: string, changedBy: string): Promise<PluginResult<ChannelOrderExport>>;
|
|
764
774
|
abandonExport(orgId: string, exportId: string, changedBy: string, reason?: string): Promise<PluginResult<ChannelOrderExport>>;
|
|
765
775
|
resolveCatalogPushEntityIds(orgId: string, storeId: string, entityIds?: string[]): Promise<string[]>;
|
package/dist/service.js
CHANGED
|
@@ -3607,7 +3607,9 @@ export class ChannelConnectorService {
|
|
|
3607
3607
|
const inventoryService = this.services.inventory;
|
|
3608
3608
|
let inventoryUpdated = 0;
|
|
3609
3609
|
for (const level of inventory.value) {
|
|
3610
|
-
|
|
3610
|
+
// A product and its only variant can share an id (a WooCommerce simple product): stock is the variant's.
|
|
3611
|
+
const mapping = levelled.find((entry) => entry.externalId === level.externalId && entry.kind === "variant")
|
|
3612
|
+
?? levelled.find((entry) => entry.externalId === level.externalId);
|
|
3611
3613
|
if (!mapping)
|
|
3612
3614
|
continue;
|
|
3613
3615
|
const current = existingLevels.find((entry) => entry.entityId === mapping.entityId && entry.variantId === (mapping.variantId ?? null));
|
|
@@ -4144,8 +4146,14 @@ export class ChannelConnectorService {
|
|
|
4144
4146
|
const archived = await this.catalog.archive(mapping.entityId, actor);
|
|
4145
4147
|
return archived.ok ? Ok([]) : PluginErr(archived.error.message);
|
|
4146
4148
|
}
|
|
4149
|
+
/**
|
|
4150
|
+
* Stock is a variant's. A provider can name a product and its only variant by the same id (a
|
|
4151
|
+
* WooCommerce simple product is both), so the variant mapping wins over the entity one, as the
|
|
4152
|
+
* paged sync's does; an entity mapping alone (a product imported with no variants) still takes it.
|
|
4153
|
+
*/
|
|
4147
4154
|
async setMappedInventory(orgId, storeId, externalId, quantity, actor) {
|
|
4148
|
-
const
|
|
4155
|
+
const rows = await this.db.select().from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.externalId, externalId)));
|
|
4156
|
+
const mapping = rows.find((row) => row.kind === "variant") ?? rows[0];
|
|
4149
4157
|
if (!mapping)
|
|
4150
4158
|
return;
|
|
4151
4159
|
await this.setInventoryLevel(mapping.entityId, mapping.variantId, quantity, actor);
|
|
@@ -4601,8 +4609,21 @@ export class ChannelConnectorService {
|
|
|
4601
4609
|
.where(and(eq(channelOrderExports.organizationId, orgId), eq(channelOrderExports.state, "failed")));
|
|
4602
4610
|
return Ok(rows);
|
|
4603
4611
|
}
|
|
4604
|
-
|
|
4605
|
-
|
|
4612
|
+
/**
|
|
4613
|
+
* The operator's retry of a failed export: it runs the push again, as a job. The connector looks for
|
|
4614
|
+
* an order it may already have created before creating one (an unclear first answer is exactly what
|
|
4615
|
+
* left the export failed), so a retry never puts a second order in the store.
|
|
4616
|
+
*/
|
|
4617
|
+
async retryExport(orgId, exportId, changedBy) {
|
|
4618
|
+
const moved = await this.transitionExport(orgId, exportId, "exported", changedBy, "Manual retry requested.");
|
|
4619
|
+
if (!moved.ok)
|
|
4620
|
+
return moved;
|
|
4621
|
+
const jobs = this.services.jobs;
|
|
4622
|
+
if (!jobs)
|
|
4623
|
+
return PluginErr("No jobs adapter is configured, so the retry cannot run.", "JOBS_UNAVAILABLE");
|
|
4624
|
+
const { storeId, orderId } = moved.value;
|
|
4625
|
+
await jobs.enqueue("channel/push-order", { orgId, storeId, orderId }, { organizationId: orgId, concurrencyKey: `push:${orderId}:${storeId}`, supersedes: true });
|
|
4626
|
+
return moved;
|
|
4606
4627
|
}
|
|
4607
4628
|
abandonExport(orgId, exportId, changedBy, reason) {
|
|
4608
4629
|
return this.transitionExport(orgId, exportId, "abandoned", changedBy, reason);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@porulle/plugin-channel-connector",
|
|
3
|
-
"version": "0.74.
|
|
3
|
+
"version": "0.74.2",
|
|
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.74.
|
|
25
|
+
"@porulle/core": "0.74.2"
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|
|
28
28
|
"@types/node": "^24.5.2",
|
package/src/index.ts
CHANGED
|
@@ -572,7 +572,16 @@ export function channelConnectorPlugin(options: ChannelConnectorPluginOptions =
|
|
|
572
572
|
const userId = actor?.userId;
|
|
573
573
|
if (!userId) return oauthError(403, "USER_REQUIRED", "Connecting a store needs a signed-in user.");
|
|
574
574
|
const typed = String((query as { shop?: string; store?: string }).shop ?? (query as { store?: string }).store ?? "");
|
|
575
|
-
|
|
575
|
+
// A connector that can look at the store first does, so the merchant is told what is wrong
|
|
576
|
+
// here rather than landing on a broken page at their own site.
|
|
577
|
+
let storeDomain: string | undefined;
|
|
578
|
+
if (connector.probeStore) {
|
|
579
|
+
const probed = await connector.probeStore(typed);
|
|
580
|
+
if (!probed.ok) return connectOutcome(oauth.postConnectRedirect, { error: probed.error.code, message: probed.error.message });
|
|
581
|
+
storeDomain = probed.value.storeDomain;
|
|
582
|
+
} else {
|
|
583
|
+
storeDomain = connector.normalizeStoreDomain ? connector.normalizeStoreDomain(typed) : typed;
|
|
584
|
+
}
|
|
576
585
|
if (!storeDomain) return connectOutcome(oauth.postConnectRedirect, { error: "INVALID_STORE_DOMAIN", message: `"${typed}" does not name a ${provider} store.` });
|
|
577
586
|
// Refused HERE, before the merchant reaches the provider: a grant issued for a shop retires
|
|
578
587
|
// that shop's other grants, so a refusal at the callback would already have broken them.
|
package/src/live-credentials.ts
CHANGED
|
@@ -19,12 +19,16 @@ import { connectedStores } from "./schema.js";
|
|
|
19
19
|
* token lapsed. The store is then marked `error`, so it reads as "reconnect" instead of failing every
|
|
20
20
|
* later call with a credential error nobody is shown.
|
|
21
21
|
*/
|
|
22
|
+
/** Why a store whose key the provider refused is in `error`. */
|
|
23
|
+
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.";
|
|
24
|
+
|
|
22
25
|
export async function resolveLiveCredentials(connector: ChannelConnector, db: PluginDb, store: ChannelStore, options: { force?: boolean } = {}): Promise<Result<ChannelStore, ChannelConnectorError>> {
|
|
23
26
|
if (!connector.liveCredentials) return Ok(store);
|
|
24
27
|
const answer = await connector.liveCredentials(store, options);
|
|
25
28
|
if (!answer.ok) {
|
|
26
29
|
if (answer.error.retriable !== true) {
|
|
27
|
-
|
|
30
|
+
const statusReason = answer.error.code === CHANNEL_CREDENTIALS_REJECTED ? CREDENTIALS_REJECTED_REASON : answer.error.message;
|
|
31
|
+
await db.update(connectedStores).set({ status: "error", statusReason, updatedAt: new Date() }).where(eq(connectedStores.id, store.id));
|
|
28
32
|
}
|
|
29
33
|
return answer;
|
|
30
34
|
}
|
|
@@ -41,9 +45,6 @@ export async function resolveLiveCredentials(connector: ChannelConnector, db: Pl
|
|
|
41
45
|
|
|
42
46
|
type StoreCall<A extends unknown[], T> = (store: ChannelStore, ...args: A) => Promise<Result<T, ChannelConnectorError>>;
|
|
43
47
|
|
|
44
|
-
/** Why a store whose key the provider refused is in `error`. */
|
|
45
|
-
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.";
|
|
46
|
-
|
|
47
48
|
async function markCredentialsRejected(db: PluginDb, storeId: string): Promise<void> {
|
|
48
49
|
await db.update(connectedStores).set({ status: "error", statusReason: CREDENTIALS_REJECTED_REASON, updatedAt: new Date() }).where(eq(connectedStores.id, storeId));
|
|
49
50
|
}
|
package/src/service.ts
CHANGED
|
@@ -4698,7 +4698,9 @@ export class ChannelConnectorService {
|
|
|
4698
4698
|
};
|
|
4699
4699
|
let inventoryUpdated = 0;
|
|
4700
4700
|
for (const level of inventory.value) {
|
|
4701
|
-
|
|
4701
|
+
// A product and its only variant can share an id (a WooCommerce simple product): stock is the variant's.
|
|
4702
|
+
const mapping = levelled.find((entry) => entry.externalId === level.externalId && entry.kind === "variant")
|
|
4703
|
+
?? levelled.find((entry) => entry.externalId === level.externalId);
|
|
4702
4704
|
if (!mapping) continue;
|
|
4703
4705
|
const current = existingLevels.find((entry) => entry.entityId === mapping.entityId && entry.variantId === (mapping.variantId ?? null));
|
|
4704
4706
|
// Stock cannot sit below zero here, so negative remote stock compares as the zero it is stored as.
|
|
@@ -5289,8 +5291,14 @@ export class ChannelConnectorService {
|
|
|
5289
5291
|
return archived.ok ? Ok([]) : PluginErr(archived.error.message);
|
|
5290
5292
|
}
|
|
5291
5293
|
|
|
5294
|
+
/**
|
|
5295
|
+
* Stock is a variant's. A provider can name a product and its only variant by the same id (a
|
|
5296
|
+
* WooCommerce simple product is both), so the variant mapping wins over the entity one, as the
|
|
5297
|
+
* paged sync's does; an entity mapping alone (a product imported with no variants) still takes it.
|
|
5298
|
+
*/
|
|
5292
5299
|
private async setMappedInventory(orgId: string, storeId: string, externalId: string, quantity: number, actor: Actor): Promise<void> {
|
|
5293
|
-
const
|
|
5300
|
+
const rows = await this.db.select().from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.externalId, externalId)));
|
|
5301
|
+
const mapping = rows.find((row) => row.kind === "variant") ?? rows[0];
|
|
5294
5302
|
if (!mapping) return;
|
|
5295
5303
|
await this.setInventoryLevel(mapping.entityId, mapping.variantId, quantity, actor);
|
|
5296
5304
|
}
|
|
@@ -5818,12 +5826,23 @@ export class ChannelConnectorService {
|
|
|
5818
5826
|
return Ok(rows as ChannelOrderExport[]);
|
|
5819
5827
|
}
|
|
5820
5828
|
|
|
5821
|
-
|
|
5829
|
+
/**
|
|
5830
|
+
* The operator's retry of a failed export: it runs the push again, as a job. The connector looks for
|
|
5831
|
+
* an order it may already have created before creating one (an unclear first answer is exactly what
|
|
5832
|
+
* left the export failed), so a retry never puts a second order in the store.
|
|
5833
|
+
*/
|
|
5834
|
+
async retryExport(
|
|
5822
5835
|
orgId: string,
|
|
5823
5836
|
exportId: string,
|
|
5824
5837
|
changedBy: string,
|
|
5825
5838
|
): Promise<PluginResult<ChannelOrderExport>> {
|
|
5826
|
-
|
|
5839
|
+
const moved = await this.transitionExport(orgId, exportId, "exported", changedBy, "Manual retry requested.");
|
|
5840
|
+
if (!moved.ok) return moved;
|
|
5841
|
+
const jobs = this.services.jobs as JobsAdapter | undefined;
|
|
5842
|
+
if (!jobs) return PluginErr("No jobs adapter is configured, so the retry cannot run.", "JOBS_UNAVAILABLE");
|
|
5843
|
+
const { storeId, orderId } = moved.value;
|
|
5844
|
+
await jobs.enqueue("channel/push-order", { orgId, storeId, orderId }, { organizationId: orgId, concurrencyKey: `push:${orderId}:${storeId}`, supersedes: true });
|
|
5845
|
+
return moved;
|
|
5827
5846
|
}
|
|
5828
5847
|
|
|
5829
5848
|
abandonExport(
|