@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.
Files changed (188) hide show
  1. package/.commiat +4 -0
  2. package/.env.example +47 -0
  3. package/README.md +110 -0
  4. package/index.js +94 -0
  5. package/package.json +67 -0
  6. package/public/css/styles.css +139 -0
  7. package/public/js/animations.js +41 -0
  8. package/sdk/error-tracking/browser/package.json +16 -0
  9. package/sdk/error-tracking/browser/src/core.js +270 -0
  10. package/sdk/error-tracking/browser/src/embed.js +18 -0
  11. package/sdk/error-tracking/browser/src/index.js +1 -0
  12. package/server.js +5 -0
  13. package/src/admin/endpointRegistry.js +300 -0
  14. package/src/controllers/admin.controller.js +321 -0
  15. package/src/controllers/adminAssets.controller.js +530 -0
  16. package/src/controllers/adminAssetsStorage.controller.js +260 -0
  17. package/src/controllers/adminEjsVirtual.controller.js +354 -0
  18. package/src/controllers/adminFeatureFlags.controller.js +155 -0
  19. package/src/controllers/adminHeadless.controller.js +1071 -0
  20. package/src/controllers/adminI18n.controller.js +604 -0
  21. package/src/controllers/adminJsonConfigs.controller.js +97 -0
  22. package/src/controllers/adminLlm.controller.js +273 -0
  23. package/src/controllers/adminMigration.controller.js +257 -0
  24. package/src/controllers/adminSeoConfig.controller.js +515 -0
  25. package/src/controllers/adminStats.controller.js +121 -0
  26. package/src/controllers/adminUploadNamespaces.controller.js +208 -0
  27. package/src/controllers/assets.controller.js +248 -0
  28. package/src/controllers/auth.controller.js +93 -0
  29. package/src/controllers/billing.controller.js +223 -0
  30. package/src/controllers/featureFlags.controller.js +35 -0
  31. package/src/controllers/forms.controller.js +217 -0
  32. package/src/controllers/globalSettings.controller.js +252 -0
  33. package/src/controllers/headlessCrud.controller.js +126 -0
  34. package/src/controllers/i18n.controller.js +12 -0
  35. package/src/controllers/invite.controller.js +249 -0
  36. package/src/controllers/jsonConfigs.controller.js +19 -0
  37. package/src/controllers/metrics.controller.js +149 -0
  38. package/src/controllers/notificationAdmin.controller.js +264 -0
  39. package/src/controllers/notifications.controller.js +131 -0
  40. package/src/controllers/org.controller.js +357 -0
  41. package/src/controllers/orgAdmin.controller.js +491 -0
  42. package/src/controllers/stripeAdmin.controller.js +410 -0
  43. package/src/controllers/user.controller.js +361 -0
  44. package/src/controllers/userAdmin.controller.js +277 -0
  45. package/src/controllers/waitingList.controller.js +167 -0
  46. package/src/controllers/webhook.controller.js +200 -0
  47. package/src/middleware/auth.js +66 -0
  48. package/src/middleware/errorCapture.js +170 -0
  49. package/src/middleware/headlessApiTokenAuth.js +57 -0
  50. package/src/middleware/org.js +108 -0
  51. package/src/middleware.js +901 -0
  52. package/src/models/ActionEvent.js +31 -0
  53. package/src/models/ActivityLog.js +41 -0
  54. package/src/models/Asset.js +84 -0
  55. package/src/models/AuditEvent.js +93 -0
  56. package/src/models/EmailLog.js +28 -0
  57. package/src/models/ErrorAggregate.js +72 -0
  58. package/src/models/FormSubmission.js +41 -0
  59. package/src/models/GlobalSetting.js +38 -0
  60. package/src/models/HeadlessApiToken.js +24 -0
  61. package/src/models/HeadlessModelDefinition.js +41 -0
  62. package/src/models/I18nEntry.js +77 -0
  63. package/src/models/I18nLocale.js +33 -0
  64. package/src/models/Invite.js +70 -0
  65. package/src/models/JsonConfig.js +46 -0
  66. package/src/models/Notification.js +60 -0
  67. package/src/models/Organization.js +57 -0
  68. package/src/models/OrganizationMember.js +43 -0
  69. package/src/models/StripeCatalogItem.js +77 -0
  70. package/src/models/StripeWebhookEvent.js +57 -0
  71. package/src/models/User.js +89 -0
  72. package/src/models/VirtualEjsFile.js +60 -0
  73. package/src/models/VirtualEjsFileVersion.js +43 -0
  74. package/src/models/VirtualEjsGroupChange.js +32 -0
  75. package/src/models/WaitingList.js +41 -0
  76. package/src/models/Webhook.js +63 -0
  77. package/src/models/Workflow.js +29 -0
  78. package/src/models/WorkflowExecution.js +12 -0
  79. package/src/routes/admin.routes.js +26 -0
  80. package/src/routes/adminAssets.routes.js +28 -0
  81. package/src/routes/adminAssetsStorage.routes.js +13 -0
  82. package/src/routes/adminAudit.routes.js +196 -0
  83. package/src/routes/adminEjsVirtual.routes.js +17 -0
  84. package/src/routes/adminErrors.routes.js +164 -0
  85. package/src/routes/adminFeatureFlags.routes.js +12 -0
  86. package/src/routes/adminHeadless.routes.js +38 -0
  87. package/src/routes/adminI18n.routes.js +22 -0
  88. package/src/routes/adminJsonConfigs.routes.js +15 -0
  89. package/src/routes/adminLlm.routes.js +12 -0
  90. package/src/routes/adminMigration.routes.js +81 -0
  91. package/src/routes/adminSeoConfig.routes.js +20 -0
  92. package/src/routes/adminUploadNamespaces.routes.js +13 -0
  93. package/src/routes/assets.routes.js +21 -0
  94. package/src/routes/auth.routes.js +12 -0
  95. package/src/routes/billing.routes.js +11 -0
  96. package/src/routes/errorTracking.routes.js +31 -0
  97. package/src/routes/featureFlags.routes.js +9 -0
  98. package/src/routes/forms.routes.js +9 -0
  99. package/src/routes/formsAdmin.routes.js +13 -0
  100. package/src/routes/globalSettings.routes.js +18 -0
  101. package/src/routes/headless.routes.js +15 -0
  102. package/src/routes/i18n.routes.js +8 -0
  103. package/src/routes/invite.routes.js +9 -0
  104. package/src/routes/jsonConfigs.routes.js +8 -0
  105. package/src/routes/log.routes.js +111 -0
  106. package/src/routes/metrics.routes.js +9 -0
  107. package/src/routes/notificationAdmin.routes.js +15 -0
  108. package/src/routes/notifications.routes.js +12 -0
  109. package/src/routes/org.routes.js +31 -0
  110. package/src/routes/orgAdmin.routes.js +20 -0
  111. package/src/routes/publicAssets.routes.js +7 -0
  112. package/src/routes/stripeAdmin.routes.js +20 -0
  113. package/src/routes/user.routes.js +22 -0
  114. package/src/routes/userAdmin.routes.js +15 -0
  115. package/src/routes/waitingList.routes.js +13 -0
  116. package/src/routes/waitingListAdmin.routes.js +9 -0
  117. package/src/routes/webhook.routes.js +32 -0
  118. package/src/routes/workflowWebhook.routes.js +54 -0
  119. package/src/routes/workflows.routes.js +110 -0
  120. package/src/services/assets.service.js +110 -0
  121. package/src/services/audit.service.js +62 -0
  122. package/src/services/auditLogger.js +165 -0
  123. package/src/services/ejsVirtual.service.js +614 -0
  124. package/src/services/email.service.js +351 -0
  125. package/src/services/errorLogger.js +221 -0
  126. package/src/services/featureFlags.service.js +202 -0
  127. package/src/services/forms.service.js +214 -0
  128. package/src/services/globalSettings.service.js +49 -0
  129. package/src/services/headlessApiTokens.service.js +158 -0
  130. package/src/services/headlessCrypto.service.js +31 -0
  131. package/src/services/headlessModels.service.js +356 -0
  132. package/src/services/i18n.service.js +314 -0
  133. package/src/services/i18nInferredKeys.service.js +337 -0
  134. package/src/services/jsonConfigs.service.js +392 -0
  135. package/src/services/llm.service.js +749 -0
  136. package/src/services/migration.service.js +581 -0
  137. package/src/services/migrationAssets/fsLocal.js +58 -0
  138. package/src/services/migrationAssets/index.js +134 -0
  139. package/src/services/migrationAssets/s3.js +75 -0
  140. package/src/services/migrationAssets/sftp.js +92 -0
  141. package/src/services/notification.service.js +212 -0
  142. package/src/services/objectStorage.service.js +514 -0
  143. package/src/services/seoConfig.service.js +402 -0
  144. package/src/services/storage.js +150 -0
  145. package/src/services/stripe.service.js +185 -0
  146. package/src/services/stripeHelper.service.js +264 -0
  147. package/src/services/uploadNamespaces.service.js +326 -0
  148. package/src/services/webhook.service.js +157 -0
  149. package/src/services/workflow.service.js +271 -0
  150. package/src/utils/asyncHandler.js +5 -0
  151. package/src/utils/encryption.js +80 -0
  152. package/src/utils/jwt.js +40 -0
  153. package/src/utils/orgRoles.js +156 -0
  154. package/src/utils/validation.js +26 -0
  155. package/src/utils/webhookRetry.js +93 -0
  156. package/views/admin-assets.ejs +444 -0
  157. package/views/admin-audit.ejs +283 -0
  158. package/views/admin-coolify-deploy.ejs +207 -0
  159. package/views/admin-dashboard-home.ejs +291 -0
  160. package/views/admin-dashboard.ejs +397 -0
  161. package/views/admin-ejs-virtual.ejs +280 -0
  162. package/views/admin-errors.ejs +368 -0
  163. package/views/admin-feature-flags.ejs +390 -0
  164. package/views/admin-forms.ejs +526 -0
  165. package/views/admin-global-settings.ejs +436 -0
  166. package/views/admin-headless.ejs +2020 -0
  167. package/views/admin-i18n-locales.ejs +221 -0
  168. package/views/admin-i18n.ejs +728 -0
  169. package/views/admin-json-configs.ejs +410 -0
  170. package/views/admin-llm.ejs +884 -0
  171. package/views/admin-metrics.ejs +274 -0
  172. package/views/admin-migration.ejs +814 -0
  173. package/views/admin-notifications.ejs +430 -0
  174. package/views/admin-organizations.ejs +984 -0
  175. package/views/admin-seo-config.ejs +673 -0
  176. package/views/admin-stripe-pricing.ejs +558 -0
  177. package/views/admin-test.ejs +342 -0
  178. package/views/admin-users.ejs +452 -0
  179. package/views/admin-waiting-list.ejs +547 -0
  180. package/views/admin-webhooks.ejs +329 -0
  181. package/views/admin-workflows.ejs +310 -0
  182. package/views/partials/admin-assets-script.ejs +2022 -0
  183. package/views/partials/admin-test-sidebar.ejs +14 -0
  184. package/views/partials/dashboard/nav-items.ejs +66 -0
  185. package/views/partials/dashboard/palette.ejs +63 -0
  186. package/views/partials/dashboard/sidebar.ejs +21 -0
  187. package/views/partials/dashboard/tab-bar.ejs +26 -0
  188. package/views/partials/footer.ejs +3 -0
@@ -0,0 +1,260 @@
1
+ const GlobalSetting = require('../models/GlobalSetting');
2
+ const Asset = require('../models/Asset');
3
+ const { encryptString } = require('../utils/encryption');
4
+ const globalSettingsService = require('../services/globalSettings.service');
5
+ const objectStorage = require('../services/objectStorage.service');
6
+
7
+ const STORAGE_BACKEND_SETTING_KEY = 'STORAGE_BACKEND';
8
+ const STORAGE_S3_CONFIG_SETTING_KEY = 'STORAGE_S3_CONFIG';
9
+
10
+ const maskS3Config = (cfg) => {
11
+ if (!cfg) return null;
12
+ return {
13
+ endpoint: cfg.endpoint,
14
+ region: cfg.region,
15
+ bucket: cfg.bucket,
16
+ forcePathStyle: Boolean(cfg.forcePathStyle),
17
+ accessKeyId: cfg.accessKeyId ? '********' : '',
18
+ secretAccessKey: cfg.secretAccessKey ? '********' : '',
19
+ };
20
+ };
21
+
22
+ const upsertSetting = async ({ key, value, type, description }) => {
23
+ const existing = await GlobalSetting.findOne({ key });
24
+ if (existing) {
25
+ existing.type = type;
26
+ existing.description = description;
27
+ existing.public = false;
28
+ existing.value = value;
29
+ await existing.save();
30
+ return existing.toObject();
31
+ }
32
+
33
+ const created = await GlobalSetting.create({
34
+ key,
35
+ value,
36
+ type,
37
+ description,
38
+ public: false,
39
+ });
40
+ return created.toObject();
41
+ };
42
+
43
+ exports.getStorageStatus = async (req, res) => {
44
+ try {
45
+ const [activeBackend, s3Config] = await Promise.all([
46
+ objectStorage.getActiveBackend(),
47
+ objectStorage.getS3Config(),
48
+ ]);
49
+
50
+ res.json({
51
+ activeBackend,
52
+ s3: {
53
+ configured: Boolean(s3Config),
54
+ config: s3Config ? maskS3Config(s3Config) : null,
55
+ },
56
+ });
57
+ } catch (error) {
58
+ console.error('Error getting storage status:', error);
59
+ res.status(500).json({ error: 'Failed to get storage status' });
60
+ }
61
+ };
62
+
63
+ exports.saveS3Config = async (req, res) => {
64
+ try {
65
+ const normalizeBlank = (v) => {
66
+ const s = v === undefined || v === null ? '' : String(v);
67
+ const trimmed = s.trim();
68
+ return trimmed ? trimmed : undefined;
69
+ };
70
+
71
+ const existing = await objectStorage.getS3Config();
72
+
73
+ const candidate = {
74
+ endpoint: normalizeBlank(req.body?.endpoint) ?? existing?.endpoint,
75
+ region: normalizeBlank(req.body?.region) ?? existing?.region,
76
+ bucket: normalizeBlank(req.body?.bucket) ?? existing?.bucket,
77
+ accessKeyId: normalizeBlank(req.body?.accessKeyId) ?? existing?.accessKeyId,
78
+ secretAccessKey: normalizeBlank(req.body?.secretAccessKey) ?? existing?.secretAccessKey,
79
+ forcePathStyle: req.body?.forcePathStyle ?? existing?.forcePathStyle,
80
+ };
81
+
82
+ const validated = objectStorage.validateS3Config(candidate);
83
+ if (!validated) {
84
+ return res.status(400).json({ error: 'Invalid S3 config. Required: endpoint, accessKeyId, secretAccessKey, bucket.' });
85
+ }
86
+
87
+ const encryptedPayload = encryptString(JSON.stringify(validated));
88
+ await upsertSetting({
89
+ key: STORAGE_S3_CONFIG_SETTING_KEY,
90
+ type: 'encrypted',
91
+ value: JSON.stringify(encryptedPayload),
92
+ description: 'S3 storage configuration (encrypted)'
93
+ });
94
+
95
+ globalSettingsService.clearSettingsCache();
96
+ objectStorage.clearStorageConfigCache();
97
+
98
+ res.json({
99
+ ok: true,
100
+ s3: {
101
+ configured: true,
102
+ config: maskS3Config(validated),
103
+ },
104
+ });
105
+ } catch (error) {
106
+ console.error('Error saving S3 config:', error);
107
+ res.status(500).json({ error: 'Failed to save S3 config' });
108
+ }
109
+ };
110
+
111
+ exports.checkS3Connection = async (req, res) => {
112
+ try {
113
+ const result = await objectStorage.checkS3Connection();
114
+ res.json({ ok: true, result });
115
+ } catch (error) {
116
+ console.error('Error checking S3 connection:', error);
117
+ res.status(400).json({ ok: false, error: error.message || 'S3 check failed', code: error.code || 'S3_CHECK_FAILED' });
118
+ }
119
+ };
120
+
121
+ const normalizeDirection = (dir) => {
122
+ const v = String(dir || '').trim().toLowerCase();
123
+ if (v === 'fs-to-s3' || v === 's3-to-fs') return v;
124
+ return null;
125
+ };
126
+
127
+ exports.sync = async (req, res) => {
128
+ try {
129
+ const direction = normalizeDirection(req.body?.direction);
130
+ if (!direction) {
131
+ return res.status(400).json({ error: 'Invalid direction. Use fs-to-s3 or s3-to-fs.' });
132
+ }
133
+
134
+ const limit = Math.min(500, Math.max(1, Number(req.body?.limit) || 100));
135
+ const cursor = req.body?.cursor ? String(req.body.cursor) : null;
136
+
137
+ const sourceBackend = direction === 'fs-to-s3' ? 'fs' : 's3';
138
+ const destBackend = direction === 'fs-to-s3' ? 's3' : 'fs';
139
+
140
+ const s3Cfg = await objectStorage.getS3Config();
141
+ if ((sourceBackend === 's3' || destBackend === 's3') && !s3Cfg) {
142
+ return res.status(400).json({ error: 'S3 is not configured' });
143
+ }
144
+
145
+ const expectedSourceProvider = sourceBackend;
146
+ const expectedSourceBucket = sourceBackend === 's3' ? s3Cfg.bucket : 'fs';
147
+
148
+ const filter = { status: 'uploaded' };
149
+ if (cursor) {
150
+ filter._id = { $gt: cursor };
151
+ }
152
+
153
+ const assets = await Asset.find(filter)
154
+ .sort({ _id: 1 })
155
+ .limit(limit)
156
+ .lean();
157
+
158
+ const stats = {
159
+ direction,
160
+ sourceBackend,
161
+ destBackend,
162
+ scanned: 0,
163
+ copied: 0,
164
+ skippedMissingSource: 0,
165
+ skippedAlreadySynced: 0,
166
+ skippedDifferentBytes: 0,
167
+ aborted: false,
168
+ abortReason: null,
169
+ nextCursor: null,
170
+ };
171
+
172
+ for (const asset of assets) {
173
+ stats.scanned += 1;
174
+ stats.nextCursor = String(asset._id);
175
+
176
+ if (asset.provider === expectedSourceProvider && asset.bucket !== expectedSourceBucket) {
177
+ // Asset claims it is in the source backend, but points to a different bucket.
178
+ // This is a dangerous mismatch, so abort.
179
+ stats.aborted = true;
180
+ stats.abortReason = {
181
+ reason: 'asset provider/bucket mismatch',
182
+ assetId: String(asset._id),
183
+ key: asset.key,
184
+ actual: { provider: asset.provider, bucket: asset.bucket },
185
+ expected: { provider: expectedSourceProvider, bucket: expectedSourceBucket },
186
+ };
187
+ return res.status(409).json({ error: 'Sync aborted: provider/bucket mismatch', details: stats.abortReason, stats });
188
+ }
189
+
190
+ const sourceExists = await objectStorage.objectExists({ key: asset.key, backend: sourceBackend });
191
+ if (!sourceExists) {
192
+ stats.skippedMissingSource += 1;
193
+ continue;
194
+ }
195
+
196
+ const destExists = await objectStorage.objectExists({ key: asset.key, backend: destBackend });
197
+ if (destExists) {
198
+ const cmp = await objectStorage.compareObjectBytes({ key: asset.key, sourceBackend, destBackend });
199
+ if (!cmp.comparable) {
200
+ stats.skippedDifferentBytes += 1;
201
+ continue;
202
+ }
203
+ if (cmp.same) {
204
+ stats.skippedAlreadySynced += 1;
205
+ } else {
206
+ stats.skippedDifferentBytes += 1;
207
+ }
208
+ continue;
209
+ }
210
+
211
+ const obj = await objectStorage.getObject({ key: asset.key, backend: sourceBackend });
212
+ if (!obj || !obj.body) {
213
+ stats.skippedMissingSource += 1;
214
+ continue;
215
+ }
216
+
217
+ await objectStorage.putObject({
218
+ key: asset.key,
219
+ body: obj.body,
220
+ contentType: asset.contentType,
221
+ backend: destBackend,
222
+ });
223
+
224
+ stats.copied += 1;
225
+ }
226
+
227
+ res.json({ ok: true, stats });
228
+ } catch (error) {
229
+ console.error('Error syncing storage:', error);
230
+ res.status(500).json({ error: 'Failed to sync storage', details: error.message });
231
+ }
232
+ };
233
+
234
+ exports.switchBackend = async (req, res) => {
235
+ try {
236
+ const backend = String(req.body?.backend || '').trim().toLowerCase();
237
+ if (backend !== 'fs' && backend !== 's3') {
238
+ return res.status(400).json({ error: 'Invalid backend. Use fs or s3.' });
239
+ }
240
+
241
+ if (backend === 's3') {
242
+ await objectStorage.checkS3Connection();
243
+ }
244
+
245
+ await upsertSetting({
246
+ key: STORAGE_BACKEND_SETTING_KEY,
247
+ type: 'string',
248
+ value: backend,
249
+ description: 'Active object storage backend (fs or s3)'
250
+ });
251
+
252
+ globalSettingsService.clearSettingsCache();
253
+ objectStorage.clearStorageConfigCache();
254
+
255
+ res.json({ ok: true, activeBackend: backend });
256
+ } catch (error) {
257
+ console.error('Error switching backend:', error);
258
+ res.status(400).json({ error: error.message || 'Failed to switch backend' });
259
+ }
260
+ };
@@ -0,0 +1,354 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ const VirtualEjsFile = require('../models/VirtualEjsFile');
5
+ const VirtualEjsFileVersion = require('../models/VirtualEjsFileVersion');
6
+ const VirtualEjsGroupChange = require('../models/VirtualEjsGroupChange');
7
+
8
+ const ejsVirtualService = require('../services/ejsVirtual.service');
9
+ const { getBasicAuthActor, createAuditEvent } = require('../services/audit.service');
10
+
11
+ function normalizeViewsRoot(req) {
12
+ const viewsRoot = (req.app && req.app.get('views')) ? req.app.get('views') : path.join(process.cwd(), 'src', 'views');
13
+ return String(viewsRoot);
14
+ }
15
+
16
+ async function listFilesRecursive(rootDir, relDir = '') {
17
+ const abs = path.join(rootDir, relDir);
18
+ const items = await fs.promises.readdir(abs, { withFileTypes: true });
19
+ const results = [];
20
+
21
+ for (const item of items) {
22
+ const name = item.name;
23
+ if (name.startsWith('.')) continue;
24
+ if (name === 'node_modules') continue;
25
+
26
+ const nextRel = path.join(relDir, name);
27
+ const nextAbs = path.join(rootDir, nextRel);
28
+
29
+ if (item.isDirectory()) {
30
+ const nested = await listFilesRecursive(rootDir, nextRel);
31
+ results.push(...nested);
32
+ continue;
33
+ }
34
+
35
+ if (item.isFile() && name.endsWith('.ejs')) {
36
+ results.push(nextRel.replace(/\\/g, '/'));
37
+ }
38
+ }
39
+
40
+ return results;
41
+ }
42
+
43
+ exports.list = async (req, res) => {
44
+ try {
45
+ const viewsRoot = normalizeViewsRoot(req);
46
+ const dbFiles = await VirtualEjsFile.find({}).select('path enabled inferred integrated updatedAt').lean();
47
+ const dbByPath = new Map((dbFiles || []).map((f) => [f.path, f]));
48
+
49
+ let fsFiles = [];
50
+ try {
51
+ fsFiles = await listFilesRecursive(viewsRoot);
52
+ } catch (_) {
53
+ fsFiles = [];
54
+ }
55
+
56
+ const allPaths = new Set([...(fsFiles || []), ...Array.from(dbByPath.keys())]);
57
+ const items = Array.from(allPaths)
58
+ .sort()
59
+ .map((p) => {
60
+ const db = dbByPath.get(p) || null;
61
+ const isAdminView = p.startsWith('admin/');
62
+ const integratedFlag = isAdminView ? Boolean(db && db.integrated) : true;
63
+ return {
64
+ path: p,
65
+ existsOnFs: fsFiles.includes(p),
66
+ hasOverride: Boolean(db && typeof db.enabled === 'boolean'),
67
+ enabled: db ? Boolean(db.enabled) : false,
68
+ inferred: db ? Boolean(db.inferred) : fsFiles.includes(p),
69
+ integrated: integratedFlag,
70
+ updatedAt: db ? db.updatedAt : null,
71
+ };
72
+ });
73
+
74
+ res.json({ viewsRoot, items });
75
+ } catch (err) {
76
+ console.error('[adminEjsVirtual] list error', err);
77
+ res.status(500).json({ error: 'Failed to list EJS files' });
78
+ }
79
+ };
80
+
81
+ exports.getFile = async (req, res) => {
82
+ try {
83
+ const viewsRoot = normalizeViewsRoot(req);
84
+ const relPath = ejsVirtualService.normalizeRelPath(String(req.query.path || '').trim());
85
+
86
+ const fsContent = await ejsVirtualService.readFsView(viewsRoot, relPath);
87
+ const override = await VirtualEjsFile.findOne({ path: relPath }).lean();
88
+
89
+ const effective = await ejsVirtualService.resolveTemplateSource({ viewsRoot, relPath, allowDb: true });
90
+
91
+ res.json({
92
+ path: relPath,
93
+ fs: { content: fsContent },
94
+ db: override ? { enabled: Boolean(override.enabled), content: override.content || '', updatedAt: override.updatedAt } : null,
95
+ effective: { source: effective.source, content: effective.content },
96
+ });
97
+ } catch (err) {
98
+ const code = err.code;
99
+ if (code === 'VALIDATION') return res.status(400).json({ error: err.message });
100
+ if (code === 'NOT_FOUND') return res.status(404).json({ error: err.message });
101
+ console.error('[adminEjsVirtual] getFile error', err);
102
+ res.status(500).json({ error: 'Failed to load file' });
103
+ }
104
+ };
105
+
106
+ exports.saveFile = async (req, res) => {
107
+ try {
108
+ const actor = getBasicAuthActor(req);
109
+ const viewsRoot = normalizeViewsRoot(req);
110
+ const relPath = ejsVirtualService.normalizeRelPath(String(req.query.path || '').trim());
111
+
112
+ const content = typeof req.body?.content === 'string' ? req.body.content : '';
113
+ const enabled = req.body?.enabled !== undefined ? Boolean(req.body.enabled) : true;
114
+ const description = String(req.body?.description || '').trim();
115
+
116
+ const existing = await VirtualEjsFile.findOne({ path: relPath });
117
+ const before = existing ? existing.toObject() : null;
118
+
119
+ const next = await VirtualEjsFile.findOneAndUpdate(
120
+ { path: relPath },
121
+ {
122
+ $set: {
123
+ path: relPath,
124
+ enabled,
125
+ content,
126
+ source: 'manual',
127
+ inferred: true,
128
+ lastSeenAt: new Date(),
129
+ },
130
+ $setOnInsert: {
131
+ integrated: false,
132
+ renderCount: 0,
133
+ lastRenderedAt: null,
134
+ },
135
+ },
136
+ { upsert: true, new: true },
137
+ );
138
+
139
+ const groupCount = await VirtualEjsGroupChange.countDocuments({});
140
+ const group = await VirtualEjsGroupChange.create({
141
+ title: `Grouped changes ${groupCount + 1}`,
142
+ summary: description || 'Manual edit',
143
+ filePaths: [relPath],
144
+ versionIds: [],
145
+ createdBy: actor.actorId || null,
146
+ });
147
+
148
+ const version = await VirtualEjsFileVersion.create({
149
+ fileId: next._id,
150
+ path: relPath,
151
+ content,
152
+ source: 'manual',
153
+ description: description || 'Manual edit',
154
+ groupId: group._id,
155
+ });
156
+
157
+ await VirtualEjsGroupChange.updateOne({ _id: group._id }, { $set: { versionIds: [version._id] } });
158
+
159
+ await createAuditEvent({
160
+ ...actor,
161
+ action: 'ejsVirtual.file.save',
162
+ entityType: 'VirtualEjsFile',
163
+ entityId: relPath,
164
+ before,
165
+ after: next.toObject(),
166
+ meta: { groupId: String(group._id) },
167
+ });
168
+
169
+ ejsVirtualService.invalidateCacheForPath(relPath);
170
+
171
+ res.json({
172
+ file: next.toObject(),
173
+ version: version.toObject(),
174
+ group: group.toObject(),
175
+ });
176
+ } catch (err) {
177
+ const code = err.code;
178
+ if (code === 'VALIDATION') return res.status(400).json({ error: err.message });
179
+ console.error('[adminEjsVirtual] saveFile error', err);
180
+ res.status(500).json({ error: 'Failed to save file' });
181
+ }
182
+ };
183
+
184
+ exports.revertToDefault = async (req, res) => {
185
+ try {
186
+ const actor = getBasicAuthActor(req);
187
+ const relPath = ejsVirtualService.normalizeRelPath(String(req.query.path || '').trim());
188
+
189
+ const existing = await VirtualEjsFile.findOne({ path: relPath });
190
+ if (!existing) {
191
+ return res.json({ success: true, message: 'No override existed' });
192
+ }
193
+
194
+ const before = existing.toObject();
195
+
196
+ await VirtualEjsFile.deleteOne({ path: relPath });
197
+
198
+ await createAuditEvent({
199
+ ...actor,
200
+ action: 'ejsVirtual.file.revert_to_default',
201
+ entityType: 'VirtualEjsFile',
202
+ entityId: relPath,
203
+ before,
204
+ after: null,
205
+ meta: null,
206
+ });
207
+
208
+ ejsVirtualService.invalidateCacheForPath(relPath);
209
+
210
+ res.json({ success: true });
211
+ } catch (err) {
212
+ const code = err.code;
213
+ if (code === 'VALIDATION') return res.status(400).json({ error: err.message });
214
+ console.error('[adminEjsVirtual] revertToDefault error', err);
215
+ res.status(500).json({ error: 'Failed to revert to default' });
216
+ }
217
+ };
218
+
219
+ exports.listHistory = async (req, res) => {
220
+ try {
221
+ const relPath = ejsVirtualService.normalizeRelPath(String(req.query.path || '').trim());
222
+ const versions = await VirtualEjsFileVersion.find({ path: relPath })
223
+ .sort({ createdAt: -1 })
224
+ .limit(50)
225
+ .lean();
226
+ res.json({ path: relPath, versions });
227
+ } catch (err) {
228
+ const code = err.code;
229
+ if (code === 'VALIDATION') return res.status(400).json({ error: err.message });
230
+ console.error('[adminEjsVirtual] listHistory error', err);
231
+ res.status(500).json({ error: 'Failed to load history' });
232
+ }
233
+ };
234
+
235
+ exports.rollback = async (req, res) => {
236
+ try {
237
+ const actor = getBasicAuthActor(req);
238
+ const versionId = String(req.body?.versionId || '').trim();
239
+ if (!versionId) return res.status(400).json({ error: 'versionId is required' });
240
+
241
+ const version = await VirtualEjsFileVersion.findById(versionId).lean();
242
+ if (!version) return res.status(404).json({ error: 'Version not found' });
243
+
244
+ const relPath = ejsVirtualService.normalizeRelPath(version.path);
245
+
246
+ const existing = await VirtualEjsFile.findOne({ path: relPath });
247
+ const before = existing ? existing.toObject() : null;
248
+
249
+ const next = await VirtualEjsFile.findOneAndUpdate(
250
+ { path: relPath },
251
+ {
252
+ $set: {
253
+ path: relPath,
254
+ enabled: true,
255
+ content: version.content,
256
+ source: 'rollback',
257
+ inferred: true,
258
+ lastSeenAt: new Date(),
259
+ },
260
+ $setOnInsert: {
261
+ integrated: false,
262
+ renderCount: 0,
263
+ lastRenderedAt: null,
264
+ },
265
+ },
266
+ { upsert: true, new: true },
267
+ );
268
+
269
+ const groupCount = await VirtualEjsGroupChange.countDocuments({});
270
+ const group = await VirtualEjsGroupChange.create({
271
+ title: `Grouped changes ${groupCount + 1}`,
272
+ summary: `Rollback to ${versionId}`,
273
+ filePaths: [relPath],
274
+ versionIds: [],
275
+ createdBy: actor.actorId || null,
276
+ });
277
+
278
+ const newVersion = await VirtualEjsFileVersion.create({
279
+ fileId: next._id,
280
+ path: relPath,
281
+ content: version.content,
282
+ source: 'rollback',
283
+ description: `Rollback to ${versionId}`,
284
+ groupId: group._id,
285
+ });
286
+
287
+ await VirtualEjsGroupChange.updateOne({ _id: group._id }, { $set: { versionIds: [newVersion._id] } });
288
+
289
+ await createAuditEvent({
290
+ ...actor,
291
+ action: 'ejsVirtual.file.rollback',
292
+ entityType: 'VirtualEjsFile',
293
+ entityId: relPath,
294
+ before,
295
+ after: next.toObject(),
296
+ meta: { versionId },
297
+ });
298
+
299
+ ejsVirtualService.invalidateCacheForPath(relPath);
300
+
301
+ res.json({ file: next.toObject(), version: newVersion.toObject(), group: group.toObject() });
302
+ } catch (err) {
303
+ console.error('[adminEjsVirtual] rollback error', err);
304
+ res.status(500).json({ error: 'Failed to rollback' });
305
+ }
306
+ };
307
+
308
+ exports.vibe = async (req, res) => {
309
+ try {
310
+ const actor = getBasicAuthActor(req);
311
+ const viewsRoot = normalizeViewsRoot(req);
312
+
313
+ const { prompt, paths, providerKey, model } = req.body || {};
314
+
315
+ const result = await ejsVirtualService.vibeEdit({
316
+ prompt,
317
+ paths,
318
+ providerKey,
319
+ model,
320
+ viewsRoot,
321
+ actor,
322
+ });
323
+
324
+ res.json(result);
325
+ } catch (err) {
326
+ const code = err.code;
327
+ if (code === 'VALIDATION') return res.status(400).json({ error: err.message });
328
+ if (code === 'AI_INVALID') return res.status(500).json({ error: err.message });
329
+ console.error('[adminEjsVirtual] vibe error', err);
330
+ res.status(500).json({ error: 'Failed to run vibe edit', details: err.message });
331
+ }
332
+ };
333
+
334
+ exports.clearCache = async (req, res) => {
335
+ try {
336
+ const actor = getBasicAuthActor(req);
337
+ ejsVirtualService.clearCache();
338
+
339
+ await createAuditEvent({
340
+ ...actor,
341
+ action: 'ejsVirtual.cache.clear',
342
+ entityType: 'ejsVirtual',
343
+ entityId: null,
344
+ before: null,
345
+ after: { cleared: true },
346
+ meta: null,
347
+ });
348
+
349
+ res.json({ success: true });
350
+ } catch (err) {
351
+ console.error('[adminEjsVirtual] clearCache error', err);
352
+ res.status(500).json({ error: 'Failed to clear cache' });
353
+ }
354
+ };