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.
- package/admin/js/app.js +2 -2
- 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/notifications.html +21 -1
- package/admin/js/views/notifications.js +67 -4
- package/package.json +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 +5 -0
- 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/adapterRegistry.js +1 -0
- package/server/services/connectionManager.js +1 -0
- package/server/services/email.js +3 -0
- package/server/services/notification-sources.js +321 -0
- package/server/services/notify.js +420 -0
- package/server/services/plugins.js +6 -2
- package/server/services/sidebarBadges.js +33 -0
|
@@ -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}/`);
|
|
@@ -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
|
}
|
package/server/services/email.js
CHANGED
|
@@ -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. '
|