@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.
- package/.env.example +5 -0
- package/README.md +11 -0
- package/index.js +39 -1
- package/package.json +11 -3
- package/public/sdk/ui-components.iife.js +191 -0
- package/sdk/ui-components/browser/src/index.js +228 -0
- package/src/admin/endpointRegistry.js +120 -0
- package/src/controllers/admin.controller.js +111 -5
- package/src/controllers/adminBlockDefinitions.controller.js +127 -0
- package/src/controllers/adminBlockDefinitionsAi.controller.js +54 -0
- package/src/controllers/adminCache.controller.js +342 -0
- package/src/controllers/adminContextBlockDefinitions.controller.js +141 -0
- package/src/controllers/adminCrons.controller.js +388 -0
- package/src/controllers/adminDbBrowser.controller.js +124 -0
- package/src/controllers/adminEjsVirtual.controller.js +13 -3
- package/src/controllers/adminHeadless.controller.js +91 -2
- package/src/controllers/adminHealthChecks.controller.js +570 -0
- package/src/controllers/adminI18n.controller.js +51 -29
- package/src/controllers/adminLlm.controller.js +126 -2
- package/src/controllers/adminPages.controller.js +720 -0
- package/src/controllers/adminPagesContextBlocksAi.controller.js +54 -0
- package/src/controllers/adminProxy.controller.js +113 -0
- package/src/controllers/adminRateLimits.controller.js +138 -0
- package/src/controllers/adminRbac.controller.js +803 -0
- package/src/controllers/adminScripts.controller.js +320 -0
- package/src/controllers/adminSeoConfig.controller.js +71 -48
- package/src/controllers/adminTerminals.controller.js +39 -0
- package/src/controllers/adminUiComponents.controller.js +315 -0
- package/src/controllers/adminUiComponentsAi.controller.js +34 -0
- package/src/controllers/blogAdmin.controller.js +279 -0
- package/src/controllers/blogAiAdmin.controller.js +224 -0
- package/src/controllers/blogAutomationAdmin.controller.js +141 -0
- package/src/controllers/blogInternal.controller.js +26 -0
- package/src/controllers/blogPublic.controller.js +89 -0
- package/src/controllers/fileManager.controller.js +190 -0
- package/src/controllers/fileManagerStoragePolicy.controller.js +23 -0
- package/src/controllers/healthChecksPublic.controller.js +196 -0
- package/src/controllers/metrics.controller.js +64 -4
- package/src/controllers/orgAdmin.controller.js +366 -0
- package/src/controllers/uiComponentsPublic.controller.js +118 -0
- package/src/middleware/auth.js +7 -0
- package/src/middleware/internalCronAuth.js +29 -0
- package/src/middleware/rbac.js +62 -0
- package/src/middleware.js +879 -56
- package/src/models/BlockDefinition.js +27 -0
- package/src/models/BlogAutomationLock.js +14 -0
- package/src/models/BlogAutomationRun.js +39 -0
- package/src/models/BlogPost.js +42 -0
- package/src/models/CacheEntry.js +26 -0
- package/src/models/ConsoleEntry.js +32 -0
- package/src/models/ConsoleLog.js +23 -0
- package/src/models/ContextBlockDefinition.js +33 -0
- package/src/models/CronExecution.js +47 -0
- package/src/models/CronJob.js +70 -0
- package/src/models/ExternalDbConnection.js +49 -0
- package/src/models/FileEntry.js +22 -0
- package/src/models/HeadlessModelDefinition.js +10 -0
- package/src/models/HealthAutoHealAttempt.js +57 -0
- package/src/models/HealthCheck.js +132 -0
- package/src/models/HealthCheckRun.js +51 -0
- package/src/models/HealthIncident.js +49 -0
- package/src/models/Page.js +95 -0
- package/src/models/PageCollection.js +42 -0
- package/src/models/ProxyEntry.js +66 -0
- package/src/models/RateLimitCounter.js +19 -0
- package/src/models/RateLimitMetricBucket.js +20 -0
- package/src/models/RbacGrant.js +25 -0
- package/src/models/RbacGroup.js +16 -0
- package/src/models/RbacGroupMember.js +13 -0
- package/src/models/RbacGroupRole.js +13 -0
- package/src/models/RbacRole.js +25 -0
- package/src/models/RbacUserRole.js +13 -0
- package/src/models/ScriptDefinition.js +42 -0
- package/src/models/ScriptRun.js +22 -0
- package/src/models/UiComponent.js +29 -0
- package/src/models/UiComponentProject.js +26 -0
- package/src/models/UiComponentProjectComponent.js +18 -0
- package/src/routes/admin.routes.js +1 -0
- package/src/routes/adminBlog.routes.js +21 -0
- package/src/routes/adminBlogAi.routes.js +16 -0
- package/src/routes/adminBlogAutomation.routes.js +27 -0
- package/src/routes/adminCache.routes.js +20 -0
- package/src/routes/adminConsoleManager.routes.js +302 -0
- package/src/routes/adminCrons.routes.js +25 -0
- package/src/routes/adminDbBrowser.routes.js +65 -0
- package/src/routes/adminEjsVirtual.routes.js +2 -1
- package/src/routes/adminHeadless.routes.js +8 -1
- package/src/routes/adminHealthChecks.routes.js +28 -0
- package/src/routes/adminI18n.routes.js +4 -3
- package/src/routes/adminLlm.routes.js +4 -2
- package/src/routes/adminPages.routes.js +55 -0
- package/src/routes/adminProxy.routes.js +15 -0
- package/src/routes/adminRateLimits.routes.js +17 -0
- package/src/routes/adminRbac.routes.js +38 -0
- package/src/routes/adminScripts.routes.js +21 -0
- package/src/routes/adminSeoConfig.routes.js +5 -4
- package/src/routes/adminTerminals.routes.js +13 -0
- package/src/routes/adminUiComponents.routes.js +30 -0
- package/src/routes/blogInternal.routes.js +14 -0
- package/src/routes/blogPublic.routes.js +9 -0
- package/src/routes/fileManager.routes.js +62 -0
- package/src/routes/fileManagerStoragePolicy.routes.js +9 -0
- package/src/routes/healthChecksPublic.routes.js +9 -0
- package/src/routes/log.routes.js +43 -60
- package/src/routes/metrics.routes.js +4 -2
- package/src/routes/orgAdmin.routes.js +6 -0
- package/src/routes/pages.routes.js +123 -0
- package/src/routes/proxy.routes.js +46 -0
- package/src/routes/rbac.routes.js +47 -0
- package/src/routes/uiComponentsPublic.routes.js +9 -0
- package/src/routes/webhook.routes.js +2 -1
- package/src/routes/workflows.routes.js +4 -0
- package/src/services/blockDefinitionsAi.service.js +247 -0
- package/src/services/blog.service.js +99 -0
- package/src/services/blogAutomation.service.js +978 -0
- package/src/services/blogCronsBootstrap.service.js +184 -0
- package/src/services/blogPublishing.service.js +58 -0
- package/src/services/cacheLayer.service.js +696 -0
- package/src/services/consoleManager.service.js +700 -0
- package/src/services/consoleOverride.service.js +6 -1
- package/src/services/cronScheduler.service.js +350 -0
- package/src/services/dbBrowser.service.js +536 -0
- package/src/services/ejsVirtual.service.js +102 -32
- package/src/services/fileManager.service.js +475 -0
- package/src/services/fileManagerStoragePolicy.service.js +285 -0
- package/src/services/headlessExternalModels.service.js +292 -0
- package/src/services/headlessModels.service.js +26 -6
- package/src/services/healthChecks.service.js +650 -0
- package/src/services/healthChecksBootstrap.service.js +109 -0
- package/src/services/healthChecksScheduler.service.js +106 -0
- package/src/services/llmDefaults.service.js +190 -0
- package/src/services/migrationAssets/s3.js +2 -2
- package/src/services/pages.service.js +602 -0
- package/src/services/pagesContext.service.js +331 -0
- package/src/services/pagesContextBlocksAi.service.js +349 -0
- package/src/services/proxy.service.js +535 -0
- package/src/services/rateLimiter.service.js +623 -0
- package/src/services/rbac.service.js +212 -0
- package/src/services/scriptsRunner.service.js +259 -0
- package/src/services/terminals.service.js +152 -0
- package/src/services/terminalsWs.service.js +100 -0
- package/src/services/uiComponentsAi.service.js +299 -0
- package/src/services/uiComponentsCrypto.service.js +39 -0
- package/src/services/workflow.service.js +23 -8
- package/src/utils/orgRoles.js +14 -0
- package/src/utils/rbac/engine.js +60 -0
- package/src/utils/rbac/rightsRegistry.js +29 -0
- package/views/admin-blog-automation.ejs +877 -0
- package/views/admin-blog-edit.ejs +542 -0
- package/views/admin-blog.ejs +399 -0
- package/views/admin-cache.ejs +681 -0
- package/views/admin-console-manager.ejs +680 -0
- package/views/admin-crons.ejs +645 -0
- package/views/admin-db-browser.ejs +445 -0
- package/views/admin-ejs-virtual.ejs +16 -10
- package/views/admin-file-manager.ejs +942 -0
- package/views/admin-headless.ejs +294 -24
- package/views/admin-health-checks.ejs +725 -0
- package/views/admin-i18n.ejs +59 -5
- package/views/admin-llm.ejs +99 -1
- package/views/admin-organizations.ejs +528 -10
- package/views/admin-pages.ejs +2424 -0
- package/views/admin-proxy.ejs +491 -0
- package/views/admin-rate-limiter.ejs +625 -0
- package/views/admin-rbac.ejs +1331 -0
- package/views/admin-scripts.ejs +497 -0
- package/views/admin-seo-config.ejs +61 -7
- package/views/admin-terminals.ejs +328 -0
- package/views/admin-ui-components.ejs +741 -0
- package/views/admin-users.ejs +261 -4
- package/views/admin-workflows.ejs +7 -7
- package/views/file-manager.ejs +866 -0
- package/views/pages/blocks/contact.ejs +27 -0
- package/views/pages/blocks/cta.ejs +18 -0
- package/views/pages/blocks/faq.ejs +20 -0
- package/views/pages/blocks/features.ejs +19 -0
- package/views/pages/blocks/hero.ejs +13 -0
- package/views/pages/blocks/html.ejs +5 -0
- package/views/pages/blocks/image.ejs +14 -0
- package/views/pages/blocks/testimonials.ejs +26 -0
- package/views/pages/blocks/text.ejs +10 -0
- package/views/pages/layouts/default.ejs +51 -0
- package/views/pages/layouts/minimal.ejs +42 -0
- package/views/pages/layouts/sidebar.ejs +54 -0
- package/views/pages/partials/footer.ejs +13 -0
- package/views/pages/partials/header.ejs +12 -0
- package/views/pages/partials/sidebar.ejs +8 -0
- package/views/pages/runtime/page.ejs +10 -0
- package/views/pages/templates/article.ejs +20 -0
- package/views/pages/templates/default.ejs +12 -0
- package/views/pages/templates/landing.ejs +14 -0
- package/views/pages/templates/listing.ejs +15 -0
- package/views/partials/admin-image-upload-modal.ejs +221 -0
- package/views/partials/dashboard/nav-items.ejs +14 -0
- 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, '<')
|
|
11
|
+
.replace(/>/g, '>')
|
|
12
|
+
.replace(/"/g, '"')
|
|
13
|
+
.replace(/'/g, ''');
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
+
}
|