domma-cms 0.79.1 → 0.81.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 +6 -1
- package/admin/index.html +2 -2
- package/admin/js/app.js +2 -2
- package/admin/js/lib/action-editor-arrange.js +1 -1
- package/admin/js/lib/desktop-notify.js +1 -0
- package/admin/js/lib/notifications-arrange.js +1 -1
- package/admin/js/lib/sidebar-renderer.js +1 -1
- package/admin/js/templates/action-editor.html +1 -0
- package/admin/js/templates/docs/usage-actions.html +5 -0
- package/admin/js/templates/form-editor.html +72 -0
- package/admin/js/templates/notifications.html +21 -1
- package/admin/js/views/form-editor.js +7 -7
- package/admin/js/views/notifications.js +67 -4
- package/package.json +1 -1
- package/public/css/forms.css +1 -1
- package/public/js/form-logic-engine.js +1 -1
- package/public/js/forms.js +2 -2
- package/public/js/site.js +1 -1
- package/server/routes/api/api-tokens.js +5 -2
- package/server/routes/api/auth.js +15 -1
- package/server/routes/api/effects.js +36 -0
- package/server/routes/api/forms.js +40 -9
- package/server/routes/api/layouts.js +6 -2
- package/server/routes/api/notifications.js +97 -20
- package/server/routes/api/users.js +11 -0
- package/server/server.js +29 -5
- package/server/services/actions.js +28 -0
- package/server/services/adapterRegistry.js +1 -0
- package/server/services/collections.js +10 -4
- package/server/services/connectionManager.js +1 -0
- package/server/services/email.js +3 -0
- package/server/services/notification-sources.js +361 -0
- package/server/services/notify.js +420 -0
- package/server/services/plugins.js +6 -2
- package/server/services/sidebarBadges.js +33 -0
- package/server/templates/page.html +4 -4
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Effects API Routes
|
|
3
3
|
* GET /api/effects/settings - public (read by public site IIFE)
|
|
4
4
|
* PUT /api/effects/settings - admin-only (save user overrides)
|
|
5
|
+
* GET /api/effects/_count - the sidebar badge: how many effects the editor offers (an inventory)
|
|
5
6
|
*
|
|
6
7
|
* The PUT whitelists and checks what it stores (normaliseEffectsSettings): it
|
|
7
8
|
* used to write the request body to config/effects.json verbatim, and GET
|
|
@@ -60,12 +61,47 @@ function loadSettings() {
|
|
|
60
61
|
* @param {import('fastify').FastifyInstance} fastify
|
|
61
62
|
* @param {{authenticate?: Function, requireAdmin?: Function}} [opts] - injectable for tests
|
|
62
63
|
*/
|
|
64
|
+
/**
|
|
65
|
+
* The sidebar badge on Effects: how many effects the page editor offers, by
|
|
66
|
+
* category, and whether the scroll-reveal defaults have been changed. An
|
|
67
|
+
* inventory, so a closed System folder does not add it up.
|
|
68
|
+
*
|
|
69
|
+
* @param {{category: string, effects: object[]}[]} byCategory - effectsByCategory()
|
|
70
|
+
* @param {object} settings - config/effects.json
|
|
71
|
+
* @param {object} defaults - the shipped defaults
|
|
72
|
+
*/
|
|
73
|
+
export function effectsBadge(byCategory = [], settings = {}, defaults = {}) {
|
|
74
|
+
const n = byCategory.reduce((t, c) => t + c.effects.length, 0);
|
|
75
|
+
const changed = Object.keys(defaults).filter(k => settings[k] !== undefined && settings[k] !== defaults[k]);
|
|
76
|
+
return {
|
|
77
|
+
count: n,
|
|
78
|
+
inventory: true,
|
|
79
|
+
label: `${n} effect${n === 1 ? '' : 's'} for the page editor`,
|
|
80
|
+
tone: 'info',
|
|
81
|
+
details: {
|
|
82
|
+
title: 'Effects',
|
|
83
|
+
rows: [
|
|
84
|
+
...byCategory.map(c => ({label: c.category, value: String(c.effects.length)})),
|
|
85
|
+
{label: 'Reveal defaults', value: changed.length ? `${changed.length} changed` : 'as shipped'}
|
|
86
|
+
]
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
63
91
|
export async function effectsRoutes(fastify, opts = {}) {
|
|
64
92
|
const authenticate = opts.authenticate || defaultAuthenticate;
|
|
65
93
|
const requireAdmin = opts.requireAdmin || defaultRequireAdmin;
|
|
66
94
|
|
|
67
95
|
fastify.get('/effects/settings', async () => loadSettings());
|
|
68
96
|
|
|
97
|
+
fastify.get('/effects/_count', {preHandler: [authenticate]}, async () => {
|
|
98
|
+
const {effectsByCategory} = await import('../../../admin/js/lib/effect-defs.js');
|
|
99
|
+
const {DEFAULT_SETTINGS} = await import('../../../admin/js/lib/effects-arrange.js');
|
|
100
|
+
let settings = {};
|
|
101
|
+
try { settings = getConfig('effects') || {}; } catch { /* no file yet: the defaults */ }
|
|
102
|
+
return effectsBadge(effectsByCategory(), settings, DEFAULT_SETTINGS);
|
|
103
|
+
});
|
|
104
|
+
|
|
69
105
|
fastify.put('/effects/settings', {preHandler: [authenticate, requireAdmin]}, async (request, reply) => {
|
|
70
106
|
const {errors, settings} = normaliseEffectsSettings(request.body);
|
|
71
107
|
if (errors.length) return reply.status(400).send({error: errors.join('; ')});
|
|
@@ -25,6 +25,7 @@ import path from 'path';
|
|
|
25
25
|
import {deleteForm, ensureCollectionForForm, ensureFormsDir, listForms, readForm, slugify, writeForm} from '../../services/forms.js';
|
|
26
26
|
import {executeAction} from '../../services/actions.js';
|
|
27
27
|
import {createTransport, sendFormEmail} from '../../services/email.js';
|
|
28
|
+
import {formActionFailed, formTriggerNotice} from '../../services/notification-sources.js';
|
|
28
29
|
import {
|
|
29
30
|
clearEntries,
|
|
30
31
|
createCollection,
|
|
@@ -797,9 +798,15 @@ export async function formsRoutes(fastify) {
|
|
|
797
798
|
// Hidden and disabled are the same thing to a submission: the visitor
|
|
798
799
|
// was given no way to answer, so the answer is not theirs and is not
|
|
799
800
|
// required. A value posted for one anyway is dropped below.
|
|
800
|
-
const triggerOff = new Set([...triggerState.hidden, ...triggerState.disabled]);
|
|
801
|
+
const triggerOff = new Set([...triggerState.hidden, ...triggerState.disabled, ...triggerState.masked]);
|
|
801
802
|
const triggerRequired = new Set(triggerState.required);
|
|
802
803
|
|
|
804
|
+
// An end-form in force: the answers so far ARE the submission, and the
|
|
805
|
+
// masked rest is neither asked for nor kept. `record: false` keeps
|
|
806
|
+
// nothing at all - the trigger's notification is the only trace.
|
|
807
|
+
const ended = triggerState.ends[0] || null;
|
|
808
|
+
const record = !ended || ended.record;
|
|
809
|
+
|
|
803
810
|
// Evaluate conditional logic
|
|
804
811
|
const missingFields = [];
|
|
805
812
|
const validationErrors = [];
|
|
@@ -841,9 +848,13 @@ export async function formsRoutes(fastify) {
|
|
|
841
848
|
|
|
842
849
|
// Build submission data - only include visible fields
|
|
843
850
|
const data = {};
|
|
851
|
+
const excused = [];
|
|
844
852
|
for (const field of form.fields || []) {
|
|
845
853
|
if (field.type === 'page-break' || field.type === 'spacer') continue;
|
|
846
|
-
|
|
854
|
+
// The collection mirrors the form's static `required`, so a field
|
|
855
|
+
// the form excused (hidden, disabled, masked) would still be
|
|
856
|
+
// refused there - the form's own pass above is the one that knows.
|
|
857
|
+
if (!visibleFieldNames.has(field.name)) { excused.push(field.name); continue; }
|
|
847
858
|
const val = body[field.name];
|
|
848
859
|
if (val !== undefined) {
|
|
849
860
|
data[field.name] = typeof val === 'string' ? val.trim() : val;
|
|
@@ -862,7 +873,8 @@ export async function formsRoutes(fastify) {
|
|
|
862
873
|
const collectionAction = form.actions?.collection;
|
|
863
874
|
const targetSlug = (collectionAction?.enabled && collectionAction.slug) ? collectionAction.slug : slug;
|
|
864
875
|
let entry = null;
|
|
865
|
-
|
|
876
|
+
// An end-form set not to record stores nothing (see `record` above).
|
|
877
|
+
if (record) try {
|
|
866
878
|
let col = await getCollection(targetSlug);
|
|
867
879
|
if (!col) {
|
|
868
880
|
// The backing collection was never created (a form pointing at a
|
|
@@ -875,7 +887,9 @@ export async function formsRoutes(fastify) {
|
|
|
875
887
|
if (col) {
|
|
876
888
|
entry = await createEntry(targetSlug, data, {
|
|
877
889
|
source: `form:${slug}`,
|
|
878
|
-
createdBy: submittingUser?.id || null
|
|
890
|
+
createdBy: submittingUser?.id || null,
|
|
891
|
+
excused,
|
|
892
|
+
outcome: ended ? {type: 'ended', trigger: ended.key, field: ended.field} : null
|
|
879
893
|
});
|
|
880
894
|
} else {
|
|
881
895
|
// Could not resolve or provision a target - e.g. collection
|
|
@@ -897,9 +911,10 @@ export async function formsRoutes(fastify) {
|
|
|
897
911
|
return reply.status(400).send({ error: msg });
|
|
898
912
|
}
|
|
899
913
|
|
|
900
|
-
// Email action
|
|
914
|
+
// Email action - like the webhook and the form's action, it reports a
|
|
915
|
+
// stored submission, so an unrecorded end has nothing for it to say.
|
|
901
916
|
const emailAction = form.actions?.email;
|
|
902
|
-
if (emailAction?.enabled && emailAction.recipients) {
|
|
917
|
+
if (record && emailAction?.enabled && emailAction.recipients) {
|
|
903
918
|
try {
|
|
904
919
|
const smtp = getConfig('site').smtp || {};
|
|
905
920
|
const transport = await createTransport(smtp);
|
|
@@ -914,12 +929,13 @@ export async function formsRoutes(fastify) {
|
|
|
914
929
|
});
|
|
915
930
|
} catch (err) {
|
|
916
931
|
fastify.log.warn(`[forms] Email send failed for "${slug}": ${err.message}`);
|
|
932
|
+
formActionFailed(slug, form.title, 'notification email', err.message);
|
|
917
933
|
}
|
|
918
934
|
}
|
|
919
935
|
|
|
920
936
|
// Webhook action
|
|
921
937
|
const webhookAction = form.actions?.webhook;
|
|
922
|
-
if (webhookAction?.enabled && webhookAction.url) {
|
|
938
|
+
if (record && webhookAction?.enabled && webhookAction.url) {
|
|
923
939
|
try {
|
|
924
940
|
await fetch(webhookAction.url, {
|
|
925
941
|
method: webhookAction.method || 'POST',
|
|
@@ -928,6 +944,7 @@ export async function formsRoutes(fastify) {
|
|
|
928
944
|
});
|
|
929
945
|
} catch (err) {
|
|
930
946
|
fastify.log.warn(`[forms] Webhook failed for "${slug}": ${err.message}`);
|
|
947
|
+
formActionFailed(slug, form.title, 'webhook', err.message);
|
|
931
948
|
}
|
|
932
949
|
}
|
|
933
950
|
|
|
@@ -940,6 +957,7 @@ export async function formsRoutes(fastify) {
|
|
|
940
957
|
await executeAction(actionSlug, entry.id, { user: submittingUser });
|
|
941
958
|
} catch (err) {
|
|
942
959
|
fastify.log.warn(`[forms] Action "${actionSlug}" failed for form "${slug}": ${err.message}`);
|
|
960
|
+
formActionFailed(slug, form.title, `action "${actionSlug}"`, err.message);
|
|
943
961
|
}
|
|
944
962
|
}
|
|
945
963
|
|
|
@@ -953,7 +971,17 @@ export async function formsRoutes(fastify) {
|
|
|
953
971
|
await executeAction(event.action.slug, entry.id, {user: submittingUser});
|
|
954
972
|
} catch (err) {
|
|
955
973
|
fastify.log.warn(`[forms] Trigger action "${event.action.slug}" failed for form "${slug}": ${err.message}`);
|
|
974
|
+
formActionFailed(slug, form.title, `action "${event.action.slug}"`, err.message);
|
|
956
975
|
}
|
|
976
|
+
} else if (event.type === 'notify') {
|
|
977
|
+
// Never throws; a switched-off source or the hourly cap just
|
|
978
|
+
// means nothing is raised.
|
|
979
|
+
const owner = (form.fields || []).find(f => f.name === event.field);
|
|
980
|
+
await formTriggerNotice({
|
|
981
|
+
slug, formTitle: form.title, action: event.action,
|
|
982
|
+
field: owner?.label || event.field, answer: formValues[event.field],
|
|
983
|
+
ended: !!ended, recorded: !!entry
|
|
984
|
+
});
|
|
957
985
|
} else if (event.type === 'redirect' && event.action.url && !triggerRedirect) {
|
|
958
986
|
// First one wins - two triggers redirecting at once is an
|
|
959
987
|
// authoring mistake, not a case to resolve cleverly.
|
|
@@ -961,7 +989,9 @@ export async function formsRoutes(fastify) {
|
|
|
961
989
|
}
|
|
962
990
|
}
|
|
963
991
|
|
|
964
|
-
|
|
992
|
+
// An unrecorded end is not a submission anyone keeps, so nothing that
|
|
993
|
+
// listens for submissions (plugins, the "Form submitted" notice) hears it.
|
|
994
|
+
if (record) hooks.emit('form:submitted', {slug, entryId: entry?.id || null, data, formTitle: form.title, ended: !!ended});
|
|
965
995
|
|
|
966
996
|
// Template interpolation for successRedirect. A trigger's redirect is
|
|
967
997
|
// the more specific instruction, so it wins over the form's default.
|
|
@@ -973,7 +1003,8 @@ export async function formsRoutes(fastify) {
|
|
|
973
1003
|
return {
|
|
974
1004
|
ok: true,
|
|
975
1005
|
entryId: entry?.id || null,
|
|
976
|
-
|
|
1006
|
+
ended: !!ended,
|
|
1007
|
+
message: ended?.doneMessage || settings.successMessage || 'Thank you for your submission.',
|
|
977
1008
|
redirect: redirect
|
|
978
1009
|
};
|
|
979
1010
|
});
|
|
@@ -122,12 +122,16 @@ export function layoutsBadge(presets = {}, usage = {byKey: {}, missing: []}) {
|
|
|
122
122
|
const missing = usage.missing || [];
|
|
123
123
|
const inUse = Object.values(usage.byKey || {}).filter(list => list.length).length;
|
|
124
124
|
const n = missing.length;
|
|
125
|
+
// A number either way (0.80): how many layouts while all is well - an
|
|
126
|
+
// inventory, left out of the System folder's total - and the pages on a
|
|
127
|
+
// missing layout, in red, when not.
|
|
125
128
|
return {
|
|
126
|
-
count: n,
|
|
129
|
+
count: n || all.length,
|
|
130
|
+
...(!n && {inventory: true}),
|
|
127
131
|
label: n
|
|
128
132
|
? `${n} page${n === 1 ? '' : 's'} use${n === 1 ? 's' : ''} a layout that no longer exists`
|
|
129
133
|
: `${all.length} layout${all.length === 1 ? '' : 's'}, every page's layout exists`,
|
|
130
|
-
tone: 'attention',
|
|
134
|
+
tone: n ? 'attention' : 'info',
|
|
131
135
|
details: {
|
|
132
136
|
title: 'Layouts',
|
|
133
137
|
rows: [
|
|
@@ -9,7 +9,14 @@
|
|
|
9
9
|
* POST /api/system/notifications/dismiss-all - Dismiss every active one, for this user
|
|
10
10
|
* POST /api/system/notifications/read-all - Mark every active one read, for this user
|
|
11
11
|
* POST /api/system/notifications/:id/dismiss - Mark dismissed
|
|
12
|
+
* POST /api/system/notifications/samples - Add one sample of each severity (admin only)
|
|
12
13
|
* DELETE /api/system/notifications/:id - Hard delete (admin only)
|
|
14
|
+
* GET /api/system/notifications/sources - What can raise one, with settings (Super Admin)
|
|
15
|
+
* PUT /api/system/notifications/sources/:id - {enabled, audience} (Super Admin)
|
|
16
|
+
* POST /api/system/notifications/sources/:id/run - Run a scheduled source now (Super Admin)
|
|
17
|
+
*
|
|
18
|
+
* Who sees a notification: its `audience` (services/notify.js). None - manager
|
|
19
|
+
* pushes and everything before 0.80 - is everyone who can read notifications.
|
|
13
20
|
*/
|
|
14
21
|
import {
|
|
15
22
|
authenticate as defaultAuthenticate,
|
|
@@ -18,6 +25,8 @@ import {
|
|
|
18
25
|
import { requireManager } from '../../middleware/managerAuth.js';
|
|
19
26
|
import { randomUUID } from 'crypto';
|
|
20
27
|
import { getAdapter } from '../../services/adapterRegistry.js';
|
|
28
|
+
import {AUDIENCES, isActiveFor, listNotificationSources, runSource, saveSourceSetting} from '../../services/notify.js';
|
|
29
|
+
import {getEffectiveRoles} from '../../services/userRoles.js';
|
|
21
30
|
|
|
22
31
|
const SLUG = 'notifications';
|
|
23
32
|
|
|
@@ -62,19 +71,6 @@ async function saveData(adapter, entry, data) {
|
|
|
62
71
|
return adapter.update(SLUG, entry.id, {...entry, data, meta: {...(entry.meta || {}), updatedAt: new Date().toISOString()}});
|
|
63
72
|
}
|
|
64
73
|
|
|
65
|
-
/**
|
|
66
|
-
* Is this notification live for this user right now: not expired, not dismissed.
|
|
67
|
-
*
|
|
68
|
-
* @param {object} data - A notification payload.
|
|
69
|
-
* @param {string} userId
|
|
70
|
-
* @param {string} now - ISO timestamp.
|
|
71
|
-
* @returns {boolean}
|
|
72
|
-
*/
|
|
73
|
-
function isActiveFor(data, userId, now) {
|
|
74
|
-
if (data.expiresAt && data.expiresAt < now) return false;
|
|
75
|
-
return !(Array.isArray(data.dismissedBy) && data.dismissedBy.includes(userId));
|
|
76
|
-
}
|
|
77
|
-
|
|
78
74
|
/**
|
|
79
75
|
* The sidebar badge on Notifications (its countUrl): the unread ones - the same
|
|
80
76
|
* number the sidebar always showed there - red while any of them is critical.
|
|
@@ -112,6 +108,42 @@ export function notificationsBadge(active = []) {
|
|
|
112
108
|
};
|
|
113
109
|
}
|
|
114
110
|
|
|
111
|
+
/**
|
|
112
|
+
* One sample of each severity, shaped like what the site's own sources will
|
|
113
|
+
* send (see the Notifications plan): something to look at on a fresh site, and
|
|
114
|
+
* a way to try the bell, the badge and desktop notifications. Each ends itself
|
|
115
|
+
* after a week.
|
|
116
|
+
*
|
|
117
|
+
* @param {Date} now
|
|
118
|
+
* @param {string} origin - the site's own address, for the one sample with a link
|
|
119
|
+
* @returns {object[]} entries, `{id, data, meta}`
|
|
120
|
+
*/
|
|
121
|
+
export function sampleNotifications(now = new Date(), origin = '') {
|
|
122
|
+
const expiresAt = new Date(now.getTime() + 7 * 86_400_000).toISOString();
|
|
123
|
+
const note = `\n\n(A sample - it goes by itself on ${expiresAt.slice(0, 10)}.)`;
|
|
124
|
+
const samples = [
|
|
125
|
+
{severity: 'critical', title: 'Plugin failed to load: Calendar',
|
|
126
|
+
body: 'The Calendar plugin stopped with an error while the site started, so its screens and pages are missing. Check Plugins for the error, then restart the site.',
|
|
127
|
+
link: origin ? `${origin}/admin/#/plugins` : null},
|
|
128
|
+
{severity: 'warning', title: 'Invoice INV-0042 is overdue',
|
|
129
|
+
body: 'INV-0042 for Acme Ltd (£1,250.00) was due 3 days ago and has not been paid.'},
|
|
130
|
+
{severity: 'info', title: 'New comment awaiting moderation',
|
|
131
|
+
body: 'Sam left a comment on "Spring opening hours". It will not show on the site until it is approved.'},
|
|
132
|
+
{severity: 'success', title: 'Payment recorded: INV-0039',
|
|
133
|
+
body: 'A payment of £480.00 was recorded against INV-0039. It is now paid in full.'}
|
|
134
|
+
];
|
|
135
|
+
// A minute apart, newest first, so the list shows its order.
|
|
136
|
+
return samples.map((x, i) => {
|
|
137
|
+
const at = new Date(now.getTime() - i * 60_000).toISOString();
|
|
138
|
+
return {
|
|
139
|
+
id: randomUUID(),
|
|
140
|
+
data: {title: x.title, body: x.body + note, severity: x.severity, source: 'sample', link: x.link || null,
|
|
141
|
+
createdAt: at, expiresAt, readBy: [], dismissedBy: []},
|
|
142
|
+
meta: {createdAt: at, updatedAt: at, source: 'sample'}
|
|
143
|
+
};
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
115
147
|
export async function notificationsRoutes(fastify, opts = {}) {
|
|
116
148
|
// Injectable so tests can supply no-ops rather than a seeded roles cache -
|
|
117
149
|
// the same DI pattern as menus.js, projects.js and context-menus.js.
|
|
@@ -182,7 +214,7 @@ export async function notificationsRoutes(fastify, opts = {}) {
|
|
|
182
214
|
return all
|
|
183
215
|
.filter(entry => {
|
|
184
216
|
const d = notificationData(entry, fastify.log);
|
|
185
|
-
return !!d && isActiveFor(d,
|
|
217
|
+
return !!d && isActiveFor(d, request.user, now);
|
|
186
218
|
})
|
|
187
219
|
// A record with no createdAt sorts last rather than taking the
|
|
188
220
|
// whole list down with it.
|
|
@@ -202,7 +234,7 @@ export async function notificationsRoutes(fastify, opts = {}) {
|
|
|
202
234
|
|
|
203
235
|
const count = all.filter(entry => {
|
|
204
236
|
const d = notificationData(entry, fastify.log);
|
|
205
|
-
if (!d || !isActiveFor(d,
|
|
237
|
+
if (!d || !isActiveFor(d, request.user, now)) return false;
|
|
206
238
|
return !Array.isArray(d.readBy) || !d.readBy.includes(userId);
|
|
207
239
|
}).length;
|
|
208
240
|
|
|
@@ -210,26 +242,27 @@ export async function notificationsRoutes(fastify, opts = {}) {
|
|
|
210
242
|
});
|
|
211
243
|
|
|
212
244
|
/** This user's active notifications, newest first, each flagged `unread`. */
|
|
213
|
-
async function activeFor(
|
|
245
|
+
async function activeFor(user) {
|
|
246
|
+
const userId = user.id;
|
|
214
247
|
const now = new Date().toISOString();
|
|
215
248
|
const adapter = await getAdapter(SLUG);
|
|
216
249
|
return (await adapter.all(SLUG))
|
|
217
250
|
.filter(entry => {
|
|
218
251
|
const d = notificationData(entry, fastify.log);
|
|
219
|
-
return !!d && isActiveFor(d,
|
|
252
|
+
return !!d && isActiveFor(d, user, now);
|
|
220
253
|
})
|
|
221
254
|
.map(entry => ({...entry, unread: !Array.isArray(entry.data.readBy) || !entry.data.readBy.includes(userId)}));
|
|
222
255
|
}
|
|
223
256
|
|
|
224
257
|
// --- 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
|
|
258
|
+
fastify.get('/system/notifications/_count', canRead, async (request) => notificationsBadge(await activeFor(request.user)));
|
|
226
259
|
|
|
227
260
|
// --- Dismiss everything active, for this user ---
|
|
228
261
|
fastify.post('/system/notifications/dismiss-all', canRead, async (request) => {
|
|
229
262
|
const userId = request.user.id;
|
|
230
263
|
const adapter = await getAdapter(SLUG);
|
|
231
264
|
let dismissed = 0;
|
|
232
|
-
for (const entry of await activeFor(
|
|
265
|
+
for (const entry of await activeFor(request.user)) {
|
|
233
266
|
const {unread: _u, ...stored} = entry;
|
|
234
267
|
const data = stored.data;
|
|
235
268
|
const dismissedBy = Array.isArray(data.dismissedBy) ? data.dismissedBy : [];
|
|
@@ -252,7 +285,7 @@ export async function notificationsRoutes(fastify, opts = {}) {
|
|
|
252
285
|
const userId = request.user.id;
|
|
253
286
|
const adapter = await getAdapter(SLUG);
|
|
254
287
|
let marked = 0;
|
|
255
|
-
for (const entry of await activeFor(
|
|
288
|
+
for (const entry of await activeFor(request.user)) {
|
|
256
289
|
if (!entry.unread) continue;
|
|
257
290
|
const {unread: _u, ...stored} = entry;
|
|
258
291
|
const readBy = Array.isArray(stored.data.readBy) ? stored.data.readBy : [];
|
|
@@ -317,6 +350,50 @@ export async function notificationsRoutes(fastify, opts = {}) {
|
|
|
317
350
|
return { ok: true, entry: updated };
|
|
318
351
|
});
|
|
319
352
|
|
|
353
|
+
// --- Samples (the settings cog's "Add samples"; same permission as deleting) ---
|
|
354
|
+
fastify.post('/system/notifications/samples', canDelete, async (request, reply) => {
|
|
355
|
+
const adapter = await getAdapter(SLUG);
|
|
356
|
+
const origin = `${request.protocol}://${request.host || request.hostname}`;
|
|
357
|
+
const made = sampleNotifications(new Date(), origin);
|
|
358
|
+
// A second click replaces the samples rather than stacking another set.
|
|
359
|
+
for (const e of await adapter.all(SLUG)) if (e?.data?.source === 'sample') await adapter.remove(SLUG, e.id);
|
|
360
|
+
for (const entry of made) await adapter.insert(SLUG, entry);
|
|
361
|
+
return reply.code(201).send({ok: true, added: made.length});
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
// --- Sources: what the site itself raises, and where it goes (Super Admins) ---
|
|
365
|
+
const superAdminOnly = async (request, reply) => {
|
|
366
|
+
if (!getEffectiveRoles(request.user || {}).includes('super-admin')) {
|
|
367
|
+
return reply.code(403).send({error: 'Only a Super Admin can change notification sources.'});
|
|
368
|
+
}
|
|
369
|
+
};
|
|
370
|
+
const canManageSources = {preHandler: [authenticate, requirePermission('notifications', 'read'), superAdminOnly]};
|
|
371
|
+
|
|
372
|
+
fastify.get('/system/notifications/sources', canManageSources, async () => ({
|
|
373
|
+
sources: listNotificationSources(),
|
|
374
|
+
audiences: Object.keys(AUDIENCES)
|
|
375
|
+
}));
|
|
376
|
+
|
|
377
|
+
fastify.put('/system/notifications/sources/:id', canManageSources, async (request, reply) => {
|
|
378
|
+
const {id} = request.params;
|
|
379
|
+
const known = listNotificationSources().find(s => s.id === id);
|
|
380
|
+
if (!known) return reply.code(404).send({error: 'No such source.'});
|
|
381
|
+
const {enabled, audience} = request.body || {};
|
|
382
|
+
if (enabled !== undefined && typeof enabled !== 'boolean') return reply.code(400).send({error: 'enabled must be true or false.'});
|
|
383
|
+
if (audience !== undefined && AUDIENCES[audience] === undefined) {
|
|
384
|
+
return reply.code(400).send({error: `audience must be one of: ${Object.keys(AUDIENCES).join(', ')}.`});
|
|
385
|
+
}
|
|
386
|
+
return {ok: true, setting: saveSourceSetting(id, {enabled, audience})};
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
fastify.post('/system/notifications/sources/:id/run', canManageSources, async (request, reply) => {
|
|
390
|
+
const known = listNotificationSources().find(s => s.id === request.params.id);
|
|
391
|
+
if (!known) return reply.code(404).send({error: 'No such source.'});
|
|
392
|
+
if (!known.every) return reply.code(400).send({error: 'That source is not a scheduled check - it raises notifications as things happen.'});
|
|
393
|
+
const result = await runSource(known.id);
|
|
394
|
+
return {...result, source: listNotificationSources().find(s => s.id === known.id)};
|
|
395
|
+
});
|
|
396
|
+
|
|
320
397
|
// --- Hard delete (admin only) ---
|
|
321
398
|
fastify.delete('/system/notifications/:id', canDelete, async (request, reply) => {
|
|
322
399
|
const { id } = request.params;
|
|
@@ -12,6 +12,7 @@ import {getPermissionsFor} from '../../services/roles.js';
|
|
|
12
12
|
import {createUser, deleteUser, getUserById, listUsers, updateUser} from '../../services/users.js';
|
|
13
13
|
import {getProfile, listProfiles, updateProfile} from '../../services/userProfiles.js';
|
|
14
14
|
import {ago, clip, plural} from '../../services/sidebarBadges.js';
|
|
15
|
+
import * as notices from '../../services/notification-sources.js';
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
18
|
* The sidebar badge on Users (its countUrl, GET /api/users/_count). The number
|
|
@@ -133,6 +134,9 @@ export async function usersRoutes(fastify) {
|
|
|
133
134
|
} catch (err) {
|
|
134
135
|
return reply.status(409).send({ error: err.message });
|
|
135
136
|
}
|
|
137
|
+
notices.userCreated(user, request.user?.name || request.user?.email);
|
|
138
|
+
const newAdmin = [user.role, ...(user.additionalRoles || [])].filter(r => r === 'admin' || r === 'super-admin');
|
|
139
|
+
if (newAdmin.length) notices.adminRightsGiven(user, newAdmin, request.user?.name || request.user?.email);
|
|
136
140
|
if (profile && typeof profile === 'object' && !Array.isArray(profile)) await updateProfile(user.id, profile);
|
|
137
141
|
return reply.status(201).send(user);
|
|
138
142
|
});
|
|
@@ -187,6 +191,12 @@ export async function usersRoutes(fastify) {
|
|
|
187
191
|
await updateProfile(id, profile);
|
|
188
192
|
}
|
|
189
193
|
|
|
194
|
+
// Admin rights given (not kept): the Super Admins hear about it.
|
|
195
|
+
const adminRoles = (u) => [u?.role, ...(Array.isArray(u?.additionalRoles) ? u.additionalRoles : [])]
|
|
196
|
+
.filter(r => r === 'admin' || r === 'super-admin');
|
|
197
|
+
const gained = adminRoles(user).filter(r => !adminRoles(target).includes(r));
|
|
198
|
+
if (gained.length) notices.adminRightsGiven(user, gained, actor.name || actor.email);
|
|
199
|
+
|
|
190
200
|
const profileEntry = await getProfile(id);
|
|
191
201
|
return {...user, profile: profileEntry?.data || {}};
|
|
192
202
|
});
|
|
@@ -208,6 +218,7 @@ export async function usersRoutes(fastify) {
|
|
|
208
218
|
}
|
|
209
219
|
|
|
210
220
|
await deleteUser(id);
|
|
221
|
+
notices.userDeleted(target, actor.name || actor.email);
|
|
211
222
|
return { success: true };
|
|
212
223
|
});
|
|
213
224
|
}
|
package/server/server.js
CHANGED
|
@@ -19,7 +19,8 @@ import fs from 'fs/promises';
|
|
|
19
19
|
import {fileURLToPath} from 'url';
|
|
20
20
|
import {createRequire} from 'module';
|
|
21
21
|
import {config, getConfig} from './config.js';
|
|
22
|
-
import {getLoadedPlugins, getPluginSettings, registerPlugins} from './services/plugins.js';
|
|
22
|
+
import {getLoadedPlugins, getPluginSettings, recordPluginLoadFailure, registerPlugins} from './services/plugins.js';
|
|
23
|
+
import {notifierFor} from './services/notify.js';
|
|
23
24
|
import {load as loadRoles, seed as seedRoles} from './services/roles.js';
|
|
24
25
|
import {ensureAllProfiles, seed as seedUserProfiles} from './services/userProfiles.js';
|
|
25
26
|
import {seedAll as seedPresetCollections} from './services/presetCollections.js';
|
|
@@ -89,13 +90,17 @@ const app = Fastify({
|
|
|
89
90
|
});
|
|
90
91
|
|
|
91
92
|
// Register process error handlers immediately so startup errors are captured
|
|
93
|
+
// Each leaves a marker, so the next start can tell the Super Admin (core:crash).
|
|
94
|
+
const {recordCrash} = await import('./services/notification-sources.js');
|
|
92
95
|
process.on('uncaughtException', (err) => {
|
|
93
96
|
app.log.error({ err }, 'Uncaught exception');
|
|
97
|
+
recordCrash(err);
|
|
94
98
|
process.exit(1);
|
|
95
99
|
});
|
|
96
100
|
|
|
97
101
|
process.on('unhandledRejection', (reason) => {
|
|
98
102
|
app.log.error({ reason }, 'Unhandled rejection');
|
|
103
|
+
recordCrash(reason);
|
|
99
104
|
process.exit(1);
|
|
100
105
|
});
|
|
101
106
|
|
|
@@ -321,7 +326,11 @@ try {
|
|
|
321
326
|
const {ran, converted, pending} = await migrateNumberFields();
|
|
322
327
|
const done = Object.entries(converted).map(([slug, n]) => `${slug} (${n})`);
|
|
323
328
|
if (ran && done.length) app.log.info(`[collections] Number fields stored as text converted: ${done.join(', ')}`);
|
|
324
|
-
if (pending.length)
|
|
329
|
+
if (pending.length) {
|
|
330
|
+
app.log.warn(`[collections] Number-field repair will retry next start for: ${pending.join(', ')}`);
|
|
331
|
+
const {startupPending} = await import('./services/notification-sources.js');
|
|
332
|
+
startupPending(`Number fields stored as text could not be converted in: ${pending.join(', ')} (a database was unreachable).`);
|
|
333
|
+
}
|
|
325
334
|
} catch (err) {
|
|
326
335
|
app.log.warn(`[collections] Number-field repair skipped: ${err.message}`);
|
|
327
336
|
}
|
|
@@ -561,17 +570,25 @@ registerSidebarItem({folder: 'data', item: {source: 'Built in', text: 'Actions',
|
|
|
561
570
|
registerSidebarItem({folder: 'data', item: {source: 'Built in', text: 'API Builder', url: '#/api-endpoints', icon: 'code', permission: 'api-endpoints', countUrl: '/api/api-endpoints/_count', inventory: true}});
|
|
562
571
|
registerSidebarItem({folder: 'data', item: {source: 'Built in', text: 'Blocks', url: '#/blocks', icon: 'box', permission: 'pages', countUrl: '/api/blocks/_count', inventory: true}});
|
|
563
572
|
registerSidebarItem({folder: 'data', item: {source: 'Built in', text: 'Components', url: '#/components', icon: 'component', permission: 'components', countUrl: '/api/components/_count', inventory: true}});
|
|
564
|
-
// System. Notifications, Site Settings
|
|
565
|
-
//
|
|
573
|
+
// System. Notifications, Site Settings and My Profile are signals (unread, something quietly wrong,
|
|
574
|
+
// details not filled in); Layouts and API Tokens are a count while all is well and a signal when not
|
|
575
|
+
// (pages on a missing layout, tokens expiring - the answer says `inventory` in the calm case); Effects
|
|
576
|
+
// is an inventory.
|
|
566
577
|
registerSidebarItem({folder: 'system', item: {source: 'Built in', text: 'Notifications', url: '#/system/notifications', icon: 'bell', permission: 'notifications', countUrl: '/api/system/notifications/_count'}});
|
|
567
578
|
registerSidebarItem({folder: 'system', item: {source: 'Built in', text: 'Roles', url: '#/roles', icon: 'shield', permission: 'plugins', countUrl: '/api/roles/_count', inventory: true}});
|
|
568
579
|
registerSidebarItem({folder: 'system', item: {source: 'Built in', text: 'Users', url: '#/users', icon: 'users', permission: 'users', countUrl: '/api/users/_count', inventory: true}});
|
|
569
580
|
registerSidebarItem({folder: 'system', item: {source: 'Built in', text: 'Site Settings', url: '#/settings', icon: 'settings', permission: 'settings', countUrl: '/api/settings/_count'}});
|
|
581
|
+
registerSidebarItem({folder: 'system', item: {source: 'Built in', text: 'Effects', url: '#/effects', icon: 'sparkles', permission: 'settings', countUrl: '/api/effects/_count', inventory: true}});
|
|
582
|
+
registerSidebarItem({folder: 'system', item: {source: 'Built in', text: 'My Profile', url: '#/my-profile', icon: 'user', countUrl: '/api/auth/me/_count'}});
|
|
570
583
|
registerSidebarItem({folder: 'system', item: {source: 'Built in', text: 'Layouts', url: '#/layouts', icon: 'layout', permission: 'layouts', countUrl: '/api/layouts/_count'}});
|
|
571
584
|
registerSidebarItem({folder: 'system', item: {source: 'Built in', text: 'Plugins', url: '#/plugins', icon: 'package', permission: 'plugins', countUrl: '/api/plugins/_count', inventory: true}});
|
|
572
585
|
registerSidebarItem({folder: 'system', item: {source: 'Built in', text: 'API Tokens', url: '#/api-tokens', icon: 'key', permission: 'api-tokens', countUrl: '/api/api-tokens/_count'}});
|
|
573
586
|
registerSidebarItem({folder: 'system', item: {source: 'Built in', text: 'Search', url: '#/search', icon: 'search', permission: 'settings', countUrl: '/api/search/_count', inventory: true}});
|
|
574
587
|
|
|
588
|
+
// Core's notification sources, before plugins add theirs (0.80).
|
|
589
|
+
const {registerCoreSources} = await import('./services/notification-sources.js');
|
|
590
|
+
await registerCoreSources();
|
|
591
|
+
|
|
575
592
|
await registerPlugins(app);
|
|
576
593
|
|
|
577
594
|
// ---------------------------------------------------------------------------
|
|
@@ -589,10 +606,13 @@ for (const [name, plugin] of Object.entries(getLoadedPlugins())) {
|
|
|
589
606
|
auth: { authenticate: _authenticate, requireAdmin: _requireAdmin, requireVisibility: _requireVisibility },
|
|
590
607
|
// getConfig() requires a name; pass the startup config singleton instead.
|
|
591
608
|
// (Without this every enabled plugin.public.js fails to register.)
|
|
592
|
-
config
|
|
609
|
+
config,
|
|
610
|
+
// notify / resolveNotification / registerNotificationSource, as plugin.js gets them (0.80).
|
|
611
|
+
hooks: notifierFor(name)
|
|
593
612
|
});
|
|
594
613
|
} catch (err) {
|
|
595
614
|
app.log.error(`[plugins] Failed to register public plugin ${name}: ${err.message}`);
|
|
615
|
+
recordPluginLoadFailure(name, `public side failed to load: ${err.message}`);
|
|
596
616
|
}
|
|
597
617
|
}
|
|
598
618
|
|
|
@@ -623,6 +643,10 @@ process.on('SIGINT', () => shutdown('SIGINT'));
|
|
|
623
643
|
|
|
624
644
|
try {
|
|
625
645
|
await app.listen({ port: serverConfig.port, host: serverConfig.host });
|
|
646
|
+
// Notifications: flush what start-up raised, run the boot checks, start the ticker.
|
|
647
|
+
const {startNotifications} = await import('./services/notify.js');
|
|
648
|
+
// Its timers are unref'd, so they never hold a shutdown open.
|
|
649
|
+
startNotifications({log: app.log});
|
|
626
650
|
console.log(`Domma CMS running at http://localhost:${serverConfig.port}`);
|
|
627
651
|
console.log(` Admin: http://localhost:${serverConfig.port}/admin/`);
|
|
628
652
|
console.log(` Public: http://localhost:${serverConfig.port}/`);
|
|
@@ -225,6 +225,34 @@ async function executeStep(step, collection, entry, context) {
|
|
|
225
225
|
return { to };
|
|
226
226
|
}
|
|
227
227
|
|
|
228
|
+
/*
|
|
229
|
+
* notify - raise a notification in the admin (the bell, and desktop
|
|
230
|
+
* notifications for those who switched them on). Goes to whoever the
|
|
231
|
+
* "Actions" source is set to reach in Notifications' settings.
|
|
232
|
+
*
|
|
233
|
+
* Config (all template-interpolated):
|
|
234
|
+
* title {string} required
|
|
235
|
+
* body {string} optional
|
|
236
|
+
* severity {string} optional - info | success | warning | critical
|
|
237
|
+
* link {string} optional - an admin route (#/…) or http(s) URL
|
|
238
|
+
*
|
|
239
|
+
* Returns: { raised: boolean } - false when the source is switched off
|
|
240
|
+
* or over its hourly cap, which is not a failure of the action.
|
|
241
|
+
*/
|
|
242
|
+
case 'notify': {
|
|
243
|
+
const title = interpolate(cfg.title || '', context).trim();
|
|
244
|
+
if (!title) throw new Error('notify: title is required');
|
|
245
|
+
const { notify } = await import('./notify.js');
|
|
246
|
+
const raised = await notify({
|
|
247
|
+
source: 'core:actions',
|
|
248
|
+
title,
|
|
249
|
+
body: interpolate(cfg.body || '', context),
|
|
250
|
+
severity: interpolate(cfg.severity || 'info', context).trim(),
|
|
251
|
+
link: interpolate(cfg.link || '', context).trim() || null
|
|
252
|
+
});
|
|
253
|
+
return { raised: !!raised };
|
|
254
|
+
}
|
|
255
|
+
|
|
228
256
|
default:
|
|
229
257
|
throw new Error(`Unknown step type: "${step.type}"`);
|
|
230
258
|
}
|
|
@@ -93,6 +93,7 @@ async function resolveMongoAdapter(schema) {
|
|
|
93
93
|
return new MongoAdapter(db);
|
|
94
94
|
} catch (err) {
|
|
95
95
|
console.warn(`[adapterRegistry] Failed to load MongoAdapter for "${schema.slug}": ${err.message}. Falling back to FileAdapter.`);
|
|
96
|
+
import('./notification-sources.js').then(m => m.storageDown(schema.storage?.connection || 'default', err.message)).catch(() => {});
|
|
96
97
|
return fileAdapter;
|
|
97
98
|
}
|
|
98
99
|
}
|
|
@@ -260,11 +260,15 @@ export async function deleteCollection(slug) {
|
|
|
260
260
|
*
|
|
261
261
|
* @param {object} schema
|
|
262
262
|
* @param {object} data
|
|
263
|
+
* @param {Iterable<string>} [excused] - fields whose `required` does not apply to this
|
|
264
|
+
* write: a form submission the form itself excused from answering them
|
|
263
265
|
* @returns {{ valid: boolean, errors: string[] }}
|
|
264
266
|
*/
|
|
265
|
-
export function validateEntryData(schema, data) {
|
|
267
|
+
export function validateEntryData(schema, data, excused = []) {
|
|
266
268
|
const errors = [];
|
|
269
|
+
const skip = new Set(excused);
|
|
267
270
|
for (const field of (schema.fields || [])) {
|
|
271
|
+
if (skip.has(field.name)) continue;
|
|
268
272
|
const val = data[field.name];
|
|
269
273
|
const isEmpty = val === undefined || val === null || val === '';
|
|
270
274
|
if (field.required && isEmpty) {
|
|
@@ -383,15 +387,17 @@ export async function getEntry(slug, entryId) {
|
|
|
383
387
|
* @param {object} [meta]
|
|
384
388
|
* @param {string} [meta.createdBy]
|
|
385
389
|
* @param {string} [meta.source] - 'admin' | 'api' | 'import' | 'form:{slug}'
|
|
390
|
+
* @param {string[]} [meta.excused] - required fields this write may leave empty (see validateEntryData)
|
|
391
|
+
* @param {object} [meta.outcome] - how a form submission ended, stored as meta.outcome
|
|
386
392
|
* @returns {Promise<object>} Created entry
|
|
387
393
|
* @throws {Error} If validation fails or collection not found
|
|
388
394
|
*/
|
|
389
|
-
export async function createEntry(slug, data, { createdBy = null, source = 'admin' } = {}) {
|
|
395
|
+
export async function createEntry(slug, data, { createdBy = null, source = 'admin', excused = [], outcome = null } = {}) {
|
|
390
396
|
const schema = await getCollection(slug);
|
|
391
397
|
if (!schema) throw new Error(`Collection "${slug}" not found`);
|
|
392
398
|
data = typedEntryData(schema, data);
|
|
393
399
|
|
|
394
|
-
const { valid, errors } = validateEntryData(schema, data);
|
|
400
|
+
const { valid, errors } = validateEntryData(schema, data, excused);
|
|
395
401
|
if (!valid) throw new Error(`Validation failed: ${errors.join('; ')}`);
|
|
396
402
|
|
|
397
403
|
const refErrors = await validateReferences(schema, data);
|
|
@@ -401,7 +407,7 @@ export async function createEntry(slug, data, { createdBy = null, source = 'admi
|
|
|
401
407
|
const entry = {
|
|
402
408
|
id: uuidv4(),
|
|
403
409
|
data,
|
|
404
|
-
meta: { createdAt: now, updatedAt: now, createdBy, source }
|
|
410
|
+
meta: { createdAt: now, updatedAt: now, createdBy, source, ...(outcome && {outcome}) }
|
|
405
411
|
};
|
|
406
412
|
|
|
407
413
|
const adapter = await getAdapter(slug);
|
|
@@ -43,6 +43,7 @@ export async function initialise(connectionsConfig) {
|
|
|
43
43
|
console.log(`[connectionManager] Connected: ${name} → ${cfg.database}`);
|
|
44
44
|
} catch (err) {
|
|
45
45
|
console.error(`[connectionManager] Failed to connect "${name}": ${err.message}`);
|
|
46
|
+
import('./notification-sources.js').then(m => m.storageDown(name, err.message)).catch(() => {});
|
|
46
47
|
// Don't throw - allow server to start with degraded MongoDB support.
|
|
47
48
|
}
|
|
48
49
|
}
|