@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.
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/email-send-rate.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 * THE PLATFORM SEND-RATE GOVERNOR — durable half (AGL-2409).\n *\n * The policy is pure and lives in `@aglyn/shared-util-email`\n * (`send-rate.ts`); this adds the Firestore counter that makes the ceiling\n * global rather than per-instance, the configuration read that makes a ramp a\n * value change instead of a deploy, and the installation that puts the whole\n * thing on `sendEmail`'s path.\n *\n * ## Storage: the `rateLimits` collection, on purpose\n *\n * Both documents live in `rateLimits`, the same collection AGL-794's counters,\n * AGL-1679's degradation markers and AGL-1907's signup-refusal markers already\n * use, for the reason those two state: it inherits the deny-all security rule\n * and the `expiresAt` TTL policy that already exist rather than needing a new\n * collection, a rules deploy and a second TTL policy.\n *\n * - `rateLimits/sendRate_{windowStartMs}` — one document per hour, holding\n * the count. Carries `expiresAt`, so the TTL policy sweeps it.\n * - `rateLimits/sendRateConfig` — the ceiling. **Carries NO `expiresAt`, and\n * must never be given one**, or the TTL policy that serves the counters\n * would quietly delete the configuration and the platform would silently\n * revert to the compiled-in default. `emailSendRateConfigWrite` is the one\n * writer and `email-send-rate.spec.ts` asserts the field is absent.\n *\n * The window document does NOT carry `lastAtMs`. AGL-1693's rate-limiter\n * health probe queries this collection with `where('lastAtMs', '>=', cutoff)`;\n * a per-hour document in that range would compete with the degradation\n * markers the probe exists to find. It uses `sentAtMs`, which keeps the two\n * queries disjoint at the index level — the same reasoning, and the same\n * mistake avoided, as `refusedAtMs` on the signup markers.\n *\n * ## Failure posture: OPEN\n *\n * If the counter or the config cannot be read, every send is granted. That is\n * the opposite of `consumeRateLimit`'s fail-closed-on-contention rule, and the\n * difference is what the two controls protect. A brute-force limiter that\n * fails open lets an attacker in; this one failing open lets a campaign out.\n * The cost of failing CLOSED here is refusing a paying customer's campaign\n * because of an unrelated Firestore blip — and, worse, refusing a bulk sweep\n * that a customer's month depends on. Neither is worth the hour of ramp it\n * would buy back, and the operator can see it: a degraded grant is reported\n * and surfaced on /admin/emails.\n */\n\nimport {\n type EmailSendGovernorRequest,\n type EmailSendGovernorVerdict,\n type EmailSendPriority,\n type EmailSendRateConfig,\n type EmailSendRateVerdict,\n EMAIL_SEND_RATE_WINDOW_MS,\n emailSendRateVerdict,\n emailSendRateWindowStartMs,\n normalizeEmailSendRateConfig,\n orgHourlyCampaignCeiling,\n setEmailSendGovernor,\n} from '@aglyn/shared-util-email'\nimport { firebaseAdmin } from './firebase-admin'\nimport { RATE_LIMIT_COLLECTION } from './rate-limit-store'\n\n/** Document id of the live ceiling. NEVER written with `expiresAt`. */\nexport const EMAIL_SEND_RATE_CONFIG_DOC = 'sendRateConfig'\n\n/** Id prefix for the per-hour counters. */\nexport const EMAIL_SEND_RATE_WINDOW_PREFIX = 'sendRate_'\n\n/** The counter document id for a window. */\nexport function emailSendRateWindowDocId(windowStartMs: number): string {\n return `${EMAIL_SEND_RATE_WINDOW_PREFIX}${windowStartMs}`\n}\n\n/**\n * Config cache TTL.\n *\n * 15s, the same number and the same reasoning as the platform lockdown doc:\n * this sits on a path that every outbound message crosses, so a per-send read\n * would be one Firestore read per email platform-wide, and 15s is the\n * worst-case lag between staff moving the ramp and a warm process honouring\n * it. A ramp is measured in hours; fifteen seconds is free.\n */\nconst CONFIG_TTL_MS = 15_000\n\nlet configCache: { at: number; config: EmailSendRateConfig } | undefined\nlet configPending: Promise<EmailSendRateConfig> | undefined\n\n/** Drop the in-process config cache — called by the console after a write. */\nexport function invalidateEmailSendRateConfigCache(): void {\n configCache = undefined\n configPending = undefined\n}\n\n/**\n * The live ceiling. Falls back to the compiled-in default on any read error,\n * which is the fail-open posture stated above: an unreachable config must not\n * be read as a ceiling of zero.\n */\nexport async function readEmailSendRateConfig(options?: {\n firestore?: any\n now?: number\n}): Promise<EmailSendRateConfig> {\n const now = options?.now ?? Date.now()\n if (!options?.firestore && configCache && now - configCache.at < CONFIG_TTL_MS) {\n return configCache.config\n }\n const load = async (): Promise<EmailSendRateConfig> => {\n let config = normalizeEmailSendRateConfig(null)\n try {\n const firestore = options?.firestore ?? firebaseAdmin.app().firestore()\n const snapshot = await firestore\n .collection(RATE_LIMIT_COLLECTION)\n .doc(EMAIL_SEND_RATE_CONFIG_DOC)\n .get()\n config = normalizeEmailSendRateConfig(\n snapshot.exists ? (snapshot.data() as Partial<EmailSendRateConfig>) : null,\n )\n } catch {\n // Fail open: an unreachable config is an outage, not a ceiling of zero.\n }\n return config\n }\n // An injected firestore is a test or a one-off read; never cached, so a\n // spec cannot poison the process cache for the next one.\n if (options?.firestore) return load()\n if (!configPending) {\n configPending = load()\n .then((config) => {\n configCache = { at: now, config }\n return config\n })\n .finally(() => {\n configPending = undefined\n })\n }\n return configPending\n}\n\nexport interface ConsumeEmailSendBudgetOptions {\n priority: EmailSendPriority\n /** Messages this send would add. */\n count: number\n now?: number\n firestore?: any\n /** Injectable for tests; otherwise read (and cached) from Firestore. */\n config?: EmailSendRateConfig\n}\n\nexport interface ConsumeEmailSendBudgetResult extends EmailSendRateVerdict {\n windowStartMs: number\n /** True when the counter was unreachable and this failed open. */\n degraded: boolean\n}\n\n/**\n * Counts `count` messages against the current hour, globally, and answers\n * whether they may go.\n *\n * The transaction reads the counter and writes an ABSOLUTE value derived from\n * that read — not `FieldValue.increment` — because the read is the authority\n * for the decision. Firestore aborts and re-runs the callback when a\n * document it read has moved, so two concurrent sweeps cannot both see the\n * same headroom and both take it. An increment would be atomic on the number\n * and useless for the decision, which is exactly the read-then-write shape\n * AGL-2267 is about one surface over.\n *\n * A refused send writes NOTHING. A caller that will retry next hour must not\n * have spent budget on being told no.\n */\nexport async function consumeEmailSendBudget(\n options: ConsumeEmailSendBudgetOptions,\n): Promise<ConsumeEmailSendBudgetResult> {\n const now = options.now ?? Date.now()\n const windowStartMs = emailSendRateWindowStartMs(now)\n const resetMs = windowStartMs + EMAIL_SEND_RATE_WINDOW_MS\n const config =\n options.config ??\n (await readEmailSendRateConfig({ firestore: options.firestore, now }))\n\n const fallback = (): ConsumeEmailSendBudgetResult => ({\n ...emailSendRateVerdict({\n priority: options.priority,\n used: 0,\n count: options.count,\n ceiling: config.perHour,\n enabled: false,\n windowStartMs,\n }),\n windowStartMs,\n degraded: true,\n })\n\n try {\n const firestore = options.firestore ?? firebaseAdmin.app().firestore()\n const ref = firestore\n .collection(RATE_LIMIT_COLLECTION)\n .doc(emailSendRateWindowDocId(windowStartMs))\n\n const verdict = await firestore.runTransaction(async (tx: any) => {\n const snapshot = await tx.get(ref)\n const used = Number((snapshot.exists ? snapshot.get('count') : 0) ?? 0)\n const decision = emailSendRateVerdict({\n priority: options.priority,\n used,\n count: options.count,\n ceiling: config.perHour,\n enabled: config.enabled,\n windowStartMs,\n })\n if (!decision.allowed) return decision\n tx.set(\n ref,\n {\n count: decision.used + Math.max(0, Math.floor(Number(options.count) || 0)),\n windowStartMs,\n // NOT `lastAtMs` — see the storage note at the top of this file.\n sentAtMs: now,\n // The TTL field the counters in this collection already use. Two\n // windows of retention so the console can show the hour just gone.\n expiresAt: new Date(resetMs + EMAIL_SEND_RATE_WINDOW_MS),\n },\n { merge: true },\n )\n return decision\n })\n\n return { ...(verdict as EmailSendRateVerdict), windowStartMs, degraded: false }\n } catch (error) {\n console.error('[send-rate] counter unavailable — allowing', error)\n return fallback()\n }\n}\n\n/**\n * What the current hour looks like. Read-only, for the staff console.\n *\n * Returns `used: 0` rather than throwing when the window document does not\n * exist, which is the ordinary state of a quiet hour.\n */\nexport async function readEmailSendRateWindow(options?: {\n firestore?: any\n now?: number\n}): Promise<{ windowStartMs: number; resetMs: number; used: number }> {\n const now = options?.now ?? Date.now()\n const windowStartMs = emailSendRateWindowStartMs(now)\n const resetMs = windowStartMs + EMAIL_SEND_RATE_WINDOW_MS\n try {\n const firestore = options?.firestore ?? firebaseAdmin.app().firestore()\n const snapshot = await firestore\n .collection(RATE_LIMIT_COLLECTION)\n .doc(emailSendRateWindowDocId(windowStartMs))\n .get()\n const used = Number((snapshot.exists ? snapshot.get('count') : 0) ?? 0)\n return {\n windowStartMs,\n resetMs,\n used: Number.isFinite(used) && used > 0 ? Math.floor(used) : 0,\n }\n } catch {\n return { windowStartMs, resetMs, used: 0 }\n }\n}\n\n/** Id prefix for the per-org, per-hour campaign counters. */\nexport const EMAIL_ORG_SEND_RATE_WINDOW_PREFIX = 'sendRateOrg_'\n\n/**\n * The per-org counter document id for a window.\n *\n * A separate document per org rather than a map field on the platform window,\n * so two orgs sending in the same hour contend on their own documents instead\n * of serialising on one. The platform window is already a single hot document\n * and adding N org fields to it would make every campaign in the hour a write\n * conflict with every other.\n */\nexport function emailOrgSendRateWindowDocId(\n windowStartMs: number,\n orgId: string,\n): string {\n return `${EMAIL_ORG_SEND_RATE_WINDOW_PREFIX}${windowStartMs}_${orgId}`\n}\n\n/** The current hour for one org. Read-only, for a usage surface. */\nexport async function readOrgEmailSendWindow(options: {\n orgId: string\n firestore?: any\n now?: number\n}): Promise<{ windowStartMs: number; resetMs: number; used: number }> {\n const now = options.now ?? Date.now()\n const windowStartMs = emailSendRateWindowStartMs(now)\n const resetMs = windowStartMs + EMAIL_SEND_RATE_WINDOW_MS\n if (!options.orgId) return { windowStartMs, resetMs, used: 0 }\n try {\n const firestore = options.firestore ?? firebaseAdmin.app().firestore()\n const snapshot = await firestore\n .collection(RATE_LIMIT_COLLECTION)\n .doc(emailOrgSendRateWindowDocId(windowStartMs, options.orgId))\n .get()\n const used = Number((snapshot.exists ? snapshot.get('count') : 0) ?? 0)\n return {\n windowStartMs,\n resetMs,\n used: Number.isFinite(used) && used > 0 ? Math.floor(used) : 0,\n }\n } catch {\n return { windowStartMs, resetMs, used: 0 }\n }\n}\n\n/** The answer to a per-org hourly claim. Every field is a stated number. */\nexport interface OrgEmailSendClaimResult {\n allowed: boolean\n /** Count in this org's window BEFORE this send. */\n used: number\n /** What this org may send in an hour. */\n ceiling: number\n /** Headroom after this send, floored at 0. */\n remaining: number\n /** When the window rolls and a deferred campaign may go. */\n retryAtMs: number\n /** True when the counter was unreachable and this failed open. */\n degraded: boolean\n}\n\n/**\n * THE PER-ORG SHARE OF THE PLATFORM HOUR.\n *\n * The platform governor bounds total volume; it does not bound how much of\n * that total ONE tenant may take. Without this, a single org with a large\n * audience occupies the whole hour and every other customer's campaigns are\n * refused by a ceiling they did nothing to reach — one tenant denying service\n * to the rest, on a limit they cannot see.\n *\n * The ceiling is derived, not configured: `orgHourlyCampaignCeiling` is a\n * share of whatever the live platform ceiling currently is, so a staff ramp\n * moves both together and the two can never drift into contradiction. See\n * `send-ceilings.ts` for the arithmetic and the relations it maintains.\n *\n * **Campaigns only.** This function is not on `sendEmail`'s path and is called\n * from the campaign sender alone. A transactional message can never reach it,\n * which is the same boundary `emailSendRateVerdict` enforces one layer down\n * and for the same reason: a password reset refused by a throttle converts a\n * reputation risk into an outage on somebody else's business.\n *\n * ## Fails OPEN\n *\n * An unreachable counter grants the send, matching `consumeEmailSendBudget`.\n * A refusal produced by a Firestore blip is a refused campaign for a paying\n * customer, and the hour of pacing it buys back is not worth it.\n *\n * ## Claimed, not reconciled\n *\n * The claim is taken for the whole batch and never refunded, unlike the\n * monthly reservation. The window is one hour and TTL-swept, so an\n * undelivered remainder costs the org the rest of that hour and nothing\n * after it — where an unreconciled MONTHLY claim would cost the rest of the\n * month, which is why that one is reconciled and this one is not. The error\n * is in the direction that can only ever pace mail more, never let more out.\n */\nexport async function claimOrgEmailSendBudget(options: {\n orgId: string\n /** Messages this campaign would send. */\n count: number\n /** The live platform ceiling; the org share is derived from it. */\n platformPerHour: number\n /** False parks the control, exactly as the platform governor's flag does. */\n enabled?: boolean\n now?: number\n firestore?: any\n}): Promise<OrgEmailSendClaimResult> {\n const now = options.now ?? Date.now()\n const windowStartMs = emailSendRateWindowStartMs(now)\n const retryAtMs = windowStartMs + EMAIL_SEND_RATE_WINDOW_MS\n const ceiling = orgHourlyCampaignCeiling(options.platformPerHour)\n const count = Math.max(0, Math.floor(Number(options.count) || 0))\n\n const granted = (used: number, degraded: boolean): OrgEmailSendClaimResult => ({\n allowed: true,\n used,\n ceiling,\n remaining: Math.max(0, ceiling - (used + count)),\n retryAtMs,\n degraded,\n })\n\n // A parked control still reports the ceiling, so a surface reading this\n // shows a real number rather than blanking while the control is off.\n if (options.enabled === false) return granted(0, false)\n // No org means the counter has nowhere to live. The monthly reservation\n // refuses in this case because an unattributable campaign must not send\n // unbounded; here the monthly claim has already made that decision, so\n // failing open costs nothing it has not already been charged for.\n if (!options.orgId) return granted(0, true)\n\n try {\n const firestore = options.firestore ?? firebaseAdmin.app().firestore()\n const ref = firestore\n .collection(RATE_LIMIT_COLLECTION)\n .doc(emailOrgSendRateWindowDocId(windowStartMs, options.orgId))\n\n return await firestore.runTransaction(async (tx: any) => {\n const snapshot = await tx.get(ref)\n const rawUsed = Number((snapshot.exists ? snapshot.get('count') : 0) ?? 0)\n // A corrupt or negative counter must not read as headroom.\n const used =\n Number.isFinite(rawUsed) && rawUsed > 0 ? Math.floor(rawUsed) : 0\n if (used + count > ceiling) {\n // A refused claim writes NOTHING. A campaign that will be retried\n // next hour must not have spent budget on being told no.\n return {\n allowed: false,\n used,\n ceiling,\n remaining: Math.max(0, ceiling - used),\n retryAtMs,\n degraded: false,\n }\n }\n // An absolute write from this transaction's own read, not\n // `FieldValue.increment` — the read is the authority for the decision,\n // and an increment would be atomic on the number while proving nothing\n // about the value the decision was made from.\n tx.set(\n ref,\n {\n count: used + count,\n windowStartMs,\n orgId: options.orgId,\n // NOT `lastAtMs` — the rate-limiter health probe queries this\n // collection on that field and a per-hour document in its range\n // would compete with the markers it exists to find.\n sentAtMs: now,\n expiresAt: new Date(retryAtMs + EMAIL_SEND_RATE_WINDOW_MS),\n },\n { merge: true },\n )\n return granted(used, false)\n })\n } catch (error) {\n console.error('[send-rate] org window unavailable — allowing', error)\n return granted(0, true)\n }\n}\n\n/**\n * The document the console writes when staff move the ramp.\n *\n * Returned rather than written so the route owns the write (and the audit row\n * beside it), and so a spec can assert the SHAPE without a Firestore. The one\n * thing it must never contain is `expiresAt`.\n */\nexport function emailSendRateConfigWrite(input: {\n perHour: number\n enabled: boolean\n actorEmail?: string | null\n note?: string\n now?: number\n}): Partial<EmailSendRateConfig> {\n const normalized = normalizeEmailSendRateConfig({\n perHour: input.perHour,\n enabled: input.enabled,\n note: input.note,\n updatedAtMs: input.now ?? Date.now(),\n updatedByEmail: input.actorEmail ?? null,\n })\n return {\n perHour: normalized.perHour,\n enabled: normalized.enabled,\n note: normalized.note,\n updatedAtMs: normalized.updatedAtMs,\n updatedByEmail: normalized.updatedByEmail,\n }\n}\n\n/**\n * Puts the durable governor on `sendEmail`'s path.\n *\n * **Called at module load**, from the bottom of this file, so that importing\n * `@aglyn/tenant-data-admin` anywhere is enough — every server surface in the\n * product already imports that barrel, and `export *` forces this module to\n * evaluate. The alternative, an `installEmailSendGovernor()` call at each\n * server entrypoint, is the 37-places-to-remember shape this codebase already\n * rejected once for the `context` tag.\n *\n * Idempotent: installing twice replaces the same closure with an equivalent\n * one, and the closure holds no state — the state is the Firestore document.\n */\nexport function installEmailSendGovernor(): void {\n setEmailSendGovernor(\n async (\n request: EmailSendGovernorRequest,\n ): Promise<EmailSendGovernorVerdict> => {\n const result = await consumeEmailSendBudget({\n priority: request.priority,\n count: request.count,\n })\n return {\n allowed: result.allowed,\n ceiling: result.ceiling,\n used: result.used,\n remaining: result.remaining,\n retryAtMs: result.retryAtMs,\n degraded: result.degraded,\n }\n },\n )\n}\n\ninstallEmailSendGovernor()\n"],"names":["EMAIL_SEND_RATE_WINDOW_MS","emailSendRateVerdict","emailSendRateWindowStartMs","normalizeEmailSendRateConfig","orgHourlyCampaignCeiling","setEmailSendGovernor","firebaseAdmin","RATE_LIMIT_COLLECTION","EMAIL_SEND_RATE_CONFIG_DOC","EMAIL_SEND_RATE_WINDOW_PREFIX","emailSendRateWindowDocId","windowStartMs","CONFIG_TTL_MS","configCache","configPending","invalidateEmailSendRateConfigCache","undefined","readEmailSendRateConfig","options","now","Date","firestore","at","config","load","app","snapshot","collection","doc","get","exists","data","then","finally","consumeEmailSendBudget","resetMs","fallback","priority","used","count","ceiling","perHour","enabled","degraded","ref","verdict","runTransaction","tx","Number","decision","allowed","set","Math","max","floor","sentAtMs","expiresAt","merge","error","console","readEmailSendRateWindow","isFinite","EMAIL_ORG_SEND_RATE_WINDOW_PREFIX","emailOrgSendRateWindowDocId","orgId","readOrgEmailSendWindow","claimOrgEmailSendBudget","retryAtMs","platformPerHour","granted","remaining","rawUsed","emailSendRateConfigWrite","input","normalized","note","updatedAtMs","updatedByEmail","actorEmail","installEmailSendGovernor","request","result"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/email-send-rate.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 * THE PLATFORM SEND-RATE GOVERNOR — durable half (AGL-2409).\n *\n * The policy is pure and lives in `@aglyn/shared-util-email`\n * (`send-rate.ts`); this adds the Firestore counter that makes the ceiling\n * global rather than per-instance, the configuration read that makes a ramp a\n * value change instead of a deploy, and the installation that puts the whole\n * thing on `sendEmail`'s path.\n *\n * ## Storage: the `rateLimits` collection, on purpose\n *\n * Both documents live in `rateLimits`, the same collection AGL-794's counters,\n * AGL-1679's degradation markers and AGL-1907's signup-refusal markers already\n * use, for the reason those two state: it inherits the deny-all security rule\n * and the `expiresAt` TTL policy that already exist rather than needing a new\n * collection, a rules deploy and a second TTL policy.\n *\n * - `rateLimits/sendRate_{windowStartMs}` — one document per hour, holding\n * the count. Carries `expiresAt`, so the TTL policy sweeps it.\n * - `rateLimits/sendRateConfig` — the ceiling. **Carries NO `expiresAt`, and\n * must never be given one**, or the TTL policy that serves the counters\n * would quietly delete the configuration and the platform would silently\n * revert to the compiled-in default. `emailSendRateConfigWrite` is the one\n * writer and `email-send-rate.spec.ts` asserts the field is absent.\n *\n * The window document does NOT carry `lastAtMs`. AGL-1693's rate-limiter\n * health probe queries this collection with `where('lastAtMs', '>=', cutoff)`;\n * a per-hour document in that range would compete with the degradation\n * markers the probe exists to find. It uses `sentAtMs`, which keeps the two\n * queries disjoint at the index level — the same reasoning, and the same\n * mistake avoided, as `refusedAtMs` on the signup markers.\n *\n * ## Failure posture: OPEN\n *\n * If the counter or the config cannot be read, every send is granted. That is\n * the opposite of `consumeRateLimit`'s fail-closed-on-contention rule, and the\n * difference is what the two controls protect. A brute-force limiter that\n * fails open lets an attacker in; this one failing open lets a campaign out.\n * The cost of failing CLOSED here is refusing a paying customer's campaign\n * because of an unrelated Firestore blip — and, worse, refusing a bulk sweep\n * that a customer's month depends on. Neither is worth the hour of ramp it\n * would buy back, and the operator can see it: a degraded grant is reported\n * and surfaced on /admin/emails.\n */\n\nimport {\n type EmailSendGovernorRequest,\n type EmailSendGovernorVerdict,\n type EmailSendPriority,\n type EmailSendRateConfig,\n type EmailSendRateVerdict,\n EMAIL_SEND_RATE_WINDOW_MS,\n emailSendRateVerdict,\n emailSendRateWindowStartMs,\n normalizeEmailSendRateConfig,\n orgHourlyCampaignCeiling,\n setEmailSendGovernor,\n} from '@aglyn/shared-util-email'\nimport { firebaseAdmin } from './firebase-admin'\nimport { RATE_LIMIT_COLLECTION } from './rate-limit-store'\n\n/** Document id of the live ceiling. NEVER written with `expiresAt`. */\nexport const EMAIL_SEND_RATE_CONFIG_DOC = 'sendRateConfig'\n\n/** Id prefix for the per-hour counters. */\nexport const EMAIL_SEND_RATE_WINDOW_PREFIX = 'sendRate_'\n\n/** The counter document id for a window. */\nexport function emailSendRateWindowDocId(windowStartMs: number): string {\n return `${EMAIL_SEND_RATE_WINDOW_PREFIX}${windowStartMs}`\n}\n\n/**\n * Config cache TTL.\n *\n * 15s, the same number and the same reasoning as the platform lockdown doc:\n * this sits on a path that every outbound message crosses, so a per-send read\n * would be one Firestore read per email platform-wide, and 15s is the\n * worst-case lag between staff moving the ramp and a warm process honouring\n * it. A ramp is measured in hours; fifteen seconds is free.\n */\nconst CONFIG_TTL_MS = 15_000\n\nlet configCache: { at: number; config: EmailSendRateConfig } | undefined\nlet configPending: Promise<EmailSendRateConfig> | undefined\n\n/** Drop the in-process config cache — called by the console after a write. */\nexport function invalidateEmailSendRateConfigCache(): void {\n configCache = undefined\n configPending = undefined\n}\n\n/**\n * The live ceiling. Falls back to the compiled-in default on any read error,\n * which is the fail-open posture stated above: an unreachable config must not\n * be read as a ceiling of zero.\n */\nexport async function readEmailSendRateConfig(options?: {\n firestore?: any\n now?: number\n}): Promise<EmailSendRateConfig> {\n const now = options?.now ?? Date.now()\n if (!options?.firestore && configCache && now - configCache.at < CONFIG_TTL_MS) {\n return configCache.config\n }\n const load = async (): Promise<EmailSendRateConfig> => {\n let config = normalizeEmailSendRateConfig(null)\n try {\n const firestore = options?.firestore ?? firebaseAdmin.app().firestore()\n const snapshot = await firestore\n .collection(RATE_LIMIT_COLLECTION)\n .doc(EMAIL_SEND_RATE_CONFIG_DOC)\n .get()\n config = normalizeEmailSendRateConfig(\n snapshot.exists ? (snapshot.data() as Partial<EmailSendRateConfig>) : null,\n )\n } catch {\n // Fail open: an unreachable config is an outage, not a ceiling of zero.\n }\n return config\n }\n // An injected firestore is a test or a one-off read; never cached, so a\n // spec cannot poison the process cache for the next one.\n if (options?.firestore) return load()\n if (!configPending) {\n configPending = load()\n .then((config) => {\n configCache = { at: now, config }\n return config\n })\n .finally(() => {\n configPending = undefined\n })\n }\n return configPending\n}\n\nexport interface ConsumeEmailSendBudgetOptions {\n priority: EmailSendPriority\n /** Messages this send would add. */\n count: number\n now?: number\n firestore?: any\n /** Injectable for tests; otherwise read (and cached) from Firestore. */\n config?: EmailSendRateConfig\n}\n\nexport interface ConsumeEmailSendBudgetResult extends EmailSendRateVerdict {\n windowStartMs: number\n /** True when the counter was unreachable and this failed open. */\n degraded: boolean\n}\n\n/**\n * Counts `count` messages against the current hour, globally, and answers\n * whether they may go.\n *\n * The transaction reads the counter and writes an ABSOLUTE value derived from\n * that read — not `FieldValue.increment` — because the read is the authority\n * for the decision. Firestore aborts and re-runs the callback when a\n * document it read has moved, so two concurrent sweeps cannot both see the\n * same headroom and both take it. An increment would be atomic on the number\n * and useless for the decision, which is exactly the read-then-write shape\n * AGL-2267 is about one surface over.\n *\n * A refused send writes NOTHING. A caller that will retry next hour must not\n * have spent budget on being told no.\n */\nexport async function consumeEmailSendBudget(\n options: ConsumeEmailSendBudgetOptions,\n): Promise<ConsumeEmailSendBudgetResult> {\n const now = options.now ?? Date.now()\n const windowStartMs = emailSendRateWindowStartMs(now)\n const resetMs = windowStartMs + EMAIL_SEND_RATE_WINDOW_MS\n const config =\n options.config ??\n (await readEmailSendRateConfig({ firestore: options.firestore, now }))\n\n const fallback = (): ConsumeEmailSendBudgetResult => ({\n ...emailSendRateVerdict({\n priority: options.priority,\n used: 0,\n count: options.count,\n ceiling: config.perHour,\n enabled: false,\n windowStartMs,\n }),\n windowStartMs,\n degraded: true,\n })\n\n try {\n const firestore = options.firestore ?? firebaseAdmin.app().firestore()\n const ref = firestore\n .collection(RATE_LIMIT_COLLECTION)\n .doc(emailSendRateWindowDocId(windowStartMs))\n\n const verdict = await firestore.runTransaction(async (tx: any) => {\n const snapshot = await tx.get(ref)\n const used = Number((snapshot.exists ? snapshot.get('count') : 0) ?? 0)\n const decision = emailSendRateVerdict({\n priority: options.priority,\n used,\n count: options.count,\n ceiling: config.perHour,\n enabled: config.enabled,\n windowStartMs,\n })\n if (!decision.allowed) return decision\n tx.set(\n ref,\n {\n count: decision.used + Math.max(0, Math.floor(Number(options.count) || 0)),\n windowStartMs,\n // NOT `lastAtMs` — see the storage note at the top of this file.\n sentAtMs: now,\n // The TTL field the counters in this collection already use. Two\n // windows of retention so the console can show the hour just gone.\n expiresAt: new Date(resetMs + EMAIL_SEND_RATE_WINDOW_MS),\n },\n { merge: true },\n )\n return decision\n })\n\n const decided = verdict as EmailSendRateVerdict\n if (!decided.allowed) {\n // Refusable mail is being deferred for the rest of the hour. Told once\n // per six hours (AGL-3377); the operator email itself is transactional,\n // which this governor never refuses.\n await raiseSendRateAlert('deliverability.sendRateSaturated', {\n scope: 'platform',\n detail: `${decided.used ?? '?'}/${decided.ceiling ?? config.perHour} this hour`,\n })\n }\n return { ...decided, windowStartMs, degraded: false }\n } catch (error) {\n console.error('[send-rate] counter unavailable — allowing', error)\n await raiseSendRateAlert('deliverability.sendRateUnavailable', {\n error: error instanceof Error ? error.message.slice(0, 200) : String(error),\n })\n return fallback()\n }\n}\n\n/**\n * Raises a governor alert, loaded lazily so the hot path imports no alert\n * pipeline until the governor actually has something to say. Never throws.\n */\nasync function raiseSendRateAlert(\n type: 'deliverability.sendRateSaturated' | 'deliverability.sendRateUnavailable',\n context: Record<string, string>,\n): Promise<void> {\n try {\n const { raiseOperatorAlert } = await import('./operator-alerts')\n await raiseOperatorAlert(type, { dedupeKey: type, context })\n } catch (error) {\n console.error(`[send-rate] ${type} could not be raised`, error)\n }\n}\n\n/**\n * What the current hour looks like. Read-only, for the staff console.\n *\n * Returns `used: 0` rather than throwing when the window document does not\n * exist, which is the ordinary state of a quiet hour.\n */\nexport async function readEmailSendRateWindow(options?: {\n firestore?: any\n now?: number\n}): Promise<{ windowStartMs: number; resetMs: number; used: number }> {\n const now = options?.now ?? Date.now()\n const windowStartMs = emailSendRateWindowStartMs(now)\n const resetMs = windowStartMs + EMAIL_SEND_RATE_WINDOW_MS\n try {\n const firestore = options?.firestore ?? firebaseAdmin.app().firestore()\n const snapshot = await firestore\n .collection(RATE_LIMIT_COLLECTION)\n .doc(emailSendRateWindowDocId(windowStartMs))\n .get()\n const used = Number((snapshot.exists ? snapshot.get('count') : 0) ?? 0)\n return {\n windowStartMs,\n resetMs,\n used: Number.isFinite(used) && used > 0 ? Math.floor(used) : 0,\n }\n } catch {\n return { windowStartMs, resetMs, used: 0 }\n }\n}\n\n/** Id prefix for the per-org, per-hour campaign counters. */\nexport const EMAIL_ORG_SEND_RATE_WINDOW_PREFIX = 'sendRateOrg_'\n\n/**\n * The per-org counter document id for a window.\n *\n * A separate document per org rather than a map field on the platform window,\n * so two orgs sending in the same hour contend on their own documents instead\n * of serialising on one. The platform window is already a single hot document\n * and adding N org fields to it would make every campaign in the hour a write\n * conflict with every other.\n */\nexport function emailOrgSendRateWindowDocId(\n windowStartMs: number,\n orgId: string,\n): string {\n return `${EMAIL_ORG_SEND_RATE_WINDOW_PREFIX}${windowStartMs}_${orgId}`\n}\n\n/** The current hour for one org. Read-only, for a usage surface. */\nexport async function readOrgEmailSendWindow(options: {\n orgId: string\n firestore?: any\n now?: number\n}): Promise<{ windowStartMs: number; resetMs: number; used: number }> {\n const now = options.now ?? Date.now()\n const windowStartMs = emailSendRateWindowStartMs(now)\n const resetMs = windowStartMs + EMAIL_SEND_RATE_WINDOW_MS\n if (!options.orgId) return { windowStartMs, resetMs, used: 0 }\n try {\n const firestore = options.firestore ?? firebaseAdmin.app().firestore()\n const snapshot = await firestore\n .collection(RATE_LIMIT_COLLECTION)\n .doc(emailOrgSendRateWindowDocId(windowStartMs, options.orgId))\n .get()\n const used = Number((snapshot.exists ? snapshot.get('count') : 0) ?? 0)\n return {\n windowStartMs,\n resetMs,\n used: Number.isFinite(used) && used > 0 ? Math.floor(used) : 0,\n }\n } catch {\n return { windowStartMs, resetMs, used: 0 }\n }\n}\n\n/** The answer to a per-org hourly claim. Every field is a stated number. */\nexport interface OrgEmailSendClaimResult {\n allowed: boolean\n /** Count in this org's window BEFORE this send. */\n used: number\n /** What this org may send in an hour. */\n ceiling: number\n /** Headroom after this send, floored at 0. */\n remaining: number\n /** When the window rolls and a deferred campaign may go. */\n retryAtMs: number\n /** True when the counter was unreachable and this failed open. */\n degraded: boolean\n}\n\n/**\n * THE PER-ORG SHARE OF THE PLATFORM HOUR.\n *\n * The platform governor bounds total volume; it does not bound how much of\n * that total ONE tenant may take. Without this, a single org with a large\n * audience occupies the whole hour and every other customer's campaigns are\n * refused by a ceiling they did nothing to reach — one tenant denying service\n * to the rest, on a limit they cannot see.\n *\n * The ceiling is derived, not configured: `orgHourlyCampaignCeiling` is a\n * share of whatever the live platform ceiling currently is, so a staff ramp\n * moves both together and the two can never drift into contradiction. See\n * `send-ceilings.ts` for the arithmetic and the relations it maintains.\n *\n * **Campaigns only.** This function is not on `sendEmail`'s path and is called\n * from the campaign sender alone. A transactional message can never reach it,\n * which is the same boundary `emailSendRateVerdict` enforces one layer down\n * and for the same reason: a password reset refused by a throttle converts a\n * reputation risk into an outage on somebody else's business.\n *\n * ## Fails OPEN\n *\n * An unreachable counter grants the send, matching `consumeEmailSendBudget`.\n * A refusal produced by a Firestore blip is a refused campaign for a paying\n * customer, and the hour of pacing it buys back is not worth it.\n *\n * ## Claimed, not reconciled\n *\n * The claim is taken for the whole batch and never refunded, unlike the\n * monthly reservation. The window is one hour and TTL-swept, so an\n * undelivered remainder costs the org the rest of that hour and nothing\n * after it — where an unreconciled MONTHLY claim would cost the rest of the\n * month, which is why that one is reconciled and this one is not. The error\n * is in the direction that can only ever pace mail more, never let more out.\n */\nexport async function claimOrgEmailSendBudget(options: {\n orgId: string\n /** Messages this campaign would send. */\n count: number\n /** The live platform ceiling; the org share is derived from it. */\n platformPerHour: number\n /** False parks the control, exactly as the platform governor's flag does. */\n enabled?: boolean\n now?: number\n firestore?: any\n}): Promise<OrgEmailSendClaimResult> {\n const now = options.now ?? Date.now()\n const windowStartMs = emailSendRateWindowStartMs(now)\n const retryAtMs = windowStartMs + EMAIL_SEND_RATE_WINDOW_MS\n const ceiling = orgHourlyCampaignCeiling(options.platformPerHour)\n const count = Math.max(0, Math.floor(Number(options.count) || 0))\n\n const granted = (used: number, degraded: boolean): OrgEmailSendClaimResult => ({\n allowed: true,\n used,\n ceiling,\n remaining: Math.max(0, ceiling - (used + count)),\n retryAtMs,\n degraded,\n })\n\n // A parked control still reports the ceiling, so a surface reading this\n // shows a real number rather than blanking while the control is off.\n if (options.enabled === false) return granted(0, false)\n // No org means the counter has nowhere to live. The monthly reservation\n // refuses in this case because an unattributable campaign must not send\n // unbounded; here the monthly claim has already made that decision, so\n // failing open costs nothing it has not already been charged for.\n if (!options.orgId) return granted(0, true)\n\n try {\n const firestore = options.firestore ?? firebaseAdmin.app().firestore()\n const ref = firestore\n .collection(RATE_LIMIT_COLLECTION)\n .doc(emailOrgSendRateWindowDocId(windowStartMs, options.orgId))\n\n return await firestore.runTransaction(async (tx: any) => {\n const snapshot = await tx.get(ref)\n const rawUsed = Number((snapshot.exists ? snapshot.get('count') : 0) ?? 0)\n // A corrupt or negative counter must not read as headroom.\n const used =\n Number.isFinite(rawUsed) && rawUsed > 0 ? Math.floor(rawUsed) : 0\n if (used + count > ceiling) {\n // A refused claim writes NOTHING. A campaign that will be retried\n // next hour must not have spent budget on being told no.\n return {\n allowed: false,\n used,\n ceiling,\n remaining: Math.max(0, ceiling - used),\n retryAtMs,\n degraded: false,\n }\n }\n // An absolute write from this transaction's own read, not\n // `FieldValue.increment` — the read is the authority for the decision,\n // and an increment would be atomic on the number while proving nothing\n // about the value the decision was made from.\n tx.set(\n ref,\n {\n count: used + count,\n windowStartMs,\n orgId: options.orgId,\n // NOT `lastAtMs` — the rate-limiter health probe queries this\n // collection on that field and a per-hour document in its range\n // would compete with the markers it exists to find.\n sentAtMs: now,\n expiresAt: new Date(retryAtMs + EMAIL_SEND_RATE_WINDOW_MS),\n },\n { merge: true },\n )\n return granted(used, false)\n })\n } catch (error) {\n console.error('[send-rate] org window unavailable — allowing', error)\n await raiseSendRateAlert('deliverability.sendRateUnavailable', {\n error: error instanceof Error ? error.message.slice(0, 200) : String(error),\n })\n return granted(0, true)\n }\n}\n\n/**\n * The document the console writes when staff move the ramp.\n *\n * Returned rather than written so the route owns the write (and the audit row\n * beside it), and so a spec can assert the SHAPE without a Firestore. The one\n * thing it must never contain is `expiresAt`.\n */\nexport function emailSendRateConfigWrite(input: {\n perHour: number\n enabled: boolean\n actorEmail?: string | null\n note?: string\n now?: number\n}): Partial<EmailSendRateConfig> {\n const normalized = normalizeEmailSendRateConfig({\n perHour: input.perHour,\n enabled: input.enabled,\n note: input.note,\n updatedAtMs: input.now ?? Date.now(),\n updatedByEmail: input.actorEmail ?? null,\n })\n return {\n perHour: normalized.perHour,\n enabled: normalized.enabled,\n note: normalized.note,\n updatedAtMs: normalized.updatedAtMs,\n updatedByEmail: normalized.updatedByEmail,\n }\n}\n\n/**\n * Puts the durable governor on `sendEmail`'s path.\n *\n * **Called at module load**, from the bottom of this file, so that importing\n * `@aglyn/tenant-data-admin` anywhere is enough — every server surface in the\n * product already imports that barrel, and `export *` forces this module to\n * evaluate. The alternative, an `installEmailSendGovernor()` call at each\n * server entrypoint, is the 37-places-to-remember shape this codebase already\n * rejected once for the `context` tag.\n *\n * Idempotent: installing twice replaces the same closure with an equivalent\n * one, and the closure holds no state — the state is the Firestore document.\n */\nexport function installEmailSendGovernor(): void {\n setEmailSendGovernor(\n async (\n request: EmailSendGovernorRequest,\n ): Promise<EmailSendGovernorVerdict> => {\n const result = await consumeEmailSendBudget({\n priority: request.priority,\n count: request.count,\n })\n return {\n allowed: result.allowed,\n ceiling: result.ceiling,\n used: result.used,\n remaining: result.remaining,\n retryAtMs: result.retryAtMs,\n degraded: result.degraded,\n }\n },\n )\n}\n\ninstallEmailSendGovernor()\n"],"names":["EMAIL_SEND_RATE_WINDOW_MS","emailSendRateVerdict","emailSendRateWindowStartMs","normalizeEmailSendRateConfig","orgHourlyCampaignCeiling","setEmailSendGovernor","firebaseAdmin","RATE_LIMIT_COLLECTION","EMAIL_SEND_RATE_CONFIG_DOC","EMAIL_SEND_RATE_WINDOW_PREFIX","emailSendRateWindowDocId","windowStartMs","CONFIG_TTL_MS","configCache","configPending","invalidateEmailSendRateConfigCache","undefined","readEmailSendRateConfig","options","now","Date","firestore","at","config","load","app","snapshot","collection","doc","get","exists","data","then","finally","consumeEmailSendBudget","resetMs","fallback","priority","used","count","ceiling","perHour","enabled","degraded","ref","verdict","runTransaction","tx","Number","decision","allowed","set","Math","max","floor","sentAtMs","expiresAt","merge","decided","raiseSendRateAlert","scope","detail","error","console","Error","message","slice","String","type","context","raiseOperatorAlert","dedupeKey","readEmailSendRateWindow","isFinite","EMAIL_ORG_SEND_RATE_WINDOW_PREFIX","emailOrgSendRateWindowDocId","orgId","readOrgEmailSendWindow","claimOrgEmailSendBudget","retryAtMs","platformPerHour","granted","remaining","rawUsed","emailSendRateConfigWrite","input","normalized","note","updatedAtMs","updatedByEmail","actorEmail","installEmailSendGovernor","request","result"],"mappings":"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"}
@@ -56,8 +56,21 @@ export declare function notifyUsers(uids: Iterable<string>, payload: Notificatio
56
56
  * reach them; this enumerates the `staff` custom claim instead. The docs land
57
57
  * at `users/{uid}/notifications`, which the console notifications menu already
58
58
  * renders, so no separate staff inbox is needed. Never throws.
59
+ *
60
+ * A type the operator alert registry names (AGL-3377) — a fraud or risk
61
+ * alert, a dispute nobody owns, a support ticket — is raised as that alert
62
+ * instead: the same console notification, plus staff's switches, the
63
+ * operator email or digest, and the webhook. See `raiseOperatorAlert`.
59
64
  */
60
65
  export declare function notifyStaff(payload: NotificationPayload): Promise<void>;
66
+ /**
67
+ * The staff console notification alone, with the per-person email channel
68
+ * it carries: what `notifyStaff` did before the registry, and what
69
+ * `raiseOperatorAlert` writes as its first step. Never throws.
70
+ */
71
+ export declare function notifyStaffConsole(payload: NotificationPayload, options?: {
72
+ skipEmail?: boolean;
73
+ }): Promise<void>;
61
74
  /** Notifies the org's owner + admins (billing, membership, org events). */
62
75
  export declare function notifyOrgAdmins(orgId: string, payload: NotificationPayload): Promise<void>;
63
76
  /**
@@ -14,7 +14,8 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
14
14
  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
15
  * See the License for the specific language governing permissions and
16
16
  * limitations under the License.
17
- */ import { buildRoute, NOTIFICATION_SELF_SENT_EMAIL_TYPES, NOTIFICATION_SETTINGS_FIELD, OPERATOR_ALERT_NOTIFICATION_TYPES, normalizeNotificationLink, notificationChannelEnabled, Route } from "@aglyn/aglyn/server";
17
+ */ import { buildRoute, NOTIFICATION_SELF_SENT_EMAIL_TYPES, NOTIFICATION_SETTINGS_FIELD, normalizeNotificationLink, notificationChannelEnabled, Route } from "@aglyn/aglyn/server";
18
+ import { operatorAlertForNotificationType } from "@aglyn/aglyn/plugin-manager/operator-alerts";
18
19
  import { isEmailConfigured, sendEmail } from "@aglyn/shared-util-email";
19
20
  import { FieldValue } from "firebase-admin/firestore";
20
21
  import { findUserByUidAcrossPools, listStaffUidsAcrossPools } from "./auth-pools.js";
@@ -22,7 +23,6 @@ import { filterSuppressedEmails } from "./email-suppression.js";
22
23
  import { meterOrgEmail, meterPlatformEmail } from "./email-metering.js";
23
24
  import firebaseAdmin from "./firebase-admin.js";
24
25
  import { listOrgMembers } from "./organizations.js";
25
- import { resolveStaffAlertRecipients, sendOperatorAlertEmail } from "./staff-alert-email.js";
26
26
  import { loadSystemEmail, renderLoadedSystemEmail, systemEmailBrand, systemEmailContent } from "./render-system-email.js";
27
27
  const firestore = ()=>firebaseAdmin.app().firestore();
28
28
  /** The console's absolute origin for an email link, or `''` when unset. */ function consoleOrigin() {
@@ -262,37 +262,49 @@ async function listStaffUids() {
262
262
  * reach them; this enumerates the `staff` custom claim instead. The docs land
263
263
  * at `users/{uid}/notifications`, which the console notifications menu already
264
264
  * renders, so no separate staff inbox is needed. Never throws.
265
+ *
266
+ * A type the operator alert registry names (AGL-3377) — a fraud or risk
267
+ * alert, a dispute nobody owns, a support ticket — is raised as that alert
268
+ * instead: the same console notification, plus staff's switches, the
269
+ * operator email or digest, and the webhook. See `raiseOperatorAlert`.
265
270
  */ export async function notifyStaff(payload) {
266
271
  try {
267
- // A fraud or risk alert also reaches the operator's inbox by email
268
- // (AGL-3375), on every install and whatever anyone switched on. Whether
269
- // there is an inbox is settled first, so the per-person channel stands
270
- // down only when the alert is actually going somewhere.
271
- const operatorAlert = OPERATOR_ALERT_NOTIFICATION_TYPES.has(payload.type) && isEmailConfigured() && (await resolveStaffAlertRecipients()).length > 0;
272
+ const alert = operatorAlertForNotificationType(payload.type);
273
+ if (alert) {
274
+ const { raiseOperatorAlert } = await import("./operator-alerts.js");
275
+ await raiseOperatorAlert(alert, _extends({
276
+ subject: payload.title
277
+ }, payload.body ? {
278
+ body: payload.body
279
+ } : {}, payload.link ? {
280
+ url: payload.link
281
+ } : {}, payload.orgId ? {
282
+ orgId: payload.orgId
283
+ } : {}, payload.hostId ? {
284
+ hostId: payload.hostId
285
+ } : {}));
286
+ return;
287
+ }
288
+ await notifyStaffConsole(payload);
289
+ } catch (error) {
290
+ console.error('staff notification failed', error);
291
+ }
292
+ }
293
+ /**
294
+ * The staff console notification alone, with the per-person email channel
295
+ * it carries: what `notifyStaff` did before the registry, and what
296
+ * `raiseOperatorAlert` writes as its first step. Never throws.
297
+ */ export async function notifyStaffConsole(payload, options = {}) {
298
+ try {
272
299
  await notifyUsers(await listStaffUids(), payload, _extends({
273
300
  audience: 'staff'
274
- }, operatorAlert ? {
301
+ }, options.skipEmail ? {
275
302
  skipEmail: true
276
303
  } : {}));
277
- if (operatorAlert) {
278
- await sendOperatorAlertEmail({
279
- title: payload.title,
280
- body: payload.body,
281
- url: absoluteConsoleLink(payload.link),
282
- context: `operator-alert ${payload.type}`
283
- });
284
- }
285
304
  } catch (error) {
286
305
  console.error('staff notification failed', error);
287
306
  }
288
307
  }
289
- /** A notification link as an inbox can follow it, or `''`. */ function absoluteConsoleLink(link) {
290
- const value = String(link != null ? link : '').trim();
291
- if (!value) return '';
292
- if (/^https?:\/\//i.test(value)) return value;
293
- const origin = consoleOrigin();
294
- return origin && value.startsWith('/') ? `${origin}${value}` : '';
295
- }
296
308
  /** Notifies the org's owner + admins (billing, membership, org events). */ export async function notifyOrgAdmins(orgId, payload) {
297
309
  try {
298
310
  const members = await listOrgMembers(orgId);
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/notifications.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 {\n buildRoute,\n NOTIFICATION_SELF_SENT_EMAIL_TYPES,\n NOTIFICATION_SETTINGS_FIELD,\n OPERATOR_ALERT_NOTIFICATION_TYPES,\n normalizeNotificationLink,\n notificationChannelEnabled,\n type AglynNotification,\n Route,\n type NotificationSettings,\n} from '@aglyn/aglyn/server'\nimport { isEmailConfigured, sendEmail } from '@aglyn/shared-util-email'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { findUserByUidAcrossPools, listStaffUidsAcrossPools } from './auth-pools'\nimport { filterSuppressedEmails } from './email-suppression'\nimport { meterOrgEmail, meterPlatformEmail } from './email-metering'\nimport firebaseAdmin from './firebase-admin'\nimport { listOrgMembers } from './organizations'\nimport {\n resolveStaffAlertRecipients,\n sendOperatorAlertEmail,\n} from './staff-alert-email'\nimport {\n loadSystemEmail,\n renderLoadedSystemEmail,\n systemEmailBrand,\n systemEmailContent,\n type SystemEmailBrand,\n} from './render-system-email'\n\nconst firestore = () => firebaseAdmin.app().firestore()\n\nexport type NotificationPayload = Omit<\n AglynNotification,\n '$id' | 'createdAt' | 'readAt'\n>\n\nexport interface NotifyUsersOptions {\n /**\n * Addresses the caller already holds, by uid (AGL-3224).\n *\n * Purely an optimization, and one worth taking where it is free: a caller\n * that read the roster — `notifyOrgAdmins` does — has every address in hand\n * already, and without this the email half would look each one up again in\n * the directory. Absent uids fall back to that lookup, so passing a partial\n * map is fine and passing none is correct.\n */\n emails?: Readonly<Record<string, string | null | undefined>>\n /**\n * Who the fan-out is for (AGL-3367). `staff` is the platform talking to its\n * own people, so the email wears the platform's brand and is the platform's\n * cost even when the notification is ABOUT an org, which a canceled\n * subscription or a new ticket always is. Anything else speaks in the org's\n * brand when the notification names one.\n */\n audience?: 'staff'\n /**\n * Write the console notification only, and mail nobody through the\n * per-person email channel (AGL-3375): the caller has emailed the same\n * alert to the operator's inbox, and a staff member who also switched\n * email on would otherwise get it twice.\n */\n skipEmail?: boolean\n}\n\n/** The console's absolute origin for an email link, or `''` when unset. */\nfunction consoleOrigin(): string {\n return (process.env.NEXT_PUBLIC_CONSOLE_URL ?? '').trim().replace(/\\/+$/, '')\n}\n\n/**\n * At most this many directory lookups per fan-out, and at most this many\n * notification emails.\n *\n * Both ceilings only ever bite on a fan-out where many recipients have\n * OPTED IN — the email channel defaults off, so the ordinary notification\n * costs exactly what it cost before this existed. They are here because a\n * fan-out takes up to 400 uids and `notifyUsers` runs inside the mutation\n * that emitted it: a notification must not be able to turn one write into\n * four hundred directory reads and four hundred sends.\n */\nconst NOTIFY_EMAIL_MAX_LOOKUPS = 25\nconst NOTIFY_EMAIL_MAX_SENDS = 50\n\n/**\n * The email beside the console notification (AGL-3224).\n *\n * One send per recipient, never a shared `to:` list — two people who manage\n * the same site have not agreed to be shown each other's addresses, and the\n * settings link in the footer belongs to one person.\n *\n * `List-Unsubscribe` points at the settings page rather than at a one-click\n * endpoint: this is not marketing mail and RFC 8058's POST form would be\n * claiming a capability that does not exist. What it does do is put the way\n * out in the headers as well as the body, so a client that surfaces it shows\n * the person the page where the switch they want actually is.\n *\n * Rendered through the `notification` system email (AGL-3367), which is what\n * gives it the header and footer every other system email wears, and makes\n * it designable on the staff System emails page. It used to hand `sendEmail`\n * bare text, which is the unbranded card the fallback HTML part draws. The\n * template is read once per fan-out and rendered per recipient.\n *\n * Never throws, like everything else on this path.\n */\nasync function emailNotification(\n uids: string[],\n payload: NotificationPayload,\n known: Readonly<Record<string, string | null | undefined>>,\n audience: NotifyUsersOptions['audience'],\n): Promise<void> {\n const origin = consoleOrigin()\n const settingsUrl = `${origin}${buildRoute(Route.MANAGE_NOTIFICATION_SETTINGS)}`\n const context = await notificationEmailContext(payload, audience)\n const url = notificationEmailUrl(payload, context, origin)\n const loaded = await loadSystemEmail('notification').catch(() => null)\n const fallbackText = [\n payload.title,\n payload.body ?? '',\n url,\n origin ? `Change what you are emailed about: ${settingsUrl}` : '',\n ]\n .filter(Boolean)\n .join('\\n\\n')\n let lookups = 0\n let sent = 0\n for (const uid of uids) {\n if (sent >= NOTIFY_EMAIL_MAX_SENDS) break\n let address = String(known[uid] ?? '').trim().toLowerCase()\n if (!address.includes('@')) {\n if (lookups >= NOTIFY_EMAIL_MAX_LOOKUPS) continue\n lookups += 1\n const pooled = await findUserByUidAcrossPools(uid).catch(() => null)\n address = String(pooled?.record?.email ?? '').trim().toLowerCase()\n }\n if (!address.includes('@')) continue\n const recipients = await filterSuppressedEmails([address])\n if (!recipients.length) continue\n const rendered = loaded\n ? renderLoadedSystemEmail(\n loaded,\n {\n ...context.brand.merge,\n 'notification.title': payload.title,\n 'notification.body': payload.body ?? '',\n 'notification.url': url,\n settingsUrl: origin ? settingsUrl : '',\n },\n context.brand.options,\n )\n : null\n const result = await sendEmail({\n to: recipients,\n ...systemEmailContent(rendered, {\n subject: payload.title,\n text: fallbackText,\n }),\n ...(context.brand.fromName ? { fromName: context.brand.fromName } : {}),\n context: 'notification',\n ...(origin\n ? { headers: { 'List-Unsubscribe': `<${settingsUrl}>` } }\n : {}),\n })\n if (!result.sent) continue\n sent += 1\n // Whose cost it is: a notification to a workspace's people is that\n // workspace's mail, and a staff alert or an account-level notice is the\n // platform's, whichever org it is about.\n await (payload.orgId && audience !== 'staff'\n ? meterOrgEmail(payload.orgId)\n : meterPlatformEmail()\n ).catch(() => undefined)\n }\n}\n\n/** What a notification email needs from the org and site it is about. */\ninterface NotificationEmailContext {\n brand: SystemEmailBrand\n orgSlug?: string\n hostSubdomain?: string\n}\n\n/**\n * The org's brand, and the slug and subdomain its stored link is rewritten\n * with (AGL-3367): at most two reads per fan-out, made only when someone is\n * being emailed.\n *\n * A staff fan-out reads nothing. Its brand is the platform's, and its links\n * are staff routes that need no rewriting.\n */\nasync function notificationEmailContext(\n payload: NotificationPayload,\n audience: NotifyUsersOptions['audience'],\n): Promise<NotificationEmailContext> {\n if (audience === 'staff' || !payload.orgId) {\n return { brand: systemEmailBrand(null) }\n }\n try {\n const db = firestore()\n const [org, host] = await Promise.all([\n db.collection('orgs').doc(payload.orgId).get(),\n payload.hostId\n ? db.collection('hosts').doc(payload.hostId).get()\n : Promise.resolve(null),\n ])\n const slug = org.get('slug')\n const subdomain = host?.get('subdomain')\n return {\n brand: systemEmailBrand(org.exists ? (org.data() ?? null) : null),\n ...(typeof slug === 'string' && slug ? { orgSlug: slug } : {}),\n ...(typeof subdomain === 'string' && subdomain\n ? { hostSubdomain: subdomain }\n : {}),\n }\n } catch (error) {\n console.error('notification email context failed', error)\n return { brand: systemEmailBrand(null) }\n }\n}\n\n/**\n * Where the email's button goes: the notification's own link, rewritten onto\n * today's routes the way the console rewrites it when it is clicked, else\n * the notifications page. A stored host link is `/{hostDocId}/…`, which only\n * that rewrite turns into a page that exists. Empty when the deployment has\n * no console origin to make a link absolute with.\n */\nfunction notificationEmailUrl(\n payload: NotificationPayload,\n context: NotificationEmailContext,\n origin: string,\n): string {\n const link = normalizeNotificationLink(payload.link, {\n orgSlug: context.orgSlug,\n hostId: payload.hostId,\n hostSubdomain: context.hostSubdomain,\n })\n if (link && /^https?:\\/\\//i.test(link)) return link\n if (!origin) return ''\n return `${origin}${link?.startsWith('/') ? link : buildRoute(Route.MANAGE_NOTIFICATIONS)}`\n}\n\n/**\n * Notification fan-out (AGL-259): batch-writes one doc per recipient at\n * `users/{uid}/notifications`, and — for recipients who asked for it\n * (AGL-3224) — sends one email beside it. Never throws: a notification miss,\n * or a send that failed, must not break the mutation that emitted it.\n */\nexport async function notifyUsers(\n uids: Iterable<string>,\n payload: NotificationPayload,\n options: NotifyUsersOptions = {},\n): Promise<void> {\n try {\n const db = firestore()\n const targets = [...new Set(uids)].filter(Boolean).slice(0, 400)\n if (!targets.length) return\n // Per-user preferences (AGL-267, AGL-3223): one getAll over the user\n // docs, which now answers for both channels and for all three scopes —\n // the settings live on this document precisely so that stays one read.\n const userDocs = await db.getAll(\n ...targets.map((uid) => db.collection('users').doc(uid)),\n )\n const scope = { orgId: payload.orgId, hostId: payload.hostId }\n const batch = db.batch()\n let count = 0\n const mailTo: string[] = []\n for (const userDoc of userDocs) {\n const settings = userDoc.get(NOTIFICATION_SETTINGS_FIELD) as\n | NotificationSettings\n | undefined\n const legacy = userDoc.get('notificationPrefs') as\n | Record<string, boolean>\n | undefined\n if (\n notificationChannelEnabled(\n settings,\n 'console',\n payload.type,\n scope,\n legacy,\n )\n ) {\n batch.set(\n db\n .collection('users')\n .doc(userDoc.id)\n .collection('notifications')\n .doc(),\n // `read: false` on create, so the feed can ask for unread and read\n // alike by equality (AGL-3321; see `AglynNotification.read`).\n { ...payload, read: false, createdAt: FieldValue.serverTimestamp() },\n )\n count += 1\n }\n if (\n notificationChannelEnabled(settings, 'email', payload.type, scope, legacy)\n ) {\n mailTo.push(userDoc.id)\n }\n }\n if (count > 0) await batch.commit()\n /*\n * THE EMAIL AFTER THE COMMIT, and not inside its `try`.\n *\n * The console notification is the durable record and the send is a\n * courtesy on top of it, so the order is the one where a failing network\n * call cannot lose the record. The two channels are independent by\n * design — a person may have muted the feed and asked for mail, or the\n * reverse — so neither is gated on the other having happened.\n *\n * Digests are excluded: they compose and send their own mail, under\n * their own switches, and the generic channel would send a second\n * message announcing that the first one had been sent.\n */\n if (\n mailTo.length &&\n !options.skipEmail &&\n isEmailConfigured() &&\n !NOTIFICATION_SELF_SENT_EMAIL_TYPES.has(payload.type)\n ) {\n await emailNotification(\n mailTo,\n payload,\n options.emails ?? {},\n options.audience,\n )\n }\n } catch (error) {\n console.error('notification fan-out failed', error)\n }\n}\n\n// Staff are identified by a Firebase Auth custom claim (`staff`), which is not\n// a Firestore query — so the roster comes from paginating the auth users. A\n// burst of ticket activity shouldn't rescan every time, so the (small) result\n// is cached briefly. Fails soft to an empty list.\nlet staffUidCache: { uids: string[]; at: number } | null = null\nconst STAFF_CACHE_MS = 60_000\n\nasync function listStaffUids(): Promise<string[]> {\n const now = Date.now()\n if (staffUidCache && now - staffUidCache.at < STAFF_CACHE_MS) {\n return staffUidCache.uids\n }\n // Across ALL auth pools (AGL-1122). This scanned only the project pool, so\n // a staff member who signs in through enterprise SSO — whose account lives\n // in a GCIP tenant pool — was never in the fan-out. Nothing errored; the\n // notification simply never arrived, which is the worst shape a miss can\n // take on an alerting path.\n const uids = await listStaffUidsAcrossPools()\n staffUidCache = { uids, at: now }\n return uids\n}\n\n/**\n * Notifies every staff-claim holder (AGL-850) — the support-desk audience.\n * Staff are not org members, so `notifyOrgAdmins`/`notifyHostManagers` never\n * reach them; this enumerates the `staff` custom claim instead. The docs land\n * at `users/{uid}/notifications`, which the console notifications menu already\n * renders, so no separate staff inbox is needed. Never throws.\n */\nexport async function notifyStaff(payload: NotificationPayload): Promise<void> {\n try {\n // A fraud or risk alert also reaches the operator's inbox by email\n // (AGL-3375), on every install and whatever anyone switched on. Whether\n // there is an inbox is settled first, so the per-person channel stands\n // down only when the alert is actually going somewhere.\n const operatorAlert =\n OPERATOR_ALERT_NOTIFICATION_TYPES.has(payload.type) &&\n isEmailConfigured() &&\n (await resolveStaffAlertRecipients()).length > 0\n await notifyUsers(await listStaffUids(), payload, {\n audience: 'staff',\n ...(operatorAlert ? { skipEmail: true } : {}),\n })\n if (operatorAlert) {\n await sendOperatorAlertEmail({\n title: payload.title,\n body: payload.body,\n url: absoluteConsoleLink(payload.link),\n context: `operator-alert ${payload.type}`,\n })\n }\n } catch (error) {\n console.error('staff notification failed', error)\n }\n}\n\n/** A notification link as an inbox can follow it, or `''`. */\nfunction absoluteConsoleLink(link: string | undefined): 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/** Notifies the org's owner + admins (billing, membership, org events). */\nexport async function notifyOrgAdmins(\n orgId: string,\n payload: NotificationPayload,\n): Promise<void> {\n try {\n const members = await listOrgMembers(orgId)\n const admins = members.filter(\n (member) => member.role === 'owner' || member.role === 'admin',\n )\n // The roster rows carry addresses, so the email channel (AGL-3224) needs\n // no directory lookup for anyone reached this way.\n const emails: Record<string, string | undefined> = {}\n for (const member of admins) {\n if (member.email) emails[member.$id] = member.email\n }\n await notifyUsers(\n admins.map((member) => member.$id),\n { ...payload, orgId },\n { emails },\n )\n } catch (error) {\n console.error('org admin notification failed', error)\n }\n}\n\n/**\n * Notifies everyone who manages a host (admin/editor in the host doc's\n * `memberRoles` projection) — the audience for form submissions and\n * bookings on that site.\n *\n * The OWNING ORG travels with the notification (AGL-1773). Host links are\n * stored in the legacy `/{hostDocId}/rest` shape and rewritten to\n * `/{orgSlug}/hosts/{subdomain}/rest` when they are FOLLOWED\n * (`normalizeNotificationLink`, AGL-644) — a rewrite that needs an org slug.\n * With no `orgId` on the doc the console had nothing to key on and fell back\n * to whichever org the reader happens to have OPEN, so a manager who belongs\n * to more than one workspace was routed to `/{other-org}/hosts/{subdomain}/…`\n * and got a designed 404: `HostGuard` only resolves subdomains belonging to\n * the current org. `notifyOrgAdmins` has always stamped it; the host fan-out\n * — every form submission, booking, order and stock alert — never did.\n *\n * The host doc already carries `orgId` (AGL-233), so this costs no extra\n * read. Spread conditionally: the field is optional on the host model and\n * Firestore rejects `undefined`.\n */\nexport async function notifyHostManagers(\n hostId: string,\n payload: NotificationPayload,\n): Promise<void> {\n try {\n const host = await firestore().collection('hosts').doc(hostId).get()\n const memberRoles =\n (host.get('memberRoles') as Record<string, string> | undefined) ?? {}\n const managers = Object.entries(memberRoles)\n .filter(([, role]) => role === 'admin' || role === 'editor')\n .map(([uid]) => uid)\n const orgId = payload.orgId ?? (host.get('orgId') as string | undefined)\n await notifyUsers(managers, {\n ...payload,\n hostId,\n ...(orgId ? { orgId } : {}),\n })\n } catch (error) {\n console.error('host manager notification failed', error)\n }\n}\n"],"names":["buildRoute","NOTIFICATION_SELF_SENT_EMAIL_TYPES","NOTIFICATION_SETTINGS_FIELD","OPERATOR_ALERT_NOTIFICATION_TYPES","normalizeNotificationLink","notificationChannelEnabled","Route","isEmailConfigured","sendEmail","FieldValue","findUserByUidAcrossPools","listStaffUidsAcrossPools","filterSuppressedEmails","meterOrgEmail","meterPlatformEmail","firebaseAdmin","listOrgMembers","resolveStaffAlertRecipients","sendOperatorAlertEmail","loadSystemEmail","renderLoadedSystemEmail","systemEmailBrand","systemEmailContent","firestore","app","consoleOrigin","process","env","NEXT_PUBLIC_CONSOLE_URL","trim","replace","NOTIFY_EMAIL_MAX_LOOKUPS","NOTIFY_EMAIL_MAX_SENDS","emailNotification","uids","payload","known","audience","origin","settingsUrl","MANAGE_NOTIFICATION_SETTINGS","context","notificationEmailContext","url","notificationEmailUrl","loaded","catch","fallbackText","title","body","filter","Boolean","join","lookups","sent","uid","address","String","toLowerCase","includes","pooled","record","email","recipients","length","rendered","brand","merge","options","result","to","subject","text","fromName","headers","orgId","undefined","org","db","host","Promise","all","collection","doc","get","hostId","resolve","slug","subdomain","exists","data","orgSlug","hostSubdomain","error","console","link","test","startsWith","MANAGE_NOTIFICATIONS","notifyUsers","targets","Set","slice","userDocs","getAll","map","scope","batch","count","mailTo","userDoc","settings","legacy","type","set","id","read","createdAt","serverTimestamp","push","commit","skipEmail","has","emails","staffUidCache","STAFF_CACHE_MS","listStaffUids","now","Date","at","notifyStaff","operatorAlert","absoluteConsoleLink","value","notifyOrgAdmins","members","admins","member","role","$id","notifyHostManagers","memberRoles","managers","Object","entries"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SACEA,UAAU,EACVC,kCAAkC,EAClCC,2BAA2B,EAC3BC,iCAAiC,EACjCC,yBAAyB,EACzBC,0BAA0B,EAE1BC,KAAK,QAEA,sBAAqB;AAC5B,SAASC,iBAAiB,EAAEC,SAAS,QAAQ,2BAA0B;AACvE,SAASC,UAAU,QAAQ,2BAA0B;AACrD,SAASC,wBAAwB,EAAEC,wBAAwB,QAAQ,kBAAc;AACjF,SAASC,sBAAsB,QAAQ,yBAAqB;AAC5D,SAASC,aAAa,EAAEC,kBAAkB,QAAQ,sBAAkB;AACpE,OAAOC,mBAAmB,sBAAkB;AAC5C,SAASC,cAAc,QAAQ,qBAAiB;AAChD,SACEC,2BAA2B,EAC3BC,sBAAsB,QACjB,yBAAqB;AAC5B,SACEC,eAAe,EACfC,uBAAuB,EACvBC,gBAAgB,EAChBC,kBAAkB,QAEb,2BAAuB;AAE9B,MAAMC,YAAY,IAAMR,cAAcS,GAAG,GAAGD,SAAS;AAmCrD,yEAAyE,GACzE,SAASE;QACCC;IAAR,OAAO,EAACA,uCAAAA,QAAQC,GAAG,CAACC,uBAAuB,YAAnCF,uCAAuC,IAAIG,IAAI,GAAGC,OAAO,CAAC,QAAQ;AAC5E;AAEA;;;;;;;;;;CAUC,GACD,MAAMC,2BAA2B;AACjC,MAAMC,yBAAyB;AAE/B;;;;;;;;;;;;;;;;;;;;CAoBC,GACD,eAAeC,kBACbC,IAAc,EACdC,OAA4B,EAC5BC,KAA0D,EAC1DC,QAAwC;QAStCF;IAPF,MAAMG,SAASb;IACf,MAAMc,cAAc,GAAGD,SAAStC,WAAWM,MAAMkC,4BAA4B,GAAG;IAChF,MAAMC,UAAU,MAAMC,yBAAyBP,SAASE;IACxD,MAAMM,MAAMC,qBAAqBT,SAASM,SAASH;IACnD,MAAMO,SAAS,MAAM1B,gBAAgB,gBAAgB2B,KAAK,CAAC,IAAM;IACjE,MAAMC,eAAe;QACnBZ,QAAQa,KAAK;SACbb,gBAAAA,QAAQc,IAAI,YAAZd,gBAAgB;QAChBQ;QACAL,SAAS,CAAC,mCAAmC,EAAEC,aAAa,GAAG;KAChE,CACEW,MAAM,CAACC,SACPC,IAAI,CAAC;IACR,IAAIC,UAAU;IACd,IAAIC,OAAO;IACX,KAAK,MAAMC,OAAOrB,KAAM;YAEDE,YAgBQD;QAjB7B,IAAImB,QAAQtB,wBAAwB;QACpC,IAAIwB,UAAUC,QAAOrB,aAAAA,KAAK,CAACmB,IAAI,YAAVnB,aAAc,IAAIP,IAAI,GAAG6B,WAAW;QACzD,IAAI,CAACF,QAAQG,QAAQ,CAAC,MAAM;;gBAITC;YAHjB,IAAIP,WAAWtB,0BAA0B;YACzCsB,WAAW;YACX,MAAMO,SAAS,MAAMlD,yBAAyB6C,KAAKT,KAAK,CAAC,IAAM;YAC/DU,UAAUC,eAAOG,2BAAAA,iBAAAA,OAAQC,MAAM,qBAAdD,eAAgBE,KAAK,mBAAI,IAAIjC,IAAI,GAAG6B,WAAW;QAClE;QACA,IAAI,CAACF,QAAQG,QAAQ,CAAC,MAAM;QAC5B,MAAMI,aAAa,MAAMnD,uBAAuB;YAAC4C;SAAQ;QACzD,IAAI,CAACO,WAAWC,MAAM,EAAE;QACxB,MAAMC,WAAWpB,SACbzB,wBACEyB,QACA,aACKJ,QAAQyB,KAAK,CAACC,KAAK;YACtB,sBAAsBhC,QAAQa,KAAK;YACnC,mBAAmB,GAAEb,iBAAAA,QAAQc,IAAI,YAAZd,iBAAgB;YACrC,oBAAoBQ;YACpBJ,aAAaD,SAASC,cAAc;YAEtCE,QAAQyB,KAAK,CAACE,OAAO,IAEvB;QACJ,MAAMC,SAAS,MAAM7D,UAAU;YAC7B8D,IAAIP;WACDzC,mBAAmB2C,UAAU;YAC9BM,SAASpC,QAAQa,KAAK;YACtBwB,MAAMzB;QACR,IACIN,QAAQyB,KAAK,CAACO,QAAQ,GAAG;YAAEA,UAAUhC,QAAQyB,KAAK,CAACO,QAAQ;QAAC,IAAI,CAAC;YACrEhC,SAAS;WACLH,SACA;YAAEoC,SAAS;gBAAE,oBAAoB,CAAC,CAAC,EAAEnC,YAAY,CAAC,CAAC;YAAC;QAAE,IACtD,CAAC;QAEP,IAAI,CAAC8B,OAAOf,IAAI,EAAE;QAClBA,QAAQ;QACR,mEAAmE;QACnE,wEAAwE;QACxE,yCAAyC;QACzC,MAAM,AAACnB,CAAAA,QAAQwC,KAAK,IAAItC,aAAa,UACjCxB,cAAcsB,QAAQwC,KAAK,IAC3B7D,oBAAmB,EACrBgC,KAAK,CAAC,IAAM8B;IAChB;AACF;AASA;;;;;;;CAOC,GACD,eAAelC,yBACbP,OAA4B,EAC5BE,QAAwC;IAExC,IAAIA,aAAa,WAAW,CAACF,QAAQwC,KAAK,EAAE;QAC1C,OAAO;YAAET,OAAO7C,iBAAiB;QAAM;IACzC;IACA,IAAI;YAWsCwD;QAVxC,MAAMC,KAAKvD;QACX,MAAM,CAACsD,KAAKE,KAAK,GAAG,MAAMC,QAAQC,GAAG,CAAC;YACpCH,GAAGI,UAAU,CAAC,QAAQC,GAAG,CAAChD,QAAQwC,KAAK,EAAES,GAAG;YAC5CjD,QAAQkD,MAAM,GACVP,GAAGI,UAAU,CAAC,SAASC,GAAG,CAAChD,QAAQkD,MAAM,EAAED,GAAG,KAC9CJ,QAAQM,OAAO,CAAC;SACrB;QACD,MAAMC,OAAOV,IAAIO,GAAG,CAAC;QACrB,MAAMI,YAAYT,wBAAAA,KAAMK,GAAG,CAAC;QAC5B,OAAO;YACLlB,OAAO7C,iBAAiBwD,IAAIY,MAAM,IAAIZ,YAAAA,IAAIa,IAAI,cAARb,YAAc,OAAQ;WACxD,OAAOU,SAAS,YAAYA,OAAO;YAAEI,SAASJ;QAAK,IAAI,CAAC,GACxD,OAAOC,cAAc,YAAYA,YACjC;YAAEI,eAAeJ;QAAU,IAC3B,CAAC;IAET,EAAE,OAAOK,OAAO;QACdC,QAAQD,KAAK,CAAC,qCAAqCA;QACnD,OAAO;YAAE3B,OAAO7C,iBAAiB;QAAM;IACzC;AACF;AAEA;;;;;;CAMC,GACD,SAASuB,qBACPT,OAA4B,EAC5BM,OAAiC,EACjCH,MAAc;IAEd,MAAMyD,OAAO3F,0BAA0B+B,QAAQ4D,IAAI,EAAE;QACnDJ,SAASlD,QAAQkD,OAAO;QACxBN,QAAQlD,QAAQkD,MAAM;QACtBO,eAAenD,QAAQmD,aAAa;IACtC;IACA,IAAIG,QAAQ,gBAAgBC,IAAI,CAACD,OAAO,OAAOA;IAC/C,IAAI,CAACzD,QAAQ,OAAO;IACpB,OAAO,GAAGA,SAASyD,CAAAA,wBAAAA,KAAME,UAAU,CAAC,QAAOF,OAAO/F,WAAWM,MAAM4F,oBAAoB,GAAG;AAC5F;AAEA;;;;;CAKC,GACD,OAAO,eAAeC,YACpBjE,IAAsB,EACtBC,OAA4B,EAC5BiC,UAA8B,CAAC,CAAC;IAEhC,IAAI;QACF,MAAMU,KAAKvD;QACX,MAAM6E,UAAU;eAAI,IAAIC,IAAInE;SAAM,CAACgB,MAAM,CAACC,SAASmD,KAAK,CAAC,GAAG;QAC5D,IAAI,CAACF,QAAQpC,MAAM,EAAE;QACrB,qEAAqE;QACrE,uEAAuE;QACvE,uEAAuE;QACvE,MAAMuC,WAAW,MAAMzB,GAAG0B,MAAM,IAC3BJ,QAAQK,GAAG,CAAC,CAAClD,MAAQuB,GAAGI,UAAU,CAAC,SAASC,GAAG,CAAC5B;QAErD,MAAMmD,QAAQ;YAAE/B,OAAOxC,QAAQwC,KAAK;YAAEU,QAAQlD,QAAQkD,MAAM;QAAC;QAC7D,MAAMsB,QAAQ7B,GAAG6B,KAAK;QACtB,IAAIC,QAAQ;QACZ,MAAMC,SAAmB,EAAE;QAC3B,KAAK,MAAMC,WAAWP,SAAU;YAC9B,MAAMQ,WAAWD,QAAQ1B,GAAG,CAAClF;YAG7B,MAAM8G,SAASF,QAAQ1B,GAAG,CAAC;YAG3B,IACE/E,2BACE0G,UACA,WACA5E,QAAQ8E,IAAI,EACZP,OACAM,SAEF;gBACAL,MAAMO,GAAG,CACPpC,GACGI,UAAU,CAAC,SACXC,GAAG,CAAC2B,QAAQK,EAAE,EACdjC,UAAU,CAAC,iBACXC,GAAG,IACN,mEAAmE;gBACnE,8DAA8D;gBAC9D,aAAKhD;oBAASiF,MAAM;oBAAOC,WAAW5G,WAAW6G,eAAe;;gBAElEV,SAAS;YACX;YACA,IACEvG,2BAA2B0G,UAAU,SAAS5E,QAAQ8E,IAAI,EAAEP,OAAOM,SACnE;gBACAH,OAAOU,IAAI,CAACT,QAAQK,EAAE;YACxB;QACF;QACA,IAAIP,QAAQ,GAAG,MAAMD,MAAMa,MAAM;QACjC;;;;;;;;;;;;KAYC,GACD,IACEX,OAAO7C,MAAM,IACb,CAACI,QAAQqD,SAAS,IAClBlH,uBACA,CAACN,mCAAmCyH,GAAG,CAACvF,QAAQ8E,IAAI,GACpD;gBAIE7C;YAHF,MAAMnC,kBACJ4E,QACA1E,UACAiC,kBAAAA,QAAQuD,MAAM,YAAdvD,kBAAkB,CAAC,GACnBA,QAAQ/B,QAAQ;QAEpB;IACF,EAAE,OAAOwD,OAAO;QACdC,QAAQD,KAAK,CAAC,+BAA+BA;IAC/C;AACF;AAEA,+EAA+E;AAC/E,4EAA4E;AAC5E,8EAA8E;AAC9E,kDAAkD;AAClD,IAAI+B,gBAAuD;AAC3D,MAAMC,iBAAiB;AAEvB,eAAeC;IACb,MAAMC,MAAMC,KAAKD,GAAG;IACpB,IAAIH,iBAAiBG,MAAMH,cAAcK,EAAE,GAAGJ,gBAAgB;QAC5D,OAAOD,cAAc1F,IAAI;IAC3B;IACA,2EAA2E;IAC3E,2EAA2E;IAC3E,yEAAyE;IACzE,yEAAyE;IACzE,4BAA4B;IAC5B,MAAMA,OAAO,MAAMvB;IACnBiH,gBAAgB;QAAE1F;QAAM+F,IAAIF;IAAI;IAChC,OAAO7F;AACT;AAEA;;;;;;CAMC,GACD,OAAO,eAAegG,YAAY/F,OAA4B;IAC5D,IAAI;QACF,mEAAmE;QACnE,wEAAwE;QACxE,uEAAuE;QACvE,wDAAwD;QACxD,MAAMgG,gBACJhI,kCAAkCuH,GAAG,CAACvF,QAAQ8E,IAAI,KAClD1G,uBACA,AAAC,CAAA,MAAMU,6BAA4B,EAAG+C,MAAM,GAAG;QACjD,MAAMmC,YAAY,MAAM2B,iBAAiB3F,SAAS;YAChDE,UAAU;WACN8F,gBAAgB;YAAEV,WAAW;QAAK,IAAI,CAAC;QAE7C,IAAIU,eAAe;YACjB,MAAMjH,uBAAuB;gBAC3B8B,OAAOb,QAAQa,KAAK;gBACpBC,MAAMd,QAAQc,IAAI;gBAClBN,KAAKyF,oBAAoBjG,QAAQ4D,IAAI;gBACrCtD,SAAS,CAAC,eAAe,EAAEN,QAAQ8E,IAAI,EAAE;YAC3C;QACF;IACF,EAAE,OAAOpB,OAAO;QACdC,QAAQD,KAAK,CAAC,6BAA6BA;IAC7C;AACF;AAEA,4DAA4D,GAC5D,SAASuC,oBAAoBrC,IAAwB;IACnD,MAAMsC,QAAQ5E,OAAOsC,eAAAA,OAAQ,IAAIlE,IAAI;IACrC,IAAI,CAACwG,OAAO,OAAO;IACnB,IAAI,gBAAgBrC,IAAI,CAACqC,QAAQ,OAAOA;IACxC,MAAM/F,SAASb;IACf,OAAOa,UAAU+F,MAAMpC,UAAU,CAAC,OAAO,GAAG3D,SAAS+F,OAAO,GAAG;AACjE;AAEA,yEAAyE,GACzE,OAAO,eAAeC,gBACpB3D,KAAa,EACbxC,OAA4B;IAE5B,IAAI;QACF,MAAMoG,UAAU,MAAMvH,eAAe2D;QACrC,MAAM6D,SAASD,QAAQrF,MAAM,CAC3B,CAACuF,SAAWA,OAAOC,IAAI,KAAK,WAAWD,OAAOC,IAAI,KAAK;QAEzD,yEAAyE;QACzE,mDAAmD;QACnD,MAAMf,SAA6C,CAAC;QACpD,KAAK,MAAMc,UAAUD,OAAQ;YAC3B,IAAIC,OAAO3E,KAAK,EAAE6D,MAAM,CAACc,OAAOE,GAAG,CAAC,GAAGF,OAAO3E,KAAK;QACrD;QACA,MAAMqC,YACJqC,OAAO/B,GAAG,CAAC,CAACgC,SAAWA,OAAOE,GAAG,GACjC,aAAKxG;YAASwC;YACd;YAAEgD;QAAO;IAEb,EAAE,OAAO9B,OAAO;QACdC,QAAQD,KAAK,CAAC,iCAAiCA;IACjD;AACF;AAEA;;;;;;;;;;;;;;;;;;;CAmBC,GACD,OAAO,eAAe+C,mBACpBvD,MAAc,EACdlD,OAA4B;IAE5B,IAAI;YAGC4C,WAIW5C;QANd,MAAM4C,OAAO,MAAMxD,YAAY2D,UAAU,CAAC,SAASC,GAAG,CAACE,QAAQD,GAAG;QAClE,MAAMyD,eACH9D,YAAAA,KAAKK,GAAG,CAAC,0BAATL,YAAkE,CAAC;QACtE,MAAM+D,WAAWC,OAAOC,OAAO,CAACH,aAC7B3F,MAAM,CAAC,CAAC,GAAGwF,KAAK,GAAKA,SAAS,WAAWA,SAAS,UAClDjC,GAAG,CAAC,CAAC,CAAClD,IAAI,GAAKA;QAClB,MAAMoB,SAAQxC,iBAAAA,QAAQwC,KAAK,YAAbxC,iBAAkB4C,KAAKK,GAAG,CAAC;QACzC,MAAMe,YAAY2C,UAAU,aACvB3G;YACHkD;WACIV,QAAQ;YAAEA;QAAM,IAAI,CAAC;IAE7B,EAAE,OAAOkB,OAAO;QACdC,QAAQD,KAAK,CAAC,oCAAoCA;IACpD;AACF"}
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/notifications.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 {\n buildRoute,\n NOTIFICATION_SELF_SENT_EMAIL_TYPES,\n NOTIFICATION_SETTINGS_FIELD,\n normalizeNotificationLink,\n notificationChannelEnabled,\n type AglynNotification,\n Route,\n type NotificationSettings,\n} from '@aglyn/aglyn/server'\nimport { operatorAlertForNotificationType } from '@aglyn/aglyn/plugin-manager/operator-alerts'\nimport { isEmailConfigured, sendEmail } from '@aglyn/shared-util-email'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { findUserByUidAcrossPools, listStaffUidsAcrossPools } from './auth-pools'\nimport { filterSuppressedEmails } from './email-suppression'\nimport { meterOrgEmail, meterPlatformEmail } from './email-metering'\nimport firebaseAdmin from './firebase-admin'\nimport { listOrgMembers } from './organizations'\nimport {\n loadSystemEmail,\n renderLoadedSystemEmail,\n systemEmailBrand,\n systemEmailContent,\n type SystemEmailBrand,\n} from './render-system-email'\n\nconst firestore = () => firebaseAdmin.app().firestore()\n\nexport type NotificationPayload = Omit<\n AglynNotification,\n '$id' | 'createdAt' | 'readAt'\n>\n\nexport interface NotifyUsersOptions {\n /**\n * Addresses the caller already holds, by uid (AGL-3224).\n *\n * Purely an optimization, and one worth taking where it is free: a caller\n * that read the roster — `notifyOrgAdmins` does — has every address in hand\n * already, and without this the email half would look each one up again in\n * the directory. Absent uids fall back to that lookup, so passing a partial\n * map is fine and passing none is correct.\n */\n emails?: Readonly<Record<string, string | null | undefined>>\n /**\n * Who the fan-out is for (AGL-3367). `staff` is the platform talking to its\n * own people, so the email wears the platform's brand and is the platform's\n * cost even when the notification is ABOUT an org, which a canceled\n * subscription or a new ticket always is. Anything else speaks in the org's\n * brand when the notification names one.\n */\n audience?: 'staff'\n /**\n * Write the console notification only, and mail nobody through the\n * per-person email channel (AGL-3375): the caller has emailed the same\n * alert to the operator's inbox, and a staff member who also switched\n * email on would otherwise get it twice.\n */\n skipEmail?: boolean\n}\n\n/** The console's absolute origin for an email link, or `''` when unset. */\nfunction consoleOrigin(): string {\n return (process.env.NEXT_PUBLIC_CONSOLE_URL ?? '').trim().replace(/\\/+$/, '')\n}\n\n/**\n * At most this many directory lookups per fan-out, and at most this many\n * notification emails.\n *\n * Both ceilings only ever bite on a fan-out where many recipients have\n * OPTED IN — the email channel defaults off, so the ordinary notification\n * costs exactly what it cost before this existed. They are here because a\n * fan-out takes up to 400 uids and `notifyUsers` runs inside the mutation\n * that emitted it: a notification must not be able to turn one write into\n * four hundred directory reads and four hundred sends.\n */\nconst NOTIFY_EMAIL_MAX_LOOKUPS = 25\nconst NOTIFY_EMAIL_MAX_SENDS = 50\n\n/**\n * The email beside the console notification (AGL-3224).\n *\n * One send per recipient, never a shared `to:` list — two people who manage\n * the same site have not agreed to be shown each other's addresses, and the\n * settings link in the footer belongs to one person.\n *\n * `List-Unsubscribe` points at the settings page rather than at a one-click\n * endpoint: this is not marketing mail and RFC 8058's POST form would be\n * claiming a capability that does not exist. What it does do is put the way\n * out in the headers as well as the body, so a client that surfaces it shows\n * the person the page where the switch they want actually is.\n *\n * Rendered through the `notification` system email (AGL-3367), which is what\n * gives it the header and footer every other system email wears, and makes\n * it designable on the staff System emails page. It used to hand `sendEmail`\n * bare text, which is the unbranded card the fallback HTML part draws. The\n * template is read once per fan-out and rendered per recipient.\n *\n * Never throws, like everything else on this path.\n */\nasync function emailNotification(\n uids: string[],\n payload: NotificationPayload,\n known: Readonly<Record<string, string | null | undefined>>,\n audience: NotifyUsersOptions['audience'],\n): Promise<void> {\n const origin = consoleOrigin()\n const settingsUrl = `${origin}${buildRoute(Route.MANAGE_NOTIFICATION_SETTINGS)}`\n const context = await notificationEmailContext(payload, audience)\n const url = notificationEmailUrl(payload, context, origin)\n const loaded = await loadSystemEmail('notification').catch(() => null)\n const fallbackText = [\n payload.title,\n payload.body ?? '',\n url,\n origin ? `Change what you are emailed about: ${settingsUrl}` : '',\n ]\n .filter(Boolean)\n .join('\\n\\n')\n let lookups = 0\n let sent = 0\n for (const uid of uids) {\n if (sent >= NOTIFY_EMAIL_MAX_SENDS) break\n let address = String(known[uid] ?? '').trim().toLowerCase()\n if (!address.includes('@')) {\n if (lookups >= NOTIFY_EMAIL_MAX_LOOKUPS) continue\n lookups += 1\n const pooled = await findUserByUidAcrossPools(uid).catch(() => null)\n address = String(pooled?.record?.email ?? '').trim().toLowerCase()\n }\n if (!address.includes('@')) continue\n const recipients = await filterSuppressedEmails([address])\n if (!recipients.length) continue\n const rendered = loaded\n ? renderLoadedSystemEmail(\n loaded,\n {\n ...context.brand.merge,\n 'notification.title': payload.title,\n 'notification.body': payload.body ?? '',\n 'notification.url': url,\n settingsUrl: origin ? settingsUrl : '',\n },\n context.brand.options,\n )\n : null\n const result = await sendEmail({\n to: recipients,\n ...systemEmailContent(rendered, {\n subject: payload.title,\n text: fallbackText,\n }),\n ...(context.brand.fromName ? { fromName: context.brand.fromName } : {}),\n context: 'notification',\n ...(origin\n ? { headers: { 'List-Unsubscribe': `<${settingsUrl}>` } }\n : {}),\n })\n if (!result.sent) continue\n sent += 1\n // Whose cost it is: a notification to a workspace's people is that\n // workspace's mail, and a staff alert or an account-level notice is the\n // platform's, whichever org it is about.\n await (payload.orgId && audience !== 'staff'\n ? meterOrgEmail(payload.orgId)\n : meterPlatformEmail()\n ).catch(() => undefined)\n }\n}\n\n/** What a notification email needs from the org and site it is about. */\ninterface NotificationEmailContext {\n brand: SystemEmailBrand\n orgSlug?: string\n hostSubdomain?: string\n}\n\n/**\n * The org's brand, and the slug and subdomain its stored link is rewritten\n * with (AGL-3367): at most two reads per fan-out, made only when someone is\n * being emailed.\n *\n * A staff fan-out reads nothing. Its brand is the platform's, and its links\n * are staff routes that need no rewriting.\n */\nasync function notificationEmailContext(\n payload: NotificationPayload,\n audience: NotifyUsersOptions['audience'],\n): Promise<NotificationEmailContext> {\n if (audience === 'staff' || !payload.orgId) {\n return { brand: systemEmailBrand(null) }\n }\n try {\n const db = firestore()\n const [org, host] = await Promise.all([\n db.collection('orgs').doc(payload.orgId).get(),\n payload.hostId\n ? db.collection('hosts').doc(payload.hostId).get()\n : Promise.resolve(null),\n ])\n const slug = org.get('slug')\n const subdomain = host?.get('subdomain')\n return {\n brand: systemEmailBrand(org.exists ? (org.data() ?? null) : null),\n ...(typeof slug === 'string' && slug ? { orgSlug: slug } : {}),\n ...(typeof subdomain === 'string' && subdomain\n ? { hostSubdomain: subdomain }\n : {}),\n }\n } catch (error) {\n console.error('notification email context failed', error)\n return { brand: systemEmailBrand(null) }\n }\n}\n\n/**\n * Where the email's button goes: the notification's own link, rewritten onto\n * today's routes the way the console rewrites it when it is clicked, else\n * the notifications page. A stored host link is `/{hostDocId}/…`, which only\n * that rewrite turns into a page that exists. Empty when the deployment has\n * no console origin to make a link absolute with.\n */\nfunction notificationEmailUrl(\n payload: NotificationPayload,\n context: NotificationEmailContext,\n origin: string,\n): string {\n const link = normalizeNotificationLink(payload.link, {\n orgSlug: context.orgSlug,\n hostId: payload.hostId,\n hostSubdomain: context.hostSubdomain,\n })\n if (link && /^https?:\\/\\//i.test(link)) return link\n if (!origin) return ''\n return `${origin}${link?.startsWith('/') ? link : buildRoute(Route.MANAGE_NOTIFICATIONS)}`\n}\n\n/**\n * Notification fan-out (AGL-259): batch-writes one doc per recipient at\n * `users/{uid}/notifications`, and — for recipients who asked for it\n * (AGL-3224) — sends one email beside it. Never throws: a notification miss,\n * or a send that failed, must not break the mutation that emitted it.\n */\nexport async function notifyUsers(\n uids: Iterable<string>,\n payload: NotificationPayload,\n options: NotifyUsersOptions = {},\n): Promise<void> {\n try {\n const db = firestore()\n const targets = [...new Set(uids)].filter(Boolean).slice(0, 400)\n if (!targets.length) return\n // Per-user preferences (AGL-267, AGL-3223): one getAll over the user\n // docs, which now answers for both channels and for all three scopes —\n // the settings live on this document precisely so that stays one read.\n const userDocs = await db.getAll(\n ...targets.map((uid) => db.collection('users').doc(uid)),\n )\n const scope = { orgId: payload.orgId, hostId: payload.hostId }\n const batch = db.batch()\n let count = 0\n const mailTo: string[] = []\n for (const userDoc of userDocs) {\n const settings = userDoc.get(NOTIFICATION_SETTINGS_FIELD) as\n | NotificationSettings\n | undefined\n const legacy = userDoc.get('notificationPrefs') as\n | Record<string, boolean>\n | undefined\n if (\n notificationChannelEnabled(\n settings,\n 'console',\n payload.type,\n scope,\n legacy,\n )\n ) {\n batch.set(\n db\n .collection('users')\n .doc(userDoc.id)\n .collection('notifications')\n .doc(),\n // `read: false` on create, so the feed can ask for unread and read\n // alike by equality (AGL-3321; see `AglynNotification.read`).\n { ...payload, read: false, createdAt: FieldValue.serverTimestamp() },\n )\n count += 1\n }\n if (\n notificationChannelEnabled(settings, 'email', payload.type, scope, legacy)\n ) {\n mailTo.push(userDoc.id)\n }\n }\n if (count > 0) await batch.commit()\n /*\n * THE EMAIL AFTER THE COMMIT, and not inside its `try`.\n *\n * The console notification is the durable record and the send is a\n * courtesy on top of it, so the order is the one where a failing network\n * call cannot lose the record. The two channels are independent by\n * design — a person may have muted the feed and asked for mail, or the\n * reverse — so neither is gated on the other having happened.\n *\n * Digests are excluded: they compose and send their own mail, under\n * their own switches, and the generic channel would send a second\n * message announcing that the first one had been sent.\n */\n if (\n mailTo.length &&\n !options.skipEmail &&\n isEmailConfigured() &&\n !NOTIFICATION_SELF_SENT_EMAIL_TYPES.has(payload.type)\n ) {\n await emailNotification(\n mailTo,\n payload,\n options.emails ?? {},\n options.audience,\n )\n }\n } catch (error) {\n console.error('notification fan-out failed', error)\n }\n}\n\n// Staff are identified by a Firebase Auth custom claim (`staff`), which is not\n// a Firestore query — so the roster comes from paginating the auth users. A\n// burst of ticket activity shouldn't rescan every time, so the (small) result\n// is cached briefly. Fails soft to an empty list.\nlet staffUidCache: { uids: string[]; at: number } | null = null\nconst STAFF_CACHE_MS = 60_000\n\nasync function listStaffUids(): Promise<string[]> {\n const now = Date.now()\n if (staffUidCache && now - staffUidCache.at < STAFF_CACHE_MS) {\n return staffUidCache.uids\n }\n // Across ALL auth pools (AGL-1122). This scanned only the project pool, so\n // a staff member who signs in through enterprise SSO — whose account lives\n // in a GCIP tenant pool — was never in the fan-out. Nothing errored; the\n // notification simply never arrived, which is the worst shape a miss can\n // take on an alerting path.\n const uids = await listStaffUidsAcrossPools()\n staffUidCache = { uids, at: now }\n return uids\n}\n\n/**\n * Notifies every staff-claim holder (AGL-850) — the support-desk audience.\n * Staff are not org members, so `notifyOrgAdmins`/`notifyHostManagers` never\n * reach them; this enumerates the `staff` custom claim instead. The docs land\n * at `users/{uid}/notifications`, which the console notifications menu already\n * renders, so no separate staff inbox is needed. Never throws.\n *\n * A type the operator alert registry names (AGL-3377) — a fraud or risk\n * alert, a dispute nobody owns, a support ticket — is raised as that alert\n * instead: the same console notification, plus staff's switches, the\n * operator email or digest, and the webhook. See `raiseOperatorAlert`.\n */\nexport async function notifyStaff(payload: NotificationPayload): Promise<void> {\n try {\n const alert = operatorAlertForNotificationType(payload.type)\n if (alert) {\n const { raiseOperatorAlert } = await import('./operator-alerts')\n await raiseOperatorAlert(alert, {\n subject: payload.title,\n ...(payload.body ? { body: payload.body } : {}),\n ...(payload.link ? { url: payload.link } : {}),\n ...(payload.orgId ? { orgId: payload.orgId } : {}),\n ...(payload.hostId ? { hostId: payload.hostId } : {}),\n })\n return\n }\n await notifyStaffConsole(payload)\n } catch (error) {\n console.error('staff notification failed', error)\n }\n}\n\n/**\n * The staff console notification alone, with the per-person email channel\n * it carries: what `notifyStaff` did before the registry, and what\n * `raiseOperatorAlert` writes as its first step. Never throws.\n */\nexport async function notifyStaffConsole(\n payload: NotificationPayload,\n options: { skipEmail?: boolean } = {},\n): Promise<void> {\n try {\n await notifyUsers(await listStaffUids(), payload, {\n audience: 'staff',\n ...(options.skipEmail ? { skipEmail: true } : {}),\n })\n } catch (error) {\n console.error('staff notification failed', error)\n }\n}\n\n/** Notifies the org's owner + admins (billing, membership, org events). */\nexport async function notifyOrgAdmins(\n orgId: string,\n payload: NotificationPayload,\n): Promise<void> {\n try {\n const members = await listOrgMembers(orgId)\n const admins = members.filter(\n (member) => member.role === 'owner' || member.role === 'admin',\n )\n // The roster rows carry addresses, so the email channel (AGL-3224) needs\n // no directory lookup for anyone reached this way.\n const emails: Record<string, string | undefined> = {}\n for (const member of admins) {\n if (member.email) emails[member.$id] = member.email\n }\n await notifyUsers(\n admins.map((member) => member.$id),\n { ...payload, orgId },\n { emails },\n )\n } catch (error) {\n console.error('org admin notification failed', error)\n }\n}\n\n/**\n * Notifies everyone who manages a host (admin/editor in the host doc's\n * `memberRoles` projection) — the audience for form submissions and\n * bookings on that site.\n *\n * The OWNING ORG travels with the notification (AGL-1773). Host links are\n * stored in the legacy `/{hostDocId}/rest` shape and rewritten to\n * `/{orgSlug}/hosts/{subdomain}/rest` when they are FOLLOWED\n * (`normalizeNotificationLink`, AGL-644) — a rewrite that needs an org slug.\n * With no `orgId` on the doc the console had nothing to key on and fell back\n * to whichever org the reader happens to have OPEN, so a manager who belongs\n * to more than one workspace was routed to `/{other-org}/hosts/{subdomain}/…`\n * and got a designed 404: `HostGuard` only resolves subdomains belonging to\n * the current org. `notifyOrgAdmins` has always stamped it; the host fan-out\n * — every form submission, booking, order and stock alert — never did.\n *\n * The host doc already carries `orgId` (AGL-233), so this costs no extra\n * read. Spread conditionally: the field is optional on the host model and\n * Firestore rejects `undefined`.\n */\nexport async function notifyHostManagers(\n hostId: string,\n payload: NotificationPayload,\n): Promise<void> {\n try {\n const host = await firestore().collection('hosts').doc(hostId).get()\n const memberRoles =\n (host.get('memberRoles') as Record<string, string> | undefined) ?? {}\n const managers = Object.entries(memberRoles)\n .filter(([, role]) => role === 'admin' || role === 'editor')\n .map(([uid]) => uid)\n const orgId = payload.orgId ?? (host.get('orgId') as string | undefined)\n await notifyUsers(managers, {\n ...payload,\n hostId,\n ...(orgId ? { orgId } : {}),\n })\n } catch (error) {\n console.error('host manager notification failed', error)\n }\n}\n"],"names":["buildRoute","NOTIFICATION_SELF_SENT_EMAIL_TYPES","NOTIFICATION_SETTINGS_FIELD","normalizeNotificationLink","notificationChannelEnabled","Route","operatorAlertForNotificationType","isEmailConfigured","sendEmail","FieldValue","findUserByUidAcrossPools","listStaffUidsAcrossPools","filterSuppressedEmails","meterOrgEmail","meterPlatformEmail","firebaseAdmin","listOrgMembers","loadSystemEmail","renderLoadedSystemEmail","systemEmailBrand","systemEmailContent","firestore","app","consoleOrigin","process","env","NEXT_PUBLIC_CONSOLE_URL","trim","replace","NOTIFY_EMAIL_MAX_LOOKUPS","NOTIFY_EMAIL_MAX_SENDS","emailNotification","uids","payload","known","audience","origin","settingsUrl","MANAGE_NOTIFICATION_SETTINGS","context","notificationEmailContext","url","notificationEmailUrl","loaded","catch","fallbackText","title","body","filter","Boolean","join","lookups","sent","uid","address","String","toLowerCase","includes","pooled","record","email","recipients","length","rendered","brand","merge","options","result","to","subject","text","fromName","headers","orgId","undefined","org","db","host","Promise","all","collection","doc","get","hostId","resolve","slug","subdomain","exists","data","orgSlug","hostSubdomain","error","console","link","test","startsWith","MANAGE_NOTIFICATIONS","notifyUsers","targets","Set","slice","userDocs","getAll","map","scope","batch","count","mailTo","userDoc","settings","legacy","type","set","id","read","createdAt","serverTimestamp","push","commit","skipEmail","has","emails","staffUidCache","STAFF_CACHE_MS","listStaffUids","now","Date","at","notifyStaff","alert","raiseOperatorAlert","notifyStaffConsole","notifyOrgAdmins","members","admins","member","role","$id","notifyHostManagers","memberRoles","managers","Object","entries"],"mappings":"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"}
@@ -0,0 +1,215 @@
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 AglynNotificationType, type OperatorAlertContext, type OperatorAlertDefinition, type OperatorAlertSettings } from '@aglyn/aglyn/server';
18
+ import { type SendEmailResult } from '@aglyn/shared-util-email';
19
+ /**
20
+ * RAISING AN OPERATOR ALERT (AGL-3377): the one server entry point every
21
+ * alert in the registry goes through.
22
+ *
23
+ * In order, for one call:
24
+ *
25
+ * 1. The type is looked up in the registry (core's catalog plus what plugins
26
+ * registered). A plugin may pass its definition itself, which registers
27
+ * it in this process if its declarations have not loaded yet.
28
+ * 2. A `dedupeKey` is claimed against `operatorAlertState`, so a condition
29
+ * that flaps inside the type's window is told once, and a type with
30
+ * `minOccurrences` fires only once it has recurred that often.
31
+ * 3. The staff console notification is written for every staff member, as
32
+ * `notifyStaff` always has.
33
+ * 4. Staff's switches (`platformSettings/operatorAlerts`, coded defaults
34
+ * until changed) decide the rest: off is console only, `digest` queues it
35
+ * for the once-a-day digest, `immediate` emails the operator now.
36
+ * 5. The email is the `operator-alert` system email, to
37
+ * `resolveStaffAlertRecipients()`; the optional out-of-band webhook
38
+ * (`OPERATOR_ALERT_WEBHOOK_URL`) gets the same alert beside it, so a mail
39
+ * provider that is itself the outage still reaches somebody.
40
+ *
41
+ * Never throws: an alert is raised from inside the failure it reports, and a
42
+ * second failure there must not replace the first.
43
+ */
44
+ /** Where staff's per-type switches live. */
45
+ export declare const OPERATOR_ALERT_SETTINGS_COLLECTION = "platformSettings";
46
+ export declare const OPERATOR_ALERT_SETTINGS_DOC = "operatorAlerts";
47
+ /** One document per (type, dedupeKey): when it last fired, how often since. */
48
+ export declare const OPERATOR_ALERT_STATE_COLLECTION = "operatorAlertState";
49
+ /** Alerts waiting for the daily digest. */
50
+ export declare const OPERATOR_ALERT_DIGEST_COLLECTION = "operatorAlertDigest";
51
+ /** The hour, UTC, the digest goes out when staff have not chosen one. */
52
+ export declare const OPERATOR_ALERT_DIGEST_DEFAULT_HOUR_UTC = 14;
53
+ export interface OperatorAlertSettingsDoc extends OperatorAlertSettings {
54
+ /** The hour of the day, UTC, the digest is sent. */
55
+ digestHourUtc?: number;
56
+ updatedAtMs?: number;
57
+ updatedByEmail?: string | null;
58
+ }
59
+ export interface RaiseOperatorAlertOptions {
60
+ /**
61
+ * What makes two raises the SAME alert — a dispute id, an org and month, a
62
+ * check id. Absent never dedupes: every raise is told.
63
+ */
64
+ dedupeKey?: string;
65
+ /** Values for the definition's `{{tokens}}`. Never personal data. */
66
+ context?: OperatorAlertContext;
67
+ /** A console path or absolute URL; overrides the definition's link. */
68
+ url?: string;
69
+ /** The title as written by the caller; overrides the rendered one. */
70
+ subject?: string;
71
+ /** The body as written by the caller; overrides the rendered one. */
72
+ body?: string;
73
+ orgId?: string;
74
+ hostId?: string;
75
+ }
76
+ export type OperatorAlertOutcome = 'deduped' | 'below-threshold' | 'console-only' | 'queued' | 'delivered' | 'undelivered';
77
+ export interface OperatorAlertResult {
78
+ outcome: OperatorAlertOutcome;
79
+ type: string;
80
+ email?: SendEmailResult;
81
+ webhook?: OperatorAlertWebhookResult;
82
+ }
83
+ export type OperatorAlertWebhookResult = {
84
+ posted: true;
85
+ status: number;
86
+ } | {
87
+ posted: false;
88
+ reason: 'unconfigured' | 'refused' | 'network';
89
+ status?: number;
90
+ };
91
+ /** One alert, rendered, as it is delivered and queued. */
92
+ export interface RenderedOperatorAlert {
93
+ type: string;
94
+ label: string;
95
+ tier: OperatorAlertDefinition['tier'];
96
+ category: OperatorAlertDefinition['category'];
97
+ title: string;
98
+ body: string;
99
+ /** Console path or absolute URL; `''` when there is none. */
100
+ link: string;
101
+ notificationType: AglynNotificationType;
102
+ orgId?: string;
103
+ hostId?: string;
104
+ }
105
+ /** Drop the in-process settings cache; the console calls it after a write. */
106
+ export declare function invalidateOperatorAlertSettingsCache(): void;
107
+ /** Clamps a stored settings document to what the pipeline understands. */
108
+ export declare function normalizeOperatorAlertSettings(raw: unknown): OperatorAlertSettingsDoc;
109
+ /**
110
+ * Staff's switches. An unreadable document is the coded defaults, uncached:
111
+ * an alert must still go out when the settings cannot be read.
112
+ */
113
+ export declare function readOperatorAlertSettings(): Promise<OperatorAlertSettingsDoc>;
114
+ export type OperatorAlertClaim = {
115
+ fire: true;
116
+ repeats: number;
117
+ } | {
118
+ fire: false;
119
+ reason: 'deduped' | 'below-threshold';
120
+ };
121
+ /**
122
+ * Whether a raise with this key fires now. One transaction on the key's
123
+ * state document: inside the window after a fire it is counted and
124
+ * suppressed; before it reaches `minOccurrences` it is counted and held.
125
+ * A store that cannot be read FIRES — a duplicate beats a silence.
126
+ */
127
+ export declare function claimOperatorAlert(definition: Pick<OperatorAlertDefinition, 'type' | 'dedupeWindowMinutes' | 'minOccurrences'>, dedupeKey: string, now?: number): Promise<OperatorAlertClaim>;
128
+ /** The webhook URL, when one is configured and is http(s). */
129
+ export declare function operatorAlertWebhookUrl(): string;
130
+ /**
131
+ * The JSON the webhook receives. `text` is what a Slack-compatible incoming
132
+ * webhook renders on its own; `alert` carries the fields for anything that
133
+ * wants to route on them.
134
+ */
135
+ export declare function operatorAlertWebhookPayload(alerts: readonly RenderedOperatorAlert[], options?: {
136
+ digest?: boolean;
137
+ now?: number;
138
+ }): Record<string, unknown>;
139
+ /**
140
+ * Posts alerts to `OPERATOR_ALERT_WEBHOOK_URL`, signed when
141
+ * `OPERATOR_ALERT_WEBHOOK_SECRET` is set: `x-operator-alert-timestamp` is the
142
+ * Unix time and `x-operator-alert-signature` is `sha256=` plus the hex HMAC
143
+ * of `{timestamp}.{body}`. Unset means email only. Never throws.
144
+ */
145
+ export declare function postOperatorAlertWebhook(alerts: readonly RenderedOperatorAlert[], options?: {
146
+ digest?: boolean;
147
+ now?: number;
148
+ }): Promise<OperatorAlertWebhookResult>;
149
+ /** A console path as an inbox or webhook can follow it, or `''`. */
150
+ export declare function operatorAlertAbsoluteLink(link: string | undefined | null): string;
151
+ /** Resolves the definition a call names, registering a plugin's own. */
152
+ export declare function resolveOperatorAlertDefinition(type: string | OperatorAlertDefinition): OperatorAlertDefinition;
153
+ export declare function renderOperatorAlert(definition: OperatorAlertDefinition, options?: RaiseOperatorAlertOptions): RenderedOperatorAlert;
154
+ /** See the module comment. Never throws. */
155
+ export declare function raiseOperatorAlert(type: string | OperatorAlertDefinition, options?: RaiseOperatorAlertOptions): Promise<OperatorAlertResult>;
156
+ /** At most this many alerts are listed in one digest; the rest are counted. */
157
+ export declare const OPERATOR_DIGEST_MAX_LISTED = 50;
158
+ export interface OperatorDigestResult {
159
+ /** Whether a digest went out (or, on a dry run, would). */
160
+ sent: boolean;
161
+ reason?: 'not-due' | 'already-sent' | 'empty' | 'dry-run' | 'unavailable';
162
+ count: number;
163
+ email?: SendEmailResult;
164
+ webhook?: OperatorAlertWebhookResult;
165
+ }
166
+ /** The state document that claims one UTC day's digest. */
167
+ export declare function operatorDigestMarkerId(now: number): string;
168
+ /** The digest's body: one paragraph per alert, newest last. */
169
+ export declare function composeOperatorDigestBody(alerts: readonly RenderedOperatorAlert[], total?: number): string;
170
+ /**
171
+ * Sends the day's digest once the configured hour has passed: every queued
172
+ * alert in one `operator-alert-digest` email and one webhook post, then
173
+ * clears what it sent. Once per UTC day, claimed by creating
174
+ * `operatorAlertState/digest-{day}`, so two ticks never both send it.
175
+ */
176
+ export declare function sendOperatorAlertDigest(options?: {
177
+ now?: number;
178
+ dryRun?: boolean;
179
+ force?: boolean;
180
+ }): Promise<OperatorDigestResult>;
181
+ /** One registry entry as Staff → Operator alerts lists it. */
182
+ export interface OperatorAlertCatalogRow {
183
+ type: string;
184
+ label: string;
185
+ description: string;
186
+ tier: OperatorAlertDefinition['tier'];
187
+ category: OperatorAlertDefinition['category'];
188
+ pluginId: string | null;
189
+ defaults: {
190
+ enabled: boolean;
191
+ delivery: OperatorAlertDefinition['delivery'];
192
+ };
193
+ effective: {
194
+ enabled: boolean;
195
+ delivery: OperatorAlertDefinition['delivery'];
196
+ };
197
+ /** Whether staff have answered for this type, rather than the default. */
198
+ overridden: boolean;
199
+ }
200
+ /** Every registered type with its default and its effective setting. */
201
+ export declare function operatorAlertCatalogRows(definitions: readonly OperatorAlertDefinition[], settings: OperatorAlertSettingsDoc): OperatorAlertCatalogRow[];
202
+ /**
203
+ * Sends one `[Test]` alert of a type straight to the operator's inbox and
204
+ * webhook, past dedupe and staff's switches, so an operator can prove the
205
+ * channels reach them. Writes no console notification.
206
+ */
207
+ export declare function sendOperatorAlertTest(definition: OperatorAlertDefinition): Promise<{
208
+ email: SendEmailResult;
209
+ webhook: OperatorAlertWebhookResult;
210
+ }>;
211
+ /** Where operator mail goes, named but never listed, for the staff page. */
212
+ export declare function describeOperatorAlertRecipients(): Promise<{
213
+ source: 'STAFF_ALERT_EMAIL' | 'NEXT_PUBLIC_OPERATOR_SUPPORT_EMAIL' | 'staff-accounts' | 'none';
214
+ count: number;
215
+ }>;