@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,196 @@
1
+ const HealthCheck = require('../models/HealthCheck');
2
+ const HealthIncident = require('../models/HealthIncident');
3
+ const globalSettingsService = require('../services/globalSettings.service');
4
+
5
+ const PUBLIC_STATUS_SETTING_KEY = 'healthChecks.publicStatusEnabled';
6
+
7
+ function escapeHtml(unsafe) {
8
+ return String(unsafe || '')
9
+ .replace(/&/g, '&')
10
+ .replace(/</g, '&lt;')
11
+ .replace(/>/g, '&gt;')
12
+ .replace(/"/g, '&quot;')
13
+ .replace(/'/g, '&#39;');
14
+ }
15
+
16
+ function renderHtml(payload) {
17
+ const status = payload.status || 'unknown';
18
+ const badgeClass = status === 'ok' ? 'badge-success' : status === 'degraded' ? 'badge-warning' : 'badge-error';
19
+
20
+ const checks = Array.isArray(payload.checks) ? payload.checks : [];
21
+
22
+ const bodyRows = checks.length > 0
23
+ ? checks
24
+ .map((c) => {
25
+ const cStatus = String(c.status || 'unknown');
26
+ const cBadgeClass = cStatus === 'healthy' ? 'badge-success' : cStatus === 'unhealthy' ? 'badge-error' : 'badge-ghost';
27
+
28
+ const incident = c.incident;
29
+ const incidentLabel = incident ? `${incident.status} (${incident.severity})` : '-';
30
+
31
+ return `
32
+ <tr>
33
+ <td class="font-medium">${escapeHtml(c.name)}</td>
34
+ <td><span class="badge ${cBadgeClass}">${escapeHtml(cStatus)}</span></td>
35
+ <td class="text-slate-500">${c.lastRunAt ? new Date(c.lastRunAt).toLocaleString() : '-'}</td>
36
+ <td class="text-slate-500">${c.lastLatencyMs != null ? escapeHtml(String(c.lastLatencyMs)) + ' ms' : '-'}</td>
37
+
38
+ <td class="text-xs">${escapeHtml(incidentLabel)}</td>
39
+ </tr>`;
40
+ })
41
+ .join('')
42
+ : '<tr><td colspan="5" class="text-slate-500 text-center">No checks found</td></tr>';
43
+
44
+ return `<!DOCTYPE html>
45
+ <html lang="en">
46
+ <head>
47
+ <meta charset="UTF-8" />
48
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
49
+ <title>Health Checks Status</title>
50
+ <script src="https://cdn.tailwindcss.com"></script>
51
+ <link href="https://cdn.jsdelivr.net/npm/daisyui@4.12.10/dist/full.min.css" rel="stylesheet" type="text/css" />
52
+ </head>
53
+ <body class="bg-slate-50">
54
+ <div class="max-w-5xl mx-auto px-6 py-8">
55
+ <div class="flex items-start justify-between gap-4">
56
+ <div>
57
+ <h1 class="text-2xl font-semibold text-slate-900">Health Checks</h1>
58
+ <div class="text-sm text-slate-500">Public status summary</div>
59
+ </div>
60
+ <div class="text-right">
61
+ <div class="text-sm text-slate-500">Overall</div>
62
+ <div class="badge ${badgeClass} badge-lg">${escapeHtml(status)}</div>
63
+ <div class="text-xs text-slate-500 mt-1">Updated: ${escapeHtml(payload.updatedAt || '')}</div>
64
+ </div>
65
+ </div>
66
+
67
+ <div class="mt-6 grid grid-cols-2 gap-3">
68
+ <div class="card bg-white border border-slate-200">
69
+ <div class="card-body py-4">
70
+ <div class="text-sm text-slate-500">Total checks</div>
71
+ <div class="text-xl font-semibold">${payload.totalChecks || 0}</div>
72
+ </div>
73
+ </div>
74
+ <div class="card bg-white border border-slate-200">
75
+ <div class="card-body py-4">
76
+ <div class="text-sm text-slate-500">Unhealthy</div>
77
+ <div class="text-xl font-semibold">${payload.unhealthyCount || 0}</div>
78
+ </div>
79
+ </div>
80
+ </div>
81
+
82
+ <div class="mt-6 card bg-white border border-slate-200">
83
+ <div class="card-body p-0">
84
+ <div class="overflow-x-auto">
85
+ <table class="table table-zebra w-full">
86
+ <thead>
87
+ <tr>
88
+ <th>Name</th>
89
+ <th>Status</th>
90
+ <th>Last run</th>
91
+ <th>Latency</th>
92
+ <th>Incident</th>
93
+ </tr>
94
+ </thead>
95
+ <tbody>
96
+ ${bodyRows}
97
+ </tbody>
98
+ </table>
99
+ </div>
100
+ </div>
101
+ </div>
102
+
103
+ <div class="mt-4 text-xs text-slate-500">
104
+ Tip: add <code class="px-1 py-0.5 bg-slate-100 rounded">/json</code> to the URL for JSON format.
105
+ </div>
106
+ </div>
107
+ </body>
108
+ </html>`;
109
+ }
110
+
111
+ async function computeStatusPayload() {
112
+ const checks = await HealthCheck.find({ enabled: true }).sort({ name: 1 }).lean();
113
+ const checkIds = checks.map((c) => String(c._id));
114
+
115
+ const incidents = await HealthIncident.find({
116
+ healthCheckId: { $in: checkIds },
117
+ status: { $in: ['open', 'acknowledged'] },
118
+ }).lean();
119
+
120
+
121
+ const incidentMap = {};
122
+ for (const incident of incidents) {
123
+ if (!incidentMap[incident.healthCheckId]) {
124
+ incidentMap[incident.healthCheckId] = incident;
125
+ }
126
+ }
127
+
128
+ const summaries = checks.map((check) => {
129
+ const incident = incidentMap[String(check._id)];
130
+ return {
131
+ id: String(check._id),
132
+ name: check.name,
133
+ status: incident ? incident.status : (check.lastStatus || 'unknown'),
134
+ lastRunAt: check.lastRunAt || null,
135
+ lastLatencyMs: check.lastLatencyMs || null,
136
+ incident: incident
137
+ ? {
138
+ id: String(incident._id),
139
+ status: incident.status,
140
+ severity: incident.severity,
141
+ openedAt: incident.openedAt,
142
+ lastSeenAt: incident.lastSeenAt,
143
+ }
144
+ : null,
145
+ };
146
+ });
147
+
148
+ const unhealthyCount = summaries.filter((s) => s.status === 'unhealthy' || s.incident).length;
149
+ const overallStatus = unhealthyCount > 0 ? 'degraded' : 'ok';
150
+
151
+ return {
152
+ ok: overallStatus === 'ok',
153
+ status: overallStatus,
154
+ updatedAt: new Date().toISOString(),
155
+ totalChecks: summaries.length,
156
+ unhealthyCount,
157
+ checks: summaries,
158
+ };
159
+ }
160
+
161
+ exports.getStatus = async (req, res) => {
162
+ try {
163
+ const raw = await globalSettingsService.getSettingValue(PUBLIC_STATUS_SETTING_KEY, 'false');
164
+ const enabled = String(raw) === 'true';
165
+
166
+ if (!enabled) {
167
+ return res.status(404).json({ error: 'Not found' });
168
+ }
169
+
170
+ const payload = await computeStatusPayload();
171
+ const html = renderHtml(payload);
172
+
173
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
174
+ return res.status(200).send(html);
175
+ } catch (error) {
176
+ console.error('Failed to compute health checks status:', error);
177
+ return res.status(500).json({ error: 'Failed to compute status' });
178
+ }
179
+ };
180
+
181
+ exports.getStatusJson = async (req, res) => {
182
+ try {
183
+ const raw = await globalSettingsService.getSettingValue(PUBLIC_STATUS_SETTING_KEY, 'false');
184
+ const enabled = String(raw) === 'true';
185
+
186
+ if (!enabled) {
187
+ return res.status(404).json({ error: 'Not found' });
188
+ }
189
+
190
+ const payload = await computeStatusPayload();
191
+ return res.json(payload);
192
+ } catch (error) {
193
+ console.error('Failed to compute health checks status json:', error);
194
+ return res.status(500).json({ error: 'Failed to compute status' });
195
+ }
196
+ };
@@ -55,14 +55,35 @@ async function tryAttachUser(req) {
55
55
 
56
56
  exports.track = async (req, res) => {
57
57
  try {
58
+ // Validate request size to prevent abuse
59
+ const contentLength = req.headers['content-length'];
60
+ if (contentLength && parseInt(contentLength) > 1024 * 10) { // 10KB limit
61
+ return res.status(413).json({ error: 'Request too large' });
62
+ }
63
+
58
64
  await tryAttachUser(req);
59
65
 
60
66
  const action = String(req.body?.action || '').trim();
61
67
  const meta = req.body?.meta ?? null;
62
68
 
69
+ // Validate action field
63
70
  if (!action) {
64
71
  return res.status(400).json({ error: 'action is required' });
65
72
  }
73
+
74
+ if (action.length > 100) {
75
+ return res.status(400).json({ error: 'action too long (max 100 characters)' });
76
+ }
77
+
78
+ // Validate action format (allow only alphanumeric, underscores, hyphens, dots)
79
+ if (!/^[a-zA-Z0-9._-]+$/.test(action)) {
80
+ return res.status(400).json({ error: 'invalid action format' });
81
+ }
82
+
83
+ // Validate meta size
84
+ if (meta && JSON.stringify(meta).length > 1024 * 5) { // 5KB limit
85
+ return res.status(400).json({ error: 'meta data too large' });
86
+ }
66
87
 
67
88
  let actorType = 'anonymous';
68
89
  let actorId = getAnonId(req);
@@ -98,12 +119,48 @@ exports.track = async (req, res) => {
98
119
 
99
120
  exports.getImpact = async (req, res) => {
100
121
  try {
101
- const { start, end } = getMonthRange(new Date());
122
+ // Validate query parameters
123
+ const { start, end } = req.query;
124
+
125
+ // Allow custom time ranges but restrict to reasonable limits
126
+ let startTime, endTime;
127
+
128
+ if (start || end) {
129
+ // Parse custom range if provided
130
+ startTime = start ? new Date(start) : null;
131
+ endTime = end ? new Date(end) : null;
132
+
133
+ // Validate dates
134
+ if ((start && isNaN(startTime.getTime())) || (end && isNaN(endTime.getTime()))) {
135
+ return res.status(400).json({ error: 'Invalid date format' });
136
+ }
137
+
138
+ // Restrict range to maximum 1 year
139
+ if (startTime && endTime) {
140
+ const rangeMs = endTime.getTime() - startTime.getTime();
141
+ const maxRangeMs = 365 * 24 * 60 * 60 * 1000; // 1 year
142
+ if (rangeMs > maxRangeMs) {
143
+ return res.status(400).json({ error: 'Time range too large (max 1 year)' });
144
+ }
145
+ }
146
+
147
+ // Default to current month if range is incomplete
148
+ if (!startTime || !endTime) {
149
+ const currentMonth = getMonthRange(new Date());
150
+ startTime = startTime || currentMonth.start;
151
+ endTime = endTime || currentMonth.end;
152
+ }
153
+ } else {
154
+ // Default to current month
155
+ const currentMonth = getMonthRange(new Date());
156
+ startTime = currentMonth.start;
157
+ endTime = currentMonth.end;
158
+ }
102
159
 
103
160
  const activeActorsAgg = await ActionEvent.aggregate([
104
161
  {
105
162
  $match: {
106
- createdAt: { $gte: start, $lt: end },
163
+ createdAt: { $gte: startTime, $lt: endTime },
107
164
  actorType: { $in: ['user', 'anonymous'] },
108
165
  },
109
166
  },
@@ -122,7 +179,7 @@ exports.getImpact = async (req, res) => {
122
179
 
123
180
  const servicesConsulted = await ActionEvent.countDocuments({
124
181
  action: 'service_view',
125
- createdAt: { $gte: start, $lt: end },
182
+ createdAt: { $gte: startTime, $lt: endTime },
126
183
  });
127
184
 
128
185
  const newsletterSetting = await GlobalSetting.findOne({ key: 'newsletter_list' }).lean();
@@ -136,8 +193,11 @@ exports.getImpact = async (req, res) => {
136
193
  }
137
194
  }
138
195
 
196
+ // Add cache headers for better performance
197
+ res.set('Cache-Control', 'public, max-age=300'); // 5 minutes cache
198
+
139
199
  return res.json({
140
- range: { start: start.toISOString(), end: end.toISOString() },
200
+ range: { start: startTime.toISOString(), end: endTime.toISOString() },
141
201
  activeUsers,
142
202
  servicesConsulted,
143
203
  newsletterSubscribers,
@@ -3,6 +3,9 @@ const mongoose = require('mongoose');
3
3
  const Organization = require('../models/Organization');
4
4
  const OrganizationMember = require('../models/OrganizationMember');
5
5
  const Invite = require('../models/Invite');
6
+ const User = require('../models/User');
7
+ const Asset = require('../models/Asset');
8
+ const Notification = require('../models/Notification');
6
9
  const emailService = require('../services/email.service');
7
10
  const { isValidOrgRole, getAllowedOrgRoles, getDefaultOrgRole } = require('../utils/orgRoles');
8
11
 
@@ -267,6 +270,86 @@ exports.removeMember = async (req, res) => {
267
270
  }
268
271
  };
269
272
 
273
+ exports.addMember = async (req, res) => {
274
+ try {
275
+ const { orgId } = req.params;
276
+ const { userId, role } = req.body;
277
+
278
+ if (!orgId || !mongoose.Types.ObjectId.isValid(String(orgId))) {
279
+ return res.status(400).json({ error: 'Invalid organization ID' });
280
+ }
281
+
282
+ if (!userId || !mongoose.Types.ObjectId.isValid(String(userId))) {
283
+ return res.status(400).json({ error: 'Invalid user ID' });
284
+ }
285
+
286
+ // Validate role
287
+ const defaultRole = await getDefaultOrgRole();
288
+ const memberRole = role || defaultRole;
289
+ if (!(await isValidOrgRole(memberRole))) {
290
+ const allowed = await getAllowedOrgRoles();
291
+ return res.status(400).json({ error: 'Invalid role', allowedRoles: allowed });
292
+ }
293
+
294
+ // Check if organization exists
295
+ const org = await Organization.findById(orgId);
296
+ if (!org) {
297
+ return res.status(404).json({ error: 'Organization not found' });
298
+ }
299
+
300
+ // Check if user exists
301
+ const user = await User.findById(userId);
302
+ if (!user) {
303
+ return res.status(404).json({ error: 'User not found' });
304
+ }
305
+
306
+ // Check if user is already a member
307
+ const existingMember = await OrganizationMember.findOne({
308
+ orgId,
309
+ userId,
310
+ status: { $in: ['active', 'removed'] }
311
+ });
312
+
313
+ if (existingMember) {
314
+ if (existingMember.status === 'active') {
315
+ return res.status(409).json({ error: 'User is already a member of this organization' });
316
+ } else {
317
+ // Reactivate removed member
318
+ existingMember.status = 'active';
319
+ existingMember.role = memberRole;
320
+ existingMember.addedByUserId = req.user?.id || org.ownerUserId;
321
+ await existingMember.save();
322
+ return res.json({
323
+ message: 'Member reactivated successfully',
324
+ member: existingMember.toObject()
325
+ });
326
+ }
327
+ }
328
+
329
+ // Create new member
330
+ const member = await OrganizationMember.create({
331
+ orgId,
332
+ userId,
333
+ role: memberRole,
334
+ status: 'active',
335
+ addedByUserId: req.user?.id || org.ownerUserId,
336
+ });
337
+
338
+ // Populate user data for response
339
+ const populatedMember = await OrganizationMember.findById(member._id)
340
+ .populate('userId', 'email name')
341
+ .lean();
342
+
343
+ return res.status(201).json({
344
+ message: 'Member added successfully',
345
+ member: populatedMember
346
+ });
347
+ } catch (error) {
348
+ console.error('Admin org member add error:', error);
349
+ return res.status(500).json({ error: 'Failed to add member' });
350
+ }
351
+ };
352
+
270
353
  exports.listInvites = async (req, res) => {
271
354
  try {
272
355
  const { orgId } = req.params;
@@ -489,3 +572,286 @@ exports.resendInvite = async (req, res) => {
489
572
  return res.status(500).json({ error: 'Failed to resend invite' });
490
573
  }
491
574
  };
575
+
576
+ // Create organization (admin only)
577
+ exports.createOrganization = async (req, res) => {
578
+ try {
579
+ const { name, description, ownerUserId } = req.body;
580
+
581
+ // Validation
582
+ if (!name || typeof name !== 'string' || name.trim().length < 2) {
583
+ return res.status(400).json({ error: 'Name must be at least 2 characters long' });
584
+ }
585
+
586
+ if (name.trim().length > 100) {
587
+ return res.status(400).json({ error: 'Name must be less than 100 characters' });
588
+ }
589
+
590
+ if (description && description.trim().length > 500) {
591
+ return res.status(400).json({ error: 'Description must be less than 500 characters' });
592
+ }
593
+
594
+ // Validate owner if specified
595
+ let ownerId = null;
596
+ if (ownerUserId) {
597
+ if (!mongoose.Types.ObjectId.isValid(String(ownerUserId))) {
598
+ return res.status(400).json({ error: 'Invalid owner user ID' });
599
+ }
600
+
601
+ const owner = await User.findById(ownerUserId);
602
+ if (!owner) {
603
+ return res.status(400).json({ error: 'Owner user not found' });
604
+ }
605
+ ownerId = owner._id;
606
+ } else {
607
+ // Default to first admin user if no owner specified
608
+ const defaultOwner = await User.findOne({ role: 'admin' });
609
+ if (!defaultOwner) {
610
+ return res.status(400).json({ error: 'No admin user available to assign as owner' });
611
+ }
612
+ ownerId = defaultOwner._id;
613
+ }
614
+
615
+ // Generate unique slug
616
+ let baseSlug = name.trim()
617
+ .toLowerCase()
618
+ .replace(/[^a-z0-9\s-]/g, '')
619
+ .replace(/\s+/g, '-')
620
+ .replace(/-+/g, '-')
621
+ .replace(/^-|-$/g, '');
622
+
623
+ if (!baseSlug || baseSlug.length < 2) {
624
+ return res.status(400).json({ error: 'Name must contain valid characters for slug generation' });
625
+ }
626
+
627
+ let slug = baseSlug;
628
+ let counter = 1;
629
+
630
+ while (await Organization.findOne({ slug })) {
631
+ slug = `${baseSlug}-${counter}`;
632
+ counter++;
633
+ if (counter > 1000) {
634
+ return res.status(500).json({ error: 'Unable to generate unique slug' });
635
+ }
636
+ }
637
+
638
+ // Create organization
639
+ const org = await Organization.create({
640
+ name: name.trim(),
641
+ slug,
642
+ description: description ? description.trim() : '',
643
+ ownerUserId: ownerId,
644
+ status: 'active',
645
+ settings: {}
646
+ });
647
+
648
+ console.log(`Admin created organization: ${org.name} (${org._id}) with owner: ${ownerId}`);
649
+
650
+ res.status(201).json({
651
+ message: 'Organization created successfully',
652
+ org: org.toObject()
653
+ });
654
+ } catch (error) {
655
+ console.error('Create organization error:', error);
656
+ if (error.code === 11000) {
657
+ // Duplicate key error
658
+ return res.status(400).json({ error: 'Organization with this name or slug already exists' });
659
+ }
660
+ return res.status(500).json({ error: 'Failed to create organization' });
661
+ }
662
+ };
663
+
664
+ // Update organization (admin only)
665
+ exports.updateOrganization = async (req, res) => {
666
+ try {
667
+ const { orgId } = req.params;
668
+ const { name, description, ownerUserId, status } = req.body;
669
+
670
+ if (!orgId || !mongoose.Types.ObjectId.isValid(String(orgId))) {
671
+ return res.status(400).json({ error: 'Invalid organization ID' });
672
+ }
673
+
674
+ const org = await Organization.findById(orgId);
675
+ if (!org) {
676
+ return res.status(404).json({ error: 'Organization not found' });
677
+ }
678
+
679
+ // Update name (but not slug - per requirements)
680
+ if (name !== undefined) {
681
+ if (!name || typeof name !== 'string' || name.trim().length < 2) {
682
+ return res.status(400).json({ error: 'Name must be at least 2 characters long' });
683
+ }
684
+ if (name.trim().length > 100) {
685
+ return res.status(400).json({ error: 'Name must be less than 100 characters' });
686
+ }
687
+ org.name = name.trim();
688
+ }
689
+
690
+ // Update description
691
+ if (description !== undefined) {
692
+ if (description && description.trim().length > 500) {
693
+ return res.status(400).json({ error: 'Description must be less than 500 characters' });
694
+ }
695
+ org.description = description ? description.trim() : '';
696
+ }
697
+
698
+ // Update owner
699
+ if (ownerUserId !== undefined) {
700
+ if (ownerUserId) {
701
+ if (!mongoose.Types.ObjectId.isValid(String(ownerUserId))) {
702
+ return res.status(400).json({ error: 'Invalid owner user ID' });
703
+ }
704
+ const owner = await User.findById(ownerUserId);
705
+ if (!owner) {
706
+ return res.status(400).json({ error: 'Owner user not found' });
707
+ }
708
+ org.ownerUserId = owner._id;
709
+ } else {
710
+ return res.status(400).json({ error: 'Owner cannot be empty' });
711
+ }
712
+ }
713
+
714
+ // Update status
715
+ if (status !== undefined) {
716
+ if (!['active', 'disabled'].includes(status)) {
717
+ return res.status(400).json({ error: 'Status must be either "active" or "disabled"' });
718
+ }
719
+ org.status = status;
720
+ }
721
+
722
+ await org.save();
723
+
724
+ console.log(`Admin updated organization: ${org.name} (${org._id})`);
725
+
726
+ res.json({
727
+ message: 'Organization updated successfully',
728
+ org: org.toObject()
729
+ });
730
+ } catch (error) {
731
+ console.error('Update organization error:', error);
732
+ return res.status(500).json({ error: 'Failed to update organization' });
733
+ }
734
+ };
735
+
736
+ // Disable organization (admin only)
737
+ exports.disableOrganization = async (req, res) => {
738
+ try {
739
+ const { orgId } = req.params;
740
+
741
+ if (!orgId || !mongoose.Types.ObjectId.isValid(String(orgId))) {
742
+ return res.status(400).json({ error: 'Invalid organization ID' });
743
+ }
744
+
745
+ const org = await Organization.findById(orgId);
746
+ if (!org) {
747
+ return res.status(404).json({ error: 'Organization not found' });
748
+ }
749
+
750
+ if (org.status === 'disabled') {
751
+ return res.status(400).json({ error: 'Organization is already disabled' });
752
+ }
753
+
754
+ org.status = 'disabled';
755
+ await org.save();
756
+
757
+ console.log(`Admin disabled organization: ${org.name} (${org._id})`);
758
+
759
+ res.json({
760
+ message: 'Organization disabled successfully',
761
+ org: org.toObject()
762
+ });
763
+ } catch (error) {
764
+ console.error('Disable organization error:', error);
765
+ return res.status(500).json({ error: 'Failed to disable organization' });
766
+ }
767
+ };
768
+
769
+ // Enable organization (admin only)
770
+ exports.enableOrganization = async (req, res) => {
771
+ try {
772
+ const { orgId } = req.params;
773
+
774
+ if (!orgId || !mongoose.Types.ObjectId.isValid(String(orgId))) {
775
+ return res.status(400).json({ error: 'Invalid organization ID' });
776
+ }
777
+
778
+ const org = await Organization.findById(orgId);
779
+ if (!org) {
780
+ return res.status(404).json({ error: 'Organization not found' });
781
+ }
782
+
783
+ if (org.status === 'active') {
784
+ return res.status(400).json({ error: 'Organization is already active' });
785
+ }
786
+
787
+ org.status = 'active';
788
+ await org.save();
789
+
790
+ console.log(`Admin enabled organization: ${org.name} (${org._id})`);
791
+
792
+ res.json({
793
+ message: 'Organization enabled successfully',
794
+ org: org.toObject()
795
+ });
796
+ } catch (error) {
797
+ console.error('Enable organization error:', error);
798
+ return res.status(500).json({ error: 'Failed to enable organization' });
799
+ }
800
+ };
801
+
802
+ // Delete organization (admin only)
803
+ exports.deleteOrganization = async (req, res) => {
804
+ try {
805
+ const { orgId } = req.params;
806
+
807
+ if (!orgId || !mongoose.Types.ObjectId.isValid(String(orgId))) {
808
+ return res.status(400).json({ error: 'Invalid organization ID' });
809
+ }
810
+
811
+ const org = await Organization.findById(orgId);
812
+ if (!org) {
813
+ return res.status(404).json({ error: 'Organization not found' });
814
+ }
815
+
816
+ // Cascade cleanup
817
+ await cleanupOrganizationData(orgId);
818
+
819
+ // Delete organization
820
+ await Organization.findByIdAndDelete(orgId);
821
+
822
+ console.log(`Admin deleted organization: ${org.name} (${org._id})`);
823
+
824
+ res.json({ message: 'Organization deleted successfully' });
825
+ } catch (error) {
826
+ console.error('Delete organization error:', error);
827
+ return res.status(500).json({ error: 'Failed to delete organization' });
828
+ }
829
+ };
830
+
831
+ // Helper function to clean up organization data
832
+ async function cleanupOrganizationData(orgId) {
833
+ try {
834
+ // Delete organization members
835
+ await OrganizationMember.deleteMany({ orgId });
836
+
837
+ // Delete organization invites
838
+ await Invite.deleteMany({ orgId });
839
+
840
+ // Delete organization assets
841
+ await Asset.deleteMany({ ownerUserId: { $in: await getOrganizationUserIds(orgId) } });
842
+
843
+ // Delete organization notifications
844
+ await Notification.deleteMany({ userId: { $in: await getOrganizationUserIds(orgId) } });
845
+
846
+ console.log(`Completed cleanup for organization ${orgId}`);
847
+ } catch (error) {
848
+ console.error('Error during organization cleanup:', error);
849
+ throw error;
850
+ }
851
+ }
852
+
853
+ // Helper function to get all user IDs in an organization
854
+ async function getOrganizationUserIds(orgId) {
855
+ const members = await OrganizationMember.find({ orgId }).distinct('userId');
856
+ return members;
857
+ }