@intranefr/superbackend 1.4.4 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/index.js +16 -1
  2. package/package.json +5 -2
  3. package/public/sdk/ui-components.iife.js +191 -0
  4. package/sdk/ui-components/browser/src/index.js +228 -0
  5. package/src/controllers/admin.controller.js +89 -0
  6. package/src/controllers/adminHeadless.controller.js +82 -0
  7. package/src/controllers/adminScripts.controller.js +229 -0
  8. package/src/controllers/adminTerminals.controller.js +39 -0
  9. package/src/controllers/adminUiComponents.controller.js +315 -0
  10. package/src/controllers/adminUiComponentsAi.controller.js +34 -0
  11. package/src/controllers/orgAdmin.controller.js +286 -0
  12. package/src/controllers/uiComponentsPublic.controller.js +118 -0
  13. package/src/middleware/auth.js +7 -0
  14. package/src/middleware.js +115 -0
  15. package/src/models/HeadlessModelDefinition.js +10 -0
  16. package/src/models/ScriptDefinition.js +42 -0
  17. package/src/models/ScriptRun.js +22 -0
  18. package/src/models/UiComponent.js +29 -0
  19. package/src/models/UiComponentProject.js +26 -0
  20. package/src/models/UiComponentProjectComponent.js +18 -0
  21. package/src/routes/admin.routes.js +1 -0
  22. package/src/routes/adminHeadless.routes.js +6 -0
  23. package/src/routes/adminScripts.routes.js +21 -0
  24. package/src/routes/adminTerminals.routes.js +13 -0
  25. package/src/routes/adminUiComponents.routes.js +29 -0
  26. package/src/routes/llmUi.routes.js +26 -0
  27. package/src/routes/orgAdmin.routes.js +5 -0
  28. package/src/routes/uiComponentsPublic.routes.js +9 -0
  29. package/src/services/headlessExternalModels.service.js +292 -0
  30. package/src/services/headlessModels.service.js +26 -6
  31. package/src/services/scriptsRunner.service.js +259 -0
  32. package/src/services/terminals.service.js +152 -0
  33. package/src/services/terminalsWs.service.js +100 -0
  34. package/src/services/uiComponentsAi.service.js +312 -0
  35. package/src/services/uiComponentsCrypto.service.js +39 -0
  36. package/views/admin-headless.ejs +294 -24
  37. package/views/admin-organizations.ejs +365 -9
  38. package/views/admin-scripts.ejs +497 -0
  39. package/views/admin-terminals.ejs +328 -0
  40. package/views/admin-ui-components.ejs +709 -0
  41. package/views/admin-users.ejs +261 -4
  42. package/views/partials/dashboard/nav-items.ejs +3 -0
package/index.js CHANGED
@@ -11,6 +11,7 @@ const express = require("express");
11
11
  * @returns {express.Router} Configured Express router
12
12
  */
13
13
  const middleware = require("./src/middleware");
14
+ const { attachTerminalWebsocketServer } = require('./src/services/terminalsWs.service');
14
15
 
15
16
  /**
16
17
  * Creates and starts a standalone SuperBackend server
@@ -24,13 +25,26 @@ function startServer(options = {}) {
24
25
  const app = express();
25
26
  const PORT = options.port || process.env.PORT || 3000;
26
27
 
27
- app.use(module.exports.middleware(options));
28
+ const router = module.exports.middleware(options);
29
+ app.use(router);
28
30
 
29
31
  // Start server
30
32
  const server = app.listen(PORT, () => {
31
33
  console.log(`🚀 SuperBackend standalone server running on http://localhost:${PORT}`);
32
34
  });
33
35
 
36
+ // Attach WebSocket server via middleware helper or directly
37
+ console.log('[Index] Attaching WebSocket server...');
38
+ if (typeof router.attachWs === 'function') {
39
+ console.log('[Index] Using router.attachWs');
40
+ router.attachWs(server);
41
+ } else {
42
+ // Fallback: attach directly with admin path
43
+ const adminPath = router.adminPath || '/admin';
44
+ console.log('[Index] Using fallback attach with adminPath:', adminPath);
45
+ attachTerminalWebsocketServer(server, { basePathPrefix: adminPath });
46
+ }
47
+
34
48
  return { app, server };
35
49
  }
36
50
 
@@ -91,6 +105,7 @@ const saasbackend = {
91
105
  org: require("./src/middleware/org"),
92
106
  i18n: require("./src/services/i18n.service"),
93
107
  jsonConfigs: require("./src/services/jsonConfigs.service"),
108
+ terminals: require("./src/services/terminalsWs.service"),
94
109
  },
95
110
  };
96
111
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intranefr/superbackend",
3
- "version": "1.4.4",
3
+ "version": "1.5.0",
4
4
  "description": "Node.js middleware that gives your project backend superpowers",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -9,6 +9,7 @@
9
9
  "start:minio": "docker compose -f compose.standalone.yml --profile minio-only up -d minio",
10
10
  "minio:envs": "node -e \"console.log(['S3_ENDPOINT=http://localhost:9000','S3_REGION=us-east-1','S3_ACCESS_KEY_ID=minioadmin','S3_SECRET_ACCESS_KEY=minioadmin','S3_BUCKET=saasbackend','S3_FORCE_PATH_STYLE=true'].join('\\n'))\"",
11
11
  "build:sdk:error-tracking:browser": "esbuild sdk/error-tracking/browser/src/embed.js --bundle --format=iife --global-name=saasbackendErrorTrackingEmbed --outfile=sdk/error-tracking/browser/dist/embed.iife.js",
12
+ "build:sdk:ui-components:browser": "esbuild sdk/ui-components/browser/src/index.js --bundle --format=iife --outfile=public/sdk/ui-components.iife.js",
12
13
  "test": "jest",
13
14
  "test:watch": "jest --watch",
14
15
  "test:coverage": "jest --coverage"
@@ -41,11 +42,13 @@
41
42
  "jsonwebtoken": "^9.0.2",
42
43
  "mongoose": "^8.0.0",
43
44
  "multer": "^1.4.5-lts.1",
45
+ "node-pty": "^1.1.0",
44
46
  "openai": "^4.0.0",
45
47
  "resend": "^6.4.0",
46
48
  "ssh2-sftp-client": "^12.0.1",
47
49
  "stripe": "^14.0.0",
48
- "vm2": "^3.10.0"
50
+ "vm2": "^3.10.0",
51
+ "ws": "^8.18.0"
49
52
  },
50
53
  "devDependencies": {
51
54
  "esbuild": "^0.25.0",
@@ -0,0 +1,191 @@
1
+ (() => {
2
+ // sdk/ui-components/browser/src/index.js
3
+ (function() {
4
+ function toStr(v) {
5
+ return v === void 0 || v === null ? "" : String(v);
6
+ }
7
+ function normalizeApiUrl(apiUrl) {
8
+ const u = toStr(apiUrl).trim();
9
+ if (!u) return "";
10
+ return u.replace(/\/$/, "");
11
+ }
12
+ function buildHeaders(apiKey) {
13
+ const headers = {};
14
+ const key = toStr(apiKey).trim();
15
+ if (key) headers["x-project-key"] = key;
16
+ return headers;
17
+ }
18
+ async function fetchJson(url, headers) {
19
+ const res = await fetch(url, { headers: headers || {} });
20
+ const text = await res.text();
21
+ let data = null;
22
+ try {
23
+ data = text ? JSON.parse(text) : null;
24
+ } catch {
25
+ data = null;
26
+ }
27
+ if (!res.ok) {
28
+ const msg = data && data.error ? data.error : "Request failed";
29
+ const err = new Error(msg);
30
+ err.status = res.status;
31
+ throw err;
32
+ }
33
+ return data;
34
+ }
35
+ function ensureTemplate(code, html) {
36
+ const id = "ui-cmp-" + code;
37
+ let tpl = document.getElementById(id);
38
+ if (!tpl) {
39
+ tpl = document.createElement("template");
40
+ tpl.id = id;
41
+ document.body.appendChild(tpl);
42
+ }
43
+ tpl.innerHTML = toStr(html);
44
+ return tpl;
45
+ }
46
+ function injectCssScoped(code, cssText) {
47
+ const id = "ui-cmp-style-" + code;
48
+ let el = document.getElementById(id);
49
+ if (!el) {
50
+ el = document.createElement("style");
51
+ el.id = id;
52
+ document.head.appendChild(el);
53
+ }
54
+ el.textContent = toStr(cssText);
55
+ }
56
+ function createShadowRootContainer() {
57
+ const host = document.createElement("div");
58
+ host.style.all = "initial";
59
+ const shadow = host.attachShadow({ mode: "open" });
60
+ return { host, shadow };
61
+ }
62
+ function defaultMountTarget() {
63
+ return document.body;
64
+ }
65
+ function compileComponentJs(jsCode) {
66
+ const code = toStr(jsCode);
67
+ if (!code.trim()) {
68
+ return function() {
69
+ return {};
70
+ };
71
+ }
72
+ return new Function("api", "templateRootEl", "props", code);
73
+ }
74
+ const state = {
75
+ initialized: false,
76
+ projectId: null,
77
+ apiKey: null,
78
+ apiUrl: "",
79
+ cssIsolation: "scoped",
80
+ components: {}
81
+ };
82
+ function registerComponent(def) {
83
+ const code = toStr(def.code).trim().toLowerCase();
84
+ if (!code) return;
85
+ const version = def.version;
86
+ const html = def.html;
87
+ const js = def.js;
88
+ const css = def.css;
89
+ ensureTemplate(code, html);
90
+ const component = {
91
+ code,
92
+ version,
93
+ css,
94
+ js,
95
+ create: function(props, options) {
96
+ const opts = options || {};
97
+ const mountEl = opts.mountEl || defaultMountTarget();
98
+ const isolation = opts.cssIsolation || state.cssIsolation;
99
+ const tpl = ensureTemplate(code, html);
100
+ const fragment = tpl.content.cloneNode(true);
101
+ let templateRootEl;
102
+ let instanceRoot;
103
+ let shadow = null;
104
+ if (isolation === "shadow") {
105
+ const c = createShadowRootContainer();
106
+ instanceRoot = c.host;
107
+ shadow = c.shadow;
108
+ templateRootEl = shadow;
109
+ if (css) {
110
+ const style = document.createElement("style");
111
+ style.textContent = toStr(css);
112
+ shadow.appendChild(style);
113
+ }
114
+ shadow.appendChild(fragment);
115
+ } else {
116
+ instanceRoot = document.createElement("div");
117
+ templateRootEl = instanceRoot;
118
+ if (css) injectCssScoped(code, css);
119
+ instanceRoot.appendChild(fragment);
120
+ }
121
+ mountEl.appendChild(instanceRoot);
122
+ const api = {
123
+ unmount: function() {
124
+ try {
125
+ instanceRoot.remove();
126
+ } catch {
127
+ }
128
+ },
129
+ mountEl,
130
+ hostEl: instanceRoot,
131
+ shadowRoot: shadow
132
+ };
133
+ const fn = compileComponentJs(js);
134
+ let exported = {};
135
+ try {
136
+ exported = fn(api, templateRootEl, props || {}) || {};
137
+ } catch (e) {
138
+ exported = {
139
+ error: e
140
+ };
141
+ }
142
+ return Object.assign({ api }, exported);
143
+ }
144
+ };
145
+ state.components[code] = component;
146
+ uiCmp[code] = component;
147
+ uiComponents[code] = component;
148
+ }
149
+ async function init(options) {
150
+ const opts = options || {};
151
+ const projectId = toStr(opts.projectId).trim();
152
+ if (!projectId) throw new Error("projectId is required");
153
+ const apiUrl = normalizeApiUrl(opts.apiUrl);
154
+ const apiKey = opts.apiKey;
155
+ state.projectId = projectId;
156
+ state.apiKey = apiKey;
157
+ state.apiUrl = apiUrl;
158
+ const cssIsolation = toStr(opts.cssIsolation || "scoped").trim().toLowerCase();
159
+ state.cssIsolation = cssIsolation === "shadow" ? "shadow" : "scoped";
160
+ const base = state.apiUrl;
161
+ const url = base + "/api/ui-components/projects/" + encodeURIComponent(projectId) + "/manifest";
162
+ const data = await fetchJson(url, buildHeaders(apiKey));
163
+ const items = data && Array.isArray(data.components) ? data.components : [];
164
+ for (const def of items) {
165
+ registerComponent(def);
166
+ }
167
+ state.initialized = true;
168
+ return { project: data ? data.project : null, count: items.length };
169
+ }
170
+ async function load(code) {
171
+ const c = toStr(code).trim().toLowerCase();
172
+ if (!c) throw new Error("code is required");
173
+ if (!state.projectId) throw new Error("uiCmp not initialized");
174
+ if (state.components[c]) return state.components[c];
175
+ const base = state.apiUrl;
176
+ const url = base + "/api/ui-components/projects/" + encodeURIComponent(state.projectId) + "/components/" + encodeURIComponent(c);
177
+ const data = await fetchJson(url, buildHeaders(state.apiKey));
178
+ if (!data || !data.component) throw new Error("Component not found");
179
+ registerComponent(data.component);
180
+ return state.components[c];
181
+ }
182
+ const uiCmp = {
183
+ init,
184
+ load,
185
+ _state: state
186
+ };
187
+ const uiComponents = uiCmp;
188
+ window.uiCmp = uiCmp;
189
+ window.uiComponents = uiComponents;
190
+ })();
191
+ })();
@@ -0,0 +1,228 @@
1
+ (function () {
2
+ function toStr(v) {
3
+ return v === undefined || v === null ? '' : String(v);
4
+ }
5
+
6
+ function normalizeApiUrl(apiUrl) {
7
+ const u = toStr(apiUrl).trim();
8
+ if (!u) return '';
9
+ return u.replace(/\/$/, '');
10
+ }
11
+
12
+ function buildHeaders(apiKey) {
13
+ const headers = {};
14
+ const key = toStr(apiKey).trim();
15
+ if (key) headers['x-project-key'] = key;
16
+ return headers;
17
+ }
18
+
19
+ async function fetchJson(url, headers) {
20
+ const res = await fetch(url, { headers: headers || {} });
21
+ const text = await res.text();
22
+ let data = null;
23
+ try {
24
+ data = text ? JSON.parse(text) : null;
25
+ } catch {
26
+ data = null;
27
+ }
28
+ if (!res.ok) {
29
+ const msg = data && data.error ? data.error : 'Request failed';
30
+ const err = new Error(msg);
31
+ err.status = res.status;
32
+ throw err;
33
+ }
34
+ return data;
35
+ }
36
+
37
+ function ensureTemplate(code, html) {
38
+ const id = 'ui-cmp-' + code;
39
+ let tpl = document.getElementById(id);
40
+ if (!tpl) {
41
+ tpl = document.createElement('template');
42
+ tpl.id = id;
43
+ document.body.appendChild(tpl);
44
+ }
45
+ tpl.innerHTML = toStr(html);
46
+ return tpl;
47
+ }
48
+
49
+ function injectCssScoped(code, cssText) {
50
+ const id = 'ui-cmp-style-' + code;
51
+ let el = document.getElementById(id);
52
+ if (!el) {
53
+ el = document.createElement('style');
54
+ el.id = id;
55
+ document.head.appendChild(el);
56
+ }
57
+ el.textContent = toStr(cssText);
58
+ }
59
+
60
+ function createShadowRootContainer() {
61
+ const host = document.createElement('div');
62
+ host.style.all = 'initial';
63
+ const shadow = host.attachShadow({ mode: 'open' });
64
+ return { host, shadow };
65
+ }
66
+
67
+ function defaultMountTarget() {
68
+ return document.body;
69
+ }
70
+
71
+ function compileComponentJs(jsCode) {
72
+ const code = toStr(jsCode);
73
+ if (!code.trim()) {
74
+ return function () {
75
+ return {};
76
+ };
77
+ }
78
+ return new Function('api', 'templateRootEl', 'props', code);
79
+ }
80
+
81
+ const state = {
82
+ initialized: false,
83
+ projectId: null,
84
+ apiKey: null,
85
+ apiUrl: '',
86
+ cssIsolation: 'scoped',
87
+ components: {},
88
+ };
89
+
90
+ function registerComponent(def) {
91
+ const code = toStr(def.code).trim().toLowerCase();
92
+ if (!code) return;
93
+
94
+ const version = def.version;
95
+ const html = def.html;
96
+ const js = def.js;
97
+ const css = def.css;
98
+
99
+ ensureTemplate(code, html);
100
+
101
+ const component = {
102
+ code,
103
+ version,
104
+ css,
105
+ js,
106
+ create: function (props, options) {
107
+ const opts = options || {};
108
+ const mountEl = opts.mountEl || defaultMountTarget();
109
+ const isolation = opts.cssIsolation || state.cssIsolation;
110
+
111
+ const tpl = ensureTemplate(code, html);
112
+ const fragment = tpl.content.cloneNode(true);
113
+
114
+ let templateRootEl;
115
+ let instanceRoot;
116
+ let shadow = null;
117
+
118
+ if (isolation === 'shadow') {
119
+ const c = createShadowRootContainer();
120
+ instanceRoot = c.host;
121
+ shadow = c.shadow;
122
+ templateRootEl = shadow;
123
+ if (css) {
124
+ const style = document.createElement('style');
125
+ style.textContent = toStr(css);
126
+ shadow.appendChild(style);
127
+ }
128
+ shadow.appendChild(fragment);
129
+ } else {
130
+ instanceRoot = document.createElement('div');
131
+ templateRootEl = instanceRoot;
132
+ if (css) injectCssScoped(code, css);
133
+ instanceRoot.appendChild(fragment);
134
+ }
135
+
136
+ mountEl.appendChild(instanceRoot);
137
+
138
+ const api = {
139
+ unmount: function () {
140
+ try {
141
+ instanceRoot.remove();
142
+ } catch {}
143
+ },
144
+ mountEl,
145
+ hostEl: instanceRoot,
146
+ shadowRoot: shadow,
147
+ };
148
+
149
+ const fn = compileComponentJs(js);
150
+ let exported = {};
151
+ try {
152
+ exported = fn(api, templateRootEl, props || {}) || {};
153
+ } catch (e) {
154
+ exported = {
155
+ error: e,
156
+ };
157
+ }
158
+
159
+ return Object.assign({ api }, exported);
160
+ },
161
+ };
162
+
163
+ state.components[code] = component;
164
+ uiCmp[code] = component;
165
+ uiComponents[code] = component;
166
+ }
167
+
168
+ async function init(options) {
169
+ const opts = options || {};
170
+ const projectId = toStr(opts.projectId).trim();
171
+ if (!projectId) throw new Error('projectId is required');
172
+
173
+ const apiUrl = normalizeApiUrl(opts.apiUrl);
174
+ const apiKey = opts.apiKey;
175
+
176
+ state.projectId = projectId;
177
+ state.apiKey = apiKey;
178
+ state.apiUrl = apiUrl;
179
+
180
+ const cssIsolation = toStr(opts.cssIsolation || 'scoped').trim().toLowerCase();
181
+ state.cssIsolation = cssIsolation === 'shadow' ? 'shadow' : 'scoped';
182
+
183
+ const base = state.apiUrl;
184
+ const url = base + '/api/ui-components/projects/' + encodeURIComponent(projectId) + '/manifest';
185
+
186
+ const data = await fetchJson(url, buildHeaders(apiKey));
187
+ const items = data && Array.isArray(data.components) ? data.components : [];
188
+
189
+ for (const def of items) {
190
+ registerComponent(def);
191
+ }
192
+
193
+ state.initialized = true;
194
+ return { project: data ? data.project : null, count: items.length };
195
+ }
196
+
197
+ async function load(code) {
198
+ const c = toStr(code).trim().toLowerCase();
199
+ if (!c) throw new Error('code is required');
200
+ if (!state.projectId) throw new Error('uiCmp not initialized');
201
+ if (state.components[c]) return state.components[c];
202
+
203
+ const base = state.apiUrl;
204
+ const url =
205
+ base +
206
+ '/api/ui-components/projects/' +
207
+ encodeURIComponent(state.projectId) +
208
+ '/components/' +
209
+ encodeURIComponent(c);
210
+
211
+ const data = await fetchJson(url, buildHeaders(state.apiKey));
212
+ if (!data || !data.component) throw new Error('Component not found');
213
+
214
+ registerComponent(data.component);
215
+ return state.components[c];
216
+ }
217
+
218
+ const uiCmp = {
219
+ init,
220
+ load,
221
+ _state: state,
222
+ };
223
+
224
+ const uiComponents = uiCmp;
225
+
226
+ window.uiCmp = uiCmp;
227
+ window.uiComponents = uiComponents;
228
+ })();
@@ -1,5 +1,12 @@
1
1
  const User = require('../models/User');
2
2
  const StripeWebhookEvent = require('../models/StripeWebhookEvent');
3
+ const Organization = require('../models/Organization');
4
+ const OrganizationMember = require('../models/OrganizationMember');
5
+ const Asset = require('../models/Asset');
6
+ const Notification = require('../models/Notification');
7
+ const Invite = require('../models/Invite');
8
+ const EmailLog = require('../models/EmailLog');
9
+ const FormSubmission = require('../models/FormSubmission');
3
10
  const asyncHandler = require('../utils/asyncHandler');
4
11
  const fs = require('fs');
5
12
  const path = require('path');
@@ -353,12 +360,94 @@ const provisionCoolifyDeploy = asyncHandler(async (req, res) => {
353
360
  }
354
361
  });
355
362
 
363
+ // Delete user (admin only)
364
+ const deleteUser = asyncHandler(async (req, res) => {
365
+
366
+ const userId = req.params.id;
367
+
368
+ // 1. Validate user exists
369
+ const user = await User.findById(userId);
370
+ if (!user) {
371
+ return res.status(404).json({ error: 'User not found' });
372
+ }
373
+
374
+ // 2. Prevent self-deletion
375
+ // Note: In a real implementation, you'd get the admin ID from req.admin or similar
376
+ // For now, we'll skip this check as the basic auth doesn't provide user identity
377
+
378
+ // 3. Check if this is the last admin
379
+ const adminCount = await User.countDocuments({ role: 'admin' });
380
+ if (user.role === 'admin' && adminCount <= 1) {
381
+ return res.status(400).json({ error: 'Cannot delete the last admin user' });
382
+ }
383
+
384
+ // 4. Cleanup dependencies
385
+ await cleanupUserData(userId);
386
+
387
+ // 5. Delete user
388
+ await User.findByIdAndDelete(userId);
389
+
390
+ // 6. Log action
391
+ console.log(`Admin deleted user: ${user.email} (${userId})`);
392
+
393
+ res.json({ message: 'User deleted successfully' });
394
+ });
395
+
396
+ // Helper function to clean up user data
397
+ async function cleanupUserData(userId) {
398
+
399
+ try {
400
+ // Handle organizations owned by user
401
+ const ownedOrgs = await Organization.find({ ownerUserId: userId });
402
+ for (const org of ownedOrgs) {
403
+ // Check if organization has other members
404
+ const memberCount = await OrganizationMember.countDocuments({
405
+ orgId: org._id,
406
+ userId: { $ne: userId }
407
+ });
408
+
409
+ if (memberCount === 0) {
410
+ // Delete organization if no other members
411
+ await Organization.findByIdAndDelete(org._id);
412
+ console.log(`Deleted organization ${org.name} (${org._id}) - no other members`);
413
+ } else {
414
+ // Remove owner but keep organization
415
+ org.ownerUserId = null;
416
+ await org.save();
417
+ console.log(`Removed owner from organization ${org.name} (${org._id}) - has other members`);
418
+ }
419
+ }
420
+
421
+ // Remove from all organization memberships
422
+ await OrganizationMember.deleteMany({ userId: userId });
423
+
424
+ // Delete user's assets
425
+ await Asset.deleteMany({ ownerUserId: userId });
426
+
427
+ // Delete notifications
428
+ await Notification.deleteMany({ userId: userId });
429
+
430
+ // Clean up other references
431
+ await Invite.deleteMany({ createdByUserId: userId });
432
+ await EmailLog.deleteMany({ userId: userId });
433
+ await FormSubmission.deleteMany({ userId: userId });
434
+
435
+ // Note: We keep ActivityLog and AuditEvent for audit purposes
436
+
437
+ console.log(`Completed cleanup for user ${userId}`);
438
+ } catch (error) {
439
+ console.error('Error during user cleanup:', error);
440
+ throw error;
441
+ }
442
+ }
443
+
356
444
  module.exports = {
357
445
  getUsers,
358
446
  registerUser,
359
447
  getUser,
360
448
  updateUserSubscription,
361
449
  updateUserPassword,
450
+ deleteUser,
362
451
  reconcileUser,
363
452
  generateToken,
364
453
  getWebhookEvents,
@@ -7,6 +7,12 @@ const {
7
7
  getDynamicModel,
8
8
  } = require('../services/headlessModels.service');
9
9
 
10
+ const {
11
+ listExternalCollections,
12
+ inferExternalModelFromCollection,
13
+ createOrUpdateExternalModel,
14
+ } = require('../services/headlessExternalModels.service');
15
+
10
16
  const llmService = require('../services/llm.service');
11
17
  const { getSettingValue } = require('../services/globalSettings.service');
12
18
  const { createAuditEvent, getBasicAuthActor } = require('../services/audit.service');
@@ -399,6 +405,82 @@ exports.deleteModel = async (req, res) => {
399
405
  }
400
406
  };
401
407
 
408
+ // External models (Mongo collections)
409
+ exports.listExternalCollections = async (req, res) => {
410
+ try {
411
+ const q = String(req.query.q || '').trim() || null;
412
+ const includeSystem = String(req.query.includeSystem || '').trim().toLowerCase() === 'true';
413
+ const items = await listExternalCollections({ q, includeSystem });
414
+ return res.json({ items });
415
+ } catch (error) {
416
+ console.error('Error listing external mongo collections:', error);
417
+ return handleServiceError(res, error);
418
+ }
419
+ };
420
+
421
+ exports.inferExternalCollection = async (req, res) => {
422
+ try {
423
+ const body = req.body && typeof req.body === 'object' ? req.body : {};
424
+ const collectionName = String(body.collectionName || '').trim();
425
+ const sampleSize = body.sampleSize;
426
+ const result = await inferExternalModelFromCollection({ collectionName, sampleSize });
427
+ return res.json(result);
428
+ } catch (error) {
429
+ console.error('Error inferring external collection schema:', error);
430
+ return handleServiceError(res, error);
431
+ }
432
+ };
433
+
434
+ exports.importExternalModel = async (req, res) => {
435
+ try {
436
+ const body = req.body && typeof req.body === 'object' ? req.body : {};
437
+ const collectionName = String(body.collectionName || '').trim();
438
+ const codeIdentifier = String(body.codeIdentifier || '').trim();
439
+ const displayName = String(body.displayName || '').trim();
440
+ const sampleSize = body.sampleSize;
441
+
442
+ const result = await createOrUpdateExternalModel({
443
+ collectionName,
444
+ codeIdentifier,
445
+ displayName,
446
+ sampleSize,
447
+ });
448
+
449
+ return res.status(result.created ? 201 : 200).json({ item: result.item, inference: result.inference });
450
+ } catch (error) {
451
+ console.error('Error importing external model:', error);
452
+ return handleServiceError(res, error);
453
+ }
454
+ };
455
+
456
+ exports.syncExternalModel = async (req, res) => {
457
+ try {
458
+ const codeIdentifier = String(req.params.codeIdentifier || '').trim();
459
+ const existing = await getModelDefinitionByCode(codeIdentifier);
460
+ if (!existing) return res.status(404).json({ error: 'Model not found' });
461
+
462
+ const isExternal = existing.sourceType === 'external' || existing.isExternal === true;
463
+ if (!isExternal) {
464
+ return res.status(400).json({ error: 'Model is not external' });
465
+ }
466
+
467
+ const body = req.body && typeof req.body === 'object' ? req.body : {};
468
+ const sampleSize = body.sampleSize;
469
+
470
+ const result = await createOrUpdateExternalModel({
471
+ collectionName: existing.sourceCollectionName,
472
+ codeIdentifier: existing.codeIdentifier,
473
+ displayName: existing.displayName,
474
+ sampleSize,
475
+ });
476
+
477
+ return res.json({ item: result.item, inference: result.inference });
478
+ } catch (error) {
479
+ console.error('Error syncing external model:', error);
480
+ return handleServiceError(res, error);
481
+ }
482
+ };
483
+
402
484
  exports.validateModelDefinition = async (req, res) => {
403
485
  try {
404
486
  const body = req.body || {};