domma-cms 0.79.1 → 0.80.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.
@@ -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}/`);
@@ -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
  }
@@ -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
  }
@@ -28,6 +28,8 @@ function recordSendResult(ok, info) {
28
28
  at: new Date().toISOString(),
29
29
  info: info || null
30
30
  };
31
+ // A failed send is the Super Admin's business (at most once a day - see notification-sources.js).
32
+ if (!ok) import('./notification-sources.js').then(m => m.emailFailed(info)).catch(() => {});
31
33
  }
32
34
 
33
35
  /**
@@ -89,6 +91,7 @@ export async function createTransport(smtp) {
89
91
  // same warning on the Email tab, and checkSmtpConfig() surfaces it in
90
92
  // Health. A site scaffolded by the CLI or the fleet manager ships with a
91
93
  // host precisely so it never lands here.
94
+ import('./notification-sources.js').then(m => m.emailNotConfigured()).catch(() => {});
92
95
  const testAccount = await nodemailer.createTestAccount();
93
96
  console.warn('[email] NO SMTP HOST CONFIGURED - mail is going to an Ethereal test inbox '
94
97
  + 'and will NOT reach its recipients. Set one in Settings > Email. '