@airoom/nextmin-node 0.1.9 → 1.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.
@@ -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
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,226 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const utils_1 = require("./utils");
4
+ function mountSearchRoutes(ctx) {
5
+ const { router } = ctx;
6
+ router.post('/search', ctx.optionalAuthMiddleware, async (req, res) => {
7
+ try {
8
+ const body = (0, utils_1.isPlainObject)(req.body)
9
+ ? req.body
10
+ : {};
11
+ const text = typeof body.text === 'string' ? body.text.trim() : '';
12
+ const models = Array.isArray(body.models) ? body.models : [];
13
+ const fields = Array.isArray(body.fields)
14
+ ? body.fields.map(String)
15
+ : [];
16
+ const select = (0, utils_1.isPlainObject)(body.select)
17
+ ? body.select
18
+ : {};
19
+ const perModelLimit = typeof body.limit === 'number' && body.limit > 0
20
+ ? Math.floor(body.limit)
21
+ : 10;
22
+ if (!text || text.length < 2) {
23
+ return res.status(400).json({
24
+ error: true,
25
+ message: 'text must be at least 2 characters',
26
+ });
27
+ }
28
+ if (!models.length) {
29
+ return res.status(400).json({
30
+ error: true,
31
+ message: 'models must be a non-empty string array',
32
+ });
33
+ }
34
+ if (!fields.length) {
35
+ return res.status(400).json({
36
+ error: true,
37
+ message: 'fields must be a non-empty string array',
38
+ });
39
+ }
40
+ // 🚫 Block sensitive/non-searchable models
41
+ const forbidden = new Set(['users', 'roles', 'settings']);
42
+ const modelsLC = models.map((m) => String(m || '').toLowerCase());
43
+ const forbiddenInRequest = modelsLC.filter((m) => forbidden.has(m));
44
+ if (forbiddenInRequest.length) {
45
+ const emptyResults = Object.fromEntries(modelsLC.map((m) => [m, []]));
46
+ return res.status(400).json({
47
+ error: true,
48
+ message: `These models are not searchable: ${forbiddenInRequest.join(', ')}`,
49
+ data: emptyResults,
50
+ });
51
+ }
52
+ const adapterAny = ctx.dbAdapter;
53
+ const results = {};
54
+ const escapeRegex = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
55
+ const textEsc = escapeRegex(text);
56
+ // Expand a logical field to likely nested properties too (name/slug/title).
57
+ // If the caller already passes a nested path (e.g. districts.name), keep as-is.
58
+ const expandFieldVariants = (fld) => {
59
+ // If it’s already a dotted path beyond a simple top-level, keep it.
60
+ if (fld.includes('.'))
61
+ return [fld];
62
+ // Try common subkeys for collections or embedded docs
63
+ // (kept generic and cheap; adjust if you want a schema-aware expansion).
64
+ return Array.from(new Set([fld, `${fld}.name`, `${fld}.slug`, `${fld}.title`]));
65
+ };
66
+ // Build an OR filter with regex for a set of fields (already expanded)
67
+ const buildOrFilter = (flds) => {
68
+ const orClauses = [];
69
+ for (const f of flds) {
70
+ orClauses.push({ [f]: { $regex: `^${textEsc}`, $options: 'i' } }); // starts-with
71
+ orClauses.push({ [f]: { $regex: textEsc, $options: 'i' } }); // contains
72
+ }
73
+ return { $or: orClauses };
74
+ };
75
+ // Pull values by a dot-path (supports arrays and nested objects) for in-memory fallback
76
+ const getValuesForPath = (row, path) => {
77
+ const parts = path.split('.');
78
+ const walk = (node, i) => {
79
+ if (node == null)
80
+ return [];
81
+ if (i >= parts.length)
82
+ return Array.isArray(node) ? node : [node];
83
+ const key = parts[i];
84
+ if (Array.isArray(node)) {
85
+ const out = [];
86
+ for (const el of node)
87
+ out.push(...walk(el?.[key], i + 1));
88
+ return out;
89
+ }
90
+ return walk(node[key], i + 1);
91
+ };
92
+ return walk(row, 0).filter((v) => v !== undefined && v !== null);
93
+ };
94
+ // Compute the fields that apply to a given model:
95
+ // - If a field starts with "<model>.", strip the prefix and use the remainder.
96
+ // - If a field is a nested path not starting with any requested model prefix (e.g. "districts.name"), keep as-is.
97
+ // - If a field is a simple key (no dots), keep as-is.
98
+ const fieldsForModel = (modelNameLC) => {
99
+ const prefix = modelNameLC + '.';
100
+ const requestedModelSet = new Set(modelsLC);
101
+ const out = [];
102
+ for (const f of fields) {
103
+ if (f.startsWith(prefix)) {
104
+ out.push(f.slice(prefix.length));
105
+ }
106
+ else if (f.includes('.')) {
107
+ const first = f.split('.')[0];
108
+ // If it looks like a model prefix but is NOT this model and IS one of the requested models, skip.
109
+ if (requestedModelSet.has(first) && first !== modelNameLC) {
110
+ continue;
111
+ }
112
+ out.push(f); // nested field like "districts.name" or "specialities.slug"
113
+ }
114
+ else {
115
+ out.push(f); // simple top-level field
116
+ }
117
+ }
118
+ // Expand variants (name/slug/title) for top-level collection fields.
119
+ return Array.from(new Set(out.flatMap((fld) => expandFieldVariants(fld))));
120
+ };
121
+ for (const rawName of models) {
122
+ const modelNameLC = String(rawName || '').toLowerCase();
123
+ let items = [];
124
+ try {
125
+ const schema = ctx.getSchema(modelNameLC);
126
+ const extendsUsers = String(schema?.extends || '').toLowerCase() === 'users';
127
+ // Projection with baseId if extends Users
128
+ const projection = Array.isArray(select?.[modelNameLC])
129
+ ? Object.fromEntries(select[modelNameLC].map((k) => [k, 1]))
130
+ : undefined;
131
+ if (extendsUsers && projection)
132
+ projection['baseId'] = 1;
133
+ const modelFields = fieldsForModel(modelNameLC);
134
+ const mongoStyleFilter = buildOrFilter(modelFields);
135
+ const useAdapter = typeof adapterAny.findMany === 'function' &&
136
+ typeof schema?.modelName === 'string';
137
+ if (useAdapter) {
138
+ // Adapter branch — passes dot-paths directly (Mongo-like)
139
+ items = await adapterAny.findMany(schema.modelName, mongoStyleFilter, { sort: undefined, skip: 0, limit: undefined, projection });
140
+ }
141
+ else {
142
+ // In-memory fallback — evaluate dot paths client-side
143
+ const all = await ctx.getModel(modelNameLC).read({}, 0, 0, true);
144
+ const reStart = new RegExp('^' + escapeRegex(text), 'i');
145
+ const reContain = new RegExp(escapeRegex(text), 'i');
146
+ const matches = all.filter((row) => modelFields.some((f) => {
147
+ const vals = getValuesForPath(row, f).map((v) => String(v ?? ''));
148
+ return vals.some((s) => reStart.test(s) || reContain.test(s));
149
+ }));
150
+ items = matches.map((it) => {
151
+ if (projection) {
152
+ const picked = { id: it.id ?? it._id };
153
+ for (const k of Object.keys(projection))
154
+ picked[k] = it[k];
155
+ return picked;
156
+ }
157
+ return it;
158
+ });
159
+ }
160
+ // If model extends Users, enrich results from base users via baseId
161
+ if (extendsUsers && Array.isArray(items) && items.length) {
162
+ const baseIds = Array.from(new Set(items
163
+ .map((row) => String(row?.baseId?._id ?? row?.baseId ?? ''))
164
+ .filter(Boolean)));
165
+ if (baseIds.length) {
166
+ const userModel = ctx.getModel('users');
167
+ const baseUsers = await userModel.read({ id: { $in: baseIds } }, baseIds.length, 0, true);
168
+ const validUsers = baseUsers.filter((u) => !('baseId' in u) &&
169
+ (!u.type || u.type === 'user') &&
170
+ (!u.status || u.status === 'active'));
171
+ const userMap = new Map(validUsers.map((u) => [String(u.id ?? u._id), u]));
172
+ items = items.map((row) => {
173
+ const bid = String(row?.baseId?._id ?? row?.baseId ?? '');
174
+ const u = bid ? userMap.get(bid) : undefined;
175
+ const merged = u
176
+ ? {
177
+ ...row,
178
+ firstName: u.firstName ?? row.firstName,
179
+ lastName: u.lastName ?? row.lastName,
180
+ profilePicture: u.profilePicture ?? row.profilePicture,
181
+ status: u.status ?? row.status,
182
+ type: u.type ?? row.type,
183
+ email: u.email ?? row.email,
184
+ phone: u.phone ?? row.phone,
185
+ }
186
+ : row;
187
+ if ('baseId' in merged)
188
+ delete merged.baseId;
189
+ return merged;
190
+ });
191
+ }
192
+ else {
193
+ items = items.map((row) => {
194
+ if ('baseId' in row)
195
+ delete row.baseId;
196
+ return row;
197
+ });
198
+ }
199
+ }
200
+ else {
201
+ // Not extended — ensure baseId removed if present
202
+ items = items.map((row) => {
203
+ if ('baseId' in row)
204
+ delete row.baseId;
205
+ return row;
206
+ });
207
+ }
208
+ results[modelNameLC] = Array.isArray(items)
209
+ ? items.slice(0, perModelLimit)
210
+ : [];
211
+ }
212
+ catch {
213
+ results[modelNameLC] = [];
214
+ }
215
+ }
216
+ return res
217
+ .status(200)
218
+ .json({ success: true, message: 'Search results', data: results });
219
+ }
220
+ catch (err) {
221
+ return res
222
+ .status(400)
223
+ .json({ error: true, message: err?.message || 'Error' });
224
+ }
225
+ });
226
+ }
@@ -22,8 +22,7 @@
22
22
  "password": {
23
23
  "type": "string",
24
24
  "required": true,
25
- "private": true,
26
- "writeOnly": true
25
+ "private": true
27
26
  },
28
27
 
29
28
  "profilePicture": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@airoom/nextmin-node",
3
- "version": "0.1.9",
3
+ "version": "1.2.0",
4
4
  "license": "MIT",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",