@airoom/nextmin-node 2.0.2 → 2.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +151 -0
- package/dist/api/apiRouter.d.ts +20 -0
- package/dist/api/apiRouter.js +122 -7
- package/dist/api/router/mountCrudRoutes.js +69 -18
- package/dist/api/router/setupAuthRoutes.js +475 -38
- package/dist/api/router/setupChatWidgetRoutes.d.ts +3 -0
- package/dist/api/router/setupChatWidgetRoutes.js +207 -0
- package/dist/api/router/setupFileRoutes.js +221 -16
- package/dist/api/router/utils.d.ts +2 -1
- package/dist/api/router/utils.js +8 -6
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +145 -64
- package/dist/database/DatabaseAdapter.d.ts +1 -0
- package/dist/database/NMAdapter.d.ts +6 -0
- package/dist/database/NMAdapter.js +437 -86
- package/dist/database/QueryEngine.js +7 -4
- package/dist/files/FileStorageAdapter.d.ts +1 -0
- package/dist/files/LocalFileStorageAdapter.d.ts +1 -0
- package/dist/files/LocalFileStorageAdapter.js +25 -3
- package/dist/files/S3FileStorageAdapter.d.ts +3 -1
- package/dist/files/S3FileStorageAdapter.js +72 -11
- package/dist/files/filename.js +6 -4
- package/dist/index.d.ts +1 -0
- package/dist/index.js +3 -1
- package/dist/models/BaseModel.d.ts +4 -0
- package/dist/policy/authorize.d.ts +1 -1
- package/dist/policy/authorize.js +64 -13
- package/dist/schemas/Users.json +20 -10
- package/dist/services/IndexingService.d.ts +24 -0
- package/dist/services/IndexingService.js +555 -0
- package/dist/services/LocalAIService.d.ts +34 -0
- package/dist/services/LocalAIService.js +471 -0
- package/dist/services/OpenAIService.d.ts +16 -0
- package/dist/services/OpenAIService.js +586 -0
- package/dist/utils/SchemaLoader.d.ts +1 -1
- package/dist/utils/SchemaLoader.js +40 -4
- package/package.json +12 -4
package/README.md
CHANGED
|
@@ -160,6 +160,150 @@ events.on(getModelEvent('Post', 'delete', 'before'), ({ id }) => {
|
|
|
160
160
|
|
|
161
161
|
See full examples in documentation and the `examples/node` app inside this monorepo.
|
|
162
162
|
|
|
163
|
+
## Cloudflare AI Search Integration (RAG Chatbot)
|
|
164
|
+
|
|
165
|
+
NextMin includes a pluggable, cloud-native AI chatbot assistant widget powered entirely by **Cloudflare AI Search** (a fully managed cloud-based Retrieval-Augmented Generation platform). It supports crawling/scraping website pages, indexing database collections, and generating context-aware chat completions natively in the cloud.
|
|
166
|
+
|
|
167
|
+
### 1. Environment Configuration
|
|
168
|
+
|
|
169
|
+
Add the following environment variables to your `.env` file to enable Cloudflare AI Search:
|
|
170
|
+
|
|
171
|
+
```env
|
|
172
|
+
CLOUDFLARE_ACCOUNT_ID=your_cloudflare_account_id
|
|
173
|
+
CLOUDFLARE_API_TOKEN=your_cloudflare_api_token
|
|
174
|
+
CLOUDFLARE_AI_SEARCH_INDEX=your_ai_search_instance_name
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
### 2. Chat Widget Backend Setup
|
|
178
|
+
|
|
179
|
+
Pass the `chatWidget` option when creating the NextMin router:
|
|
180
|
+
|
|
181
|
+
```ts
|
|
182
|
+
import { createNextMinRouter } from '@airoom/nextmin-node';
|
|
183
|
+
|
|
184
|
+
const router = createNextMinRouter({
|
|
185
|
+
dbAdapter,
|
|
186
|
+
server, // Required for socket.io realtime events
|
|
187
|
+
chatWidget: {
|
|
188
|
+
enabled: true,
|
|
189
|
+
siteUrl: 'http://localhost:3000', // Root URL to crawl & scrape
|
|
190
|
+
scrapePaths: ['/'], // Entry points for crawler
|
|
191
|
+
frontendBuildPath: './.next', // Local build directory (optional)
|
|
192
|
+
autoCrawlIntervalMs: 3600000, // Auto-crawl interval (1 hour)
|
|
193
|
+
concurrency: 5 // Concurrent crawler thread count (default: 5)
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
* **Zero Local server CPU/GPU footprint**: No GGUF or local model files are loaded in memory.
|
|
199
|
+
* **Pluggable Database Modularity**: No database tables or vector schemas (like `nextminembedding`) are registered when using Cloudflare AI Search, keeping your database 100% clean.
|
|
200
|
+
* **Scraped Staged Pages**: Scraped files are saved as `.txt` files in `.nextmin/scraped-pages/` on the local disk.
|
|
201
|
+
* **Cost-Efficient Incremental Crawls**: Hashes are tracked in `.nextmin/indexing-manifest.json` to only upload modified pages and delete removed ones.
|
|
202
|
+
|
|
203
|
+
---
|
|
204
|
+
|
|
205
|
+
### 3. Reindexing
|
|
206
|
+
|
|
207
|
+
#### Programmatically in Code
|
|
208
|
+
|
|
209
|
+
```ts
|
|
210
|
+
import { reindex } from '@airoom/nextmin-node';
|
|
211
|
+
|
|
212
|
+
await reindex({
|
|
213
|
+
chatWidget: {
|
|
214
|
+
enabled: true,
|
|
215
|
+
siteUrl: 'http://localhost:3000',
|
|
216
|
+
scrapePaths: ['/'],
|
|
217
|
+
concurrency: 5 // concurrent scraping threads (default: 5)
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
#### Manual Reindexing via CLI
|
|
223
|
+
|
|
224
|
+
To manually trigger a crawl and sync with Cloudflare AI Search, invoke the CLI directly:
|
|
225
|
+
|
|
226
|
+
```bash
|
|
227
|
+
# Using npx (globally available)
|
|
228
|
+
npx nextmin reindex
|
|
229
|
+
|
|
230
|
+
or
|
|
231
|
+
|
|
232
|
+
npx nextmin reindex --force
|
|
233
|
+
|
|
234
|
+
# Set custom concurrency threads
|
|
235
|
+
npx nextmin reindex --threads 10
|
|
236
|
+
|
|
237
|
+
# Or invoking the compiled JS file directly
|
|
238
|
+
node ./node_modules/@airoom/nextmin-node/dist/cli.js reindex
|
|
239
|
+
node ./node_modules/@airoom/nextmin-node/dist/cli.js reindex --force --threads 10
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
> **Note:** The CLI automatically loads your server's configuration from `.nextmin/config.json` (saved transparently on your server boot) so you do not need to duplicate setup params or provide database credentials. It also traverses directories upwards to load `.env.local` or `.env` files automatically.
|
|
243
|
+
|
|
244
|
+
#### Environment Variables for Reindexing
|
|
245
|
+
|
|
246
|
+
The `reindex` command reads the following environment variables:
|
|
247
|
+
|
|
248
|
+
| Variable | Description | Default / Fallback |
|
|
249
|
+
|---|---|---|
|
|
250
|
+
| `NEXTMIN_CHAT_ENABLED` | Set to `false` to disable the indexer process completely. | `true` |
|
|
251
|
+
| `CLOUDFLARE_ACCOUNT_ID` | Your Cloudflare Account ID (Required). | - |
|
|
252
|
+
| `CLOUDFLARE_API_TOKEN` | Cloudflare API Token with vector/AI permissions (Required). | - |
|
|
253
|
+
| `CLOUDFLARE_AI_SEARCH_INDEX` | Cloudflare AI Search index name (Required, falls back to `CLOUDFLARE_VECTORIZE_INDEX`). | - |
|
|
254
|
+
| `NEXTMIN_CHAT_SITE_URL` | The full base website URL to crawl. | `process.env.FRONTEND_URL` \|\| `http://localhost:${PORT \|\| 3000}` |
|
|
255
|
+
| `NEXTMIN_CHAT_SCRAPE_PATHS` | Comma-separated paths to scrape from the site URL. | `/` |
|
|
256
|
+
| `NEXTMIN_CHAT_CONCURRENCY` | Number of concurrent scraping threads to run. | `5` |
|
|
257
|
+
| `NEXTMIN_CHAT_FRONTEND_PATH` | Path to your frontend Next.js build directory. | `./.next` |
|
|
258
|
+
| `NEXTMIN_CHAT_MODEL_PATH` | Path to a local GGUF model (if using local chat). | - |
|
|
259
|
+
|
|
260
|
+
Alternatively, you can pass these directly as CLI flags (e.g. `--cf-account-id <id>`, `--cf-token <token>`, `--cf-index <index>`, `--site-url <url>`, `--threads <count>`).
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
## Registration Whitelisting and Role Onboarding
|
|
265
|
+
|
|
266
|
+
You can secure the user registration flow by configuring `publicRegisterFields` and `roleDefaultStatuses` in your `Users.json` schema:
|
|
267
|
+
|
|
268
|
+
- **`publicRegisterFields`**: Array of fields allowed during public signup (e.g. `["username", "email", "firstName", "lastName", "password", "role"]`). If a field outside this list (like `status`) is submitted, a `400 Bad Request` is returned. If omitted, all fields are permitted for backward compatibility.
|
|
269
|
+
- **`roleDefaultStatuses`**: Map of initial user statuses based on their selected role (e.g., `"buyer": "active"` to allow buyers immediately, and `"developer": "pending"` or `"agent": "pending"` to require administrator verification).
|
|
270
|
+
- **Admin role protection**: Public endpoints block attempts to register under the `admin` or `superadmin` role (returns `403 Forbidden` unless requested by an authenticated administrator).
|
|
271
|
+
|
|
272
|
+
## Schema Features: Dynamic `refPath` & `readOnly` Attributes
|
|
273
|
+
|
|
274
|
+
### Dynamic References (`refPath`)
|
|
275
|
+
To dynamically populate a reference based on another field's value in the same document (e.g. `subjectId` pointing to `Doctors` or `Hospitals` based on `subjectType`), specify `refPath` on the attribute definition:
|
|
276
|
+
|
|
277
|
+
```json
|
|
278
|
+
"subjectType": {
|
|
279
|
+
"type": "string",
|
|
280
|
+
"required": true,
|
|
281
|
+
"enum": ["Doctors", "Hospitals"],
|
|
282
|
+
"readOnly": true
|
|
283
|
+
},
|
|
284
|
+
"subjectId": {
|
|
285
|
+
"type": "ObjectId",
|
|
286
|
+
"refPath": "subjectType",
|
|
287
|
+
"show": "fullName",
|
|
288
|
+
"required": true,
|
|
289
|
+
"readOnly": true
|
|
290
|
+
}
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
- **Mutually Exclusive with `ref`**: If `refPath` is defined, `refPath` takes precedence to resolve the reference target model dynamically per document. If `refPath` is absent, standard static `ref` is used as before.
|
|
294
|
+
- **Strict Enum Constraint**: The attribute referenced by `refPath` (e.g. `subjectType`) MUST exist in the same schema and define an `enum` array of valid model names (e.g. `enum: ["Doctors", "Hospitals"]`).
|
|
295
|
+
- **Developer Warnings**: During schema initialization, `nextmin-node` automatically validates `refPath` configurations and logs warnings in the terminal if:
|
|
296
|
+
1. The target field specified in `refPath` does not exist in the same schema.
|
|
297
|
+
2. The target field is not a string attribute with an `enum` array of target model names.
|
|
298
|
+
3. An `enum` string value does not correspond to any registered schema model.
|
|
299
|
+
4. Both `ref` and `refPath` are declared on the same attribute.
|
|
300
|
+
- **Runtime Safety**: If a document has an invalid or missing `refPath` value or if the model is unpopulated, `nextmin-node` keeps the raw ID gracefully without throwing uncaught runtime exceptions.
|
|
301
|
+
|
|
302
|
+
### Attribute Immutability (`readOnly`)
|
|
303
|
+
Set `"readOnly": true` or `"editable": false` on an attribute definition. When present:
|
|
304
|
+
- **API Level**: Update endpoints (`PUT /:model/:id`) strip read-only attributes from the payload automatically.
|
|
305
|
+
- **Admin UI**: `@airoom/nextmin-react` renders disabled inputs in form views.
|
|
306
|
+
|
|
163
307
|
## Headers and auth
|
|
164
308
|
|
|
165
309
|
- All requests must include the API key header: `x-api-key: <YOUR_API_KEY>`
|
|
@@ -182,6 +326,13 @@ Database
|
|
|
182
326
|
- `MONGO_URL`: e.g. mongodb://localhost:27017
|
|
183
327
|
- `MONGO_DB`: database name
|
|
184
328
|
|
|
329
|
+
Cloudflare Vector Search (optional)
|
|
330
|
+
|
|
331
|
+
- `CLOUDFLARE_ACCOUNT_ID`: Your Cloudflare Account ID
|
|
332
|
+
- `CLOUDFLARE_API_TOKEN`: Cloudflare API Token with Vectorize & Workers AI permissions
|
|
333
|
+
- `CLOUDFLARE_VECTORIZE_INDEX`: Cloudflare Vectorize index name
|
|
334
|
+
- `CLOUDFLARE_EMBEDDING_MODEL`: Cloudflare Workers AI model name (e.g. `@cf/baai/bge-small-en-v1.5`)
|
|
335
|
+
|
|
185
336
|
File storage (optional)
|
|
186
337
|
|
|
187
338
|
- `S3_BUCKET`, `S3_REGION`
|
package/dist/api/apiRouter.d.ts
CHANGED
|
@@ -2,6 +2,21 @@ import express from 'express';
|
|
|
2
2
|
import type { Server as HttpServer } from 'http';
|
|
3
3
|
import { DatabaseAdapter } from '../database/DatabaseAdapter';
|
|
4
4
|
import type { FileStorageAdapter } from '../files/FileStorageAdapter';
|
|
5
|
+
export interface ChatWidgetConfig {
|
|
6
|
+
enabled: boolean;
|
|
7
|
+
whitelabelSchemas?: string[];
|
|
8
|
+
sourceType?: 'file' | 'db' | 'both';
|
|
9
|
+
siteUrl?: string;
|
|
10
|
+
scrapePaths?: string[];
|
|
11
|
+
autoCrawlIntervalMs?: number;
|
|
12
|
+
concurrency?: number;
|
|
13
|
+
sitemapUrl?: string;
|
|
14
|
+
systemPrompt?: string;
|
|
15
|
+
localModelPath?: string;
|
|
16
|
+
threads?: number;
|
|
17
|
+
enableOpenai?: boolean;
|
|
18
|
+
maxMessagesPerThread?: number;
|
|
19
|
+
}
|
|
5
20
|
export interface APIRouterOptions {
|
|
6
21
|
dbAdapter: DatabaseAdapter;
|
|
7
22
|
server?: HttpServer;
|
|
@@ -10,6 +25,7 @@ export interface APIRouterOptions {
|
|
|
10
25
|
originalBase?: string;
|
|
11
26
|
proxyBase?: string;
|
|
12
27
|
};
|
|
28
|
+
chatWidget?: ChatWidgetConfig;
|
|
13
29
|
}
|
|
14
30
|
export declare class APIRouter {
|
|
15
31
|
private router;
|
|
@@ -31,6 +47,10 @@ export declare class APIRouter {
|
|
|
31
47
|
private realtimeService?;
|
|
32
48
|
private fileRoutesMounted;
|
|
33
49
|
private aggregateService?;
|
|
50
|
+
private chatWidgetConfig?;
|
|
51
|
+
private autoCrawlTimer?;
|
|
52
|
+
private isAutoCrawling;
|
|
53
|
+
private startAutoCrawlScheduler;
|
|
34
54
|
constructor(options: APIRouterOptions);
|
|
35
55
|
getRouter(): express.Router;
|
|
36
56
|
private wireSchemaHotReload;
|
package/dist/api/apiRouter.js
CHANGED
|
@@ -1,4 +1,37 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
2
35
|
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
36
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
37
|
};
|
|
@@ -7,6 +40,8 @@ exports.APIRouter = void 0;
|
|
|
7
40
|
const express_1 = __importDefault(require("express"));
|
|
8
41
|
const BaseModel_1 = require("../models/BaseModel");
|
|
9
42
|
const Logger_1 = __importDefault(require("../utils/Logger"));
|
|
43
|
+
const fs_1 = __importDefault(require("fs"));
|
|
44
|
+
const path_1 = __importDefault(require("path"));
|
|
10
45
|
const jsonwebtoken_1 = __importDefault(require("jsonwebtoken"));
|
|
11
46
|
const SchemaLoader_1 = require("../utils/SchemaLoader");
|
|
12
47
|
const DefaultDataInitializer_1 = require("../utils/DefaultDataInitializer");
|
|
@@ -19,11 +54,48 @@ const mountCrudRoutes_1 = require("./router/mountCrudRoutes");
|
|
|
19
54
|
const mountFindRoutes_1 = require("./router/mountFindRoutes");
|
|
20
55
|
const mountSearchRoutes_1 = require("./router/mountSearchRoutes");
|
|
21
56
|
const mountBatchRoutes_1 = require("./router/mountBatchRoutes");
|
|
57
|
+
const setupChatWidgetRoutes_1 = require("./router/setupChatWidgetRoutes");
|
|
22
58
|
class APIRouter {
|
|
23
59
|
get isDevelopment() {
|
|
24
60
|
return (process.env.APP_MODE?.toLowerCase() !== 'production' &&
|
|
25
61
|
process.env.NODE_ENV?.toLowerCase() !== 'production');
|
|
26
62
|
}
|
|
63
|
+
startAutoCrawlScheduler() {
|
|
64
|
+
const config = this.chatWidgetConfig;
|
|
65
|
+
if (!config ||
|
|
66
|
+
!config.enabled ||
|
|
67
|
+
process.env.DISABLE_AUTO_CRAWL === 'true' ||
|
|
68
|
+
process.env.NEXTMIN_DISABLE_AUTO_CRAWL === 'true' ||
|
|
69
|
+
!config.autoCrawlIntervalMs ||
|
|
70
|
+
config.autoCrawlIntervalMs <= 0) {
|
|
71
|
+
Logger_1.default.info('APIRouter', 'Auto-crawl scheduler is disabled (stop switch active or interval <= 0).');
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
Logger_1.default.info('APIRouter', `Auto-crawl scheduler initialized. Interval: ${config.autoCrawlIntervalMs}ms (${(config.autoCrawlIntervalMs / 1000 / 60).toFixed(2)} minutes)`);
|
|
75
|
+
const runCrawl = async () => {
|
|
76
|
+
if (this.isAutoCrawling) {
|
|
77
|
+
Logger_1.default.warn('APIRouter', 'Previous auto-crawl run is still in progress. Skipping this interval.');
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
this.isAutoCrawling = true;
|
|
81
|
+
Logger_1.default.info('APIRouter', 'Starting auto-crawl and reindexing...');
|
|
82
|
+
try {
|
|
83
|
+
const { reindex } = await Promise.resolve().then(() => __importStar(require('../services/IndexingService')));
|
|
84
|
+
await reindex({ chatWidget: config });
|
|
85
|
+
Logger_1.default.info('APIRouter', 'Auto-crawl and reindexing completed successfully.');
|
|
86
|
+
}
|
|
87
|
+
catch (err) {
|
|
88
|
+
Logger_1.default.error('APIRouter', `Auto-crawl and reindexing failed: ${err?.message || err}`);
|
|
89
|
+
}
|
|
90
|
+
finally {
|
|
91
|
+
this.isAutoCrawling = false;
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
// Run immediately on startup (2s delay to allow server to finish booting) - Disabled as requested to avoid startup overhead
|
|
95
|
+
// setTimeout(() => { runCrawl(); }, 2000);
|
|
96
|
+
// Then run on the recurring interval
|
|
97
|
+
this.autoCrawlTimer = setInterval(runCrawl, config.autoCrawlIntervalMs);
|
|
98
|
+
}
|
|
27
99
|
constructor(options) {
|
|
28
100
|
this.models = {};
|
|
29
101
|
this.findRoutesMounted = false;
|
|
@@ -34,6 +106,7 @@ class APIRouter {
|
|
|
34
106
|
this.registeredModels = new Set();
|
|
35
107
|
this.liveSchemas = {};
|
|
36
108
|
this.fileRoutesMounted = false;
|
|
109
|
+
this.isAutoCrawling = false;
|
|
37
110
|
// ---------- Live lookups ----------
|
|
38
111
|
this.getSchema = (name) => {
|
|
39
112
|
const s = this.liveSchemas[name];
|
|
@@ -78,10 +151,16 @@ class APIRouter {
|
|
|
78
151
|
const fromString = async (s) => {
|
|
79
152
|
if (!s)
|
|
80
153
|
return null;
|
|
81
|
-
if (
|
|
82
|
-
const
|
|
83
|
-
const
|
|
84
|
-
|
|
154
|
+
if (rolesModel) {
|
|
155
|
+
const hexMatch = isHex24(s);
|
|
156
|
+
const numericMatch = !isNaN(Number(s)) && s.trim() !== '';
|
|
157
|
+
if (hexMatch || numericMatch) {
|
|
158
|
+
const queryId = numericMatch ? Number(s) : s;
|
|
159
|
+
const docs = await rolesModel.read({ id: queryId }, 1, 0, true);
|
|
160
|
+
const n = docs?.[0]?.name;
|
|
161
|
+
if (typeof n === 'string')
|
|
162
|
+
return n;
|
|
163
|
+
}
|
|
85
164
|
}
|
|
86
165
|
return s;
|
|
87
166
|
};
|
|
@@ -191,6 +270,36 @@ class APIRouter {
|
|
|
191
270
|
};
|
|
192
271
|
this.router = express_1.default.Router();
|
|
193
272
|
this.fileStorage = options.fileStorageAdapter;
|
|
273
|
+
this.chatWidgetConfig = options.chatWidget;
|
|
274
|
+
if (options.chatWidget) {
|
|
275
|
+
try {
|
|
276
|
+
const dir = path_1.default.join(process.cwd(), '.nextmin');
|
|
277
|
+
if (!fs_1.default.existsSync(dir)) {
|
|
278
|
+
fs_1.default.mkdirSync(dir, { recursive: true });
|
|
279
|
+
}
|
|
280
|
+
const configPath = path_1.default.join(dir, 'config.json');
|
|
281
|
+
let existingConfig = {};
|
|
282
|
+
if (fs_1.default.existsSync(configPath)) {
|
|
283
|
+
try {
|
|
284
|
+
existingConfig = JSON.parse(fs_1.default.readFileSync(configPath, 'utf-8'));
|
|
285
|
+
}
|
|
286
|
+
catch { }
|
|
287
|
+
}
|
|
288
|
+
const mergedConfig = {
|
|
289
|
+
...options.chatWidget,
|
|
290
|
+
enableOpenai: options.chatWidget.enableOpenai !== false,
|
|
291
|
+
};
|
|
292
|
+
if (existingConfig.openai) {
|
|
293
|
+
mergedConfig.openai = existingConfig.openai;
|
|
294
|
+
}
|
|
295
|
+
fs_1.default.writeFileSync(configPath, JSON.stringify(mergedConfig, null, 2), 'utf-8');
|
|
296
|
+
Logger_1.default.info('APIRouter', `Saved merged chat widget config to .nextmin/config.json`);
|
|
297
|
+
this.chatWidgetConfig = mergedConfig;
|
|
298
|
+
}
|
|
299
|
+
catch (err) {
|
|
300
|
+
Logger_1.default.error('APIRouter', 'Failed to save chat widget config to disk', err);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
194
303
|
this.dbAdapter = options.dbAdapter;
|
|
195
304
|
this.jwtSecret = process.env.JWT_SECRET || 'default_jwt_secret';
|
|
196
305
|
if (options.server) {
|
|
@@ -205,7 +314,7 @@ class APIRouter {
|
|
|
205
314
|
}
|
|
206
315
|
this.schemaLoader =
|
|
207
316
|
SchemaLoader_1.SchemaLoader.getInstance?.() ?? new SchemaLoader_1.SchemaLoader();
|
|
208
|
-
const initialSchemas = this.schemaLoader.getSchemas();
|
|
317
|
+
const initialSchemas = { ...this.schemaLoader.getSchemas() };
|
|
209
318
|
this.setLiveSchemas(initialSchemas);
|
|
210
319
|
const finishBoot = async () => {
|
|
211
320
|
if (typeof this.dbAdapter.registerSchemas === 'function') {
|
|
@@ -233,6 +342,7 @@ class APIRouter {
|
|
|
233
342
|
this.setupNotFoundMiddleware();
|
|
234
343
|
this.aggregateService = new AggregateService_1.AggregateService(this.createCtx());
|
|
235
344
|
this.wireSchemaHotReload();
|
|
345
|
+
this.startAutoCrawlScheduler();
|
|
236
346
|
};
|
|
237
347
|
if (typeof this.dbAdapter.registerSchemas === 'function') {
|
|
238
348
|
const res = this.dbAdapter.registerSchemas(initialSchemas);
|
|
@@ -257,8 +367,9 @@ class APIRouter {
|
|
|
257
367
|
const anyLoader = this.schemaLoader;
|
|
258
368
|
if (typeof anyLoader.on !== 'function')
|
|
259
369
|
return;
|
|
260
|
-
anyLoader.on('schemasChanged', async (
|
|
370
|
+
anyLoader.on('schemasChanged', async (newSchemasMap) => {
|
|
261
371
|
try {
|
|
372
|
+
const newSchemas = { ...newSchemasMap };
|
|
262
373
|
const removed = this.diffRemovedModels(this.liveSchemas, newSchemas);
|
|
263
374
|
if (removed.length &&
|
|
264
375
|
typeof this.dbAdapter.unregisterSchemas === 'function') {
|
|
@@ -369,6 +480,8 @@ class APIRouter {
|
|
|
369
480
|
(0, setupAuthRoutes_1.setupAuthRoutes)(this.createCtx());
|
|
370
481
|
this.authRoutesInitialized = true;
|
|
371
482
|
}
|
|
483
|
+
// Mount Chat Widget Routes
|
|
484
|
+
(0, setupChatWidgetRoutes_1.setupChatWidgetRoutes)(this.createCtx(), this.chatWidgetConfig);
|
|
372
485
|
for (const s of Object.values(schemas)) {
|
|
373
486
|
const name = s.modelName.toLowerCase();
|
|
374
487
|
if (this.registeredModels.has(name))
|
|
@@ -503,10 +616,12 @@ class APIRouter {
|
|
|
503
616
|
const conflictingFields = [];
|
|
504
617
|
for (const field of uniqueFields) {
|
|
505
618
|
const val = data[field];
|
|
619
|
+
if (val === undefined)
|
|
620
|
+
continue;
|
|
506
621
|
const attr = schema.attributes?.[field];
|
|
507
622
|
const isRequired = Array.isArray(attr) ? attr[0]?.required : attr?.required;
|
|
508
623
|
// Sparse uniqueness: skip checking if value is empty/null AND the field is not required
|
|
509
|
-
if (val ===
|
|
624
|
+
if (val === null || (typeof val === 'string' && val.trim() === '')) {
|
|
510
625
|
if (!isRequired)
|
|
511
626
|
continue;
|
|
512
627
|
}
|
|
@@ -36,6 +36,35 @@ async function injectActionRelatedData(ctx, schema, rows) {
|
|
|
36
36
|
}
|
|
37
37
|
}
|
|
38
38
|
const utils_1 = require("./utils");
|
|
39
|
+
/**
|
|
40
|
+
* For User-extending schemas: strip inherited fields from a response doc
|
|
41
|
+
* when no base user is linked (baseId is null/empty).
|
|
42
|
+
* This keeps API responses clean — no null Username, First Name, etc.
|
|
43
|
+
*/
|
|
44
|
+
function stripInheritedIfNoBase(doc, schema) {
|
|
45
|
+
if (!doc || !schema?.extends)
|
|
46
|
+
return doc;
|
|
47
|
+
const isUserExt = String(schema.extends).toLowerCase() === 'users';
|
|
48
|
+
if (!isUserExt)
|
|
49
|
+
return doc;
|
|
50
|
+
const hasBase = !!(doc.baseId || doc.exId);
|
|
51
|
+
if (hasBase)
|
|
52
|
+
return doc;
|
|
53
|
+
const attrs = schema.attributes || {};
|
|
54
|
+
const result = { ...doc };
|
|
55
|
+
for (const [key, attr] of Object.entries(attrs)) {
|
|
56
|
+
const head = Array.isArray(attr) ? attr[0] : attr;
|
|
57
|
+
if (head?.inherited) {
|
|
58
|
+
delete result[key];
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return result;
|
|
62
|
+
}
|
|
63
|
+
function stripInheritedIfNoBaseMany(docs, schema) {
|
|
64
|
+
if (!schema?.extends)
|
|
65
|
+
return docs;
|
|
66
|
+
return docs.map(d => stripInheritedIfNoBase(d, schema));
|
|
67
|
+
}
|
|
39
68
|
function mountCrudRoutes(ctx, modelNameLC) {
|
|
40
69
|
const { router } = ctx;
|
|
41
70
|
const basePath = `/${modelNameLC}`;
|
|
@@ -93,7 +122,14 @@ function mountCrudRoutes(ctx, modelNameLC) {
|
|
|
93
122
|
return res.status(403).json({ error: true, message: 'forbidden' });
|
|
94
123
|
}
|
|
95
124
|
let payload = (0, authorize_1.mergeCreateDefaults)(req.body, cdec.createDefaults);
|
|
96
|
-
|
|
125
|
+
// Apply schema attribute defaults if missing in the request body
|
|
126
|
+
// For extended schemas, skip defaults for inherited attributes (they belong to the base record)
|
|
127
|
+
for (const [key, attr] of Object.entries(schema.attributes || {})) {
|
|
128
|
+
if (!Array.isArray(attr) && attr?.default !== undefined && payload[key] === undefined && !attr?.inherited) {
|
|
129
|
+
payload[key] = attr.default;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
await (0, authorize_1.enforceRestrictions)(payload, cdec.restrictions, pctx, ctx);
|
|
97
133
|
if (!cdec.exposePrivate)
|
|
98
134
|
payload = (0, authorize_1.stripWriteDeny)(payload, cdec.writeDeny);
|
|
99
135
|
payload = (0, fieldCodecs_1.coerceForStorage)(schema, payload);
|
|
@@ -126,6 +162,9 @@ function mountCrudRoutes(ctx, modelNameLC) {
|
|
|
126
162
|
const basePayload = {};
|
|
127
163
|
const childPayload = {};
|
|
128
164
|
for (const [k, v] of Object.entries(payload)) {
|
|
165
|
+
if (k === 'id' || k === '_id' || k === 'exId' || k === 'createdAt' || k === 'updatedAt') {
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
129
168
|
if (k === 'baseId') {
|
|
130
169
|
childPayload[k] = v;
|
|
131
170
|
continue;
|
|
@@ -164,7 +203,13 @@ function mountCrudRoutes(ctx, modelNameLC) {
|
|
|
164
203
|
// Only attempt creation if at least one base field is provided or if it's not a UserExt?
|
|
165
204
|
// Actually, for UserExt, if baseId is missing and they didn't fill user fields (which are hidden),
|
|
166
205
|
// we should skip base creation and just create the child.
|
|
167
|
-
|
|
206
|
+
// For User extensions: only create a base user if meaningful user fields
|
|
207
|
+
// are explicitly provided (not just auto-injected defaults like status, type, role)
|
|
208
|
+
const AUTO_DEFAULT_KEYS = new Set(['status', 'type', 'role', 'createdBy']);
|
|
209
|
+
const hasMeaningfulBaseData = isUserExt
|
|
210
|
+
? Object.keys(basePayload).some(k => !AUTO_DEFAULT_KEYS.has(k))
|
|
211
|
+
: Object.keys(basePayload).length > 0;
|
|
212
|
+
if (baseMissing.length === 0 && hasMeaningfulBaseData) {
|
|
168
213
|
if (baseLC === 'users' && basePayload.password && ctx.jwtSecret) {
|
|
169
214
|
const salt = await bcrypt_1.default.genSalt(10);
|
|
170
215
|
basePayload.password = await bcrypt_1.default.hash(String(basePayload.password) + ctx.jwtSecret, salt);
|
|
@@ -231,7 +276,7 @@ function mountCrudRoutes(ctx, modelNameLC) {
|
|
|
231
276
|
return res.status(201).json({
|
|
232
277
|
success: true,
|
|
233
278
|
message: `${schema.modelName} has been created successfully.`,
|
|
234
|
-
data: masked,
|
|
279
|
+
data: stripInheritedIfNoBase(masked, schema),
|
|
235
280
|
});
|
|
236
281
|
}
|
|
237
282
|
// non-extended
|
|
@@ -275,7 +320,7 @@ function mountCrudRoutes(ctx, modelNameLC) {
|
|
|
275
320
|
}
|
|
276
321
|
const model = ctx.getModel(modelNameLC);
|
|
277
322
|
const baseKeys = new Set(Object.keys(schema.attributes || {}));
|
|
278
|
-
const { limit, page, skip, sort, projection } = (0, utils_1.parseQuery)(req, baseKeys);
|
|
323
|
+
const { limit, page, skip, sort, projection, _populate } = (0, utils_1.parseQuery)(req, baseKeys);
|
|
279
324
|
const q = String(req.query.q ?? '').trim();
|
|
280
325
|
const searchKey = String(req.query.searchKey ?? '').trim();
|
|
281
326
|
const searchKeysCSV = String(req.query.searchKeys ?? '').trim();
|
|
@@ -330,13 +375,14 @@ function mountCrudRoutes(ctx, modelNameLC) {
|
|
|
330
375
|
let dataRows = [];
|
|
331
376
|
if (requiresBaseProcessing) {
|
|
332
377
|
// If we need to filter/sort by base fields, read ALL child rows (already hydrated by adapter)
|
|
333
|
-
dataRows = await model.read(childFilter, 0, 0, needPrivateForHydrate, { projection });
|
|
378
|
+
dataRows = await model.read(childFilter, 0, 0, needPrivateForHydrate, { projection, _populate });
|
|
334
379
|
}
|
|
335
380
|
else {
|
|
336
381
|
// Normal paginated read (already hydrated by adapter)
|
|
337
382
|
dataRows = await model.read(childFilter, limit, skip, needPrivateForHydrate, {
|
|
338
383
|
sort: Object.keys(childSort).length ? childSort : undefined,
|
|
339
384
|
projection,
|
|
385
|
+
_populate,
|
|
340
386
|
});
|
|
341
387
|
}
|
|
342
388
|
let merged = dataRows;
|
|
@@ -377,9 +423,6 @@ function mountCrudRoutes(ctx, modelNameLC) {
|
|
|
377
423
|
}
|
|
378
424
|
// **dedupe**: one row per base entity
|
|
379
425
|
merged = (0, utils_1.dedupeBy)(merged, (r) => String(r.exId || r.id || r._id || ''));
|
|
380
|
-
if (currentUserId && isUsersModel) {
|
|
381
|
-
merged = merged.filter((r) => String(r?.id) !== String(currentUserId));
|
|
382
|
-
}
|
|
383
426
|
// pagination + totals
|
|
384
427
|
let totalRows;
|
|
385
428
|
let paged;
|
|
@@ -400,7 +443,7 @@ function mountCrudRoutes(ctx, modelNameLC) {
|
|
|
400
443
|
return res.status(200).json({
|
|
401
444
|
success: true,
|
|
402
445
|
message: `Data fetched for ${schema.modelName}`,
|
|
403
|
-
data,
|
|
446
|
+
data: stripInheritedIfNoBaseMany(data, schema),
|
|
404
447
|
pagination: { totalRows, page, limit },
|
|
405
448
|
sort: combinedSort,
|
|
406
449
|
});
|
|
@@ -410,7 +453,7 @@ function mountCrudRoutes(ctx, modelNameLC) {
|
|
|
410
453
|
let rawRows = [];
|
|
411
454
|
const exposePrivateForRead = !!rdec.exposePrivate;
|
|
412
455
|
try {
|
|
413
|
-
rawRows = await model.read(finalFilter, limit + 1, skip, exposePrivateForRead, { sort, projection });
|
|
456
|
+
rawRows = await model.read(finalFilter, limit + 1, skip, exposePrivateForRead, { sort, projection, _populate });
|
|
414
457
|
}
|
|
415
458
|
catch {
|
|
416
459
|
rawRows = await model.read(finalFilter, limit + 1, skip, exposePrivateForRead);
|
|
@@ -438,11 +481,7 @@ function mountCrudRoutes(ctx, modelNameLC) {
|
|
|
438
481
|
});
|
|
439
482
|
}
|
|
440
483
|
}
|
|
441
|
-
let rows =
|
|
442
|
-
? rawRows
|
|
443
|
-
.filter((r) => String(r?.id) !== String(currentUserId))
|
|
444
|
-
.slice(0, limit)
|
|
445
|
-
: rawRows.slice(0, limit);
|
|
484
|
+
let rows = rawRows.slice(0, limit);
|
|
446
485
|
const data = rdec.exposePrivate
|
|
447
486
|
? (0, authorize_1.applyReadMaskMany)(rows, rdec.sensitiveMask)
|
|
448
487
|
: (0, authorize_1.applyReadMaskMany)(rows, rdec.readMask);
|
|
@@ -524,7 +563,7 @@ function mountCrudRoutes(ctx, modelNameLC) {
|
|
|
524
563
|
return res.status(200).json({
|
|
525
564
|
success: true,
|
|
526
565
|
message: `${schema.modelName} found`,
|
|
527
|
-
data,
|
|
566
|
+
data: stripInheritedIfNoBase(data, schema),
|
|
528
567
|
});
|
|
529
568
|
}
|
|
530
569
|
catch (error) {
|
|
@@ -556,9 +595,18 @@ function mountCrudRoutes(ctx, modelNameLC) {
|
|
|
556
595
|
return res.status(403).json({ error: true, message: 'forbidden' });
|
|
557
596
|
}
|
|
558
597
|
let upd = { ...req.body };
|
|
559
|
-
(0, authorize_1.enforceRestrictions)(upd, udec.restrictions, pctx);
|
|
598
|
+
await (0, authorize_1.enforceRestrictions)(upd, udec.restrictions, pctx, ctx);
|
|
560
599
|
if (!udec.exposePrivate)
|
|
561
600
|
upd = (0, authorize_1.stripWriteDeny)(upd, udec.writeDeny);
|
|
601
|
+
// Enforce schema readOnly / non-editable fields on update
|
|
602
|
+
if (schema?.attributes) {
|
|
603
|
+
for (const [attrName, attrDef] of Object.entries(schema.attributes)) {
|
|
604
|
+
const a = Array.isArray(attrDef) ? attrDef[0] : attrDef;
|
|
605
|
+
if (a?.readOnly === true || a?.editable === false) {
|
|
606
|
+
delete upd[attrName];
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
}
|
|
562
610
|
upd = (0, fieldCodecs_1.coerceForStorage)(schema, upd);
|
|
563
611
|
if (modelNameLC === 'users' &&
|
|
564
612
|
Object.prototype.hasOwnProperty.call(upd, 'password')) {
|
|
@@ -589,6 +637,9 @@ function mountCrudRoutes(ctx, modelNameLC) {
|
|
|
589
637
|
const baseUpd = {};
|
|
590
638
|
const childUpd = {};
|
|
591
639
|
for (const [k, v] of Object.entries(upd)) {
|
|
640
|
+
if (k === 'id' || k === '_id' || k === 'exId' || k === 'createdAt' || k === 'updatedAt') {
|
|
641
|
+
continue;
|
|
642
|
+
}
|
|
592
643
|
if (k === 'baseId') {
|
|
593
644
|
childUpd[k] = v;
|
|
594
645
|
continue;
|
|
@@ -680,7 +731,7 @@ function mountCrudRoutes(ctx, modelNameLC) {
|
|
|
680
731
|
const masked = udec.exposePrivate
|
|
681
732
|
? (0, authorize_1.applyReadMaskOne)(responseDoc, udec.sensitiveMask)
|
|
682
733
|
: (0, authorize_1.applyReadMaskOne)(responseDoc, udec.readMask);
|
|
683
|
-
return res.json(masked);
|
|
734
|
+
return res.json(stripInheritedIfNoBase(masked, schema));
|
|
684
735
|
}
|
|
685
736
|
// non-extended
|
|
686
737
|
const conflicts = await ctx.checkUniqueFields(schema, upd, req.params.id);
|