@intranefr/superbackend 1.4.4 → 1.5.1

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 (195) hide show
  1. package/.env.example +5 -0
  2. package/README.md +11 -0
  3. package/index.js +39 -1
  4. package/package.json +11 -3
  5. package/public/sdk/ui-components.iife.js +191 -0
  6. package/sdk/ui-components/browser/src/index.js +228 -0
  7. package/src/admin/endpointRegistry.js +120 -0
  8. package/src/controllers/admin.controller.js +111 -5
  9. package/src/controllers/adminBlockDefinitions.controller.js +127 -0
  10. package/src/controllers/adminBlockDefinitionsAi.controller.js +54 -0
  11. package/src/controllers/adminCache.controller.js +342 -0
  12. package/src/controllers/adminContextBlockDefinitions.controller.js +141 -0
  13. package/src/controllers/adminCrons.controller.js +388 -0
  14. package/src/controllers/adminDbBrowser.controller.js +124 -0
  15. package/src/controllers/adminEjsVirtual.controller.js +13 -3
  16. package/src/controllers/adminHeadless.controller.js +91 -2
  17. package/src/controllers/adminHealthChecks.controller.js +570 -0
  18. package/src/controllers/adminI18n.controller.js +51 -29
  19. package/src/controllers/adminLlm.controller.js +126 -2
  20. package/src/controllers/adminPages.controller.js +720 -0
  21. package/src/controllers/adminPagesContextBlocksAi.controller.js +54 -0
  22. package/src/controllers/adminProxy.controller.js +113 -0
  23. package/src/controllers/adminRateLimits.controller.js +138 -0
  24. package/src/controllers/adminRbac.controller.js +803 -0
  25. package/src/controllers/adminScripts.controller.js +320 -0
  26. package/src/controllers/adminSeoConfig.controller.js +71 -48
  27. package/src/controllers/adminTerminals.controller.js +39 -0
  28. package/src/controllers/adminUiComponents.controller.js +315 -0
  29. package/src/controllers/adminUiComponentsAi.controller.js +34 -0
  30. package/src/controllers/blogAdmin.controller.js +279 -0
  31. package/src/controllers/blogAiAdmin.controller.js +224 -0
  32. package/src/controllers/blogAutomationAdmin.controller.js +141 -0
  33. package/src/controllers/blogInternal.controller.js +26 -0
  34. package/src/controllers/blogPublic.controller.js +89 -0
  35. package/src/controllers/fileManager.controller.js +190 -0
  36. package/src/controllers/fileManagerStoragePolicy.controller.js +23 -0
  37. package/src/controllers/healthChecksPublic.controller.js +196 -0
  38. package/src/controllers/metrics.controller.js +64 -4
  39. package/src/controllers/orgAdmin.controller.js +366 -0
  40. package/src/controllers/uiComponentsPublic.controller.js +118 -0
  41. package/src/middleware/auth.js +7 -0
  42. package/src/middleware/internalCronAuth.js +29 -0
  43. package/src/middleware/rbac.js +62 -0
  44. package/src/middleware.js +879 -56
  45. package/src/models/BlockDefinition.js +27 -0
  46. package/src/models/BlogAutomationLock.js +14 -0
  47. package/src/models/BlogAutomationRun.js +39 -0
  48. package/src/models/BlogPost.js +42 -0
  49. package/src/models/CacheEntry.js +26 -0
  50. package/src/models/ConsoleEntry.js +32 -0
  51. package/src/models/ConsoleLog.js +23 -0
  52. package/src/models/ContextBlockDefinition.js +33 -0
  53. package/src/models/CronExecution.js +47 -0
  54. package/src/models/CronJob.js +70 -0
  55. package/src/models/ExternalDbConnection.js +49 -0
  56. package/src/models/FileEntry.js +22 -0
  57. package/src/models/HeadlessModelDefinition.js +10 -0
  58. package/src/models/HealthAutoHealAttempt.js +57 -0
  59. package/src/models/HealthCheck.js +132 -0
  60. package/src/models/HealthCheckRun.js +51 -0
  61. package/src/models/HealthIncident.js +49 -0
  62. package/src/models/Page.js +95 -0
  63. package/src/models/PageCollection.js +42 -0
  64. package/src/models/ProxyEntry.js +66 -0
  65. package/src/models/RateLimitCounter.js +19 -0
  66. package/src/models/RateLimitMetricBucket.js +20 -0
  67. package/src/models/RbacGrant.js +25 -0
  68. package/src/models/RbacGroup.js +16 -0
  69. package/src/models/RbacGroupMember.js +13 -0
  70. package/src/models/RbacGroupRole.js +13 -0
  71. package/src/models/RbacRole.js +25 -0
  72. package/src/models/RbacUserRole.js +13 -0
  73. package/src/models/ScriptDefinition.js +42 -0
  74. package/src/models/ScriptRun.js +22 -0
  75. package/src/models/UiComponent.js +29 -0
  76. package/src/models/UiComponentProject.js +26 -0
  77. package/src/models/UiComponentProjectComponent.js +18 -0
  78. package/src/routes/admin.routes.js +1 -0
  79. package/src/routes/adminBlog.routes.js +21 -0
  80. package/src/routes/adminBlogAi.routes.js +16 -0
  81. package/src/routes/adminBlogAutomation.routes.js +27 -0
  82. package/src/routes/adminCache.routes.js +20 -0
  83. package/src/routes/adminConsoleManager.routes.js +302 -0
  84. package/src/routes/adminCrons.routes.js +25 -0
  85. package/src/routes/adminDbBrowser.routes.js +65 -0
  86. package/src/routes/adminEjsVirtual.routes.js +2 -1
  87. package/src/routes/adminHeadless.routes.js +8 -1
  88. package/src/routes/adminHealthChecks.routes.js +28 -0
  89. package/src/routes/adminI18n.routes.js +4 -3
  90. package/src/routes/adminLlm.routes.js +4 -2
  91. package/src/routes/adminPages.routes.js +55 -0
  92. package/src/routes/adminProxy.routes.js +15 -0
  93. package/src/routes/adminRateLimits.routes.js +17 -0
  94. package/src/routes/adminRbac.routes.js +38 -0
  95. package/src/routes/adminScripts.routes.js +21 -0
  96. package/src/routes/adminSeoConfig.routes.js +5 -4
  97. package/src/routes/adminTerminals.routes.js +13 -0
  98. package/src/routes/adminUiComponents.routes.js +30 -0
  99. package/src/routes/blogInternal.routes.js +14 -0
  100. package/src/routes/blogPublic.routes.js +9 -0
  101. package/src/routes/fileManager.routes.js +62 -0
  102. package/src/routes/fileManagerStoragePolicy.routes.js +9 -0
  103. package/src/routes/healthChecksPublic.routes.js +9 -0
  104. package/src/routes/log.routes.js +43 -60
  105. package/src/routes/metrics.routes.js +4 -2
  106. package/src/routes/orgAdmin.routes.js +6 -0
  107. package/src/routes/pages.routes.js +123 -0
  108. package/src/routes/proxy.routes.js +46 -0
  109. package/src/routes/rbac.routes.js +47 -0
  110. package/src/routes/uiComponentsPublic.routes.js +9 -0
  111. package/src/routes/webhook.routes.js +2 -1
  112. package/src/routes/workflows.routes.js +4 -0
  113. package/src/services/blockDefinitionsAi.service.js +247 -0
  114. package/src/services/blog.service.js +99 -0
  115. package/src/services/blogAutomation.service.js +978 -0
  116. package/src/services/blogCronsBootstrap.service.js +184 -0
  117. package/src/services/blogPublishing.service.js +58 -0
  118. package/src/services/cacheLayer.service.js +696 -0
  119. package/src/services/consoleManager.service.js +700 -0
  120. package/src/services/consoleOverride.service.js +6 -1
  121. package/src/services/cronScheduler.service.js +350 -0
  122. package/src/services/dbBrowser.service.js +536 -0
  123. package/src/services/ejsVirtual.service.js +102 -32
  124. package/src/services/fileManager.service.js +475 -0
  125. package/src/services/fileManagerStoragePolicy.service.js +285 -0
  126. package/src/services/headlessExternalModels.service.js +292 -0
  127. package/src/services/headlessModels.service.js +26 -6
  128. package/src/services/healthChecks.service.js +650 -0
  129. package/src/services/healthChecksBootstrap.service.js +109 -0
  130. package/src/services/healthChecksScheduler.service.js +106 -0
  131. package/src/services/llmDefaults.service.js +190 -0
  132. package/src/services/migrationAssets/s3.js +2 -2
  133. package/src/services/pages.service.js +602 -0
  134. package/src/services/pagesContext.service.js +331 -0
  135. package/src/services/pagesContextBlocksAi.service.js +349 -0
  136. package/src/services/proxy.service.js +535 -0
  137. package/src/services/rateLimiter.service.js +623 -0
  138. package/src/services/rbac.service.js +212 -0
  139. package/src/services/scriptsRunner.service.js +259 -0
  140. package/src/services/terminals.service.js +152 -0
  141. package/src/services/terminalsWs.service.js +100 -0
  142. package/src/services/uiComponentsAi.service.js +299 -0
  143. package/src/services/uiComponentsCrypto.service.js +39 -0
  144. package/src/services/workflow.service.js +23 -8
  145. package/src/utils/orgRoles.js +14 -0
  146. package/src/utils/rbac/engine.js +60 -0
  147. package/src/utils/rbac/rightsRegistry.js +29 -0
  148. package/views/admin-blog-automation.ejs +877 -0
  149. package/views/admin-blog-edit.ejs +542 -0
  150. package/views/admin-blog.ejs +399 -0
  151. package/views/admin-cache.ejs +681 -0
  152. package/views/admin-console-manager.ejs +680 -0
  153. package/views/admin-crons.ejs +645 -0
  154. package/views/admin-db-browser.ejs +445 -0
  155. package/views/admin-ejs-virtual.ejs +16 -10
  156. package/views/admin-file-manager.ejs +942 -0
  157. package/views/admin-headless.ejs +294 -24
  158. package/views/admin-health-checks.ejs +725 -0
  159. package/views/admin-i18n.ejs +59 -5
  160. package/views/admin-llm.ejs +99 -1
  161. package/views/admin-organizations.ejs +528 -10
  162. package/views/admin-pages.ejs +2424 -0
  163. package/views/admin-proxy.ejs +491 -0
  164. package/views/admin-rate-limiter.ejs +625 -0
  165. package/views/admin-rbac.ejs +1331 -0
  166. package/views/admin-scripts.ejs +497 -0
  167. package/views/admin-seo-config.ejs +61 -7
  168. package/views/admin-terminals.ejs +328 -0
  169. package/views/admin-ui-components.ejs +741 -0
  170. package/views/admin-users.ejs +261 -4
  171. package/views/admin-workflows.ejs +7 -7
  172. package/views/file-manager.ejs +866 -0
  173. package/views/pages/blocks/contact.ejs +27 -0
  174. package/views/pages/blocks/cta.ejs +18 -0
  175. package/views/pages/blocks/faq.ejs +20 -0
  176. package/views/pages/blocks/features.ejs +19 -0
  177. package/views/pages/blocks/hero.ejs +13 -0
  178. package/views/pages/blocks/html.ejs +5 -0
  179. package/views/pages/blocks/image.ejs +14 -0
  180. package/views/pages/blocks/testimonials.ejs +26 -0
  181. package/views/pages/blocks/text.ejs +10 -0
  182. package/views/pages/layouts/default.ejs +51 -0
  183. package/views/pages/layouts/minimal.ejs +42 -0
  184. package/views/pages/layouts/sidebar.ejs +54 -0
  185. package/views/pages/partials/footer.ejs +13 -0
  186. package/views/pages/partials/header.ejs +12 -0
  187. package/views/pages/partials/sidebar.ejs +8 -0
  188. package/views/pages/runtime/page.ejs +10 -0
  189. package/views/pages/templates/article.ejs +20 -0
  190. package/views/pages/templates/default.ejs +12 -0
  191. package/views/pages/templates/landing.ejs +14 -0
  192. package/views/pages/templates/listing.ejs +15 -0
  193. package/views/partials/admin-image-upload-modal.ejs +221 -0
  194. package/views/partials/dashboard/nav-items.ejs +14 -0
  195. package/views/partials/llm-provider-model-picker.ejs +183 -0
@@ -0,0 +1,285 @@
1
+ const mongoose = require('mongoose');
2
+
3
+ const Asset = require('../models/Asset');
4
+ const FileEntry = require('../models/FileEntry');
5
+ const RbacGroup = require('../models/RbacGroup');
6
+ const RbacGroupMember = require('../models/RbacGroupMember');
7
+
8
+ const globalSettingsService = require('./globalSettings.service');
9
+
10
+ const SETTING_POLICY_JSON = 'FILE_MANAGER_STORAGE_POLICY_JSON';
11
+ const SETTING_LEGACY_MAX_UPLOAD = 'FILE_MANAGER_MAX_UPLOAD_BYTES';
12
+
13
+ const DEFAULT_MAX_UPLOAD_BYTES = 1073741824;
14
+ const DEFAULT_MAX_STORAGE_BYTES = 104857600;
15
+
16
+ function normalizeObjectId(id, name) {
17
+ const str = String(id || '');
18
+ if (!mongoose.Types.ObjectId.isValid(str)) {
19
+ const err = new Error(`${name} must be a valid ObjectId`);
20
+ err.code = 'VALIDATION';
21
+ throw err;
22
+ }
23
+ return new mongoose.Types.ObjectId(str);
24
+ }
25
+
26
+ function normalizeDriveType(value) {
27
+ const t = String(value || '').trim();
28
+ if (t === 'user' || t === 'group' || t === 'org') return t;
29
+ const err = new Error('driveType must be one of: user, group, org');
30
+ err.code = 'VALIDATION';
31
+ throw err;
32
+ }
33
+
34
+ function toPositiveIntOrNull(value) {
35
+ const n = Number(value);
36
+ if (!Number.isFinite(n) || n <= 0) return null;
37
+ return Math.floor(n);
38
+ }
39
+
40
+ async function loadPolicy() {
41
+ const raw = await globalSettingsService.getSettingValue(SETTING_POLICY_JSON, null);
42
+ if (!raw) return { version: 1, global: {}, orgs: {} };
43
+
44
+ try {
45
+ const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw;
46
+ if (!parsed || typeof parsed !== 'object') return { version: 1, global: {}, orgs: {} };
47
+
48
+ const version = toPositiveIntOrNull(parsed.version) || 1;
49
+ const global = parsed.global && typeof parsed.global === 'object' ? parsed.global : {};
50
+ const orgs = parsed.orgs && typeof parsed.orgs === 'object' ? parsed.orgs : {};
51
+
52
+ return { version, global, orgs };
53
+ } catch {
54
+ return { version: 1, global: {}, orgs: {} };
55
+ }
56
+ }
57
+
58
+ function getEnvDefaultMaxUploadBytes() {
59
+ const fromEnv = toPositiveIntOrNull(process.env.FILE_MANAGER_DEFAULT_MAX_UPLOAD_BYTES);
60
+ return fromEnv || DEFAULT_MAX_UPLOAD_BYTES;
61
+ }
62
+
63
+ async function getDefaultMaxUploadBytes() {
64
+ const legacy = await globalSettingsService.getSettingValue(SETTING_LEGACY_MAX_UPLOAD, null);
65
+ const legacyParsed = toPositiveIntOrNull(legacy);
66
+ return legacyParsed || getEnvDefaultMaxUploadBytes();
67
+ }
68
+
69
+ function getDefaultMaxStorageBytes() {
70
+ const fromEnv = toPositiveIntOrNull(process.env.FILE_MANAGER_DEFAULT_MAX_STORAGE_BYTES);
71
+ return fromEnv || DEFAULT_MAX_STORAGE_BYTES;
72
+ }
73
+
74
+ async function getUserOrgGroupIds({ userId, orgId }) {
75
+ const uid = normalizeObjectId(userId, 'userId');
76
+ const oid = normalizeObjectId(orgId, 'orgId');
77
+
78
+ const links = await RbacGroupMember.find({ userId: uid }).select('groupId').lean();
79
+ const groupIds = links.map((l) => l.groupId).filter(Boolean);
80
+ if (!groupIds.length) return [];
81
+
82
+ const groups = await RbacGroup.find({ _id: { $in: groupIds }, orgId: oid, status: 'active', isGlobal: false })
83
+ .select('_id')
84
+ .lean();
85
+
86
+ return groups.map((g) => String(g._id));
87
+ }
88
+
89
+ function pickMax(values) {
90
+ let best = null;
91
+ for (const v of values) {
92
+ const n = toPositiveIntOrNull(v);
93
+ if (n === null) continue;
94
+ if (best === null || n > best) best = n;
95
+ }
96
+ return best;
97
+ }
98
+
99
+ function getOrgConfig(policy, orgId) {
100
+ const orgs = policy?.orgs || {};
101
+ const orgConfig = orgs[String(orgId)] || null;
102
+ return orgConfig && typeof orgConfig === 'object' ? orgConfig : null;
103
+ }
104
+
105
+ function getGroupConfig(orgConfig, groupId) {
106
+ const groups = orgConfig?.groups || {};
107
+ const g = groups[String(groupId)] || null;
108
+ return g && typeof g === 'object' ? g : null;
109
+ }
110
+
111
+ function getUserConfig(orgConfig, userId) {
112
+ const users = orgConfig?.users || {};
113
+ const u = users[String(userId)] || null;
114
+ return u && typeof u === 'object' ? u : null;
115
+ }
116
+
117
+ async function resolveEffectiveLimits({ userId, orgId, driveType, driveId }) {
118
+ const dt = normalizeDriveType(driveType);
119
+ const oid = normalizeObjectId(orgId, 'orgId');
120
+ const did = normalizeObjectId(driveId, 'driveId');
121
+
122
+ const policy = await loadPolicy();
123
+ const orgConfig = getOrgConfig(policy, String(oid));
124
+
125
+ const source = { maxUpload: 'default', maxStorage: 'default' };
126
+
127
+ const defaultMaxUpload = await getDefaultMaxUploadBytes();
128
+ const defaultMaxStorage = getDefaultMaxStorageBytes();
129
+
130
+ const globalMaxUpload = toPositiveIntOrNull(policy?.global?.maxUploadBytes);
131
+ const globalMaxStorage = toPositiveIntOrNull(policy?.global?.maxStorageBytes);
132
+
133
+ let maxUploadBytes = null;
134
+ let maxStorageBytes = null;
135
+
136
+ const orgMaxUpload = toPositiveIntOrNull(orgConfig?.maxUploadBytes);
137
+ const orgMaxStorage = toPositiveIntOrNull(orgConfig?.maxStorageBytes);
138
+
139
+ if (dt === 'org') {
140
+ maxUploadBytes = orgMaxUpload;
141
+ if (maxUploadBytes !== null) source.maxUpload = 'org';
142
+
143
+ maxStorageBytes = orgMaxStorage;
144
+ if (maxStorageBytes !== null) source.maxStorage = 'org';
145
+ }
146
+
147
+ if (dt === 'group') {
148
+ const groupConfig = getGroupConfig(orgConfig, String(did));
149
+ const groupMaxUpload = toPositiveIntOrNull(groupConfig?.maxUploadBytes);
150
+ const groupMaxStorage = toPositiveIntOrNull(groupConfig?.maxStorageBytes);
151
+
152
+ maxUploadBytes = groupMaxUpload;
153
+ if (maxUploadBytes !== null) source.maxUpload = 'group';
154
+
155
+ maxStorageBytes = groupMaxStorage;
156
+ if (maxStorageBytes !== null) source.maxStorage = 'group';
157
+
158
+ if (maxUploadBytes === null) {
159
+ maxUploadBytes = orgMaxUpload;
160
+ if (maxUploadBytes !== null) source.maxUpload = 'org';
161
+ }
162
+
163
+ if (maxStorageBytes === null) {
164
+ maxStorageBytes = orgMaxStorage;
165
+ if (maxStorageBytes !== null) source.maxStorage = 'org';
166
+ }
167
+ }
168
+
169
+ if (dt === 'user') {
170
+ const userConfig = getUserConfig(orgConfig, String(did));
171
+ const userMaxUpload = toPositiveIntOrNull(userConfig?.maxUploadBytes);
172
+ const userMaxStorage = toPositiveIntOrNull(userConfig?.maxStorageBytes);
173
+
174
+ maxUploadBytes = userMaxUpload;
175
+ if (maxUploadBytes !== null) source.maxUpload = 'user';
176
+
177
+ maxStorageBytes = userMaxStorage;
178
+ if (maxStorageBytes !== null) source.maxStorage = 'user';
179
+
180
+ const groupIds = await getUserOrgGroupIds({ userId, orgId: oid });
181
+ if (groupIds.length) {
182
+ const groupUploads = groupIds.map((gid) => getGroupConfig(orgConfig, gid)?.maxUploadBytes);
183
+ const groupStorages = groupIds.map((gid) => getGroupConfig(orgConfig, gid)?.maxStorageBytes);
184
+
185
+ if (maxUploadBytes === null) {
186
+ maxUploadBytes = pickMax(groupUploads);
187
+ if (maxUploadBytes !== null) source.maxUpload = 'group';
188
+ }
189
+
190
+ if (maxStorageBytes === null) {
191
+ maxStorageBytes = pickMax(groupStorages);
192
+ if (maxStorageBytes !== null) source.maxStorage = 'group';
193
+ }
194
+ }
195
+
196
+ if (maxUploadBytes === null) {
197
+ maxUploadBytes = orgMaxUpload;
198
+ if (maxUploadBytes !== null) source.maxUpload = 'org';
199
+ }
200
+
201
+ if (maxStorageBytes === null) {
202
+ maxStorageBytes = orgMaxStorage;
203
+ if (maxStorageBytes !== null) source.maxStorage = 'org';
204
+ }
205
+ }
206
+
207
+ if (maxUploadBytes === null && globalMaxUpload !== null) {
208
+ maxUploadBytes = globalMaxUpload;
209
+ source.maxUpload = 'global';
210
+ }
211
+
212
+ if (maxStorageBytes === null && globalMaxStorage !== null) {
213
+ maxStorageBytes = globalMaxStorage;
214
+ source.maxStorage = 'global';
215
+ }
216
+
217
+ if (maxUploadBytes === null) {
218
+ maxUploadBytes = defaultMaxUpload;
219
+ }
220
+
221
+ if (maxStorageBytes === null) {
222
+ maxStorageBytes = defaultMaxStorage;
223
+ }
224
+
225
+ return {
226
+ maxUploadBytes,
227
+ maxStorageBytes,
228
+ source,
229
+ };
230
+ }
231
+
232
+ async function computeDriveUsedBytes({ orgId, driveType, driveId }) {
233
+ const oid = normalizeObjectId(orgId, 'orgId');
234
+ const dt = normalizeDriveType(driveType);
235
+ const did = normalizeObjectId(driveId, 'driveId');
236
+
237
+ const rows = await FileEntry.aggregate([
238
+ {
239
+ $match: {
240
+ orgId: oid,
241
+ driveType: dt,
242
+ driveId: did,
243
+ deletedAt: null,
244
+ },
245
+ },
246
+ {
247
+ $lookup: {
248
+ from: 'assets',
249
+ localField: 'assetId',
250
+ foreignField: '_id',
251
+ as: 'asset',
252
+ },
253
+ },
254
+ { $unwind: { path: '$asset', preserveNullAndEmptyArrays: false } },
255
+ { $match: { 'asset.status': 'uploaded' } },
256
+ {
257
+ $group: {
258
+ _id: null,
259
+ usedBytes: { $sum: '$asset.sizeBytes' },
260
+ },
261
+ },
262
+ ]);
263
+
264
+ const usedBytes = rows?.[0]?.usedBytes;
265
+ return Number.isFinite(usedBytes) ? usedBytes : 0;
266
+ }
267
+
268
+ async function getEffectivePolicy({ userId, orgId, driveType, driveId }) {
269
+ const effective = await resolveEffectiveLimits({ userId, orgId, driveType, driveId });
270
+ const usedBytes = await computeDriveUsedBytes({ orgId, driveType, driveId });
271
+ const overageBytes = Math.max(0, usedBytes - effective.maxStorageBytes);
272
+
273
+ return {
274
+ effective,
275
+ usage: { usedBytes, overageBytes },
276
+ };
277
+ }
278
+
279
+ module.exports = {
280
+ loadPolicy,
281
+ resolveEffectiveLimits,
282
+ computeDriveUsedBytes,
283
+ getEffectivePolicy,
284
+ getUserOrgGroupIds,
285
+ };
@@ -0,0 +1,292 @@
1
+ const mongoose = require('mongoose');
2
+
3
+ const HeadlessModelDefinition = require('../models/HeadlessModelDefinition');
4
+ const { normalizeCodeIdentifier, computeSchemaHash } = require('./headlessModels.service');
5
+
6
+ function isObjectId(value) {
7
+ if (!value) return false;
8
+ if (value instanceof mongoose.Types.ObjectId) return true;
9
+ if (value && typeof value === 'object' && value._bsontype === 'ObjectID') return true;
10
+ return false;
11
+ }
12
+
13
+ function detectFieldType(value) {
14
+ if (value === null || value === undefined) return null;
15
+ if (isObjectId(value)) return 'objectid';
16
+ if (value instanceof Date) return 'date';
17
+ if (Array.isArray(value)) return 'array';
18
+ const t = typeof value;
19
+ if (t === 'string') return 'string';
20
+ if (t === 'number') return 'number';
21
+ if (t === 'boolean') return 'boolean';
22
+ if (t === 'object') return 'object';
23
+ return null;
24
+ }
25
+
26
+ function pickBestType(seenTypes) {
27
+ const types = new Set(Array.from(seenTypes || []).filter(Boolean));
28
+ if (types.size === 0) return { type: 'object', warning: 'No non-null sample values' };
29
+ if (types.size === 1) return { type: Array.from(types)[0], warning: null };
30
+ if (types.has('array')) return { type: 'array', warning: `Mixed types: ${Array.from(types).join('|')}` };
31
+ if (types.has('object')) return { type: 'object', warning: `Mixed types: ${Array.from(types).join('|')}` };
32
+ return { type: 'string', warning: `Mixed types: ${Array.from(types).join('|')}` };
33
+ }
34
+
35
+ function tryInferRefFromFieldName(fieldName, externalModelsByCollection) {
36
+ const name = String(fieldName || '').trim();
37
+ if (!name) return null;
38
+
39
+ const lower = name.toLowerCase();
40
+ if (!lower.endsWith('id') || lower === 'id') return null;
41
+
42
+ const stem = name.slice(0, -2);
43
+ const candidates = [stem, `${stem}s`, `${stem}es`];
44
+ for (const c of candidates) {
45
+ const match = externalModelsByCollection.get(String(c).toLowerCase());
46
+ if (match) return match;
47
+ }
48
+ return null;
49
+ }
50
+
51
+ function normalizeIndexFromMongo(idx) {
52
+ if (!idx || typeof idx !== 'object') return null;
53
+ const fields = idx.key;
54
+ if (!fields || typeof fields !== 'object') return null;
55
+
56
+ const options = { ...idx };
57
+ delete options.key;
58
+ delete options.v;
59
+ delete options.ns;
60
+
61
+ return { fields, options };
62
+ }
63
+
64
+ async function listExternalCollections({ q, includeSystem } = {}) {
65
+ if (!mongoose.connection || !mongoose.connection.db) {
66
+ const err = new Error('Mongo connection not ready');
67
+ err.code = 'VALIDATION';
68
+ throw err;
69
+ }
70
+
71
+ const filter = {};
72
+ if (q) filter.name = { $regex: String(q), $options: 'i' };
73
+
74
+ const cursor = await mongoose.connection.db.listCollections(filter, { nameOnly: true });
75
+ const items = await cursor.toArray();
76
+
77
+ const out = [];
78
+ for (const c of items) {
79
+ const name = String(c && c.name ? c.name : '').trim();
80
+ if (!name) continue;
81
+ if (!includeSystem && name.startsWith('system.')) continue;
82
+ out.push({ name, type: 'collection' });
83
+ }
84
+
85
+ out.sort((a, b) => a.name.localeCompare(b.name));
86
+ return out;
87
+ }
88
+
89
+ async function inferExternalModelFromCollection({ collectionName, sampleSize = 200 } = {}) {
90
+ if (!mongoose.connection || !mongoose.connection.db) {
91
+ const err = new Error('Mongo connection not ready');
92
+ err.code = 'VALIDATION';
93
+ throw err;
94
+ }
95
+
96
+ const collName = String(collectionName || '').trim();
97
+ if (!collName) {
98
+ const err = new Error('collectionName is required');
99
+ err.code = 'VALIDATION';
100
+ throw err;
101
+ }
102
+
103
+ const N = Math.max(1, Math.min(Number(sampleSize) || 200, 1000));
104
+ const coll = mongoose.connection.db.collection(collName);
105
+
106
+ let docs;
107
+ try {
108
+ docs = await coll.aggregate([{ $sample: { size: N } }]).toArray();
109
+ } catch {
110
+ docs = await coll.find({}).limit(N).toArray();
111
+ }
112
+
113
+ const externalModels = await HeadlessModelDefinition.find({ isActive: true, sourceType: 'external' })
114
+ .select({ codeIdentifier: 1, sourceCollectionName: 1 })
115
+ .lean();
116
+
117
+ const externalModelsByCollection = new Map();
118
+ for (const m of externalModels || []) {
119
+ const cn = String(m?.sourceCollectionName || '').trim();
120
+ const code = String(m?.codeIdentifier || '').trim();
121
+ if (cn && code) externalModelsByCollection.set(cn.toLowerCase(), code);
122
+ }
123
+
124
+ const perField = new Map();
125
+ const warnings = [];
126
+
127
+ for (const doc of docs || []) {
128
+ if (!doc || typeof doc !== 'object') continue;
129
+ for (const [k, v] of Object.entries(doc)) {
130
+ if (!k || k === '_id') continue;
131
+ const type = detectFieldType(v);
132
+ let stats = perField.get(k);
133
+ if (!stats) {
134
+ stats = { seenTypes: new Set(), objectIdCount: 0, nonNullCount: 0 };
135
+ perField.set(k, stats);
136
+ }
137
+ if (type) stats.seenTypes.add(type);
138
+ if (type === 'objectid') stats.objectIdCount += 1;
139
+ if (v !== null && v !== undefined) stats.nonNullCount += 1;
140
+ }
141
+ }
142
+
143
+ const fields = [];
144
+ for (const [name, stats] of perField.entries()) {
145
+ const { type, warning } = pickBestType(stats.seenTypes);
146
+
147
+ if (warning) warnings.push(`Field ${name}: ${warning}`);
148
+
149
+ if (type === 'objectid') {
150
+ const refModelCode = tryInferRefFromFieldName(name, externalModelsByCollection);
151
+ if (refModelCode) {
152
+ fields.push({ name, type: 'ref', required: false, unique: false, refModelCode });
153
+ } else {
154
+ fields.push({ name, type: 'string', required: false, unique: false });
155
+ }
156
+ continue;
157
+ }
158
+
159
+ if (type === 'date') {
160
+ fields.push({ name, type: 'date', required: false, unique: false });
161
+ continue;
162
+ }
163
+
164
+ if (type === 'array') {
165
+ fields.push({ name, type: 'array', required: false, unique: false });
166
+ continue;
167
+ }
168
+
169
+ if (type === 'object') {
170
+ fields.push({ name, type: 'object', required: false, unique: false });
171
+ continue;
172
+ }
173
+
174
+ if (type === 'number' || type === 'boolean' || type === 'string') {
175
+ fields.push({ name, type, required: false, unique: false });
176
+ continue;
177
+ }
178
+
179
+ fields.push({ name, type: 'object', required: false, unique: false });
180
+ }
181
+
182
+ fields.sort((a, b) => a.name.localeCompare(b.name));
183
+
184
+ let indexes = [];
185
+ try {
186
+ const idx = await coll.indexes();
187
+ indexes = (idx || []).map(normalizeIndexFromMongo).filter(Boolean);
188
+ } catch {
189
+ indexes = [];
190
+ }
191
+
192
+ const fieldsHash = computeSchemaHash({ fields, indexes });
193
+
194
+ return {
195
+ collectionName: collName,
196
+ fields,
197
+ indexes,
198
+ warnings,
199
+ stats: {
200
+ sampled: (docs || []).length,
201
+ maxSampleSize: N,
202
+ fields: fields.length,
203
+ },
204
+ fieldsHash,
205
+ };
206
+ }
207
+
208
+ async function createOrUpdateExternalModel({ collectionName, codeIdentifier, displayName, sampleSize } = {}) {
209
+ const cn = String(collectionName || '').trim();
210
+ if (!cn) {
211
+ const err = new Error('collectionName is required');
212
+ err.code = 'VALIDATION';
213
+ throw err;
214
+ }
215
+
216
+ const code = normalizeCodeIdentifier(codeIdentifier);
217
+ if (!code.startsWith('ext_')) {
218
+ const err = new Error('External model codeIdentifier must start with ext_');
219
+ err.code = 'VALIDATION';
220
+ throw err;
221
+ }
222
+
223
+ const name = String(displayName || code).trim();
224
+ if (!name) {
225
+ const err = new Error('displayName is required');
226
+ err.code = 'VALIDATION';
227
+ throw err;
228
+ }
229
+
230
+ const inferred = await inferExternalModelFromCollection({ collectionName: cn, sampleSize });
231
+
232
+ const existing = await HeadlessModelDefinition.findOne({ codeIdentifier: code, isActive: true });
233
+ if (!existing) {
234
+ const doc = await HeadlessModelDefinition.create({
235
+ codeIdentifier: code,
236
+ displayName: name,
237
+ description: '',
238
+ fields: inferred.fields,
239
+ indexes: inferred.indexes,
240
+ fieldsHash: inferred.fieldsHash,
241
+ version: 1,
242
+ previousFields: [],
243
+ previousIndexes: [],
244
+ sourceType: 'external',
245
+ sourceCollectionName: cn,
246
+ isExternal: true,
247
+ inference: {
248
+ enabled: true,
249
+ lastInferredAt: new Date(),
250
+ sampleSize: Number(sampleSize) || null,
251
+ warnings: inferred.warnings || [],
252
+ stats: inferred.stats || null,
253
+ },
254
+ isActive: true,
255
+ });
256
+
257
+ return { created: true, item: doc.toObject(), inference: inferred };
258
+ }
259
+
260
+ existing.displayName = name;
261
+ existing.sourceType = 'external';
262
+ existing.sourceCollectionName = cn;
263
+ existing.isExternal = true;
264
+
265
+ const newHash = inferred.fieldsHash;
266
+ if (newHash !== existing.fieldsHash) {
267
+ existing.previousFields = existing.fields;
268
+ existing.previousIndexes = existing.indexes;
269
+ existing.fields = inferred.fields;
270
+ existing.indexes = inferred.indexes;
271
+ existing.fieldsHash = newHash;
272
+ existing.version = Number(existing.version || 1) + 1;
273
+ }
274
+
275
+ existing.inference = {
276
+ enabled: true,
277
+ lastInferredAt: new Date(),
278
+ sampleSize: Number(sampleSize) || null,
279
+ warnings: inferred.warnings || [],
280
+ stats: inferred.stats || null,
281
+ };
282
+
283
+ await existing.save();
284
+
285
+ return { created: false, item: existing.toObject(), inference: inferred };
286
+ }
287
+
288
+ module.exports = {
289
+ listExternalCollections,
290
+ inferExternalModelFromCollection,
291
+ createOrUpdateExternalModel,
292
+ };
@@ -89,6 +89,18 @@ function getMongoCollectionName(codeIdentifier) {
89
89
  return `${MODEL_COLLECTION_PREFIX}${code}`;
90
90
  }
91
91
 
92
+ function isExternalDefinition(def) {
93
+ return def && (def.sourceType === 'external' || def.isExternal === true);
94
+ }
95
+
96
+ function getCollectionNameForDefinition(def) {
97
+ if (isExternalDefinition(def)) {
98
+ const cn = String(def.sourceCollectionName || '').trim();
99
+ return cn || getMongoCollectionName(def.codeIdentifier);
100
+ }
101
+ return getMongoCollectionName(def.codeIdentifier);
102
+ }
103
+
92
104
  function buildSchemaFromDefinition(def) {
93
105
  const schemaShape = {};
94
106
  for (const field of def.fields || []) {
@@ -99,12 +111,14 @@ function buildSchemaFromDefinition(def) {
99
111
  schemaShape[fieldName] = toMongooseField(field);
100
112
  }
101
113
 
102
- schemaShape._headlessModelCode = { type: String, default: def.codeIdentifier, index: true };
103
- schemaShape._headlessSchemaVersion = { type: Number, default: def.version, index: true };
114
+ if (!isExternalDefinition(def)) {
115
+ schemaShape._headlessModelCode = { type: String, default: def.codeIdentifier, index: true };
116
+ schemaShape._headlessSchemaVersion = { type: Number, default: def.version, index: true };
117
+ }
104
118
 
105
119
  const schema = new mongoose.Schema(schemaShape, {
106
120
  timestamps: true,
107
- collection: getMongoCollectionName(def.codeIdentifier),
121
+ collection: getCollectionNameForDefinition(def),
108
122
  strict: false,
109
123
  });
110
124
 
@@ -254,6 +268,8 @@ async function disableModelDefinition(codeIdentifier) {
254
268
  const modelCache = new Map();
255
269
 
256
270
  async function ensureAutoMigration(modelDef) {
271
+ if (isExternalDefinition(modelDef)) return;
272
+
257
273
  const collectionName = getMongoCollectionName(modelDef.codeIdentifier);
258
274
  const coll = mongoose.connection.collection(collectionName);
259
275
 
@@ -318,7 +334,8 @@ async function getDynamicModel(codeIdentifier) {
318
334
  throw err;
319
335
  }
320
336
 
321
- const cacheKey = `${def.codeIdentifier}:${def.version}:${def.fieldsHash}`;
337
+ const collectionName = getCollectionNameForDefinition(def);
338
+ const cacheKey = `${def.codeIdentifier}:${def.version}:${def.fieldsHash}:${collectionName}`;
322
339
  const cached = modelCache.get(cacheKey);
323
340
  if (cached) return cached;
324
341
 
@@ -334,8 +351,10 @@ async function getDynamicModel(codeIdentifier) {
334
351
  const schema = buildSchemaFromDefinition(def);
335
352
  const Model = mongoose.model(modelName, schema);
336
353
 
337
- await ensureAutoMigration(def);
338
- await ensureIndexesBestEffort(Model);
354
+ if (!isExternalDefinition(def)) {
355
+ await ensureAutoMigration(def);
356
+ await ensureIndexesBestEffort(Model);
357
+ }
339
358
 
340
359
  modelCache.set(cacheKey, Model);
341
360
  return Model;
@@ -346,6 +365,7 @@ module.exports = {
346
365
  normalizeCodeIdentifier,
347
366
  getMongooseModelName,
348
367
  getMongoCollectionName,
368
+ getCollectionNameForDefinition,
349
369
  computeSchemaHash,
350
370
  listModelDefinitions,
351
371
  getModelDefinitionByCode,