@porulle/core 0.8.0 → 0.10.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/auth/middleware.d.ts.map +1 -1
- package/dist/auth/middleware.js +26 -7
- package/dist/auth/setup.d.ts.map +1 -1
- package/dist/auth/setup.js +7 -5
- package/dist/config/defaults.d.ts.map +1 -1
- package/dist/config/defaults.js +3 -1
- package/dist/config/types.d.ts +13 -3
- package/dist/config/types.d.ts.map +1 -1
- package/dist/generated/plugin-manifest.d.ts +3 -0
- package/dist/generated/plugin-manifest.d.ts.map +1 -1
- package/dist/generated/plugin-manifest.js +1 -0
- package/dist/hooks/checkout-completion.d.ts.map +1 -1
- package/dist/hooks/checkout-completion.js +2 -2
- package/dist/hooks/checkout.d.ts.map +1 -1
- package/dist/hooks/checkout.js +1 -1
- package/dist/index.d.ts +5 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/interfaces/rest/router.d.ts.map +1 -1
- package/dist/interfaces/rest/router.js +2 -0
- package/dist/interfaces/rest/routes/admin/staff.d.ts.map +1 -1
- package/dist/interfaces/rest/routes/admin/staff.js +37 -3
- package/dist/interfaces/rest/routes/checkout.d.ts +9 -0
- package/dist/interfaces/rest/routes/checkout.d.ts.map +1 -1
- package/dist/interfaces/rest/routes/checkout.js +82 -22
- package/dist/interfaces/rest/routes/inventory.d.ts.map +1 -1
- package/dist/interfaces/rest/routes/inventory.js +13 -0
- package/dist/interfaces/rest/routes/orders.d.ts.map +1 -1
- package/dist/interfaces/rest/routes/orders.js +13 -0
- package/dist/interfaces/rest/schemas/catalog.d.ts +391 -0
- package/dist/interfaces/rest/schemas/catalog.d.ts.map +1 -1
- package/dist/interfaces/rest/schemas/orders.js +2 -2
- package/dist/interfaces/rest/schemas/responses.d.ts +85 -0
- package/dist/interfaces/rest/schemas/responses.d.ts.map +1 -1
- package/dist/interfaces/rest/schemas/search.d.ts +17 -0
- package/dist/interfaces/rest/schemas/search.d.ts.map +1 -1
- package/dist/kernel/database/scoped-db.d.ts +2 -1
- package/dist/kernel/database/scoped-db.d.ts.map +1 -1
- package/dist/kernel/database/scoped-db.js +74 -15
- package/dist/kernel/errors.d.ts +12 -0
- package/dist/kernel/errors.d.ts.map +1 -1
- package/dist/kernel/errors.js +16 -0
- package/dist/kernel/jobs/adapter.d.ts +27 -8
- package/dist/kernel/jobs/adapter.d.ts.map +1 -1
- package/dist/kernel/jobs/adapter.js +1 -2
- package/dist/kernel/jobs/drizzle-adapter.d.ts +13 -3
- package/dist/kernel/jobs/drizzle-adapter.d.ts.map +1 -1
- package/dist/kernel/jobs/drizzle-adapter.js +46 -5
- package/dist/kernel/jobs/runner.d.ts +2 -1
- package/dist/kernel/jobs/runner.d.ts.map +1 -1
- package/dist/kernel/jobs/runner.js +100 -15
- package/dist/kernel/jobs/types.d.ts +13 -0
- package/dist/kernel/jobs/types.d.ts.map +1 -1
- package/dist/kernel/plugin/manifest.d.ts +5 -0
- package/dist/kernel/plugin/manifest.d.ts.map +1 -1
- package/dist/kernel/plugin/manifest.js +9 -0
- package/dist/modules/analytics/drizzle-adapter.d.ts.map +1 -1
- package/dist/modules/analytics/drizzle-adapter.js +16 -3
- package/dist/modules/cart/service.d.ts.map +1 -1
- package/dist/modules/cart/service.js +3 -2
- package/dist/modules/catalog/entity-service.d.ts +1 -0
- package/dist/modules/catalog/entity-service.d.ts.map +1 -1
- package/dist/modules/catalog/entity-service.js +49 -3
- package/dist/modules/catalog/repository/index.d.ts +2 -2
- package/dist/modules/catalog/repository/index.d.ts.map +1 -1
- package/dist/modules/catalog/repository/index.js +9 -1
- package/dist/modules/catalog/schema.d.ts +51 -0
- package/dist/modules/catalog/schema.d.ts.map +1 -1
- package/dist/modules/catalog/schema.js +12 -1
- package/dist/modules/catalog/schemas.d.ts +3 -1
- package/dist/modules/catalog/schemas.d.ts.map +1 -1
- package/dist/modules/channels/adapter.d.ts +117 -0
- package/dist/modules/channels/adapter.d.ts.map +1 -0
- package/dist/modules/channels/adapter.js +3 -0
- package/dist/modules/customers/service.d.ts.map +1 -1
- package/dist/modules/customers/service.js +6 -0
- package/dist/modules/inventory/repository/index.js +4 -4
- package/dist/modules/inventory/service.d.ts.map +1 -1
- package/dist/modules/inventory/service.js +7 -3
- package/dist/modules/orders/schema.d.ts +17 -0
- package/dist/modules/orders/schema.d.ts.map +1 -1
- package/dist/modules/orders/schema.js +4 -0
- package/dist/modules/orders/service.d.ts +16 -2
- package/dist/modules/orders/service.d.ts.map +1 -1
- package/dist/modules/orders/service.js +226 -26
- package/dist/modules/orders/stale-order-cleanup.d.ts.map +1 -1
- package/dist/modules/orders/stale-order-cleanup.js +37 -16
- package/dist/modules/pricing/service.d.ts.map +1 -1
- package/dist/modules/pricing/service.js +5 -5
- package/dist/modules/promotions/repository/index.d.ts +1 -1
- package/dist/modules/promotions/repository/index.d.ts.map +1 -1
- package/dist/modules/promotions/repository/index.js +6 -3
- package/dist/modules/promotions/service.d.ts +1 -0
- package/dist/modules/promotions/service.d.ts.map +1 -1
- package/dist/modules/promotions/service.js +1 -1
- package/dist/modules/webhooks/repository/index.d.ts +1 -1
- package/dist/modules/webhooks/repository/index.d.ts.map +1 -1
- package/dist/modules/webhooks/repository/index.js +16 -10
- package/dist/runtime/kernel.d.ts.map +1 -1
- package/dist/runtime/kernel.js +14 -2
- package/dist/runtime/server.d.ts +1 -4
- package/dist/runtime/server.d.ts.map +1 -1
- package/dist/runtime/server.js +16 -34
- package/dist/test-utils/create-test-config.d.ts.map +1 -1
- package/dist/test-utils/create-test-config.js +2 -0
- package/dist/test-utils/order-test-helpers.d.ts +12 -0
- package/dist/test-utils/order-test-helpers.d.ts.map +1 -0
- package/dist/test-utils/order-test-helpers.js +18 -0
- package/dist/test-utils/rest-api-test-utils.d.ts.map +1 -1
- package/dist/test-utils/rest-api-test-utils.js +1 -0
- package/dist/test-utils/test-actors.js +1 -1
- package/dist/testing.d.ts +1 -0
- package/dist/testing.d.ts.map +1 -1
- package/dist/testing.js +1 -0
- package/package.json +1 -1
- package/src/auth/middleware.ts +26 -7
- package/src/auth/setup.ts +10 -4
- package/src/config/defaults.ts +3 -1
- package/src/config/types.ts +16 -3
- package/src/generated/plugin-capabilities.d.ts +1 -0
- package/src/generated/plugin-manifest.ts +1 -0
- package/src/generated/plugin-repositories.d.ts +1 -0
- package/src/hooks/checkout-completion.ts +22 -16
- package/src/hooks/checkout.ts +11 -8
- package/src/index.ts +31 -1
- package/src/interfaces/rest/router.ts +2 -0
- package/src/interfaces/rest/routes/admin/staff.ts +45 -3
- package/src/interfaces/rest/routes/checkout.ts +94 -28
- package/src/interfaces/rest/routes/inventory.ts +14 -0
- package/src/interfaces/rest/routes/orders.ts +11 -0
- package/src/interfaces/rest/schemas/orders.ts +2 -2
- package/src/kernel/database/scoped-db.ts +83 -15
- package/src/kernel/errors.ts +20 -0
- package/src/kernel/jobs/adapter.ts +39 -8
- package/src/kernel/jobs/drizzle-adapter.ts +72 -9
- package/src/kernel/jobs/runner.ts +194 -72
- package/src/kernel/jobs/types.ts +25 -0
- package/src/kernel/plugin/manifest.ts +15 -0
- package/src/modules/analytics/drizzle-adapter.ts +19 -3
- package/src/modules/cart/service.ts +3 -2
- package/src/modules/catalog/entity-service.ts +37 -2
- package/src/modules/catalog/repository/index.ts +13 -3
- package/src/modules/catalog/schema.ts +33 -18
- package/src/modules/catalog/schemas.ts +3 -1
- package/src/modules/channels/adapter.ts +139 -0
- package/src/modules/customers/service.ts +6 -0
- package/src/modules/inventory/repository/index.ts +4 -4
- package/src/modules/inventory/service.ts +12 -2
- package/src/modules/orders/schema.ts +4 -0
- package/src/modules/orders/service.ts +404 -64
- package/src/modules/orders/stale-order-cleanup.ts +52 -27
- package/src/modules/pricing/service.ts +5 -6
- package/src/modules/promotions/repository/index.ts +6 -2
- package/src/modules/promotions/service.ts +2 -0
- package/src/modules/webhooks/repository/index.ts +16 -14
- package/src/runtime/kernel.ts +16 -2
- package/src/runtime/server.ts +22 -42
- package/src/test-utils/create-test-config.ts +2 -0
- package/src/test-utils/order-test-helpers.ts +26 -0
- package/src/test-utils/rest-api-test-utils.ts +1 -0
- package/src/test-utils/test-actors.ts +1 -1
- package/src/testing.ts +1 -0
|
@@ -55,6 +55,35 @@ export function adminStaffRoutes(kernel: Kernel) {
|
|
|
55
55
|
);
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
const BUILTIN_ROLE_RANK: Record<string, number> = {
|
|
59
|
+
owner: 3,
|
|
60
|
+
admin: 2,
|
|
61
|
+
};
|
|
62
|
+
const CUSTOM_ROLE_RANK = 1;
|
|
63
|
+
|
|
64
|
+
function roleRank(role: string): number {
|
|
65
|
+
return BUILTIN_ROLE_RANK[role] ?? CUSTOM_ROLE_RANK;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function canGrantRole(actorRole: string, targetRole: string): boolean {
|
|
69
|
+
return roleRank(actorRole) >= roleRank(targetRole);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function insufficientPrivilege(
|
|
73
|
+
c: { json: (d: unknown, s: number) => unknown },
|
|
74
|
+
targetRole: string,
|
|
75
|
+
) {
|
|
76
|
+
return c.json(
|
|
77
|
+
{
|
|
78
|
+
error: {
|
|
79
|
+
code: "FORBIDDEN",
|
|
80
|
+
message: `Cannot assign role "${targetRole}": your role does not have sufficient privilege.`,
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
403,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
58
87
|
async function countOwners(orgId: string): Promise<number> {
|
|
59
88
|
const rows = await db
|
|
60
89
|
.select({ id: member.id })
|
|
@@ -83,9 +112,11 @@ export function adminStaffRoutes(kernel: Kernel) {
|
|
|
83
112
|
// @ts-expect-error -- openapi handler union return type
|
|
84
113
|
router.openapi(createStaffRoute, async (c) => {
|
|
85
114
|
const body = c.req.valid("json") as { userId: string; role: string };
|
|
86
|
-
const
|
|
115
|
+
const actor = c.get("actor");
|
|
116
|
+
const orgId = resolveOrgId(actor);
|
|
87
117
|
|
|
88
118
|
if (!validRoles().has(body.role)) return invalidRole(c, body.role);
|
|
119
|
+
if (!canGrantRole(actor!.role, body.role)) return insufficientPrivilege(c, body.role);
|
|
89
120
|
|
|
90
121
|
const users = await db.select().from(user).where(eq(user.id, body.userId));
|
|
91
122
|
if (users.length === 0) {
|
|
@@ -160,10 +191,12 @@ export function adminStaffRoutes(kernel: Kernel) {
|
|
|
160
191
|
// @ts-expect-error -- openapi handler union return type
|
|
161
192
|
router.openapi(updateStaffRoleRoute, async (c) => {
|
|
162
193
|
const body = c.req.valid("json") as { role: string };
|
|
163
|
-
const
|
|
194
|
+
const actor = c.get("actor");
|
|
195
|
+
const orgId = resolveOrgId(actor);
|
|
164
196
|
const id = c.req.param("id");
|
|
165
197
|
|
|
166
198
|
if (!validRoles().has(body.role)) return invalidRole(c, body.role);
|
|
199
|
+
if (!canGrantRole(actor!.role, body.role)) return insufficientPrivilege(c, body.role);
|
|
167
200
|
|
|
168
201
|
const rows = await db
|
|
169
202
|
.select()
|
|
@@ -174,6 +207,10 @@ export function adminStaffRoutes(kernel: Kernel) {
|
|
|
174
207
|
return c.json({ error: { code: "NOT_FOUND", message: "Staff member not found." } }, 404);
|
|
175
208
|
}
|
|
176
209
|
|
|
210
|
+
// SEC-18/R-02: the actor must also outrank (or equal) the target's CURRENT
|
|
211
|
+
// role, else an admin could demote an owner they do not outrank.
|
|
212
|
+
if (!canGrantRole(actor!.role, target.role)) return insufficientPrivilege(c, target.role);
|
|
213
|
+
|
|
177
214
|
if (target.role === "owner" && body.role !== "owner" && (await countOwners(orgId)) <= 1) {
|
|
178
215
|
return c.json(
|
|
179
216
|
{ error: { code: "VALIDATION_FAILED", message: "Cannot demote the organization's last owner." } },
|
|
@@ -191,7 +228,8 @@ export function adminStaffRoutes(kernel: Kernel) {
|
|
|
191
228
|
|
|
192
229
|
// @ts-expect-error -- openapi handler union return type
|
|
193
230
|
router.openapi(revokeStaffRoute, async (c) => {
|
|
194
|
-
const
|
|
231
|
+
const actor = c.get("actor");
|
|
232
|
+
const orgId = resolveOrgId(actor);
|
|
195
233
|
const id = c.req.param("id");
|
|
196
234
|
|
|
197
235
|
const rows = await db
|
|
@@ -203,6 +241,10 @@ export function adminStaffRoutes(kernel: Kernel) {
|
|
|
203
241
|
return c.json({ error: { code: "NOT_FOUND", message: "Staff member not found." } }, 404);
|
|
204
242
|
}
|
|
205
243
|
|
|
244
|
+
// SEC-18/R-02: the actor must outrank (or equal) the target's current role
|
|
245
|
+
// to revoke it — an admin cannot revoke an owner.
|
|
246
|
+
if (!canGrantRole(actor!.role, target.role)) return insufficientPrivilege(c, target.role);
|
|
247
|
+
|
|
206
248
|
if (target.role === "owner" && (await countOwners(orgId)) <= 1) {
|
|
207
249
|
return c.json(
|
|
208
250
|
{ error: { code: "VALIDATION_FAILED", message: "Cannot revoke the organization's last owner." } },
|
|
@@ -21,8 +21,58 @@ import type { AfterHook, BeforeHook, ServiceContainer } from "../../../kernel/ho
|
|
|
21
21
|
import type { PluginDb } from "../../../kernel/database/plugin-types.js";
|
|
22
22
|
import { type AppEnv, mapErrorToResponse, mapErrorToStatus } from "../utils.js";
|
|
23
23
|
import { isCommerceError } from "../../../kernel/errors.js";
|
|
24
|
+
import { assertPermission } from "../../../auth/permissions.js";
|
|
24
25
|
import { makeId } from "../../../utils/id.js";
|
|
25
26
|
import type { ShippingAddress } from "../../../modules/shipping/calculator.js";
|
|
27
|
+
import type { Actor } from "../../../auth/types.js";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* SEC-07 — resolve the customer profile a checkout order is attributed to.
|
|
31
|
+
* A self-service actor may ONLY attribute the order to its own customer
|
|
32
|
+
* profile; only actors with org-level `customers:read` (staff/clienteling) may
|
|
33
|
+
* name an arbitrary `customerId`. Returns undefined for guests / unresolved
|
|
34
|
+
* (guest checkout). Exported for unit testing.
|
|
35
|
+
*/
|
|
36
|
+
export async function resolveCheckoutCustomerUuid(
|
|
37
|
+
customers: Kernel["services"]["customers"],
|
|
38
|
+
actor: Actor | null,
|
|
39
|
+
customerId: string | undefined,
|
|
40
|
+
): Promise<string | undefined> {
|
|
41
|
+
const actorUserId = actor?.userId;
|
|
42
|
+
let canActForOthers = false;
|
|
43
|
+
if (actorUserId) {
|
|
44
|
+
try {
|
|
45
|
+
assertPermission(actor, "customers:read");
|
|
46
|
+
canActForOthers = true;
|
|
47
|
+
} catch {
|
|
48
|
+
canActForOthers = false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (customerId) {
|
|
53
|
+
const uuidRe = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
54
|
+
if (uuidRe.test(customerId)) {
|
|
55
|
+
const byId = await customers.getById(customerId, actor);
|
|
56
|
+
// Honor a profile UUID only if it is the actor's own, or the actor may
|
|
57
|
+
// act for other customers.
|
|
58
|
+
if (byId.ok && (canActForOthers || byId.value.userId === actorUserId)) {
|
|
59
|
+
return byId.value.id;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (canActForOthers) {
|
|
63
|
+
// Staff may resolve/create a customer by a supplied user_id.
|
|
64
|
+
const byUser = await customers.getByUserId(customerId, actor);
|
|
65
|
+
if (byUser.ok) return byUser.value.id;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Self-service default: the authenticated actor's own customer profile.
|
|
70
|
+
if (actorUserId && !canActForOthers) {
|
|
71
|
+
const own = await customers.getByUserId(actorUserId, actor);
|
|
72
|
+
if (own.ok) return own.value.id;
|
|
73
|
+
}
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
26
76
|
|
|
27
77
|
export function checkoutRoutes(kernel: Kernel) {
|
|
28
78
|
const router = new OpenAPIHono<AppEnv>();
|
|
@@ -44,7 +94,35 @@ export function checkoutRoutes(kernel: Kernel) {
|
|
|
44
94
|
actor,
|
|
45
95
|
);
|
|
46
96
|
if (replay.ok && replay.value) {
|
|
47
|
-
|
|
97
|
+
// IDOR guard: an idempotency key must only replay the requester's OWN
|
|
98
|
+
// order. Without this, a same-org customer who supplies/guesses another
|
|
99
|
+
// customer's key would receive that customer's order (PII, totals, lines).
|
|
100
|
+
let canActForOthers = false;
|
|
101
|
+
try {
|
|
102
|
+
assertPermission(actor, "customers:read");
|
|
103
|
+
canActForOthers = true;
|
|
104
|
+
} catch {
|
|
105
|
+
canActForOthers = false;
|
|
106
|
+
}
|
|
107
|
+
const ownCustomer = actor
|
|
108
|
+
? await resolveCheckoutCustomerUuid(kernel.services.customers, actor, undefined)
|
|
109
|
+
: undefined;
|
|
110
|
+
const replayCustomer = replay.value.customerId ?? null;
|
|
111
|
+
const ownsOrder =
|
|
112
|
+
(replayCustomer !== null && replayCustomer === ownCustomer) ||
|
|
113
|
+
(replayCustomer === null && !ownCustomer);
|
|
114
|
+
if (canActForOthers || ownsOrder) {
|
|
115
|
+
return c.json({ data: replay.value }, 201);
|
|
116
|
+
}
|
|
117
|
+
return c.json(
|
|
118
|
+
{
|
|
119
|
+
error: {
|
|
120
|
+
code: "IDEMPOTENCY_CONFLICT",
|
|
121
|
+
message: "Idempotency key does not belong to this account.",
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
409,
|
|
125
|
+
);
|
|
48
126
|
}
|
|
49
127
|
}
|
|
50
128
|
|
|
@@ -135,32 +213,14 @@ export function checkoutRoutes(kernel: Kernel) {
|
|
|
135
213
|
context,
|
|
136
214
|
);
|
|
137
215
|
|
|
138
|
-
//
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
actor,
|
|
147
|
-
);
|
|
148
|
-
if (byIdResult.ok) {
|
|
149
|
-
customerUuid = byIdResult.value.id;
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
if (!customerUuid) {
|
|
153
|
-
// Fall back to user_id lookup (auto-creates customer profile if needed)
|
|
154
|
-
const byUserIdResult = await kernel.services.customers.getByUserId(
|
|
155
|
-
processed.customerId,
|
|
156
|
-
actor,
|
|
157
|
-
);
|
|
158
|
-
if (byUserIdResult.ok) {
|
|
159
|
-
customerUuid = byUserIdResult.value.id;
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
// If both lookups fail, we still allow guest checkout (customerUuid remains undefined)
|
|
163
|
-
}
|
|
216
|
+
// SEC-07: resolve the order's customer server-side. A self-service actor
|
|
217
|
+
// can only attribute the order to its own profile; a client-supplied
|
|
218
|
+
// foreign customerId is ignored unless the actor is staff.
|
|
219
|
+
const customerUuid = await resolveCheckoutCustomerUuid(
|
|
220
|
+
kernel.services.customers,
|
|
221
|
+
actor,
|
|
222
|
+
processed.customerId,
|
|
223
|
+
);
|
|
164
224
|
|
|
165
225
|
const orderPayload = {
|
|
166
226
|
...(body.idempotencyKey !== undefined
|
|
@@ -209,7 +269,12 @@ export function checkoutRoutes(kernel: Kernel) {
|
|
|
209
269
|
: {}),
|
|
210
270
|
};
|
|
211
271
|
|
|
212
|
-
|
|
272
|
+
// Checkout is a trusted, already-server-priced pipeline (resolveCurrentPrices
|
|
273
|
+
// + promotions/tax) and reserves stock in its own after-hooks — so it hands
|
|
274
|
+
// the order primitive precomputed totals rather than re-deriving them.
|
|
275
|
+
const order = await kernel.services.orders.create(orderPayload, actor, undefined, {
|
|
276
|
+
trustedPricing: true,
|
|
277
|
+
});
|
|
213
278
|
|
|
214
279
|
if (!order.ok) {
|
|
215
280
|
return c.json(
|
|
@@ -221,6 +286,7 @@ export function checkoutRoutes(kernel: Kernel) {
|
|
|
221
286
|
if (order.ok && (processed.appliedPromotions?.length ?? 0) > 0) {
|
|
222
287
|
await kernel.services.promotions.recordUsage({
|
|
223
288
|
promotions: processed.appliedPromotions ?? [],
|
|
289
|
+
organizationId: order.value.organizationId,
|
|
224
290
|
orderId: order.value.id,
|
|
225
291
|
...(customerUuid !== undefined
|
|
226
292
|
? { customerId: customerUuid }
|
|
@@ -17,6 +17,14 @@ export function inventoryRoutes(kernel: Kernel) {
|
|
|
17
17
|
// Inventory levels listing requires inventory:read
|
|
18
18
|
router.use("/levels", requirePerm("inventory:read"));
|
|
19
19
|
|
|
20
|
+
// Warehouse config + reservation are staff operations, never public. Gate them
|
|
21
|
+
// at the route (reserve/release are also called internally by checkout with the
|
|
22
|
+
// customer's actor, so the service itself must stay callable — the boundary is
|
|
23
|
+
// the HTTP route). Blocks anonymous (401) and customers lacking the perm (403).
|
|
24
|
+
router.use("/warehouses", requirePerm("inventory:read"));
|
|
25
|
+
router.use("/reserve", requirePerm("inventory:adjust"));
|
|
26
|
+
router.use("/release", requirePerm("inventory:adjust"));
|
|
27
|
+
|
|
20
28
|
// @ts-expect-error -- openapi handler union return type
|
|
21
29
|
router.openapi(listInventoryLevelsRoute, async (c) => {
|
|
22
30
|
const actor = c.get("actor");
|
|
@@ -78,6 +86,12 @@ export function inventoryRoutes(kernel: Kernel) {
|
|
|
78
86
|
router.openapi(createWarehouseRoute, async (c) => {
|
|
79
87
|
const body = c.req.valid("json") as Parameters<typeof kernel.services.inventory.createWarehouse>[0];
|
|
80
88
|
const actor = c.get("actor");
|
|
89
|
+
// Creating a warehouse (write) needs the higher inventory:adjust perm; the
|
|
90
|
+
// router.use above already requires inventory:read for the /warehouses path.
|
|
91
|
+
const perms = (actor as { permissions?: string[] } | null)?.permissions ?? [];
|
|
92
|
+
if (!(perms.includes("inventory:adjust") || perms.includes("inventory:*") || perms.includes("*:*"))) {
|
|
93
|
+
return c.json({ error: { code: "FORBIDDEN", message: "Permission 'inventory:adjust' is required." } }, 403);
|
|
94
|
+
}
|
|
81
95
|
const result = await kernel.services.inventory.createWarehouse(body, actor);
|
|
82
96
|
if (!result.ok) return c.json(mapErrorToResponse(result.error), mapErrorToStatus(result.error));
|
|
83
97
|
return c.json({ data: result.value }, 201);
|
|
@@ -3,6 +3,7 @@ import type { Kernel } from "../../../runtime/kernel.js";
|
|
|
3
3
|
import { changeOrderStatusRoute, listOrdersRoute, orderLookupRoute, getOrderRoute, getOrderFulfillmentsRoute, createOrderRoute, refundOrderRoute, captureOrderRoute, createOrderFulfillmentRoute, addOrderLineItemRoute, updateOrderLineItemRoute, removeOrderLineItemRoute, refundOrderLinesRoute, undoOrderRefundRoute, listOrderRefundsRoute, refundCapStatusRoute, createOrderNoteRoute, listOrderNotesRoute, deleteOrderNoteRoute, orderTimelineRoute } from "../schemas/orders.js";
|
|
4
4
|
import { type AppEnv, isUUID, mapErrorToResponse, mapErrorToStatus, parsePagination } from "../utils.js";
|
|
5
5
|
import type { CreateOrderInput } from "../../../modules/orders/service.js";
|
|
6
|
+
import { assertPermission } from "../../../auth/permissions.js";
|
|
6
7
|
|
|
7
8
|
export function orderRoutes(kernel: Kernel) {
|
|
8
9
|
const router = new OpenAPIHono<AppEnv>();
|
|
@@ -62,6 +63,11 @@ export function orderRoutes(kernel: Kernel) {
|
|
|
62
63
|
|
|
63
64
|
// @ts-expect-error -- openapi handler union return type
|
|
64
65
|
router.openapi(createOrderRoute, async (c) => {
|
|
66
|
+
try {
|
|
67
|
+
assertPermission(c.get("actor"), "orders:manage");
|
|
68
|
+
} catch (error) {
|
|
69
|
+
return c.json(mapErrorToResponse(error), mapErrorToStatus(error));
|
|
70
|
+
}
|
|
65
71
|
const body = c.req.valid("json") as CreateOrderInput;
|
|
66
72
|
const result = await kernel.services.orders.create(body, c.get("actor"));
|
|
67
73
|
if (!result.ok) return c.json(mapErrorToResponse(result.error), mapErrorToStatus(result.error));
|
|
@@ -170,6 +176,11 @@ export function orderRoutes(kernel: Kernel) {
|
|
|
170
176
|
|
|
171
177
|
// @ts-expect-error -- openapi handler union return type
|
|
172
178
|
router.openapi(addOrderLineItemRoute, async (c) => {
|
|
179
|
+
try {
|
|
180
|
+
assertPermission(c.get("actor"), "orders:manage");
|
|
181
|
+
} catch (error) {
|
|
182
|
+
return c.json(mapErrorToResponse(error), mapErrorToStatus(error));
|
|
183
|
+
}
|
|
173
184
|
const body = c.req.valid("json");
|
|
174
185
|
const result = await kernel.services.orders.addLineItem(
|
|
175
186
|
c.req.param("id"),
|
|
@@ -184,7 +184,7 @@ export const createOrderRoute = createRoute({
|
|
|
184
184
|
path: "/",
|
|
185
185
|
tags: ["Orders"],
|
|
186
186
|
summary: "Create a draft / manual order",
|
|
187
|
-
description: "
|
|
187
|
+
description: "Requires `orders:manage`. Creates an operator-entered order (phone / POS / manual); supplied line prices are stored as audited manual overrides.",
|
|
188
188
|
request: {
|
|
189
189
|
body: {
|
|
190
190
|
content: { "application/json": { schema: CreateOrderBodySchema } },
|
|
@@ -272,7 +272,7 @@ export const addOrderLineItemRoute = createRoute({
|
|
|
272
272
|
path: "/{id}/line-items",
|
|
273
273
|
tags: ["Orders"],
|
|
274
274
|
summary: "Add a line item to a placed order",
|
|
275
|
-
description: "Adds a line item to a non-terminal order
|
|
275
|
+
description: "Requires `orders:manage`. Adds a line item to a non-terminal order, records its supplied price as a manual override, and recalculates totals.",
|
|
276
276
|
request: {
|
|
277
277
|
params: OrderIdParam,
|
|
278
278
|
body: {
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Wraps a Drizzle PgDatabase instance so that INSERT operations on
|
|
5
5
|
* org-scoped tables automatically include the actor's organizationId,
|
|
6
|
-
* and SELECT
|
|
6
|
+
* and SELECT / UPDATE / DELETE against org-scoped tables constrain rows to
|
|
7
|
+
* that organization — including when the caller supplies no WHERE clause.
|
|
7
8
|
*
|
|
8
9
|
* Plugin route handlers receive this scoped db via PluginContext.database.db
|
|
9
10
|
* (organization resolved per operation from AsyncLocalStorage when used from
|
|
@@ -30,6 +31,45 @@ function tableHasOrganizationId(table: unknown): boolean {
|
|
|
30
31
|
}
|
|
31
32
|
}
|
|
32
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Wrap a WHERE-able Drizzle builder so every `.where()` call AND-s `orgEq` in
|
|
36
|
+
* AND re-wraps its result. Re-wrapping is what makes a chained
|
|
37
|
+
* `.where(a).where(b)` safe: Drizzle's second `.where()` REPLACES the first, so
|
|
38
|
+
* without re-wrapping the chained call would reach the raw builder and execute
|
|
39
|
+
* with only the caller's condition — dropping the org predicate and leaking
|
|
40
|
+
* across tenants. Every link in the chain re-injects `orgEq`.
|
|
41
|
+
*/
|
|
42
|
+
function wrapWhereable(builder: unknown, orgEq: SQL): unknown {
|
|
43
|
+
return new Proxy(builder as Record<string, unknown>, {
|
|
44
|
+
get(st, sp, sr) {
|
|
45
|
+
const sv = Reflect.get(st, sp, sr);
|
|
46
|
+
if (sp === "where" && typeof sv === "function") {
|
|
47
|
+
return (condition?: unknown, ...wRest: unknown[]) => {
|
|
48
|
+
const c = condition as SQL | undefined;
|
|
49
|
+
const merged = c ? and(orgEq, c) : orgEq;
|
|
50
|
+
const next = (sv as (...a: unknown[]) => unknown).call(st, merged, ...wRest);
|
|
51
|
+
return wrapWhereable(next, orgEq);
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
return typeof sv === "function" ? sv.bind(st) : sv;
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Pre-apply the org predicate to a WHERE-able builder (UPDATE/DELETE) and wrap
|
|
61
|
+
* its `.where()` so a caller-supplied condition is AND-ed with the org filter
|
|
62
|
+
* rather than replacing it. Pre-applying `orgEq` immediately means a builder
|
|
63
|
+
* executed with NO caller `.where()` (e.g. `update(t).set(...)`) is still
|
|
64
|
+
* constrained to the org — it can never touch another tenant's rows.
|
|
65
|
+
*/
|
|
66
|
+
function scopeWhereBuilder(
|
|
67
|
+
builder: { where: (c: SQL | undefined) => unknown },
|
|
68
|
+
orgEq: SQL,
|
|
69
|
+
): unknown {
|
|
70
|
+
return wrapWhereable(builder.where(orgEq), orgEq);
|
|
71
|
+
}
|
|
72
|
+
|
|
33
73
|
export function createScopedDb<TDb>(rawDb: TDb, orgSource: ScopedOrganizationId): TDb {
|
|
34
74
|
if (!rawDb || typeof rawDb !== "object") return rawDb;
|
|
35
75
|
|
|
@@ -84,20 +124,7 @@ export function createScopedDb<TDb>(rawDb: TDb, orgSource: ScopedOrganizationId)
|
|
|
84
124
|
|
|
85
125
|
const orgEq = eq(orgCol, resolveOrganizationId(orgSource));
|
|
86
126
|
const inner = chain as { where: (c: SQL | undefined) => unknown };
|
|
87
|
-
|
|
88
|
-
return new Proxy(scoped as Record<string, unknown>, {
|
|
89
|
-
get(st, sp, sr) {
|
|
90
|
-
const sv = Reflect.get(st, sp, sr);
|
|
91
|
-
if (sp === "where" && typeof sv === "function") {
|
|
92
|
-
return (condition?: unknown, ...wRest: unknown[]) => {
|
|
93
|
-
const c = condition as SQL | undefined;
|
|
94
|
-
const merged = c ? and(orgEq, c) : orgEq;
|
|
95
|
-
return sv.call(st, merged, ...wRest);
|
|
96
|
-
};
|
|
97
|
-
}
|
|
98
|
-
return typeof sv === "function" ? sv.bind(st) : sv;
|
|
99
|
-
},
|
|
100
|
-
});
|
|
127
|
+
return wrapWhereable(inner.where(orgEq), orgEq);
|
|
101
128
|
};
|
|
102
129
|
}
|
|
103
130
|
return typeof sbVal === "function" ? sbVal.bind(sbTarget) : sbVal;
|
|
@@ -106,6 +133,47 @@ export function createScopedDb<TDb>(rawDb: TDb, orgSource: ScopedOrganizationId)
|
|
|
106
133
|
};
|
|
107
134
|
}
|
|
108
135
|
|
|
136
|
+
if (prop === "update" && typeof value === "function") {
|
|
137
|
+
return (table: PgTable) => {
|
|
138
|
+
const builder = value.call(target, table);
|
|
139
|
+
if (!tableHasOrganizationId(table)) return builder;
|
|
140
|
+
const orgCol = getTableColumns(table).organizationId;
|
|
141
|
+
if (!orgCol) return builder;
|
|
142
|
+
const orgEq = eq(orgCol, resolveOrganizationId(orgSource));
|
|
143
|
+
// The org filter lives after `.set()`, so wrap `.set()` and scope the
|
|
144
|
+
// resulting WHERE-able builder.
|
|
145
|
+
return new Proxy(builder as Record<string, unknown>, {
|
|
146
|
+
get(t, p, r) {
|
|
147
|
+
const v = Reflect.get(t, p, r);
|
|
148
|
+
if (p === "set" && typeof v === "function") {
|
|
149
|
+
return (data: unknown) => {
|
|
150
|
+
const setBuilder = (v as (...a: unknown[]) => unknown).call(t, data);
|
|
151
|
+
return scopeWhereBuilder(
|
|
152
|
+
setBuilder as { where: (c: SQL | undefined) => unknown },
|
|
153
|
+
orgEq,
|
|
154
|
+
);
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
return typeof v === "function" ? v.bind(t) : v;
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (prop === "delete" && typeof value === "function") {
|
|
164
|
+
return (table: PgTable) => {
|
|
165
|
+
const builder = value.call(target, table);
|
|
166
|
+
if (!tableHasOrganizationId(table)) return builder;
|
|
167
|
+
const orgCol = getTableColumns(table).organizationId;
|
|
168
|
+
if (!orgCol) return builder;
|
|
169
|
+
const orgEq = eq(orgCol, resolveOrganizationId(orgSource));
|
|
170
|
+
return scopeWhereBuilder(
|
|
171
|
+
builder as { where: (c: SQL | undefined) => unknown },
|
|
172
|
+
orgEq,
|
|
173
|
+
);
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
109
177
|
return typeof value === "function" ? value.bind(target) : value;
|
|
110
178
|
},
|
|
111
179
|
}) as TDb;
|
package/src/kernel/errors.ts
CHANGED
|
@@ -32,6 +32,26 @@ export class CommerceValidationError extends Error implements CommerceError {
|
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
/** Canonical message for a missing inventory record — the single source used by
|
|
36
|
+
* the inventory repository and mapped to the typed error below. */
|
|
37
|
+
export const INVENTORY_RECORD_NOT_FOUND_MESSAGE =
|
|
38
|
+
"No inventory record found for this entity.";
|
|
39
|
+
|
|
40
|
+
/** Raised when an inventory reserve/release targets an entity with no inventory
|
|
41
|
+
* record. Consumers (e.g. order status transitions that tolerate a missing
|
|
42
|
+
* record) discriminate on `code === "INVENTORY_RECORD_NOT_FOUND"`, never the
|
|
43
|
+
* message string. */
|
|
44
|
+
export class CommerceInventoryRecordNotFoundError extends Error implements CommerceError {
|
|
45
|
+
code = "INVENTORY_RECORD_NOT_FOUND" as const;
|
|
46
|
+
constructor(
|
|
47
|
+
message: string = INVENTORY_RECORD_NOT_FOUND_MESSAGE,
|
|
48
|
+
public details?: unknown,
|
|
49
|
+
) {
|
|
50
|
+
super(message);
|
|
51
|
+
this.name = "CommerceInventoryRecordNotFoundError";
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
35
55
|
export class CommerceForbiddenError extends Error implements CommerceError {
|
|
36
56
|
code = "FORBIDDEN" as const;
|
|
37
57
|
constructor(
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
1
|
+
import type {
|
|
2
|
+
JobProcessingOrder,
|
|
3
|
+
TaskContext,
|
|
4
|
+
TaskDefinition,
|
|
5
|
+
} from "./types.js";
|
|
6
|
+
|
|
7
|
+
/** Enqueue-only surface exposed to hooks and services. */
|
|
7
8
|
export interface JobsAdapter {
|
|
8
9
|
enqueue(
|
|
9
10
|
taskSlug: string,
|
|
@@ -12,6 +13,37 @@ export interface JobsAdapter {
|
|
|
12
13
|
): Promise<string>;
|
|
13
14
|
}
|
|
14
15
|
|
|
16
|
+
export interface RunJobsOptions {
|
|
17
|
+
queue?: string;
|
|
18
|
+
limit?: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface RunJobsResult {
|
|
22
|
+
processed: number;
|
|
23
|
+
failed: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ExecutionEngineSetup {
|
|
27
|
+
tasks: ReadonlyMap<string, TaskDefinition>;
|
|
28
|
+
context: TaskContext;
|
|
29
|
+
processingOrder?: JobProcessingOrder;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type ExecutionDriver =
|
|
33
|
+
| {
|
|
34
|
+
mode: "pull";
|
|
35
|
+
run(options?: RunJobsOptions): Promise<RunJobsResult>;
|
|
36
|
+
}
|
|
37
|
+
| {
|
|
38
|
+
mode: "push";
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** Full job-engine contract selected through `config.jobs.adapter`. */
|
|
42
|
+
export interface ExecutionEngine extends JobsAdapter {
|
|
43
|
+
readonly execution: ExecutionDriver;
|
|
44
|
+
register(setup: ExecutionEngineSetup): void;
|
|
45
|
+
}
|
|
46
|
+
|
|
15
47
|
export interface EnqueueOptions {
|
|
16
48
|
organizationId: string;
|
|
17
49
|
queue?: string;
|
|
@@ -22,8 +54,7 @@ export interface EnqueueOptions {
|
|
|
22
54
|
}
|
|
23
55
|
|
|
24
56
|
/**
|
|
25
|
-
* No-op
|
|
26
|
-
* All enqueue calls silently succeed and return a placeholder ID.
|
|
57
|
+
* No-op enqueue surface used by isolated hook contexts.
|
|
27
58
|
*/
|
|
28
59
|
export class NullJobsAdapter implements JobsAdapter {
|
|
29
60
|
async enqueue(
|
|
@@ -1,21 +1,79 @@
|
|
|
1
1
|
import { eq, and } from "drizzle-orm";
|
|
2
2
|
import type { DrizzleDatabase } from "../database/drizzle-db.js";
|
|
3
3
|
import type { TaskDefinition } from "./types.js";
|
|
4
|
-
import type {
|
|
4
|
+
import type {
|
|
5
|
+
EnqueueOptions,
|
|
6
|
+
ExecutionEngine,
|
|
7
|
+
ExecutionEngineSetup,
|
|
8
|
+
RunJobsOptions,
|
|
9
|
+
} from "./adapter.js";
|
|
5
10
|
import { OrgResolutionError } from "../errors.js";
|
|
6
11
|
import { commerceJobs } from "./schema.js";
|
|
12
|
+
import { runPendingJobs } from "./runner.js";
|
|
13
|
+
import {
|
|
14
|
+
getJobReapThresholdMs,
|
|
15
|
+
getJobsReaperIntervalMs,
|
|
16
|
+
runStaleJobReaper,
|
|
17
|
+
} from "./reaper.js";
|
|
7
18
|
|
|
8
19
|
/**
|
|
9
20
|
* PostgreSQL-backed job queue adapter using the application's own database.
|
|
10
21
|
* Stores jobs in the `commerce_jobs` table. Supports concurrency keys
|
|
11
22
|
* and supersede semantics for deduplication.
|
|
12
23
|
*/
|
|
13
|
-
export class DrizzleJobsAdapter implements
|
|
24
|
+
export class DrizzleJobsAdapter implements ExecutionEngine {
|
|
25
|
+
private setup: ExecutionEngineSetup | undefined;
|
|
26
|
+
private lastStaleJobReaperAt = 0;
|
|
27
|
+
|
|
28
|
+
readonly execution = {
|
|
29
|
+
mode: "pull" as const,
|
|
30
|
+
run: async (options: RunJobsOptions = {}) => {
|
|
31
|
+
if (!this.setup) {
|
|
32
|
+
throw new Error(
|
|
33
|
+
"DrizzleJobsAdapter must be registered before running jobs.",
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const now = Date.now();
|
|
38
|
+
if (now - this.lastStaleJobReaperAt >= getJobsReaperIntervalMs()) {
|
|
39
|
+
this.lastStaleJobReaperAt = now;
|
|
40
|
+
try {
|
|
41
|
+
await runStaleJobReaper(
|
|
42
|
+
this.db,
|
|
43
|
+
getJobReapThresholdMs(),
|
|
44
|
+
this.setup.context.logger,
|
|
45
|
+
);
|
|
46
|
+
} catch (error) {
|
|
47
|
+
this.setup.context.logger.error("Stale job reaper failed", {
|
|
48
|
+
error: error instanceof Error ? error.message : String(error),
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return runPendingJobs({
|
|
54
|
+
db: this.db,
|
|
55
|
+
tasks: new Map(this.setup.tasks),
|
|
56
|
+
logger: this.setup.context.logger,
|
|
57
|
+
services: this.setup.context.services,
|
|
58
|
+
...(options.queue !== undefined ? { queue: options.queue } : {}),
|
|
59
|
+
...(options.limit !== undefined ? { limit: options.limit } : {}),
|
|
60
|
+
...(this.setup.processingOrder !== undefined
|
|
61
|
+
? { processingOrder: this.setup.processingOrder }
|
|
62
|
+
: {}),
|
|
63
|
+
});
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
|
|
14
67
|
constructor(
|
|
15
68
|
private db: DrizzleDatabase,
|
|
16
|
-
private tasks: Map<string, TaskDefinition
|
|
69
|
+
private tasks: Map<string, TaskDefinition> = new Map(),
|
|
17
70
|
) {}
|
|
18
71
|
|
|
72
|
+
register(setup: ExecutionEngineSetup): void {
|
|
73
|
+
this.setup = setup;
|
|
74
|
+
this.tasks = new Map(setup.tasks);
|
|
75
|
+
}
|
|
76
|
+
|
|
19
77
|
async enqueue(
|
|
20
78
|
taskSlug: string,
|
|
21
79
|
input: Record<string, unknown>,
|
|
@@ -28,22 +86,27 @@ export class DrizzleJobsAdapter implements JobsAdapter {
|
|
|
28
86
|
);
|
|
29
87
|
}
|
|
30
88
|
|
|
89
|
+
const task = this.tasks.get(taskSlug);
|
|
90
|
+
const concurrencyKey =
|
|
91
|
+
options.concurrencyKey ?? task?.concurrency?.key(input);
|
|
92
|
+
const supersedes = options.supersedes ?? task?.concurrency?.supersedes;
|
|
93
|
+
|
|
31
94
|
// If supersedes is set, delete existing pending jobs with the same concurrency key
|
|
32
|
-
if (
|
|
95
|
+
if (concurrencyKey && supersedes) {
|
|
33
96
|
await this.db
|
|
34
97
|
.delete(commerceJobs)
|
|
35
98
|
.where(
|
|
36
99
|
and(
|
|
37
|
-
eq(commerceJobs.
|
|
100
|
+
eq(commerceJobs.organizationId, organizationId),
|
|
101
|
+
eq(commerceJobs.taskSlug, taskSlug),
|
|
102
|
+
eq(commerceJobs.concurrencyKey, concurrencyKey),
|
|
38
103
|
eq(commerceJobs.status, "pending"),
|
|
39
104
|
),
|
|
40
105
|
);
|
|
41
106
|
}
|
|
42
107
|
|
|
43
108
|
// Look up task definition for default retry config
|
|
44
|
-
const
|
|
45
|
-
const maxAttempts =
|
|
46
|
-
options.maxAttempts ?? task?.retries?.attempts ?? 1;
|
|
109
|
+
const maxAttempts = options.maxAttempts ?? task?.retries?.attempts ?? 1;
|
|
47
110
|
|
|
48
111
|
const rows = await this.db
|
|
49
112
|
.insert(commerceJobs)
|
|
@@ -56,7 +119,7 @@ export class DrizzleJobsAdapter implements JobsAdapter {
|
|
|
56
119
|
waitUntil: options.delayMs
|
|
57
120
|
? new Date(Date.now() + options.delayMs)
|
|
58
121
|
: null,
|
|
59
|
-
concurrencyKey:
|
|
122
|
+
concurrencyKey: concurrencyKey ?? null,
|
|
60
123
|
})
|
|
61
124
|
.returning({ id: commerceJobs.id });
|
|
62
125
|
|