@nymbalhq/platform 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chunk-74KODSYJ.js +2564 -0
- package/dist/chunk-74KODSYJ.js.map +1 -0
- package/dist/chunk-PW6ILJF7.js +740 -0
- package/dist/chunk-PW6ILJF7.js.map +1 -0
- package/dist/chunk-PZ5AY32C.js +10 -0
- package/dist/chunk-PZ5AY32C.js.map +1 -0
- package/dist/db/schema/index.d.ts +10125 -0
- package/dist/db/schema/index.js +10 -0
- package/dist/db/schema/index.js.map +1 -0
- package/dist/index.d.ts +1372 -0
- package/dist/index.js +6652 -0
- package/dist/index.js.map +1 -0
- package/dist/register-IVFYBJSX.js +8 -0
- package/dist/register-IVFYBJSX.js.map +1 -0
- package/drizzle.config.postgres.ts +10 -0
- package/drizzle.config.sqlite.ts +10 -0
- package/migrations/postgres/0000_tearful_longshot.sql +166 -0
- package/migrations/postgres/0001_neat_zeigeist.sql +1 -0
- package/migrations/postgres/0002_sloppy_mad_thinker.sql +20 -0
- package/migrations/postgres/0003_violet_violations.sql +15 -0
- package/migrations/postgres/0004_white_reavers.sql +15 -0
- package/migrations/postgres/0005_natural_nico_minoru.sql +12 -0
- package/migrations/postgres/0006_minor_dragon_man.sql +28 -0
- package/migrations/postgres/meta/0000_snapshot.json +1172 -0
- package/migrations/postgres/meta/0001_snapshot.json +1187 -0
- package/migrations/postgres/meta/0002_snapshot.json +1325 -0
- package/migrations/postgres/meta/0003_snapshot.json +1432 -0
- package/migrations/postgres/meta/0004_snapshot.json +1439 -0
- package/migrations/postgres/meta/0005_snapshot.json +1461 -0
- package/migrations/postgres/meta/0006_snapshot.json +1598 -0
- package/migrations/postgres/meta/_journal.json +55 -0
- package/migrations/sqlite/0000_glorious_vision.sql +166 -0
- package/migrations/sqlite/0001_tricky_glorian.sql +1 -0
- package/migrations/sqlite/0002_puzzling_epoch.sql +19 -0
- package/migrations/sqlite/0003_fantastic_captain_stacy.sql +15 -0
- package/migrations/sqlite/0004_remarkable_shriek.sql +12 -0
- package/migrations/sqlite/0005_conscious_speedball.sql +13 -0
- package/migrations/sqlite/0006_flat_psylocke.sql +28 -0
- package/migrations/sqlite/meta/0000_snapshot.json +1093 -0
- package/migrations/sqlite/meta/0001_snapshot.json +1100 -0
- package/migrations/sqlite/meta/0002_snapshot.json +1222 -0
- package/migrations/sqlite/meta/0003_snapshot.json +1320 -0
- package/migrations/sqlite/meta/0004_snapshot.json +1328 -0
- package/migrations/sqlite/meta/0005_snapshot.json +1343 -0
- package/migrations/sqlite/meta/0006_snapshot.json +1470 -0
- package/migrations/sqlite/meta/_journal.json +55 -0
- package/package.json +69 -3
- 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
|