@aglyn/aglyn 1.0.0-beta.269 → 1.0.0-beta.271
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +11 -11
- package/src/lib/app-utils/account-preferences.d.ts +248 -0
- package/src/lib/app-utils/account-preferences.js +434 -0
- package/src/lib/app-utils/account-preferences.js.map +1 -0
- package/src/lib/app-utils/activity-labels.js +2 -0
- package/src/lib/app-utils/activity-labels.js.map +1 -1
- package/src/lib/app-utils/analytics-events.d.ts +13 -0
- package/src/lib/app-utils/analytics-events.js +2 -0
- package/src/lib/app-utils/analytics-events.js.map +1 -1
- package/src/lib/app-utils/append-only-history.d.ts +94 -0
- package/src/lib/app-utils/append-only-history.js +73 -0
- package/src/lib/app-utils/append-only-history.js.map +1 -0
- package/src/lib/app-utils/bundle-intent.d.ts +186 -0
- package/src/lib/app-utils/bundle-intent.js +386 -0
- package/src/lib/app-utils/bundle-intent.js.map +1 -0
- package/src/lib/app-utils/client-build.d.ts +204 -0
- package/src/lib/app-utils/client-build.js +237 -0
- package/src/lib/app-utils/client-build.js.map +1 -0
- package/src/lib/app-utils/console-routes.d.ts +4 -0
- package/src/lib/app-utils/console-routes.js +2 -0
- package/src/lib/app-utils/console-routes.js.map +1 -1
- package/src/lib/app-utils/country-options.d.ts +27 -0
- package/src/lib/app-utils/country-options.js +96 -0
- package/src/lib/app-utils/country-options.js.map +1 -0
- package/src/lib/app-utils/docs-help-sections.generated.js +7 -5
- package/src/lib/app-utils/docs-help-sections.generated.js.map +1 -1
- package/src/lib/app-utils/docs-help.generated.d.ts +11 -5
- package/src/lib/app-utils/docs-help.generated.js +22 -2
- package/src/lib/app-utils/docs-help.generated.js.map +1 -1
- package/src/lib/app-utils/docs-index.generated.js +116 -39
- package/src/lib/app-utils/docs-index.generated.js.map +1 -1
- package/src/lib/app-utils/margin-utilization.js +6 -4
- package/src/lib/app-utils/margin-utilization.js.map +1 -1
- package/src/lib/app-utils/media-filter.d.ts +26 -0
- package/src/lib/app-utils/media-filter.js +32 -0
- package/src/lib/app-utils/media-filter.js.map +1 -1
- package/src/lib/app-utils/mobile-push.d.ts +8 -0
- package/src/lib/app-utils/mobile-push.js +4 -1
- package/src/lib/app-utils/mobile-push.js.map +1 -1
- package/src/lib/app-utils/native-app-association.d.ts +3 -2
- package/src/lib/app-utils/native-app-association.js +4 -2
- package/src/lib/app-utils/native-app-association.js.map +1 -1
- package/src/lib/app-utils/native-nav.d.ts +265 -0
- package/src/lib/app-utils/native-nav.js +741 -0
- package/src/lib/app-utils/native-nav.js.map +1 -0
- package/src/lib/app-utils/native-themes.d.ts +249 -0
- package/src/lib/app-utils/native-themes.js +732 -0
- package/src/lib/app-utils/native-themes.js.map +1 -0
- package/src/lib/app-utils/notifications.d.ts +1 -1
- package/src/lib/app-utils/notifications.js +10 -0
- package/src/lib/app-utils/notifications.js.map +1 -1
- package/src/lib/app-utils/onboarding-deep-link.d.ts +36 -2
- package/src/lib/app-utils/onboarding-deep-link.js +48 -5
- package/src/lib/app-utils/onboarding-deep-link.js.map +1 -1
- package/src/lib/app-utils/one-box-search.d.ts +108 -0
- package/src/lib/app-utils/one-box-search.js +167 -0
- package/src/lib/app-utils/one-box-search.js.map +1 -0
- package/src/lib/app-utils/operator-alerts.js +21 -0
- package/src/lib/app-utils/operator-alerts.js.map +1 -1
- package/src/lib/app-utils/org-permissions.js +10 -61
- package/src/lib/app-utils/org-permissions.js.map +1 -1
- package/src/lib/app-utils/org-role-grants.d.ts +95 -0
- package/src/lib/app-utils/org-role-grants.js +193 -0
- package/src/lib/app-utils/org-role-grants.js.map +1 -0
- package/src/lib/app-utils/owner-approval-record.d.ts +102 -0
- package/src/lib/app-utils/owner-approval-record.js +148 -0
- package/src/lib/app-utils/owner-approval-record.js.map +1 -0
- package/src/lib/app-utils/plan-entitlements.d.ts +224 -68
- package/src/lib/app-utils/plan-entitlements.js +278 -61
- package/src/lib/app-utils/plan-entitlements.js.map +1 -1
- package/src/lib/app-utils/release-flags.d.ts +1 -1
- package/src/lib/app-utils/release-flags.js +10 -0
- package/src/lib/app-utils/release-flags.js.map +1 -1
- package/src/lib/app-utils/server.d.ts +4 -0
- package/src/lib/app-utils/server.js +7 -0
- package/src/lib/app-utils/server.js.map +1 -1
- package/src/lib/app-utils/site-care-project.d.ts +227 -0
- package/src/lib/app-utils/site-care-project.js +423 -0
- package/src/lib/app-utils/site-care-project.js.map +1 -0
- package/src/lib/app-utils/site-care.d.ts +268 -0
- package/src/lib/app-utils/site-care.js +283 -0
- package/src/lib/app-utils/site-care.js.map +1 -0
- package/src/lib/app-utils/tenant-dns-targets.d.ts +62 -0
- package/src/lib/app-utils/tenant-dns-targets.js +80 -0
- package/src/lib/app-utils/tenant-dns-targets.js.map +1 -0
- package/src/lib/app-utils/upgrade-proposal.d.ts +29 -4
- package/src/lib/app-utils/upgrade-proposal.js +60 -9
- package/src/lib/app-utils/upgrade-proposal.js.map +1 -1
- package/src/lib/foundation/definitions/org-billing.types.d.ts +54 -10
- package/src/lib/foundation/definitions/org-billing.types.js.map +1 -1
- package/src/lib/foundation/definitions/organization.types.d.ts +3 -1
- package/src/lib/foundation/definitions/organization.types.js.map +1 -1
- package/src/lib/foundation/definitions/platform.types.d.ts +9 -0
- package/src/lib/foundation/definitions/platform.types.js.map +1 -1
- package/src/lib/foundation/index.d.ts +1 -1
- package/src/lib/foundation/index.js.map +1 -1
- package/src/lib/plugin-manager/feature-plugins.d.ts +86 -0
- package/src/lib/plugin-manager/feature-plugins.js +20 -0
- package/src/lib/plugin-manager/feature-plugins.js.map +1 -1
- package/src/lib/plugin-manager/first-party-plugins.generated.js +22 -1
- package/src/lib/plugin-manager/first-party-plugins.generated.js.map +1 -1
- package/src/lib/plugin-manager/plugin-ai-capabilities.d.ts +19 -0
- package/src/lib/plugin-manager/plugin-ai-capabilities.js.map +1 -1
- package/src/lib/plugin-manager/plugin-domain-events.d.ts +4 -3
- package/src/lib/plugin-manager/plugin-domain-events.js.map +1 -1
- package/src/lib/plugin-manager/plugin-domain-search.d.ts +81 -0
- package/src/lib/plugin-manager/plugin-domain-search.js +63 -0
- package/src/lib/plugin-manager/plugin-domain-search.js.map +1 -0
- package/src/lib/plugin-manager/plugin-entitlements.d.ts +23 -5
- package/src/lib/plugin-manager/plugin-entitlements.js +10 -0
- package/src/lib/plugin-manager/plugin-entitlements.js.map +1 -1
- package/src/lib/plugin-manager/plugin-media-ingest.d.ts +25 -0
- package/src/lib/plugin-manager/plugin-media-ingest.js.map +1 -1
- package/src/lib/plugin-manager/plugin-search-sources.d.ts +85 -0
- package/src/lib/plugin-manager/plugin-search-sources.js +91 -0
- package/src/lib/plugin-manager/plugin-search-sources.js.map +1 -0
- package/src/lib/plugin-manager/plugin-services.js +6 -0
- package/src/lib/plugin-manager/plugin-services.js.map +1 -1
- package/src/lib/plugin-manager/purchase-gate-preclearance.d.ts +61 -0
- package/src/lib/plugin-manager/purchase-gate-preclearance.js +102 -0
- package/src/lib/plugin-manager/purchase-gate-preclearance.js.map +1 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/native-themes.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 NATIVE APPS' THEMES, AS DATA (AGL-3950).\n *\n * The values are read from the Native App Figma file's variables (Themes,\n * page 2:8; collections Aglyn / Theme, Aglyn / Appearance, Aglyn / Accent,\n * Aglyn / Brand and Aglyn / Density), which win wherever they define a value:\n *\n * - `THEME_PALETTES`: Aglyn is the look that ships, the console's resolved\n * MUI scheme (see `AGLYN_LIGHT`); Quiet, Night and High contrast, light and\n * dark, are exactly as Figma's Theme modes hold them. Ocean and Warm exist in Figma\n * only as four-color previews (rail, surface, canvas, accent), so their\n * full palettes are derived here by `deriveThemeFromPreview`, the rule\n * below, and drawn back into Figma as swatch boards.\n * - `THEME_ACCENTS`: the Accent modes Blue, Violet, Pink and Workspace\n * brand, exactly. `deriveAccent` returns them for their ring colors and\n * derives any other color (a picked accent, a workspace's brand) by rule.\n * - `THEME_ACCENT_SWATCHES`: the six swatches the Theme settings sheet shows.\n * - `THEME_DENSITIES`: Comfortable and Compact.\n *\n * Contrast is WCAG 2's relative luminance, as `accessible-shade.ts` (the\n * console's theme) measures it; lightness steps are HSL lightness, as it\n * walks them.\n *\n * NO IMPORTS, on purpose: the native apps read the values, the types and the\n * answers of these functions through the generated contracts.\n */\n\n/** A theme the apps offer. */\nexport type ThemePaletteId = 'aglyn' | 'quiet' | 'night' | 'ocean' | 'warm' | 'highContrast'\n\n/** One appearance of a theme: every color a native screen draws from. */\nexport interface ThemeColors {\n canvas: string\n surface: string\n raised: string\n sunken: string\n rail: string\n hover: string\n titlebar: string\n borderSubtle: string\n borderStrong: string\n textPrimary: string\n textSecondary: string\n textTertiary: string\n successBg: string\n successFg: string\n warningBg: string\n warningFg: string\n dangerBg: string\n dangerFg: string\n infoBg: string\n infoFg: string\n newBg: string\n newFg: string\n /** `#RRGGBBAA`: the wash behind a sheet. */\n scrim: string\n /** A destructive button's fill, and its hover and pressed fills. */\n dangerFill: string\n dangerFillHover: string\n dangerFillPressed: string\n}\n\n/**\n * Where a theme's values come from: the console's resolved MUI scheme (the\n * look that ships, Aglyn), Figma's variables, or derived from a Figma preview.\n */\nexport type ThemeSource = 'console' | 'figma' | 'derived'\n\nexport interface ThemePalette {\n id: ThemePaletteId\n /** The name a picker shows; empty when `brandLabel` says to show the configured brand name. */\n label: string\n /** The theme is named after the product: show the build's configured brand name (white-label safe). */\n brandLabel?: boolean\n light: ThemeColors\n dark: ThemeColors\n /** The theme's own accent (\"Accent: Theme\"), as a raw color for `deriveAccent`. */\n accent: string\n /** Night is the same in both appearances: the theme is always dark. */\n alwaysDark?: boolean\n source: ThemeSource\n}\n\n/** An accent, as every control that paints it reads it. */\nexport interface ThemeAccent {\n /** A filled control's background. */\n fill: string\n /** An icon or an indicator in the accent, on the light surfaces. */\n icon: string\n /** `#RRGGBBAA`: a selected row's or a chip's wash. */\n soft: string\n /** The focus ring: the raw accent. */\n ring: string\n /** Text and icons ON `fill`. */\n on: string\n hover: string\n pressed: string\n /** The accent as text on the light surface, at 4.5:1. */\n textLight: string\n /** The accent as text on the dark surface, at 4.5:1. */\n textDark: string\n}\n\n/** An accent the Theme settings sheet offers by name (Figma's Accent modes). */\nexport interface ThemeNamedAccent {\n id: string\n label: string\n accent: ThemeAccent\n}\n\n/** A swatch of the accent picker. */\nexport interface ThemeAccentSwatch {\n id: string\n label: string\n color: string\n}\n\nexport type ThemeDensityId = 'comfortable' | 'compact'\n\n/** One density's sizes, in points (Apple) and dp (Android). */\nexport interface ThemeDensity {\n id: ThemeDensityId\n label: string\n space2: number\n space4: number\n space6: number\n space8: number\n space10: number\n space12: number\n space16: number\n space20: number\n space24: number\n space32: number\n space40: number\n space48: number\n space64: number\n radiusXs: number\n radiusSm: number\n radiusMd: number\n radiusLg: number\n radiusXl: number\n radius2xl: number\n radiusFull: number\n rowHeight: number\n controlHeight: number\n listRowHeight: number\n phoneRowHeight: number\n titlebarHeight: number\n}\n\n/** The four colors Figma previews a theme by. */\nexport interface ThemePreview {\n rail: string\n surface: string\n canvas: string\n accent: string\n}\n\n// ── Color arithmetic ────────────────────────────────────────────────────────\n\nconst HEX = /^#([0-9a-f]{6})([0-9a-f]{2})?$/i\n\n/** `[r, g, b]` in 0–255, or null for anything that is not `#RRGGBB(AA)`. */\nfunction channels(color: string): [number, number, number] | null {\n const match = HEX.exec(color ?? '')\n if (!match) return null\n const value = match[1]\n return [0, 2, 4].map((index) => parseInt(value.slice(index, index + 2), 16)) as [number, number, number]\n}\n\nfunction hexOf(rgb: readonly number[]): string {\n return `#${rgb\n .map((value) => Math.round(Math.min(255, Math.max(0, value))).toString(16).padStart(2, '0'))\n .join('')}`\n}\n\n/** Whether a value is `#RRGGBB`. */\nexport function isThemeColor(value: unknown): value is string {\n return typeof value === 'string' && /^#[0-9a-f]{6}$/i.test(value)\n}\n\nfunction linear(channel: number): number {\n const value = channel / 255\n return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4\n}\n\nfunction luminance(rgb: readonly number[]): number {\n return 0.2126 * linear(rgb[0]) + 0.7152 * linear(rgb[1]) + 0.0722 * linear(rgb[2])\n}\n\n/** WCAG 2 contrast between two `#RRGGBB` colors, as the console's `contrastRatio` measures it. */\nexport function themeContrast(a: string, b: string): number {\n const x = channels(a)\n const y = channels(b)\n if (!x || !y) return 1\n const [high, low] = [luminance(x), luminance(y)].sort((p, q) => q - p)\n return (high + 0.05) / (low + 0.05)\n}\n\nfunction toHsl([r, g, b]: readonly number[]): [number, number, number] {\n const [red, green, blue] = [r / 255, g / 255, b / 255]\n const max = Math.max(red, green, blue)\n const min = Math.min(red, green, blue)\n const lightness = (max + min) / 2\n if (max === min) return [0, 0, lightness]\n const delta = max - min\n const saturation = lightness > 0.5 ? delta / (2 - max - min) : delta / (max + min)\n let hue: number\n if (max === red) hue = (green - blue) / delta + (green < blue ? 6 : 0)\n else if (max === green) hue = (blue - red) / delta + 2\n else hue = (red - green) / delta + 4\n return [hue / 6, saturation, lightness]\n}\n\nfunction fromHsl(hue: number, saturation: number, lightness: number): string {\n const l = Math.min(1, Math.max(0, lightness))\n if (saturation === 0) return hexOf([l * 255, l * 255, l * 255])\n const q = l < 0.5 ? l * (1 + saturation) : l + saturation - l * saturation\n const p = 2 * l - q\n const channel = (t: number) => {\n let x = t\n if (x < 0) x += 1\n if (x > 1) x -= 1\n if (x < 1 / 6) return p + (q - p) * 6 * x\n if (x < 1 / 2) return q\n if (x < 2 / 3) return p + (q - p) * (2 / 3 - x) * 6\n return p\n }\n return hexOf([channel(hue + 1 / 3) * 255, channel(hue) * 255, channel(hue - 1 / 3) * 255])\n}\n\n/** `color` with its HSL lightness moved by `delta` (−1 to 1). */\nfunction shiftLightness(color: string, delta: number): string {\n const rgb = channels(color)\n if (!rgb) return color\n const [h, s, l] = toHsl(rgb)\n return fromHsl(h, s, l + delta)\n}\n\n/**\n * `color` walked in 2% HSL-lightness steps (darker, or lighter) until it\n * reaches `minContrast` against `against`; the pole when nothing reaches it.\n */\nfunction walkToContrast(color: string, against: string, minContrast: number, direction: -1 | 1): string {\n let candidate = color\n for (let step = 0; step <= 50; step += 1) {\n candidate = shiftLightness(color, direction * 0.02 * step)\n if (themeContrast(candidate, against) >= minContrast) return candidate\n }\n return candidate\n}\n\n/** `from` moved `amount` (0–1) of the way to `to`, per sRGB channel. */\nexport function themeLerp(from: string, to: string, amount: number): string {\n const a = channels(from)\n const b = channels(to)\n if (!a || !b) return from\n return hexOf(a.map((value, index) => value + (b[index] - value) * amount))\n}\n\n/** `color` with an alpha byte: `#RRGGBBAA`. */\nfunction withAlpha(color: string, alpha: number): string {\n const rgb = channels(color)\n if (!rgb) return color\n return `${hexOf(rgb)}${Math.round(alpha * 255).toString(16).padStart(2, '0')}`\n}\n\n// ── Themes ──────────────────────────────────────────────────────────────────\n\n/** Text that must read on every surface: WCAG AA. */\nexport const THEME_TEXT_CONTRAST = 4.5\n\n/** An icon, a border or an indicator: WCAG AA for non-text. */\nexport const THEME_NON_TEXT_CONTRAST = 3\n\nconst DANGER_LIGHT = { dangerFill: '#b3261e', dangerFillHover: '#9c211a', dangerFillPressed: '#861c16' }\nconst DANGER_DARK = { dangerFill: '#c93a31', dangerFillHover: '#b3261e', dangerFillPressed: '#9c211a' }\n\n/**\n * Figma's Aglyn mode as it stood before that mode moved to the console's\n * scheme (2026-10-19): the base Quiet's status colors and the Ocean and Warm\n * derivations build on, as the native shells' themes already do. Kept by\n * value, so moving the shipping Aglyn look moves nothing else.\n */\nconst FIGMA_AGLYN_LIGHT: ThemeColors = {\n canvas: '#f5f5f5',\n surface: '#ffffff',\n raised: '#ffffff',\n sunken: '#edf0f4',\n rail: '#e6eaf0',\n hover: '#eef1f5',\n titlebar: '#fafbfc',\n borderSubtle: '#e3e7ec',\n borderStrong: '#c8d0d9',\n textPrimary: '#1f2329',\n textSecondary: '#5e6773',\n textTertiary: '#7a838e',\n successBg: '#e5f3e6',\n successFg: '#2e6f31',\n warningBg: '#fff0da',\n warningFg: '#85560f',\n dangerBg: '#fce6e5',\n dangerFg: '#a8231e',\n infoBg: '#e3f0fb',\n infoFg: '#10548f',\n newBg: '#f5e4f9',\n newFg: '#8e2aa1',\n scrim: '#0f172152',\n ...DANGER_LIGHT,\n}\n\nconst FIGMA_AGLYN_DARK: ThemeColors = {\n canvas: '#161c21',\n surface: '#1e262e',\n raised: '#27313b',\n sunken: '#12181d',\n rail: '#0f1418',\n hover: '#2a343e',\n titlebar: '#1a2128',\n borderSubtle: '#2e3944',\n borderStrong: '#45525f',\n textPrimary: '#f3f5f7',\n textSecondary: '#b3bcc6',\n textTertiary: '#8a96a3',\n successBg: '#1b3320',\n successFg: '#82c785',\n warningBg: '#3a2c14',\n warningFg: '#ffc479',\n dangerBg: '#3b1a1a',\n dangerFg: '#ed7876',\n infoBg: '#132c42',\n infoFg: '#5da1dc',\n newBg: '#3a1e44',\n newFg: '#e979fc',\n scrim: '#0000008c',\n ...DANGER_DARK,\n}\n\n/** A light and a dark palette. */\nexport interface ThemeColorsPair {\n light: ThemeColors\n dark: ThemeColors\n}\n\n/**\n * The palettes `deriveThemeFromPreview` starts from (the original Figma Aglyn\n * mode), exported so each platform can port the derivation and replay its\n * cases against the same base.\n */\nexport const THEME_FIGMA_BASE: ThemeColorsPair = { light: FIGMA_AGLYN_LIGHT, dark: FIGMA_AGLYN_DARK }\n\n/**\n * The Aglyn theme is the look that ships: the console's resolved MUI scheme\n * (`getConsoleTheme`, the same scheme the native shared tokens are generated\n * from), with translucent text, dividers and hover composited onto the paper\n * they sit on, and the status colors as the console's MUI Alert draws them\n * (standard: the intent's light shade lightened 90% for the ground and\n * darkened 60% for the text in light, the reverse in dark). Where the console\n * scheme has no such color (sunken, rail, titlebar, the strong border, the\n * \"new\" status, the scrim) Figma's Aglyn mode supplies it, and so does the\n * danger FILL: the console's `error.main` carries white text at 4.36:1, under\n * the 4.5:1 a button label owes. `apps/console/specs/native-theme-aglyn.spec.ts`\n * holds these to the console's scheme.\n */\nconst AGLYN_LIGHT: ThemeColors = {\n ...FIGMA_AGLYN_LIGHT,\n canvas: '#f5f5f5',\n surface: '#ffffff',\n raised: '#ffffff',\n hover: '#f5f5f5',\n borderSubtle: '#e0e0e0',\n textPrimary: '#212121',\n textSecondary: '#666666',\n textTertiary: '#9e9e9e',\n successBg: '#f0f8f1',\n successFg: '#2c4c2e',\n warningBg: '#fff8ef',\n warningFg: '#664a28',\n dangerBg: '#fceeed',\n dangerFg: '#5c2220',\n infoBg: '#ecf4fb',\n infoFg: '#1c3a56',\n}\n\nconst AGLYN_DARK: ThemeColors = {\n ...FIGMA_AGLYN_DARK,\n canvas: '#161c21',\n surface: '#2a3440',\n raised: '#2a3440',\n hover: '#3b444f',\n borderSubtle: '#444c57',\n textPrimary: '#ffffff',\n textSecondary: '#bfc2c6',\n textTertiary: '#959aa0',\n successBg: '#0b130b',\n successFg: '#c5e5c7',\n warningBg: '#19120a',\n warningFg: '#ffe3c1',\n dangerBg: '#170c0b',\n dangerFg: '#f7c9c8',\n infoBg: '#091015',\n infoFg: '#bfd9f1',\n}\n\n/** The Aglyn theme's fields the console's scheme supplies; the rest are Figma's Aglyn mode. */\nexport const THEME_CONSOLE_FIELDS: readonly string[] = [\n 'canvas',\n 'surface',\n 'raised',\n 'hover',\n 'borderSubtle',\n 'textPrimary',\n 'textSecondary',\n 'textTertiary',\n 'successBg',\n 'successFg',\n 'warningBg',\n 'warningFg',\n 'dangerBg',\n 'dangerFg',\n 'infoBg',\n 'infoFg',\n]\n\nconst QUIET_LIGHT: ThemeColors = {\n ...FIGMA_AGLYN_LIGHT,\n canvas: '#f4f4f2',\n surface: '#ffffff',\n raised: '#ffffff',\n sunken: '#ecece9',\n rail: '#eaeae7',\n hover: '#efefec',\n titlebar: '#fafaf8',\n borderSubtle: '#e2e2de',\n borderStrong: '#cbcbc5',\n textPrimary: '#222220',\n textSecondary: '#5f5f5a',\n textTertiary: '#7c7c76',\n}\n\nconst QUIET_DARK: ThemeColors = {\n ...FIGMA_AGLYN_DARK,\n canvas: '#1a1a19',\n surface: '#222221',\n raised: '#2b2b29',\n sunken: '#141413',\n rail: '#121211',\n hover: '#30302e',\n titlebar: '#1e1e1d',\n borderSubtle: '#353533',\n borderStrong: '#4a4a47',\n textPrimary: '#f2f2ef',\n textSecondary: '#b5b5af',\n textTertiary: '#8e8e88',\n}\n\nconst NIGHT: ThemeColors = {\n canvas: '#0b1120',\n surface: '#121a2b',\n raised: '#1a2337',\n sunken: '#070c17',\n rail: '#050913',\n hover: '#1e2840',\n titlebar: '#0e1525',\n borderSubtle: '#222c42',\n borderStrong: '#33405c',\n textPrimary: '#eef2fa',\n textSecondary: '#a9b4cb',\n textTertiary: '#8290ab',\n successBg: '#12301f',\n successFg: '#7bcb8f',\n warningBg: '#352914',\n warningFg: '#f6be6e',\n dangerBg: '#3a1820',\n dangerFg: '#f08a8f',\n infoBg: '#122a47',\n infoFg: '#79b2ee',\n newBg: '#36193f',\n newFg: '#e68af6',\n scrim: '#00000099',\n ...DANGER_DARK,\n}\n\nconst HIGH_CONTRAST_LIGHT: ThemeColors = {\n canvas: '#ffffff',\n surface: '#ffffff',\n raised: '#ffffff',\n sunken: '#f0f0f0',\n rail: '#efefef',\n hover: '#e4e4e4',\n titlebar: '#ffffff',\n borderSubtle: '#6b6b6b',\n borderStrong: '#000000',\n textPrimary: '#000000',\n textSecondary: '#1f1f1f',\n textTertiary: '#3d3d3d',\n successBg: '#ddf3de',\n successFg: '#0b4d10',\n warningBg: '#ffe9c7',\n warningFg: '#5c3a00',\n dangerBg: '#fadbd9',\n dangerFg: '#7a0f0b',\n infoBg: '#ddebfa',\n infoFg: '#0a3a66',\n newBg: '#f1ddf6',\n newFg: '#5e0f70',\n scrim: '#00000080',\n ...DANGER_LIGHT,\n}\n\nconst HIGH_CONTRAST_DARK: ThemeColors = {\n canvas: '#000000',\n surface: '#000000',\n raised: '#0a0a0a',\n sunken: '#141414',\n rail: '#0f0f0f',\n hover: '#262626',\n titlebar: '#000000',\n borderSubtle: '#9a9a9a',\n borderStrong: '#ffffff',\n textPrimary: '#ffffff',\n textSecondary: '#ededed',\n textTertiary: '#c7c7c7',\n successBg: '#0b2a0e',\n successFg: '#9be7a0',\n warningBg: '#2e2000',\n warningFg: '#ffd08a',\n dangerBg: '#330b0a',\n dangerFg: '#ff9e9b',\n infoBg: '#0a2340',\n infoFg: '#9ccbff',\n newBg: '#2e0b38',\n newFg: '#f2a8ff',\n scrim: '#000000b2',\n ...DANGER_DARK,\n}\n\n/** Figma's previews of the two themes it does not hold in full. */\nexport const THEME_PREVIEWS: Readonly<Record<'ocean' | 'warm', ThemePreview>> = {\n ocean: { rail: '#0b3954', surface: '#f2f8fb', canvas: '#e3eef4', accent: '#087e8b' },\n warm: { rail: '#e9dccb', surface: '#fffaf3', canvas: '#f5ece0', accent: '#c2571a' },\n}\n\n/**\n * The rail color a theme's DARK appearance is tinted toward. Ocean's rail is\n * already dark, so it is its own seed; Warm's is a light sand, so its dark\n * appearance leans to a dark sand instead.\n */\nexport const THEME_DARK_RAIL_SEEDS: Readonly<Record<'ocean' | 'warm', string>> = {\n ocean: '#0b3954',\n warm: '#7a5634',\n}\n\n/**\n * A theme's full palette from Figma's four-color preview (AGL-3950), by the\n * same rules for both:\n *\n * Light: the preview's canvas, surface and rail as given; raised = surface;\n * sunken = canvas 25% toward the rail; hover = surface 10% toward the rail;\n * titlebar = surface 35% toward the canvas; the borders are Figma's Aglyn\n * mode's, 18% (subtle) and 22% (strong) toward the rail. Text, status, scrim\n * and the danger fill are Figma's Aglyn mode's.\n *\n * Dark: Figma's Aglyn dark mode tinted toward the dark rail seed: canvas\n * 22%, surface and raised 18%, rail 45%; everything else is that mode's.\n *\n * \"Toward\" is a straight per-channel sRGB blend, rounded to the byte.\n */\nexport function deriveThemeFromPreview(preview: ThemePreview, darkRailSeed: string): {\n light: ThemeColors\n dark: ThemeColors\n} {\n return {\n light: {\n ...FIGMA_AGLYN_LIGHT,\n canvas: preview.canvas,\n surface: preview.surface,\n raised: preview.surface,\n rail: preview.rail,\n sunken: themeLerp(preview.canvas, preview.rail, 0.25),\n hover: themeLerp(preview.surface, preview.rail, 0.1),\n titlebar: themeLerp(preview.surface, preview.canvas, 0.35),\n borderSubtle: themeLerp(FIGMA_AGLYN_LIGHT.borderSubtle, preview.rail, 0.18),\n borderStrong: themeLerp(FIGMA_AGLYN_LIGHT.borderStrong, preview.rail, 0.22),\n },\n dark: {\n ...FIGMA_AGLYN_DARK,\n canvas: themeLerp(FIGMA_AGLYN_DARK.canvas, darkRailSeed, 0.22),\n surface: themeLerp(FIGMA_AGLYN_DARK.surface, darkRailSeed, 0.18),\n raised: themeLerp(FIGMA_AGLYN_DARK.raised, darkRailSeed, 0.18),\n rail: themeLerp(FIGMA_AGLYN_DARK.rail, darkRailSeed, 0.45),\n },\n }\n}\n\nconst OCEAN = deriveThemeFromPreview(THEME_PREVIEWS.ocean, THEME_DARK_RAIL_SEEDS.ocean)\nconst WARM = deriveThemeFromPreview(THEME_PREVIEWS.warm, THEME_DARK_RAIL_SEEDS.warm)\n\n/** The Aglyn blue every Figma theme's \"Accent: Theme\" resolves to. */\nconst AGLYN_ACCENT = '#00b0ff'\n\n/** Every theme, in the order the Theme settings sheet lists them. */\nexport const THEME_PALETTES: readonly ThemePalette[] = [\n { id: 'aglyn', label: '', brandLabel: true, light: AGLYN_LIGHT, dark: AGLYN_DARK, accent: AGLYN_ACCENT, source: 'console' },\n { id: 'quiet', label: 'Quiet', light: QUIET_LIGHT, dark: QUIET_DARK, accent: AGLYN_ACCENT, source: 'figma' },\n { id: 'night', label: 'Night', light: NIGHT, dark: NIGHT, accent: AGLYN_ACCENT, alwaysDark: true, source: 'figma' },\n { id: 'ocean', label: 'Ocean', light: OCEAN.light, dark: OCEAN.dark, accent: THEME_PREVIEWS.ocean.accent, source: 'derived' },\n { id: 'warm', label: 'Warm', light: WARM.light, dark: WARM.dark, accent: THEME_PREVIEWS.warm.accent, source: 'derived' },\n {\n id: 'highContrast',\n label: 'High contrast',\n light: HIGH_CONTRAST_LIGHT,\n dark: HIGH_CONTRAST_DARK,\n accent: AGLYN_ACCENT,\n source: 'figma',\n },\n]\n\n// ── Accents ─────────────────────────────────────────────────────────────────\n\n/** The surfaces an accent's text is held to. */\nconst LIGHT_PAGE = AGLYN_LIGHT.surface\nconst DARK_PAGE = AGLYN_DARK.surface\n\n/** Text and icons on a fill that keeps a light brand color. */\nconst DARK_ON_LIGHTNESS = 0.08\n\nfunction accentText(raw: string): Pick<ThemeAccent, 'textLight' | 'textDark'> {\n return {\n textLight: walkToContrast(raw, LIGHT_PAGE, THEME_TEXT_CONTRAST, -1),\n textDark: walkToContrast(raw, DARK_PAGE, THEME_TEXT_CONTRAST, 1),\n }\n}\n\n/** Figma's Accent modes, exactly as the Accent collection holds them. */\nconst FIGMA_ACCENTS: ReadonlyArray<{ id: string; label: string; accent: Omit<ThemeAccent, 'textLight' | 'textDark'> }> = [\n {\n id: 'blue',\n label: 'Blue',\n accent: {\n fill: '#0073ae',\n icon: '#0091d5',\n soft: '#00b0ff29',\n ring: '#00b0ff',\n on: '#ffffff',\n hover: '#00669b',\n pressed: '#005884',\n },\n },\n {\n id: 'violet',\n label: 'Violet',\n accent: {\n fill: '#5b3fd6',\n icon: '#8c72f5',\n soft: '#7a5cf02e',\n ring: '#7a5cf0',\n on: '#ffffff',\n hover: '#4e34c2',\n pressed: '#4129aa',\n },\n },\n {\n id: 'pink',\n label: 'Pink',\n accent: {\n fill: '#e040fb',\n icon: '#c93ae3',\n soft: '#e040fb29',\n ring: '#e040fb',\n on: '#1e0424',\n hover: '#cc2ee6',\n pressed: '#b523cf',\n },\n },\n {\n id: 'workspaceBrand',\n label: 'Workspace brand',\n accent: {\n fill: '#2e7d5b',\n icon: '#2e8f66',\n soft: '#2e7d5b2e',\n ring: '#3fa77a',\n on: '#ffffff',\n hover: '#276c4e',\n pressed: '#205a41',\n },\n },\n]\n\n/** The named accents, Figma's own values with their text shades. */\nexport const THEME_ACCENTS: readonly ThemeNamedAccent[] = FIGMA_ACCENTS.map(({ id, label, accent }) => ({\n id,\n label,\n accent: { ...accent, ...accentText(accent.ring) },\n}))\n\n/** The accent picker's swatches, in Figma's order. */\nexport const THEME_ACCENT_SWATCHES: readonly ThemeAccentSwatch[] = [\n { id: 'aglynBlue', label: 'Brand blue', color: '#0073ae' },\n { id: 'sky', label: 'Sky', color: '#00b0ff' },\n { id: 'violet', label: 'Violet', color: '#7a5cf0' },\n { id: 'pink', label: 'Pink', color: '#e040fb' },\n { id: 'green', label: 'Green', color: '#2e7d5b' },\n { id: 'warm', label: 'Warm', color: '#c2571a' },\n]\n\n/**\n * Every control's shade of one accent color (AGL-3950).\n *\n * A color one of Figma's Accent modes is drawn from (its ring) answers with\n * that mode exactly: Figma wins where it defines a value. Any other color is\n * derived:\n *\n * 1. White text on the color already reads (4.5:1): the fill is the color,\n * `on` is white.\n * 2. The color carries white text only at the large-text bar (3:1 up to\n * 4.5:1) and near-black text reads on it at 4.5:1: the fill KEEPS the\n * brand color and `on` flips to near-black, the color's own hue at 8%\n * lightness. Darkening such a color far enough for white text moves it\n * visibly off the brand; this is Figma's Pink (#E040FB, 3.34:1). The 3:1\n * threshold is the one that sends Pink here and Blue (2.43:1) and the\n * workspace green (2.99:1) to rule 3, as Figma draws them.\n * 3. Otherwise the fill is the color darkened in 2% HSL-lightness steps until\n * white reaches 4.5:1, and `on` is white.\n *\n * hover and pressed are the fill 4% and 8% darker (HSL lightness), soft is\n * the color at 16% alpha, ring is the color, icon is the color darkened in 2%\n * steps until it reaches 3:1 on the light surface, and the two text shades\n * walk it (darker on light, lighter on dark) until 4.5:1 on that surface.\n * Null for anything that is not `#RRGGBB`.\n */\nexport function deriveAccent(color: string): ThemeAccent | null {\n if (!isThemeColor(color)) return null\n const raw = color.toLowerCase()\n const named = THEME_ACCENTS.find((entry) => entry.accent.ring === raw)\n if (named) return { ...named.accent }\n const white = '#ffffff'\n const onWhite = themeContrast(raw, white)\n let fill = raw\n let on = white\n if (onWhite < THEME_TEXT_CONTRAST) {\n const rgb = channels(raw) as [number, number, number]\n const [h, s] = toHsl(rgb)\n const nearBlack = fromHsl(h, Math.min(s, 0.8), DARK_ON_LIGHTNESS)\n if (onWhite >= THEME_NON_TEXT_CONTRAST && themeContrast(raw, nearBlack) >= THEME_TEXT_CONTRAST) {\n on = nearBlack\n } else {\n fill = walkToContrast(raw, white, THEME_TEXT_CONTRAST, -1)\n }\n }\n return {\n fill,\n icon: walkToContrast(raw, LIGHT_PAGE, THEME_NON_TEXT_CONTRAST, -1),\n soft: withAlpha(raw, 0.16),\n ring: raw,\n on,\n hover: shiftLightness(fill, -0.04),\n pressed: shiftLightness(fill, -0.08),\n ...accentText(raw),\n }\n}\n\n// ── Density ─────────────────────────────────────────────────────────────────\n\n/** Figma's Density modes. */\nexport const THEME_DENSITIES: readonly ThemeDensity[] = [\n {\n id: 'comfortable',\n label: 'Comfortable',\n space2: 2,\n space4: 4,\n space6: 6,\n space8: 8,\n space10: 10,\n space12: 12,\n space16: 16,\n space20: 20,\n space24: 24,\n space32: 32,\n space40: 40,\n space48: 48,\n space64: 64,\n radiusXs: 4,\n radiusSm: 6,\n radiusMd: 8,\n radiusLg: 12,\n radiusXl: 16,\n radius2xl: 24,\n radiusFull: 999,\n rowHeight: 32,\n controlHeight: 32,\n listRowHeight: 60,\n phoneRowHeight: 52,\n titlebarHeight: 52,\n },\n {\n id: 'compact',\n label: 'Compact',\n space2: 2,\n space4: 4,\n space6: 4,\n space8: 6,\n space10: 8,\n space12: 8,\n space16: 12,\n space20: 16,\n space24: 20,\n space32: 24,\n space40: 32,\n space48: 40,\n space64: 48,\n radiusXs: 4,\n radiusSm: 6,\n radiusMd: 8,\n radiusLg: 12,\n radiusXl: 16,\n radius2xl: 24,\n radiusFull: 999,\n rowHeight: 28,\n controlHeight: 28,\n listRowHeight: 48,\n phoneRowHeight: 44,\n titlebarHeight: 44,\n },\n]\n\n/** A theme by id, Aglyn for anything unknown. */\nexport function themePalette(id: unknown): ThemePalette {\n return THEME_PALETTES.find((entry) => entry.id === id) ?? THEME_PALETTES[0]\n}\n"],"names":["HEX","channels","color","match","exec","value","map","index","parseInt","slice","hexOf","rgb","Math","round","min","max","toString","padStart","join","isThemeColor","test","linear","channel","luminance","themeContrast","a","b","x","y","high","low","sort","p","q","toHsl","r","g","red","green","blue","lightness","delta","saturation","hue","fromHsl","l","t","shiftLightness","h","s","walkToContrast","against","minContrast","direction","candidate","step","themeLerp","from","to","amount","withAlpha","alpha","THEME_TEXT_CONTRAST","THEME_NON_TEXT_CONTRAST","DANGER_LIGHT","dangerFill","dangerFillHover","dangerFillPressed","DANGER_DARK","FIGMA_AGLYN_LIGHT","canvas","surface","raised","sunken","rail","hover","titlebar","borderSubtle","borderStrong","textPrimary","textSecondary","textTertiary","successBg","successFg","warningBg","warningFg","dangerBg","dangerFg","infoBg","infoFg","newBg","newFg","scrim","FIGMA_AGLYN_DARK","THEME_FIGMA_BASE","light","dark","AGLYN_LIGHT","AGLYN_DARK","THEME_CONSOLE_FIELDS","QUIET_LIGHT","QUIET_DARK","NIGHT","HIGH_CONTRAST_LIGHT","HIGH_CONTRAST_DARK","THEME_PREVIEWS","ocean","accent","warm","THEME_DARK_RAIL_SEEDS","deriveThemeFromPreview","preview","darkRailSeed","OCEAN","WARM","AGLYN_ACCENT","THEME_PALETTES","id","label","brandLabel","source","alwaysDark","LIGHT_PAGE","DARK_PAGE","DARK_ON_LIGHTNESS","accentText","raw","textLight","textDark","FIGMA_ACCENTS","fill","icon","soft","ring","on","pressed","THEME_ACCENTS","THEME_ACCENT_SWATCHES","deriveAccent","toLowerCase","named","find","entry","white","onWhite","nearBlack","THEME_DENSITIES","space2","space4","space6","space8","space10","space12","space16","space20","space24","space32","space40","space48","space64","radiusXs","radiusSm","radiusMd","radiusLg","radiusXl","radius2xl","radiusFull","rowHeight","controlHeight","listRowHeight","phoneRowHeight","titlebarHeight","themePalette"],"mappings":"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"}
|
|
@@ -22,7 +22,7 @@ import type { ITimestamp } from '@aglyn/shared-util-timestamp';
|
|
|
22
22
|
* their owner. Types follow the common SaaS taxonomy so the console can
|
|
23
23
|
* icon/group them without a registry.
|
|
24
24
|
*/
|
|
25
|
-
export type AglynNotificationType = 'billing.invoice' | 'billing.paymentFailed' | 'billing.subscriptionCanceled' | 'billing.usage' | 'team.invite' | 'team.roleChanged' | 'team.hostAccessGranted' | 'content.formSubmission' | 'content.booking' | 'content.order' | 'content.lowStock' | 'content.taskAssigned' | 'content.taskReminder' | 'content.contactAssigned' | 'content.leadAssigned' | 'content.crmDailyDigest' | 'content.insightsDigest' | 'content.aiJobNeedsYou' | 'content.aiJobDone' | 'content.aiJobFailed' | 'marketplace.review' | 'support.ticketOpened' | 'support.ticketReply' | 'system.announcement' | 'system.pluginVerifierRegression' | 'system.signInMethodRemoved' | 'system.scopeDrift' | 'system.ssoDomainUnverified' | 'system.formSubmissionsPaused' | 'system.visitorRecordsPaused' | 'system.abuseReportUrgent' | 'system.riskNotice' | 'system.dmcaCounterNotice' | 'system.bandwidthCeilingTripped' | 'system.bandwidthCapEngaged' | 'system.billingWebhookHalfApplied' | 'system.disputeUnattributed' | 'system.operatorAlert' | 'staff.userSignedUp' | 'staff.orgCreated' | 'staff.siteCreated' | 'staff.subscriptionStarted' | 'staff.subscriptionCanceled' | 'staff.planChanged' | 'staff.paymentFailed';
|
|
25
|
+
export type AglynNotificationType = 'billing.invoice' | 'billing.paymentFailed' | 'billing.subscriptionCanceled' | 'billing.usage' | 'billing.domain' | 'team.invite' | 'team.roleChanged' | 'team.hostAccessGranted' | 'content.formSubmission' | 'content.booking' | 'content.order' | 'content.lowStock' | 'content.taskAssigned' | 'content.taskReminder' | 'content.contactAssigned' | 'content.leadAssigned' | 'content.crmDailyDigest' | 'content.insightsDigest' | 'content.aiJobNeedsYou' | 'content.aiJobDone' | 'content.aiJobFailed' | 'marketplace.review' | 'support.ticketOpened' | 'support.ticketReply' | 'system.announcement' | 'system.pluginVerifierRegression' | 'system.signInMethodRemoved' | 'system.scopeDrift' | 'system.ssoDomainUnverified' | 'system.formSubmissionsPaused' | 'system.visitorRecordsPaused' | 'system.abuseReportUrgent' | 'system.riskNotice' | 'system.dmcaCounterNotice' | 'system.bandwidthCeilingTripped' | 'system.bandwidthCapEngaged' | 'system.billingWebhookHalfApplied' | 'system.disputeUnattributed' | 'system.operatorAlert' | 'staff.userSignedUp' | 'staff.orgCreated' | 'staff.siteCreated' | 'staff.subscriptionStarted' | 'staff.subscriptionCanceled' | 'staff.planChanged' | 'staff.paymentFailed';
|
|
26
26
|
export interface AglynNotification {
|
|
27
27
|
$id?: string;
|
|
28
28
|
type: AglynNotificationType;
|
|
@@ -21,6 +21,7 @@ export const NOTIFICATION_TYPE_LABELS = {
|
|
|
21
21
|
'billing.paymentFailed': 'Payment failed',
|
|
22
22
|
'billing.subscriptionCanceled': 'Subscription canceled',
|
|
23
23
|
'billing.usage': 'Usage threshold',
|
|
24
|
+
'billing.domain': 'Domain',
|
|
24
25
|
'team.invite': 'Team invite',
|
|
25
26
|
'team.roleChanged': 'Role changed',
|
|
26
27
|
'team.hostAccessGranted': 'Site access granted',
|
|
@@ -92,6 +93,9 @@ export const NOTIFICATION_LEVEL_LABELS = {
|
|
|
92
93
|
'billing.subscriptionCanceled': 'critical',
|
|
93
94
|
// At a limit. A step on the way to it is stamped `info` by its emitter.
|
|
94
95
|
'billing.usage': 'warning',
|
|
96
|
+
// Stamped by the emitter: success for a purchase or renewal, warning for a
|
|
97
|
+
// refund, a registrant email to confirm, or an expiry.
|
|
98
|
+
'billing.domain': 'info',
|
|
95
99
|
'team.invite': 'info',
|
|
96
100
|
'team.roleChanged': 'info',
|
|
97
101
|
'team.hostAccessGranted': 'info',
|
|
@@ -394,6 +398,11 @@ export const NOTIFICATION_CHANNEL_DEFAULTS = inCategoryOrder(CORE_CHANNEL_DEFAUL
|
|
|
394
398
|
'billing.invoice': {
|
|
395
399
|
email: true
|
|
396
400
|
},
|
|
401
|
+
// The domains plugin sends its own designed email for each and passes
|
|
402
|
+
// `skipEmail`, so this default never mails one twice.
|
|
403
|
+
'billing.domain': {
|
|
404
|
+
email: true
|
|
405
|
+
},
|
|
397
406
|
'billing.paymentFailed': {
|
|
398
407
|
email: true
|
|
399
408
|
},
|
|
@@ -454,6 +463,7 @@ export const NOTIFICATION_CHANNEL_DEFAULTS = inCategoryOrder(CORE_CHANNEL_DEFAUL
|
|
|
454
463
|
'content.leadAssigned': 'Open lead',
|
|
455
464
|
'billing.invoice': 'Open billing',
|
|
456
465
|
'billing.paymentFailed': 'Update payment method',
|
|
466
|
+
'billing.domain': 'Open domain',
|
|
457
467
|
'billing.subscriptionCanceled': 'Open billing',
|
|
458
468
|
'billing.usage': 'Open billing',
|
|
459
469
|
'support.ticketOpened': 'Open ticket',
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/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 type { ITimestamp } from '@aglyn/shared-util-timestamp'\nimport { buildRoute, Route } from './console-routes'\nimport {\n PLUGIN_NOTIFICATION_CATEGORIES_DECLARED,\n PLUGIN_NOTIFICATION_DIGESTS_DECLARED,\n} from '../plugin-manager/first-party-plugins.generated'\n\n/**\n * In-app notifications (AGL-259): per-user docs at\n * `users/{uid}/notifications/{id}`, written by the Admin SDK (emitters in\n * the API routes + the `notifyAdmins` automation step) and read/marked by\n * their owner. Types follow the common SaaS taxonomy so the console can\n * icon/group them without a registry.\n */\nexport type AglynNotificationType =\n | 'billing.invoice'\n | 'billing.paymentFailed'\n // Stripe gave up on a failed renewal and CANCELLED the subscription\n // (AGL-1877). Distinct from `paymentFailed`, which announces one failed\n // attempt inside a retry window the customer can still recover from; this\n // one is the window having closed. Measured on the test account with a\n // test clock: five attempts over 21.08 days, then\n // `cancellation_details.reason: 'payment_failed'` — and until this existed\n // the org went silently to Free at that moment, with the `past_due` banner\n // disappearing at exactly the instant the consequence arrived.\n | 'billing.subscriptionCanceled'\n | 'billing.usage'\n | 'team.invite'\n | 'team.roleChanged'\n | 'team.hostAccessGranted'\n | 'content.formSubmission'\n | 'content.booking'\n | 'content.order'\n | 'content.lowStock'\n // A CRM task was assigned to the recipient by somebody else (AGL-2599).\n // `content.` because it is the same kind of message as a form submission\n // or a booking: a thing on the site that now needs this person's hands,\n // not a change to their standing (`team.`) and not a platform notice\n // (`system.`). Someone who has muted the operational stream has said they\n // do not want to be told about work as it arrives, and a task is work.\n | 'content.taskAssigned'\n // A CRM task's reminder came due (AGL-2659): the hourly runner telling\n // the assignee, at the task's own time, that the task is due. `content.`\n // beside `taskAssigned` for the same reason it gives — work on the site,\n // not standing — and so the one mute that stops \"work arriving\" stops\n // \"work falling due\" with it. The mute governs the whole reminder, mail\n // included, unlike the digest's: a reminder is one message about one\n // task, and there is no schedule to keep separately from it.\n | 'content.taskReminder'\n // A contact, or a lead, became the recipient's to work (AGL-2618): a\n // capture the assignment rules or the site's default owner routed to\n // them, a lead somebody converted and handed to them, or an automation's\n // \"assign an owner\" step — every server path that writes `ownerUid`\n // except the recipient assigning themselves. Two types rather than one\n // because the record the person opens differs: a lead is the site's own\n // working record and a contact is the org's, and the link goes to the\n // one they will actually work. `content.`, beside `taskAssigned`, for the\n // same reason: work arriving, not standing changing.\n | 'content.contactAssigned'\n | 'content.leadAssigned'\n // The morning's CRM digest (AGL-2619): what the recipient owes today —\n // overdue and due-today tasks, and the leads nobody has worked. `content.`\n // for the reason `taskAssigned` is: it is about work on the site, and the\n // person who muted the operational stream has asked not to be told about\n // work. The digest EMAIL is governed separately, by its switch in\n // `digestPrefs` (see `digestEnabled`): the mute is a fact about the console\n // feed and the digest switch is a fact about the digest.\n | 'content.crmDailyDigest'\n // The weekly insights (AGL-2915): what a site's figures showed that week,\n // for a person who asked for them. `content.` beside the CRM digest, for the\n // reason that one gives: it is about the site's work, and the operational\n // mute governs the console notification while the person's own switch for\n // the digest governs the digest.\n | 'content.insightsDigest'\n // An AI job the recipient started (AGL-3593): its plan is ready and waits\n // for them to confirm it, it finished and its drafts are ready to open, or\n // it stopped. Raised by the jobs machine where the job's state changes —\n // on the beat as often as in a request — to the person who created the\n // job, once per change. `content.` beside the insights digest: work on the\n // workspace's sites arriving for the person, which the operational mute\n // governs. The plan-ready one is how a person who left the dialog learns\n // their site is waiting on them.\n | 'content.aiJobNeedsYou'\n | 'content.aiJobDone'\n | 'content.aiJobFailed'\n // Marketplace review verdicts (AGL-432/653).\n | 'marketplace.review'\n // Support desk, staff audience (AGL-850): a subscriber opened or replied to\n // a ticket. Fanned out to staff-claim holders, not org members.\n | 'support.ticketOpened'\n | 'support.ticketReply'\n | 'system.announcement'\n // A live plugin version stopped passing the static verifier (AGL-1086).\n // Staff audience: bytes we told workspaces were checked now fail checks\n // that did not exist when they were approved.\n //\n // Deliberately NOT under `marketplace.` (AGL-1088). Category is the prefix,\n // categories are mutable per user, and Marketplace is the category a staff\n // member mutes to stop routine listing-review chatter — which would drop\n // this alert as collateral. `system` is the bucket nobody mutes to reduce\n // noise. The adminAudit record survives a mute either way; the timeliness\n // does not, and timeliness is the whole point of the alert.\n | 'system.pluginVerifierRegression'\n // A sign-in method was removed from the user's own account because their\n // organization turned on SSO enforcement (AGL-1129). `system.`, not\n // `team.`, for the AGL-1088 reason above: `team` is the category someone\n // mutes to stop routine roster chatter, and \"the way you sign in just\n // changed\" is not chatter — the next sign-in fails without it.\n | 'system.signInMethodRemoved'\n // Documents in a scoped collection carrying no `visibleTo` (AGL-1478).\n // Staff audience: the weekly dry run found resources that are invisible\n // to every site-scoped read, which always means a creation path forgot\n // the field. `system.` for the AGL-1088 reason above, and because the\n // collection it names may be `marketplace`-adjacent or not.\n | 'system.scopeDrift'\n // An SSO domain that WAS DNS-proven has stopped answering with our\n // challenge record, for three consecutive weekly sweeps (AGL-1210).\n // Nothing has been turned off — sign-in for that domain still routes\n // exactly as it did — and that is precisely why this has to be said out\n // loud rather than left in a log.\n //\n // `system.`, not `team.`, for the AGL-1088 reason above and one of its\n // own: the audience is the org's own admins, the subject is whether their\n // people can still sign in, and it must reach them BEFORE anybody decides\n // to revoke the routing. A category somebody mutes to quieten roster\n // chatter is the wrong place for the one warning that precedes a lockout.\n | 'system.ssoDomainUnverified'\n // A site hit the per-month form-submission abuse ceiling and further\n // submissions are being refused (AGL-1655). `system.`, not `content.`, for\n // the AGL-1088 reason above and more sharply than any of them: `content` is\n // literally the category a site owner mutes to stop routine form-submission\n // chatter, and this is the one form notification that says the form has\n // STOPPED accepting. Filing it under the muted bucket would guarantee it\n // reaches nobody on exactly the sites busy enough to trip it.\n | 'system.formSubmissionsPaused'\n // A site reached the flat platform ceiling on member accounts or on lead\n // records, so further ones are being refused (AGL-1529). `system.` for the\n // same reason as `formSubmissionsPaused` directly above, and with the same\n // sharpness: `content.` is the bucket an owner mutes to stop routine\n // sign-up and lead chatter, and this is the one notification saying the\n // sign-ups have STOPPED — muted exactly on the sites busy enough to trip it.\n | 'system.visitorRecordsPaused'\n // An outsider reported one of our sites for phishing, malware or CSAM\n // (AGL-1964). `system` for the AGL-1088 reason and, again, more sharply\n // than most: this notification goes to STAFF, not to a customer, and its\n // subject is somebody else's site. There is no bucket a recipient could\n // mute it into that would be honest — nobody has opted into being told a\n // stranger is being phished through our platform, and nobody should be able\n // to opt out of it either.\n //\n // Only the urgent categories raise one, and only on a first report. See the\n // fan-out in apps/tenant/app/api/report-abuse/route.ts for why: a flood of\n // alerts IS the flood, and the alert it would cost us is the phishing one.\n | 'system.abuseReportUrgent'\n // Something on the recipient's OWN workspace was held, flagged, locked or\n // paused (AGL-3368): an email or page held for review, a flagged domain,\n // a fraud signal on a payment, a lock and its lift. Written by\n // `notifyRiskEvent` from the risk notice catalog, which says what happened,\n // what it means and how to request a review. `system.` for the AGL-1088\n // reason: it is the one message saying the owner's work has STOPPED, and a\n // category muted to quieten routine chatter must not swallow it. It sends\n // its own email, transactional and preference-free, so it is also in\n // NOTIFICATION_SELF_SENT_EMAIL_TYPES.\n | 'system.riskNotice'\n // The §512(g) counter-notice (AGL-1983), and the one place the \"only urgent\n // categories raise a notification\" restraint above is deliberately not\n // applied. Every counter-notice carries a statutory deadline that is\n // ALREADY RUNNING when it arrives — the clock counts from the subscriber's\n // submission, not from our attention — so there is no low-value tail of\n // these to drown out the important ones, and the one that goes unread is a\n // customer locked out of their own work plus an unmet obligation under\n // §512(g)(2)(A). Raised on first submission only, like the one above, so a\n // resubmitting customer cannot re-alert.\n | 'system.dmcaCounterNotice'\n // A site crossed the per-month bandwidth abuse ceiling (AGL-2155). Two\n // audiences share one type: staff, because an uncompensated free site\n // serving six figures of page views is an incident; and the site's own\n // managers, because on free the site is now serving a capped notice and\n // nobody should learn that from a visitor.\n //\n // `system.`, not `billing.`, for the AGL-1088 reason and one specific to\n // this meter: on the free plan there is no bill, so `billing` would be the\n // one category where the message is literally never about money — and it is\n // exactly the free-tier trip that changes what visitors see.\n | 'system.bandwidthCeilingTripped'\n // A free site crossed its PLAN's included bandwidth and is now serving the\n // capped notice (AGL-2413). Distinct from the ceiling above on purpose: that\n // one is an incident at 10x the band and goes to staff first; this one is an\n // ordinary quota at 1x, concerns only the site's own managers, and its\n // remedy is an upgrade rather than an investigation. Sending both under one\n // type would tell an owner their traffic was being treated as suspected\n // abuse when it is simply a successful free site.\n | 'system.bandwidthCapEngaged'\n // A Stripe billing webhook threw AFTER its handlers had begun (AGL-2157),\n // so its side effects may be half applied and its idempotency claim is\n // being HELD — Stripe will not retry it. Staff audience: this is the one\n // failure on that route no automatic retry can make safe, because the\n // handlers behind it are not all idempotent, and a human has to reconcile.\n | 'system.billingWebhookHalfApplied'\n // A card dispute arrived that NOTHING in the platform claimed (AGL-2429):\n // no `platformRevenue` row, no storefront order, no marketplace purchase.\n // Staff audience, and the reason it needs a type of its own is that its\n // absence was the bug — the routine case (a storefront or marketplace\n // chargeback, which the plugins own) and the fault case were the same\n // silence from the route's side, so a dispute nobody handled looked\n // exactly like one somebody did.\n //\n // `system.`, not `billing.`, for the AGL-1088 reason and one specific to\n // this alert: `billing` is the category a recipient is most likely to have\n // muted as routine invoice traffic, and this is the one message on that\n // route where muting it means money moves with nobody looking.\n | 'system.disputeUnattributed'\n // An operator alert from the registry in `./operator-alerts` (AGL-3377)\n // that has no notification type of its own: a lost dispute, a failed\n // erasure, a degraded health check. One type for all of them because the\n // registry, not the taxonomy, is what names the alert; the title and body\n // say which one it is. `system.` for the AGL-1088 reason above: these are\n // the faults nobody may mute away as routine chatter.\n | 'system.operatorAlert'\n // Somebody created an account, and somebody created a workspace\n // (AGL-3225). Staff audience, and the only two types in the taxonomy whose\n // subject is the platform's own growth rather than anybody's work.\n //\n // `staff.`, and NOT `system.`, and the AGL-1088 note above is the reason\n // rather than an exception to it: `system` is the bucket nobody mutes to\n // reduce noise, which is exactly what makes it the wrong home for the two\n // routine, high-volume events in the product. A staff member must be able\n // to stop hearing about every sign-up without also dropping the verifier\n // regression and the unattributed dispute that share that bucket — and\n // before this category existed, there was nowhere to put them where that\n // was true.\n //\n // The category also tells the settings page who a row is for: `staff` is\n // rendered only to claim holders, so no customer is shown a switch for\n // notifications they could never receive.\n | 'staff.userSignedUp'\n | 'staff.orgCreated'\n // And somebody created a site (AGL-3491): the third growth event, the one\n // that says an account started building. Same category, same reason.\n | 'staff.siteCreated'\n // And when money moves (AGL-3267). The category was two signup types, so\n // the one thing a platform feed exists to report — revenue starting,\n // changing and stopping — was the one thing it did not.\n //\n // `staff.paymentFailed` is deliberately NOT the customer's\n // `billing.paymentFailed`: different audience, different action. The\n // customer is told to fix their card; staff are told a paying workspace is\n // about to stop paying.\n | 'staff.subscriptionStarted'\n | 'staff.subscriptionCanceled'\n | 'staff.planChanged'\n | 'staff.paymentFailed'\n\nexport interface AglynNotification {\n $id?: string\n type: AglynNotificationType\n title: string\n body?: string\n /** Console path the notification opens (e.g. a host inbox). */\n link?: string\n orgId?: string\n hostId?: string\n /**\n * The pending invitation this notification offers the recipient\n * (AGL-3402), under `orgs/{orgId}/invites`. Present only on the invitee's\n * own `team.invite`; the console opens the accept/decline dialog for it\n * instead of following `link`, and answering marks it read.\n */\n inviteId?: string\n createdAt?: ITimestamp\n /** When the owner read it; absent or null while unread. */\n readAt?: ITimestamp | null\n /**\n * Whether the owner has read it — `false` on create, `true` beside\n * `readAt` when read (AGL-3321).\n *\n * A boolean rather than `readAt: null`, because the feed filters by it\n * under its newest-first cursor. Equality on a boolean answers BOTH\n * states (`read == false`, `read == true`) beneath `orderBy('createdAt')`\n * with one composite index. A nullable timestamp answers \"unread\" by\n * equality but \"read\" only as `readAt != null`, and Firestore requires an\n * inequality's field to be the FIRST sort, which would order the feed by\n * when things were read instead of when they arrived. Absent only on\n * notifications written before the field existed, which\n * `tools/scripts/backfill-notification-read.mjs` stamps.\n */\n read?: boolean\n /**\n * How urgently this one notification asks to be read (AGL-3437), stamped\n * by its emitter. Absent on a notification whose emitter has no reason to\n * say more than its type does, and on everything written before the field\n * existed; read it through {@link notificationLevel}, never directly.\n */\n level?: NotificationLevel\n}\n\nexport const NOTIFICATION_TYPE_LABELS: Record<AglynNotificationType, string> = {\n 'billing.invoice': 'Invoice available',\n 'billing.paymentFailed': 'Payment failed',\n 'billing.subscriptionCanceled': 'Subscription canceled',\n 'billing.usage': 'Usage threshold',\n 'team.invite': 'Team invite',\n 'team.roleChanged': 'Role changed',\n 'team.hostAccessGranted': 'Site access granted',\n 'content.formSubmission': 'Form submission',\n 'content.booking': 'New booking',\n 'content.order': 'New order',\n 'content.lowStock': 'Low stock',\n 'content.taskAssigned': 'Task assigned to you',\n 'content.taskReminder': 'Task reminder',\n 'content.contactAssigned': 'Contact assigned to you',\n 'content.leadAssigned': 'Lead assigned to you',\n 'content.crmDailyDigest': 'Daily CRM digest',\n 'content.insightsDigest': 'Weekly insights',\n 'content.aiJobNeedsYou': 'AI job needs you',\n 'content.aiJobDone': 'AI job finished',\n 'content.aiJobFailed': 'AI job stopped',\n 'marketplace.review': 'Listing review',\n\n 'support.ticketOpened': 'New support ticket',\n 'support.ticketReply': 'Support ticket reply',\n 'system.announcement': 'Announcement',\n 'system.pluginVerifierRegression': 'Plugin verifier regression',\n 'system.signInMethodRemoved': 'Sign-in method removed',\n 'system.scopeDrift': 'Resources missing a sharing scope',\n 'system.ssoDomainUnverified': 'SSO domain no longer proves ownership',\n 'system.formSubmissionsPaused': 'Form submissions paused',\n 'system.visitorRecordsPaused': 'Sign-ups or leads paused',\n 'system.abuseReportUrgent': 'Urgent abuse report',\n 'system.riskNotice': 'Held, flagged or locked on your workspace',\n 'system.dmcaCounterNotice': 'DMCA counter-notice',\n 'system.bandwidthCeilingTripped': 'Bandwidth ceiling reached',\n 'system.bandwidthCapEngaged': 'Monthly traffic limit reached',\n 'system.billingWebhookHalfApplied': 'Billing webhook half applied',\n 'system.disputeUnattributed': 'Card dispute with no owner',\n 'system.operatorAlert': 'Operator alert',\n 'staff.userSignedUp': 'New account',\n 'staff.orgCreated': 'New workspace',\n 'staff.siteCreated': 'New site',\n 'staff.subscriptionStarted': 'New subscription',\n 'staff.subscriptionCanceled': 'Subscription canceled',\n 'staff.planChanged': 'Plan changed',\n 'staff.paymentFailed': 'Payment failed (workspace)',\n}\n\n/**\n * HOW LOUD A NOTIFICATION IS (AGL-3437), which the console draws as its\n * color, icon and accent.\n *\n * - `critical` — something stopped, money is moving with nobody looking, or\n * fraud: a fraud signal, a failed payment, a form that stopped accepting.\n * - `warning` — something degraded or is close to stopping: a health check\n * degraded, a limit reached, low stock.\n * - `success` — good news: an account, a workspace or a subscription\n * started, an order or a booking arrived, a health check recovered.\n * - `info` — worth reading, nothing wrong: an invite, a ticket reply, a\n * usage step on the way to a limit.\n * - `neutral` — routine work arriving: a form submission, an assignment, a\n * digest.\n *\n * A level is a property of the NOTIFICATION, not only of its type: one\n * `system.operatorAlert` says a check degraded and the next says it\n * recovered, and a usage notice at 75% is not the one at 100%. The emitter\n * stamps `level` when it knows more than the type; the type's default in\n * {@link NOTIFICATION_TYPE_LEVELS} answers for everything else.\n */\nexport type NotificationLevel =\n | 'critical'\n | 'warning'\n | 'success'\n | 'info'\n | 'neutral'\n\n/** Every level, loudest first — the order {@link loudestNotificationLevel} ranks by. */\nexport const NOTIFICATION_LEVELS: readonly NotificationLevel[] = [\n 'critical',\n 'warning',\n 'success',\n 'info',\n 'neutral',\n]\n\nexport const NOTIFICATION_LEVEL_LABELS: Record<NotificationLevel, string> = {\n critical: 'Critical',\n warning: 'Warning',\n success: 'Good news',\n info: 'Info',\n neutral: 'Routine',\n}\n\n/**\n * Each core type's level when its emitter stamps none. A plugin's types are\n * not listed: a plugin emitter stamps `level` itself, and an unstamped one\n * reads as `neutral`.\n *\n * A type the operator alert registry raises under its own notification type\n * must agree with that alert's level, so the backlog written before `level`\n * existed reads the same as what the registry writes today\n * (`notification-levels.spec.ts` holds the two together).\n */\nexport const NOTIFICATION_TYPE_LEVELS: Record<\n AglynNotificationType,\n NotificationLevel\n> = {\n 'billing.invoice': 'neutral',\n 'billing.paymentFailed': 'critical',\n 'billing.subscriptionCanceled': 'critical',\n // At a limit. A step on the way to it is stamped `info` by its emitter.\n 'billing.usage': 'warning',\n 'team.invite': 'info',\n 'team.roleChanged': 'info',\n 'team.hostAccessGranted': 'info',\n 'content.formSubmission': 'neutral',\n 'content.booking': 'success',\n 'content.order': 'success',\n 'content.lowStock': 'warning',\n 'content.taskAssigned': 'neutral',\n 'content.taskReminder': 'info',\n 'content.contactAssigned': 'neutral',\n 'content.leadAssigned': 'neutral',\n 'content.crmDailyDigest': 'neutral',\n 'content.insightsDigest': 'neutral',\n // A plan waits for the person; nothing is built until they confirm it.\n 'content.aiJobNeedsYou': 'warning',\n 'content.aiJobDone': 'success',\n 'content.aiJobFailed': 'warning',\n 'marketplace.review': 'info',\n 'support.ticketOpened': 'info',\n 'support.ticketReply': 'info',\n 'system.announcement': 'info',\n 'system.pluginVerifierRegression': 'warning',\n 'system.signInMethodRemoved': 'warning',\n 'system.scopeDrift': 'info',\n 'system.ssoDomainUnverified': 'warning',\n 'system.formSubmissionsPaused': 'critical',\n 'system.visitorRecordsPaused': 'critical',\n 'system.abuseReportUrgent': 'critical',\n 'system.riskNotice': 'critical',\n 'system.dmcaCounterNotice': 'critical',\n 'system.bandwidthCeilingTripped': 'warning',\n // The site is serving the capped notice to its visitors.\n 'system.bandwidthCapEngaged': 'critical',\n 'system.billingWebhookHalfApplied': 'critical',\n 'system.disputeUnattributed': 'critical',\n // The registry stamps each alert's own level; this answers only for one\n // written before it did.\n 'system.operatorAlert': 'warning',\n 'staff.userSignedUp': 'success',\n 'staff.orgCreated': 'success',\n 'staff.siteCreated': 'success',\n 'staff.subscriptionStarted': 'success',\n 'staff.subscriptionCanceled': 'warning',\n 'staff.planChanged': 'info',\n 'staff.paymentFailed': 'warning',\n}\n\nconst LEVEL_SET: ReadonlySet<string> = new Set(NOTIFICATION_LEVELS)\n\n/** Whether a stored value is a level this build knows. */\nexport function isNotificationLevel(value: unknown): value is NotificationLevel {\n return typeof value === 'string' && LEVEL_SET.has(value)\n}\n\n/**\n * A notification's level: what its emitter stamped, else its type's\n * default, else `neutral`. A stamped value this build does not know — a\n * level added later, read by an older console — falls back the same way\n * rather than drawing nothing.\n */\nexport function notificationLevel(\n notification: { type?: string; level?: unknown } | null | undefined,\n): NotificationLevel {\n const stamped = notification?.level\n if (isNotificationLevel(stamped)) return stamped\n const byType = (NOTIFICATION_TYPE_LEVELS as Record<string, NotificationLevel>)[\n notification?.type ?? ''\n ]\n return byType ?? 'neutral'\n}\n\n/**\n * A usage notice's level at `percent` of its allowance: `info` on a step\n * toward it, `warning` once it is reached. Every usage, budget and allotment\n * notice says the same thing at the same step, so they share this.\n */\nexport function usageNotificationLevel(percent: number): NotificationLevel {\n return percent >= 100 ? 'warning' : 'info'\n}\n\n/** The loudest of several levels, or `neutral` for none. */\nexport function loudestNotificationLevel(\n levels: Iterable<NotificationLevel>,\n): NotificationLevel {\n let best = NOTIFICATION_LEVELS.length - 1\n for (const level of levels) {\n const rank = NOTIFICATION_LEVELS.indexOf(level)\n if (rank >= 0 && rank < best) best = rank\n }\n return NOTIFICATION_LEVELS[best]\n}\n\n/**\n * The preference buckets the platform owns (AGL-267): the prefix before the\n * dot. A plugin adds its own through {@link NotificationCategoryDeclaration},\n * so this union is the core's and nothing else's.\n */\nexport type CoreNotificationCategory =\n | 'billing'\n | 'team'\n | 'content'\n | 'support'\n | 'system'\n // Staff-only, and shown only to staff (AGL-3225) — see\n // {@link STAFF_NOTIFICATION_CATEGORIES}.\n | 'staff'\n\n/**\n * A preference bucket: one of the core's, or one a first-party plugin\n * declares. The id is persisted — it keys every stored preference map — so a\n * declared id keeps its meaning only while its declaration keeps its spelling.\n */\nexport type NotificationCategory = CoreNotificationCategory | (string & {})\n\n/**\n * A notification category a plugin declares for the notifications it sends\n * (AGL-3080), under `notificationCategories` in `plugins.config.json`.\n *\n * Compiled rather than registered: `notifyUsers` resolves a recipient's\n * channels in server processes that load no plugin, and the settings page\n * draws its rows before any plugin could register. A category that had to\n * wait for its plugin would read as `system` until then — so a person who had\n * muted it would be told, and a person who had asked for its email would not.\n * The generator refuses an id the core owns or another plugin declares.\n */\nexport interface NotificationCategoryDeclaration {\n /** The plugin whose notifications carry this prefix. */\n pluginId: string\n /** The type prefix, and the key every stored preference is written under. */\n id: string\n /** The row's name on the settings page. */\n label: string\n /** What arrives under it, in the reader's words — see {@link NOTIFICATION_CATEGORY_DESCRIPTIONS}. */\n description: string\n /** What each channel does before anybody says — see {@link NOTIFICATION_CHANNEL_DEFAULTS}. */\n defaults: Record<NotificationChannel, boolean>\n}\n\n/**\n * The declared categories, in the order the settings page lists them: after\n * the workspace's own work and before the platform's notices, which is where\n * a plugin's traffic sits in a reader's day.\n */\nconst DECLARED_CATEGORIES: readonly NotificationCategoryDeclaration[] =\n PLUGIN_NOTIFICATION_CATEGORIES_DECLARED\n\n/**\n * The core's rows listed before the declared ones. Every other core category\n * follows them, in the order its `Record` spells it, so a new core category\n * cannot be left off the page.\n */\nconst LEADING_CATEGORIES: readonly CoreNotificationCategory[] = [\n 'billing',\n 'team',\n 'content',\n]\n\nfunction inCategoryOrder<T>(\n core: Record<CoreNotificationCategory, T>,\n declared: (declaration: NotificationCategoryDeclaration) => T,\n): Readonly<Record<NotificationCategory, T>> {\n // Every core id is written below, so the keys the type promises are there.\n const out = {} as Record<NotificationCategory, T>\n for (const id of LEADING_CATEGORIES) out[id] = core[id]\n for (const declaration of DECLARED_CATEGORIES) {\n out[declaration.id] = declared(declaration)\n }\n for (const id of Object.keys(core) as CoreNotificationCategory[]) {\n if (!LEADING_CATEGORIES.includes(id)) out[id] = core[id]\n }\n return out\n}\n\nconst CORE_CATEGORY_LABELS: Record<CoreNotificationCategory, string> = {\n billing: 'Billing',\n team: 'Team & access',\n content: 'Forms & bookings',\n support: 'Support',\n system: 'Product & system',\n staff: 'Platform growth',\n}\n\n/** Every category's row name, the core's and the declared, in page order. */\nexport const NOTIFICATION_CATEGORY_LABELS = inCategoryOrder(\n CORE_CATEGORY_LABELS,\n (declaration) => declaration.label,\n)\n\n/**\n * The categories only staff can receive (AGL-3225).\n *\n * The settings page hides these rows from everybody else, because a switch\n * for mail that can never arrive is a promise the product does not keep. It\n * is presentation only: `notifyStaff` is what decides the audience, and it\n * enumerates the `staff` claim rather than reading this.\n */\nexport const STAFF_NOTIFICATION_CATEGORIES: ReadonlySet<NotificationCategory> =\n new Set<NotificationCategory>(['staff'])\n\n/**\n * What each category actually covers, in the reader's words (AGL-3251).\n *\n * The settings page listed seven bare labels and two switches, so deciding\n * whether to silence `Product & system` meant guessing what was in it. The\n * type labels beneath each row now name the contents exactly; this is the\n * one-line answer for somebody who does not want to expand anything.\n *\n * Written as what ARRIVES rather than as what the bucket is called: \"someone\n * joins, a role changes\" is checkable against your own feed in a way that\n * \"team and access events\" is not. Exhaustive over the core's categories,\n * like the channel defaults below, and required of every declaration — a new\n * category cannot ship without somebody saying in plain words what lands in\n * it.\n */\nconst CORE_CATEGORY_DESCRIPTIONS: Record<CoreNotificationCategory, string> = {\n billing:\n 'Invoices, failed payments, cancellations, and usage that crosses a plan limit.',\n team: 'Somebody joins or leaves, a role changes, or a site is shared with you.',\n content:\n 'Work arriving on your sites: form submissions, bookings, orders, low stock, and the tasks and leads assigned to you.',\n support: 'New support tickets and replies on tickets you are following.',\n system:\n 'Announcements, and the faults the platform finds in your account: sign-in methods removed, traffic limits reached, billing or sharing left in a broken state.',\n staff:\n 'New accounts, new workspaces, and money moving — subscriptions starting, changing, failing and ending — across the whole platform. Only staff receive these.',\n}\n\nexport const NOTIFICATION_CATEGORY_DESCRIPTIONS = inCategoryOrder(\n CORE_CATEGORY_DESCRIPTIONS,\n (declaration) => declaration.description,\n)\n\n/**\n * The bucket a type falls in: its prefix, when a category of that id exists,\n * and `system` otherwise — the bucket nobody mutes to reduce noise, so a type\n * nothing claims still reaches the person rather than riding a mute they set\n * for something else.\n */\nexport function notificationCategory(\n type: AglynNotificationType | string,\n): NotificationCategory {\n const prefix = String(type).split('.')[0]\n return Object.prototype.hasOwnProperty.call(\n NOTIFICATION_CATEGORY_LABELS,\n prefix,\n )\n ? prefix\n : 'system'\n}\n\n/**\n * Per-user mute map stored at `users/{uid}.notificationPrefs`\n * (`{ [category]: false }` mutes); absent categories stay on.\n */\nexport function notificationMuted(\n prefs: Record<string, boolean> | null | undefined,\n type: AglynNotificationType | string,\n): boolean {\n return prefs?.[notificationCategory(type)] === false\n}\n\n/**\n * The field on `users/{uid}` that holds a person's digest switches\n * (AGL-2619): `{ [digestKey]: false }` turns one digest off, and an absent key\n * leaves it on. Its own map rather than a key in `notificationPrefs`, because\n * that map is keyed by CATEGORY and read by {@link notificationMuted} — a\n * digest is a schedule a person keeps or drops, not a bucket of types, and one\n * switch governs both the console notification and the email it travels with.\n */\nexport const DIGEST_PREFS_FIELD = 'digestPrefs'\n\n/**\n * A digest a plugin sends on its own schedule (AGL-3080), declared under\n * `notificationDigests` in `plugins.config.json` so the settings page can draw\n * its switch without loading the plugin, and the plugin's sender and that\n * switch read one key.\n *\n * On until the person turns it off: the key is stored in\n * {@link DIGEST_PREFS_FIELD} only once somebody has said no. The key is\n * persisted, so it keeps its spelling for as long as anybody's switch is\n * stored under it.\n */\nexport interface NotificationDigestDeclaration {\n /** The plugin that composes and sends it; `core` for the platform's own. */\n pluginId: string\n /** The key its switch is stored under in {@link DIGEST_PREFS_FIELD}. */\n key: string\n /** The switch's name. */\n label: string\n /** When it arrives and what it holds, in the reader's words. */\n description: string\n}\n\n/**\n * The platform's own digests, listed before any plugin's. The Weekly summary\n * (AGL-3867) is the core's because it reads every plugin's figures and\n * belongs to none of them; its key is the one\n * `@aglyn/tenant-data-admin/server/weekly-summary` reads.\n */\nconst CORE_NOTIFICATION_DIGESTS: readonly NotificationDigestDeclaration[] = [\n {\n pluginId: 'core',\n key: 'weeklySummary',\n label: 'Weekly summary',\n description:\n 'Each Monday by email: how each of your sites did last week — visitors, top pages, form ' +\n 'submissions, new leads, bookings, sales — and an overview of each workspace you run. Free, and nothing is generated.',\n },\n]\n\n/** Every declared digest, in the order the settings page lists them. */\nexport const NOTIFICATION_DIGESTS: readonly NotificationDigestDeclaration[] = [\n ...CORE_NOTIFICATION_DIGESTS,\n ...PLUGIN_NOTIFICATION_DIGESTS_DECLARED,\n]\n\n/**\n * Whether a person keeps one digest: on unless its key is stored `false`. It\n * reads only its own key — a category mute lives in a different map and does\n * not reach it.\n */\nexport function digestEnabled(\n prefs: Record<string, boolean> | null | undefined,\n key: string,\n): boolean {\n return prefs?.[key] !== false\n}\n\n/**\n * The field on `users/{uid}` naming the workspaces whose weekly insights a\n * person asked for (AGL-2915): `{ [orgId]: true }`. OPT-IN, unlike the CRM\n * digest — an absent key is off — because a weekly insight is generated, and a\n * generation spends the workspace's credits. The person turns it on beside the\n * answers themselves, where the plan, the release and their permission have\n * already been checked, and off again there or in Notifications; the weekly\n * sweep checks all three again before it spends anything.\n */\nexport const INSIGHT_DIGESTS_FIELD = 'insightDigests'\n\n/** Whether a person asked for a workspace's weekly insights. */\nexport function insightDigestSubscribed(\n value: Record<string, boolean> | null | undefined,\n orgId: string,\n): boolean {\n return Boolean(orgId) && value?.[orgId] === true\n}\n\n/**\n * The channels a notification can travel on (AGL-3223).\n *\n * `console` is the feed at `/manage/notifications` and the app-bar dropdown\n * that reads it — the only channel that existed. `email` is the message the\n * fan-out sends beside that doc when the recipient asked for one.\n */\nexport type NotificationChannel = 'console' | 'email'\n\n/**\n * One scope's answer for one category. **Tri-state on purpose**: `true` and\n * `false` decide, and an ABSENT key inherits from the scope above.\n *\n * Inheritance is the whole reason this is a partial rather than a pair of\n * booleans. A person who wants form submissions from one busy site and not\n * from the other five has to be able to say that about the one site without\n * restating their answer for every other category at every other scope — and\n * a two-valued leaf cannot express \"I have not said\", so every override would\n * have to be written out in full and would then stop tracking the account\n * default it was never meant to detach from.\n */\nexport interface NotificationChannelPrefs {\n console?: boolean\n email?: boolean\n /**\n * The mobile apps' push channel (AGL-3620), tri-state like the others.\n * Resolved by `notificationPushEnabled` (`notification-push.ts`), where an\n * unanswered push follows the console feed, so it is not a\n * {@link NotificationChannel} and no category declares a default for it.\n */\n push?: boolean\n}\n\nexport type NotificationCategoryPrefs = Partial<\n Record<NotificationCategory, NotificationChannelPrefs>\n>\n\n/**\n * One scope's answer for a single notification TYPE (AGL-3251), tri-state on\n * the same terms as {@link NotificationChannelPrefs}: an absent key falls\n * through to the category.\n *\n * Its own map rather than extra keys in {@link NotificationCategoryPrefs},\n * because that one is an exhaustive record keyed by category and a type key\n * inside it would type-check only by widening the thing that makes a missing\n * category a compile error.\n */\nexport type NotificationTypePrefs = Partial<\n Record<AglynNotificationType, NotificationChannelPrefs>\n>\n\n/**\n * Per-scope, per-channel notification preferences (AGL-3223), stored at\n * `users/{uid}.notificationSettings`.\n *\n * Three layers, narrowest first when resolving: the SITE a notification\n * concerns, then the WORKSPACE, then the account. Anything none of them\n * answers falls to {@link NOTIFICATION_CHANNEL_DEFAULTS}.\n *\n * ON THE USER DOCUMENT, not on `orgs/{orgId}/members/{uid}`, and that is a\n * cost decision rather than a modelling preference. `notifyUsers` already\n * does exactly one `getAll` over the recipients' user docs to read their\n * category mutes, so every layer living here means per-site preferences are\n * read for free on the fan-out's hot path. The member row would add a read\n * per recipient per notification to answer a question the document already in\n * hand could have answered.\n */\nexport interface NotificationSettings {\n account?: NotificationCategoryPrefs\n /**\n * Per-TYPE answers at the account scope (AGL-3251), consulted before\n * {@link NotificationSettings.account}'s category answer and never above a\n * narrower scope — see {@link notificationChannelEnabled}.\n *\n * ACCOUNT ONLY, deliberately. The per-workspace and per-site card is\n * already seven categories by three states by two channels, and a type row\n * for each of the ~thirty types, per workspace AND per site, is a page\n * nobody can read — which is the complaint this whole issue started as.\n * The account scope is where \"stop telling me about THIS\" belongs anyway:\n * it is a statement about the kind of thing, and kinds do not vary by site.\n */\n accountTypes?: NotificationTypePrefs\n /** Keyed by org id. */\n orgs?: Record<string, NotificationCategoryPrefs>\n /** Keyed by host id — a site's own answer, narrower than its workspace's. */\n hosts?: Record<string, NotificationCategoryPrefs>\n /**\n * Per-TYPE answers at the workspace and site scopes (AGL-3267), beside the\n * category maps above rather than nested inside them.\n *\n * Parallel maps because the category maps are already stored under `orgs`\n * and `hosts` on live user documents: folding both into one\n * `{ categories, types }` object per scope would be a migration of every\n * preference anybody has set, to express something two more keys express\n * without touching a byte of what exists.\n *\n * This supersedes the account-only limit AGL-3251 shipped under. That was\n * the right call for a card nobody had used yet and the wrong one once it\n * existed: \"quiet this one type down on this one busy site\" is the question\n * the scope card is FOR, and answering it only for whole categories made\n * the fine grain stop exactly where the noise is worst.\n */\n orgTypes?: Record<string, NotificationTypePrefs>\n hostTypes?: Record<string, NotificationTypePrefs>\n}\n\nexport const NOTIFICATION_SETTINGS_FIELD = 'notificationSettings'\n\n/**\n * What a category does when nobody has said otherwise.\n *\n * Console on, email OFF, for every category but `staff`. The types a person needs to hear\n * about without opening the console are the exception, by type, in\n * {@link NOTIFICATION_TYPE_CHANNEL_DEFAULTS}. Email defaults off because the inbox is\n * not ours to fill: the product already sends transactional mail nobody opted\n * into — welcome, verification, invites, dunning, usage alerts — and every one\n * of those leaves on the same domain a customer's password reset depends on.\n * Turning a busy site's form submissions into mail by default would put that\n * domain's reputation behind traffic the recipient never asked for.\n *\n * Exhaustive over the core's categories deliberately: a new\n * {@link CoreNotificationCategory} is a compile error here until somebody\n * decides what it does by default, which is the one question a new category\n * must not be able to ship without answering — and a declared category may not\n * compile without its `defaults` for the same reason.\n */\nconst CORE_CHANNEL_DEFAULTS: Record<\n CoreNotificationCategory,\n Record<NotificationChannel, boolean>\n> = {\n billing: { console: true, email: false },\n team: { console: true, email: false },\n content: { console: true, email: false },\n support: { console: true, email: false },\n system: { console: true, email: false },\n // Console and email ON: staff are the platform's own people, every one of\n // them is meant to hear about growth and money moving, and an account that\n // never opened its settings heard about almost none of it. The one category\n // whose recipients are all us, so the inbox reasoning above does not apply.\n staff: { console: true, email: true },\n}\n\nexport const NOTIFICATION_CHANNEL_DEFAULTS = inCategoryOrder(\n CORE_CHANNEL_DEFAULTS,\n (declaration) => ({ ...declaration.defaults }),\n)\n\n/**\n * The types whose default differs from their category's: each emails its\n * recipients unless they said otherwise.\n *\n * Below every stored answer, the category's included, so a person who\n * switched content email off keeps it off; above the category default, so\n * everyone who never answered (every account, new or old) is emailed.\n *\n * What is here is what somebody misses by hearing about it only in the\n * console: a site's own transactions, work handed to them, their AI jobs,\n * their bill, a reply or a verdict they are waiting on, an announcement, and\n * every notice that something STOPPED or needs a person now. A type that\n * already mails its own message stays out of the channel's way either way —\n * see {@link NOTIFICATION_SELF_SENT_EMAIL_TYPES} and the `skipEmail` callers.\n */\nexport const NOTIFICATION_TYPE_CHANNEL_DEFAULTS: Partial<\n Record<AglynNotificationType, Partial<Record<NotificationChannel, boolean>>>\n> = {\n // A site's own transactions (AGL-3600).\n 'content.formSubmission': { email: true },\n 'content.booking': { email: true },\n 'content.order': { email: true },\n // Work handed to one person, and the AI jobs they started.\n 'content.taskAssigned': { email: true },\n 'content.contactAssigned': { email: true },\n 'content.leadAssigned': { email: true },\n 'content.aiJobNeedsYou': { email: true },\n 'content.aiJobDone': { email: true },\n 'content.aiJobFailed': { email: true },\n // The workspace's bill. A usage crossing sends its own email and passes\n // `skipEmail`, so this default never mails it twice.\n 'billing.invoice': { email: true },\n 'billing.paymentFailed': { email: true },\n 'billing.subscriptionCanceled': { email: true },\n 'billing.usage': { email: true },\n // An answer somebody is waiting on.\n 'marketplace.review': { email: true },\n 'support.ticketReply': { email: true },\n 'system.announcement': { email: true },\n // Something stopped, or needs a person now.\n 'system.formSubmissionsPaused': { email: true },\n 'system.visitorRecordsPaused': { email: true },\n 'system.abuseReportUrgent': { email: true },\n 'system.riskNotice': { email: true },\n 'system.dmcaCounterNotice': { email: true },\n 'system.bandwidthCeilingTripped': { email: true },\n 'system.billingWebhookHalfApplied': { email: true },\n 'system.disputeUnattributed': { email: true },\n}\n\n/**\n * What a notification email's button says it opens (AGL-3897), by type: the\n * record the notification links to, named, rather than \"Open in Aglyn\" on\n * every one. A type with no entry keeps that general label.\n */\nconst NOTIFICATION_EMAIL_ACTIONS: Partial<Record<AglynNotificationType, string>> = {\n 'content.formSubmission': 'View submission',\n 'content.booking': 'View booking',\n 'content.order': 'View order',\n 'content.lowStock': 'View product',\n 'content.taskAssigned': 'Open task',\n 'content.taskReminder': 'Open task',\n 'content.contactAssigned': 'Open contact',\n 'content.leadAssigned': 'Open lead',\n 'billing.invoice': 'Open billing',\n 'billing.paymentFailed': 'Update payment method',\n 'billing.subscriptionCanceled': 'Open billing',\n 'billing.usage': 'Open billing',\n 'support.ticketOpened': 'Open ticket',\n 'support.ticketReply': 'Open ticket',\n 'marketplace.review': 'Open listing',\n 'staff.userSignedUp': 'Open account',\n 'staff.orgCreated': 'Open workspace',\n 'staff.siteCreated': 'Open site',\n 'staff.subscriptionStarted': 'Open workspace',\n 'staff.subscriptionCanceled': 'Open workspace',\n 'staff.planChanged': 'Open workspace',\n 'staff.paymentFailed': 'Open workspace',\n}\n\n/** The words on a notification email's button: the record it opens, else \"Open in {product}\". */\nexport function notificationEmailAction(type: AglynNotificationType | string, productName: string): string {\n return NOTIFICATION_EMAIL_ACTIONS[type as AglynNotificationType] ?? `Open in ${productName}`\n}\n\n/** What a type does on a channel when nobody has answered for it or its category. */\nexport function notificationTypeChannelDefault(\n type: AglynNotificationType | string,\n channel: NotificationChannel,\n): boolean {\n const own = NOTIFICATION_TYPE_CHANNEL_DEFAULTS[type as AglynNotificationType]?.[channel]\n return typeof own === 'boolean'\n ? own\n : NOTIFICATION_CHANNEL_DEFAULTS[notificationCategory(type)][channel]\n}\n\n/**\n * The types that send their OWN email and must never be mailed again by the\n * generic channel (AGL-3224).\n *\n * Both digests compose a message the fan-out could not reproduce — a day's\n * owed tasks, a week's figures — and send it from their own route under their\n * own switch (a key in `digestPrefs`, `insightDigests.{orgId}`). A recipient\n * who switches the `content` email channel on would otherwise receive the\n * digest twice: once as the digest, once as a one-line \"Daily CRM digest\"\n * notification saying that the digest happened.\n */\nexport const NOTIFICATION_SELF_SENT_EMAIL_TYPES: ReadonlySet<string> =\n new Set<AglynNotificationType>([\n 'content.crmDailyDigest',\n 'content.insightsDigest',\n // The task-reminders route mails one reminder per member per run, listing\n // every task due, and writes one notification per task; mailed again\n // here, each task would arrive a second time as its own email (AGL-3432).\n 'content.taskReminder',\n // Emailed by `notifyRiskEvent` itself, as account mail (AGL-3368).\n 'system.riskNotice',\n ])\n\n/**\n * The tooltip on a self-sent type's disabled email switch: it names what\n * DOES govern that mail, which differs by type (AGL-3432). Calling a task\n * reminder a digest sent people looking for a Digests switch that is not it.\n */\nexport function selfSentEmailNote(type: string): string {\n switch (type) {\n case 'content.taskReminder':\n return 'Task reminders send their own email, one per run listing every task due. Mute this category to stop them.'\n case 'system.riskNotice':\n return 'Risk and account notices are always emailed, whatever this switch says.'\n default:\n return 'This digest sends its own email, under its switch in Digests.'\n }\n}\n\n/**\n * Whether a channel is on for one notification, at its own scope.\n *\n * Resolution order, first answer wins: the notification's SITE, its\n * WORKSPACE, the account, then {@link NOTIFICATION_CHANNEL_DEFAULTS}. A scope\n * that holds no entry for the category, or an entry with the channel key\n * absent, does not answer — see {@link NotificationChannelPrefs}.\n *\n * `legacyPrefs` is the flat `notificationPrefs` mute map this replaces\n * (AGL-267), and it sits BELOW the account layer rather than beside it.\n * Nothing migrates it: a person who never opens the new settings page keeps\n * being governed by the mutes they set years ago, and the first thing they do\n * set on the account layer overrides the old map for that category without\n * disturbing the rest of it. It answers for `console` only, because the map\n * predates there being a second channel and reading a console mute as an\n * email preference would be inventing an answer its author never gave.\n */\nexport function notificationChannelEnabled(\n settings: NotificationSettings | null | undefined,\n channel: NotificationChannel,\n type: AglynNotificationType | string,\n scope?: { orgId?: string | null; hostId?: string | null },\n legacyPrefs?: Record<string, boolean> | null,\n): boolean {\n const category = notificationCategory(type)\n /*\n * ⛔ A STAFF NOTIFICATION HAS NO SCOPE TO BE NARROWED BY (AGL-3267).\n *\n * It is about the platform, and the workspace one MENTIONS is its subject,\n * not its audience: the staff member reading \"Acme Co subscribed\" is\n * almost never a member of Acme Co, and if they happen to be, their\n * preferences for their own membership have nothing to do with it.\n *\n * This was already true by accident — no staff emitter passes `orgId` or\n * `hostId`, so the scope layers never matched — which made every\n * \"Platform growth\" row in the per-workspace card a control that could be\n * set and could never do anything. Stating it here rather than only hiding\n * those rows is the difference between the model being right and the UI\n * covering for a model that is wrong: a `staff.*` type that ever does\n * start carrying an `orgId`, for its link or its subject line, must not\n * silently become mutable per workspace.\n */\n const staffWide = STAFF_NOTIFICATION_CATEGORIES.has(category)\n /*\n * Narrowest scope first, and within each scope the TYPE before its\n * CATEGORY (AGL-3251, widened to every scope by AGL-3267).\n *\n * Both halves matter and they answer different questions. Scope order is\n * \"where is this from\" — a site's answer beats its workspace's, which beats\n * the account's, because quietening one busy site is the thing the scope\n * card exists for. Type-before-category is \"what kind of thing is this\" —\n * somebody who switched `Payment failed` off said something about that\n * type, and it must beat their own broader `Billing` answer at the SAME\n * scope without leaking upward past a narrower one.\n */\n const layers: Array<NotificationChannelPrefs | undefined> = staffWide\n ? [\n settings?.accountTypes?.[type as AglynNotificationType],\n settings?.account?.[category],\n ]\n : [\n scope?.hostId\n ? settings?.hostTypes?.[scope.hostId]?.[type as AglynNotificationType]\n : undefined,\n scope?.hostId ? settings?.hosts?.[scope.hostId]?.[category] : undefined,\n scope?.orgId\n ? settings?.orgTypes?.[scope.orgId]?.[type as AglynNotificationType]\n : undefined,\n scope?.orgId ? settings?.orgs?.[scope.orgId]?.[category] : undefined,\n settings?.accountTypes?.[type as AglynNotificationType],\n settings?.account?.[category],\n ]\n for (const layer of layers) {\n const answer = layer?.[channel]\n if (typeof answer === 'boolean') return answer\n }\n if (channel === 'console' && notificationMuted(legacyPrefs, type))\n return false\n return notificationTypeChannelDefault(type, channel)\n}\n\n/**\n * What one scope says, with no inheritance applied — what the settings page\n * renders as Inherit / On / Off, and `undefined` is Inherit.\n *\n * `scope` names the layer directly rather than being derived from a\n * notification, because the page edits a layer whether or not anything has\n * ever arrived from it.\n */\n/**\n * What the account scope says about one TYPE, with no inheritance applied\n * (AGL-3251) — `undefined` means the row follows its category.\n *\n * The page needs the unresolved answer to draw the difference between \"on\n * because Billing is on\" and \"on because you said so\": only the second can be\n * reset, and a switch that cannot show which one it is leaves a person unable\n * to get back to following the category.\n */\nexport function notificationAccountTypePref(\n settings: NotificationSettings | null | undefined,\n type: AglynNotificationType | string,\n channel: NotificationChannel,\n): boolean | undefined {\n return settings?.accountTypes?.[type as AglynNotificationType]?.[channel]\n}\n\n/**\n * Every type in a category, in the order the labels declare them\n * (AGL-3251) — what the settings page expands a category row into.\n *\n * Derived from {@link NOTIFICATION_TYPE_LABELS} rather than held as a second\n * map, so a type added there appears under its category without anybody\n * remembering to list it twice.\n */\nexport function notificationTypesInCategory(\n category: NotificationCategory,\n): AglynNotificationType[] {\n return (\n Object.keys(NOTIFICATION_TYPE_LABELS) as AglynNotificationType[]\n ).filter((type) => notificationCategory(type) === category)\n}\n\n/**\n * What ONE scope says about ONE type, with no inheritance applied\n * (AGL-3267) — `undefined` means the row follows its category at that scope.\n *\n * The scope-aware sibling of {@link notificationAccountTypePref}, which stays\n * as the account-only shorthand its callers already use.\n */\nexport function notificationScopeTypePref(\n settings: NotificationSettings | null | undefined,\n scope: { kind: 'account' } | { kind: 'org' | 'host'; id: string },\n type: AglynNotificationType | string,\n channel: NotificationChannel,\n): boolean | undefined {\n const layer =\n scope.kind === 'account'\n ? settings?.accountTypes\n : scope.kind === 'org'\n ? settings?.orgTypes?.[scope.id]\n : settings?.hostTypes?.[scope.id]\n return layer?.[type as AglynNotificationType]?.[channel]\n}\n\nexport function notificationScopePref(\n settings: NotificationSettings | null | undefined,\n scope: { kind: 'account' } | { kind: 'org' | 'host'; id: string },\n category: NotificationCategory,\n channel: NotificationChannel,\n): boolean | undefined {\n const layer =\n scope.kind === 'account'\n ? settings?.account\n : scope.kind === 'org'\n ? settings?.orgs?.[scope.id]\n : settings?.hosts?.[scope.id]\n return layer?.[category]?.[channel]\n}\n\n/**\n * The scopes this person has said something different about — what the\n * settings page lists so an override is never somewhere you have to go\n * looking for.\n *\n * Returns ids, not names: the page holds the org and host rosters and this\n * module holds no lookups. An id with no name is still worth listing, because\n * a preference about a workspace somebody left is exactly the kind of\n * leftover that is invisible until it is listed.\n */\nexport function notificationOverriddenScopes(\n settings: NotificationSettings | null | undefined,\n): { orgIds: string[]; hostIds: string[] } {\n /*\n * BOTH maps for a scope, categories and types (AGL-3267).\n *\n * Reading only the category map would make a scope whose ONLY answer is a\n * per-type one invisible here — and this list is the single place a person\n * can find an override they set months ago. Silencing one type on one busy\n * site and then being unable to find where you did it is precisely the\n * failure this function exists to prevent.\n */\n const answered = (prefs: Record<string, NotificationChannelPrefs> | undefined) =>\n Object.values(prefs ?? {}).some((channels) =>\n Object.values(channels ?? {}).some((value) => typeof value === 'boolean'),\n )\n const named = (\n categories: Record<string, NotificationCategoryPrefs> | undefined,\n types: Record<string, NotificationTypePrefs> | undefined,\n ) =>\n [\n ...new Set([\n ...Object.keys(categories ?? {}),\n ...Object.keys(types ?? {}),\n ]),\n ]\n .filter(\n (id) =>\n answered(categories?.[id] as Record<string, NotificationChannelPrefs>) ||\n answered(types?.[id] as Record<string, NotificationChannelPrefs>),\n )\n .sort()\n return {\n orgIds: named(settings?.orgs, settings?.orgTypes),\n hostIds: named(settings?.hosts, settings?.hostTypes),\n }\n}\n\n/**\n * Rewrites a stored notification link onto the current URL scheme (AGL-644).\n *\n * A notification's `link` is frozen at write time — `notifyUsers` persists\n * whatever string the emitter passed and never normalizes it. So every\n * notification written before the org-slug migration (AGL-621) and the\n * subdomain migration (AGL-622) still points at a route that no longer\n * exists, and fixing the emitters alone would leave that whole backlog dead.\n * Normalizing when the link is FOLLOWED repairs old and new alike, and keeps\n * working for emitters that haven't been migrated yet.\n *\n * Rewrites, in order — each prefix also followed by a query string, as\n * `/{hostDocId}?aiJob={id}` (AGL-3593):\n * - `/{hostDocId}` or `/{hostDocId}/rest` → `/{orgSlug}/hosts/{subdomain}/rest`\n * - `/org` or `/org/rest` → `/{orgSlug}/rest`\n * - `/hosts` (exactly) → `/{orgSlug}/hosts`\n *\n * Anything already canonical, user-scoped (`/manage/...`), staff (`/admin/...`)\n * or absolute is returned untouched. Every rewrite is gated on having the\n * context it needs, so an unresolvable link degrades to its stored value\n * rather than to a wrong destination.\n *\n * The host rewrite is keyed on the notification's own `hostId` rather than\n * guessing from the path shape, so it can never mistake a real first segment\n * for a doc id.\n *\n * The email copy of a notification goes through it too (AGL-3367): a button\n * in an inbox is a followed link with no console around it to repair it.\n */\nexport function normalizeNotificationLink(\n link: string | undefined | null,\n context: {\n orgSlug?: string | null\n /** The notification's `hostId` (a Firestore doc id). */\n hostId?: string | null\n /** That host's subdomain, which the current routes are keyed by. */\n hostSubdomain?: string | null\n },\n): string | undefined {\n if (!link) return undefined\n // Absolute URLs (staff broadcasts can carry them) are not ours to rewrite.\n if (!link.startsWith('/')) return link\n\n const { orgSlug, hostId, hostSubdomain } = context\n\n if (orgSlug && hostId && hostSubdomain) {\n const prefix = `/${hostId}`\n if (link === prefix || link.startsWith(`${prefix}/`) || link.startsWith(`${prefix}?`)) {\n return `${buildRoute(Route.HOST_DASHBOARD, {\n orgSlug,\n host: hostSubdomain,\n })}${link.slice(prefix.length)}`\n }\n }\n\n if (orgSlug) {\n if (link === '/org' || link.startsWith('/org/') || link.startsWith('/org?')) {\n return `${buildRoute(Route.ORG_HOME, { orgSlug })}${link.slice(\n '/org'.length,\n )}`\n }\n // Only the bare list — `/hosts/{docId}` would still need a subdomain, and\n // guessing one is worse than leaving the link alone.\n if (link === '/hosts') return buildRoute(Route.HOST_LIST, { orgSlug })\n }\n\n return link\n}\n"],"names":["buildRoute","Route","PLUGIN_NOTIFICATION_CATEGORIES_DECLARED","PLUGIN_NOTIFICATION_DIGESTS_DECLARED","NOTIFICATION_TYPE_LABELS","NOTIFICATION_LEVELS","NOTIFICATION_LEVEL_LABELS","critical","warning","success","info","neutral","NOTIFICATION_TYPE_LEVELS","LEVEL_SET","Set","isNotificationLevel","value","has","notificationLevel","notification","stamped","level","byType","type","usageNotificationLevel","percent","loudestNotificationLevel","levels","best","length","rank","indexOf","DECLARED_CATEGORIES","LEADING_CATEGORIES","inCategoryOrder","core","declared","out","id","declaration","Object","keys","includes","CORE_CATEGORY_LABELS","billing","team","content","support","system","staff","NOTIFICATION_CATEGORY_LABELS","label","STAFF_NOTIFICATION_CATEGORIES","CORE_CATEGORY_DESCRIPTIONS","NOTIFICATION_CATEGORY_DESCRIPTIONS","description","notificationCategory","prefix","String","split","prototype","hasOwnProperty","call","notificationMuted","prefs","DIGEST_PREFS_FIELD","CORE_NOTIFICATION_DIGESTS","pluginId","key","NOTIFICATION_DIGESTS","digestEnabled","INSIGHT_DIGESTS_FIELD","insightDigestSubscribed","orgId","Boolean","NOTIFICATION_SETTINGS_FIELD","CORE_CHANNEL_DEFAULTS","console","email","NOTIFICATION_CHANNEL_DEFAULTS","defaults","NOTIFICATION_TYPE_CHANNEL_DEFAULTS","NOTIFICATION_EMAIL_ACTIONS","notificationEmailAction","productName","notificationTypeChannelDefault","channel","own","NOTIFICATION_SELF_SENT_EMAIL_TYPES","selfSentEmailNote","notificationChannelEnabled","settings","scope","legacyPrefs","category","staffWide","layers","accountTypes","account","hostId","hostTypes","undefined","hosts","orgTypes","orgs","layer","answer","notificationAccountTypePref","notificationTypesInCategory","filter","notificationScopeTypePref","kind","notificationScopePref","notificationOverriddenScopes","answered","values","some","channels","named","categories","types","sort","orgIds","hostIds","normalizeNotificationLink","link","context","startsWith","orgSlug","hostSubdomain","HOST_DASHBOARD","host","slice","ORG_HOME","HOST_LIST"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/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 type { ITimestamp } from '@aglyn/shared-util-timestamp'\nimport { buildRoute, Route } from './console-routes'\nimport {\n PLUGIN_NOTIFICATION_CATEGORIES_DECLARED,\n PLUGIN_NOTIFICATION_DIGESTS_DECLARED,\n} from '../plugin-manager/first-party-plugins.generated'\n\n/**\n * In-app notifications (AGL-259): per-user docs at\n * `users/{uid}/notifications/{id}`, written by the Admin SDK (emitters in\n * the API routes + the `notifyAdmins` automation step) and read/marked by\n * their owner. Types follow the common SaaS taxonomy so the console can\n * icon/group them without a registry.\n */\nexport type AglynNotificationType =\n | 'billing.invoice'\n | 'billing.paymentFailed'\n // Stripe gave up on a failed renewal and CANCELLED the subscription\n // (AGL-1877). Distinct from `paymentFailed`, which announces one failed\n // attempt inside a retry window the customer can still recover from; this\n // one is the window having closed. Measured on the test account with a\n // test clock: five attempts over 21.08 days, then\n // `cancellation_details.reason: 'payment_failed'` — and until this existed\n // the org went silently to Free at that moment, with the `past_due` banner\n // disappearing at exactly the instant the consequence arrived.\n | 'billing.subscriptionCanceled'\n | 'billing.usage'\n // A domain bought through the domains plugin (AGL-3962): bought, renewed,\n // refunded, its registrant email to confirm, or about to expire. `billing`\n // because each is about money the workspace paid or will pay, and the\n // emitter stamps the level. A failed renewal charge is `paymentFailed`.\n | 'billing.domain'\n | 'team.invite'\n | 'team.roleChanged'\n | 'team.hostAccessGranted'\n | 'content.formSubmission'\n | 'content.booking'\n | 'content.order'\n | 'content.lowStock'\n // A CRM task was assigned to the recipient by somebody else (AGL-2599).\n // `content.` because it is the same kind of message as a form submission\n // or a booking: a thing on the site that now needs this person's hands,\n // not a change to their standing (`team.`) and not a platform notice\n // (`system.`). Someone who has muted the operational stream has said they\n // do not want to be told about work as it arrives, and a task is work.\n | 'content.taskAssigned'\n // A CRM task's reminder came due (AGL-2659): the hourly runner telling\n // the assignee, at the task's own time, that the task is due. `content.`\n // beside `taskAssigned` for the same reason it gives — work on the site,\n // not standing — and so the one mute that stops \"work arriving\" stops\n // \"work falling due\" with it. The mute governs the whole reminder, mail\n // included, unlike the digest's: a reminder is one message about one\n // task, and there is no schedule to keep separately from it.\n | 'content.taskReminder'\n // A contact, or a lead, became the recipient's to work (AGL-2618): a\n // capture the assignment rules or the site's default owner routed to\n // them, a lead somebody converted and handed to them, or an automation's\n // \"assign an owner\" step — every server path that writes `ownerUid`\n // except the recipient assigning themselves. Two types rather than one\n // because the record the person opens differs: a lead is the site's own\n // working record and a contact is the org's, and the link goes to the\n // one they will actually work. `content.`, beside `taskAssigned`, for the\n // same reason: work arriving, not standing changing.\n | 'content.contactAssigned'\n | 'content.leadAssigned'\n // The morning's CRM digest (AGL-2619): what the recipient owes today —\n // overdue and due-today tasks, and the leads nobody has worked. `content.`\n // for the reason `taskAssigned` is: it is about work on the site, and the\n // person who muted the operational stream has asked not to be told about\n // work. The digest EMAIL is governed separately, by its switch in\n // `digestPrefs` (see `digestEnabled`): the mute is a fact about the console\n // feed and the digest switch is a fact about the digest.\n | 'content.crmDailyDigest'\n // The weekly insights (AGL-2915): what a site's figures showed that week,\n // for a person who asked for them. `content.` beside the CRM digest, for the\n // reason that one gives: it is about the site's work, and the operational\n // mute governs the console notification while the person's own switch for\n // the digest governs the digest.\n | 'content.insightsDigest'\n // An AI job the recipient started (AGL-3593): its plan is ready and waits\n // for them to confirm it, it finished and its drafts are ready to open, or\n // it stopped. Raised by the jobs machine where the job's state changes —\n // on the beat as often as in a request — to the person who created the\n // job, once per change. `content.` beside the insights digest: work on the\n // workspace's sites arriving for the person, which the operational mute\n // governs. The plan-ready one is how a person who left the dialog learns\n // their site is waiting on them.\n | 'content.aiJobNeedsYou'\n | 'content.aiJobDone'\n | 'content.aiJobFailed'\n // Marketplace review verdicts (AGL-432/653).\n | 'marketplace.review'\n // Support desk, staff audience (AGL-850): a subscriber opened or replied to\n // a ticket. Fanned out to staff-claim holders, not org members.\n | 'support.ticketOpened'\n | 'support.ticketReply'\n | 'system.announcement'\n // A live plugin version stopped passing the static verifier (AGL-1086).\n // Staff audience: bytes we told workspaces were checked now fail checks\n // that did not exist when they were approved.\n //\n // Deliberately NOT under `marketplace.` (AGL-1088). Category is the prefix,\n // categories are mutable per user, and Marketplace is the category a staff\n // member mutes to stop routine listing-review chatter — which would drop\n // this alert as collateral. `system` is the bucket nobody mutes to reduce\n // noise. The adminAudit record survives a mute either way; the timeliness\n // does not, and timeliness is the whole point of the alert.\n | 'system.pluginVerifierRegression'\n // A sign-in method was removed from the user's own account because their\n // organization turned on SSO enforcement (AGL-1129). `system.`, not\n // `team.`, for the AGL-1088 reason above: `team` is the category someone\n // mutes to stop routine roster chatter, and \"the way you sign in just\n // changed\" is not chatter — the next sign-in fails without it.\n | 'system.signInMethodRemoved'\n // Documents in a scoped collection carrying no `visibleTo` (AGL-1478).\n // Staff audience: the weekly dry run found resources that are invisible\n // to every site-scoped read, which always means a creation path forgot\n // the field. `system.` for the AGL-1088 reason above, and because the\n // collection it names may be `marketplace`-adjacent or not.\n | 'system.scopeDrift'\n // An SSO domain that WAS DNS-proven has stopped answering with our\n // challenge record, for three consecutive weekly sweeps (AGL-1210).\n // Nothing has been turned off — sign-in for that domain still routes\n // exactly as it did — and that is precisely why this has to be said out\n // loud rather than left in a log.\n //\n // `system.`, not `team.`, for the AGL-1088 reason above and one of its\n // own: the audience is the org's own admins, the subject is whether their\n // people can still sign in, and it must reach them BEFORE anybody decides\n // to revoke the routing. A category somebody mutes to quieten roster\n // chatter is the wrong place for the one warning that precedes a lockout.\n | 'system.ssoDomainUnverified'\n // A site hit the per-month form-submission abuse ceiling and further\n // submissions are being refused (AGL-1655). `system.`, not `content.`, for\n // the AGL-1088 reason above and more sharply than any of them: `content` is\n // literally the category a site owner mutes to stop routine form-submission\n // chatter, and this is the one form notification that says the form has\n // STOPPED accepting. Filing it under the muted bucket would guarantee it\n // reaches nobody on exactly the sites busy enough to trip it.\n | 'system.formSubmissionsPaused'\n // A site reached the flat platform ceiling on member accounts or on lead\n // records, so further ones are being refused (AGL-1529). `system.` for the\n // same reason as `formSubmissionsPaused` directly above, and with the same\n // sharpness: `content.` is the bucket an owner mutes to stop routine\n // sign-up and lead chatter, and this is the one notification saying the\n // sign-ups have STOPPED — muted exactly on the sites busy enough to trip it.\n | 'system.visitorRecordsPaused'\n // An outsider reported one of our sites for phishing, malware or CSAM\n // (AGL-1964). `system` for the AGL-1088 reason and, again, more sharply\n // than most: this notification goes to STAFF, not to a customer, and its\n // subject is somebody else's site. There is no bucket a recipient could\n // mute it into that would be honest — nobody has opted into being told a\n // stranger is being phished through our platform, and nobody should be able\n // to opt out of it either.\n //\n // Only the urgent categories raise one, and only on a first report. See the\n // fan-out in apps/tenant/app/api/report-abuse/route.ts for why: a flood of\n // alerts IS the flood, and the alert it would cost us is the phishing one.\n | 'system.abuseReportUrgent'\n // Something on the recipient's OWN workspace was held, flagged, locked or\n // paused (AGL-3368): an email or page held for review, a flagged domain,\n // a fraud signal on a payment, a lock and its lift. Written by\n // `notifyRiskEvent` from the risk notice catalog, which says what happened,\n // what it means and how to request a review. `system.` for the AGL-1088\n // reason: it is the one message saying the owner's work has STOPPED, and a\n // category muted to quieten routine chatter must not swallow it. It sends\n // its own email, transactional and preference-free, so it is also in\n // NOTIFICATION_SELF_SENT_EMAIL_TYPES.\n | 'system.riskNotice'\n // The §512(g) counter-notice (AGL-1983), and the one place the \"only urgent\n // categories raise a notification\" restraint above is deliberately not\n // applied. Every counter-notice carries a statutory deadline that is\n // ALREADY RUNNING when it arrives — the clock counts from the subscriber's\n // submission, not from our attention — so there is no low-value tail of\n // these to drown out the important ones, and the one that goes unread is a\n // customer locked out of their own work plus an unmet obligation under\n // §512(g)(2)(A). Raised on first submission only, like the one above, so a\n // resubmitting customer cannot re-alert.\n | 'system.dmcaCounterNotice'\n // A site crossed the per-month bandwidth abuse ceiling (AGL-2155). Two\n // audiences share one type: staff, because an uncompensated free site\n // serving six figures of page views is an incident; and the site's own\n // managers, because on free the site is now serving a capped notice and\n // nobody should learn that from a visitor.\n //\n // `system.`, not `billing.`, for the AGL-1088 reason and one specific to\n // this meter: on the free plan there is no bill, so `billing` would be the\n // one category where the message is literally never about money — and it is\n // exactly the free-tier trip that changes what visitors see.\n | 'system.bandwidthCeilingTripped'\n // A free site crossed its PLAN's included bandwidth and is now serving the\n // capped notice (AGL-2413). Distinct from the ceiling above on purpose: that\n // one is an incident at 10x the band and goes to staff first; this one is an\n // ordinary quota at 1x, concerns only the site's own managers, and its\n // remedy is an upgrade rather than an investigation. Sending both under one\n // type would tell an owner their traffic was being treated as suspected\n // abuse when it is simply a successful free site.\n | 'system.bandwidthCapEngaged'\n // A Stripe billing webhook threw AFTER its handlers had begun (AGL-2157),\n // so its side effects may be half applied and its idempotency claim is\n // being HELD — Stripe will not retry it. Staff audience: this is the one\n // failure on that route no automatic retry can make safe, because the\n // handlers behind it are not all idempotent, and a human has to reconcile.\n | 'system.billingWebhookHalfApplied'\n // A card dispute arrived that NOTHING in the platform claimed (AGL-2429):\n // no `platformRevenue` row, no storefront order, no marketplace purchase.\n // Staff audience, and the reason it needs a type of its own is that its\n // absence was the bug — the routine case (a storefront or marketplace\n // chargeback, which the plugins own) and the fault case were the same\n // silence from the route's side, so a dispute nobody handled looked\n // exactly like one somebody did.\n //\n // `system.`, not `billing.`, for the AGL-1088 reason and one specific to\n // this alert: `billing` is the category a recipient is most likely to have\n // muted as routine invoice traffic, and this is the one message on that\n // route where muting it means money moves with nobody looking.\n | 'system.disputeUnattributed'\n // An operator alert from the registry in `./operator-alerts` (AGL-3377)\n // that has no notification type of its own: a lost dispute, a failed\n // erasure, a degraded health check. One type for all of them because the\n // registry, not the taxonomy, is what names the alert; the title and body\n // say which one it is. `system.` for the AGL-1088 reason above: these are\n // the faults nobody may mute away as routine chatter.\n | 'system.operatorAlert'\n // Somebody created an account, and somebody created a workspace\n // (AGL-3225). Staff audience, and the only two types in the taxonomy whose\n // subject is the platform's own growth rather than anybody's work.\n //\n // `staff.`, and NOT `system.`, and the AGL-1088 note above is the reason\n // rather than an exception to it: `system` is the bucket nobody mutes to\n // reduce noise, which is exactly what makes it the wrong home for the two\n // routine, high-volume events in the product. A staff member must be able\n // to stop hearing about every sign-up without also dropping the verifier\n // regression and the unattributed dispute that share that bucket — and\n // before this category existed, there was nowhere to put them where that\n // was true.\n //\n // The category also tells the settings page who a row is for: `staff` is\n // rendered only to claim holders, so no customer is shown a switch for\n // notifications they could never receive.\n | 'staff.userSignedUp'\n | 'staff.orgCreated'\n // And somebody created a site (AGL-3491): the third growth event, the one\n // that says an account started building. Same category, same reason.\n | 'staff.siteCreated'\n // And when money moves (AGL-3267). The category was two signup types, so\n // the one thing a platform feed exists to report — revenue starting,\n // changing and stopping — was the one thing it did not.\n //\n // `staff.paymentFailed` is deliberately NOT the customer's\n // `billing.paymentFailed`: different audience, different action. The\n // customer is told to fix their card; staff are told a paying workspace is\n // about to stop paying.\n | 'staff.subscriptionStarted'\n | 'staff.subscriptionCanceled'\n | 'staff.planChanged'\n | 'staff.paymentFailed'\n\nexport interface AglynNotification {\n $id?: string\n type: AglynNotificationType\n title: string\n body?: string\n /** Console path the notification opens (e.g. a host inbox). */\n link?: string\n orgId?: string\n hostId?: string\n /**\n * The pending invitation this notification offers the recipient\n * (AGL-3402), under `orgs/{orgId}/invites`. Present only on the invitee's\n * own `team.invite`; the console opens the accept/decline dialog for it\n * instead of following `link`, and answering marks it read.\n */\n inviteId?: string\n createdAt?: ITimestamp\n /** When the owner read it; absent or null while unread. */\n readAt?: ITimestamp | null\n /**\n * Whether the owner has read it — `false` on create, `true` beside\n * `readAt` when read (AGL-3321).\n *\n * A boolean rather than `readAt: null`, because the feed filters by it\n * under its newest-first cursor. Equality on a boolean answers BOTH\n * states (`read == false`, `read == true`) beneath `orderBy('createdAt')`\n * with one composite index. A nullable timestamp answers \"unread\" by\n * equality but \"read\" only as `readAt != null`, and Firestore requires an\n * inequality's field to be the FIRST sort, which would order the feed by\n * when things were read instead of when they arrived. Absent only on\n * notifications written before the field existed, which\n * `tools/scripts/backfill-notification-read.mjs` stamps.\n */\n read?: boolean\n /**\n * How urgently this one notification asks to be read (AGL-3437), stamped\n * by its emitter. Absent on a notification whose emitter has no reason to\n * say more than its type does, and on everything written before the field\n * existed; read it through {@link notificationLevel}, never directly.\n */\n level?: NotificationLevel\n}\n\nexport const NOTIFICATION_TYPE_LABELS: Record<AglynNotificationType, string> = {\n 'billing.invoice': 'Invoice available',\n 'billing.paymentFailed': 'Payment failed',\n 'billing.subscriptionCanceled': 'Subscription canceled',\n 'billing.usage': 'Usage threshold',\n 'billing.domain': 'Domain',\n 'team.invite': 'Team invite',\n 'team.roleChanged': 'Role changed',\n 'team.hostAccessGranted': 'Site access granted',\n 'content.formSubmission': 'Form submission',\n 'content.booking': 'New booking',\n 'content.order': 'New order',\n 'content.lowStock': 'Low stock',\n 'content.taskAssigned': 'Task assigned to you',\n 'content.taskReminder': 'Task reminder',\n 'content.contactAssigned': 'Contact assigned to you',\n 'content.leadAssigned': 'Lead assigned to you',\n 'content.crmDailyDigest': 'Daily CRM digest',\n 'content.insightsDigest': 'Weekly insights',\n 'content.aiJobNeedsYou': 'AI job needs you',\n 'content.aiJobDone': 'AI job finished',\n 'content.aiJobFailed': 'AI job stopped',\n 'marketplace.review': 'Listing review',\n\n 'support.ticketOpened': 'New support ticket',\n 'support.ticketReply': 'Support ticket reply',\n 'system.announcement': 'Announcement',\n 'system.pluginVerifierRegression': 'Plugin verifier regression',\n 'system.signInMethodRemoved': 'Sign-in method removed',\n 'system.scopeDrift': 'Resources missing a sharing scope',\n 'system.ssoDomainUnverified': 'SSO domain no longer proves ownership',\n 'system.formSubmissionsPaused': 'Form submissions paused',\n 'system.visitorRecordsPaused': 'Sign-ups or leads paused',\n 'system.abuseReportUrgent': 'Urgent abuse report',\n 'system.riskNotice': 'Held, flagged or locked on your workspace',\n 'system.dmcaCounterNotice': 'DMCA counter-notice',\n 'system.bandwidthCeilingTripped': 'Bandwidth ceiling reached',\n 'system.bandwidthCapEngaged': 'Monthly traffic limit reached',\n 'system.billingWebhookHalfApplied': 'Billing webhook half applied',\n 'system.disputeUnattributed': 'Card dispute with no owner',\n 'system.operatorAlert': 'Operator alert',\n 'staff.userSignedUp': 'New account',\n 'staff.orgCreated': 'New workspace',\n 'staff.siteCreated': 'New site',\n 'staff.subscriptionStarted': 'New subscription',\n 'staff.subscriptionCanceled': 'Subscription canceled',\n 'staff.planChanged': 'Plan changed',\n 'staff.paymentFailed': 'Payment failed (workspace)',\n}\n\n/**\n * HOW LOUD A NOTIFICATION IS (AGL-3437), which the console draws as its\n * color, icon and accent.\n *\n * - `critical` — something stopped, money is moving with nobody looking, or\n * fraud: a fraud signal, a failed payment, a form that stopped accepting.\n * - `warning` — something degraded or is close to stopping: a health check\n * degraded, a limit reached, low stock.\n * - `success` — good news: an account, a workspace or a subscription\n * started, an order or a booking arrived, a health check recovered.\n * - `info` — worth reading, nothing wrong: an invite, a ticket reply, a\n * usage step on the way to a limit.\n * - `neutral` — routine work arriving: a form submission, an assignment, a\n * digest.\n *\n * A level is a property of the NOTIFICATION, not only of its type: one\n * `system.operatorAlert` says a check degraded and the next says it\n * recovered, and a usage notice at 75% is not the one at 100%. The emitter\n * stamps `level` when it knows more than the type; the type's default in\n * {@link NOTIFICATION_TYPE_LEVELS} answers for everything else.\n */\nexport type NotificationLevel =\n | 'critical'\n | 'warning'\n | 'success'\n | 'info'\n | 'neutral'\n\n/** Every level, loudest first — the order {@link loudestNotificationLevel} ranks by. */\nexport const NOTIFICATION_LEVELS: readonly NotificationLevel[] = [\n 'critical',\n 'warning',\n 'success',\n 'info',\n 'neutral',\n]\n\nexport const NOTIFICATION_LEVEL_LABELS: Record<NotificationLevel, string> = {\n critical: 'Critical',\n warning: 'Warning',\n success: 'Good news',\n info: 'Info',\n neutral: 'Routine',\n}\n\n/**\n * Each core type's level when its emitter stamps none. A plugin's types are\n * not listed: a plugin emitter stamps `level` itself, and an unstamped one\n * reads as `neutral`.\n *\n * A type the operator alert registry raises under its own notification type\n * must agree with that alert's level, so the backlog written before `level`\n * existed reads the same as what the registry writes today\n * (`notification-levels.spec.ts` holds the two together).\n */\nexport const NOTIFICATION_TYPE_LEVELS: Record<\n AglynNotificationType,\n NotificationLevel\n> = {\n 'billing.invoice': 'neutral',\n 'billing.paymentFailed': 'critical',\n 'billing.subscriptionCanceled': 'critical',\n // At a limit. A step on the way to it is stamped `info` by its emitter.\n 'billing.usage': 'warning',\n // Stamped by the emitter: success for a purchase or renewal, warning for a\n // refund, a registrant email to confirm, or an expiry.\n 'billing.domain': 'info',\n 'team.invite': 'info',\n 'team.roleChanged': 'info',\n 'team.hostAccessGranted': 'info',\n 'content.formSubmission': 'neutral',\n 'content.booking': 'success',\n 'content.order': 'success',\n 'content.lowStock': 'warning',\n 'content.taskAssigned': 'neutral',\n 'content.taskReminder': 'info',\n 'content.contactAssigned': 'neutral',\n 'content.leadAssigned': 'neutral',\n 'content.crmDailyDigest': 'neutral',\n 'content.insightsDigest': 'neutral',\n // A plan waits for the person; nothing is built until they confirm it.\n 'content.aiJobNeedsYou': 'warning',\n 'content.aiJobDone': 'success',\n 'content.aiJobFailed': 'warning',\n 'marketplace.review': 'info',\n 'support.ticketOpened': 'info',\n 'support.ticketReply': 'info',\n 'system.announcement': 'info',\n 'system.pluginVerifierRegression': 'warning',\n 'system.signInMethodRemoved': 'warning',\n 'system.scopeDrift': 'info',\n 'system.ssoDomainUnverified': 'warning',\n 'system.formSubmissionsPaused': 'critical',\n 'system.visitorRecordsPaused': 'critical',\n 'system.abuseReportUrgent': 'critical',\n 'system.riskNotice': 'critical',\n 'system.dmcaCounterNotice': 'critical',\n 'system.bandwidthCeilingTripped': 'warning',\n // The site is serving the capped notice to its visitors.\n 'system.bandwidthCapEngaged': 'critical',\n 'system.billingWebhookHalfApplied': 'critical',\n 'system.disputeUnattributed': 'critical',\n // The registry stamps each alert's own level; this answers only for one\n // written before it did.\n 'system.operatorAlert': 'warning',\n 'staff.userSignedUp': 'success',\n 'staff.orgCreated': 'success',\n 'staff.siteCreated': 'success',\n 'staff.subscriptionStarted': 'success',\n 'staff.subscriptionCanceled': 'warning',\n 'staff.planChanged': 'info',\n 'staff.paymentFailed': 'warning',\n}\n\nconst LEVEL_SET: ReadonlySet<string> = new Set(NOTIFICATION_LEVELS)\n\n/** Whether a stored value is a level this build knows. */\nexport function isNotificationLevel(value: unknown): value is NotificationLevel {\n return typeof value === 'string' && LEVEL_SET.has(value)\n}\n\n/**\n * A notification's level: what its emitter stamped, else its type's\n * default, else `neutral`. A stamped value this build does not know — a\n * level added later, read by an older console — falls back the same way\n * rather than drawing nothing.\n */\nexport function notificationLevel(\n notification: { type?: string; level?: unknown } | null | undefined,\n): NotificationLevel {\n const stamped = notification?.level\n if (isNotificationLevel(stamped)) return stamped\n const byType = (NOTIFICATION_TYPE_LEVELS as Record<string, NotificationLevel>)[\n notification?.type ?? ''\n ]\n return byType ?? 'neutral'\n}\n\n/**\n * A usage notice's level at `percent` of its allowance: `info` on a step\n * toward it, `warning` once it is reached. Every usage, budget and allotment\n * notice says the same thing at the same step, so they share this.\n */\nexport function usageNotificationLevel(percent: number): NotificationLevel {\n return percent >= 100 ? 'warning' : 'info'\n}\n\n/** The loudest of several levels, or `neutral` for none. */\nexport function loudestNotificationLevel(\n levels: Iterable<NotificationLevel>,\n): NotificationLevel {\n let best = NOTIFICATION_LEVELS.length - 1\n for (const level of levels) {\n const rank = NOTIFICATION_LEVELS.indexOf(level)\n if (rank >= 0 && rank < best) best = rank\n }\n return NOTIFICATION_LEVELS[best]\n}\n\n/**\n * The preference buckets the platform owns (AGL-267): the prefix before the\n * dot. A plugin adds its own through {@link NotificationCategoryDeclaration},\n * so this union is the core's and nothing else's.\n */\nexport type CoreNotificationCategory =\n | 'billing'\n | 'team'\n | 'content'\n | 'support'\n | 'system'\n // Staff-only, and shown only to staff (AGL-3225) — see\n // {@link STAFF_NOTIFICATION_CATEGORIES}.\n | 'staff'\n\n/**\n * A preference bucket: one of the core's, or one a first-party plugin\n * declares. The id is persisted — it keys every stored preference map — so a\n * declared id keeps its meaning only while its declaration keeps its spelling.\n */\nexport type NotificationCategory = CoreNotificationCategory | (string & {})\n\n/**\n * A notification category a plugin declares for the notifications it sends\n * (AGL-3080), under `notificationCategories` in `plugins.config.json`.\n *\n * Compiled rather than registered: `notifyUsers` resolves a recipient's\n * channels in server processes that load no plugin, and the settings page\n * draws its rows before any plugin could register. A category that had to\n * wait for its plugin would read as `system` until then — so a person who had\n * muted it would be told, and a person who had asked for its email would not.\n * The generator refuses an id the core owns or another plugin declares.\n */\nexport interface NotificationCategoryDeclaration {\n /** The plugin whose notifications carry this prefix. */\n pluginId: string\n /** The type prefix, and the key every stored preference is written under. */\n id: string\n /** The row's name on the settings page. */\n label: string\n /** What arrives under it, in the reader's words — see {@link NOTIFICATION_CATEGORY_DESCRIPTIONS}. */\n description: string\n /** What each channel does before anybody says — see {@link NOTIFICATION_CHANNEL_DEFAULTS}. */\n defaults: Record<NotificationChannel, boolean>\n}\n\n/**\n * The declared categories, in the order the settings page lists them: after\n * the workspace's own work and before the platform's notices, which is where\n * a plugin's traffic sits in a reader's day.\n */\nconst DECLARED_CATEGORIES: readonly NotificationCategoryDeclaration[] =\n PLUGIN_NOTIFICATION_CATEGORIES_DECLARED\n\n/**\n * The core's rows listed before the declared ones. Every other core category\n * follows them, in the order its `Record` spells it, so a new core category\n * cannot be left off the page.\n */\nconst LEADING_CATEGORIES: readonly CoreNotificationCategory[] = [\n 'billing',\n 'team',\n 'content',\n]\n\nfunction inCategoryOrder<T>(\n core: Record<CoreNotificationCategory, T>,\n declared: (declaration: NotificationCategoryDeclaration) => T,\n): Readonly<Record<NotificationCategory, T>> {\n // Every core id is written below, so the keys the type promises are there.\n const out = {} as Record<NotificationCategory, T>\n for (const id of LEADING_CATEGORIES) out[id] = core[id]\n for (const declaration of DECLARED_CATEGORIES) {\n out[declaration.id] = declared(declaration)\n }\n for (const id of Object.keys(core) as CoreNotificationCategory[]) {\n if (!LEADING_CATEGORIES.includes(id)) out[id] = core[id]\n }\n return out\n}\n\nconst CORE_CATEGORY_LABELS: Record<CoreNotificationCategory, string> = {\n billing: 'Billing',\n team: 'Team & access',\n content: 'Forms & bookings',\n support: 'Support',\n system: 'Product & system',\n staff: 'Platform growth',\n}\n\n/** Every category's row name, the core's and the declared, in page order. */\nexport const NOTIFICATION_CATEGORY_LABELS = inCategoryOrder(\n CORE_CATEGORY_LABELS,\n (declaration) => declaration.label,\n)\n\n/**\n * The categories only staff can receive (AGL-3225).\n *\n * The settings page hides these rows from everybody else, because a switch\n * for mail that can never arrive is a promise the product does not keep. It\n * is presentation only: `notifyStaff` is what decides the audience, and it\n * enumerates the `staff` claim rather than reading this.\n */\nexport const STAFF_NOTIFICATION_CATEGORIES: ReadonlySet<NotificationCategory> =\n new Set<NotificationCategory>(['staff'])\n\n/**\n * What each category actually covers, in the reader's words (AGL-3251).\n *\n * The settings page listed seven bare labels and two switches, so deciding\n * whether to silence `Product & system` meant guessing what was in it. The\n * type labels beneath each row now name the contents exactly; this is the\n * one-line answer for somebody who does not want to expand anything.\n *\n * Written as what ARRIVES rather than as what the bucket is called: \"someone\n * joins, a role changes\" is checkable against your own feed in a way that\n * \"team and access events\" is not. Exhaustive over the core's categories,\n * like the channel defaults below, and required of every declaration — a new\n * category cannot ship without somebody saying in plain words what lands in\n * it.\n */\nconst CORE_CATEGORY_DESCRIPTIONS: Record<CoreNotificationCategory, string> = {\n billing:\n 'Invoices, failed payments, cancellations, and usage that crosses a plan limit.',\n team: 'Somebody joins or leaves, a role changes, or a site is shared with you.',\n content:\n 'Work arriving on your sites: form submissions, bookings, orders, low stock, and the tasks and leads assigned to you.',\n support: 'New support tickets and replies on tickets you are following.',\n system:\n 'Announcements, and the faults the platform finds in your account: sign-in methods removed, traffic limits reached, billing or sharing left in a broken state.',\n staff:\n 'New accounts, new workspaces, and money moving — subscriptions starting, changing, failing and ending — across the whole platform. Only staff receive these.',\n}\n\nexport const NOTIFICATION_CATEGORY_DESCRIPTIONS = inCategoryOrder(\n CORE_CATEGORY_DESCRIPTIONS,\n (declaration) => declaration.description,\n)\n\n/**\n * The bucket a type falls in: its prefix, when a category of that id exists,\n * and `system` otherwise — the bucket nobody mutes to reduce noise, so a type\n * nothing claims still reaches the person rather than riding a mute they set\n * for something else.\n */\nexport function notificationCategory(\n type: AglynNotificationType | string,\n): NotificationCategory {\n const prefix = String(type).split('.')[0]\n return Object.prototype.hasOwnProperty.call(\n NOTIFICATION_CATEGORY_LABELS,\n prefix,\n )\n ? prefix\n : 'system'\n}\n\n/**\n * Per-user mute map stored at `users/{uid}.notificationPrefs`\n * (`{ [category]: false }` mutes); absent categories stay on.\n */\nexport function notificationMuted(\n prefs: Record<string, boolean> | null | undefined,\n type: AglynNotificationType | string,\n): boolean {\n return prefs?.[notificationCategory(type)] === false\n}\n\n/**\n * The field on `users/{uid}` that holds a person's digest switches\n * (AGL-2619): `{ [digestKey]: false }` turns one digest off, and an absent key\n * leaves it on. Its own map rather than a key in `notificationPrefs`, because\n * that map is keyed by CATEGORY and read by {@link notificationMuted} — a\n * digest is a schedule a person keeps or drops, not a bucket of types, and one\n * switch governs both the console notification and the email it travels with.\n */\nexport const DIGEST_PREFS_FIELD = 'digestPrefs'\n\n/**\n * A digest a plugin sends on its own schedule (AGL-3080), declared under\n * `notificationDigests` in `plugins.config.json` so the settings page can draw\n * its switch without loading the plugin, and the plugin's sender and that\n * switch read one key.\n *\n * On until the person turns it off: the key is stored in\n * {@link DIGEST_PREFS_FIELD} only once somebody has said no. The key is\n * persisted, so it keeps its spelling for as long as anybody's switch is\n * stored under it.\n */\nexport interface NotificationDigestDeclaration {\n /** The plugin that composes and sends it; `core` for the platform's own. */\n pluginId: string\n /** The key its switch is stored under in {@link DIGEST_PREFS_FIELD}. */\n key: string\n /** The switch's name. */\n label: string\n /** When it arrives and what it holds, in the reader's words. */\n description: string\n}\n\n/**\n * The platform's own digests, listed before any plugin's. The Weekly summary\n * (AGL-3867) is the core's because it reads every plugin's figures and\n * belongs to none of them; its key is the one\n * `@aglyn/tenant-data-admin/server/weekly-summary` reads.\n */\nconst CORE_NOTIFICATION_DIGESTS: readonly NotificationDigestDeclaration[] = [\n {\n pluginId: 'core',\n key: 'weeklySummary',\n label: 'Weekly summary',\n description:\n 'Each Monday by email: how each of your sites did last week — visitors, top pages, form ' +\n 'submissions, new leads, bookings, sales — and an overview of each workspace you run. Free, and nothing is generated.',\n },\n]\n\n/** Every declared digest, in the order the settings page lists them. */\nexport const NOTIFICATION_DIGESTS: readonly NotificationDigestDeclaration[] = [\n ...CORE_NOTIFICATION_DIGESTS,\n ...PLUGIN_NOTIFICATION_DIGESTS_DECLARED,\n]\n\n/**\n * Whether a person keeps one digest: on unless its key is stored `false`. It\n * reads only its own key — a category mute lives in a different map and does\n * not reach it.\n */\nexport function digestEnabled(\n prefs: Record<string, boolean> | null | undefined,\n key: string,\n): boolean {\n return prefs?.[key] !== false\n}\n\n/**\n * The field on `users/{uid}` naming the workspaces whose weekly insights a\n * person asked for (AGL-2915): `{ [orgId]: true }`. OPT-IN, unlike the CRM\n * digest — an absent key is off — because a weekly insight is generated, and a\n * generation spends the workspace's credits. The person turns it on beside the\n * answers themselves, where the plan, the release and their permission have\n * already been checked, and off again there or in Notifications; the weekly\n * sweep checks all three again before it spends anything.\n */\nexport const INSIGHT_DIGESTS_FIELD = 'insightDigests'\n\n/** Whether a person asked for a workspace's weekly insights. */\nexport function insightDigestSubscribed(\n value: Record<string, boolean> | null | undefined,\n orgId: string,\n): boolean {\n return Boolean(orgId) && value?.[orgId] === true\n}\n\n/**\n * The channels a notification can travel on (AGL-3223).\n *\n * `console` is the feed at `/manage/notifications` and the app-bar dropdown\n * that reads it — the only channel that existed. `email` is the message the\n * fan-out sends beside that doc when the recipient asked for one.\n */\nexport type NotificationChannel = 'console' | 'email'\n\n/**\n * One scope's answer for one category. **Tri-state on purpose**: `true` and\n * `false` decide, and an ABSENT key inherits from the scope above.\n *\n * Inheritance is the whole reason this is a partial rather than a pair of\n * booleans. A person who wants form submissions from one busy site and not\n * from the other five has to be able to say that about the one site without\n * restating their answer for every other category at every other scope — and\n * a two-valued leaf cannot express \"I have not said\", so every override would\n * have to be written out in full and would then stop tracking the account\n * default it was never meant to detach from.\n */\nexport interface NotificationChannelPrefs {\n console?: boolean\n email?: boolean\n /**\n * The mobile apps' push channel (AGL-3620), tri-state like the others.\n * Resolved by `notificationPushEnabled` (`notification-push.ts`), where an\n * unanswered push follows the console feed, so it is not a\n * {@link NotificationChannel} and no category declares a default for it.\n */\n push?: boolean\n}\n\nexport type NotificationCategoryPrefs = Partial<\n Record<NotificationCategory, NotificationChannelPrefs>\n>\n\n/**\n * One scope's answer for a single notification TYPE (AGL-3251), tri-state on\n * the same terms as {@link NotificationChannelPrefs}: an absent key falls\n * through to the category.\n *\n * Its own map rather than extra keys in {@link NotificationCategoryPrefs},\n * because that one is an exhaustive record keyed by category and a type key\n * inside it would type-check only by widening the thing that makes a missing\n * category a compile error.\n */\nexport type NotificationTypePrefs = Partial<\n Record<AglynNotificationType, NotificationChannelPrefs>\n>\n\n/**\n * Per-scope, per-channel notification preferences (AGL-3223), stored at\n * `users/{uid}.notificationSettings`.\n *\n * Three layers, narrowest first when resolving: the SITE a notification\n * concerns, then the WORKSPACE, then the account. Anything none of them\n * answers falls to {@link NOTIFICATION_CHANNEL_DEFAULTS}.\n *\n * ON THE USER DOCUMENT, not on `orgs/{orgId}/members/{uid}`, and that is a\n * cost decision rather than a modelling preference. `notifyUsers` already\n * does exactly one `getAll` over the recipients' user docs to read their\n * category mutes, so every layer living here means per-site preferences are\n * read for free on the fan-out's hot path. The member row would add a read\n * per recipient per notification to answer a question the document already in\n * hand could have answered.\n */\nexport interface NotificationSettings {\n account?: NotificationCategoryPrefs\n /**\n * Per-TYPE answers at the account scope (AGL-3251), consulted before\n * {@link NotificationSettings.account}'s category answer and never above a\n * narrower scope — see {@link notificationChannelEnabled}.\n *\n * ACCOUNT ONLY, deliberately. The per-workspace and per-site card is\n * already seven categories by three states by two channels, and a type row\n * for each of the ~thirty types, per workspace AND per site, is a page\n * nobody can read — which is the complaint this whole issue started as.\n * The account scope is where \"stop telling me about THIS\" belongs anyway:\n * it is a statement about the kind of thing, and kinds do not vary by site.\n */\n accountTypes?: NotificationTypePrefs\n /** Keyed by org id. */\n orgs?: Record<string, NotificationCategoryPrefs>\n /** Keyed by host id — a site's own answer, narrower than its workspace's. */\n hosts?: Record<string, NotificationCategoryPrefs>\n /**\n * Per-TYPE answers at the workspace and site scopes (AGL-3267), beside the\n * category maps above rather than nested inside them.\n *\n * Parallel maps because the category maps are already stored under `orgs`\n * and `hosts` on live user documents: folding both into one\n * `{ categories, types }` object per scope would be a migration of every\n * preference anybody has set, to express something two more keys express\n * without touching a byte of what exists.\n *\n * This supersedes the account-only limit AGL-3251 shipped under. That was\n * the right call for a card nobody had used yet and the wrong one once it\n * existed: \"quiet this one type down on this one busy site\" is the question\n * the scope card is FOR, and answering it only for whole categories made\n * the fine grain stop exactly where the noise is worst.\n */\n orgTypes?: Record<string, NotificationTypePrefs>\n hostTypes?: Record<string, NotificationTypePrefs>\n}\n\nexport const NOTIFICATION_SETTINGS_FIELD = 'notificationSettings'\n\n/**\n * What a category does when nobody has said otherwise.\n *\n * Console on, email OFF, for every category but `staff`. The types a person needs to hear\n * about without opening the console are the exception, by type, in\n * {@link NOTIFICATION_TYPE_CHANNEL_DEFAULTS}. Email defaults off because the inbox is\n * not ours to fill: the product already sends transactional mail nobody opted\n * into — welcome, verification, invites, dunning, usage alerts — and every one\n * of those leaves on the same domain a customer's password reset depends on.\n * Turning a busy site's form submissions into mail by default would put that\n * domain's reputation behind traffic the recipient never asked for.\n *\n * Exhaustive over the core's categories deliberately: a new\n * {@link CoreNotificationCategory} is a compile error here until somebody\n * decides what it does by default, which is the one question a new category\n * must not be able to ship without answering — and a declared category may not\n * compile without its `defaults` for the same reason.\n */\nconst CORE_CHANNEL_DEFAULTS: Record<\n CoreNotificationCategory,\n Record<NotificationChannel, boolean>\n> = {\n billing: { console: true, email: false },\n team: { console: true, email: false },\n content: { console: true, email: false },\n support: { console: true, email: false },\n system: { console: true, email: false },\n // Console and email ON: staff are the platform's own people, every one of\n // them is meant to hear about growth and money moving, and an account that\n // never opened its settings heard about almost none of it. The one category\n // whose recipients are all us, so the inbox reasoning above does not apply.\n staff: { console: true, email: true },\n}\n\nexport const NOTIFICATION_CHANNEL_DEFAULTS = inCategoryOrder(\n CORE_CHANNEL_DEFAULTS,\n (declaration) => ({ ...declaration.defaults }),\n)\n\n/**\n * The types whose default differs from their category's: each emails its\n * recipients unless they said otherwise.\n *\n * Below every stored answer, the category's included, so a person who\n * switched content email off keeps it off; above the category default, so\n * everyone who never answered (every account, new or old) is emailed.\n *\n * What is here is what somebody misses by hearing about it only in the\n * console: a site's own transactions, work handed to them, their AI jobs,\n * their bill, a reply or a verdict they are waiting on, an announcement, and\n * every notice that something STOPPED or needs a person now. A type that\n * already mails its own message stays out of the channel's way either way —\n * see {@link NOTIFICATION_SELF_SENT_EMAIL_TYPES} and the `skipEmail` callers.\n */\nexport const NOTIFICATION_TYPE_CHANNEL_DEFAULTS: Partial<\n Record<AglynNotificationType, Partial<Record<NotificationChannel, boolean>>>\n> = {\n // A site's own transactions (AGL-3600).\n 'content.formSubmission': { email: true },\n 'content.booking': { email: true },\n 'content.order': { email: true },\n // Work handed to one person, and the AI jobs they started.\n 'content.taskAssigned': { email: true },\n 'content.contactAssigned': { email: true },\n 'content.leadAssigned': { email: true },\n 'content.aiJobNeedsYou': { email: true },\n 'content.aiJobDone': { email: true },\n 'content.aiJobFailed': { email: true },\n // The workspace's bill. A usage crossing sends its own email and passes\n // `skipEmail`, so this default never mails it twice.\n 'billing.invoice': { email: true },\n // The domains plugin sends its own designed email for each and passes\n // `skipEmail`, so this default never mails one twice.\n 'billing.domain': { email: true },\n 'billing.paymentFailed': { email: true },\n 'billing.subscriptionCanceled': { email: true },\n 'billing.usage': { email: true },\n // An answer somebody is waiting on.\n 'marketplace.review': { email: true },\n 'support.ticketReply': { email: true },\n 'system.announcement': { email: true },\n // Something stopped, or needs a person now.\n 'system.formSubmissionsPaused': { email: true },\n 'system.visitorRecordsPaused': { email: true },\n 'system.abuseReportUrgent': { email: true },\n 'system.riskNotice': { email: true },\n 'system.dmcaCounterNotice': { email: true },\n 'system.bandwidthCeilingTripped': { email: true },\n 'system.billingWebhookHalfApplied': { email: true },\n 'system.disputeUnattributed': { email: true },\n}\n\n/**\n * What a notification email's button says it opens (AGL-3897), by type: the\n * record the notification links to, named, rather than \"Open in Aglyn\" on\n * every one. A type with no entry keeps that general label.\n */\nconst NOTIFICATION_EMAIL_ACTIONS: Partial<Record<AglynNotificationType, string>> = {\n 'content.formSubmission': 'View submission',\n 'content.booking': 'View booking',\n 'content.order': 'View order',\n 'content.lowStock': 'View product',\n 'content.taskAssigned': 'Open task',\n 'content.taskReminder': 'Open task',\n 'content.contactAssigned': 'Open contact',\n 'content.leadAssigned': 'Open lead',\n 'billing.invoice': 'Open billing',\n 'billing.paymentFailed': 'Update payment method',\n 'billing.domain': 'Open domain',\n 'billing.subscriptionCanceled': 'Open billing',\n 'billing.usage': 'Open billing',\n 'support.ticketOpened': 'Open ticket',\n 'support.ticketReply': 'Open ticket',\n 'marketplace.review': 'Open listing',\n 'staff.userSignedUp': 'Open account',\n 'staff.orgCreated': 'Open workspace',\n 'staff.siteCreated': 'Open site',\n 'staff.subscriptionStarted': 'Open workspace',\n 'staff.subscriptionCanceled': 'Open workspace',\n 'staff.planChanged': 'Open workspace',\n 'staff.paymentFailed': 'Open workspace',\n}\n\n/** The words on a notification email's button: the record it opens, else \"Open in {product}\". */\nexport function notificationEmailAction(type: AglynNotificationType | string, productName: string): string {\n return NOTIFICATION_EMAIL_ACTIONS[type as AglynNotificationType] ?? `Open in ${productName}`\n}\n\n/** What a type does on a channel when nobody has answered for it or its category. */\nexport function notificationTypeChannelDefault(\n type: AglynNotificationType | string,\n channel: NotificationChannel,\n): boolean {\n const own = NOTIFICATION_TYPE_CHANNEL_DEFAULTS[type as AglynNotificationType]?.[channel]\n return typeof own === 'boolean'\n ? own\n : NOTIFICATION_CHANNEL_DEFAULTS[notificationCategory(type)][channel]\n}\n\n/**\n * The types that send their OWN email and must never be mailed again by the\n * generic channel (AGL-3224).\n *\n * Both digests compose a message the fan-out could not reproduce — a day's\n * owed tasks, a week's figures — and send it from their own route under their\n * own switch (a key in `digestPrefs`, `insightDigests.{orgId}`). A recipient\n * who switches the `content` email channel on would otherwise receive the\n * digest twice: once as the digest, once as a one-line \"Daily CRM digest\"\n * notification saying that the digest happened.\n */\nexport const NOTIFICATION_SELF_SENT_EMAIL_TYPES: ReadonlySet<string> =\n new Set<AglynNotificationType>([\n 'content.crmDailyDigest',\n 'content.insightsDigest',\n // The task-reminders route mails one reminder per member per run, listing\n // every task due, and writes one notification per task; mailed again\n // here, each task would arrive a second time as its own email (AGL-3432).\n 'content.taskReminder',\n // Emailed by `notifyRiskEvent` itself, as account mail (AGL-3368).\n 'system.riskNotice',\n ])\n\n/**\n * The tooltip on a self-sent type's disabled email switch: it names what\n * DOES govern that mail, which differs by type (AGL-3432). Calling a task\n * reminder a digest sent people looking for a Digests switch that is not it.\n */\nexport function selfSentEmailNote(type: string): string {\n switch (type) {\n case 'content.taskReminder':\n return 'Task reminders send their own email, one per run listing every task due. Mute this category to stop them.'\n case 'system.riskNotice':\n return 'Risk and account notices are always emailed, whatever this switch says.'\n default:\n return 'This digest sends its own email, under its switch in Digests.'\n }\n}\n\n/**\n * Whether a channel is on for one notification, at its own scope.\n *\n * Resolution order, first answer wins: the notification's SITE, its\n * WORKSPACE, the account, then {@link NOTIFICATION_CHANNEL_DEFAULTS}. A scope\n * that holds no entry for the category, or an entry with the channel key\n * absent, does not answer — see {@link NotificationChannelPrefs}.\n *\n * `legacyPrefs` is the flat `notificationPrefs` mute map this replaces\n * (AGL-267), and it sits BELOW the account layer rather than beside it.\n * Nothing migrates it: a person who never opens the new settings page keeps\n * being governed by the mutes they set years ago, and the first thing they do\n * set on the account layer overrides the old map for that category without\n * disturbing the rest of it. It answers for `console` only, because the map\n * predates there being a second channel and reading a console mute as an\n * email preference would be inventing an answer its author never gave.\n */\nexport function notificationChannelEnabled(\n settings: NotificationSettings | null | undefined,\n channel: NotificationChannel,\n type: AglynNotificationType | string,\n scope?: { orgId?: string | null; hostId?: string | null },\n legacyPrefs?: Record<string, boolean> | null,\n): boolean {\n const category = notificationCategory(type)\n /*\n * ⛔ A STAFF NOTIFICATION HAS NO SCOPE TO BE NARROWED BY (AGL-3267).\n *\n * It is about the platform, and the workspace one MENTIONS is its subject,\n * not its audience: the staff member reading \"Acme Co subscribed\" is\n * almost never a member of Acme Co, and if they happen to be, their\n * preferences for their own membership have nothing to do with it.\n *\n * This was already true by accident — no staff emitter passes `orgId` or\n * `hostId`, so the scope layers never matched — which made every\n * \"Platform growth\" row in the per-workspace card a control that could be\n * set and could never do anything. Stating it here rather than only hiding\n * those rows is the difference between the model being right and the UI\n * covering for a model that is wrong: a `staff.*` type that ever does\n * start carrying an `orgId`, for its link or its subject line, must not\n * silently become mutable per workspace.\n */\n const staffWide = STAFF_NOTIFICATION_CATEGORIES.has(category)\n /*\n * Narrowest scope first, and within each scope the TYPE before its\n * CATEGORY (AGL-3251, widened to every scope by AGL-3267).\n *\n * Both halves matter and they answer different questions. Scope order is\n * \"where is this from\" — a site's answer beats its workspace's, which beats\n * the account's, because quietening one busy site is the thing the scope\n * card exists for. Type-before-category is \"what kind of thing is this\" —\n * somebody who switched `Payment failed` off said something about that\n * type, and it must beat their own broader `Billing` answer at the SAME\n * scope without leaking upward past a narrower one.\n */\n const layers: Array<NotificationChannelPrefs | undefined> = staffWide\n ? [\n settings?.accountTypes?.[type as AglynNotificationType],\n settings?.account?.[category],\n ]\n : [\n scope?.hostId\n ? settings?.hostTypes?.[scope.hostId]?.[type as AglynNotificationType]\n : undefined,\n scope?.hostId ? settings?.hosts?.[scope.hostId]?.[category] : undefined,\n scope?.orgId\n ? settings?.orgTypes?.[scope.orgId]?.[type as AglynNotificationType]\n : undefined,\n scope?.orgId ? settings?.orgs?.[scope.orgId]?.[category] : undefined,\n settings?.accountTypes?.[type as AglynNotificationType],\n settings?.account?.[category],\n ]\n for (const layer of layers) {\n const answer = layer?.[channel]\n if (typeof answer === 'boolean') return answer\n }\n if (channel === 'console' && notificationMuted(legacyPrefs, type))\n return false\n return notificationTypeChannelDefault(type, channel)\n}\n\n/**\n * What one scope says, with no inheritance applied — what the settings page\n * renders as Inherit / On / Off, and `undefined` is Inherit.\n *\n * `scope` names the layer directly rather than being derived from a\n * notification, because the page edits a layer whether or not anything has\n * ever arrived from it.\n */\n/**\n * What the account scope says about one TYPE, with no inheritance applied\n * (AGL-3251) — `undefined` means the row follows its category.\n *\n * The page needs the unresolved answer to draw the difference between \"on\n * because Billing is on\" and \"on because you said so\": only the second can be\n * reset, and a switch that cannot show which one it is leaves a person unable\n * to get back to following the category.\n */\nexport function notificationAccountTypePref(\n settings: NotificationSettings | null | undefined,\n type: AglynNotificationType | string,\n channel: NotificationChannel,\n): boolean | undefined {\n return settings?.accountTypes?.[type as AglynNotificationType]?.[channel]\n}\n\n/**\n * Every type in a category, in the order the labels declare them\n * (AGL-3251) — what the settings page expands a category row into.\n *\n * Derived from {@link NOTIFICATION_TYPE_LABELS} rather than held as a second\n * map, so a type added there appears under its category without anybody\n * remembering to list it twice.\n */\nexport function notificationTypesInCategory(\n category: NotificationCategory,\n): AglynNotificationType[] {\n return (\n Object.keys(NOTIFICATION_TYPE_LABELS) as AglynNotificationType[]\n ).filter((type) => notificationCategory(type) === category)\n}\n\n/**\n * What ONE scope says about ONE type, with no inheritance applied\n * (AGL-3267) — `undefined` means the row follows its category at that scope.\n *\n * The scope-aware sibling of {@link notificationAccountTypePref}, which stays\n * as the account-only shorthand its callers already use.\n */\nexport function notificationScopeTypePref(\n settings: NotificationSettings | null | undefined,\n scope: { kind: 'account' } | { kind: 'org' | 'host'; id: string },\n type: AglynNotificationType | string,\n channel: NotificationChannel,\n): boolean | undefined {\n const layer =\n scope.kind === 'account'\n ? settings?.accountTypes\n : scope.kind === 'org'\n ? settings?.orgTypes?.[scope.id]\n : settings?.hostTypes?.[scope.id]\n return layer?.[type as AglynNotificationType]?.[channel]\n}\n\nexport function notificationScopePref(\n settings: NotificationSettings | null | undefined,\n scope: { kind: 'account' } | { kind: 'org' | 'host'; id: string },\n category: NotificationCategory,\n channel: NotificationChannel,\n): boolean | undefined {\n const layer =\n scope.kind === 'account'\n ? settings?.account\n : scope.kind === 'org'\n ? settings?.orgs?.[scope.id]\n : settings?.hosts?.[scope.id]\n return layer?.[category]?.[channel]\n}\n\n/**\n * The scopes this person has said something different about — what the\n * settings page lists so an override is never somewhere you have to go\n * looking for.\n *\n * Returns ids, not names: the page holds the org and host rosters and this\n * module holds no lookups. An id with no name is still worth listing, because\n * a preference about a workspace somebody left is exactly the kind of\n * leftover that is invisible until it is listed.\n */\nexport function notificationOverriddenScopes(\n settings: NotificationSettings | null | undefined,\n): { orgIds: string[]; hostIds: string[] } {\n /*\n * BOTH maps for a scope, categories and types (AGL-3267).\n *\n * Reading only the category map would make a scope whose ONLY answer is a\n * per-type one invisible here — and this list is the single place a person\n * can find an override they set months ago. Silencing one type on one busy\n * site and then being unable to find where you did it is precisely the\n * failure this function exists to prevent.\n */\n const answered = (prefs: Record<string, NotificationChannelPrefs> | undefined) =>\n Object.values(prefs ?? {}).some((channels) =>\n Object.values(channels ?? {}).some((value) => typeof value === 'boolean'),\n )\n const named = (\n categories: Record<string, NotificationCategoryPrefs> | undefined,\n types: Record<string, NotificationTypePrefs> | undefined,\n ) =>\n [\n ...new Set([\n ...Object.keys(categories ?? {}),\n ...Object.keys(types ?? {}),\n ]),\n ]\n .filter(\n (id) =>\n answered(categories?.[id] as Record<string, NotificationChannelPrefs>) ||\n answered(types?.[id] as Record<string, NotificationChannelPrefs>),\n )\n .sort()\n return {\n orgIds: named(settings?.orgs, settings?.orgTypes),\n hostIds: named(settings?.hosts, settings?.hostTypes),\n }\n}\n\n/**\n * Rewrites a stored notification link onto the current URL scheme (AGL-644).\n *\n * A notification's `link` is frozen at write time — `notifyUsers` persists\n * whatever string the emitter passed and never normalizes it. So every\n * notification written before the org-slug migration (AGL-621) and the\n * subdomain migration (AGL-622) still points at a route that no longer\n * exists, and fixing the emitters alone would leave that whole backlog dead.\n * Normalizing when the link is FOLLOWED repairs old and new alike, and keeps\n * working for emitters that haven't been migrated yet.\n *\n * Rewrites, in order — each prefix also followed by a query string, as\n * `/{hostDocId}?aiJob={id}` (AGL-3593):\n * - `/{hostDocId}` or `/{hostDocId}/rest` → `/{orgSlug}/hosts/{subdomain}/rest`\n * - `/org` or `/org/rest` → `/{orgSlug}/rest`\n * - `/hosts` (exactly) → `/{orgSlug}/hosts`\n *\n * Anything already canonical, user-scoped (`/manage/...`), staff (`/admin/...`)\n * or absolute is returned untouched. Every rewrite is gated on having the\n * context it needs, so an unresolvable link degrades to its stored value\n * rather than to a wrong destination.\n *\n * The host rewrite is keyed on the notification's own `hostId` rather than\n * guessing from the path shape, so it can never mistake a real first segment\n * for a doc id.\n *\n * The email copy of a notification goes through it too (AGL-3367): a button\n * in an inbox is a followed link with no console around it to repair it.\n */\nexport function normalizeNotificationLink(\n link: string | undefined | null,\n context: {\n orgSlug?: string | null\n /** The notification's `hostId` (a Firestore doc id). */\n hostId?: string | null\n /** That host's subdomain, which the current routes are keyed by. */\n hostSubdomain?: string | null\n },\n): string | undefined {\n if (!link) return undefined\n // Absolute URLs (staff broadcasts can carry them) are not ours to rewrite.\n if (!link.startsWith('/')) return link\n\n const { orgSlug, hostId, hostSubdomain } = context\n\n if (orgSlug && hostId && hostSubdomain) {\n const prefix = `/${hostId}`\n if (link === prefix || link.startsWith(`${prefix}/`) || link.startsWith(`${prefix}?`)) {\n return `${buildRoute(Route.HOST_DASHBOARD, {\n orgSlug,\n host: hostSubdomain,\n })}${link.slice(prefix.length)}`\n }\n }\n\n if (orgSlug) {\n if (link === '/org' || link.startsWith('/org/') || link.startsWith('/org?')) {\n return `${buildRoute(Route.ORG_HOME, { orgSlug })}${link.slice(\n '/org'.length,\n )}`\n }\n // Only the bare list — `/hosts/{docId}` would still need a subdomain, and\n // guessing one is worse than leaving the link alone.\n if (link === '/hosts') return buildRoute(Route.HOST_LIST, { orgSlug })\n }\n\n return link\n}\n"],"names":["buildRoute","Route","PLUGIN_NOTIFICATION_CATEGORIES_DECLARED","PLUGIN_NOTIFICATION_DIGESTS_DECLARED","NOTIFICATION_TYPE_LABELS","NOTIFICATION_LEVELS","NOTIFICATION_LEVEL_LABELS","critical","warning","success","info","neutral","NOTIFICATION_TYPE_LEVELS","LEVEL_SET","Set","isNotificationLevel","value","has","notificationLevel","notification","stamped","level","byType","type","usageNotificationLevel","percent","loudestNotificationLevel","levels","best","length","rank","indexOf","DECLARED_CATEGORIES","LEADING_CATEGORIES","inCategoryOrder","core","declared","out","id","declaration","Object","keys","includes","CORE_CATEGORY_LABELS","billing","team","content","support","system","staff","NOTIFICATION_CATEGORY_LABELS","label","STAFF_NOTIFICATION_CATEGORIES","CORE_CATEGORY_DESCRIPTIONS","NOTIFICATION_CATEGORY_DESCRIPTIONS","description","notificationCategory","prefix","String","split","prototype","hasOwnProperty","call","notificationMuted","prefs","DIGEST_PREFS_FIELD","CORE_NOTIFICATION_DIGESTS","pluginId","key","NOTIFICATION_DIGESTS","digestEnabled","INSIGHT_DIGESTS_FIELD","insightDigestSubscribed","orgId","Boolean","NOTIFICATION_SETTINGS_FIELD","CORE_CHANNEL_DEFAULTS","console","email","NOTIFICATION_CHANNEL_DEFAULTS","defaults","NOTIFICATION_TYPE_CHANNEL_DEFAULTS","NOTIFICATION_EMAIL_ACTIONS","notificationEmailAction","productName","notificationTypeChannelDefault","channel","own","NOTIFICATION_SELF_SENT_EMAIL_TYPES","selfSentEmailNote","notificationChannelEnabled","settings","scope","legacyPrefs","category","staffWide","layers","accountTypes","account","hostId","hostTypes","undefined","hosts","orgTypes","orgs","layer","answer","notificationAccountTypePref","notificationTypesInCategory","filter","notificationScopeTypePref","kind","notificationScopePref","notificationOverriddenScopes","answered","values","some","channels","named","categories","types","sort","orgIds","hostIds","normalizeNotificationLink","link","context","startsWith","orgSlug","hostSubdomain","HOST_DASHBOARD","host","slice","ORG_HOME","HOST_LIST"],"mappings":"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"}
|