@aglyn/tenant-data-admin 1.0.0-beta.206 → 1.0.0-beta.209

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,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