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
@@ -40,6 +40,7 @@ import {
40
40
  } from '../../services/collections.js';
41
41
  import {authenticate, requireAdmin, requirePermission} from '../../middleware/auth.js';
42
42
  import {getRoleLevel, invalidate as invalidateRoles} from '../../services/roles.js';
43
+ import {getEffectiveLevel} from '../../services/userRoles.js';
43
44
  import {getConfig, saveConfig} from '../../config.js';
44
45
  import {PRESET_COLLECTION_SLUGS} from '../../services/presetCollections.js';
45
46
  import {ensureFormForCollection} from '../../services/forms.js';
@@ -57,6 +58,30 @@ function roleLevel(roleName) {
57
58
  return getRoleLevel(roleName);
58
59
  }
59
60
 
61
+ /**
62
+ * Extract bracket-notation query params (e.g. `filter[location]=London`) into
63
+ * a flat sub-object keyed by the inner name.
64
+ *
65
+ * @example
66
+ * extractBracketed({ 'filter[location]': 'London', 'filter[salary_gte]': '50000' }, 'filter')
67
+ * → { location: 'London', salary_gte: '50000' }
68
+ *
69
+ * @param {Record<string, string>} query
70
+ * @param {string} prefix
71
+ * @returns {Record<string, string>}
72
+ */
73
+ function extractBracketed(query, prefix) {
74
+ const out = {};
75
+ const head = `${prefix}[`;
76
+ for (const [k, v] of Object.entries(query)) {
77
+ if (k.startsWith(head) && k.endsWith(']')) {
78
+ const inner = k.slice(head.length, -1);
79
+ if (inner) out[inner] = v;
80
+ }
81
+ }
82
+ return out;
83
+ }
84
+
60
85
  /**
61
86
  * Check public collection API access.
62
87
  * Returns an error reply if access is denied, otherwise resolves (returns undefined).
@@ -84,7 +109,9 @@ async function checkPublicAccess(schema, operation, request, reply) {
84
109
 
85
110
  const user = request.user;
86
111
  const requiredLevel = roleLevel(access.access);
87
- const userLevel = roleLevel(user?.role);
112
+ // Effective level — multi-role users get the lowest (most privileged) level
113
+ // across their primary + additional roles for this access decision.
114
+ const userLevel = getEffectiveLevel(user);
88
115
 
89
116
  if (userLevel > requiredLevel) {
90
117
  return reply.status(403).send({ error: 'Insufficient permissions' });
@@ -101,8 +128,16 @@ export async function collectionsRoutes(fastify) {
101
128
  // Collection CRUD (schema management)
102
129
  // -------------------------------------------------------------------------
103
130
 
104
- fastify.get('/collections', canRead, async () => {
105
- return listCollections();
131
+ fastify.get('/collections', canRead, async (request) => {
132
+ const {canSeeArtefact} = await import('../../services/projects.js');
133
+ const all = await listCollections();
134
+ const filtered = [];
135
+ for (const c of all) {
136
+ // listCollections returns metadata only; getCollection returns full record with meta
137
+ const full = await getCollection(c.slug);
138
+ if (canSeeArtefact(request.user, full)) filtered.push(c);
139
+ }
140
+ return filtered;
106
141
  });
107
142
 
108
143
  fastify.get('/collections/pro-status', canRead, async () => {
@@ -153,6 +188,10 @@ export async function collectionsRoutes(fastify) {
153
188
  fastify.get('/collections/:slug', canRead, async (request, reply) => {
154
189
  const schema = await getCollection(request.params.slug);
155
190
  if (!schema) return reply.status(404).send({ error: 'Collection not found' });
191
+ const {canSeeArtefact} = await import('../../services/projects.js');
192
+ if (!canSeeArtefact(request.user, schema)) {
193
+ return reply.status(403).send({ error: 'Access denied for this project' });
194
+ }
156
195
  return schema;
157
196
  });
158
197
 
@@ -180,16 +219,34 @@ export async function collectionsRoutes(fastify) {
180
219
  // Entry CRUD
181
220
  // -------------------------------------------------------------------------
182
221
 
222
+ /*
223
+ * GET /api/collections/:slug/entries
224
+ *
225
+ * Query parameters:
226
+ * page, limit, sort, order — pagination/sort (limit=0 means "no limit")
227
+ * search — free-text substring across all field values
228
+ * filter[<field>_<op>]=val — structured filter; see filterEngine.js
229
+ *
230
+ * Example:
231
+ * /api/collections/jobs/entries?filter[location]=London&filter[salary_gte]=50000
232
+ * /api/collections/jobs/entries?filter[tags_in]=remote,hybrid&sort=postedAt
233
+ *
234
+ * Fastify's default querystring parser keeps bracketed keys flat
235
+ * (`'filter[location]': 'London'`), so we expand them ourselves to avoid
236
+ * adding the `qs` dependency for one route.
237
+ */
183
238
  fastify.get('/collections/:slug/entries', canRead, async (request, reply) => {
184
239
  const schema = await getCollection(request.params.slug);
185
240
  if (!schema) return reply.status(404).send({ error: 'Collection not found' });
186
241
  const { page, limit, sort, order, search } = request.query;
242
+ const filter = extractBracketed(request.query, 'filter');
187
243
  return listEntries(request.params.slug, {
188
244
  page: parseInt(page, 10) || 1,
189
245
  limit: parseInt(limit, 10) || 50,
190
246
  sort: sort || 'createdAt',
191
247
  order: order || 'desc',
192
- search: search || undefined
248
+ search: search || undefined,
249
+ filter: Object.keys(filter).length ? filter : undefined
193
250
  });
194
251
  });
195
252
 
@@ -345,20 +402,54 @@ export async function collectionsRoutes(fastify) {
345
402
  // Public access endpoints
346
403
  // -------------------------------------------------------------------------
347
404
 
405
+ /*
406
+ * GET /api/collections/:slug/public
407
+ *
408
+ * Public read endpoint — gated by `api.read` in the collection schema.
409
+ * Supports the same pagination/sort/search/filter as the admin endpoint
410
+ * (see GET /collections/:slug/entries above), plus:
411
+ *
412
+ * scope=mine — restrict results to entries created by the current
413
+ * user (`meta.createdBy === user.id`). Requires a valid
414
+ * JWT; returns 401 if unauthenticated. Used by the
415
+ * `[collection scope="mine"]` shortcode for per-user
416
+ * client-side hydration (e.g. "My Applications").
417
+ */
348
418
  fastify.get('/collections/:slug/public', async (request, reply) => {
349
419
  const schema = await getCollection(request.params.slug);
350
420
  if (!schema) return reply.status(404).send({ error: 'Collection not found' });
351
421
 
352
- const denied = await checkPublicAccess(schema, 'read', request, reply);
353
- if (denied !== undefined) return;
422
+ const { page, limit, sort, order, search, scope, resolveRefs } = request.query;
423
+ const filter = extractBracketed(request.query, 'filter');
424
+
425
+ // scope=mine has its own security model: it requires auth and scopes
426
+ // results to the caller's own entries via `createdBy = user.id`. Bypasses
427
+ // the regular `api.read` gate so users can always view their own data
428
+ // (the typical "My applications", "My bookmarks", "My orders" pattern)
429
+ // without the admin having to expose the whole collection publicly.
430
+ if (scope === 'mine') {
431
+ try {
432
+ const decoded = await request.jwtVerify();
433
+ if (decoded.type !== 'access' || !decoded.id) {
434
+ return reply.status(401).send({ error: 'Authentication required' });
435
+ }
436
+ filter.createdBy = decoded.id;
437
+ } catch {
438
+ return reply.status(401).send({ error: 'Authentication required' });
439
+ }
440
+ } else {
441
+ const denied = await checkPublicAccess(schema, 'read', request, reply);
442
+ if (denied !== undefined) return;
443
+ }
354
444
 
355
- const { page, limit, sort, order, search } = request.query;
356
445
  return listEntries(request.params.slug, {
357
446
  page: parseInt(page, 10) || 1,
358
447
  limit: parseInt(limit, 10) || 50,
359
448
  sort: sort || 'createdAt',
360
449
  order: order || 'desc',
361
- search: search || undefined
450
+ search: search || undefined,
451
+ filter: Object.keys(filter).length ? filter : undefined,
452
+ resolveRefs: resolveRefs === 'true' || resolveRefs === '1'
362
453
  });
363
454
  });
364
455
 
@@ -374,6 +465,108 @@ export async function collectionsRoutes(fastify) {
374
465
  return entry;
375
466
  });
376
467
 
468
+ /*
469
+ * POST /api/collections/render-scope
470
+ *
471
+ * Renders a `[collection scope="mine"]` shortcode for the current user.
472
+ * Called by site.js for every `.dm-collection-hydrate` placeholder on a
473
+ * public page. The page itself is cached per-role; the per-user data is
474
+ * resolved here and the rendered HTML fragment swapped in client-side.
475
+ *
476
+ * Body: { attrs: <base64 JSON of the original shortcode attributes> }
477
+ * Auth: JWT required — `createdBy = <user.id>` is injected server-side
478
+ * (the client cannot tamper with which user's data they see).
479
+ * Returns: { html: '<div class="dm-collection-…">…</div>' }
480
+ *
481
+ * Security: results are always scoped to `createdBy = <user.id>` —
482
+ * a caller can only ever render their own entries, regardless of the
483
+ * collection's `api.read` setting. This matches the security model
484
+ * of GET /:slug/public?scope=mine.
485
+ */
486
+ fastify.post('/collections/render-fragment', async (request, reply) => {
487
+ /*
488
+ * POST /api/collections/render-fragment
489
+ *
490
+ * Renders a `[collection]` shortcode fragment server-side for the
491
+ * Collection Browser's `display="block"` mode (and any future display
492
+ * that needs server-rendered HTML per state change). Used because
493
+ * block templates are arbitrary HTML the client can't replicate.
494
+ *
495
+ * Body: {
496
+ * attrs: <base64 JSON of the shortcode attributes>,
497
+ * page: <number, optional — applied via attrs.limit + slicing>
498
+ * }
499
+ *
500
+ * Auth: gated by the target collection's `api.read` setting via
501
+ * checkPublicAccess(). No user identity is injected — this
502
+ * endpoint is for general fragment rendering, not per-user
503
+ * scoping (that's render-scope's job).
504
+ *
505
+ * Returns: { html: '<div class="dm-collection-…">…</div>' }
506
+ */
507
+ const encoded = request.body?.attrs;
508
+ if (typeof encoded !== 'string' || !encoded) {
509
+ return reply.status(400).send({ error: 'attrs is required' });
510
+ }
511
+
512
+ let attrs;
513
+ try {
514
+ attrs = JSON.parse(Buffer.from(encoded, 'base64').toString('utf8'));
515
+ } catch {
516
+ return reply.status(400).send({ error: 'Invalid attrs payload' });
517
+ }
518
+ if (!attrs || typeof attrs !== 'object' || typeof attrs.slug !== 'string') {
519
+ return reply.status(400).send({ error: 'Invalid attrs payload' });
520
+ }
521
+
522
+ const schema = await getCollection(attrs.slug);
523
+ if (!schema) return reply.status(404).send({ error: 'Collection not found' });
524
+
525
+ const denied = await checkPublicAccess(schema, 'read', request, reply);
526
+ if (denied !== undefined) return;
527
+
528
+ const { renderCollectionFragment } = await import('../../services/markdown.js');
529
+ const html = await renderCollectionFragment(attrs);
530
+ return { html };
531
+ });
532
+
533
+ fastify.post('/collections/render-scope', async (request, reply) => {
534
+ let decoded;
535
+ try {
536
+ decoded = await request.jwtVerify();
537
+ if (decoded.type !== 'access' || !decoded.id) {
538
+ return reply.status(401).send({ error: 'Authentication required' });
539
+ }
540
+ } catch {
541
+ return reply.status(401).send({ error: 'Authentication required' });
542
+ }
543
+
544
+ const encoded = request.body?.attrs;
545
+ if (typeof encoded !== 'string' || !encoded) {
546
+ return reply.status(400).send({ error: 'attrs is required' });
547
+ }
548
+
549
+ let attrs;
550
+ try {
551
+ attrs = JSON.parse(Buffer.from(encoded, 'base64').toString('utf8'));
552
+ } catch {
553
+ return reply.status(400).send({ error: 'Invalid attrs payload' });
554
+ }
555
+ if (!attrs || typeof attrs !== 'object' || typeof attrs.slug !== 'string') {
556
+ return reply.status(400).send({ error: 'Invalid attrs payload' });
557
+ }
558
+
559
+ const schema = await getCollection(attrs.slug);
560
+ if (!schema) return reply.status(404).send({ error: 'Collection not found' });
561
+
562
+ const { renderCollectionFragment } = await import('../../services/markdown.js');
563
+ const html = await renderCollectionFragment(attrs, {
564
+ extraFilter: { createdBy: decoded.id }
565
+ });
566
+
567
+ return { html };
568
+ });
569
+
377
570
  fastify.post('/collections/:slug/public', async (request, reply) => {
378
571
  const schema = await getCollection(request.params.slug);
379
572
  if (!schema) return reply.status(404).send({ error: 'Collection not found' });
@@ -34,6 +34,161 @@ import {
34
34
  import {getConfig} from '../../config.js';
35
35
  import {authenticate, requireAdmin, requirePermission} from '../../middleware/auth.js';
36
36
  import {hooks} from '../../services/hooks.js';
37
+ import {saveMedia} from '../../services/content.js';
38
+ import {v4 as uuidv4} from 'uuid';
39
+
40
+ /**
41
+ * Detect form↔collection mismatches at save time so admins know BEFORE
42
+ * users hit silent rejection. Returns an array of human-readable warning
43
+ * strings — empty when the form lines up cleanly with its target collection.
44
+ *
45
+ * Checks performed:
46
+ * - Target collection exists (otherwise every submission goes nowhere)
47
+ * - Every required collection field has a matching form field
48
+ * - Every form field references a known collection field (typos)
49
+ *
50
+ * @param {object} form - The just-saved form definition
51
+ * @returns {Promise<string[]>}
52
+ */
53
+ async function detectFormCollectionMismatch(form) {
54
+ const warnings = [];
55
+ const colAction = form?.actions?.collection;
56
+ const targetSlug = (colAction?.enabled && colAction.slug) ? colAction.slug : form?.slug;
57
+ if (!targetSlug) return warnings;
58
+
59
+ let collection;
60
+ try {
61
+ collection = await getCollection(targetSlug);
62
+ } catch {
63
+ return warnings;
64
+ }
65
+ if (!collection) {
66
+ warnings.push(`Target collection "${targetSlug}" does not exist — submissions will fail. Either create the collection or change the form's target.`);
67
+ return warnings;
68
+ }
69
+
70
+ const formFieldNames = new Set((form.fields || []).map(f => f.name).filter(Boolean));
71
+ const colFieldNames = new Set((collection.fields || []).map(f => f.name));
72
+
73
+ // Missing-required: collection requires X but form doesn't collect X
74
+ const missingRequired = (collection.fields || [])
75
+ .filter(f => f.required)
76
+ .filter(f => !formFieldNames.has(f.name))
77
+ .map(f => f.label || f.name);
78
+ if (missingRequired.length) {
79
+ warnings.push(`The "${targetSlug}" collection requires fields the form does not collect: ${missingRequired.join(', ')}. Submissions will be rejected with "X is required". Either mark these fields as not required on the collection, add them to the form, or set them via an action's createInCollection step.`);
80
+ }
81
+
82
+ // Unknown fields: form sends X but collection has no such field (typo / drift)
83
+ const unknown = [...formFieldNames].filter(n => !colFieldNames.has(n));
84
+ if (unknown.length) {
85
+ warnings.push(`The form has fields the "${targetSlug}" collection doesn't define: ${unknown.join(', ')}. These values will be stored but won't be validated, indexed, or displayed in [collection] blocks unless you add matching fields to the collection.`);
86
+ }
87
+
88
+ return warnings;
89
+ }
90
+
91
+ /**
92
+ * Sanitise a user-supplied filename:
93
+ * - strip path separators and traversal markers
94
+ * - keep only `[a-z0-9._-]`, lower-case
95
+ * - prepend an 8-char uuid prefix so concurrent uploads of the same name
96
+ * don't overwrite each other
97
+ *
98
+ * @param {string} raw
99
+ * @returns {string}
100
+ */
101
+ function safeFilename(raw) {
102
+ const cleaned = String(raw || 'upload')
103
+ .toLowerCase()
104
+ .replace(/[^\w.-]+/g, '-')
105
+ .replace(/-+/g, '-')
106
+ .replace(/^[-.]+|[-.]+$/g, '')
107
+ .slice(0, 200) || 'upload';
108
+ const prefix = uuidv4().slice(0, 8);
109
+ return `${prefix}-${cleaned}`;
110
+ }
111
+
112
+ /**
113
+ * Check a multipart file part against a form's `type: file` field config.
114
+ *
115
+ * @param {object} fieldCfg - The form-field definition
116
+ * @param {object} part - Multipart part (mimetype, filename)
117
+ * @param {number} size - Buffer length in bytes
118
+ * @returns {string|null} - Error message, or null if OK
119
+ */
120
+ function validateUpload(fieldCfg, part, size) {
121
+ const cfg = fieldCfg?.file || {};
122
+ const max = Number(cfg.maxSize) || 5 * 1024 * 1024; // 5 MB default
123
+ if (size > max) {
124
+ return `"${fieldCfg.label || fieldCfg.name}" file is too large (max ${Math.round(max / 1024)} KB).`;
125
+ }
126
+ const accept = String(cfg.accept || '').trim();
127
+ if (accept) {
128
+ // Comma-separated list of mime types, allowing wildcards like image/*
129
+ const patterns = accept.split(',').map(s => s.trim()).filter(Boolean);
130
+ const mime = part.mimetype || '';
131
+ const ok = patterns.some(p => {
132
+ if (p.endsWith('/*')) return mime.startsWith(p.slice(0, -1));
133
+ return p === mime;
134
+ });
135
+ if (!ok) {
136
+ return `"${fieldCfg.label || fieldCfg.name}" file type "${mime}" not allowed (expected: ${accept}).`;
137
+ }
138
+ }
139
+ return null;
140
+ }
141
+
142
+ /**
143
+ * Drain a multipart request into a JSON-shaped body. Text parts become string
144
+ * values; file parts are validated against the matching form field, saved to
145
+ * `content/media/`, and replaced by a `{ url, name, size, mime }` reference.
146
+ *
147
+ * Throws on validation failure (caller handles HTTP 400). Files are streamed
148
+ * into memory up to fastify-multipart's configured limit — fine for the
149
+ * resume-pdf / portrait-photo scale; large uploads should still use the admin
150
+ * media flow.
151
+ *
152
+ * @param {import('fastify').FastifyRequest} request
153
+ * @param {object} form
154
+ * @returns {Promise<object>}
155
+ */
156
+ async function parseMultipartForm(request, form) {
157
+ const out = {};
158
+ const fileFields = new Map(
159
+ (form.fields || []).filter(f => f.type === 'file').map(f => [f.name, f])
160
+ );
161
+
162
+ for await (const part of request.parts()) {
163
+ if (part.type === 'file') {
164
+ const fieldCfg = fileFields.get(part.fieldname);
165
+ if (!fieldCfg) {
166
+ // Unknown file field — drain and skip rather than 400, so a
167
+ // form that gains a file input later doesn't reject older
168
+ // submissions in-flight from a stale page.
169
+ for await (const _ of part.file) { /* drain */ }
170
+ continue;
171
+ }
172
+ const chunks = [];
173
+ for await (const chunk of part.file) chunks.push(chunk);
174
+ const buffer = Buffer.concat(chunks);
175
+ const err = validateUpload(fieldCfg, part, buffer.length);
176
+ if (err) throw new Error(err);
177
+ if (buffer.length === 0) continue; // empty file input — skip
178
+ const saved = await saveMedia(safeFilename(part.filename), buffer);
179
+ out[part.fieldname] = {
180
+ url: saved.url,
181
+ name: part.filename,
182
+ size: buffer.length,
183
+ mime: part.mimetype
184
+ };
185
+ } else {
186
+ // Text field — last value wins on duplicates (browser-standard).
187
+ out[part.fieldname] = part.value;
188
+ }
189
+ }
190
+ return out;
191
+ }
37
192
 
38
193
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
39
194
  const ROOT = path.resolve(__dirname, '..', '..', '..');
@@ -84,19 +239,27 @@ export async function formsRoutes(fastify) {
84
239
  // -----------------------------------------------------------------------
85
240
  // GET /forms — list all form definitions with submission counts
86
241
  // -----------------------------------------------------------------------
87
- fastify.get('/forms', canRead, async () => {
242
+ fastify.get('/forms', canRead, async (request) => {
243
+ const {canSeeArtefact} = await import('../../services/projects.js');
88
244
  const forms = await listForms();
89
245
  const result = await Promise.all(forms.map(async form => {
90
246
  let submissionCount = 0;
91
247
  try {
92
- const entries = await listEntries(form.slug);
93
- submissionCount = entries.length;
248
+ // listEntries returns { entries, total, page, limit } — total is the
249
+ // unpaginated count which is what we want for the badge here.
250
+ const r = await listEntries(form.slug, { limit: 0 });
251
+ submissionCount = r.total ?? (r.entries || []).length;
94
252
  } catch {
95
253
  // collection may not exist yet
96
254
  }
97
- return { ...form, submissionCount };
255
+ // listForms returns metadata only; readForm returns full record with meta
256
+ let full = null;
257
+ try { full = await readForm(form.slug); } catch { /* skip */ }
258
+ return { form: { ...form, submissionCount }, full };
98
259
  }));
99
- return result;
260
+ return result
261
+ .filter(({ full }) => canSeeArtefact(request.user, full))
262
+ .map(({ form }) => form);
100
263
  });
101
264
 
102
265
  // -----------------------------------------------------------------------
@@ -165,18 +328,25 @@ export async function formsRoutes(fastify) {
165
328
  fastify.log.warn(`[forms] Could not auto-create collection "${slug}": ${err.message}`);
166
329
  }
167
330
 
168
- return reply.status(201).send(form);
331
+ const warnings = await detectFormCollectionMismatch(form);
332
+ return reply.status(201).send(warnings.length ? { ...form, warnings } : form);
169
333
  });
170
334
 
171
335
  // -----------------------------------------------------------------------
172
336
  // GET /forms/:slug — get single form (admin, includes actions)
173
337
  // -----------------------------------------------------------------------
174
338
  fastify.get('/forms/:slug', canRead, async (request, reply) => {
339
+ let form;
175
340
  try {
176
- return await readForm(request.params.slug);
341
+ form = await readForm(request.params.slug);
177
342
  } catch {
178
343
  return reply.status(404).send({ error: 'Form not found.' });
179
344
  }
345
+ const {canSeeArtefact} = await import('../../services/projects.js');
346
+ if (!canSeeArtefact(request.user, form)) {
347
+ return reply.status(403).send({ error: 'Access denied for this project' });
348
+ }
349
+ return form;
180
350
  });
181
351
 
182
352
  // -----------------------------------------------------------------------
@@ -213,7 +383,13 @@ export async function formsRoutes(fastify) {
213
383
  updatedAt: new Date().toISOString()
214
384
  };
215
385
  await writeForm(slug, updated);
216
- return updated;
386
+
387
+ // Non-blocking warnings: check the form's fields against its target
388
+ // collection's required fields. Missing-required-on-form would cause
389
+ // every submission to fail server-side validation — admin gets a
390
+ // heads-up here so they can fix it BEFORE users hit silent rejection.
391
+ const warnings = await detectFormCollectionMismatch(updated);
392
+ return warnings.length ? { ...updated, warnings } : updated;
217
393
  });
218
394
 
219
395
  // -----------------------------------------------------------------------
@@ -235,8 +411,11 @@ export async function formsRoutes(fastify) {
235
411
  fastify.get('/forms/:slug/submissions', canRead, async (request, reply) => {
236
412
  const { slug } = request.params;
237
413
  try {
238
- const entries = await listEntries(slug);
239
- return entries.slice().reverse();
414
+ // listEntries returns { entries, total, page, limit } — unwrap to
415
+ // the array. Newest-first is the conventional display order for
416
+ // submission lists.
417
+ const { entries } = await listEntries(slug, { limit: 0 });
418
+ return [...entries].reverse();
240
419
  } catch {
241
420
  return reply.status(404).send({ error: 'Collection not found for this form.' });
242
421
  }
@@ -255,7 +434,8 @@ export async function formsRoutes(fastify) {
255
434
  }
256
435
  let entries = [];
257
436
  try {
258
- entries = await listEntries(slug);
437
+ const r = await listEntries(slug, { limit: 0 });
438
+ entries = r.entries || [];
259
439
  } catch {
260
440
  // empty collection
261
441
  }
@@ -278,7 +458,8 @@ export async function formsRoutes(fastify) {
278
458
  }
279
459
  let entries = [];
280
460
  try {
281
- entries = await listEntries(slug);
461
+ const r = await listEntries(slug, { limit: 0 });
462
+ entries = r.entries || [];
282
463
  } catch {
283
464
  // empty
284
465
  }
@@ -313,9 +494,20 @@ export async function formsRoutes(fastify) {
313
494
  return { ok: true };
314
495
  });
315
496
 
316
- // -----------------------------------------------------------------------
317
- // POST /forms/submit/:slug — public form submission
318
- // -----------------------------------------------------------------------
497
+ /*
498
+ * POST /forms/submit/:slug — public form submission.
499
+ *
500
+ * Authentication is OPTIONAL: when a valid JWT is present the submitter
501
+ * is captured as the entry's `createdBy` and passed into any triggered
502
+ * action's template context (as `{{user.id}}`, `{{user.email}}`, etc.).
503
+ * Anonymous submissions still work — `createdBy` is null and actions
504
+ * receive an empty user. This is what makes the apply/bookmark patterns
505
+ * work without forcing every form to be auth-only:
506
+ *
507
+ * - Anonymous contact form → submitted by guest, user=null
508
+ * - "Apply for job" form → submitted by candidate, action's
509
+ * createInCollection step stamps the application with their id
510
+ */
319
511
  fastify.post('/forms/submit/:slug', async (request, reply) => {
320
512
  const { slug } = request.params;
321
513
  let form;
@@ -325,9 +517,35 @@ export async function formsRoutes(fastify) {
325
517
  return reply.status(404).send({ error: 'Form not found.' });
326
518
  }
327
519
 
328
- const body = request.body || {};
520
+ // Multipart parsing — when the request is multipart (file uploads),
521
+ // we walk parts ourselves: text fields go into `body`, file parts are
522
+ // validated against the form's field config and saved to /content/media,
523
+ // then their { url, name, size, mime } reference object lands in `body`
524
+ // under the field name. Downstream code sees a uniform JSON-shaped body.
525
+ let body = request.body || {};
526
+ if (request.isMultipart && request.isMultipart()) {
527
+ try {
528
+ body = await parseMultipartForm(request, form);
529
+ } catch (err) {
530
+ return reply.status(400).send({ error: err.message });
531
+ }
532
+ }
329
533
  const settings = form.settings || {};
330
534
 
535
+ // Best-effort auth — never reject, just enrich.
536
+ let submittingUser = null;
537
+ try {
538
+ const decoded = await request.jwtVerify();
539
+ if (decoded.type === 'access') {
540
+ submittingUser = {
541
+ id: decoded.id,
542
+ name: decoded.name,
543
+ email: decoded.email,
544
+ role: decoded.role
545
+ };
546
+ }
547
+ } catch { /* anonymous submission */ }
548
+
331
549
  // Honeypot check — silently accept if filled (bot detected)
332
550
  if (settings.honeypot && body._hp) {
333
551
  return { ok: true, message: settings.successMessage, redirect: settings.successRedirect || null };
@@ -397,17 +615,42 @@ export async function formsRoutes(fastify) {
397
615
  }
398
616
  }
399
617
 
400
- // Store in collection (sole submission store)
618
+ // Store in collection (sole submission store).
619
+ //
620
+ // CRITICAL: collection write failure is the difference between "your
621
+ // submission was saved" and "your submission disappeared into thin air".
622
+ // We MUST surface the error to the user rather than logging a warning
623
+ // and returning success — anything else is a silent-data-loss bug.
624
+ // (Email / webhook / action failures downstream are still treated as
625
+ // non-fatal — they fire AFTER the entry is persisted, so even a partial
626
+ // delivery has the original submission safely stored.)
401
627
  const collectionAction = form.actions?.collection;
402
628
  const targetSlug = (collectionAction?.enabled && collectionAction.slug) ? collectionAction.slug : slug;
403
629
  let entry = null;
404
630
  try {
405
631
  const col = await getCollection(targetSlug);
406
632
  if (col) {
407
- entry = await createEntry(targetSlug, data, { source: `form:${slug}` });
633
+ entry = await createEntry(targetSlug, data, {
634
+ source: `form:${slug}`,
635
+ createdBy: submittingUser?.id || null
636
+ });
637
+ } else {
638
+ // Target collection doesn't exist — admin misconfiguration.
639
+ // Better to fail loudly than silently lose submissions.
640
+ fastify.log.warn(`[forms] Submission for "${slug}" had no target collection "${targetSlug}"`);
641
+ return reply.status(500).send({
642
+ error: `Submission not saved — target collection "${targetSlug}" does not exist. Please contact the site administrator.`
643
+ });
408
644
  }
409
645
  } catch (err) {
410
- fastify.log.warn(`[forms] Collection write failed for "${slug}": ${err.message}`);
646
+ fastify.log.warn(`[forms] Collection write failed for "${slug}" → "${targetSlug}": ${err.message}`);
647
+ // Distinguish validation errors (user-actionable) from other
648
+ // failures (admin needs to look) so the message helps the right
649
+ // person. Validation errors typically start with "Validation failed:".
650
+ const msg = err.message.startsWith('Validation failed')
651
+ ? err.message.replace('Validation failed: ', '')
652
+ : `Submission could not be saved: ${err.message}`;
653
+ return reply.status(400).send({ error: msg });
411
654
  }
412
655
 
413
656
  // Email action
@@ -444,11 +687,13 @@ export async function formsRoutes(fastify) {
444
687
  }
445
688
  }
446
689
 
447
- // CMS Action trigger
690
+ // CMS Action trigger — forwards the authenticated submitter so
691
+ // action steps can interpolate {{user.id}}, {{user.email}}, etc.
692
+ // (Anonymous submissions still trigger the action with user={}.)
448
693
  const actionSlug = form.settings?.actionSlug;
449
694
  if (actionSlug && entry) {
450
695
  try {
451
- await executeAction(actionSlug, entry.id, { user: null });
696
+ await executeAction(actionSlug, entry.id, { user: submittingUser });
452
697
  } catch (err) {
453
698
  fastify.log.warn(`[forms] Action "${actionSlug}" failed for form "${slug}": ${err.message}`);
454
699
  }