@airoom/nextmin-node 2.0.1 → 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 +28 -1
- package/dist/api/apiRouter.js +138 -8
- package/dist/api/router/mountBatchRoutes.d.ts +2 -0
- package/dist/api/router/mountBatchRoutes.js +90 -0
- package/dist/api/router/mountCrudRoutes.js +79 -20
- 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 +258 -12
- package/dist/api/router/utils.d.ts +4 -1
- package/dist/api/router/utils.js +25 -7
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +145 -64
- package/dist/database/DatabaseAdapter.d.ts +2 -0
- package/dist/database/NMAdapter.d.ts +11 -0
- package/dist/database/NMAdapter.js +688 -73
- package/dist/database/QueryEngine.js +7 -4
- package/dist/files/FileStorageAdapter.d.ts +6 -0
- package/dist/files/LocalFileStorageAdapter.d.ts +6 -0
- package/dist/files/LocalFileStorageAdapter.js +39 -3
- package/dist/files/S3FileStorageAdapter.d.ts +8 -1
- package/dist/files/S3FileStorageAdapter.js +86 -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 +19 -2
- package/dist/models/BaseModel.js +4 -4
- 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/AggregateService.d.ts +8 -0
- package/dist/services/AggregateService.js +87 -0
- 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/Events.js +1 -1
- package/dist/utils/Logger.d.ts +2 -1
- package/dist/utils/Logger.js +15 -6
- package/dist/utils/SchemaLoader.d.ts +2 -2
- package/dist/utils/SchemaLoader.js +44 -6
- 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,10 +2,30 @@ 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;
|
|
8
23
|
fileStorageAdapter?: FileStorageAdapter;
|
|
24
|
+
imageProxy?: {
|
|
25
|
+
originalBase?: string;
|
|
26
|
+
proxyBase?: string;
|
|
27
|
+
};
|
|
28
|
+
chatWidget?: ChatWidgetConfig;
|
|
9
29
|
}
|
|
10
30
|
export declare class APIRouter {
|
|
11
31
|
private router;
|
|
@@ -14,9 +34,10 @@ export declare class APIRouter {
|
|
|
14
34
|
private jwtSecret;
|
|
15
35
|
private trustedApiKey;
|
|
16
36
|
private schemaLoader;
|
|
17
|
-
private isDevelopment;
|
|
37
|
+
private get isDevelopment();
|
|
18
38
|
private findRoutesMounted;
|
|
19
39
|
private searchRoutesMounted;
|
|
40
|
+
private batchRoutesMounted;
|
|
20
41
|
private authRoutesInitialized;
|
|
21
42
|
private schemasRouteRegistered;
|
|
22
43
|
private registeredModels;
|
|
@@ -25,6 +46,11 @@ export declare class APIRouter {
|
|
|
25
46
|
private fileStorage?;
|
|
26
47
|
private realtimeService?;
|
|
27
48
|
private fileRoutesMounted;
|
|
49
|
+
private aggregateService?;
|
|
50
|
+
private chatWidgetConfig?;
|
|
51
|
+
private autoCrawlTimer?;
|
|
52
|
+
private isAutoCrawling;
|
|
53
|
+
private startAutoCrawlScheduler;
|
|
28
54
|
constructor(options: APIRouterOptions);
|
|
29
55
|
getRouter(): express.Router;
|
|
30
56
|
private wireSchemaHotReload;
|
|
@@ -37,6 +63,7 @@ export declare class APIRouter {
|
|
|
37
63
|
private mountRoutes;
|
|
38
64
|
private mountFindRoutes;
|
|
39
65
|
private mountSearchRoutes;
|
|
66
|
+
private mountBatchRoutes;
|
|
40
67
|
private mountFileRoutes;
|
|
41
68
|
private createCtx;
|
|
42
69
|
private getSchema;
|
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,26 +40,73 @@ 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");
|
|
13
48
|
const RealtimeService_1 = require("../services/RealtimeService");
|
|
14
49
|
const Events_1 = require("../utils/Events");
|
|
50
|
+
const AggregateService_1 = require("../services/AggregateService");
|
|
15
51
|
const setupFileRoutes_1 = require("./router/setupFileRoutes");
|
|
16
52
|
const setupAuthRoutes_1 = require("./router/setupAuthRoutes");
|
|
17
53
|
const mountCrudRoutes_1 = require("./router/mountCrudRoutes");
|
|
18
54
|
const mountFindRoutes_1 = require("./router/mountFindRoutes");
|
|
19
55
|
const mountSearchRoutes_1 = require("./router/mountSearchRoutes");
|
|
56
|
+
const mountBatchRoutes_1 = require("./router/mountBatchRoutes");
|
|
57
|
+
const setupChatWidgetRoutes_1 = require("./router/setupChatWidgetRoutes");
|
|
20
58
|
class APIRouter {
|
|
59
|
+
get isDevelopment() {
|
|
60
|
+
return (process.env.APP_MODE?.toLowerCase() !== 'production' &&
|
|
61
|
+
process.env.NODE_ENV?.toLowerCase() !== 'production');
|
|
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
|
+
}
|
|
21
99
|
constructor(options) {
|
|
22
100
|
this.models = {};
|
|
23
101
|
this.findRoutesMounted = false;
|
|
24
102
|
this.searchRoutesMounted = false;
|
|
103
|
+
this.batchRoutesMounted = false;
|
|
25
104
|
this.authRoutesInitialized = false;
|
|
26
105
|
this.schemasRouteRegistered = false;
|
|
27
106
|
this.registeredModels = new Set();
|
|
28
107
|
this.liveSchemas = {};
|
|
29
108
|
this.fileRoutesMounted = false;
|
|
109
|
+
this.isAutoCrawling = false;
|
|
30
110
|
// ---------- Live lookups ----------
|
|
31
111
|
this.getSchema = (name) => {
|
|
32
112
|
const s = this.liveSchemas[name];
|
|
@@ -71,10 +151,16 @@ class APIRouter {
|
|
|
71
151
|
const fromString = async (s) => {
|
|
72
152
|
if (!s)
|
|
73
153
|
return null;
|
|
74
|
-
if (
|
|
75
|
-
const
|
|
76
|
-
const
|
|
77
|
-
|
|
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
|
+
}
|
|
78
164
|
}
|
|
79
165
|
return s;
|
|
80
166
|
};
|
|
@@ -182,9 +268,38 @@ class APIRouter {
|
|
|
182
268
|
}
|
|
183
269
|
return missing;
|
|
184
270
|
};
|
|
185
|
-
this.isDevelopment = process.env.APP_MODE !== 'production';
|
|
186
271
|
this.router = express_1.default.Router();
|
|
187
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
|
+
}
|
|
188
303
|
this.dbAdapter = options.dbAdapter;
|
|
189
304
|
this.jwtSecret = process.env.JWT_SECRET || 'default_jwt_secret';
|
|
190
305
|
if (options.server) {
|
|
@@ -199,7 +314,7 @@ class APIRouter {
|
|
|
199
314
|
}
|
|
200
315
|
this.schemaLoader =
|
|
201
316
|
SchemaLoader_1.SchemaLoader.getInstance?.() ?? new SchemaLoader_1.SchemaLoader();
|
|
202
|
-
const initialSchemas = this.schemaLoader.getSchemas();
|
|
317
|
+
const initialSchemas = { ...this.schemaLoader.getSchemas() };
|
|
203
318
|
this.setLiveSchemas(initialSchemas);
|
|
204
319
|
const finishBoot = async () => {
|
|
205
320
|
if (typeof this.dbAdapter.registerSchemas === 'function') {
|
|
@@ -211,6 +326,7 @@ class APIRouter {
|
|
|
211
326
|
this.mountRoutes(initialSchemas);
|
|
212
327
|
this.mountFindRoutes();
|
|
213
328
|
this.mountSearchRoutes();
|
|
329
|
+
this.mountBatchRoutes();
|
|
214
330
|
this.mountFileRoutes();
|
|
215
331
|
await this.syncAllIndexes(initialSchemas);
|
|
216
332
|
const initializer = new DefaultDataInitializer_1.DefaultDataInitializer(this.dbAdapter, this.models);
|
|
@@ -224,7 +340,9 @@ class APIRouter {
|
|
|
224
340
|
Logger_1.default.error('APIRouter', 'Failed to initialize default data', err);
|
|
225
341
|
}
|
|
226
342
|
this.setupNotFoundMiddleware();
|
|
343
|
+
this.aggregateService = new AggregateService_1.AggregateService(this.createCtx());
|
|
227
344
|
this.wireSchemaHotReload();
|
|
345
|
+
this.startAutoCrawlScheduler();
|
|
228
346
|
};
|
|
229
347
|
if (typeof this.dbAdapter.registerSchemas === 'function') {
|
|
230
348
|
const res = this.dbAdapter.registerSchemas(initialSchemas);
|
|
@@ -249,8 +367,9 @@ class APIRouter {
|
|
|
249
367
|
const anyLoader = this.schemaLoader;
|
|
250
368
|
if (typeof anyLoader.on !== 'function')
|
|
251
369
|
return;
|
|
252
|
-
anyLoader.on('schemasChanged', async (
|
|
370
|
+
anyLoader.on('schemasChanged', async (newSchemasMap) => {
|
|
253
371
|
try {
|
|
372
|
+
const newSchemas = { ...newSchemasMap };
|
|
254
373
|
const removed = this.diffRemovedModels(this.liveSchemas, newSchemas);
|
|
255
374
|
if (removed.length &&
|
|
256
375
|
typeof this.dbAdapter.unregisterSchemas === 'function') {
|
|
@@ -361,6 +480,8 @@ class APIRouter {
|
|
|
361
480
|
(0, setupAuthRoutes_1.setupAuthRoutes)(this.createCtx());
|
|
362
481
|
this.authRoutesInitialized = true;
|
|
363
482
|
}
|
|
483
|
+
// Mount Chat Widget Routes
|
|
484
|
+
(0, setupChatWidgetRoutes_1.setupChatWidgetRoutes)(this.createCtx(), this.chatWidgetConfig);
|
|
364
485
|
for (const s of Object.values(schemas)) {
|
|
365
486
|
const name = s.modelName.toLowerCase();
|
|
366
487
|
if (this.registeredModels.has(name))
|
|
@@ -384,6 +505,13 @@ class APIRouter {
|
|
|
384
505
|
(0, mountSearchRoutes_1.mountSearchRoutes)(this.createCtx());
|
|
385
506
|
this.ensureNotFoundLast();
|
|
386
507
|
}
|
|
508
|
+
mountBatchRoutes() {
|
|
509
|
+
if (this.batchRoutesMounted)
|
|
510
|
+
return;
|
|
511
|
+
this.batchRoutesMounted = true;
|
|
512
|
+
(0, mountBatchRoutes_1.mountBatchRoutes)(this.createCtx());
|
|
513
|
+
this.ensureNotFoundLast();
|
|
514
|
+
}
|
|
387
515
|
mountFileRoutes() {
|
|
388
516
|
if (this.fileRoutesMounted)
|
|
389
517
|
return;
|
|
@@ -488,10 +616,12 @@ class APIRouter {
|
|
|
488
616
|
const conflictingFields = [];
|
|
489
617
|
for (const field of uniqueFields) {
|
|
490
618
|
const val = data[field];
|
|
619
|
+
if (val === undefined)
|
|
620
|
+
continue;
|
|
491
621
|
const attr = schema.attributes?.[field];
|
|
492
622
|
const isRequired = Array.isArray(attr) ? attr[0]?.required : attr?.required;
|
|
493
623
|
// Sparse uniqueness: skip checking if value is empty/null AND the field is not required
|
|
494
|
-
if (val ===
|
|
624
|
+
if (val === null || (typeof val === 'string' && val.trim() === '')) {
|
|
495
625
|
if (!isRequired)
|
|
496
626
|
continue;
|
|
497
627
|
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.mountBatchRoutes = mountBatchRoutes;
|
|
4
|
+
const authorize_1 = require("../../policy/authorize");
|
|
5
|
+
const QueryEngine_1 = require("../../database/QueryEngine");
|
|
6
|
+
const utils_1 = require("./utils");
|
|
7
|
+
function mountBatchRoutes(ctx) {
|
|
8
|
+
const { router } = ctx;
|
|
9
|
+
router.post('/_batch', ctx.optionalAuthMiddleware, async (req, res) => {
|
|
10
|
+
try {
|
|
11
|
+
const batchRequest = req.body;
|
|
12
|
+
if (!batchRequest || typeof batchRequest !== 'object') {
|
|
13
|
+
return res.status(400).json({ error: true, message: 'Invalid batch request' });
|
|
14
|
+
}
|
|
15
|
+
const results = {};
|
|
16
|
+
for (const [key, config] of Object.entries(batchRequest)) {
|
|
17
|
+
try {
|
|
18
|
+
const { path, query: queryParams } = config;
|
|
19
|
+
if (!path) {
|
|
20
|
+
results[key] = { error: true, message: 'Missing path' };
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
// Path is usually something like "/doctors" or "/doctors?slug=... "
|
|
24
|
+
// We need to extract the model name
|
|
25
|
+
const modelMatch = path.match(/^\/([^/?]+)/);
|
|
26
|
+
if (!modelMatch) {
|
|
27
|
+
results[key] = { error: true, message: 'Invalid path' };
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
const modelName = modelMatch[1].toLowerCase();
|
|
31
|
+
const schema = ctx.getSchema(modelName);
|
|
32
|
+
const model = ctx.getModel(modelName);
|
|
33
|
+
// Prepare pseudo-request for parseQuery
|
|
34
|
+
const pseudoReq = {
|
|
35
|
+
query: queryParams || {},
|
|
36
|
+
user: req.user,
|
|
37
|
+
};
|
|
38
|
+
const { limit, page, skip, sort, projection, sample, _populate } = (0, utils_1.parseQuery)(pseudoReq);
|
|
39
|
+
const filter = QueryEngine_1.QueryEngine.parse(pseudoReq.query, schema);
|
|
40
|
+
const roleStr = req.user?.role;
|
|
41
|
+
const pctx = {
|
|
42
|
+
isAuthenticated: !!req.user,
|
|
43
|
+
role: typeof roleStr === 'string' ? roleStr.toLowerCase() : null,
|
|
44
|
+
userId: req.user?.id || req.user?._id || null,
|
|
45
|
+
isSuperadmin: roleStr?.toLowerCase() === 'superadmin',
|
|
46
|
+
apiKeyOk: true,
|
|
47
|
+
};
|
|
48
|
+
const rdec = (0, authorize_1.authorize)(modelName, 'read', { allowedMethods: schema.allowedMethods, access: schema.access }, pctx);
|
|
49
|
+
if (!rdec.allow) {
|
|
50
|
+
results[key] = { error: true, message: 'Forbidden' };
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
const finalFilter = { ...filter, ...(rdec.queryFilter || {}) };
|
|
54
|
+
// Handle countBy if present in query
|
|
55
|
+
const countBy = String(pseudoReq.query.countBy || '').trim();
|
|
56
|
+
if (countBy && schema.attributes[countBy]) {
|
|
57
|
+
const counts = await ctx.dbAdapter.countBy(schema.modelName, countBy, finalFilter, schema);
|
|
58
|
+
results[key] = { success: true, counts };
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
const totalRows = await model.count(finalFilter);
|
|
62
|
+
const rows = await model.read(finalFilter, limit, skip, !!rdec.exposePrivate, {
|
|
63
|
+
sort,
|
|
64
|
+
projection,
|
|
65
|
+
sample,
|
|
66
|
+
_populate,
|
|
67
|
+
});
|
|
68
|
+
const data = rdec.exposePrivate
|
|
69
|
+
? (0, authorize_1.applyReadMaskMany)(rows, rdec.sensitiveMask)
|
|
70
|
+
: (0, authorize_1.applyReadMaskMany)(rows, rdec.readMask);
|
|
71
|
+
results[key] = {
|
|
72
|
+
success: true,
|
|
73
|
+
data,
|
|
74
|
+
pagination: { totalRows, page, limit },
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
catch (err) {
|
|
78
|
+
results[key] = { error: true, message: err.message || 'Error' };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return res.status(200).json({
|
|
82
|
+
success: true,
|
|
83
|
+
results,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
catch (err) {
|
|
87
|
+
return res.status(500).json({ error: true, message: err.message || 'Batch execution failed' });
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
}
|