@nymbalhq/platform 0.0.0-stage → 1.0.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.
Files changed (48) hide show
  1. package/dist/chunk-74KODSYJ.js +2564 -0
  2. package/dist/chunk-74KODSYJ.js.map +1 -0
  3. package/dist/chunk-PW6ILJF7.js +740 -0
  4. package/dist/chunk-PW6ILJF7.js.map +1 -0
  5. package/dist/chunk-PZ5AY32C.js +10 -0
  6. package/dist/chunk-PZ5AY32C.js.map +1 -0
  7. package/dist/db/schema/index.d.ts +10125 -0
  8. package/dist/db/schema/index.js +10 -0
  9. package/dist/db/schema/index.js.map +1 -0
  10. package/dist/index.d.ts +1373 -0
  11. package/dist/index.js +6462 -0
  12. package/dist/index.js.map +1 -0
  13. package/dist/register-IVFYBJSX.js +8 -0
  14. package/dist/register-IVFYBJSX.js.map +1 -0
  15. package/drizzle.config.postgres.ts +10 -0
  16. package/drizzle.config.sqlite.ts +10 -0
  17. package/migrations/postgres/0000_tearful_longshot.sql +166 -0
  18. package/migrations/postgres/0001_neat_zeigeist.sql +1 -0
  19. package/migrations/postgres/0002_sloppy_mad_thinker.sql +20 -0
  20. package/migrations/postgres/0003_violet_violations.sql +15 -0
  21. package/migrations/postgres/0004_white_reavers.sql +15 -0
  22. package/migrations/postgres/0005_natural_nico_minoru.sql +12 -0
  23. package/migrations/postgres/0006_minor_dragon_man.sql +28 -0
  24. package/migrations/postgres/meta/0000_snapshot.json +1172 -0
  25. package/migrations/postgres/meta/0001_snapshot.json +1187 -0
  26. package/migrations/postgres/meta/0002_snapshot.json +1325 -0
  27. package/migrations/postgres/meta/0003_snapshot.json +1432 -0
  28. package/migrations/postgres/meta/0004_snapshot.json +1439 -0
  29. package/migrations/postgres/meta/0005_snapshot.json +1461 -0
  30. package/migrations/postgres/meta/0006_snapshot.json +1598 -0
  31. package/migrations/postgres/meta/_journal.json +55 -0
  32. package/migrations/sqlite/0000_glorious_vision.sql +166 -0
  33. package/migrations/sqlite/0001_tricky_glorian.sql +1 -0
  34. package/migrations/sqlite/0002_puzzling_epoch.sql +19 -0
  35. package/migrations/sqlite/0003_fantastic_captain_stacy.sql +15 -0
  36. package/migrations/sqlite/0004_remarkable_shriek.sql +12 -0
  37. package/migrations/sqlite/0005_conscious_speedball.sql +13 -0
  38. package/migrations/sqlite/0006_flat_psylocke.sql +28 -0
  39. package/migrations/sqlite/meta/0000_snapshot.json +1093 -0
  40. package/migrations/sqlite/meta/0001_snapshot.json +1100 -0
  41. package/migrations/sqlite/meta/0002_snapshot.json +1222 -0
  42. package/migrations/sqlite/meta/0003_snapshot.json +1320 -0
  43. package/migrations/sqlite/meta/0004_snapshot.json +1328 -0
  44. package/migrations/sqlite/meta/0005_snapshot.json +1343 -0
  45. package/migrations/sqlite/meta/0006_snapshot.json +1470 -0
  46. package/migrations/sqlite/meta/_journal.json +55 -0
  47. package/package.json +85 -3
  48. package/README.md +0 -3
@@ -0,0 +1,2564 @@
1
+ // src/routes/auth-middleware.ts
2
+ function createAuthMiddleware(auth) {
3
+ return async (ctx) => {
4
+ const raw = ctx.headers.authorization;
5
+ const header = Array.isArray(raw) ? raw[0] : raw;
6
+ if (!header || !header.toLowerCase().startsWith("bearer ")) return;
7
+ const token = header.slice("bearer ".length).trim();
8
+ try {
9
+ const payload = await auth.verifyAccessToken(token);
10
+ ctx.auth = {
11
+ userId: payload.sub,
12
+ roles: payload.roles,
13
+ token
14
+ };
15
+ } catch {
16
+ }
17
+ return;
18
+ };
19
+ }
20
+
21
+ // src/routes/auth.ts
22
+ import {
23
+ AuthError
24
+ } from "@nymbalhq/types";
25
+
26
+ // src/routes/envelope.ts
27
+ import {
28
+ CheckoutError,
29
+ HttpError,
30
+ NotFoundError,
31
+ NymbalError,
32
+ OrderConflictError,
33
+ StatusTransitionError,
34
+ ValidationError
35
+ } from "@nymbalhq/types";
36
+ function ok(data, meta, status = 200) {
37
+ const body = { data, ...meta !== void 0 && { meta } };
38
+ return { status, body };
39
+ }
40
+ function fail(code, message, status = 400, context) {
41
+ const body = {
42
+ error: { code, message, ...context !== void 0 && { context } }
43
+ };
44
+ return { status, body };
45
+ }
46
+ var CHECKOUT_STATUS = {
47
+ "checkout.cart_empty": 409,
48
+ "checkout.in_progress": 409,
49
+ "checkout.payment_in_progress": 409,
50
+ "checkout.out_of_stock": 409,
51
+ "checkout.reservation_failed": 409,
52
+ "checkout.variant_missing": 422
53
+ };
54
+ function renderError(err) {
55
+ if (err instanceof HttpError) {
56
+ return fail(err.code, err.message, err.status);
57
+ }
58
+ if (err instanceof NotFoundError) {
59
+ return fail(err.code, err.message, 404, err.context);
60
+ }
61
+ if (err instanceof ValidationError) {
62
+ return fail(err.code, err.message, 400, err.context);
63
+ }
64
+ if (err instanceof StatusTransitionError || err instanceof OrderConflictError) {
65
+ return fail(err.code, err.message, 409, err.context);
66
+ }
67
+ if (err instanceof CheckoutError) {
68
+ return fail(err.code, err.message, CHECKOUT_STATUS[err.code] ?? 500, err.context);
69
+ }
70
+ if (err instanceof NymbalError) {
71
+ return fail(err.code, err.message, 500, err.context);
72
+ }
73
+ const message = err instanceof Error ? err.message : String(err);
74
+ return fail("internal", message, 500);
75
+ }
76
+
77
+ // src/routes/auth.ts
78
+ var REFRESH_COOKIE = "nymbal.refresh";
79
+ function refreshCookies(token, maxAgeSeconds) {
80
+ return [
81
+ {
82
+ name: REFRESH_COOKIE,
83
+ value: token,
84
+ path: "/",
85
+ httpOnly: true,
86
+ sameSite: "lax",
87
+ secure: process.env.NODE_ENV === "production",
88
+ maxAge: maxAgeSeconds
89
+ }
90
+ ];
91
+ }
92
+ function expiredCookies() {
93
+ return [{ name: REFRESH_COOKIE, value: "", path: "/", maxAge: 0, httpOnly: true }];
94
+ }
95
+ function registerAuthRoutes(adapter, auth) {
96
+ adapter.registerRoute("POST", "/api/auth/register", async (ctx) => {
97
+ try {
98
+ const body = ctx.body;
99
+ if (!body?.email || !body.password) {
100
+ return fail("auth.invalid_input", "email and password are required", 400);
101
+ }
102
+ const registerInput = {
103
+ email: body.email,
104
+ password: body.password,
105
+ ...body.firstName !== void 0 && { firstName: body.firstName },
106
+ ...body.lastName !== void 0 && { lastName: body.lastName },
107
+ ...body.phone !== void 0 && { phone: body.phone }
108
+ };
109
+ const result = await auth.register(registerInput);
110
+ const envelope = ok(
111
+ { customer: result.customer, accessToken: result.tokens.accessToken, expiresIn: result.tokens.expiresIn },
112
+ void 0,
113
+ 201
114
+ );
115
+ envelope.cookies = refreshCookies(result.tokens.refreshToken, 60 * 60 * 24 * 7);
116
+ return envelope;
117
+ } catch (err) {
118
+ return renderError(err);
119
+ }
120
+ });
121
+ adapter.registerRoute("POST", "/api/auth/login", async (ctx) => {
122
+ try {
123
+ const body = ctx.body;
124
+ if (!body?.email || !body.password) {
125
+ return fail("auth.invalid_input", "email and password are required", 400);
126
+ }
127
+ const result = await auth.login(body.email, body.password);
128
+ const envelope = ok({
129
+ customer: result.customer,
130
+ accessToken: result.tokens.accessToken,
131
+ expiresIn: result.tokens.expiresIn
132
+ });
133
+ envelope.cookies = refreshCookies(result.tokens.refreshToken, 60 * 60 * 24 * 7);
134
+ return envelope;
135
+ } catch (err) {
136
+ return renderError(err);
137
+ }
138
+ });
139
+ adapter.registerRoute("POST", "/api/auth/refresh", async (ctx) => {
140
+ try {
141
+ const body = ctx.body;
142
+ const refreshToken = body?.refreshToken ?? ctx.cookies[REFRESH_COOKIE];
143
+ if (!refreshToken) throw new AuthError("token_invalid", "refresh token missing");
144
+ const tokens = await auth.refresh(refreshToken);
145
+ const envelope = ok({ accessToken: tokens.accessToken, expiresIn: tokens.expiresIn });
146
+ envelope.cookies = refreshCookies(tokens.refreshToken, 60 * 60 * 24 * 7);
147
+ return envelope;
148
+ } catch (err) {
149
+ return renderError(err);
150
+ }
151
+ });
152
+ adapter.registerRoute("POST", "/api/auth/logout", async (ctx) => {
153
+ try {
154
+ const refreshToken = ctx.body?.refreshToken ?? ctx.cookies[REFRESH_COOKIE];
155
+ if (refreshToken) await auth.logout(refreshToken);
156
+ const envelope = ok({ ok: true });
157
+ envelope.cookies = expiredCookies();
158
+ return envelope;
159
+ } catch (err) {
160
+ return renderError(err);
161
+ }
162
+ });
163
+ }
164
+
165
+ // src/routes/products.ts
166
+ import {
167
+ DEFAULT_PRODUCT_SORT,
168
+ NotFoundError as NotFoundError2
169
+ } from "@nymbalhq/types";
170
+ var ORDER_BY = {
171
+ newest: { field: "createdAt", direction: "desc" },
172
+ "price-asc": { field: "priceRange.minMinor", direction: "asc", nulls: "last" },
173
+ "price-desc": { field: "priceRange.minMinor", direction: "desc", nulls: "last" },
174
+ "name-asc": { field: "name", direction: "asc" },
175
+ "name-desc": { field: "name", direction: "desc" }
176
+ };
177
+ function buildCategoryFacet(products) {
178
+ const counts = /* @__PURE__ */ new Map();
179
+ for (const product of products) {
180
+ const categories = product["categories"];
181
+ if (!Array.isArray(categories)) continue;
182
+ for (const cat of categories) {
183
+ const entry = counts.get(cat.slug);
184
+ if (entry) entry.count++;
185
+ else counts.set(cat.slug, { name: cat.name, count: 1 });
186
+ }
187
+ }
188
+ return {
189
+ field: "category",
190
+ label: "Category",
191
+ values: Array.from(counts.entries()).map(([slug, { name, count }]) => ({
192
+ value: slug,
193
+ label: name,
194
+ count
195
+ }))
196
+ };
197
+ }
198
+ function registerProductRoutes(adapter, documentStore, storeName, reviewsAdapter, searchService) {
199
+ adapter.registerRoute("GET", "/api/products/search", async (ctx) => {
200
+ try {
201
+ const rawQ = ctx.query.q;
202
+ const q = Array.isArray(rawQ) ? rawQ[0] : rawQ;
203
+ if (!q || !q.trim()) return ok({ items: [], nextCursor: null, facets: [] });
204
+ if (!searchService) return ok({ items: [], nextCursor: null, facets: [] });
205
+ const rawLimit = ctx.query.limit;
206
+ const limit = rawLimit ? Math.min(100, Math.max(1, Number(Array.isArray(rawLimit) ? rawLimit[0] : rawLimit) || 20)) : 20;
207
+ const rawCursor = ctx.query.cursor;
208
+ const cursor = Array.isArray(rawCursor) ? rawCursor[0] : rawCursor;
209
+ const searchResult = await searchService.products(q.trim(), {
210
+ limit,
211
+ ...cursor !== void 0 && { cursor }
212
+ });
213
+ const slugs = searchResult.items.map((doc) => String(doc.fields?.slug ?? "")).filter(Boolean);
214
+ const products = await Promise.all(slugs.map((slug) => documentStore.get("products", slug)));
215
+ const items = products.filter(Boolean);
216
+ return ok({ items, nextCursor: searchResult.nextCursor, facets: [] });
217
+ } catch (err) {
218
+ return renderError(err);
219
+ }
220
+ });
221
+ adapter.registerRoute("GET", "/api/products", async (ctx) => {
222
+ try {
223
+ const rawLimit = ctx.query.limit;
224
+ const limit = Math.min(
225
+ 100,
226
+ Math.max(1, Number(Array.isArray(rawLimit) ? rawLimit[0] : rawLimit) || 20)
227
+ );
228
+ const rawCursor = ctx.query.cursor;
229
+ const cursor = Array.isArray(rawCursor) ? rawCursor[0] : rawCursor;
230
+ const rawCategory = ctx.query.category;
231
+ const category = Array.isArray(rawCategory) ? rawCategory[0] : rawCategory;
232
+ const sort = ctx.query.sort ?? DEFAULT_PRODUCT_SORT;
233
+ const orderBy = ORDER_BY[sort];
234
+ const result = await (category ? documentStore.query("products-by-category", {
235
+ partitionKey: { field: "partitionKey", value: `category:${category}` },
236
+ orderBy,
237
+ limit,
238
+ ...cursor !== void 0 && { cursor }
239
+ }) : documentStore.query("products", {
240
+ partitionKey: { field: "storeId", value: storeName },
241
+ orderBy,
242
+ limit,
243
+ ...cursor !== void 0 && { cursor }
244
+ }));
245
+ const allResult = await documentStore.query("products", {
246
+ partitionKey: { field: "storeId", value: storeName },
247
+ limit: 1e3
248
+ });
249
+ const categoryFacet = buildCategoryFacet(allResult.items);
250
+ return ok({ items: result.items, nextCursor: result.nextCursor, facets: [categoryFacet] });
251
+ } catch (err) {
252
+ return renderError(err);
253
+ }
254
+ });
255
+ adapter.registerRoute("GET", "/api/products/:slug", async (ctx) => {
256
+ try {
257
+ const slug = ctx.params.slug;
258
+ if (!slug) return fail("products.slug_required", "slug param required", 400);
259
+ const doc = await documentStore.get("products", slug);
260
+ if (!doc) throw new NotFoundError2("product", slug);
261
+ return ok(doc);
262
+ } catch (err) {
263
+ return renderError(err);
264
+ }
265
+ });
266
+ adapter.registerRoute("GET", "/api/products/:id/reviews", async (ctx) => {
267
+ try {
268
+ const id2 = ctx.params.id;
269
+ if (!id2) return fail("products.id_required", "id param required", 400);
270
+ if (!reviewsAdapter) return ok({ items: [], nextCursor: null });
271
+ const rawLimit = ctx.query.limit;
272
+ const limit = rawLimit ? Math.min(100, Math.max(1, Number(Array.isArray(rawLimit) ? rawLimit[0] : rawLimit) || 20)) : 20;
273
+ const rawCursor = ctx.query.cursor;
274
+ const cursor = Array.isArray(rawCursor) ? rawCursor[0] : rawCursor;
275
+ const result = await reviewsAdapter.getProductReviews(id2, {
276
+ limit,
277
+ ...cursor && { cursor }
278
+ });
279
+ return ok(result);
280
+ } catch (err) {
281
+ return renderError(err);
282
+ }
283
+ });
284
+ }
285
+
286
+ // src/routes/categories.ts
287
+ function registerCategoryRoutes(adapter, documentStore, storeName) {
288
+ adapter.registerRoute("GET", "/api/categories", async () => {
289
+ try {
290
+ const result = await documentStore.query(
291
+ "categories",
292
+ { partitionKey: { field: "storeId", value: storeName }, limit: 500 }
293
+ );
294
+ const sorted = result.items.slice().sort((a, b) => (a.position ?? 0) - (b.position ?? 0));
295
+ return ok(sorted);
296
+ } catch (err) {
297
+ return renderError(err);
298
+ }
299
+ });
300
+ }
301
+
302
+ // src/routes/cart.ts
303
+ import "@nymbalhq/types";
304
+ var CART_COOKIE = "nymbal.cart";
305
+ function cookie(token) {
306
+ return [
307
+ {
308
+ name: CART_COOKIE,
309
+ value: token,
310
+ path: "/",
311
+ httpOnly: true,
312
+ sameSite: "lax",
313
+ secure: process.env.NODE_ENV === "production",
314
+ maxAge: 60 * 60 * 24 * 30
315
+ }
316
+ ];
317
+ }
318
+ function registerCartRoutes(adapter, deps) {
319
+ const { cart, productRepo, variantRepo } = deps;
320
+ adapter.registerRoute("GET", "/api/cart", async (ctx) => {
321
+ try {
322
+ const token = ctx.cookies[CART_COOKIE];
323
+ const existing = token ? await cart.get(token) : null;
324
+ if (existing) return ok(existing);
325
+ const fresh = await cart.getOrCreate();
326
+ const env = ok(fresh);
327
+ env.cookies = cookie(fresh.token);
328
+ return env;
329
+ } catch (err) {
330
+ return renderError(err);
331
+ }
332
+ });
333
+ adapter.registerRoute("POST", "/api/cart/items", async (ctx) => {
334
+ try {
335
+ const body = ctx.body;
336
+ if (!body?.variantId) return fail("cart.invalid", "variantId required", 400);
337
+ const qty = body.qty ?? 1;
338
+ const { variantId } = body;
339
+ const variant = await variantRepo.store.read((tx) => variantRepo.findById(tx, variantId));
340
+ if (!variant) return fail("cart.variant_missing", "variant not found", 404);
341
+ if (variant.stock < qty) {
342
+ return fail("cart.out_of_stock", "insufficient stock", 409, {
343
+ variantId: variant.id,
344
+ available: variant.stock
345
+ });
346
+ }
347
+ const product = await productRepo.store.read(
348
+ (tx) => productRepo.findById(tx, variant.productId)
349
+ );
350
+ if (!product) return fail("cart.product_missing", "product not found", 404);
351
+ const token = ctx.cookies[CART_COOKIE] ?? (await cart.getOrCreate()).token;
352
+ const updated = await cart.addItem(token, {
353
+ variantId: variant.id,
354
+ productId: product.id,
355
+ productName: product.name,
356
+ variantName: variant.name,
357
+ priceMinor: variant.priceMinor,
358
+ qty,
359
+ imageUrl: product.media[0]?.url ?? ""
360
+ });
361
+ const env = ok(updated);
362
+ env.cookies = cookie(token);
363
+ return env;
364
+ } catch (err) {
365
+ return renderError(err);
366
+ }
367
+ });
368
+ adapter.registerRoute("PATCH", "/api/cart/items/:variantId", async (ctx) => {
369
+ try {
370
+ const token = ctx.cookies[CART_COOKIE];
371
+ if (!token) return fail("cart.missing", "no active cart", 404);
372
+ const variantId = ctx.params.variantId;
373
+ if (!variantId) return fail("cart.variant_required", "variantId param required", 400);
374
+ const qty = ctx.body?.qty ?? 0;
375
+ const updated = await cart.updateItemQuantity(token, variantId, qty);
376
+ return ok(updated);
377
+ } catch (err) {
378
+ return renderError(err);
379
+ }
380
+ });
381
+ adapter.registerRoute("DELETE", "/api/cart/items/:variantId", async (ctx) => {
382
+ try {
383
+ const token = ctx.cookies[CART_COOKIE];
384
+ if (!token) return fail("cart.missing", "no active cart", 404);
385
+ const variantId = ctx.params.variantId;
386
+ if (!variantId) return fail("cart.variant_required", "variantId param required", 400);
387
+ const updated = await cart.removeItem(token, variantId);
388
+ return ok(updated);
389
+ } catch (err) {
390
+ return renderError(err);
391
+ }
392
+ });
393
+ adapter.registerRoute("DELETE", "/api/cart", async (ctx) => {
394
+ try {
395
+ const token = ctx.cookies[CART_COOKIE];
396
+ if (!token) return fail("cart.missing", "no active cart", 404);
397
+ const cleared = await cart.clear(token);
398
+ return ok(cleared);
399
+ } catch (err) {
400
+ return renderError(err);
401
+ }
402
+ });
403
+ }
404
+
405
+ // src/routes/checkout.ts
406
+ import "@nymbalhq/types";
407
+ var CART_COOKIE2 = "nymbal.cart";
408
+ function registerCheckoutRoutes(adapter, checkout) {
409
+ adapter.registerRoute("POST", "/api/checkout", async (ctx) => {
410
+ try {
411
+ const body = ctx.body;
412
+ const cartToken = ctx.cookies[CART_COOKIE2];
413
+ if (!cartToken) return fail("checkout.no_cart", "No active cart", 400);
414
+ if (!body?.email || !body.billingAddress || !body.shippingAddress) {
415
+ return fail("checkout.invalid", "email, billingAddress, shippingAddress are required", 400);
416
+ }
417
+ const result = await checkout.beginCheckout({
418
+ cartToken,
419
+ ...ctx.auth?.userId !== void 0 && { customerId: ctx.auth.userId },
420
+ email: body.email,
421
+ billingAddress: body.billingAddress,
422
+ shippingAddress: body.shippingAddress,
423
+ ...body.notes !== void 0 && { notes: body.notes }
424
+ });
425
+ return ok({
426
+ orderId: result.orderId,
427
+ orderNumber: result.orderNumber,
428
+ paymentIntent: result.paymentIntent,
429
+ currency: result.currency,
430
+ totalMinor: result.totalMinor,
431
+ orderStatus: result.orderStatus,
432
+ payment: result.payment
433
+ });
434
+ } catch (err) {
435
+ return renderError(err);
436
+ }
437
+ });
438
+ }
439
+
440
+ // src/routes/orders.ts
441
+ import {
442
+ NotFoundError as NotFoundError3
443
+ } from "@nymbalhq/types";
444
+
445
+ // src/routes/role-middleware.ts
446
+ import { AuthError as AuthError2 } from "@nymbalhq/types";
447
+ function requireAuth(handler) {
448
+ return (ctx) => {
449
+ if (!ctx.auth?.userId) {
450
+ return fail("auth.unauthorized", "Authentication required", 401);
451
+ }
452
+ return handler(ctx);
453
+ };
454
+ }
455
+ function requireRole(role, handler) {
456
+ return (ctx) => {
457
+ if (!ctx.auth?.userId) {
458
+ return fail("auth.unauthorized", "Authentication required", 401);
459
+ }
460
+ if (!ctx.auth.roles?.includes(role)) {
461
+ return fail("auth.forbidden", `Role required: ${role}`, 403);
462
+ }
463
+ return handler(ctx);
464
+ };
465
+ }
466
+
467
+ // src/routes/orders.ts
468
+ function registerOrderRoutes(adapter, orders, documentStore) {
469
+ adapter.registerRoute(
470
+ "GET",
471
+ "/api/orders",
472
+ requireAuth(async (ctx) => {
473
+ try {
474
+ const customerId = ctx.auth.userId;
475
+ const result = await documentStore.query("orders-by-customer", {
476
+ partitionKey: { field: "partitionKey", value: `customer:${customerId}` },
477
+ limit: 50
478
+ });
479
+ return ok(result.items, { nextCursor: result.nextCursor });
480
+ } catch (err) {
481
+ return renderError(err);
482
+ }
483
+ })
484
+ );
485
+ adapter.registerRoute("GET", "/api/orders/:orderNumber", async (ctx) => {
486
+ try {
487
+ const orderNumber = ctx.params.orderNumber;
488
+ if (!orderNumber) return fail("orders.invalid", "orderNumber required", 400);
489
+ const doc = await documentStore.get("orders", orderNumber);
490
+ if (!doc) throw new NotFoundError3("order", orderNumber);
491
+ if (ctx.auth?.userId) {
492
+ const o = doc;
493
+ if (o.customerId && o.customerId !== ctx.auth.userId) {
494
+ return fail("auth.forbidden", "forbidden", 403);
495
+ }
496
+ }
497
+ return ok(doc);
498
+ } catch (err) {
499
+ return renderError(err);
500
+ }
501
+ });
502
+ }
503
+
504
+ // src/routes/customers.ts
505
+ import "@nymbalhq/types";
506
+ function registerCustomerRoutes(adapter, customers) {
507
+ adapter.registerRoute(
508
+ "GET",
509
+ "/api/customers/me",
510
+ requireAuth(async (ctx) => {
511
+ try {
512
+ return ok(await customers.getProfile(ctx.auth.userId));
513
+ } catch (err) {
514
+ return renderError(err);
515
+ }
516
+ })
517
+ );
518
+ adapter.registerRoute(
519
+ "PATCH",
520
+ "/api/customers/me",
521
+ requireAuth(async (ctx) => {
522
+ try {
523
+ const patch = ctx.body;
524
+ const profile = await customers.updateProfile(ctx.auth.userId, {
525
+ ...typeof patch.firstName === "string" && { firstName: patch.firstName },
526
+ ...typeof patch.lastName === "string" && { lastName: patch.lastName },
527
+ ...typeof patch.phone === "string" && { phone: patch.phone }
528
+ });
529
+ return ok(profile);
530
+ } catch (err) {
531
+ return renderError(err);
532
+ }
533
+ })
534
+ );
535
+ adapter.registerRoute(
536
+ "GET",
537
+ "/api/customers/me/addresses",
538
+ requireAuth(async (ctx) => {
539
+ try {
540
+ return ok(await customers.listAddresses(ctx.auth.userId));
541
+ } catch (err) {
542
+ return renderError(err);
543
+ }
544
+ })
545
+ );
546
+ adapter.registerRoute(
547
+ "POST",
548
+ "/api/customers/me/addresses",
549
+ requireAuth(async (ctx) => {
550
+ try {
551
+ const body = ctx.body;
552
+ if (!body || !body.addressLine1) return fail("address.invalid", "address invalid", 400);
553
+ const addr = await customers.addAddress(ctx.auth.userId, {
554
+ firstName: body.firstName,
555
+ lastName: body.lastName,
556
+ addressLine1: body.addressLine1,
557
+ city: body.city,
558
+ region: body.region,
559
+ postalCode: body.postalCode,
560
+ country: body.country,
561
+ isDefaultBilling: body.isDefaultBilling ?? false,
562
+ isDefaultShipping: body.isDefaultShipping ?? false,
563
+ ...body.addressLine2 !== void 0 && { addressLine2: body.addressLine2 },
564
+ ...body.company !== void 0 && { company: body.company },
565
+ ...body.phone !== void 0 && { phone: body.phone }
566
+ });
567
+ return ok(addr, void 0, 201);
568
+ } catch (err) {
569
+ return renderError(err);
570
+ }
571
+ })
572
+ );
573
+ adapter.registerRoute(
574
+ "DELETE",
575
+ "/api/customers/me/addresses/:id",
576
+ requireAuth(async (ctx) => {
577
+ try {
578
+ const id2 = ctx.params.id;
579
+ if (!id2) return fail("address.invalid", "id required", 400);
580
+ await customers.deleteAddress(ctx.auth.userId, id2);
581
+ return ok({ ok: true });
582
+ } catch (err) {
583
+ return renderError(err);
584
+ }
585
+ })
586
+ );
587
+ }
588
+
589
+ // src/routes/admin-me.ts
590
+ function registerAdminMeRoute(adapter, deps) {
591
+ const { config } = deps;
592
+ adapter.registerRoute(
593
+ "GET",
594
+ "/api/admin/me",
595
+ requireRole("admin", async (ctx) => {
596
+ try {
597
+ return ok({
598
+ userId: ctx.auth.userId,
599
+ roles: ctx.auth.roles,
600
+ storeName: config.store.name,
601
+ environment: process.env["NODE_ENV"] ?? "development"
602
+ });
603
+ } catch (err) {
604
+ return renderError(err);
605
+ }
606
+ })
607
+ );
608
+ }
609
+
610
+ // src/routes/admin-dashboard.ts
611
+ function registerAdminDashboardRoute(adapter, deps) {
612
+ const { repos } = deps;
613
+ const { store } = repos.order;
614
+ adapter.registerRoute(
615
+ "GET",
616
+ "/api/admin/dashboard",
617
+ requireRole("admin", async () => {
618
+ try {
619
+ const startOfToday = new Date((/* @__PURE__ */ new Date()).setHours(0, 0, 0, 0));
620
+ const allOrders = await store.read((tx) => repos.order.list(tx, { limit: 200 }));
621
+ const todayOrders = allOrders.filter((o) => new Date(o.createdAt) >= startOfToday);
622
+ const shippedStatuses = /* @__PURE__ */ new Set(["shipped", "delivered"]);
623
+ const todayOrderCount = todayOrders.length;
624
+ const todayRevenueMinor = todayOrders.reduce(
625
+ (sum, o) => sum + o.capturedMinor - o.refundedMinor,
626
+ 0
627
+ );
628
+ const todayItemsShipped = todayOrders.filter((o) => shippedStatuses.has(o.status)).length;
629
+ const recentOrders = allOrders.slice(0, 10);
630
+ const productVariants = await store.read(async (tx) => {
631
+ const products = await repos.product.list(tx, { limit: 1e3 });
632
+ const out = [];
633
+ for (const product of products) {
634
+ out.push({ product, variants: await repos.variant.findByProduct(tx, product.id) });
635
+ }
636
+ return out;
637
+ });
638
+ const lowStock = [];
639
+ for (const { product, variants } of productVariants) {
640
+ for (const variant of variants) {
641
+ if (variant.stock <= variant.lowStockThreshold) {
642
+ lowStock.push({
643
+ variantId: variant.id,
644
+ productId: product.id,
645
+ productName: product.name,
646
+ sku: variant.sku,
647
+ stock: variant.stock,
648
+ threshold: variant.lowStockThreshold,
649
+ status: variant.stock <= 0 ? "out_of_stock" : "low_stock"
650
+ });
651
+ }
652
+ }
653
+ }
654
+ return ok({
655
+ today: {
656
+ orderCount: todayOrderCount,
657
+ revenueMinor: todayRevenueMinor,
658
+ itemsShipped: todayItemsShipped
659
+ },
660
+ recentOrders,
661
+ lowStock
662
+ });
663
+ } catch (err) {
664
+ return renderError(err);
665
+ }
666
+ })
667
+ );
668
+ }
669
+
670
+ // src/routes/admin-orders.ts
671
+ import {
672
+ OrderConflictError as OrderConflictError3,
673
+ canTransitionOrder as canTransitionOrder2
674
+ } from "@nymbalhq/types";
675
+
676
+ // src/services/refund-service.ts
677
+ import { v7 as uuidv7 } from "uuid";
678
+ import {
679
+ EVT_ORDER_PARTIALLY_REFUNDED,
680
+ EVT_ORDER_REFUNDED,
681
+ EVT_PAYMENT_REFUNDED,
682
+ NotFoundError as NotFoundError4,
683
+ OrderConflictError as OrderConflictError2,
684
+ PaymentError,
685
+ StatusTransitionError as StatusTransitionError2,
686
+ ValidationError as ValidationError2,
687
+ canTransitionOrder,
688
+ paymentState,
689
+ refundableMinor
690
+ } from "@nymbalhq/types";
691
+
692
+ // src/money.ts
693
+ function formatMoney(amountMinor, currency) {
694
+ const code = currency.toUpperCase();
695
+ const format = new Intl.NumberFormat("en-GB", { style: "currency", currency: code });
696
+ const digits = format.resolvedOptions().maximumFractionDigits ?? 2;
697
+ return format.format(amountMinor / 10 ** digits);
698
+ }
699
+
700
+ // src/services/refund-service.ts
701
+ var PROVIDER_REFUND_REASON = "requested_by_customer";
702
+ function orderPayment(order, refunds) {
703
+ return {
704
+ state: paymentState(order),
705
+ capturedMinor: order.capturedMinor,
706
+ refundedMinor: order.refundedMinor,
707
+ refundableMinor: refundableMinor(order, refunds),
708
+ currency: order.currency,
709
+ refunds
710
+ };
711
+ }
712
+ function settlementOf(answer, row) {
713
+ const matches = answer.amountMinor === row.amountMinor && answer.currency.toUpperCase() === row.currency.toUpperCase();
714
+ const providerRefundId = answer.refundId;
715
+ if (answer.status === "failed") return { status: "failed", providerRefundId, mismatch: false };
716
+ if (!matches) return { status: "pending", providerRefundId, mismatch: true };
717
+ return { status: answer.status, providerRefundId, mismatch: false };
718
+ }
719
+ async function refreshRefunded(repos, tx, orderId, at) {
720
+ const refunds = await repos.orderRefund.listByOrder(tx, orderId);
721
+ const refundedMinor = refunds.filter((r) => r.status === "succeeded").reduce((sum, r) => sum + r.amountMinor, 0);
722
+ await repos.order.setRefunded(tx, orderId, refundedMinor, at);
723
+ return { refunds, refundedMinor };
724
+ }
725
+ function createRefundService(deps) {
726
+ const { store, repos, publisher, logger, payments } = deps;
727
+ async function hold(tx, input) {
728
+ const order = await repos.order.lock(tx, input.orderId);
729
+ if (!order) throw new NotFoundError4("order", input.orderId);
730
+ if (input.requestId !== void 0) {
731
+ const earlier = await repos.orderRefund.findByRequestId(tx, order.id, input.requestId);
732
+ if (earlier !== null) {
733
+ if (earlier.amountMinor !== input.amountMinor) {
734
+ throw new OrderConflictError2(
735
+ "refund.request_reused",
736
+ `Request ${input.requestId} already refunded ${formatMoney(earlier.amountMinor, earlier.currency)} on order ${order.orderNumber}`,
737
+ {
738
+ context: { amountMinor: input.amountMinor, earlierAmountMinor: earlier.amountMinor }
739
+ }
740
+ );
741
+ }
742
+ const refunds = await repos.orderRefund.listByOrder(tx, order.id);
743
+ return { repeat: { order, payment: orderPayment(order, refunds), refund: earlier } };
744
+ }
745
+ }
746
+ if (order.capturedMinor <= 0 || order.captureMethod === "provider" && !order.paymentIntentId) {
747
+ throw new OrderConflictError2(
748
+ "refund.not_captured",
749
+ `Order ${order.orderNumber} has no captured payment to refund`
750
+ );
751
+ }
752
+ const refundable = refundableMinor(order, await repos.orderRefund.listByOrder(tx, order.id));
753
+ if (input.amountMinor > refundable) {
754
+ throw new OrderConflictError2(
755
+ "refund.exceeds_captured",
756
+ `Refund of ${formatMoney(input.amountMinor, order.currency)} exceeds the ${formatMoney(refundable, order.currency)} refundable on order ${order.orderNumber}`,
757
+ { context: { amountMinor: input.amountMinor, refundableMinor: refundable } }
758
+ );
759
+ }
760
+ if (!canTransitionOrder(order.status, "refunded")) {
761
+ throw new StatusTransitionError2("order", order.status, "refunded");
762
+ }
763
+ const row = {
764
+ id: uuidv7(),
765
+ orderId: order.id,
766
+ amountMinor: input.amountMinor,
767
+ currency: order.currency,
768
+ providerRefundId: null,
769
+ status: "pending",
770
+ reason: input.reason ?? null,
771
+ actor: input.actor,
772
+ createdAt: /* @__PURE__ */ new Date(),
773
+ requestId: input.requestId ?? null
774
+ };
775
+ await repos.orderRefund.insert(tx, row);
776
+ return { order, row };
777
+ }
778
+ async function moveMoney(order, row) {
779
+ if (order.captureMethod !== "provider") {
780
+ return { status: "succeeded", providerRefundId: null, mismatch: false };
781
+ }
782
+ const context = { orderId: order.id, refundId: row.id, amountMinor: row.amountMinor };
783
+ try {
784
+ const answer = await payments.refund(
785
+ order.paymentIntentId,
786
+ row.amountMinor,
787
+ PROVIDER_REFUND_REASON,
788
+ { idempotencyKey: row.id, currency: order.currency }
789
+ );
790
+ const settlement = settlementOf(answer, row);
791
+ if (settlement.mismatch) {
792
+ logger.error(
793
+ { ...context, answer: { amountMinor: answer.amountMinor, currency: answer.currency } },
794
+ "provider refund answer differs from the refund; left pending"
795
+ );
796
+ }
797
+ return settlement;
798
+ } catch (err) {
799
+ if (err instanceof PaymentError && err.code === "payment.refund_rejected") {
800
+ logger.warn({ ...context, err }, "provider refused the refund");
801
+ return { status: "failed", providerRefundId: null, mismatch: false };
802
+ }
803
+ logger.error({ ...context, err }, "refund outcome unknown; left pending until reconciled");
804
+ return { status: "pending", providerRefundId: null, mismatch: false };
805
+ }
806
+ }
807
+ async function settle(tx, orderId, row, settlement) {
808
+ const order = await repos.order.lock(tx, orderId);
809
+ if (!order) throw new NotFoundError4("order", orderId);
810
+ await repos.orderRefund.settle(tx, row.id, {
811
+ status: settlement.status,
812
+ providerRefundId: settlement.providerRefundId
813
+ });
814
+ const now = /* @__PURE__ */ new Date();
815
+ const { refunds, refundedMinor } = await refreshRefunded(repos, tx, order.id, now);
816
+ const refund = refunds.find((r) => r.id === row.id);
817
+ if (refund.status === "succeeded") await recordSucceeded(tx, order, refundedMinor, refund, now);
818
+ const fresh = await repos.order.findById(tx, order.id);
819
+ return { order: fresh, payment: orderPayment(fresh, refunds), refund };
820
+ }
821
+ async function recordSucceeded(tx, order, refundedMinor, refund, now) {
822
+ const full = refundedMinor >= order.capturedMinor;
823
+ const target = full ? "refunded" : "partially_refunded";
824
+ const note = `refund of ${formatMoney(refund.amountMinor, refund.currency)}${refund.reason ? `: ${refund.reason}` : ""}`;
825
+ let toStatus = order.status;
826
+ if (order.status !== target && canTransitionOrder(order.status, target)) {
827
+ await repos.order.setStatus(tx, order.id, target, now);
828
+ toStatus = target;
829
+ } else if (order.status !== target) {
830
+ logger.warn(
831
+ { orderId: order.id, refundId: refund.id, status: order.status, wanted: target },
832
+ "refund settled; the order status no longer allows the move and is kept"
833
+ );
834
+ }
835
+ await repos.orderHistory.append(tx, {
836
+ id: uuidv7(),
837
+ orderId: order.id,
838
+ fromStatus: order.status,
839
+ toStatus,
840
+ actor: refund.actor,
841
+ note,
842
+ timestamp: now
843
+ });
844
+ if (order.customerId) {
845
+ await repos.customer.incrementStats(tx, order.customerId, 0, -refund.amountMinor, now);
846
+ }
847
+ const payload = {
848
+ orderId: order.id,
849
+ refundId: refund.id,
850
+ amountMinor: refund.amountMinor,
851
+ currency: refund.currency,
852
+ reason: refund.reason ?? ""
853
+ };
854
+ await publisher.publish(full ? EVT_ORDER_REFUNDED : EVT_ORDER_PARTIALLY_REFUNDED, payload);
855
+ await publisher.publish(EVT_PAYMENT_REFUNDED, {
856
+ paymentId: order.captureMethod === "provider" ? order.paymentIntentId ?? "" : "",
857
+ orderId: order.id,
858
+ refundId: refund.id,
859
+ amountMinor: refund.amountMinor,
860
+ currency: refund.currency
861
+ });
862
+ }
863
+ return {
864
+ async refund(input) {
865
+ if (!Number.isInteger(input.amountMinor) || input.amountMinor <= 0) {
866
+ throw new ValidationError2("amountMinor must be a positive whole number of minor units", {
867
+ context: { amountMinor: input.amountMinor }
868
+ });
869
+ }
870
+ const held = await store.transaction((tx) => hold(tx, input));
871
+ if ("repeat" in held) return held.repeat;
872
+ const { order, row } = held;
873
+ const settlement = await moveMoney(order, row);
874
+ return store.transaction((tx) => settle(tx, order.id, row, settlement));
875
+ }
876
+ };
877
+ }
878
+
879
+ // src/routes/admin-orders.ts
880
+ function parseNotes(rawNotes, createdAt) {
881
+ if (!rawNotes) return [];
882
+ try {
883
+ const parsed = JSON.parse(rawNotes);
884
+ if (Array.isArray(parsed)) return parsed;
885
+ return [{ body: rawNotes, actor: "system", at: createdAt }];
886
+ } catch {
887
+ return [{ body: rawNotes, actor: "system", at: createdAt }];
888
+ }
889
+ }
890
+ function registerAdminOrdersRoutes(adapter, deps) {
891
+ const { order, checkout, refund, repos } = deps;
892
+ const { store } = repos.order;
893
+ async function withPayments(tx, orders) {
894
+ const refunds = await repos.orderRefund.listByOrders(
895
+ tx,
896
+ orders.map((o) => o.id)
897
+ );
898
+ return orders.map((o) => ({ ...o, payment: orderPayment(o, refunds.get(o.id) ?? []) }));
899
+ }
900
+ async function adminOrder(fresh) {
901
+ const [read] = await store.read((tx) => withPayments(tx, [fresh]));
902
+ return read;
903
+ }
904
+ async function findOrder(tx, idOrNumber) {
905
+ return await repos.order.findById(tx, idOrNumber) ?? await repos.order.findByOrderNumber(tx, idOrNumber);
906
+ }
907
+ adapter.registerRoute(
908
+ "GET",
909
+ "/api/admin/orders",
910
+ requireRole("admin", async (ctx) => {
911
+ try {
912
+ const q = ctx.query;
913
+ const statusRaw = Array.isArray(q.status) ? q.status[0] : q.status;
914
+ const dateFrom = Array.isArray(q.dateFrom) ? q.dateFrom[0] : q.dateFrom;
915
+ const dateTo = Array.isArray(q.dateTo) ? q.dateTo[0] : q.dateTo;
916
+ const customer = Array.isArray(q.customer) ? q.customer[0] : q.customer;
917
+ const search = Array.isArray(q.q) ? q.q[0] : q.q;
918
+ const limitRaw = Array.isArray(q.limit) ? q.limit[0] : q.limit;
919
+ const offsetRaw = Array.isArray(q.offset) ? q.offset[0] : q.offset;
920
+ const limit = limitRaw ? Math.min(parseInt(limitRaw, 10) || 50, 200) : 50;
921
+ const offset = offsetRaw ? parseInt(offsetRaw, 10) || 0 : 0;
922
+ const listOpts = {
923
+ limit: limit + offset + 200,
924
+ // over-fetch for in-memory filtering
925
+ offset: 0
926
+ };
927
+ if (statusRaw) listOpts.status = statusRaw;
928
+ let orders = await store.read((tx) => repos.order.list(tx, listOpts));
929
+ if (dateFrom) {
930
+ const from = new Date(dateFrom);
931
+ orders = orders.filter((o) => new Date(o.createdAt) >= from);
932
+ }
933
+ if (dateTo) {
934
+ const to = new Date(dateTo);
935
+ orders = orders.filter((o) => new Date(o.createdAt) <= to);
936
+ }
937
+ if (customer) {
938
+ const lc = customer.toLowerCase();
939
+ orders = orders.filter(
940
+ (o) => (o.customerId ?? "").toLowerCase().includes(lc) || o.email.toLowerCase().includes(lc)
941
+ );
942
+ }
943
+ if (search) {
944
+ const lc = search.toLowerCase();
945
+ orders = orders.filter(
946
+ (o) => o.orderNumber.toLowerCase().includes(lc) || o.email.toLowerCase().includes(lc)
947
+ );
948
+ }
949
+ const page = orders.slice(offset, offset + limit);
950
+ return ok(await store.read((tx) => withPayments(tx, page)));
951
+ } catch (err) {
952
+ return renderError(err);
953
+ }
954
+ })
955
+ );
956
+ adapter.registerRoute(
957
+ "GET",
958
+ "/api/admin/orders/:orderNumber",
959
+ requireRole("admin", async (ctx) => {
960
+ try {
961
+ const orderNumber = ctx.params.orderNumber;
962
+ if (!orderNumber) return fail("admin.invalid", "orderNumber required", 400);
963
+ const detail = await store.read(async (tx) => {
964
+ const found = await repos.order.findByOrderNumber(tx, orderNumber);
965
+ if (!found) return null;
966
+ const [read] = await withPayments(tx, [found]);
967
+ return { ...read, history: await repos.orderHistory.listByOrder(tx, found.id) };
968
+ });
969
+ if (!detail) return fail("orders.not_found", `Order not found: ${orderNumber}`, 404);
970
+ return ok(detail);
971
+ } catch (err) {
972
+ return renderError(err);
973
+ }
974
+ })
975
+ );
976
+ adapter.registerRoute(
977
+ "POST",
978
+ "/api/admin/orders/:id/confirm",
979
+ requireRole("admin", async (ctx) => {
980
+ try {
981
+ const id2 = ctx.params.id;
982
+ if (!id2) return fail("admin.invalid", "id required", 400);
983
+ const current = await order.getById(id2);
984
+ if (canTransitionOrder2(current.status, "confirmed") && current.capturedMinor === 0) {
985
+ throw new OrderConflictError3(
986
+ "order.unpaid",
987
+ `Order ${current.orderNumber} is unpaid: record its payment to confirm it`
988
+ );
989
+ }
990
+ const fresh = await order.updateStatus(id2, "confirmed", `admin:${ctx.auth.userId}`);
991
+ return ok(await adminOrder(fresh));
992
+ } catch (err) {
993
+ return renderError(err);
994
+ }
995
+ })
996
+ );
997
+ adapter.registerRoute(
998
+ "POST",
999
+ "/api/admin/orders/:id/process",
1000
+ requireRole("admin", async (ctx) => {
1001
+ try {
1002
+ const id2 = ctx.params.id;
1003
+ if (!id2) return fail("admin.invalid", "id required", 400);
1004
+ const fresh = await order.updateStatus(id2, "processing", `admin:${ctx.auth.userId}`);
1005
+ return ok(await adminOrder(fresh));
1006
+ } catch (err) {
1007
+ return renderError(err);
1008
+ }
1009
+ })
1010
+ );
1011
+ adapter.registerRoute(
1012
+ "POST",
1013
+ "/api/admin/orders/:id/cancel",
1014
+ requireRole("admin", async (ctx) => {
1015
+ try {
1016
+ const id2 = ctx.params.id;
1017
+ if (!id2) return fail("admin.invalid", "id required", 400);
1018
+ const body = ctx.body;
1019
+ const reason = body?.reason ?? "Cancelled by admin";
1020
+ const fresh = await order.cancel(id2, reason, `admin:${ctx.auth.userId}`);
1021
+ return ok(await adminOrder(fresh));
1022
+ } catch (err) {
1023
+ return renderError(err);
1024
+ }
1025
+ })
1026
+ );
1027
+ adapter.registerRoute(
1028
+ "POST",
1029
+ "/api/admin/orders/:id/mark-paid",
1030
+ requireRole("admin", async (ctx) => {
1031
+ try {
1032
+ const id2 = ctx.params.id;
1033
+ if (!id2) return fail("admin.invalid", "id required", 400);
1034
+ const body = ctx.body;
1035
+ const fresh = await checkout.markPaid(id2, `admin:${ctx.auth.userId}`, body?.note);
1036
+ return ok(await adminOrder(fresh));
1037
+ } catch (err) {
1038
+ return renderError(err);
1039
+ }
1040
+ })
1041
+ );
1042
+ adapter.registerRoute(
1043
+ "POST",
1044
+ "/api/admin/orders/:id/mark-delivered",
1045
+ requireRole("admin", async (ctx) => {
1046
+ try {
1047
+ const id2 = ctx.params.id;
1048
+ if (!id2) return fail("admin.invalid", "id required", 400);
1049
+ const fresh = await order.markDelivered(id2, `admin:${ctx.auth.userId}`);
1050
+ return ok(await adminOrder(fresh));
1051
+ } catch (err) {
1052
+ return renderError(err);
1053
+ }
1054
+ })
1055
+ );
1056
+ adapter.registerRoute(
1057
+ "POST",
1058
+ "/api/admin/orders/:id/ship",
1059
+ requireRole("admin", async (ctx) => {
1060
+ try {
1061
+ const id2 = ctx.params.id;
1062
+ if (!id2) return fail("admin.invalid", "id required", 400);
1063
+ const body = ctx.body;
1064
+ if (!body?.trackingNumber || !body.carrier) {
1065
+ return fail("orders.invalid", "trackingNumber and carrier required", 400);
1066
+ }
1067
+ const fresh = await order.markShipped(
1068
+ id2,
1069
+ body.trackingNumber,
1070
+ body.carrier,
1071
+ `admin:${ctx.auth.userId}`
1072
+ );
1073
+ return ok(await adminOrder(fresh));
1074
+ } catch (err) {
1075
+ return renderError(err);
1076
+ }
1077
+ })
1078
+ );
1079
+ adapter.registerRoute(
1080
+ "POST",
1081
+ "/api/admin/orders/:id/refund",
1082
+ requireRole("admin", async (ctx) => {
1083
+ try {
1084
+ const id2 = ctx.params.id;
1085
+ if (!id2) return fail("admin.invalid", "id required", 400);
1086
+ const body = ctx.body;
1087
+ const result = await refund.refund({
1088
+ orderId: id2,
1089
+ amountMinor: body.amountMinor,
1090
+ ...body.reason !== void 0 && { reason: body.reason },
1091
+ ...body.requestId !== void 0 && { requestId: body.requestId },
1092
+ actor: `admin:${ctx.auth.userId}`
1093
+ });
1094
+ return ok(result);
1095
+ } catch (err) {
1096
+ return renderError(err);
1097
+ }
1098
+ })
1099
+ );
1100
+ adapter.registerRoute(
1101
+ "GET",
1102
+ "/api/admin/orders/:id/timeline",
1103
+ requireRole("admin", async (ctx) => {
1104
+ try {
1105
+ const id2 = ctx.params.id;
1106
+ if (!id2) return fail("admin.invalid", "id required", 400);
1107
+ const history = await store.read(async (tx) => {
1108
+ const found = await findOrder(tx, id2);
1109
+ return found ? repos.orderHistory.listByOrder(tx, found.id) : null;
1110
+ });
1111
+ if (!history) return fail("orders.not_found", `Order not found: ${id2}`, 404);
1112
+ return ok(history);
1113
+ } catch (err) {
1114
+ return renderError(err);
1115
+ }
1116
+ })
1117
+ );
1118
+ adapter.registerRoute(
1119
+ "GET",
1120
+ "/api/admin/orders/:id/notes",
1121
+ requireRole("admin", async (ctx) => {
1122
+ try {
1123
+ const id2 = ctx.params.id;
1124
+ if (!id2) return fail("admin.invalid", "id required", 400);
1125
+ const found = await store.read((tx) => findOrder(tx, id2));
1126
+ if (!found) return fail("orders.not_found", `Order not found: ${id2}`, 404);
1127
+ const notes = parseNotes(found.notes, found.createdAt);
1128
+ return ok(notes);
1129
+ } catch (err) {
1130
+ return renderError(err);
1131
+ }
1132
+ })
1133
+ );
1134
+ adapter.registerRoute(
1135
+ "POST",
1136
+ "/api/admin/orders/:id/notes",
1137
+ requireRole("admin", async (ctx) => {
1138
+ try {
1139
+ const id2 = ctx.params.id;
1140
+ if (!id2) return fail("admin.invalid", "id required", 400);
1141
+ const body = ctx.body;
1142
+ if (!body?.body?.trim()) {
1143
+ return fail("orders.invalid", "note body required", 400);
1144
+ }
1145
+ const newNote = {
1146
+ body: body.body.trim(),
1147
+ actor: `admin:${ctx.auth.userId}`,
1148
+ at: (/* @__PURE__ */ new Date()).toISOString()
1149
+ };
1150
+ const noted = await store.transaction(async (tx) => {
1151
+ const found = await findOrder(tx, id2);
1152
+ if (!found) return false;
1153
+ const updated = [...parseNotes(found.notes, found.createdAt), newNote];
1154
+ await repos.order.setNotes(tx, found.id, JSON.stringify(updated), /* @__PURE__ */ new Date());
1155
+ return true;
1156
+ });
1157
+ if (!noted) return fail("orders.not_found", `Order not found: ${id2}`, 404);
1158
+ return ok({ note: newNote });
1159
+ } catch (err) {
1160
+ return renderError(err);
1161
+ }
1162
+ })
1163
+ );
1164
+ adapter.registerRoute(
1165
+ "POST",
1166
+ "/api/admin/orders/:id/resend-confirmation",
1167
+ requireRole("admin", async (ctx) => {
1168
+ try {
1169
+ const id2 = ctx.params.id;
1170
+ if (!id2) return fail("admin.invalid", "id required", 400);
1171
+ return ok({ sent: true, orderId: id2 });
1172
+ } catch (err) {
1173
+ return renderError(err);
1174
+ }
1175
+ })
1176
+ );
1177
+ }
1178
+
1179
+ // src/routes/admin-products.ts
1180
+ function registerAdminProductsRoutes(adapter, deps) {
1181
+ const { product } = deps;
1182
+ adapter.registerRoute(
1183
+ "GET",
1184
+ "/api/admin/products",
1185
+ requireRole("admin", async (ctx) => {
1186
+ try {
1187
+ const q = ctx.query;
1188
+ const statusRaw = Array.isArray(q.status) ? q.status[0] : q.status;
1189
+ const search = Array.isArray(q.q) ? q.q[0] : q.q;
1190
+ const limitRaw = Array.isArray(q.limit) ? q.limit[0] : q.limit;
1191
+ const offsetRaw = Array.isArray(q.offset) ? q.offset[0] : q.offset;
1192
+ const limit = limitRaw ? Math.min(parseInt(limitRaw, 10) || 50, 200) : 50;
1193
+ const offset = offsetRaw ? parseInt(offsetRaw, 10) || 0 : 0;
1194
+ const listOpts = {
1195
+ limit: limit + offset + 200,
1196
+ offset: 0
1197
+ };
1198
+ if (statusRaw) listOpts.status = statusRaw;
1199
+ let products = await product.list(listOpts);
1200
+ if (search) {
1201
+ const lc = search.toLowerCase();
1202
+ products = products.filter((p) => p.name.toLowerCase().includes(lc));
1203
+ }
1204
+ const page = products.slice(offset, offset + limit);
1205
+ return ok(page);
1206
+ } catch (err) {
1207
+ return renderError(err);
1208
+ }
1209
+ })
1210
+ );
1211
+ adapter.registerRoute(
1212
+ "POST",
1213
+ "/api/admin/products",
1214
+ requireRole("admin", async (ctx) => {
1215
+ try {
1216
+ const snap = await product.create(ctx.body);
1217
+ return ok(snap, void 0, 201);
1218
+ } catch (err) {
1219
+ return renderError(err);
1220
+ }
1221
+ })
1222
+ );
1223
+ adapter.registerRoute(
1224
+ "PATCH",
1225
+ "/api/admin/products/:id",
1226
+ requireRole("admin", async (ctx) => {
1227
+ try {
1228
+ const id2 = ctx.params.id;
1229
+ if (!id2) return fail("admin.invalid", "id required", 400);
1230
+ const snap = await product.update(
1231
+ id2,
1232
+ ctx.body
1233
+ );
1234
+ return ok(snap);
1235
+ } catch (err) {
1236
+ return renderError(err);
1237
+ }
1238
+ })
1239
+ );
1240
+ adapter.registerRoute(
1241
+ "DELETE",
1242
+ "/api/admin/products/:id",
1243
+ requireRole("admin", async (ctx) => {
1244
+ try {
1245
+ const id2 = ctx.params.id;
1246
+ if (!id2) return fail("admin.invalid", "id required", 400);
1247
+ await product.delete(id2);
1248
+ return ok({ ok: true });
1249
+ } catch (err) {
1250
+ return renderError(err);
1251
+ }
1252
+ })
1253
+ );
1254
+ adapter.registerRoute(
1255
+ "POST",
1256
+ "/api/admin/products/:id/publish",
1257
+ requireRole("admin", async (ctx) => {
1258
+ try {
1259
+ const id2 = ctx.params.id;
1260
+ if (!id2) return fail("admin.invalid", "id required", 400);
1261
+ await product.publish(id2);
1262
+ return ok({ ok: true });
1263
+ } catch (err) {
1264
+ return renderError(err);
1265
+ }
1266
+ })
1267
+ );
1268
+ adapter.registerRoute(
1269
+ "POST",
1270
+ "/api/admin/products/:id/unpublish",
1271
+ requireRole("admin", async (ctx) => {
1272
+ try {
1273
+ const id2 = ctx.params.id;
1274
+ if (!id2) return fail("admin.invalid", "id required", 400);
1275
+ await product.unpublish(id2);
1276
+ return ok({ ok: true });
1277
+ } catch (err) {
1278
+ return renderError(err);
1279
+ }
1280
+ })
1281
+ );
1282
+ adapter.registerRoute(
1283
+ "POST",
1284
+ "/api/admin/products/:id/archive",
1285
+ requireRole("admin", async (ctx) => {
1286
+ try {
1287
+ const id2 = ctx.params.id;
1288
+ if (!id2) return fail("admin.invalid", "id required", 400);
1289
+ const snap = await product.update(id2, { status: "archived" });
1290
+ return ok(snap);
1291
+ } catch (err) {
1292
+ return renderError(err);
1293
+ }
1294
+ })
1295
+ );
1296
+ adapter.registerRoute(
1297
+ "POST",
1298
+ "/api/admin/products/:id/duplicate",
1299
+ requireRole("admin", async (ctx) => {
1300
+ try {
1301
+ const id2 = ctx.params.id;
1302
+ if (!id2) return fail("admin.invalid", "id required", 400);
1303
+ const original = await product.getById(id2);
1304
+ const newSlug = `${original.slug}-copy`;
1305
+ const snap = await product.create({
1306
+ slug: newSlug,
1307
+ name: `${original.name} (Copy)`,
1308
+ description: original.description,
1309
+ shortDescription: original.shortDescription,
1310
+ status: "draft",
1311
+ type: original.type,
1312
+ seoTitle: original.seoTitle,
1313
+ seoDescription: original.seoDescription,
1314
+ media: original.media,
1315
+ metadata: original.metadata,
1316
+ categoryIds: original.categoryIds,
1317
+ variants: original.variants.map((v) => ({
1318
+ sku: `${v.sku}-COPY`,
1319
+ name: v.name,
1320
+ priceMinor: v.priceMinor,
1321
+ compareAtPriceMinor: v.compareAtPriceMinor,
1322
+ weightGrams: v.weightGrams,
1323
+ dimensions: v.dimensions,
1324
+ stock: v.stock,
1325
+ lowStockThreshold: v.lowStockThreshold,
1326
+ options: v.options,
1327
+ status: v.status
1328
+ }))
1329
+ });
1330
+ return ok(snap, void 0, 201);
1331
+ } catch (err) {
1332
+ return renderError(err);
1333
+ }
1334
+ })
1335
+ );
1336
+ adapter.registerRoute(
1337
+ "POST",
1338
+ "/api/admin/products/:id/enhance",
1339
+ requireRole("admin", async (ctx) => {
1340
+ try {
1341
+ const id2 = ctx.params.id;
1342
+ if (!id2) return fail("admin.invalid", "id required", 400);
1343
+ const body = ctx.body;
1344
+ if (!body?.field) return fail("admin.invalid", "field required", 400);
1345
+ const validFields = ["description", "alt", "title", "seoDescription"];
1346
+ if (!validFields.includes(body.field)) {
1347
+ return fail("admin.invalid", `field must be one of: ${validFields.join(", ")}`, 400);
1348
+ }
1349
+ if (!deps.ai) {
1350
+ return fail("admin.unavailable", "AI adapter not configured", 503);
1351
+ }
1352
+ const context = body.context ?? {};
1353
+ let prompt;
1354
+ switch (body.field) {
1355
+ case "description":
1356
+ prompt = `Write a compelling product description for: ${context["name"] ?? "this product"}. Current description: ${context["current"] ?? "(empty)"}. Keep it under 200 words, engaging, and highlight key benefits.`;
1357
+ break;
1358
+ case "seoDescription":
1359
+ prompt = `Write an SEO meta description (under 155 chars) for product: ${context["name"] ?? "this product"}`;
1360
+ break;
1361
+ case "title":
1362
+ prompt = `Write an SEO title tag (under 60 chars) for product: ${context["name"] ?? "this product"}`;
1363
+ break;
1364
+ case "alt":
1365
+ prompt = `Write alt text for a product image of: ${context["name"] ?? "this product"}. Keep it under 100 chars, descriptive.`;
1366
+ break;
1367
+ default:
1368
+ return fail("admin.invalid", "unknown field", 400);
1369
+ }
1370
+ const suggestion = await deps.ai.complete(prompt, { maxTokens: 300 });
1371
+ return ok({ suggestion });
1372
+ } catch (err) {
1373
+ return renderError(err);
1374
+ }
1375
+ })
1376
+ );
1377
+ }
1378
+
1379
+ // src/routes/admin-inventory.ts
1380
+ function stockStatus(stock, threshold) {
1381
+ if (stock <= 0) return "out_of_stock";
1382
+ if (stock <= threshold) return "low_stock";
1383
+ return "in_stock";
1384
+ }
1385
+ function urgencyOrder(status) {
1386
+ if (status === "out_of_stock") return 0;
1387
+ if (status === "low_stock") return 1;
1388
+ return 2;
1389
+ }
1390
+ function registerAdminInventoryRoutes(adapter, deps) {
1391
+ const { inventory, product, repos } = deps;
1392
+ adapter.registerRoute(
1393
+ "GET",
1394
+ "/api/admin/inventory",
1395
+ requireRole("admin", async (ctx) => {
1396
+ try {
1397
+ const q = ctx.query;
1398
+ const statusFilter = Array.isArray(q.status) ? q.status[0] : q.status;
1399
+ const search = Array.isArray(q.q) ? q.q[0] : q.q;
1400
+ const limitRaw = Array.isArray(q.limit) ? q.limit[0] : q.limit;
1401
+ const offsetRaw = Array.isArray(q.offset) ? q.offset[0] : q.offset;
1402
+ const limit = limitRaw ? Math.min(parseInt(limitRaw, 10) || 100, 500) : 100;
1403
+ const offset = offsetRaw ? parseInt(offsetRaw, 10) || 0 : 0;
1404
+ const allProducts = await product.list({ limit: 1e3 });
1405
+ let rows = [];
1406
+ for (const p of allProducts) {
1407
+ for (const variant of p.variants) {
1408
+ rows.push({
1409
+ variantId: variant.id,
1410
+ productId: p.id,
1411
+ productName: p.name,
1412
+ sku: variant.sku,
1413
+ options: variant.options,
1414
+ stock: variant.stock,
1415
+ threshold: variant.lowStockThreshold,
1416
+ status: stockStatus(variant.stock, variant.lowStockThreshold),
1417
+ updatedAt: variant.updatedAt
1418
+ });
1419
+ }
1420
+ }
1421
+ if (statusFilter) {
1422
+ rows = rows.filter((r) => r.status === statusFilter);
1423
+ }
1424
+ if (search) {
1425
+ const lc = search.toLowerCase();
1426
+ rows = rows.filter(
1427
+ (r) => r.sku.toLowerCase().includes(lc) || r.productName.toLowerCase().includes(lc)
1428
+ );
1429
+ }
1430
+ rows.sort((a, b) => {
1431
+ const urgencyDiff = urgencyOrder(a.status) - urgencyOrder(b.status);
1432
+ if (urgencyDiff !== 0) return urgencyDiff;
1433
+ return a.stock - b.stock;
1434
+ });
1435
+ const page = rows.slice(offset, offset + limit);
1436
+ return ok(page);
1437
+ } catch (err) {
1438
+ return renderError(err);
1439
+ }
1440
+ })
1441
+ );
1442
+ adapter.registerRoute(
1443
+ "POST",
1444
+ "/api/admin/inventory/:variantId/adjust",
1445
+ requireRole("admin", async (ctx) => {
1446
+ try {
1447
+ const variantId = ctx.params.variantId;
1448
+ if (!variantId) return fail("admin.invalid", "variantId required", 400);
1449
+ const body = ctx.body;
1450
+ if (body?.delta == null) return fail("inventory.invalid", "delta required", 400);
1451
+ if (!body.reason) return fail("inventory.invalid", "reason required", 400);
1452
+ const result = await inventory.adjustStock(
1453
+ variantId,
1454
+ body.delta,
1455
+ body.reason,
1456
+ `admin:${ctx.auth.userId}`
1457
+ );
1458
+ return ok({ newQty: result.newQty, variantId });
1459
+ } catch (err) {
1460
+ return renderError(err);
1461
+ }
1462
+ })
1463
+ );
1464
+ adapter.registerRoute(
1465
+ "GET",
1466
+ "/api/admin/inventory/:variantId/history",
1467
+ requireRole("admin", async (ctx) => {
1468
+ try {
1469
+ const variantId = ctx.params.variantId;
1470
+ if (!variantId) return fail("admin.invalid", "variantId required", 400);
1471
+ const history = await repos.stockAdjustment.store.read(
1472
+ (tx) => repos.stockAdjustment.listByVariant(tx, variantId, 100)
1473
+ );
1474
+ return ok(history);
1475
+ } catch (err) {
1476
+ return renderError(err);
1477
+ }
1478
+ })
1479
+ );
1480
+ }
1481
+
1482
+ // src/routes/admin-customers.ts
1483
+ function computeStats(orders) {
1484
+ if (orders.length === 0) {
1485
+ return {
1486
+ orderCount: 0,
1487
+ lifetimeRevenueMinor: 0,
1488
+ avgOrderValueMinor: 0,
1489
+ firstOrderAt: null,
1490
+ lastOrderAt: null
1491
+ };
1492
+ }
1493
+ const sorted = [...orders].sort(
1494
+ (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
1495
+ );
1496
+ const paidStatuses = /* @__PURE__ */ new Set([
1497
+ "confirmed",
1498
+ "processing",
1499
+ "shipped",
1500
+ "delivered",
1501
+ "partially_refunded"
1502
+ ]);
1503
+ const lifetimeRevenueMinor = orders.filter((o) => paidStatuses.has(o.status)).reduce((sum, o) => sum + o.totalMinor, 0);
1504
+ return {
1505
+ orderCount: orders.length,
1506
+ lifetimeRevenueMinor,
1507
+ avgOrderValueMinor: orders.length > 0 ? Math.round(lifetimeRevenueMinor / orders.length) : 0,
1508
+ firstOrderAt: sorted[0]?.createdAt ?? null,
1509
+ lastOrderAt: sorted[sorted.length - 1]?.createdAt ?? null
1510
+ };
1511
+ }
1512
+ function registerAdminCustomersRoutes(adapter, deps) {
1513
+ const { repos } = deps;
1514
+ const { store } = repos.customer;
1515
+ adapter.registerRoute(
1516
+ "GET",
1517
+ "/api/admin/customers",
1518
+ requireRole("admin", async (ctx) => {
1519
+ try {
1520
+ const q = ctx.query;
1521
+ const search = Array.isArray(q.q) ? q.q[0] : q.q;
1522
+ const sortRaw = Array.isArray(q.sort) ? q.sort[0] : q.sort;
1523
+ const limitRaw = Array.isArray(q.limit) ? q.limit[0] : q.limit;
1524
+ const offsetRaw = Array.isArray(q.offset) ? q.offset[0] : q.offset;
1525
+ const limit = limitRaw ? Math.min(parseInt(limitRaw, 10) || 50, 200) : 50;
1526
+ const offset = offsetRaw ? parseInt(offsetRaw, 10) || 0 : 0;
1527
+ const customerResults = await store.read(async (tx) => {
1528
+ const allOrders = await repos.order.list(tx, { limit: 500 });
1529
+ const customerIdSet = /* @__PURE__ */ new Set();
1530
+ for (const o of allOrders) {
1531
+ if (o.customerId) customerIdSet.add(o.customerId);
1532
+ }
1533
+ return Promise.all([...customerIdSet].map((id2) => repos.customer.findById(tx, id2)));
1534
+ });
1535
+ let customers = customerResults.filter((c) => c !== null).map(({ passwordHash: _pw, ...rest }) => rest);
1536
+ if (search) {
1537
+ const lc = search.toLowerCase();
1538
+ customers = customers.filter(
1539
+ (c) => c.email.toLowerCase().includes(lc) || c.firstName.toLowerCase().includes(lc) || c.lastName.toLowerCase().includes(lc)
1540
+ );
1541
+ }
1542
+ const sortField = sortRaw?.split(":")[0] ?? "totalSpentMinor";
1543
+ const sortDir = sortRaw?.split(":")[1] ?? "desc";
1544
+ customers.sort((a, b) => {
1545
+ const aVal = a[sortField];
1546
+ const bVal = b[sortField];
1547
+ const aNum = typeof aVal === "number" ? aVal : 0;
1548
+ const bNum = typeof bVal === "number" ? bVal : 0;
1549
+ return sortDir === "asc" ? aNum - bNum : bNum - aNum;
1550
+ });
1551
+ const page = customers.slice(offset, offset + limit);
1552
+ return ok({ customers: page });
1553
+ } catch (err) {
1554
+ return renderError(err);
1555
+ }
1556
+ })
1557
+ );
1558
+ adapter.registerRoute(
1559
+ "GET",
1560
+ "/api/admin/customers/:id",
1561
+ requireRole("admin", async (ctx) => {
1562
+ try {
1563
+ const id2 = ctx.params.id;
1564
+ if (!id2) return fail("admin.invalid", "id required", 400);
1565
+ const found = await store.read(async (tx) => {
1566
+ const raw = await repos.customer.findById(tx, id2);
1567
+ return raw ? { raw, orders: await repos.order.listByCustomer(tx, id2) } : null;
1568
+ });
1569
+ if (!found) return fail("customers.not_found", `Customer not found: ${id2}`, 404);
1570
+ const { passwordHash: _pw, ...customer } = found.raw;
1571
+ const { orders } = found;
1572
+ const stats = computeStats(orders);
1573
+ return ok({ customer, stats });
1574
+ } catch (err) {
1575
+ return renderError(err);
1576
+ }
1577
+ })
1578
+ );
1579
+ adapter.registerRoute(
1580
+ "GET",
1581
+ "/api/admin/customers/:id/orders",
1582
+ requireRole("admin", async (ctx) => {
1583
+ try {
1584
+ const id2 = ctx.params.id;
1585
+ if (!id2) return fail("admin.invalid", "id required", 400);
1586
+ const orders = await store.read((tx) => repos.order.listByCustomer(tx, id2));
1587
+ return ok(orders);
1588
+ } catch (err) {
1589
+ return renderError(err);
1590
+ }
1591
+ })
1592
+ );
1593
+ adapter.registerRoute(
1594
+ "POST",
1595
+ "/api/admin/customers/:id/notes",
1596
+ requireRole("admin", async (ctx) => {
1597
+ try {
1598
+ const id2 = ctx.params.id;
1599
+ if (!id2) return fail("admin.invalid", "id required", 400);
1600
+ const body = ctx.body;
1601
+ if (!body?.body?.trim()) {
1602
+ return fail("customers.invalid", "note body required", 400);
1603
+ }
1604
+ const newNote = {
1605
+ body: body.body.trim(),
1606
+ actor: `admin:${ctx.auth.userId}`,
1607
+ at: (/* @__PURE__ */ new Date()).toISOString()
1608
+ };
1609
+ const noted = await store.transaction(async (tx) => {
1610
+ const raw = await repos.customer.findById(tx, id2);
1611
+ if (!raw) return false;
1612
+ const existingNotes = raw.metadata.adminNotes ?? [];
1613
+ const updatedMeta = {
1614
+ ...raw.metadata,
1615
+ adminNotes: [...existingNotes, newNote]
1616
+ };
1617
+ await repos.customer.update(tx, id2, {
1618
+ metadata: updatedMeta,
1619
+ updatedAt: /* @__PURE__ */ new Date()
1620
+ });
1621
+ return true;
1622
+ });
1623
+ if (!noted) return fail("customers.not_found", `Customer not found: ${id2}`, 404);
1624
+ return ok({ note: newNote });
1625
+ } catch (err) {
1626
+ return renderError(err);
1627
+ }
1628
+ })
1629
+ );
1630
+ }
1631
+
1632
+ // src/routes/admin-search.ts
1633
+ function registerAdminSearchRoute(adapter, deps) {
1634
+ const { product, repos } = deps;
1635
+ adapter.registerRoute(
1636
+ "GET",
1637
+ "/api/admin/search",
1638
+ requireRole("admin", async (ctx) => {
1639
+ try {
1640
+ const qRaw = Array.isArray(ctx.query.q) ? ctx.query.q[0] : ctx.query.q;
1641
+ if (!qRaw?.trim()) {
1642
+ return fail("search.invalid", "q parameter required", 400);
1643
+ }
1644
+ const q = qRaw.toLowerCase();
1645
+ const allOrders = await repos.order.store.read((tx) => repos.order.list(tx, { limit: 100 }));
1646
+ const matchedOrders = allOrders.filter(
1647
+ (o) => o.orderNumber.toLowerCase().includes(q) || o.email.toLowerCase().includes(q)
1648
+ ).slice(0, 5);
1649
+ const allProducts = await product.list({ limit: 200 });
1650
+ const matchedProducts = allProducts.filter((p) => p.name.toLowerCase().includes(q) || p.slug.toLowerCase().includes(q)).slice(0, 5);
1651
+ const customerResults = await repos.order.store.read(async (tx) => {
1652
+ const allOrdersFull = await repos.order.list(tx, { limit: 500 });
1653
+ const customerIdSet = /* @__PURE__ */ new Set();
1654
+ for (const o of allOrdersFull) {
1655
+ if (o.customerId) customerIdSet.add(o.customerId);
1656
+ }
1657
+ return Promise.all([...customerIdSet].map((id2) => repos.customer.findById(tx, id2)));
1658
+ });
1659
+ const matchedCustomers = customerResults.filter((c) => c !== null).filter(
1660
+ (c) => c.email.toLowerCase().includes(q) || c.firstName.toLowerCase().includes(q) || c.lastName.toLowerCase().includes(q)
1661
+ ).slice(0, 5).map(({ passwordHash: _pw, ...rest }) => rest);
1662
+ return ok({
1663
+ orders: matchedOrders,
1664
+ products: matchedProducts,
1665
+ customers: matchedCustomers
1666
+ });
1667
+ } catch (err) {
1668
+ return renderError(err);
1669
+ }
1670
+ })
1671
+ );
1672
+ }
1673
+
1674
+ // src/routes/admin-reviews.ts
1675
+ function registerAdminReviewsRoutes(adapter, deps) {
1676
+ const { reviewRepo } = deps;
1677
+ adapter.registerRoute(
1678
+ "GET",
1679
+ "/api/admin/reviews",
1680
+ requireRole("admin", async () => {
1681
+ try {
1682
+ const pending = await reviewRepo.store.read((tx) => reviewRepo.listByStatus(tx, "pending"));
1683
+ return ok(pending);
1684
+ } catch (err) {
1685
+ return renderError(err);
1686
+ }
1687
+ })
1688
+ );
1689
+ adapter.registerRoute(
1690
+ "POST",
1691
+ "/api/admin/reviews/:id/moderate",
1692
+ requireRole("admin", async (ctx) => {
1693
+ try {
1694
+ const id2 = ctx.params.id;
1695
+ if (!id2) return fail("admin.invalid", "id required", 400);
1696
+ const body = ctx.body;
1697
+ if (!body?.status || !["approved", "rejected"].includes(body.status)) {
1698
+ return fail("reviews.invalid", "status must be approved|rejected", 400);
1699
+ }
1700
+ const status = body.status;
1701
+ await reviewRepo.store.transaction(
1702
+ (tx) => reviewRepo.setModerationStatus(tx, id2, status, `admin:${ctx.auth.userId}`, /* @__PURE__ */ new Date())
1703
+ );
1704
+ return ok({ ok: true });
1705
+ } catch (err) {
1706
+ return renderError(err);
1707
+ }
1708
+ })
1709
+ );
1710
+ }
1711
+
1712
+ // src/routes/admin.ts
1713
+ function registerAdminRoutes(adapter, deps) {
1714
+ const { config, services, repos, adapters } = deps;
1715
+ registerAdminMeRoute(adapter, { config });
1716
+ registerAdminDashboardRoute(adapter, { repos, config });
1717
+ registerAdminOrdersRoutes(adapter, {
1718
+ order: services.order,
1719
+ checkout: services.checkout,
1720
+ refund: services.refund,
1721
+ repos
1722
+ });
1723
+ registerAdminProductsRoutes(adapter, { product: services.product, ai: adapters.ai });
1724
+ registerAdminInventoryRoutes(adapter, {
1725
+ inventory: services.inventory,
1726
+ product: services.product,
1727
+ repos
1728
+ });
1729
+ registerAdminCustomersRoutes(adapter, { order: services.order, repos });
1730
+ registerAdminSearchRoute(adapter, {
1731
+ order: services.order,
1732
+ product: services.product,
1733
+ repos
1734
+ });
1735
+ registerAdminReviewsRoutes(adapter, {
1736
+ reviewsAdapter: adapters.reviews,
1737
+ reviewRepo: repos.review
1738
+ });
1739
+ }
1740
+
1741
+ // src/routes/admin-static.ts
1742
+ import { readFile, realpath, stat } from "fs/promises";
1743
+ import { extname, resolve, sep } from "path";
1744
+ var MIME = /* @__PURE__ */ new Map([
1745
+ [".html", "text/html; charset=utf-8"],
1746
+ [".js", "text/javascript; charset=utf-8"],
1747
+ [".mjs", "text/javascript; charset=utf-8"],
1748
+ [".css", "text/css; charset=utf-8"],
1749
+ [".json", "application/json; charset=utf-8"],
1750
+ [".svg", "image/svg+xml"],
1751
+ [".png", "image/png"],
1752
+ [".jpg", "image/jpeg"],
1753
+ [".jpeg", "image/jpeg"],
1754
+ [".webp", "image/webp"],
1755
+ [".ico", "image/x-icon"],
1756
+ [".woff", "font/woff"],
1757
+ [".woff2", "font/woff2"],
1758
+ [".ttf", "font/ttf"]
1759
+ ]);
1760
+ var BUILD_HINT = "The Nymbal admin is not built. Run `pnpm --filter @nymbalhq/admin build` in the Nymbal repository, or install the `@nymbalhq/admin` package beside `nymbal`.\n";
1761
+ var notFound = (body = "Not found\n") => ({
1762
+ status: 404,
1763
+ headers: { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-cache" },
1764
+ body
1765
+ });
1766
+ async function fileWithin(root, requestPath) {
1767
+ if (requestPath.includes("\0") || requestPath.includes("\\")) return null;
1768
+ const candidate = resolve(root, `.${sep}${requestPath}`);
1769
+ if (candidate !== root && !candidate.startsWith(root + sep)) return null;
1770
+ try {
1771
+ if (!(await stat(candidate)).isFile()) return null;
1772
+ const real = await realpath(candidate);
1773
+ return real.startsWith(root + sep) ? real : null;
1774
+ } catch {
1775
+ return null;
1776
+ }
1777
+ }
1778
+ function registerAdminStaticRoute(adapter, root) {
1779
+ adapter.registerRoute("GET", "/admin", async () => ({
1780
+ status: 308,
1781
+ headers: { Location: "/admin/" },
1782
+ body: void 0
1783
+ }));
1784
+ let realRoot = null;
1785
+ const resolvedRoot = () => realRoot ??= root === null ? Promise.resolve(null) : realpath(root).catch(() => null);
1786
+ const serve = async (wildcard) => {
1787
+ const base = await resolvedRoot();
1788
+ if (base === null) return notFound(BUILD_HINT);
1789
+ const requestPath = wildcard === "" ? "index.html" : wildcard;
1790
+ const file = await fileWithin(base, requestPath);
1791
+ if (file !== null) {
1792
+ const ext = extname(file).toLowerCase();
1793
+ return {
1794
+ status: 200,
1795
+ headers: {
1796
+ "Content-Type": MIME.get(ext) ?? "application/octet-stream",
1797
+ "Cache-Control": requestPath.startsWith("assets/") ? "public, max-age=31536000, immutable" : "no-cache"
1798
+ },
1799
+ body: await readFile(file)
1800
+ };
1801
+ }
1802
+ if (requestPath !== "index.html" && MIME.has(extname(requestPath).toLowerCase())) {
1803
+ return notFound();
1804
+ }
1805
+ const index = await fileWithin(base, "index.html");
1806
+ if (index === null) return notFound(BUILD_HINT);
1807
+ return {
1808
+ status: 200,
1809
+ headers: { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-cache" },
1810
+ body: await readFile(index)
1811
+ };
1812
+ };
1813
+ adapter.registerRoute("GET", "/admin/", () => serve(""));
1814
+ adapter.registerRoute("GET", "/admin/*", (ctx) => serve(ctx.params["*"] ?? ""));
1815
+ }
1816
+
1817
+ // src/routes/events-sse.ts
1818
+ import { createHmac, randomBytes } from "crypto";
1819
+ import "@nymbalhq/types";
1820
+ var TICKET_TTL_SECONDS = 60;
1821
+ var NONCE_COLLECTION = "sse-ticket-nonces";
1822
+ function sign(payload, secret) {
1823
+ const body = Buffer.from(JSON.stringify(payload)).toString("base64url");
1824
+ const sig = createHmac("sha256", secret).update(body).digest("base64url");
1825
+ return `${body}.${sig}`;
1826
+ }
1827
+ function verify(token, secret) {
1828
+ const [body, sig] = token.split(".");
1829
+ if (!body || !sig) return null;
1830
+ const expected = createHmac("sha256", secret).update(body).digest("base64url");
1831
+ if (expected !== sig) return null;
1832
+ try {
1833
+ return JSON.parse(Buffer.from(body, "base64url").toString("utf8"));
1834
+ } catch {
1835
+ return null;
1836
+ }
1837
+ }
1838
+ function registerEventsSseRoute(adapter, deps) {
1839
+ const { eventBus, documentStore, ticketSecret } = deps;
1840
+ adapter.registerRoute(
1841
+ "POST",
1842
+ "/api/admin/events/ticket",
1843
+ requireRole("admin", (ctx) => {
1844
+ try {
1845
+ const nonce = randomBytes(12).toString("base64url");
1846
+ const payload = {
1847
+ uid: ctx.auth.userId,
1848
+ roles: ctx.auth.roles ?? ["admin"],
1849
+ iat: Math.floor(Date.now() / 1e3),
1850
+ nonce
1851
+ };
1852
+ const token = sign(payload, ticketSecret);
1853
+ return ok({ ticket: token, expiresIn: TICKET_TTL_SECONDS });
1854
+ } catch (err) {
1855
+ return renderError(err);
1856
+ }
1857
+ })
1858
+ );
1859
+ adapter.registerStreamRoute("GET", "/api/admin/events", async (ctx, stream) => {
1860
+ const rawTicket = ctx.query.ticket;
1861
+ const ticket = Array.isArray(rawTicket) ? rawTicket[0] : rawTicket;
1862
+ if (!ticket) {
1863
+ stream.send({ event: "error", data: { code: "ticket.missing" } });
1864
+ stream.close();
1865
+ return;
1866
+ }
1867
+ const payload = verify(ticket, ticketSecret);
1868
+ if (!payload) {
1869
+ stream.send({ event: "error", data: { code: "ticket.invalid" } });
1870
+ stream.close();
1871
+ return;
1872
+ }
1873
+ if (Math.floor(Date.now() / 1e3) - payload.iat > TICKET_TTL_SECONDS) {
1874
+ stream.send({ event: "error", data: { code: "ticket.expired" } });
1875
+ stream.close();
1876
+ return;
1877
+ }
1878
+ const nonceKey = `${payload.uid}:${payload.nonce}`;
1879
+ const seen = await documentStore.get(NONCE_COLLECTION, nonceKey);
1880
+ if (seen) {
1881
+ stream.send({ event: "error", data: { code: "ticket.replayed" } });
1882
+ stream.close();
1883
+ return;
1884
+ }
1885
+ await documentStore.put(NONCE_COLLECTION, nonceKey, { seen: true }, { ttl: 120 });
1886
+ if (!payload.roles.includes("admin")) {
1887
+ stream.send({ event: "error", data: { code: "ticket.forbidden" } });
1888
+ stream.close();
1889
+ return;
1890
+ }
1891
+ const subscription = await eventBus.subscribe(
1892
+ ["order.*", "payment.*", "inventory.*", "adapter.*"],
1893
+ (event) => {
1894
+ stream.send({
1895
+ id: event.id,
1896
+ event: event.type,
1897
+ data: { id: event.id, type: event.type, timestamp: event.timestamp, payload: event.payload }
1898
+ });
1899
+ },
1900
+ { name: "sse-admin-events" }
1901
+ );
1902
+ stream.onClose(() => {
1903
+ subscription.unsubscribe().catch(() => void 0);
1904
+ });
1905
+ stream.send({ event: "connected", data: { at: (/* @__PURE__ */ new Date()).toISOString() } });
1906
+ });
1907
+ void fail;
1908
+ }
1909
+
1910
+ // src/routes/webhooks-stripe.ts
1911
+ import {
1912
+ EVT_PAYMENT_FAILED,
1913
+ PaymentError as PaymentError2
1914
+ } from "@nymbalhq/types";
1915
+ var HANDLER = "webhook:stripe";
1916
+ function registerStripeWebhookRoute(adapter, deps) {
1917
+ const { payments, checkout, publisher, store, processedEvents, orders } = deps;
1918
+ async function publishFailed(tx, paymentIntentId, reason) {
1919
+ const order = paymentIntentId ? await orders.findByPaymentIntentId(tx, paymentIntentId) : null;
1920
+ await publisher.publish(EVT_PAYMENT_FAILED, {
1921
+ paymentId: paymentIntentId,
1922
+ orderId: order?.id ?? "",
1923
+ reason
1924
+ });
1925
+ }
1926
+ async function handle(tx, result) {
1927
+ if (result.kind === "payment.captured") {
1928
+ const paymentIntentId = result.paymentIntentId ?? "";
1929
+ if (paymentIntentId === "" || typeof result.amountMinor !== "number" || typeof result.currency !== "string" || result.currency === "") {
1930
+ await publishFailed(tx, paymentIntentId, "malformed_capture");
1931
+ return ok({ finalized: false, reason: "malformed_capture" });
1932
+ }
1933
+ const finalized = await checkout.finalize(paymentIntentId, {
1934
+ amountMinor: result.amountMinor,
1935
+ currency: result.currency
1936
+ });
1937
+ return finalized.finalized ? ok({ finalized: true, orderNumber: finalized.orderNumber }) : ok({ finalized: false, reason: finalized.reason });
1938
+ }
1939
+ if (result.kind === "payment.failed" && result.paymentIntentId) {
1940
+ await publishFailed(tx, result.paymentIntentId, result.reason ?? "unknown");
1941
+ return ok({ failed: true });
1942
+ }
1943
+ return ok({ ignored: true });
1944
+ }
1945
+ adapter.registerRoute(
1946
+ "POST",
1947
+ "/api/webhooks/stripe",
1948
+ async (ctx) => {
1949
+ try {
1950
+ const rawSig = ctx.headers["stripe-signature"];
1951
+ const sig = Array.isArray(rawSig) ? rawSig[0] : rawSig;
1952
+ if (!sig) return fail("webhook.signature_missing", "stripe-signature header missing", 400);
1953
+ if (ctx.rawBody === void 0) {
1954
+ return fail("webhook.raw_body_missing", "the HTTP adapter kept no raw request body", 500);
1955
+ }
1956
+ let result;
1957
+ try {
1958
+ result = await payments.parseWebhook(ctx.rawBody, sig);
1959
+ } catch (err) {
1960
+ if (err instanceof PaymentError2 && err.code === "payment.webhook_invalid") {
1961
+ return fail("webhook.invalid", "signature verification failed", 400);
1962
+ }
1963
+ throw err;
1964
+ }
1965
+ if (!result.verified) return fail("webhook.invalid", "signature verification failed", 400);
1966
+ return await store.transaction(async (tx) => {
1967
+ if (await processedEvents.seen(tx, HANDLER, result.eventId)) return ok({ deduped: true });
1968
+ const answer = await handle(tx, result);
1969
+ await processedEvents.markProcessed(tx, HANDLER, result.eventId, /* @__PURE__ */ new Date());
1970
+ return answer;
1971
+ });
1972
+ } catch (err) {
1973
+ return renderError(err);
1974
+ }
1975
+ },
1976
+ { csrf: false, rawBody: true }
1977
+ );
1978
+ }
1979
+
1980
+ // src/routes/validated-adapter.ts
1981
+ import {
1982
+ ConfigError
1983
+ } from "@nymbalhq/types";
1984
+
1985
+ // src/routes/schemas.ts
1986
+ import { z } from "zod";
1987
+ import { PRODUCT_SORT_KEYS } from "@nymbalhq/types";
1988
+ var id = z.string().min(1);
1989
+ var queryValue = z.union([z.string(), z.array(z.string())]).optional();
1990
+ var paging = { limit: queryValue, offset: queryValue };
1991
+ var idParams = z.object({ id });
1992
+ var orderIdParams = idParams;
1993
+ var variantIdParams = z.object({ variantId: id });
1994
+ var orderNumberParams = z.object({ orderNumber: id });
1995
+ var address = z.object({
1996
+ firstName: z.string(),
1997
+ lastName: z.string(),
1998
+ company: z.string().optional(),
1999
+ addressLine1: z.string().min(1),
2000
+ addressLine2: z.string().optional(),
2001
+ city: z.string(),
2002
+ region: z.string(),
2003
+ postalCode: z.string(),
2004
+ country: z.string(),
2005
+ phone: z.string().optional()
2006
+ });
2007
+ var money = z.number().int();
2008
+ var productStatus = z.enum(["draft", "active", "archived"]);
2009
+ var productMedia = z.object({ url: z.string(), altText: z.string(), position: z.number() });
2010
+ var variantDimensions = z.object({
2011
+ lengthMm: z.number().optional(),
2012
+ widthMm: z.number().optional(),
2013
+ heightMm: z.number().optional()
2014
+ });
2015
+ var productVariant = z.object({
2016
+ id: z.string().optional(),
2017
+ sku: z.string(),
2018
+ name: z.string(),
2019
+ priceMinor: money,
2020
+ compareAtPriceMinor: money.nullable().optional(),
2021
+ weightGrams: z.number().nullable().optional(),
2022
+ dimensions: variantDimensions.nullable().optional(),
2023
+ stock: z.number().int(),
2024
+ lowStockThreshold: z.number().int().optional(),
2025
+ options: z.array(z.object({ name: z.string(), value: z.string() })),
2026
+ status: z.enum(["active", "inactive"]).optional()
2027
+ });
2028
+ var productFields = {
2029
+ description: z.string().optional(),
2030
+ shortDescription: z.string().optional(),
2031
+ status: productStatus.optional(),
2032
+ type: z.enum(["simple", "variable"]).optional(),
2033
+ seoTitle: z.string().optional(),
2034
+ seoDescription: z.string().optional(),
2035
+ media: z.array(productMedia).optional(),
2036
+ metadata: z.record(z.unknown()).optional(),
2037
+ categoryIds: z.array(z.string()).optional()
2038
+ };
2039
+ var noteBody = z.object({ body: z.string().min(1) });
2040
+ var optionalRefresh = z.object({ refreshToken: z.string().optional() }).optional();
2041
+ var routeSchemas = {
2042
+ // Auth
2043
+ "POST /api/auth/register": {
2044
+ name: "auth.register",
2045
+ description: "Create a customer account and sign it in.",
2046
+ body: z.object({
2047
+ email: z.string().min(1),
2048
+ password: z.string().min(1),
2049
+ firstName: z.string().optional(),
2050
+ lastName: z.string().optional(),
2051
+ phone: z.string().optional()
2052
+ })
2053
+ },
2054
+ "POST /api/auth/login": {
2055
+ name: "auth.login",
2056
+ description: "Sign a customer in with email and password.",
2057
+ body: z.object({ email: z.string().min(1), password: z.string().min(1) })
2058
+ },
2059
+ "POST /api/auth/refresh": {
2060
+ name: "auth.refresh",
2061
+ description: "Exchange a refresh token for a new access token.",
2062
+ body: optionalRefresh
2063
+ },
2064
+ "POST /api/auth/logout": {
2065
+ name: "auth.logout",
2066
+ description: "Revoke the refresh token and clear its cookie.",
2067
+ body: optionalRefresh
2068
+ },
2069
+ // Catalogue
2070
+ "GET /api/categories": {
2071
+ name: "categories.list",
2072
+ description: "List the store categories in display order."
2073
+ },
2074
+ "GET /api/products/search": {
2075
+ name: "products.search",
2076
+ description: "Search products by text.",
2077
+ query: z.object({ q: queryValue, limit: queryValue, cursor: queryValue })
2078
+ },
2079
+ "GET /api/products": {
2080
+ name: "products.list",
2081
+ description: "List products, optionally in one category, in one of the sort orders (default newest).",
2082
+ query: z.object({
2083
+ limit: queryValue,
2084
+ cursor: queryValue,
2085
+ category: queryValue,
2086
+ sort: z.enum(PRODUCT_SORT_KEYS).optional()
2087
+ })
2088
+ },
2089
+ "GET /api/products/:slug": {
2090
+ name: "products.get",
2091
+ description: "Get one product by slug.",
2092
+ params: z.object({ slug: id })
2093
+ },
2094
+ "GET /api/products/:id/reviews": {
2095
+ name: "products.reviews",
2096
+ description: "List the published reviews of a product.",
2097
+ params: idParams,
2098
+ query: z.object({ limit: queryValue, cursor: queryValue })
2099
+ },
2100
+ // Cart
2101
+ "GET /api/cart": {
2102
+ name: "cart.get",
2103
+ description: "Get the current cart, creating one when there is none."
2104
+ },
2105
+ "POST /api/cart/items": {
2106
+ name: "cart.addItem",
2107
+ description: "Add a quantity of a variant to the cart.",
2108
+ body: z.object({ variantId: id, qty: z.number().int().positive().optional() })
2109
+ },
2110
+ "PATCH /api/cart/items/:variantId": {
2111
+ name: "cart.updateItem",
2112
+ description: "Set the quantity of a variant in the cart.",
2113
+ params: variantIdParams,
2114
+ body: z.object({ qty: z.number().int().nonnegative() })
2115
+ },
2116
+ "DELETE /api/cart/items/:variantId": {
2117
+ name: "cart.removeItem",
2118
+ description: "Remove a variant from the cart.",
2119
+ params: variantIdParams
2120
+ },
2121
+ "DELETE /api/cart": {
2122
+ name: "cart.clear",
2123
+ description: "Remove every item from the cart."
2124
+ },
2125
+ // Checkout and orders
2126
+ "POST /api/checkout": {
2127
+ name: "checkout.begin",
2128
+ description: "Place an order for the cart and start its payment.",
2129
+ body: z.object({
2130
+ email: z.string().min(1),
2131
+ billingAddress: address,
2132
+ shippingAddress: address,
2133
+ notes: z.string().optional()
2134
+ })
2135
+ },
2136
+ "GET /api/orders": {
2137
+ name: "orders.list",
2138
+ description: "List the signed-in customer's orders."
2139
+ },
2140
+ "GET /api/orders/:orderNumber": {
2141
+ name: "orders.get",
2142
+ description: "Get one order by its number.",
2143
+ params: orderNumberParams
2144
+ },
2145
+ // Customer account
2146
+ "GET /api/customers/me": {
2147
+ name: "customers.me",
2148
+ description: "Get the signed-in customer's profile."
2149
+ },
2150
+ "PATCH /api/customers/me": {
2151
+ name: "customers.updateMe",
2152
+ description: "Update the signed-in customer's name or phone.",
2153
+ body: z.object({
2154
+ firstName: z.string().optional(),
2155
+ lastName: z.string().optional(),
2156
+ phone: z.string().optional()
2157
+ })
2158
+ },
2159
+ "GET /api/customers/me/addresses": {
2160
+ name: "customers.addresses",
2161
+ description: "List the signed-in customer's saved addresses."
2162
+ },
2163
+ "POST /api/customers/me/addresses": {
2164
+ name: "customers.addAddress",
2165
+ description: "Save an address for the signed-in customer.",
2166
+ body: address.extend({
2167
+ isDefaultBilling: z.boolean().optional(),
2168
+ isDefaultShipping: z.boolean().optional()
2169
+ })
2170
+ },
2171
+ "DELETE /api/customers/me/addresses/:id": {
2172
+ name: "customers.removeAddress",
2173
+ description: "Delete one of the signed-in customer's saved addresses.",
2174
+ params: idParams
2175
+ },
2176
+ // Admin: session and overview
2177
+ "GET /api/admin/me": {
2178
+ name: "admin.me",
2179
+ description: "Get the signed-in admin and the store it administers."
2180
+ },
2181
+ "GET /api/admin/dashboard": {
2182
+ name: "admin.dashboard",
2183
+ description: "Today's order figures, recent orders and low stock."
2184
+ },
2185
+ "GET /api/admin/search": {
2186
+ name: "admin.search",
2187
+ description: "Search orders, products and customers.",
2188
+ query: z.object({ q: queryValue })
2189
+ },
2190
+ // Admin: orders
2191
+ "GET /api/admin/orders": {
2192
+ name: "admin.orders.list",
2193
+ description: "List orders, filtered by status, date, customer or text.",
2194
+ query: z.object({
2195
+ status: queryValue,
2196
+ dateFrom: queryValue,
2197
+ dateTo: queryValue,
2198
+ customer: queryValue,
2199
+ q: queryValue,
2200
+ ...paging
2201
+ })
2202
+ },
2203
+ "GET /api/admin/orders/:orderNumber": {
2204
+ name: "admin.orders.get",
2205
+ description: "Get one order with its status history.",
2206
+ params: orderNumberParams
2207
+ },
2208
+ "POST /api/admin/orders/:id/confirm": {
2209
+ name: "admin.orders.confirm",
2210
+ description: "Move a paid order to confirmed; an unpaid order is refused (record its payment instead).",
2211
+ params: orderIdParams
2212
+ },
2213
+ "POST /api/admin/orders/:id/process": {
2214
+ name: "admin.orders.process",
2215
+ description: "Move a confirmed order to processing.",
2216
+ params: orderIdParams
2217
+ },
2218
+ "POST /api/admin/orders/:id/cancel": {
2219
+ name: "admin.orders.cancel",
2220
+ description: "Cancel an order nothing was captured for, with an optional reason.",
2221
+ params: orderIdParams,
2222
+ body: z.object({ reason: z.string().optional() }).optional()
2223
+ },
2224
+ "POST /api/admin/orders/:id/mark-paid": {
2225
+ name: "admin.orders.markPaid",
2226
+ description: "Record a payment of the order's total taken outside the store, confirming the order.",
2227
+ params: orderIdParams,
2228
+ body: z.object({ note: z.string().optional() }).optional()
2229
+ },
2230
+ "POST /api/admin/orders/:id/mark-delivered": {
2231
+ name: "admin.orders.markDelivered",
2232
+ description: "Move a shipped order to delivered.",
2233
+ params: orderIdParams
2234
+ },
2235
+ "POST /api/admin/orders/:id/ship": {
2236
+ name: "admin.orders.ship",
2237
+ description: "Mark an order shipped with its tracking number and carrier.",
2238
+ params: orderIdParams,
2239
+ body: z.object({ trackingNumber: z.string().min(1), carrier: z.string().min(1) })
2240
+ },
2241
+ "POST /api/admin/orders/:id/refund": {
2242
+ name: "admin.orders.refund",
2243
+ description: "Refund part or all of what an order captured, through the payments provider. A repeated requestId answers the refund it made and moves no money.",
2244
+ params: orderIdParams,
2245
+ body: z.object({
2246
+ amountMinor: money.positive(),
2247
+ reason: z.string().optional(),
2248
+ requestId: z.string().uuid().optional()
2249
+ })
2250
+ },
2251
+ "GET /api/admin/orders/:id/timeline": {
2252
+ name: "admin.orders.timeline",
2253
+ description: "List an order's status history.",
2254
+ params: orderIdParams
2255
+ },
2256
+ "GET /api/admin/orders/:id/notes": {
2257
+ name: "admin.orders.notes",
2258
+ description: "List an order's internal notes.",
2259
+ params: orderIdParams
2260
+ },
2261
+ "POST /api/admin/orders/:id/notes": {
2262
+ name: "admin.orders.addNote",
2263
+ description: "Add an internal note to an order.",
2264
+ params: orderIdParams,
2265
+ body: noteBody
2266
+ },
2267
+ "POST /api/admin/orders/:id/resend-confirmation": {
2268
+ name: "admin.orders.resendConfirmation",
2269
+ description: "Resend an order's confirmation email.",
2270
+ params: orderIdParams
2271
+ },
2272
+ // Admin: products
2273
+ "GET /api/admin/products": {
2274
+ name: "admin.products.list",
2275
+ description: "List products, filtered by status or name.",
2276
+ query: z.object({ status: queryValue, q: queryValue, ...paging })
2277
+ },
2278
+ "POST /api/admin/products": {
2279
+ name: "admin.products.create",
2280
+ description: "Create a product with its variants.",
2281
+ body: z.object({
2282
+ slug: z.string().min(1),
2283
+ name: z.string().min(1),
2284
+ ...productFields,
2285
+ variants: z.array(productVariant).optional()
2286
+ })
2287
+ },
2288
+ "PATCH /api/admin/products/:id": {
2289
+ name: "admin.products.update",
2290
+ description: "Update a product's fields.",
2291
+ params: idParams,
2292
+ body: z.object({ name: z.string().optional(), ...productFields })
2293
+ },
2294
+ "DELETE /api/admin/products/:id": {
2295
+ name: "admin.products.delete",
2296
+ description: "Delete a product.",
2297
+ params: idParams
2298
+ },
2299
+ "POST /api/admin/products/:id/publish": {
2300
+ name: "admin.products.publish",
2301
+ description: "Make a product visible in the store.",
2302
+ params: idParams
2303
+ },
2304
+ "POST /api/admin/products/:id/unpublish": {
2305
+ name: "admin.products.unpublish",
2306
+ description: "Hide a product from the store.",
2307
+ params: idParams
2308
+ },
2309
+ "POST /api/admin/products/:id/archive": {
2310
+ name: "admin.products.archive",
2311
+ description: "Archive a product.",
2312
+ params: idParams
2313
+ },
2314
+ "POST /api/admin/products/:id/duplicate": {
2315
+ name: "admin.products.duplicate",
2316
+ description: "Copy a product as a draft.",
2317
+ params: idParams
2318
+ },
2319
+ "POST /api/admin/products/:id/enhance": {
2320
+ name: "admin.products.enhance",
2321
+ description: "Suggest copy for one product field with the AI adapter.",
2322
+ params: idParams,
2323
+ body: z.object({
2324
+ field: z.enum(["description", "alt", "title", "seoDescription"]),
2325
+ context: z.record(z.string()).optional()
2326
+ })
2327
+ },
2328
+ // Admin: inventory
2329
+ "GET /api/admin/inventory": {
2330
+ name: "admin.inventory.list",
2331
+ description: "List variant stock, most urgent first.",
2332
+ query: z.object({ status: queryValue, q: queryValue, ...paging })
2333
+ },
2334
+ "POST /api/admin/inventory/:variantId/adjust": {
2335
+ name: "admin.inventory.adjust",
2336
+ description: "Adjust a variant's stock by a delta, with a reason.",
2337
+ params: variantIdParams,
2338
+ body: z.object({
2339
+ delta: z.number().int(),
2340
+ reason: z.enum([
2341
+ "sale",
2342
+ "return",
2343
+ "manual",
2344
+ "import",
2345
+ "reservation-commit",
2346
+ "reservation-release"
2347
+ ])
2348
+ })
2349
+ },
2350
+ "GET /api/admin/inventory/:variantId/history": {
2351
+ name: "admin.inventory.history",
2352
+ description: "List a variant's stock adjustments.",
2353
+ params: variantIdParams
2354
+ },
2355
+ // Admin: customers
2356
+ "GET /api/admin/customers": {
2357
+ name: "admin.customers.list",
2358
+ description: "List customers who have ordered, filtered and sorted.",
2359
+ query: z.object({ q: queryValue, sort: queryValue, ...paging })
2360
+ },
2361
+ "GET /api/admin/customers/:id": {
2362
+ name: "admin.customers.get",
2363
+ description: "Get one customer with order statistics.",
2364
+ params: idParams
2365
+ },
2366
+ "GET /api/admin/customers/:id/orders": {
2367
+ name: "admin.customers.orders",
2368
+ description: "List one customer's orders.",
2369
+ params: idParams
2370
+ },
2371
+ "POST /api/admin/customers/:id/notes": {
2372
+ name: "admin.customers.addNote",
2373
+ description: "Add an internal note to a customer.",
2374
+ params: idParams,
2375
+ body: noteBody
2376
+ },
2377
+ // Admin: reviews
2378
+ "GET /api/admin/reviews": {
2379
+ name: "admin.reviews.pending",
2380
+ description: "List reviews awaiting moderation."
2381
+ },
2382
+ "POST /api/admin/reviews/:id/moderate": {
2383
+ name: "admin.reviews.moderate",
2384
+ description: "Approve or reject a review.",
2385
+ params: idParams,
2386
+ body: z.object({ status: z.enum(["approved", "rejected"]) })
2387
+ },
2388
+ // Admin: live events
2389
+ "POST /api/admin/events/ticket": {
2390
+ name: "admin.events.ticket",
2391
+ description: "Issue a one-time ticket for the admin event stream."
2392
+ },
2393
+ "GET /api/admin/events": {
2394
+ name: "admin.events.stream",
2395
+ description: "Stream order, payment and inventory events to the admin (SSE).",
2396
+ query: z.object({ ticket: queryValue })
2397
+ },
2398
+ // Webhooks
2399
+ "POST /api/webhooks/stripe": {
2400
+ name: "webhooks.stripe",
2401
+ description: "Receive a signed Stripe event.",
2402
+ // The signature covers the raw bytes, and a malformed event is answered by the handler itself.
2403
+ body: z.unknown()
2404
+ },
2405
+ // The admin SPA, served when attachHttp is given an admin root (or NYMBAL_ADMIN_DIST is set)
2406
+ "GET /admin": {
2407
+ name: "admin.app.redirect",
2408
+ description: "Redirect to the admin app."
2409
+ },
2410
+ "GET /admin/": {
2411
+ name: "admin.app.index",
2412
+ description: "Serve the admin app."
2413
+ },
2414
+ "GET /admin/*": {
2415
+ name: "admin.app.asset",
2416
+ description: "Serve the admin app and its assets.",
2417
+ params: z.object({ "*": z.string().optional() })
2418
+ }
2419
+ };
2420
+
2421
+ // src/routes/validated-adapter.ts
2422
+ var PARTS = ["params", "query", "body"];
2423
+ function schemaFor(method, path) {
2424
+ const key = `${method} ${path}`;
2425
+ const schema = routeSchemas[key];
2426
+ if (!schema) {
2427
+ throw new ConfigError(`route ${key} has no entry in routeSchemas`, {
2428
+ code: "config.route_schema_missing",
2429
+ context: { method, path }
2430
+ });
2431
+ }
2432
+ return schema;
2433
+ }
2434
+ function parseInput(schema, ctx) {
2435
+ const issues = [];
2436
+ const parsed = {
2437
+ params: {},
2438
+ query: {},
2439
+ body: void 0
2440
+ };
2441
+ for (const part of PARTS) {
2442
+ const partSchema = schema[part];
2443
+ if (!partSchema) continue;
2444
+ const result = partSchema.safeParse(ctx[part]);
2445
+ if (result.success) parsed[part] = result.data;
2446
+ else {
2447
+ for (const issue of result.error.issues) {
2448
+ issues.push({ path: [part, ...issue.path], message: issue.message });
2449
+ }
2450
+ }
2451
+ }
2452
+ if (issues.length > 0) return { ok: false, issues };
2453
+ return {
2454
+ ok: true,
2455
+ ctx: {
2456
+ ...ctx,
2457
+ params: parsed.params,
2458
+ query: parsed.query,
2459
+ body: parsed.body
2460
+ }
2461
+ };
2462
+ }
2463
+ function invalid(issues) {
2464
+ return fail("validation.failed", "The request input is invalid", 400, { issues });
2465
+ }
2466
+ function validatedAdapter(adapter) {
2467
+ return {
2468
+ registerRoute(method, path, handler, options) {
2469
+ const schema = schemaFor(method, path);
2470
+ adapter.registerRoute(
2471
+ method,
2472
+ path,
2473
+ async (ctx) => {
2474
+ const input = parseInput(schema, ctx);
2475
+ return input.ok ? handler(input.ctx) : invalid(input.issues);
2476
+ },
2477
+ options
2478
+ );
2479
+ },
2480
+ registerStreamRoute(method, path, handler, options) {
2481
+ const schema = schemaFor(method, path);
2482
+ adapter.registerStreamRoute(
2483
+ method,
2484
+ path,
2485
+ async (ctx, stream) => {
2486
+ const input = parseInput(schema, ctx);
2487
+ if (input.ok) return handler(input.ctx, stream);
2488
+ stream.send({ event: "error", data: { code: "validation.failed", issues: input.issues } });
2489
+ stream.close();
2490
+ },
2491
+ options
2492
+ );
2493
+ },
2494
+ registerMiddleware(middleware) {
2495
+ adapter.registerMiddleware(middleware);
2496
+ },
2497
+ setDefaultHeaders(headers) {
2498
+ adapter.setDefaultHeaders(headers);
2499
+ },
2500
+ start(port, host) {
2501
+ return adapter.start(port, host);
2502
+ },
2503
+ stop() {
2504
+ return adapter.stop();
2505
+ }
2506
+ };
2507
+ }
2508
+
2509
+ // src/routes/register.ts
2510
+ function registerAllRoutes(deps) {
2511
+ const { config, services, adapters, repos, documentStore, eventBus, publisher } = deps;
2512
+ const adapter = validatedAdapter(deps.adapter);
2513
+ adapter.registerMiddleware(createAuthMiddleware(services.auth));
2514
+ registerAuthRoutes(adapter, services.auth);
2515
+ registerCategoryRoutes(adapter, documentStore, config.store.name);
2516
+ registerProductRoutes(
2517
+ adapter,
2518
+ documentStore,
2519
+ config.store.name,
2520
+ adapters.reviews,
2521
+ services.search
2522
+ );
2523
+ registerCartRoutes(adapter, {
2524
+ cart: services.cart,
2525
+ productRepo: repos.product,
2526
+ variantRepo: repos.variant
2527
+ });
2528
+ registerCheckoutRoutes(adapter, services.checkout);
2529
+ registerOrderRoutes(adapter, services.order, documentStore);
2530
+ registerCustomerRoutes(adapter, services.customer);
2531
+ registerAdminRoutes(adapter, {
2532
+ config,
2533
+ services,
2534
+ repos,
2535
+ adapters
2536
+ });
2537
+ registerEventsSseRoute(adapter, {
2538
+ eventBus,
2539
+ documentStore,
2540
+ ticketSecret: config.security.auth.jwtSecret
2541
+ });
2542
+ registerStripeWebhookRoute(adapter, {
2543
+ payments: adapters.payments,
2544
+ checkout: services.checkout,
2545
+ publisher,
2546
+ store: repos.processedEvent.store,
2547
+ processedEvents: repos.processedEvent,
2548
+ orders: repos.order
2549
+ });
2550
+ if (deps.adminRoot !== void 0) registerAdminStaticRoute(adapter, deps.adminRoot);
2551
+ }
2552
+
2553
+ export {
2554
+ formatMoney,
2555
+ PROVIDER_REFUND_REASON,
2556
+ orderPayment,
2557
+ settlementOf,
2558
+ refreshRefunded,
2559
+ createRefundService,
2560
+ registerAdminStaticRoute,
2561
+ routeSchemas,
2562
+ registerAllRoutes
2563
+ };
2564
+ //# sourceMappingURL=chunk-74KODSYJ.js.map