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.
- package/CLAUDE.md +1 -1
- package/admin/js/api.js +1 -1
- package/admin/js/app.js +4 -4
- package/admin/js/lib/action-editor-arrange.js +1 -0
- package/admin/js/lib/api-endpoint-editor-arrange.js +2 -0
- package/admin/js/lib/api-tokens-arrange.js +3 -0
- package/admin/js/lib/block-editor-arrange.js +2 -0
- package/admin/js/lib/collection-editor-arrange.js +3 -0
- package/admin/js/lib/collection-entries-arrange.js +1 -0
- package/admin/js/lib/component-editor-arrange.js +27 -0
- package/admin/js/lib/context-menu-editor-arrange.js +1 -0
- package/admin/js/lib/dashboard-arrange.js +1 -0
- package/admin/js/lib/effects-arrange.js +3 -0
- package/admin/js/lib/form-editor-arrange.js +1 -0
- package/admin/js/lib/form-submissions-arrange.js +2 -0
- package/admin/js/lib/layouts-arrange.js +1 -0
- package/admin/js/lib/menu-editor-arrange.js +2 -0
- package/admin/js/lib/my-profile-arrange.js +1 -0
- package/admin/js/lib/notifications-arrange.js +1 -0
- package/admin/js/lib/page-editor-arrange.js +1 -0
- package/admin/js/lib/plugin-code-arrange.js +1 -0
- package/admin/js/lib/plugins-arrange.js +2 -0
- package/admin/js/lib/project-page-arrange.js +1 -0
- package/admin/js/lib/role-editor-arrange.js +3 -0
- package/admin/js/lib/roles-arrange.js +1 -0
- package/admin/js/lib/search-settings-arrange.js +1 -0
- package/admin/js/lib/sidebar-renderer.js +2 -2
- package/admin/js/lib/site-settings-arrange.js +1 -0
- package/admin/js/lib/user-editor-arrange.js +1 -0
- package/admin/js/lib/users-arrange.js +1 -0
- package/admin/js/lib/view-editor-arrange.js +14 -0
- package/admin/js/templates/action-editor.html +223 -147
- package/admin/js/templates/api-endpoint-editor.html +172 -117
- package/admin/js/templates/api-tokens.html +83 -10
- package/admin/js/templates/block-editor.html +194 -203
- package/admin/js/templates/collection-editor.html +191 -113
- package/admin/js/templates/collection-entries.html +99 -36
- package/admin/js/templates/component-editor.html +128 -126
- package/admin/js/templates/context-menu-editor.html +208 -148
- package/admin/js/templates/dashboard.html +184 -22
- package/admin/js/templates/effects.html +155 -72
- package/admin/js/templates/form-editor.html +41 -26
- package/admin/js/templates/form-submissions.html +78 -26
- package/admin/js/templates/layouts.html +126 -43
- package/admin/js/templates/menu-editor.html +57 -17
- package/admin/js/templates/menu-locations.html +54 -11
- package/admin/js/templates/my-profile.html +100 -12
- package/admin/js/templates/notifications.html +70 -10
- package/admin/js/templates/page-editor.html +47 -26
- package/admin/js/templates/plugin-code.html +91 -73
- package/admin/js/templates/plugins.html +100 -28
- package/admin/js/templates/project-detail.html +83 -145
- package/admin/js/templates/project-settings.html +88 -53
- package/admin/js/templates/role-editor.html +158 -83
- package/admin/js/templates/roles.html +78 -7
- package/admin/js/templates/search.html +193 -216
- package/admin/js/templates/settings.html +404 -472
- package/admin/js/templates/user-editor.html +150 -14
- package/admin/js/templates/users.html +69 -9
- package/admin/js/templates/view-editor.html +203 -180
- package/admin/js/templates/view-preview.html +45 -45
- package/admin/js/views/action-editor.js +7 -1
- package/admin/js/views/api-endpoint-editor.js +2 -3
- package/admin/js/views/api-tokens.js +62 -8
- package/admin/js/views/block-editor.js +14 -8
- package/admin/js/views/collection-editor.js +64 -5
- package/admin/js/views/collection-entries.js +50 -1
- package/admin/js/views/component-editor.js +2 -28
- package/admin/js/views/context-menu-editor.js +54 -55
- package/admin/js/views/dashboard.js +7 -1
- package/admin/js/views/effects.js +31 -1
- package/admin/js/views/form-editor.js +7 -7
- package/admin/js/views/form-submissions.js +11 -1
- package/admin/js/views/index.js +1 -1
- package/admin/js/views/layouts.js +81 -1
- package/admin/js/views/login.js +2 -2
- package/admin/js/views/menu-editor.js +21 -43
- package/admin/js/views/menu-locations.js +16 -1
- package/admin/js/views/my-profile.js +1 -1
- package/admin/js/views/notifications.js +18 -1
- package/admin/js/views/page-editor.js +26 -26
- package/admin/js/views/plugin-code.js +23 -4
- package/admin/js/views/plugins.js +97 -39
- package/admin/js/views/project-detail.js +9 -1
- package/admin/js/views/project-settings.js +1 -1
- package/admin/js/views/role-editor.js +11 -3
- package/admin/js/views/roles.js +49 -4
- package/admin/js/views/search.js +7 -1
- package/admin/js/views/settings.js +13 -3
- package/admin/js/views/user-editor.js +1 -1
- package/admin/js/views/users.js +13 -4
- package/admin/js/views/view-editor.js +1 -1
- package/admin/js/views/view-preview.js +1 -1
- package/package.json +1 -1
- package/plugins/_lib/admin/mail/compose-window.js +97 -40
- package/plugins/_lib/admin/mail/contacts.js +4 -1
- package/plugins/_lib/admin/mail/diagnostics-section.js +4 -1
- package/plugins/_lib/admin/mail/folder-tree.js +1 -1
- package/plugins/_lib/admin/mail/image-senders-section.js +69 -90
- package/plugins/_lib/admin/mail/mail.css +1 -1
- package/plugins/_lib/admin/mail/mail.html +1 -1
- package/plugins/_lib/admin/mail/reader-view.js +303 -44
- package/plugins/_lib/admin/mail/when-closed.js +26 -0
- package/plugins/_lib/mail/readRoutes.js +43 -1
- package/plugins/_lib/mail/unreadBadge.js +42 -0
- package/plugins/free-tier.lock.json +5 -5
- package/plugins/mail-reader/CLAUDE.md +1 -0
- package/plugins/mail-reader/plugin.js +3 -1
- package/plugins/mail-reader/plugin.json +5 -4
- package/server/routes/api/api-tokens.js +56 -0
- package/server/routes/api/auth.js +7 -1
- package/server/routes/api/collections.js +13 -1
- package/server/routes/api/dashboard.js +32 -4
- package/server/routes/api/effects.js +68 -13
- package/server/routes/api/forms.js +104 -13
- package/server/routes/api/layouts.js +93 -2
- package/server/routes/api/notifications.js +140 -14
- package/server/routes/api/plugins.js +135 -4
- package/server/routes/api/roles.js +111 -0
- package/server/routes/api/search.js +38 -4
- package/server/routes/api/settings.js +52 -0
- package/server/routes/api/users.js +65 -3
- package/server/routes/api/views.js +55 -1
- package/server/server.js +12 -0
- package/server/services/components.js +9 -0
- package/server/services/plugins.js +7 -0
- package/server/services/roles.js +3 -0
- package/server/services/search.js +58 -0
- package/server/services/users.js +16 -2
- package/server/services/views.js +14 -0
- package/admin/js/templates/dashboard/activity-feed.html +0 -3
- package/admin/js/templates/dashboard/cache.html +0 -32
- package/admin/js/templates/dashboard/health-detail.html +0 -2
- package/admin/js/templates/dashboard/journeys.html +0 -17
- package/admin/js/templates/dashboard/kpi-strip.html +0 -34
- package/admin/js/templates/dashboard/spike-feed.html +0 -3
- package/admin/js/templates/dashboard/top-pages.html +0 -3
- package/admin/js/templates/dashboard/traffic-chart.html +0 -3
- package/admin/js/templates/plugin-marketplace.html +0 -29
- package/admin/js/views/block-editor-enhance.js +0 -1
- package/admin/js/views/dashboard/lib/escape.js +0 -1
- package/admin/js/views/dashboard/widgets/activity-feed.js +0 -1
- package/admin/js/views/dashboard/widgets/cache.js +0 -1
- package/admin/js/views/dashboard/widgets/health-detail.js +0 -1
- package/admin/js/views/dashboard/widgets/journeys.js +0 -1
- package/admin/js/views/dashboard/widgets/kpi-strip.js +0 -1
- package/admin/js/views/dashboard/widgets/spike-feed.js +0 -6
- package/admin/js/views/dashboard/widgets/top-pages.js +0 -1
- package/admin/js/views/dashboard/widgets/traffic-chart.js +0 -1
- 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
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
534
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
|
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
|
|
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
|