@airoom/nextmin-node 1.4.5 → 2.0.1
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/README.md +48 -5
- package/dist/api/apiRouter.d.ts +2 -0
- package/dist/api/apiRouter.js +68 -19
- package/dist/api/router/mountCrudRoutes.js +209 -221
- package/dist/api/router/mountFindRoutes.js +2 -49
- package/dist/api/router/mountSearchRoutes.js +10 -52
- package/dist/api/router/mountSearchRoutes_extended.js +7 -48
- package/dist/api/router/setupAuthRoutes.js +6 -2
- package/dist/api/router/utils.js +20 -7
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +83 -0
- package/dist/database/DatabaseAdapter.d.ts +7 -0
- package/dist/database/NMAdapter.d.ts +41 -0
- package/dist/database/NMAdapter.js +979 -0
- package/dist/database/QueryEngine.d.ts +14 -0
- package/dist/database/QueryEngine.js +215 -0
- package/dist/database/utils.d.ts +2 -0
- package/dist/database/utils.js +21 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.js +11 -5
- package/dist/models/BaseModel.d.ts +16 -0
- package/dist/models/BaseModel.js +32 -4
- package/dist/policy/authorize.js +118 -43
- package/dist/schemas/Users.json +66 -30
- package/dist/services/RealtimeService.d.ts +20 -0
- package/dist/services/RealtimeService.js +93 -0
- package/dist/services/SchemaService.d.ts +3 -0
- package/dist/services/SchemaService.js +9 -5
- package/dist/utils/DefaultDataInitializer.js +10 -2
- package/dist/utils/Events.d.ts +34 -0
- package/dist/utils/Events.js +55 -0
- package/dist/utils/Logger.js +12 -10
- package/dist/utils/QueryCache.d.ts +16 -0
- package/dist/utils/QueryCache.js +106 -0
- package/dist/utils/SchemaLoader.d.ts +7 -2
- package/dist/utils/SchemaLoader.js +58 -18
- package/package.json +19 -4
- package/dist/database/InMemoryAdapter.d.ts +0 -15
- package/dist/database/InMemoryAdapter.js +0 -71
- package/dist/database/MongoAdapter.d.ts +0 -52
- package/dist/database/MongoAdapter.js +0 -410
|
@@ -8,6 +8,33 @@ const bcrypt_1 = __importDefault(require("bcrypt"));
|
|
|
8
8
|
const Logger_1 = __importDefault(require("../../utils/Logger"));
|
|
9
9
|
const fieldCodecs_1 = require("../../utils/fieldCodecs");
|
|
10
10
|
const authorize_1 = require("../../policy/authorize");
|
|
11
|
+
const QueryEngine_1 = require("../../database/QueryEngine");
|
|
12
|
+
async function injectActionRelatedData(ctx, schema, rows) {
|
|
13
|
+
const actionsWithRelated = schema.actions?.filter((a) => a.relatedModel && a.relatedField);
|
|
14
|
+
if (!actionsWithRelated?.length || rows.length === 0)
|
|
15
|
+
return;
|
|
16
|
+
const ids = rows.map((r) => String(r.id));
|
|
17
|
+
for (const action of actionsWithRelated) {
|
|
18
|
+
try {
|
|
19
|
+
const model = ctx.getModel(action.relatedModel.toLowerCase());
|
|
20
|
+
if (model) {
|
|
21
|
+
// Precise grouping lookup instead of limited read()
|
|
22
|
+
const relatedMap = await model.findFirstRelatedIds(action.relatedField, ids);
|
|
23
|
+
for (const row of rows) {
|
|
24
|
+
if (!row._actionsData)
|
|
25
|
+
row._actionsData = {};
|
|
26
|
+
const matchedId = relatedMap.get(String(row.id));
|
|
27
|
+
if (matchedId) {
|
|
28
|
+
row._actionsData[action.relatedModel] = matchedId;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
catch (err) {
|
|
34
|
+
Logger_1.default.error('mountCrudRoutes', `Error fetching related data for action ${action.label}:`, err);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
11
38
|
const utils_1 = require("./utils");
|
|
12
39
|
function mountCrudRoutes(ctx, modelNameLC) {
|
|
13
40
|
const { router } = ctx;
|
|
@@ -99,25 +126,62 @@ function mountCrudRoutes(ctx, modelNameLC) {
|
|
|
99
126
|
const basePayload = {};
|
|
100
127
|
const childPayload = {};
|
|
101
128
|
for (const [k, v] of Object.entries(payload)) {
|
|
102
|
-
if (
|
|
129
|
+
if (k === 'baseId') {
|
|
130
|
+
childPayload[k] = v;
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
const baseAttr = baseSchema.attributes[k];
|
|
134
|
+
const childAttr = schema.attributes[k];
|
|
135
|
+
const bHead = Array.isArray(baseAttr) ? baseAttr[0] : baseAttr;
|
|
136
|
+
const cHead = Array.isArray(childAttr) ? childAttr[0] : childAttr;
|
|
137
|
+
if (bHead?.safe === true) {
|
|
103
138
|
basePayload[k] = v;
|
|
104
|
-
|
|
139
|
+
}
|
|
140
|
+
else if (cHead && !cHead.inherited) {
|
|
141
|
+
childPayload[k] = v;
|
|
142
|
+
}
|
|
143
|
+
else if (baseAttr) {
|
|
144
|
+
basePayload[k] = v;
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
105
147
|
childPayload[k] = v;
|
|
148
|
+
}
|
|
106
149
|
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
150
|
+
const isUserExt = baseLC === 'users';
|
|
151
|
+
const baseId = payload.baseId;
|
|
152
|
+
let baseCreated;
|
|
153
|
+
if (isUserExt && baseId) {
|
|
154
|
+
// Use existing user
|
|
155
|
+
const arr = await baseModel.read({ id: baseId }, 1, 0, true);
|
|
156
|
+
baseCreated = arr?.[0];
|
|
157
|
+
if (!baseCreated) {
|
|
158
|
+
return res.status(400).json({ error: true, message: 'Associate user account not found.' });
|
|
159
|
+
}
|
|
110
160
|
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
161
|
+
else {
|
|
162
|
+
// Normal flow: create base record if we have enough data
|
|
163
|
+
const baseMissing = ctx.validateRequiredFields(baseSchema, basePayload, 'create');
|
|
164
|
+
// Only attempt creation if at least one base field is provided or if it's not a UserExt?
|
|
165
|
+
// Actually, for UserExt, if baseId is missing and they didn't fill user fields (which are hidden),
|
|
166
|
+
// we should skip base creation and just create the child.
|
|
167
|
+
if (baseMissing.length === 0 && Object.keys(basePayload).length > 0) {
|
|
168
|
+
if (baseLC === 'users' && basePayload.password && ctx.jwtSecret) {
|
|
169
|
+
const salt = await bcrypt_1.default.genSalt(10);
|
|
170
|
+
basePayload.password = await bcrypt_1.default.hash(String(basePayload.password) + ctx.jwtSecret, salt);
|
|
171
|
+
}
|
|
172
|
+
const baseConflicts = await ctx.checkUniqueFields(baseSchema, basePayload);
|
|
173
|
+
if (baseConflicts && baseConflicts.length) {
|
|
174
|
+
return res.status(400).json({
|
|
175
|
+
error: true,
|
|
176
|
+
fields: baseConflicts.map((field) => ({
|
|
177
|
+
field,
|
|
178
|
+
error: true,
|
|
179
|
+
message: `You cannot use this ${field}. It's already been used.`,
|
|
180
|
+
})),
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
baseCreated = await baseModel.create(basePayload);
|
|
184
|
+
}
|
|
121
185
|
}
|
|
122
186
|
const childConflicts = await ctx.checkUniqueFields(schema, childPayload);
|
|
123
187
|
if (childConflicts && childConflicts.length) {
|
|
@@ -130,24 +194,35 @@ function mountCrudRoutes(ctx, modelNameLC) {
|
|
|
130
194
|
})),
|
|
131
195
|
});
|
|
132
196
|
}
|
|
133
|
-
const
|
|
134
|
-
const childToCreate = { ...childPayload, baseId: baseCreated.id };
|
|
197
|
+
const childToCreate = { ...childPayload, baseId: baseCreated?.id || null };
|
|
135
198
|
const childCreated = await model.create(childToCreate);
|
|
136
|
-
let resultDoc =
|
|
137
|
-
|
|
138
|
-
|
|
199
|
+
let resultDoc = baseCreated
|
|
200
|
+
? { ...baseCreated, ...childCreated }
|
|
201
|
+
: { ...childCreated };
|
|
202
|
+
if (baseCreated) {
|
|
203
|
+
resultDoc.exId = String(baseCreated.id || baseCreated._id || '');
|
|
204
|
+
}
|
|
205
|
+
resultDoc.id = String(childCreated?.id || childCreated?._id || resultDoc.id || '');
|
|
139
206
|
resultDoc._id = resultDoc.id;
|
|
140
|
-
|
|
141
|
-
|
|
207
|
+
if (resultDoc.baseId)
|
|
208
|
+
resultDoc.baseId = String(resultDoc.baseId);
|
|
209
|
+
// delete resultDoc.baseId; // Preserve baseId for frontend use
|
|
142
210
|
if (cdec.exposePrivate && childCreated?.id) {
|
|
143
211
|
const [refChild] = await model.read({ id: childCreated.id }, 1, 0, true);
|
|
144
|
-
|
|
145
|
-
if (
|
|
146
|
-
|
|
147
|
-
|
|
212
|
+
let refBase = null;
|
|
213
|
+
if (baseCreated) {
|
|
214
|
+
const [b] = await baseModel.read({ id: baseCreated.id }, 1, 0, true);
|
|
215
|
+
refBase = b;
|
|
216
|
+
}
|
|
217
|
+
if (refChild) {
|
|
218
|
+
resultDoc = refBase ? { ...refBase, ...refChild } : { ...refChild };
|
|
219
|
+
if (refBase)
|
|
220
|
+
resultDoc.exId = String(refBase.id ?? refBase._id);
|
|
148
221
|
resultDoc.id = String(refChild.id ?? refChild._id);
|
|
149
222
|
resultDoc._id = resultDoc.id;
|
|
150
|
-
|
|
223
|
+
if (resultDoc.baseId)
|
|
224
|
+
resultDoc.baseId = String(resultDoc.baseId);
|
|
225
|
+
// delete resultDoc.baseId; // Preserve baseId
|
|
151
226
|
}
|
|
152
227
|
}
|
|
153
228
|
const masked = cdec.exposePrivate
|
|
@@ -199,8 +274,8 @@ function mountCrudRoutes(ctx, modelNameLC) {
|
|
|
199
274
|
.json({ error: true, message: 'Method not allowed' });
|
|
200
275
|
}
|
|
201
276
|
const model = ctx.getModel(modelNameLC);
|
|
202
|
-
const
|
|
203
|
-
const limit =
|
|
277
|
+
const baseKeys = new Set(Object.keys(schema.attributes || {}));
|
|
278
|
+
const { limit, page, skip, sort, projection } = (0, utils_1.parseQuery)(req, baseKeys);
|
|
204
279
|
const q = String(req.query.q ?? '').trim();
|
|
205
280
|
const searchKey = String(req.query.searchKey ?? '').trim();
|
|
206
281
|
const searchKeysCSV = String(req.query.searchKeys ?? '').trim();
|
|
@@ -210,127 +285,7 @@ function mountCrudRoutes(ctx, modelNameLC) {
|
|
|
210
285
|
const dateFromStr = String(req.query.dateFrom ?? '').trim();
|
|
211
286
|
const dateToStr = String(req.query.dateTo ?? '').trim();
|
|
212
287
|
let dateKey = String(req.query.dateKey ?? 'createdAt').trim();
|
|
213
|
-
const
|
|
214
|
-
const sortTypeRaw = String(req.query.sortType ?? '').trim();
|
|
215
|
-
const filter = {};
|
|
216
|
-
// ---------- SEARCH ----------
|
|
217
|
-
const ors = [];
|
|
218
|
-
const ands = [];
|
|
219
|
-
if (q && searchKey && schema.attributes?.[searchKey]) {
|
|
220
|
-
const p = (0, utils_1.buildPredicateForField)(searchKey, schema.attributes[searchKey], q);
|
|
221
|
-
if (p)
|
|
222
|
-
ors.push(p);
|
|
223
|
-
}
|
|
224
|
-
if (q && searchKeysCSV) {
|
|
225
|
-
const keys = (0, utils_1.splitCSV)(searchKeysCSV);
|
|
226
|
-
const preds = keys
|
|
227
|
-
.filter((k) => !!schema.attributes?.[k])
|
|
228
|
-
.map((k) => (0, utils_1.buildPredicateForField)(k, schema.attributes[k], q))
|
|
229
|
-
.filter(Boolean);
|
|
230
|
-
if (preds.length) {
|
|
231
|
-
if (searchMode === 'and')
|
|
232
|
-
ands.push(...preds);
|
|
233
|
-
else
|
|
234
|
-
ors.push(...preds);
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
if (ands.length && ors.length)
|
|
238
|
-
filter.$and = [...ands, { $or: ors }];
|
|
239
|
-
else if (ands.length)
|
|
240
|
-
filter.$and = [...(filter.$and || []), ...ands];
|
|
241
|
-
else if (ors.length)
|
|
242
|
-
filter.$or = [...(filter.$or || []), ...ors];
|
|
243
|
-
if (dateFromStr || dateToStr) {
|
|
244
|
-
if (!schema.attributes?.[dateKey]) {
|
|
245
|
-
if (schema.attributes?.createdAt)
|
|
246
|
-
dateKey = 'createdAt';
|
|
247
|
-
else if (schema.attributes?.updatedAt)
|
|
248
|
-
dateKey = 'updatedAt';
|
|
249
|
-
}
|
|
250
|
-
if (dateKey && schema.attributes?.[dateKey]) {
|
|
251
|
-
const range = {};
|
|
252
|
-
if (dateFromStr) {
|
|
253
|
-
const d = new Date(dateFromStr);
|
|
254
|
-
if (!Number.isNaN(+d))
|
|
255
|
-
range.$gte = d;
|
|
256
|
-
}
|
|
257
|
-
if (dateToStr) {
|
|
258
|
-
const d = new Date(dateToStr);
|
|
259
|
-
if (!Number.isNaN(+d))
|
|
260
|
-
range.$lte = d;
|
|
261
|
-
}
|
|
262
|
-
if (Object.keys(range).length)
|
|
263
|
-
filter[dateKey] = range;
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
// ---------- Direct field filters from req.query ----------
|
|
267
|
-
const RESERVED = new Set([
|
|
268
|
-
'page',
|
|
269
|
-
'limit',
|
|
270
|
-
'q',
|
|
271
|
-
'searchKey',
|
|
272
|
-
'searchKeys',
|
|
273
|
-
'searchMode',
|
|
274
|
-
'dateFrom',
|
|
275
|
-
'dateTo',
|
|
276
|
-
'dateKey',
|
|
277
|
-
'sort',
|
|
278
|
-
'sortType',
|
|
279
|
-
]);
|
|
280
|
-
const attrMap = schema.attributes || {};
|
|
281
|
-
const coerceQueryVal = (raw) => {
|
|
282
|
-
const s = String(raw).trim();
|
|
283
|
-
if (/^(true|false)$/i.test(s))
|
|
284
|
-
return /^true$/i.test(s);
|
|
285
|
-
if (/^-?\d+(\.\d+)?$/.test(s))
|
|
286
|
-
return Number(s);
|
|
287
|
-
if (/^null$/i.test(s))
|
|
288
|
-
return null;
|
|
289
|
-
if (/^undefined$/i.test(s))
|
|
290
|
-
return undefined;
|
|
291
|
-
return s;
|
|
292
|
-
};
|
|
293
|
-
for (const [k, vRaw] of Object.entries(req.query)) {
|
|
294
|
-
if (RESERVED.has(k))
|
|
295
|
-
continue;
|
|
296
|
-
if (!Object.prototype.hasOwnProperty.call(attrMap, k))
|
|
297
|
-
continue;
|
|
298
|
-
const v = Array.isArray(vRaw)
|
|
299
|
-
? vRaw.map(String).join(',')
|
|
300
|
-
: String(vRaw);
|
|
301
|
-
if (!v?.length)
|
|
302
|
-
continue;
|
|
303
|
-
// Operators:
|
|
304
|
-
// - "a,b,c" -> $in
|
|
305
|
-
// - "!x" -> $ne
|
|
306
|
-
// - "gt:10" -> {$gt:10}, "gte:10","lt:5","lte:5"
|
|
307
|
-
// - default -> exact match
|
|
308
|
-
const tryNumOrBool = (s) => coerceQueryVal(s);
|
|
309
|
-
if (v.includes(',')) {
|
|
310
|
-
const arr = v
|
|
311
|
-
.split(',')
|
|
312
|
-
.map((s) => s.trim())
|
|
313
|
-
.filter(Boolean)
|
|
314
|
-
.map(tryNumOrBool);
|
|
315
|
-
filter[k] = { $in: arr };
|
|
316
|
-
}
|
|
317
|
-
else if (v.startsWith('!')) {
|
|
318
|
-
filter[k] = { $ne: tryNumOrBool(v.slice(1)) };
|
|
319
|
-
}
|
|
320
|
-
else if (/^(gt|gte|lt|lte):/i.test(v)) {
|
|
321
|
-
const [op, rest] = v.split(':', 2);
|
|
322
|
-
const map = {
|
|
323
|
-
gt: '$gt',
|
|
324
|
-
gte: '$gte',
|
|
325
|
-
lt: '$lt',
|
|
326
|
-
lte: '$lte',
|
|
327
|
-
};
|
|
328
|
-
filter[k] = { [map[op.toLowerCase()]]: tryNumOrBool(rest) };
|
|
329
|
-
}
|
|
330
|
-
else {
|
|
331
|
-
filter[k] = tryNumOrBool(v);
|
|
332
|
-
}
|
|
333
|
-
}
|
|
288
|
+
const filter = QueryEngine_1.QueryEngine.parse(req.query, schema);
|
|
334
289
|
const pctx = ctxFromReq(req);
|
|
335
290
|
const schemaPolicy = {
|
|
336
291
|
allowedMethods: schema.allowedMethods,
|
|
@@ -340,9 +295,6 @@ function mountCrudRoutes(ctx, modelNameLC) {
|
|
|
340
295
|
if (!rdec.allow) {
|
|
341
296
|
return res.status(403).json({ error: true, message: 'forbidden' });
|
|
342
297
|
}
|
|
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);
|
|
346
298
|
const finalFilter = (0, authorize_1.andFilter)(filter, rdec.queryFilter || {});
|
|
347
299
|
const isUsersModel = modelNameLC === 'user' ||
|
|
348
300
|
modelNameLC === 'users' ||
|
|
@@ -355,40 +307,31 @@ function mountCrudRoutes(ctx, modelNameLC) {
|
|
|
355
307
|
const baseSchema = ctx.getSchema(baseLC);
|
|
356
308
|
const baseModel = ctx.getModel(baseLC);
|
|
357
309
|
const baseAttrKeys = new Set(Object.keys(baseSchema.attributes || {}));
|
|
310
|
+
const childAttrs = schema.attributes || {};
|
|
311
|
+
for (const k of Object.keys(childAttrs)) {
|
|
312
|
+
const cHead = Array.isArray(childAttrs[k]) ? childAttrs[k][0] : childAttrs[k];
|
|
313
|
+
if (cHead && !cHead.inherited) {
|
|
314
|
+
baseAttrKeys.delete(k);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
358
317
|
const { child: childFilter, base: extBaseFilter } = (0, utils_1.splitFilterForExtended)(finalFilter, baseAttrKeys);
|
|
359
|
-
const { child: childSort, base: extBaseSort } = (0, utils_1.splitSortForExtended)(
|
|
318
|
+
const { child: childSort, base: extBaseSort } = (0, utils_1.splitSortForExtended)(sort, baseAttrKeys);
|
|
360
319
|
const needPrivateForHydrate = true;
|
|
361
320
|
const requiresBaseProcessing = Object.keys(extBaseFilter).length > 0 ||
|
|
362
321
|
Object.keys(extBaseSort).length > 0;
|
|
363
|
-
let
|
|
322
|
+
let dataRows = [];
|
|
364
323
|
if (requiresBaseProcessing) {
|
|
365
|
-
|
|
324
|
+
// If we need to filter/sort by base fields, read ALL child rows (already hydrated by adapter)
|
|
325
|
+
dataRows = await model.read(childFilter, 0, 0, needPrivateForHydrate, { projection });
|
|
366
326
|
}
|
|
367
327
|
else {
|
|
368
|
-
|
|
328
|
+
// Normal paginated read (already hydrated by adapter)
|
|
329
|
+
dataRows = await model.read(childFilter, limit, skip, needPrivateForHydrate, {
|
|
330
|
+
sort: Object.keys(childSort).length ? childSort : undefined,
|
|
331
|
+
projection,
|
|
332
|
+
});
|
|
369
333
|
}
|
|
370
|
-
|
|
371
|
-
const baseIds = Array.from(new Set(childRows
|
|
372
|
-
.map((r) => (0, utils_1.toIdString)(r?.baseId))
|
|
373
|
-
.filter((s) => !!s)));
|
|
374
|
-
const baseDocs = baseIds.length
|
|
375
|
-
? await baseModel.read({ id: { $in: baseIds } }, baseIds.length, 0, !!rdec.exposePrivate)
|
|
376
|
-
: [];
|
|
377
|
-
const baseMap = new Map(baseDocs.map((b) => [String(b.id), b]));
|
|
378
|
-
// merge, but force merged id to BASE id (single entity identity)
|
|
379
|
-
let merged = childRows.map((row) => {
|
|
380
|
-
const bid = (0, utils_1.toIdString)(row?.baseId);
|
|
381
|
-
const base = bid ? baseMap.get(bid) : null;
|
|
382
|
-
const m = base ? { ...base, ...row } : { ...row };
|
|
383
|
-
// expose base id as exId, keep child's id as id
|
|
384
|
-
m.exId = String(base?.id ?? base?._id ?? bid ?? '');
|
|
385
|
-
m.id = String(row?.id ?? row?._id);
|
|
386
|
-
m._id = m.id;
|
|
387
|
-
// optional: keep for trace/debug
|
|
388
|
-
m.__childId = m.id;
|
|
389
|
-
delete m.baseId;
|
|
390
|
-
return m;
|
|
391
|
-
});
|
|
334
|
+
let merged = dataRows;
|
|
392
335
|
// apply base-level filters/sort, then DEDUPE by canonical id
|
|
393
336
|
if (Object.keys(extBaseFilter).length) {
|
|
394
337
|
merged = merged.filter((m) => (0, utils_1.matchDoc)(m, extBaseFilter));
|
|
@@ -396,10 +339,18 @@ function mountCrudRoutes(ctx, modelNameLC) {
|
|
|
396
339
|
const combinedSort = { ...extBaseSort, ...childSort };
|
|
397
340
|
if (Object.keys(combinedSort).length) {
|
|
398
341
|
merged = merged.sort((a, b) => {
|
|
342
|
+
const getField = (obj, k) => {
|
|
343
|
+
if (obj?.[k] !== undefined)
|
|
344
|
+
return obj[k];
|
|
345
|
+
if (obj?.baseId && typeof obj.baseId === 'object' && obj.baseId[k] !== undefined) {
|
|
346
|
+
return obj.baseId[k];
|
|
347
|
+
}
|
|
348
|
+
return undefined;
|
|
349
|
+
};
|
|
399
350
|
for (const k of Object.keys(combinedSort)) {
|
|
400
351
|
const dir = combinedSort[k];
|
|
401
|
-
const av = a
|
|
402
|
-
const bv = b
|
|
352
|
+
const av = getField(a, k);
|
|
353
|
+
const bv = getField(b, k);
|
|
403
354
|
const ax = av instanceof Date ? +av : av;
|
|
404
355
|
const bx = bv instanceof Date ? +bv : bv;
|
|
405
356
|
if (ax == null && bx == null)
|
|
@@ -426,16 +377,18 @@ function mountCrudRoutes(ctx, modelNameLC) {
|
|
|
426
377
|
let paged;
|
|
427
378
|
if (requiresBaseProcessing) {
|
|
428
379
|
totalRows = merged.length;
|
|
429
|
-
const start =
|
|
380
|
+
const start = skip;
|
|
430
381
|
paged = merged.slice(start, start + limit);
|
|
431
382
|
}
|
|
432
383
|
else {
|
|
433
|
-
|
|
384
|
+
// Get actual total count from database (not just current page length)
|
|
385
|
+
totalRows = await model.count(childFilter);
|
|
434
386
|
paged = merged.slice(0, limit);
|
|
435
387
|
}
|
|
436
388
|
const data = rdec.exposePrivate
|
|
437
389
|
? (0, authorize_1.applyReadMaskMany)(paged, rdec.sensitiveMask)
|
|
438
390
|
: (0, authorize_1.applyReadMaskMany)(paged, rdec.readMask);
|
|
391
|
+
await injectActionRelatedData(ctx, schema, data);
|
|
439
392
|
return res.status(200).json({
|
|
440
393
|
success: true,
|
|
441
394
|
message: `Data fetched for ${schema.modelName}`,
|
|
@@ -445,15 +398,14 @@ function mountCrudRoutes(ctx, modelNameLC) {
|
|
|
445
398
|
});
|
|
446
399
|
}
|
|
447
400
|
// ---------- NON-EXTENDED ----------
|
|
448
|
-
const sort = baseSort;
|
|
449
401
|
const totalRows = await model.count(finalFilter);
|
|
450
402
|
let rawRows = [];
|
|
451
403
|
const exposePrivateForRead = !!rdec.exposePrivate;
|
|
452
404
|
try {
|
|
453
|
-
rawRows = await model.read(finalFilter, limit + 1,
|
|
405
|
+
rawRows = await model.read(finalFilter, limit + 1, skip, exposePrivateForRead, { sort, projection });
|
|
454
406
|
}
|
|
455
407
|
catch {
|
|
456
|
-
rawRows = await model.read(finalFilter, limit + 1,
|
|
408
|
+
rawRows = await model.read(finalFilter, limit + 1, skip, exposePrivateForRead);
|
|
457
409
|
if (sort && Object.keys(sort).length) {
|
|
458
410
|
const orderKeys = Object.keys(sort);
|
|
459
411
|
rawRows.sort((a, b) => {
|
|
@@ -486,6 +438,7 @@ function mountCrudRoutes(ctx, modelNameLC) {
|
|
|
486
438
|
const data = rdec.exposePrivate
|
|
487
439
|
? (0, authorize_1.applyReadMaskMany)(rows, rdec.sensitiveMask)
|
|
488
440
|
: (0, authorize_1.applyReadMaskMany)(rows, rdec.readMask);
|
|
441
|
+
await injectActionRelatedData(ctx, schema, data);
|
|
489
442
|
return res.status(200).json({
|
|
490
443
|
success: true,
|
|
491
444
|
message: `Data fetched for ${schema.modelName}`,
|
|
@@ -511,6 +464,8 @@ function mountCrudRoutes(ctx, modelNameLC) {
|
|
|
511
464
|
.json({ error: true, message: 'Method not allowed' });
|
|
512
465
|
}
|
|
513
466
|
const model = ctx.getModel(modelNameLC);
|
|
467
|
+
const baseKeys = new Set(Object.keys(schema.attributes || {}));
|
|
468
|
+
const { projection } = (0, utils_1.parseQuery)(req, baseKeys);
|
|
514
469
|
const pctx = ctxFromReq(req);
|
|
515
470
|
const schemaPolicy = {
|
|
516
471
|
allowedMethods: schema.allowedMethods,
|
|
@@ -519,7 +474,16 @@ function mountCrudRoutes(ctx, modelNameLC) {
|
|
|
519
474
|
const decForInclude = (0, authorize_1.authorize)(modelNameLC, 'read', schemaPolicy, pctx);
|
|
520
475
|
const needPrivateForHydrate = !!schema.extends;
|
|
521
476
|
const exposePrivateForRead = needPrivateForHydrate || !!decForInclude.exposePrivate;
|
|
522
|
-
const recordArr = await model.read({ id: req.params.id }, 1, 0, exposePrivateForRead
|
|
477
|
+
const recordArr = await model.read({ id: req.params.id }, 1, 0, exposePrivateForRead, {
|
|
478
|
+
projection: (() => {
|
|
479
|
+
if (!projection)
|
|
480
|
+
return undefined;
|
|
481
|
+
if (!schema.extends)
|
|
482
|
+
return projection;
|
|
483
|
+
// For extended schemas, ensure baseId is included for hydration
|
|
484
|
+
return { ...projection, baseId: 1 };
|
|
485
|
+
})()
|
|
486
|
+
});
|
|
523
487
|
const doc = recordArr?.[0];
|
|
524
488
|
if (!doc) {
|
|
525
489
|
return res
|
|
@@ -531,22 +495,20 @@ function mountCrudRoutes(ctx, modelNameLC) {
|
|
|
531
495
|
return res.status(403).json({ error: true, message: 'forbidden' });
|
|
532
496
|
let toReturn = doc;
|
|
533
497
|
if (schema.extends) {
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
if (baseDoc) {
|
|
541
|
-
toReturn = { ...baseDoc, ...doc };
|
|
542
|
-
toReturn.exId = String(baseDoc.id ?? baseDoc._id ?? baseIdStr);
|
|
543
|
-
toReturn.id = String(doc.id ?? doc._id);
|
|
544
|
-
toReturn._id = toReturn.id;
|
|
545
|
-
delete toReturn.baseId;
|
|
498
|
+
// Hydration is handled by adapter.
|
|
499
|
+
// We just ensure exId and baseId consistency if hydrated.
|
|
500
|
+
if (doc) {
|
|
501
|
+
toReturn = { ...doc };
|
|
502
|
+
if (!toReturn.exId && toReturn.baseId) {
|
|
503
|
+
toReturn.exId = (0, utils_1.toIdString)(toReturn.baseId);
|
|
546
504
|
}
|
|
505
|
+
if (toReturn.id)
|
|
506
|
+
toReturn.id = String(toReturn.id);
|
|
507
|
+
if (toReturn._id)
|
|
508
|
+
toReturn._id = String(toReturn._id);
|
|
509
|
+
if (toReturn.baseId)
|
|
510
|
+
toReturn.baseId = String(toReturn.baseId);
|
|
547
511
|
}
|
|
548
|
-
if (toReturn)
|
|
549
|
-
delete toReturn.baseId;
|
|
550
512
|
}
|
|
551
513
|
const data = dec.exposePrivate
|
|
552
514
|
? (0, authorize_1.applyReadMaskOne)(toReturn, dec.sensitiveMask)
|
|
@@ -613,25 +575,36 @@ function mountCrudRoutes(ctx, modelNameLC) {
|
|
|
613
575
|
const baseLC = baseName.toLowerCase();
|
|
614
576
|
const baseSchema = ctx.getSchema(baseLC);
|
|
615
577
|
const baseModel = ctx.getModel(baseLC);
|
|
616
|
-
|
|
617
|
-
const
|
|
618
|
-
|
|
619
|
-
return res.status(400).json({
|
|
620
|
-
error: true,
|
|
621
|
-
message: 'Invalid extended record: missing baseId',
|
|
622
|
-
});
|
|
623
|
-
}
|
|
578
|
+
// Determine target baseId: prefer payload if changed, otherwise current
|
|
579
|
+
const targetBaseIdRaw = upd.baseId !== undefined ? upd.baseId : before?.baseId;
|
|
580
|
+
const targetBaseId = (0, utils_1.toIdString)(targetBaseIdRaw);
|
|
624
581
|
const baseUpd = {};
|
|
625
582
|
const childUpd = {};
|
|
626
583
|
for (const [k, v] of Object.entries(upd)) {
|
|
627
|
-
if (k === 'baseId')
|
|
584
|
+
if (k === 'baseId') {
|
|
585
|
+
childUpd[k] = v;
|
|
628
586
|
continue;
|
|
629
|
-
|
|
587
|
+
}
|
|
588
|
+
const baseAttr = baseSchema.attributes[k];
|
|
589
|
+
const childAttr = schema.attributes[k];
|
|
590
|
+
const bHead = Array.isArray(baseAttr) ? baseAttr[0] : baseAttr;
|
|
591
|
+
const cHead = Array.isArray(childAttr) ? childAttr[0] : childAttr;
|
|
592
|
+
if (bHead?.safe === true) {
|
|
630
593
|
baseUpd[k] = v;
|
|
631
|
-
|
|
594
|
+
}
|
|
595
|
+
else if (cHead && !cHead.inherited) {
|
|
632
596
|
childUpd[k] = v;
|
|
597
|
+
}
|
|
598
|
+
else if (baseAttr) {
|
|
599
|
+
baseUpd[k] = v;
|
|
600
|
+
}
|
|
601
|
+
else {
|
|
602
|
+
childUpd[k] = v;
|
|
603
|
+
}
|
|
633
604
|
}
|
|
634
|
-
const baseConflicts =
|
|
605
|
+
const baseConflicts = targetBaseId
|
|
606
|
+
? await ctx.checkUniqueFields(baseSchema, baseUpd, targetBaseId || undefined)
|
|
607
|
+
: null;
|
|
635
608
|
if (baseConflicts && baseConflicts.length) {
|
|
636
609
|
return res.status(400).json({
|
|
637
610
|
error: true,
|
|
@@ -669,17 +642,32 @@ function mountCrudRoutes(ctx, modelNameLC) {
|
|
|
669
642
|
if (Object.keys(childUpd).length) {
|
|
670
643
|
updatedChild = await model.update(req.params.id, childUpd);
|
|
671
644
|
}
|
|
672
|
-
if (Object.keys(baseUpd).length) {
|
|
673
|
-
await baseModel.update(
|
|
645
|
+
if (targetBaseId && Object.keys(baseUpd).length) {
|
|
646
|
+
await baseModel.update(targetBaseId, baseUpd);
|
|
647
|
+
}
|
|
648
|
+
const [refChild] = await model.read({ id: String(req.params.id) }, 1, 0, true);
|
|
649
|
+
const refBase = targetBaseId
|
|
650
|
+
? (await baseModel.read({ id: targetBaseId }, 1, 0, true))?.[0]
|
|
651
|
+
: null;
|
|
652
|
+
let responseDoc = refChild ? { ...refChild } : { ...updatedChild, id: req.params.id };
|
|
653
|
+
if (refChild && refBase) {
|
|
654
|
+
// Overlap logic: prefer child value if it's set/not-empty
|
|
655
|
+
responseDoc = { ...refBase };
|
|
656
|
+
for (const k of Object.keys(refChild)) {
|
|
657
|
+
const val = refChild[k];
|
|
658
|
+
const hasValue = val != null && (!Array.isArray(val) || val.length > 0);
|
|
659
|
+
if (hasValue) {
|
|
660
|
+
responseDoc[k] = val;
|
|
661
|
+
}
|
|
662
|
+
}
|
|
674
663
|
}
|
|
675
|
-
const [refChild] = await model.read({ id: String(updatedChild.id) }, 1, 0, true);
|
|
676
|
-
const [refBase] = await baseModel.read({ id: baseId }, 1, 0, true);
|
|
677
|
-
let responseDoc = refChild && refBase ? { ...refBase, ...refChild } : updatedChild;
|
|
678
664
|
if (responseDoc) {
|
|
679
|
-
responseDoc.exId = String(
|
|
680
|
-
responseDoc.id = String(
|
|
665
|
+
responseDoc.exId = String(targetBaseId || '');
|
|
666
|
+
responseDoc.id = String(req.params.id);
|
|
681
667
|
responseDoc._id = responseDoc.id;
|
|
682
|
-
|
|
668
|
+
if (responseDoc.baseId)
|
|
669
|
+
responseDoc.baseId = String(responseDoc.baseId);
|
|
670
|
+
// delete (responseDoc as any).baseId;
|
|
683
671
|
}
|
|
684
672
|
const masked = udec.exposePrivate
|
|
685
673
|
? (0, authorize_1.applyReadMaskOne)(responseDoc, udec.sensitiveMask)
|
|
@@ -83,31 +83,7 @@ function mountFindRoutes(ctx) {
|
|
|
83
83
|
else {
|
|
84
84
|
items = await ctx.getModel(targetLC).read({ id: { $in: ids } }, limit, skip, !!tDec.exposePrivate);
|
|
85
85
|
}
|
|
86
|
-
// extended hydration
|
|
87
|
-
if (targetSchema.extends && Array.isArray(items) && items.length) {
|
|
88
|
-
const baseName = String(targetSchema.extends);
|
|
89
|
-
const baseLC = baseName.toLowerCase();
|
|
90
|
-
const baseModel = ctx.getModel(baseLC);
|
|
91
|
-
const baseIds = Array.from(new Set(items.map((it) => it?.baseId).filter(Boolean)));
|
|
92
|
-
if (baseIds.length) {
|
|
93
|
-
const baseDocs = await baseModel.read({ id: { $in: baseIds } }, baseIds.length, 0, !!tDec.exposePrivate);
|
|
94
|
-
const baseMap = new Map(baseDocs.map((b) => [String(b?.id ?? b?._id), b]));
|
|
95
|
-
items = items.map((it) => {
|
|
96
|
-
const bid = String(it?.baseId || "");
|
|
97
|
-
const b = bid ? baseMap.get(bid) : null;
|
|
98
|
-
const merged = b ? { ...b, ...it } : { ...it };
|
|
99
|
-
delete merged.baseId;
|
|
100
|
-
return merged;
|
|
101
|
-
});
|
|
102
|
-
}
|
|
103
|
-
else {
|
|
104
|
-
items = items.map((it) => {
|
|
105
|
-
const copy = { ...it };
|
|
106
|
-
delete copy.baseId;
|
|
107
|
-
return copy;
|
|
108
|
-
});
|
|
109
|
-
}
|
|
110
|
-
}
|
|
86
|
+
// extended hydration is handled by adapter.read/findMany
|
|
111
87
|
const masked = tDec.exposePrivate ? (0, authorize_1.applyReadMaskMany)(items, tDec.sensitiveMask) : (0, authorize_1.applyReadMaskMany)(items, tDec.readMask);
|
|
112
88
|
const totalRows = ids.length;
|
|
113
89
|
return res.status(200).json({
|
|
@@ -163,30 +139,7 @@ function mountFindRoutes(ctx) {
|
|
|
163
139
|
totalRows = all.length;
|
|
164
140
|
items = all.slice(skip, skip + limit);
|
|
165
141
|
}
|
|
166
|
-
|
|
167
|
-
const baseName = String(targetSchema.extends);
|
|
168
|
-
const baseLC = baseName.toLowerCase();
|
|
169
|
-
const baseModel = ctx.getModel(baseLC);
|
|
170
|
-
const baseIds = Array.from(new Set(items.map((it) => it?.baseId).filter(Boolean)));
|
|
171
|
-
if (baseIds.length) {
|
|
172
|
-
const baseDocs = await baseModel.read({ id: { $in: baseIds } }, baseIds.length, 0, !!tDec.exposePrivate);
|
|
173
|
-
const baseMap = new Map(baseDocs.map((b) => [String(b?.id ?? b?._id), b]));
|
|
174
|
-
items = items.map((it) => {
|
|
175
|
-
const bid = String(it?.baseId || "");
|
|
176
|
-
const b = bid ? baseMap.get(bid) : null;
|
|
177
|
-
const merged = b ? { ...b, ...it } : { ...it };
|
|
178
|
-
delete merged.baseId;
|
|
179
|
-
return merged;
|
|
180
|
-
});
|
|
181
|
-
}
|
|
182
|
-
else {
|
|
183
|
-
items = items.map((it) => {
|
|
184
|
-
const copy = { ...it };
|
|
185
|
-
delete copy.baseId;
|
|
186
|
-
return copy;
|
|
187
|
-
});
|
|
188
|
-
}
|
|
189
|
-
}
|
|
142
|
+
// extended hydration is handled by adapter.read/findMany
|
|
190
143
|
const masked = tDec.exposePrivate ? (0, authorize_1.applyReadMaskMany)(items, tDec.sensitiveMask) : (0, authorize_1.applyReadMaskMany)(items, tDec.readMask);
|
|
191
144
|
return res.status(200).json({
|
|
192
145
|
success: true,
|