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.
- package/CLAUDE.md +39 -3
- package/admin/css/admin.css +1 -1
- package/admin/css/dashboard.css +1 -1
- package/admin/index.html +2 -2
- package/admin/js/api.js +1 -1
- package/admin/js/app.js +4 -4
- package/admin/js/config/sidebar-config.js +1 -1
- package/admin/js/lib/card-builder.js +3 -3
- package/admin/js/lib/crud-tutorial.js +1 -0
- package/admin/js/lib/effects-builder.js +1 -1
- package/admin/js/lib/markdown-toolbar.js +6 -6
- package/admin/js/lib/project-context.js +1 -0
- package/admin/js/lib/sidebar-renderer.js +4 -0
- package/admin/js/templates/action-editor.html +7 -0
- package/admin/js/templates/block-editor.html +7 -0
- package/admin/js/templates/collection-editor.html +9 -0
- package/admin/js/templates/dashboard/cache.html +32 -0
- package/admin/js/templates/dashboard.html +4 -0
- package/admin/js/templates/form-editor.html +9 -0
- package/admin/js/templates/menu-editor.html +98 -0
- package/admin/js/templates/menu-locations.html +14 -0
- package/admin/js/templates/menus.html +14 -0
- package/admin/js/templates/page-editor.html +9 -2
- package/admin/js/templates/project-detail.html +50 -0
- package/admin/js/templates/project-editor.html +45 -0
- package/admin/js/templates/project-settings.html +60 -0
- package/admin/js/templates/projects.html +13 -0
- package/admin/js/templates/role-editor.html +11 -0
- package/admin/js/templates/settings.html +26 -0
- package/admin/js/templates/tutorials.html +335 -2
- package/admin/js/templates/view-editor.html +7 -0
- package/admin/js/views/action-editor.js +1 -1
- package/admin/js/views/actions-list.js +1 -1
- package/admin/js/views/block-editor-enhance.js +1 -1
- package/admin/js/views/block-editor.js +8 -8
- package/admin/js/views/blocks.js +2 -2
- package/admin/js/views/collection-editor.js +4 -4
- package/admin/js/views/collections.js +1 -1
- package/admin/js/views/dashboard/widgets/activity-feed.js +1 -1
- package/admin/js/views/dashboard/widgets/cache.js +1 -0
- package/admin/js/views/dashboard/widgets/journeys.js +1 -1
- package/admin/js/views/dashboard/widgets/spike-feed.js +1 -1
- package/admin/js/views/dashboard/widgets/top-pages.js +1 -1
- package/admin/js/views/dashboard.js +1 -1
- package/admin/js/views/form-editor.js +6 -6
- package/admin/js/views/forms.js +1 -1
- package/admin/js/views/index.js +1 -1
- package/admin/js/views/menu-editor.js +19 -0
- package/admin/js/views/menu-locations.js +1 -0
- package/admin/js/views/menus.js +5 -0
- package/admin/js/views/page-editor.js +41 -36
- package/admin/js/views/pages.js +3 -3
- package/admin/js/views/project-detail.js +4 -0
- package/admin/js/views/project-editor.js +1 -0
- package/admin/js/views/project-settings.js +1 -0
- package/admin/js/views/projects.js +7 -0
- package/admin/js/views/role-editor.js +1 -1
- package/admin/js/views/roles.js +3 -3
- package/admin/js/views/settings.js +3 -3
- package/admin/js/views/tutorials.js +1 -1
- package/admin/js/views/user-editor.js +1 -1
- package/admin/js/views/users.js +3 -3
- package/admin/js/views/view-editor.js +1 -1
- package/admin/js/views/views-list.js +1 -1
- package/config/cache.json +4 -0
- package/config/cache.json.example +12 -0
- package/config/menu-locations.json +5 -0
- package/config/menus/admin-sidebar.json +185 -0
- package/config/menus/footer.json +33 -0
- package/config/menus/main.json +35 -0
- package/config/menus/sproj-1779696558011-menu.json +17 -0
- package/config/menus/sproj-1779696960337-menu.json +18 -0
- package/config/menus/sproj-1779696985353-menu.json +18 -0
- package/config/site.json +6 -22
- package/package.json +4 -3
- package/plugins/analytics/daily.json +3 -0
- package/plugins/analytics/journeys.json +8 -0
- package/plugins/analytics/lifetime.json +1 -1
- package/public/css/site.css +1 -1
- package/public/js/collection-browser.js +4 -0
- package/public/js/forms.js +1 -1
- package/public/js/site.js +1 -1
- package/server/config.js +12 -1
- package/server/middleware/auth.js +88 -22
- package/server/routes/api/actions.js +58 -5
- package/server/routes/api/auth.js +2 -2
- package/server/routes/api/blocks.js +18 -3
- package/server/routes/api/cache.js +57 -0
- package/server/routes/api/collections.js +201 -8
- package/server/routes/api/forms.js +266 -21
- package/server/routes/api/menu-locations.js +46 -0
- package/server/routes/api/menus.js +115 -0
- package/server/routes/api/navigation.js +2 -0
- package/server/routes/api/pages.js +1 -1
- package/server/routes/api/projects.js +107 -0
- package/server/routes/api/scaffold.js +86 -0
- package/server/routes/api/settings.js +3 -0
- package/server/routes/api/sidebar.js +23 -0
- package/server/routes/api/users.js +32 -7
- package/server/routes/api/views.js +10 -2
- package/server/routes/public.js +88 -7
- package/server/server.js +54 -3
- package/server/services/actions.js +137 -8
- package/server/services/adapters/FileAdapter.js +23 -8
- package/server/services/adapters/MongoAdapter.js +36 -18
- package/server/services/blocks.js +23 -8
- package/server/services/cache/drivers/MemoryDriver.js +118 -0
- package/server/services/cache/drivers/NoneDriver.js +12 -0
- package/server/services/cache/index.js +229 -0
- package/server/services/cache/lru.js +61 -0
- package/server/services/collections.js +102 -12
- package/server/services/content.js +25 -6
- package/server/services/filterEngine.js +281 -0
- package/server/services/forms.js +3 -0
- package/server/services/hooks.js +48 -0
- package/server/services/markdown.js +711 -124
- package/server/services/menus-migration.js +107 -0
- package/server/services/menus.js +422 -0
- package/server/services/permissionRegistry.js +26 -0
- package/server/services/plugins.js +9 -2
- package/server/services/presetCollections.js +22 -0
- package/server/services/projects.js +429 -0
- package/server/services/recipes/contact-list.json +78 -0
- package/server/services/recipes/onboarding.json +426 -0
- package/server/services/references.js +174 -0
- package/server/services/renderer.js +237 -40
- package/server/services/roles.js +6 -1
- package/server/services/rowAccess.js +86 -13
- package/server/services/scaffolder.js +465 -0
- package/server/services/sidebar-migration.js +117 -0
- package/server/services/sitemap.js +112 -0
- package/server/services/userRoles.js +86 -0
- package/server/services/users.js +23 -2
- package/server/services/views.js +19 -4
- package/server/templates/page.html +135 -130
- /package/config/{navigation.json → navigation.json.bak} +0 -0
package/server/routes/public.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
|
|
69
|
-
|
|
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')
|
|
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(
|
|
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
|
|
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-
|
|
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
|
-
|
|
131
|
-
|
|
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
|
-
*
|
|
8
|
-
* updateField
|
|
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.
|
|
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: {
|
|
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: {
|
|
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,
|
|
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}
|
|
58
|
-
* @param {number}
|
|
59
|
-
* @param {string}
|
|
60
|
-
* @param {string}
|
|
61
|
-
* @param {string}
|
|
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
|
|
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,
|
|
65
|
+
* List entries with optional pagination, sorting, free-text search,
|
|
66
|
+
* and structured filtering.
|
|
65
67
|
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
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}
|
|
74
|
-
* @param {number}
|
|
75
|
-
* @param {string}
|
|
76
|
-
* @param {string}
|
|
77
|
-
* @param {string}
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
104
|
-
|
|
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
|
|
107
|
-
|
|
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)
|