@airoom/nextmin-node 0.1.8 → 1.1.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/api/apiRouter.d.ts +2 -0
- package/dist/api/apiRouter.js +10 -0
- package/dist/api/router/mountCrudRoutes.js +31 -26
- package/dist/api/router/mountSearchRoutes.d.ts +2 -0
- package/dist/api/router/mountSearchRoutes.js +197 -0
- package/dist/api/router/setupFileRoutes.d.ts +1 -1
- package/dist/api/router/setupFileRoutes.js +66 -13
- package/dist/api/router/utils.d.ts +7 -6
- package/dist/api/router/utils.js +58 -19
- package/package.json +1 -1
package/dist/api/apiRouter.d.ts
CHANGED
|
@@ -16,6 +16,7 @@ export declare class APIRouter {
|
|
|
16
16
|
private schemaLoader;
|
|
17
17
|
private isDevelopment;
|
|
18
18
|
private findRoutesMounted;
|
|
19
|
+
private searchRoutesMounted;
|
|
19
20
|
private authRoutesInitialized;
|
|
20
21
|
private schemasRouteRegistered;
|
|
21
22
|
private registeredModels;
|
|
@@ -33,6 +34,7 @@ export declare class APIRouter {
|
|
|
33
34
|
private mountSchemasEndpointOnce;
|
|
34
35
|
private mountRoutes;
|
|
35
36
|
private mountFindRoutes;
|
|
37
|
+
private mountSearchRoutes;
|
|
36
38
|
private mountFileRoutes;
|
|
37
39
|
private createCtx;
|
|
38
40
|
private getSchema;
|
package/dist/api/apiRouter.js
CHANGED
|
@@ -16,10 +16,12 @@ const setupFileRoutes_1 = require("./router/setupFileRoutes");
|
|
|
16
16
|
const setupAuthRoutes_1 = require("./router/setupAuthRoutes");
|
|
17
17
|
const mountCrudRoutes_1 = require("./router/mountCrudRoutes");
|
|
18
18
|
const mountFindRoutes_1 = require("./router/mountFindRoutes");
|
|
19
|
+
const mountSearchRoutes_1 = require("./router/mountSearchRoutes");
|
|
19
20
|
class APIRouter {
|
|
20
21
|
constructor(options) {
|
|
21
22
|
this.models = {};
|
|
22
23
|
this.findRoutesMounted = false;
|
|
24
|
+
this.searchRoutesMounted = false;
|
|
23
25
|
this.authRoutesInitialized = false;
|
|
24
26
|
this.schemasRouteRegistered = false;
|
|
25
27
|
this.registeredModels = new Set();
|
|
@@ -196,6 +198,7 @@ class APIRouter {
|
|
|
196
198
|
this.mountSchemasEndpointOnce();
|
|
197
199
|
this.mountRoutes(initialSchemas);
|
|
198
200
|
this.mountFindRoutes();
|
|
201
|
+
this.mountSearchRoutes();
|
|
199
202
|
this.mountFileRoutes();
|
|
200
203
|
await this.syncAllIndexes(initialSchemas);
|
|
201
204
|
const initializer = new DefaultDataInitializer_1.DefaultDataInitializer(this.dbAdapter, this.models);
|
|
@@ -334,6 +337,13 @@ class APIRouter {
|
|
|
334
337
|
(0, mountFindRoutes_1.mountFindRoutes)(this.createCtx());
|
|
335
338
|
this.ensureNotFoundLast();
|
|
336
339
|
}
|
|
340
|
+
mountSearchRoutes() {
|
|
341
|
+
if (this.searchRoutesMounted)
|
|
342
|
+
return;
|
|
343
|
+
this.searchRoutesMounted = true;
|
|
344
|
+
(0, mountSearchRoutes_1.mountSearchRoutes)(this.createCtx());
|
|
345
|
+
this.ensureNotFoundLast();
|
|
346
|
+
}
|
|
337
347
|
mountFileRoutes() {
|
|
338
348
|
if (this.fileRoutesMounted)
|
|
339
349
|
return;
|
|
@@ -213,21 +213,7 @@ function mountCrudRoutes(ctx, modelNameLC) {
|
|
|
213
213
|
const sortRaw = String(req.query.sort ?? '').trim();
|
|
214
214
|
const sortTypeRaw = String(req.query.sortType ?? '').trim();
|
|
215
215
|
const filter = {};
|
|
216
|
-
|
|
217
|
-
const keys = (0, utils_1.splitCSV)(sortRaw);
|
|
218
|
-
const dirs = (0, utils_1.splitCSV)(sortTypeRaw);
|
|
219
|
-
const s = {};
|
|
220
|
-
keys.forEach((k, i) => {
|
|
221
|
-
if (!schema.attributes?.[k])
|
|
222
|
-
return;
|
|
223
|
-
const d = dirs[i] ?? dirs[dirs.length - 1] ?? 'desc';
|
|
224
|
-
s[k] = /^(desc|-1)$/i.test(d) ? -1 : 1;
|
|
225
|
-
});
|
|
226
|
-
if (!Object.keys(s).length)
|
|
227
|
-
s.createdAt = -1;
|
|
228
|
-
return s;
|
|
229
|
-
};
|
|
230
|
-
// SEARCH
|
|
216
|
+
// ---------- SEARCH ----------
|
|
231
217
|
const ors = [];
|
|
232
218
|
const ands = [];
|
|
233
219
|
if (q && searchKey && schema.attributes?.[searchKey]) {
|
|
@@ -277,7 +263,7 @@ function mountCrudRoutes(ctx, modelNameLC) {
|
|
|
277
263
|
filter[dateKey] = range;
|
|
278
264
|
}
|
|
279
265
|
}
|
|
280
|
-
//
|
|
266
|
+
// ---------- Direct field filters from req.query ----------
|
|
281
267
|
const RESERVED = new Set([
|
|
282
268
|
'page',
|
|
283
269
|
'limit',
|
|
@@ -354,21 +340,23 @@ function mountCrudRoutes(ctx, modelNameLC) {
|
|
|
354
340
|
if (!rdec.allow) {
|
|
355
341
|
return res.status(403).json({ error: true, message: 'forbidden' });
|
|
356
342
|
}
|
|
357
|
-
|
|
343
|
+
// ---------- SORT (updated; type-safe; honors client; allows virtual keys) ----------
|
|
344
|
+
const baseKeys = new Set(Object.keys(schema.attributes || {}));
|
|
345
|
+
const baseSort = (0, utils_1.parseSort)(sortRaw, sortTypeRaw, baseKeys);
|
|
358
346
|
const finalFilter = (0, authorize_1.andFilter)(filter, rdec.queryFilter || {});
|
|
359
347
|
const isUsersModel = modelNameLC === 'user' ||
|
|
360
348
|
modelNameLC === 'users' ||
|
|
361
349
|
(schema?.modelName ?? '').toLowerCase() === 'users';
|
|
362
350
|
const currentUserId = req?.user?.id;
|
|
363
|
-
// EXTENDED PATH
|
|
351
|
+
// ---------- EXTENDED PATH ----------
|
|
364
352
|
if (schema.extends) {
|
|
365
353
|
const baseName = String(schema.extends);
|
|
366
354
|
const baseLC = baseName.toLowerCase();
|
|
367
355
|
const baseSchema = ctx.getSchema(baseLC);
|
|
368
356
|
const baseModel = ctx.getModel(baseLC);
|
|
369
|
-
const
|
|
370
|
-
const { child: childFilter, base: extBaseFilter } = (0, utils_1.splitFilterForExtended)(finalFilter,
|
|
371
|
-
const { child: childSort, base: extBaseSort } = (0, utils_1.splitSortForExtended)(baseSort,
|
|
357
|
+
const baseAttrKeys = new Set(Object.keys(baseSchema.attributes || {}));
|
|
358
|
+
const { child: childFilter, base: extBaseFilter } = (0, utils_1.splitFilterForExtended)(finalFilter, baseAttrKeys);
|
|
359
|
+
const { child: childSort, base: extBaseSort } = (0, utils_1.splitSortForExtended)(baseSort, baseAttrKeys);
|
|
372
360
|
const needPrivateForHydrate = true;
|
|
373
361
|
const requiresBaseProcessing = Object.keys(extBaseFilter).length > 0 ||
|
|
374
362
|
Object.keys(extBaseSort).length > 0;
|
|
@@ -407,7 +395,26 @@ function mountCrudRoutes(ctx, modelNameLC) {
|
|
|
407
395
|
}
|
|
408
396
|
const combinedSort = { ...extBaseSort, ...childSort };
|
|
409
397
|
if (Object.keys(combinedSort).length) {
|
|
410
|
-
merged = (
|
|
398
|
+
merged = merged.sort((a, b) => {
|
|
399
|
+
for (const k of Object.keys(combinedSort)) {
|
|
400
|
+
const dir = combinedSort[k];
|
|
401
|
+
const av = a?.[k];
|
|
402
|
+
const bv = b?.[k];
|
|
403
|
+
const ax = av instanceof Date ? +av : av;
|
|
404
|
+
const bx = bv instanceof Date ? +bv : bv;
|
|
405
|
+
if (ax == null && bx == null)
|
|
406
|
+
continue;
|
|
407
|
+
if (ax == null)
|
|
408
|
+
return 1 * dir;
|
|
409
|
+
if (bx == null)
|
|
410
|
+
return -1 * dir;
|
|
411
|
+
if (ax > bx)
|
|
412
|
+
return 1 * dir;
|
|
413
|
+
if (ax < bx)
|
|
414
|
+
return -1 * dir;
|
|
415
|
+
}
|
|
416
|
+
return 0;
|
|
417
|
+
});
|
|
411
418
|
}
|
|
412
419
|
// **dedupe**: one row per base entity
|
|
413
420
|
merged = (0, utils_1.dedupeBy)(merged, (r) => String(r.exId || r.id || r._id || ''));
|
|
@@ -423,9 +430,7 @@ function mountCrudRoutes(ctx, modelNameLC) {
|
|
|
423
430
|
paged = merged.slice(start, start + limit);
|
|
424
431
|
}
|
|
425
432
|
else {
|
|
426
|
-
|
|
427
|
-
// totalRows = await (model as any).countDistinct?.('baseId', childFilter) ?? merged.length;
|
|
428
|
-
totalRows = merged.length; // fallback keeps UI consistent with deduped list
|
|
433
|
+
totalRows = merged.length;
|
|
429
434
|
paged = merged.slice(0, limit);
|
|
430
435
|
}
|
|
431
436
|
const data = rdec.exposePrivate
|
|
@@ -439,7 +444,7 @@ function mountCrudRoutes(ctx, modelNameLC) {
|
|
|
439
444
|
sort: combinedSort,
|
|
440
445
|
});
|
|
441
446
|
}
|
|
442
|
-
// NON-EXTENDED
|
|
447
|
+
// ---------- NON-EXTENDED ----------
|
|
443
448
|
const sort = baseSort;
|
|
444
449
|
const totalRows = await model.count(finalFilter);
|
|
445
450
|
let rawRows = [];
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.mountSearchRoutes = mountSearchRoutes;
|
|
4
|
+
const utils_1 = require("./utils");
|
|
5
|
+
function mountSearchRoutes(ctx) {
|
|
6
|
+
const { router } = ctx;
|
|
7
|
+
router.post('/search', ctx.optionalAuthMiddleware, async (req, res) => {
|
|
8
|
+
try {
|
|
9
|
+
const body = (0, utils_1.isPlainObject)(req.body)
|
|
10
|
+
? req.body
|
|
11
|
+
: {};
|
|
12
|
+
const text = typeof body.text === 'string' ? body.text.trim() : '';
|
|
13
|
+
const models = Array.isArray(body.models) ? body.models : [];
|
|
14
|
+
const fields = Array.isArray(body.fields) ? body.fields : [];
|
|
15
|
+
const select = (0, utils_1.isPlainObject)(body.select)
|
|
16
|
+
? body.select
|
|
17
|
+
: {};
|
|
18
|
+
const perModelLimit = typeof body.limit === 'number' && body.limit > 0
|
|
19
|
+
? Math.floor(body.limit)
|
|
20
|
+
: 10;
|
|
21
|
+
if (!text || text.length < 2) {
|
|
22
|
+
return res
|
|
23
|
+
.status(400)
|
|
24
|
+
.json({
|
|
25
|
+
error: true,
|
|
26
|
+
message: 'text must be at least 2 characters',
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
if (!models.length) {
|
|
30
|
+
return res
|
|
31
|
+
.status(400)
|
|
32
|
+
.json({
|
|
33
|
+
error: true,
|
|
34
|
+
message: 'models must be a non-empty string array',
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
if (!fields.length) {
|
|
38
|
+
return res
|
|
39
|
+
.status(400)
|
|
40
|
+
.json({
|
|
41
|
+
error: true,
|
|
42
|
+
message: 'fields must be a non-empty string array',
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
// 🚫 Block sensitive/non-searchable models
|
|
46
|
+
const forbidden = new Set(['users', 'roles', 'settings']);
|
|
47
|
+
const modelsLC = models.map((m) => String(m || '').toLowerCase());
|
|
48
|
+
const forbiddenInRequest = modelsLC.filter((m) => forbidden.has(m));
|
|
49
|
+
if (forbiddenInRequest.length) {
|
|
50
|
+
// build an empty results shape for client predictability
|
|
51
|
+
const emptyResults = Object.fromEntries(modelsLC.map((m) => [m, []]));
|
|
52
|
+
return res.status(400).json({
|
|
53
|
+
error: true,
|
|
54
|
+
message: `These models are not searchable: ${forbiddenInRequest.join(', ')}`,
|
|
55
|
+
data: emptyResults,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
const adapterAny = ctx.dbAdapter;
|
|
59
|
+
const results = {};
|
|
60
|
+
const escapeRegex = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
61
|
+
const textEsc = escapeRegex(text);
|
|
62
|
+
const buildOrFilter = (flds) => {
|
|
63
|
+
const orClauses = [];
|
|
64
|
+
for (const f of flds) {
|
|
65
|
+
orClauses.push({ [f]: { $regex: `^${textEsc}`, $options: 'i' } });
|
|
66
|
+
orClauses.push({ [f]: { $regex: textEsc, $options: 'i' } });
|
|
67
|
+
}
|
|
68
|
+
return { $or: orClauses };
|
|
69
|
+
};
|
|
70
|
+
const getValuesForPath = (row, path) => {
|
|
71
|
+
const parts = path.split('.');
|
|
72
|
+
const walk = (node, i) => {
|
|
73
|
+
if (node == null)
|
|
74
|
+
return [];
|
|
75
|
+
if (i >= parts.length)
|
|
76
|
+
return Array.isArray(node) ? node : [node];
|
|
77
|
+
const key = parts[i];
|
|
78
|
+
if (Array.isArray(node)) {
|
|
79
|
+
const out = [];
|
|
80
|
+
for (const el of node)
|
|
81
|
+
out.push(...walk(el?.[key], i + 1));
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
return walk(node[key], i + 1);
|
|
85
|
+
};
|
|
86
|
+
return walk(row, 0).filter((v) => v !== undefined && v !== null);
|
|
87
|
+
};
|
|
88
|
+
for (const rawName of models) {
|
|
89
|
+
const modelNameLC = String(rawName || '').toLowerCase();
|
|
90
|
+
let items = [];
|
|
91
|
+
try {
|
|
92
|
+
const schema = ctx.getSchema(modelNameLC);
|
|
93
|
+
const extendsUsers = String(schema?.extends || '').toLowerCase() === 'users';
|
|
94
|
+
// Projection with baseId if extends Users
|
|
95
|
+
const projection = Array.isArray(select?.[modelNameLC])
|
|
96
|
+
? Object.fromEntries(select[modelNameLC].map((k) => [k, 1]))
|
|
97
|
+
: undefined;
|
|
98
|
+
if (extendsUsers && projection)
|
|
99
|
+
projection['baseId'] = 1;
|
|
100
|
+
const filter = buildOrFilter(fields);
|
|
101
|
+
const useAdapter = typeof adapterAny.findMany === 'function' &&
|
|
102
|
+
typeof schema?.modelName === 'string';
|
|
103
|
+
if (useAdapter) {
|
|
104
|
+
items = await adapterAny.findMany(schema.modelName, filter, {
|
|
105
|
+
sort: undefined,
|
|
106
|
+
skip: 0,
|
|
107
|
+
limit: undefined,
|
|
108
|
+
projection,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
const all = await ctx.getModel(modelNameLC).read({}, 0, 0, true);
|
|
113
|
+
const reStart = new RegExp('^' + escapeRegex(text), 'i');
|
|
114
|
+
const reContain = new RegExp(escapeRegex(text), 'i');
|
|
115
|
+
const matches = all.filter((row) => fields.some((f) => {
|
|
116
|
+
const vals = getValuesForPath(row, f).map((v) => String(v ?? ''));
|
|
117
|
+
return vals.some((s) => reStart.test(s) || reContain.test(s));
|
|
118
|
+
}));
|
|
119
|
+
items = matches.map((it) => {
|
|
120
|
+
if (projection) {
|
|
121
|
+
const picked = { id: it.id ?? it._id };
|
|
122
|
+
for (const k of Object.keys(projection))
|
|
123
|
+
picked[k] = it[k];
|
|
124
|
+
return picked;
|
|
125
|
+
}
|
|
126
|
+
return it;
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
// If model extends Users, enrich results from base users via baseId
|
|
130
|
+
if (extendsUsers && Array.isArray(items) && items.length) {
|
|
131
|
+
const baseIds = Array.from(new Set(items
|
|
132
|
+
.map((row) => String(row?.baseId?._id ?? row?.baseId ?? ''))
|
|
133
|
+
.filter(Boolean)));
|
|
134
|
+
if (baseIds.length) {
|
|
135
|
+
const userModel = ctx.getModel('users');
|
|
136
|
+
const baseUsers = await userModel.read({ id: { $in: baseIds } }, baseIds.length, 0, true);
|
|
137
|
+
// keep only pure base users and sensible visibility
|
|
138
|
+
const validUsers = baseUsers.filter((u) => !('baseId' in u) &&
|
|
139
|
+
(!u.type || u.type === 'user') &&
|
|
140
|
+
(!u.status || u.status === 'active'));
|
|
141
|
+
const userMap = new Map(validUsers.map((u) => [String(u.id ?? u._id), u]));
|
|
142
|
+
items = items.map((row) => {
|
|
143
|
+
const bid = String(row?.baseId?._id ?? row?.baseId ?? '');
|
|
144
|
+
const u = bid ? userMap.get(bid) : undefined;
|
|
145
|
+
const merged = u
|
|
146
|
+
? {
|
|
147
|
+
...row,
|
|
148
|
+
firstName: u.firstName ?? row.firstName,
|
|
149
|
+
lastName: u.lastName ?? row.lastName,
|
|
150
|
+
profilePicture: u.profilePicture ?? row.profilePicture,
|
|
151
|
+
status: u.status ?? row.status,
|
|
152
|
+
type: u.type ?? row.type,
|
|
153
|
+
email: u.email ?? row.email,
|
|
154
|
+
phone: u.phone ?? row.phone,
|
|
155
|
+
}
|
|
156
|
+
: row;
|
|
157
|
+
// remove baseId before returning
|
|
158
|
+
if ('baseId' in merged)
|
|
159
|
+
delete merged.baseId;
|
|
160
|
+
return merged;
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
items = items.map((row) => {
|
|
165
|
+
if ('baseId' in row)
|
|
166
|
+
delete row.baseId;
|
|
167
|
+
return row;
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
// Not extended — ensure baseId removed if present
|
|
173
|
+
items = items.map((row) => {
|
|
174
|
+
if ('baseId' in row)
|
|
175
|
+
delete row.baseId;
|
|
176
|
+
return row;
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
results[modelNameLC] = Array.isArray(items)
|
|
180
|
+
? items.slice(0, perModelLimit)
|
|
181
|
+
: [];
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
results[modelNameLC] = [];
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return res
|
|
188
|
+
.status(200)
|
|
189
|
+
.json({ success: true, message: 'Search results', data: results });
|
|
190
|
+
}
|
|
191
|
+
catch (err) {
|
|
192
|
+
return res
|
|
193
|
+
.status(400)
|
|
194
|
+
.json({ error: true, message: err?.message || 'Error' });
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import type { RouterCtx } from
|
|
1
|
+
import type { RouterCtx } from './ctx';
|
|
2
2
|
export declare function setupFileRoutes(ctx: RouterCtx): void;
|
|
@@ -14,23 +14,26 @@ function setupFileRoutes(ctx) {
|
|
|
14
14
|
limits: { fileSize: 50 * 1024 * 1024, files: 20 },
|
|
15
15
|
});
|
|
16
16
|
const fileAuthMiddleware = (req, res, next) => ctx.authenticateMiddleware(req, res, next);
|
|
17
|
-
|
|
17
|
+
const fileApiKeyMiddleware = (req, res, next) => ctx.apiKeyMiddleware(req, res, next);
|
|
18
|
+
ctx.router.post('/file', fileApiKeyMiddleware, upload.any(), async (req, res) => {
|
|
18
19
|
try {
|
|
19
20
|
const files = req.files ?? [];
|
|
20
21
|
if (!files.length) {
|
|
21
|
-
return res
|
|
22
|
+
return res
|
|
23
|
+
.status(400)
|
|
24
|
+
.json({ error: true, message: 'No files uploaded' });
|
|
22
25
|
}
|
|
23
26
|
const results = await Promise.all(files.map(async (f) => {
|
|
24
27
|
const folder = shortFolder();
|
|
25
28
|
const ext = (f.originalname.match(/\.([A-Za-z0-9]{1,8})$/)?.[1] ??
|
|
26
29
|
(0, filename_1.extFromMime)(f.mimetype) ??
|
|
27
|
-
|
|
30
|
+
'bin').toLowerCase();
|
|
28
31
|
const key = `${folder}/${shortUid()}.${ext}`;
|
|
29
32
|
const out = await ctx.fileStorage.upload({
|
|
30
33
|
key,
|
|
31
34
|
body: f.buffer,
|
|
32
35
|
contentType: f.mimetype,
|
|
33
|
-
metadata: { originalName: f.originalname ||
|
|
36
|
+
metadata: { originalName: f.originalname || '' },
|
|
34
37
|
});
|
|
35
38
|
return {
|
|
36
39
|
provider: out.provider,
|
|
@@ -46,38 +49,88 @@ function setupFileRoutes(ctx) {
|
|
|
46
49
|
}));
|
|
47
50
|
return res.json({
|
|
48
51
|
success: true,
|
|
49
|
-
message:
|
|
52
|
+
message: 'Files uploaded successfully',
|
|
50
53
|
data: results,
|
|
51
54
|
});
|
|
52
55
|
}
|
|
53
56
|
catch (err) {
|
|
54
|
-
return res
|
|
57
|
+
return res
|
|
58
|
+
.status(400)
|
|
59
|
+
.json({ error: true, message: err?.message ?? 'Upload failed' });
|
|
55
60
|
}
|
|
56
61
|
});
|
|
57
|
-
ctx.router.
|
|
62
|
+
ctx.router.post('/files', fileAuthMiddleware, upload.any(), async (req, res) => {
|
|
58
63
|
try {
|
|
59
|
-
const
|
|
64
|
+
const files = req.files ?? [];
|
|
65
|
+
if (!files.length) {
|
|
66
|
+
return res
|
|
67
|
+
.status(400)
|
|
68
|
+
.json({ error: true, message: 'No files uploaded' });
|
|
69
|
+
}
|
|
70
|
+
const results = await Promise.all(files.map(async (f) => {
|
|
71
|
+
const folder = shortFolder();
|
|
72
|
+
const ext = (f.originalname.match(/\.([A-Za-z0-9]{1,8})$/)?.[1] ??
|
|
73
|
+
(0, filename_1.extFromMime)(f.mimetype) ??
|
|
74
|
+
'bin').toLowerCase();
|
|
75
|
+
const key = `${folder}/${shortUid()}.${ext}`;
|
|
76
|
+
const out = await ctx.fileStorage.upload({
|
|
77
|
+
key,
|
|
78
|
+
body: f.buffer,
|
|
79
|
+
contentType: f.mimetype,
|
|
80
|
+
metadata: { originalName: f.originalname || '' },
|
|
81
|
+
});
|
|
82
|
+
return {
|
|
83
|
+
provider: out.provider,
|
|
84
|
+
bucket: out.bucket,
|
|
85
|
+
key: out.key,
|
|
86
|
+
url: out.url,
|
|
87
|
+
etag: out.etag,
|
|
88
|
+
contentType: out.contentType,
|
|
89
|
+
size: out.size,
|
|
90
|
+
metadata: out.metadata,
|
|
91
|
+
originalName: f.originalname,
|
|
92
|
+
};
|
|
93
|
+
}));
|
|
94
|
+
return res.json({
|
|
95
|
+
success: true,
|
|
96
|
+
message: 'Files uploaded successfully',
|
|
97
|
+
data: results,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
catch (err) {
|
|
101
|
+
return res
|
|
102
|
+
.status(400)
|
|
103
|
+
.json({ error: true, message: err?.message ?? 'Upload failed' });
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
ctx.router.delete('/files/:key(*)', fileAuthMiddleware, async (req, res) => {
|
|
107
|
+
try {
|
|
108
|
+
const key = String(req.params.key || '');
|
|
60
109
|
if (!key) {
|
|
61
|
-
return res
|
|
110
|
+
return res
|
|
111
|
+
.status(400)
|
|
112
|
+
.json({ error: true, message: 'Key is required' });
|
|
62
113
|
}
|
|
63
114
|
const { deleted } = await ctx.fileStorage.delete(key);
|
|
64
115
|
return res.json({
|
|
65
116
|
success: true,
|
|
66
|
-
message: deleted ?
|
|
117
|
+
message: deleted ? 'File deleted' : 'Delete attempted',
|
|
67
118
|
key,
|
|
68
119
|
deleted,
|
|
69
120
|
});
|
|
70
121
|
}
|
|
71
122
|
catch (err) {
|
|
72
|
-
return res
|
|
123
|
+
return res
|
|
124
|
+
.status(400)
|
|
125
|
+
.json({ error: true, message: err?.message ?? 'Delete failed' });
|
|
73
126
|
}
|
|
74
127
|
});
|
|
75
128
|
}
|
|
76
129
|
function shortFolder() {
|
|
77
130
|
const d = new Date();
|
|
78
131
|
const y = d.getFullYear();
|
|
79
|
-
const m = String(d.getMonth() + 1).padStart(2,
|
|
80
|
-
const day = String(d.getDate()).padStart(2,
|
|
132
|
+
const m = String(d.getMonth() + 1).padStart(2, '0');
|
|
133
|
+
const day = String(d.getDate()).padStart(2, '0');
|
|
81
134
|
return `uploads/${y}/${m}/${day}`;
|
|
82
135
|
}
|
|
83
136
|
function shortUid() {
|
|
@@ -2,6 +2,7 @@ import type { Request } from 'express';
|
|
|
2
2
|
export type AnyRec = Record<string, any>;
|
|
3
3
|
export type SortDir = 1 | -1;
|
|
4
4
|
export type SortSpec = Record<string, SortDir>;
|
|
5
|
+
export declare const ALLOWED_VIRTUAL_ATTRS: Set<string>;
|
|
5
6
|
export declare const isPlainObject: (v: unknown) => v is AnyRec;
|
|
6
7
|
export declare const splitCSV: (raw: string) => string[];
|
|
7
8
|
export declare function normalizeAttrType(attr: any): string;
|
|
@@ -46,16 +47,16 @@ export declare function buildPredicateForField(field: string, attr: any, raw: st
|
|
|
46
47
|
} | {
|
|
47
48
|
[field]: Date;
|
|
48
49
|
} | null;
|
|
49
|
-
export declare
|
|
50
|
-
export declare function
|
|
50
|
+
export declare const VIRTUAL_SORT_KEYS: Set<string>;
|
|
51
|
+
export declare function parseSort(sortRaw: unknown, sortTypeRaw: unknown, allowedKeys?: Set<string>): SortSpec;
|
|
52
|
+
export type ParsedQuery = {
|
|
51
53
|
limit: number;
|
|
52
54
|
page: number;
|
|
53
55
|
skip: number;
|
|
54
|
-
projection
|
|
55
|
-
|
|
56
|
-
} | undefined;
|
|
57
|
-
sort: Record<string, 1 | -1> | undefined;
|
|
56
|
+
projection?: Record<string, 1>;
|
|
57
|
+
sort: SortSpec;
|
|
58
58
|
};
|
|
59
|
+
export declare function parseQuery(req: Request, allowedKeys?: Set<string>): ParsedQuery;
|
|
59
60
|
export declare function extractIds(val: unknown): string[];
|
|
60
61
|
export declare function refInfoFromAttr(attr: any): {
|
|
61
62
|
ref: string;
|
package/dist/api/router/utils.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.dedupeBy = exports.splitCSV = exports.isPlainObject = void 0;
|
|
3
|
+
exports.dedupeBy = exports.VIRTUAL_SORT_KEYS = exports.splitCSV = exports.isPlainObject = exports.ALLOWED_VIRTUAL_ATTRS = void 0;
|
|
4
4
|
exports.normalizeAttrType = normalizeAttrType;
|
|
5
5
|
exports.toIdString = toIdString;
|
|
6
6
|
exports.splitFilterForExtended = splitFilterForExtended;
|
|
@@ -12,6 +12,12 @@ exports.parseSort = parseSort;
|
|
|
12
12
|
exports.parseQuery = parseQuery;
|
|
13
13
|
exports.extractIds = extractIds;
|
|
14
14
|
exports.refInfoFromAttr = refInfoFromAttr;
|
|
15
|
+
exports.ALLOWED_VIRTUAL_ATTRS = new Set([
|
|
16
|
+
'createdAt',
|
|
17
|
+
'updatedAt',
|
|
18
|
+
'_id',
|
|
19
|
+
'id',
|
|
20
|
+
]);
|
|
15
21
|
const isPlainObject = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
16
22
|
exports.isPlainObject = isPlainObject;
|
|
17
23
|
const splitCSV = (raw) => raw
|
|
@@ -206,35 +212,68 @@ function buildPredicateForField(field, attr, raw) {
|
|
|
206
212
|
: { [field]: raw };
|
|
207
213
|
}
|
|
208
214
|
}
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
215
|
+
exports.VIRTUAL_SORT_KEYS = new Set([
|
|
216
|
+
'createdAt',
|
|
217
|
+
'updatedAt',
|
|
218
|
+
'_id',
|
|
219
|
+
'id',
|
|
220
|
+
]);
|
|
221
|
+
function parseSort(sortRaw, sortTypeRaw, allowedKeys) {
|
|
222
|
+
const keys = String(sortRaw ?? '')
|
|
214
223
|
.split(',')
|
|
215
224
|
.map((s) => s.trim())
|
|
216
|
-
.filter(Boolean)
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
225
|
+
.filter(Boolean);
|
|
226
|
+
const dirs = String(sortTypeRaw ?? '')
|
|
227
|
+
.split(',')
|
|
228
|
+
.map((s) => s.trim())
|
|
229
|
+
.filter(Boolean);
|
|
230
|
+
const hasKey = (k) => (allowedKeys?.has(k) ?? false) || exports.VIRTUAL_SORT_KEYS.has(k);
|
|
231
|
+
const spec = {};
|
|
232
|
+
if (keys.length) {
|
|
233
|
+
keys.forEach((k, i) => {
|
|
234
|
+
let field = k;
|
|
235
|
+
let dir = null;
|
|
236
|
+
// compact + / - prefix
|
|
237
|
+
if (k.startsWith('-')) {
|
|
238
|
+
field = k.slice(1);
|
|
239
|
+
dir = -1;
|
|
240
|
+
}
|
|
241
|
+
else if (k.startsWith('+')) {
|
|
242
|
+
field = k.slice(1);
|
|
243
|
+
dir = 1;
|
|
244
|
+
}
|
|
245
|
+
if (dir === null) {
|
|
246
|
+
const d = dirs[i] ?? dirs[dirs.length - 1] ?? 'desc';
|
|
247
|
+
dir = /^(desc|-1)$/i.test(d)
|
|
248
|
+
? -1
|
|
249
|
+
: /^(asc|1)$/i.test(d)
|
|
250
|
+
? 1
|
|
251
|
+
: -1;
|
|
252
|
+
}
|
|
253
|
+
if (field && hasKey(field)) {
|
|
254
|
+
spec[field] = dir;
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
// client sent sort but none validated → honor “no default”
|
|
258
|
+
return spec;
|
|
223
259
|
}
|
|
224
|
-
|
|
260
|
+
// no sort sent → default
|
|
261
|
+
return { createdAt: -1 };
|
|
225
262
|
}
|
|
226
|
-
function parseQuery(req) {
|
|
227
|
-
const limit = Math.min(parseInt(String(req.query.limit ?? '12'), 10) || 12, 100);
|
|
228
|
-
const page = Math.max(parseInt(String(req.query.page ?? '1'), 10) || 1, 1);
|
|
263
|
+
function parseQuery(req, allowedKeys) {
|
|
264
|
+
const limit = Math.min(Number.parseInt(String(req.query.limit ?? '12'), 10) || 12, 100);
|
|
265
|
+
const page = Math.max(Number.parseInt(String(req.query.page ?? '1'), 10) || 1, 1);
|
|
229
266
|
const skip = (page - 1) * limit;
|
|
230
267
|
const fields = String(req.query.fields ?? '')
|
|
231
268
|
.split(',')
|
|
232
269
|
.map((s) => s.trim())
|
|
233
270
|
.filter(Boolean);
|
|
234
|
-
const projection = fields.length
|
|
271
|
+
const projection = fields.length > 0
|
|
235
272
|
? Object.fromEntries(fields.map((f) => [f, 1]))
|
|
236
273
|
: undefined;
|
|
237
|
-
|
|
274
|
+
// NOTE: do NOT force a fallback like '-createdAt' here;
|
|
275
|
+
// parseSort will apply { createdAt: -1 } only if no sort is provided.
|
|
276
|
+
const sort = parseSort(req.query.sort, req.query.sortType, allowedKeys);
|
|
238
277
|
return { limit, page, skip, projection, sort };
|
|
239
278
|
}
|
|
240
279
|
function extractIds(val) {
|