@aglyn/tenant-data-admin 1.0.0-beta.205 → 1.0.0-beta.207
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/package.json +7 -7
- package/src/lib/server/connect-payout-failure.js +18 -3
- package/src/lib/server/connect-payout-failure.js.map +1 -1
- package/src/lib/server/email-deliverability.js +19 -1
- package/src/lib/server/email-deliverability.js.map +1 -1
- package/src/lib/server/email-send-rate.js +32 -1
- package/src/lib/server/email-send-rate.js.map +1 -1
- package/src/lib/server/host-memberships.d.ts +20 -0
- package/src/lib/server/host-memberships.js +29 -1
- package/src/lib/server/host-memberships.js.map +1 -1
- package/src/lib/server/notifications.d.ts +13 -0
- package/src/lib/server/notifications.js +35 -23
- package/src/lib/server/notifications.js.map +1 -1
- package/src/lib/server/operator-alerts.d.ts +215 -0
- package/src/lib/server/operator-alerts.js +683 -0
- package/src/lib/server/operator-alerts.js.map +1 -0
- package/src/lib/server/operator-health.d.ts +59 -0
- package/src/lib/server/operator-health.js +200 -0
- package/src/lib/server/operator-health.js.map +1 -0
- package/src/lib/server/staff-alert-email.d.ts +18 -5
- package/src/lib/server/staff-alert-email.js +36 -5
- package/src/lib/server/staff-alert-email.js.map +1 -1
- package/src/lib/server/transfer-host.d.ts +52 -0
- package/src/lib/server/transfer-host.js +377 -0
- package/src/lib/server/transfer-host.js.map +1 -0
- package/src/tenancy.d.ts +2 -0
- package/src/tenancy.js +4 -0
- package/src/tenancy.js.map +1 -1
|
@@ -0,0 +1,683 @@
|
|
|
1
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
2
|
+
/**
|
|
3
|
+
* @license
|
|
4
|
+
* Copyright 2026 Aglyn LLC
|
|
5
|
+
*
|
|
6
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
7
|
+
* you may not use this file except in compliance with the License.
|
|
8
|
+
* You may obtain a copy of the License at
|
|
9
|
+
*
|
|
10
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
11
|
+
*
|
|
12
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
13
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
14
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
|
+
* See the License for the specific language governing permissions and
|
|
16
|
+
* limitations under the License.
|
|
17
|
+
*/ import { createHash, createHmac } from "node:crypto";
|
|
18
|
+
import { effectiveOperatorAlertSetting, OPERATOR_ALERT_DEFAULT_NOTIFICATION_TYPE, OPERATOR_ALERT_TIER_LABELS, renderOperatorAlertTemplate } from "@aglyn/aglyn/server";
|
|
19
|
+
import { getOperatorAlert, registerOperatorAlerts } from "@aglyn/aglyn/plugin-manager/operator-alerts";
|
|
20
|
+
import { createResourceUid } from "@aglyn/aglyn/app-utils/create-resource-uid";
|
|
21
|
+
import { isEmailConfigured } from "@aglyn/shared-util-email";
|
|
22
|
+
import firebaseAdmin from "./firebase-admin.js";
|
|
23
|
+
/**
|
|
24
|
+
* RAISING AN OPERATOR ALERT (AGL-3377): the one server entry point every
|
|
25
|
+
* alert in the registry goes through.
|
|
26
|
+
*
|
|
27
|
+
* In order, for one call:
|
|
28
|
+
*
|
|
29
|
+
* 1. The type is looked up in the registry (core's catalog plus what plugins
|
|
30
|
+
* registered). A plugin may pass its definition itself, which registers
|
|
31
|
+
* it in this process if its declarations have not loaded yet.
|
|
32
|
+
* 2. A `dedupeKey` is claimed against `operatorAlertState`, so a condition
|
|
33
|
+
* that flaps inside the type's window is told once, and a type with
|
|
34
|
+
* `minOccurrences` fires only once it has recurred that often.
|
|
35
|
+
* 3. The staff console notification is written for every staff member, as
|
|
36
|
+
* `notifyStaff` always has.
|
|
37
|
+
* 4. Staff's switches (`platformSettings/operatorAlerts`, coded defaults
|
|
38
|
+
* until changed) decide the rest: off is console only, `digest` queues it
|
|
39
|
+
* for the once-a-day digest, `immediate` emails the operator now.
|
|
40
|
+
* 5. The email is the `operator-alert` system email, to
|
|
41
|
+
* `resolveStaffAlertRecipients()`; the optional out-of-band webhook
|
|
42
|
+
* (`OPERATOR_ALERT_WEBHOOK_URL`) gets the same alert beside it, so a mail
|
|
43
|
+
* provider that is itself the outage still reaches somebody.
|
|
44
|
+
*
|
|
45
|
+
* Never throws: an alert is raised from inside the failure it reports, and a
|
|
46
|
+
* second failure there must not replace the first.
|
|
47
|
+
*/ /** Where staff's per-type switches live. */ export const OPERATOR_ALERT_SETTINGS_COLLECTION = 'platformSettings';
|
|
48
|
+
export const OPERATOR_ALERT_SETTINGS_DOC = 'operatorAlerts';
|
|
49
|
+
/** One document per (type, dedupeKey): when it last fired, how often since. */ export const OPERATOR_ALERT_STATE_COLLECTION = 'operatorAlertState';
|
|
50
|
+
/** Alerts waiting for the daily digest. */ export const OPERATOR_ALERT_DIGEST_COLLECTION = 'operatorAlertDigest';
|
|
51
|
+
/** The hour, UTC, the digest goes out when staff have not chosen one. */ export const OPERATOR_ALERT_DIGEST_DEFAULT_HOUR_UTC = 14;
|
|
52
|
+
const firestore = ()=>firebaseAdmin.app().firestore();
|
|
53
|
+
// ── Settings ────────────────────────────────────────────────────────────────
|
|
54
|
+
const SETTINGS_TTL_MS = 15000;
|
|
55
|
+
let settingsCache;
|
|
56
|
+
/** Drop the in-process settings cache; the console calls it after a write. */ export function invalidateOperatorAlertSettingsCache() {
|
|
57
|
+
settingsCache = undefined;
|
|
58
|
+
}
|
|
59
|
+
/** Clamps a stored settings document to what the pipeline understands. */ export function normalizeOperatorAlertSettings(raw) {
|
|
60
|
+
const source = raw && typeof raw === 'object' ? raw : {};
|
|
61
|
+
const types = {};
|
|
62
|
+
const rawTypes = source['types'];
|
|
63
|
+
if (rawTypes && typeof rawTypes === 'object') {
|
|
64
|
+
for (const [type, value] of Object.entries(rawTypes)){
|
|
65
|
+
if (!type || !value || typeof value !== 'object') continue;
|
|
66
|
+
const entry = value;
|
|
67
|
+
const setting = {};
|
|
68
|
+
if (typeof entry['enabled'] === 'boolean') setting.enabled = entry['enabled'];
|
|
69
|
+
if (entry['delivery'] === 'immediate' || entry['delivery'] === 'digest') {
|
|
70
|
+
setting.delivery = entry['delivery'];
|
|
71
|
+
}
|
|
72
|
+
if (Object.keys(setting).length) types[type] = setting;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const hour = Number(source['digestHourUtc']);
|
|
76
|
+
return _extends({
|
|
77
|
+
types,
|
|
78
|
+
digestHourUtc: Number.isInteger(hour) && hour >= 0 && hour <= 23 ? hour : OPERATOR_ALERT_DIGEST_DEFAULT_HOUR_UTC
|
|
79
|
+
}, typeof source['updatedAtMs'] === 'number' ? {
|
|
80
|
+
updatedAtMs: source['updatedAtMs']
|
|
81
|
+
} : {}, typeof source['updatedByEmail'] === 'string' ? {
|
|
82
|
+
updatedByEmail: source['updatedByEmail']
|
|
83
|
+
} : {});
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Staff's switches. An unreadable document is the coded defaults, uncached:
|
|
87
|
+
* an alert must still go out when the settings cannot be read.
|
|
88
|
+
*/ export async function readOperatorAlertSettings() {
|
|
89
|
+
const now = Date.now();
|
|
90
|
+
if (settingsCache && now - settingsCache.at < SETTINGS_TTL_MS) return settingsCache.doc;
|
|
91
|
+
try {
|
|
92
|
+
const snapshot = await firestore().collection(OPERATOR_ALERT_SETTINGS_COLLECTION).doc(OPERATOR_ALERT_SETTINGS_DOC).get();
|
|
93
|
+
const doc = normalizeOperatorAlertSettings(snapshot.exists ? snapshot.data() : null);
|
|
94
|
+
settingsCache = {
|
|
95
|
+
at: now,
|
|
96
|
+
doc
|
|
97
|
+
};
|
|
98
|
+
return doc;
|
|
99
|
+
} catch (error) {
|
|
100
|
+
console.error('[operator-alerts] settings unreadable; using the defaults', error);
|
|
101
|
+
return normalizeOperatorAlertSettings(null);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
// ── Dedupe ──────────────────────────────────────────────────────────────────
|
|
105
|
+
/** Keys this instance fired, and until when they stay quiet. */ const firedLocally = new Map();
|
|
106
|
+
const FIRED_LOCALLY_MAX = 500;
|
|
107
|
+
function stateDocId(type, dedupeKey) {
|
|
108
|
+
return createHash('sha256').update(`${type}\u0000${dedupeKey}`).digest('hex').slice(0, 40);
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Whether a raise with this key fires now. One transaction on the key's
|
|
112
|
+
* state document: inside the window after a fire it is counted and
|
|
113
|
+
* suppressed; before it reaches `minOccurrences` it is counted and held.
|
|
114
|
+
* A store that cannot be read FIRES — a duplicate beats a silence.
|
|
115
|
+
*/ export async function claimOperatorAlert(definition, dedupeKey, now = Date.now()) {
|
|
116
|
+
var _definition_minOccurrences;
|
|
117
|
+
const windowMs = Math.max(0, definition.dedupeWindowMinutes) * 60000;
|
|
118
|
+
const minOccurrences = Math.max(1, Math.floor((_definition_minOccurrences = definition.minOccurrences) != null ? _definition_minOccurrences : 1));
|
|
119
|
+
if (!windowMs) return {
|
|
120
|
+
fire: true,
|
|
121
|
+
repeats: 0
|
|
122
|
+
};
|
|
123
|
+
const localKey = `${definition.type}\u0000${dedupeKey}`;
|
|
124
|
+
const quietUntil = firedLocally.get(localKey);
|
|
125
|
+
if (quietUntil && quietUntil > now) return {
|
|
126
|
+
fire: false,
|
|
127
|
+
reason: 'deduped'
|
|
128
|
+
};
|
|
129
|
+
try {
|
|
130
|
+
const ref = firestore().collection(OPERATOR_ALERT_STATE_COLLECTION).doc(stateDocId(definition.type, dedupeKey));
|
|
131
|
+
const claim = await firestore().runTransaction(async (transaction)=>{
|
|
132
|
+
var _state_lastFiredAtMs, _state_windowStartMs, _state_occurrences, _state_fired, _state_suppressed;
|
|
133
|
+
const snapshot = await transaction.get(ref);
|
|
134
|
+
const state = snapshot.exists ? snapshot.data() : {};
|
|
135
|
+
const lastFiredAtMs = Number((_state_lastFiredAtMs = state['lastFiredAtMs']) != null ? _state_lastFiredAtMs : 0);
|
|
136
|
+
const base = {
|
|
137
|
+
type: definition.type,
|
|
138
|
+
dedupeKey: dedupeKey.slice(0, 300),
|
|
139
|
+
updatedAtMs: now
|
|
140
|
+
};
|
|
141
|
+
if (lastFiredAtMs && now - lastFiredAtMs < windowMs) {
|
|
142
|
+
var _state_suppressed1;
|
|
143
|
+
transaction.set(ref, _extends({}, base, {
|
|
144
|
+
suppressed: Number((_state_suppressed1 = state['suppressed']) != null ? _state_suppressed1 : 0) + 1
|
|
145
|
+
}), {
|
|
146
|
+
merge: true
|
|
147
|
+
});
|
|
148
|
+
return {
|
|
149
|
+
fire: false,
|
|
150
|
+
reason: 'deduped'
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
const windowStartMs = Number((_state_windowStartMs = state['windowStartMs']) != null ? _state_windowStartMs : 0);
|
|
154
|
+
const inWindow = windowStartMs && now - windowStartMs < windowMs;
|
|
155
|
+
const occurrences = (inWindow ? Number((_state_occurrences = state['occurrences']) != null ? _state_occurrences : 0) : 0) + 1;
|
|
156
|
+
if (occurrences < minOccurrences) {
|
|
157
|
+
transaction.set(ref, _extends({}, base, {
|
|
158
|
+
occurrences,
|
|
159
|
+
windowStartMs: inWindow ? windowStartMs : now
|
|
160
|
+
}), {
|
|
161
|
+
merge: true
|
|
162
|
+
});
|
|
163
|
+
return {
|
|
164
|
+
fire: false,
|
|
165
|
+
reason: 'below-threshold'
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
transaction.set(ref, _extends({}, base, {
|
|
169
|
+
lastFiredAtMs: now,
|
|
170
|
+
occurrences: 0,
|
|
171
|
+
windowStartMs: 0,
|
|
172
|
+
suppressed: 0,
|
|
173
|
+
fired: Number((_state_fired = state['fired']) != null ? _state_fired : 0) + 1
|
|
174
|
+
}), {
|
|
175
|
+
merge: true
|
|
176
|
+
});
|
|
177
|
+
return {
|
|
178
|
+
fire: true,
|
|
179
|
+
repeats: Number((_state_suppressed = state['suppressed']) != null ? _state_suppressed : 0)
|
|
180
|
+
};
|
|
181
|
+
});
|
|
182
|
+
if (claim.fire) {
|
|
183
|
+
if (firedLocally.size >= FIRED_LOCALLY_MAX) firedLocally.clear();
|
|
184
|
+
firedLocally.set(localKey, now + windowMs);
|
|
185
|
+
}
|
|
186
|
+
return claim;
|
|
187
|
+
} catch (error) {
|
|
188
|
+
console.error(`[operator-alerts] dedupe unavailable for ${definition.type}; firing`, error);
|
|
189
|
+
return {
|
|
190
|
+
fire: true,
|
|
191
|
+
repeats: 0
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
// ── Out-of-band webhook ─────────────────────────────────────────────────────
|
|
196
|
+
const WEBHOOK_TIMEOUT_MS = 5000;
|
|
197
|
+
/** The webhook URL, when one is configured and is http(s). */ export function operatorAlertWebhookUrl() {
|
|
198
|
+
var _process_env_OPERATOR_ALERT_WEBHOOK_URL;
|
|
199
|
+
const value = String((_process_env_OPERATOR_ALERT_WEBHOOK_URL = process.env['OPERATOR_ALERT_WEBHOOK_URL']) != null ? _process_env_OPERATOR_ALERT_WEBHOOK_URL : '').trim();
|
|
200
|
+
return /^https?:\/\/\S+$/i.test(value) ? value : '';
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* The JSON the webhook receives. `text` is what a Slack-compatible incoming
|
|
204
|
+
* webhook renders on its own; `alert` carries the fields for anything that
|
|
205
|
+
* wants to route on them.
|
|
206
|
+
*/ export function operatorAlertWebhookPayload(alerts, options = {}) {
|
|
207
|
+
var _options_now, _shaped_;
|
|
208
|
+
const origin = consoleOrigin();
|
|
209
|
+
const lines = alerts.map((alert)=>{
|
|
210
|
+
const link = operatorAlertAbsoluteLink(alert.link);
|
|
211
|
+
return [
|
|
212
|
+
`[${OPERATOR_ALERT_TIER_LABELS[alert.tier]}] ${alert.title}`,
|
|
213
|
+
alert.body,
|
|
214
|
+
link
|
|
215
|
+
].filter(Boolean).join('\n');
|
|
216
|
+
});
|
|
217
|
+
const text = options.digest ? [
|
|
218
|
+
`Operator digest: ${alerts.length} alert${alerts.length === 1 ? '' : 's'}`,
|
|
219
|
+
...lines
|
|
220
|
+
].join('\n\n') : lines.join('\n\n');
|
|
221
|
+
const occurredAt = new Date((_options_now = options.now) != null ? _options_now : Date.now()).toISOString();
|
|
222
|
+
const shaped = alerts.map((alert)=>({
|
|
223
|
+
type: alert.type,
|
|
224
|
+
tier: alert.tier,
|
|
225
|
+
category: alert.category,
|
|
226
|
+
title: alert.title,
|
|
227
|
+
body: alert.body,
|
|
228
|
+
url: operatorAlertAbsoluteLink(alert.link) || null
|
|
229
|
+
}));
|
|
230
|
+
return _extends({
|
|
231
|
+
text
|
|
232
|
+
}, options.digest ? {
|
|
233
|
+
digest: shaped
|
|
234
|
+
} : {
|
|
235
|
+
alert: (_shaped_ = shaped[0]) != null ? _shaped_ : null
|
|
236
|
+
}, {
|
|
237
|
+
occurredAt
|
|
238
|
+
}, origin ? {
|
|
239
|
+
install: origin
|
|
240
|
+
} : {});
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Posts alerts to `OPERATOR_ALERT_WEBHOOK_URL`, signed when
|
|
244
|
+
* `OPERATOR_ALERT_WEBHOOK_SECRET` is set: `x-operator-alert-timestamp` is the
|
|
245
|
+
* Unix time and `x-operator-alert-signature` is `sha256=` plus the hex HMAC
|
|
246
|
+
* of `{timestamp}.{body}`. Unset means email only. Never throws.
|
|
247
|
+
*/ export async function postOperatorAlertWebhook(alerts, options = {}) {
|
|
248
|
+
var _process_env_OPERATOR_ALERT_WEBHOOK_SECRET;
|
|
249
|
+
const url = operatorAlertWebhookUrl();
|
|
250
|
+
if (!url || !alerts.length) return {
|
|
251
|
+
posted: false,
|
|
252
|
+
reason: 'unconfigured'
|
|
253
|
+
};
|
|
254
|
+
const body = JSON.stringify(operatorAlertWebhookPayload(alerts, options));
|
|
255
|
+
const headers = {
|
|
256
|
+
'content-type': 'application/json'
|
|
257
|
+
};
|
|
258
|
+
const secret = String((_process_env_OPERATOR_ALERT_WEBHOOK_SECRET = process.env['OPERATOR_ALERT_WEBHOOK_SECRET']) != null ? _process_env_OPERATOR_ALERT_WEBHOOK_SECRET : '').trim();
|
|
259
|
+
if (secret) {
|
|
260
|
+
var _options_now;
|
|
261
|
+
const timestamp = String(Math.floor(((_options_now = options.now) != null ? _options_now : Date.now()) / 1000));
|
|
262
|
+
headers['x-operator-alert-timestamp'] = timestamp;
|
|
263
|
+
headers['x-operator-alert-signature'] = 'sha256=' + createHmac('sha256', secret).update(`${timestamp}.${body}`).digest('hex');
|
|
264
|
+
}
|
|
265
|
+
try {
|
|
266
|
+
const response = await fetch(url, {
|
|
267
|
+
method: 'POST',
|
|
268
|
+
headers,
|
|
269
|
+
body,
|
|
270
|
+
redirect: 'manual',
|
|
271
|
+
signal: AbortSignal.timeout(WEBHOOK_TIMEOUT_MS)
|
|
272
|
+
});
|
|
273
|
+
if (response.ok) return {
|
|
274
|
+
posted: true,
|
|
275
|
+
status: response.status
|
|
276
|
+
};
|
|
277
|
+
console.error(`[operator-alerts] webhook answered ${response.status}`);
|
|
278
|
+
return {
|
|
279
|
+
posted: false,
|
|
280
|
+
reason: 'refused',
|
|
281
|
+
status: response.status
|
|
282
|
+
};
|
|
283
|
+
} catch (error) {
|
|
284
|
+
console.error('[operator-alerts] webhook unreachable', error);
|
|
285
|
+
return {
|
|
286
|
+
posted: false,
|
|
287
|
+
reason: 'network'
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
// ── Links ───────────────────────────────────────────────────────────────────
|
|
292
|
+
function consoleOrigin() {
|
|
293
|
+
var _process_env_NEXT_PUBLIC_CONSOLE_URL;
|
|
294
|
+
return String((_process_env_NEXT_PUBLIC_CONSOLE_URL = process.env['NEXT_PUBLIC_CONSOLE_URL']) != null ? _process_env_NEXT_PUBLIC_CONSOLE_URL : '').trim().replace(/\/+$/, '');
|
|
295
|
+
}
|
|
296
|
+
/** A console path as an inbox or webhook can follow it, or `''`. */ export function operatorAlertAbsoluteLink(link) {
|
|
297
|
+
const value = String(link != null ? link : '').trim();
|
|
298
|
+
if (!value) return '';
|
|
299
|
+
if (/^https?:\/\//i.test(value)) return value;
|
|
300
|
+
const origin = consoleOrigin();
|
|
301
|
+
return origin && value.startsWith('/') ? `${origin}${value}` : '';
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* The definition's link with its tokens filled, or `''` when a token it
|
|
305
|
+
* needs is missing — `/admin/orgs/` would open the wrong page.
|
|
306
|
+
*/ function renderLink(template, context) {
|
|
307
|
+
if (!template) return '';
|
|
308
|
+
const missing = [
|
|
309
|
+
...template.matchAll(/\{\{\s*([\w.]+)\s*\}\}/g)
|
|
310
|
+
].some(([, name])=>{
|
|
311
|
+
const value = context == null ? void 0 : context[name];
|
|
312
|
+
return value === null || value === undefined || value === '';
|
|
313
|
+
});
|
|
314
|
+
return missing ? '' : renderOperatorAlertTemplate(template, context);
|
|
315
|
+
}
|
|
316
|
+
// ── Rendering ───────────────────────────────────────────────────────────────
|
|
317
|
+
/** Stands in for a type nobody registered, so it is still told. */ function unregisteredDefinition(type) {
|
|
318
|
+
return {
|
|
319
|
+
type,
|
|
320
|
+
label: type,
|
|
321
|
+
description: 'An alert type this install has no registry entry for.',
|
|
322
|
+
tier: 'should',
|
|
323
|
+
category: 'ops',
|
|
324
|
+
title: '{{title}}',
|
|
325
|
+
body: '{{body}}',
|
|
326
|
+
delivery: 'immediate',
|
|
327
|
+
dedupeWindowMinutes: 60,
|
|
328
|
+
defaultEnabled: true
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
/** Resolves the definition a call names, registering a plugin's own. */ export function resolveOperatorAlertDefinition(type) {
|
|
332
|
+
if (typeof type !== 'string') {
|
|
333
|
+
const known = getOperatorAlert(type.type);
|
|
334
|
+
if (known) return known;
|
|
335
|
+
if (type.pluginId) {
|
|
336
|
+
try {
|
|
337
|
+
registerOperatorAlerts([
|
|
338
|
+
type
|
|
339
|
+
], {
|
|
340
|
+
pluginId: type.pluginId
|
|
341
|
+
});
|
|
342
|
+
} catch (error) {
|
|
343
|
+
console.error(`[operator-alerts] ${type.type} could not be registered`, error);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
return type;
|
|
347
|
+
}
|
|
348
|
+
const known = getOperatorAlert(type);
|
|
349
|
+
if (known) return known;
|
|
350
|
+
console.error(`[operator-alerts] "${type}" is not a registered alert type; telling it anyway`);
|
|
351
|
+
return unregisteredDefinition(type);
|
|
352
|
+
}
|
|
353
|
+
export function renderOperatorAlert(definition, options = {}) {
|
|
354
|
+
var _options_context, _options_subject, _options_body, _options_url, _definition_notificationType;
|
|
355
|
+
const context = _extends({}, options.subject ? {
|
|
356
|
+
title: options.subject
|
|
357
|
+
} : {}, options.body ? {
|
|
358
|
+
body: options.body
|
|
359
|
+
} : {}, (_options_context = options.context) != null ? _options_context : {});
|
|
360
|
+
const title = String((_options_subject = options.subject) != null ? _options_subject : '').trim() || renderOperatorAlertTemplate(definition.title, context) || definition.label;
|
|
361
|
+
const body = String((_options_body = options.body) != null ? _options_body : '').trim() || renderOperatorAlertTemplate(definition.body, context);
|
|
362
|
+
return _extends({
|
|
363
|
+
type: definition.type,
|
|
364
|
+
label: definition.label,
|
|
365
|
+
tier: definition.tier,
|
|
366
|
+
category: definition.category,
|
|
367
|
+
title,
|
|
368
|
+
body,
|
|
369
|
+
link: String((_options_url = options.url) != null ? _options_url : '').trim() || renderLink(definition.link, context),
|
|
370
|
+
notificationType: (_definition_notificationType = definition.notificationType) != null ? _definition_notificationType : OPERATOR_ALERT_DEFAULT_NOTIFICATION_TYPE
|
|
371
|
+
}, options.orgId ? {
|
|
372
|
+
orgId: options.orgId
|
|
373
|
+
} : {}, options.hostId ? {
|
|
374
|
+
hostId: options.hostId
|
|
375
|
+
} : {});
|
|
376
|
+
}
|
|
377
|
+
// ── The entry point ─────────────────────────────────────────────────────────
|
|
378
|
+
/** Whether an email to the operator can go at all on this install. */ async function operatorEmailReachable() {
|
|
379
|
+
if (!isEmailConfigured()) return false;
|
|
380
|
+
const { resolveStaffAlertRecipients } = await import("./staff-alert-email.js");
|
|
381
|
+
return (await resolveStaffAlertRecipients()).length > 0;
|
|
382
|
+
}
|
|
383
|
+
/** See the module comment. Never throws. */ export async function raiseOperatorAlert(type, options = {}) {
|
|
384
|
+
const typeId = typeof type === 'string' ? type : type.type;
|
|
385
|
+
try {
|
|
386
|
+
var _options_dedupeKey;
|
|
387
|
+
const definition = resolveOperatorAlertDefinition(type);
|
|
388
|
+
const dedupeKey = String((_options_dedupeKey = options.dedupeKey) != null ? _options_dedupeKey : '').trim();
|
|
389
|
+
let repeats = 0;
|
|
390
|
+
if (dedupeKey) {
|
|
391
|
+
const claim = await claimOperatorAlert(definition, dedupeKey);
|
|
392
|
+
if (claim.fire === false) return {
|
|
393
|
+
outcome: claim.reason,
|
|
394
|
+
type: typeId
|
|
395
|
+
};
|
|
396
|
+
repeats = claim.repeats;
|
|
397
|
+
}
|
|
398
|
+
const alert = renderOperatorAlert(definition, options);
|
|
399
|
+
if (repeats > 0) {
|
|
400
|
+
alert.body = [
|
|
401
|
+
alert.body,
|
|
402
|
+
`Raised ${repeats} more time${repeats === 1 ? '' : 's'} since it was last told.`
|
|
403
|
+
].filter(Boolean).join(' ');
|
|
404
|
+
}
|
|
405
|
+
const settings = await readOperatorAlertSettings();
|
|
406
|
+
const { enabled, delivery } = effectiveOperatorAlertSetting(definition, settings);
|
|
407
|
+
const emailNow = enabled && delivery === 'immediate' && await operatorEmailReachable();
|
|
408
|
+
const { notifyStaffConsole } = await import("./notifications.js");
|
|
409
|
+
await notifyStaffConsole(_extends({
|
|
410
|
+
type: alert.notificationType,
|
|
411
|
+
title: alert.title
|
|
412
|
+
}, alert.body ? {
|
|
413
|
+
body: alert.body
|
|
414
|
+
} : {}, alert.link && alert.link.startsWith('/') ? {
|
|
415
|
+
link: alert.link
|
|
416
|
+
} : {}, alert.orgId ? {
|
|
417
|
+
orgId: alert.orgId
|
|
418
|
+
} : {}, alert.hostId ? {
|
|
419
|
+
hostId: alert.hostId
|
|
420
|
+
} : {}), // The operator is emailed; a staff member who also switched email on
|
|
421
|
+
// for this category is not mailed the same alert a second time.
|
|
422
|
+
emailNow ? {
|
|
423
|
+
skipEmail: true
|
|
424
|
+
} : {});
|
|
425
|
+
if (!enabled) return {
|
|
426
|
+
outcome: 'console-only',
|
|
427
|
+
type: typeId
|
|
428
|
+
};
|
|
429
|
+
if (delivery === 'digest') {
|
|
430
|
+
await queueOperatorAlertForDigest(alert);
|
|
431
|
+
return {
|
|
432
|
+
outcome: 'queued',
|
|
433
|
+
type: typeId
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
return await deliverOperatorAlert(alert, {
|
|
437
|
+
emailNow
|
|
438
|
+
});
|
|
439
|
+
} catch (error) {
|
|
440
|
+
console.error(`[operator-alerts] ${typeId} could not be raised`, error);
|
|
441
|
+
return {
|
|
442
|
+
outcome: 'undelivered',
|
|
443
|
+
type: typeId
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
/** Emails and posts one alert now. */ async function deliverOperatorAlert(alert, options) {
|
|
448
|
+
const { sendOperatorAlertEmail } = await import("./staff-alert-email.js");
|
|
449
|
+
const [email, webhook] = await Promise.all([
|
|
450
|
+
options.emailNow ? sendOperatorAlertEmail({
|
|
451
|
+
title: alert.title,
|
|
452
|
+
body: alert.body,
|
|
453
|
+
url: operatorAlertAbsoluteLink(alert.link),
|
|
454
|
+
context: `operator-alert ${alert.type}`
|
|
455
|
+
}) : Promise.resolve({
|
|
456
|
+
sent: false,
|
|
457
|
+
reason: 'unconfigured'
|
|
458
|
+
}),
|
|
459
|
+
postOperatorAlertWebhook([
|
|
460
|
+
alert
|
|
461
|
+
])
|
|
462
|
+
]);
|
|
463
|
+
return {
|
|
464
|
+
outcome: email.sent || webhook.posted ? 'delivered' : 'undelivered',
|
|
465
|
+
type: alert.type,
|
|
466
|
+
email,
|
|
467
|
+
webhook
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
// ── The digest ──────────────────────────────────────────────────────────────
|
|
471
|
+
async function queueOperatorAlertForDigest(alert) {
|
|
472
|
+
try {
|
|
473
|
+
await firestore().collection(OPERATOR_ALERT_DIGEST_COLLECTION).doc(createResourceUid()).set(_extends({}, alert, {
|
|
474
|
+
atMs: Date.now()
|
|
475
|
+
}));
|
|
476
|
+
} catch (error) {
|
|
477
|
+
// The queue is the only record the digest will have; with it gone, the
|
|
478
|
+
// alert goes now rather than never.
|
|
479
|
+
console.error(`[operator-alerts] digest queue unavailable; sending ${alert.type} now`, error);
|
|
480
|
+
await deliverOperatorAlert(alert, {
|
|
481
|
+
emailNow: await operatorEmailReachable()
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
/** At most this many alerts are listed in one digest; the rest are counted. */ export const OPERATOR_DIGEST_MAX_LISTED = 50;
|
|
486
|
+
const DIGEST_READ_LIMIT = 500;
|
|
487
|
+
function dayKey(now) {
|
|
488
|
+
return new Date(now).toISOString().slice(0, 10);
|
|
489
|
+
}
|
|
490
|
+
/** The state document that claims one UTC day's digest. */ export function operatorDigestMarkerId(now) {
|
|
491
|
+
return `digest-${dayKey(now)}`;
|
|
492
|
+
}
|
|
493
|
+
/** The digest's body: one paragraph per alert, newest last. */ export function composeOperatorDigestBody(alerts, total = alerts.length) {
|
|
494
|
+
const listed = alerts.slice(0, OPERATOR_DIGEST_MAX_LISTED).map((alert)=>{
|
|
495
|
+
const link = operatorAlertAbsoluteLink(alert.link);
|
|
496
|
+
return [
|
|
497
|
+
`• ${alert.title}`,
|
|
498
|
+
alert.body,
|
|
499
|
+
link
|
|
500
|
+
].filter(Boolean).join('\n');
|
|
501
|
+
});
|
|
502
|
+
const rest = total - listed.length;
|
|
503
|
+
if (rest > 0) listed.push(`…and ${rest} more in the staff console.`);
|
|
504
|
+
return listed.join('\n\n');
|
|
505
|
+
}
|
|
506
|
+
/**
|
|
507
|
+
* Sends the day's digest once the configured hour has passed: every queued
|
|
508
|
+
* alert in one `operator-alert-digest` email and one webhook post, then
|
|
509
|
+
* clears what it sent. Once per UTC day, claimed by creating
|
|
510
|
+
* `operatorAlertState/digest-{day}`, so two ticks never both send it.
|
|
511
|
+
*/ export async function sendOperatorAlertDigest(options = {}) {
|
|
512
|
+
var _options_now;
|
|
513
|
+
const now = (_options_now = options.now) != null ? _options_now : Date.now();
|
|
514
|
+
try {
|
|
515
|
+
var _settings_digestHourUtc;
|
|
516
|
+
const settings = await readOperatorAlertSettings();
|
|
517
|
+
const hour = (_settings_digestHourUtc = settings.digestHourUtc) != null ? _settings_digestHourUtc : OPERATOR_ALERT_DIGEST_DEFAULT_HOUR_UTC;
|
|
518
|
+
if (!options.force && new Date(now).getUTCHours() < hour) {
|
|
519
|
+
return {
|
|
520
|
+
sent: false,
|
|
521
|
+
reason: 'not-due',
|
|
522
|
+
count: 0
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
const collection = firestore().collection(OPERATOR_ALERT_DIGEST_COLLECTION);
|
|
526
|
+
const marker = firestore().collection(OPERATOR_ALERT_STATE_COLLECTION).doc(operatorDigestMarkerId(now));
|
|
527
|
+
if (!options.force && (await marker.get()).exists) {
|
|
528
|
+
return {
|
|
529
|
+
sent: false,
|
|
530
|
+
reason: 'already-sent',
|
|
531
|
+
count: 0
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
const snapshot = await collection.orderBy('atMs', 'asc').limit(DIGEST_READ_LIMIT).get();
|
|
535
|
+
const queued = snapshot.docs;
|
|
536
|
+
if (!queued.length) return {
|
|
537
|
+
sent: false,
|
|
538
|
+
reason: 'empty',
|
|
539
|
+
count: 0
|
|
540
|
+
};
|
|
541
|
+
if (options.dryRun) return {
|
|
542
|
+
sent: false,
|
|
543
|
+
reason: 'dry-run',
|
|
544
|
+
count: queued.length
|
|
545
|
+
};
|
|
546
|
+
if (!options.force) {
|
|
547
|
+
try {
|
|
548
|
+
await marker.create({
|
|
549
|
+
atMs: now,
|
|
550
|
+
count: queued.length
|
|
551
|
+
});
|
|
552
|
+
} catch (unused) {
|
|
553
|
+
return {
|
|
554
|
+
sent: false,
|
|
555
|
+
reason: 'already-sent',
|
|
556
|
+
count: 0
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
const alerts = queued.map((doc)=>doc.data());
|
|
561
|
+
const { sendOperatorAlertDigestEmail } = await import("./staff-alert-email.js");
|
|
562
|
+
const [email, webhook] = await Promise.all([
|
|
563
|
+
sendOperatorAlertDigestEmail({
|
|
564
|
+
date: dayKey(now),
|
|
565
|
+
count: alerts.length,
|
|
566
|
+
body: composeOperatorDigestBody(alerts),
|
|
567
|
+
url: operatorAlertAbsoluteLink('/admin/operator-alerts')
|
|
568
|
+
}),
|
|
569
|
+
postOperatorAlertWebhook(alerts, {
|
|
570
|
+
digest: true,
|
|
571
|
+
now
|
|
572
|
+
})
|
|
573
|
+
]);
|
|
574
|
+
if (email.sent || webhook.posted) {
|
|
575
|
+
const batch = firestore().batch();
|
|
576
|
+
for (const doc of queued)batch.delete(doc.ref);
|
|
577
|
+
await batch.commit();
|
|
578
|
+
} else if (!options.force) {
|
|
579
|
+
// Nothing reached anybody: release the day so the next tick retries.
|
|
580
|
+
await marker.delete().catch(()=>undefined);
|
|
581
|
+
}
|
|
582
|
+
return {
|
|
583
|
+
sent: email.sent || webhook.posted,
|
|
584
|
+
count: alerts.length,
|
|
585
|
+
email,
|
|
586
|
+
webhook
|
|
587
|
+
};
|
|
588
|
+
} catch (error) {
|
|
589
|
+
console.error('[operator-alerts] digest failed', error);
|
|
590
|
+
return {
|
|
591
|
+
sent: false,
|
|
592
|
+
reason: 'unavailable',
|
|
593
|
+
count: 0
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
/** Every registered type with its default and its effective setting. */ export function operatorAlertCatalogRows(definitions, settings) {
|
|
598
|
+
return definitions.map((definition)=>{
|
|
599
|
+
var _definition_pluginId;
|
|
600
|
+
var _settings_types;
|
|
601
|
+
return {
|
|
602
|
+
type: definition.type,
|
|
603
|
+
label: definition.label,
|
|
604
|
+
description: definition.description,
|
|
605
|
+
tier: definition.tier,
|
|
606
|
+
category: definition.category,
|
|
607
|
+
pluginId: (_definition_pluginId = definition.pluginId) != null ? _definition_pluginId : null,
|
|
608
|
+
defaults: {
|
|
609
|
+
enabled: definition.defaultEnabled,
|
|
610
|
+
delivery: definition.delivery
|
|
611
|
+
},
|
|
612
|
+
effective: effectiveOperatorAlertSetting(definition, settings),
|
|
613
|
+
overridden: Boolean((_settings_types = settings.types) == null ? void 0 : _settings_types[definition.type])
|
|
614
|
+
};
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
/**
|
|
618
|
+
* Sends one `[Test]` alert of a type straight to the operator's inbox and
|
|
619
|
+
* webhook, past dedupe and staff's switches, so an operator can prove the
|
|
620
|
+
* channels reach them. Writes no console notification.
|
|
621
|
+
*/ export async function sendOperatorAlertTest(definition) {
|
|
622
|
+
var _definition_link;
|
|
623
|
+
const sample = {};
|
|
624
|
+
for (const [, name] of `${definition.title} ${definition.body} ${(_definition_link = definition.link) != null ? _definition_link : ''}`.matchAll(/\{\{\s*([\w.]+)\s*\}\}/g)){
|
|
625
|
+
sample[name] = `sample ${name}`;
|
|
626
|
+
}
|
|
627
|
+
const alert = renderOperatorAlert(definition, {
|
|
628
|
+
context: sample
|
|
629
|
+
});
|
|
630
|
+
alert.title = `[Test] ${alert.title}`;
|
|
631
|
+
alert.body = [
|
|
632
|
+
alert.body,
|
|
633
|
+
'This is a test of the operator alert channels; nothing happened.'
|
|
634
|
+
].join(' ');
|
|
635
|
+
const { sendOperatorAlertEmail } = await import("./staff-alert-email.js");
|
|
636
|
+
const [email, webhook] = await Promise.all([
|
|
637
|
+
isEmailConfigured() ? sendOperatorAlertEmail({
|
|
638
|
+
title: alert.title,
|
|
639
|
+
body: alert.body,
|
|
640
|
+
url: operatorAlertAbsoluteLink(alert.link) || operatorAlertAbsoluteLink('/admin/operator-alerts'),
|
|
641
|
+
context: `operator-alert-test ${definition.type}`
|
|
642
|
+
}) : Promise.resolve({
|
|
643
|
+
sent: false,
|
|
644
|
+
reason: 'unconfigured'
|
|
645
|
+
}),
|
|
646
|
+
postOperatorAlertWebhook([
|
|
647
|
+
alert
|
|
648
|
+
])
|
|
649
|
+
]);
|
|
650
|
+
return {
|
|
651
|
+
email,
|
|
652
|
+
webhook
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
/** Where operator mail goes, named but never listed, for the staff page. */ export async function describeOperatorAlertRecipients() {
|
|
656
|
+
var _process_env_STAFF_ALERT_EMAIL, _process_env_NEXT_PUBLIC_OPERATOR_SUPPORT_EMAIL;
|
|
657
|
+
const { resolveStaffAlertRecipients } = await import("./staff-alert-email.js");
|
|
658
|
+
const recipients = await resolveStaffAlertRecipients();
|
|
659
|
+
if (!recipients.length) return {
|
|
660
|
+
source: 'none',
|
|
661
|
+
count: 0
|
|
662
|
+
};
|
|
663
|
+
const configured = String((_process_env_STAFF_ALERT_EMAIL = process.env['STAFF_ALERT_EMAIL']) != null ? _process_env_STAFF_ALERT_EMAIL : '').trim().toLowerCase();
|
|
664
|
+
if (configured.includes('@') && recipients[0] === configured) {
|
|
665
|
+
return {
|
|
666
|
+
source: 'STAFF_ALERT_EMAIL',
|
|
667
|
+
count: 1
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
const support = String((_process_env_NEXT_PUBLIC_OPERATOR_SUPPORT_EMAIL = process.env['NEXT_PUBLIC_OPERATOR_SUPPORT_EMAIL']) != null ? _process_env_NEXT_PUBLIC_OPERATOR_SUPPORT_EMAIL : '').trim().toLowerCase();
|
|
671
|
+
if (support.includes('@') && recipients[0] === support) {
|
|
672
|
+
return {
|
|
673
|
+
source: 'NEXT_PUBLIC_OPERATOR_SUPPORT_EMAIL',
|
|
674
|
+
count: 1
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
return {
|
|
678
|
+
source: 'staff-accounts',
|
|
679
|
+
count: recipients.length
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
//# sourceMappingURL=operator-alerts.js.map
|