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
|
@@ -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
|
+
}
|