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.
@@ -0,0 +1,420 @@
1
+ /**
2
+ * Notifications the site raises itself - core and plugins alike.
3
+ *
4
+ * Until 0.80 the only way in was the manager's push. Now:
5
+ *
6
+ * notify({title, body, severity, link, source, dedupeKey, audience, expiresAt})
7
+ * Something happened. Goes to the Super Admins unless the source's
8
+ * setting (or `audience: {userIds}`, for a personal one) says otherwise.
9
+ * resolveNotification(dedupeKey)
10
+ * The cause went away (the plugin loads again, the invoice is paid).
11
+ * registerNotificationSource({id, owner, label, description, severity,
12
+ * defaultOn, every, atBoot, check})
13
+ * Names a source so Notifications' settings can list it, switch it off
14
+ * or send it to other people - and, with `every` + `check`, runs it on a
15
+ * timer: 'boot', '15m', 'hourly' or 'daily' (08:00 site time).
16
+ *
17
+ * Plugins get these through `options.hooks` (notify, resolveNotification,
18
+ * registerNotificationSource), stamped with their name, so a plugin's source
19
+ * ids are `<plugin>:<id>`. Core's are `core:<id>`.
20
+ *
21
+ * A failure in here is logged and swallowed: raising a notification never
22
+ * breaks the thing that raised it.
23
+ *
24
+ * @module services/notify
25
+ */
26
+ import fs from 'fs';
27
+ import path from 'path';
28
+ import {randomUUID} from 'crypto';
29
+ import {config, getConfig, saveConfig} from '../config.js';
30
+ import {getAdapter} from './adapterRegistry.js';
31
+ import {getEffectiveRoles} from './userRoles.js';
32
+
33
+ const SLUG = 'notifications';
34
+ export const SEVERITIES = ['info', 'success', 'warning', 'critical'];
35
+ const RANK = {info: 0, success: 0, warning: 1, critical: 2};
36
+
37
+ /** Who a source's notifications go to, as a setting. */
38
+ export const AUDIENCES = {
39
+ 'super-admin': {roles: ['super-admin']},
40
+ admins: {roles: ['super-admin', 'admin']},
41
+ everyone: null
42
+ };
43
+ export const SCHEDULES = ['boot', '15m', 'hourly', 'daily'];
44
+ const INTERVAL_MS = {'15m': 15 * 60_000, hourly: 60 * 60_000};
45
+ const DAILY_HOUR = 8;
46
+ /** At most this many new notifications per source per hour - a loop cannot bury the inbox. */
47
+ const RATE_CAP = 30;
48
+ const CHECK_TIMEOUT_MS = 10_000;
49
+
50
+ let log = console;
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // Pure rules (exported for the tests)
54
+ // ---------------------------------------------------------------------------
55
+
56
+ /** A link a notification may carry: an http(s) address or an admin route (`#/…`), else null. */
57
+ export function cleanLink(link) {
58
+ if (typeof link !== 'string') return null;
59
+ const s = link.trim();
60
+ if (/^https?:\/\/\S+$/i.test(s)) return s;
61
+ if (/^#\/[^\s]*$/.test(s)) return s;
62
+ return null;
63
+ }
64
+
65
+ /**
66
+ * May this user see a notification with this audience. None (manager pushes,
67
+ * everything before 0.80) is everyone who can read notifications, as before.
68
+ *
69
+ * @param {object|null|undefined} audience - {roles: string[]} | {userIds: string[]}
70
+ * @param {object} user - request.user: {id, role, additionalRoles}
71
+ */
72
+ export function inAudience(audience, user) {
73
+ if (!audience || typeof audience !== 'object') return true;
74
+ if (Array.isArray(audience.userIds)) return audience.userIds.includes(user?.id);
75
+ if (Array.isArray(audience.roles)) {
76
+ const held = getEffectiveRoles(user || {});
77
+ return audience.roles.some(r => held.includes(r));
78
+ }
79
+ return true;
80
+ }
81
+
82
+ /** Is this notification live for this user now: in its audience, not expired, not dismissed. */
83
+ export function isActiveFor(data, user, now = new Date().toISOString()) {
84
+ if (data.expiresAt && data.expiresAt < now) return false;
85
+ if (!inAudience(data.audience, user)) return false;
86
+ return !(Array.isArray(data.dismissedBy) && data.dismissedBy.includes(user?.id));
87
+ }
88
+
89
+ /**
90
+ * When a scheduled source is next due, given when it last ran.
91
+ *
92
+ * @param {string} every - '15m' | 'hourly' | 'daily' ('boot' is never "due")
93
+ * @param {number|null} lastRun - ms
94
+ * @param {Date} now
95
+ */
96
+ export function isDue(every, lastRun, now = new Date()) {
97
+ if (every === 'boot') return false;
98
+ if (INTERVAL_MS[every]) return !lastRun || now.getTime() - lastRun >= INTERVAL_MS[every];
99
+ if (every === 'daily') {
100
+ const today = new Date(now);
101
+ today.setHours(DAILY_HOUR, 0, 0, 0);
102
+ // The most recent 08:00 that has passed.
103
+ const mark = now >= today ? today.getTime() : today.getTime() - 86_400_000;
104
+ return !lastRun || lastRun < mark;
105
+ }
106
+ return false;
107
+ }
108
+
109
+ // ---------------------------------------------------------------------------
110
+ // Sources and their settings
111
+ // ---------------------------------------------------------------------------
112
+
113
+ const sources = new Map(); // id -> definition
114
+ const runs = new Map(); // id -> {lastRun, lastError, lastCount}
115
+
116
+ /**
117
+ * Name a source (and optionally schedule it). Registering the same id again
118
+ * replaces it - a plugin reloaded in place does not end up with two.
119
+ *
120
+ * @param {object} def
121
+ * @param {string} def.id - 'core:plugin-load', 'invoice:overdue'
122
+ * @param {string} [def.owner] - 'core' or the plugin's name
123
+ * @param {string} [def.ownerLabel]
124
+ * @param {string} def.label
125
+ * @param {string} [def.description]
126
+ * @param {string} [def.severity] - what it usually sends, for the settings list
127
+ * @param {boolean} [def.defaultOn=true]
128
+ * @param {string} [def.audience='super-admin']
129
+ * @param {string} [def.every] - one of SCHEDULES; needs `check`
130
+ * @param {boolean} [def.atBoot] - also run once at start-up
131
+ * @param {Function} [def.check] - async ({now, notify, resolve}) => void
132
+ */
133
+ export function registerNotificationSource(def) {
134
+ if (!def || typeof def.id !== 'string' || !def.id) throw new Error('A notification source needs an id.');
135
+ if (def.every && !SCHEDULES.includes(def.every)) throw new Error(`every must be one of ${SCHEDULES.join(', ')}.`);
136
+ if (def.every && typeof def.check !== 'function') throw new Error('A scheduled notification source needs a check function.');
137
+ const owner = def.owner || def.id.split(':')[0];
138
+ sources.set(def.id, {
139
+ ...def,
140
+ owner, ownerLabel: def.ownerLabel || (owner === 'core' ? 'System' : owner),
141
+ label: String(def.label || def.id), description: String(def.description || ''),
142
+ severity: SEVERITIES.includes(def.severity) ? def.severity : 'info',
143
+ defaultOn: def.defaultOn !== false,
144
+ audience: AUDIENCES[def.audience] !== undefined ? def.audience : 'super-admin'
145
+ });
146
+ }
147
+
148
+ function readSettings() {
149
+ try {
150
+ const s = getConfig('notifications');
151
+ return s && typeof s === 'object' && s.sources && typeof s.sources === 'object' ? s : {sources: {}};
152
+ } catch {
153
+ return {sources: {}};
154
+ }
155
+ }
156
+
157
+ /** A source's effective setting: {enabled, audience}. An unregistered source is on, for the Super Admins. */
158
+ export function sourceSetting(id) {
159
+ const def = sources.get(id);
160
+ const saved = readSettings().sources[id] || {};
161
+ return {
162
+ enabled: typeof saved.enabled === 'boolean' ? saved.enabled : (def ? def.defaultOn : true),
163
+ audience: AUDIENCES[saved.audience] !== undefined ? saved.audience : (def?.audience || 'super-admin')
164
+ };
165
+ }
166
+
167
+ /** Change one source's setting (Notifications' settings, Super Admins only). */
168
+ export function saveSourceSetting(id, {enabled, audience} = {}) {
169
+ const all = readSettings();
170
+ const current = sourceSetting(id);
171
+ all.sources[id] = {
172
+ enabled: typeof enabled === 'boolean' ? enabled : current.enabled,
173
+ audience: AUDIENCES[audience] !== undefined ? audience : current.audience
174
+ };
175
+ saveConfig('notifications', all);
176
+ return sourceSetting(id);
177
+ }
178
+
179
+ /** Every registered source with its setting and last run, for the settings screen. */
180
+ export function listNotificationSources() {
181
+ return [...sources.values()]
182
+ .map(d => ({
183
+ id: d.id, owner: d.owner, ownerLabel: d.ownerLabel, label: d.label, description: d.description,
184
+ severity: d.severity, every: d.every || null, atBoot: Boolean(d.atBoot), defaultOn: d.defaultOn,
185
+ ...sourceSetting(d.id),
186
+ lastRun: runs.get(d.id)?.lastRun ? new Date(runs.get(d.id).lastRun).toISOString() : null,
187
+ lastError: runs.get(d.id)?.lastError || null
188
+ }))
189
+ // System first, then each plugin by name; within one owner, by label.
190
+ .sort((a, b) => (Number(b.owner === 'core') - Number(a.owner === 'core'))
191
+ || a.ownerLabel.localeCompare(b.ownerLabel) || a.label.localeCompare(b.label));
192
+ }
193
+
194
+ // ---------------------------------------------------------------------------
195
+ // Writing
196
+ // ---------------------------------------------------------------------------
197
+
198
+ // One write at a time: on file storage each insert is a read-modify-write of
199
+ // the whole collection, and two at once lose one.
200
+ let chain = Promise.resolve();
201
+ const serial = (fn) => { const next = chain.then(fn, fn); chain = next.catch(() => {}); return next; };
202
+
203
+ // Before the server is ready (collections seeded, plugins loaded) notify() queues.
204
+ let ready = false;
205
+ const early = [];
206
+
207
+ const sent = new Map(); // source -> timestamps in the last hour
208
+
209
+ function underCap(source, now = Date.now()) {
210
+ const recent = (sent.get(source) || []).filter(t => now - t < 3_600_000);
211
+ if (recent.length >= RATE_CAP) { sent.set(source, recent); return false; }
212
+ recent.push(now);
213
+ sent.set(source, recent);
214
+ return true;
215
+ }
216
+
217
+ /** The payload as stored, or null (with the reason) when it is not a notification. */
218
+ export function normalisePayload(p, setting = {audience: 'super-admin'}) {
219
+ if (!p || typeof p !== 'object') return {error: 'no payload'};
220
+ const title = typeof p.title === 'string' ? p.title.trim().slice(0, 200) : '';
221
+ if (!title) return {error: 'title is required'};
222
+ const expiresAt = p.expiresAt && !Number.isNaN(Date.parse(p.expiresAt)) ? new Date(p.expiresAt).toISOString() : null;
223
+ const audience = p.audience && Array.isArray(p.audience.userIds)
224
+ ? {userIds: p.audience.userIds.map(String)}
225
+ : AUDIENCES[setting.audience] ?? null;
226
+ return {
227
+ data: {
228
+ title,
229
+ body: typeof p.body === 'string' ? p.body.trim().slice(0, 4000) : '',
230
+ severity: SEVERITIES.includes(p.severity) ? p.severity : 'info',
231
+ source: String(p.source || 'core:general'),
232
+ sourceLabel: typeof p.sourceLabel === 'string' ? p.sourceLabel : undefined,
233
+ link: cleanLink(p.link),
234
+ dedupeKey: typeof p.dedupeKey === 'string' && p.dedupeKey ? p.dedupeKey.slice(0, 200) : null,
235
+ audience,
236
+ expiresAt
237
+ }
238
+ };
239
+ }
240
+
241
+ async function write(p) {
242
+ const setting = sourceSetting(String(p.source || 'core:general'));
243
+ if (!setting.enabled) return null;
244
+ const {data, error} = normalisePayload(p, setting);
245
+ if (error) { log.warn?.(`[notify] Not raised (${error}) from ${p?.source || 'unknown'}.`); return null; }
246
+ if (data.sourceLabel === undefined) data.sourceLabel = sources.get(data.source)?.ownerLabel || (data.source.startsWith('core:') ? 'System' : data.source.split(':')[0]);
247
+
248
+ const adapter = await getAdapter(SLUG);
249
+ const now = new Date().toISOString();
250
+
251
+ if (data.dedupeKey) {
252
+ const existing = (await adapter.all(SLUG)).find(e => e?.data?.dedupeKey === data.dedupeKey
253
+ && e.data.source === data.source && !(e.data.expiresAt && e.data.expiresAt <= now));
254
+ if (existing) {
255
+ // Still going on: say it again in place. Worse than before, it is news again.
256
+ const worse = RANK[data.severity] > RANK[existing.data.severity];
257
+ const merged = {...existing.data, ...data, createdAt: worse ? now : existing.data.createdAt,
258
+ readBy: worse ? [] : existing.data.readBy || [], dismissedBy: worse ? [] : existing.data.dismissedBy || []};
259
+ return adapter.update(SLUG, existing.id, {...existing, data: merged, meta: {...(existing.meta || {}), updatedAt: now}});
260
+ }
261
+ }
262
+
263
+ if (!underCap(data.source)) {
264
+ log.warn?.(`[notify] ${data.source} has sent ${RATE_CAP} in the last hour - holding the rest.`);
265
+ return null;
266
+ }
267
+ return adapter.insert(SLUG, {
268
+ id: randomUUID(),
269
+ data: {...data, createdAt: now, readBy: [], dismissedBy: []},
270
+ meta: {createdAt: now, updatedAt: now, source: data.source}
271
+ });
272
+ }
273
+
274
+ /**
275
+ * Raise a notification. Never throws; resolves to the stored entry, or null
276
+ * when the source is switched off, capped, or the payload is not usable.
277
+ *
278
+ * @param {object} payload
279
+ * @returns {Promise<object|null>}
280
+ */
281
+ export function notify(payload) {
282
+ if (!ready) { early.push(payload); return Promise.resolve(null); }
283
+ return serial(() => write(payload)).catch(err => {
284
+ log.warn?.(`[notify] Could not raise "${payload?.title}": ${err.message}`);
285
+ return null;
286
+ });
287
+ }
288
+
289
+ /**
290
+ * The cause is gone: end every live notification with this key (from this
291
+ * source, when one is given). Never throws.
292
+ *
293
+ * @param {string} dedupeKey
294
+ * @param {string} [source]
295
+ * @returns {Promise<number>} how many were ended
296
+ */
297
+ export function resolveNotification(dedupeKey, source) {
298
+ if (!ready || !dedupeKey) return Promise.resolve(0);
299
+ return serial(async () => {
300
+ const adapter = await getAdapter(SLUG);
301
+ const now = new Date().toISOString();
302
+ let n = 0;
303
+ for (const e of await adapter.all(SLUG)) {
304
+ const d = e?.data;
305
+ if (!d || d.dedupeKey !== dedupeKey || (source && d.source !== source)) continue;
306
+ if (d.expiresAt && d.expiresAt <= now) continue;
307
+ await adapter.update(SLUG, e.id, {...e, data: {...d, expiresAt: now}, meta: {...(e.meta || {}), updatedAt: now}});
308
+ n++;
309
+ }
310
+ return n;
311
+ }).catch(err => { log.warn?.(`[notify] Could not resolve "${dedupeKey}": ${err.message}`); return 0; });
312
+ }
313
+
314
+ /**
315
+ * The same three, bound to one owner - what a plugin is handed. Its ids are
316
+ * `<owner>:<id>`, and it can only resolve its own.
317
+ *
318
+ * @param {string} owner
319
+ * @param {string} [ownerLabel]
320
+ */
321
+ export function notifierFor(owner, ownerLabel = owner) {
322
+ const id = (s) => `${owner}:${String(s || 'general').replace(/^[^:]*:/, '')}`;
323
+ return {
324
+ notify: (p = {}) => notify({...p, source: id(p.source), sourceLabel: ownerLabel}),
325
+ resolveNotification: (key, source) => resolveNotification(key, source ? id(source) : undefined),
326
+ registerNotificationSource: (def = {}) => registerNotificationSource({...def, id: id(def.id), owner, ownerLabel})
327
+ };
328
+ }
329
+
330
+ // ---------------------------------------------------------------------------
331
+ // The ticker
332
+ // ---------------------------------------------------------------------------
333
+
334
+ function stateFile() {
335
+ return path.join(path.dirname(path.resolve(config.content.collectionsDir)), '.notification-sources.json');
336
+ }
337
+ function readState() {
338
+ try { return JSON.parse(fs.readFileSync(stateFile(), 'utf8')) || {}; } catch { return {}; }
339
+ }
340
+ function writeState(state) {
341
+ try { fs.writeFileSync(stateFile(), JSON.stringify(state, null, 2) + '\n'); } catch { /* next start runs it again */ }
342
+ }
343
+
344
+ /**
345
+ * Run one source's check now (the ticker, or "Run now" in settings).
346
+ *
347
+ * @param {string} id
348
+ * @returns {Promise<{ok: boolean, error?: string}>}
349
+ */
350
+ export async function runSource(id) {
351
+ const def = sources.get(id);
352
+ if (!def?.check) return {ok: false, error: 'not a scheduled source'};
353
+ const run = runs.get(id) || {};
354
+ run.lastRun = Date.now();
355
+ runs.set(id, run);
356
+ if (def.every === 'daily') writeState({...readState(), [id]: run.lastRun});
357
+ if (!sourceSetting(id).enabled) { run.lastError = null; return {ok: true, skipped: true}; }
358
+ const ctx = {
359
+ now: new Date(),
360
+ notify: (p = {}) => notify({...p, source: id, sourceLabel: def.ownerLabel}),
361
+ resolve: (key) => resolveNotification(key, id)
362
+ };
363
+ let timer;
364
+ try {
365
+ await Promise.race([
366
+ Promise.resolve().then(() => def.check(ctx)),
367
+ new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(`took over ${CHECK_TIMEOUT_MS / 1000}s`)), CHECK_TIMEOUT_MS); })
368
+ ]);
369
+ run.lastError = null;
370
+ return {ok: true};
371
+ } catch (err) {
372
+ run.lastError = String(err?.message || err);
373
+ log.warn?.(`[notify] Source ${id} failed: ${run.lastError}`);
374
+ return {ok: false, error: run.lastError};
375
+ } finally {
376
+ clearTimeout(timer);
377
+ }
378
+ }
379
+
380
+ let ticker = null;
381
+
382
+ /**
383
+ * Start raising: flush what was raised during start-up, run the boot checks,
384
+ * then look for due sources every minute. Called once the server is up.
385
+ *
386
+ * @param {{log?: object}} [opts]
387
+ * @returns {Function} stop
388
+ */
389
+ export function startNotifications({log: logger} = {}) {
390
+ if (logger) log = logger;
391
+ ready = true;
392
+ for (const p of early.splice(0)) notify(p);
393
+ const saved = readState();
394
+ for (const [id, at] of Object.entries(saved)) runs.set(id, {...(runs.get(id) || {}), lastRun: at});
395
+
396
+ const tick = async (boot = false) => {
397
+ const now = new Date();
398
+ for (const def of [...sources.values()]) {
399
+ if (!def.check) continue;
400
+ const due = boot ? (def.every === 'boot' || def.atBoot || isDue(def.every, runs.get(def.id)?.lastRun, now))
401
+ : isDue(def.every, runs.get(def.id)?.lastRun, now);
402
+ if (due) await runSource(def.id);
403
+ }
404
+ };
405
+ // Boot checks a little after start, when the first requests are served.
406
+ const first = setTimeout(() => tick(true), 15_000);
407
+ ticker = setInterval(() => tick(false), 60_000);
408
+ first.unref?.();
409
+ ticker.unref?.();
410
+ return () => { clearTimeout(first); clearInterval(ticker); ticker = null; };
411
+ }
412
+
413
+ /** Test-only: forget everything. */
414
+ export function _reset() {
415
+ sources.clear(); runs.clear(); sent.clear(); early.length = 0; ready = false;
416
+ if (ticker) clearInterval(ticker);
417
+ ticker = null;
418
+ }
419
+ /** Test-only: be ready without the ticker. */
420
+ export function _ready() { ready = true; }
@@ -30,6 +30,7 @@ import {
30
30
  import {registerPluginResource, unregisterPluginResourcesByPlugin} from './permissionRegistry.js';
31
31
  import {registerThemeProvider} from './themeSettings.js';
32
32
  import {classifyEntitlement, mayLoad} from './pluginEntitlement.js';
33
+ import {notifierFor} from './notify.js';
33
34
  import {createCollection, getCollection} from './collections.js';
34
35
  import * as defaultRolesService from './roles.js';
35
36
  import {normaliseAdminScope} from './roles.js';
@@ -482,7 +483,10 @@ export async function registerPlugins(fastify) {
482
483
  registerCalendarSource: (source) => registerCalendarSource({...source, owner: manifest.name}),
483
484
  getCalendarSources,
484
485
  registerRelatedItems: (kind, provider) => registerRelatedItems(kind, {...provider, owner: manifest.name}),
485
- on: hooks.on.bind(hooks), ...pluginRoleHooks(manifest.name)},
486
+ on: hooks.on.bind(hooks), ...pluginRoleHooks(manifest.name),
487
+ // Notifications (0.80) - notify, resolveNotification, registerNotificationSource,
488
+ // stamped with the plugin's name (its source ids are `<plugin>:<id>`).
489
+ ...notifierFor(manifest.name, manifest.displayName || manifest.name)},
486
490
  settings,
487
491
  config: {}
488
492
  });
@@ -1079,7 +1083,7 @@ export async function runLifecycleHook(name, hook, fastify) {
1079
1083
  }
1080
1084
 
1081
1085
  if (typeof mod[hook] === 'function') {
1082
- await mod[hook]({ fastify, services, hooks: pluginRoleHooks(name) });
1086
+ await mod[hook]({ fastify, services, hooks: {...pluginRoleHooks(name), ...notifierFor(name)} });
1083
1087
  }
1084
1088
  } catch (err) {
1085
1089
  fastify.log.error(`Plugin "${name}" lifecycle hook "${hook}" failed: ${err.message}`);
@@ -35,3 +35,36 @@ export function clip(text, max = 60) {
35
35
  const cut = s.slice(0, max - 1);
36
36
  return `${cut.slice(0, cut.lastIndexOf(' ') > max * 0.6 ? cut.lastIndexOf(' ') : cut.length)}…`;
37
37
  }
38
+
39
+ /**
40
+ * My Profile's sidebar badge: the details of yours still empty - your name, and
41
+ * each extra profile field an administrator added (the user-profiles
42
+ * collection). None missing is no badge. Plain text throughout.
43
+ *
44
+ * @param {object} user
45
+ * @param {object} profile - this user's profile data
46
+ * @param {object[]} fields - the user-profiles collection's fields
47
+ */
48
+ export function profileBadge(user = {}, profile = {}, fields = []) {
49
+ const empty = (v) => v === undefined || v === null || (typeof v === 'string' && !v.trim()) || (Array.isArray(v) && !v.length);
50
+ const missing = [
51
+ ...(empty(user.name) ? ['Name'] : []),
52
+ ...fields.filter(f => f && f.name && f.type !== 'hidden' && empty(profile[f.name])).map(f => String(f.label || f.name))
53
+ ];
54
+ const n = missing.length;
55
+ const shown = fields.filter(f => f && f.name && f.type !== 'hidden').length + 1;
56
+ return {
57
+ count: n,
58
+ label: n ? `${n} profile detail${n === 1 ? '' : 's'} not filled in` : 'Your profile is complete',
59
+ tone: 'info',
60
+ details: {
61
+ title: 'My Profile',
62
+ rows: [
63
+ {label: 'Filled in', value: `${shown - n} of ${shown}`},
64
+ {label: 'Role', value: String(user.role || '')}
65
+ ],
66
+ items: missing.slice(0, 8).map(text => ({text, meta: 'empty'})),
67
+ empty: 'Every detail is filled in.'
68
+ }
69
+ };
70
+ }