@intranefr/superbackend 1.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.commiat +4 -0
- package/.env.example +47 -0
- package/README.md +110 -0
- package/index.js +94 -0
- package/package.json +67 -0
- package/public/css/styles.css +139 -0
- package/public/js/animations.js +41 -0
- package/sdk/error-tracking/browser/package.json +16 -0
- package/sdk/error-tracking/browser/src/core.js +270 -0
- package/sdk/error-tracking/browser/src/embed.js +18 -0
- package/sdk/error-tracking/browser/src/index.js +1 -0
- package/server.js +5 -0
- package/src/admin/endpointRegistry.js +300 -0
- package/src/controllers/admin.controller.js +321 -0
- package/src/controllers/adminAssets.controller.js +530 -0
- package/src/controllers/adminAssetsStorage.controller.js +260 -0
- package/src/controllers/adminEjsVirtual.controller.js +354 -0
- package/src/controllers/adminFeatureFlags.controller.js +155 -0
- package/src/controllers/adminHeadless.controller.js +1071 -0
- package/src/controllers/adminI18n.controller.js +604 -0
- package/src/controllers/adminJsonConfigs.controller.js +97 -0
- package/src/controllers/adminLlm.controller.js +273 -0
- package/src/controllers/adminMigration.controller.js +257 -0
- package/src/controllers/adminSeoConfig.controller.js +515 -0
- package/src/controllers/adminStats.controller.js +121 -0
- package/src/controllers/adminUploadNamespaces.controller.js +208 -0
- package/src/controllers/assets.controller.js +248 -0
- package/src/controllers/auth.controller.js +93 -0
- package/src/controllers/billing.controller.js +223 -0
- package/src/controllers/featureFlags.controller.js +35 -0
- package/src/controllers/forms.controller.js +217 -0
- package/src/controllers/globalSettings.controller.js +252 -0
- package/src/controllers/headlessCrud.controller.js +126 -0
- package/src/controllers/i18n.controller.js +12 -0
- package/src/controllers/invite.controller.js +249 -0
- package/src/controllers/jsonConfigs.controller.js +19 -0
- package/src/controllers/metrics.controller.js +149 -0
- package/src/controllers/notificationAdmin.controller.js +264 -0
- package/src/controllers/notifications.controller.js +131 -0
- package/src/controllers/org.controller.js +357 -0
- package/src/controllers/orgAdmin.controller.js +491 -0
- package/src/controllers/stripeAdmin.controller.js +410 -0
- package/src/controllers/user.controller.js +361 -0
- package/src/controllers/userAdmin.controller.js +277 -0
- package/src/controllers/waitingList.controller.js +167 -0
- package/src/controllers/webhook.controller.js +200 -0
- package/src/middleware/auth.js +66 -0
- package/src/middleware/errorCapture.js +170 -0
- package/src/middleware/headlessApiTokenAuth.js +57 -0
- package/src/middleware/org.js +108 -0
- package/src/middleware.js +901 -0
- package/src/models/ActionEvent.js +31 -0
- package/src/models/ActivityLog.js +41 -0
- package/src/models/Asset.js +84 -0
- package/src/models/AuditEvent.js +93 -0
- package/src/models/EmailLog.js +28 -0
- package/src/models/ErrorAggregate.js +72 -0
- package/src/models/FormSubmission.js +41 -0
- package/src/models/GlobalSetting.js +38 -0
- package/src/models/HeadlessApiToken.js +24 -0
- package/src/models/HeadlessModelDefinition.js +41 -0
- package/src/models/I18nEntry.js +77 -0
- package/src/models/I18nLocale.js +33 -0
- package/src/models/Invite.js +70 -0
- package/src/models/JsonConfig.js +46 -0
- package/src/models/Notification.js +60 -0
- package/src/models/Organization.js +57 -0
- package/src/models/OrganizationMember.js +43 -0
- package/src/models/StripeCatalogItem.js +77 -0
- package/src/models/StripeWebhookEvent.js +57 -0
- package/src/models/User.js +89 -0
- package/src/models/VirtualEjsFile.js +60 -0
- package/src/models/VirtualEjsFileVersion.js +43 -0
- package/src/models/VirtualEjsGroupChange.js +32 -0
- package/src/models/WaitingList.js +41 -0
- package/src/models/Webhook.js +63 -0
- package/src/models/Workflow.js +29 -0
- package/src/models/WorkflowExecution.js +12 -0
- package/src/routes/admin.routes.js +26 -0
- package/src/routes/adminAssets.routes.js +28 -0
- package/src/routes/adminAssetsStorage.routes.js +13 -0
- package/src/routes/adminAudit.routes.js +196 -0
- package/src/routes/adminEjsVirtual.routes.js +17 -0
- package/src/routes/adminErrors.routes.js +164 -0
- package/src/routes/adminFeatureFlags.routes.js +12 -0
- package/src/routes/adminHeadless.routes.js +38 -0
- package/src/routes/adminI18n.routes.js +22 -0
- package/src/routes/adminJsonConfigs.routes.js +15 -0
- package/src/routes/adminLlm.routes.js +12 -0
- package/src/routes/adminMigration.routes.js +81 -0
- package/src/routes/adminSeoConfig.routes.js +20 -0
- package/src/routes/adminUploadNamespaces.routes.js +13 -0
- package/src/routes/assets.routes.js +21 -0
- package/src/routes/auth.routes.js +12 -0
- package/src/routes/billing.routes.js +11 -0
- package/src/routes/errorTracking.routes.js +31 -0
- package/src/routes/featureFlags.routes.js +9 -0
- package/src/routes/forms.routes.js +9 -0
- package/src/routes/formsAdmin.routes.js +13 -0
- package/src/routes/globalSettings.routes.js +18 -0
- package/src/routes/headless.routes.js +15 -0
- package/src/routes/i18n.routes.js +8 -0
- package/src/routes/invite.routes.js +9 -0
- package/src/routes/jsonConfigs.routes.js +8 -0
- package/src/routes/log.routes.js +111 -0
- package/src/routes/metrics.routes.js +9 -0
- package/src/routes/notificationAdmin.routes.js +15 -0
- package/src/routes/notifications.routes.js +12 -0
- package/src/routes/org.routes.js +31 -0
- package/src/routes/orgAdmin.routes.js +20 -0
- package/src/routes/publicAssets.routes.js +7 -0
- package/src/routes/stripeAdmin.routes.js +20 -0
- package/src/routes/user.routes.js +22 -0
- package/src/routes/userAdmin.routes.js +15 -0
- package/src/routes/waitingList.routes.js +13 -0
- package/src/routes/waitingListAdmin.routes.js +9 -0
- package/src/routes/webhook.routes.js +32 -0
- package/src/routes/workflowWebhook.routes.js +54 -0
- package/src/routes/workflows.routes.js +110 -0
- package/src/services/assets.service.js +110 -0
- package/src/services/audit.service.js +62 -0
- package/src/services/auditLogger.js +165 -0
- package/src/services/ejsVirtual.service.js +614 -0
- package/src/services/email.service.js +351 -0
- package/src/services/errorLogger.js +221 -0
- package/src/services/featureFlags.service.js +202 -0
- package/src/services/forms.service.js +214 -0
- package/src/services/globalSettings.service.js +49 -0
- package/src/services/headlessApiTokens.service.js +158 -0
- package/src/services/headlessCrypto.service.js +31 -0
- package/src/services/headlessModels.service.js +356 -0
- package/src/services/i18n.service.js +314 -0
- package/src/services/i18nInferredKeys.service.js +337 -0
- package/src/services/jsonConfigs.service.js +392 -0
- package/src/services/llm.service.js +749 -0
- package/src/services/migration.service.js +581 -0
- package/src/services/migrationAssets/fsLocal.js +58 -0
- package/src/services/migrationAssets/index.js +134 -0
- package/src/services/migrationAssets/s3.js +75 -0
- package/src/services/migrationAssets/sftp.js +92 -0
- package/src/services/notification.service.js +212 -0
- package/src/services/objectStorage.service.js +514 -0
- package/src/services/seoConfig.service.js +402 -0
- package/src/services/storage.js +150 -0
- package/src/services/stripe.service.js +185 -0
- package/src/services/stripeHelper.service.js +264 -0
- package/src/services/uploadNamespaces.service.js +326 -0
- package/src/services/webhook.service.js +157 -0
- package/src/services/workflow.service.js +271 -0
- package/src/utils/asyncHandler.js +5 -0
- package/src/utils/encryption.js +80 -0
- package/src/utils/jwt.js +40 -0
- package/src/utils/orgRoles.js +156 -0
- package/src/utils/validation.js +26 -0
- package/src/utils/webhookRetry.js +93 -0
- package/views/admin-assets.ejs +444 -0
- package/views/admin-audit.ejs +283 -0
- package/views/admin-coolify-deploy.ejs +207 -0
- package/views/admin-dashboard-home.ejs +291 -0
- package/views/admin-dashboard.ejs +397 -0
- package/views/admin-ejs-virtual.ejs +280 -0
- package/views/admin-errors.ejs +368 -0
- package/views/admin-feature-flags.ejs +390 -0
- package/views/admin-forms.ejs +526 -0
- package/views/admin-global-settings.ejs +436 -0
- package/views/admin-headless.ejs +2020 -0
- package/views/admin-i18n-locales.ejs +221 -0
- package/views/admin-i18n.ejs +728 -0
- package/views/admin-json-configs.ejs +410 -0
- package/views/admin-llm.ejs +884 -0
- package/views/admin-metrics.ejs +274 -0
- package/views/admin-migration.ejs +814 -0
- package/views/admin-notifications.ejs +430 -0
- package/views/admin-organizations.ejs +984 -0
- package/views/admin-seo-config.ejs +673 -0
- package/views/admin-stripe-pricing.ejs +558 -0
- package/views/admin-test.ejs +342 -0
- package/views/admin-users.ejs +452 -0
- package/views/admin-waiting-list.ejs +547 -0
- package/views/admin-webhooks.ejs +329 -0
- package/views/admin-workflows.ejs +310 -0
- package/views/partials/admin-assets-script.ejs +2022 -0
- package/views/partials/admin-test-sidebar.ejs +14 -0
- package/views/partials/dashboard/nav-items.ejs +66 -0
- package/views/partials/dashboard/palette.ejs +63 -0
- package/views/partials/dashboard/sidebar.ejs +21 -0
- package/views/partials/dashboard/tab-bar.ejs +26 -0
- package/views/partials/footer.ejs +3 -0
|
@@ -0,0 +1,515 @@
|
|
|
1
|
+
const OpenAI = require('openai');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
|
|
5
|
+
const {
|
|
6
|
+
getSeoJsonConfig,
|
|
7
|
+
getSeoConfigData,
|
|
8
|
+
updateSeoJsonConfig,
|
|
9
|
+
applySeoPageEntry,
|
|
10
|
+
getOgSvgSettingRaw,
|
|
11
|
+
setOgSvgSettingRaw,
|
|
12
|
+
generateOgPng,
|
|
13
|
+
getSeoconfigOpenRouterApiKey,
|
|
14
|
+
getSeoconfigOpenRouterModel,
|
|
15
|
+
DEFAULT_OG_PNG_OUTPUT_PATH,
|
|
16
|
+
} = require('../services/seoConfig.service');
|
|
17
|
+
|
|
18
|
+
function handleServiceError(res, error) {
|
|
19
|
+
const msg = error?.message || 'Operation failed';
|
|
20
|
+
const code = error?.code;
|
|
21
|
+
|
|
22
|
+
if (code === 'VALIDATION' || code === 'INVALID_JSON') {
|
|
23
|
+
return res.status(400).json({ error: msg });
|
|
24
|
+
}
|
|
25
|
+
if (code === 'NOT_FOUND') {
|
|
26
|
+
return res.status(404).json({ error: msg });
|
|
27
|
+
}
|
|
28
|
+
if (code === 'NO_CONVERTER') {
|
|
29
|
+
return res.status(400).json({ error: msg });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return res.status(500).json({ error: msg });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
exports.get = async (req, res) => {
|
|
36
|
+
try {
|
|
37
|
+
const config = await getSeoJsonConfig();
|
|
38
|
+
const ogSvg = await getOgSvgSettingRaw();
|
|
39
|
+
|
|
40
|
+
return res.json({
|
|
41
|
+
config: {
|
|
42
|
+
id: String(config._id),
|
|
43
|
+
slug: config.slug,
|
|
44
|
+
title: config.title,
|
|
45
|
+
publicEnabled: Boolean(config.publicEnabled),
|
|
46
|
+
cacheTtlSeconds: Number(config.cacheTtlSeconds || 0) || 0,
|
|
47
|
+
jsonRaw: String(config.jsonRaw || ''),
|
|
48
|
+
updatedAt: config.updatedAt,
|
|
49
|
+
},
|
|
50
|
+
og: {
|
|
51
|
+
svgRaw: ogSvg,
|
|
52
|
+
defaultPngOutputPath: DEFAULT_OG_PNG_OUTPUT_PATH,
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
} catch (error) {
|
|
56
|
+
console.error('Error fetching SEO config:', error);
|
|
57
|
+
return handleServiceError(res, error);
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
function validateRoutePathOrThrow(routePath) {
|
|
62
|
+
const route = String(routePath || '').trim();
|
|
63
|
+
if (!route || !route.startsWith('/')) {
|
|
64
|
+
const err = new Error('routePath must start with /');
|
|
65
|
+
err.code = 'VALIDATION';
|
|
66
|
+
throw err;
|
|
67
|
+
}
|
|
68
|
+
return route;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function extractJsonCandidate(text) {
|
|
72
|
+
const raw = String(text || '');
|
|
73
|
+
if (!raw.trim()) return '';
|
|
74
|
+
|
|
75
|
+
const fenced = raw.match(/```(?:json)?\s*([\s\S]*?)\s*```/i);
|
|
76
|
+
if (fenced && fenced[1]) {
|
|
77
|
+
return String(fenced[1]).trim();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const start = raw.indexOf('{');
|
|
81
|
+
if (start === -1) return raw.trim();
|
|
82
|
+
|
|
83
|
+
let depth = 0;
|
|
84
|
+
let inString = false;
|
|
85
|
+
let escape = false;
|
|
86
|
+
|
|
87
|
+
for (let i = start; i < raw.length; i += 1) {
|
|
88
|
+
const ch = raw[i];
|
|
89
|
+
|
|
90
|
+
if (escape) {
|
|
91
|
+
escape = false;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (inString) {
|
|
96
|
+
if (ch === '\\') {
|
|
97
|
+
escape = true;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (ch === '"') {
|
|
101
|
+
inString = false;
|
|
102
|
+
}
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (ch === '"') {
|
|
107
|
+
inString = true;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (ch === '{') {
|
|
112
|
+
depth += 1;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (ch === '}') {
|
|
116
|
+
depth -= 1;
|
|
117
|
+
if (depth === 0) {
|
|
118
|
+
return raw.slice(start, i + 1).trim();
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return raw.slice(start).trim();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function parseAiJsonObjectOrThrow(raw) {
|
|
127
|
+
const text = extractJsonCandidate(raw);
|
|
128
|
+
if (!text) {
|
|
129
|
+
const err = new Error('AI returned empty response');
|
|
130
|
+
err.code = 'AI_INVALID';
|
|
131
|
+
throw err;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
let obj;
|
|
135
|
+
try {
|
|
136
|
+
obj = JSON.parse(text);
|
|
137
|
+
} catch (e) {
|
|
138
|
+
const err = new Error('AI returned invalid JSON');
|
|
139
|
+
err.code = 'AI_INVALID';
|
|
140
|
+
throw err;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) {
|
|
144
|
+
const err = new Error('AI returned invalid entry object');
|
|
145
|
+
err.code = 'AI_INVALID';
|
|
146
|
+
throw err;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const title = String(obj.title || '').trim();
|
|
150
|
+
const description = String(obj.description || '').trim();
|
|
151
|
+
const robots = obj.robots !== undefined && obj.robots !== null ? String(obj.robots).trim() : undefined;
|
|
152
|
+
|
|
153
|
+
if (!title) {
|
|
154
|
+
const err = new Error('AI returned missing title');
|
|
155
|
+
err.code = 'AI_INVALID';
|
|
156
|
+
throw err;
|
|
157
|
+
}
|
|
158
|
+
if (!description) {
|
|
159
|
+
const err = new Error('AI returned missing description');
|
|
160
|
+
err.code = 'AI_INVALID';
|
|
161
|
+
throw err;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const entry = { title, description };
|
|
165
|
+
if (robots) entry.robots = robots;
|
|
166
|
+
|
|
167
|
+
return entry;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function listEjsFilesRecursive(rootDir, relDir = '') {
|
|
171
|
+
const abs = path.join(rootDir, relDir);
|
|
172
|
+
const items = await fs.promises.readdir(abs, { withFileTypes: true });
|
|
173
|
+
const results = [];
|
|
174
|
+
|
|
175
|
+
for (const item of items) {
|
|
176
|
+
const name = item.name;
|
|
177
|
+
if (name.startsWith('.')) continue;
|
|
178
|
+
if (name === 'node_modules') continue;
|
|
179
|
+
|
|
180
|
+
const nextRel = path.join(relDir, name);
|
|
181
|
+
const nextAbs = path.join(rootDir, nextRel);
|
|
182
|
+
|
|
183
|
+
if (item.isDirectory()) {
|
|
184
|
+
const nested = await listEjsFilesRecursive(rootDir, nextRel);
|
|
185
|
+
results.push(...nested);
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (item.isFile() && name.endsWith('.ejs')) {
|
|
190
|
+
results.push(nextRel.replace(/\\/g, '/'));
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return results;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
exports.seoConfigAiListViews = async (req, res) => {
|
|
198
|
+
try {
|
|
199
|
+
const viewsRoot = path.resolve(process.cwd(), 'views');
|
|
200
|
+
const views = await listEjsFilesRecursive(viewsRoot);
|
|
201
|
+
views.sort();
|
|
202
|
+
return res.json({ views });
|
|
203
|
+
} catch (error) {
|
|
204
|
+
console.error('Error listing EJS views:', error);
|
|
205
|
+
return res.status(500).json({ error: 'Failed to list views' });
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
function buildSeoEntryPromptFromEjs({ routePath, viewRelPath, ejsSource, siteName, baseUrl }) {
|
|
210
|
+
return [
|
|
211
|
+
'You are generating SEO metadata for a website page.',
|
|
212
|
+
'Return ONLY valid JSON (no markdown).',
|
|
213
|
+
'The JSON must be an object with keys:',
|
|
214
|
+
'- title (string)',
|
|
215
|
+
'- description (string)',
|
|
216
|
+
'- robots (string, optional)',
|
|
217
|
+
'Keep descriptions concise and marketing-friendly.',
|
|
218
|
+
'',
|
|
219
|
+
`Site name: ${String(siteName || '').trim()}`,
|
|
220
|
+
`Base URL: ${String(baseUrl || '').trim()}`,
|
|
221
|
+
`Route path: ${String(routePath || '').trim()}`,
|
|
222
|
+
`View file: ${String(viewRelPath || '').trim()}`,
|
|
223
|
+
'',
|
|
224
|
+
'EJS source:',
|
|
225
|
+
String(ejsSource || ''),
|
|
226
|
+
].join('\n');
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
exports.seoConfigAiGenerateEntry = async (req, res) => {
|
|
230
|
+
try {
|
|
231
|
+
const viewPath = String(req.body?.viewPath || '').trim();
|
|
232
|
+
const routePath = validateRoutePathOrThrow(req.body?.routePath);
|
|
233
|
+
const modelOverride = req.body?.model;
|
|
234
|
+
|
|
235
|
+
if (!viewPath || !viewPath.endsWith('.ejs')) {
|
|
236
|
+
return res.status(400).json({ error: 'viewPath is required and must end with .ejs' });
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const viewsRoot = path.resolve(process.cwd(), 'views');
|
|
240
|
+
const abs = path.resolve(viewsRoot, viewPath);
|
|
241
|
+
if (!abs.startsWith(viewsRoot + path.sep) && abs !== viewsRoot) {
|
|
242
|
+
return res.status(400).json({ error: 'Invalid viewPath' });
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const stat = await fs.promises.stat(abs);
|
|
246
|
+
if (!stat.isFile()) {
|
|
247
|
+
return res.status(400).json({ error: 'viewPath must be a file' });
|
|
248
|
+
}
|
|
249
|
+
if (stat.size > 200_000) {
|
|
250
|
+
return res.status(400).json({ error: 'view file is too large' });
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const apiKey = await getSeoconfigOpenRouterApiKey();
|
|
254
|
+
if (!apiKey) {
|
|
255
|
+
return res.status(400).json({ error: 'AI is disabled (missing OpenRouter API key)' });
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const model = modelOverride || (await getSeoconfigOpenRouterModel());
|
|
259
|
+
|
|
260
|
+
const { data } = await getSeoConfigData();
|
|
261
|
+
const siteName = data?.siteName || '';
|
|
262
|
+
const baseUrl = data?.baseUrl || '';
|
|
263
|
+
|
|
264
|
+
const ejsSource = await fs.promises.readFile(abs, 'utf8');
|
|
265
|
+
|
|
266
|
+
const client = new OpenAI({
|
|
267
|
+
apiKey,
|
|
268
|
+
baseURL: 'https://openrouter.ai/api/v1',
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
const prompt = buildSeoEntryPromptFromEjs({
|
|
272
|
+
routePath,
|
|
273
|
+
viewRelPath: viewPath,
|
|
274
|
+
ejsSource,
|
|
275
|
+
siteName,
|
|
276
|
+
baseUrl,
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
const resp = await client.chat.completions.create({
|
|
280
|
+
model,
|
|
281
|
+
messages: [{ role: 'user', content: prompt }],
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
const out = resp.choices?.[0]?.message?.content || '';
|
|
285
|
+
const entry = parseAiJsonObjectOrThrow(out);
|
|
286
|
+
|
|
287
|
+
return res.json({ routePath, entry, model });
|
|
288
|
+
} catch (error) {
|
|
289
|
+
const code = error?.code;
|
|
290
|
+
if (code === 'VALIDATION') {
|
|
291
|
+
return res.status(400).json({ error: error.message });
|
|
292
|
+
}
|
|
293
|
+
if (code === 'AI_INVALID') {
|
|
294
|
+
return res.status(500).json({ error: error.message });
|
|
295
|
+
}
|
|
296
|
+
console.error('Error generating SEO entry with AI:', error);
|
|
297
|
+
return res.status(500).json({ error: error?.message || 'Failed to generate entry' });
|
|
298
|
+
}
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
function buildSeoEntryPromptImprove({ routePath, existingEntry, instruction, siteName, baseUrl }) {
|
|
302
|
+
return [
|
|
303
|
+
'You are improving SEO metadata for a website page.',
|
|
304
|
+
'Return ONLY valid JSON (no markdown).',
|
|
305
|
+
'The JSON must be an object with keys:',
|
|
306
|
+
'- title (string)',
|
|
307
|
+
'- description (string)',
|
|
308
|
+
'- robots (string, optional)',
|
|
309
|
+
'Keep it consistent with the existing entry unless the instruction changes it.',
|
|
310
|
+
'',
|
|
311
|
+
`Site name: ${String(siteName || '').trim()}`,
|
|
312
|
+
`Base URL: ${String(baseUrl || '').trim()}`,
|
|
313
|
+
`Route path: ${String(routePath || '').trim()}`,
|
|
314
|
+
`Instruction: ${String(instruction || '').trim()}`,
|
|
315
|
+
'',
|
|
316
|
+
'Existing entry JSON:',
|
|
317
|
+
JSON.stringify(existingEntry || {}, null, 2),
|
|
318
|
+
].join('\n');
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
exports.seoConfigAiImproveEntry = async (req, res) => {
|
|
322
|
+
try {
|
|
323
|
+
const routePath = validateRoutePathOrThrow(req.body?.routePath);
|
|
324
|
+
const instruction = String(req.body?.instruction || '').trim();
|
|
325
|
+
const modelOverride = req.body?.model;
|
|
326
|
+
|
|
327
|
+
if (!instruction) {
|
|
328
|
+
return res.status(400).json({ error: 'instruction is required' });
|
|
329
|
+
}
|
|
330
|
+
if (instruction.length > 4_000) {
|
|
331
|
+
return res.status(400).json({ error: 'instruction is too large' });
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const apiKey = await getSeoconfigOpenRouterApiKey();
|
|
335
|
+
if (!apiKey) {
|
|
336
|
+
return res.status(400).json({ error: 'AI is disabled (missing OpenRouter API key)' });
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const model = modelOverride || (await getSeoconfigOpenRouterModel());
|
|
340
|
+
|
|
341
|
+
const { data } = await getSeoConfigData();
|
|
342
|
+
const siteName = data?.siteName || '';
|
|
343
|
+
const baseUrl = data?.baseUrl || '';
|
|
344
|
+
const existingEntry = data?.pages?.[routePath] || null;
|
|
345
|
+
if (!existingEntry) {
|
|
346
|
+
return res.status(404).json({ error: `No existing entry for ${routePath}` });
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const client = new OpenAI({
|
|
350
|
+
apiKey,
|
|
351
|
+
baseURL: 'https://openrouter.ai/api/v1',
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
const prompt = buildSeoEntryPromptImprove({
|
|
355
|
+
routePath,
|
|
356
|
+
existingEntry,
|
|
357
|
+
instruction,
|
|
358
|
+
siteName,
|
|
359
|
+
baseUrl,
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
const resp = await client.chat.completions.create({
|
|
363
|
+
model,
|
|
364
|
+
messages: [{ role: 'user', content: prompt }],
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
const out = resp.choices?.[0]?.message?.content || '';
|
|
368
|
+
const entry = parseAiJsonObjectOrThrow(out);
|
|
369
|
+
|
|
370
|
+
return res.json({ routePath, entry, model });
|
|
371
|
+
} catch (error) {
|
|
372
|
+
const code = error?.code;
|
|
373
|
+
if (code === 'VALIDATION') {
|
|
374
|
+
return res.status(400).json({ error: error.message });
|
|
375
|
+
}
|
|
376
|
+
if (code === 'AI_INVALID') {
|
|
377
|
+
return res.status(500).json({ error: error.message });
|
|
378
|
+
}
|
|
379
|
+
console.error('Error improving SEO entry with AI:', error);
|
|
380
|
+
return res.status(500).json({ error: error?.message || 'Failed to improve entry' });
|
|
381
|
+
}
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
exports.seoConfigApplyEntry = async (req, res) => {
|
|
385
|
+
try {
|
|
386
|
+
const routePath = validateRoutePathOrThrow(req.body?.routePath);
|
|
387
|
+
const entry = req.body?.entry;
|
|
388
|
+
|
|
389
|
+
const result = await applySeoPageEntry({ routePath, entry });
|
|
390
|
+
return res.json({ result });
|
|
391
|
+
} catch (error) {
|
|
392
|
+
console.error('Error applying SEO entry:', error);
|
|
393
|
+
return handleServiceError(res, error);
|
|
394
|
+
}
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
exports.update = async (req, res) => {
|
|
398
|
+
try {
|
|
399
|
+
const patch = req.body || {};
|
|
400
|
+
const updated = await updateSeoJsonConfig(patch);
|
|
401
|
+
|
|
402
|
+
return res.json({
|
|
403
|
+
config: {
|
|
404
|
+
id: String(updated._id),
|
|
405
|
+
slug: updated.slug,
|
|
406
|
+
title: updated.title,
|
|
407
|
+
publicEnabled: Boolean(updated.publicEnabled),
|
|
408
|
+
cacheTtlSeconds: Number(updated.cacheTtlSeconds || 0) || 0,
|
|
409
|
+
jsonRaw: String(updated.jsonRaw || ''),
|
|
410
|
+
updatedAt: updated.updatedAt,
|
|
411
|
+
},
|
|
412
|
+
});
|
|
413
|
+
} catch (error) {
|
|
414
|
+
console.error('Error updating SEO config:', error);
|
|
415
|
+
return handleServiceError(res, error);
|
|
416
|
+
}
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
exports.updateOgSvg = async (req, res) => {
|
|
420
|
+
try {
|
|
421
|
+
const svgRaw = req.body?.svgRaw;
|
|
422
|
+
if (svgRaw === undefined || svgRaw === null) {
|
|
423
|
+
return res.status(400).json({ error: 'svgRaw is required' });
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
await setOgSvgSettingRaw(svgRaw);
|
|
427
|
+
return res.json({ success: true });
|
|
428
|
+
} catch (error) {
|
|
429
|
+
console.error('Error updating OG SVG:', error);
|
|
430
|
+
return handleServiceError(res, error);
|
|
431
|
+
}
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
exports.generateOgPng = async (req, res) => {
|
|
435
|
+
try {
|
|
436
|
+
const svgRaw = req.body?.svgRaw;
|
|
437
|
+
const outputPath = req.body?.outputPath || DEFAULT_OG_PNG_OUTPUT_PATH;
|
|
438
|
+
const width = req.body?.width;
|
|
439
|
+
const height = req.body?.height;
|
|
440
|
+
|
|
441
|
+
if (svgRaw === undefined || svgRaw === null) {
|
|
442
|
+
return res.status(400).json({ error: 'svgRaw is required' });
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const result = await generateOgPng({ svgRaw, outputPath, width, height });
|
|
446
|
+
return res.json({ result });
|
|
447
|
+
} catch (error) {
|
|
448
|
+
console.error('Error generating OG PNG:', error);
|
|
449
|
+
return handleServiceError(res, error);
|
|
450
|
+
}
|
|
451
|
+
};
|
|
452
|
+
|
|
453
|
+
function buildSvgAiPrompt({ svg, instruction }) {
|
|
454
|
+
return [
|
|
455
|
+
'You are editing an SVG used to generate an OpenGraph PNG image.',
|
|
456
|
+
'Return only valid SVG markup (start with <svg ...> and end with </svg>).',
|
|
457
|
+
'Do not include scripts. Do not include markdown fences. Do not add explanations.',
|
|
458
|
+
'Keep the design compatible with 1200x630 output.',
|
|
459
|
+
'',
|
|
460
|
+
`Instruction: ${String(instruction || '').trim()}`,
|
|
461
|
+
'',
|
|
462
|
+
'Current SVG:',
|
|
463
|
+
String(svg || ''),
|
|
464
|
+
].join('\n');
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
exports.aiEditSvg = async (req, res) => {
|
|
468
|
+
try {
|
|
469
|
+
const svgRaw = req.body?.svgRaw;
|
|
470
|
+
const instruction = req.body?.instruction;
|
|
471
|
+
const modelOverride = req.body?.model;
|
|
472
|
+
|
|
473
|
+
if (typeof svgRaw !== 'string' || svgRaw.trim() === '') {
|
|
474
|
+
return res.status(400).json({ error: 'svgRaw is required' });
|
|
475
|
+
}
|
|
476
|
+
if (typeof instruction !== 'string' || instruction.trim() === '') {
|
|
477
|
+
return res.status(400).json({ error: 'instruction is required' });
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
if (svgRaw.length > 200_000) {
|
|
481
|
+
return res.status(400).json({ error: 'svgRaw is too large' });
|
|
482
|
+
}
|
|
483
|
+
if (instruction.length > 4_000) {
|
|
484
|
+
return res.status(400).json({ error: 'instruction is too large' });
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const apiKey = await getSeoconfigOpenRouterApiKey();
|
|
488
|
+
if (!apiKey) {
|
|
489
|
+
return res.status(400).json({ error: 'AI is disabled (missing OpenRouter API key)' });
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const model = modelOverride || (await getSeoconfigOpenRouterModel());
|
|
493
|
+
|
|
494
|
+
const client = new OpenAI({
|
|
495
|
+
apiKey,
|
|
496
|
+
baseURL: 'https://openrouter.ai/api/v1',
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
const prompt = buildSvgAiPrompt({ svg: svgRaw, instruction });
|
|
500
|
+
const resp = await client.chat.completions.create({
|
|
501
|
+
model,
|
|
502
|
+
messages: [{ role: 'user', content: prompt }],
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
const out = resp.choices?.[0]?.message?.content?.trim() || '';
|
|
506
|
+
if (!out.startsWith('<svg') || !out.includes('</svg>')) {
|
|
507
|
+
return res.status(500).json({ error: 'AI returned invalid SVG' });
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
return res.json({ svgRaw: out, model });
|
|
511
|
+
} catch (error) {
|
|
512
|
+
console.error('Error editing SVG with AI:', error);
|
|
513
|
+
return res.status(500).json({ error: error?.message || 'Failed to edit SVG' });
|
|
514
|
+
}
|
|
515
|
+
};
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
const User = require('../models/User');
|
|
2
|
+
const Organization = require('../models/Organization');
|
|
3
|
+
const AuditEvent = require('../models/AuditEvent');
|
|
4
|
+
const ErrorAggregate = require('../models/ErrorAggregate');
|
|
5
|
+
const Asset = require('../models/Asset');
|
|
6
|
+
const FormSubmission = require('../models/FormSubmission');
|
|
7
|
+
const WaitingList = require('../models/WaitingList');
|
|
8
|
+
const EmailLog = require('../models/EmailLog');
|
|
9
|
+
const VirtualEjsFile = require('../models/VirtualEjsFile');
|
|
10
|
+
const JsonConfig = require('../models/JsonConfig');
|
|
11
|
+
const StripeCatalogItem = require('../models/StripeCatalogItem');
|
|
12
|
+
const Workflow = require('../models/Workflow');
|
|
13
|
+
|
|
14
|
+
exports.getOverviewStats = async (req, res) => {
|
|
15
|
+
try {
|
|
16
|
+
const now = new Date();
|
|
17
|
+
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
18
|
+
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
|
19
|
+
|
|
20
|
+
// 1. Data Aggregation by Category
|
|
21
|
+
const [
|
|
22
|
+
// User Management
|
|
23
|
+
totalUsers,
|
|
24
|
+
newUsersToday,
|
|
25
|
+
totalOrgs,
|
|
26
|
+
totalInvites,
|
|
27
|
+
// Monitoring
|
|
28
|
+
unresolvedErrors,
|
|
29
|
+
audit24h,
|
|
30
|
+
emailsSent,
|
|
31
|
+
emailsFailed,
|
|
32
|
+
// Content
|
|
33
|
+
totalAssets,
|
|
34
|
+
totalVirtualEjs,
|
|
35
|
+
totalJsonConfigs,
|
|
36
|
+
// SaaS & Billing
|
|
37
|
+
totalForms,
|
|
38
|
+
totalWaiting,
|
|
39
|
+
totalPlans,
|
|
40
|
+
totalWorkflows
|
|
41
|
+
] = await Promise.all([
|
|
42
|
+
User.countDocuments(),
|
|
43
|
+
User.countDocuments({ createdAt: { $gte: startOfToday } }),
|
|
44
|
+
Organization.countDocuments(),
|
|
45
|
+
require('../models/Invite').countDocuments({ status: 'pending' }),
|
|
46
|
+
ErrorAggregate.countDocuments({ resolved: { $ne: true } }),
|
|
47
|
+
AuditEvent.countDocuments({ createdAt: { $gte: new Date(now.getTime() - 24 * 60 * 60 * 1000) } }),
|
|
48
|
+
EmailLog.countDocuments({ status: 'sent' }),
|
|
49
|
+
EmailLog.countDocuments({ status: 'error' }),
|
|
50
|
+
Asset.countDocuments(),
|
|
51
|
+
VirtualEjsFile.countDocuments(),
|
|
52
|
+
JsonConfig.countDocuments(),
|
|
53
|
+
FormSubmission.countDocuments(),
|
|
54
|
+
WaitingList.countDocuments(),
|
|
55
|
+
StripeCatalogItem.countDocuments({ active: true }),
|
|
56
|
+
Workflow.countDocuments()
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
// 2. Recent Activity (Last 10 Audit Events)
|
|
60
|
+
const recentActivity = await AuditEvent.find()
|
|
61
|
+
.sort({ createdAt: -1 })
|
|
62
|
+
.limit(10)
|
|
63
|
+
.lean();
|
|
64
|
+
|
|
65
|
+
// 3. Time-Series Data (Last 7 Days)
|
|
66
|
+
const timeSeries = [];
|
|
67
|
+
for (let i = 6; i >= 0; i--) {
|
|
68
|
+
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - i, 0, 0, 0);
|
|
69
|
+
const end = new Date(now.getFullYear(), now.getMonth(), now.getDate() - i, 23, 59, 59);
|
|
70
|
+
|
|
71
|
+
const [dayUsers, dayActivity, dayErrors, dayEmails] = await Promise.all([
|
|
72
|
+
User.countDocuments({ createdAt: { $gte: start, $lte: end } }),
|
|
73
|
+
AuditEvent.countDocuments({ createdAt: { $gte: start, $lte: end } }),
|
|
74
|
+
ErrorAggregate.countDocuments({ createdAt: { $gte: start, $lte: end } }),
|
|
75
|
+
EmailLog.countDocuments({ createdAt: { $gte: start, $lte: end }, status: 'sent' })
|
|
76
|
+
]);
|
|
77
|
+
|
|
78
|
+
timeSeries.push({
|
|
79
|
+
date: start.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }),
|
|
80
|
+
users: dayUsers,
|
|
81
|
+
activity: dayActivity,
|
|
82
|
+
errors: dayErrors,
|
|
83
|
+
emails: dayEmails
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return res.json({
|
|
88
|
+
categories: {
|
|
89
|
+
users: {
|
|
90
|
+
total: totalUsers,
|
|
91
|
+
newToday: newUsersToday,
|
|
92
|
+
orgs: totalOrgs,
|
|
93
|
+
invites: totalInvites
|
|
94
|
+
},
|
|
95
|
+
monitoring: {
|
|
96
|
+
errors: unresolvedErrors,
|
|
97
|
+
audit24h: audit24h,
|
|
98
|
+
emailsSent,
|
|
99
|
+
emailsFailed,
|
|
100
|
+
health: unresolvedErrors > 10 ? 'critical' : unresolvedErrors > 0 ? 'warning' : 'healthy'
|
|
101
|
+
},
|
|
102
|
+
content: {
|
|
103
|
+
assets: totalAssets,
|
|
104
|
+
virtualEjs: totalVirtualEjs,
|
|
105
|
+
jsonConfigs: totalJsonConfigs
|
|
106
|
+
},
|
|
107
|
+
saas: {
|
|
108
|
+
forms: totalForms,
|
|
109
|
+
waiting: totalWaiting,
|
|
110
|
+
plans: totalPlans,
|
|
111
|
+
workflows: totalWorkflows
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
recentActivity,
|
|
115
|
+
timeSeries
|
|
116
|
+
});
|
|
117
|
+
} catch (error) {
|
|
118
|
+
console.error('Overview stats error:', error);
|
|
119
|
+
return res.status(500).json({ error: 'Failed to fetch overview stats' });
|
|
120
|
+
}
|
|
121
|
+
};
|