@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.
@@ -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;
@@ -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,2 @@
1
+ import type { RouterCtx } from './ctx';
2
+ export declare function mountSearchRoutes(ctx: RouterCtx): void;
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@airoom/nextmin-node",
3
- "version": "0.1.9",
3
+ "version": "1.1.0",
4
4
  "license": "MIT",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",