@aglyn/tenant-data-admin 1.0.0-beta.206 → 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.
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/operator-alerts.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { createHash, createHmac } from 'node:crypto'\nimport {\n effectiveOperatorAlertSetting,\n OPERATOR_ALERT_DEFAULT_NOTIFICATION_TYPE,\n OPERATOR_ALERT_TIER_LABELS,\n renderOperatorAlertTemplate,\n type AglynNotificationType,\n type OperatorAlertContext,\n type OperatorAlertDefinition,\n type OperatorAlertSettings,\n type OperatorAlertTypeSetting,\n} from '@aglyn/aglyn/server'\nimport {\n getOperatorAlert,\n registerOperatorAlerts,\n} from '@aglyn/aglyn/plugin-manager/operator-alerts'\nimport { createResourceUid } from '@aglyn/aglyn/app-utils/create-resource-uid'\nimport { isEmailConfigured, type SendEmailResult } from '@aglyn/shared-util-email'\nimport firebaseAdmin from './firebase-admin'\n\n/**\n * RAISING AN OPERATOR ALERT (AGL-3377): the one server entry point every\n * alert in the registry goes through.\n *\n * In order, for one call:\n *\n * 1. The type is looked up in the registry (core's catalog plus what plugins\n * registered). A plugin may pass its definition itself, which registers\n * it in this process if its declarations have not loaded yet.\n * 2. A `dedupeKey` is claimed against `operatorAlertState`, so a condition\n * that flaps inside the type's window is told once, and a type with\n * `minOccurrences` fires only once it has recurred that often.\n * 3. The staff console notification is written for every staff member, as\n * `notifyStaff` always has.\n * 4. Staff's switches (`platformSettings/operatorAlerts`, coded defaults\n * until changed) decide the rest: off is console only, `digest` queues it\n * for the once-a-day digest, `immediate` emails the operator now.\n * 5. The email is the `operator-alert` system email, to\n * `resolveStaffAlertRecipients()`; the optional out-of-band webhook\n * (`OPERATOR_ALERT_WEBHOOK_URL`) gets the same alert beside it, so a mail\n * provider that is itself the outage still reaches somebody.\n *\n * Never throws: an alert is raised from inside the failure it reports, and a\n * second failure there must not replace the first.\n */\n\n/** Where staff's per-type switches live. */\nexport const OPERATOR_ALERT_SETTINGS_COLLECTION = 'platformSettings'\nexport const OPERATOR_ALERT_SETTINGS_DOC = 'operatorAlerts'\n/** One document per (type, dedupeKey): when it last fired, how often since. */\nexport const OPERATOR_ALERT_STATE_COLLECTION = 'operatorAlertState'\n/** Alerts waiting for the daily digest. */\nexport const OPERATOR_ALERT_DIGEST_COLLECTION = 'operatorAlertDigest'\n\n/** The hour, UTC, the digest goes out when staff have not chosen one. */\nexport const OPERATOR_ALERT_DIGEST_DEFAULT_HOUR_UTC = 14\n\nexport interface OperatorAlertSettingsDoc extends OperatorAlertSettings {\n /** The hour of the day, UTC, the digest is sent. */\n digestHourUtc?: number\n updatedAtMs?: number\n updatedByEmail?: string | null\n}\n\nexport interface RaiseOperatorAlertOptions {\n /**\n * What makes two raises the SAME alert — a dispute id, an org and month, a\n * check id. Absent never dedupes: every raise is told.\n */\n dedupeKey?: string\n /** Values for the definition's `{{tokens}}`. Never personal data. */\n context?: OperatorAlertContext\n /** A console path or absolute URL; overrides the definition's link. */\n url?: string\n /** The title as written by the caller; overrides the rendered one. */\n subject?: string\n /** The body as written by the caller; overrides the rendered one. */\n body?: string\n orgId?: string\n hostId?: string\n}\n\nexport type OperatorAlertOutcome =\n | 'deduped'\n | 'below-threshold'\n | 'console-only'\n | 'queued'\n | 'delivered'\n | 'undelivered'\n\nexport interface OperatorAlertResult {\n outcome: OperatorAlertOutcome\n type: string\n email?: SendEmailResult\n webhook?: OperatorAlertWebhookResult\n}\n\nexport type OperatorAlertWebhookResult =\n | { posted: true; status: number }\n | { posted: false; reason: 'unconfigured' | 'refused' | 'network'; status?: number }\n\n/** One alert, rendered, as it is delivered and queued. */\nexport interface RenderedOperatorAlert {\n type: string\n label: string\n tier: OperatorAlertDefinition['tier']\n category: OperatorAlertDefinition['category']\n title: string\n body: string\n /** Console path or absolute URL; `''` when there is none. */\n link: string\n notificationType: AglynNotificationType\n orgId?: string\n hostId?: string\n}\n\nconst firestore = () => firebaseAdmin.app().firestore()\n\n// ── Settings ────────────────────────────────────────────────────────────────\n\nconst SETTINGS_TTL_MS = 15_000\nlet settingsCache: { at: number; doc: OperatorAlertSettingsDoc } | undefined\n\n/** Drop the in-process settings cache; the console calls it after a write. */\nexport function invalidateOperatorAlertSettingsCache(): void {\n settingsCache = undefined\n}\n\n/** Clamps a stored settings document to what the pipeline understands. */\nexport function normalizeOperatorAlertSettings(\n raw: unknown,\n): OperatorAlertSettingsDoc {\n const source = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>\n const types: Record<string, OperatorAlertTypeSetting> = {}\n const rawTypes = source['types']\n if (rawTypes && typeof rawTypes === 'object') {\n for (const [type, value] of Object.entries(rawTypes as Record<string, unknown>)) {\n if (!type || !value || typeof value !== 'object') continue\n const entry = value as Record<string, unknown>\n const setting: OperatorAlertTypeSetting = {}\n if (typeof entry['enabled'] === 'boolean') setting.enabled = entry['enabled']\n if (entry['delivery'] === 'immediate' || entry['delivery'] === 'digest') {\n setting.delivery = entry['delivery']\n }\n if (Object.keys(setting).length) types[type] = setting\n }\n }\n const hour = Number(source['digestHourUtc'])\n return {\n types,\n digestHourUtc:\n Number.isInteger(hour) && hour >= 0 && hour <= 23\n ? hour\n : OPERATOR_ALERT_DIGEST_DEFAULT_HOUR_UTC,\n ...(typeof source['updatedAtMs'] === 'number'\n ? { updatedAtMs: source['updatedAtMs'] as number }\n : {}),\n ...(typeof source['updatedByEmail'] === 'string'\n ? { updatedByEmail: source['updatedByEmail'] as string }\n : {}),\n }\n}\n\n/**\n * Staff's switches. An unreadable document is the coded defaults, uncached:\n * an alert must still go out when the settings cannot be read.\n */\nexport async function readOperatorAlertSettings(): Promise<OperatorAlertSettingsDoc> {\n const now = Date.now()\n if (settingsCache && now - settingsCache.at < SETTINGS_TTL_MS) return settingsCache.doc\n try {\n const snapshot = await firestore()\n .collection(OPERATOR_ALERT_SETTINGS_COLLECTION)\n .doc(OPERATOR_ALERT_SETTINGS_DOC)\n .get()\n const doc = normalizeOperatorAlertSettings(snapshot.exists ? snapshot.data() : null)\n settingsCache = { at: now, doc }\n return doc\n } catch (error) {\n console.error('[operator-alerts] settings unreadable; using the defaults', error)\n return normalizeOperatorAlertSettings(null)\n }\n}\n\n// ── Dedupe ──────────────────────────────────────────────────────────────────\n\n/** Keys this instance fired, and until when they stay quiet. */\nconst firedLocally = new Map<string, number>()\nconst FIRED_LOCALLY_MAX = 500\n\nfunction stateDocId(type: string, dedupeKey: string): string {\n return createHash('sha256').update(`${type}\\u0000${dedupeKey}`).digest('hex').slice(0, 40)\n}\n\nexport type OperatorAlertClaim =\n | { fire: true; repeats: number }\n | { fire: false; reason: 'deduped' | 'below-threshold' }\n\n/**\n * Whether a raise with this key fires now. One transaction on the key's\n * state document: inside the window after a fire it is counted and\n * suppressed; before it reaches `minOccurrences` it is counted and held.\n * A store that cannot be read FIRES — a duplicate beats a silence.\n */\nexport async function claimOperatorAlert(\n definition: Pick<OperatorAlertDefinition, 'type' | 'dedupeWindowMinutes' | 'minOccurrences'>,\n dedupeKey: string,\n now = Date.now(),\n): Promise<OperatorAlertClaim> {\n const windowMs = Math.max(0, definition.dedupeWindowMinutes) * 60_000\n const minOccurrences = Math.max(1, Math.floor(definition.minOccurrences ?? 1))\n if (!windowMs) return { fire: true, repeats: 0 }\n const localKey = `${definition.type}\\u0000${dedupeKey}`\n const quietUntil = firedLocally.get(localKey)\n if (quietUntil && quietUntil > now) return { fire: false, reason: 'deduped' }\n try {\n const ref = firestore()\n .collection(OPERATOR_ALERT_STATE_COLLECTION)\n .doc(stateDocId(definition.type, dedupeKey))\n const claim = await firestore().runTransaction(async (transaction: any) => {\n const snapshot = await transaction.get(ref)\n const state = (snapshot.exists ? snapshot.data() : {}) as Record<string, unknown>\n const lastFiredAtMs = Number(state['lastFiredAtMs'] ?? 0)\n const base = { type: definition.type, dedupeKey: dedupeKey.slice(0, 300), updatedAtMs: now }\n if (lastFiredAtMs && now - lastFiredAtMs < windowMs) {\n transaction.set(ref, { ...base, suppressed: Number(state['suppressed'] ?? 0) + 1 }, { merge: true })\n return { fire: false, reason: 'deduped' } as const\n }\n const windowStartMs = Number(state['windowStartMs'] ?? 0)\n const inWindow = windowStartMs && now - windowStartMs < windowMs\n const occurrences = (inWindow ? Number(state['occurrences'] ?? 0) : 0) + 1\n if (occurrences < minOccurrences) {\n transaction.set(\n ref,\n { ...base, occurrences, windowStartMs: inWindow ? windowStartMs : now },\n { merge: true },\n )\n return { fire: false, reason: 'below-threshold' } as const\n }\n transaction.set(\n ref,\n {\n ...base,\n lastFiredAtMs: now,\n occurrences: 0,\n windowStartMs: 0,\n suppressed: 0,\n fired: Number(state['fired'] ?? 0) + 1,\n },\n { merge: true },\n )\n return { fire: true, repeats: Number(state['suppressed'] ?? 0) } as const\n })\n if (claim.fire) {\n if (firedLocally.size >= FIRED_LOCALLY_MAX) firedLocally.clear()\n firedLocally.set(localKey, now + windowMs)\n }\n return claim\n } catch (error) {\n console.error(`[operator-alerts] dedupe unavailable for ${definition.type}; firing`, error)\n return { fire: true, repeats: 0 }\n }\n}\n\n// ── Out-of-band webhook ─────────────────────────────────────────────────────\n\nconst WEBHOOK_TIMEOUT_MS = 5_000\n\n/** The webhook URL, when one is configured and is http(s). */\nexport function operatorAlertWebhookUrl(): string {\n const value = String(process.env['OPERATOR_ALERT_WEBHOOK_URL'] ?? '').trim()\n return /^https?:\\/\\/\\S+$/i.test(value) ? value : ''\n}\n\n/**\n * The JSON the webhook receives. `text` is what a Slack-compatible incoming\n * webhook renders on its own; `alert` carries the fields for anything that\n * wants to route on them.\n */\nexport function operatorAlertWebhookPayload(\n alerts: readonly RenderedOperatorAlert[],\n options: { digest?: boolean; now?: number } = {},\n): Record<string, unknown> {\n const origin = consoleOrigin()\n const lines = alerts.map((alert) => {\n const link = operatorAlertAbsoluteLink(alert.link)\n return [\n `[${OPERATOR_ALERT_TIER_LABELS[alert.tier]}] ${alert.title}`,\n alert.body,\n link,\n ]\n .filter(Boolean)\n .join('\\n')\n })\n const text = options.digest\n ? [`Operator digest: ${alerts.length} alert${alerts.length === 1 ? '' : 's'}`, ...lines].join('\\n\\n')\n : lines.join('\\n\\n')\n const occurredAt = new Date(options.now ?? Date.now()).toISOString()\n const shaped = alerts.map((alert) => ({\n type: alert.type,\n tier: alert.tier,\n category: alert.category,\n title: alert.title,\n body: alert.body,\n url: operatorAlertAbsoluteLink(alert.link) || null,\n }))\n return {\n text,\n ...(options.digest ? { digest: shaped } : { alert: shaped[0] ?? null }),\n occurredAt,\n ...(origin ? { install: origin } : {}),\n }\n}\n\n/**\n * Posts alerts to `OPERATOR_ALERT_WEBHOOK_URL`, signed when\n * `OPERATOR_ALERT_WEBHOOK_SECRET` is set: `x-operator-alert-timestamp` is the\n * Unix time and `x-operator-alert-signature` is `sha256=` plus the hex HMAC\n * of `{timestamp}.{body}`. Unset means email only. Never throws.\n */\nexport async function postOperatorAlertWebhook(\n alerts: readonly RenderedOperatorAlert[],\n options: { digest?: boolean; now?: number } = {},\n): Promise<OperatorAlertWebhookResult> {\n const url = operatorAlertWebhookUrl()\n if (!url || !alerts.length) return { posted: false, reason: 'unconfigured' }\n const body = JSON.stringify(operatorAlertWebhookPayload(alerts, options))\n const headers: Record<string, string> = { 'content-type': 'application/json' }\n const secret = String(process.env['OPERATOR_ALERT_WEBHOOK_SECRET'] ?? '').trim()\n if (secret) {\n const timestamp = String(Math.floor((options.now ?? Date.now()) / 1000))\n headers['x-operator-alert-timestamp'] = timestamp\n headers['x-operator-alert-signature'] =\n 'sha256=' + createHmac('sha256', secret).update(`${timestamp}.${body}`).digest('hex')\n }\n try {\n const response = await fetch(url, {\n method: 'POST',\n headers,\n body,\n redirect: 'manual',\n signal: AbortSignal.timeout(WEBHOOK_TIMEOUT_MS),\n })\n if (response.ok) return { posted: true, status: response.status }\n console.error(`[operator-alerts] webhook answered ${response.status}`)\n return { posted: false, reason: 'refused', status: response.status }\n } catch (error) {\n console.error('[operator-alerts] webhook unreachable', error)\n return { posted: false, reason: 'network' }\n }\n}\n\n// ── Links ───────────────────────────────────────────────────────────────────\n\nfunction consoleOrigin(): string {\n return String(process.env['NEXT_PUBLIC_CONSOLE_URL'] ?? '').trim().replace(/\\/+$/, '')\n}\n\n/** A console path as an inbox or webhook can follow it, or `''`. */\nexport function operatorAlertAbsoluteLink(link: string | undefined | null): string {\n const value = String(link ?? '').trim()\n if (!value) return ''\n if (/^https?:\\/\\//i.test(value)) return value\n const origin = consoleOrigin()\n return origin && value.startsWith('/') ? `${origin}${value}` : ''\n}\n\n/**\n * The definition's link with its tokens filled, or `''` when a token it\n * needs is missing — `/admin/orgs/` would open the wrong page.\n */\nfunction renderLink(template: string | undefined, context: OperatorAlertContext | undefined): string {\n if (!template) return ''\n const missing = [...template.matchAll(/\\{\\{\\s*([\\w.]+)\\s*\\}\\}/g)].some(([, name]) => {\n const value = context?.[name as string]\n return value === null || value === undefined || value === ''\n })\n return missing ? '' : renderOperatorAlertTemplate(template, context)\n}\n\n// ── Rendering ───────────────────────────────────────────────────────────────\n\n/** Stands in for a type nobody registered, so it is still told. */\nfunction unregisteredDefinition(type: string): OperatorAlertDefinition {\n return {\n type,\n label: type,\n description: 'An alert type this install has no registry entry for.',\n tier: 'should',\n category: 'ops',\n title: '{{title}}',\n body: '{{body}}',\n delivery: 'immediate',\n dedupeWindowMinutes: 60,\n defaultEnabled: true,\n }\n}\n\n/** Resolves the definition a call names, registering a plugin's own. */\nexport function resolveOperatorAlertDefinition(\n type: string | OperatorAlertDefinition,\n): OperatorAlertDefinition {\n if (typeof type !== 'string') {\n const known = getOperatorAlert(type.type)\n if (known) return known\n if (type.pluginId) {\n try {\n registerOperatorAlerts([type], { pluginId: type.pluginId })\n } catch (error) {\n console.error(`[operator-alerts] ${type.type} could not be registered`, error)\n }\n }\n return type\n }\n const known = getOperatorAlert(type)\n if (known) return known\n console.error(`[operator-alerts] \"${type}\" is not a registered alert type; telling it anyway`)\n return unregisteredDefinition(type)\n}\n\nexport function renderOperatorAlert(\n definition: OperatorAlertDefinition,\n options: RaiseOperatorAlertOptions = {},\n): RenderedOperatorAlert {\n const context: OperatorAlertContext = {\n ...(options.subject ? { title: options.subject } : {}),\n ...(options.body ? { body: options.body } : {}),\n ...(options.context ?? {}),\n }\n const title =\n String(options.subject ?? '').trim() ||\n renderOperatorAlertTemplate(definition.title, context) ||\n definition.label\n const body =\n String(options.body ?? '').trim() || renderOperatorAlertTemplate(definition.body, context)\n return {\n type: definition.type,\n label: definition.label,\n tier: definition.tier,\n category: definition.category,\n title,\n body,\n link: String(options.url ?? '').trim() || renderLink(definition.link, context),\n notificationType: definition.notificationType ?? OPERATOR_ALERT_DEFAULT_NOTIFICATION_TYPE,\n ...(options.orgId ? { orgId: options.orgId } : {}),\n ...(options.hostId ? { hostId: options.hostId } : {}),\n }\n}\n\n// ── The entry point ─────────────────────────────────────────────────────────\n\n/** Whether an email to the operator can go at all on this install. */\nasync function operatorEmailReachable(): Promise<boolean> {\n if (!isEmailConfigured()) return false\n const { resolveStaffAlertRecipients } = await import('./staff-alert-email')\n return (await resolveStaffAlertRecipients()).length > 0\n}\n\n/** See the module comment. Never throws. */\nexport async function raiseOperatorAlert(\n type: string | OperatorAlertDefinition,\n options: RaiseOperatorAlertOptions = {},\n): Promise<OperatorAlertResult> {\n const typeId = typeof type === 'string' ? type : type.type\n try {\n const definition = resolveOperatorAlertDefinition(type)\n const dedupeKey = String(options.dedupeKey ?? '').trim()\n let repeats = 0\n if (dedupeKey) {\n const claim = await claimOperatorAlert(definition, dedupeKey)\n if (claim.fire === false) return { outcome: claim.reason, type: typeId }\n repeats = claim.repeats\n }\n const alert = renderOperatorAlert(definition, options)\n if (repeats > 0) {\n alert.body = [alert.body, `Raised ${repeats} more time${repeats === 1 ? '' : 's'} since it was last told.`]\n .filter(Boolean)\n .join(' ')\n }\n const settings = await readOperatorAlertSettings()\n const { enabled, delivery } = effectiveOperatorAlertSetting(definition, settings)\n const emailNow = enabled && delivery === 'immediate' && (await operatorEmailReachable())\n\n const { notifyStaffConsole } = await import('./notifications')\n await notifyStaffConsole(\n {\n type: alert.notificationType,\n title: alert.title,\n ...(alert.body ? { body: alert.body } : {}),\n ...(alert.link && alert.link.startsWith('/') ? { link: alert.link } : {}),\n ...(alert.orgId ? { orgId: alert.orgId } : {}),\n ...(alert.hostId ? { hostId: alert.hostId } : {}),\n },\n // The operator is emailed; a staff member who also switched email on\n // for this category is not mailed the same alert a second time.\n emailNow ? { skipEmail: true } : {},\n )\n if (!enabled) return { outcome: 'console-only', type: typeId }\n if (delivery === 'digest') {\n await queueOperatorAlertForDigest(alert)\n return { outcome: 'queued', type: typeId }\n }\n return await deliverOperatorAlert(alert, { emailNow })\n } catch (error) {\n console.error(`[operator-alerts] ${typeId} could not be raised`, error)\n return { outcome: 'undelivered', type: typeId }\n }\n}\n\n/** Emails and posts one alert now. */\nasync function deliverOperatorAlert(\n alert: RenderedOperatorAlert,\n options: { emailNow: boolean },\n): Promise<OperatorAlertResult> {\n const { sendOperatorAlertEmail } = await import('./staff-alert-email')\n const [email, webhook] = await Promise.all([\n options.emailNow\n ? sendOperatorAlertEmail({\n title: alert.title,\n body: alert.body,\n url: operatorAlertAbsoluteLink(alert.link),\n context: `operator-alert ${alert.type}`,\n })\n : Promise.resolve<SendEmailResult>({ sent: false, reason: 'unconfigured' }),\n postOperatorAlertWebhook([alert]),\n ])\n return {\n outcome: email.sent || webhook.posted ? 'delivered' : 'undelivered',\n type: alert.type,\n email,\n webhook,\n }\n}\n\n// ── The digest ──────────────────────────────────────────────────────────────\n\nasync function queueOperatorAlertForDigest(alert: RenderedOperatorAlert): Promise<void> {\n try {\n await firestore()\n .collection(OPERATOR_ALERT_DIGEST_COLLECTION)\n .doc(createResourceUid())\n .set({ ...alert, atMs: Date.now() })\n } catch (error) {\n // The queue is the only record the digest will have; with it gone, the\n // alert goes now rather than never.\n console.error(`[operator-alerts] digest queue unavailable; sending ${alert.type} now`, error)\n await deliverOperatorAlert(alert, { emailNow: await operatorEmailReachable() })\n }\n}\n\n/** At most this many alerts are listed in one digest; the rest are counted. */\nexport const OPERATOR_DIGEST_MAX_LISTED = 50\nconst DIGEST_READ_LIMIT = 500\n\nexport interface OperatorDigestResult {\n /** Whether a digest went out (or, on a dry run, would). */\n sent: boolean\n reason?: 'not-due' | 'already-sent' | 'empty' | 'dry-run' | 'unavailable'\n count: number\n email?: SendEmailResult\n webhook?: OperatorAlertWebhookResult\n}\n\nfunction dayKey(now: number): string {\n return new Date(now).toISOString().slice(0, 10)\n}\n\n/** The state document that claims one UTC day's digest. */\nexport function operatorDigestMarkerId(now: number): string {\n return `digest-${dayKey(now)}`\n}\n\n/** The digest's body: one paragraph per alert, newest last. */\nexport function composeOperatorDigestBody(\n alerts: readonly RenderedOperatorAlert[],\n total = alerts.length,\n): string {\n const listed = alerts.slice(0, OPERATOR_DIGEST_MAX_LISTED).map((alert) => {\n const link = operatorAlertAbsoluteLink(alert.link)\n return [`• ${alert.title}`, alert.body, link].filter(Boolean).join('\\n')\n })\n const rest = total - listed.length\n if (rest > 0) listed.push(`…and ${rest} more in the staff console.`)\n return listed.join('\\n\\n')\n}\n\n/**\n * Sends the day's digest once the configured hour has passed: every queued\n * alert in one `operator-alert-digest` email and one webhook post, then\n * clears what it sent. Once per UTC day, claimed by creating\n * `operatorAlertState/digest-{day}`, so two ticks never both send it.\n */\nexport async function sendOperatorAlertDigest(\n options: { now?: number; dryRun?: boolean; force?: boolean } = {},\n): Promise<OperatorDigestResult> {\n const now = options.now ?? Date.now()\n try {\n const settings = await readOperatorAlertSettings()\n const hour = settings.digestHourUtc ?? OPERATOR_ALERT_DIGEST_DEFAULT_HOUR_UTC\n if (!options.force && new Date(now).getUTCHours() < hour) {\n return { sent: false, reason: 'not-due', count: 0 }\n }\n const collection = firestore().collection(OPERATOR_ALERT_DIGEST_COLLECTION)\n const marker = firestore()\n .collection(OPERATOR_ALERT_STATE_COLLECTION)\n .doc(operatorDigestMarkerId(now))\n if (!options.force && (await marker.get()).exists) {\n return { sent: false, reason: 'already-sent', count: 0 }\n }\n const snapshot = await collection.orderBy('atMs', 'asc').limit(DIGEST_READ_LIMIT).get()\n const queued = snapshot.docs\n if (!queued.length) return { sent: false, reason: 'empty', count: 0 }\n if (options.dryRun) return { sent: false, reason: 'dry-run', count: queued.length }\n if (!options.force) {\n try {\n await marker.create({ atMs: now, count: queued.length })\n } catch {\n return { sent: false, reason: 'already-sent', count: 0 }\n }\n }\n const alerts = queued.map((doc: any) => doc.data() as RenderedOperatorAlert)\n const { sendOperatorAlertDigestEmail } = await import('./staff-alert-email')\n const [email, webhook] = await Promise.all([\n sendOperatorAlertDigestEmail({\n date: dayKey(now),\n count: alerts.length,\n body: composeOperatorDigestBody(alerts),\n url: operatorAlertAbsoluteLink('/admin/operator-alerts'),\n }),\n postOperatorAlertWebhook(alerts, { digest: true, now }),\n ])\n if (email.sent || webhook.posted) {\n const batch = firestore().batch()\n for (const doc of queued) batch.delete(doc.ref)\n await batch.commit()\n } else if (!options.force) {\n // Nothing reached anybody: release the day so the next tick retries.\n await marker.delete().catch(() => undefined)\n }\n return { sent: email.sent || webhook.posted, count: alerts.length, email, webhook }\n } catch (error) {\n console.error('[operator-alerts] digest failed', error)\n return { sent: false, reason: 'unavailable', count: 0 }\n }\n}\n\n\n// ── The staff page ──────────────────────────────────────────────────────────\n\n/** One registry entry as Staff → Operator alerts lists it. */\nexport interface OperatorAlertCatalogRow {\n type: string\n label: string\n description: string\n tier: OperatorAlertDefinition['tier']\n category: OperatorAlertDefinition['category']\n pluginId: string | null\n defaults: { enabled: boolean; delivery: OperatorAlertDefinition['delivery'] }\n effective: { enabled: boolean; delivery: OperatorAlertDefinition['delivery'] }\n /** Whether staff have answered for this type, rather than the default. */\n overridden: boolean\n}\n\n/** Every registered type with its default and its effective setting. */\nexport function operatorAlertCatalogRows(\n definitions: readonly OperatorAlertDefinition[],\n settings: OperatorAlertSettingsDoc,\n): OperatorAlertCatalogRow[] {\n return definitions.map((definition) => ({\n type: definition.type,\n label: definition.label,\n description: definition.description,\n tier: definition.tier,\n category: definition.category,\n pluginId: definition.pluginId ?? null,\n defaults: { enabled: definition.defaultEnabled, delivery: definition.delivery },\n effective: effectiveOperatorAlertSetting(definition, settings),\n overridden: Boolean(settings.types?.[definition.type]),\n }))\n}\n\n/**\n * Sends one `[Test]` alert of a type straight to the operator's inbox and\n * webhook, past dedupe and staff's switches, so an operator can prove the\n * channels reach them. Writes no console notification.\n */\nexport async function sendOperatorAlertTest(\n definition: OperatorAlertDefinition,\n): Promise<{ email: SendEmailResult; webhook: OperatorAlertWebhookResult }> {\n const sample: Record<string, string> = {}\n for (const [, name] of `${definition.title} ${definition.body} ${definition.link ?? ''}`.matchAll(\n /\\{\\{\\s*([\\w.]+)\\s*\\}\\}/g,\n )) {\n sample[name as string] = `sample ${name}`\n }\n const alert = renderOperatorAlert(definition, { context: sample })\n alert.title = `[Test] ${alert.title}`\n alert.body = [\n alert.body,\n 'This is a test of the operator alert channels; nothing happened.',\n ].join(' ')\n const { sendOperatorAlertEmail } = await import('./staff-alert-email')\n const [email, webhook] = await Promise.all([\n isEmailConfigured()\n ? sendOperatorAlertEmail({\n title: alert.title,\n body: alert.body,\n url: operatorAlertAbsoluteLink(alert.link) || operatorAlertAbsoluteLink('/admin/operator-alerts'),\n context: `operator-alert-test ${definition.type}`,\n })\n : Promise.resolve<SendEmailResult>({ sent: false, reason: 'unconfigured' }),\n postOperatorAlertWebhook([alert]),\n ])\n return { email, webhook }\n}\n\n/** Where operator mail goes, named but never listed, for the staff page. */\nexport async function describeOperatorAlertRecipients(): Promise<{\n source: 'STAFF_ALERT_EMAIL' | 'NEXT_PUBLIC_OPERATOR_SUPPORT_EMAIL' | 'staff-accounts' | 'none'\n count: number\n}> {\n const { resolveStaffAlertRecipients } = await import('./staff-alert-email')\n const recipients = await resolveStaffAlertRecipients()\n if (!recipients.length) return { source: 'none', count: 0 }\n const configured = String(process.env['STAFF_ALERT_EMAIL'] ?? '').trim().toLowerCase()\n if (configured.includes('@') && recipients[0] === configured) {\n return { source: 'STAFF_ALERT_EMAIL', count: 1 }\n }\n const support = String(process.env['NEXT_PUBLIC_OPERATOR_SUPPORT_EMAIL'] ?? '').trim().toLowerCase()\n if (support.includes('@') && recipients[0] === support) {\n return { source: 'NEXT_PUBLIC_OPERATOR_SUPPORT_EMAIL', count: 1 }\n }\n return { source: 'staff-accounts', count: recipients.length }\n}\n"],"names":["createHash","createHmac","effectiveOperatorAlertSetting","OPERATOR_ALERT_DEFAULT_NOTIFICATION_TYPE","OPERATOR_ALERT_TIER_LABELS","renderOperatorAlertTemplate","getOperatorAlert","registerOperatorAlerts","createResourceUid","isEmailConfigured","firebaseAdmin","OPERATOR_ALERT_SETTINGS_COLLECTION","OPERATOR_ALERT_SETTINGS_DOC","OPERATOR_ALERT_STATE_COLLECTION","OPERATOR_ALERT_DIGEST_COLLECTION","OPERATOR_ALERT_DIGEST_DEFAULT_HOUR_UTC","firestore","app","SETTINGS_TTL_MS","settingsCache","invalidateOperatorAlertSettingsCache","undefined","normalizeOperatorAlertSettings","raw","source","types","rawTypes","type","value","Object","entries","entry","setting","enabled","delivery","keys","length","hour","Number","digestHourUtc","isInteger","updatedAtMs","updatedByEmail","readOperatorAlertSettings","now","Date","at","doc","snapshot","collection","get","exists","data","error","console","firedLocally","Map","FIRED_LOCALLY_MAX","stateDocId","dedupeKey","update","digest","slice","claimOperatorAlert","definition","windowMs","Math","max","dedupeWindowMinutes","minOccurrences","floor","fire","repeats","localKey","quietUntil","reason","ref","claim","runTransaction","transaction","state","lastFiredAtMs","base","set","suppressed","merge","windowStartMs","inWindow","occurrences","fired","size","clear","WEBHOOK_TIMEOUT_MS","operatorAlertWebhookUrl","process","String","env","trim","test","operatorAlertWebhookPayload","alerts","options","shaped","origin","consoleOrigin","lines","map","alert","link","operatorAlertAbsoluteLink","tier","title","body","filter","Boolean","join","text","occurredAt","toISOString","category","url","install","postOperatorAlertWebhook","posted","JSON","stringify","headers","secret","timestamp","response","fetch","method","redirect","signal","AbortSignal","timeout","ok","status","replace","startsWith","renderLink","template","context","missing","matchAll","some","name","unregisteredDefinition","label","description","defaultEnabled","resolveOperatorAlertDefinition","known","pluginId","renderOperatorAlert","subject","notificationType","orgId","hostId","operatorEmailReachable","resolveStaffAlertRecipients","raiseOperatorAlert","typeId","outcome","settings","emailNow","notifyStaffConsole","skipEmail","queueOperatorAlertForDigest","deliverOperatorAlert","sendOperatorAlertEmail","email","webhook","Promise","all","resolve","sent","atMs","OPERATOR_DIGEST_MAX_LISTED","DIGEST_READ_LIMIT","dayKey","operatorDigestMarkerId","composeOperatorDigestBody","total","listed","rest","push","sendOperatorAlertDigest","force","getUTCHours","count","marker","orderBy","limit","queued","docs","dryRun","create","sendOperatorAlertDigestEmail","date","batch","delete","commit","catch","operatorAlertCatalogRows","definitions","defaults","effective","overridden","sendOperatorAlertTest","sample","describeOperatorAlertRecipients","recipients","configured","toLowerCase","includes","support"],"mappings":"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"}
@@ -0,0 +1,59 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ export declare const OPERATOR_HEALTH_COLLECTION = "operatorHealthState";
18
+ export type OperatorHealthStatus = 'ok' | 'degraded';
19
+ export interface OperatorHealthStateDoc {
20
+ checkId: string;
21
+ label: string;
22
+ status: OperatorHealthStatus;
23
+ /** When the check entered `status`. */
24
+ sinceMs: number;
25
+ detail: string;
26
+ updatedAtMs: number;
27
+ }
28
+ export type OperatorHealthTransition = 'degraded' | 'recovered' | null;
29
+ /** Test seam. */
30
+ export declare function resetOperatorHealthCacheForTests(): void;
31
+ /** "3 h 5 min", for a recovery's duration. */
32
+ export declare function describeHealthDuration(ms: number): string;
33
+ export declare function recordHealthState(checkId: string, status: OperatorHealthStatus, detail: string, options?: {
34
+ label?: string;
35
+ now?: number;
36
+ }): Promise<OperatorHealthTransition>;
37
+ /** Every recorded check, for the staff page and the sweep's report. */
38
+ export declare function listHealthStates(): Promise<OperatorHealthStateDoc[]>;
39
+ /**
40
+ * One health endpoint's body as a state: `status` is the endpoint's own
41
+ * verdict, and the detail names each failing check and its code.
42
+ */
43
+ export declare function healthStateOfBody(body: Record<string, unknown> | null, httpStatus: number | null): {
44
+ status: OperatorHealthStatus;
45
+ detail: string;
46
+ };
47
+ /**
48
+ * What each health endpoint is called on the board and in an alert, keyed by
49
+ * the `service` its body names. An unlisted service is called by its name.
50
+ */
51
+ export declare const HEALTH_SERVICE_LABELS: Readonly<Record<string, string>>;
52
+ /**
53
+ * Records a health endpoint's own response and hands it back unchanged
54
+ * (AGL-3377): `return recordHealthResponse(Response.json(healthBody(…)))`.
55
+ * The check id is the body's `service`, so the endpoint, the scheduled sweep
56
+ * and any other poller all write the same state. Costs nothing while the
57
+ * verdict is the one this instance last wrote. Never throws.
58
+ */
59
+ export declare function recordHealthResponse(response: Response): Promise<Response>;
@@ -0,0 +1,200 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ /**
17
+ * HEALTH TRANSITIONS (AGL-3377): the health checks, remembered.
18
+ *
19
+ * Every `/api/health/*` endpoint answers 200 or 503 to whoever asks and
20
+ * forgets the answer. An install with an external uptime monitor hears the
21
+ * 503; an install without one — most self-hosted ones — hears nothing, and
22
+ * a dead cron or a stale backup is found on the day it is needed.
23
+ *
24
+ * `recordHealthState` keeps the last verdict per check in
25
+ * `operatorHealthState/{checkId}` and raises an operator alert on the EDGE:
26
+ * `system.healthDegraded` when a healthy (or never-seen) check goes
27
+ * degraded, `system.healthRecovered` when it comes back. Once each, however
28
+ * many instances and pollers see the same state, because the edge is decided
29
+ * inside a transaction on the check's document.
30
+ *
31
+ * Cheap on the hot path: each instance remembers the last state it wrote, so
32
+ * a poll that finds nothing changed costs no Firestore round trip at all.
33
+ * The Admin SDK is loaded on the first write rather than at import, so every
34
+ * health route can import this without initializing anything. Never throws.
35
+ */ async function firestore() {
36
+ const { default: firebaseAdmin } = await import("./firebase-admin.js");
37
+ return firebaseAdmin.app().firestore();
38
+ }
39
+ export const OPERATOR_HEALTH_COLLECTION = 'operatorHealthState';
40
+ /** The last status this instance wrote per check. */ const lastWritten = new Map();
41
+ /** Test seam. */ export function resetOperatorHealthCacheForTests() {
42
+ lastWritten.clear();
43
+ }
44
+ /** "3 h 5 min", for a recovery's duration. */ export function describeHealthDuration(ms) {
45
+ const minutes = Math.max(1, Math.round(ms / 60000));
46
+ if (minutes < 60) return `${minutes} min`;
47
+ const hours = Math.floor(minutes / 60);
48
+ const rest = minutes % 60;
49
+ if (hours < 48) return rest ? `${hours} h ${rest} min` : `${hours} h`;
50
+ return `${Math.round(hours / 24)} days`;
51
+ }
52
+ export async function recordHealthState(checkId, status, detail, options = {}) {
53
+ var _options_now, _options_label;
54
+ const id = String(checkId != null ? checkId : '').trim();
55
+ if (!id) return null;
56
+ if (lastWritten.get(id) === status) return null;
57
+ const now = (_options_now = options.now) != null ? _options_now : Date.now();
58
+ const label = String((_options_label = options.label) != null ? _options_label : '').trim() || id;
59
+ const summary = String(detail != null ? detail : '').trim().slice(0, 1000);
60
+ try {
61
+ const db = await firestore();
62
+ const ref = db.collection(OPERATOR_HEALTH_COLLECTION).doc(id);
63
+ const edge = await db.runTransaction(async (transaction)=>{
64
+ var _ref;
65
+ const snapshot = await transaction.get(ref);
66
+ const prior = snapshot.exists ? snapshot.data() : null;
67
+ if ((prior == null ? void 0 : prior.status) === status) {
68
+ var _prior_sinceMs;
69
+ transaction.update(ref, {
70
+ detail: summary,
71
+ updatedAtMs: now
72
+ });
73
+ return {
74
+ transition: null,
75
+ priorSinceMs: (_prior_sinceMs = prior.sinceMs) != null ? _prior_sinceMs : now
76
+ };
77
+ }
78
+ const next = {
79
+ checkId: id,
80
+ label,
81
+ status,
82
+ sinceMs: now,
83
+ detail: summary,
84
+ updatedAtMs: now
85
+ };
86
+ transaction.set(ref, next);
87
+ // A check first seen healthy is the ordinary case and says nothing; a
88
+ // check first seen degraded is an install that was broken before
89
+ // anybody watched, which is exactly the one to say out loud.
90
+ const transition = status === 'degraded' ? 'degraded' : prior ? 'recovered' : null;
91
+ return {
92
+ transition,
93
+ priorSinceMs: (_ref = prior == null ? void 0 : prior.sinceMs) != null ? _ref : now
94
+ };
95
+ });
96
+ lastWritten.set(id, status);
97
+ if (edge.transition) {
98
+ const { raiseOperatorAlert } = await import("./operator-alerts.js");
99
+ if (edge.transition === 'degraded') {
100
+ await raiseOperatorAlert('system.healthDegraded', {
101
+ dedupeKey: id,
102
+ context: {
103
+ check: label,
104
+ detail: summary
105
+ }
106
+ });
107
+ } else {
108
+ await raiseOperatorAlert('system.healthRecovered', {
109
+ dedupeKey: id,
110
+ context: {
111
+ check: label,
112
+ duration: describeHealthDuration(now - edge.priorSinceMs)
113
+ }
114
+ });
115
+ }
116
+ }
117
+ return edge.transition;
118
+ } catch (error) {
119
+ // Once per verdict per instance: a store that is down must not turn
120
+ // every poll of a public endpoint into another log line. The next
121
+ // change of verdict tries again.
122
+ lastWritten.set(id, status);
123
+ console.error(`[operator-health] ${id} could not be recorded`, error);
124
+ return null;
125
+ }
126
+ }
127
+ /** Every recorded check, for the staff page and the sweep's report. */ export async function listHealthStates() {
128
+ try {
129
+ const snapshot = await (await firestore()).collection(OPERATOR_HEALTH_COLLECTION).get();
130
+ return snapshot.docs.map((doc)=>doc.data());
131
+ } catch (error) {
132
+ console.error('[operator-health] states unreadable', error);
133
+ return [];
134
+ }
135
+ }
136
+ /**
137
+ * One health endpoint's body as a state: `status` is the endpoint's own
138
+ * verdict, and the detail names each failing check and its code.
139
+ */ export function healthStateOfBody(body, httpStatus) {
140
+ var _body_checks;
141
+ if (!body) {
142
+ return {
143
+ status: 'degraded',
144
+ detail: httpStatus === null ? 'The endpoint did not answer.' : `The endpoint answered ${httpStatus} with no readable body.`
145
+ };
146
+ }
147
+ const checks = (_body_checks = body['checks']) != null ? _body_checks : {};
148
+ const failing = Object.entries(checks).filter(([, check])=>check && check['ok'] !== true).map(([name, check])=>typeof (check == null ? void 0 : check['code']) === 'string' ? `${name} (${check['code']})` : name);
149
+ const degraded = body['status'] === 'degraded' || body['status'] !== 'ok' && (httpStatus === 503 || failing.length > 0);
150
+ return {
151
+ status: degraded ? 'degraded' : 'ok',
152
+ detail: degraded ? failing.length ? `Failing: ${failing.join(', ')}.` : `The endpoint answered ${httpStatus != null ? httpStatus : 'nothing'}.` : 'All checks pass.'
153
+ };
154
+ }
155
+ /**
156
+ * What each health endpoint is called on the board and in an alert, keyed by
157
+ * the `service` its body names. An unlisted service is called by its name.
158
+ */ export const HEALTH_SERVICE_LABELS = {
159
+ console: 'Console serving',
160
+ 'console-auth-doors': 'Ways in',
161
+ 'console-backups': 'Backups & exports',
162
+ 'console-billing': 'Billing webhook',
163
+ 'console-crons': 'Scheduled jobs',
164
+ 'console-error-beacon': 'Console error beacon',
165
+ 'console-journeys': 'Console journeys',
166
+ 'console-rate-limits': 'Rate limiters',
167
+ 'console-server-errors': 'Server errors',
168
+ 'console-signup-volume': 'Signup volume',
169
+ 'console-email': 'Email delivery',
170
+ tenant: 'Site serving',
171
+ 'tenant-error-beacon': 'Site error beacon',
172
+ 'tenant-funnel': 'Site capture funnel',
173
+ 'tenant-render-site': 'Site rendering',
174
+ 'tenant-render-marketing': 'Marketing site rendering'
175
+ };
176
+ /**
177
+ * Records a health endpoint's own response and hands it back unchanged
178
+ * (AGL-3377): `return recordHealthResponse(Response.json(healthBody(…)))`.
179
+ * The check id is the body's `service`, so the endpoint, the scheduled sweep
180
+ * and any other poller all write the same state. Costs nothing while the
181
+ * verdict is the one this instance last wrote. Never throws.
182
+ */ export async function recordHealthResponse(response) {
183
+ try {
184
+ var _ref;
185
+ const body = await response.clone().json();
186
+ const service = String((_ref = body == null ? void 0 : body['service']) != null ? _ref : '').trim();
187
+ if (service) {
188
+ var _HEALTH_SERVICE_LABELS_service;
189
+ const { status, detail } = healthStateOfBody(body, response.status);
190
+ await recordHealthState(service, status, detail, {
191
+ label: (_HEALTH_SERVICE_LABELS_service = HEALTH_SERVICE_LABELS[service]) != null ? _HEALTH_SERVICE_LABELS_service : service
192
+ });
193
+ }
194
+ } catch (error) {
195
+ console.error('[operator-health] a health response could not be recorded', error);
196
+ }
197
+ return response;
198
+ }
199
+
200
+ //# sourceMappingURL=operator-health.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/operator-health.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * HEALTH TRANSITIONS (AGL-3377): the health checks, remembered.\n *\n * Every `/api/health/*` endpoint answers 200 or 503 to whoever asks and\n * forgets the answer. An install with an external uptime monitor hears the\n * 503; an install without one — most self-hosted ones — hears nothing, and\n * a dead cron or a stale backup is found on the day it is needed.\n *\n * `recordHealthState` keeps the last verdict per check in\n * `operatorHealthState/{checkId}` and raises an operator alert on the EDGE:\n * `system.healthDegraded` when a healthy (or never-seen) check goes\n * degraded, `system.healthRecovered` when it comes back. Once each, however\n * many instances and pollers see the same state, because the edge is decided\n * inside a transaction on the check's document.\n *\n * Cheap on the hot path: each instance remembers the last state it wrote, so\n * a poll that finds nothing changed costs no Firestore round trip at all.\n * The Admin SDK is loaded on the first write rather than at import, so every\n * health route can import this without initializing anything. Never throws.\n */\n\nasync function firestore(): Promise<any> {\n const { default: firebaseAdmin } = await import('./firebase-admin')\n return firebaseAdmin.app().firestore()\n}\n\nexport const OPERATOR_HEALTH_COLLECTION = 'operatorHealthState'\n\nexport type OperatorHealthStatus = 'ok' | 'degraded'\n\nexport interface OperatorHealthStateDoc {\n checkId: string\n label: string\n status: OperatorHealthStatus\n /** When the check entered `status`. */\n sinceMs: number\n detail: string\n updatedAtMs: number\n}\n\nexport type OperatorHealthTransition = 'degraded' | 'recovered' | null\n\n/** The last status this instance wrote per check. */\nconst lastWritten = new Map<string, OperatorHealthStatus>()\n\n/** Test seam. */\nexport function resetOperatorHealthCacheForTests(): void {\n lastWritten.clear()\n}\n\n/** \"3 h 5 min\", for a recovery's duration. */\nexport function describeHealthDuration(ms: number): string {\n const minutes = Math.max(1, Math.round(ms / 60_000))\n if (minutes < 60) return `${minutes} min`\n const hours = Math.floor(minutes / 60)\n const rest = minutes % 60\n if (hours < 48) return rest ? `${hours} h ${rest} min` : `${hours} h`\n return `${Math.round(hours / 24)} days`\n}\n\nexport async function recordHealthState(\n checkId: string,\n status: OperatorHealthStatus,\n detail: string,\n options: { label?: string; now?: number } = {},\n): Promise<OperatorHealthTransition> {\n const id = String(checkId ?? '').trim()\n if (!id) return null\n if (lastWritten.get(id) === status) return null\n const now = options.now ?? Date.now()\n const label = String(options.label ?? '').trim() || id\n const summary = String(detail ?? '').trim().slice(0, 1_000)\n try {\n const db = await firestore()\n const ref = db.collection(OPERATOR_HEALTH_COLLECTION).doc(id)\n const edge = await db.runTransaction(async (transaction: any) => {\n const snapshot = await transaction.get(ref)\n const prior = (snapshot.exists ? snapshot.data() : null) as\n | Partial<OperatorHealthStateDoc>\n | null\n if (prior?.status === status) {\n transaction.update(ref, { detail: summary, updatedAtMs: now })\n return { transition: null, priorSinceMs: prior.sinceMs ?? now }\n }\n const next: OperatorHealthStateDoc = {\n checkId: id,\n label,\n status,\n sinceMs: now,\n detail: summary,\n updatedAtMs: now,\n }\n transaction.set(ref, next)\n // A check first seen healthy is the ordinary case and says nothing; a\n // check first seen degraded is an install that was broken before\n // anybody watched, which is exactly the one to say out loud.\n const transition: OperatorHealthTransition =\n status === 'degraded' ? 'degraded' : prior ? 'recovered' : null\n return { transition, priorSinceMs: prior?.sinceMs ?? now }\n })\n lastWritten.set(id, status)\n if (edge.transition) {\n const { raiseOperatorAlert } = await import('./operator-alerts')\n if (edge.transition === 'degraded') {\n await raiseOperatorAlert('system.healthDegraded', {\n dedupeKey: id,\n context: { check: label, detail: summary },\n })\n } else {\n await raiseOperatorAlert('system.healthRecovered', {\n dedupeKey: id,\n context: {\n check: label,\n duration: describeHealthDuration(now - edge.priorSinceMs),\n },\n })\n }\n }\n return edge.transition\n } catch (error) {\n // Once per verdict per instance: a store that is down must not turn\n // every poll of a public endpoint into another log line. The next\n // change of verdict tries again.\n lastWritten.set(id, status)\n console.error(`[operator-health] ${id} could not be recorded`, error)\n return null\n }\n}\n\n/** Every recorded check, for the staff page and the sweep's report. */\nexport async function listHealthStates(): Promise<OperatorHealthStateDoc[]> {\n try {\n const snapshot = await (await firestore()).collection(OPERATOR_HEALTH_COLLECTION).get()\n return snapshot.docs.map((doc: any) => doc.data() as OperatorHealthStateDoc)\n } catch (error) {\n console.error('[operator-health] states unreadable', error)\n return []\n }\n}\n\n/**\n * One health endpoint's body as a state: `status` is the endpoint's own\n * verdict, and the detail names each failing check and its code.\n */\nexport function healthStateOfBody(\n body: Record<string, unknown> | null,\n httpStatus: number | null,\n): { status: OperatorHealthStatus; detail: string } {\n if (!body) {\n return {\n status: 'degraded',\n detail:\n httpStatus === null\n ? 'The endpoint did not answer.'\n : `The endpoint answered ${httpStatus} with no readable body.`,\n }\n }\n const checks = (body['checks'] ?? {}) as Record<string, Record<string, unknown> | undefined>\n const failing = Object.entries(checks)\n .filter(([, check]) => check && check['ok'] !== true)\n .map(([name, check]) =>\n typeof check?.['code'] === 'string' ? `${name} (${check['code']})` : name,\n )\n const degraded =\n body['status'] === 'degraded' ||\n (body['status'] !== 'ok' && (httpStatus === 503 || failing.length > 0))\n return {\n status: degraded ? 'degraded' : 'ok',\n detail: degraded\n ? failing.length\n ? `Failing: ${failing.join(', ')}.`\n : `The endpoint answered ${httpStatus ?? 'nothing'}.`\n : 'All checks pass.',\n }\n}\n\n/**\n * What each health endpoint is called on the board and in an alert, keyed by\n * the `service` its body names. An unlisted service is called by its name.\n */\nexport const HEALTH_SERVICE_LABELS: Readonly<Record<string, string>> = {\n console: 'Console serving',\n 'console-auth-doors': 'Ways in',\n 'console-backups': 'Backups & exports',\n 'console-billing': 'Billing webhook',\n 'console-crons': 'Scheduled jobs',\n 'console-error-beacon': 'Console error beacon',\n 'console-journeys': 'Console journeys',\n 'console-rate-limits': 'Rate limiters',\n 'console-server-errors': 'Server errors',\n 'console-signup-volume': 'Signup volume',\n 'console-email': 'Email delivery',\n tenant: 'Site serving',\n 'tenant-error-beacon': 'Site error beacon',\n 'tenant-funnel': 'Site capture funnel',\n 'tenant-render-site': 'Site rendering',\n 'tenant-render-marketing': 'Marketing site rendering',\n}\n\n/**\n * Records a health endpoint's own response and hands it back unchanged\n * (AGL-3377): `return recordHealthResponse(Response.json(healthBody(…)))`.\n * The check id is the body's `service`, so the endpoint, the scheduled sweep\n * and any other poller all write the same state. Costs nothing while the\n * verdict is the one this instance last wrote. Never throws.\n */\nexport async function recordHealthResponse(response: Response): Promise<Response> {\n try {\n const body = (await response.clone().json()) as Record<string, unknown> | null\n const service = String(body?.['service'] ?? '').trim()\n if (service) {\n const { status, detail } = healthStateOfBody(body, response.status)\n await recordHealthState(service, status, detail, {\n label: HEALTH_SERVICE_LABELS[service] ?? service,\n })\n }\n } catch (error) {\n console.error('[operator-health] a health response could not be recorded', error)\n }\n return response\n}\n"],"names":["firestore","default","firebaseAdmin","app","OPERATOR_HEALTH_COLLECTION","lastWritten","Map","resetOperatorHealthCacheForTests","clear","describeHealthDuration","ms","minutes","Math","max","round","hours","floor","rest","recordHealthState","checkId","status","detail","options","id","String","trim","get","now","Date","label","summary","slice","db","ref","collection","doc","edge","runTransaction","transaction","snapshot","prior","exists","data","update","updatedAtMs","transition","priorSinceMs","sinceMs","next","set","raiseOperatorAlert","dedupeKey","context","check","duration","error","console","listHealthStates","docs","map","healthStateOfBody","body","httpStatus","checks","failing","Object","entries","filter","name","degraded","length","join","HEALTH_SERVICE_LABELS","tenant","recordHealthResponse","response","clone","json","service"],"mappings":"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"}
@@ -41,11 +41,10 @@ export declare function sendStaffAlertEmail(input: {
41
41
  context: string;
42
42
  }): Promise<SendEmailResult>;
43
43
  /**
44
- * A fraud or risk alert for the operator (AGL-3375), as the `operator-alert`
45
- * system email: the alert, a button to the item it is about, in the
46
- * operator's own brand. Sent by `notifyStaff` for every
47
- * `OPERATOR_ALERT_NOTIFICATION_TYPES` notification, beside the console one.
48
- * Never throws.
44
+ * One operator alert (AGL-3375, AGL-3377), as the `operator-alert` system
45
+ * email: the alert, a button to the item it is about, in the operator's own
46
+ * brand. Sent by `raiseOperatorAlert` for every alert delivered immediately;
47
+ * nothing else should call it. Never throws.
49
48
  */
50
49
  export declare function sendOperatorAlertEmail(input: {
51
50
  title: string;
@@ -54,3 +53,17 @@ export declare function sendOperatorAlertEmail(input: {
54
53
  url?: string | null;
55
54
  context: string;
56
55
  }): Promise<SendEmailResult>;
56
+ /**
57
+ * The day's batched operator alerts (AGL-3377), as the
58
+ * `operator-alert-digest` system email. Sent by `sendOperatorAlertDigest`.
59
+ * Never throws.
60
+ */
61
+ export declare function sendOperatorAlertDigestEmail(input: {
62
+ /** The UTC day, `YYYY-MM-DD`. */
63
+ date: string;
64
+ count: number;
65
+ /** The alerts, composed as one block of text. */
66
+ body: string;
67
+ /** Absolute link to the staff console, when there is one. */
68
+ url?: string | null;
69
+ }): Promise<SendEmailResult>;
@@ -141,11 +141,10 @@ export async function sendStaffAlertEmail(input) {
141
141
  }
142
142
  }
143
143
  /**
144
- * A fraud or risk alert for the operator (AGL-3375), as the `operator-alert`
145
- * system email: the alert, a button to the item it is about, in the
146
- * operator's own brand. Sent by `notifyStaff` for every
147
- * `OPERATOR_ALERT_NOTIFICATION_TYPES` notification, beside the console one.
148
- * Never throws.
144
+ * One operator alert (AGL-3375, AGL-3377), as the `operator-alert` system
145
+ * email: the alert, a button to the item it is about, in the operator's own
146
+ * brand. Sent by `raiseOperatorAlert` for every alert delivered immediately;
147
+ * nothing else should call it. Never throws.
149
148
  */ export async function sendOperatorAlertEmail(input) {
150
149
  try {
151
150
  var _input_body, _input_url;
@@ -173,5 +172,37 @@ export async function sendStaffAlertEmail(input) {
173
172
  };
174
173
  }
175
174
  }
175
+ /**
176
+ * The day's batched operator alerts (AGL-3377), as the
177
+ * `operator-alert-digest` system email. Sent by `sendOperatorAlertDigest`.
178
+ * Never throws.
179
+ */ export async function sendOperatorAlertDigestEmail(input) {
180
+ try {
181
+ var _input_url;
182
+ const { renderSystemEmailContent, systemEmailBrand } = await import("./render-system-email.js");
183
+ const subject = `${input.count} operator alert${input.count === 1 ? '' : 's'} on ${input.date}`;
184
+ const url = String((_input_url = input.url) != null ? _input_url : '').trim();
185
+ const content = await renderSystemEmailContent('operator-alert-digest', {
186
+ 'digest.date': input.date,
187
+ 'digest.count': String(input.count),
188
+ 'digest.body': input.body,
189
+ 'digest.url': url
190
+ }, systemEmailBrand(null), {
191
+ subject,
192
+ text: [
193
+ subject,
194
+ input.body,
195
+ url
196
+ ].filter(Boolean).join('\n\n')
197
+ });
198
+ return await sendToOperators(content, 'operator-alert-digest');
199
+ } catch (error) {
200
+ console.error('[staff-alert-email] operator digest failed', error);
201
+ return {
202
+ sent: false,
203
+ reason: 'network'
204
+ };
205
+ }
206
+ }
176
207
 
177
208
  //# sourceMappingURL=staff-alert-email.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/staff-alert-email.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { sendEmail, type SendEmailResult } from '@aglyn/shared-util-email'\n\n/**\n * Staff alerts by email, from LIBRARY code (AGL-2925).\n *\n * One destination for every subsystem's alarm — the console's\n * `emailStaffAlert` in `apps/console/app/api/_lib/usage-alert-email.ts` sends\n * through here too — resolved by {@link resolveStaffAlertRecipients}\n * (AGL-3375). Nothing here throws, because the mail is a courtesy and the\n * refusal it announces is the control; with no address anywhere the answer\n * is `unconfigured`.\n *\n * Platform email metering is loaded lazily and only after a send, so that\n * importing this module — which every unit test of the meter does — never\n * pulls the Firestore-backed meter, and with it the admin app, into a test\n * that only wanted to count credits. The system-email renderer and the staff\n * roster, which read through the same admin app, are loaded the same way.\n */\n\n/** At most this many staff accounts are mailed when no inbox is configured. */\nconst STAFF_FALLBACK_MAX = 10\n\n/**\n * Where a staff alarm goes (AGL-3375), first answer wins:\n *\n * 1. `STAFF_ALERT_EMAIL`, the operator's one alerts inbox.\n * 2. The operator's published support address,\n * `NEXT_PUBLIC_OPERATOR_SUPPORT_EMAIL`, which every self-hosted install is\n * asked for and most set.\n * 3. Every staff account's own address, up to {@link STAFF_FALLBACK_MAX}.\n *\n * Only the first applies on a preview or development deployment.\n *\n * The chain exists because the alarms that matter most are the ones an\n * install nobody tuned would otherwise send to no one: card testing on its\n * checkout, a stolen card on its first subscription, a phishing campaign\n * held on its sending domain. An unset variable used to mean silence.\n *\n * Empty only when none of the three yields an address.\n */\nexport async function resolveStaffAlertRecipients(): Promise<string[]> {\n const configured = String(process.env.STAFF_ALERT_EMAIL ?? '').trim().toLowerCase()\n if (configured.includes('@')) return [configured]\n // A preview or development deployment says what it is; only a production\n // one, or a self-hosted install that sets no such thing, falls back. A\n // preview's alarms reaching the operator's real inbox would be noise.\n const deployment = String(process.env.VERCEL_ENV ?? '').trim()\n if (deployment && deployment !== 'production') return []\n const operator = String(process.env.NEXT_PUBLIC_OPERATOR_SUPPORT_EMAIL ?? '')\n .trim()\n .toLowerCase()\n if (operator.includes('@')) return [operator]\n try {\n const { findUserByUidAcrossPools, listStaffUidsAcrossPools } = await import(\n './auth-pools'\n )\n const uids = (await listStaffUidsAcrossPools()).slice(0, STAFF_FALLBACK_MAX)\n const addresses = new Set<string>()\n for (const uid of uids) {\n const pooled = await findUserByUidAcrossPools(uid).catch(() => null)\n const address = String(pooled?.record?.email ?? '').trim().toLowerCase()\n if (address.includes('@')) addresses.add(address)\n }\n return [...addresses]\n } catch (error) {\n console.error('[staff-alert-email] staff roster unavailable', error)\n return []\n }\n}\n\n/**\n * Sends one rendered alert to each operator recipient, one message each so\n * no staff member is shown another's address. Metered to the platform: our\n * own alarm is our own cost (AGL-1438). Never throws.\n */\nasync function sendToOperators(\n content: { subject: string; text: string; html?: string },\n context: string,\n): Promise<SendEmailResult> {\n const recipients = await resolveStaffAlertRecipients()\n if (!recipients.length) return { sent: false, reason: 'unconfigured' }\n let delivered: SendEmailResult | null = null\n let failure: SendEmailResult = { sent: false, reason: 'unconfigured' }\n let sent = 0\n for (const to of recipients) {\n const result = await sendEmail({ to, ...content, context })\n if (result.sent) {\n sent += 1\n delivered ??= result\n } else {\n failure = result\n }\n }\n if (sent) {\n const { meterPlatformEmail } = await import('./email-metering')\n await meterPlatformEmail(sent).catch(() => undefined)\n }\n // Delivered to anyone is delivered: one dead staff mailbox does not make\n // the alert a failure for the caller.\n return delivered ?? failure\n}\n\nexport async function sendStaffAlertEmail(input: {\n subject: string\n text: string\n /** Resend tag / log label. */\n context: string\n}): Promise<SendEmailResult> {\n try {\n // In the platform's header and footer, as the `staff-alert` system email\n // (AGL-3367). Loaded lazily for the reason the meter is.\n const { renderSystemEmailContent, systemEmailBrand } = await import(\n './render-system-email'\n )\n const content = await renderSystemEmailContent(\n 'staff-alert',\n { 'alert.subject': input.subject, 'alert.body': input.text },\n systemEmailBrand(null),\n { subject: input.subject, text: input.text },\n )\n return await sendToOperators(content, input.context)\n } catch (error) {\n console.error('[staff-alert-email] send failed', error)\n return { sent: false, reason: 'network' }\n }\n}\n\n/**\n * A fraud or risk alert for the operator (AGL-3375), as the `operator-alert`\n * system email: the alert, a button to the item it is about, in the\n * operator's own brand. Sent by `notifyStaff` for every\n * `OPERATOR_ALERT_NOTIFICATION_TYPES` notification, beside the console one.\n * Never throws.\n */\nexport async function sendOperatorAlertEmail(input: {\n title: string\n body?: string | null\n /** Absolute link to the item, when there is one. */\n url?: string | null\n context: string\n}): Promise<SendEmailResult> {\n try {\n const { renderSystemEmailContent, systemEmailBrand } = await import(\n './render-system-email'\n )\n const body = String(input.body ?? '').trim()\n const url = String(input.url ?? '').trim()\n const content = await renderSystemEmailContent(\n 'operator-alert',\n { 'alert.title': input.title, 'alert.body': body, 'alert.url': url },\n systemEmailBrand(null),\n { subject: input.title, text: [input.title, body, url].filter(Boolean).join('\\n\\n') },\n )\n return await sendToOperators(content, input.context)\n } catch (error) {\n console.error('[staff-alert-email] operator alert failed', error)\n return { sent: false, reason: 'network' }\n }\n}\n"],"names":["sendEmail","STAFF_FALLBACK_MAX","resolveStaffAlertRecipients","process","configured","String","env","STAFF_ALERT_EMAIL","trim","toLowerCase","includes","deployment","VERCEL_ENV","operator","NEXT_PUBLIC_OPERATOR_SUPPORT_EMAIL","findUserByUidAcrossPools","listStaffUidsAcrossPools","uids","slice","addresses","Set","uid","pooled","catch","address","record","email","add","error","console","sendToOperators","content","context","recipients","length","sent","reason","delivered","failure","to","result","meterPlatformEmail","undefined","sendStaffAlertEmail","input","renderSystemEmailContent","systemEmailBrand","subject","text","sendOperatorAlertEmail","body","url","title","filter","Boolean","join"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/staff-alert-email.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { sendEmail, type SendEmailResult } from '@aglyn/shared-util-email'\n\n/**\n * Staff alerts by email, from LIBRARY code (AGL-2925).\n *\n * One destination for every subsystem's alarm — the console's\n * `emailStaffAlert` in `apps/console/app/api/_lib/usage-alert-email.ts` sends\n * through here too — resolved by {@link resolveStaffAlertRecipients}\n * (AGL-3375). Nothing here throws, because the mail is a courtesy and the\n * refusal it announces is the control; with no address anywhere the answer\n * is `unconfigured`.\n *\n * Platform email metering is loaded lazily and only after a send, so that\n * importing this module — which every unit test of the meter does — never\n * pulls the Firestore-backed meter, and with it the admin app, into a test\n * that only wanted to count credits. The system-email renderer and the staff\n * roster, which read through the same admin app, are loaded the same way.\n */\n\n/** At most this many staff accounts are mailed when no inbox is configured. */\nconst STAFF_FALLBACK_MAX = 10\n\n/**\n * Where a staff alarm goes (AGL-3375), first answer wins:\n *\n * 1. `STAFF_ALERT_EMAIL`, the operator's one alerts inbox.\n * 2. The operator's published support address,\n * `NEXT_PUBLIC_OPERATOR_SUPPORT_EMAIL`, which every self-hosted install is\n * asked for and most set.\n * 3. Every staff account's own address, up to {@link STAFF_FALLBACK_MAX}.\n *\n * Only the first applies on a preview or development deployment.\n *\n * The chain exists because the alarms that matter most are the ones an\n * install nobody tuned would otherwise send to no one: card testing on its\n * checkout, a stolen card on its first subscription, a phishing campaign\n * held on its sending domain. An unset variable used to mean silence.\n *\n * Empty only when none of the three yields an address.\n */\nexport async function resolveStaffAlertRecipients(): Promise<string[]> {\n const configured = String(process.env.STAFF_ALERT_EMAIL ?? '').trim().toLowerCase()\n if (configured.includes('@')) return [configured]\n // A preview or development deployment says what it is; only a production\n // one, or a self-hosted install that sets no such thing, falls back. A\n // preview's alarms reaching the operator's real inbox would be noise.\n const deployment = String(process.env.VERCEL_ENV ?? '').trim()\n if (deployment && deployment !== 'production') return []\n const operator = String(process.env.NEXT_PUBLIC_OPERATOR_SUPPORT_EMAIL ?? '')\n .trim()\n .toLowerCase()\n if (operator.includes('@')) return [operator]\n try {\n const { findUserByUidAcrossPools, listStaffUidsAcrossPools } = await import(\n './auth-pools'\n )\n const uids = (await listStaffUidsAcrossPools()).slice(0, STAFF_FALLBACK_MAX)\n const addresses = new Set<string>()\n for (const uid of uids) {\n const pooled = await findUserByUidAcrossPools(uid).catch(() => null)\n const address = String(pooled?.record?.email ?? '').trim().toLowerCase()\n if (address.includes('@')) addresses.add(address)\n }\n return [...addresses]\n } catch (error) {\n console.error('[staff-alert-email] staff roster unavailable', error)\n return []\n }\n}\n\n/**\n * Sends one rendered alert to each operator recipient, one message each so\n * no staff member is shown another's address. Metered to the platform: our\n * own alarm is our own cost (AGL-1438). Never throws.\n */\nasync function sendToOperators(\n content: { subject: string; text: string; html?: string },\n context: string,\n): Promise<SendEmailResult> {\n const recipients = await resolveStaffAlertRecipients()\n if (!recipients.length) return { sent: false, reason: 'unconfigured' }\n let delivered: SendEmailResult | null = null\n let failure: SendEmailResult = { sent: false, reason: 'unconfigured' }\n let sent = 0\n for (const to of recipients) {\n const result = await sendEmail({ to, ...content, context })\n if (result.sent) {\n sent += 1\n delivered ??= result\n } else {\n failure = result\n }\n }\n if (sent) {\n const { meterPlatformEmail } = await import('./email-metering')\n await meterPlatformEmail(sent).catch(() => undefined)\n }\n // Delivered to anyone is delivered: one dead staff mailbox does not make\n // the alert a failure for the caller.\n return delivered ?? failure\n}\n\nexport async function sendStaffAlertEmail(input: {\n subject: string\n text: string\n /** Resend tag / log label. */\n context: string\n}): Promise<SendEmailResult> {\n try {\n // In the platform's header and footer, as the `staff-alert` system email\n // (AGL-3367). Loaded lazily for the reason the meter is.\n const { renderSystemEmailContent, systemEmailBrand } = await import(\n './render-system-email'\n )\n const content = await renderSystemEmailContent(\n 'staff-alert',\n { 'alert.subject': input.subject, 'alert.body': input.text },\n systemEmailBrand(null),\n { subject: input.subject, text: input.text },\n )\n return await sendToOperators(content, input.context)\n } catch (error) {\n console.error('[staff-alert-email] send failed', error)\n return { sent: false, reason: 'network' }\n }\n}\n\n/**\n * One operator alert (AGL-3375, AGL-3377), as the `operator-alert` system\n * email: the alert, a button to the item it is about, in the operator's own\n * brand. Sent by `raiseOperatorAlert` for every alert delivered immediately;\n * nothing else should call it. Never throws.\n */\nexport async function sendOperatorAlertEmail(input: {\n title: string\n body?: string | null\n /** Absolute link to the item, when there is one. */\n url?: string | null\n context: string\n}): Promise<SendEmailResult> {\n try {\n const { renderSystemEmailContent, systemEmailBrand } = await import(\n './render-system-email'\n )\n const body = String(input.body ?? '').trim()\n const url = String(input.url ?? '').trim()\n const content = await renderSystemEmailContent(\n 'operator-alert',\n { 'alert.title': input.title, 'alert.body': body, 'alert.url': url },\n systemEmailBrand(null),\n { subject: input.title, text: [input.title, body, url].filter(Boolean).join('\\n\\n') },\n )\n return await sendToOperators(content, input.context)\n } catch (error) {\n console.error('[staff-alert-email] operator alert failed', error)\n return { sent: false, reason: 'network' }\n }\n}\n\n/**\n * The day's batched operator alerts (AGL-3377), as the\n * `operator-alert-digest` system email. Sent by `sendOperatorAlertDigest`.\n * Never throws.\n */\nexport async function sendOperatorAlertDigestEmail(input: {\n /** The UTC day, `YYYY-MM-DD`. */\n date: string\n count: number\n /** The alerts, composed as one block of text. */\n body: string\n /** Absolute link to the staff console, when there is one. */\n url?: string | null\n}): Promise<SendEmailResult> {\n try {\n const { renderSystemEmailContent, systemEmailBrand } = await import(\n './render-system-email'\n )\n const subject = `${input.count} operator alert${input.count === 1 ? '' : 's'} on ${input.date}`\n const url = String(input.url ?? '').trim()\n const content = await renderSystemEmailContent(\n 'operator-alert-digest',\n {\n 'digest.date': input.date,\n 'digest.count': String(input.count),\n 'digest.body': input.body,\n 'digest.url': url,\n },\n systemEmailBrand(null),\n { subject, text: [subject, input.body, url].filter(Boolean).join('\\n\\n') },\n )\n return await sendToOperators(content, 'operator-alert-digest')\n } catch (error) {\n console.error('[staff-alert-email] operator digest failed', error)\n return { sent: false, reason: 'network' }\n }\n}\n"],"names":["sendEmail","STAFF_FALLBACK_MAX","resolveStaffAlertRecipients","process","configured","String","env","STAFF_ALERT_EMAIL","trim","toLowerCase","includes","deployment","VERCEL_ENV","operator","NEXT_PUBLIC_OPERATOR_SUPPORT_EMAIL","findUserByUidAcrossPools","listStaffUidsAcrossPools","uids","slice","addresses","Set","uid","pooled","catch","address","record","email","add","error","console","sendToOperators","content","context","recipients","length","sent","reason","delivered","failure","to","result","meterPlatformEmail","undefined","sendStaffAlertEmail","input","renderSystemEmailContent","systemEmailBrand","subject","text","sendOperatorAlertEmail","body","url","title","filter","Boolean","join","sendOperatorAlertDigestEmail","count","date"],"mappings":"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"}
@@ -0,0 +1,52 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ import type { HostTransferPlan } from '@aglyn/aglyn/app-utils/host-transfer';
18
+ export type { HostTransferHold, HostTransferPlan } from '@aglyn/aglyn/app-utils/host-transfer';
19
+ export interface PlanHostTransferInput {
20
+ hostId: string;
21
+ toOrgId: string;
22
+ /** Staff may place a site past the destination's site limit. */
23
+ overrideSiteLimit?: boolean;
24
+ }
25
+ /**
26
+ * The transfer, worked out and not performed. Read only: safe to call for a
27
+ * confirmation dialog, and called again by {@link transferHost} so what runs
28
+ * is what was decided a moment ago, not what was true when the dialog opened.
29
+ */
30
+ export declare function planHostTransfer(input: PlanHostTransferInput): Promise<HostTransferPlan>;
31
+ /** A transfer refused by its plan: nothing was written. */
32
+ export declare class HostTransferRefusedError extends Error {
33
+ readonly plan: HostTransferPlan;
34
+ constructor(plan: HostTransferPlan);
35
+ }
36
+ /**
37
+ * Move a site to another organization (AGL-3381). Re-plans first and refuses
38
+ * — writing nothing — when the plan holds; see the header for what moves,
39
+ * what stays, and why.
40
+ *
41
+ * The ownership flip is one transaction over the site and both organization
42
+ * documents, re-reading the site's organization inside it, so two transfers
43
+ * of one site cannot both land. The site's access list is rebuilt straight
44
+ * after it, before anything else. Everything after the flip is a projection
45
+ * of it, recomputed from the documents rather than carried from the plan, so
46
+ * a failure part-way is finished by running the projections again
47
+ * ({@link syncOrgAuthProjections} and the membership sync).
48
+ *
49
+ * A site that belongs to no organization — left behind by a failed create or
50
+ * a hand edit — is given one the same way, with nothing to release.
51
+ */
52
+ export declare function transferHost(input: PlanHostTransferInput): Promise<HostTransferPlan>;