domma-cms 0.44.0 → 0.45.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +53 -53
- package/README.md +27 -27
- package/admin/css/admin.css +1 -1
- package/admin/dist/domma/domma-tools.css +3 -3
- package/admin/dist/domma/domma-tools.min.js +3 -3
- package/admin/index.html +2 -2
- package/admin/js/app.js +1 -1
- package/admin/js/lib/card-builder.js +2 -2
- package/admin/js/lib/crud-tutorial.js +1 -1
- package/admin/js/lib/effect-defs.js +1 -1
- package/admin/js/lib/effects-builder.js +1 -1
- package/admin/js/lib/image-editor.js +1 -1
- package/admin/js/lib/markdown-toolbar.js +2 -2
- package/admin/js/lib/project-context.js +1 -1
- package/admin/js/lib/project-quick-create.js +1 -1
- package/admin/js/lib/scribe-composer.js +1 -1
- package/admin/js/lib/server-restart.js +1 -1
- package/admin/js/lib/shortcode-context-menu.js +2 -2
- package/admin/js/lib/simple-editor.js +1 -1
- package/admin/js/lib/themes.js +1 -1
- package/admin/js/lib/timeline-builder.js +1 -1
- package/admin/js/templates/action-editor.html +8 -8
- package/admin/js/templates/api-endpoint-editor.html +7 -7
- package/admin/js/templates/api-endpoints.html +1 -1
- package/admin/js/templates/api-reference.html +1 -1
- package/admin/js/templates/block-editor.html +6 -6
- package/admin/js/templates/collection-editor.html +1 -1
- package/admin/js/templates/component-editor.html +2 -2
- package/admin/js/templates/dashboard/cache.html +3 -3
- package/admin/js/templates/dashboard/journeys.html +3 -3
- package/admin/js/templates/dashboard/kpi-strip.html +3 -3
- package/admin/js/templates/dashboard/top-pages.html +1 -1
- package/admin/js/templates/dashboard/traffic-chart.html +1 -1
- package/admin/js/templates/docs/api-actions.html +1 -1
- package/admin/js/templates/docs/api-collections.html +4 -4
- package/admin/js/templates/docs/api-pages.html +2 -2
- package/admin/js/templates/docs/api-plugins.html +1 -1
- package/admin/js/templates/docs/api-settings.html +2 -2
- package/admin/js/templates/docs/api-users.html +1 -1
- package/admin/js/templates/docs/components-howto.html +4 -4
- package/admin/js/templates/docs/components-reference.html +21 -21
- package/admin/js/templates/docs/components-rules.html +14 -14
- package/admin/js/templates/docs/components-walkthrough.html +13 -13
- package/admin/js/templates/docs/tutorial-crud.html +49 -49
- package/admin/js/templates/docs/tutorial-forms.html +4 -4
- package/admin/js/templates/docs/tutorial-plugin.html +10 -10
- package/admin/js/templates/docs/usage-actions.html +6 -6
- package/admin/js/templates/docs/usage-cta-shortcode.html +9 -9
- package/admin/js/templates/docs/usage-dconfig.html +4 -4
- package/admin/js/templates/docs/usage-navigation.html +1 -1
- package/admin/js/templates/docs/usage-pages.html +1 -1
- package/admin/js/templates/docs/usage-shortcodes.html +24 -24
- package/admin/js/templates/docs/usage-users-roles.html +3 -3
- package/admin/js/templates/docs/usage-views.html +4 -4
- package/admin/js/templates/documentation.html +1 -1
- package/admin/js/templates/effects.html +752 -752
- package/admin/js/templates/form-editor.html +5 -5
- package/admin/js/templates/forms.html +17 -17
- package/admin/js/templates/login.html +5 -5
- package/admin/js/templates/menu-editor.html +10 -10
- package/admin/js/templates/my-profile.html +17 -17
- package/admin/js/templates/navigation.html +11 -11
- package/admin/js/templates/notifications.html +1 -1
- package/admin/js/templates/page-editor.html +4 -4
- package/admin/js/templates/pro-docs.html +14 -14
- package/admin/js/templates/role-editor.html +70 -70
- package/admin/js/templates/roles.html +10 -10
- package/admin/js/templates/settings.html +10 -10
- package/admin/js/templates/tutorials.html +3 -3
- package/admin/js/templates/view-editor.html +15 -15
- package/admin/js/views/action-editor.js +1 -1
- package/admin/js/views/actions-list.js +1 -1
- package/admin/js/views/api-endpoint-editor.js +2 -2
- package/admin/js/views/api-endpoints.js +2 -2
- package/admin/js/views/api-tokens.js +3 -3
- package/admin/js/views/blocks.js +2 -2
- package/admin/js/views/collection-editor.js +1 -1
- package/admin/js/views/collection-entries.js +1 -1
- package/admin/js/views/collections.js +1 -1
- package/admin/js/views/component-editor.js +1 -1
- package/admin/js/views/components.js +4 -4
- package/admin/js/views/dashboard/widgets/cache.js +1 -1
- package/admin/js/views/dashboard/widgets/journeys.js +1 -1
- package/admin/js/views/dashboard/widgets/kpi-strip.js +1 -1
- package/admin/js/views/form-editor.js +7 -7
- package/admin/js/views/form-submissions.js +1 -1
- package/admin/js/views/forms.js +1 -1
- package/admin/js/views/layouts.js +1 -1
- package/admin/js/views/login.js +2 -2
- package/admin/js/views/menu-editor.js +3 -3
- package/admin/js/views/menu-locations.js +1 -1
- package/admin/js/views/menus.js +3 -3
- package/admin/js/views/navigation.js +8 -8
- package/admin/js/views/page-editor.js +40 -40
- package/admin/js/views/pages.js +3 -3
- package/admin/js/views/plugin-code.js +2 -2
- package/admin/js/views/plugins.js +1 -1
- package/admin/js/views/project-settings.js +1 -1
- package/admin/js/views/projects.js +1 -1
- package/admin/js/views/roles.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/view-preview.js +1 -1
- package/admin/js/views/views-list.js +1 -1
- package/bin/cli.js +8 -8
- package/bin/lib/config-merge.js +44 -44
- package/bin/update.js +16 -16
- package/config/plugins.json +1 -1
- package/package.json +2 -2
- package/plugins/_template/admin/views/index.js +1 -1
- package/plugins/_template/config.js +1 -1
- package/plugins/_template/plugin.js +1 -1
- package/plugins/analytics/admin/templates/analytics.html +3 -3
- package/plugins/analytics/admin/views/analytics.js +1 -1
- package/plugins/analytics/config.js +1 -1
- package/plugins/analytics/plugin.js +4 -4
- package/plugins/analytics/plugin.json +1 -1
- package/plugins/analytics/public/inject-body.html +2 -2
- package/plugins/analytics/public/inject-head.html +1 -1
- package/plugins/blog/admin/templates/blog.html +4 -4
- package/plugins/blog/admin/views/blog.js +3 -3
- package/plugins/blog/admin/views/categories.js +2 -2
- package/plugins/blog/admin/views/comments.js +4 -4
- package/plugins/blog/admin/views/post-editor.js +1 -1
- package/plugins/blog/plugin.js +2 -2
- package/plugins/blog/plugin.json +4 -4
- package/plugins/contacts/admin/views/contacts.js +8 -8
- package/plugins/contacts/plugin.js +4 -4
- package/plugins/contacts/plugin.json +1 -1
- package/plugins/invoice/admin/templates/editor.html +6 -6
- package/plugins/invoice/admin/templates/index.html +4 -4
- package/plugins/invoice/admin/views/editor.js +3 -3
- package/plugins/invoice/admin/views/index.js +4 -4
- package/plugins/invoice/admin/views/issuers.js +1 -1
- package/plugins/invoice/admin/views/party-view.js +3 -3
- package/plugins/invoice/admin/views/receivers.js +1 -1
- package/plugins/invoice/config.js +1 -1
- package/plugins/invoice/plugin.js +1 -1
- package/plugins/invoice/plugin.json +4 -4
- package/plugins/notes/admin/views/notes.js +4 -4
- package/plugins/notes/plugin.js +1 -1
- package/plugins/notes/plugin.json +1 -1
- package/plugins/site-search/admin/templates/site-search.html +3 -3
- package/plugins/site-search/admin/views/site-search.js +1 -1
- package/plugins/site-search/config.js +1 -1
- package/plugins/site-search/plugin.js +4 -4
- package/plugins/site-search/plugin.json +1 -1
- package/plugins/surveys/admin/templates/results.html +1 -1
- package/plugins/surveys/admin/templates/survey-editor.html +4 -4
- package/plugins/surveys/admin/views/audience.js +6 -6
- package/plugins/surveys/admin/views/results.js +3 -3
- package/plugins/surveys/admin/views/survey-editor.js +1 -1
- package/plugins/surveys/admin/views/surveys.js +2 -2
- package/plugins/surveys/plugin.js +1 -1
- package/plugins/surveys/plugin.json +4 -4
- package/plugins/theme-switcher/admin/views/theme-switcher.js +1 -1
- package/plugins/theme-switcher/plugin.json +3 -3
- package/plugins/theme-switcher/public/inject-body.html +28 -28
- package/plugins/theme-switcher/public/inject-head.html +1 -1
- package/plugins/todo/plugin.js +1 -1
- package/public/css/forms.css +1 -1
- package/public/js/collection-browser.js +1 -1
- package/public/js/collection-context.js +1 -1
- package/public/js/form-logic-engine.js +1 -1
- package/public/js/forms.js +2 -1
- package/public/js/site.js +1 -1
- package/scripts/build.js +14 -14
- package/scripts/create-plugin.js +2 -2
- package/scripts/fresh.js +2 -2
- package/scripts/gen-instance-secret.js +1 -1
- package/scripts/pro.js +12 -12
- package/scripts/reset.js +2 -2
- package/scripts/setup.js +15 -15
- package/scripts/users.js +5 -5
- package/scripts/verify-assets.mjs +6 -6
- package/server/config.js +1 -1
- package/server/middleware/auth.js +253 -253
- package/server/middleware/managerAuth.js +3 -3
- package/server/routes/api/actions.js +4 -4
- package/server/routes/api/api-endpoints.js +7 -7
- package/server/routes/api/api-tokens.js +5 -5
- package/server/routes/api/auth.js +309 -309
- package/server/routes/api/blocks.js +3 -3
- package/server/routes/api/collections.js +18 -18
- package/server/routes/api/dashboard.js +5 -5
- package/server/routes/api/effects.js +2 -2
- package/server/routes/api/endpoints-public.js +5 -5
- package/server/routes/api/forms.js +47 -47
- package/server/routes/api/menu-locations.js +3 -3
- package/server/routes/api/menus.js +7 -7
- package/server/routes/api/navigation.js +42 -42
- package/server/routes/api/notifications.js +2 -2
- package/server/routes/api/pages.js +14 -2
- package/server/routes/api/plugin-marketplace.js +3 -3
- package/server/routes/api/plugins.js +6 -6
- package/server/routes/api/projects.js +9 -9
- package/server/routes/api/scaffold.js +7 -7
- package/server/routes/api/settings.js +6 -6
- package/server/routes/api/sidebar.js +1 -1
- package/server/routes/api/users.js +5 -5
- package/server/routes/api/versions.js +1 -1
- package/server/routes/api/views.js +1 -1
- package/server/routes/docs-public.js +3 -3
- package/server/routes/public.js +14 -14
- package/server/server.js +34 -16
- package/server/services/actions.js +19 -19
- package/server/services/adapters/FileAdapter.js +2 -2
- package/server/services/adapters/MongoAdapter.js +5 -5
- package/server/services/apiEndpoints.js +15 -15
- package/server/services/apiTokens.js +7 -7
- package/server/services/blocks.js +13 -13
- package/server/services/cache/drivers/MemoryDriver.js +3 -3
- package/server/services/cache/drivers/NoneDriver.js +1 -1
- package/server/services/cache/index.js +5 -5
- package/server/services/collections.js +14 -14
- package/server/services/components.js +9 -9
- package/server/services/connectionManager.js +4 -4
- package/server/services/content.js +25 -2
- package/server/services/docs.js +14 -14
- package/server/services/email.js +167 -167
- package/server/services/filterEngine.js +6 -6
- package/server/services/forms.js +18 -10
- package/server/services/health.js +1 -1
- package/server/services/hooks.js +7 -7
- package/server/services/images.js +4 -4
- package/server/services/managerClient.js +1 -1
- package/server/services/markdown.js +79 -79
- package/server/services/menuRender.js +5 -5
- package/server/services/menus-migration.js +3 -3
- package/server/services/menus.js +31 -31
- package/server/services/permissionRegistry.js +1 -1
- package/server/services/pluginFiles.js +5 -5
- package/server/services/pluginInstaller.js +17 -17
- package/server/services/pluginScaffold.js +1 -1
- package/server/services/plugins.js +17 -17
- package/server/services/presetCollections.js +2 -2
- package/server/services/projects.js +35 -26
- package/server/services/recipes/contact-list.json +1 -1
- package/server/services/recipes/onboarding.json +8 -8
- package/server/services/references.js +8 -8
- package/server/services/renderer.js +9 -9
- package/server/services/roles.js +9 -9
- package/server/services/rowAccess.js +10 -10
- package/server/services/scaffolder.js +30 -30
- package/server/services/sidebar-migration.js +10 -10
- package/server/services/sitemap.js +1 -1
- package/server/services/userProfiles.js +199 -199
- package/server/services/userRoles.js +7 -7
- package/server/services/users.js +302 -302
- package/server/services/versions.js +3 -3
- package/server/services/viewPipeline.js +6 -6
- package/server/services/views.js +11 -11
- package/server/templates/page.html +5 -5
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
* DELETE /v1/:slug/:id ≡ DELETE /collections/:slug/public/:id
|
|
32
32
|
*
|
|
33
33
|
* Access modes per verb (`schema.api.<verb>.access`): 'public', a role name
|
|
34
|
-
* (JWT + role level), or 'token' (project-scoped API token
|
|
34
|
+
* (JWT + role level), or 'token' (project-scoped API token - see
|
|
35
35
|
* services/apiTokens.js). A token is ONLY accepted when the mode is 'token';
|
|
36
36
|
* it is never a substitute for a role, and a JWT is never accepted in token
|
|
37
37
|
* mode. `schema.api.read.fields` optionally whitelists which data fields the
|
|
@@ -116,7 +116,7 @@ function redactTokenHash(entry) {
|
|
|
116
116
|
/**
|
|
117
117
|
* Apply the read-field allowlist (`schema.api.read.fields`) to a public read
|
|
118
118
|
* payload. Absent or empty allowlist = all fields. Handles both list payloads
|
|
119
|
-
* ({entries: [...]}) and single entries. Only entry.data is filtered
|
|
119
|
+
* ({entries: [...]}) and single entries. Only entry.data is filtered -
|
|
120
120
|
* `_refs` from resolveRefs is not (refs of stripped fields may still appear;
|
|
121
121
|
* documented v1 behaviour).
|
|
122
122
|
*
|
|
@@ -147,10 +147,10 @@ export function applyReadFieldAllowlist(schema, payload) {
|
|
|
147
147
|
*/
|
|
148
148
|
export async function checkPublicAccess(schema, operation, request, reply) {
|
|
149
149
|
// Hard kill-switch: if the artefact's project is disabled, the whole
|
|
150
|
-
// external surface is off
|
|
150
|
+
// external surface is off - 404 (no oracle). This covers /api/v1
|
|
151
151
|
// collections, the synthesized-schema custom endpoints (/api/x/*, whose
|
|
152
152
|
// meta.project is the endpoint's project), and any token bound to the
|
|
153
|
-
// project
|
|
153
|
+
// project - all funnel through here.
|
|
154
154
|
if (!(await isProjectEnabled(resolveArtefactProject(schema)))) {
|
|
155
155
|
return reply.status(404).send({ error: 'Not found' });
|
|
156
156
|
}
|
|
@@ -162,7 +162,7 @@ export async function checkPublicAccess(schema, operation, request, reply) {
|
|
|
162
162
|
|
|
163
163
|
if (access.access === 'public') return; // No auth needed
|
|
164
164
|
|
|
165
|
-
// Token mode
|
|
165
|
+
// Token mode - project-scoped API token, strict: a token is the ONLY
|
|
166
166
|
// accepted credential here (a JWT never satisfies token mode, and a
|
|
167
167
|
// token never satisfies a role mode).
|
|
168
168
|
if (access.access === 'token') {
|
|
@@ -184,7 +184,7 @@ export async function checkPublicAccess(schema, operation, request, reply) {
|
|
|
184
184
|
return;
|
|
185
185
|
}
|
|
186
186
|
|
|
187
|
-
// Auth required
|
|
187
|
+
// Auth required - try to verify JWT
|
|
188
188
|
try {
|
|
189
189
|
await request.jwtVerify();
|
|
190
190
|
} catch {
|
|
@@ -193,7 +193,7 @@ export async function checkPublicAccess(schema, operation, request, reply) {
|
|
|
193
193
|
|
|
194
194
|
const user = request.user;
|
|
195
195
|
const requiredLevel = roleLevel(access.access);
|
|
196
|
-
// Effective level
|
|
196
|
+
// Effective level - multi-role users get the lowest (most privileged) level
|
|
197
197
|
// across their primary + additional roles for this access decision.
|
|
198
198
|
const userLevel = getEffectiveLevel(user);
|
|
199
199
|
|
|
@@ -307,9 +307,9 @@ export async function collectionsRoutes(fastify) {
|
|
|
307
307
|
* GET /api/collections/:slug/entries
|
|
308
308
|
*
|
|
309
309
|
* Query parameters:
|
|
310
|
-
* page, limit, sort, order
|
|
311
|
-
* search
|
|
312
|
-
* filter[<field>_<op>]=val
|
|
310
|
+
* page, limit, sort, order - pagination/sort (limit=0 means "no limit")
|
|
311
|
+
* search - free-text substring across all field values
|
|
312
|
+
* filter[<field>_<op>]=val - structured filter; see filterEngine.js
|
|
313
313
|
*
|
|
314
314
|
* Example:
|
|
315
315
|
* /api/collections/jobs/entries?filter[location]=London&filter[salary_gte]=50000
|
|
@@ -388,7 +388,7 @@ export async function collectionsRoutes(fastify) {
|
|
|
388
388
|
}
|
|
389
389
|
});
|
|
390
390
|
|
|
391
|
-
// Clear all entries
|
|
391
|
+
// Clear all entries - DELETE /collections/:slug/entries (no :id)
|
|
392
392
|
fastify.delete('/collections/:slug/entries', canDelete, async (request, reply) => {
|
|
393
393
|
try {
|
|
394
394
|
await clearEntries(request.params.slug);
|
|
@@ -493,11 +493,11 @@ export async function collectionsRoutes(fastify) {
|
|
|
493
493
|
/*
|
|
494
494
|
* GET /api/collections/:slug/public
|
|
495
495
|
*
|
|
496
|
-
* Public read endpoint
|
|
496
|
+
* Public read endpoint - gated by `api.read` in the collection schema.
|
|
497
497
|
* Supports the same pagination/sort/search/filter as the admin endpoint
|
|
498
498
|
* (see GET /collections/:slug/entries above), plus:
|
|
499
499
|
*
|
|
500
|
-
* scope=mine
|
|
500
|
+
* scope=mine - restrict results to entries created by the current
|
|
501
501
|
* user (`meta.createdBy === user.id`). Requires a valid
|
|
502
502
|
* JWT; returns 401 if unauthenticated. Used by the
|
|
503
503
|
* `[collection scope="mine"]` shortcode for per-user
|
|
@@ -566,11 +566,11 @@ export async function collectionsRoutes(fastify) {
|
|
|
566
566
|
* resolved here and the rendered HTML fragment swapped in client-side.
|
|
567
567
|
*
|
|
568
568
|
* Body: { attrs: <base64 JSON of the original shortcode attributes> }
|
|
569
|
-
* Auth: JWT required
|
|
569
|
+
* Auth: JWT required - `createdBy = <user.id>` is injected server-side
|
|
570
570
|
* (the client cannot tamper with which user's data they see).
|
|
571
571
|
* Returns: { html: '<div class="dm-collection-…">…</div>' }
|
|
572
572
|
*
|
|
573
|
-
* Security: results are always scoped to `createdBy = <user.id>`
|
|
573
|
+
* Security: results are always scoped to `createdBy = <user.id>` -
|
|
574
574
|
* a caller can only ever render their own entries, regardless of the
|
|
575
575
|
* collection's `api.read` setting. This matches the security model
|
|
576
576
|
* of GET /:slug/public?scope=mine.
|
|
@@ -586,11 +586,11 @@ export async function collectionsRoutes(fastify) {
|
|
|
586
586
|
*
|
|
587
587
|
* Body: {
|
|
588
588
|
* attrs: <base64 JSON of the shortcode attributes>,
|
|
589
|
-
* page: <number, optional
|
|
589
|
+
* page: <number, optional - applied via attrs.limit + slicing>
|
|
590
590
|
* }
|
|
591
591
|
*
|
|
592
592
|
* Auth: gated by the target collection's `api.read` setting via
|
|
593
|
-
* checkPublicAccess(). No user identity is injected
|
|
593
|
+
* checkPublicAccess(). No user identity is injected - this
|
|
594
594
|
* endpoint is for general fragment rendering, not per-user
|
|
595
595
|
* scoping (that's render-scope's job).
|
|
596
596
|
*
|
|
@@ -712,7 +712,7 @@ export async function collectionsRoutes(fastify) {
|
|
|
712
712
|
fastify.delete('/collections/:slug/public/:id', publicDeleteEntry);
|
|
713
713
|
|
|
714
714
|
// -------------------------------------------------------------------------
|
|
715
|
-
// External versioned API
|
|
715
|
+
// External versioned API - stable alias of the public endpoints above.
|
|
716
716
|
// Documented surface for external consumers (docs/api-reference.md).
|
|
717
717
|
// -------------------------------------------------------------------------
|
|
718
718
|
|
|
@@ -98,7 +98,7 @@ function buildTopPages(daily, days = 7) {
|
|
|
98
98
|
.slice(0, 5);
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
-
// ── Journeys / spikes
|
|
101
|
+
// ── Journeys / spikes - ported verbatim from plugins/analytics so the core
|
|
102
102
|
// dashboard can compute them off disk without the plugin's encapsulated
|
|
103
103
|
// `fastify.analytics` decorator (which it cannot see). Keep in sync with
|
|
104
104
|
// plugins/analytics/plugin.js.
|
|
@@ -181,7 +181,7 @@ function detectSpikes(daily, now = new Date()) {
|
|
|
181
181
|
}
|
|
182
182
|
|
|
183
183
|
/**
|
|
184
|
-
* Recent activity feed
|
|
184
|
+
* Recent activity feed - combines page version events with recent collection
|
|
185
185
|
* entries (form submissions). Returns at most 10 items, newest first.
|
|
186
186
|
*
|
|
187
187
|
* @returns {Promise<Array<object>>}
|
|
@@ -189,7 +189,7 @@ function detectSpikes(daily, now = new Date()) {
|
|
|
189
189
|
async function buildActivity() {
|
|
190
190
|
const items = [];
|
|
191
191
|
|
|
192
|
-
// Versions
|
|
192
|
+
// Versions - read each page's _meta.json via the service so the schema
|
|
193
193
|
// stays in one place. listVersions returns newest-first.
|
|
194
194
|
const versionsDir = path.join(ROOT, 'content', 'versions');
|
|
195
195
|
try {
|
|
@@ -211,7 +211,7 @@ async function buildActivity() {
|
|
|
211
211
|
}
|
|
212
212
|
} catch { /* no versions dir */ }
|
|
213
213
|
|
|
214
|
-
// Collection entries
|
|
214
|
+
// Collection entries - each slug has a single data.json with an array of
|
|
215
215
|
// entries. Take the newest ENTRIES_PER_COLLECTION by meta.createdAt.
|
|
216
216
|
const collectionsDir = path.join(ROOT, 'content', 'collections');
|
|
217
217
|
try {
|
|
@@ -277,7 +277,7 @@ export async function dashboardRoutes(fastify, opts = {}) {
|
|
|
277
277
|
const analytics = fastify.analytics;
|
|
278
278
|
// The analytics plugin decorates `analytics` inside its own prefixed,
|
|
279
279
|
// encapsulated scope, so `fastify.analytics` is undefined here in the
|
|
280
|
-
// core dashboard route
|
|
280
|
+
// core dashboard route - traffic/topPages always read 0 even though hits
|
|
281
281
|
// are recorded. Read the plugin's daily counters straight off disk (same
|
|
282
282
|
// pattern as buildActivity reading versions/collections below).
|
|
283
283
|
const daily = (await safe('analytics.daily', async () => {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Effects API Routes
|
|
3
|
-
* GET /api/effects/settings
|
|
4
|
-
* PUT /api/effects/settings
|
|
3
|
+
* GET /api/effects/settings - public (read by public site IIFE)
|
|
4
|
+
* PUT /api/effects/settings - admin-only (save user overrides)
|
|
5
5
|
*/
|
|
6
6
|
import {authenticate, requireAdmin} from '../../middleware/auth.js';
|
|
7
7
|
import {getConfig, saveConfig} from '../../config.js';
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Custom API Endpoints
|
|
2
|
+
* Custom API Endpoints - public surface
|
|
3
3
|
*
|
|
4
4
|
* One catch-all serves every user-defined endpoint:
|
|
5
5
|
* GET /api/x/<project><path> e.g. /api/x/world-cup/fixtures-day/2026-06-11
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* Definitions live in the `api-endpoints` preset collection and are matched
|
|
8
8
|
* at request time by services/apiEndpoints.js. Auth (public / token / role),
|
|
9
9
|
* project binding, token scopes, and the read field allowlist all reuse the
|
|
10
|
-
* public collections machinery via a synthesized schema
|
|
10
|
+
* public collections machinery via a synthesized schema - one battle-tested
|
|
11
11
|
* code path for every external read.
|
|
12
12
|
*
|
|
13
13
|
* Unknown project, unknown path, and disabled endpoints all 404 identically
|
|
@@ -17,7 +17,7 @@ import {EndpointParamError, executeEndpoint, matchEndpoint} from '../../services
|
|
|
17
17
|
import {applyReadFieldAllowlist, checkPublicAccess} from './collections.js';
|
|
18
18
|
import * as cache from '../../services/cache/index.js';
|
|
19
19
|
|
|
20
|
-
/** Cached sentinel for single-mode misses
|
|
20
|
+
/** Cached sentinel for single-mode misses - a miss is a cacheable answer. */
|
|
21
21
|
const NOT_FOUND = {__endpointNotFound: true};
|
|
22
22
|
|
|
23
23
|
/**
|
|
@@ -44,7 +44,7 @@ export async function endpointsPublicRoutes(fastify) {
|
|
|
44
44
|
|
|
45
45
|
const {def, params} = match;
|
|
46
46
|
|
|
47
|
-
// Synthesized schema
|
|
47
|
+
// Synthesized schema - checkPublicAccess reads exactly api.read,
|
|
48
48
|
// slug (token scopes), and meta.project (token project binding).
|
|
49
49
|
const synth = {
|
|
50
50
|
slug: def.collection,
|
|
@@ -63,7 +63,7 @@ export async function endpointsPublicRoutes(fastify) {
|
|
|
63
63
|
};
|
|
64
64
|
|
|
65
65
|
try {
|
|
66
|
-
// Only public responses are cacheable
|
|
66
|
+
// Only public responses are cacheable - token/role responses are
|
|
67
67
|
// caller-dependent. Both tags are invalidated by the service layer
|
|
68
68
|
// on every write path: entry mutations bust collection:<slug>, and
|
|
69
69
|
// definition edits (entries in api-endpoints) bust
|
|
@@ -4,18 +4,18 @@
|
|
|
4
4
|
* Submissions are stored exclusively in Collections (no dual-storage).
|
|
5
5
|
*
|
|
6
6
|
* Endpoints (prefix: /api):
|
|
7
|
-
* GET /forms
|
|
8
|
-
* POST /forms
|
|
9
|
-
* GET /forms/:slug
|
|
10
|
-
* GET /forms/:slug/public
|
|
11
|
-
* PUT /forms/:slug
|
|
12
|
-
* DELETE /forms/:slug
|
|
13
|
-
* GET /forms/:slug/submissions
|
|
14
|
-
* GET /forms/:slug/submissions/export
|
|
15
|
-
* DELETE /forms/:slug/submissions
|
|
16
|
-
* DELETE /forms/:slug/submissions/:id
|
|
17
|
-
* POST /forms/submit/:slug
|
|
18
|
-
* POST /forms/test-email
|
|
7
|
+
* GET /forms - admin: list all forms
|
|
8
|
+
* POST /forms - admin: create new form
|
|
9
|
+
* GET /forms/:slug - admin: get form definition
|
|
10
|
+
* GET /forms/:slug/public - public: get form (no actions block)
|
|
11
|
+
* PUT /forms/:slug - admin: update form definition
|
|
12
|
+
* DELETE /forms/:slug - admin: delete form
|
|
13
|
+
* GET /forms/:slug/submissions - admin: list submissions from collection
|
|
14
|
+
* GET /forms/:slug/submissions/export - admin: CSV export from collection
|
|
15
|
+
* DELETE /forms/:slug/submissions - admin: clear all submissions
|
|
16
|
+
* DELETE /forms/:slug/submissions/:id - admin: delete one submission
|
|
17
|
+
* POST /forms/submit/:slug - public: accept submission
|
|
18
|
+
* POST /forms/test-email - admin: send test email
|
|
19
19
|
*/
|
|
20
20
|
import {createRequire} from 'module';
|
|
21
21
|
import {fileURLToPath} from 'url';
|
|
@@ -40,7 +40,7 @@ import {v4 as uuidv4} from 'uuid';
|
|
|
40
40
|
/**
|
|
41
41
|
* Detect form↔collection mismatches at save time so admins know BEFORE
|
|
42
42
|
* users hit silent rejection. Returns an array of human-readable warning
|
|
43
|
-
* strings
|
|
43
|
+
* strings - empty when the form lines up cleanly with its target collection.
|
|
44
44
|
*
|
|
45
45
|
* Checks performed:
|
|
46
46
|
* - Target collection exists (otherwise every submission goes nowhere)
|
|
@@ -63,7 +63,7 @@ async function detectFormCollectionMismatch(form) {
|
|
|
63
63
|
return warnings;
|
|
64
64
|
}
|
|
65
65
|
if (!collection) {
|
|
66
|
-
warnings.push(`Target collection "${targetSlug}" does not exist
|
|
66
|
+
warnings.push(`Target collection "${targetSlug}" does not exist - submissions will fail. Either create the collection or change the form's target.`);
|
|
67
67
|
return warnings;
|
|
68
68
|
}
|
|
69
69
|
|
|
@@ -145,7 +145,7 @@ function validateUpload(fieldCfg, part, size) {
|
|
|
145
145
|
* `content/media/`, and replaced by a `{ url, name, size, mime }` reference.
|
|
146
146
|
*
|
|
147
147
|
* Throws on validation failure (caller handles HTTP 400). Files are streamed
|
|
148
|
-
* into memory up to fastify-multipart's configured limit
|
|
148
|
+
* into memory up to fastify-multipart's configured limit - fine for the
|
|
149
149
|
* resume-pdf / portrait-photo scale; large uploads should still use the admin
|
|
150
150
|
* media flow.
|
|
151
151
|
*
|
|
@@ -163,7 +163,7 @@ async function parseMultipartForm(request, form) {
|
|
|
163
163
|
if (part.type === 'file') {
|
|
164
164
|
const fieldCfg = fileFields.get(part.fieldname);
|
|
165
165
|
if (!fieldCfg) {
|
|
166
|
-
// Unknown file field
|
|
166
|
+
// Unknown file field - drain and skip rather than 400, so a
|
|
167
167
|
// form that gains a file input later doesn't reject older
|
|
168
168
|
// submissions in-flight from a stale page.
|
|
169
169
|
for await (const _ of part.file) { /* drain */ }
|
|
@@ -174,7 +174,7 @@ async function parseMultipartForm(request, form) {
|
|
|
174
174
|
const buffer = Buffer.concat(chunks);
|
|
175
175
|
const err = validateUpload(fieldCfg, part, buffer.length);
|
|
176
176
|
if (err) throw new Error(err);
|
|
177
|
-
if (buffer.length === 0) continue; // empty file input
|
|
177
|
+
if (buffer.length === 0) continue; // empty file input - skip
|
|
178
178
|
const saved = await saveMedia(safeFilename(part.filename), buffer);
|
|
179
179
|
out[part.fieldname] = {
|
|
180
180
|
url: saved.url,
|
|
@@ -183,7 +183,7 @@ async function parseMultipartForm(request, form) {
|
|
|
183
183
|
mime: part.mimetype
|
|
184
184
|
};
|
|
185
185
|
} else {
|
|
186
|
-
// Text field
|
|
186
|
+
// Text field - last value wins on duplicates (browser-standard).
|
|
187
187
|
out[part.fieldname] = part.value;
|
|
188
188
|
}
|
|
189
189
|
}
|
|
@@ -237,7 +237,7 @@ export async function formsRoutes(fastify) {
|
|
|
237
237
|
const canDelete = { preHandler: [authenticate, requirePermission('collections', 'delete')] };
|
|
238
238
|
|
|
239
239
|
// -----------------------------------------------------------------------
|
|
240
|
-
// GET /forms
|
|
240
|
+
// GET /forms - list all form definitions with submission counts
|
|
241
241
|
// -----------------------------------------------------------------------
|
|
242
242
|
fastify.get('/forms', canRead, async (request) => {
|
|
243
243
|
const {canSeeArtefact} = await import('../../services/projects.js');
|
|
@@ -245,7 +245,7 @@ export async function formsRoutes(fastify) {
|
|
|
245
245
|
const result = await Promise.all(forms.map(async form => {
|
|
246
246
|
let submissionCount = 0;
|
|
247
247
|
try {
|
|
248
|
-
// listEntries returns { entries, total, page, limit }
|
|
248
|
+
// listEntries returns { entries, total, page, limit } - total is the
|
|
249
249
|
// unpaginated count which is what we want for the badge here.
|
|
250
250
|
const r = await listEntries(form.slug, { limit: 0 });
|
|
251
251
|
submissionCount = r.total ?? (r.entries || []).length;
|
|
@@ -263,7 +263,7 @@ export async function formsRoutes(fastify) {
|
|
|
263
263
|
});
|
|
264
264
|
|
|
265
265
|
// -----------------------------------------------------------------------
|
|
266
|
-
// POST /forms
|
|
266
|
+
// POST /forms - create new form
|
|
267
267
|
// -----------------------------------------------------------------------
|
|
268
268
|
fastify.post('/forms', canCreate, async (request, reply) => {
|
|
269
269
|
const { title, slug: rawSlug } = request.body || {};
|
|
@@ -280,7 +280,7 @@ export async function formsRoutes(fastify) {
|
|
|
280
280
|
await readForm(slug);
|
|
281
281
|
return reply.status(409).send({ error: `A form with slug "${slug}" already exists.` });
|
|
282
282
|
} catch {
|
|
283
|
-
// Does not exist
|
|
283
|
+
// Does not exist - good
|
|
284
284
|
}
|
|
285
285
|
|
|
286
286
|
const now = new Date().toISOString();
|
|
@@ -333,7 +333,7 @@ export async function formsRoutes(fastify) {
|
|
|
333
333
|
});
|
|
334
334
|
|
|
335
335
|
// -----------------------------------------------------------------------
|
|
336
|
-
// GET /forms/:slug
|
|
336
|
+
// GET /forms/:slug - get single form (admin, includes actions)
|
|
337
337
|
// -----------------------------------------------------------------------
|
|
338
338
|
fastify.get('/forms/:slug', canRead, async (request, reply) => {
|
|
339
339
|
let form;
|
|
@@ -350,7 +350,7 @@ export async function formsRoutes(fastify) {
|
|
|
350
350
|
});
|
|
351
351
|
|
|
352
352
|
// -----------------------------------------------------------------------
|
|
353
|
-
// GET /forms/:slug/public
|
|
353
|
+
// GET /forms/:slug/public - get form for public rendering (no actions)
|
|
354
354
|
// -----------------------------------------------------------------------
|
|
355
355
|
fastify.get('/forms/:slug/public', async (request, reply) => {
|
|
356
356
|
try {
|
|
@@ -363,7 +363,7 @@ export async function formsRoutes(fastify) {
|
|
|
363
363
|
});
|
|
364
364
|
|
|
365
365
|
// -----------------------------------------------------------------------
|
|
366
|
-
// PUT /forms/:slug
|
|
366
|
+
// PUT /forms/:slug - update form definition
|
|
367
367
|
// -----------------------------------------------------------------------
|
|
368
368
|
fastify.put('/forms/:slug', canUpdate, async (request, reply) => {
|
|
369
369
|
const { slug } = request.params;
|
|
@@ -386,14 +386,14 @@ export async function formsRoutes(fastify) {
|
|
|
386
386
|
|
|
387
387
|
// Non-blocking warnings: check the form's fields against its target
|
|
388
388
|
// collection's required fields. Missing-required-on-form would cause
|
|
389
|
-
// every submission to fail server-side validation
|
|
389
|
+
// every submission to fail server-side validation - admin gets a
|
|
390
390
|
// heads-up here so they can fix it BEFORE users hit silent rejection.
|
|
391
391
|
const warnings = await detectFormCollectionMismatch(updated);
|
|
392
392
|
return warnings.length ? { ...updated, warnings } : updated;
|
|
393
393
|
});
|
|
394
394
|
|
|
395
395
|
// -----------------------------------------------------------------------
|
|
396
|
-
// DELETE /forms/:slug
|
|
396
|
+
// DELETE /forms/:slug - delete form
|
|
397
397
|
// -----------------------------------------------------------------------
|
|
398
398
|
fastify.delete('/forms/:slug', canDelete, async (request, reply) => {
|
|
399
399
|
const { slug } = request.params;
|
|
@@ -406,7 +406,7 @@ export async function formsRoutes(fastify) {
|
|
|
406
406
|
});
|
|
407
407
|
|
|
408
408
|
// -----------------------------------------------------------------------
|
|
409
|
-
// GET /forms/:slug/submissions
|
|
409
|
+
// GET /forms/:slug/submissions - list submissions from collection
|
|
410
410
|
// -----------------------------------------------------------------------
|
|
411
411
|
fastify.get('/forms/:slug/submissions', canRead, async (request, reply) => {
|
|
412
412
|
const { slug } = request.params;
|
|
@@ -420,19 +420,19 @@ export async function formsRoutes(fastify) {
|
|
|
420
420
|
const targetSlug = (collectionAction?.enabled && collectionAction.slug) ? collectionAction.slug : slug;
|
|
421
421
|
|
|
422
422
|
// A form whose backing collection has not been provisioned yet simply
|
|
423
|
-
// has no submissions
|
|
423
|
+
// has no submissions - return an empty list rather than a 404 so the
|
|
424
424
|
// admin view loads cleanly instead of erroring.
|
|
425
425
|
const collection = await getCollection(targetSlug);
|
|
426
426
|
if (!collection) return [];
|
|
427
427
|
|
|
428
|
-
// listEntries returns { entries, total, page, limit }
|
|
428
|
+
// listEntries returns { entries, total, page, limit } - unwrap to the
|
|
429
429
|
// array. Newest-first is the conventional display order.
|
|
430
430
|
const { entries } = await listEntries(targetSlug, { limit: 0 });
|
|
431
431
|
return [...entries].reverse();
|
|
432
432
|
});
|
|
433
433
|
|
|
434
434
|
// -----------------------------------------------------------------------
|
|
435
|
-
// GET /forms/:slug/submissions/export
|
|
435
|
+
// GET /forms/:slug/submissions/export - CSV download from collection
|
|
436
436
|
// -----------------------------------------------------------------------
|
|
437
437
|
fastify.get('/forms/:slug/submissions/export', canRead, async (request, reply) => {
|
|
438
438
|
const { slug } = request.params;
|
|
@@ -456,7 +456,7 @@ export async function formsRoutes(fastify) {
|
|
|
456
456
|
});
|
|
457
457
|
|
|
458
458
|
// -----------------------------------------------------------------------
|
|
459
|
-
// GET /forms/:slug/submissions/export/json
|
|
459
|
+
// GET /forms/:slug/submissions/export/json - JSON export from collection
|
|
460
460
|
// -----------------------------------------------------------------------
|
|
461
461
|
fastify.get('/forms/:slug/submissions/export/json', canRead, async (request, reply) => {
|
|
462
462
|
const { slug } = request.params;
|
|
@@ -479,7 +479,7 @@ export async function formsRoutes(fastify) {
|
|
|
479
479
|
});
|
|
480
480
|
|
|
481
481
|
// -----------------------------------------------------------------------
|
|
482
|
-
// DELETE /forms/:slug/submissions
|
|
482
|
+
// DELETE /forms/:slug/submissions - clear all submissions
|
|
483
483
|
// -----------------------------------------------------------------------
|
|
484
484
|
fastify.delete('/forms/:slug/submissions', canDelete, async (request, reply) => {
|
|
485
485
|
const { slug } = request.params;
|
|
@@ -492,7 +492,7 @@ export async function formsRoutes(fastify) {
|
|
|
492
492
|
});
|
|
493
493
|
|
|
494
494
|
// -----------------------------------------------------------------------
|
|
495
|
-
// DELETE /forms/:slug/submissions/:id
|
|
495
|
+
// DELETE /forms/:slug/submissions/:id - delete one submission
|
|
496
496
|
// -----------------------------------------------------------------------
|
|
497
497
|
fastify.delete('/forms/:slug/submissions/:id', canDelete, async (request, reply) => {
|
|
498
498
|
const { slug, id } = request.params;
|
|
@@ -505,12 +505,12 @@ export async function formsRoutes(fastify) {
|
|
|
505
505
|
});
|
|
506
506
|
|
|
507
507
|
/*
|
|
508
|
-
* POST /forms/submit/:slug
|
|
508
|
+
* POST /forms/submit/:slug - public form submission.
|
|
509
509
|
*
|
|
510
510
|
* Authentication is OPTIONAL: when a valid JWT is present the submitter
|
|
511
511
|
* is captured as the entry's `createdBy` and passed into any triggered
|
|
512
512
|
* action's template context (as `{{user.id}}`, `{{user.email}}`, etc.).
|
|
513
|
-
* Anonymous submissions still work
|
|
513
|
+
* Anonymous submissions still work - `createdBy` is null and actions
|
|
514
514
|
* receive an empty user. This is what makes the apply/bookmark patterns
|
|
515
515
|
* work without forcing every form to be auth-only:
|
|
516
516
|
*
|
|
@@ -527,7 +527,7 @@ export async function formsRoutes(fastify) {
|
|
|
527
527
|
return reply.status(404).send({ error: 'Form not found.' });
|
|
528
528
|
}
|
|
529
529
|
|
|
530
|
-
// Multipart parsing
|
|
530
|
+
// Multipart parsing - when the request is multipart (file uploads),
|
|
531
531
|
// we walk parts ourselves: text fields go into `body`, file parts are
|
|
532
532
|
// validated against the form's field config and saved to /content/media,
|
|
533
533
|
// then their { url, name, size, mime } reference object lands in `body`
|
|
@@ -542,7 +542,7 @@ export async function formsRoutes(fastify) {
|
|
|
542
542
|
}
|
|
543
543
|
const settings = form.settings || {};
|
|
544
544
|
|
|
545
|
-
// Best-effort auth
|
|
545
|
+
// Best-effort auth - never reject, just enrich.
|
|
546
546
|
let submittingUser = null;
|
|
547
547
|
try {
|
|
548
548
|
const decoded = await request.jwtVerify();
|
|
@@ -556,12 +556,12 @@ export async function formsRoutes(fastify) {
|
|
|
556
556
|
}
|
|
557
557
|
} catch { /* anonymous submission */ }
|
|
558
558
|
|
|
559
|
-
// Honeypot check
|
|
559
|
+
// Honeypot check - silently accept if filled (bot detected)
|
|
560
560
|
if (settings.honeypot && body._hp) {
|
|
561
561
|
return { ok: true, message: settings.successMessage, redirect: settings.successRedirect || null };
|
|
562
562
|
}
|
|
563
563
|
|
|
564
|
-
// Timing check
|
|
564
|
+
// Timing check - silently accept if submitted too fast (< 2 s, likely a bot)
|
|
565
565
|
if (settings.honeypot && body._t) {
|
|
566
566
|
const elapsed = Date.now() - Number(body._t);
|
|
567
567
|
if (!Number.isNaN(elapsed) && elapsed < 2000) {
|
|
@@ -614,7 +614,7 @@ export async function formsRoutes(fastify) {
|
|
|
614
614
|
return reply.status(429).send({ error: 'Too many submissions. Please try again later.' });
|
|
615
615
|
}
|
|
616
616
|
|
|
617
|
-
// Build submission data
|
|
617
|
+
// Build submission data - only include visible fields
|
|
618
618
|
const data = {};
|
|
619
619
|
for (const field of form.fields || []) {
|
|
620
620
|
if (field.type === 'page-break' || field.type === 'spacer') continue;
|
|
@@ -630,9 +630,9 @@ export async function formsRoutes(fastify) {
|
|
|
630
630
|
// CRITICAL: collection write failure is the difference between "your
|
|
631
631
|
// submission was saved" and "your submission disappeared into thin air".
|
|
632
632
|
// We MUST surface the error to the user rather than logging a warning
|
|
633
|
-
// and returning success
|
|
633
|
+
// and returning success - anything else is a silent-data-loss bug.
|
|
634
634
|
// (Email / webhook / action failures downstream are still treated as
|
|
635
|
-
// non-fatal
|
|
635
|
+
// non-fatal - they fire AFTER the entry is persisted, so even a partial
|
|
636
636
|
// delivery has the original submission safely stored.)
|
|
637
637
|
const collectionAction = form.actions?.collection;
|
|
638
638
|
const targetSlug = (collectionAction?.enabled && collectionAction.slug) ? collectionAction.slug : slug;
|
|
@@ -653,12 +653,12 @@ export async function formsRoutes(fastify) {
|
|
|
653
653
|
createdBy: submittingUser?.id || null
|
|
654
654
|
});
|
|
655
655
|
} else {
|
|
656
|
-
// Could not resolve or provision a target
|
|
656
|
+
// Could not resolve or provision a target - e.g. collection
|
|
657
657
|
// storage is explicitly disabled with no slug. Fail loudly
|
|
658
658
|
// rather than silently lose submissions.
|
|
659
659
|
fastify.log.warn(`[forms] Submission for "${slug}" had no target collection "${targetSlug}"`);
|
|
660
660
|
return reply.status(500).send({
|
|
661
|
-
error: `Submission not saved
|
|
661
|
+
error: `Submission not saved - target collection "${targetSlug}" does not exist. Please contact the site administrator.`
|
|
662
662
|
});
|
|
663
663
|
}
|
|
664
664
|
} catch (err) {
|
|
@@ -706,7 +706,7 @@ export async function formsRoutes(fastify) {
|
|
|
706
706
|
}
|
|
707
707
|
}
|
|
708
708
|
|
|
709
|
-
// CMS Action trigger
|
|
709
|
+
// CMS Action trigger - forwards the authenticated submitter so
|
|
710
710
|
// action steps can interpolate {{user.id}}, {{user.email}}, etc.
|
|
711
711
|
// (Anonymous submissions still trigger the action with user={}.)
|
|
712
712
|
const actionSlug = form.settings?.actionSlug;
|
|
@@ -735,7 +735,7 @@ export async function formsRoutes(fastify) {
|
|
|
735
735
|
});
|
|
736
736
|
|
|
737
737
|
// -----------------------------------------------------------------------
|
|
738
|
-
// POST /forms/test-email
|
|
738
|
+
// POST /forms/test-email - send a test email
|
|
739
739
|
// -----------------------------------------------------------------------
|
|
740
740
|
fastify.post('/forms/test-email', { preHandler: [authenticate, requireAdmin] }, async (request, reply) => {
|
|
741
741
|
const smtp = getConfig('site').smtp || {};
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Menu locations API
|
|
3
3
|
*
|
|
4
|
-
* GET /api/menu-locations
|
|
5
|
-
* PUT /api/menu-locations
|
|
6
|
-
* GET /api/menu-locations/registry
|
|
4
|
+
* GET /api/menu-locations - current slot → menu-slug map
|
|
5
|
+
* PUT /api/menu-locations - replace the map (validates against registry + menus)
|
|
6
|
+
* GET /api/menu-locations/registry - list registered slots (core + plugin-contributed)
|
|
7
7
|
*
|
|
8
8
|
* Auth middlewares are accepted as options so tests can supply no-ops without
|
|
9
9
|
* needing to register `@fastify/jwt` + seed the roles cache. In production the
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Menus API
|
|
3
3
|
*
|
|
4
|
-
* GET /api/menus
|
|
5
|
-
* GET /api/menus/:slug
|
|
6
|
-
* POST /api/menus
|
|
7
|
-
* PUT /api/menus/:slug
|
|
8
|
-
* DELETE /api/menus/:slug
|
|
9
|
-
* POST /api/menus/:slug/duplicate
|
|
4
|
+
* GET /api/menus - list (metadata only)
|
|
5
|
+
* GET /api/menus/:slug - full menu
|
|
6
|
+
* POST /api/menus - create
|
|
7
|
+
* PUT /api/menus/:slug - replace
|
|
8
|
+
* DELETE /api/menus/:slug - delete (409 if mapped to a slot)
|
|
9
|
+
* POST /api/menus/:slug/duplicate - copy as <slug>-copy
|
|
10
10
|
*
|
|
11
11
|
* Auth middlewares are accepted as options so tests can supply no-ops without
|
|
12
12
|
* needing to register `@fastify/jwt` + seed the roles cache. In production the
|
|
@@ -93,7 +93,7 @@ export async function menusRoutes(fastify, opts = {}) {
|
|
|
93
93
|
const slot = Object.entries(map).find(([, slug]) => slug === request.params.slug)?.[0];
|
|
94
94
|
if (slot) {
|
|
95
95
|
return reply.status(409).send({
|
|
96
|
-
error: `Cannot delete
|
|
96
|
+
error: `Cannot delete - menu is mapped to slot "${slot}". Unmap it first.`
|
|
97
97
|
});
|
|
98
98
|
}
|
|
99
99
|
const ok = await deleteMenu(request.params.slug);
|