@airoom/nextmin-node 1.1.0 β†’ 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.
@@ -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": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "license": "MIT",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",