domma-cms 0.3.0 → 0.5.2

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 (150) hide show
  1. package/README.md +3 -3
  2. package/admin/css/admin.css +1 -1
  3. package/admin/dist/domma/domma-tools.css +2313 -0
  4. package/admin/dist/domma/domma-tools.min.js +10 -0
  5. package/admin/index.html +4 -0
  6. package/admin/js/api.js +1 -1
  7. package/admin/js/app.js +8 -4
  8. package/admin/js/config/sidebar-config.js +1 -1
  9. package/admin/js/lib/markdown-toolbar.js +18 -10
  10. package/admin/js/templates/action-editor.html +171 -0
  11. package/admin/js/templates/actions-list.html +19 -0
  12. package/admin/js/templates/api-reference.html +1411 -0
  13. package/admin/js/templates/block-editor.html +158 -0
  14. package/admin/js/templates/blocks.html +8 -0
  15. package/admin/js/templates/collection-editor.html +47 -0
  16. package/admin/js/templates/collection-entries.html +3 -0
  17. package/admin/js/templates/collections.html +51 -4
  18. package/admin/js/templates/documentation.html +258 -0
  19. package/{plugins/form-builder/admin → admin/js}/templates/form-editor.html +238 -199
  20. package/{plugins/form-builder/admin → admin/js}/templates/form-submissions.html +30 -30
  21. package/{plugins/form-builder/admin/templates/forms-list.html → admin/js/templates/forms.html} +17 -17
  22. package/admin/js/templates/login.html +29 -4
  23. package/admin/js/templates/my-profile.html +17 -0
  24. package/admin/js/templates/page-editor.html +39 -0
  25. package/admin/js/templates/pages.html +6 -1
  26. package/admin/js/templates/pro-docs.html +259 -0
  27. package/admin/js/templates/role-editor.html +59 -0
  28. package/admin/js/templates/roles.html +10 -0
  29. package/admin/js/templates/settings.html +167 -23
  30. package/admin/js/templates/tutorials.html +81 -0
  31. package/admin/js/templates/user-editor.html +7 -0
  32. package/admin/js/templates/users.html +3 -26
  33. package/admin/js/templates/view-editor.html +201 -0
  34. package/admin/js/templates/view-preview.html +51 -0
  35. package/admin/js/templates/views-list.html +19 -0
  36. package/admin/js/views/action-editor.js +1 -0
  37. package/admin/js/views/actions-list.js +1 -0
  38. package/admin/js/views/api-reference.js +1 -0
  39. package/admin/js/views/block-editor.js +8 -0
  40. package/admin/js/views/blocks.js +4 -0
  41. package/admin/js/views/collection-editor.js +3 -3
  42. package/admin/js/views/collection-entries.js +1 -1
  43. package/admin/js/views/collections.js +1 -1
  44. package/admin/js/views/dashboard.js +1 -1
  45. package/admin/js/views/form-editor.js +8 -0
  46. package/admin/js/views/form-submissions.js +1 -0
  47. package/admin/js/views/forms.js +1 -0
  48. package/admin/js/views/index.js +1 -1
  49. package/admin/js/views/login.js +2 -2
  50. package/admin/js/views/media.js +1 -1
  51. package/admin/js/views/my-profile.js +1 -0
  52. package/admin/js/views/page-editor.js +34 -15
  53. package/admin/js/views/pages.js +5 -5
  54. package/admin/js/views/plugins.js +10 -10
  55. package/admin/js/views/pro-docs.js +1 -0
  56. package/admin/js/views/role-editor.js +1 -0
  57. package/admin/js/views/roles.js +4 -0
  58. package/admin/js/views/settings.js +3 -1
  59. package/admin/js/views/user-editor.js +1 -1
  60. package/admin/js/views/users.js +4 -7
  61. package/admin/js/views/view-editor.js +1 -0
  62. package/admin/js/views/view-preview.js +1 -0
  63. package/admin/js/views/views-list.js +1 -0
  64. package/bin/cli.js +1 -1
  65. package/config/auth.json +1 -0
  66. package/config/connections.json.bak +9 -0
  67. package/config/connections.json.example +9 -0
  68. package/config/navigation.json +5 -15
  69. package/config/plugins.json +19 -29
  70. package/config/server.json +6 -6
  71. package/config/site.json +16 -6
  72. package/package.json +25 -10
  73. package/plugins/example-analytics/stats.json +17 -12
  74. package/plugins/form-builder/data/forms/contacts.json +62 -62
  75. package/plugins/form-builder/data/forms/enquiries.json +103 -0
  76. package/plugins/form-builder/data/forms/feedback.json +17 -16
  77. package/plugins/form-builder/data/forms/notes.json +79 -0
  78. package/plugins/form-builder/data/forms/to-do.json +100 -0
  79. package/plugins/form-builder/data/submissions/contacts.json +1 -26
  80. package/plugins/form-builder/data/submissions/notes.json +1 -0
  81. package/plugins/form-builder/data/submissions/to-do.json +1 -0
  82. package/plugins/theme-roller/admin/templates/theme-roller.html +71 -0
  83. package/plugins/theme-roller/admin/views/theme-roller-view.js +403 -0
  84. package/plugins/theme-roller/config.js +1 -0
  85. package/plugins/theme-roller/plugin.js +233 -0
  86. package/plugins/theme-roller/plugin.json +31 -0
  87. package/plugins/theme-roller/public/active-theme.css +0 -0
  88. package/plugins/theme-roller/public/inject-head-late.html +1 -0
  89. package/public/css/forms.css +1 -0
  90. package/public/css/site.css +1 -1
  91. package/public/js/forms.js +1 -0
  92. package/public/js/site.js +1 -1
  93. package/scripts/build.js +194 -129
  94. package/scripts/pro.js +254 -0
  95. package/scripts/reset.js +33 -8
  96. package/scripts/seed.js +677 -128
  97. package/scripts/setup.js +1 -0
  98. package/server/middleware/auth.js +136 -120
  99. package/server/routes/api/actions.js +200 -0
  100. package/server/routes/api/auth.js +292 -146
  101. package/server/routes/api/blocks.js +84 -0
  102. package/server/routes/api/collections.js +79 -27
  103. package/{plugins/form-builder/plugin.js → server/routes/api/forms.js} +491 -505
  104. package/server/routes/api/layouts.js +49 -39
  105. package/server/routes/api/media.js +118 -92
  106. package/server/routes/api/navigation.js +40 -36
  107. package/server/routes/api/pages.js +132 -118
  108. package/server/routes/api/plugins.js +6 -3
  109. package/server/routes/api/settings.js +104 -88
  110. package/server/routes/api/users.js +27 -19
  111. package/server/routes/api/views.js +148 -0
  112. package/server/routes/public.js +124 -108
  113. package/server/server.js +269 -181
  114. package/server/services/actions.js +387 -0
  115. package/server/services/adapterRegistry.js +98 -0
  116. package/server/services/adapters/FileAdapter.js +192 -0
  117. package/server/services/adapters/MongoAdapter.js +220 -0
  118. package/server/services/blocks.js +162 -0
  119. package/server/services/collections.js +74 -86
  120. package/server/services/connectionManager.js +102 -0
  121. package/server/services/content.js +312 -307
  122. package/server/services/email.js +126 -0
  123. package/server/services/forms.js +173 -0
  124. package/server/services/markdown.js +1378 -747
  125. package/server/services/permissionRegistry.js +173 -0
  126. package/server/services/presetCollections.js +251 -0
  127. package/server/services/renderer.js +98 -2
  128. package/server/services/roles.js +227 -0
  129. package/server/services/rowAccess.js +104 -0
  130. package/server/services/userProfiles.js +199 -0
  131. package/server/services/users.js +281 -212
  132. package/server/services/views.js +280 -0
  133. package/server/templates/page.html +124 -113
  134. package/plugins/form-builder/admin/templates/form-settings.html +0 -29
  135. package/plugins/form-builder/admin/views/form-editor.js +0 -1444
  136. package/plugins/form-builder/admin/views/form-settings.js +0 -38
  137. package/plugins/form-builder/admin/views/form-submissions.js +0 -295
  138. package/plugins/form-builder/admin/views/forms-list.js +0 -164
  139. package/plugins/form-builder/config.js +0 -9
  140. package/plugins/form-builder/data/forms/consent.json +0 -104
  141. package/plugins/form-builder/data/forms/contact-details.json +0 -99
  142. package/plugins/form-builder/data/submissions/consent.json +0 -13
  143. package/plugins/form-builder/plugin.json +0 -52
  144. package/plugins/form-builder/public/inject-body.html +0 -352
  145. package/plugins/form-builder/public/inject-head.html +0 -58
  146. package/plugins/form-builder/public/package.json +0 -1
  147. package/scripts/copy-domma.js +0 -48
  148. package/server/services/userTypes.js +0 -167
  149. /package/plugins/form-builder/data/submissions/{contact-details.json → enquiries.json} +0 -0
  150. /package/{plugins/form-builder/public → public/js}/form-logic-engine.js +0 -0
@@ -4,12 +4,15 @@
4
4
  * PUT /api/plugins/:name - enable/disable, update settings (admin only)
5
5
  * GET /api/plugins/admin-config - sidebar/routes/views for enabled plugins (authenticated)
6
6
  */
7
- import { authenticate, requireAdmin } from '../../middleware/auth.js';
7
+ import { authenticate, requireAdmin, requirePermission } from '../../middleware/auth.js';
8
8
  import { discoverPlugins, getPluginStates, savePluginState, getAdminPluginConfig } from '../../services/plugins.js';
9
9
 
10
10
  export async function pluginsRoutes(fastify) {
11
+ const canRead = { preHandler: [authenticate, requirePermission('plugins', 'read')] };
12
+ const canUpdate = { preHandler: [authenticate, requirePermission('plugins', 'update')] };
13
+
11
14
  // List all plugins with their current state
12
- fastify.get('/plugins', { preHandler: [authenticate, requireAdmin] }, async () => {
15
+ fastify.get('/plugins', canRead, async () => {
13
16
  const manifests = await discoverPlugins();
14
17
  const states = getPluginStates();
15
18
 
@@ -27,7 +30,7 @@ export async function pluginsRoutes(fastify) {
27
30
  });
28
31
 
29
32
  // Enable/disable or update settings for a plugin
30
- fastify.put('/plugins/:name', { preHandler: [authenticate, requireAdmin] }, async (request, reply) => {
33
+ fastify.put('/plugins/:name', canUpdate, async (request, reply) => {
31
34
  const { name } = request.params;
32
35
  const { enabled, settings } = request.body || {};
33
36
 
@@ -1,88 +1,104 @@
1
- /**
2
- * Settings API
3
- * GET /api/settings - get site settings
4
- * PUT /api/settings - save site settings
5
- * POST /api/settings/test-email - send a test email using stored SMTP config
6
- */
7
- import { getConfig, saveConfig } from '../../config.js';
8
- import { authenticate, requirePermission } from '../../middleware/auth.js';
9
- import nodemailer from 'nodemailer';
10
- import fs from 'fs/promises';
11
- import path from 'path';
12
- import {fileURLToPath} from 'url';
13
-
14
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
15
- const CUSTOM_CSS_FILE = path.resolve(__dirname, '../../../content/custom.css');
16
- const CUSTOM_CSS_MAX = 100 * 1024; // 100 KB
17
-
18
- export async function settingsRoutes(fastify) {
19
- const guard = { preHandler: [authenticate, requirePermission('settings')] };
20
-
21
- fastify.get('/settings', guard, async () => {
22
- return getConfig('site');
23
- });
24
-
25
- fastify.put('/settings', guard, async (request, reply) => {
26
- const data = request.body;
27
- if (!data || typeof data !== 'object') {
28
- return reply.status(400).send({ error: 'Invalid settings data' });
29
- }
30
- saveConfig('site', data);
31
- return { success: true };
32
- });
33
-
34
- fastify.post('/settings/test-email', guard, async (request, reply) => {
35
- const smtp = getConfig('site')?.smtp;
36
- if (!smtp?.host) {
37
- return reply.status(400).send({ error: 'SMTP is not configured. Save your SMTP settings first.' });
38
- }
39
-
40
- const transporter = nodemailer.createTransport({
41
- host: smtp.host,
42
- port: smtp.port || 587,
43
- secure: smtp.secure || false,
44
- auth: smtp.user ? { user: smtp.user, pass: smtp.pass } : undefined
45
- });
46
-
47
- const to = request.body?.to || smtp.fromAddress;
48
- if (!to) {
49
- return reply.status(400).send({ error: 'No recipient address. Provide "to" in the request body or set a From Address.' });
50
- }
51
-
52
- try {
53
- await transporter.sendMail({
54
- from: smtp.fromName ? `"${smtp.fromName}" <${smtp.fromAddress}>` : smtp.fromAddress,
55
- to,
56
- subject: 'Domma CMS — Test Email',
57
- text: 'This is a test email sent from Domma CMS to verify your SMTP configuration.',
58
- html: '<p>This is a test email sent from <strong>Domma CMS</strong> to verify your SMTP configuration.</p>'
59
- });
60
- return { success: true, message: `Test email sent to ${to}` };
61
- } catch (err) {
62
- return reply.status(500).send({ error: `Failed to send email: ${err.message}` });
63
- }
64
- });
65
-
66
- // GET /api/settings/custom-css — return current CSS as JSON
67
- fastify.get('/settings/custom-css', guard, async () => {
68
- try {
69
- const css = await fs.readFile(CUSTOM_CSS_FILE, 'utf8');
70
- return { css };
71
- } catch {
72
- return { css: '' };
73
- }
74
- });
75
-
76
- // PUT /api/settings/custom-css save CSS to content/custom.css
77
- fastify.put('/settings/custom-css', guard, async (request, reply) => {
78
- const { css } = request.body || {};
79
- if (typeof css !== 'string') {
80
- return reply.status(400).send({ error: 'css must be a string.' });
81
- }
82
- if (Buffer.byteLength(css, 'utf8') > CUSTOM_CSS_MAX) {
83
- return reply.status(400).send({ error: 'CSS exceeds the 100 KB limit.' });
84
- }
85
- await fs.writeFile(CUSTOM_CSS_FILE, css, 'utf8');
86
- return { success: true };
87
- });
88
- }
1
+ /**
2
+ * Settings API
3
+ * GET /api/settings - get site settings
4
+ * PUT /api/settings - save site settings
5
+ * POST /api/settings/test-email - send a test email using stored SMTP config
6
+ */
7
+ import {getConfig, saveConfig} from '../../config.js';
8
+ import {authenticate, requirePermission} from '../../middleware/auth.js';
9
+ import nodemailer from 'nodemailer';
10
+ import fs from 'fs/promises';
11
+ import path from 'path';
12
+ import {fileURLToPath} from 'url';
13
+
14
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
15
+ const CUSTOM_CSS_FILE = path.resolve(__dirname, '../../../content/custom.css');
16
+ const CUSTOM_CSS_MAX = 100 * 1024; // 100 KB
17
+
18
+ export async function settingsRoutes(fastify) {
19
+ const canRead = {preHandler: [authenticate, requirePermission('settings', 'read')]};
20
+ const canUpdate = {preHandler: [authenticate, requirePermission('settings', 'update')]};
21
+
22
+ fastify.get('/settings', canRead, async (request, reply) => {
23
+ const config = getConfig('site');
24
+ const safeConfig = { ...config };
25
+ if (safeConfig.smtp) {
26
+ safeConfig.smtp = { ...safeConfig.smtp, pass: '' };
27
+ }
28
+ return reply.send(safeConfig);
29
+ });
30
+
31
+ fastify.put('/settings', canUpdate, async (request, reply) => {
32
+ const data = request.body;
33
+ if (!data || typeof data !== 'object') {
34
+ return reply.status(400).send({ error: 'Invalid settings data' });
35
+ }
36
+ const ALLOWED_KEYS = new Set([
37
+ 'title', 'tagline', 'description', 'logo', 'favicon',
38
+ 'theme', 'adminTheme', 'fontFamily', 'fontSize',
39
+ 'smtp', 'footer', 'analytics', 'social', 'locale',
40
+ 'layoutOptions', 'seo', 'backToTop', 'cookieConsent', 'breadcrumbs', 'autoTheme'
41
+ ]);
42
+ const unknownKeys = Object.keys(data).filter(k => !ALLOWED_KEYS.has(k));
43
+ if (unknownKeys.length > 0) {
44
+ return reply.status(400).send({ error: `Unknown settings keys: ${unknownKeys.join(', ')}` });
45
+ }
46
+ saveConfig('site', data);
47
+ return { success: true };
48
+ });
49
+
50
+ fastify.post('/settings/test-email', canRead, async (request, reply) => {
51
+ const smtp = getConfig('site')?.smtp;
52
+ if (!smtp?.host) {
53
+ return reply.status(400).send({ error: 'SMTP is not configured. Save your SMTP settings first.' });
54
+ }
55
+
56
+ const transporter = nodemailer.createTransport({
57
+ host: smtp.host,
58
+ port: smtp.port || 587,
59
+ secure: smtp.secure || false,
60
+ auth: smtp.user ? { user: smtp.user, pass: smtp.pass } : undefined
61
+ });
62
+
63
+ const to = request.body?.to || smtp.fromAddress;
64
+ if (!to) {
65
+ return reply.status(400).send({ error: 'No recipient address. Provide "to" in the request body or set a From Address.' });
66
+ }
67
+
68
+ try {
69
+ await transporter.sendMail({
70
+ from: smtp.fromName ? `"${smtp.fromName}" <${smtp.fromAddress}>` : smtp.fromAddress,
71
+ to,
72
+ subject: 'Domma CMS — Test Email',
73
+ text: 'This is a test email sent from Domma CMS to verify your SMTP configuration.',
74
+ html: '<p>This is a test email sent from <strong>Domma CMS</strong> to verify your SMTP configuration.</p>'
75
+ });
76
+ return { success: true, message: `Test email sent to ${to}` };
77
+ } catch (err) {
78
+ return reply.status(500).send({ error: `Failed to send email: ${err.message}` });
79
+ }
80
+ });
81
+
82
+ // GET /api/settings/custom-css return current CSS as JSON
83
+ fastify.get('/settings/custom-css', canUpdate, async () => {
84
+ try {
85
+ const css = await fs.readFile(CUSTOM_CSS_FILE, 'utf8');
86
+ return { css };
87
+ } catch {
88
+ return { css: '' };
89
+ }
90
+ });
91
+
92
+ // PUT /api/settings/custom-css — save CSS to content/custom.css
93
+ fastify.put('/settings/custom-css', canUpdate, async (request, reply) => {
94
+ const { css } = request.body || {};
95
+ if (typeof css !== 'string') {
96
+ return reply.status(400).send({ error: 'css must be a string.' });
97
+ }
98
+ if (Buffer.byteLength(css, 'utf8') > CUSTOM_CSS_MAX) {
99
+ return reply.status(400).send({ error: 'CSS exceeds the 100 KB limit.' });
100
+ }
101
+ await fs.writeFile(CUSTOM_CSS_FILE, css, 'utf8');
102
+ return { success: true };
103
+ });
104
+ }
@@ -6,22 +6,21 @@
6
6
  * PUT /api/users/:id - update user (admin, manager — manager cannot edit admin)
7
7
  * DELETE /api/users/:id - delete user (admin, manager — manager cannot delete admin, no self-delete)
8
8
  */
9
- import { authenticate, requirePermission, canManageUser } from '../../middleware/auth.js';
10
- import { getPermissionsFor } from '../../services/userTypes.js';
11
- import {
12
- listUsers,
13
- getUserById,
14
- createUser,
15
- updateUser,
16
- deleteUser
17
- } from '../../services/users.js';
9
+ import {authenticate, canManageUser, requirePermission} from '../../middleware/auth.js';
10
+ import {getPermissionsFor} from '../../services/roles.js';
11
+ import {createUser, deleteUser, getUserById, listUsers, updateUser} from '../../services/users.js';
12
+ import {getProfile, listProfiles, updateProfile} from '../../services/userProfiles.js';
18
13
 
19
14
  export async function usersRoutes(fastify) {
20
- const guard = [authenticate, requirePermission('users')];
15
+ const canRead = [authenticate, requirePermission('users', 'read')];
16
+ const canCreate = [authenticate, requirePermission('users', 'create')];
17
+ const canUpdate = [authenticate, requirePermission('users', 'update')];
18
+ const canDelete = [authenticate, requirePermission('users', 'delete')];
21
19
 
22
20
  // List all users
23
- fastify.get('/users', { preHandler: guard }, async () => {
24
- return listUsers();
21
+ fastify.get('/users', {preHandler: canRead}, async () => {
22
+ const [users, profiles] = await Promise.all([listUsers(), listProfiles()]);
23
+ return users.map(u => ({...u, profile: profiles.get(u.id) || {}}));
25
24
  });
26
25
 
27
26
  // Get single user (admin, manager, or the user themselves)
@@ -30,7 +29,7 @@ export async function usersRoutes(fastify) {
30
29
  const actor = request.user;
31
30
 
32
31
  const isSelf = actor.id === id;
33
- const canManage = getPermissionsFor('users').includes(actor.role);
32
+ const canManage = getPermissionsFor('users', 'read').includes(actor.role);
34
33
 
35
34
  if (!isSelf && !canManage) {
36
35
  return reply.code(403).send({ error: 'Forbidden' });
@@ -38,11 +37,12 @@ export async function usersRoutes(fastify) {
38
37
 
39
38
  const user = await getUserById(id);
40
39
  if (!user) return reply.status(404).send({ error: 'User not found' });
41
- return user;
40
+ const profileEntry = await getProfile(id);
41
+ return {...user, profile: profileEntry?.data || {}};
42
42
  });
43
43
 
44
44
  // Create user
45
- fastify.post('/users', { preHandler: guard }, async (request, reply) => {
45
+ fastify.post('/users', {preHandler: canCreate}, async (request, reply) => {
46
46
  const { name, email, password, role } = request.body || {};
47
47
  if (!name || !email || !password) {
48
48
  return reply.status(400).send({ error: 'name, email and password are required' });
@@ -65,7 +65,7 @@ export async function usersRoutes(fastify) {
65
65
  });
66
66
 
67
67
  // Update user
68
- fastify.put('/users/:id', { preHandler: guard }, async (request, reply) => {
68
+ fastify.put('/users/:id', {preHandler: canUpdate}, async (request, reply) => {
69
69
  const { id } = request.params;
70
70
  const actor = request.user;
71
71
 
@@ -82,12 +82,20 @@ export async function usersRoutes(fastify) {
82
82
  return reply.code(403).send({ error: 'You cannot assign that role' });
83
83
  }
84
84
 
85
- const user = await updateUser(id, updates);
86
- return user;
85
+ // Strip profile from core updates and handle separately
86
+ const {profile, ...coreUpdates} = updates;
87
+ const user = await updateUser(id, coreUpdates);
88
+
89
+ if (profile && typeof profile === 'object') {
90
+ await updateProfile(id, profile);
91
+ }
92
+
93
+ const profileEntry = await getProfile(id);
94
+ return {...user, profile: profileEntry?.data || {}};
87
95
  });
88
96
 
89
97
  // Delete user
90
- fastify.delete('/users/:id', { preHandler: guard }, async (request, reply) => {
98
+ fastify.delete('/users/:id', {preHandler: canDelete}, async (request, reply) => {
91
99
  const { id } = request.params;
92
100
  const actor = request.user;
93
101
 
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Views API (Pro — requires MongoDB)
3
+ *
4
+ * Admin endpoints (authenticated + views permission):
5
+ * GET /views - List all view configs
6
+ * POST /views - Create view config
7
+ * GET /views/:slug - Get view config
8
+ * PUT /views/:slug - Update view config
9
+ * DELETE /views/:slug - Delete view config
10
+ * GET /views/:slug/execute - Execute view (admin)
11
+ * GET /views/collection/:slug - List views targeting a collection
12
+ *
13
+ * Public endpoint (role-checked per view access config):
14
+ * GET /views/:slug/public - Execute view publicly
15
+ */
16
+ import {createView, deleteView, executeView, getView, listViews, updateView} from '../../services/views.js';
17
+ import {authenticate, requirePermission} from '../../middleware/auth.js';
18
+ import {getRoleLevel} from '../../services/roles.js';
19
+
20
+ export async function viewsRoutes(fastify) {
21
+ const canRead = {preHandler: [authenticate, requirePermission('views', 'read')]};
22
+ const canCreate = {preHandler: [authenticate, requirePermission('views', 'create')]};
23
+ const canUpdate = {preHandler: [authenticate, requirePermission('views', 'update')]};
24
+ const canDelete = {preHandler: [authenticate, requirePermission('views', 'delete')]};
25
+
26
+ // -------------------------------------------------------------------------
27
+ // Admin CRUD
28
+ // -------------------------------------------------------------------------
29
+
30
+ fastify.get('/views', canRead, async (request, reply) => {
31
+ try {
32
+ return await listViews();
33
+ } catch (err) {
34
+ return reply.status(503).send({ error: err.message });
35
+ }
36
+ });
37
+
38
+ fastify.post('/views', canCreate, async (request, reply) => {
39
+ try {
40
+ const view = await createView(request.body || {}, request.user?.id || null);
41
+ return reply.status(201).send(view);
42
+ } catch (err) {
43
+ const status = err.message.includes('already exists') ? 409 : 400;
44
+ return reply.status(status).send({ error: err.message });
45
+ }
46
+ });
47
+
48
+ // Static sub-routes must be declared before the parameterised :slug route
49
+ // so Fastify's radix router gives them priority.
50
+ fastify.get('/views/collection/:collectionSlug', canRead, async (request, reply) => {
51
+ try {
52
+ const allViews = await listViews();
53
+ return allViews.filter(v => v.pipeline?.source === request.params.collectionSlug);
54
+ } catch (err) {
55
+ return reply.status(503).send({ error: err.message });
56
+ }
57
+ });
58
+
59
+ fastify.get('/views/:slug', canRead, async (request, reply) => {
60
+ try {
61
+ const view = await getView(request.params.slug);
62
+ if (!view) return reply.status(404).send({ error: 'View not found' });
63
+ return view;
64
+ } catch (err) {
65
+ return reply.status(503).send({ error: err.message });
66
+ }
67
+ });
68
+
69
+ fastify.put('/views/:slug', canUpdate, async (request, reply) => {
70
+ try {
71
+ return await updateView(request.params.slug, request.body || {});
72
+ } catch (err) {
73
+ const status = err.message.includes('not found') ? 404 : 400;
74
+ return reply.status(status).send({ error: err.message });
75
+ }
76
+ });
77
+
78
+ fastify.delete('/views/:slug', canDelete, async (request, reply) => {
79
+ try {
80
+ await deleteView(request.params.slug);
81
+ return { success: true };
82
+ } catch (err) {
83
+ const status = err.message.includes('not found') ? 404 : 400;
84
+ return reply.status(status).send({ error: err.message });
85
+ }
86
+ });
87
+
88
+ // -------------------------------------------------------------------------
89
+ // Execute (admin)
90
+ // -------------------------------------------------------------------------
91
+
92
+ fastify.get('/views/:slug/execute', canRead, async (request, reply) => {
93
+ const { page, limit } = request.query;
94
+ try {
95
+ return await executeView(request.params.slug, {
96
+ page: parseInt(page, 10) || 1,
97
+ limit: parseInt(limit, 10) || 25,
98
+ user: request.user || null
99
+ });
100
+ } catch (err) {
101
+ const status = err.message.includes('not found') ? 404 : 400;
102
+ return reply.status(status).send({ error: err.message });
103
+ }
104
+ });
105
+
106
+ // -------------------------------------------------------------------------
107
+ // Public execute (role-checked per view access config)
108
+ // -------------------------------------------------------------------------
109
+
110
+ fastify.get('/views/:slug/public', async (request, reply) => {
111
+ let view;
112
+ try {
113
+ view = await getView(request.params.slug);
114
+ } catch (err) {
115
+ return reply.status(503).send({ error: err.message });
116
+ }
117
+
118
+ if (!view) return reply.status(404).send({ error: 'View not found' });
119
+
120
+ if (!view.access?.public) {
121
+ try {
122
+ await request.jwtVerify();
123
+ } catch {
124
+ return reply.status(401).send({ error: 'Unauthorised' });
125
+ }
126
+
127
+ const user = request.user;
128
+ const allowedRoles = view.access?.roles || ['admin'];
129
+ const userLevel = getRoleLevel(user?.role);
130
+ const minAllowed = Math.min(...allowedRoles.map(r => getRoleLevel(r)));
131
+
132
+ if (userLevel > minAllowed) {
133
+ return reply.status(403).send({ error: 'Insufficient permissions' });
134
+ }
135
+ }
136
+
137
+ const { page, limit } = request.query;
138
+ try {
139
+ return await executeView(view.slug, {
140
+ page: parseInt(page, 10) || 1,
141
+ limit: parseInt(limit, 10) || 25,
142
+ user: request.user || null
143
+ });
144
+ } catch (err) {
145
+ return reply.status(400).send({ error: err.message });
146
+ }
147
+ });
148
+ }