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.
Files changed (36) hide show
  1. package/CLAUDE.md +6 -1
  2. package/admin/index.html +2 -2
  3. package/admin/js/app.js +2 -2
  4. package/admin/js/lib/action-editor-arrange.js +1 -1
  5. package/admin/js/lib/desktop-notify.js +1 -0
  6. package/admin/js/lib/notifications-arrange.js +1 -1
  7. package/admin/js/lib/sidebar-renderer.js +1 -1
  8. package/admin/js/templates/action-editor.html +1 -0
  9. package/admin/js/templates/docs/usage-actions.html +5 -0
  10. package/admin/js/templates/form-editor.html +72 -0
  11. package/admin/js/templates/notifications.html +21 -1
  12. package/admin/js/views/form-editor.js +7 -7
  13. package/admin/js/views/notifications.js +67 -4
  14. package/package.json +1 -1
  15. package/public/css/forms.css +1 -1
  16. package/public/js/form-logic-engine.js +1 -1
  17. package/public/js/forms.js +2 -2
  18. package/public/js/site.js +1 -1
  19. package/server/routes/api/api-tokens.js +5 -2
  20. package/server/routes/api/auth.js +15 -1
  21. package/server/routes/api/effects.js +36 -0
  22. package/server/routes/api/forms.js +40 -9
  23. package/server/routes/api/layouts.js +6 -2
  24. package/server/routes/api/notifications.js +97 -20
  25. package/server/routes/api/users.js +11 -0
  26. package/server/server.js +29 -5
  27. package/server/services/actions.js +28 -0
  28. package/server/services/adapterRegistry.js +1 -0
  29. package/server/services/collections.js +10 -4
  30. package/server/services/connectionManager.js +1 -0
  31. package/server/services/email.js +3 -0
  32. package/server/services/notification-sources.js +361 -0
  33. package/server/services/notify.js +420 -0
  34. package/server/services/plugins.js +6 -2
  35. package/server/services/sidebarBadges.js +33 -0
  36. 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
- if (!visibleFieldNames.has(field.name)) continue;
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
- try {
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
- hooks.emit('form:submitted', {slug, entryId: entry?.id || null, data, formTitle: form.title});
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
- message: settings.successMessage || 'Thank you for your submission.',
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, userId, now);
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, userId, now)) return false;
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(userId) {
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, userId, now);
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.id)));
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(userId)) {
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(userId)) {
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) app.log.warn(`[collections] Number-field repair will retry next start for: ${pending.join(', ')}`);
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, Layouts and API Tokens are signals (unread, something quietly
565
- // wrong, pages on a missing layout, tokens expiring), so not `inventory`.
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
  }