domma-cms 0.75.0 → 0.77.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 (150) hide show
  1. package/CLAUDE.md +1 -1
  2. package/admin/js/api.js +1 -1
  3. package/admin/js/app.js +4 -4
  4. package/admin/js/lib/action-editor-arrange.js +1 -0
  5. package/admin/js/lib/api-endpoint-editor-arrange.js +2 -0
  6. package/admin/js/lib/api-tokens-arrange.js +3 -0
  7. package/admin/js/lib/block-editor-arrange.js +2 -0
  8. package/admin/js/lib/collection-editor-arrange.js +3 -0
  9. package/admin/js/lib/collection-entries-arrange.js +1 -0
  10. package/admin/js/lib/component-editor-arrange.js +27 -0
  11. package/admin/js/lib/context-menu-editor-arrange.js +1 -0
  12. package/admin/js/lib/dashboard-arrange.js +1 -0
  13. package/admin/js/lib/effects-arrange.js +3 -0
  14. package/admin/js/lib/form-editor-arrange.js +1 -0
  15. package/admin/js/lib/form-submissions-arrange.js +2 -0
  16. package/admin/js/lib/layouts-arrange.js +1 -0
  17. package/admin/js/lib/menu-editor-arrange.js +2 -0
  18. package/admin/js/lib/my-profile-arrange.js +1 -0
  19. package/admin/js/lib/notifications-arrange.js +1 -0
  20. package/admin/js/lib/page-editor-arrange.js +1 -0
  21. package/admin/js/lib/plugin-code-arrange.js +1 -0
  22. package/admin/js/lib/plugins-arrange.js +2 -0
  23. package/admin/js/lib/project-page-arrange.js +1 -0
  24. package/admin/js/lib/role-editor-arrange.js +3 -0
  25. package/admin/js/lib/roles-arrange.js +1 -0
  26. package/admin/js/lib/search-settings-arrange.js +1 -0
  27. package/admin/js/lib/sidebar-renderer.js +2 -2
  28. package/admin/js/lib/site-settings-arrange.js +1 -0
  29. package/admin/js/lib/user-editor-arrange.js +1 -0
  30. package/admin/js/lib/users-arrange.js +1 -0
  31. package/admin/js/lib/view-editor-arrange.js +14 -0
  32. package/admin/js/templates/action-editor.html +223 -147
  33. package/admin/js/templates/api-endpoint-editor.html +172 -117
  34. package/admin/js/templates/api-tokens.html +83 -10
  35. package/admin/js/templates/block-editor.html +194 -203
  36. package/admin/js/templates/collection-editor.html +191 -113
  37. package/admin/js/templates/collection-entries.html +99 -36
  38. package/admin/js/templates/component-editor.html +128 -126
  39. package/admin/js/templates/context-menu-editor.html +208 -148
  40. package/admin/js/templates/dashboard.html +184 -22
  41. package/admin/js/templates/effects.html +155 -72
  42. package/admin/js/templates/form-editor.html +41 -26
  43. package/admin/js/templates/form-submissions.html +78 -26
  44. package/admin/js/templates/layouts.html +126 -43
  45. package/admin/js/templates/menu-editor.html +57 -17
  46. package/admin/js/templates/menu-locations.html +54 -11
  47. package/admin/js/templates/my-profile.html +100 -12
  48. package/admin/js/templates/notifications.html +70 -10
  49. package/admin/js/templates/page-editor.html +47 -26
  50. package/admin/js/templates/plugin-code.html +91 -73
  51. package/admin/js/templates/plugins.html +100 -28
  52. package/admin/js/templates/project-detail.html +83 -145
  53. package/admin/js/templates/project-settings.html +88 -53
  54. package/admin/js/templates/role-editor.html +158 -83
  55. package/admin/js/templates/roles.html +78 -7
  56. package/admin/js/templates/search.html +193 -216
  57. package/admin/js/templates/settings.html +404 -472
  58. package/admin/js/templates/user-editor.html +150 -14
  59. package/admin/js/templates/users.html +69 -9
  60. package/admin/js/templates/view-editor.html +203 -180
  61. package/admin/js/templates/view-preview.html +45 -45
  62. package/admin/js/views/action-editor.js +7 -1
  63. package/admin/js/views/api-endpoint-editor.js +2 -3
  64. package/admin/js/views/api-tokens.js +62 -8
  65. package/admin/js/views/block-editor.js +14 -8
  66. package/admin/js/views/collection-editor.js +64 -5
  67. package/admin/js/views/collection-entries.js +50 -1
  68. package/admin/js/views/component-editor.js +2 -28
  69. package/admin/js/views/context-menu-editor.js +54 -55
  70. package/admin/js/views/dashboard.js +7 -1
  71. package/admin/js/views/effects.js +31 -1
  72. package/admin/js/views/form-editor.js +7 -7
  73. package/admin/js/views/form-submissions.js +11 -1
  74. package/admin/js/views/index.js +1 -1
  75. package/admin/js/views/layouts.js +81 -1
  76. package/admin/js/views/login.js +2 -2
  77. package/admin/js/views/menu-editor.js +21 -43
  78. package/admin/js/views/menu-locations.js +16 -1
  79. package/admin/js/views/my-profile.js +1 -1
  80. package/admin/js/views/notifications.js +18 -1
  81. package/admin/js/views/page-editor.js +26 -26
  82. package/admin/js/views/plugin-code.js +23 -4
  83. package/admin/js/views/plugins.js +97 -39
  84. package/admin/js/views/project-detail.js +9 -1
  85. package/admin/js/views/project-settings.js +1 -1
  86. package/admin/js/views/role-editor.js +11 -3
  87. package/admin/js/views/roles.js +49 -4
  88. package/admin/js/views/search.js +7 -1
  89. package/admin/js/views/settings.js +13 -3
  90. package/admin/js/views/user-editor.js +1 -1
  91. package/admin/js/views/users.js +13 -4
  92. package/admin/js/views/view-editor.js +1 -1
  93. package/admin/js/views/view-preview.js +1 -1
  94. package/package.json +1 -1
  95. package/plugins/_lib/admin/mail/compose-window.js +97 -40
  96. package/plugins/_lib/admin/mail/contacts.js +4 -1
  97. package/plugins/_lib/admin/mail/diagnostics-section.js +4 -1
  98. package/plugins/_lib/admin/mail/folder-tree.js +1 -1
  99. package/plugins/_lib/admin/mail/image-senders-section.js +69 -90
  100. package/plugins/_lib/admin/mail/mail.css +1 -1
  101. package/plugins/_lib/admin/mail/mail.html +1 -1
  102. package/plugins/_lib/admin/mail/reader-view.js +303 -44
  103. package/plugins/_lib/admin/mail/when-closed.js +26 -0
  104. package/plugins/_lib/mail/readRoutes.js +43 -1
  105. package/plugins/_lib/mail/unreadBadge.js +42 -0
  106. package/plugins/free-tier.lock.json +5 -5
  107. package/plugins/mail-reader/CLAUDE.md +1 -0
  108. package/plugins/mail-reader/plugin.js +3 -1
  109. package/plugins/mail-reader/plugin.json +5 -4
  110. package/server/routes/api/api-tokens.js +56 -0
  111. package/server/routes/api/auth.js +7 -1
  112. package/server/routes/api/collections.js +13 -1
  113. package/server/routes/api/dashboard.js +32 -4
  114. package/server/routes/api/effects.js +68 -13
  115. package/server/routes/api/forms.js +104 -13
  116. package/server/routes/api/layouts.js +93 -2
  117. package/server/routes/api/notifications.js +140 -14
  118. package/server/routes/api/plugins.js +135 -4
  119. package/server/routes/api/roles.js +111 -0
  120. package/server/routes/api/search.js +38 -4
  121. package/server/routes/api/settings.js +52 -0
  122. package/server/routes/api/users.js +65 -3
  123. package/server/routes/api/views.js +55 -1
  124. package/server/server.js +12 -0
  125. package/server/services/components.js +9 -0
  126. package/server/services/plugins.js +7 -0
  127. package/server/services/roles.js +3 -0
  128. package/server/services/search.js +58 -0
  129. package/server/services/users.js +16 -2
  130. package/server/services/views.js +14 -0
  131. package/admin/js/templates/dashboard/activity-feed.html +0 -3
  132. package/admin/js/templates/dashboard/cache.html +0 -32
  133. package/admin/js/templates/dashboard/health-detail.html +0 -2
  134. package/admin/js/templates/dashboard/journeys.html +0 -17
  135. package/admin/js/templates/dashboard/kpi-strip.html +0 -34
  136. package/admin/js/templates/dashboard/spike-feed.html +0 -3
  137. package/admin/js/templates/dashboard/top-pages.html +0 -3
  138. package/admin/js/templates/dashboard/traffic-chart.html +0 -3
  139. package/admin/js/templates/plugin-marketplace.html +0 -29
  140. package/admin/js/views/block-editor-enhance.js +0 -1
  141. package/admin/js/views/dashboard/lib/escape.js +0 -1
  142. package/admin/js/views/dashboard/widgets/activity-feed.js +0 -1
  143. package/admin/js/views/dashboard/widgets/cache.js +0 -1
  144. package/admin/js/views/dashboard/widgets/health-detail.js +0 -1
  145. package/admin/js/views/dashboard/widgets/journeys.js +0 -1
  146. package/admin/js/views/dashboard/widgets/kpi-strip.js +0 -1
  147. package/admin/js/views/dashboard/widgets/spike-feed.js +0 -6
  148. package/admin/js/views/dashboard/widgets/top-pages.js +0 -1
  149. package/admin/js/views/dashboard/widgets/traffic-chart.js +0 -1
  150. package/admin/js/views/plugin-marketplace.js +0 -1
@@ -14,6 +14,8 @@
14
14
  * GET /forms/:slug/submissions/export - admin: CSV export from collection
15
15
  * DELETE /forms/:slug/submissions - admin: clear all submissions
16
16
  * DELETE /forms/:slug/submissions/:id - admin: delete one submission
17
+ * POST /forms/:slug/submissions/:id/spam - admin: flag / unflag one submission as spam {spam}
18
+ * (every submissions route reads and writes submissionsCollectionFor(form))
17
19
  * POST /forms/submit/:slug - public: accept submission
18
20
  * POST /forms/test-email - admin: send test email
19
21
  */
@@ -29,7 +31,9 @@ import {
29
31
  createEntry,
30
32
  deleteEntry,
31
33
  getCollection,
32
- listEntries
34
+ getEntry,
35
+ listEntries,
36
+ updateEntry
33
37
  } from '../../services/collections.js';
34
38
  import {getConfig} from '../../config.js';
35
39
  import {authenticate, requireAdmin, requirePermission} from '../../middleware/auth.js';
@@ -243,6 +247,30 @@ export function submissionsCollectionFor(form) {
243
247
  return c?.enabled && c.slug ? c.slug : form?.slug;
244
248
  }
245
249
 
250
+ /**
251
+ * Whether an entry is one of THIS form's submissions. In the form's own
252
+ * collection, every entry is. In a collection its action shares with others
253
+ * (a signup form writing to `members`, two forms writing to `enquiries`), only
254
+ * the entries it made - submit stamps them `meta.source: 'form:<slug>'` - so
255
+ * that clearing, deleting, flagging, exporting or counting one form's
256
+ * submissions never touches the rest of that collection.
257
+ */
258
+ export function isSubmissionOf(form, entry) {
259
+ if (submissionsCollectionFor(form) === form?.slug) return true;
260
+ return entry?.meta?.source === `form:${form?.slug}`;
261
+ }
262
+
263
+ async function canSeeForm(user, form) {
264
+ const {canSeeArtefact} = await import('../../services/projects.js');
265
+ return canSeeArtefact(user, form);
266
+ }
267
+
268
+ /** This form's submissions, oldest first (listEntries order). */
269
+ async function submissionsOf(form) {
270
+ const r = await listEntries(submissionsCollectionFor(form), {limit: 0});
271
+ return (r.entries || []).filter(e => isSubmissionOf(form, e));
272
+ }
273
+
246
274
  const DAY_MS = 86_400_000;
247
275
 
248
276
  /**
@@ -256,8 +284,7 @@ const DAY_MS = 86_400_000;
256
284
  */
257
285
  async function submissionStats(form, now = Date.now()) {
258
286
  try {
259
- const r = await listEntries(submissionsCollectionFor(form), {limit: 0});
260
- const entries = r.entries || [];
287
+ const entries = await submissionsOf(form);
261
288
  let week = 0;
262
289
  let today = 0;
263
290
  let last = null;
@@ -269,7 +296,7 @@ async function submissionStats(form, now = Date.now()) {
269
296
  if (now - t <= DAY_MS) today++;
270
297
  if (!last || at > last) last = at;
271
298
  }
272
- return {total: r.total ?? entries.length, week, today, last};
299
+ return {total: entries.length, week, today, last};
273
300
  } catch {
274
301
  return {total: 0, week: 0, today: 0, last: null};
275
302
  }
@@ -530,8 +557,10 @@ export async function formsRoutes(fastify) {
530
557
  } catch {
531
558
  return reply.status(404).send({ error: 'Form not found.' });
532
559
  }
533
- const collectionAction = form.actions?.collection;
534
- const targetSlug = (collectionAction?.enabled && collectionAction.slug) ? collectionAction.slug : slug;
560
+ // The form's project scope, as the form routes check it: a user confined to
561
+ // other projects must not read or change this form's submissions.
562
+ if (!(await canSeeForm(request.user, form))) return reply.status(404).send({ error: 'Form not found.' });
563
+ const targetSlug = submissionsCollectionFor(form);
535
564
 
536
565
  // A form whose backing collection has not been provisioned yet simply
537
566
  // has no submissions - return an empty list rather than a 404 so the
@@ -541,7 +570,7 @@ export async function formsRoutes(fastify) {
541
570
 
542
571
  // listEntries returns { entries, total, page, limit } - unwrap to the
543
572
  // array. Newest-first is the conventional display order.
544
- const { entries } = await listEntries(targetSlug, { limit: 0 });
573
+ const entries = await submissionsOf(form);
545
574
  return [...entries].reverse();
546
575
  });
547
576
 
@@ -556,10 +585,14 @@ export async function formsRoutes(fastify) {
556
585
  } catch {
557
586
  return reply.status(404).send({ error: 'Form not found.' });
558
587
  }
588
+ // The form's project scope, as the form routes check it: a user confined to
589
+ // other projects must not read or change this form's submissions.
590
+ if (!(await canSeeForm(request.user, form))) return reply.status(404).send({ error: 'Form not found.' });
591
+ // Where the list reads them: a form whose collection action writes elsewhere
592
+ // exported (and cleared, and deleted from) its own-slug collection instead.
559
593
  let entries = [];
560
594
  try {
561
- const r = await listEntries(slug, { limit: 0 });
562
- entries = r.entries || [];
595
+ entries = await submissionsOf(form);
563
596
  } catch {
564
597
  // empty collection
565
598
  }
@@ -580,10 +613,12 @@ export async function formsRoutes(fastify) {
580
613
  } catch {
581
614
  return reply.status(404).send({ error: 'Form not found.' });
582
615
  }
616
+ // The form's project scope, as the form routes check it: a user confined to
617
+ // other projects must not read or change this form's submissions.
618
+ if (!(await canSeeForm(request.user, form))) return reply.status(404).send({ error: 'Form not found.' });
583
619
  let entries = [];
584
620
  try {
585
- const r = await listEntries(slug, { limit: 0 });
586
- entries = r.entries || [];
621
+ entries = await submissionsOf(form);
587
622
  } catch {
588
623
  // empty
589
624
  }
@@ -597,8 +632,24 @@ export async function formsRoutes(fastify) {
597
632
  // -----------------------------------------------------------------------
598
633
  fastify.delete('/forms/:slug/submissions', canDelete, async (request, reply) => {
599
634
  const { slug } = request.params;
635
+ let form;
600
636
  try {
601
- await clearEntries(slug);
637
+ form = await readForm(slug);
638
+ } catch {
639
+ return reply.status(404).send({ error: 'Form not found.' });
640
+ }
641
+ // The form's project scope, as the form routes check it: a user confined to
642
+ // other projects must not read or change this form's submissions.
643
+ if (!(await canSeeForm(request.user, form))) return reply.status(404).send({ error: 'Form not found.' });
644
+ const target = submissionsCollectionFor(form);
645
+ try {
646
+ // Its own collection: clear it. A shared one: delete only this form's
647
+ // entries (one at a time - parallel writes to a file collection lose updates).
648
+ if (target === form.slug) {
649
+ await clearEntries(target);
650
+ } else {
651
+ for (const e of await submissionsOf(form)) await deleteEntry(target, e.id);
652
+ }
602
653
  } catch {
603
654
  return reply.status(404).send({ error: 'Collection not found for this form.' });
604
655
  }
@@ -610,14 +661,54 @@ export async function formsRoutes(fastify) {
610
661
  // -----------------------------------------------------------------------
611
662
  fastify.delete('/forms/:slug/submissions/:id', canDelete, async (request, reply) => {
612
663
  const { slug, id } = request.params;
664
+ let form;
613
665
  try {
614
- await deleteEntry(slug, id);
666
+ form = await readForm(slug);
667
+ } catch {
668
+ return reply.status(404).send({ error: 'Form not found.' });
669
+ }
670
+ // The form's project scope, as the form routes check it: a user confined to
671
+ // other projects must not read or change this form's submissions.
672
+ if (!(await canSeeForm(request.user, form))) return reply.status(404).send({ error: 'Form not found.' });
673
+ const target = submissionsCollectionFor(form);
674
+ try {
675
+ if (!isSubmissionOf(form, await getEntry(target, id))) return reply.status(404).send({ error: 'Submission not found.' });
676
+ await deleteEntry(target, id);
615
677
  } catch {
616
678
  return reply.status(404).send({ error: 'Submission not found.' });
617
679
  }
618
680
  return { ok: true };
619
681
  });
620
682
 
683
+ // -----------------------------------------------------------------------
684
+ // POST /forms/:slug/submissions/:id/spam - flag or unflag one submission.
685
+ // Merged server-side (updateEntry replaces `data` wholesale), in the field
686
+ // the collections spam route and the mark-as-spam context action use: `spam`.
687
+ // -----------------------------------------------------------------------
688
+ fastify.post('/forms/:slug/submissions/:id/spam', canUpdate, async (request, reply) => {
689
+ const { slug, id } = request.params;
690
+ let form;
691
+ try {
692
+ form = await readForm(slug);
693
+ } catch {
694
+ return reply.status(404).send({ error: 'Form not found.' });
695
+ }
696
+ // The form's project scope, as the form routes check it: a user confined to
697
+ // other projects must not read or change this form's submissions.
698
+ if (!(await canSeeForm(request.user, form))) return reply.status(404).send({ error: 'Form not found.' });
699
+ const target = submissionsCollectionFor(form);
700
+ const value = request.body?.spam !== false;
701
+ try {
702
+ const existing = await getEntry(target, id);
703
+ if (!existing || !isSubmissionOf(form, existing)) return reply.status(404).send({ error: 'Submission not found.' });
704
+ const entry = await updateEntry(target, id, {...(existing.data || {}), spam: value});
705
+ hooks.emit('collection:entryUpdated', {slug: target, entryId: entry.id, data: entry.data, userId: request.user?.id || null});
706
+ return {entry, spam: value};
707
+ } catch (err) {
708
+ return reply.status(/not found/i.test(err.message) ? 404 : 400).send({ error: err.message });
709
+ }
710
+ });
711
+
621
712
  /*
622
713
  * POST /forms/submit/:slug - public form submission.
623
714
  *
@@ -7,9 +7,16 @@
7
7
  * DELETE /api/layouts/:key - delete a preset (builtin presets cannot be deleted)
8
8
  * GET /api/layouts/options - get layout options
9
9
  * PUT /api/layouts/options - save layout options
10
+ * GET /api/layouts/_usage - which pages use each layout, and which use one that is gone
11
+ * GET /api/layouts/_count - the sidebar badge: pages whose layout no longer exists
12
+ *
13
+ * `_` is outside the key alphabet (slugify below), so no layout can shadow the last two.
10
14
  */
11
15
  import {getConfig, saveConfig} from '../../config.js';
12
- import {authenticate, requirePermission} from '../../middleware/auth.js';
16
+ import {
17
+ authenticate as defaultAuthenticate,
18
+ requirePermission as defaultRequirePermission
19
+ } from '../../middleware/auth.js';
13
20
 
14
21
  const VALID_WIDTHS = new Set(['narrow', 'normal', 'wide', 'full']);
15
22
  const BG_COLOR_RE = /^[a-zA-Z0-9#(),.\s%/-]+$/;
@@ -78,7 +85,74 @@ function sanitisePreset(data) {
78
85
  };
79
86
  }
80
87
 
81
- export async function layoutsRoutes(fastify) {
88
+ /**
89
+ * Which pages use each layout. A page names its layout in frontmatter
90
+ * (`layout: key`); none means `default`. A page naming a layout that no longer
91
+ * exists is rendered with the default one (renderer.js: presets[page.layout] ||
92
+ * presets.default) - nothing says so, which is what `missing` is for.
93
+ *
94
+ * @param {Record<string, object>} presets
95
+ * @param {{urlPath: string, title?: string, status?: string, layout?: string}[]} pages
96
+ * @returns {{byKey: Record<string, {urlPath, title, status}[]>, missing: {urlPath, title, status, layout}[]}}
97
+ */
98
+ export function layoutUsage(presets = {}, pages = []) {
99
+ const byKey = Object.fromEntries(Object.keys(presets).map(k => [k, []]));
100
+ const missing = [];
101
+ for (const p of pages) {
102
+ const key = typeof p.layout === 'string' && p.layout.trim() ? p.layout.trim() : 'default';
103
+ const item = {urlPath: p.urlPath, title: p.title || p.urlPath, status: p.status || 'published'};
104
+ if (byKey[key]) byKey[key].push(item);
105
+ else missing.push({...item, layout: key});
106
+ }
107
+ return {byKey, missing};
108
+ }
109
+
110
+ /**
111
+ * The sidebar badge on Layouts: pages that name a layout which no longer
112
+ * exists (they quietly show as Default). A count of layouts would be noise -
113
+ * nothing needs doing about it - so this answers 0 (no badge) until something
114
+ * is wrong. Plain text throughout: the popover sets it with textContent.
115
+ *
116
+ * @param {Record<string, object>} presets
117
+ * @param {ReturnType<typeof layoutUsage>} usage
118
+ */
119
+ export function layoutsBadge(presets = {}, usage = {byKey: {}, missing: []}) {
120
+ const all = Object.values(presets);
121
+ const builtin = all.filter(p => p.builtin).length;
122
+ const missing = usage.missing || [];
123
+ const inUse = Object.values(usage.byKey || {}).filter(list => list.length).length;
124
+ const n = missing.length;
125
+ return {
126
+ count: n,
127
+ label: n
128
+ ? `${n} page${n === 1 ? '' : 's'} use${n === 1 ? 's' : ''} a layout that no longer exists`
129
+ : `${all.length} layout${all.length === 1 ? '' : 's'}, every page's layout exists`,
130
+ tone: 'attention',
131
+ details: {
132
+ title: 'Layouts',
133
+ rows: [
134
+ {label: 'Layouts', value: `${all.length} (${builtin} built in)`},
135
+ {label: 'In use', value: String(inUse)},
136
+ {label: 'Pages on a missing layout', value: String(n), ...(n && {tone: 'danger'})}
137
+ ],
138
+ items: missing.slice(0, 8).map(p => ({
139
+ text: String(p.title || p.urlPath),
140
+ meta: `${p.urlPath} · "${p.layout}" is gone - showing as Default`,
141
+ tone: 'danger'
142
+ })),
143
+ empty: "Every page's layout exists."
144
+ }
145
+ };
146
+ }
147
+
148
+ /**
149
+ * @param {import('fastify').FastifyInstance} fastify
150
+ * @param {{authenticate?: Function, requirePermission?: Function, listPages?: Function}} [opts] - DI for tests
151
+ */
152
+ export async function layoutsRoutes(fastify, opts = {}) {
153
+ const authenticate = opts.authenticate || defaultAuthenticate;
154
+ const requirePermission = opts.requirePermission || defaultRequirePermission;
155
+ const listPages = opts.listPages || (async () => (await import('../../services/content.js')).listPages());
82
156
  // ── Merge-in seeding: ensure built-in presets exist ──────────────────────
83
157
  const existing = getConfig('presets') || {};
84
158
  let seeded = false;
@@ -174,6 +248,23 @@ export async function layoutsRoutes(fastify) {
174
248
  return {success: true};
175
249
  });
176
250
 
251
+ // ── Usage and the sidebar badge ──────────────────────────────────────────
252
+ const usageNow = async () => layoutUsage(getConfig('presets') || {}, await listPages().catch(() => []));
253
+
254
+ fastify.get('/layouts/_usage', canRead, async () => usageNow());
255
+
256
+ // Polled every minute from every open admin tab, and reading every page is not
257
+ // free: the answer is kept a minute (the screen's own _usage always reads afresh).
258
+ let badgeCache = {at: 0, key: '', value: null};
259
+ fastify.get('/layouts/_count', canRead, async () => {
260
+ const presets = getConfig('presets') || {};
261
+ const key = Object.keys(presets).sort().join('|');
262
+ if (!badgeCache.value || badgeCache.key !== key || Date.now() - badgeCache.at > 60_000) {
263
+ badgeCache = {at: Date.now(), key, value: layoutsBadge(presets, await usageNow())};
264
+ }
265
+ return badgeCache.value;
266
+ });
267
+
177
268
  // ── Layout options ────────────────────────────────────────────────────────
178
269
  fastify.get('/layouts/options', canRead, async () => {
179
270
  return getConfig('site')?.layoutOptions ?? {spacerSize: 8};
@@ -3,7 +3,11 @@
3
3
  * POST /api/system/notifications - Manager push (requireManager)
4
4
  * GET /api/system/notifications - List active notifications (admin/manager)
5
5
  * GET /api/system/notifications/unread-count - Unread count for bell badge
6
+ * GET /api/system/notifications/_count - The sidebar badge (unread, with a popover)
6
7
  * POST /api/system/notifications/:id/read - Mark read (idempotent)
8
+ * POST /api/system/notifications/:id/unread - Mark unread again (idempotent)
9
+ * POST /api/system/notifications/dismiss-all - Dismiss every active one, for this user
10
+ * POST /api/system/notifications/read-all - Mark every active one read, for this user
7
11
  * POST /api/system/notifications/:id/dismiss - Mark dismissed
8
12
  * DELETE /api/system/notifications/:id - Hard delete (admin only)
9
13
  */
@@ -12,6 +16,7 @@ import {
12
16
  requirePermission as defaultRequirePermission
13
17
  } from '../../middleware/auth.js';
14
18
  import { requireManager } from '../../middleware/managerAuth.js';
19
+ import { randomUUID } from 'crypto';
15
20
  import { getAdapter } from '../../services/adapterRegistry.js';
16
21
 
17
22
  const SLUG = 'notifications';
@@ -45,6 +50,18 @@ function notificationData(entry, log) {
45
50
  return null;
46
51
  }
47
52
 
53
+ /**
54
+ * Write a notification's payload back as a whole entry - `{id, data, meta}`.
55
+ *
56
+ * The adapter's update() REPLACES the stored entry with what it is given, and
57
+ * until 0.76.0 these routes gave it the bare payload: marking a notification
58
+ * read turned it into a flat record with no id and no `data`, which the list
59
+ * then skipped as malformed - read once, gone for good.
60
+ */
61
+ async function saveData(adapter, entry, data) {
62
+ return adapter.update(SLUG, entry.id, {...entry, data, meta: {...(entry.meta || {}), updatedAt: new Date().toISOString()}});
63
+ }
64
+
48
65
  /**
49
66
  * Is this notification live for this user right now: not expired, not dismissed.
50
67
  *
@@ -58,6 +75,43 @@ function isActiveFor(data, userId, now) {
58
75
  return !(Array.isArray(data.dismissedBy) && data.dismissedBy.includes(userId));
59
76
  }
60
77
 
78
+ /**
79
+ * The sidebar badge on Notifications (its countUrl): the unread ones - the same
80
+ * number the sidebar always showed there - red while any of them is critical.
81
+ * Plain text throughout: the popover sets it with textContent.
82
+ *
83
+ * @param {object[]} active - this user's active entries, each with `unread`
84
+ * @returns {{count: number, label: string, tone: string, details: object}}
85
+ */
86
+ export function notificationsBadge(active = []) {
87
+ const unread = active.filter(e => e.unread)
88
+ .sort((a, b) => String(b.data.createdAt || '').localeCompare(String(a.data.createdAt || '')));
89
+ const sev = (e) => e.data?.severity || 'info';
90
+ const critical = unread.filter(e => sev(e) === 'critical').length;
91
+ const warning = unread.filter(e => sev(e) === 'warning').length;
92
+ const n = unread.length;
93
+ return {
94
+ count: n,
95
+ label: n ? `${n} unread notification${n === 1 ? '' : 's'}${critical ? `, ${critical} critical` : ''}` : 'No unread notifications',
96
+ tone: critical ? 'danger' : 'info',
97
+ details: {
98
+ title: 'Notifications',
99
+ rows: [
100
+ {label: 'Unread', value: String(n)},
101
+ {label: 'Critical', value: String(critical), ...(critical && {tone: 'danger'})},
102
+ {label: 'Warnings', value: String(warning)},
103
+ {label: 'In the inbox', value: String(active.length)}
104
+ ],
105
+ items: unread.slice(0, 6).map(e => ({
106
+ text: String(e.data.title || '(no title)').slice(0, 80),
107
+ meta: [sev(e), String(e.data.createdAt || '').slice(0, 10)].filter(Boolean).join(' · '),
108
+ ...(sev(e) === 'critical' && {tone: 'danger'})
109
+ })),
110
+ empty: 'Nothing unread.'
111
+ }
112
+ };
113
+ }
114
+
61
115
  export async function notificationsRoutes(fastify, opts = {}) {
62
116
  // Injectable so tests can supply no-ops rather than a seeded roles cache -
63
117
  // the same DI pattern as menus.js, projects.js and context-menus.js.
@@ -94,17 +148,25 @@ export async function notificationsRoutes(fastify, opts = {}) {
94
148
  }
95
149
  }
96
150
 
151
+ // A whole entry, {id, data, meta}: the adapter stores what it is given, and
152
+ // before 0.76.0 this handed it the bare payload - no id, no `data` - so the
153
+ // list skipped every pushed notification as malformed and none ever showed.
97
154
  const adapter = await getAdapter(SLUG);
155
+ const now = new Date().toISOString();
98
156
  const entry = await adapter.insert(SLUG, {
99
- title: title.trim(),
100
- body: body.trim(),
101
- severity,
102
- source: 'manager',
103
- link: link || null,
104
- createdAt: new Date().toISOString(),
105
- expiresAt: expiresAt || null,
106
- readBy: [],
107
- dismissedBy: []
157
+ id: randomUUID(),
158
+ data: {
159
+ title: title.trim(),
160
+ body: body.trim(),
161
+ severity,
162
+ source: 'manager',
163
+ link: link || null,
164
+ createdAt: now,
165
+ expiresAt: expiresAt || null,
166
+ readBy: [],
167
+ dismissedBy: []
168
+ },
169
+ meta: {createdAt: now, updatedAt: now, source: 'manager'}
108
170
  });
109
171
 
110
172
  return reply.code(201).send(entry);
@@ -147,6 +209,73 @@ export async function notificationsRoutes(fastify, opts = {}) {
147
209
  return { count };
148
210
  });
149
211
 
212
+ /** This user's active notifications, newest first, each flagged `unread`. */
213
+ async function activeFor(userId) {
214
+ const now = new Date().toISOString();
215
+ const adapter = await getAdapter(SLUG);
216
+ return (await adapter.all(SLUG))
217
+ .filter(entry => {
218
+ const d = notificationData(entry, fastify.log);
219
+ return !!d && isActiveFor(d, userId, now);
220
+ })
221
+ .map(entry => ({...entry, unread: !Array.isArray(entry.data.readBy) || !entry.data.readBy.includes(userId)}));
222
+ }
223
+
224
+ // --- Sidebar badge (static path: GET has no /:id route to shadow) ---
225
+ fastify.get('/system/notifications/_count', canRead, async (request) => notificationsBadge(await activeFor(request.user.id)));
226
+
227
+ // --- Dismiss everything active, for this user ---
228
+ fastify.post('/system/notifications/dismiss-all', canRead, async (request) => {
229
+ const userId = request.user.id;
230
+ const adapter = await getAdapter(SLUG);
231
+ let dismissed = 0;
232
+ for (const entry of await activeFor(userId)) {
233
+ const {unread: _u, ...stored} = entry;
234
+ const data = stored.data;
235
+ const dismissedBy = Array.isArray(data.dismissedBy) ? data.dismissedBy : [];
236
+ const readBy = Array.isArray(data.readBy) ? data.readBy : [];
237
+ await saveData(adapter, stored, {
238
+ ...data,
239
+ dismissedBy: [...dismissedBy, userId],
240
+ readBy: readBy.includes(userId) ? readBy : [...readBy, userId]
241
+ });
242
+ dismissed++;
243
+ }
244
+ return {ok: true, dismissed};
245
+ });
246
+
247
+ // --- Mark everything active read, for this user ---
248
+ // One request, one pass: the screen used to fire a mark-read per notification
249
+ // at once, and on file storage each read-modify-write overwrote the others -
250
+ // six marked, one stuck.
251
+ fastify.post('/system/notifications/read-all', canRead, async (request) => {
252
+ const userId = request.user.id;
253
+ const adapter = await getAdapter(SLUG);
254
+ let marked = 0;
255
+ for (const entry of await activeFor(userId)) {
256
+ if (!entry.unread) continue;
257
+ const {unread: _u, ...stored} = entry;
258
+ const readBy = Array.isArray(stored.data.readBy) ? stored.data.readBy : [];
259
+ await saveData(adapter, stored, {...stored.data, readBy: [...readBy, userId]});
260
+ marked++;
261
+ }
262
+ return {ok: true, marked};
263
+ });
264
+
265
+ // --- Mark unread again (idempotent) ---
266
+ fastify.post('/system/notifications/:id/unread', canRead, async (request, reply) => {
267
+ const { id } = request.params;
268
+ const userId = request.user.id;
269
+ const adapter = await getAdapter(SLUG);
270
+ const entry = await adapter.get(SLUG, id);
271
+ const data = notificationData(entry, fastify.log);
272
+ if (!data) return reply.code(404).send({ error: 'Notification not found.' });
273
+ const readBy = Array.isArray(data.readBy) ? data.readBy : [];
274
+ if (!readBy.includes(userId)) return { ok: true };
275
+ const updated = await saveData(adapter, entry, {...data, readBy: readBy.filter(u => u !== userId)});
276
+ return { ok: true, entry: updated };
277
+ });
278
+
150
279
  // --- Mark read (idempotent) ---
151
280
  fastify.post('/system/notifications/:id/read', canRead, async (request, reply) => {
152
281
  const { id } = request.params;
@@ -161,10 +290,7 @@ export async function notificationsRoutes(fastify, opts = {}) {
161
290
  const readBy = Array.isArray(data.readBy) ? data.readBy : [];
162
291
  if (readBy.includes(userId)) return { ok: true }; // already read - idempotent
163
292
 
164
- const updated = await adapter.update(SLUG, id, {
165
- ...data,
166
- readBy: [...readBy, userId]
167
- });
293
+ const updated = await saveData(adapter, entry, {...data, readBy: [...readBy, userId]});
168
294
 
169
295
  return { ok: true, entry: updated };
170
296
  });
@@ -182,7 +308,7 @@ export async function notificationsRoutes(fastify, opts = {}) {
182
308
  const dismissedBy = Array.isArray(data.dismissedBy) ? data.dismissedBy : [];
183
309
  const readBy = Array.isArray(data.readBy) ? data.readBy : [];
184
310
 
185
- const updated = await adapter.update(SLUG, id, {
311
+ const updated = await saveData(adapter, entry, {
186
312
  ...data,
187
313
  dismissedBy: dismissedBy.includes(userId) ? dismissedBy : [...dismissedBy, userId],
188
314
  readBy: readBy.includes(userId) ? readBy : [...readBy, userId] // dismissing also marks read