@airoom/nextmin-node 0.1.3 → 0.1.5
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/LICENSE +18 -46
- package/dist/api/apiRouter.d.ts +8 -24
- package/dist/api/apiRouter.js +126 -1251
- package/dist/api/router/ctx.d.ts +25 -0
- package/dist/api/router/ctx.js +2 -0
- package/dist/api/router/mountCrudRoutes.d.ts +2 -0
- package/dist/api/router/mountCrudRoutes.js +754 -0
- package/dist/api/router/mountFindRoutes.d.ts +2 -0
- package/dist/api/router/mountFindRoutes.js +205 -0
- package/dist/api/router/setupAuthRoutes.d.ts +2 -0
- package/dist/api/router/setupAuthRoutes.js +247 -0
- package/dist/api/router/setupFileRoutes.d.ts +2 -0
- package/dist/api/router/setupFileRoutes.js +85 -0
- package/dist/api/router/utils.d.ts +63 -0
- package/dist/api/router/utils.js +247 -0
- package/dist/database/MongoAdapter.d.ts +1 -1
- package/dist/database/MongoAdapter.js +33 -32
- package/dist/schemas/Roles.json +7 -2
- package/dist/utils/DefaultDataInitializer.js +3 -0
- package/dist/utils/SchemaLoader.js +55 -11
- package/package.json +2 -2
package/dist/api/apiRouter.js
CHANGED
|
@@ -7,16 +7,14 @@ exports.APIRouter = void 0;
|
|
|
7
7
|
const express_1 = __importDefault(require("express"));
|
|
8
8
|
const BaseModel_1 = require("../models/BaseModel");
|
|
9
9
|
const Logger_1 = __importDefault(require("../utils/Logger"));
|
|
10
|
-
const bcrypt_1 = __importDefault(require("bcrypt"));
|
|
11
10
|
const jsonwebtoken_1 = __importDefault(require("jsonwebtoken"));
|
|
12
11
|
const SchemaLoader_1 = require("../utils/SchemaLoader");
|
|
13
12
|
const InMemoryAdapter_1 = require("../database/InMemoryAdapter");
|
|
14
13
|
const DefaultDataInitializer_1 = require("../utils/DefaultDataInitializer");
|
|
15
14
|
const SchemaService_1 = require("../services/SchemaService");
|
|
16
|
-
const
|
|
17
|
-
const
|
|
18
|
-
const
|
|
19
|
-
const fieldCodecs_1 = require("../utils/fieldCodecs");
|
|
15
|
+
const setupAuthRoutes_1 = require("./router/setupAuthRoutes");
|
|
16
|
+
const mountCrudRoutes_1 = require("./router/mountCrudRoutes");
|
|
17
|
+
const mountFindRoutes_1 = require("./router/mountFindRoutes");
|
|
20
18
|
class APIRouter {
|
|
21
19
|
constructor(options) {
|
|
22
20
|
this.models = {};
|
|
@@ -25,12 +23,12 @@ class APIRouter {
|
|
|
25
23
|
this.schemasRouteRegistered = false;
|
|
26
24
|
this.registeredModels = new Set();
|
|
27
25
|
this.liveSchemas = {};
|
|
28
|
-
//
|
|
26
|
+
// ---------- Live lookups ----------
|
|
29
27
|
this.getSchema = (name) => {
|
|
30
28
|
const s = this.liveSchemas[name];
|
|
31
29
|
if (!s) {
|
|
32
30
|
const err = new Error(`Model '${name}' removed`);
|
|
33
|
-
err.code =
|
|
31
|
+
err.code = "MODEL_REMOVED";
|
|
34
32
|
throw err;
|
|
35
33
|
}
|
|
36
34
|
return s;
|
|
@@ -40,19 +38,18 @@ class APIRouter {
|
|
|
40
38
|
const m = this.models[name];
|
|
41
39
|
if (!m) {
|
|
42
40
|
const err = new Error(`Model '${name}' unavailable`);
|
|
43
|
-
err.code =
|
|
41
|
+
err.code = "MODEL_UNAVAILABLE";
|
|
44
42
|
throw err;
|
|
45
43
|
}
|
|
46
44
|
return m;
|
|
47
45
|
};
|
|
48
|
-
|
|
49
|
-
// ---------------- Auth (login/me/change-password) ----------------
|
|
46
|
+
// ---------- middleware ----------
|
|
50
47
|
this.optionalAuthMiddleware = (req, res, next) => {
|
|
51
48
|
this.apiKeyMiddleware(req, res, () => {
|
|
52
49
|
const authHeader = req.headers.authorization;
|
|
53
50
|
if (!authHeader)
|
|
54
51
|
return next();
|
|
55
|
-
const token = authHeader.split(
|
|
52
|
+
const token = authHeader.split(" ")[1];
|
|
56
53
|
if (!token)
|
|
57
54
|
return next();
|
|
58
55
|
try {
|
|
@@ -65,7 +62,7 @@ class APIRouter {
|
|
|
65
62
|
});
|
|
66
63
|
};
|
|
67
64
|
this.normalizeRoleName = async (value) => {
|
|
68
|
-
const rolesModel = this.getModel(
|
|
65
|
+
const rolesModel = this.getModel("roles");
|
|
69
66
|
const isHex24 = (s) => /^[0-9a-fA-F]{24}$/.test(s);
|
|
70
67
|
const fromString = async (s) => {
|
|
71
68
|
if (!s)
|
|
@@ -73,17 +70,17 @@ class APIRouter {
|
|
|
73
70
|
if (isHex24(s) && rolesModel) {
|
|
74
71
|
const docs = await rolesModel.read({ id: s }, 1, 0, true);
|
|
75
72
|
const n = docs?.[0]?.name;
|
|
76
|
-
return typeof n ===
|
|
73
|
+
return typeof n === "string" ? n : null;
|
|
77
74
|
}
|
|
78
|
-
return s;
|
|
75
|
+
return s;
|
|
79
76
|
};
|
|
80
|
-
if (typeof value ===
|
|
77
|
+
if (typeof value === "string")
|
|
81
78
|
return (await fromString(value))?.toLowerCase() ?? null;
|
|
82
79
|
if (Array.isArray(value)) {
|
|
83
80
|
for (const v of value) {
|
|
84
|
-
const n = typeof v ===
|
|
81
|
+
const n = typeof v === "string"
|
|
85
82
|
? await fromString(v)
|
|
86
|
-
: v && typeof v ===
|
|
83
|
+
: v && typeof v === "object" && typeof v.name === "string"
|
|
87
84
|
? v.name
|
|
88
85
|
: null;
|
|
89
86
|
if (n)
|
|
@@ -91,21 +88,22 @@ class APIRouter {
|
|
|
91
88
|
}
|
|
92
89
|
return null;
|
|
93
90
|
}
|
|
94
|
-
if (value && typeof value ===
|
|
91
|
+
if (value && typeof value === "object") {
|
|
95
92
|
const o = value;
|
|
96
|
-
if (typeof o.name ===
|
|
93
|
+
if (typeof o.name === "string")
|
|
97
94
|
return o.name.toLowerCase();
|
|
98
|
-
if (typeof o.id ===
|
|
95
|
+
if (typeof o.id === "string") {
|
|
99
96
|
const n = await fromString(o.id);
|
|
100
97
|
return n ? n.toLowerCase() : null;
|
|
101
98
|
}
|
|
102
99
|
}
|
|
103
100
|
return null;
|
|
104
101
|
};
|
|
102
|
+
// ---------- auth middlewares ----------
|
|
105
103
|
this.apiKeyMiddleware = (req, res, next) => {
|
|
106
|
-
const apiKey = req.headers[
|
|
107
|
-
if (typeof apiKey !==
|
|
108
|
-
return res.status(401).json({ error:
|
|
104
|
+
const apiKey = req.headers["x-api-key"];
|
|
105
|
+
if (typeof apiKey !== "string" || apiKey !== this.trustedApiKey) {
|
|
106
|
+
return res.status(401).json({ error: "Invalid or missing API key" });
|
|
109
107
|
}
|
|
110
108
|
next();
|
|
111
109
|
};
|
|
@@ -113,10 +111,10 @@ class APIRouter {
|
|
|
113
111
|
this.apiKeyMiddleware(req, res, () => {
|
|
114
112
|
const authHeader = req.headers.authorization;
|
|
115
113
|
if (!authHeader)
|
|
116
|
-
return res.status(401).json({ error:
|
|
117
|
-
const token = authHeader.split(
|
|
114
|
+
return res.status(401).json({ error: "Authorization header missing" });
|
|
115
|
+
const token = authHeader.split(" ")[1];
|
|
118
116
|
if (!token)
|
|
119
|
-
return res.status(401).json({ error:
|
|
117
|
+
return res.status(401).json({ error: "API Token missing." });
|
|
120
118
|
try {
|
|
121
119
|
const decoded = jsonwebtoken_1.default.verify(token, this.jwtSecret);
|
|
122
120
|
// @ts-ignore
|
|
@@ -124,32 +122,68 @@ class APIRouter {
|
|
|
124
122
|
next();
|
|
125
123
|
}
|
|
126
124
|
catch {
|
|
127
|
-
res.status(401).json({ error:
|
|
125
|
+
res.status(401).json({ error: "Invalid or expired token" });
|
|
128
126
|
}
|
|
129
127
|
});
|
|
130
128
|
};
|
|
131
|
-
|
|
129
|
+
// ---------- helpers ----------
|
|
130
|
+
this.validateRequiredFields = (schema, payload, mode = "create") => {
|
|
131
|
+
const missing = [];
|
|
132
|
+
const isEmpty = (v) => v === undefined || v === null || (typeof v === "string" && v.trim() === "");
|
|
133
|
+
let baseKeys = null;
|
|
134
|
+
const baseName = schema?.extends;
|
|
135
|
+
if (baseName) {
|
|
136
|
+
try {
|
|
137
|
+
const baseLC = baseName.toLowerCase();
|
|
138
|
+
const baseSchema = this.getSchema(baseLC);
|
|
139
|
+
baseKeys = new Set(Object.keys(baseSchema.attributes || {}));
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
baseKeys = null;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
for (const [key, attribute] of Object.entries(schema.attributes)) {
|
|
146
|
+
if (Array.isArray(attribute))
|
|
147
|
+
continue;
|
|
148
|
+
if (attribute?.private)
|
|
149
|
+
continue;
|
|
150
|
+
if (baseKeys && key !== "baseId" && baseKeys.has(key))
|
|
151
|
+
continue;
|
|
152
|
+
const reqd = Boolean(attribute?.required);
|
|
153
|
+
if (!reqd)
|
|
154
|
+
continue;
|
|
155
|
+
if (mode === "create") {
|
|
156
|
+
if (isEmpty(payload[key]))
|
|
157
|
+
missing.push(key);
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
if (Object.prototype.hasOwnProperty.call(payload, key) && isEmpty(payload[key])) {
|
|
161
|
+
missing.push(key);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return missing;
|
|
166
|
+
};
|
|
167
|
+
this.isDevelopment = process.env.APP_MODE !== "production";
|
|
132
168
|
this.router = express_1.default.Router();
|
|
133
169
|
this.fileStorage = options.fileStorageAdapter;
|
|
134
|
-
this.setupFileRoutes();
|
|
135
170
|
this.dbAdapter = options.dbAdapter || new InMemoryAdapter_1.InMemoryAdapter();
|
|
136
|
-
this.jwtSecret = process.env.JWT_SECRET ||
|
|
171
|
+
this.jwtSecret = process.env.JWT_SECRET || "default_jwt_secret";
|
|
137
172
|
if (this.isDevelopment && options.server) {
|
|
138
173
|
(0, SchemaService_1.startSchemaService)(options.server, {
|
|
139
174
|
getApiKey: () => this.trustedApiKey,
|
|
140
175
|
});
|
|
141
|
-
options.server.on(
|
|
176
|
+
options.server.on("listening", () => {
|
|
142
177
|
// @ts-ignore
|
|
143
178
|
const addr = options.server.address();
|
|
144
|
-
Logger_1.default.info(
|
|
179
|
+
Logger_1.default.info("SchemaService", `[schema-service] started at /__nextmin__/schema ns /schema on ${typeof addr === "string" ? addr : `${addr?.address}:${addr?.port}`}`);
|
|
145
180
|
});
|
|
146
181
|
}
|
|
147
|
-
this.schemaLoader =
|
|
148
|
-
SchemaLoader_1.SchemaLoader.getInstance?.() ?? new SchemaLoader_1.SchemaLoader();
|
|
182
|
+
this.schemaLoader = SchemaLoader_1.SchemaLoader.getInstance?.() ?? new SchemaLoader_1.SchemaLoader();
|
|
149
183
|
const initialSchemas = this.schemaLoader.getSchemas();
|
|
150
184
|
this.setLiveSchemas(initialSchemas);
|
|
151
185
|
const finishBoot = async () => {
|
|
152
|
-
if (typeof this.dbAdapter.registerSchemas ===
|
|
186
|
+
if (typeof this.dbAdapter.registerSchemas === "function") {
|
|
153
187
|
await this.dbAdapter.registerSchemas(initialSchemas);
|
|
154
188
|
}
|
|
155
189
|
this.rebuildModels(Object.values(initialSchemas));
|
|
@@ -160,48 +194,47 @@ class APIRouter {
|
|
|
160
194
|
const initializer = new DefaultDataInitializer_1.DefaultDataInitializer(this.dbAdapter, this.models);
|
|
161
195
|
try {
|
|
162
196
|
await initializer.initialize();
|
|
163
|
-
this.trustedApiKey = initializer.getApiKey() ||
|
|
164
|
-
Logger_1.default.info(
|
|
197
|
+
this.trustedApiKey = initializer.getApiKey() || "";
|
|
198
|
+
Logger_1.default.info("APIRouter", `Trusted API key set: ${this.trustedApiKey ? "[hidden]" : "none"}`);
|
|
165
199
|
}
|
|
166
200
|
catch (err) {
|
|
167
|
-
Logger_1.default.error(
|
|
201
|
+
Logger_1.default.error("APIRouter", "Failed to initialize default data", err);
|
|
168
202
|
}
|
|
169
203
|
this.setupNotFoundMiddleware();
|
|
170
204
|
this.wireSchemaHotReload();
|
|
171
205
|
};
|
|
172
|
-
if (typeof this.dbAdapter.registerSchemas ===
|
|
206
|
+
if (typeof this.dbAdapter.registerSchemas === "function") {
|
|
173
207
|
const res = this.dbAdapter.registerSchemas(initialSchemas);
|
|
174
208
|
if (res instanceof Promise) {
|
|
175
|
-
res
|
|
176
|
-
|
|
209
|
+
res
|
|
210
|
+
.then(finishBoot)
|
|
211
|
+
.catch((err) => {
|
|
212
|
+
Logger_1.default.error("APIRouter", "registerSchemas failed", err);
|
|
177
213
|
this.setupNotFoundMiddleware();
|
|
178
214
|
});
|
|
179
215
|
return;
|
|
180
216
|
}
|
|
181
217
|
}
|
|
182
218
|
finishBoot().catch((err) => {
|
|
183
|
-
Logger_1.default.error(
|
|
219
|
+
Logger_1.default.error("APIRouter", "finishBoot error", err);
|
|
184
220
|
this.setupNotFoundMiddleware();
|
|
185
221
|
});
|
|
186
222
|
}
|
|
187
223
|
getRouter() {
|
|
188
|
-
// Avoid noisy logs on each getRouter() call
|
|
189
224
|
return this.router;
|
|
190
225
|
}
|
|
191
|
-
//
|
|
226
|
+
// ---------- Hot reload ----------
|
|
192
227
|
wireSchemaHotReload() {
|
|
193
228
|
const anyLoader = this.schemaLoader;
|
|
194
|
-
if (typeof anyLoader.on !==
|
|
229
|
+
if (typeof anyLoader.on !== "function")
|
|
195
230
|
return;
|
|
196
|
-
anyLoader.on(
|
|
231
|
+
anyLoader.on("schemasChanged", async (newSchemas) => {
|
|
197
232
|
try {
|
|
198
233
|
const removed = this.diffRemovedModels(this.liveSchemas, newSchemas);
|
|
199
|
-
if (removed.length &&
|
|
200
|
-
typeof this.dbAdapter.unregisterSchemas === 'function') {
|
|
234
|
+
if (removed.length && typeof this.dbAdapter.unregisterSchemas === "function") {
|
|
201
235
|
await this.dbAdapter.unregisterSchemas(removed);
|
|
202
236
|
}
|
|
203
|
-
else if (removed.length &&
|
|
204
|
-
typeof this.dbAdapter.dropModel === 'function') {
|
|
237
|
+
else if (removed.length && typeof this.dbAdapter.dropModel === "function") {
|
|
205
238
|
for (const name of removed) {
|
|
206
239
|
try {
|
|
207
240
|
await this.dbAdapter.dropModel(name);
|
|
@@ -209,24 +242,24 @@ class APIRouter {
|
|
|
209
242
|
catch { }
|
|
210
243
|
}
|
|
211
244
|
}
|
|
212
|
-
if (typeof this.dbAdapter.registerSchemas ===
|
|
245
|
+
if (typeof this.dbAdapter.registerSchemas === "function") {
|
|
213
246
|
await this.dbAdapter.registerSchemas(newSchemas);
|
|
214
247
|
}
|
|
215
248
|
this.setLiveSchemas(newSchemas);
|
|
216
249
|
this.rebuildModels(Object.values(newSchemas));
|
|
217
250
|
this.mountRoutes(newSchemas);
|
|
218
251
|
await this.syncAllIndexes(newSchemas);
|
|
219
|
-
Logger_1.default.info(
|
|
252
|
+
Logger_1.default.info("APIRouter", `Schemas reloaded (added/updated: ${Object.keys(newSchemas).length}, removed: ${removed.join(", ") || "none"})`);
|
|
220
253
|
}
|
|
221
254
|
catch (err) {
|
|
222
|
-
Logger_1.default.error(
|
|
255
|
+
Logger_1.default.error("APIRouter", "Failed to refresh after schemasChanged", err);
|
|
223
256
|
}
|
|
224
257
|
});
|
|
225
258
|
}
|
|
226
259
|
async syncAllIndexes(schemas) {
|
|
227
|
-
if (typeof this.dbAdapter.syncIndexes !==
|
|
260
|
+
if (typeof this.dbAdapter.syncIndexes !== "function")
|
|
228
261
|
return;
|
|
229
|
-
const plan = this.schemaLoader.getIndexPlan();
|
|
262
|
+
const plan = this.schemaLoader.getIndexPlan();
|
|
230
263
|
for (const s of Object.values(schemas)) {
|
|
231
264
|
const modelName = s.modelName;
|
|
232
265
|
const spec = plan[modelName];
|
|
@@ -235,7 +268,7 @@ class APIRouter {
|
|
|
235
268
|
await this.dbAdapter.syncIndexes(modelName, spec);
|
|
236
269
|
}
|
|
237
270
|
catch (err) {
|
|
238
|
-
Logger_1.default.warn(
|
|
271
|
+
Logger_1.default.warn("APIRouter", `Index sync failed for ${modelName}: ${err?.message || err}`);
|
|
239
272
|
}
|
|
240
273
|
}
|
|
241
274
|
}
|
|
@@ -247,7 +280,7 @@ class APIRouter {
|
|
|
247
280
|
if (!next.has(name.toLowerCase()))
|
|
248
281
|
removed.push(name.toLowerCase());
|
|
249
282
|
}
|
|
250
|
-
return removed.filter((n) => n !==
|
|
283
|
+
return removed.filter((n) => n !== "users" && n !== "roles");
|
|
251
284
|
}
|
|
252
285
|
setLiveSchemas(schemas) {
|
|
253
286
|
const idx = {};
|
|
@@ -262,12 +295,11 @@ class APIRouter {
|
|
|
262
295
|
this.models[name] = new BaseModel_1.BaseModel(s, this.dbAdapter);
|
|
263
296
|
}
|
|
264
297
|
}
|
|
265
|
-
// ---------------- Mounting ----------------
|
|
266
298
|
mountSchemasEndpointOnce() {
|
|
267
299
|
if (this.schemasRouteRegistered)
|
|
268
300
|
return;
|
|
269
301
|
this.schemasRouteRegistered = true;
|
|
270
|
-
this.router.get(
|
|
302
|
+
this.router.get("/_schemas", this.apiKeyMiddleware, (_req, res) => {
|
|
271
303
|
res.json({
|
|
272
304
|
success: true,
|
|
273
305
|
data: this.schemaLoader.getPublicSchemaList(),
|
|
@@ -275,906 +307,48 @@ class APIRouter {
|
|
|
275
307
|
});
|
|
276
308
|
}
|
|
277
309
|
mountRoutes(schemas) {
|
|
278
|
-
if (!this.authRoutesInitialized && this.liveSchemas[
|
|
279
|
-
|
|
310
|
+
if (!this.authRoutesInitialized && this.liveSchemas["users"]) {
|
|
311
|
+
(0, setupAuthRoutes_1.setupAuthRoutes)(this.createCtx());
|
|
280
312
|
this.authRoutesInitialized = true;
|
|
281
313
|
}
|
|
282
314
|
for (const s of Object.values(schemas)) {
|
|
283
315
|
const name = s.modelName.toLowerCase();
|
|
284
316
|
if (this.registeredModels.has(name))
|
|
285
317
|
continue;
|
|
286
|
-
this.
|
|
318
|
+
(0, mountCrudRoutes_1.mountCrudRoutes)(this.createCtx(), name);
|
|
287
319
|
this.registeredModels.add(name);
|
|
288
320
|
}
|
|
289
321
|
this.ensureNotFoundLast();
|
|
290
322
|
}
|
|
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
323
|
mountFindRoutes() {
|
|
801
324
|
if (this.findRoutesMounted)
|
|
802
325
|
return;
|
|
803
326
|
this.findRoutesMounted = true;
|
|
804
|
-
|
|
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
|
|
327
|
+
(0, mountFindRoutes_1.mountFindRoutes)(this.createCtx());
|
|
1073
328
|
this.ensureNotFoundLast();
|
|
1074
329
|
}
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
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 || '');
|
|
330
|
+
// ---------- Context builder for modules ----------
|
|
331
|
+
createCtx() {
|
|
332
|
+
return {
|
|
333
|
+
router: this.router,
|
|
334
|
+
dbAdapter: this.dbAdapter,
|
|
335
|
+
schemaLoader: this.schemaLoader,
|
|
336
|
+
liveSchemas: this.liveSchemas,
|
|
337
|
+
models: this.models,
|
|
338
|
+
jwtSecret: this.jwtSecret,
|
|
339
|
+
trustedApiKey: this.trustedApiKey,
|
|
340
|
+
fileStorage: this.fileStorage,
|
|
341
|
+
getSchema: this.getSchema,
|
|
342
|
+
getModel: this.getModel,
|
|
343
|
+
apiKeyMiddleware: this.apiKeyMiddleware,
|
|
344
|
+
optionalAuthMiddleware: this.optionalAuthMiddleware,
|
|
345
|
+
authenticateMiddleware: this.authenticateMiddleware,
|
|
346
|
+
pickAuthFor: this.pickAuthFor,
|
|
347
|
+
validateRequiredFields: this.validateRequiredFields,
|
|
348
|
+
handleWriteError: this.handleWriteError.bind(this),
|
|
349
|
+
checkUniqueFields: this.checkUniqueFields.bind(this),
|
|
350
|
+
normalizeRoleName: this.normalizeRoleName.bind(this),
|
|
351
|
+
};
|
|
1178
352
|
}
|
|
1179
353
|
pickAuthFor(action, modelNameLC) {
|
|
1180
354
|
try {
|
|
@@ -1185,327 +359,35 @@ class APIRouter {
|
|
|
1185
359
|
return this.optionalAuthMiddleware;
|
|
1186
360
|
if (publicRule === false)
|
|
1187
361
|
return this.authenticateMiddleware;
|
|
1188
|
-
return action ===
|
|
1189
|
-
? this.optionalAuthMiddleware
|
|
1190
|
-
: this.authenticateMiddleware;
|
|
362
|
+
return action === "read" ? this.optionalAuthMiddleware : this.authenticateMiddleware;
|
|
1191
363
|
}
|
|
1192
364
|
catch {
|
|
1193
|
-
return action ===
|
|
1194
|
-
? this.optionalAuthMiddleware
|
|
1195
|
-
: this.authenticateMiddleware;
|
|
365
|
+
return action === "read" ? this.optionalAuthMiddleware : this.authenticateMiddleware;
|
|
1196
366
|
}
|
|
1197
367
|
}
|
|
1198
368
|
getUserRoleFromReq(req) {
|
|
1199
369
|
const r = req.user?.role;
|
|
1200
|
-
return typeof r ===
|
|
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;
|
|
370
|
+
return typeof r === "string" ? r : typeof r?.name === "string" ? r.name : null;
|
|
1487
371
|
}
|
|
1488
372
|
handleWriteError(error, res) {
|
|
1489
|
-
if (error?.code ===
|
|
373
|
+
if (error?.code === "MODEL_REMOVED") {
|
|
1490
374
|
res.status(410).json({ error: true, message: error.message });
|
|
1491
375
|
return;
|
|
1492
376
|
}
|
|
1493
377
|
if (error?.code === 11000) {
|
|
1494
378
|
const field = Object.keys(error.keyPattern || {})[0];
|
|
1495
|
-
res
|
|
1496
|
-
.status(400)
|
|
1497
|
-
.json({ error: true, message: `Duplicate value for field: ${field}` });
|
|
379
|
+
res.status(400).json({ error: true, message: `Duplicate value for field: ${field}` });
|
|
1498
380
|
}
|
|
1499
381
|
else {
|
|
1500
|
-
res.status(400).json({ error: true, message: error?.message ||
|
|
382
|
+
res.status(400).json({ error: true, message: error?.message || "Error" });
|
|
1501
383
|
}
|
|
1502
384
|
}
|
|
1503
385
|
setupNotFoundMiddleware() {
|
|
1504
386
|
if (!this.notFoundHandler) {
|
|
1505
387
|
this.notFoundHandler = (req, res) => {
|
|
1506
|
-
Logger_1.default.warn(
|
|
388
|
+
Logger_1.default.warn("apiRouter", `API route not found: ${req.originalUrl}`);
|
|
1507
389
|
res.status(404).json({
|
|
1508
|
-
error:
|
|
390
|
+
error: "API route not found",
|
|
1509
391
|
path: req.originalUrl,
|
|
1510
392
|
method: req.method,
|
|
1511
393
|
});
|
|
@@ -1518,14 +400,7 @@ class APIRouter {
|
|
|
1518
400
|
return;
|
|
1519
401
|
// @ts-ignore
|
|
1520
402
|
this.router.stack = this.router.stack.filter((layer) => layer?.handle !== this.notFoundHandler);
|
|
1521
|
-
this.router.use(
|
|
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;
|
|
403
|
+
this.router.use("*", this.notFoundHandler);
|
|
1529
404
|
}
|
|
1530
405
|
async checkUniqueFields(schema, data, excludeId) {
|
|
1531
406
|
const uniqueFields = Object.entries(schema.attributes)
|