domma-cms 0.79.1 → 0.81.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +6 -1
- package/admin/index.html +2 -2
- package/admin/js/app.js +2 -2
- package/admin/js/lib/action-editor-arrange.js +1 -1
- 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/action-editor.html +1 -0
- package/admin/js/templates/docs/usage-actions.html +5 -0
- package/admin/js/templates/form-editor.html +72 -0
- package/admin/js/templates/notifications.html +21 -1
- package/admin/js/views/form-editor.js +7 -7
- package/admin/js/views/notifications.js +67 -4
- package/package.json +1 -1
- package/public/css/forms.css +1 -1
- package/public/js/form-logic-engine.js +1 -1
- package/public/js/forms.js +2 -2
- package/public/js/site.js +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 +40 -9
- 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/actions.js +28 -0
- package/server/services/adapterRegistry.js +1 -0
- package/server/services/collections.js +10 -4
- package/server/services/connectionManager.js +1 -0
- package/server/services/email.js +3 -0
- package/server/services/notification-sources.js +361 -0
- package/server/services/notify.js +420 -0
- package/server/services/plugins.js +6 -2
- package/server/services/sidebarBadges.js +33 -0
- package/server/templates/page.html +4 -4
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. '
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What core itself tells the Super Admin about - the system's notification
|
|
3
|
+
* sources. Registered once at start-up (server.js); each shows in
|
|
4
|
+
* Notifications' settings, where a Super Admin can switch it off or send it to
|
|
5
|
+
* more people.
|
|
6
|
+
*
|
|
7
|
+
* Two kinds:
|
|
8
|
+
* - scheduled checks (`every` + `check`), run by services/notify.js's ticker
|
|
9
|
+
* - event sources (no `check`): named here so they can be listed and switched
|
|
10
|
+
* off; raised with notify({source: 'core:…'}) where the thing happens -
|
|
11
|
+
* the helpers at the bottom are those call sites' one line each.
|
|
12
|
+
*
|
|
13
|
+
* Pure decisions (what a licence state or a login count means) are exported
|
|
14
|
+
* for tests/services/notification-sources.test.js.
|
|
15
|
+
*
|
|
16
|
+
* @module services/notification-sources
|
|
17
|
+
*/
|
|
18
|
+
import fs from 'fs';
|
|
19
|
+
import path from 'path';
|
|
20
|
+
import {config, getConfig} from '../config.js';
|
|
21
|
+
import {notify, registerNotificationSource} from './notify.js';
|
|
22
|
+
|
|
23
|
+
const day = (d = new Date()) => d.toISOString().slice(0, 10);
|
|
24
|
+
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Pure decisions
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* What a licensed plugin's entitlement means for the Super Admin, or null.
|
|
31
|
+
*
|
|
32
|
+
* @param {string} name - plugin name
|
|
33
|
+
* @param {string} label - its display name
|
|
34
|
+
* @param {{state: string, entitlement: object|null}} verdict - classifyEntitlement()
|
|
35
|
+
* @param {Date} now
|
|
36
|
+
* @returns {{severity: string, title: string, body: string}|null}
|
|
37
|
+
*/
|
|
38
|
+
export function licenceNotice(name, label, verdict, now = new Date()) {
|
|
39
|
+
const e = verdict?.entitlement || {};
|
|
40
|
+
const ends = (iso) => String(iso || '').slice(0, 10);
|
|
41
|
+
switch (verdict?.state) {
|
|
42
|
+
case 'unlicensed':
|
|
43
|
+
return {severity: 'critical', title: `${label} is not running: no valid licence`,
|
|
44
|
+
body: `${label} is switched on but has no valid licence for this site, so it did not start. Its screens and pages are missing until a licence is installed.`};
|
|
45
|
+
case 'expired':
|
|
46
|
+
return {severity: 'critical', title: `${label} has stopped: licence expired`,
|
|
47
|
+
body: `The ${label} licence ended on ${ends(e.expiresAt)} and its grace period has passed, so it no longer starts. Renew it to bring it back - its data is kept.`};
|
|
48
|
+
case 'grace': {
|
|
49
|
+
const graceEnd = new Date(Date.parse(e.expiresAt) + (Number(e.gracePeriodDays) || 0) * 86_400_000);
|
|
50
|
+
return {severity: 'warning', title: `${label} licence has expired`,
|
|
51
|
+
body: `The ${label} licence ended on ${ends(e.expiresAt)}. It keeps working until ${day(graceEnd)}, then stops. Renew it before then.`};
|
|
52
|
+
}
|
|
53
|
+
case 'support-ended':
|
|
54
|
+
return {severity: 'info', title: `${label}: support and updates have ended`,
|
|
55
|
+
body: `Your ${label} licence is permanent and keeps working, but updates stopped on ${ends(e.supportExpiresAt)}.`};
|
|
56
|
+
case 'valid': {
|
|
57
|
+
if (e.model !== 'rental') return null;
|
|
58
|
+
const left = Math.ceil((Date.parse(e.expiresAt) - now.getTime()) / 86_400_000);
|
|
59
|
+
if (!(left >= 0 && left <= 14)) return null;
|
|
60
|
+
return {severity: 'warning', title: `${label} licence ends in ${left} day${left === 1 ? '' : 's'}`,
|
|
61
|
+
body: `The ${label} licence ends on ${ends(e.expiresAt)}. Renew it to keep ${label} running.`};
|
|
62
|
+
}
|
|
63
|
+
default:
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* An API token worth a word: expiring within 7 days, or expired in the last 7.
|
|
70
|
+
*
|
|
71
|
+
* @param {object} data - the token entry's data
|
|
72
|
+
* @param {Date} now
|
|
73
|
+
*/
|
|
74
|
+
export function tokenNotice(data, now = new Date()) {
|
|
75
|
+
if (!data?.expiresAt || data.enabled === false) return null;
|
|
76
|
+
const left = Math.ceil((Date.parse(data.expiresAt) - now.getTime()) / 86_400_000);
|
|
77
|
+
if (!Number.isFinite(left) || left > 7 || left < -7) return null;
|
|
78
|
+
const name = data.name || 'An API token';
|
|
79
|
+
return left < 0
|
|
80
|
+
? {severity: 'warning', title: `API token "${name}" has expired`,
|
|
81
|
+
body: `It expired on ${String(data.expiresAt).slice(0, 10)}. Anything still using it is being refused - issue a new one in API Tokens.`}
|
|
82
|
+
: {severity: 'warning', title: `API token "${name}" expires in ${left} day${left === 1 ? '' : 's'}`,
|
|
83
|
+
body: `It expires on ${String(data.expiresAt).slice(0, 10)}. Anything using it will be refused after that - extend it or issue a new one in API Tokens.`};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Sign-in failures: this many for one account inside the window is worth telling. */
|
|
87
|
+
export const LOGIN_FAILURES = {count: 5, windowMs: 15 * 60_000};
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Count one failed sign-in; true when it tips over the threshold (once per window).
|
|
91
|
+
*
|
|
92
|
+
* @param {Map} counts - email -> timestamps (state kept by the caller)
|
|
93
|
+
* @param {string} email
|
|
94
|
+
* @param {number} now
|
|
95
|
+
*/
|
|
96
|
+
export function countLoginFailure(counts, email, now = Date.now()) {
|
|
97
|
+
const key = String(email || '').trim().toLowerCase();
|
|
98
|
+
const recent = (counts.get(key) || []).filter(t => now - t < LOGIN_FAILURES.windowMs);
|
|
99
|
+
recent.push(now);
|
|
100
|
+
counts.set(key, recent);
|
|
101
|
+
return recent.length === LOGIN_FAILURES.count;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Free space worth a warning: under 10% or under 1 GB. */
|
|
105
|
+
export function diskNotice(freeBytes, totalBytes) {
|
|
106
|
+
if (!(totalBytes > 0)) return null;
|
|
107
|
+
const pct = freeBytes / totalBytes;
|
|
108
|
+
if (pct >= 0.1 && freeBytes >= 1024 ** 3) return null;
|
|
109
|
+
const gb = (freeBytes / 1024 ** 3).toFixed(1);
|
|
110
|
+
return {severity: pct < 0.03 ? 'critical' : 'warning', title: `Disk space is low: ${gb} GB free`,
|
|
111
|
+
body: `Only ${Math.round(pct * 100)}% of the disk holding this site's content is free (${gb} GB). Uploads, backups and saves fail when it runs out.`};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
// Registration
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
function crashMarker() {
|
|
119
|
+
return path.join(path.dirname(path.resolve(config.content.collectionsDir)), '.crash.json');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Note a crash on the way down (sync - the process is exiting), so the next
|
|
124
|
+
* start can say so. Never throws.
|
|
125
|
+
*
|
|
126
|
+
* @param {unknown} err
|
|
127
|
+
*/
|
|
128
|
+
export function recordCrash(err) {
|
|
129
|
+
try {
|
|
130
|
+
fs.writeFileSync(crashMarker(), JSON.stringify({at: new Date().toISOString(),
|
|
131
|
+
message: String(err?.message || err || 'unknown').slice(0, 500)}));
|
|
132
|
+
} catch { /* nothing more to be done */ }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Register every core source. Called once, before plugins load. */
|
|
136
|
+
export async function registerCoreSources() {
|
|
137
|
+
const plugins = await import('./plugins.js');
|
|
138
|
+
const {classifyEntitlement} = await import('./pluginEntitlement.js');
|
|
139
|
+
|
|
140
|
+
registerNotificationSource({
|
|
141
|
+
id: 'core:plugin-load', label: 'Plugin failed to start', severity: 'critical', every: 'boot',
|
|
142
|
+
description: 'A plugin that is switched on stopped with an error while the site started.',
|
|
143
|
+
async check({notify: raise, resolve}) {
|
|
144
|
+
const failures = plugins.getPluginLoadFailures();
|
|
145
|
+
for (const f of failures) {
|
|
146
|
+
await raise({severity: 'critical', dedupeKey: `plugin-load:${f.name}`, link: '#/plugins',
|
|
147
|
+
title: `Plugin failed to start: ${f.name}`,
|
|
148
|
+
body: `${f.name} stopped with an error while the site started, so its screens and pages are missing.\n\n${f.reason}`});
|
|
149
|
+
}
|
|
150
|
+
const failed = new Set(failures.map(f => f.name));
|
|
151
|
+
for (const [name, p] of Object.entries(plugins.getLoadedPlugins())) {
|
|
152
|
+
if (p.enabled && !failed.has(name)) await resolve(`plugin-load:${name}`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
registerNotificationSource({
|
|
158
|
+
id: 'core:licence', label: 'Plugin licences', severity: 'warning', every: 'daily', atBoot: true,
|
|
159
|
+
description: 'A licensed plugin that has stopped, is in its grace period, or ends within 14 days.',
|
|
160
|
+
async check({notify: raise, resolve, now}) {
|
|
161
|
+
const states = plugins.getPluginStates();
|
|
162
|
+
for (const m of await plugins.discoverPlugins()) {
|
|
163
|
+
if (m.closedSource !== true) continue;
|
|
164
|
+
const key = `licence:${m.name}`;
|
|
165
|
+
if (!states[m.name]?.enabled) { await resolve(key); continue; }
|
|
166
|
+
const notice = licenceNotice(m.name, m.displayName || m.name, classifyEntitlement(m.name), now);
|
|
167
|
+
if (notice) await raise({...notice, dedupeKey: key, link: '#/plugins'});
|
|
168
|
+
else await resolve(key);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
registerNotificationSource({
|
|
174
|
+
id: 'core:crash', label: 'Site restarted after a crash', severity: 'critical', every: 'boot',
|
|
175
|
+
description: 'The site stopped on an unexpected error and was started again.',
|
|
176
|
+
async check({notify: raise}) {
|
|
177
|
+
let marker;
|
|
178
|
+
try { marker = JSON.parse(fs.readFileSync(crashMarker(), 'utf8')); } catch { return; }
|
|
179
|
+
try { fs.rmSync(crashMarker(), {force: true}); } catch { /* raised once anyway: dedupe */ }
|
|
180
|
+
await raise({severity: 'critical', dedupeKey: `crash:${marker.at}`,
|
|
181
|
+
title: 'The site restarted after a crash',
|
|
182
|
+
body: `It stopped on an unexpected error at ${String(marker.at).replace('T', ' ').slice(0, 16)} UTC and was started again.\n\n${marker.message}`});
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
registerNotificationSource({
|
|
187
|
+
id: 'core:api-tokens', label: 'API tokens expiring', severity: 'warning', every: 'daily', atBoot: true,
|
|
188
|
+
description: 'An API token that expires within 7 days, or expired in the last 7.',
|
|
189
|
+
async check({notify: raise}) {
|
|
190
|
+
const {listEntries} = await import('./collections.js');
|
|
191
|
+
const {entries} = await listEntries('api-tokens', {limit: 0}).catch(() => ({entries: []}));
|
|
192
|
+
for (const e of entries || []) {
|
|
193
|
+
const notice = tokenNotice(e.data);
|
|
194
|
+
if (notice) await raise({...notice, dedupeKey: `api-token:${e.id}`, link: '#/api-tokens'});
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
registerNotificationSource({
|
|
200
|
+
id: 'core:disk', label: 'Disk space low', severity: 'warning', every: 'hourly', atBoot: true,
|
|
201
|
+
description: 'Under 10% (or 1 GB) free on the disk that holds the site\'s content.',
|
|
202
|
+
async check({notify: raise, resolve}) {
|
|
203
|
+
if (typeof fs.promises.statfs !== 'function') return;
|
|
204
|
+
const st = await fs.promises.statfs(path.resolve(config.content.contentDir || 'content'));
|
|
205
|
+
const notice = diskNotice(st.bavail * st.bsize, st.blocks * st.bsize);
|
|
206
|
+
if (notice) await raise({...notice, dedupeKey: 'disk'});
|
|
207
|
+
else await resolve('disk');
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// Event sources - raised where the thing happens (see the helpers below).
|
|
212
|
+
const events = [
|
|
213
|
+
{id: 'core:storage', label: 'Database unavailable', severity: 'critical',
|
|
214
|
+
description: 'A MongoDB connection failed, so collections on it fell back to file storage.'},
|
|
215
|
+
{id: 'core:startup', label: 'Start-up problems', severity: 'warning',
|
|
216
|
+
description: 'A start-up repair that did not finish and will be retried.'},
|
|
217
|
+
{id: 'core:email', label: 'Email not delivered', severity: 'warning',
|
|
218
|
+
description: 'The site could not send an email, or has no mail server set up (mail goes nowhere). At most once a day.'},
|
|
219
|
+
{id: 'core:logins', label: 'Repeated failed sign-ins', severity: 'warning',
|
|
220
|
+
description: `${LOGIN_FAILURES.count} failed sign-ins to one account within 15 minutes, or the sign-in limit being hit.`},
|
|
221
|
+
{id: 'core:roles', label: 'Admin rights given', severity: 'warning',
|
|
222
|
+
description: 'Someone was given the Admin or Super Admin role.'},
|
|
223
|
+
{id: 'core:forms', label: 'Form actions failing', severity: 'warning',
|
|
224
|
+
description: 'A form\'s email, webhook or action failed after a submission. At most once a day per form.'},
|
|
225
|
+
{id: 'core:form-submitted', label: 'Form submitted', severity: 'info', defaultOn: false,
|
|
226
|
+
description: 'Every form submission. Off by default - busy forms would fill the inbox.'},
|
|
227
|
+
// On, and to every admin: the author asked for it answer by answer, so
|
|
228
|
+
// it is not noise, and fleet sites have no Super Admin to receive it.
|
|
229
|
+
{id: 'core:form-triggers', label: 'Form triggers', severity: 'info', audience: 'admins',
|
|
230
|
+
description: 'A form trigger set to "Notify the admins" fired when a form was submitted.'},
|
|
231
|
+
{id: 'core:actions', label: 'Actions', severity: 'info', audience: 'admins',
|
|
232
|
+
description: 'An Action with a "Notify the admins" step ran.'},
|
|
233
|
+
{id: 'core:users', label: 'Users added or removed', severity: 'info', defaultOn: false,
|
|
234
|
+
description: 'A user account was created or deleted.'}
|
|
235
|
+
];
|
|
236
|
+
for (const e of events) registerNotificationSource(e);
|
|
237
|
+
|
|
238
|
+
// Hook-bus events core already emits.
|
|
239
|
+
const {hooks} = await import('./hooks.js');
|
|
240
|
+
hooks.on('form:submitted', ({slug, formTitle}) => {
|
|
241
|
+
notify({source: 'core:form-submitted', severity: 'info', link: `#/forms/${slug}/submissions`,
|
|
242
|
+
title: `New submission: ${formTitle || slug}`, body: `Someone submitted the "${formTitle || slug}" form.`});
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ---------------------------------------------------------------------------
|
|
247
|
+
// Call-site helpers (one line where the thing happens)
|
|
248
|
+
// ---------------------------------------------------------------------------
|
|
249
|
+
|
|
250
|
+
/** email.js: a send failed. */
|
|
251
|
+
export function emailFailed(message) {
|
|
252
|
+
return notify({source: 'core:email', severity: 'warning', dedupeKey: `email-failed:${day()}`, link: '#/settings',
|
|
253
|
+
title: 'An email could not be sent',
|
|
254
|
+
body: `The mail server refused or could not be reached: ${String(message || '').slice(0, 300)}\n\nPassword resets and form emails may not be arriving. Check Settings > Email.`});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** email.js: no SMTP host, so mail goes to a test inbox. */
|
|
258
|
+
export function emailNotConfigured() {
|
|
259
|
+
return notify({source: 'core:email', severity: 'warning', dedupeKey: 'smtp-missing', link: '#/settings',
|
|
260
|
+
title: 'Email is not set up',
|
|
261
|
+
body: 'No mail server is configured, so the site\'s emails (password resets, form notifications) go to a test inbox and reach nobody. Set one in Settings > Email.'});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const loginCounts = new Map();
|
|
265
|
+
|
|
266
|
+
/** auth.js: a sign-in failed. */
|
|
267
|
+
export function loginFailed(email, ip) {
|
|
268
|
+
if (!countLoginFailure(loginCounts, email)) return null;
|
|
269
|
+
return notify({source: 'core:logins', severity: 'warning', dedupeKey: `login-failures:${String(email).toLowerCase()}`, link: '#/users',
|
|
270
|
+
title: `Repeated failed sign-ins for ${email}`,
|
|
271
|
+
body: `${LOGIN_FAILURES.count} wrong passwords for ${email} in 15 minutes${ip ? `, the last from ${ip}` : ''}. If that was not the account holder, someone may be guessing the password.`});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** auth.js: the sign-in rate limit refused someone. */
|
|
275
|
+
export function loginRateLimited(ip) {
|
|
276
|
+
const hour = new Date().toISOString().slice(0, 13);
|
|
277
|
+
return notify({source: 'core:logins', severity: 'warning', dedupeKey: `login-limit:${ip}:${hour}`,
|
|
278
|
+
title: 'Sign-in limit reached',
|
|
279
|
+
body: `${ip || 'One address'} made more than 5 sign-in attempts in a minute and is being refused for now. Repeated, that is someone guessing passwords.`});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** users route: someone was given admin rights. */
|
|
283
|
+
export function adminRightsGiven(user, roles, by) {
|
|
284
|
+
const which = roles.includes('super-admin') ? 'Super Admin' : 'Admin';
|
|
285
|
+
return notify({source: 'core:roles', severity: 'warning', link: '#/users',
|
|
286
|
+
title: `${user.name || user.email} is now ${which === 'Admin' ? 'an' : 'a'} ${which}`,
|
|
287
|
+
body: `${user.name || ''} <${user.email}> was given the ${which} role${by ? ` by ${by}` : ''}.`.trim()});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** users route: an account was created. */
|
|
291
|
+
export function userCreated(user, by) {
|
|
292
|
+
return notify({source: 'core:users', severity: 'info', link: '#/users',
|
|
293
|
+
title: `New user: ${user.name || user.email}`,
|
|
294
|
+
body: `${user.name || ''} <${user.email}> was added as ${user.role}${by ? ` by ${by}` : ''}.`.trim()});
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** users route: an account was deleted. */
|
|
298
|
+
export function userDeleted(user, by) {
|
|
299
|
+
return notify({source: 'core:users', severity: 'info', link: '#/users',
|
|
300
|
+
title: `User removed: ${user.name || user.email}`,
|
|
301
|
+
body: `${user.name || ''} <${user.email}> was deleted${by ? ` by ${by}` : ''}.`.trim()});
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** forms route: a side effect of a submission failed. */
|
|
305
|
+
export function formActionFailed(slug, title, what, message) {
|
|
306
|
+
return notify({source: 'core:forms', severity: 'warning', dedupeKey: `form-failed:${slug}:${what}:${day()}`, link: `#/forms/edit/${slug}`,
|
|
307
|
+
title: `Form "${title || slug}": ${what} failed`,
|
|
308
|
+
body: `After a submission to "${title || slug}", its ${what} failed: ${String(message || '').slice(0, 300)}\n\nThe submission itself was saved.`});
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** What a trigger's level means to a notification: `error` is the one word they do not share. */
|
|
312
|
+
const TRIGGER_SEVERITY = {info: 'info', success: 'success', warning: 'warning', error: 'critical'};
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* The notice a form trigger's "Notify the admins" raises. Pure, for the tests.
|
|
316
|
+
*
|
|
317
|
+
* @param {object} o
|
|
318
|
+
* @param {string} o.slug
|
|
319
|
+
* @param {string} [o.formTitle]
|
|
320
|
+
* @param {object} o.action - the trigger action: {message, level}
|
|
321
|
+
* @param {string} [o.field] - label of the field the trigger hangs off
|
|
322
|
+
* @param {*} [o.answer] - that field's answer
|
|
323
|
+
* @param {boolean} [o.ended] - an end-form cut the form short
|
|
324
|
+
* @param {boolean} [o.recorded] - the submission was stored
|
|
325
|
+
*/
|
|
326
|
+
export function formTriggerPayload({slug, formTitle, action = {}, field, answer, ended, recorded}) {
|
|
327
|
+
const form = formTitle || slug;
|
|
328
|
+
const said = Array.isArray(answer) ? answer.join(', ') : String(answer ?? '');
|
|
329
|
+
const lines = [`On the "${form}" form, ${field ? `"${field}" was answered "${said.slice(0, 200)}"` : 'a trigger fired'}.`];
|
|
330
|
+
if (ended) lines.push(recorded ? 'The form ended there; the answers given so far were saved.' : 'The form ended there; nothing was saved.');
|
|
331
|
+
return {
|
|
332
|
+
source: 'core:form-triggers',
|
|
333
|
+
severity: TRIGGER_SEVERITY[action.level] || 'info',
|
|
334
|
+
title: String(action.message || '').trim() || `Form "${form}": a trigger fired`,
|
|
335
|
+
body: lines.join('\n\n'),
|
|
336
|
+
link: recorded ? `#/forms/${slug}/submissions` : `#/forms/edit/${slug}`
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** forms route: a trigger's "Notify the admins" fired on a submission. */
|
|
341
|
+
export function formTriggerNotice(o) {
|
|
342
|
+
return notify(formTriggerPayload(o));
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** adapterRegistry / connectionManager: a database connection failed. */
|
|
346
|
+
export function storageDown(name, message) {
|
|
347
|
+
return notify({source: 'core:storage', severity: 'critical', dedupeKey: `storage:${name}`,
|
|
348
|
+
title: `Database connection "${name}" failed`,
|
|
349
|
+
body: `The site could not connect to MongoDB ("${name}"): ${String(message || '').slice(0, 300)}\n\nCollections stored there are being read from and written to local files instead, so they may look empty or out of date.`});
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** server.js: a start-up repair left something to retry. */
|
|
353
|
+
export function startupPending(what) {
|
|
354
|
+
return notify({source: 'core:startup', severity: 'warning', dedupeKey: `startup:${what}:${day()}`,
|
|
355
|
+
title: 'A start-up repair did not finish', body: `${what} It will be tried again at the next start.`});
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** Used by the smtp check - is mail set up at all (config/site.json). */
|
|
359
|
+
export function smtpConfigured() {
|
|
360
|
+
try { return Boolean(String(getConfig('site')?.smtp?.host || '').trim()); } catch { return true; }
|
|
361
|
+
}
|