domma-cms 0.17.0 → 0.21.0

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 (136) hide show
  1. package/CLAUDE.md +39 -3
  2. package/admin/css/admin.css +1 -1
  3. package/admin/css/dashboard.css +1 -1
  4. package/admin/index.html +2 -2
  5. package/admin/js/api.js +1 -1
  6. package/admin/js/app.js +4 -4
  7. package/admin/js/config/sidebar-config.js +1 -1
  8. package/admin/js/lib/card-builder.js +3 -3
  9. package/admin/js/lib/crud-tutorial.js +1 -0
  10. package/admin/js/lib/effects-builder.js +1 -1
  11. package/admin/js/lib/markdown-toolbar.js +6 -6
  12. package/admin/js/lib/project-context.js +1 -0
  13. package/admin/js/lib/sidebar-renderer.js +4 -0
  14. package/admin/js/templates/action-editor.html +7 -0
  15. package/admin/js/templates/block-editor.html +7 -0
  16. package/admin/js/templates/collection-editor.html +9 -0
  17. package/admin/js/templates/dashboard/cache.html +32 -0
  18. package/admin/js/templates/dashboard.html +4 -0
  19. package/admin/js/templates/form-editor.html +9 -0
  20. package/admin/js/templates/menu-editor.html +98 -0
  21. package/admin/js/templates/menu-locations.html +14 -0
  22. package/admin/js/templates/menus.html +14 -0
  23. package/admin/js/templates/page-editor.html +9 -2
  24. package/admin/js/templates/project-detail.html +50 -0
  25. package/admin/js/templates/project-editor.html +45 -0
  26. package/admin/js/templates/project-settings.html +60 -0
  27. package/admin/js/templates/projects.html +13 -0
  28. package/admin/js/templates/role-editor.html +11 -0
  29. package/admin/js/templates/settings.html +26 -0
  30. package/admin/js/templates/tutorials.html +335 -2
  31. package/admin/js/templates/view-editor.html +7 -0
  32. package/admin/js/views/action-editor.js +1 -1
  33. package/admin/js/views/actions-list.js +1 -1
  34. package/admin/js/views/block-editor-enhance.js +1 -1
  35. package/admin/js/views/block-editor.js +8 -8
  36. package/admin/js/views/blocks.js +2 -2
  37. package/admin/js/views/collection-editor.js +4 -4
  38. package/admin/js/views/collections.js +1 -1
  39. package/admin/js/views/dashboard/widgets/activity-feed.js +1 -1
  40. package/admin/js/views/dashboard/widgets/cache.js +1 -0
  41. package/admin/js/views/dashboard/widgets/journeys.js +1 -1
  42. package/admin/js/views/dashboard/widgets/spike-feed.js +1 -1
  43. package/admin/js/views/dashboard/widgets/top-pages.js +1 -1
  44. package/admin/js/views/dashboard.js +1 -1
  45. package/admin/js/views/form-editor.js +6 -6
  46. package/admin/js/views/forms.js +1 -1
  47. package/admin/js/views/index.js +1 -1
  48. package/admin/js/views/menu-editor.js +19 -0
  49. package/admin/js/views/menu-locations.js +1 -0
  50. package/admin/js/views/menus.js +5 -0
  51. package/admin/js/views/page-editor.js +41 -36
  52. package/admin/js/views/pages.js +3 -3
  53. package/admin/js/views/project-detail.js +4 -0
  54. package/admin/js/views/project-editor.js +1 -0
  55. package/admin/js/views/project-settings.js +1 -0
  56. package/admin/js/views/projects.js +7 -0
  57. package/admin/js/views/role-editor.js +1 -1
  58. package/admin/js/views/roles.js +3 -3
  59. package/admin/js/views/settings.js +3 -3
  60. package/admin/js/views/tutorials.js +1 -1
  61. package/admin/js/views/user-editor.js +1 -1
  62. package/admin/js/views/users.js +3 -3
  63. package/admin/js/views/view-editor.js +1 -1
  64. package/admin/js/views/views-list.js +1 -1
  65. package/config/cache.json +4 -0
  66. package/config/cache.json.example +12 -0
  67. package/config/menu-locations.json +5 -0
  68. package/config/menus/admin-sidebar.json +185 -0
  69. package/config/menus/footer.json +33 -0
  70. package/config/menus/main.json +35 -0
  71. package/config/menus/sproj-1779696558011-menu.json +17 -0
  72. package/config/menus/sproj-1779696960337-menu.json +18 -0
  73. package/config/menus/sproj-1779696985353-menu.json +18 -0
  74. package/config/site.json +6 -22
  75. package/package.json +4 -3
  76. package/plugins/analytics/daily.json +3 -0
  77. package/plugins/analytics/journeys.json +8 -0
  78. package/plugins/analytics/lifetime.json +1 -1
  79. package/public/css/site.css +1 -1
  80. package/public/js/collection-browser.js +4 -0
  81. package/public/js/forms.js +1 -1
  82. package/public/js/site.js +1 -1
  83. package/server/config.js +12 -1
  84. package/server/middleware/auth.js +88 -22
  85. package/server/routes/api/actions.js +58 -5
  86. package/server/routes/api/auth.js +2 -2
  87. package/server/routes/api/blocks.js +18 -3
  88. package/server/routes/api/cache.js +57 -0
  89. package/server/routes/api/collections.js +201 -8
  90. package/server/routes/api/forms.js +266 -21
  91. package/server/routes/api/menu-locations.js +46 -0
  92. package/server/routes/api/menus.js +115 -0
  93. package/server/routes/api/navigation.js +2 -0
  94. package/server/routes/api/pages.js +1 -1
  95. package/server/routes/api/projects.js +107 -0
  96. package/server/routes/api/scaffold.js +86 -0
  97. package/server/routes/api/settings.js +3 -0
  98. package/server/routes/api/sidebar.js +23 -0
  99. package/server/routes/api/users.js +32 -7
  100. package/server/routes/api/views.js +10 -2
  101. package/server/routes/public.js +88 -7
  102. package/server/server.js +54 -3
  103. package/server/services/actions.js +137 -8
  104. package/server/services/adapters/FileAdapter.js +23 -8
  105. package/server/services/adapters/MongoAdapter.js +36 -18
  106. package/server/services/blocks.js +23 -8
  107. package/server/services/cache/drivers/MemoryDriver.js +118 -0
  108. package/server/services/cache/drivers/NoneDriver.js +12 -0
  109. package/server/services/cache/index.js +229 -0
  110. package/server/services/cache/lru.js +61 -0
  111. package/server/services/collections.js +102 -12
  112. package/server/services/content.js +25 -6
  113. package/server/services/filterEngine.js +281 -0
  114. package/server/services/forms.js +3 -0
  115. package/server/services/hooks.js +48 -0
  116. package/server/services/markdown.js +711 -124
  117. package/server/services/menus-migration.js +107 -0
  118. package/server/services/menus.js +422 -0
  119. package/server/services/permissionRegistry.js +26 -0
  120. package/server/services/plugins.js +9 -2
  121. package/server/services/presetCollections.js +22 -0
  122. package/server/services/projects.js +429 -0
  123. package/server/services/recipes/contact-list.json +78 -0
  124. package/server/services/recipes/onboarding.json +426 -0
  125. package/server/services/references.js +174 -0
  126. package/server/services/renderer.js +237 -40
  127. package/server/services/roles.js +6 -1
  128. package/server/services/rowAccess.js +86 -13
  129. package/server/services/scaffolder.js +465 -0
  130. package/server/services/sidebar-migration.js +117 -0
  131. package/server/services/sitemap.js +112 -0
  132. package/server/services/userRoles.js +86 -0
  133. package/server/services/users.js +23 -2
  134. package/server/services/views.js +19 -4
  135. package/server/templates/page.html +135 -130
  136. /package/config/{navigation.json → navigation.json.bak} +0 -0
@@ -6,8 +6,31 @@
6
6
  */
7
7
  import {getPage} from '../services/content.js';
8
8
  import {renderPage} from '../services/renderer.js';
9
+ import {buildRobotsTxt, generate as generateSitemap} from '../services/sitemap.js';
9
10
  import {checkVisibility} from '../middleware/auth.js';
10
11
  import {hooks} from '../services/hooks.js';
12
+ import {getConfig} from '../config.js';
13
+ import * as cache from '../services/cache/index.js';
14
+
15
+ /**
16
+ * Derive the absolute origin for the current request.
17
+ *
18
+ * Honours `site.baseUrl` as an explicit override (useful when the CMS sits
19
+ * behind infrastructure that doesn't forward Host correctly). Otherwise
20
+ * builds it from `request.protocol` + `request.host` — both of which Fastify
21
+ * resolves from `X-Forwarded-*` headers when `trustProxy` is on. `request.host`
22
+ * includes the port for non-default ports (e.g. `localhost:4096` in dev) and
23
+ * omits it for the standard 80/443.
24
+ *
25
+ * @param {import('fastify').FastifyRequest} request
26
+ * @returns {string} e.g. 'https://example.com' (no trailing slash)
27
+ */
28
+ function getBaseUrl(request) {
29
+ const override = (getConfig('site') || {}).baseUrl;
30
+ if (override) return override.toString().trim().replace(/\/+$/, '');
31
+ const host = request.host || request.headers.host || request.hostname;
32
+ return `${request.protocol}://${host}`;
33
+ }
11
34
 
12
35
  /**
13
36
  * Escape user-controlled strings before interpolating into HTML.
@@ -34,6 +57,24 @@ export async function publicRoutes(fastify) {
34
57
  // Health check
35
58
  fastify.get('/api/health', async () => ({ status: 'ok' }));
36
59
 
60
+ // SEO: sitemap.xml — cached per origin (so a single CMS responding on
61
+ // multiple domains gets the right absolute URLs); invalidated by page
62
+ // CRUD via the 'sitemap' tag.
63
+ fastify.get('/sitemap.xml', async (request, reply) => {
64
+ const baseUrl = getBaseUrl(request);
65
+ const xml = await cache.wrap(
66
+ `sitemap:xml:${baseUrl}`,
67
+ () => generateSitemap(baseUrl),
68
+ {tags: ['sitemap']}
69
+ );
70
+ return reply.type('application/xml').send(xml);
71
+ });
72
+
73
+ // SEO: robots.txt — cheap to build, no cache wrap needed.
74
+ fastify.get('/robots.txt', async (request, reply) => {
75
+ return reply.type('text/plain').send(buildRobotsTxt(getBaseUrl(request)));
76
+ });
77
+
37
78
  // Public pages catch-all
38
79
  fastify.get('/*', async (request, reply) => {
39
80
  const rawPath = request.params['*'];
@@ -45,7 +86,10 @@ export async function publicRoutes(fastify) {
45
86
 
46
87
  const urlPath = '/' + (rawPath || '');
47
88
 
48
- // Try exact path first, then with /index suffix for directory-style URLs
89
+ // First-pass page lookup used only for the visibility decision
90
+ // (metadata-only is sufficient). We re-fetch below with `user` once
91
+ // it is resolved so the body's [menu] shortcode renders against the
92
+ // correct role.
49
93
  let page = await getPage(urlPath);
50
94
 
51
95
  // Try fetching index of a directory path
@@ -64,21 +108,58 @@ export async function publicRoutes(fastify) {
64
108
  return reply.type('text/html').send(await render404(urlPath));
65
109
  }
66
110
 
67
- // Enforce page visibility
68
- if (page.visibility && page.visibility !== 'public') {
69
- let userRole = null;
111
+ // Enforce page visibility — role only resolved for gated pages,
112
+ // so public pages share a single cache entry keyed `roleanon`.
113
+ //
114
+ // `visibility` may be a string ('public' | 'private' | role name) or
115
+ // an array of role names — see checkVisibility() for full semantics.
116
+ // Effectively-public values (missing, 'public', or an array containing
117
+ // 'public') skip JWT verification entirely so anonymous traffic hits
118
+ // the shared cache without auth cost.
119
+ // For per-role cache keying we use the primary role only — multi-role
120
+ // users still see correctly-gated content (checkVisibility consults
121
+ // additionalRoles too) but the cache key stays bounded to one entry
122
+ // per primary role rather than 2^N per role combination. The fallback
123
+ // is correctness: any user whose access depends on an additional role
124
+ // is granted, but their served HTML is the variant cached for their
125
+ // primary role's tier — fine for the public-site use case.
126
+ let userRole = null;
127
+ let userObj = null;
128
+ const vis = page.visibility;
129
+ const isPublic = !vis
130
+ || vis === 'public'
131
+ || (Array.isArray(vis) && (vis.length === 0 || vis.includes('public')));
132
+
133
+ if (!isPublic) {
70
134
  try {
71
135
  const decoded = await request.jwtVerify();
72
- if (decoded.type === 'access') userRole = decoded.role;
136
+ if (decoded.type === 'access') {
137
+ userRole = decoded.role;
138
+ userObj = { role: decoded.role, additionalRoles: decoded.additionalRoles || [] };
139
+ }
73
140
  } catch { /* no token — treat as unauthenticated */ }
74
141
 
75
- if (!checkVisibility(userRole, page.visibility)) {
142
+ if (!checkVisibility(userObj, vis)) {
76
143
  reply.status(403);
77
144
  return reply.type('text/html').send(accessDeniedHtml(urlPath));
78
145
  }
79
146
  }
80
147
 
81
- const html = await renderPage(page);
148
+ const baseUrl = getBaseUrl(request);
149
+ const cacheKey = `page:${urlPath}:role${userRole ?? 'anon'}:o${baseUrl}`;
150
+ const cacheTags = [`page:${urlPath}`, ...(page.tags || []), 'nav', 'site'];
151
+ const html = await cache.wrap(
152
+ cacheKey,
153
+ async () => {
154
+ // Re-parse with user context so the body's [menu] shortcode
155
+ // sees the visitor's role for visibility filtering. The first
156
+ // getPage() above was anonymous because we hadn't decoded the
157
+ // JWT yet; this second pass uses the resolved userObj.
158
+ const pageForRole = await getPage(page.urlPath, {user: userObj}) || page;
159
+ return renderPage(pageForRole, {baseUrl, user: userObj});
160
+ },
161
+ {tags: cacheTags}
162
+ );
82
163
  hooks.emit('content:pageViewed', {urlPath, title: page.title || ''});
83
164
  return reply.type('text/html').send(html);
84
165
  });
package/server/server.js CHANGED
@@ -12,6 +12,7 @@ import cors from '@fastify/cors';
12
12
  import multipart from '@fastify/multipart';
13
13
  import rateLimit from '@fastify/rate-limit';
14
14
  import helmet from '@fastify/helmet';
15
+ import compress from '@fastify/compress';
15
16
  import path from 'path';
16
17
  import fs from 'fs/promises';
17
18
  import {fileURLToPath} from 'url';
@@ -24,6 +25,7 @@ import {seedAll as seedPresetCollections} from './services/presetCollections.js'
24
25
  import {seedDefaultBlocks} from './services/blocks.js';
25
26
  import {seedDefaultComponents} from './services/components.js';
26
27
  import {refreshComponentTagAllowlist} from './services/markdown.js';
28
+ import * as cache from './services/cache/index.js';
27
29
 
28
30
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
29
31
  const ROOT = path.resolve(__dirname, '..');
@@ -104,6 +106,14 @@ await app.register(rateLimit, {
104
106
  allowList: ['127.0.0.1', '::1', '::ffff:127.0.0.1'],
105
107
  });
106
108
 
109
+ // Negotiates brotli/gzip/deflate per Accept-Encoding. Skips already-compressed
110
+ // content types (images, fonts, video) and responses under threshold.
111
+ await app.register(compress, {
112
+ global: true,
113
+ threshold: 1024,
114
+ encodings: ['br', 'gzip', 'deflate']
115
+ });
116
+
107
117
  // ---------------------------------------------------------------------------
108
118
  // Static Assets
109
119
  // ---------------------------------------------------------------------------
@@ -122,13 +132,19 @@ await app.register(staticPlugin, {
122
132
  decorateReply: false
123
133
  });
124
134
 
125
- // Serve admin panel assets — no-cache so JS/CSS changes are always picked up
135
+ // Serve admin panel assets — no-store on JS/CSS so ESM module URLs are never
136
+ // cached. `no-cache` revalidates but browsers may keep the parsed ESM module
137
+ // keyed by URL across reloads, which means a hard refresh can still load the
138
+ // stale code path. `no-store` skips the cache entirely. Other admin assets
139
+ // (images, html) keep no-cache. Local-only impact — admin is not public.
126
140
  await app.register(staticPlugin, {
127
141
  root: path.join(ROOT, 'admin'),
128
142
  prefix: '/admin/',
129
143
  decorateReply: false,
130
- setHeaders: (res) => {
131
- res.setHeader('Cache-Control', 'no-cache');
144
+ cacheControl: false,
145
+ setHeaders: (res, filePath) => {
146
+ const noStore = /\.(?:js|mjs|css)$/i.test(filePath);
147
+ res.setHeader('Cache-Control', noStore ? 'no-store' : 'no-cache');
132
148
  }
133
149
  });
134
150
 
@@ -172,6 +188,28 @@ await seedDefaultBlocks();
172
188
  await seedDefaultComponents();
173
189
  await refreshComponentTagAllowlist();
174
190
 
191
+ // ---------------------------------------------------------------------------
192
+ // Menus — one-shot migration from legacy navigation.json + site footer links
193
+ // ---------------------------------------------------------------------------
194
+
195
+ try {
196
+ const {runMigration: runMenusMigration} = await import('./services/menus-migration.js');
197
+ await runMenusMigration();
198
+ } catch (err) {
199
+ app.log.warn(`[menus] Migration skipped: ${err.message}`);
200
+ }
201
+
202
+ try {
203
+ const {runMigration: runSidebarMigration} = await import('./services/sidebar-migration.js');
204
+ await runSidebarMigration();
205
+ } catch (err) {
206
+ app.log.warn(`[admin-sidebar] Migration skipped: ${err.message}`);
207
+ }
208
+
209
+ // Initialise response cache (Memory/None/Redis driver per config.cache).
210
+ await cache.initCache(config.cache);
211
+ console.log(`[cache] driver=${cache.isEnabled() ? config.cache.driver : 'disabled'}`);
212
+
175
213
  // Serve uploaded media files — nosniff prevents browsers rendering spoofed content types
176
214
  await app.register(staticPlugin, {
177
215
  root: mediaDir,
@@ -236,6 +274,10 @@ const { pagesRoutes } = await import('./routes/api/pages.js');
236
274
  const { settingsRoutes } = await import('./routes/api/settings.js');
237
275
  const { layoutsRoutes } = await import('./routes/api/layouts.js');
238
276
  const { navigationRoutes } = await import('./routes/api/navigation.js');
277
+ const {menusRoutes} = await import('./routes/api/menus.js');
278
+ const {menuLocationsRoutes} = await import('./routes/api/menu-locations.js');
279
+ const {projectsRoutes} = await import('./routes/api/projects.js');
280
+ const {sidebarRoutes} = await import('./routes/api/sidebar.js');
239
281
  const { mediaRoutes } = await import('./routes/api/media.js');
240
282
  const { usersRoutes } = await import('./routes/api/users.js');
241
283
  const { pluginsRoutes } = await import('./routes/api/plugins.js');
@@ -250,11 +292,17 @@ const {versionsRoutes} = await import('./routes/api/versions.js');
250
292
  const {effectsRoutes} = await import('./routes/api/effects.js');
251
293
  const {notificationsRoutes} = await import('./routes/api/notifications.js');
252
294
  const {dashboardRoutes} = await import('./routes/api/dashboard.js');
295
+ const {cacheRoutes} = await import('./routes/api/cache.js');
296
+ const {scaffoldRoutes} = await import('./routes/api/scaffold.js');
253
297
 
254
298
  await app.register(pagesRoutes, { prefix: '/api' });
255
299
  await app.register(settingsRoutes, { prefix: '/api' });
256
300
  await app.register(layoutsRoutes, { prefix: '/api' });
257
301
  await app.register(navigationRoutes, { prefix: '/api' });
302
+ await app.register(menusRoutes, {prefix: '/api'});
303
+ await app.register(menuLocationsRoutes, {prefix: '/api'});
304
+ await app.register(projectsRoutes, {prefix: '/api'});
305
+ await app.register(sidebarRoutes, {prefix: '/api'});
258
306
  await app.register(mediaRoutes, { prefix: '/api' });
259
307
  await app.register(usersRoutes, { prefix: '/api' });
260
308
  await app.register(pluginsRoutes, { prefix: '/api' });
@@ -269,6 +317,8 @@ await app.register(versionsRoutes, {prefix: '/api'});
269
317
  await app.register(effectsRoutes, {prefix: '/api'});
270
318
  await app.register(notificationsRoutes, {prefix: '/api'});
271
319
  await app.register(dashboardRoutes, {prefix: '/api'});
320
+ await app.register(cacheRoutes, {prefix: '/api'});
321
+ await app.register(scaffoldRoutes, {prefix: '/api'});
272
322
 
273
323
  // ---------------------------------------------------------------------------
274
324
  // CMS Plugins (server-side Fastify plugins from plugins/ directory)
@@ -325,3 +375,4 @@ try {
325
375
  app.log.error(err);
326
376
  process.exit(1);
327
377
  }
378
+ // scaffold-restart 1779615684
@@ -4,13 +4,23 @@
4
4
  * Action configs are stored in MongoDB `cms__actions` on the 'default' connection.
5
5
  * Executing an action runs a sequence of steps against a target collection entry.
6
6
  *
7
- * Phase 1 step types:
8
- * updateField, deleteEntry, moveToCollection, webhook, email
7
+ * Supported step types:
8
+ * updateField overwrite one field on the source entry
9
+ * deleteEntry — remove the source entry
10
+ * moveToCollection — copy source entry into target collection AND delete source
11
+ * createInCollection — create a new entry in another collection; source untouched
12
+ * (used for apply/follow/bookmark/upvote-style relations)
13
+ * webhook — POST/PUT/GET to an external URL with interpolated body
14
+ * email — send a transactional email via configured SMTP
9
15
  *
10
16
  * Template interpolation in step configs:
11
- * {{entry.data.field}} → entry field value
17
+ * {{entry.id}} source entry id
18
+ * {{entry.data.field}} → source entry field value
12
19
  * {{now}} → current ISO timestamp
20
+ * {{user.id}} → executing user's id
13
21
  * {{user.name}} → executing user's name
22
+ * {{user.email}} → executing user's email
23
+ * {{user.role}} → executing user's role
14
24
  * {{env.CMS_PUBLIC_*}} → environment variables with CMS_PUBLIC_ prefix only
15
25
  *
16
26
  * Note: execution is not transactional — partial results are returned on failure
@@ -140,6 +150,35 @@ async function executeStep(step, collection, entry, context) {
140
150
  return { moved: entry.id, to: target };
141
151
  }
142
152
 
153
+ /*
154
+ * createInCollection — append an entry to another collection without
155
+ * touching the source. Designed for relational patterns like:
156
+ * - Apply for job → create entry in `applications` { jobId, candidateId, status }
157
+ * - Bookmark page → create entry in `bookmarks` { pageId, userId }
158
+ * - Upvote item → create entry in `votes` { itemId, userId, value: 1 }
159
+ *
160
+ * Config:
161
+ * targetCollection {string} required — slug of the collection to append to
162
+ * data {object} required — field map; values are template-interpolated
163
+ * createdBy {string} optional — '{{user.id}}' is the conventional value;
164
+ * enables row-level "mine" filtering downstream
165
+ *
166
+ * Returns: { created: <new entry id>, in: <targetCollection> }
167
+ */
168
+ case 'createInCollection': {
169
+ const target = cfg.targetCollection;
170
+ if (!target) throw new Error('createInCollection: targetCollection is required');
171
+ if (!cfg.data || typeof cfg.data !== 'object') {
172
+ throw new Error('createInCollection: data object is required');
173
+ }
174
+ const data = interpolateValue(cfg.data, context);
175
+ const createdBy = cfg.createdBy
176
+ ? interpolate(String(cfg.createdBy), context)
177
+ : (context.user?.id || null);
178
+ const created = await createEntry(target, data, { createdBy, source: 'action' });
179
+ return { created: created.id, in: target };
180
+ }
181
+
143
182
  case 'webhook': {
144
183
  const url = interpolate(cfg.url || '', context);
145
184
  const method = (cfg.method || 'POST').toUpperCase();
@@ -239,7 +278,7 @@ export async function getAction(slug) {
239
278
  */
240
279
  export async function createAction(data, userId = null) {
241
280
  const db = await getMetaDb();
242
- const { title, description = '', collection, trigger, steps = [], access } = data;
281
+ const { title, description = '', collection, trigger, steps = [], access, transition, meta: inputMeta } = data;
243
282
 
244
283
  if (!title) throw new Error('title is required');
245
284
  if (!collection) throw new Error('collection is required');
@@ -264,11 +303,25 @@ export async function createAction(data, userId = null) {
264
303
  confirmMessage: trigger?.confirmMessage || null
265
304
  },
266
305
  steps,
306
+ // State-machine transition — when set, the action is gated by the
307
+ // entry's current value of `field` matching one of the `from` values,
308
+ // and is advertised as an "available transition" for that state.
309
+ // Optional `to` is informational only (steps still do the actual write).
310
+ transition: transition ? {
311
+ field: transition.field,
312
+ from: Array.isArray(transition.from) ? transition.from : [transition.from].filter(Boolean),
313
+ to: transition.to
314
+ } : null,
267
315
  access: {
268
316
  roles: access?.roles || ['admin', 'super-admin'],
269
317
  rowLevel: access?.rowLevel || null
270
318
  },
271
- meta: { createdAt: now, updatedAt: now, createdBy: userId }
319
+ meta: {
320
+ createdAt: now,
321
+ updatedAt: now,
322
+ createdBy: userId,
323
+ ...(inputMeta?.project != null && {project: inputMeta.project})
324
+ }
272
325
  };
273
326
 
274
327
  await db.collection(ACTIONS_COLLECTION).insertOne({ ...action });
@@ -288,12 +341,18 @@ export async function updateAction(slug, data) {
288
341
  const existing = await db.collection(ACTIONS_COLLECTION).findOne({ slug });
289
342
  if (!existing) throw new Error(`Action "${slug}" not found`);
290
343
 
291
- const { _id, id, meta, ...rest } = data;
344
+ const { _id, id, meta: inputMeta, ...rest } = data;
292
345
  const updated = {
293
346
  ...existing,
294
347
  ...rest,
295
348
  slug,
296
- meta: { ...existing.meta, updatedAt: new Date().toISOString() }
349
+ meta: {
350
+ ...existing.meta,
351
+ ...(inputMeta && typeof inputMeta === 'object'
352
+ ? ('project' in inputMeta ? {project: inputMeta.project} : {})
353
+ : {}),
354
+ updatedAt: new Date().toISOString()
355
+ }
297
356
  };
298
357
 
299
358
  await db.collection(ACTIONS_COLLECTION).replaceOne({ slug }, { ...updated });
@@ -328,6 +387,59 @@ export async function listActionsForCollection(collectionSlug) {
328
387
  .toArray();
329
388
  }
330
389
 
390
+ /**
391
+ * List the actions that represent valid next-state transitions for a given
392
+ * entry as seen by a given user. Used by the per-row transitions UI in the
393
+ * Collection Browser and the `[transitions]` shortcode.
394
+ *
395
+ * Criteria for inclusion:
396
+ * 1. action.collection matches the entry's collection
397
+ * 2. action.transition is set and action.transition.from includes the
398
+ * entry's current value of action.transition.field
399
+ * 3. action.access.roles allows the user (compared by role level so
400
+ * higher-privileged roles inherit access automatically)
401
+ *
402
+ * Returns a stripped projection — slug, title, trigger config, transition —
403
+ * so the client can render buttons without leaking step internals.
404
+ *
405
+ * @param {string} collectionSlug
406
+ * @param {object} entry - The entry being inspected (needed for field-value match)
407
+ * @param {object} [user] - { role } — anonymous if missing
408
+ * @returns {Promise<object[]>}
409
+ */
410
+ export async function listTransitionsForEntry(collectionSlug, entry, user = null) {
411
+ const all = await listActionsForCollection(collectionSlug);
412
+ if (!Array.isArray(all) || !all.length) return [];
413
+
414
+ const { getRoleLevel } = await import('./roles.js');
415
+ const { getEffectiveLevel } = await import('./userRoles.js');
416
+ // Use effective level so multi-role users see transitions any of their
417
+ // roles permit — e.g. a user who's primarily a candidate but also an
418
+ // admin sees BOTH the candidate-only "withdraw" AND the admin-only
419
+ // "approve" transitions on the same entry.
420
+ const userLevel = getEffectiveLevel(user);
421
+
422
+ return all
423
+ .filter(a => {
424
+ if (!a.transition) return false;
425
+ const allowedFrom = Array.isArray(a.transition.from) ? a.transition.from : [a.transition.from].filter(Boolean);
426
+ if (allowedFrom.length) {
427
+ const current = entry.data?.[a.transition.field];
428
+ if (!allowedFrom.includes(current)) return false;
429
+ }
430
+ const roles = a.access?.roles || ['admin', 'super-admin'];
431
+ const minLevel = Math.min(...roles.map(r => getRoleLevel(r)));
432
+ if (!(userLevel <= minLevel)) return false;
433
+ return true;
434
+ })
435
+ .map(a => ({
436
+ slug: a.slug,
437
+ title: a.title,
438
+ trigger: a.trigger,
439
+ transition: a.transition
440
+ }));
441
+ }
442
+
331
443
  // ---------------------------------------------------------------------------
332
444
  // Execution
333
445
  // ---------------------------------------------------------------------------
@@ -351,12 +463,29 @@ export async function executeAction(slug, entryId, { user = null } = {}) {
351
463
  const entry = await getEntry(action.collection, entryId);
352
464
  if (!entry) throw new Error(`Entry "${entryId}" not found in collection "${action.collection}"`);
353
465
 
354
- if (!checkEntryAccess(entry, user, action.access?.rowLevel)) {
466
+ if (!(await checkEntryAccess(entry, user, action.access?.rowLevel))) {
355
467
  const err = new Error('Row-level access denied');
356
468
  err.statusCode = 403;
357
469
  throw err;
358
470
  }
359
471
 
472
+ // State-machine guard — an action with a transition config only runs when
473
+ // the entry's current value of `field` is in `from`. Stops illegal moves
474
+ // like "withdraw" on an already-rejected application; the API returns 409
475
+ // so the client can present a meaningful "no longer available" message
476
+ // (we use 409 = Conflict rather than 403 because the request itself was
477
+ // authorised, just inconsistent with current state).
478
+ if (action.transition) {
479
+ const t = action.transition;
480
+ const currentValue = entry.data?.[t.field];
481
+ const allowed = Array.isArray(t.from) ? t.from : (t.from ? [t.from] : []);
482
+ if (allowed.length && !allowed.includes(currentValue)) {
483
+ const err = new Error(`Cannot apply "${action.title}" — current ${t.field} is "${currentValue ?? '(none)'}", allowed: ${allowed.join(', ')}`);
484
+ err.statusCode = 409;
485
+ throw err;
486
+ }
487
+ }
488
+
360
489
  const context = {
361
490
  entry,
362
491
  now: new Date().toISOString(),
@@ -19,6 +19,7 @@
19
19
  import fs from 'fs/promises';
20
20
  import path from 'path';
21
21
  import { config } from '../../config.js';
22
+ import { applyFilter } from '../filterEngine.js';
22
23
 
23
24
  const COLLECTIONS_DIR = path.resolve(config.content.collectionsDir);
24
25
 
@@ -50,18 +51,25 @@ async function write(slug, entries) {
50
51
  export class FileAdapter {
51
52
 
52
53
  /**
53
- * List entries with optional pagination, sorting, and search.
54
+ * List entries with optional pagination, sorting, free-text search,
55
+ * and structured filtering.
56
+ *
57
+ * `search` is a simple substring match across all field values (legacy
58
+ * behaviour, kept for the admin search box). `filter` is the structured
59
+ * DSL — see `filterEngine.js` for operator syntax. Both may be combined;
60
+ * search runs first, then filter.
54
61
  *
55
62
  * @param {string} slug
56
63
  * @param {object} [opts]
57
- * @param {number} [opts.page=1]
58
- * @param {number} [opts.limit=50]
59
- * @param {string} [opts.sort='createdAt']
60
- * @param {string} [opts.order='desc']
61
- * @param {string} [opts.search]
64
+ * @param {number} [opts.page=1]
65
+ * @param {number} [opts.limit=50] - Use 0 for "no limit" (returns all)
66
+ * @param {string} [opts.sort='createdAt']
67
+ * @param {string} [opts.order='desc'] - 'asc' | 'desc'
68
+ * @param {string} [opts.search] - Free-text substring across all fields
69
+ * @param {Record<string, unknown>} [opts.filter] - Structured filter; see filterEngine.js
62
70
  * @returns {Promise<{ entries: object[], total: number, page: number, limit: number }>}
63
71
  */
64
- async list(slug, { page = 1, limit = 50, sort = 'createdAt', order = 'desc', search } = {}) {
72
+ async list(slug, { page = 1, limit = 50, sort = 'createdAt', order = 'desc', search, filter } = {}) {
65
73
  let entries = await read(slug);
66
74
 
67
75
  if (search) {
@@ -71,6 +79,10 @@ export class FileAdapter {
71
79
  );
72
80
  }
73
81
 
82
+ if (filter) {
83
+ entries = applyFilter(entries, filter);
84
+ }
85
+
74
86
  entries.sort((a, b) => {
75
87
  const aVal = sort === 'createdAt' ? a.meta?.createdAt : (a.data?.[sort] ?? '');
76
88
  const bVal = sort === 'createdAt' ? b.meta?.createdAt : (b.data?.[sort] ?? '');
@@ -78,7 +90,10 @@ export class FileAdapter {
78
90
  return order === 'desc' ? -cmp : cmp;
79
91
  });
80
92
 
81
- const total = entries.length;
93
+ const total = entries.length;
94
+ if (limit === 0 || limit == null) {
95
+ return { entries, total, page: 1, limit: total };
96
+ }
82
97
  const offset = (page - 1) * limit;
83
98
  return { entries: entries.slice(offset, offset + limit), total, page, limit };
84
99
  }
@@ -18,6 +18,7 @@
18
18
  * insertMany(slug, entries) → { imported, skipped, errors }
19
19
  * count(slug) → number
20
20
  */
21
+ import { toMongoQuery } from '../filterEngine.js';
21
22
 
22
23
  /** Prefix applied to all CMS collection names in MongoDB to avoid collisions. */
23
24
  const PREFIX = 'cms_';
@@ -61,32 +62,39 @@ export class MongoAdapter {
61
62
  }
62
63
 
63
64
  /**
64
- * List entries with optional pagination, sorting, and search.
65
+ * List entries with optional pagination, sorting, free-text search,
66
+ * and structured filtering.
65
67
  *
66
- * When no search term is provided, pagination and sorting are pushed to MongoDB
67
- * for efficiency. When a search term is provided, all documents are fetched and
68
- * filtered in memory identical behaviour to FileAdapter. For large collections
69
- * requiring efficient full-text search, configure a MongoDB Atlas Search index.
68
+ * `filter` is translated to native MongoDB query operators via
69
+ * `filterEngine.toMongoQuery()` and pushed down to the database page
70
+ * authors writing `[collection where_salary_gte="50000"]` get index-aware
71
+ * queries automatically. When `search` is present, all documents are
72
+ * fetched and filtered in memory (same behaviour as FileAdapter, kept
73
+ * for parity); combine with `filter` to keep the in-memory set small.
70
74
  *
71
75
  * @param {string} slug
72
76
  * @param {object} [opts]
73
- * @param {number} [opts.page=1]
74
- * @param {number} [opts.limit=50]
75
- * @param {string} [opts.sort='createdAt']
76
- * @param {string} [opts.order='desc']
77
- * @param {string} [opts.search]
77
+ * @param {number} [opts.page=1]
78
+ * @param {number} [opts.limit=50] - Use 0 for "no limit"
79
+ * @param {string} [opts.sort='createdAt']
80
+ * @param {string} [opts.order='desc'] - 'asc' | 'desc'
81
+ * @param {string} [opts.search] - Free-text substring across all fields
82
+ * @param {Record<string, unknown>} [opts.filter] - Structured filter; see filterEngine.js
78
83
  * @returns {Promise<{ entries: object[], total: number, page: number, limit: number }>}
79
84
  */
80
- async list(slug, { page = 1, limit = 50, sort = 'createdAt', order = 'desc', search } = {}) {
85
+ async list(slug, { page = 1, limit = 50, sort = 'createdAt', order = 'desc', search, filter } = {}) {
81
86
  const col = await this._col(slug);
82
87
 
83
88
  const sortField = sort === 'createdAt' ? 'meta.createdAt' : `data.${sort}`;
84
89
  const sortDir = order === 'desc' ? -1 : 1;
90
+ const query = filter ? toMongoQuery(filter) : {};
85
91
 
86
92
  if (search) {
87
- // Fetch all and filter in memory to avoid $where (often disabled in Atlas).
93
+ // Fetch matching (by filter) docs and substring-match in memory.
94
+ // Atlas Search is the right tool for large datasets; this is the
95
+ // portable fallback that matches FileAdapter semantics exactly.
88
96
  const all = await col
89
- .find({}, { projection: { _id: 0 } })
97
+ .find(query, { projection: { _id: 0 } })
90
98
  .sort({ [sortField]: sortDir })
91
99
  .toArray();
92
100
 
@@ -95,16 +103,26 @@ export class MongoAdapter {
95
103
  Object.values(entry.data || {}).some(v => String(v).toLowerCase().includes(term))
96
104
  );
97
105
 
98
- const total = matched.length;
106
+ const total = matched.length;
107
+ if (limit === 0 || limit == null) {
108
+ return { entries: matched, total, page: 1, limit: total };
109
+ }
99
110
  const offset = (page - 1) * limit;
100
111
  return { entries: matched.slice(offset, offset + limit), total, page, limit };
101
112
  }
102
113
 
103
- const total = await col.countDocuments({});
104
- const offset = (page - 1) * limit;
114
+ const total = await col.countDocuments(query);
115
+ if (limit === 0 || limit == null) {
116
+ const docs = await col
117
+ .find(query, { projection: { _id: 0 } })
118
+ .sort({ [sortField]: sortDir })
119
+ .toArray();
120
+ return { entries: docs, total, page: 1, limit: total };
121
+ }
105
122
 
106
- const docs = await col
107
- .find({}, { projection: { _id: 0 } })
123
+ const offset = (page - 1) * limit;
124
+ const docs = await col
125
+ .find(query, { projection: { _id: 0 } })
108
126
  .sort({ [sortField]: sortDir })
109
127
  .skip(offset)
110
128
  .limit(limit)