@airoom/nextmin-node 0.1.9 → 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
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;
|
|
@@ -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
|
+
}
|