@porulle/core 0.1.0 → 0.5.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 +17 -1
- package/dist/config/define-config.d.ts.map +1 -1
- package/dist/config/define-config.js +6 -0
- package/dist/config/types.d.ts +12 -0
- package/dist/config/types.d.ts.map +1 -1
- package/dist/hooks/checkout.d.ts +3 -3
- package/dist/hooks/checkout.js +4 -4
- package/dist/index.d.ts +9 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -1
- package/dist/interfaces/rest/audit-middleware.d.ts +46 -0
- package/dist/interfaces/rest/audit-middleware.d.ts.map +1 -0
- package/dist/interfaces/rest/audit-middleware.js +82 -0
- package/dist/interfaces/rest/parse-json.d.ts +39 -0
- package/dist/interfaces/rest/parse-json.d.ts.map +1 -0
- package/dist/interfaces/rest/parse-json.js +45 -0
- package/dist/interfaces/rest/routes/catalog.d.ts.map +1 -1
- package/dist/interfaces/rest/routes/catalog.js +17 -2
- package/dist/interfaces/rest/routes/customers.d.ts.map +1 -1
- package/dist/interfaces/rest/routes/customers.js +59 -4
- package/dist/interfaces/rest/routes/inventory.d.ts.map +1 -1
- package/dist/interfaces/rest/routes/inventory.js +4 -2
- package/dist/interfaces/rest/routes/media.d.ts.map +1 -1
- package/dist/interfaces/rest/routes/media.js +6 -0
- package/dist/interfaces/rest/routes/orders.d.ts.map +1 -1
- package/dist/interfaces/rest/routes/orders.js +23 -1
- package/dist/interfaces/rest/routes/promotions.d.ts.map +1 -1
- package/dist/interfaces/rest/routes/promotions.js +21 -1
- package/dist/interfaces/rest/schemas/carts.d.ts +175 -0
- package/dist/interfaces/rest/schemas/carts.d.ts.map +1 -1
- package/dist/interfaces/rest/schemas/catalog.d.ts +1410 -0
- package/dist/interfaces/rest/schemas/catalog.d.ts.map +1 -1
- package/dist/interfaces/rest/schemas/catalog.js +28 -1
- package/dist/interfaces/rest/schemas/checkout.d.ts +35 -0
- package/dist/interfaces/rest/schemas/checkout.d.ts.map +1 -1
- package/dist/interfaces/rest/schemas/customer-portal.d.ts +280 -0
- package/dist/interfaces/rest/schemas/customer-portal.d.ts.map +1 -1
- package/dist/interfaces/rest/schemas/customers.d.ts +863 -0
- package/dist/interfaces/rest/schemas/customers.d.ts.map +1 -1
- package/dist/interfaces/rest/schemas/customers.js +88 -0
- package/dist/interfaces/rest/schemas/inventory.d.ts +182 -1
- package/dist/interfaces/rest/schemas/inventory.d.ts.map +1 -1
- package/dist/interfaces/rest/schemas/media.d.ts +105 -0
- package/dist/interfaces/rest/schemas/media.d.ts.map +1 -1
- package/dist/interfaces/rest/schemas/orders.d.ts +136 -0
- package/dist/interfaces/rest/schemas/orders.d.ts.map +1 -1
- package/dist/interfaces/rest/schemas/orders.js +20 -0
- package/dist/interfaces/rest/schemas/pricing.d.ts +70 -0
- package/dist/interfaces/rest/schemas/pricing.d.ts.map +1 -1
- package/dist/interfaces/rest/schemas/promotions.d.ts +298 -0
- package/dist/interfaces/rest/schemas/promotions.d.ts.map +1 -1
- package/dist/interfaces/rest/schemas/promotions.js +25 -1
- package/dist/interfaces/rest/schemas/shared.d.ts +47 -0
- package/dist/interfaces/rest/schemas/shared.d.ts.map +1 -1
- package/dist/interfaces/rest/schemas/shared.js +8 -0
- package/dist/interfaces/rest/schemas/webhooks.d.ts +70 -0
- package/dist/interfaces/rest/schemas/webhooks.d.ts.map +1 -1
- package/dist/kernel/database/adapter.d.ts +6 -0
- package/dist/kernel/database/adapter.d.ts.map +1 -1
- package/dist/kernel/database/adapter.js +62 -1
- package/dist/kernel/database/migrate.d.ts +22 -2
- package/dist/kernel/database/migrate.d.ts.map +1 -1
- package/dist/kernel/database/migrate.js +42 -2
- package/dist/modules/analytics/hooks.d.ts +5 -5
- package/dist/modules/analytics/hooks.js +5 -5
- package/dist/modules/analytics/repository/index.d.ts.map +1 -1
- package/dist/modules/catalog/category-service.d.ts +6 -1
- package/dist/modules/catalog/category-service.d.ts.map +1 -1
- package/dist/modules/catalog/category-service.js +35 -5
- package/dist/modules/catalog/schema.d.ts +17 -0
- package/dist/modules/catalog/schema.d.ts.map +1 -1
- package/dist/modules/catalog/schema.js +2 -0
- package/dist/modules/catalog/service.d.ts +11 -2
- package/dist/modules/catalog/service.d.ts.map +1 -1
- package/dist/modules/catalog/service.js +8 -2
- package/dist/modules/customers/repository/index.d.ts +8 -1
- package/dist/modules/customers/repository/index.d.ts.map +1 -1
- package/dist/modules/customers/repository/index.js +42 -2
- package/dist/modules/customers/schema.d.ts +162 -0
- package/dist/modules/customers/schema.d.ts.map +1 -1
- package/dist/modules/customers/schema.js +18 -0
- package/dist/modules/customers/service.d.ts +32 -2
- package/dist/modules/customers/service.d.ts.map +1 -1
- package/dist/modules/customers/service.js +119 -3
- package/dist/modules/inventory/schemas.d.ts +7 -1
- package/dist/modules/inventory/schemas.d.ts.map +1 -1
- package/dist/modules/inventory/schemas.js +6 -1
- package/dist/modules/inventory/service.d.ts +18 -0
- package/dist/modules/inventory/service.d.ts.map +1 -1
- package/dist/modules/inventory/service.js +58 -21
- package/dist/modules/media/noop-adapter.d.ts +11 -0
- package/dist/modules/media/noop-adapter.d.ts.map +1 -0
- package/dist/modules/media/noop-adapter.js +31 -0
- package/dist/modules/orders/repository/index.d.ts +19 -0
- package/dist/modules/orders/repository/index.d.ts.map +1 -1
- package/dist/modules/orders/repository/index.js +42 -1
- package/dist/modules/orders/service.d.ts +31 -0
- package/dist/modules/orders/service.d.ts.map +1 -1
- package/dist/modules/orders/service.js +52 -2
- package/dist/modules/payments/repository/index.d.ts.map +1 -1
- package/dist/modules/promotions/schemas.d.ts +46 -0
- package/dist/modules/promotions/schemas.d.ts.map +1 -1
- package/dist/modules/promotions/schemas.js +19 -8
- package/dist/modules/promotions/service.d.ts +8 -2
- package/dist/modules/promotions/service.d.ts.map +1 -1
- package/dist/modules/promotions/service.js +74 -9
- package/dist/modules/search/repository/index.d.ts.map +1 -1
- package/dist/modules/shipping/repository/index.d.ts.map +1 -1
- package/dist/modules/tax/repository/index.d.ts.map +1 -1
- package/dist/runtime/client-ip.d.ts +31 -0
- package/dist/runtime/client-ip.d.ts.map +1 -0
- package/dist/runtime/client-ip.js +31 -0
- package/dist/runtime/server.d.ts +4 -0
- package/dist/runtime/server.d.ts.map +1 -1
- package/dist/runtime/server.js +35 -19
- package/dist/test-utils/create-plugin-test-app.d.ts.map +1 -1
- package/dist/test-utils/create-plugin-test-app.js +4 -1
- package/dist/test-utils/test-actors.d.ts +3 -2
- package/dist/test-utils/test-actors.d.ts.map +1 -1
- package/dist/test-utils/test-actors.js +3 -2
- package/package.json +4 -4
- package/src/auth/middleware.ts +18 -1
- package/src/config/define-config.ts +7 -0
- package/src/config/types.ts +12 -0
- package/src/hooks/checkout.ts +4 -4
- package/src/index.ts +18 -1
- package/src/interfaces/rest/audit-middleware.ts +110 -0
- package/src/interfaces/rest/parse-json.ts +81 -0
- package/src/interfaces/rest/routes/catalog.ts +21 -1
- package/src/interfaces/rest/routes/customers.ts +64 -3
- package/src/interfaces/rest/routes/inventory.ts +4 -2
- package/src/interfaces/rest/routes/media.ts +7 -0
- package/src/interfaces/rest/routes/orders.ts +21 -1
- package/src/interfaces/rest/routes/promotions.ts +22 -1
- package/src/interfaces/rest/schemas/catalog.ts +30 -1
- package/src/interfaces/rest/schemas/customers.ts +99 -0
- package/src/interfaces/rest/schemas/orders.ts +21 -0
- package/src/interfaces/rest/schemas/promotions.ts +32 -1
- package/src/interfaces/rest/schemas/shared.ts +9 -0
- package/src/kernel/database/adapter.ts +64 -1
- package/src/kernel/database/migrate.ts +50 -2
- package/src/modules/analytics/hooks.ts +5 -5
- package/src/modules/analytics/repository/index.ts +0 -1
- package/src/modules/catalog/category-service.ts +26 -5
- package/src/modules/catalog/schema.ts +2 -0
- package/src/modules/catalog/service.ts +25 -3
- package/src/modules/customers/repository/index.ts +55 -1
- package/src/modules/customers/schema.ts +19 -0
- package/src/modules/customers/service.ts +137 -2
- package/src/modules/inventory/schemas.ts +6 -1
- package/src/modules/inventory/service.ts +84 -29
- package/src/modules/media/noop-adapter.ts +36 -0
- package/src/modules/orders/repository/index.ts +63 -1
- package/src/modules/orders/service.ts +86 -3
- package/src/modules/payments/repository/index.ts +0 -1
- package/src/modules/promotions/schemas.ts +21 -4
- package/src/modules/promotions/service.ts +90 -18
- package/src/modules/search/repository/index.ts +0 -1
- package/src/modules/shipping/repository/index.ts +0 -1
- package/src/modules/tax/repository/index.ts +0 -1
- package/src/runtime/client-ip.ts +49 -0
- package/src/runtime/server.ts +39 -18
- package/src/test-utils/create-plugin-test-app.ts +3 -1
- package/src/test-utils/test-actors.ts +3 -2
|
@@ -310,40 +310,87 @@ export class InventoryService {
|
|
|
310
310
|
});
|
|
311
311
|
}
|
|
312
312
|
|
|
313
|
-
|
|
313
|
+
/**
|
|
314
|
+
* Adjust inventory with optional add/remove/set modes, returning before /
|
|
315
|
+
* after / delta and the movement id. `mode` omitted ⇒ signed-delta (legacy)
|
|
316
|
+
* behavior using `adjustment`. `remove` clamps at 0; `set` writes an absolute
|
|
317
|
+
* value. The lock, compute, write, and movement all happen in one transaction
|
|
318
|
+
* so concurrent adjustments can't lose updates.
|
|
319
|
+
*/
|
|
320
|
+
async adjustDetailed(
|
|
314
321
|
input: InventoryAdjustInput,
|
|
315
322
|
actor?: Actor | null,
|
|
316
323
|
ctx?: TxContext,
|
|
317
|
-
): Promise<
|
|
324
|
+
): Promise<
|
|
325
|
+
Result<{
|
|
326
|
+
level: InventoryLevel;
|
|
327
|
+
before: number;
|
|
328
|
+
after: number;
|
|
329
|
+
delta: number;
|
|
330
|
+
movementId: string;
|
|
331
|
+
}>
|
|
332
|
+
> {
|
|
318
333
|
try {
|
|
319
334
|
assertPermission(actor ?? null, "inventory:adjust");
|
|
320
335
|
} catch (error) {
|
|
321
336
|
return Err(toCommerceError(error));
|
|
322
337
|
}
|
|
323
338
|
|
|
339
|
+
// Cross-field validation: mode form needs `amount`; legacy form needs `adjustment`.
|
|
340
|
+
if (input.mode) {
|
|
341
|
+
if (input.amount === undefined) {
|
|
342
|
+
return Err(new CommerceValidationError("`amount` is required when `mode` is set."));
|
|
343
|
+
}
|
|
344
|
+
} else if (input.adjustment === undefined) {
|
|
345
|
+
return Err(new CommerceValidationError("`adjustment` is required when `mode` is omitted."));
|
|
346
|
+
}
|
|
347
|
+
|
|
324
348
|
const warehouseId = input.warehouseId ?? (await this.pickWarehouse(actor, ctx));
|
|
325
349
|
const variantId = input.variantId ?? null;
|
|
326
350
|
const performedBy = input.performedBy ?? actor?.userId ?? "system";
|
|
327
351
|
|
|
328
|
-
const doAdjust = async (txCtx: TxContext)
|
|
329
|
-
const orgId = resolveOrgId(
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
);
|
|
334
|
-
// Row-locked read + atomic SQL increment prevents lost updates
|
|
335
|
-
const lockResult = await this.repo.adjustWithLock(
|
|
352
|
+
const doAdjust = async (txCtx: TxContext) => {
|
|
353
|
+
const orgId = resolveOrgId(actor ?? txCtx.actor ?? null, undefined, this.deps.config);
|
|
354
|
+
|
|
355
|
+
// Lock the level first so `before` and the write are atomic.
|
|
356
|
+
const existing = await this.repo.findLevelForUpdate(
|
|
336
357
|
orgId,
|
|
337
358
|
input.entityId,
|
|
338
359
|
variantId,
|
|
339
360
|
warehouseId,
|
|
340
|
-
input.adjustment,
|
|
341
361
|
txCtx,
|
|
342
362
|
);
|
|
363
|
+
const before = existing?.quantityOnHand ?? 0;
|
|
364
|
+
|
|
365
|
+
const amount = input.amount ?? 0;
|
|
366
|
+
let effectiveAdjustment: number;
|
|
367
|
+
switch (input.mode) {
|
|
368
|
+
case "add":
|
|
369
|
+
effectiveAdjustment = amount;
|
|
370
|
+
break;
|
|
371
|
+
case "remove":
|
|
372
|
+
effectiveAdjustment = -amount;
|
|
373
|
+
break;
|
|
374
|
+
case "set":
|
|
375
|
+
effectiveAdjustment = amount - before;
|
|
376
|
+
break;
|
|
377
|
+
default:
|
|
378
|
+
effectiveAdjustment = input.adjustment ?? 0;
|
|
379
|
+
break;
|
|
380
|
+
}
|
|
343
381
|
|
|
344
382
|
let level: InventoryLevel;
|
|
345
|
-
if (
|
|
346
|
-
|
|
383
|
+
if (existing) {
|
|
384
|
+
// Row-locked atomic SQL increment (GREATEST(0, qoh + delta)).
|
|
385
|
+
const lockResult = await this.repo.adjustWithLock(
|
|
386
|
+
orgId,
|
|
387
|
+
input.entityId,
|
|
388
|
+
variantId,
|
|
389
|
+
warehouseId,
|
|
390
|
+
effectiveAdjustment,
|
|
391
|
+
txCtx,
|
|
392
|
+
);
|
|
393
|
+
level = lockResult.ok ? lockResult.level : existing;
|
|
347
394
|
} else {
|
|
348
395
|
// No existing level — create is safe; unique index on
|
|
349
396
|
// (entityId, variantId, warehouseId) prevents duplicate inserts.
|
|
@@ -352,35 +399,30 @@ export class InventoryService {
|
|
|
352
399
|
organizationId: orgId,
|
|
353
400
|
entityId: input.entityId,
|
|
354
401
|
warehouseId,
|
|
355
|
-
quantityOnHand: Math.max(0,
|
|
402
|
+
quantityOnHand: Math.max(0, effectiveAdjustment),
|
|
356
403
|
quantityReserved: 0,
|
|
357
404
|
quantityIncoming: 0,
|
|
358
|
-
...(input.variantId !== undefined
|
|
359
|
-
? { variantId: input.variantId }
|
|
360
|
-
: {}),
|
|
405
|
+
...(input.variantId !== undefined ? { variantId: input.variantId } : {}),
|
|
361
406
|
},
|
|
362
407
|
txCtx,
|
|
363
408
|
);
|
|
364
409
|
}
|
|
365
410
|
|
|
366
|
-
|
|
411
|
+
const after = level.quantityOnHand;
|
|
412
|
+
const delta = after - before;
|
|
413
|
+
|
|
414
|
+
const movement = await this.repo.createMovement(
|
|
367
415
|
{
|
|
368
416
|
organizationId: orgId,
|
|
369
417
|
entityId: input.entityId,
|
|
370
418
|
warehouseId,
|
|
371
419
|
type: "adjustment",
|
|
372
|
-
quantity:
|
|
420
|
+
quantity: delta,
|
|
373
421
|
reason: input.reason,
|
|
374
422
|
performedBy,
|
|
375
|
-
...(input.variantId !== undefined
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
...(input.referenceType !== undefined
|
|
379
|
-
? { referenceType: input.referenceType }
|
|
380
|
-
: {}),
|
|
381
|
-
...(input.referenceId !== undefined
|
|
382
|
-
? { referenceId: input.referenceId }
|
|
383
|
-
: {}),
|
|
423
|
+
...(input.variantId !== undefined ? { variantId: input.variantId } : {}),
|
|
424
|
+
...(input.referenceType !== undefined ? { referenceType: input.referenceType } : {}),
|
|
425
|
+
...(input.referenceId !== undefined ? { referenceId: input.referenceId } : {}),
|
|
384
426
|
},
|
|
385
427
|
txCtx,
|
|
386
428
|
);
|
|
@@ -403,7 +445,7 @@ export class InventoryService {
|
|
|
403
445
|
hookCtx,
|
|
404
446
|
);
|
|
405
447
|
|
|
406
|
-
return Ok(level);
|
|
448
|
+
return Ok({ level, before, after, delta, movementId: movement.id });
|
|
407
449
|
};
|
|
408
450
|
|
|
409
451
|
return this.withTransaction(ctx, async (tx) => {
|
|
@@ -412,6 +454,19 @@ export class InventoryService {
|
|
|
412
454
|
});
|
|
413
455
|
}
|
|
414
456
|
|
|
457
|
+
/**
|
|
458
|
+
* Back-compat wrapper: adjust inventory and return just the level. Existing
|
|
459
|
+
* callers and the signed-delta `adjustment` form are unchanged.
|
|
460
|
+
*/
|
|
461
|
+
async adjust(
|
|
462
|
+
input: InventoryAdjustInput,
|
|
463
|
+
actor?: Actor | null,
|
|
464
|
+
ctx?: TxContext,
|
|
465
|
+
): Promise<Result<InventoryLevel>> {
|
|
466
|
+
const result = await this.adjustDetailed(input, actor, ctx);
|
|
467
|
+
return result.ok ? Ok(result.value.level) : result;
|
|
468
|
+
}
|
|
469
|
+
|
|
415
470
|
/**
|
|
416
471
|
* Set inventory to an absolute quantity (not a delta).
|
|
417
472
|
*
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { CommerceError } from "../../kernel/errors.js";
|
|
2
|
+
import { Ok, Err, type Result } from "../../kernel/result.js";
|
|
3
|
+
import type { StorageAdapter } from "./adapter.js";
|
|
4
|
+
|
|
5
|
+
const notSupported = (op: string): Result<never, CommerceError> =>
|
|
6
|
+
Err({
|
|
7
|
+
code: "STORAGE_NOT_SUPPORTED",
|
|
8
|
+
message: `storage.${op} is disabled — no storage adapter is configured.`,
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The default `StorageAdapter` used when `defineConfig` is given no `storage`.
|
|
13
|
+
*
|
|
14
|
+
* Lets a catalog-only deployment boot with zero storage config: read-only
|
|
15
|
+
* `getUrl` passes the key through (so catalog projections keep working), while
|
|
16
|
+
* the mutating media operations return a typed `STORAGE_NOT_SUPPORTED` error.
|
|
17
|
+
* Configure a real adapter (local/S3/R2) to enable media.
|
|
18
|
+
*/
|
|
19
|
+
export const noopStorageAdapter: StorageAdapter = {
|
|
20
|
+
providerId: "noop",
|
|
21
|
+
async upload() {
|
|
22
|
+
return notSupported("upload");
|
|
23
|
+
},
|
|
24
|
+
async getUrl(key) {
|
|
25
|
+
return Ok(key);
|
|
26
|
+
},
|
|
27
|
+
async getSignedUrl() {
|
|
28
|
+
return notSupported("getSignedUrl");
|
|
29
|
+
},
|
|
30
|
+
async delete() {
|
|
31
|
+
return Ok(undefined);
|
|
32
|
+
},
|
|
33
|
+
async list() {
|
|
34
|
+
return Ok([]);
|
|
35
|
+
},
|
|
36
|
+
};
|
|
@@ -1,10 +1,23 @@
|
|
|
1
|
-
import { eq, and, desc, sql } from "drizzle-orm";
|
|
1
|
+
import { eq, and, or, gte, lte, desc, sql } from "drizzle-orm";
|
|
2
2
|
import type { TxContext } from "../../../kernel/database/tx-context.js";
|
|
3
3
|
import type {
|
|
4
4
|
DrizzleDatabase,
|
|
5
5
|
DbOrTx,
|
|
6
6
|
} from "../../../kernel/database/drizzle-db.js";
|
|
7
7
|
import { orders, orderLineItems, orderStatusHistory } from "../schema.js";
|
|
8
|
+
import { customers } from "../../customers/schema.js";
|
|
9
|
+
|
|
10
|
+
export interface OrderLookupRow {
|
|
11
|
+
id: string;
|
|
12
|
+
orderNumber: string;
|
|
13
|
+
placedAt: Date;
|
|
14
|
+
status: string;
|
|
15
|
+
grandTotal: number;
|
|
16
|
+
customerId: string | null;
|
|
17
|
+
firstName: string | null;
|
|
18
|
+
lastName: string | null;
|
|
19
|
+
phone: string | null;
|
|
20
|
+
}
|
|
8
21
|
|
|
9
22
|
// Infer types from Drizzle schema
|
|
10
23
|
export type Order = typeof orders.$inferSelect;
|
|
@@ -66,6 +79,55 @@ export class OrdersRepository {
|
|
|
66
79
|
.orderBy(desc(orders.placedAt));
|
|
67
80
|
}
|
|
68
81
|
|
|
82
|
+
/**
|
|
83
|
+
* Fuzzy lookup across order number, customer email/name/phone, and the
|
|
84
|
+
* walk-in label. Phone matches ignore non-digits on both sides. Org-scoped.
|
|
85
|
+
*/
|
|
86
|
+
async lookup(
|
|
87
|
+
orgId: string,
|
|
88
|
+
q: string,
|
|
89
|
+
opts: { from?: Date; to?: Date },
|
|
90
|
+
ctx?: TxContext,
|
|
91
|
+
): Promise<OrderLookupRow[]> {
|
|
92
|
+
const db = this.getDb(ctx);
|
|
93
|
+
const like = `%${q}%`;
|
|
94
|
+
const qDigits = q.replace(/\D/g, "");
|
|
95
|
+
|
|
96
|
+
const matchers = [
|
|
97
|
+
sql`${orders.orderNumber} ILIKE ${like}`,
|
|
98
|
+
sql`${customers.email} ILIKE ${like}`,
|
|
99
|
+
sql`(coalesce(${customers.firstName}, '') || ' ' || coalesce(${customers.lastName}, '')) ILIKE ${like}`,
|
|
100
|
+
sql`(${orders.metadata} ->> 'customerLabel') ILIKE ${like}`,
|
|
101
|
+
];
|
|
102
|
+
if (qDigits.length >= 3) {
|
|
103
|
+
matchers.push(
|
|
104
|
+
sql`regexp_replace(coalesce(${customers.phone}, ''), '[^0-9]', '', 'g') ILIKE ${`%${qDigits}%`}`,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const filters = [eq(orders.organizationId, orgId), or(...matchers)];
|
|
109
|
+
if (opts.from) filters.push(gte(orders.placedAt, opts.from));
|
|
110
|
+
if (opts.to) filters.push(lte(orders.placedAt, opts.to));
|
|
111
|
+
|
|
112
|
+
return db
|
|
113
|
+
.select({
|
|
114
|
+
id: orders.id,
|
|
115
|
+
orderNumber: orders.orderNumber,
|
|
116
|
+
placedAt: orders.placedAt,
|
|
117
|
+
status: orders.status,
|
|
118
|
+
grandTotal: orders.grandTotal,
|
|
119
|
+
customerId: customers.id,
|
|
120
|
+
firstName: customers.firstName,
|
|
121
|
+
lastName: customers.lastName,
|
|
122
|
+
phone: customers.phone,
|
|
123
|
+
})
|
|
124
|
+
.from(orders)
|
|
125
|
+
.leftJoin(customers, eq(orders.customerId, customers.id))
|
|
126
|
+
.where(and(...filters))
|
|
127
|
+
.orderBy(desc(orders.placedAt))
|
|
128
|
+
.limit(50) as Promise<OrderLookupRow[]>;
|
|
129
|
+
}
|
|
130
|
+
|
|
69
131
|
async findByStatus(orgId: string, status: string, ctx?: TxContext): Promise<Order[]> {
|
|
70
132
|
const db = this.getDb(ctx);
|
|
71
133
|
return db
|
|
@@ -64,6 +64,14 @@ export interface ListOrdersParams {
|
|
|
64
64
|
page?: number;
|
|
65
65
|
limit?: number;
|
|
66
66
|
status?: string;
|
|
67
|
+
/** When true, listByCustomer also returns a lifetime-spend rollup. */
|
|
68
|
+
includeTotals?: boolean;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface CustomerOrderTotals {
|
|
72
|
+
count: number;
|
|
73
|
+
lifetimeSpend: number;
|
|
74
|
+
averageBasket: number;
|
|
67
75
|
}
|
|
68
76
|
|
|
69
77
|
export interface ChangeStatusInput {
|
|
@@ -82,7 +90,11 @@ export interface OrderServiceDeps {
|
|
|
82
90
|
}
|
|
83
91
|
|
|
84
92
|
export type HydratedOrder = Order & { lineItems: OrderLineItem[] };
|
|
85
|
-
type OrderListResult = {
|
|
93
|
+
type OrderListResult = {
|
|
94
|
+
items: HydratedOrder[];
|
|
95
|
+
pagination: Pagination;
|
|
96
|
+
totals?: CustomerOrderTotals;
|
|
97
|
+
};
|
|
86
98
|
type BeforeCreateOrderHook = BeforeHook<CreateOrderInput>;
|
|
87
99
|
type AfterCreateOrderHook = AfterHook<HydratedOrder>;
|
|
88
100
|
type StatusChangeHookInput = {
|
|
@@ -324,6 +336,23 @@ export class OrderService {
|
|
|
324
336
|
}
|
|
325
337
|
|
|
326
338
|
items.sort((a, b) => b.placedAt.getTime() - a.placedAt.getTime());
|
|
339
|
+
|
|
340
|
+
// Lifetime rollup over the full (un-paginated) set, computed server-side.
|
|
341
|
+
// Refunds/voids are excluded from lifetimeSpend.
|
|
342
|
+
let totals: CustomerOrderTotals | undefined;
|
|
343
|
+
if (params.includeTotals) {
|
|
344
|
+
const excluded = new Set(["refunded", "voided"]);
|
|
345
|
+
const count = items.length;
|
|
346
|
+
const lifetimeSpend = items
|
|
347
|
+
.filter((order) => !excluded.has(order.status))
|
|
348
|
+
.reduce((sum, order) => sum + order.grandTotal, 0);
|
|
349
|
+
totals = {
|
|
350
|
+
count,
|
|
351
|
+
lifetimeSpend,
|
|
352
|
+
averageBasket: count > 0 ? Math.round(lifetimeSpend / count) : 0,
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
|
|
327
356
|
const paged = paginate(items, params.page ?? 1, params.limit ?? 20);
|
|
328
357
|
const hydratedItems = await Promise.all(
|
|
329
358
|
paged.items.map((order) => this.hydrateOrder(order, ctx)),
|
|
@@ -332,9 +361,63 @@ export class OrderService {
|
|
|
332
361
|
return Ok({
|
|
333
362
|
items: hydratedItems,
|
|
334
363
|
pagination: paged.pagination,
|
|
364
|
+
...(totals ? { totals } : {}),
|
|
335
365
|
});
|
|
336
366
|
}
|
|
337
367
|
|
|
368
|
+
/**
|
|
369
|
+
* Fuzzy order lookup for receipt-less returns / support: matches across
|
|
370
|
+
* order number, customer email/name/phone (digits-normalized), and the
|
|
371
|
+
* walk-in label. Returns a compact result; <3 chars returns a hint.
|
|
372
|
+
*/
|
|
373
|
+
async lookup(
|
|
374
|
+
q: string,
|
|
375
|
+
opts: { from?: Date; to?: Date },
|
|
376
|
+
actor?: Actor | null,
|
|
377
|
+
ctx?: TxContext,
|
|
378
|
+
): Promise<
|
|
379
|
+
Result<{
|
|
380
|
+
items: Array<{
|
|
381
|
+
id: string;
|
|
382
|
+
orderNumber: string;
|
|
383
|
+
placedAt: Date;
|
|
384
|
+
status: string;
|
|
385
|
+
grandTotal: number;
|
|
386
|
+
customer: { id: string; name: string | null; phone: string | null } | null;
|
|
387
|
+
}>;
|
|
388
|
+
hint?: string;
|
|
389
|
+
}>
|
|
390
|
+
> {
|
|
391
|
+
try {
|
|
392
|
+
assertPermission(actor ?? null, "orders:read");
|
|
393
|
+
} catch (error) {
|
|
394
|
+
return Err(toCommerceError(error));
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const term = (q ?? "").trim();
|
|
398
|
+
if (term.length < 3) {
|
|
399
|
+
return Ok({ items: [], hint: "Enter at least 3 characters to search." });
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const orgId = resolveOrgId(actor ?? ctx?.actor ?? null);
|
|
403
|
+
const rows = await this.repo.lookup(orgId, term, opts, ctx);
|
|
404
|
+
const items = rows.map((r) => ({
|
|
405
|
+
id: r.id,
|
|
406
|
+
orderNumber: r.orderNumber,
|
|
407
|
+
placedAt: r.placedAt,
|
|
408
|
+
status: r.status,
|
|
409
|
+
grandTotal: r.grandTotal,
|
|
410
|
+
customer: r.customerId
|
|
411
|
+
? {
|
|
412
|
+
id: r.customerId,
|
|
413
|
+
name: `${r.firstName ?? ""} ${r.lastName ?? ""}`.trim() || null,
|
|
414
|
+
phone: r.phone ?? null,
|
|
415
|
+
}
|
|
416
|
+
: null,
|
|
417
|
+
}));
|
|
418
|
+
return Ok({ items });
|
|
419
|
+
}
|
|
420
|
+
|
|
338
421
|
async changeStatus(
|
|
339
422
|
input: ChangeStatusInput,
|
|
340
423
|
actor: Actor | null,
|
|
@@ -556,8 +639,8 @@ export class OrderService {
|
|
|
556
639
|
ctx,
|
|
557
640
|
);
|
|
558
641
|
|
|
559
|
-
// Audit logging is
|
|
560
|
-
//
|
|
642
|
+
// Audit logging is automatic via the audit hooks registered at kernel
|
|
643
|
+
// boot — no manual audit.record() call needed here.
|
|
561
644
|
|
|
562
645
|
const hydrated = await this.hydrateOrder(cas, ctx);
|
|
563
646
|
const report = await runAfterHooks(
|
|
@@ -2,16 +2,29 @@ import { z } from "@hono/zod-openapi";
|
|
|
2
2
|
|
|
3
3
|
// ─── Zod Body Schemas (single source of truth) ─────────────────────────────
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
/**
|
|
6
|
+
* The valid promotion `type` values. Single source of truth for the REST
|
|
7
|
+
* body schema, the OpenAPI enum, the service-layer validation, and the
|
|
8
|
+
* exported {@link PromotionType} union.
|
|
9
|
+
*/
|
|
10
|
+
export const promotionTypeEnum = z
|
|
11
|
+
.enum([
|
|
8
12
|
"percentage_off_order",
|
|
9
13
|
"fixed_off_order",
|
|
10
14
|
"percentage_off_item",
|
|
11
15
|
"fixed_off_item",
|
|
12
16
|
"free_shipping",
|
|
13
17
|
"buy_x_get_y",
|
|
14
|
-
])
|
|
18
|
+
])
|
|
19
|
+
.openapi("PromotionType", { example: "percentage_off_order" });
|
|
20
|
+
|
|
21
|
+
/** Valid promotion type discriminator. @see promotionTypeEnum */
|
|
22
|
+
export type PromotionType = z.infer<typeof promotionTypeEnum>;
|
|
23
|
+
|
|
24
|
+
export const CreatePromotionBodySchema = z.object({
|
|
25
|
+
name: z.string().openapi({ example: "Summer Sale" }),
|
|
26
|
+
/** @see PromotionType for valid values */
|
|
27
|
+
type: promotionTypeEnum,
|
|
15
28
|
value: z.number().openapi({ example: 10 }),
|
|
16
29
|
code: z.string().optional().openapi({ example: "SUMMER10" }),
|
|
17
30
|
buyQuantity: z.number().int().optional(),
|
|
@@ -33,6 +46,10 @@ export const CreatePromotionBodySchema = z.object({
|
|
|
33
46
|
metadata: z.record(z.string(), z.unknown()).optional(),
|
|
34
47
|
}).openapi("CreatePromotionRequest");
|
|
35
48
|
|
|
49
|
+
// Edit any subset of the create body. Validated the same way create is.
|
|
50
|
+
export const UpdatePromotionBodySchema = CreatePromotionBodySchema.partial();
|
|
51
|
+
|
|
36
52
|
// ─── Derived Input Types ────────────────────────────────────────────────────
|
|
37
53
|
|
|
38
54
|
export type CreatePromotionInput = z.infer<typeof CreatePromotionBodySchema>;
|
|
55
|
+
export type UpdatePromotionInput = z.infer<typeof UpdatePromotionBodySchema>;
|
|
@@ -15,20 +15,17 @@ import type {
|
|
|
15
15
|
PromotionsRepository,
|
|
16
16
|
Promotion,
|
|
17
17
|
PromotionUsage,
|
|
18
|
+
PromotionInsert,
|
|
18
19
|
} from "./repository/index.js";
|
|
19
20
|
import type { CatalogRepository } from "../catalog/repository/index.js";
|
|
20
21
|
import type { OrdersRepository } from "../orders/repository/index.js";
|
|
21
22
|
import { resolveOrgId } from "../../auth/org.js";
|
|
22
23
|
import type { Actor } from "../../auth/types.js";
|
|
23
24
|
|
|
24
|
-
//
|
|
25
|
-
export
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
| "percentage_off_item"
|
|
29
|
-
| "fixed_off_item"
|
|
30
|
-
| "free_shipping"
|
|
31
|
-
| "buy_x_get_y";
|
|
25
|
+
// PromotionType is derived from the zod enum (single source of truth) in
|
|
26
|
+
// ./schemas.js; re-export it here for backwards-compatible imports.
|
|
27
|
+
import { promotionTypeEnum, type PromotionType } from "./schemas.js";
|
|
28
|
+
export type { PromotionType };
|
|
32
29
|
|
|
33
30
|
/** Filter status for listing promotions. Used by the REST API and service layer. */
|
|
34
31
|
export type PromotionStatusFilter = "active" | "inactive" | "expired" | "scheduled";
|
|
@@ -88,7 +85,7 @@ export interface PromotionConditions {
|
|
|
88
85
|
}
|
|
89
86
|
|
|
90
87
|
export type { CreatePromotionInput } from "./schemas.js";
|
|
91
|
-
import type { CreatePromotionInput } from "./schemas.js";
|
|
88
|
+
import type { CreatePromotionInput, UpdatePromotionInput } from "./schemas.js";
|
|
92
89
|
|
|
93
90
|
export interface AppliedPromotion {
|
|
94
91
|
promotionId: string;
|
|
@@ -182,15 +179,8 @@ export class PromotionService {
|
|
|
182
179
|
);
|
|
183
180
|
}
|
|
184
181
|
|
|
185
|
-
// Validate promotion type
|
|
186
|
-
const validTypes
|
|
187
|
-
"percentage_off_order",
|
|
188
|
-
"fixed_off_order",
|
|
189
|
-
"percentage_off_item",
|
|
190
|
-
"fixed_off_item",
|
|
191
|
-
"free_shipping",
|
|
192
|
-
"buy_x_get_y",
|
|
193
|
-
];
|
|
182
|
+
// Validate promotion type against the single-source enum
|
|
183
|
+
const validTypes = promotionTypeEnum.options;
|
|
194
184
|
if (!validTypes.includes(input.type)) {
|
|
195
185
|
return Err(
|
|
196
186
|
new CommerceValidationError(
|
|
@@ -269,6 +259,88 @@ export class PromotionService {
|
|
|
269
259
|
return Ok(updated);
|
|
270
260
|
}
|
|
271
261
|
|
|
262
|
+
/**
|
|
263
|
+
* Edit any subset of a promotion's fields. Validated the same way create is
|
|
264
|
+
* (value bounds, type enum, code uniqueness). Only provided keys are written.
|
|
265
|
+
*/
|
|
266
|
+
async update(
|
|
267
|
+
orgId: string,
|
|
268
|
+
id: string,
|
|
269
|
+
input: UpdatePromotionInput,
|
|
270
|
+
actor?: Actor | null,
|
|
271
|
+
ctx?: TxContext,
|
|
272
|
+
): Promise<Result<Promotion>> {
|
|
273
|
+
const existing = await this.repo.findById(orgId, id, ctx);
|
|
274
|
+
if (!existing) {
|
|
275
|
+
return Err(new CommerceNotFoundError("Promotion not found."));
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (input.value !== undefined && input.value < 0) {
|
|
279
|
+
return Err(new CommerceValidationError("Promotion value cannot be negative."));
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (input.type !== undefined) {
|
|
283
|
+
const validTypes = promotionTypeEnum.options;
|
|
284
|
+
if (!validTypes.includes(input.type)) {
|
|
285
|
+
return Err(
|
|
286
|
+
new CommerceValidationError(
|
|
287
|
+
`Invalid promotion type "${input.type}". Must be one of: ${validTypes.join(", ")}`,
|
|
288
|
+
),
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
let normalizedCode: string | null | undefined;
|
|
294
|
+
if (input.code !== undefined) {
|
|
295
|
+
normalizedCode = input.code ? input.code.trim().toUpperCase() : null;
|
|
296
|
+
if (normalizedCode) {
|
|
297
|
+
const other = await this.repo.findByCode(orgId, normalizedCode, ctx);
|
|
298
|
+
if (other && other.id !== id) {
|
|
299
|
+
return Err(
|
|
300
|
+
new CommerceValidationError(`Promotion code ${normalizedCode} already exists.`),
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const patch: Partial<Omit<PromotionInsert, "id">> = {};
|
|
307
|
+
if (input.name !== undefined) patch.name = input.name;
|
|
308
|
+
if (input.type !== undefined) patch.type = input.type;
|
|
309
|
+
if (input.value !== undefined) patch.value = roundMoney(input.value);
|
|
310
|
+
if (input.isAutomatic !== undefined) patch.isAutomatic = input.isAutomatic;
|
|
311
|
+
if (input.isActive !== undefined) patch.isActive = input.isActive;
|
|
312
|
+
if (input.priority !== undefined) patch.priority = input.priority;
|
|
313
|
+
if (input.conditions !== undefined) patch.conditions = input.conditions as Record<string, unknown>;
|
|
314
|
+
if (input.metadata !== undefined) patch.metadata = input.metadata;
|
|
315
|
+
if (normalizedCode !== undefined) patch.code = normalizedCode;
|
|
316
|
+
if (input.buyQuantity !== undefined) patch.buyQuantity = input.buyQuantity;
|
|
317
|
+
if (input.getQuantity !== undefined) patch.getQuantity = input.getQuantity;
|
|
318
|
+
if (input.usageLimitTotal !== undefined) patch.usageLimitTotal = input.usageLimitTotal;
|
|
319
|
+
if (input.usageLimitPerCustomer !== undefined) {
|
|
320
|
+
patch.usageLimitPerCustomer = input.usageLimitPerCustomer;
|
|
321
|
+
}
|
|
322
|
+
if (input.validFrom !== undefined) patch.validFrom = input.validFrom;
|
|
323
|
+
if (input.validUntil !== undefined) patch.validUntil = input.validUntil;
|
|
324
|
+
|
|
325
|
+
const updated = await this.repo.update(id, patch, ctx);
|
|
326
|
+
if (!updated) {
|
|
327
|
+
return Err(new CommerceNotFoundError("Promotion not found."));
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const afterHooks = this.deps.hooks.resolve(
|
|
331
|
+
"promotions.afterUpdate",
|
|
332
|
+
) as AfterHook<Promotion>[];
|
|
333
|
+
const hctx = hookContext(
|
|
334
|
+
actor ?? ctx?.actor ?? null,
|
|
335
|
+
this.deps.services,
|
|
336
|
+
this.deps.database,
|
|
337
|
+
ctx?.tx ?? null,
|
|
338
|
+
);
|
|
339
|
+
await runAfterHooks(afterHooks, existing, updated, "update", hctx);
|
|
340
|
+
|
|
341
|
+
return Ok(updated);
|
|
342
|
+
}
|
|
343
|
+
|
|
272
344
|
async list(
|
|
273
345
|
filter?: { status?: PromotionStatusFilter },
|
|
274
346
|
actor?: Actor | null,
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-IP resolution for rate limiting.
|
|
3
|
+
*
|
|
4
|
+
* The Node default reads `c.req.raw.socket.remoteAddress` — which is always
|
|
5
|
+
* undefined on edge runtimes (Cloudflare Workers, Deno Deploy, Vercel Edge),
|
|
6
|
+
* collapsing every client onto a single rate-limit key (a trivial DoS). Edge
|
|
7
|
+
* deployments inject `runtime.getClientIp` to read the platform header instead
|
|
8
|
+
* (CF: `cf-connecting-ip`, Vercel Edge: `x-real-ip`, Fly: `fly-client-ip`).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface ClientIpContext {
|
|
12
|
+
req: { raw: unknown; header(name: string): string | undefined };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type ClientIpResolver = (c: ClientIpContext) => string;
|
|
16
|
+
|
|
17
|
+
export interface RuntimeConfig {
|
|
18
|
+
/** Resolve the client IP from the request (used as the rate-limit key). */
|
|
19
|
+
getClientIp?: ClientIpResolver;
|
|
20
|
+
/** Direct-connection IP of the trusted reverse proxy (Node default only). */
|
|
21
|
+
trustedProxyIp?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Build the client-IP resolver. Prefers an injected `runtime.getClientIp`;
|
|
26
|
+
* otherwise falls back to the Node behavior (socket address, trusting
|
|
27
|
+
* X-Forwarded-For only from `trustedProxyIp` / `TRUSTED_PROXY_IP`).
|
|
28
|
+
*/
|
|
29
|
+
export function createClientIpResolver(config: {
|
|
30
|
+
runtime?: RuntimeConfig;
|
|
31
|
+
}): ClientIpResolver {
|
|
32
|
+
if (config.runtime?.getClientIp) {
|
|
33
|
+
return config.runtime.getClientIp;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const trustedProxyIp =
|
|
37
|
+
config.runtime?.trustedProxyIp ??
|
|
38
|
+
(typeof process !== "undefined" ? process.env?.TRUSTED_PROXY_IP : undefined);
|
|
39
|
+
|
|
40
|
+
return (c: ClientIpContext): string => {
|
|
41
|
+
const raw = c.req.raw as { socket?: { remoteAddress?: string } } | undefined;
|
|
42
|
+
const remoteAddress = raw?.socket?.remoteAddress;
|
|
43
|
+
if (trustedProxyIp && remoteAddress === trustedProxyIp) {
|
|
44
|
+
const xff = c.req.header("x-forwarded-for")?.split(",")[0]?.trim();
|
|
45
|
+
if (xff) return xff;
|
|
46
|
+
}
|
|
47
|
+
return remoteAddress ?? "unknown";
|
|
48
|
+
};
|
|
49
|
+
}
|