@airoom/nextmin-node 0.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.
Files changed (45) hide show
  1. package/LICENSE +49 -0
  2. package/README.md +178 -0
  3. package/dist/api/apiRouter.d.ts +65 -0
  4. package/dist/api/apiRouter.js +1548 -0
  5. package/dist/database/DatabaseAdapter.d.ts +14 -0
  6. package/dist/database/DatabaseAdapter.js +2 -0
  7. package/dist/database/InMemoryAdapter.d.ts +15 -0
  8. package/dist/database/InMemoryAdapter.js +71 -0
  9. package/dist/database/MongoAdapter.d.ts +52 -0
  10. package/dist/database/MongoAdapter.js +409 -0
  11. package/dist/files/FileStorageAdapter.d.ts +35 -0
  12. package/dist/files/FileStorageAdapter.js +2 -0
  13. package/dist/files/S3FileStorageAdapter.d.ts +30 -0
  14. package/dist/files/S3FileStorageAdapter.js +84 -0
  15. package/dist/files/filename.d.ts +5 -0
  16. package/dist/files/filename.js +40 -0
  17. package/dist/index.d.ts +13 -0
  18. package/dist/index.js +87 -0
  19. package/dist/models/BaseModel.d.ts +44 -0
  20. package/dist/models/BaseModel.js +31 -0
  21. package/dist/policy/authorize.d.ts +25 -0
  22. package/dist/policy/authorize.js +305 -0
  23. package/dist/policy/conditions.d.ts +14 -0
  24. package/dist/policy/conditions.js +30 -0
  25. package/dist/policy/types.d.ts +53 -0
  26. package/dist/policy/types.js +2 -0
  27. package/dist/policy/utils.d.ts +9 -0
  28. package/dist/policy/utils.js +118 -0
  29. package/dist/schemas/Roles.json +64 -0
  30. package/dist/schemas/Settings.json +62 -0
  31. package/dist/schemas/Users.json +123 -0
  32. package/dist/services/SchemaService.d.ts +10 -0
  33. package/dist/services/SchemaService.js +46 -0
  34. package/dist/utils/DefaultDataInitializer.d.ts +21 -0
  35. package/dist/utils/DefaultDataInitializer.js +269 -0
  36. package/dist/utils/Logger.d.ts +12 -0
  37. package/dist/utils/Logger.js +79 -0
  38. package/dist/utils/SchemaLoader.d.ts +51 -0
  39. package/dist/utils/SchemaLoader.js +323 -0
  40. package/dist/utils/apiKey.d.ts +5 -0
  41. package/dist/utils/apiKey.js +14 -0
  42. package/dist/utils/fieldCodecs.d.ts +13 -0
  43. package/dist/utils/fieldCodecs.js +133 -0
  44. package/package.json +45 -0
  45. package/tsconfig.json +8 -0
@@ -0,0 +1,1548 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.APIRouter = void 0;
7
+ const express_1 = __importDefault(require("express"));
8
+ const BaseModel_1 = require("../models/BaseModel");
9
+ const Logger_1 = __importDefault(require("../utils/Logger"));
10
+ const bcrypt_1 = __importDefault(require("bcrypt"));
11
+ const jsonwebtoken_1 = __importDefault(require("jsonwebtoken"));
12
+ const SchemaLoader_1 = require("../utils/SchemaLoader");
13
+ const InMemoryAdapter_1 = require("../database/InMemoryAdapter");
14
+ const DefaultDataInitializer_1 = require("../utils/DefaultDataInitializer");
15
+ const SchemaService_1 = require("../services/SchemaService");
16
+ const multer_1 = __importDefault(require("multer"));
17
+ const filename_1 = require("../files/filename");
18
+ const authorize_1 = require("../policy/authorize");
19
+ const fieldCodecs_1 = require("../utils/fieldCodecs");
20
+ class APIRouter {
21
+ constructor(options) {
22
+ this.models = {};
23
+ this.findRoutesMounted = false;
24
+ this.authRoutesInitialized = false;
25
+ this.schemasRouteRegistered = false;
26
+ this.registeredModels = new Set();
27
+ this.liveSchemas = {};
28
+ // ---------------- Live lookups ----------------
29
+ this.getSchema = (name) => {
30
+ const s = this.liveSchemas[name];
31
+ if (!s) {
32
+ const err = new Error(`Model '${name}' removed`);
33
+ err.code = 'MODEL_REMOVED';
34
+ throw err;
35
+ }
36
+ return s;
37
+ };
38
+ this.getModel = (name) => {
39
+ this.getSchema(name);
40
+ const m = this.models[name];
41
+ if (!m) {
42
+ const err = new Error(`Model '${name}' unavailable`);
43
+ err.code = 'MODEL_UNAVAILABLE';
44
+ throw err;
45
+ }
46
+ return m;
47
+ };
48
+ this.fileAuthMiddleware = (req, res, next) => this.authenticateMiddleware(req, res, next);
49
+ // ---------------- Auth (login/me/change-password) ----------------
50
+ this.optionalAuthMiddleware = (req, res, next) => {
51
+ this.apiKeyMiddleware(req, res, () => {
52
+ const authHeader = req.headers.authorization;
53
+ if (!authHeader)
54
+ return next();
55
+ const token = authHeader.split(' ')[1];
56
+ if (!token)
57
+ return next();
58
+ try {
59
+ const decoded = jsonwebtoken_1.default.verify(token, this.jwtSecret);
60
+ // @ts-ignore
61
+ req.user = decoded;
62
+ }
63
+ catch { }
64
+ next();
65
+ });
66
+ };
67
+ this.normalizeRoleName = async (value) => {
68
+ const rolesModel = this.getModel('roles'); // already available in your router
69
+ const isHex24 = (s) => /^[0-9a-fA-F]{24}$/.test(s);
70
+ const fromString = async (s) => {
71
+ if (!s)
72
+ return null;
73
+ if (isHex24(s) && rolesModel) {
74
+ const docs = await rolesModel.read({ id: s }, 1, 0, true);
75
+ const n = docs?.[0]?.name;
76
+ return typeof n === 'string' ? n : null;
77
+ }
78
+ return s; // assume literal role name like "admin"
79
+ };
80
+ if (typeof value === 'string')
81
+ return (await fromString(value))?.toLowerCase() ?? null;
82
+ if (Array.isArray(value)) {
83
+ for (const v of value) {
84
+ const n = typeof v === 'string'
85
+ ? await fromString(v)
86
+ : v && typeof v === 'object' && typeof v.name === 'string'
87
+ ? v.name
88
+ : null;
89
+ if (n)
90
+ return n.toLowerCase();
91
+ }
92
+ return null;
93
+ }
94
+ if (value && typeof value === 'object') {
95
+ const o = value;
96
+ if (typeof o.name === 'string')
97
+ return o.name.toLowerCase();
98
+ if (typeof o.id === 'string') {
99
+ const n = await fromString(o.id);
100
+ return n ? n.toLowerCase() : null;
101
+ }
102
+ }
103
+ return null;
104
+ };
105
+ this.apiKeyMiddleware = (req, res, next) => {
106
+ const apiKey = req.headers['x-api-key'];
107
+ if (typeof apiKey !== 'string' || apiKey !== this.trustedApiKey) {
108
+ return res.status(401).json({ error: 'Invalid or missing API key' });
109
+ }
110
+ next();
111
+ };
112
+ this.authenticateMiddleware = (req, res, next) => {
113
+ this.apiKeyMiddleware(req, res, () => {
114
+ const authHeader = req.headers.authorization;
115
+ if (!authHeader)
116
+ return res.status(401).json({ error: 'Authorization header missing' });
117
+ const token = authHeader.split(' ')[1];
118
+ if (!token)
119
+ return res.status(401).json({ error: 'API Token missing.' });
120
+ try {
121
+ const decoded = jsonwebtoken_1.default.verify(token, this.jwtSecret);
122
+ // @ts-ignore
123
+ req.user = decoded;
124
+ next();
125
+ }
126
+ catch {
127
+ res.status(401).json({ error: 'Invalid or expired token' });
128
+ }
129
+ });
130
+ };
131
+ this.isDevelopment = process.env.APP_MODE !== 'production';
132
+ this.router = express_1.default.Router();
133
+ this.fileStorage = options.fileStorageAdapter;
134
+ this.setupFileRoutes();
135
+ this.dbAdapter = options.dbAdapter || new InMemoryAdapter_1.InMemoryAdapter();
136
+ this.jwtSecret = process.env.JWT_SECRET || 'default_jwt_secret';
137
+ if (this.isDevelopment && options.server) {
138
+ (0, SchemaService_1.startSchemaService)(options.server, {
139
+ getApiKey: () => this.trustedApiKey,
140
+ });
141
+ options.server.on('listening', () => {
142
+ // @ts-ignore
143
+ const addr = options.server.address();
144
+ Logger_1.default.info('SchemaService', `[schema-service] started at /__nextmin__/schema ns /schema on ${typeof addr === 'string' ? addr : `${addr?.address}:${addr?.port}`}`);
145
+ });
146
+ }
147
+ this.schemaLoader =
148
+ SchemaLoader_1.SchemaLoader.getInstance?.() ?? new SchemaLoader_1.SchemaLoader();
149
+ const initialSchemas = this.schemaLoader.getSchemas();
150
+ this.setLiveSchemas(initialSchemas);
151
+ const finishBoot = async () => {
152
+ if (typeof this.dbAdapter.registerSchemas === 'function') {
153
+ await this.dbAdapter.registerSchemas(initialSchemas);
154
+ }
155
+ this.rebuildModels(Object.values(initialSchemas));
156
+ this.mountSchemasEndpointOnce();
157
+ this.mountRoutes(initialSchemas);
158
+ this.mountFindRoutes();
159
+ await this.syncAllIndexes(initialSchemas);
160
+ const initializer = new DefaultDataInitializer_1.DefaultDataInitializer(this.dbAdapter, this.models);
161
+ try {
162
+ await initializer.initialize();
163
+ this.trustedApiKey = initializer.getApiKey() || '';
164
+ Logger_1.default.info('APIRouter', `Trusted API key set: ${this.trustedApiKey ? '[hidden]' : 'none'}`);
165
+ }
166
+ catch (err) {
167
+ Logger_1.default.error('APIRouter', 'Failed to initialize default data', err);
168
+ }
169
+ this.setupNotFoundMiddleware();
170
+ this.wireSchemaHotReload();
171
+ };
172
+ if (typeof this.dbAdapter.registerSchemas === 'function') {
173
+ const res = this.dbAdapter.registerSchemas(initialSchemas);
174
+ if (res instanceof Promise) {
175
+ res.then(finishBoot).catch((err) => {
176
+ Logger_1.default.error('APIRouter', 'registerSchemas failed', err);
177
+ this.setupNotFoundMiddleware();
178
+ });
179
+ return;
180
+ }
181
+ }
182
+ finishBoot().catch((err) => {
183
+ Logger_1.default.error('APIRouter', 'finishBoot error', err);
184
+ this.setupNotFoundMiddleware();
185
+ });
186
+ }
187
+ getRouter() {
188
+ // Avoid noisy logs on each getRouter() call
189
+ return this.router;
190
+ }
191
+ // ---------------- Hot reload ----------------
192
+ wireSchemaHotReload() {
193
+ const anyLoader = this.schemaLoader;
194
+ if (typeof anyLoader.on !== 'function')
195
+ return;
196
+ anyLoader.on('schemasChanged', async (newSchemas) => {
197
+ try {
198
+ const removed = this.diffRemovedModels(this.liveSchemas, newSchemas);
199
+ if (removed.length &&
200
+ typeof this.dbAdapter.unregisterSchemas === 'function') {
201
+ await this.dbAdapter.unregisterSchemas(removed);
202
+ }
203
+ else if (removed.length &&
204
+ typeof this.dbAdapter.dropModel === 'function') {
205
+ for (const name of removed) {
206
+ try {
207
+ await this.dbAdapter.dropModel(name);
208
+ }
209
+ catch { }
210
+ }
211
+ }
212
+ if (typeof this.dbAdapter.registerSchemas === 'function') {
213
+ await this.dbAdapter.registerSchemas(newSchemas);
214
+ }
215
+ this.setLiveSchemas(newSchemas);
216
+ this.rebuildModels(Object.values(newSchemas));
217
+ this.mountRoutes(newSchemas);
218
+ await this.syncAllIndexes(newSchemas);
219
+ Logger_1.default.info('APIRouter', `Schemas reloaded (added/updated: ${Object.keys(newSchemas).length}, removed: ${removed.join(', ') || 'none'})`);
220
+ }
221
+ catch (err) {
222
+ Logger_1.default.error('APIRouter', 'Failed to refresh after schemasChanged', err);
223
+ }
224
+ });
225
+ }
226
+ async syncAllIndexes(schemas) {
227
+ if (typeof this.dbAdapter.syncIndexes !== 'function')
228
+ return;
229
+ const plan = this.schemaLoader.getIndexPlan(); // modelName -> { field: dir }
230
+ for (const s of Object.values(schemas)) {
231
+ const modelName = s.modelName;
232
+ const spec = plan[modelName];
233
+ if (spec) {
234
+ try {
235
+ await this.dbAdapter.syncIndexes(modelName, spec);
236
+ }
237
+ catch (err) {
238
+ Logger_1.default.warn('APIRouter', `Index sync failed for ${modelName}: ${err?.message || err}`);
239
+ }
240
+ }
241
+ }
242
+ }
243
+ diffRemovedModels(prev, nextMap) {
244
+ const next = new Set(Object.keys(nextMap).map((k) => k.toLowerCase()));
245
+ const removed = [];
246
+ for (const name of Object.keys(prev)) {
247
+ if (!next.has(name.toLowerCase()))
248
+ removed.push(name.toLowerCase());
249
+ }
250
+ return removed.filter((n) => n !== 'users' && n !== 'roles');
251
+ }
252
+ setLiveSchemas(schemas) {
253
+ const idx = {};
254
+ for (const s of Object.values(schemas)) {
255
+ idx[s.modelName.toLowerCase()] = s;
256
+ }
257
+ this.liveSchemas = idx;
258
+ }
259
+ rebuildModels(schemas) {
260
+ for (const s of schemas) {
261
+ const name = s.modelName.toLowerCase();
262
+ this.models[name] = new BaseModel_1.BaseModel(s, this.dbAdapter);
263
+ }
264
+ }
265
+ // ---------------- Mounting ----------------
266
+ mountSchemasEndpointOnce() {
267
+ if (this.schemasRouteRegistered)
268
+ return;
269
+ this.schemasRouteRegistered = true;
270
+ this.router.get('/_schemas', this.apiKeyMiddleware, (_req, res) => {
271
+ res.json({
272
+ success: true,
273
+ data: this.schemaLoader.getPublicSchemaList(),
274
+ });
275
+ });
276
+ }
277
+ mountRoutes(schemas) {
278
+ if (!this.authRoutesInitialized && this.liveSchemas['users']) {
279
+ this.setupAuthRoutes();
280
+ this.authRoutesInitialized = true;
281
+ }
282
+ for (const s of Object.values(schemas)) {
283
+ const name = s.modelName.toLowerCase();
284
+ if (this.registeredModels.has(name))
285
+ continue;
286
+ this.setupRoutes(name);
287
+ this.registeredModels.add(name);
288
+ }
289
+ this.ensureNotFoundLast();
290
+ }
291
+ // ---------------- Generic CRUD (policy-enforced) ----------------
292
+ setupRoutes(modelNameLC) {
293
+ const basePath = `/${modelNameLC}`;
294
+ const mwCreate = this.pickAuthFor('create', modelNameLC);
295
+ const mwRead = this.pickAuthFor('read', modelNameLC);
296
+ const mwUpdate = this.pickAuthFor('update', modelNameLC);
297
+ const mwDelete = this.pickAuthFor('delete', modelNameLC);
298
+ const ctxFromReq = (req) => {
299
+ const raw = req.user?.role;
300
+ let roleStr = null;
301
+ if (typeof raw === 'string') {
302
+ roleStr = raw;
303
+ }
304
+ else if (Array.isArray(raw)) {
305
+ const first = raw[0];
306
+ if (typeof first === 'string')
307
+ roleStr = first;
308
+ else if (first &&
309
+ typeof first === 'object' &&
310
+ typeof first.name === 'string') {
311
+ roleStr = first.name;
312
+ }
313
+ }
314
+ else if (raw &&
315
+ typeof raw === 'object' &&
316
+ typeof raw.name === 'string') {
317
+ roleStr = raw.name;
318
+ }
319
+ roleStr = roleStr ? roleStr.toLowerCase() : null;
320
+ return {
321
+ isAuthenticated: !!req.user,
322
+ role: roleStr,
323
+ userId: req.user?.id ?? req.user?._id ?? null,
324
+ isSuperadmin: roleStr === 'superadmin',
325
+ apiKeyOk: true,
326
+ };
327
+ };
328
+ // -------------- CREATE --------------
329
+ this.router.post(basePath, mwCreate, async (req, res) => {
330
+ try {
331
+ const schema = this.getSchema(modelNameLC);
332
+ if (!schema.allowedMethods.create) {
333
+ return res
334
+ .status(405)
335
+ .json({ error: true, message: 'Method not allowed' });
336
+ }
337
+ const model = this.getModel(modelNameLC);
338
+ const ctx = ctxFromReq(req);
339
+ const schemaPolicy = {
340
+ allowedMethods: schema.allowedMethods,
341
+ access: schema.access,
342
+ };
343
+ const cdec = (0, authorize_1.authorize)(modelNameLC, 'create', schemaPolicy, ctx);
344
+ if (!cdec.allow) {
345
+ return res.status(403).json({ error: true, message: 'forbidden' });
346
+ }
347
+ let payload = (0, authorize_1.mergeCreateDefaults)(req.body, cdec.createDefaults);
348
+ (0, authorize_1.enforceRestrictions)(payload, cdec.restrictions, ctx);
349
+ if (!cdec.exposePrivate) {
350
+ payload = (0, authorize_1.stripWriteDeny)(payload, cdec.writeDeny);
351
+ }
352
+ // ✅ NEW: DB-agnostic coercion (time/date/range → canonical strings)
353
+ payload = (0, fieldCodecs_1.coerceForStorage)(schema, payload);
354
+ // CREATE: required fields must be present and non-empty (now in canonical shape)
355
+ const missing = this.validateRequiredFields(schema, payload, 'create');
356
+ if (missing.length) {
357
+ return res.status(400).json({
358
+ error: true,
359
+ message: `Missing required fields: ${missing.join(', ')}`,
360
+ });
361
+ }
362
+ const conflicts = await this.checkUniqueFields(schema, payload);
363
+ if (conflicts && conflicts.length > 0) {
364
+ return res.status(400).json({
365
+ error: true,
366
+ fields: conflicts.map((field) => ({
367
+ field,
368
+ error: true,
369
+ message: `You cannot use this ${field}. It's already been used.`,
370
+ })),
371
+ });
372
+ }
373
+ // Hash password for Users creates
374
+ if (modelNameLC === 'users' && payload.password && this.jwtSecret) {
375
+ const salt = await bcrypt_1.default.genSalt(10);
376
+ payload.password = await bcrypt_1.default.hash(String(payload.password) + this.jwtSecret, salt);
377
+ }
378
+ const created = await model.create(payload);
379
+ let resultDoc = created;
380
+ if (cdec.exposePrivate && created?.id) {
381
+ const refetched = await model.read({ id: created.id }, 1, 0, true);
382
+ if (refetched?.[0])
383
+ resultDoc = refetched[0];
384
+ }
385
+ const masked = cdec.exposePrivate
386
+ ? (0, authorize_1.applyReadMaskOne)(resultDoc, cdec.sensitiveMask)
387
+ : (0, authorize_1.applyReadMaskOne)(resultDoc, cdec.readMask);
388
+ return res.status(201).json({
389
+ success: true,
390
+ message: `${schema.modelName} has been created successfully.`,
391
+ data: masked,
392
+ });
393
+ }
394
+ catch (error) {
395
+ if (error?.code === 'MODEL_REMOVED') {
396
+ return res
397
+ .status(410)
398
+ .json({ error: true, message: error.message });
399
+ }
400
+ this.handleWriteError(error, res);
401
+ }
402
+ });
403
+ // -------------- READ (list) --------------
404
+ this.router.get(basePath, mwRead, async (req, res) => {
405
+ try {
406
+ const schema = this.getSchema(modelNameLC);
407
+ if (!schema.allowedMethods.read) {
408
+ return res
409
+ .status(405)
410
+ .json({ error: true, message: 'Method not allowed' });
411
+ }
412
+ const model = this.getModel(modelNameLC);
413
+ const page = Number.parseInt(String(req.query.page ?? '0'), 10) || 0;
414
+ const limit = Number.parseInt(String(req.query.limit ?? '10'), 10) || 10;
415
+ // ---- search params (extended) ----
416
+ const q = String(req.query.q ?? '').trim();
417
+ const searchKey = String(req.query.searchKey ?? '').trim();
418
+ const searchKeysCSV = String(req.query.searchKeys ?? '').trim();
419
+ const searchMode = /^(and|or)$/i.test(String(req.query.searchMode ?? ''))
420
+ ? String(req.query.searchMode).toLowerCase()
421
+ : 'or';
422
+ const dateFromStr = String(req.query.dateFrom ?? '').trim();
423
+ const dateToStr = String(req.query.dateTo ?? '').trim();
424
+ let dateKey = String(req.query.dateKey ?? 'createdAt').trim();
425
+ // ---- NEW: sort params (supports CSV for multi-field) ----
426
+ const sortRaw = String(req.query.sort ?? '').trim(); // e.g. "name,createdAt"
427
+ const sortTypeRaw = String(req.query.sortType ?? '').trim(); // e.g. "asc,desc"
428
+ const filter = {};
429
+ const splitCSV = (raw) => raw
430
+ .split(',')
431
+ .map((s) => s.trim())
432
+ .filter(Boolean);
433
+ // Build sort spec limited to schema fields. Default desc(createdAt) if nothing valid provided.
434
+ const buildSortSpec = () => {
435
+ const keys = splitCSV(sortRaw);
436
+ const dirs = splitCSV(sortTypeRaw);
437
+ const sort = {};
438
+ keys.forEach((k, i) => {
439
+ // keep the guard so unknown fields are ignored
440
+ if (!schema.attributes?.[k])
441
+ return;
442
+ const d = dirs[i] ?? dirs[dirs.length - 1] ?? 'desc';
443
+ sort[k] = /^(desc|-1)$/i.test(d) ? -1 : 1;
444
+ });
445
+ if (!Object.keys(sort).length) {
446
+ // ✅ always default
447
+ sort.createdAt = -1;
448
+ }
449
+ return sort;
450
+ };
451
+ // ---- SEARCH (legacy + multi) ----
452
+ const ors = [];
453
+ const ands = [];
454
+ const buildPredicateForField = (field, attr, raw) => {
455
+ const isArray = Array.isArray(attr);
456
+ const base = isArray ? attr[0] : attr;
457
+ const attrType = this.normalizeAttrType(base);
458
+ const tokens = splitCSV(raw);
459
+ switch (attrType) {
460
+ case 'string':
461
+ return isArray
462
+ ? { [field]: { $in: tokens.length ? tokens : [raw] } }
463
+ : { [field]: { $regex: raw, $options: 'i' } };
464
+ case 'number': {
465
+ const nums = tokens.map(Number).filter((n) => !Number.isNaN(n));
466
+ return isArray
467
+ ? nums.length
468
+ ? { [field]: { $in: nums } }
469
+ : null
470
+ : nums.length
471
+ ? { [field]: nums[0] }
472
+ : null;
473
+ }
474
+ case 'boolean': {
475
+ const toBool = (t) => /^(true|1|yes)$/i.test(t)
476
+ ? true
477
+ : /^(false|0|no)$/i.test(t)
478
+ ? false
479
+ : null;
480
+ if (isArray) {
481
+ const bools = tokens
482
+ .map(toBool)
483
+ .filter((v) => v !== null);
484
+ return bools.length ? { [field]: { $in: bools } } : null;
485
+ }
486
+ const b = toBool(raw);
487
+ return b === null ? null : { [field]: b };
488
+ }
489
+ case 'objectid':
490
+ return isArray || tokens.length > 1
491
+ ? { [field]: { $in: tokens } }
492
+ : raw
493
+ ? { [field]: raw }
494
+ : null;
495
+ case 'date': {
496
+ const toDate = (t) => {
497
+ const d = new Date(t);
498
+ return Number.isNaN(+d) ? null : d;
499
+ };
500
+ if (isArray) {
501
+ const ds = tokens.map(toDate).filter((d) => !!d);
502
+ return ds.length ? { [field]: { $in: ds } } : null;
503
+ }
504
+ const d = toDate(raw);
505
+ return d ? { [field]: d } : null;
506
+ }
507
+ default:
508
+ return isArray
509
+ ? { [field]: { $in: tokens.length ? tokens : [raw] } }
510
+ : { [field]: raw };
511
+ }
512
+ };
513
+ if (q && searchKey && schema.attributes?.[searchKey]) {
514
+ const p = buildPredicateForField(searchKey, schema.attributes[searchKey], q);
515
+ if (p)
516
+ ors.push(p);
517
+ }
518
+ if (q && searchKeysCSV) {
519
+ const keys = splitCSV(searchKeysCSV);
520
+ const preds = keys
521
+ .filter((k) => !!schema.attributes?.[k])
522
+ .map((k) => buildPredicateForField(k, schema.attributes[k], q))
523
+ .filter(Boolean);
524
+ if (preds.length) {
525
+ if (searchMode === 'and')
526
+ ands.push(...preds);
527
+ else
528
+ ors.push(...preds);
529
+ }
530
+ }
531
+ if (ands.length && ors.length)
532
+ filter.$and = [...ands, { $or: ors }];
533
+ else if (ands.length)
534
+ filter.$and = [...(filter.$and || []), ...ands];
535
+ else if (ors.length)
536
+ filter.$or = [...(filter.$or || []), ...ors];
537
+ // ---- DATE RANGE (unchanged) ----
538
+ if (dateFromStr || dateToStr) {
539
+ if (!schema.attributes?.[dateKey]) {
540
+ if (schema.attributes?.createdAt)
541
+ dateKey = 'createdAt';
542
+ else if (schema.attributes?.updatedAt)
543
+ dateKey = 'updatedAt';
544
+ }
545
+ if (dateKey && schema.attributes?.[dateKey]) {
546
+ const range = {};
547
+ if (dateFromStr) {
548
+ const d = new Date(dateFromStr);
549
+ if (!Number.isNaN(+d))
550
+ range.$gte = d;
551
+ }
552
+ if (dateToStr) {
553
+ const d = new Date(dateToStr);
554
+ if (!Number.isNaN(+d))
555
+ range.$lte = d;
556
+ }
557
+ if (Object.keys(range).length)
558
+ filter[dateKey] = range;
559
+ }
560
+ }
561
+ const ctx = ctxFromReq(req);
562
+ const schemaPolicy = {
563
+ allowedMethods: schema.allowedMethods,
564
+ access: schema.access,
565
+ };
566
+ const rdec = (0, authorize_1.authorize)(modelNameLC, 'read', schemaPolicy, ctx);
567
+ if (!rdec.allow)
568
+ return res.status(403).json({ error: true, message: 'forbidden' });
569
+ const finalFilter = (0, authorize_1.andFilter)(filter, rdec.queryFilter || {});
570
+ const isUsersModel = modelNameLC === 'user' ||
571
+ modelNameLC === 'users' ||
572
+ (schema?.modelName ?? '').toLowerCase() === 'users';
573
+ const currentUserId = req?.user?.id;
574
+ // ---- NEW: compute sort spec
575
+ const sort = buildSortSpec();
576
+ // count
577
+ const totalRows = await model.count(finalFilter);
578
+ // try DB-level sort (preferred). Fallback to in-memory if adapter lacks sort support.
579
+ let rawRows = [];
580
+ try {
581
+ rawRows = await model.read(finalFilter, limit + 1, page * limit, !!rdec.exposePrivate, { sort });
582
+ }
583
+ catch {
584
+ rawRows = await model.read(finalFilter, limit + 1, page * limit, !!rdec.exposePrivate);
585
+ if (sort && Object.keys(sort).length) {
586
+ const orderKeys = Object.keys(sort);
587
+ rawRows.sort((a, b) => {
588
+ for (const k of orderKeys) {
589
+ const dir = sort[k];
590
+ const av = a?.[k];
591
+ const bv = b?.[k];
592
+ const ax = av instanceof Date ? +av : (av?.toString?.() ?? av);
593
+ const bx = bv instanceof Date ? +bv : (bv?.toString?.() ?? bv);
594
+ if (ax == null && bx == null)
595
+ continue;
596
+ if (ax == null)
597
+ return 1 * dir;
598
+ if (bx == null)
599
+ return -1 * dir;
600
+ if (ax > bx)
601
+ return 1 * dir;
602
+ if (ax < bx)
603
+ return -1 * dir;
604
+ }
605
+ return 0;
606
+ });
607
+ }
608
+ }
609
+ let rows = currentUserId && isUsersModel
610
+ ? rawRows
611
+ .filter((r) => String(r?.id) !== String(currentUserId))
612
+ .slice(0, limit)
613
+ : rawRows.slice(0, limit);
614
+ const data = rdec.exposePrivate
615
+ ? (0, authorize_1.applyReadMaskMany)(rows, rdec.sensitiveMask)
616
+ : (0, authorize_1.applyReadMaskMany)(rows, rdec.readMask);
617
+ return res.status(200).json({
618
+ success: true,
619
+ message: `Data fetched for ${schema.modelName}`,
620
+ data,
621
+ pagination: { totalRows, page, limit },
622
+ sort, // optional echo
623
+ });
624
+ }
625
+ catch (error) {
626
+ if (error?.code === 'MODEL_REMOVED') {
627
+ return res.status(410).json({ error: true, message: error.message });
628
+ }
629
+ return res.status(400).json({ error: true, message: error.message });
630
+ }
631
+ });
632
+ // -------------- READ (by id) --------------
633
+ this.router.get(`${basePath}/:id`, mwRead, async (req, res) => {
634
+ try {
635
+ const schema = this.getSchema(modelNameLC);
636
+ if (!schema.allowedMethods.read) {
637
+ return res
638
+ .status(405)
639
+ .json({ error: true, message: 'Method not allowed' });
640
+ }
641
+ const model = this.getModel(modelNameLC);
642
+ const ctx = ctxFromReq(req);
643
+ const schemaPolicy = {
644
+ allowedMethods: schema.allowedMethods,
645
+ access: schema.access,
646
+ };
647
+ const decForInclude = (0, authorize_1.authorize)(modelNameLC, 'read', schemaPolicy, ctx);
648
+ const recordArr = await model.read({ id: req.params.id }, 1, 0, !!decForInclude.exposePrivate);
649
+ const doc = recordArr?.[0];
650
+ if (!doc) {
651
+ return res
652
+ .status(404)
653
+ .json({ error: true, message: `${schema.modelName} not found` });
654
+ }
655
+ const dec = (0, authorize_1.authorize)(modelNameLC, 'read', schemaPolicy, ctx, doc);
656
+ if (!dec.allow)
657
+ return res.status(403).json({ error: true, message: 'forbidden' });
658
+ const data = dec.exposePrivate
659
+ ? (0, authorize_1.applyReadMaskOne)(doc, dec.sensitiveMask)
660
+ : (0, authorize_1.applyReadMaskOne)(doc, dec.readMask);
661
+ return res.status(200).json({
662
+ success: true,
663
+ message: `${schema.modelName} found`,
664
+ data,
665
+ });
666
+ }
667
+ catch (error) {
668
+ if (error?.code === 'MODEL_REMOVED')
669
+ return res
670
+ .status(410)
671
+ .json({ error: true, message: error.message });
672
+ Logger_1.default.error('error', error.message);
673
+ res.status(400).json({ error: true, message: error.message });
674
+ }
675
+ });
676
+ // -------------- UPDATE --------------
677
+ this.router.put(`${basePath}/:id`, mwUpdate, async (req, res) => {
678
+ try {
679
+ const schema = this.getSchema(modelNameLC);
680
+ if (!schema.allowedMethods.update) {
681
+ return res
682
+ .status(405)
683
+ .json({ error: true, message: 'Method not allowed' });
684
+ }
685
+ const model = this.getModel(modelNameLC);
686
+ const beforeArr = await model.read({ id: req.params.id }, 1, 0, true);
687
+ const before = beforeArr?.[0];
688
+ const ctx = ctxFromReq(req);
689
+ const schemaPolicy = {
690
+ allowedMethods: schema.allowedMethods,
691
+ access: schema.access,
692
+ };
693
+ const udec = (0, authorize_1.authorize)(modelNameLC, 'update', schemaPolicy, ctx, before);
694
+ if (!udec.allow) {
695
+ return res.status(403).json({ error: true, message: 'forbidden' });
696
+ }
697
+ let upd = { ...req.body };
698
+ (0, authorize_1.enforceRestrictions)(upd, udec.restrictions, ctx);
699
+ if (!udec.exposePrivate) {
700
+ upd = (0, authorize_1.stripWriteDeny)(upd, udec.writeDeny);
701
+ }
702
+ // ✅ NEW: DB-agnostic coercion (time/date/range → canonical strings)
703
+ upd = (0, fieldCodecs_1.coerceForStorage)(schema, upd);
704
+ // Users: handle password if (and only if) provided
705
+ if (modelNameLC === 'users' &&
706
+ Object.prototype.hasOwnProperty.call(upd, 'password')) {
707
+ if (!upd.password) {
708
+ delete upd.password; // ignore empty → do not overwrite
709
+ }
710
+ else if (this.jwtSecret) {
711
+ const salt = await bcrypt_1.default.genSalt(10);
712
+ upd.password = await bcrypt_1.default.hash(String(upd.password) + this.jwtSecret, salt);
713
+ }
714
+ }
715
+ // UPDATE: validate only fields present in payload (now in canonical shape)
716
+ const missing = this.validateRequiredFields(schema, upd, 'update');
717
+ if (missing.length) {
718
+ return res.status(400).json({
719
+ error: true,
720
+ message: `Missing required fields: ${missing.join(', ')}`,
721
+ });
722
+ }
723
+ const conflicts = await this.checkUniqueFields(schema, upd, req.params.id);
724
+ if (conflicts && conflicts.length > 0) {
725
+ return res.status(400).json({
726
+ error: true,
727
+ message: 'There are some error while updating record.',
728
+ fields: conflicts.map((field) => ({
729
+ field,
730
+ error: true,
731
+ message: `You cannot use this ${field}. It's already been used.`,
732
+ })),
733
+ });
734
+ }
735
+ const updatedRecord = await model.update(req.params.id, upd);
736
+ let responseDoc = updatedRecord;
737
+ if (udec.exposePrivate && updatedRecord?.id) {
738
+ const refetched = await model.read({ id: updatedRecord.id }, 1, 0, true);
739
+ if (refetched?.[0])
740
+ responseDoc = refetched[0];
741
+ }
742
+ const masked = udec.exposePrivate
743
+ ? (0, authorize_1.applyReadMaskOne)(responseDoc, udec.sensitiveMask)
744
+ : (0, authorize_1.applyReadMaskOne)(responseDoc, udec.readMask);
745
+ return res.json(masked);
746
+ }
747
+ catch (error) {
748
+ if (error?.code === 'MODEL_REMOVED') {
749
+ return res
750
+ .status(410)
751
+ .json({ error: true, message: error.message });
752
+ }
753
+ this.handleWriteError(error, res);
754
+ }
755
+ });
756
+ // -------------- DELETE --------------
757
+ this.router.delete(`${basePath}/:id`, mwDelete, async (req, res) => {
758
+ try {
759
+ const schema = this.getSchema(modelNameLC);
760
+ if (!schema.allowedMethods.delete) {
761
+ return res
762
+ .status(405)
763
+ .json({ error: true, message: 'Method not allowed' });
764
+ }
765
+ const model = this.getModel(modelNameLC);
766
+ const docArr = await model.read({ id: req.params.id }, 1, 0, true);
767
+ const doc = docArr?.[0];
768
+ const ctx = ctxFromReq(req);
769
+ const schemaPolicy = {
770
+ allowedMethods: schema.allowedMethods,
771
+ access: schema.access,
772
+ };
773
+ const ddec = (0, authorize_1.authorize)(modelNameLC, 'delete', schemaPolicy, ctx, doc);
774
+ if (!ddec.allow)
775
+ return res.status(403).json({ error: true, message: 'forbidden' });
776
+ const deletedRecord = await model.delete(req.params.id);
777
+ if (!deletedRecord) {
778
+ return res
779
+ .status(404)
780
+ .json({ error: true, message: `${schema.modelName} not found` });
781
+ }
782
+ const masked = ddec.exposePrivate
783
+ ? (0, authorize_1.applyReadMaskOne)(deletedRecord, ddec.sensitiveMask)
784
+ : (0, authorize_1.applyReadMaskOne)(deletedRecord, ddec.readMask);
785
+ return res.json({
786
+ success: true,
787
+ message: `We have deleted the record successfully.`,
788
+ data: masked,
789
+ });
790
+ }
791
+ catch (error) {
792
+ if (error?.code === 'MODEL_REMOVED')
793
+ return res
794
+ .status(410)
795
+ .json({ error: true, message: error.message });
796
+ res.status(400).json({ error: true, message: error.message });
797
+ }
798
+ });
799
+ }
800
+ mountFindRoutes() {
801
+ if (this.findRoutesMounted)
802
+ return;
803
+ this.findRoutesMounted = true;
804
+ // ---------- helpers ----------
805
+ const parseSort = (expr) => {
806
+ if (!expr)
807
+ return;
808
+ const out = {};
809
+ for (const raw of expr
810
+ .split(',')
811
+ .map((s) => s.trim())
812
+ .filter(Boolean)) {
813
+ if (raw.startsWith('-'))
814
+ out[raw.slice(1)] = -1;
815
+ else if (raw.startsWith('+'))
816
+ out[raw.slice(1)] = 1;
817
+ else
818
+ out[raw] = 1;
819
+ }
820
+ return Object.keys(out).length ? out : undefined;
821
+ };
822
+ const parseQuery = (req) => {
823
+ const limit = Math.min(parseInt(String(req.query.limit ?? '12'), 10) || 12, 100);
824
+ const page = Math.max(parseInt(String(req.query.page ?? '1'), 10) || 1, 1);
825
+ const skip = (page - 1) * limit;
826
+ const fields = String(req.query.fields ?? '')
827
+ .split(',')
828
+ .map((s) => s.trim())
829
+ .filter(Boolean);
830
+ const projection = fields.length
831
+ ? Object.fromEntries(fields.map((f) => [f, 1]))
832
+ : undefined;
833
+ const sort = parseSort(String(req.query.sort ?? '-createdAt'));
834
+ return { limit, page, skip, projection, sort };
835
+ };
836
+ // normalize possible values into an array of string ids
837
+ const extractIds = (val) => {
838
+ if (val == null)
839
+ return [];
840
+ const arr = Array.isArray(val) ? val : [val];
841
+ const toId = (v) => {
842
+ if (!v)
843
+ return null;
844
+ if (typeof v === 'string')
845
+ return v; // "6523..."
846
+ if (typeof v === 'number')
847
+ return String(v);
848
+ if (typeof v === 'object') {
849
+ if (typeof v.id === 'string')
850
+ return v.id; // populated doc.id
851
+ if (v._id && typeof v._id === 'string')
852
+ return v._id;
853
+ if (v._id && typeof v._id.toString === 'function')
854
+ return v._id.toString();
855
+ }
856
+ return null;
857
+ };
858
+ return arr.map(toId).filter((s) => !!s);
859
+ };
860
+ // minimal ctx (same logic as setupRoutes)
861
+ const ctxFromReq = (req) => {
862
+ const raw = req.user?.role;
863
+ let roleStr = null;
864
+ if (typeof raw === 'string')
865
+ roleStr = raw;
866
+ else if (Array.isArray(raw)) {
867
+ const first = raw[0];
868
+ if (typeof first === 'string')
869
+ roleStr = first;
870
+ else if (first &&
871
+ typeof first === 'object' &&
872
+ typeof first.name === 'string') {
873
+ roleStr = first.name;
874
+ }
875
+ }
876
+ else if (raw &&
877
+ typeof raw === 'object' &&
878
+ typeof raw.name === 'string') {
879
+ roleStr = raw.name;
880
+ }
881
+ roleStr = roleStr ? roleStr.toLowerCase() : null;
882
+ return {
883
+ isAuthenticated: !!req.user,
884
+ role: roleStr,
885
+ userId: req.user?.id ?? req.user?._id ?? null,
886
+ isSuperadmin: roleStr === 'superadmin',
887
+ apiKeyOk: true,
888
+ };
889
+ };
890
+ // detect { type: "ObjectId"/"ref" } or array of { type: "ObjectId", ref }
891
+ const refInfoFromAttr = (attr) => {
892
+ if (!attr)
893
+ return null;
894
+ // array style from your JSON schema
895
+ if (Array.isArray(attr) && attr[0] && attr[0].ref) {
896
+ return { ref: String(attr[0].ref), isArray: true };
897
+ }
898
+ const a = Array.isArray(attr) ? attr?.[0] : attr;
899
+ const t = (typeof a?.type === 'string' ? a.type : String(a?.type || '')).toLowerCase();
900
+ if (a?.ref && (t === 'objectid' || t === 'ref')) {
901
+ return { ref: String(a.ref), isArray: false };
902
+ }
903
+ return null;
904
+ };
905
+ // try adapter.findOne with projection to avoid autopopulate; fallback to model.read
906
+ const safeGetContainerRefIDs = async (containerModelLC, id, refField) => {
907
+ const containerSchema = this.getSchema(containerModelLC);
908
+ const adapterAny = this.dbAdapter;
909
+ if (typeof adapterAny.findOne === 'function') {
910
+ try {
911
+ // adapter should map id → db PK correctly (e.g., _id for Mongo)
912
+ const raw = await adapterAny.findOne(containerSchema.modelName, { id }, { projection: { [refField]: 1 } });
913
+ return extractIds(raw?.[refField]);
914
+ }
915
+ catch {
916
+ // fall through
917
+ }
918
+ }
919
+ // fallback: BaseModel.read (may autopopulate, so we still normalize)
920
+ const containerDoc = (await this.getModel(containerModelLC).read({ id }, 1, 0, true))?.[0];
921
+ return extractIds(containerDoc?.[refField]);
922
+ };
923
+ // ---------- FORWARD ----------
924
+ // /find/:container/:refField/:id
925
+ this.router.get('/find/:container/:refField/:id', this.optionalAuthMiddleware, async (req, res) => {
926
+ try {
927
+ const containerLC = String(req.params.container || '').toLowerCase();
928
+ const refField = String(req.params.refField || '');
929
+ const id = String(req.params.id || '');
930
+ const schema = this.getSchema(containerLC); // ensure container exists
931
+ const attr = schema.attributes?.[refField];
932
+ const rinfo = refInfoFromAttr(attr);
933
+ if (!rinfo) {
934
+ return res.status(400).json({
935
+ error: true,
936
+ message: 'refField is not a reference field',
937
+ });
938
+ }
939
+ const { limit, page, skip, projection, sort } = parseQuery(req);
940
+ // policy on container read
941
+ const ctx = ctxFromReq(req);
942
+ const cPolicy = {
943
+ allowedMethods: schema.allowedMethods,
944
+ access: schema.access,
945
+ };
946
+ const cDec = (0, authorize_1.authorize)(containerLC, 'read', cPolicy, ctx);
947
+ if (!cDec.allow)
948
+ return res.status(403).json({ error: true, message: 'forbidden' });
949
+ // get raw ids safely (no autopopulate dependency)
950
+ const ids = await safeGetContainerRefIDs(containerLC, id, refField);
951
+ const targetLC = String(rinfo.ref || '').toLowerCase();
952
+ const targetSchema = this.getSchema(targetLC);
953
+ const tPolicy = {
954
+ allowedMethods: targetSchema.allowedMethods,
955
+ access: targetSchema.access,
956
+ };
957
+ const tDec = (0, authorize_1.authorize)(targetLC, 'read', tPolicy, ctx);
958
+ if (!tDec.allow)
959
+ return res.status(403).json({ error: true, message: 'forbidden' });
960
+ if (ids.length === 0) {
961
+ return res.status(200).json({
962
+ success: true,
963
+ message: `Data fetched for ${targetSchema.modelName}`,
964
+ data: [],
965
+ pagination: { totalRows: 0, page, limit },
966
+ });
967
+ }
968
+ const adapterAny = this.dbAdapter;
969
+ let items = [];
970
+ if (typeof adapterAny.findMany === 'function') {
971
+ items = await adapterAny.findMany(targetSchema.modelName, { id: { $in: ids } }, // DB-agnostic: adapter maps id → PK
972
+ { sort, skip, limit, projection });
973
+ }
974
+ else {
975
+ // fallback (BaseModel.read); note: no projection/sort
976
+ items = await this.getModel(targetLC).read({ id: { $in: ids } }, limit, skip, !!tDec.exposePrivate);
977
+ }
978
+ const masked = tDec.exposePrivate
979
+ ? (0, authorize_1.applyReadMaskMany)(items, tDec.sensitiveMask)
980
+ : (0, authorize_1.applyReadMaskMany)(items, tDec.readMask);
981
+ const totalRows = ids.length;
982
+ return res.status(200).json({
983
+ success: true,
984
+ message: `Data fetched for ${targetSchema.modelName}`,
985
+ data: masked,
986
+ pagination: { totalRows, page, limit },
987
+ });
988
+ }
989
+ catch (err) {
990
+ if (err?.code === 'MODEL_REMOVED') {
991
+ return res.status(410).json({ error: true, message: err.message });
992
+ }
993
+ return res
994
+ .status(400)
995
+ .json({ error: true, message: err?.message || 'Error' });
996
+ }
997
+ });
998
+ // ---------- REVERSE ----------
999
+ // /find/reverse/:target/:byField/:id
1000
+ this.router.get('/find/reverse/:target/:byField/:id', this.optionalAuthMiddleware, async (req, res) => {
1001
+ try {
1002
+ const targetLC = String(req.params.target || '').toLowerCase();
1003
+ const byField = String(req.params.byField || '');
1004
+ const id = String(req.params.id || '');
1005
+ const targetSchema = this.getSchema(targetLC);
1006
+ const attr = targetSchema.attributes?.[byField];
1007
+ const rinfo = refInfoFromAttr(attr);
1008
+ if (!rinfo) {
1009
+ return res.status(400).json({
1010
+ error: true,
1011
+ message: 'byField is not a reference field',
1012
+ });
1013
+ }
1014
+ const { limit, page, skip, projection, sort } = parseQuery(req);
1015
+ // policy on target read
1016
+ const ctx = ctxFromReq(req);
1017
+ const tPolicy = {
1018
+ allowedMethods: targetSchema.allowedMethods,
1019
+ access: targetSchema.access,
1020
+ };
1021
+ const tDec = (0, authorize_1.authorize)(targetLC, 'read', tPolicy, ctx);
1022
+ if (!tDec.allow)
1023
+ return res.status(403).json({ error: true, message: 'forbidden' });
1024
+ // reverse filter (DB-agnostic; adapter maps appropriately)
1025
+ const filter = rinfo.isArray
1026
+ ? { [byField]: { $in: [id] } }
1027
+ : { [byField]: id };
1028
+ const adapterAny = this.dbAdapter;
1029
+ let items = [];
1030
+ let totalRows = 0;
1031
+ if (typeof adapterAny.findMany === 'function') {
1032
+ items = await adapterAny.findMany(targetSchema.modelName, filter, {
1033
+ sort,
1034
+ skip,
1035
+ limit,
1036
+ projection,
1037
+ });
1038
+ if (typeof adapterAny.count === 'function') {
1039
+ totalRows = await adapterAny.count(targetSchema.modelName, filter);
1040
+ }
1041
+ else {
1042
+ // fallback count
1043
+ const all = await this.getModel(targetLC).read(filter, 0, 0, !!tDec.exposePrivate);
1044
+ totalRows = all.length;
1045
+ }
1046
+ }
1047
+ else {
1048
+ // fallback (BaseModel.read); note: no projection/sort
1049
+ const all = await this.getModel(targetLC).read(filter, 0, 0, !!tDec.exposePrivate);
1050
+ totalRows = all.length;
1051
+ items = all.slice(skip, skip + limit);
1052
+ }
1053
+ const masked = tDec.exposePrivate
1054
+ ? (0, authorize_1.applyReadMaskMany)(items, tDec.sensitiveMask)
1055
+ : (0, authorize_1.applyReadMaskMany)(items, tDec.readMask);
1056
+ return res.status(200).json({
1057
+ success: true,
1058
+ message: `Data fetched for ${targetSchema.modelName}`,
1059
+ data: masked,
1060
+ pagination: { totalRows, page, limit },
1061
+ });
1062
+ }
1063
+ catch (err) {
1064
+ if (err?.code === 'MODEL_REMOVED') {
1065
+ return res.status(410).json({ error: true, message: err.message });
1066
+ }
1067
+ return res
1068
+ .status(400)
1069
+ .json({ error: true, message: err?.message || 'Error' });
1070
+ }
1071
+ });
1072
+ // keep 404 as last if needed
1073
+ this.ensureNotFoundLast();
1074
+ }
1075
+ /** Mount generic file endpoints under this router */
1076
+ setupFileRoutes() {
1077
+ if (!this.fileStorage)
1078
+ return;
1079
+ const upload = (0, multer_1.default)({
1080
+ storage: multer_1.default.memoryStorage(),
1081
+ limits: { fileSize: 50 * 1024 * 1024, files: 20 }, // 50MB
1082
+ });
1083
+ // POST /files
1084
+ this.router.post('/files', this.fileAuthMiddleware, upload.any(), async (req, res) => {
1085
+ try {
1086
+ const files = req.files ?? [];
1087
+ if (!files.length) {
1088
+ return res
1089
+ .status(400)
1090
+ .json({ error: true, message: 'No files uploaded' });
1091
+ }
1092
+ const results = await Promise.all(files.map(async (f) => {
1093
+ const folder = this.shortFolder(); // uploads/YYYY/MM/DD
1094
+ const ext = (f.originalname.match(/\.([A-Za-z0-9]{1,8})$/)?.[1] ??
1095
+ (0, filename_1.extFromMime)(f.mimetype) ??
1096
+ 'bin').toLowerCase();
1097
+ const key = `${folder}/${this.shortUid()}.${ext}`;
1098
+ const out = await this.fileStorage.upload({
1099
+ key,
1100
+ body: f.buffer,
1101
+ contentType: f.mimetype,
1102
+ metadata: { originalName: f.originalname || '' },
1103
+ });
1104
+ return {
1105
+ provider: out.provider,
1106
+ bucket: out.bucket,
1107
+ key: out.key,
1108
+ url: out.url,
1109
+ etag: out.etag,
1110
+ contentType: out.contentType,
1111
+ size: out.size,
1112
+ metadata: out.metadata,
1113
+ originalName: f.originalname,
1114
+ };
1115
+ }));
1116
+ return res.json({
1117
+ success: true,
1118
+ message: 'Files uploaded successfully',
1119
+ data: results,
1120
+ });
1121
+ }
1122
+ catch (err) {
1123
+ return res
1124
+ .status(400)
1125
+ .json({ error: true, message: err?.message ?? 'Upload failed' });
1126
+ }
1127
+ });
1128
+ // DELETE /files/:key(*)
1129
+ this.router.delete('/files/:key(*)', this.fileAuthMiddleware, async (req, res) => {
1130
+ try {
1131
+ const key = String(req.params.key || '');
1132
+ if (!key) {
1133
+ return res
1134
+ .status(400)
1135
+ .json({ error: true, message: 'Key is required' });
1136
+ }
1137
+ const { deleted } = await this.fileStorage.delete(key);
1138
+ return res.json({
1139
+ success: true,
1140
+ message: deleted ? 'File deleted' : 'Delete attempted',
1141
+ key,
1142
+ deleted,
1143
+ });
1144
+ }
1145
+ catch (err) {
1146
+ return res
1147
+ .status(400)
1148
+ .json({ error: true, message: err?.message ?? 'Delete failed' });
1149
+ }
1150
+ });
1151
+ }
1152
+ /** uploads/YYYY/MM/DD */
1153
+ shortFolder() {
1154
+ const d = new Date();
1155
+ const y = d.getFullYear();
1156
+ const m = String(d.getMonth() + 1).padStart(2, '0');
1157
+ const day = String(d.getDate()).padStart(2, '0');
1158
+ return `uploads/${y}/${m}/${day}`;
1159
+ }
1160
+ /** small, URL-safe uid */
1161
+ shortUid() {
1162
+ return (Date.now().toString(36) + Math.random().toString(36).slice(2, 6)).toLowerCase();
1163
+ }
1164
+ normalizeAttrType(attr) {
1165
+ const a = Array.isArray(attr) ? attr?.[0] : attr;
1166
+ let t = a?.type ?? a;
1167
+ if (typeof t === 'function' && t.name)
1168
+ t = t.name;
1169
+ if (t && typeof t === 'object' && 'name' in t)
1170
+ t = t.name;
1171
+ if (typeof t === 'string')
1172
+ t = t.toLowerCase();
1173
+ if (t === 'bool')
1174
+ t = 'boolean';
1175
+ if (t === 'objectid' || t === 'oid' || t === 'ref')
1176
+ t = 'objectid';
1177
+ return String(t || '');
1178
+ }
1179
+ pickAuthFor(action, modelNameLC) {
1180
+ try {
1181
+ const schema = this.getSchema(modelNameLC);
1182
+ const access = schema.access || {};
1183
+ const publicRule = access?.public?.[action];
1184
+ if (publicRule === true)
1185
+ return this.optionalAuthMiddleware;
1186
+ if (publicRule === false)
1187
+ return this.authenticateMiddleware;
1188
+ return action === 'read'
1189
+ ? this.optionalAuthMiddleware
1190
+ : this.authenticateMiddleware;
1191
+ }
1192
+ catch {
1193
+ return action === 'read'
1194
+ ? this.optionalAuthMiddleware
1195
+ : this.authenticateMiddleware;
1196
+ }
1197
+ }
1198
+ getUserRoleFromReq(req) {
1199
+ const r = req.user?.role;
1200
+ return typeof r === 'string'
1201
+ ? r
1202
+ : typeof r?.name === 'string'
1203
+ ? r.name
1204
+ : null;
1205
+ }
1206
+ setupAuthRoutes() {
1207
+ const basePath = '/auth/users';
1208
+ // ---- REGISTER ----
1209
+ this.router.post(`${basePath}/register`, this.apiKeyMiddleware, async (req, res) => {
1210
+ try {
1211
+ let { email, username, password, ...rest } = req.body;
1212
+ // Basic password policy (keep minimal, align with change-password)
1213
+ if (String(password).length < 8) {
1214
+ return res.status(400).json({
1215
+ error: true,
1216
+ message: 'Password must be at least 8 characters long',
1217
+ });
1218
+ }
1219
+ const userModel = this.getModel('users');
1220
+ const usersSchema = this.getSchema('users');
1221
+ // Normalize unique identifiers
1222
+ if (email)
1223
+ email = String(email).trim().toLowerCase();
1224
+ if (username)
1225
+ username = String(username).trim();
1226
+ // Prevent client from sending password in plain; we will hash below
1227
+ const data = { ...rest };
1228
+ if (email)
1229
+ data.email = email;
1230
+ if (username)
1231
+ data.username = username;
1232
+ // Provide a sensible default status so login works immediately
1233
+ if (data.status == null)
1234
+ data.status = 'active';
1235
+ // Validate required fields against schema (create mode)
1236
+ if (usersSchema) {
1237
+ const missing = this.validateRequiredFields(usersSchema, {
1238
+ ...data,
1239
+ password,
1240
+ });
1241
+ if (missing.length > 0) {
1242
+ return res.status(400).json({
1243
+ error: true,
1244
+ message: `Missing required field(s): ${missing.join(', ')}`,
1245
+ });
1246
+ }
1247
+ // Check unique constraints before writing
1248
+ const conflicts = await this.checkUniqueFields(usersSchema, data);
1249
+ if (conflicts && conflicts.length) {
1250
+ return res.status(400).json({
1251
+ error: true,
1252
+ message: `Duplicate value for field(s): ${conflicts.join(', ')}`,
1253
+ });
1254
+ }
1255
+ }
1256
+ // Hash password
1257
+ const salt = await bcrypt_1.default.genSalt(10);
1258
+ const hashed = await bcrypt_1.default.hash(String(password) + this.jwtSecret, salt);
1259
+ data.password = hashed;
1260
+ // Create user
1261
+ const created = await userModel.create(data);
1262
+ // Prepare response: generate token and strip password
1263
+ const roleName = (await this.normalizeRoleName(created.role)) ?? '';
1264
+ const token = jsonwebtoken_1.default.sign({ id: created.id, role: roleName }, this.jwtSecret, {
1265
+ expiresIn: '7days',
1266
+ });
1267
+ delete created.password;
1268
+ return res.json({
1269
+ success: true,
1270
+ message: 'Registration successful.',
1271
+ data: { token, user: created },
1272
+ });
1273
+ }
1274
+ catch (error) {
1275
+ this.handleWriteError(error, res);
1276
+ }
1277
+ });
1278
+ // ---- LOGIN ----
1279
+ this.router.post(`${basePath}/login`, this.apiKeyMiddleware, async (req, res) => {
1280
+ let { email, username, password } = req.body;
1281
+ if ((!email && !username) || !password) {
1282
+ return res.status(400).json({
1283
+ error: true,
1284
+ message: 'Email/username and password are required',
1285
+ });
1286
+ }
1287
+ try {
1288
+ const userModel = this.getModel('users');
1289
+ const findBy = email
1290
+ ? { email: String(email).trim().toLowerCase() }
1291
+ : { username: String(username).trim() };
1292
+ const users = await userModel.read(findBy, 1, 0, true);
1293
+ const user = users?.[0];
1294
+ if (!user)
1295
+ return res
1296
+ .status(400)
1297
+ .json({ error: true, message: 'Invalid credentials' });
1298
+ const hashedPassword = user.password;
1299
+ if (!hashedPassword)
1300
+ return res
1301
+ .status(400)
1302
+ .json({ error: true, message: 'User password not set' });
1303
+ const isMatch = await bcrypt_1.default.compare(String(password) + this.jwtSecret, hashedPassword);
1304
+ if (!isMatch)
1305
+ return res
1306
+ .status(400)
1307
+ .json({ error: true, message: 'Invalid credentials' });
1308
+ const status = user.status;
1309
+ if (status && status !== 'active') {
1310
+ const msg = status === 'pending'
1311
+ ? 'Your account is awaiting approval.'
1312
+ : 'Your account is suspended.';
1313
+ return res.status(403).json({ error: true, message: msg });
1314
+ }
1315
+ const roleName = (await this.normalizeRoleName(user.role)) ?? '';
1316
+ const token = jsonwebtoken_1.default.sign({ id: user.id, role: roleName }, // normalize here
1317
+ this.jwtSecret, { expiresIn: '7days' });
1318
+ delete user.password;
1319
+ res.json({
1320
+ success: true,
1321
+ message: 'You are successfully logged in.',
1322
+ data: { token, user },
1323
+ });
1324
+ }
1325
+ catch (error) {
1326
+ if (error?.code === 'MODEL_REMOVED')
1327
+ return res
1328
+ .status(410)
1329
+ .json({ error: true, message: error.message });
1330
+ res.status(500).json({ error: true, message: error.message });
1331
+ }
1332
+ });
1333
+ // ---- ME ----
1334
+ this.router.get(`${basePath}/me`, this.apiKeyMiddleware, this.authenticateMiddleware.bind(this), async (req, res) => {
1335
+ try {
1336
+ const userId = req.user?.id ?? req.user?._id ?? null;
1337
+ if (!userId) {
1338
+ return res
1339
+ .status(401)
1340
+ .json({ error: true, message: 'Not authenticated' });
1341
+ }
1342
+ const userModel = this.getModel('users');
1343
+ const arr = await userModel.read({ id: userId }, 1, 0, true);
1344
+ const user = arr?.[0];
1345
+ if (!user) {
1346
+ return res
1347
+ .status(404)
1348
+ .json({ error: true, message: 'User not found' });
1349
+ }
1350
+ delete user.password;
1351
+ return res.json({ success: true, data: user });
1352
+ }
1353
+ catch (error) {
1354
+ return res
1355
+ .status(400)
1356
+ .json({ error: true, message: error?.message || 'Error' });
1357
+ }
1358
+ });
1359
+ // ---- CHANGE PASSWORD ----
1360
+ this.router.post(`${basePath}/change-password`, this.apiKeyMiddleware, this.authenticateMiddleware.bind(this), async (req, res) => {
1361
+ try {
1362
+ const { oldPassword, newPassword } = req.body;
1363
+ if (!oldPassword || !newPassword) {
1364
+ return res.status(400).json({
1365
+ error: true,
1366
+ message: 'oldPassword and newPassword are required',
1367
+ });
1368
+ }
1369
+ // basic policy (customize as desired)
1370
+ if (String(newPassword).length < 8) {
1371
+ return res.status(400).json({
1372
+ error: true,
1373
+ message: 'New password must be at least 8 characters long',
1374
+ });
1375
+ }
1376
+ if (String(newPassword) === String(oldPassword)) {
1377
+ return res.status(400).json({
1378
+ error: true,
1379
+ message: 'New password must be different from old password',
1380
+ });
1381
+ }
1382
+ const userId = req.user?.id ?? req.user?._id ?? null;
1383
+ if (!userId) {
1384
+ return res
1385
+ .status(401)
1386
+ .json({ error: true, message: 'Not authenticated' });
1387
+ }
1388
+ const userModel = this.getModel('users');
1389
+ const arr = await userModel.read({ id: userId }, 1, 0, true);
1390
+ const user = arr?.[0];
1391
+ if (!user) {
1392
+ return res
1393
+ .status(404)
1394
+ .json({ error: true, message: 'User not found' });
1395
+ }
1396
+ const storedHash = user.password;
1397
+ if (!storedHash) {
1398
+ return res
1399
+ .status(400)
1400
+ .json({ error: true, message: 'User password not set' });
1401
+ }
1402
+ const match = await bcrypt_1.default.compare(String(oldPassword) + this.jwtSecret, storedHash);
1403
+ if (!match) {
1404
+ return res
1405
+ .status(400)
1406
+ .json({ error: true, message: 'Old password is incorrect' });
1407
+ }
1408
+ const salt = await bcrypt_1.default.genSalt(10);
1409
+ const newHash = await bcrypt_1.default.hash(String(newPassword) + this.jwtSecret, salt);
1410
+ await userModel.update(user.id, { password: newHash });
1411
+ return res.json({
1412
+ success: true,
1413
+ message: 'Password changed successfully.',
1414
+ });
1415
+ }
1416
+ catch (error) {
1417
+ res
1418
+ .status(400)
1419
+ .json({ error: true, message: error?.message || 'Error' });
1420
+ }
1421
+ });
1422
+ // ---- FORGOT PASSWORD ----
1423
+ const forgotPasswordHandler = async (req, res) => {
1424
+ try {
1425
+ const { email } = (req.body || {});
1426
+ if (!email || !String(email).trim()) {
1427
+ return res
1428
+ .status(400)
1429
+ .json({ error: true, message: 'Email is required' });
1430
+ }
1431
+ // Normalize and (optionally) look up the user. We do NOT reveal whether the user exists.
1432
+ const normalizedEmail = String(email).trim().toLowerCase();
1433
+ try {
1434
+ const userModel = this.getModel('users');
1435
+ // Best-effort read; ignore result to avoid leaking existence
1436
+ await userModel.read({ email: normalizedEmail }, 1, 0, true);
1437
+ }
1438
+ catch {
1439
+ // Ignore lookup errors deliberately
1440
+ }
1441
+ // In a real implementation, generate a reset token, store it with expiry, and send an email.
1442
+ // Here we return a generic success message so the React app flow can proceed.
1443
+ return res.json({
1444
+ success: true,
1445
+ message: 'If an account exists, reset instructions have been sent.',
1446
+ });
1447
+ }
1448
+ catch (error) {
1449
+ return res
1450
+ .status(400)
1451
+ .json({ error: true, message: error?.message || 'Error' });
1452
+ }
1453
+ };
1454
+ // Primary (plural) route under /auth/users
1455
+ this.router.post(`${basePath}/forgot-password`, this.apiKeyMiddleware, forgotPasswordHandler);
1456
+ // NOTE: All Users/Roles CRUD routes are handled by generic routes.
1457
+ }
1458
+ // ---------------- Common helpers ----------------
1459
+ /**
1460
+ * Validate required fields.
1461
+ * - create: field must exist and be non-empty (not null/undefined/'').
1462
+ * - update: only validate fields that are explicitly present in the payload;
1463
+ * absence means "unchanged".
1464
+ */
1465
+ validateRequiredFields(schema, payload, mode = 'create') {
1466
+ const missing = [];
1467
+ const isEmpty = (v) => v === undefined ||
1468
+ v === null ||
1469
+ (typeof v === 'string' && v.trim() === '');
1470
+ for (const [key, attribute] of Object.entries(schema.attributes)) {
1471
+ const req = attribute && !Array.isArray(attribute) && attribute.required;
1472
+ if (!req)
1473
+ continue;
1474
+ if (mode === 'create') {
1475
+ if (isEmpty(payload[key]))
1476
+ missing.push(key);
1477
+ }
1478
+ else {
1479
+ // update → only if field is being sent AND is empty
1480
+ if (Object.prototype.hasOwnProperty.call(payload, key) &&
1481
+ isEmpty(payload[key])) {
1482
+ missing.push(key);
1483
+ }
1484
+ }
1485
+ }
1486
+ return missing;
1487
+ }
1488
+ handleWriteError(error, res) {
1489
+ if (error?.code === 'MODEL_REMOVED') {
1490
+ res.status(410).json({ error: true, message: error.message });
1491
+ return;
1492
+ }
1493
+ if (error?.code === 11000) {
1494
+ const field = Object.keys(error.keyPattern || {})[0];
1495
+ res
1496
+ .status(400)
1497
+ .json({ error: true, message: `Duplicate value for field: ${field}` });
1498
+ }
1499
+ else {
1500
+ res.status(400).json({ error: true, message: error?.message || 'Error' });
1501
+ }
1502
+ }
1503
+ setupNotFoundMiddleware() {
1504
+ if (!this.notFoundHandler) {
1505
+ this.notFoundHandler = (req, res) => {
1506
+ Logger_1.default.warn('apiRouter', `API route not found: ${req.originalUrl}`);
1507
+ res.status(404).json({
1508
+ error: 'API route not found',
1509
+ path: req.originalUrl,
1510
+ method: req.method,
1511
+ });
1512
+ };
1513
+ }
1514
+ this.ensureNotFoundLast();
1515
+ }
1516
+ ensureNotFoundLast() {
1517
+ if (!this.notFoundHandler)
1518
+ return;
1519
+ // @ts-ignore
1520
+ this.router.stack = this.router.stack.filter((layer) => layer?.handle !== this.notFoundHandler);
1521
+ this.router.use('*', this.notFoundHandler);
1522
+ }
1523
+ async validateRoleValue(roleValue) {
1524
+ const roleModel = this.models['role'];
1525
+ if (!roleModel)
1526
+ return false;
1527
+ const roles = await roleModel.read({ name: roleValue }, 1, 0);
1528
+ return roles.length > 0;
1529
+ }
1530
+ async checkUniqueFields(schema, data, excludeId) {
1531
+ const uniqueFields = Object.entries(schema.attributes)
1532
+ .filter(([_, attr]) => attr.unique)
1533
+ .map(([key]) => key);
1534
+ const conflictingFields = [];
1535
+ for (const field of uniqueFields) {
1536
+ if (data[field] !== undefined) {
1537
+ const query = { [field]: data[field] };
1538
+ if (excludeId)
1539
+ query.id = { $ne: excludeId };
1540
+ const existingRecords = await this.getModel(schema.modelName.toLowerCase()).read(query);
1541
+ if (existingRecords.length > 0)
1542
+ conflictingFields.push(field);
1543
+ }
1544
+ }
1545
+ return conflictingFields.length > 0 ? conflictingFields : null;
1546
+ }
1547
+ }
1548
+ exports.APIRouter = APIRouter;