@develemit/commerce-server 0.2.0 → 0.3.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/README.md +32 -0
- package/dist/index.d.ts +7 -1
- package/dist/index.js +225 -72
- package/dist/index.js.map +1 -1
- package/docs/admin.md +5 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -131,6 +131,38 @@ reference, see `apps/playground-e2e/tests/order-management.spec.ts`.
|
|
|
131
131
|
- [Admin API](docs/admin.md): product and order management, refunds, and orders that need attention.
|
|
132
132
|
- [Payment gateways](docs/payments.md): the `PaymentGateway` contract and the manual Stripe test-mode purchase.
|
|
133
133
|
|
|
134
|
+
## Caching
|
|
135
|
+
|
|
136
|
+
The public catalog GETs send `Cache-Control: public, max-age=30,
|
|
137
|
+
stale-while-revalidate=300` and a strong `ETag`, and answer a matching
|
|
138
|
+
`If-None-Match` (including `W/` and comma lists) with an empty 304. Errors get
|
|
139
|
+
`Cache-Control: no-store`. Admin, checkout and order routes are untouched.
|
|
140
|
+
|
|
141
|
+
Pass `catalogCache: { cacheControl: '...' }` to `commercePlugin` to change the
|
|
142
|
+
header, or `cacheControl: false` to omit it (the `ETag` is still sent), for
|
|
143
|
+
hosts that manage caching at a CDN.
|
|
144
|
+
|
|
145
|
+
The `ETag` hashes the uncompressed body. Register `@fastify/compress` at the
|
|
146
|
+
app root before `commercePlugin`, so its `onSend` runs after the catalog's.
|
|
147
|
+
|
|
148
|
+
## Production checklist
|
|
149
|
+
|
|
150
|
+
- **Compression:** register `@fastify/compress` at the app root before
|
|
151
|
+
`commercePlugin` (JSON shrinks 70-80%). The kit doesn't wrap your app.
|
|
152
|
+
- **Catalog caching:** see [Caching](#caching); put a CDN in front of the
|
|
153
|
+
catalog GETs.
|
|
154
|
+
- **Images:** serve uploads with long immutable caching and lazy-loaded
|
|
155
|
+
`<img>`s (the storefront's default); keep image types out of compression.
|
|
156
|
+
- **Uploads on a CDN:** point `storage` at object storage behind a CDN rather
|
|
157
|
+
than the API process.
|
|
158
|
+
- **Webhooks:** forward the Stripe events (`checkout.session.completed`,
|
|
159
|
+
expiry and async failure) to `/orders/webhooks/stripe`; see
|
|
160
|
+
[Payment gateways](docs/payments.md).
|
|
161
|
+
Webhook raw-body parsing is request-side and unaffected by compression.
|
|
162
|
+
- **Retries:** refunds require an `Idempotency-Key`; reuse it when retrying so
|
|
163
|
+
a refund can't be issued twice.
|
|
164
|
+
- Admin routes: see [Admin API](docs/admin.md).
|
|
165
|
+
|
|
134
166
|
## Routes
|
|
135
167
|
|
|
136
168
|
Relative to the host's prefix:
|
package/dist/index.d.ts
CHANGED
|
@@ -1257,6 +1257,10 @@ interface AdminOptions {
|
|
|
1257
1257
|
storage: FileStorage;
|
|
1258
1258
|
}
|
|
1259
1259
|
|
|
1260
|
+
interface CatalogCacheOptions {
|
|
1261
|
+
cacheControl?: string | false;
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1260
1264
|
declare class ProductNotFoundError extends Error {
|
|
1261
1265
|
readonly slug: string;
|
|
1262
1266
|
readonly code = "PRODUCT_NOT_FOUND";
|
|
@@ -1395,12 +1399,14 @@ type CommercePluginOptions = {
|
|
|
1395
1399
|
currency: string;
|
|
1396
1400
|
admin?: AdminOptions;
|
|
1397
1401
|
categoryLabels?: CategoryLabels;
|
|
1402
|
+
catalogCache?: CatalogCacheOptions;
|
|
1398
1403
|
} & (CheckoutOptions | CatalogOnlyOptions);
|
|
1399
1404
|
declare const commercePlugin: FastifyPluginAsync<CommercePluginOptions>;
|
|
1400
1405
|
|
|
1401
1406
|
interface CatalogPluginOptions {
|
|
1402
1407
|
db: CommerceDb;
|
|
1403
1408
|
categoryLabels?: CategoryLabels;
|
|
1409
|
+
catalogCache?: CatalogCacheOptions;
|
|
1404
1410
|
}
|
|
1405
1411
|
declare const catalogPlugin: FastifyPluginAsync<CatalogPluginOptions>;
|
|
1406
1412
|
|
|
@@ -1498,4 +1504,4 @@ interface MemoryStorage extends FileStorage {
|
|
|
1498
1504
|
declare function createMemoryStorage(baseUrl?: string): MemoryStorage;
|
|
1499
1505
|
|
|
1500
1506
|
export { ProductNotFoundError, SESSION_ID_PLACEHOLDER, WebhookSignatureError, catalogPlugin, commercePlugin, commerceSchema, createFakeGateway, createMemoryStorage, createStripeGateway, ordersPlugin, runCommerceMigrations, seedProducts, seedShippingRates, startHoldSweeper };
|
|
1501
|
-
export type { AdminOptions, CatalogPluginOptions, CategoryLabels, CheckoutLine, CheckoutSession, CheckoutUrls, ChosenShipping, CommerceDb, CommercePluginOptions, CommerceSchema, CreateCheckoutSessionInput, FakeGateway, FakeGatewayOptions, FileStorage, GatewayRefund, HoldSweeper, HoldSweeperOptions, MemoryStorage, OrderHooks, OrdersPluginOptions, PaymentEvent, PaymentGateway, RefundInput, SeedProduct, SeedShippingRate, ShippingAddress, ShippingRateOption, SignedWebhook, StoredFile, StripeGatewayOptions };
|
|
1507
|
+
export type { AdminOptions, CatalogCacheOptions, CatalogPluginOptions, CategoryLabels, CheckoutLine, CheckoutSession, CheckoutUrls, ChosenShipping, CommerceDb, CommercePluginOptions, CommerceSchema, CreateCheckoutSessionInput, FakeGateway, FakeGatewayOptions, FileStorage, GatewayRefund, HoldSweeper, HoldSweeperOptions, MemoryStorage, OrderHooks, OrdersPluginOptions, PaymentEvent, PaymentGateway, RefundInput, SeedProduct, SeedShippingRate, ShippingAddress, ShippingRateOption, SignedWebhook, StoredFile, StripeGatewayOptions };
|
package/dist/index.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import {
|
|
3
3
|
adminProductInputSchema as adminProductInputSchema2,
|
|
4
4
|
adminProductPatchSchema as adminProductPatchSchema2,
|
|
5
|
-
|
|
5
|
+
adminProductPageSchema,
|
|
6
6
|
imageOrderInputSchema,
|
|
7
7
|
shippingRatesInputSchema
|
|
8
8
|
} from "@develemit/commerce-contracts";
|
|
@@ -18,6 +18,28 @@ var ProductNotFoundError = class extends Error {
|
|
|
18
18
|
code = "PRODUCT_NOT_FOUND";
|
|
19
19
|
};
|
|
20
20
|
|
|
21
|
+
// src/shared/cursor.ts
|
|
22
|
+
var InvalidCursorError = class extends Error {
|
|
23
|
+
code = "INVALID_CURSOR";
|
|
24
|
+
constructor() {
|
|
25
|
+
super("The cursor is malformed");
|
|
26
|
+
this.name = "InvalidCursorError";
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
var PG_TIMESTAMP = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(\.\d+)?[+-]\d{2}(:?\d{2})?$/;
|
|
30
|
+
function encodeCursor({ at, key }) {
|
|
31
|
+
return Buffer.from(`${at}|${key}`).toString("base64url");
|
|
32
|
+
}
|
|
33
|
+
function decodeCursor(raw, accepts) {
|
|
34
|
+
const text2 = Buffer.from(raw, "base64url").toString("utf8");
|
|
35
|
+
const split = text2.indexOf("|");
|
|
36
|
+
const cursor = { at: text2.slice(0, split), key: text2.slice(split + 1) };
|
|
37
|
+
if (split < 0 || !cursor.key || !accepts(cursor)) {
|
|
38
|
+
throw new InvalidCursorError();
|
|
39
|
+
}
|
|
40
|
+
return cursor;
|
|
41
|
+
}
|
|
42
|
+
|
|
21
43
|
// src/shipping/repo.ts
|
|
22
44
|
import { and, asc, eq, notInArray } from "drizzle-orm";
|
|
23
45
|
|
|
@@ -242,7 +264,16 @@ async function setShippingRates(db, rates) {
|
|
|
242
264
|
import { Readable } from "stream";
|
|
243
265
|
|
|
244
266
|
// src/admin/repo.ts
|
|
245
|
-
import {
|
|
267
|
+
import {
|
|
268
|
+
and as and2,
|
|
269
|
+
asc as asc2,
|
|
270
|
+
eq as eq2,
|
|
271
|
+
gt,
|
|
272
|
+
ilike,
|
|
273
|
+
inArray,
|
|
274
|
+
or,
|
|
275
|
+
sql as sql2
|
|
276
|
+
} from "drizzle-orm";
|
|
246
277
|
async function withImages(db, rows) {
|
|
247
278
|
if (rows.length === 0) return [];
|
|
248
279
|
const images = await db.select().from(productImages).where(
|
|
@@ -256,9 +287,42 @@ async function withImages(db, rows) {
|
|
|
256
287
|
images: images.filter((i) => i.productId === row.id)
|
|
257
288
|
}));
|
|
258
289
|
}
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
290
|
+
function escapeLike(value) {
|
|
291
|
+
return value.replace(/[\\%_]/g, (c) => `\\${c}`);
|
|
292
|
+
}
|
|
293
|
+
function pageConditions(filter) {
|
|
294
|
+
const parts = [];
|
|
295
|
+
if (filter.status) parts.push(eq2(products.status, filter.status));
|
|
296
|
+
if (filter.stock === "in") parts.push(gt(products.quantity, 0));
|
|
297
|
+
if (filter.stock === "sold") parts.push(eq2(products.quantity, 0));
|
|
298
|
+
if (filter.q) parts.push(ilike(products.name, `%${escapeLike(filter.q)}%`));
|
|
299
|
+
if (filter.after) {
|
|
300
|
+
const createdAt = sql2`${filter.after.createdAt}::timestamptz`;
|
|
301
|
+
parts.push(
|
|
302
|
+
or(
|
|
303
|
+
gt(products.createdAt, createdAt),
|
|
304
|
+
and2(
|
|
305
|
+
eq2(products.createdAt, createdAt),
|
|
306
|
+
gt(products.slug, filter.after.slug)
|
|
307
|
+
)
|
|
308
|
+
)
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
return and2(...parts);
|
|
312
|
+
}
|
|
313
|
+
async function selectProductPage(db, filter) {
|
|
314
|
+
const selected = await db.select({
|
|
315
|
+
product: products,
|
|
316
|
+
createdAtKey: sql2`${products.createdAt}::text`
|
|
317
|
+
}).from(products).where(pageConditions(filter)).orderBy(asc2(products.createdAt), asc2(products.slug)).limit(filter.limit);
|
|
318
|
+
const records = await withImages(
|
|
319
|
+
db,
|
|
320
|
+
selected.map((s) => s.product)
|
|
321
|
+
);
|
|
322
|
+
return records.map((r, i) => ({
|
|
323
|
+
...r,
|
|
324
|
+
createdAtKey: selected[i].createdAtKey
|
|
325
|
+
}));
|
|
262
326
|
}
|
|
263
327
|
async function findProductById(db, id) {
|
|
264
328
|
const rows = await db.select().from(products).where(eq2(products.id, id));
|
|
@@ -295,6 +359,7 @@ async function setImagePosition(db, id, position) {
|
|
|
295
359
|
// src/admin/service.ts
|
|
296
360
|
import {
|
|
297
361
|
adminProductInputSchema,
|
|
362
|
+
adminProductListQuerySchema,
|
|
298
363
|
adminProductPatchSchema
|
|
299
364
|
} from "@develemit/commerce-contracts";
|
|
300
365
|
|
|
@@ -338,8 +403,25 @@ async function requireProduct(db, id) {
|
|
|
338
403
|
if (!found) throw new ProductNotFoundError(id);
|
|
339
404
|
return found;
|
|
340
405
|
}
|
|
341
|
-
|
|
342
|
-
|
|
406
|
+
function decodeProductCursor(raw) {
|
|
407
|
+
const { at, key } = decodeCursor(raw, ({ at: at2 }) => PG_TIMESTAMP.test(at2));
|
|
408
|
+
return { createdAt: at, slug: key };
|
|
409
|
+
}
|
|
410
|
+
async function listAdminProducts(db, rawQuery = {}) {
|
|
411
|
+
const query = adminProductListQuerySchema.parse(rawQuery);
|
|
412
|
+
const found = await selectProductPage(db, {
|
|
413
|
+
status: query.status,
|
|
414
|
+
stock: query.stock,
|
|
415
|
+
q: query.q,
|
|
416
|
+
after: query.cursor ? decodeProductCursor(query.cursor) : void 0,
|
|
417
|
+
limit: query.limit + 1
|
|
418
|
+
});
|
|
419
|
+
const page = found.slice(0, query.limit);
|
|
420
|
+
const last = page[page.length - 1];
|
|
421
|
+
return {
|
|
422
|
+
items: page.map(toAdminProduct),
|
|
423
|
+
nextCursor: found.length > query.limit && last ? encodeCursor({ at: last.createdAtKey, key: last.row.slug }) : null
|
|
424
|
+
};
|
|
343
425
|
}
|
|
344
426
|
async function getAdminProduct(db, id) {
|
|
345
427
|
return toAdminProduct(await requireProduct(db, id));
|
|
@@ -473,7 +555,7 @@ var adminPlugin = async (app, { db, currency, authorize, storage }) => {
|
|
|
473
555
|
if (err instanceof ProductNotFoundError || err instanceof ProductImageNotFoundError) {
|
|
474
556
|
return reply.code(404).send({ error: err.code, message: err.message });
|
|
475
557
|
}
|
|
476
|
-
if (err instanceof InvalidImageError || err instanceof InvalidImageOrderError) {
|
|
558
|
+
if (err instanceof InvalidCursorError || err instanceof InvalidImageError || err instanceof InvalidImageOrderError) {
|
|
477
559
|
return reply.code(400).send({ error: err.code, message: err.message });
|
|
478
560
|
}
|
|
479
561
|
if (err.code === "FST_REQ_FILE_TOO_LARGE") {
|
|
@@ -488,7 +570,9 @@ var adminPlugin = async (app, { db, currency, authorize, storage }) => {
|
|
|
488
570
|
});
|
|
489
571
|
app.get(
|
|
490
572
|
"/products",
|
|
491
|
-
async () =>
|
|
573
|
+
async (req) => adminProductPageSchema.parse(
|
|
574
|
+
await listAdminProducts(db, req.query)
|
|
575
|
+
)
|
|
492
576
|
);
|
|
493
577
|
app.post("/products", async (req, reply) => {
|
|
494
578
|
const input = adminProductInputSchema2.parse(req.body);
|
|
@@ -544,12 +628,46 @@ var adminPlugin = async (app, { db, currency, authorize, storage }) => {
|
|
|
544
628
|
// src/catalog/routes.ts
|
|
545
629
|
import {
|
|
546
630
|
categorySchema,
|
|
631
|
+
productCardPageSchema,
|
|
547
632
|
productListQuerySchema,
|
|
548
633
|
productSchema
|
|
549
634
|
} from "@develemit/commerce-contracts";
|
|
550
635
|
|
|
636
|
+
// src/catalog/cache-hooks.ts
|
|
637
|
+
import { createHash } from "crypto";
|
|
638
|
+
var DEFAULT_CATALOG_CACHE_CONTROL = "public, max-age=30, stale-while-revalidate=300";
|
|
639
|
+
function etagOf(body) {
|
|
640
|
+
return `"${createHash("sha1").update(body).digest("base64url")}"`;
|
|
641
|
+
}
|
|
642
|
+
function matchesIfNoneMatch(header, etag) {
|
|
643
|
+
if (!header) return false;
|
|
644
|
+
if (header.trim() === "*") return true;
|
|
645
|
+
return header.split(",").map((t) => t.trim().replace(/^W\//, "")).includes(etag);
|
|
646
|
+
}
|
|
647
|
+
function addCatalogCaching(app, { cacheControl = DEFAULT_CATALOG_CACHE_CONTROL } = {}) {
|
|
648
|
+
app.addHook("onSend", async (req, reply, payload) => {
|
|
649
|
+
if (reply.statusCode >= 400) {
|
|
650
|
+
reply.header("cache-control", "no-store");
|
|
651
|
+
return payload;
|
|
652
|
+
}
|
|
653
|
+
if (reply.statusCode !== 200 || typeof payload !== "string") {
|
|
654
|
+
return payload;
|
|
655
|
+
}
|
|
656
|
+
const etag = etagOf(payload);
|
|
657
|
+
reply.header("etag", etag);
|
|
658
|
+
if (cacheControl !== false) reply.header("cache-control", cacheControl);
|
|
659
|
+
if (matchesIfNoneMatch(req.headers["if-none-match"], etag)) {
|
|
660
|
+
reply.code(304);
|
|
661
|
+
reply.removeHeader("content-length");
|
|
662
|
+
reply.removeHeader("content-type");
|
|
663
|
+
return "";
|
|
664
|
+
}
|
|
665
|
+
return payload;
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
|
|
551
669
|
// src/inventory/repo.ts
|
|
552
|
-
import { and as
|
|
670
|
+
import { and as and3, asc as asc3, eq as eq3, gt as gt2, inArray as inArray2, lt, sql as sql3 } from "drizzle-orm";
|
|
553
671
|
async function lockProductQuantities(tx, productIds) {
|
|
554
672
|
const rows = await tx.select({ id: products.id, quantity: products.quantity }).from(products).where(inArray2(products.id, productIds)).orderBy(asc3(products.id)).for("update");
|
|
555
673
|
return new Map(rows.map((r) => [r.id, r.quantity]));
|
|
@@ -560,10 +678,10 @@ async function activeHeldQuantities(db, productIds) {
|
|
|
560
678
|
productId: inventoryHolds.productId,
|
|
561
679
|
held: sql3`sum(${inventoryHolds.quantity})::int`
|
|
562
680
|
}).from(inventoryHolds).where(
|
|
563
|
-
|
|
681
|
+
and3(
|
|
564
682
|
inArray2(inventoryHolds.productId, productIds),
|
|
565
683
|
eq3(inventoryHolds.status, "active"),
|
|
566
|
-
|
|
684
|
+
gt2(inventoryHolds.expiresAt, sql3`now()`)
|
|
567
685
|
)
|
|
568
686
|
).groupBy(inventoryHolds.productId);
|
|
569
687
|
return new Map(rows.map((r) => [r.productId, r.held]));
|
|
@@ -585,7 +703,7 @@ async function insertHolds(tx, reference, items, expiresAt) {
|
|
|
585
703
|
}
|
|
586
704
|
async function releaseHolds(db, reference) {
|
|
587
705
|
await db.update(inventoryHolds).set({ status: "released" }).where(
|
|
588
|
-
|
|
706
|
+
and3(
|
|
589
707
|
eq3(inventoryHolds.reference, reference),
|
|
590
708
|
eq3(inventoryHolds.status, "active")
|
|
591
709
|
)
|
|
@@ -597,7 +715,7 @@ async function uncommittedHolds(tx, reference) {
|
|
|
597
715
|
quantity: inventoryHolds.quantity,
|
|
598
716
|
lapsed: sql3`(${inventoryHolds.status} = 'released' or ${inventoryHolds.expiresAt} <= now())`
|
|
599
717
|
}).from(inventoryHolds).where(
|
|
600
|
-
|
|
718
|
+
and3(
|
|
601
719
|
eq3(inventoryHolds.reference, reference),
|
|
602
720
|
inArray2(inventoryHolds.status, ["active", "released"])
|
|
603
721
|
)
|
|
@@ -605,7 +723,7 @@ async function uncommittedHolds(tx, reference) {
|
|
|
605
723
|
}
|
|
606
724
|
async function markHoldsCommitted(tx, reference) {
|
|
607
725
|
await tx.update(inventoryHolds).set({ status: "committed" }).where(
|
|
608
|
-
|
|
726
|
+
and3(
|
|
609
727
|
eq3(inventoryHolds.reference, reference),
|
|
610
728
|
inArray2(inventoryHolds.status, ["active", "released"])
|
|
611
729
|
)
|
|
@@ -613,10 +731,10 @@ async function markHoldsCommitted(tx, reference) {
|
|
|
613
731
|
}
|
|
614
732
|
async function extendActiveHolds(db, reference, expiresAt) {
|
|
615
733
|
await db.update(inventoryHolds).set({ expiresAt }).where(
|
|
616
|
-
|
|
734
|
+
and3(
|
|
617
735
|
eq3(inventoryHolds.reference, reference),
|
|
618
736
|
eq3(inventoryHolds.status, "active"),
|
|
619
|
-
|
|
737
|
+
gt2(inventoryHolds.expiresAt, sql3`now()`)
|
|
620
738
|
)
|
|
621
739
|
);
|
|
622
740
|
}
|
|
@@ -634,7 +752,7 @@ async function incrementQuantity(tx, productId, quantity) {
|
|
|
634
752
|
}
|
|
635
753
|
async function releaseExpiredHolds(db) {
|
|
636
754
|
const released = await db.update(inventoryHolds).set({ status: "released" }).where(
|
|
637
|
-
|
|
755
|
+
and3(
|
|
638
756
|
eq3(inventoryHolds.status, "active"),
|
|
639
757
|
lt(inventoryHolds.expiresAt, sql3`now()`)
|
|
640
758
|
)
|
|
@@ -741,15 +859,14 @@ async function restock(tx, items) {
|
|
|
741
859
|
}
|
|
742
860
|
|
|
743
861
|
// src/catalog/repo.ts
|
|
744
|
-
import { and as
|
|
745
|
-
async function attachImages(db, rows) {
|
|
862
|
+
import { and as and4, asc as asc4, eq as eq4, gt as gt3, inArray as inArray3, or as or2, sql as sql4 } from "drizzle-orm";
|
|
863
|
+
async function attachImages(db, rows, firstOnly = false) {
|
|
746
864
|
if (rows.length === 0) return [];
|
|
747
|
-
const
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
).orderBy(asc4(productImages.position));
|
|
865
|
+
const where = inArray3(
|
|
866
|
+
productImages.productId,
|
|
867
|
+
rows.map((r) => r.id)
|
|
868
|
+
);
|
|
869
|
+
const images = firstOnly ? await db.selectDistinctOn([productImages.productId]).from(productImages).where(where).orderBy(productImages.productId, asc4(productImages.position)) : await db.select().from(productImages).where(where).orderBy(asc4(productImages.position));
|
|
753
870
|
return rows.map((row) => ({
|
|
754
871
|
row,
|
|
755
872
|
images: images.filter((i) => i.productId === row.id)
|
|
@@ -760,11 +877,34 @@ async function listActiveProducts(db, filter) {
|
|
|
760
877
|
if (filter.category) conditions2.push(eq4(products.category, filter.category));
|
|
761
878
|
if (filter.featured !== void 0)
|
|
762
879
|
conditions2.push(eq4(products.featured, filter.featured));
|
|
763
|
-
|
|
764
|
-
|
|
880
|
+
if (filter.after) {
|
|
881
|
+
const createdAt = sql4`${filter.after.createdAt}::timestamptz`;
|
|
882
|
+
conditions2.push(
|
|
883
|
+
or2(
|
|
884
|
+
gt3(products.createdAt, createdAt),
|
|
885
|
+
and4(
|
|
886
|
+
eq4(products.createdAt, createdAt),
|
|
887
|
+
gt3(products.slug, filter.after.slug)
|
|
888
|
+
)
|
|
889
|
+
)
|
|
890
|
+
);
|
|
891
|
+
}
|
|
892
|
+
const selected = await db.select({
|
|
893
|
+
product: products,
|
|
894
|
+
createdAtKey: sql4`${products.createdAt}::text`
|
|
895
|
+
}).from(products).where(and4(...conditions2)).orderBy(asc4(products.createdAt), asc4(products.slug)).limit(filter.limit + 1);
|
|
896
|
+
const withImages2 = await attachImages(
|
|
897
|
+
db,
|
|
898
|
+
selected.map((s) => s.product),
|
|
899
|
+
true
|
|
900
|
+
);
|
|
901
|
+
return withImages2.map((p, i) => ({
|
|
902
|
+
...p,
|
|
903
|
+
createdAtKey: selected[i].createdAtKey
|
|
904
|
+
}));
|
|
765
905
|
}
|
|
766
906
|
async function findActiveProductBySlug(db, slug) {
|
|
767
|
-
const rows = await db.select().from(products).where(
|
|
907
|
+
const rows = await db.select().from(products).where(and4(eq4(products.slug, slug), eq4(products.status, "active"))).limit(1);
|
|
768
908
|
return (await attachImages(db, rows))[0];
|
|
769
909
|
}
|
|
770
910
|
async function countActiveByCategory(db) {
|
|
@@ -776,7 +916,7 @@ async function countActiveByCategory(db) {
|
|
|
776
916
|
}
|
|
777
917
|
async function findActiveProductsByIds(db, ids) {
|
|
778
918
|
if (ids.length === 0) return [];
|
|
779
|
-
return db.select().from(products).where(
|
|
919
|
+
return db.select().from(products).where(and4(inArray3(products.id, ids), eq4(products.status, "active")));
|
|
780
920
|
}
|
|
781
921
|
|
|
782
922
|
// src/catalog/service.ts
|
|
@@ -802,17 +942,34 @@ function toProduct({ row, images }, free) {
|
|
|
802
942
|
featured: row.featured
|
|
803
943
|
};
|
|
804
944
|
}
|
|
945
|
+
function toCard(product) {
|
|
946
|
+
const { description, materials, dimensions, ...card } = product;
|
|
947
|
+
void [description, materials, dimensions];
|
|
948
|
+
return card;
|
|
949
|
+
}
|
|
805
950
|
function labelFor(slug, labels) {
|
|
806
951
|
const label = typeof labels === "function" ? labels(slug) : labels?.[slug];
|
|
807
952
|
return label ?? slug;
|
|
808
953
|
}
|
|
954
|
+
function decodeProductCursor2(raw) {
|
|
955
|
+
const { at, key } = decodeCursor(raw, ({ at: at2 }) => PG_TIMESTAMP.test(at2));
|
|
956
|
+
return { createdAt: at, slug: key };
|
|
957
|
+
}
|
|
809
958
|
async function listProducts(db, filter) {
|
|
810
|
-
const found = await listActiveProducts(db,
|
|
959
|
+
const found = await listActiveProducts(db, {
|
|
960
|
+
...filter,
|
|
961
|
+
after: filter.cursor ? decodeProductCursor2(filter.cursor) : void 0
|
|
962
|
+
});
|
|
963
|
+
const page = found.slice(0, filter.limit);
|
|
964
|
+
const last = page[page.length - 1];
|
|
811
965
|
const free = await freeQuantity(
|
|
812
966
|
db,
|
|
813
|
-
|
|
967
|
+
page.map((p) => p.row.id)
|
|
814
968
|
);
|
|
815
|
-
return
|
|
969
|
+
return {
|
|
970
|
+
items: page.map((p) => toCard(toProduct(p, free))),
|
|
971
|
+
nextCursor: found.length > filter.limit && last ? encodeCursor({ at: last.createdAtKey ?? "", key: last.row.slug }) : null
|
|
972
|
+
};
|
|
816
973
|
}
|
|
817
974
|
async function getProduct(db, slug) {
|
|
818
975
|
const found = await findActiveProductBySlug(db, slug);
|
|
@@ -837,24 +994,29 @@ async function findPurchasable(db, ids) {
|
|
|
837
994
|
}
|
|
838
995
|
|
|
839
996
|
// src/catalog/routes.ts
|
|
840
|
-
var catalogPlugin = async (app, { db, categoryLabels }) => {
|
|
997
|
+
var catalogPlugin = async (app, { db, categoryLabels, catalogCache }) => {
|
|
998
|
+
addCatalogCaching(app, catalogCache);
|
|
841
999
|
app.setErrorHandler((err, _req, reply) => {
|
|
842
1000
|
if (err instanceof ProductNotFoundError) {
|
|
843
1001
|
return reply.code(404).send({ error: err.code, message: err.message });
|
|
844
1002
|
}
|
|
1003
|
+
if (err instanceof InvalidCursorError) {
|
|
1004
|
+
return reply.code(400).send({ error: err.code, message: err.message });
|
|
1005
|
+
}
|
|
845
1006
|
return reply.send(err);
|
|
846
1007
|
});
|
|
847
1008
|
app.get("/products", async (req, reply) => {
|
|
848
1009
|
const raw = req.query;
|
|
849
1010
|
const parsed = productListQuerySchema.safeParse({
|
|
850
1011
|
category: raw.category || void 0,
|
|
851
|
-
featured: raw.featured === void 0 ? void 0 : raw.featured === "true"
|
|
1012
|
+
featured: raw.featured === void 0 ? void 0 : raw.featured === "true",
|
|
1013
|
+
limit: raw.limit,
|
|
1014
|
+
cursor: raw.cursor || void 0
|
|
852
1015
|
});
|
|
853
1016
|
if (!parsed.success) {
|
|
854
1017
|
return reply.code(400).send({ error: "INVALID_QUERY" });
|
|
855
1018
|
}
|
|
856
|
-
|
|
857
|
-
return products2.map((p) => productSchema.parse(p));
|
|
1019
|
+
return productCardPageSchema.parse(await listProducts(db, parsed.data));
|
|
858
1020
|
});
|
|
859
1021
|
app.get("/products/:slug", async (req) => {
|
|
860
1022
|
return productSchema.parse(await getProduct(db, req.params.slug));
|
|
@@ -874,8 +1036,8 @@ import {
|
|
|
874
1036
|
} from "@develemit/commerce-contracts";
|
|
875
1037
|
|
|
876
1038
|
// src/orders/admin-repo.ts
|
|
877
|
-
import { and as
|
|
878
|
-
function
|
|
1039
|
+
import { and as and5, desc, eq as eq5, ilike as ilike2, lt as lt2, or as or3, sql as sql5 } from "drizzle-orm";
|
|
1040
|
+
function escapeLike2(value) {
|
|
879
1041
|
return value.replace(/[\\%_]/g, (c) => `\\${c}`);
|
|
880
1042
|
}
|
|
881
1043
|
function conditions(filter) {
|
|
@@ -890,21 +1052,21 @@ function conditions(filter) {
|
|
|
890
1052
|
parts.push(filter.hasPendingRefund ? pending : sql5`not ${pending}`);
|
|
891
1053
|
}
|
|
892
1054
|
if (filter.q) {
|
|
893
|
-
const prefix = `${
|
|
1055
|
+
const prefix = `${escapeLike2(filter.q)}%`;
|
|
894
1056
|
parts.push(
|
|
895
|
-
|
|
1057
|
+
or3(ilike2(orders.email, prefix), sql5`${orders.id}::text ilike ${prefix}`)
|
|
896
1058
|
);
|
|
897
1059
|
}
|
|
898
1060
|
if (filter.after) {
|
|
899
1061
|
const { createdAt, id } = filter.after;
|
|
900
1062
|
parts.push(
|
|
901
|
-
|
|
1063
|
+
or3(
|
|
902
1064
|
lt2(orders.createdAt, createdAt),
|
|
903
|
-
|
|
1065
|
+
and5(eq5(orders.createdAt, createdAt), lt2(orders.id, id))
|
|
904
1066
|
)
|
|
905
1067
|
);
|
|
906
1068
|
}
|
|
907
|
-
return
|
|
1069
|
+
return and5(...parts);
|
|
908
1070
|
}
|
|
909
1071
|
async function selectOrderPage(db, filter) {
|
|
910
1072
|
const itemCount = sql5`coalesce((
|
|
@@ -926,11 +1088,11 @@ async function findOrderRefunds(db, orderId) {
|
|
|
926
1088
|
}
|
|
927
1089
|
|
|
928
1090
|
// src/orders/repo.ts
|
|
929
|
-
import { and as
|
|
1091
|
+
import { and as and7, asc as asc5, eq as eq7, sql as sql7 } from "drizzle-orm";
|
|
930
1092
|
|
|
931
1093
|
// src/orders/refund-repo.ts
|
|
932
|
-
import { and as
|
|
933
|
-
var isRecordedRefund =
|
|
1094
|
+
import { and as and6, eq as eq6, isNotNull, isNull, or as or4, sql as sql6 } from "drizzle-orm";
|
|
1095
|
+
var isRecordedRefund = or4(
|
|
934
1096
|
isNull(orderRefunds.idempotencyKey),
|
|
935
1097
|
isNotNull(orderRefunds.gatewayRefundId)
|
|
936
1098
|
);
|
|
@@ -951,7 +1113,7 @@ async function sumReservedCents(tx, orderId) {
|
|
|
951
1113
|
}
|
|
952
1114
|
async function findRefundByKey(tx, orderId, idempotencyKey2) {
|
|
953
1115
|
const [row] = await tx.select().from(orderRefunds).where(
|
|
954
|
-
|
|
1116
|
+
and6(
|
|
955
1117
|
eq6(orderRefunds.orderId, orderId),
|
|
956
1118
|
eq6(orderRefunds.idempotencyKey, idempotencyKey2)
|
|
957
1119
|
)
|
|
@@ -963,7 +1125,7 @@ async function insertPendingRefund(tx, refund) {
|
|
|
963
1125
|
return row;
|
|
964
1126
|
}
|
|
965
1127
|
async function deletePendingRefund(db, id) {
|
|
966
|
-
await db.delete(orderRefunds).where(
|
|
1128
|
+
await db.delete(orderRefunds).where(and6(eq6(orderRefunds.id, id), isNull(orderRefunds.gatewayRefundId)));
|
|
967
1129
|
}
|
|
968
1130
|
async function confirmRefund(tx, pending, gatewayRefundId) {
|
|
969
1131
|
const [existing] = await tx.select({ id: orderRefunds.id }).from(orderRefunds).where(eq6(orderRefunds.gatewayRefundId, gatewayRefundId));
|
|
@@ -972,7 +1134,7 @@ async function confirmRefund(tx, pending, gatewayRefundId) {
|
|
|
972
1134
|
return void 0;
|
|
973
1135
|
}
|
|
974
1136
|
const [updated] = await tx.update(orderRefunds).set({ gatewayRefundId }).where(
|
|
975
|
-
|
|
1137
|
+
and6(
|
|
976
1138
|
eq6(orderRefunds.id, pending.id),
|
|
977
1139
|
isNull(orderRefunds.gatewayRefundId)
|
|
978
1140
|
)
|
|
@@ -998,7 +1160,7 @@ async function confirmPendingByKey(tx, orderId, scopedKey, gatewayRefundId) {
|
|
|
998
1160
|
const prefix = `${orderId}:`;
|
|
999
1161
|
if (!scopedKey.startsWith(prefix)) return void 0;
|
|
1000
1162
|
const [updated] = await tx.update(orderRefunds).set({ gatewayRefundId }).where(
|
|
1001
|
-
|
|
1163
|
+
and6(
|
|
1002
1164
|
eq6(orderRefunds.orderId, orderId),
|
|
1003
1165
|
eq6(orderRefunds.idempotencyKey, scopedKey.slice(prefix.length)),
|
|
1004
1166
|
isNull(orderRefunds.gatewayRefundId)
|
|
@@ -1050,7 +1212,7 @@ async function recordEvent(db, id, type) {
|
|
|
1050
1212
|
async function sumRefundedCents(db, orderId) {
|
|
1051
1213
|
const [row] = await db.select({
|
|
1052
1214
|
total: sql7`coalesce(sum(${orderRefunds.amountCents}), 0)::int`
|
|
1053
|
-
}).from(orderRefunds).where(
|
|
1215
|
+
}).from(orderRefunds).where(and7(eq7(orderRefunds.orderId, orderId), isRecordedRefund));
|
|
1054
1216
|
return row.total;
|
|
1055
1217
|
}
|
|
1056
1218
|
|
|
@@ -1100,13 +1262,6 @@ async function detailById(db, orderId) {
|
|
|
1100
1262
|
|
|
1101
1263
|
// src/orders/admin-service.ts
|
|
1102
1264
|
var UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
1103
|
-
var InvalidCursorError = class extends Error {
|
|
1104
|
-
code = "INVALID_CURSOR";
|
|
1105
|
-
constructor() {
|
|
1106
|
-
super("The cursor is malformed");
|
|
1107
|
-
this.name = "InvalidCursorError";
|
|
1108
|
-
}
|
|
1109
|
-
};
|
|
1110
1265
|
var InvalidOrderIdError = class extends Error {
|
|
1111
1266
|
code = "INVALID_ORDER_ID";
|
|
1112
1267
|
constructor() {
|
|
@@ -1137,30 +1292,26 @@ function toOrderRefund(row) {
|
|
|
1137
1292
|
function isOrderId(value) {
|
|
1138
1293
|
return UUID.test(value);
|
|
1139
1294
|
}
|
|
1140
|
-
function
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
if (rest.length || !id || !isOrderId(id) || Number.isNaN(+createdAt)) {
|
|
1147
|
-
throw new InvalidCursorError();
|
|
1148
|
-
}
|
|
1149
|
-
return { createdAt, id };
|
|
1295
|
+
function decodeOrderCursor(raw) {
|
|
1296
|
+
const { at, key } = decodeCursor(
|
|
1297
|
+
raw,
|
|
1298
|
+
(c) => isOrderId(c.key) && !Number.isNaN(+new Date(c.at))
|
|
1299
|
+
);
|
|
1300
|
+
return { createdAt: new Date(at), id: key };
|
|
1150
1301
|
}
|
|
1151
1302
|
async function listAdminOrders(db, query) {
|
|
1152
1303
|
const rows = await selectOrderPage(db, {
|
|
1153
1304
|
status: query.status,
|
|
1154
1305
|
q: query.q,
|
|
1155
1306
|
hasPendingRefund: query.hasPendingRefund,
|
|
1156
|
-
after: query.cursor ?
|
|
1307
|
+
after: query.cursor ? decodeOrderCursor(query.cursor) : void 0,
|
|
1157
1308
|
limit: query.limit + 1
|
|
1158
1309
|
});
|
|
1159
1310
|
const page = rows.slice(0, query.limit);
|
|
1160
1311
|
const last = page[page.length - 1];
|
|
1161
1312
|
return {
|
|
1162
1313
|
items: page.map((r) => ({ ...r, createdAt: r.createdAt.toISOString() })),
|
|
1163
|
-
nextCursor: rows.length > query.limit && last ? encodeCursor(last.createdAt, last.id) : null
|
|
1314
|
+
nextCursor: rows.length > query.limit && last ? encodeCursor({ at: last.createdAt.toISOString(), key: last.id }) : null
|
|
1164
1315
|
};
|
|
1165
1316
|
}
|
|
1166
1317
|
async function getAdminOrder(db, id) {
|
|
@@ -1838,12 +1989,14 @@ var commercePlugin = async (app, {
|
|
|
1838
1989
|
shippingCountries = ["US"],
|
|
1839
1990
|
hooks,
|
|
1840
1991
|
admin,
|
|
1841
|
-
categoryLabels
|
|
1992
|
+
categoryLabels,
|
|
1993
|
+
catalogCache
|
|
1842
1994
|
}) => {
|
|
1843
1995
|
app.get("/health", async () => ({ status: "ok" }));
|
|
1844
1996
|
await app.register(catalogPlugin, {
|
|
1845
1997
|
db,
|
|
1846
1998
|
categoryLabels,
|
|
1999
|
+
catalogCache,
|
|
1847
2000
|
prefix: "/catalog"
|
|
1848
2001
|
});
|
|
1849
2002
|
if (admin) {
|