@intranefr/superbackend 1.4.4 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (195) hide show
  1. package/.env.example +5 -0
  2. package/README.md +11 -0
  3. package/index.js +39 -1
  4. package/package.json +11 -3
  5. package/public/sdk/ui-components.iife.js +191 -0
  6. package/sdk/ui-components/browser/src/index.js +228 -0
  7. package/src/admin/endpointRegistry.js +120 -0
  8. package/src/controllers/admin.controller.js +111 -5
  9. package/src/controllers/adminBlockDefinitions.controller.js +127 -0
  10. package/src/controllers/adminBlockDefinitionsAi.controller.js +54 -0
  11. package/src/controllers/adminCache.controller.js +342 -0
  12. package/src/controllers/adminContextBlockDefinitions.controller.js +141 -0
  13. package/src/controllers/adminCrons.controller.js +388 -0
  14. package/src/controllers/adminDbBrowser.controller.js +124 -0
  15. package/src/controllers/adminEjsVirtual.controller.js +13 -3
  16. package/src/controllers/adminHeadless.controller.js +91 -2
  17. package/src/controllers/adminHealthChecks.controller.js +570 -0
  18. package/src/controllers/adminI18n.controller.js +51 -29
  19. package/src/controllers/adminLlm.controller.js +126 -2
  20. package/src/controllers/adminPages.controller.js +720 -0
  21. package/src/controllers/adminPagesContextBlocksAi.controller.js +54 -0
  22. package/src/controllers/adminProxy.controller.js +113 -0
  23. package/src/controllers/adminRateLimits.controller.js +138 -0
  24. package/src/controllers/adminRbac.controller.js +803 -0
  25. package/src/controllers/adminScripts.controller.js +320 -0
  26. package/src/controllers/adminSeoConfig.controller.js +71 -48
  27. package/src/controllers/adminTerminals.controller.js +39 -0
  28. package/src/controllers/adminUiComponents.controller.js +315 -0
  29. package/src/controllers/adminUiComponentsAi.controller.js +34 -0
  30. package/src/controllers/blogAdmin.controller.js +279 -0
  31. package/src/controllers/blogAiAdmin.controller.js +224 -0
  32. package/src/controllers/blogAutomationAdmin.controller.js +141 -0
  33. package/src/controllers/blogInternal.controller.js +26 -0
  34. package/src/controllers/blogPublic.controller.js +89 -0
  35. package/src/controllers/fileManager.controller.js +190 -0
  36. package/src/controllers/fileManagerStoragePolicy.controller.js +23 -0
  37. package/src/controllers/healthChecksPublic.controller.js +196 -0
  38. package/src/controllers/metrics.controller.js +64 -4
  39. package/src/controllers/orgAdmin.controller.js +366 -0
  40. package/src/controllers/uiComponentsPublic.controller.js +118 -0
  41. package/src/middleware/auth.js +7 -0
  42. package/src/middleware/internalCronAuth.js +29 -0
  43. package/src/middleware/rbac.js +62 -0
  44. package/src/middleware.js +879 -56
  45. package/src/models/BlockDefinition.js +27 -0
  46. package/src/models/BlogAutomationLock.js +14 -0
  47. package/src/models/BlogAutomationRun.js +39 -0
  48. package/src/models/BlogPost.js +42 -0
  49. package/src/models/CacheEntry.js +26 -0
  50. package/src/models/ConsoleEntry.js +32 -0
  51. package/src/models/ConsoleLog.js +23 -0
  52. package/src/models/ContextBlockDefinition.js +33 -0
  53. package/src/models/CronExecution.js +47 -0
  54. package/src/models/CronJob.js +70 -0
  55. package/src/models/ExternalDbConnection.js +49 -0
  56. package/src/models/FileEntry.js +22 -0
  57. package/src/models/HeadlessModelDefinition.js +10 -0
  58. package/src/models/HealthAutoHealAttempt.js +57 -0
  59. package/src/models/HealthCheck.js +132 -0
  60. package/src/models/HealthCheckRun.js +51 -0
  61. package/src/models/HealthIncident.js +49 -0
  62. package/src/models/Page.js +95 -0
  63. package/src/models/PageCollection.js +42 -0
  64. package/src/models/ProxyEntry.js +66 -0
  65. package/src/models/RateLimitCounter.js +19 -0
  66. package/src/models/RateLimitMetricBucket.js +20 -0
  67. package/src/models/RbacGrant.js +25 -0
  68. package/src/models/RbacGroup.js +16 -0
  69. package/src/models/RbacGroupMember.js +13 -0
  70. package/src/models/RbacGroupRole.js +13 -0
  71. package/src/models/RbacRole.js +25 -0
  72. package/src/models/RbacUserRole.js +13 -0
  73. package/src/models/ScriptDefinition.js +42 -0
  74. package/src/models/ScriptRun.js +22 -0
  75. package/src/models/UiComponent.js +29 -0
  76. package/src/models/UiComponentProject.js +26 -0
  77. package/src/models/UiComponentProjectComponent.js +18 -0
  78. package/src/routes/admin.routes.js +1 -0
  79. package/src/routes/adminBlog.routes.js +21 -0
  80. package/src/routes/adminBlogAi.routes.js +16 -0
  81. package/src/routes/adminBlogAutomation.routes.js +27 -0
  82. package/src/routes/adminCache.routes.js +20 -0
  83. package/src/routes/adminConsoleManager.routes.js +302 -0
  84. package/src/routes/adminCrons.routes.js +25 -0
  85. package/src/routes/adminDbBrowser.routes.js +65 -0
  86. package/src/routes/adminEjsVirtual.routes.js +2 -1
  87. package/src/routes/adminHeadless.routes.js +8 -1
  88. package/src/routes/adminHealthChecks.routes.js +28 -0
  89. package/src/routes/adminI18n.routes.js +4 -3
  90. package/src/routes/adminLlm.routes.js +4 -2
  91. package/src/routes/adminPages.routes.js +55 -0
  92. package/src/routes/adminProxy.routes.js +15 -0
  93. package/src/routes/adminRateLimits.routes.js +17 -0
  94. package/src/routes/adminRbac.routes.js +38 -0
  95. package/src/routes/adminScripts.routes.js +21 -0
  96. package/src/routes/adminSeoConfig.routes.js +5 -4
  97. package/src/routes/adminTerminals.routes.js +13 -0
  98. package/src/routes/adminUiComponents.routes.js +30 -0
  99. package/src/routes/blogInternal.routes.js +14 -0
  100. package/src/routes/blogPublic.routes.js +9 -0
  101. package/src/routes/fileManager.routes.js +62 -0
  102. package/src/routes/fileManagerStoragePolicy.routes.js +9 -0
  103. package/src/routes/healthChecksPublic.routes.js +9 -0
  104. package/src/routes/log.routes.js +43 -60
  105. package/src/routes/metrics.routes.js +4 -2
  106. package/src/routes/orgAdmin.routes.js +6 -0
  107. package/src/routes/pages.routes.js +123 -0
  108. package/src/routes/proxy.routes.js +46 -0
  109. package/src/routes/rbac.routes.js +47 -0
  110. package/src/routes/uiComponentsPublic.routes.js +9 -0
  111. package/src/routes/webhook.routes.js +2 -1
  112. package/src/routes/workflows.routes.js +4 -0
  113. package/src/services/blockDefinitionsAi.service.js +247 -0
  114. package/src/services/blog.service.js +99 -0
  115. package/src/services/blogAutomation.service.js +978 -0
  116. package/src/services/blogCronsBootstrap.service.js +184 -0
  117. package/src/services/blogPublishing.service.js +58 -0
  118. package/src/services/cacheLayer.service.js +696 -0
  119. package/src/services/consoleManager.service.js +700 -0
  120. package/src/services/consoleOverride.service.js +6 -1
  121. package/src/services/cronScheduler.service.js +350 -0
  122. package/src/services/dbBrowser.service.js +536 -0
  123. package/src/services/ejsVirtual.service.js +102 -32
  124. package/src/services/fileManager.service.js +475 -0
  125. package/src/services/fileManagerStoragePolicy.service.js +285 -0
  126. package/src/services/headlessExternalModels.service.js +292 -0
  127. package/src/services/headlessModels.service.js +26 -6
  128. package/src/services/healthChecks.service.js +650 -0
  129. package/src/services/healthChecksBootstrap.service.js +109 -0
  130. package/src/services/healthChecksScheduler.service.js +106 -0
  131. package/src/services/llmDefaults.service.js +190 -0
  132. package/src/services/migrationAssets/s3.js +2 -2
  133. package/src/services/pages.service.js +602 -0
  134. package/src/services/pagesContext.service.js +331 -0
  135. package/src/services/pagesContextBlocksAi.service.js +349 -0
  136. package/src/services/proxy.service.js +535 -0
  137. package/src/services/rateLimiter.service.js +623 -0
  138. package/src/services/rbac.service.js +212 -0
  139. package/src/services/scriptsRunner.service.js +259 -0
  140. package/src/services/terminals.service.js +152 -0
  141. package/src/services/terminalsWs.service.js +100 -0
  142. package/src/services/uiComponentsAi.service.js +299 -0
  143. package/src/services/uiComponentsCrypto.service.js +39 -0
  144. package/src/services/workflow.service.js +23 -8
  145. package/src/utils/orgRoles.js +14 -0
  146. package/src/utils/rbac/engine.js +60 -0
  147. package/src/utils/rbac/rightsRegistry.js +29 -0
  148. package/views/admin-blog-automation.ejs +877 -0
  149. package/views/admin-blog-edit.ejs +542 -0
  150. package/views/admin-blog.ejs +399 -0
  151. package/views/admin-cache.ejs +681 -0
  152. package/views/admin-console-manager.ejs +680 -0
  153. package/views/admin-crons.ejs +645 -0
  154. package/views/admin-db-browser.ejs +445 -0
  155. package/views/admin-ejs-virtual.ejs +16 -10
  156. package/views/admin-file-manager.ejs +942 -0
  157. package/views/admin-headless.ejs +294 -24
  158. package/views/admin-health-checks.ejs +725 -0
  159. package/views/admin-i18n.ejs +59 -5
  160. package/views/admin-llm.ejs +99 -1
  161. package/views/admin-organizations.ejs +528 -10
  162. package/views/admin-pages.ejs +2424 -0
  163. package/views/admin-proxy.ejs +491 -0
  164. package/views/admin-rate-limiter.ejs +625 -0
  165. package/views/admin-rbac.ejs +1331 -0
  166. package/views/admin-scripts.ejs +497 -0
  167. package/views/admin-seo-config.ejs +61 -7
  168. package/views/admin-terminals.ejs +328 -0
  169. package/views/admin-ui-components.ejs +741 -0
  170. package/views/admin-users.ejs +261 -4
  171. package/views/admin-workflows.ejs +7 -7
  172. package/views/file-manager.ejs +866 -0
  173. package/views/pages/blocks/contact.ejs +27 -0
  174. package/views/pages/blocks/cta.ejs +18 -0
  175. package/views/pages/blocks/faq.ejs +20 -0
  176. package/views/pages/blocks/features.ejs +19 -0
  177. package/views/pages/blocks/hero.ejs +13 -0
  178. package/views/pages/blocks/html.ejs +5 -0
  179. package/views/pages/blocks/image.ejs +14 -0
  180. package/views/pages/blocks/testimonials.ejs +26 -0
  181. package/views/pages/blocks/text.ejs +10 -0
  182. package/views/pages/layouts/default.ejs +51 -0
  183. package/views/pages/layouts/minimal.ejs +42 -0
  184. package/views/pages/layouts/sidebar.ejs +54 -0
  185. package/views/pages/partials/footer.ejs +13 -0
  186. package/views/pages/partials/header.ejs +12 -0
  187. package/views/pages/partials/sidebar.ejs +8 -0
  188. package/views/pages/runtime/page.ejs +10 -0
  189. package/views/pages/templates/article.ejs +20 -0
  190. package/views/pages/templates/default.ejs +12 -0
  191. package/views/pages/templates/landing.ejs +14 -0
  192. package/views/pages/templates/listing.ejs +15 -0
  193. package/views/partials/admin-image-upload-modal.ejs +221 -0
  194. package/views/partials/dashboard/nav-items.ejs +14 -0
  195. package/views/partials/llm-provider-model-picker.ejs +183 -0
@@ -0,0 +1,720 @@
1
+ const Page = require('../models/Page');
2
+ const PageCollection = require('../models/PageCollection');
3
+ const VirtualEjsFile = require('../models/VirtualEjsFile');
4
+ const pagesService = require('../services/pages.service');
5
+ const pagesContextService = require('../services/pagesContext.service');
6
+ const { getBasicAuthActor, createAuditEvent } = require('../services/audit.service');
7
+
8
+ exports.listCollections = async (req, res) => {
9
+ try {
10
+ const { tenantId, status, isGlobal, limit, offset, search } = req.query;
11
+ const result = await pagesService.listCollections({
12
+ tenantId: tenantId || undefined,
13
+ status: status || undefined,
14
+ isGlobal: isGlobal === 'true' ? true : isGlobal === 'false' ? false : undefined,
15
+ limit: parseInt(limit, 10) || 50,
16
+ offset: parseInt(offset, 10) || 0,
17
+ search: search || undefined,
18
+ });
19
+ res.json(result);
20
+ } catch (err) {
21
+ console.error('[adminPages] listCollections error:', err);
22
+ res.status(500).json({ error: 'Failed to list collections' });
23
+ }
24
+ };
25
+
26
+ exports.getCollection = async (req, res) => {
27
+ try {
28
+ const { id } = req.params;
29
+ const collection = await PageCollection.findById(id).lean();
30
+ if (!collection) {
31
+ return res.status(404).json({ error: 'Collection not found' });
32
+ }
33
+ res.json({ collection });
34
+ } catch (err) {
35
+ console.error('[adminPages] getCollection error:', err);
36
+ res.status(500).json({ error: 'Failed to get collection' });
37
+ }
38
+ };
39
+
40
+ exports.createCollection = async (req, res) => {
41
+ try {
42
+ const actor = getBasicAuthActor(req);
43
+ const { slug, name, description, tenantId, isGlobal, status } = req.body;
44
+
45
+ const pagesPrefix = req.app.get('pagesPrefix') || '/';
46
+ const adminPath = req.app.get('adminPath') || '/admin';
47
+
48
+ const validatedSlug = pagesService.validateCollectionSlug(slug, pagesPrefix, adminPath);
49
+
50
+ const existing = await PageCollection.findOne({
51
+ slug: validatedSlug,
52
+ tenantId: tenantId || null,
53
+ });
54
+
55
+ if (existing) {
56
+ return res.status(409).json({ error: 'Collection with this slug already exists' });
57
+ }
58
+
59
+ const collection = await PageCollection.create({
60
+ slug: validatedSlug,
61
+ name: name || validatedSlug,
62
+ description: description || '',
63
+ tenantId: tenantId || null,
64
+ isGlobal: isGlobal !== false,
65
+ status: status || 'active',
66
+ });
67
+
68
+ await createAuditEvent({
69
+ ...actor,
70
+ action: 'pageCollection.create',
71
+ entityType: 'PageCollection',
72
+ entityId: String(collection._id),
73
+ before: null,
74
+ after: collection.toObject(),
75
+ meta: null,
76
+ });
77
+
78
+ res.status(201).json({ collection: collection.toObject() });
79
+ } catch (err) {
80
+ if (err.code === 'VALIDATION') {
81
+ return res.status(400).json({ error: err.message });
82
+ }
83
+ console.error('[adminPages] createCollection error:', err);
84
+ res.status(500).json({ error: 'Failed to create collection' });
85
+ }
86
+ };
87
+
88
+ exports.updateCollection = async (req, res) => {
89
+ try {
90
+ const actor = getBasicAuthActor(req);
91
+ const { id } = req.params;
92
+ const { slug, name, description, tenantId, isGlobal, status } = req.body;
93
+
94
+ const existing = await PageCollection.findById(id);
95
+ if (!existing) {
96
+ return res.status(404).json({ error: 'Collection not found' });
97
+ }
98
+
99
+ const before = existing.toObject();
100
+
101
+ const pagesPrefix = req.app.get('pagesPrefix') || '/';
102
+ const adminPath = req.app.get('adminPath') || '/admin';
103
+
104
+ if (slug !== undefined) {
105
+ const validatedSlug = pagesService.validateCollectionSlug(slug, pagesPrefix, adminPath);
106
+ const duplicate = await PageCollection.findOne({
107
+ _id: { $ne: id },
108
+ slug: validatedSlug,
109
+ tenantId: existing.tenantId,
110
+ });
111
+ if (duplicate) {
112
+ return res.status(409).json({ error: 'Another collection with this slug already exists' });
113
+ }
114
+ existing.slug = validatedSlug;
115
+ }
116
+ if (name !== undefined) existing.name = name;
117
+ if (description !== undefined) existing.description = description;
118
+ if (tenantId !== undefined) existing.tenantId = tenantId || null;
119
+ if (isGlobal !== undefined) existing.isGlobal = isGlobal;
120
+ if (status !== undefined) existing.status = status;
121
+
122
+ await existing.save();
123
+
124
+ await createAuditEvent({
125
+ ...actor,
126
+ action: 'pageCollection.update',
127
+ entityType: 'PageCollection',
128
+ entityId: String(existing._id),
129
+ before,
130
+ after: existing.toObject(),
131
+ meta: null,
132
+ });
133
+
134
+ res.json({ collection: existing.toObject() });
135
+ } catch (err) {
136
+ if (err.code === 'VALIDATION') {
137
+ return res.status(400).json({ error: err.message });
138
+ }
139
+ console.error('[adminPages] updateCollection error:', err);
140
+ res.status(500).json({ error: 'Failed to update collection' });
141
+ }
142
+ };
143
+
144
+ exports.deleteCollection = async (req, res) => {
145
+ try {
146
+ const actor = getBasicAuthActor(req);
147
+ const { id } = req.params;
148
+
149
+ const existing = await PageCollection.findById(id);
150
+ if (!existing) {
151
+ return res.status(404).json({ error: 'Collection not found' });
152
+ }
153
+
154
+ const pagesInCollection = await Page.countDocuments({ collectionId: id });
155
+ if (pagesInCollection > 0) {
156
+ return res.status(400).json({
157
+ error: `Cannot delete collection with ${pagesInCollection} page(s). Move or delete pages first.`
158
+ });
159
+ }
160
+
161
+ const before = existing.toObject();
162
+ await PageCollection.deleteOne({ _id: id });
163
+
164
+ await createAuditEvent({
165
+ ...actor,
166
+ action: 'pageCollection.delete',
167
+ entityType: 'PageCollection',
168
+ entityId: String(id),
169
+ before,
170
+ after: null,
171
+ meta: null,
172
+ });
173
+
174
+ res.json({ success: true });
175
+ } catch (err) {
176
+ console.error('[adminPages] deleteCollection error:', err);
177
+ res.status(500).json({ error: 'Failed to delete collection' });
178
+ }
179
+ };
180
+
181
+ exports.listPages = async (req, res) => {
182
+ try {
183
+ const { tenantId, collectionId, status, isGlobal, limit, offset, search } = req.query;
184
+ const result = await pagesService.listPages({
185
+ tenantId: tenantId || undefined,
186
+ collectionId: collectionId || undefined,
187
+ status: status || undefined,
188
+ isGlobal: isGlobal === 'true' ? true : isGlobal === 'false' ? false : undefined,
189
+ limit: parseInt(limit, 10) || 50,
190
+ offset: parseInt(offset, 10) || 0,
191
+ search: search || undefined,
192
+ });
193
+ res.json(result);
194
+ } catch (err) {
195
+ console.error('[adminPages] listPages error:', err);
196
+ res.status(500).json({ error: 'Failed to list pages' });
197
+ }
198
+ };
199
+
200
+ exports.getPage = async (req, res) => {
201
+ try {
202
+ const { id } = req.params;
203
+ const page = await Page.findById(id).populate('collectionId', 'slug name').lean();
204
+ if (!page) {
205
+ return res.status(404).json({ error: 'Page not found' });
206
+ }
207
+ res.json({ page });
208
+ } catch (err) {
209
+ console.error('[adminPages] getPage error:', err);
210
+ res.status(500).json({ error: 'Failed to get page' });
211
+ }
212
+ };
213
+
214
+ exports.createPage = async (req, res) => {
215
+ try {
216
+ const actor = getBasicAuthActor(req);
217
+ const {
218
+ slug,
219
+ collectionId,
220
+ title,
221
+ templateKey,
222
+ layoutKey,
223
+ blocks,
224
+ repeat,
225
+ customCss,
226
+ customJs,
227
+ seoMeta,
228
+ tenantId,
229
+ isGlobal,
230
+ status,
231
+ } = req.body;
232
+
233
+ const pagesPrefix = req.app.get('pagesPrefix') || '/';
234
+ const adminPath = req.app.get('adminPath') || '/admin';
235
+
236
+ let collection = null;
237
+ if (collectionId) {
238
+ collection = await PageCollection.findById(collectionId).lean();
239
+ if (!collection) {
240
+ return res.status(400).json({ error: 'Collection not found' });
241
+ }
242
+ }
243
+
244
+ const validatedSlug = pagesService.validatePageSlug(
245
+ slug,
246
+ collection?.slug,
247
+ pagesPrefix,
248
+ adminPath,
249
+ );
250
+
251
+ const blocksSchema = await pagesService.getBlocksSchema();
252
+ pagesService.validateBlocks(blocks || [], blocksSchema);
253
+
254
+ const existing = await Page.findOne({
255
+ slug: validatedSlug,
256
+ collectionId: collectionId || null,
257
+ tenantId: tenantId || null,
258
+ });
259
+
260
+ if (existing) {
261
+ return res.status(409).json({ error: 'Page with this slug already exists in this collection' });
262
+ }
263
+
264
+ const page = await Page.create({
265
+ slug: validatedSlug,
266
+ collectionId: collectionId || null,
267
+ title: title || validatedSlug,
268
+ templateKey: templateKey || 'default',
269
+ layoutKey: layoutKey || 'default',
270
+ blocks: blocks || [],
271
+ repeat: repeat === undefined ? null : repeat,
272
+ customCss: customCss || '',
273
+ customJs: customJs || '',
274
+ seoMeta: seoMeta || {},
275
+ tenantId: tenantId || null,
276
+ isGlobal: isGlobal !== false,
277
+ status: status || 'draft',
278
+ });
279
+
280
+ await createAuditEvent({
281
+ ...actor,
282
+ action: 'page.create',
283
+ entityType: 'Page',
284
+ entityId: String(page._id),
285
+ before: null,
286
+ after: page.toObject(),
287
+ meta: null,
288
+ });
289
+
290
+ res.status(201).json({ page: page.toObject() });
291
+ } catch (err) {
292
+ if (err.code === 'VALIDATION') {
293
+ return res.status(400).json({ error: err.message });
294
+ }
295
+ console.error('[adminPages] createPage error:', err);
296
+ res.status(500).json({ error: 'Failed to create page' });
297
+ }
298
+ };
299
+
300
+ exports.updatePage = async (req, res) => {
301
+ try {
302
+ const actor = getBasicAuthActor(req);
303
+ const { id } = req.params;
304
+ const {
305
+ slug,
306
+ collectionId,
307
+ title,
308
+ templateKey,
309
+ layoutKey,
310
+ blocks,
311
+ repeat,
312
+ customCss,
313
+ customJs,
314
+ seoMeta,
315
+ tenantId,
316
+ isGlobal,
317
+ status,
318
+ } = req.body;
319
+
320
+ const existing = await Page.findById(id);
321
+ if (!existing) {
322
+ return res.status(404).json({ error: 'Page not found' });
323
+ }
324
+
325
+ const before = existing.toObject();
326
+
327
+ const pagesPrefix = req.app.get('pagesPrefix') || '/';
328
+ const adminPath = req.app.get('adminPath') || '/admin';
329
+
330
+ let collection = null;
331
+ const newCollectionId = collectionId !== undefined ? collectionId : existing.collectionId;
332
+ if (newCollectionId) {
333
+ collection = await PageCollection.findById(newCollectionId).lean();
334
+ if (!collection) {
335
+ return res.status(400).json({ error: 'Collection not found' });
336
+ }
337
+ }
338
+
339
+ if (slug !== undefined) {
340
+ const validatedSlug = pagesService.validatePageSlug(
341
+ slug,
342
+ collection?.slug,
343
+ pagesPrefix,
344
+ adminPath,
345
+ );
346
+ const duplicate = await Page.findOne({
347
+ _id: { $ne: id },
348
+ slug: validatedSlug,
349
+ collectionId: newCollectionId || null,
350
+ tenantId: existing.tenantId,
351
+ });
352
+ if (duplicate) {
353
+ return res.status(409).json({ error: 'Another page with this slug already exists in this collection' });
354
+ }
355
+ existing.slug = validatedSlug;
356
+ }
357
+
358
+ if (collectionId !== undefined) existing.collectionId = collectionId || null;
359
+ if (title !== undefined) existing.title = title;
360
+ if (templateKey !== undefined) existing.templateKey = templateKey;
361
+ if (layoutKey !== undefined) existing.layoutKey = layoutKey;
362
+ if (blocks !== undefined) {
363
+ const blocksSchema = await pagesService.getBlocksSchema();
364
+ pagesService.validateBlocks(blocks || [], blocksSchema);
365
+ existing.blocks = blocks;
366
+ }
367
+ if (repeat !== undefined) existing.repeat = repeat;
368
+ if (customCss !== undefined) existing.customCss = customCss;
369
+ if (customJs !== undefined) existing.customJs = customJs;
370
+ if (seoMeta !== undefined) existing.seoMeta = seoMeta;
371
+ if (tenantId !== undefined) existing.tenantId = tenantId || null;
372
+ if (isGlobal !== undefined) existing.isGlobal = isGlobal;
373
+ if (status !== undefined) {
374
+ if (status === 'published' && existing.status !== 'published') {
375
+ existing.publishedAt = new Date();
376
+ }
377
+ existing.status = status;
378
+ }
379
+
380
+ await existing.save();
381
+
382
+ await createAuditEvent({
383
+ ...actor,
384
+ action: 'page.update',
385
+ entityType: 'Page',
386
+ entityId: String(existing._id),
387
+ before,
388
+ after: existing.toObject(),
389
+ meta: null,
390
+ });
391
+
392
+ res.json({ page: existing.toObject() });
393
+ } catch (err) {
394
+ if (err.code === 'VALIDATION') {
395
+ return res.status(400).json({ error: err.message });
396
+ }
397
+ console.error('[adminPages] updatePage error:', err);
398
+ res.status(500).json({ error: 'Failed to update page' });
399
+ }
400
+ };
401
+
402
+ exports.deletePage = async (req, res) => {
403
+ try {
404
+ const actor = getBasicAuthActor(req);
405
+ const { id } = req.params;
406
+
407
+ const existing = await Page.findById(id);
408
+ if (!existing) {
409
+ return res.status(404).json({ error: 'Page not found' });
410
+ }
411
+
412
+ const before = existing.toObject();
413
+ await Page.deleteOne({ _id: id });
414
+
415
+ await createAuditEvent({
416
+ ...actor,
417
+ action: 'page.delete',
418
+ entityType: 'Page',
419
+ entityId: String(id),
420
+ before,
421
+ after: null,
422
+ meta: null,
423
+ });
424
+
425
+ res.json({ success: true });
426
+ } catch (err) {
427
+ console.error('[adminPages] deletePage error:', err);
428
+ res.status(500).json({ error: 'Failed to delete page' });
429
+ }
430
+ };
431
+
432
+ exports.publishPage = async (req, res) => {
433
+ try {
434
+ const actor = getBasicAuthActor(req);
435
+ const { id } = req.params;
436
+
437
+ const existing = await Page.findById(id);
438
+ if (!existing) {
439
+ return res.status(404).json({ error: 'Page not found' });
440
+ }
441
+
442
+ const before = existing.toObject();
443
+ existing.status = 'published';
444
+ existing.publishedAt = new Date();
445
+ await existing.save();
446
+
447
+ await createAuditEvent({
448
+ ...actor,
449
+ action: 'page.publish',
450
+ entityType: 'Page',
451
+ entityId: String(existing._id),
452
+ before,
453
+ after: existing.toObject(),
454
+ meta: null,
455
+ });
456
+
457
+ res.json({ page: existing.toObject() });
458
+ } catch (err) {
459
+ console.error('[adminPages] publishPage error:', err);
460
+ res.status(500).json({ error: 'Failed to publish page' });
461
+ }
462
+ };
463
+
464
+ exports.unpublishPage = async (req, res) => {
465
+ try {
466
+ const actor = getBasicAuthActor(req);
467
+ const { id } = req.params;
468
+
469
+ const existing = await Page.findById(id);
470
+ if (!existing) {
471
+ return res.status(404).json({ error: 'Page not found' });
472
+ }
473
+
474
+ const before = existing.toObject();
475
+ existing.status = 'draft';
476
+ await existing.save();
477
+
478
+ await createAuditEvent({
479
+ ...actor,
480
+ action: 'page.unpublish',
481
+ entityType: 'Page',
482
+ entityId: String(existing._id),
483
+ before,
484
+ after: existing.toObject(),
485
+ meta: null,
486
+ });
487
+
488
+ res.json({ page: existing.toObject() });
489
+ } catch (err) {
490
+ console.error('[adminPages] unpublishPage error:', err);
491
+ res.status(500).json({ error: 'Failed to unpublish page' });
492
+ }
493
+ };
494
+
495
+ exports.getAvailableTemplates = async (req, res) => {
496
+ try {
497
+ const base = [
498
+ { key: 'default', name: 'Default Template', description: 'Basic page template' },
499
+ { key: 'landing', name: 'Landing Page', description: 'Marketing landing page with hero and CTA sections' },
500
+ { key: 'article', name: 'Article', description: 'Blog post or article layout' },
501
+ { key: 'listing', name: 'Listing', description: 'Grid or list of items' },
502
+ ];
503
+
504
+ let dbFiles = [];
505
+ try {
506
+ dbFiles = await VirtualEjsFile.find({ path: /^pages\/templates\/[^/]+\.ejs$/ }).select('path updatedAt enabled').lean();
507
+ } catch (_) {
508
+ dbFiles = [];
509
+ }
510
+
511
+ const byKey = new Map(base.map((t) => [t.key, t]));
512
+ for (const f of dbFiles) {
513
+ const m = String(f.path || '').match(/^pages\/templates\/([^/]+)\.ejs$/);
514
+ if (!m) continue;
515
+ const key = String(m[1] || '').trim();
516
+ if (!key) continue;
517
+ if (byKey.has(key)) continue;
518
+ byKey.set(key, { key, name: key, description: '' });
519
+ }
520
+
521
+ const templates = Array.from(byKey.values()).sort((a, b) => String(a.key).localeCompare(String(b.key)));
522
+ res.json({ templates });
523
+ } catch (err) {
524
+ console.error('[adminPages] getAvailableTemplates error:', err);
525
+ res.status(500).json({ error: 'Failed to get templates' });
526
+ }
527
+ };
528
+
529
+ exports.getAvailableLayouts = async (req, res) => {
530
+ try {
531
+ const base = [
532
+ { key: 'default', name: 'Default Layout', description: 'Standard layout with header and footer' },
533
+ { key: 'minimal', name: 'Minimal', description: 'Clean layout without navigation' },
534
+ { key: 'sidebar', name: 'Sidebar', description: 'Layout with sidebar navigation' },
535
+ ];
536
+
537
+ let dbFiles = [];
538
+ try {
539
+ dbFiles = await VirtualEjsFile.find({ path: /^pages\/layouts\/[^/]+\.ejs$/ }).select('path updatedAt enabled').lean();
540
+ } catch (_) {
541
+ dbFiles = [];
542
+ }
543
+
544
+ const byKey = new Map(base.map((l) => [l.key, l]));
545
+ for (const f of dbFiles) {
546
+ const m = String(f.path || '').match(/^pages\/layouts\/([^/]+)\.ejs$/);
547
+ if (!m) continue;
548
+ const key = String(m[1] || '').trim();
549
+ if (!key) continue;
550
+ if (byKey.has(key)) continue;
551
+ byKey.set(key, { key, name: key, description: '' });
552
+ }
553
+
554
+ const layouts = Array.from(byKey.values()).sort((a, b) => String(a.key).localeCompare(String(b.key)));
555
+ res.json({ layouts });
556
+ } catch (err) {
557
+ console.error('[adminPages] getAvailableLayouts error:', err);
558
+ res.status(500).json({ error: 'Failed to get layouts' });
559
+ }
560
+ };
561
+
562
+ exports.getAvailableBlocks = async (req, res) => {
563
+ try {
564
+ const schema = await pagesService.getBlocksSchema();
565
+ const defs = schema?.blocks || {};
566
+ const blocks = Object.keys(defs).map((type) => ({
567
+ type,
568
+ name: defs[type]?.label || type,
569
+ description: defs[type]?.description || '',
570
+ }));
571
+ res.json({ blocks });
572
+ } catch (err) {
573
+ console.error('[adminPages] getAvailableBlocks error:', err);
574
+ res.status(500).json({ error: 'Failed to get available blocks' });
575
+ }
576
+ };
577
+
578
+ exports.getBlocksSchema = async (req, res) => {
579
+ try {
580
+ const schema = await pagesService.getBlocksSchema();
581
+ res.json({ schema, alias: pagesService.BLOCKS_SCHEMA_JSON_CONFIG_ALIAS });
582
+ } catch (err) {
583
+ console.error('[adminPages] getBlocksSchema error:', err);
584
+ res.status(500).json({ error: 'Failed to get blocks schema' });
585
+ }
586
+ };
587
+
588
+ function toSafeJsonError(error) {
589
+ const msg = error?.message || 'Operation failed';
590
+ const code = error?.code;
591
+ if (code === 'VALIDATION') return { status: 400, body: { error: msg } };
592
+ if (code === 'NOT_FOUND') return { status: 404, body: { error: msg } };
593
+ if (code === 'TIMEOUT') return { status: 408, body: { error: msg } };
594
+ return { status: 500, body: { error: msg } };
595
+ }
596
+
597
+ exports.testPageContextPhase = async (req, res) => {
598
+ try {
599
+ const { id } = req.params;
600
+ const page = await Page.findById(id).lean();
601
+ if (!page) return res.status(404).json({ error: 'Page not found' });
602
+
603
+ const routePath = req.body?.routePath || (page.collectionId ? '/_test' : '/_test');
604
+ const params = req.body?.params || (page._params || {});
605
+ const mockContext = req.body?.mockContext || null;
606
+
607
+ const startedAt = Date.now();
608
+ const { pageContext, contextBlocks } = await pagesContextService.resolvePageContext({
609
+ page,
610
+ req,
611
+ res,
612
+ routePath,
613
+ params,
614
+ mockContext,
615
+ });
616
+
617
+ res.json({
618
+ ok: true,
619
+ elapsedMs: Date.now() - startedAt,
620
+ contextBlocksCount: contextBlocks.length,
621
+ vars: pageContext.vars,
622
+ });
623
+ } catch (err) {
624
+ const safe = toSafeJsonError(err);
625
+ res.status(safe.status).json(safe.body);
626
+ }
627
+ };
628
+
629
+ exports.testPageContextBlock = async (req, res) => {
630
+ try {
631
+ const { id } = req.params;
632
+ const page = await Page.findById(id).lean();
633
+ if (!page) return res.status(404).json({ error: 'Page not found' });
634
+
635
+ const block = req.body?.block;
636
+ if (!block || typeof block !== 'object') {
637
+ return res.status(400).json({ error: 'block is required' });
638
+ }
639
+
640
+ const type = String(block.type || '').trim();
641
+ if (!type.startsWith('context.')) {
642
+ return res.status(400).json({ error: 'Only context.* blocks can be tested with this endpoint' });
643
+ }
644
+
645
+ const routePath = req.body?.routePath || (page.collectionId ? '/_test' : '/_test');
646
+ const params = req.body?.params || (page._params || {});
647
+ const mockContext = req.body?.mockContext || null;
648
+
649
+ const startedAt = Date.now();
650
+ const synthetic = { ...page, blocks: [block] };
651
+ const { pageContext, contextBlocks } = await pagesContextService.resolvePageContext({
652
+ page: synthetic,
653
+ req,
654
+ res,
655
+ routePath,
656
+ params,
657
+ mockContext,
658
+ });
659
+
660
+ res.json({
661
+ ok: true,
662
+ elapsedMs: Date.now() - startedAt,
663
+ contextBlocksCount: contextBlocks.length,
664
+ vars: pageContext.vars,
665
+ });
666
+ } catch (err) {
667
+ const safe = toSafeJsonError(err);
668
+ res.status(safe.status).json(safe.body);
669
+ }
670
+ };
671
+
672
+ exports.testContextBlockAdhoc = async (req, res) => {
673
+ try {
674
+ const block = req.body?.block;
675
+ if (!block || typeof block !== 'object') {
676
+ return res.status(400).json({ error: 'block is required' });
677
+ }
678
+
679
+ const type = String(block.type || '').trim();
680
+ if (!type.startsWith('context.')) {
681
+ return res.status(400).json({ error: 'Only context.* blocks can be tested with this endpoint' });
682
+ }
683
+
684
+ const mockContext = req.body?.mockContext || null;
685
+ const routePath = req.body?.routePath || '/_test';
686
+ const params = req.body?.params || {};
687
+
688
+ const startedAt = Date.now();
689
+ const syntheticPage = {
690
+ slug: '_test',
691
+ title: 'Test',
692
+ templateKey: 'default',
693
+ layoutKey: 'default',
694
+ blocks: [block],
695
+ repeat: null,
696
+ seoMeta: {},
697
+ customCss: '',
698
+ customJs: '',
699
+ };
700
+
701
+ const { pageContext, contextBlocks } = await pagesContextService.resolvePageContext({
702
+ page: syntheticPage,
703
+ req,
704
+ res,
705
+ routePath,
706
+ params,
707
+ mockContext,
708
+ });
709
+
710
+ res.json({
711
+ ok: true,
712
+ elapsedMs: Date.now() - startedAt,
713
+ contextBlocksCount: contextBlocks.length,
714
+ vars: pageContext.vars,
715
+ });
716
+ } catch (err) {
717
+ const safe = toSafeJsonError(err);
718
+ res.status(safe.status).json(safe.body);
719
+ }
720
+ };