@porulle/plugin-channel-connector 0.11.0 → 0.13.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/catalog-push-trigger.d.ts +26 -0
- package/dist/catalog-push-trigger.js +70 -0
- package/dist/hooks.js +18 -3
- package/dist/index.d.ts +3 -3
- package/dist/index.js +85 -7
- package/dist/schema.d.ts +812 -55
- package/dist/schema.js +73 -1
- package/dist/service.d.ts +91 -6
- package/dist/service.js +1080 -45
- package/package.json +4 -4
- package/src/catalog-push-trigger.ts +99 -0
- package/src/hooks.ts +30 -6
- package/src/index.ts +124 -5
- package/src/schema.ts +101 -1
- package/src/service.ts +1417 -49
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@porulle/plugin-channel-connector",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -19,15 +19,15 @@
|
|
|
19
19
|
"dependencies": {
|
|
20
20
|
"@hono/zod-openapi": "^1.2.2",
|
|
21
21
|
"hono": "^4.12.5",
|
|
22
|
-
"@porulle/core": "0.
|
|
22
|
+
"@porulle/core": "0.13.0"
|
|
23
23
|
},
|
|
24
24
|
"devDependencies": {
|
|
25
25
|
"@types/node": "^24.5.2",
|
|
26
26
|
"eslint": "^9.39.1",
|
|
27
27
|
"typescript": "5.9.2",
|
|
28
28
|
"vitest": "^3.2.4",
|
|
29
|
-
"@porulle/
|
|
30
|
-
"@porulle/
|
|
29
|
+
"@porulle/typescript-config": "0.1.0",
|
|
30
|
+
"@porulle/eslint-config": "0.1.0"
|
|
31
31
|
},
|
|
32
32
|
"publishConfig": {
|
|
33
33
|
"access": "public"
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { resolveOrgIdForCommerce, isValidFieldPath, type FieldOwner } from "@porulle/core";
|
|
2
|
+
import type { HookContext } from "@porulle/core";
|
|
3
|
+
import { and, eq } from "@porulle/core/drizzle";
|
|
4
|
+
import { catalogPushConcurrencyKey } from "./service.js";
|
|
5
|
+
import { channelEntityMap } from "./schema.js";
|
|
6
|
+
|
|
7
|
+
type CatalogUpdateInput = {
|
|
8
|
+
slug?: string;
|
|
9
|
+
status?: string;
|
|
10
|
+
metadata?: Record<string, unknown>;
|
|
11
|
+
customFields?: Record<string, unknown | null>;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export const CHANNEL_CONVERGENCE_ORIGIN = "channel-convergence";
|
|
15
|
+
|
|
16
|
+
export const CHANNEL_CONVERGENCE_CTX = {
|
|
17
|
+
hookContext: { origin: CHANNEL_CONVERGENCE_ORIGIN },
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
interface CatalogOwnershipService {
|
|
21
|
+
resolveFieldOwners(entityId: string, storeId: string): Promise<Map<string, FieldOwner>>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function updateInputFieldPaths(input: CatalogUpdateInput): string[] {
|
|
25
|
+
const paths: string[] = [];
|
|
26
|
+
if (input.slug !== undefined) paths.push("entity.slug");
|
|
27
|
+
if (input.status !== undefined) paths.push("entity.status");
|
|
28
|
+
if (input.metadata) {
|
|
29
|
+
for (const key of Object.keys(input.metadata)) paths.push(`entity.metadata.${key}`);
|
|
30
|
+
}
|
|
31
|
+
if (input.customFields) {
|
|
32
|
+
for (const name of Object.keys(input.customFields)) paths.push(`customFields.${name}.en`);
|
|
33
|
+
}
|
|
34
|
+
return paths.filter(isValidFieldPath);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function maybeEnqueueCatalogPush(args: {
|
|
38
|
+
entityId: string;
|
|
39
|
+
changedFieldPaths: string[];
|
|
40
|
+
context: HookContext;
|
|
41
|
+
}): Promise<void> {
|
|
42
|
+
if (args.context.context.origin === CHANNEL_CONVERGENCE_ORIGIN) return;
|
|
43
|
+
if (args.changedFieldPaths.length === 0) return;
|
|
44
|
+
|
|
45
|
+
const orgId = resolveOrgIdForCommerce(args.context.actor, args.context.commerceConfig);
|
|
46
|
+
const catalog = args.context.services.catalog as CatalogOwnershipService;
|
|
47
|
+
const mappings = await args.context.db
|
|
48
|
+
.select({ storeId: channelEntityMap.storeId })
|
|
49
|
+
.from(channelEntityMap)
|
|
50
|
+
.where(and(
|
|
51
|
+
eq(channelEntityMap.organizationId, orgId),
|
|
52
|
+
eq(channelEntityMap.entityId, args.entityId),
|
|
53
|
+
eq(channelEntityMap.kind, "entity"),
|
|
54
|
+
));
|
|
55
|
+
|
|
56
|
+
const forceFieldPathsByStore = new Map<string, string[]>();
|
|
57
|
+
for (const mapping of mappings) {
|
|
58
|
+
const owners = await catalog.resolveFieldOwners(args.entityId, mapping.storeId);
|
|
59
|
+
const changedPaths = args.changedFieldPaths.filter((path) => {
|
|
60
|
+
const owner = owners.get(path);
|
|
61
|
+
return owner === "platform" || owner === "shared";
|
|
62
|
+
});
|
|
63
|
+
if (changedPaths.length > 0) forceFieldPathsByStore.set(mapping.storeId, changedPaths);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
await Promise.all([...forceFieldPathsByStore].map(([storeId, forceFieldPaths]) => args.context.jobs.enqueue(
|
|
67
|
+
"channel/push-catalog",
|
|
68
|
+
{
|
|
69
|
+
organizationId: orgId,
|
|
70
|
+
storeId,
|
|
71
|
+
entityIds: [args.entityId],
|
|
72
|
+
forceFieldPaths: { [args.entityId]: forceFieldPaths },
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
organizationId: orgId,
|
|
76
|
+
concurrencyKey: catalogPushConcurrencyKey({ storeId, entityIds: [args.entityId] }),
|
|
77
|
+
supersedes: true,
|
|
78
|
+
},
|
|
79
|
+
)));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function recordUpdateFieldPaths(input: CatalogUpdateInput, context: HookContext): CatalogUpdateInput {
|
|
83
|
+
context.context.changedFieldPaths = updateInputFieldPaths(input);
|
|
84
|
+
return input;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function handleCatalogAfterUpdate(args: {
|
|
88
|
+
result: { id: string };
|
|
89
|
+
context: HookContext;
|
|
90
|
+
}): Promise<void> {
|
|
91
|
+
const changedFieldPaths = Array.isArray(args.context.context.changedFieldPaths)
|
|
92
|
+
? args.context.context.changedFieldPaths.map(String)
|
|
93
|
+
: [];
|
|
94
|
+
await maybeEnqueueCatalogPush({
|
|
95
|
+
entityId: args.result.id,
|
|
96
|
+
changedFieldPaths,
|
|
97
|
+
context: args.context,
|
|
98
|
+
});
|
|
99
|
+
}
|
package/src/hooks.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import type { PluginHookRegistration } from "@porulle/core";
|
|
1
|
+
import { resolveOrgIdForCommerce } from "@porulle/core";
|
|
2
|
+
import type { Actor, CommerceConfig, PluginHookRegistration } from "@porulle/core";
|
|
3
3
|
import { and, eq, inArray } from "@porulle/core/drizzle";
|
|
4
4
|
import { sellableEntities } from "@porulle/core/schema";
|
|
5
5
|
import {
|
|
@@ -7,6 +7,10 @@ import {
|
|
|
7
7
|
type ChannelConnectorPluginOptions,
|
|
8
8
|
type ChannelStockLine,
|
|
9
9
|
} from "./service.js";
|
|
10
|
+
import {
|
|
11
|
+
handleCatalogAfterUpdate,
|
|
12
|
+
recordUpdateFieldPaths,
|
|
13
|
+
} from "./catalog-push-trigger.js";
|
|
10
14
|
|
|
11
15
|
export function buildHooks(options: ChannelConnectorPluginOptions): PluginHookRegistration[] {
|
|
12
16
|
return [{
|
|
@@ -15,14 +19,16 @@ export function buildHooks(options: ChannelConnectorPluginOptions): PluginHookRe
|
|
|
15
19
|
const { data, context } = args as {
|
|
16
20
|
data: { lineItems: ChannelStockLine[] };
|
|
17
21
|
context: {
|
|
18
|
-
actor:
|
|
22
|
+
actor: Actor | null;
|
|
23
|
+
commerceConfig?: CommerceConfig | null;
|
|
19
24
|
db: ConstructorParameters<typeof ChannelConnectorService>[0];
|
|
20
25
|
services: Record<string, unknown>;
|
|
21
26
|
};
|
|
22
27
|
};
|
|
28
|
+
if (data.lineItems.length === 0) return data;
|
|
23
29
|
const service = new ChannelConnectorService(context.db, context.services, options);
|
|
24
30
|
await service.validateLineStock(
|
|
25
|
-
|
|
31
|
+
resolveOrgIdForCommerce(context.actor, context.commerceConfig),
|
|
26
32
|
data.lineItems,
|
|
27
33
|
options.inventoryTimeoutMs,
|
|
28
34
|
);
|
|
@@ -33,12 +39,30 @@ export function buildHooks(options: ChannelConnectorPluginOptions): PluginHookRe
|
|
|
33
39
|
async handler(args: unknown) {
|
|
34
40
|
const { result, context } = args as {
|
|
35
41
|
result: { id: string; lineItems?: Array<{ entityId: string }> };
|
|
36
|
-
context: { actor:
|
|
42
|
+
context: { actor: Actor | null; commerceConfig?: CommerceConfig | null; db: ConstructorParameters<typeof ChannelConnectorService>[0]; services: Record<string, unknown>; jobs: { enqueue(task: string, input: Record<string, unknown>, options: { organizationId: string; concurrencyKey: string; supersedes: boolean }): Promise<string> } };
|
|
37
43
|
};
|
|
38
|
-
const orgId =
|
|
44
|
+
const orgId = resolveOrgIdForCommerce(context.actor, context.commerceConfig);
|
|
39
45
|
const entities = await context.db.select({ id: sellableEntities.id, sourceStoreId: sellableEntities.sourceStoreId }).from(sellableEntities).where(and(eq(sellableEntities.organizationId, orgId), inArray(sellableEntities.id, (result.lineItems ?? []).map((line) => line.entityId))));
|
|
40
46
|
const stores = new Set(entities.map((entity) => entity.sourceStoreId).filter((storeId): storeId is string => storeId !== null));
|
|
41
47
|
await Promise.all([...stores].map((storeId) => context.jobs.enqueue("channel/push-order", { orgId, storeId, orderId: result.id }, { organizationId: orgId, concurrencyKey: `push:${result.id}:${storeId}`, supersedes: true })));
|
|
42
48
|
},
|
|
49
|
+
}, {
|
|
50
|
+
key: "catalog.beforeUpdate",
|
|
51
|
+
handler(args: unknown) {
|
|
52
|
+
const { data, context } = args as {
|
|
53
|
+
data: Parameters<typeof recordUpdateFieldPaths>[0];
|
|
54
|
+
context: Parameters<typeof recordUpdateFieldPaths>[1];
|
|
55
|
+
};
|
|
56
|
+
return recordUpdateFieldPaths(data, context);
|
|
57
|
+
},
|
|
58
|
+
}, {
|
|
59
|
+
key: "catalog.afterUpdate",
|
|
60
|
+
async handler(args: unknown) {
|
|
61
|
+
const { result, context } = args as {
|
|
62
|
+
result: { id: string };
|
|
63
|
+
context: Parameters<typeof handleCatalogAfterUpdate>[0]["context"];
|
|
64
|
+
};
|
|
65
|
+
await handleCatalogAfterUpdate({ result, context });
|
|
66
|
+
},
|
|
43
67
|
}];
|
|
44
68
|
}
|
package/src/index.ts
CHANGED
|
@@ -7,12 +7,18 @@ import {
|
|
|
7
7
|
defineCommercePlugin,
|
|
8
8
|
router,
|
|
9
9
|
createSystemActor,
|
|
10
|
+
isValidFieldPath,
|
|
11
|
+
requireUserId,
|
|
10
12
|
} from "@porulle/core";
|
|
11
|
-
import type { JobsAdapter, PluginResult, PluginRouteRegistration, TaskDefinition } from "@porulle/core";
|
|
13
|
+
import type { FieldPath, JobsAdapter, PluginResult, PluginRouteRegistration, TaskDefinition } from "@porulle/core";
|
|
12
14
|
import { z } from "@hono/zod-openapi";
|
|
13
15
|
import { and, eq } from "@porulle/core/drizzle";
|
|
14
16
|
import { processedWebhookEvents } from "@porulle/core/schema";
|
|
15
17
|
import {
|
|
18
|
+
channelCatalogPushEvents,
|
|
19
|
+
channelCatalogPushes,
|
|
20
|
+
channelCatalogConflicts,
|
|
21
|
+
channelCatalogConflictEvents,
|
|
16
22
|
channelEntityMap,
|
|
17
23
|
channelExportEvents,
|
|
18
24
|
channelOrderExports,
|
|
@@ -22,6 +28,8 @@ import {
|
|
|
22
28
|
} from "./schema.js";
|
|
23
29
|
import {
|
|
24
30
|
ChannelConnectorService,
|
|
31
|
+
catalogPushConcurrencyKey,
|
|
32
|
+
type CatalogConflictState,
|
|
25
33
|
type ChannelComplianceData,
|
|
26
34
|
type ChannelConnectorPluginOptions,
|
|
27
35
|
} from "./service.js";
|
|
@@ -30,16 +38,24 @@ import { oauthStateEventId, signState, verifyState } from "./oauth-state.js";
|
|
|
30
38
|
|
|
31
39
|
type ChannelRouteContext = {
|
|
32
40
|
input: unknown;
|
|
41
|
+
query?: unknown;
|
|
33
42
|
params: Record<string, string>;
|
|
34
43
|
orgId: string;
|
|
35
|
-
actor: { userId: string } | null;
|
|
44
|
+
actor: { userId: string | null } | null;
|
|
36
45
|
};
|
|
37
46
|
|
|
38
47
|
export { mockChannelConnector } from "./mock-connector.js";
|
|
39
48
|
export type { MockChannelConnectorOptions } from "./mock-connector.js";
|
|
40
49
|
export {
|
|
41
50
|
ChannelConnectorService,
|
|
51
|
+
CATALOG_OUTBOUND_SUPPRESSION_WINDOW_MS,
|
|
52
|
+
CATALOG_PUSH_BATCH_SIZES,
|
|
53
|
+
CATALOG_PUSH_MAX_ATTEMPTS,
|
|
54
|
+
canCatalogPushTransition,
|
|
42
55
|
canExportTransition,
|
|
56
|
+
catalogPushConcurrencyKey,
|
|
57
|
+
catalogPushRetryDelayMs,
|
|
58
|
+
isCatalogPushBreakerOpen,
|
|
43
59
|
} from "./service.js";
|
|
44
60
|
export {
|
|
45
61
|
isValidCatalogMappingFieldPath,
|
|
@@ -63,10 +79,22 @@ export type {
|
|
|
63
79
|
BackfillCatalogReport,
|
|
64
80
|
BuildCatalogPushItemsOptions,
|
|
65
81
|
BuildCatalogPushItemsResult,
|
|
82
|
+
CatalogPushAssemblyField,
|
|
83
|
+
CatalogPushAssemblyImage,
|
|
84
|
+
CatalogPushAssemblyItem,
|
|
85
|
+
CatalogPushPreviewBefore,
|
|
86
|
+
CatalogPushPreviewBeforeStatus,
|
|
87
|
+
CatalogPushPreviewDiff,
|
|
88
|
+
CatalogPushPreviewItem,
|
|
89
|
+
CatalogPushPreviewResult,
|
|
90
|
+
CatalogPushPreviewUnavailable,
|
|
91
|
+
PushCatalogToStoreResult,
|
|
92
|
+
CatalogPushJobResult,
|
|
66
93
|
CatalogFieldConflict,
|
|
67
94
|
CatalogFieldSkip,
|
|
68
95
|
CatalogPushFieldSkip,
|
|
69
96
|
CatalogPushSkipReason,
|
|
97
|
+
CatalogConflictState,
|
|
70
98
|
CatalogWriteSettings,
|
|
71
99
|
ChannelComplianceData,
|
|
72
100
|
ChannelConnectorPluginOptions,
|
|
@@ -77,6 +105,10 @@ export type {
|
|
|
77
105
|
} from "./service.js";
|
|
78
106
|
export type { OAuthStatePayload, OAuthStateResult } from "./oauth-state.js";
|
|
79
107
|
export type {
|
|
108
|
+
ChannelCatalogPush,
|
|
109
|
+
ChannelCatalogPushEvent,
|
|
110
|
+
ChannelCatalogConflict,
|
|
111
|
+
ChannelCatalogConflictEvent,
|
|
80
112
|
ChannelEntityMapEntry,
|
|
81
113
|
ChannelExportEvent,
|
|
82
114
|
ChannelOrderExport,
|
|
@@ -228,6 +260,37 @@ export function channelConnectorPlugin(options: ChannelConnectorPluginOptions =
|
|
|
228
260
|
return { output: { exportId: result.value.id, state: result.value.state } };
|
|
229
261
|
},
|
|
230
262
|
},
|
|
263
|
+
{
|
|
264
|
+
slug: "channel/push-catalog",
|
|
265
|
+
concurrency: { key: catalogPushConcurrencyKey, supersedes: true },
|
|
266
|
+
handler: async ({ input, ctx }: { input: Record<string, unknown>; ctx: import("@porulle/core").TaskContext }) => {
|
|
267
|
+
const service = new ChannelConnectorService(ctx.db, ctx.services, options);
|
|
268
|
+
const orgId = String(input.organizationId ?? input.orgId);
|
|
269
|
+
const storeId = String(input.storeId);
|
|
270
|
+
const entityIds = Array.isArray(input.entityIds)
|
|
271
|
+
? input.entityIds.map(String)
|
|
272
|
+
: undefined;
|
|
273
|
+
const forceFieldPaths = typeof input.forceFieldPaths === "object" && input.forceFieldPaths !== null
|
|
274
|
+
? Object.fromEntries(Object.entries(input.forceFieldPaths).flatMap(([entityId, paths]) => [
|
|
275
|
+
[entityId, Array.isArray(paths) ? paths.filter((path): path is FieldPath => typeof path === "string" && isValidFieldPath(path)) : []],
|
|
276
|
+
]))
|
|
277
|
+
: undefined;
|
|
278
|
+
const cursor = typeof input.cursor === "string" ? input.cursor : undefined;
|
|
279
|
+
const result = await service.executeCatalogPushJob(
|
|
280
|
+
orgId,
|
|
281
|
+
storeId,
|
|
282
|
+
{
|
|
283
|
+
...(entityIds ? { entityIds } : {}),
|
|
284
|
+
...(forceFieldPaths ? { forceFieldPaths } : {}),
|
|
285
|
+
...(cursor ? { cursor } : {}),
|
|
286
|
+
},
|
|
287
|
+
createSystemActor(orgId),
|
|
288
|
+
{ jobs: ctx.services.jobs as JobsAdapter },
|
|
289
|
+
);
|
|
290
|
+
if (!result.ok) throw new Error(result.error);
|
|
291
|
+
return { output: result.value };
|
|
292
|
+
},
|
|
293
|
+
},
|
|
231
294
|
{
|
|
232
295
|
slug: "channel/reap-exports",
|
|
233
296
|
handler: async ({ input, ctx }: { input: Record<string, unknown>; ctx: import("@porulle/core").TaskContext }) => {
|
|
@@ -250,6 +313,10 @@ export function channelConnectorPlugin(options: ChannelConnectorPluginOptions =
|
|
|
250
313
|
schema: () => ({
|
|
251
314
|
connectedStores,
|
|
252
315
|
channelEntityMap,
|
|
316
|
+
channelCatalogPushes,
|
|
317
|
+
channelCatalogPushEvents,
|
|
318
|
+
channelCatalogConflicts,
|
|
319
|
+
channelCatalogConflictEvents,
|
|
253
320
|
channelOrderExports,
|
|
254
321
|
channelExportEvents,
|
|
255
322
|
channelRefundRequests,
|
|
@@ -458,6 +525,25 @@ export function channelConnectorPlugin(options: ChannelConnectorPluginOptions =
|
|
|
458
525
|
.permission("channels:read")
|
|
459
526
|
.handler(async ({ params, orgId }: ChannelRouteContext) => unwrap(await service.getReconcileStatus(orgId, params.storeId!)));
|
|
460
527
|
|
|
528
|
+
channels.get("/conflicts")
|
|
529
|
+
.summary("List channel catalog conflicts")
|
|
530
|
+
.permission("channels:read")
|
|
531
|
+
.query(z.object({ storeId: z.string().min(1).optional(), state: z.enum(["open", "resolved"]).optional() }))
|
|
532
|
+
.handler(async ({ query, orgId }: ChannelRouteContext) => {
|
|
533
|
+
const values = query as { storeId?: string; state?: CatalogConflictState };
|
|
534
|
+
return unwrap(await service.listCatalogConflicts(orgId, values.storeId, values.state));
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
channels.post("/conflicts/{id}/resolve")
|
|
538
|
+
.summary("Resolve a channel catalog conflict")
|
|
539
|
+
.permission("channels:manage")
|
|
540
|
+
.params(z.object({ id: z.string().min(1) }))
|
|
541
|
+
.input(z.object({ choose: z.enum(["platform", "store"]) }))
|
|
542
|
+
.handler(async ({ params, orgId, input, actor }: ChannelRouteContext) => {
|
|
543
|
+
const values = input as { choose: "platform" | "store" };
|
|
544
|
+
return unwrap(await service.resolveCatalogConflict(orgId, params.id!, values.choose, actor!));
|
|
545
|
+
});
|
|
546
|
+
|
|
461
547
|
channels.post("/stores/{storeId}/backfill")
|
|
462
548
|
.summary("Backfill a channel catalog into the PIM")
|
|
463
549
|
.permission("channels:manage")
|
|
@@ -480,6 +566,39 @@ export function channelConnectorPlugin(options: ChannelConnectorPluginOptions =
|
|
|
480
566
|
return { enqueued: true, storeId: params.storeId! };
|
|
481
567
|
});
|
|
482
568
|
|
|
569
|
+
channels.post("/stores/{storeId}/push-catalog")
|
|
570
|
+
.summary("Enqueue a catalog push for a connected store")
|
|
571
|
+
.permission("channels:manage")
|
|
572
|
+
.input(z.object({ entityIds: z.array(z.string()).optional() }))
|
|
573
|
+
.handler(async ({ params, orgId, input }: ChannelRouteContext) => {
|
|
574
|
+
unwrap(await service.getStore(orgId, params.storeId!));
|
|
575
|
+
const values = input as { entityIds?: string[] };
|
|
576
|
+
const jobs = ctx.services.jobs as JobsAdapter;
|
|
577
|
+
await jobs.enqueue("channel/push-catalog", {
|
|
578
|
+
organizationId: orgId,
|
|
579
|
+
storeId: params.storeId!,
|
|
580
|
+
...(values.entityIds ? { entityIds: values.entityIds } : {}),
|
|
581
|
+
}, {
|
|
582
|
+
organizationId: orgId,
|
|
583
|
+
concurrencyKey: catalogPushConcurrencyKey({
|
|
584
|
+
storeId: params.storeId!,
|
|
585
|
+
...(values.entityIds ? { entityIds: values.entityIds } : {}),
|
|
586
|
+
}),
|
|
587
|
+
supersedes: true,
|
|
588
|
+
});
|
|
589
|
+
return { enqueued: true, storeId: params.storeId! };
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
channels.post("/stores/{storeId}/push-catalog/preview")
|
|
593
|
+
.summary("Preview a catalog push for a connected store")
|
|
594
|
+
.permission("channels:manage")
|
|
595
|
+
.input(z.object({ entityIds: z.array(z.string()).optional() }))
|
|
596
|
+
.handler(async ({ params, orgId, input }: ChannelRouteContext) => {
|
|
597
|
+
unwrap(await service.getStore(orgId, params.storeId!));
|
|
598
|
+
const values = input as { entityIds?: string[] };
|
|
599
|
+
return unwrap(await service.previewCatalogPush(orgId, params.storeId!, values.entityIds));
|
|
600
|
+
});
|
|
601
|
+
|
|
483
602
|
channels.post("/stores/{id}/disconnect")
|
|
484
603
|
.summary("Disconnect a channel store")
|
|
485
604
|
.permission("channels:manage")
|
|
@@ -498,12 +617,12 @@ export function channelConnectorPlugin(options: ChannelConnectorPluginOptions =
|
|
|
498
617
|
channels.post("/refund-requests/{id}/approve")
|
|
499
618
|
.summary("Approve a channel refund request")
|
|
500
619
|
.permission("channels:manage")
|
|
501
|
-
.handler(async ({ params, orgId, actor }: ChannelRouteContext) => unwrap(await service.approveRefund(orgId, params.id!, actor
|
|
620
|
+
.handler(async ({ params, orgId, actor }: ChannelRouteContext) => unwrap(await service.approveRefund(orgId, params.id!, { userId: requireUserId(actor) })));
|
|
502
621
|
|
|
503
622
|
channels.post("/refund-requests/{id}/reject")
|
|
504
623
|
.summary("Reject a channel refund request")
|
|
505
624
|
.permission("channels:manage")
|
|
506
|
-
.handler(async ({ params, orgId, actor }: ChannelRouteContext) => unwrap(await service.rejectRefund(orgId, params.id!, actor
|
|
625
|
+
.handler(async ({ params, orgId, actor }: ChannelRouteContext) => unwrap(await service.rejectRefund(orgId, params.id!, { userId: requireUserId(actor) })));
|
|
507
626
|
|
|
508
627
|
channels.post("/exports/{id}/retry")
|
|
509
628
|
.summary("Retry a failed channel order export")
|
|
@@ -511,7 +630,7 @@ export function channelConnectorPlugin(options: ChannelConnectorPluginOptions =
|
|
|
511
630
|
.handler(async ({ params, orgId, actor }: ChannelRouteContext) => unwrap(await service.retryExport(
|
|
512
631
|
orgId,
|
|
513
632
|
params.id!,
|
|
514
|
-
actor
|
|
633
|
+
requireUserId(actor),
|
|
515
634
|
)));
|
|
516
635
|
|
|
517
636
|
return channels.routes() as PluginRouteRegistration[];
|
package/src/schema.ts
CHANGED
|
@@ -8,8 +8,9 @@ import {
|
|
|
8
8
|
timestamp,
|
|
9
9
|
uniqueIndex,
|
|
10
10
|
uuid,
|
|
11
|
+
sql,
|
|
11
12
|
} from "@porulle/core/drizzle";
|
|
12
|
-
import type { FieldPath } from "@porulle/core";
|
|
13
|
+
import type { ChannelPushCatalogItem, FieldPath } from "@porulle/core";
|
|
13
14
|
import type { CatalogFieldMapping } from "./catalog-field-mapping.js";
|
|
14
15
|
|
|
15
16
|
export const connectedStores = pgTable(
|
|
@@ -53,7 +54,11 @@ export const channelEntityMap = pgTable(
|
|
|
53
54
|
variantId: uuid("variant_id"),
|
|
54
55
|
lastSyncedAt: timestamp("last_synced_at", { withTimezone: true }).defaultNow().notNull(),
|
|
55
56
|
syncHash: text("sync_hash").notNull(),
|
|
57
|
+
outboundHash: text("outbound_hash"),
|
|
58
|
+
outboundPushedAt: timestamp("outbound_pushed_at", { withTimezone: true }),
|
|
59
|
+
outboundFieldPaths: jsonb("outbound_field_paths").$type<FieldPath[]>().notNull().default([]),
|
|
56
60
|
heldFieldPaths: jsonb("held_field_paths").$type<FieldPath[]>().notNull().default([]),
|
|
61
|
+
forcedPushFieldPaths: jsonb("forced_push_field_paths").$type<FieldPath[]>().notNull().default([]),
|
|
57
62
|
},
|
|
58
63
|
(table) => ({
|
|
59
64
|
orgIdx: index("idx_channel_entity_map_org").on(table.organizationId),
|
|
@@ -66,6 +71,48 @@ export const channelEntityMap = pgTable(
|
|
|
66
71
|
}),
|
|
67
72
|
);
|
|
68
73
|
|
|
74
|
+
export const channelCatalogConflicts = pgTable(
|
|
75
|
+
"channel_catalog_conflicts",
|
|
76
|
+
{
|
|
77
|
+
id: uuid("id").defaultRandom().primaryKey(),
|
|
78
|
+
organizationId: text("organization_id").notNull(),
|
|
79
|
+
storeId: uuid("store_id").references(() => connectedStores.id, { onDelete: "cascade" }).notNull(),
|
|
80
|
+
entityId: uuid("entity_id").notNull(),
|
|
81
|
+
fieldPath: text("field_path").notNull(),
|
|
82
|
+
platformValue: jsonb("platform_value").$type<unknown>().notNull(),
|
|
83
|
+
storeValue: jsonb("store_value").$type<unknown>().notNull(),
|
|
84
|
+
state: text("state", { enum: ["open", "resolved"] }).notNull().default("open"),
|
|
85
|
+
resolvedBy: text("resolved_by"),
|
|
86
|
+
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
|
87
|
+
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
|
88
|
+
},
|
|
89
|
+
(table) => ({
|
|
90
|
+
orgIdx: index("idx_channel_catalog_conflicts_org").on(table.organizationId),
|
|
91
|
+
stateIdx: index("idx_channel_catalog_conflicts_org_state").on(table.organizationId, table.state),
|
|
92
|
+
openUnique: uniqueIndex("channel_catalog_conflicts_open_unique")
|
|
93
|
+
.on(table.storeId, table.entityId, table.fieldPath)
|
|
94
|
+
.where(sql`${table.state} = 'open'`),
|
|
95
|
+
}),
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
export const channelCatalogConflictEvents = pgTable(
|
|
99
|
+
"channel_catalog_conflict_events",
|
|
100
|
+
{
|
|
101
|
+
id: uuid("id").defaultRandom().primaryKey(),
|
|
102
|
+
organizationId: text("organization_id").notNull(),
|
|
103
|
+
conflictId: uuid("conflict_id").references(() => channelCatalogConflicts.id, { onDelete: "cascade" }).notNull(),
|
|
104
|
+
fromState: text("from_state"),
|
|
105
|
+
toState: text("to_state").notNull(),
|
|
106
|
+
reason: text("reason"),
|
|
107
|
+
changedBy: text("changed_by").notNull(),
|
|
108
|
+
changedAt: timestamp("changed_at", { withTimezone: true }).defaultNow().notNull(),
|
|
109
|
+
},
|
|
110
|
+
(table) => ({
|
|
111
|
+
orgIdx: index("idx_channel_catalog_conflict_events_org").on(table.organizationId),
|
|
112
|
+
conflictIdx: index("idx_channel_catalog_conflict_events_conflict").on(table.conflictId),
|
|
113
|
+
}),
|
|
114
|
+
);
|
|
115
|
+
|
|
69
116
|
export const channelOrderExports = pgTable(
|
|
70
117
|
"channel_order_exports",
|
|
71
118
|
{
|
|
@@ -97,6 +144,55 @@ export const channelOrderExports = pgTable(
|
|
|
97
144
|
}),
|
|
98
145
|
);
|
|
99
146
|
|
|
147
|
+
export const channelCatalogPushes = pgTable(
|
|
148
|
+
"channel_catalog_pushes",
|
|
149
|
+
{
|
|
150
|
+
id: uuid("id").defaultRandom().primaryKey(),
|
|
151
|
+
organizationId: text("organization_id").notNull(),
|
|
152
|
+
storeId: uuid("store_id").references(() => connectedStores.id, { onDelete: "cascade" }).notNull(),
|
|
153
|
+
entityId: uuid("entity_id").notNull(),
|
|
154
|
+
payloadSnapshot: jsonb("payload_snapshot").$type<ChannelPushCatalogItem | null>(),
|
|
155
|
+
state: text("state", { enum: ["pending", "exported", "confirmed", "failed", "abandoned"] })
|
|
156
|
+
.notNull()
|
|
157
|
+
.default("pending"),
|
|
158
|
+
failureKind: text("failure_kind", { enum: ["definitive", "transient"] }),
|
|
159
|
+
attempts: integer("attempts").notNull().default(0),
|
|
160
|
+
lastError: text("last_error"),
|
|
161
|
+
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
|
162
|
+
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
|
163
|
+
},
|
|
164
|
+
(table) => ({
|
|
165
|
+
orgIdx: index("idx_channel_catalog_pushes_org").on(table.organizationId),
|
|
166
|
+
storeIdx: index("idx_channel_catalog_pushes_store").on(table.storeId),
|
|
167
|
+
stateIdx: index("idx_channel_catalog_pushes_state").on(table.organizationId, table.state),
|
|
168
|
+
entityIdx: index("idx_channel_catalog_pushes_entity").on(table.organizationId, table.entityId),
|
|
169
|
+
storeEntityUnique: uniqueIndex("channel_catalog_pushes_store_entity_unique").on(
|
|
170
|
+
table.storeId,
|
|
171
|
+
table.entityId,
|
|
172
|
+
),
|
|
173
|
+
}),
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
export const channelCatalogPushEvents = pgTable(
|
|
177
|
+
"channel_catalog_push_events",
|
|
178
|
+
{
|
|
179
|
+
id: uuid("id").defaultRandom().primaryKey(),
|
|
180
|
+
organizationId: text("organization_id").notNull(),
|
|
181
|
+
pushId: uuid("push_id")
|
|
182
|
+
.references(() => channelCatalogPushes.id, { onDelete: "cascade" })
|
|
183
|
+
.notNull(),
|
|
184
|
+
fromState: text("from_state").notNull(),
|
|
185
|
+
toState: text("to_state").notNull(),
|
|
186
|
+
reason: text("reason"),
|
|
187
|
+
changedBy: text("changed_by").notNull(),
|
|
188
|
+
changedAt: timestamp("changed_at", { withTimezone: true }).defaultNow().notNull(),
|
|
189
|
+
},
|
|
190
|
+
(table) => ({
|
|
191
|
+
orgIdx: index("idx_channel_catalog_push_events_org").on(table.organizationId),
|
|
192
|
+
pushIdx: index("idx_channel_catalog_push_events_push").on(table.pushId),
|
|
193
|
+
}),
|
|
194
|
+
);
|
|
195
|
+
|
|
100
196
|
export const channelExportEvents = pgTable(
|
|
101
197
|
"channel_export_events",
|
|
102
198
|
{
|
|
@@ -158,6 +254,10 @@ export const channelRefundEvents = pgTable(
|
|
|
158
254
|
|
|
159
255
|
export type ConnectedStore = typeof connectedStores.$inferSelect;
|
|
160
256
|
export type ChannelEntityMapEntry = typeof channelEntityMap.$inferSelect;
|
|
257
|
+
export type ChannelCatalogConflict = typeof channelCatalogConflicts.$inferSelect;
|
|
258
|
+
export type ChannelCatalogConflictEvent = typeof channelCatalogConflictEvents.$inferSelect;
|
|
259
|
+
export type ChannelCatalogPush = typeof channelCatalogPushes.$inferSelect;
|
|
260
|
+
export type ChannelCatalogPushEvent = typeof channelCatalogPushEvents.$inferSelect;
|
|
161
261
|
export type ChannelOrderExport = typeof channelOrderExports.$inferSelect;
|
|
162
262
|
export type ChannelExportEvent = typeof channelExportEvents.$inferSelect;
|
|
163
263
|
export type ChannelRefundRequest = typeof channelRefundRequests.$inferSelect;
|