@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.
Files changed (45) hide show
  1. package/README.md +151 -0
  2. package/dist/api/apiRouter.d.ts +28 -1
  3. package/dist/api/apiRouter.js +138 -8
  4. package/dist/api/router/mountBatchRoutes.d.ts +2 -0
  5. package/dist/api/router/mountBatchRoutes.js +90 -0
  6. package/dist/api/router/mountCrudRoutes.js +79 -20
  7. package/dist/api/router/setupAuthRoutes.js +475 -38
  8. package/dist/api/router/setupChatWidgetRoutes.d.ts +3 -0
  9. package/dist/api/router/setupChatWidgetRoutes.js +207 -0
  10. package/dist/api/router/setupFileRoutes.js +258 -12
  11. package/dist/api/router/utils.d.ts +4 -1
  12. package/dist/api/router/utils.js +25 -7
  13. package/dist/cli.d.ts +1 -0
  14. package/dist/cli.js +145 -64
  15. package/dist/database/DatabaseAdapter.d.ts +2 -0
  16. package/dist/database/NMAdapter.d.ts +11 -0
  17. package/dist/database/NMAdapter.js +688 -73
  18. package/dist/database/QueryEngine.js +7 -4
  19. package/dist/files/FileStorageAdapter.d.ts +6 -0
  20. package/dist/files/LocalFileStorageAdapter.d.ts +6 -0
  21. package/dist/files/LocalFileStorageAdapter.js +39 -3
  22. package/dist/files/S3FileStorageAdapter.d.ts +8 -1
  23. package/dist/files/S3FileStorageAdapter.js +86 -11
  24. package/dist/files/filename.js +6 -4
  25. package/dist/index.d.ts +1 -0
  26. package/dist/index.js +3 -1
  27. package/dist/models/BaseModel.d.ts +19 -2
  28. package/dist/models/BaseModel.js +4 -4
  29. package/dist/policy/authorize.d.ts +1 -1
  30. package/dist/policy/authorize.js +64 -13
  31. package/dist/schemas/Users.json +20 -10
  32. package/dist/services/AggregateService.d.ts +8 -0
  33. package/dist/services/AggregateService.js +87 -0
  34. package/dist/services/IndexingService.d.ts +24 -0
  35. package/dist/services/IndexingService.js +555 -0
  36. package/dist/services/LocalAIService.d.ts +34 -0
  37. package/dist/services/LocalAIService.js +471 -0
  38. package/dist/services/OpenAIService.d.ts +16 -0
  39. package/dist/services/OpenAIService.js +586 -0
  40. package/dist/utils/Events.js +1 -1
  41. package/dist/utils/Logger.d.ts +2 -1
  42. package/dist/utils/Logger.js +15 -6
  43. package/dist/utils/SchemaLoader.d.ts +2 -2
  44. package/dist/utils/SchemaLoader.js +44 -6
  45. 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`
@@ -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;
@@ -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 (isHex24(s) && rolesModel) {
75
- const docs = await rolesModel.read({ id: s }, 1, 0, true);
76
- const n = docs?.[0]?.name;
77
- return typeof n === 'string' ? n : null;
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 (newSchemas) => {
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 === undefined || val === null || (typeof val === 'string' && val.trim() === '')) {
624
+ if (val === null || (typeof val === 'string' && val.trim() === '')) {
495
625
  if (!isRequired)
496
626
  continue;
497
627
  }
@@ -0,0 +1,2 @@
1
+ import type { RouterCtx } from './ctx';
2
+ export declare function mountBatchRoutes(ctx: RouterCtx): void;
@@ -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
+ }