@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.
Files changed (121) hide show
  1. package/package.json +11 -11
  2. package/src/lib/app-utils/account-preferences.d.ts +248 -0
  3. package/src/lib/app-utils/account-preferences.js +434 -0
  4. package/src/lib/app-utils/account-preferences.js.map +1 -0
  5. package/src/lib/app-utils/activity-labels.js +2 -0
  6. package/src/lib/app-utils/activity-labels.js.map +1 -1
  7. package/src/lib/app-utils/analytics-events.d.ts +13 -0
  8. package/src/lib/app-utils/analytics-events.js +2 -0
  9. package/src/lib/app-utils/analytics-events.js.map +1 -1
  10. package/src/lib/app-utils/append-only-history.d.ts +94 -0
  11. package/src/lib/app-utils/append-only-history.js +73 -0
  12. package/src/lib/app-utils/append-only-history.js.map +1 -0
  13. package/src/lib/app-utils/bundle-intent.d.ts +186 -0
  14. package/src/lib/app-utils/bundle-intent.js +386 -0
  15. package/src/lib/app-utils/bundle-intent.js.map +1 -0
  16. package/src/lib/app-utils/client-build.d.ts +204 -0
  17. package/src/lib/app-utils/client-build.js +237 -0
  18. package/src/lib/app-utils/client-build.js.map +1 -0
  19. package/src/lib/app-utils/console-routes.d.ts +4 -0
  20. package/src/lib/app-utils/console-routes.js +2 -0
  21. package/src/lib/app-utils/console-routes.js.map +1 -1
  22. package/src/lib/app-utils/country-options.d.ts +27 -0
  23. package/src/lib/app-utils/country-options.js +96 -0
  24. package/src/lib/app-utils/country-options.js.map +1 -0
  25. package/src/lib/app-utils/docs-help-sections.generated.js +7 -5
  26. package/src/lib/app-utils/docs-help-sections.generated.js.map +1 -1
  27. package/src/lib/app-utils/docs-help.generated.d.ts +11 -5
  28. package/src/lib/app-utils/docs-help.generated.js +22 -2
  29. package/src/lib/app-utils/docs-help.generated.js.map +1 -1
  30. package/src/lib/app-utils/docs-index.generated.js +116 -39
  31. package/src/lib/app-utils/docs-index.generated.js.map +1 -1
  32. package/src/lib/app-utils/margin-utilization.js +6 -4
  33. package/src/lib/app-utils/margin-utilization.js.map +1 -1
  34. package/src/lib/app-utils/media-filter.d.ts +26 -0
  35. package/src/lib/app-utils/media-filter.js +32 -0
  36. package/src/lib/app-utils/media-filter.js.map +1 -1
  37. package/src/lib/app-utils/mobile-push.d.ts +8 -0
  38. package/src/lib/app-utils/mobile-push.js +4 -1
  39. package/src/lib/app-utils/mobile-push.js.map +1 -1
  40. package/src/lib/app-utils/native-app-association.d.ts +3 -2
  41. package/src/lib/app-utils/native-app-association.js +4 -2
  42. package/src/lib/app-utils/native-app-association.js.map +1 -1
  43. package/src/lib/app-utils/native-nav.d.ts +265 -0
  44. package/src/lib/app-utils/native-nav.js +741 -0
  45. package/src/lib/app-utils/native-nav.js.map +1 -0
  46. package/src/lib/app-utils/native-themes.d.ts +249 -0
  47. package/src/lib/app-utils/native-themes.js +732 -0
  48. package/src/lib/app-utils/native-themes.js.map +1 -0
  49. package/src/lib/app-utils/notifications.d.ts +1 -1
  50. package/src/lib/app-utils/notifications.js +10 -0
  51. package/src/lib/app-utils/notifications.js.map +1 -1
  52. package/src/lib/app-utils/onboarding-deep-link.d.ts +36 -2
  53. package/src/lib/app-utils/onboarding-deep-link.js +48 -5
  54. package/src/lib/app-utils/onboarding-deep-link.js.map +1 -1
  55. package/src/lib/app-utils/one-box-search.d.ts +108 -0
  56. package/src/lib/app-utils/one-box-search.js +167 -0
  57. package/src/lib/app-utils/one-box-search.js.map +1 -0
  58. package/src/lib/app-utils/operator-alerts.js +21 -0
  59. package/src/lib/app-utils/operator-alerts.js.map +1 -1
  60. package/src/lib/app-utils/org-permissions.js +10 -61
  61. package/src/lib/app-utils/org-permissions.js.map +1 -1
  62. package/src/lib/app-utils/org-role-grants.d.ts +95 -0
  63. package/src/lib/app-utils/org-role-grants.js +193 -0
  64. package/src/lib/app-utils/org-role-grants.js.map +1 -0
  65. package/src/lib/app-utils/owner-approval-record.d.ts +102 -0
  66. package/src/lib/app-utils/owner-approval-record.js +148 -0
  67. package/src/lib/app-utils/owner-approval-record.js.map +1 -0
  68. package/src/lib/app-utils/plan-entitlements.d.ts +224 -68
  69. package/src/lib/app-utils/plan-entitlements.js +278 -61
  70. package/src/lib/app-utils/plan-entitlements.js.map +1 -1
  71. package/src/lib/app-utils/release-flags.d.ts +1 -1
  72. package/src/lib/app-utils/release-flags.js +10 -0
  73. package/src/lib/app-utils/release-flags.js.map +1 -1
  74. package/src/lib/app-utils/server.d.ts +4 -0
  75. package/src/lib/app-utils/server.js +7 -0
  76. package/src/lib/app-utils/server.js.map +1 -1
  77. package/src/lib/app-utils/site-care-project.d.ts +227 -0
  78. package/src/lib/app-utils/site-care-project.js +423 -0
  79. package/src/lib/app-utils/site-care-project.js.map +1 -0
  80. package/src/lib/app-utils/site-care.d.ts +268 -0
  81. package/src/lib/app-utils/site-care.js +283 -0
  82. package/src/lib/app-utils/site-care.js.map +1 -0
  83. package/src/lib/app-utils/tenant-dns-targets.d.ts +62 -0
  84. package/src/lib/app-utils/tenant-dns-targets.js +80 -0
  85. package/src/lib/app-utils/tenant-dns-targets.js.map +1 -0
  86. package/src/lib/app-utils/upgrade-proposal.d.ts +29 -4
  87. package/src/lib/app-utils/upgrade-proposal.js +60 -9
  88. package/src/lib/app-utils/upgrade-proposal.js.map +1 -1
  89. package/src/lib/foundation/definitions/org-billing.types.d.ts +54 -10
  90. package/src/lib/foundation/definitions/org-billing.types.js.map +1 -1
  91. package/src/lib/foundation/definitions/organization.types.d.ts +3 -1
  92. package/src/lib/foundation/definitions/organization.types.js.map +1 -1
  93. package/src/lib/foundation/definitions/platform.types.d.ts +9 -0
  94. package/src/lib/foundation/definitions/platform.types.js.map +1 -1
  95. package/src/lib/foundation/index.d.ts +1 -1
  96. package/src/lib/foundation/index.js.map +1 -1
  97. package/src/lib/plugin-manager/feature-plugins.d.ts +86 -0
  98. package/src/lib/plugin-manager/feature-plugins.js +20 -0
  99. package/src/lib/plugin-manager/feature-plugins.js.map +1 -1
  100. package/src/lib/plugin-manager/first-party-plugins.generated.js +22 -1
  101. package/src/lib/plugin-manager/first-party-plugins.generated.js.map +1 -1
  102. package/src/lib/plugin-manager/plugin-ai-capabilities.d.ts +19 -0
  103. package/src/lib/plugin-manager/plugin-ai-capabilities.js.map +1 -1
  104. package/src/lib/plugin-manager/plugin-domain-events.d.ts +4 -3
  105. package/src/lib/plugin-manager/plugin-domain-events.js.map +1 -1
  106. package/src/lib/plugin-manager/plugin-domain-search.d.ts +81 -0
  107. package/src/lib/plugin-manager/plugin-domain-search.js +63 -0
  108. package/src/lib/plugin-manager/plugin-domain-search.js.map +1 -0
  109. package/src/lib/plugin-manager/plugin-entitlements.d.ts +23 -5
  110. package/src/lib/plugin-manager/plugin-entitlements.js +10 -0
  111. package/src/lib/plugin-manager/plugin-entitlements.js.map +1 -1
  112. package/src/lib/plugin-manager/plugin-media-ingest.d.ts +25 -0
  113. package/src/lib/plugin-manager/plugin-media-ingest.js.map +1 -1
  114. package/src/lib/plugin-manager/plugin-search-sources.d.ts +85 -0
  115. package/src/lib/plugin-manager/plugin-search-sources.js +91 -0
  116. package/src/lib/plugin-manager/plugin-search-sources.js.map +1 -0
  117. package/src/lib/plugin-manager/plugin-services.js +6 -0
  118. package/src/lib/plugin-manager/plugin-services.js.map +1 -1
  119. package/src/lib/plugin-manager/purchase-gate-preclearance.d.ts +61 -0
  120. package/src/lib/plugin-manager/purchase-gate-preclearance.js +102 -0
  121. package/src/lib/plugin-manager/purchase-gate-preclearance.js.map +1 -0
@@ -17,7 +17,7 @@
17
17
  // serves it — so it may import neither entry barrel, for the reason
18
18
  // `usage-metering.ts` states at its own head. The specific modules underneath
19
19
  // are safe in both.
20
- import { hasAiAddon, INFRA_COGS_PER_SITE_USD, MARGIN_SCOPE_NOTE, netMarginRating, PLAN_PRICING, orgListPriceMonthlyUsd, orgMonthlyCogsUsd, orgMonthlyRevenueUsd, orgNetMonthlyRevenueUsd, orgSiteCount, pageViewsFromBandwidthGb, resolveOrgEntitlements, resolveEffectivePlan } from "./plan-entitlements.js";
20
+ import { AI_TIERS, INFRA_COGS_PER_SITE_USD, MARGIN_SCOPE_NOTE, netMarginRating, orgListPriceMonthlyUsd, orgMonthlyCogsUsd, resolveAiTier, orgMonthlyRevenueUsd, orgNetMonthlyRevenueUsd, orgSiteCount, pageViewsFromBandwidthGb, resolveOrgEntitlements, resolveEffectivePlan } from "./plan-entitlements.js";
21
21
  import { planQuotaOf } from "../plugin-manager/plugin-plan-entitlements.js";
22
22
  import { declaredMeterReading, pluginUsageBands } from "../plugin-manager/plugin-usage-axes.js";
23
23
  export { MARGIN_SCOPE_NOTE };
@@ -233,7 +233,7 @@ const finite = (value)=>{
233
233
  * field rather than assuming the meters are driving anything yet.
234
234
  */ export function orgMarginRow(input) {
235
235
  var _input_name, _input_month;
236
- var _org_subscription, _PLAN_PRICING_plan;
236
+ var _org_subscription;
237
237
  const { org, rollup } = input;
238
238
  const included = orgIncludedBands(org);
239
239
  const used = bandUsage(org, rollup);
@@ -247,11 +247,13 @@ const finite = (value)=>{
247
247
  const netRevenueUsd = orgNetMonthlyRevenueUsd(org);
248
248
  // Only when the org bills something at all — `listPriceUsd` is 0 for a
249
249
  // comped or lapsed org whatever its `seatAddons` say, and the add-on's
250
- // share of nothing is nothing. `hasAiAddon` already drops a dead
250
+ // share of nothing is nothing. `resolveAiTier` already drops a dead
251
251
  // subscription's add-on; the list-price check catches the comped one.
252
252
  const plan = resolveEffectivePlan(org);
253
253
  const customPriced = typeof (org == null ? void 0 : (_org_subscription = org.subscription) == null ? void 0 : _org_subscription.customMonthlyUsd) === 'number' && org.subscription.customMonthlyUsd > 0;
254
- const aiAddonRevenueUsd = listPriceUsd > 0 && !customPriced && hasAiAddon(org) ? finite((_PLAN_PRICING_plan = PLAN_PRICING[plan]) == null ? void 0 : _PLAN_PRICING_plan.aiAddonMonthlyUsd) : 0;
254
+ // The tier's own price (AGL-3965); `resolveAiTier` drops a dead one.
255
+ const aiTier = resolveAiTier(org);
256
+ const aiAddonRevenueUsd = listPriceUsd > 0 && !customPriced && aiTier ? finite(AI_TIERS[aiTier].monthlyUsd) : 0;
255
257
  // A margin needs revenue to be a fraction OF. Free and comped orgs have
256
258
  // none, and inventing one for them fills the top of a worst-first list with
257
259
  // customers who were never billed.
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/margin-utilization.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// Imported from BOTH graphs — the staff page (client) and the App Route that\n// serves it — so it may import neither entry barrel, for the reason\n// `usage-metering.ts` states at its own head. The specific modules underneath\n// are safe in both.\nimport type { AglynOrgBilling, OrgPlan } from '../foundation'\nimport {\n hasAiAddon,\n INFRA_COGS_PER_SITE_USD,\n MARGIN_SCOPE_NOTE,\n netMarginRating,\n PLAN_PRICING,\n orgListPriceMonthlyUsd,\n orgMonthlyCogsUsd,\n orgMonthlyRevenueUsd,\n orgNetMonthlyRevenueUsd,\n orgSiteCount,\n pageViewsFromBandwidthGb,\n resolveOrgEntitlements,\n resolveEffectivePlan,\n type DiscountMarginRating,\n type OrgCogsResult,\n type OrgUsageRollupInput,\n} from './plan-entitlements'\nimport { planQuotaOf } from '../plugin-manager/plugin-plan-entitlements'\nimport {\n declaredMeterReading,\n pluginUsageBands,\n type ResolvedPluginUsageBand,\n} from '../plugin-manager/plugin-usage-axes'\n\nexport { MARGIN_SCOPE_NOTE }\n\n/**\n * WHAT SHARE OF THE BANDS THEY BOUGHT DO CUSTOMERS ACTUALLY SPEND?\n *\n * `tier-margin-floor.spec.ts` proves what every tier costs at 100% of every\n * band, and pins the margin at 3 / 25 / 50 / 100%. Which of those four columns\n * the business is actually operating in has never been measured — and the\n * spread between them is the whole question, because on Pro the same price\n * list yields 89.3% at the 3% column and 7.1% at the 100% one.\n *\n * Everything needed to answer it was already recorded and already priced. The\n * monthly rollup carries the meters, `ORG_COGS_UNIT_RATES_USD` carries their\n * unit costs, and `orgMonthlyCogsUsd` already turns one into the other. What\n * did not exist was the DIVISION: usage over the band the plan sold, per org\n * and across the fleet.\n *\n * This module is that division, and nothing else. It runs no query, holds no\n * rate of its own and re-derives no cost — every dollar here comes back out of\n * `orgMonthlyCogsUsd`, so a surface built on it cannot come to disagree with\n * the discount guardrail about what an org costs.\n *\n * ## The three answers a band can give, and why two of them are not numbers\n *\n * A percentage needs a denominator, and two of the bands in `PLAN_ENTITLEMENTS`\n * do not supply one:\n *\n * * `UNLIMITED` — `Number.POSITIVE_INFINITY`. Enterprise carries it on\n * contacts, API requests and dataset storage. `used / Infinity` is 0, which\n * would render the platform's largest customers as its lightest users; and\n * a model that clamps to 100% instead would render them as its heaviest.\n * Both are inventions. The band is UNCAPPED, the fraction is `null`, and no\n * aggregate may average it in either direction.\n * * `0` — Free carries it on email sends, API requests and dataset storage.\n * `0 / 0` is `NaN` and `n / 0` is `Infinity`. Neither is a utilization. The\n * band admits nothing, so there is no allowance to be a fraction of, and an\n * org that spent the meter anyway did so with no included band behind it —\n * which is a cost signal in its own right, reported as\n * `usageWithNoAllowance` rather than folded into a percentile.\n *\n * The two are kept DISTINCT from each other and from `0%`. An uncapped band\n * and an exhausted one are opposite facts, and the recurring defect in this\n * repo's cost work is exactly that an absent figure reads as a zero one.\n *\n * ## What the bands are\n *\n * `meteredIncludedAllowance` sizes three of them and is the shape followed\n * here: the two PER-HOST bands are expanded by `hostLimit`, the rest are\n * org-wide as stored. `contactsPerHost` is org-wide DESPITE ITS NAME —\n * `checkContactQuota` compares it against an org-wide headcount, and\n * `tier-margin-floor.spec.ts` costs it unexpanded. Following either of those\n * and not the name is what keeps this table agreeing with both.\n */\n\n/**\n * The platform's own bands, at the positions the plugins' declared bands\n * interleave with (`order`, a multiple of 10 here) — every meter\n * `orgMonthlyCogsUsd` prices that is the platform's, plus `hosts`.\n */\nconst CORE_BANDS: ReadonlyArray<{ id: string; label: string; order: number }> = [\n { id: 'hosts', label: 'Sites', order: 10 },\n { id: 'storageGb', label: 'Media storage', order: 20 },\n { id: 'pageViews', label: 'Bandwidth (page views)', order: 30 },\n { id: 'dataStorageMb', label: 'Dataset storage', order: 50 },\n { id: 'apiRequests', label: 'API requests', order: 60 },\n { id: 'emailSends', label: 'Email sends', order: 80 },\n]\n\n/**\n * Every band the table reads, in column order: the platform's, and each one a\n * plugin declares beside the meter it contributes (`plugin-usage-axes.ts`) —\n * the AI plugin's credits, the CRM's records, the forms plugin's\n * submissions, the workflows plugin's runs.\n *\n * A plugin band sold in a unit OF cost is measured in that unit rather than\n * in the dollars the rollup stores: the AI plugin's credits are the dollars\n * over its declared unit cost, rounded up, which is the one conversion the\n * customer's own meter uses. It is also the band most worth watching —\n * provider spend is the only line item on the platform whose unit cost is\n * real money paid to a third party, and the only one that can clear the\n * $2/site floor on its own.\n */\nconst BANDS: ReadonlyArray<{\n id: string\n label: string\n order: number\n declared?: ResolvedPluginUsageBand\n}> = [\n ...CORE_BANDS,\n ...pluginUsageBands().map((declared) => ({\n id: declared.id,\n label: declared.label,\n order: declared.order,\n declared,\n })),\n].sort((a, b) => a.order - b.order)\n\n/** Every band, keyed by what it measures, in column order. */\nexport const UTILIZATION_BANDS: readonly string[] = BANDS.map((band) => band.id)\n\nexport type UtilizationBand = string\n\n/** Column headings, so the page and the aggregate name a band identically. */\nexport const UTILIZATION_BAND_LABELS: Readonly<Record<UtilizationBand, string>> =\n Object.fromEntries(BANDS.map((band) => [band.id, band.label]))\n\n/**\n * Why a band has no percentage, or that it has one.\n *\n * `uncapped` and `noAllowance` are the two non-numbers described at the head\n * of this module. They are separate values rather than one `null` because the\n * remedies are opposite: an uncapped band is a pricing exposure, an exhausted\n * one is an upgrade conversation.\n */\nexport type BandState = 'measured' | 'uncapped' | 'noAllowance'\n\nexport interface BandUtilization {\n band: UtilizationBand\n /** The org-wide included allowance. `Infinity` when uncapped, 0 when none. */\n included: number\n /** What the rollup recorded. Always a finite, non-negative number. */\n used: number\n state: BandState\n /**\n * Share of the band consumed, as a fraction of 1.\n *\n * `null` for every state but `measured`, and callers must render it as\n * neither 0 nor 1. It is NOT clamped: an org past its included band reads\n * above 1, which is the number worth seeing.\n */\n fraction: number | null\n}\n\n/** One org's realised position: what it consumed, what it cost, what it paid. */\nexport interface OrgMarginRow {\n orgId: string\n name: string | null\n plan: OrgPlan\n /** The rollup month these figures describe, or null when none has run. */\n month: string | null\n bands: Record<UtilizationBand, BandUtilization>\n /**\n * The shared cost model's answer — the same function and the same fields\n * `/api/admin/org-discount` underwrites against.\n */\n cogs: OrgCogsResult\n /** Pre-discount sticker price. */\n listPriceUsd: number\n /** List price net of any per-org discount. */\n mrrUsd: number\n /** MRR net of Stripe's processing fee — the base the margin is taken on. */\n netRevenueUsd: number\n /**\n * The AI add-on's share of `listPriceUsd` (AGL-2930) — already INSIDE\n * `listPriceUsd`, `mrrUsd` and `netRevenueUsd`, because\n * `orgListPriceMonthlyUsd` folds every purchased add-on. Named here so the\n * surface can show that the assist band's cost has revenue against it\n * rather than reading as pure margin drag; adding it again would count it\n * twice. 0 on a custom-priced deal, whose quote already folds the add-on\n * into one negotiated figure.\n */\n aiAddonRevenueUsd: number\n /**\n * `(net revenue − COGS) / net revenue`, the same arithmetic\n * `checkDiscountMargin` rates a discount on. See `MARGIN_SCOPE_NOTE`: it is\n * a contribution margin, not a profit.\n *\n * `null` when the org bills nothing. A free or comped org has no revenue to\n * take a fraction of, and reporting it as 0% or −100% would put a customer\n * who was never meant to be profitable at the top of a list whose whole\n * purpose is finding the one who was.\n */\n marginPct: number | null\n rating: DiscountMarginRating | null\n}\n\n/**\n * A rollup as this surface reads it: every field `orgMonthlyCogsUsd` prices.\n *\n * The two run counters used to be the surface's own extension of that shape\n * — recorded and banded but unpriced. Since 2026-09-07 the cost model prices\n * them at `perRun`, so the shape is the model's and nothing here is extra.\n */\nexport type UtilizationRollup = OrgUsageRollupInput\n\nconst finite = (value: unknown): number => {\n const parsed = Number(value ?? 0)\n return Number.isFinite(parsed) && parsed > 0 ? parsed : 0\n}\n\n/**\n * The org-wide included band for every metered axis, on this org's plan and\n * with this org's overrides and add-ons applied.\n *\n * `resolveOrgEntitlements` rather than `PLAN_ENTITLEMENTS[plan]`: a staff\n * entitlement override and a purchased host add-on both change what the\n * customer actually bought, and measuring consumption against the catalogue\n * row instead of the resolved one is the same class of error as measuring it\n * against a constant.\n */\nexport function orgIncludedBands(\n org: Partial<AglynOrgBilling> | null | undefined,\n): Record<UtilizationBand, number> {\n const entitlements = resolveOrgEntitlements(org)\n const hostLimit = Math.max(1, entitlements.hostLimit)\n const included: Record<UtilizationBand, number> = {\n hosts: entitlements.hostLimit,\n // Per host, expanded by the host limit — `meteredIncludedAllowance`\n // expands the per-host bands and nothing else.\n storageGb: (hostLimit * entitlements.storagePerHostMb) / 1024,\n // Bandwidth IS the page-view band, expressed in the unit customers buy.\n pageViews: pageViewsFromBandwidthGb(entitlements.bandwidthGb),\n // Org-wide, in the unit the rollup stores. `dataStorageMbPerOrg` is\n // megabytes and so is `dataStorageMb`; the GB conversion happens inside\n // `orgMonthlyCogsUsd` and must not happen twice.\n dataStorageMb: entitlements.dataStorageMbPerOrg,\n apiRequests: entitlements.apiRequestsPerMonth,\n emailSends: entitlements.emailSendsPerMonth,\n }\n // A plugin's band reads the entitlement its declaration names — absent is\n // nothing included — expanded by the host limit where it is per site.\n for (const band of pluginUsageBands()) {\n const figure = planQuotaOf(entitlements, band.entitlement)\n included[band.id] = band.perHost ? hostLimit * figure : figure\n }\n return included\n}\n\n/** One band's reading. The whole of the `UNLIMITED` and zero-band rule. */\nexport function bandUtilization(\n band: UtilizationBand,\n used: number,\n included: number,\n): BandUtilization {\n const usedSafe = Number.isFinite(used) && used > 0 ? used : 0\n if (!Number.isFinite(included)) {\n return { band, included: Number.POSITIVE_INFINITY, used: usedSafe, state: 'uncapped', fraction: null }\n }\n if (!(included > 0)) {\n return { band, included: 0, used: usedSafe, state: 'noAllowance', fraction: null }\n }\n return { band, included, used: usedSafe, state: 'measured', fraction: usedSafe / included }\n}\n\n/**\n * What the rollup recorded, per band.\n *\n * `hosts` comes from the ORG rather than the rollup: `orgSiteCount` is what\n * the cost floor is charged on and what `checkDiscountMargin` counts, and a\n * site added since the cron last ran is a site the org has. The rollup's own\n * `hostCount` is a snapshot of the same thing at cron time.\n */\nfunction bandUsage(\n org: Partial<AglynOrgBilling> | null | undefined,\n rollup: UtilizationRollup | null | undefined,\n): Record<UtilizationBand, number> {\n const used: Record<UtilizationBand, number> = {\n hosts: orgSiteCount(org),\n storageGb: finite(rollup?.storageGb),\n pageViews: finite(rollup?.pageViews),\n dataStorageMb: finite(rollup?.dataStorageMb),\n apiRequests: finite(rollup?.apiRequests),\n emailSends: finite(rollup?.emailSends),\n }\n for (const band of pluginUsageBands()) {\n const reading = declaredMeterReading(rollup, band)\n // Dollars on the rollup, a unit of cost on the band: rounded UP, so spend\n // already made never reads as less of the band.\n used[band.id] =\n band.unitCostUsd && reading > 0\n ? Math.ceil(reading / band.unitCostUsd)\n : reading\n }\n return used\n}\n\nexport interface OrgMarginInput {\n orgId: string\n name?: string | null\n /** The merged `{...orgDoc, ...billingDoc}` every revenue helper reads. */\n org: Partial<AglynOrgBilling> | null | undefined\n rollup: UtilizationRollup | null | undefined\n month?: string | null\n}\n\n/**\n * One org's realised utilization, cost and margin.\n *\n * The cost is `orgMonthlyCogsUsd`'s, unmodified, floor included — the same\n * `max(measured, $2 × sites)` the guardrail applies. On production today that\n * floor is what every org's figure is, which is worth reading off the `basis`\n * field rather than assuming the meters are driving anything yet.\n */\nexport function orgMarginRow(input: OrgMarginInput): OrgMarginRow {\n const { org, rollup } = input\n const included = orgIncludedBands(org)\n const used = bandUsage(org, rollup)\n const bands = Object.fromEntries(\n UTILIZATION_BANDS.map((band) => [band, bandUtilization(band, used[band], included[band])]),\n ) as Record<UtilizationBand, BandUtilization>\n\n const cogs = orgMonthlyCogsUsd(rollup, orgSiteCount(org))\n const listPriceUsd = orgListPriceMonthlyUsd(org)\n const mrrUsd = orgMonthlyRevenueUsd(org)\n const netRevenueUsd = orgNetMonthlyRevenueUsd(org)\n // Only when the org bills something at all — `listPriceUsd` is 0 for a\n // comped or lapsed org whatever its `seatAddons` say, and the add-on's\n // share of nothing is nothing. `hasAiAddon` already drops a dead\n // subscription's add-on; the list-price check catches the comped one.\n const plan = resolveEffectivePlan(org)\n const customPriced =\n typeof org?.subscription?.customMonthlyUsd === 'number' &&\n org.subscription.customMonthlyUsd > 0\n const aiAddonRevenueUsd =\n listPriceUsd > 0 && !customPriced && hasAiAddon(org)\n ? finite(PLAN_PRICING[plan]?.aiAddonMonthlyUsd)\n : 0\n // A margin needs revenue to be a fraction OF. Free and comped orgs have\n // none, and inventing one for them fills the top of a worst-first list with\n // customers who were never billed.\n const marginPct =\n netRevenueUsd > 0\n ? Math.round(((netRevenueUsd - cogs.cogsUsd) / netRevenueUsd) * 10000) / 10000\n : null\n\n return {\n orgId: input.orgId,\n name: input.name ?? null,\n plan,\n month: input.month ?? null,\n bands,\n cogs,\n listPriceUsd,\n mrrUsd,\n netRevenueUsd,\n aiAddonRevenueUsd,\n marginPct,\n rating: marginPct === null ? null : netMarginRating(marginPct),\n }\n}\n\n/**\n * Nearest-rank percentile over a sample sorted ascending.\n *\n * `null` for an empty sample. A median of nothing is not zero, and the fleet\n * view exists to answer a question that has no answer yet for most bands.\n */\nexport function percentile(sorted: readonly number[], p: number): number | null {\n if (!sorted.length) return null\n const rank = Math.ceil(p * sorted.length)\n return sorted[Math.min(sorted.length - 1, Math.max(0, rank - 1))]\n}\n\nexport interface BandDistribution {\n band: UtilizationBand\n /**\n * Orgs a percentage EXISTS for — a finite, positive band. Every percentile\n * below is over this sample and no other.\n */\n counted: number\n /** Excluded because the band is `UNLIMITED`. Not 0%, not 100%: no answer. */\n excludedUncapped: number\n /** Excluded because the plan includes none of this meter. */\n excludedNoAllowance: number\n /**\n * Orgs with no included allowance that spent the meter anyway — cost with no\n * band behind it. Excluded from the percentiles, reported on its own.\n */\n usageWithNoAllowance: number\n p50: number | null\n p25: number | null\n p75: number | null\n p90: number | null\n max: number | null\n /** Orgs at or past 100% of the band, out of `counted`. */\n overBand: number\n}\n\nexport interface FleetUtilization {\n /** Orgs folded in — every row handed to it, whatever its plan. */\n orgs: number\n /** Of those, how many had a usage rollup at all. */\n withRollup: number\n distributions: BandDistribution[]\n /** Median contribution margin across orgs that bill something. */\n medianMarginPct: number | null\n /** Orgs billing something whose margin sits under `NET_MARGIN_FLOOR_PCT`. */\n orgsUnderFloor: number\n /** Orgs billing something whose COGS exceeds their net revenue. */\n orgsUnderwater: number\n /** Total measured COGS and net revenue across the fold, in USD. */\n totalCogsUsd: number\n totalNetRevenueUsd: number\n /** Of `totalNetRevenueUsd`'s gross, how much the AI add-on brought in. */\n totalAiAddonRevenueUsd: number\n}\n\n/**\n * The fleet answer: the median and spread of utilization per band.\n *\n * This is the figure every margin projection has been assuming. It is folded\n * over whatever rows the caller read — see the route's truncation disclosure,\n * because a distribution over a partial fleet is a distribution over a partial\n * fleet and must say so.\n *\n * An org WITHOUT a rollup still contributes its `hosts` band (the org doc\n * knows its own sites) and is excluded from the other seven by having no\n * measurement, not by being scored at zero. `withRollup` is what says how much\n * of the fold is real.\n */\nexport function fleetUtilization(rows: readonly OrgMarginRow[]): FleetUtilization {\n const distributions = UTILIZATION_BANDS.map<BandDistribution>((band) => {\n const sample: number[] = []\n let excludedUncapped = 0\n let excludedNoAllowance = 0\n let usageWithNoAllowance = 0\n for (const row of rows) {\n const reading = row.bands[band]\n if (!reading) continue\n // A band the cron has never measured contributes nothing rather than a\n // zero: `hosts` is known without a rollup, the seven meters are not.\n if (band !== 'hosts' && row.month === null) continue\n if (reading.state === 'uncapped') {\n excludedUncapped += 1\n continue\n }\n if (reading.state === 'noAllowance') {\n excludedNoAllowance += 1\n if (reading.used > 0) usageWithNoAllowance += 1\n continue\n }\n sample.push(reading.fraction as number)\n }\n sample.sort((a, b) => a - b)\n return {\n band,\n counted: sample.length,\n excludedUncapped,\n excludedNoAllowance,\n usageWithNoAllowance,\n p50: percentile(sample, 0.5),\n p25: percentile(sample, 0.25),\n p75: percentile(sample, 0.75),\n p90: percentile(sample, 0.9),\n max: sample.length ? sample[sample.length - 1] : null,\n overBand: sample.filter((fraction) => fraction >= 1).length,\n }\n })\n\n const margins = rows\n .map((row) => row.marginPct)\n .filter((margin): margin is number => margin !== null)\n .sort((a, b) => a - b)\n\n return {\n orgs: rows.length,\n withRollup: rows.filter((row) => row.month !== null).length,\n distributions,\n medianMarginPct: percentile(margins, 0.5),\n orgsUnderFloor: margins.filter((margin) => netMarginRating(margin) !== 'ok').length,\n orgsUnderwater: margins.filter((margin) => margin < 0).length,\n totalCogsUsd: Math.round(rows.reduce((sum, row) => sum + row.cogs.cogsUsd, 0) * 100) / 100,\n totalNetRevenueUsd:\n Math.round(rows.reduce((sum, row) => sum + row.netRevenueUsd, 0) * 100) / 100,\n totalAiAddonRevenueUsd:\n Math.round(\n rows.reduce((sum, row) => sum + (row.aiAddonRevenueUsd ?? 0), 0) * 100,\n ) / 100,\n }\n}\n\n/**\n * Worst margin first — the ordering the surface exists for.\n *\n * An org with no revenue has no margin and sorts to the END regardless. It\n * cannot be the unprofitable customer this list is looking for, and putting\n * the free tier at the top would bury the one org that is.\n */\nexport function byWorstMargin(a: OrgMarginRow, b: OrgMarginRow): number {\n if (a.marginPct === null && b.marginPct === null) return b.cogs.cogsUsd - a.cogs.cogsUsd\n if (a.marginPct === null) return 1\n if (b.marginPct === null) return -1\n return a.marginPct - b.marginPct || b.cogs.cogsUsd - a.cogs.cogsUsd\n}\n\n/** Re-exported so the page states the floor it is colouring against. */\nexport { INFRA_COGS_PER_SITE_USD }\n"],"names":["hasAiAddon","INFRA_COGS_PER_SITE_USD","MARGIN_SCOPE_NOTE","netMarginRating","PLAN_PRICING","orgListPriceMonthlyUsd","orgMonthlyCogsUsd","orgMonthlyRevenueUsd","orgNetMonthlyRevenueUsd","orgSiteCount","pageViewsFromBandwidthGb","resolveOrgEntitlements","resolveEffectivePlan","planQuotaOf","declaredMeterReading","pluginUsageBands","CORE_BANDS","id","label","order","BANDS","map","declared","sort","a","b","UTILIZATION_BANDS","band","UTILIZATION_BAND_LABELS","Object","fromEntries","finite","value","parsed","Number","isFinite","orgIncludedBands","org","entitlements","hostLimit","Math","max","included","hosts","storageGb","storagePerHostMb","pageViews","bandwidthGb","dataStorageMb","dataStorageMbPerOrg","apiRequests","apiRequestsPerMonth","emailSends","emailSendsPerMonth","figure","entitlement","perHost","bandUtilization","used","usedSafe","POSITIVE_INFINITY","state","fraction","bandUsage","rollup","reading","unitCostUsd","ceil","orgMarginRow","input","bands","cogs","listPriceUsd","mrrUsd","netRevenueUsd","plan","customPriced","subscription","customMonthlyUsd","aiAddonRevenueUsd","aiAddonMonthlyUsd","marginPct","round","cogsUsd","orgId","name","month","rating","percentile","sorted","p","length","rank","min","fleetUtilization","rows","distributions","sample","excludedUncapped","excludedNoAllowance","usageWithNoAllowance","row","push","counted","p50","p25","p75","p90","overBand","filter","margins","margin","orgs","withRollup","medianMarginPct","orgsUnderFloor","orgsUnderwater","totalCogsUsd","reduce","sum","totalNetRevenueUsd","totalAiAddonRevenueUsd","byWorstMargin"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/margin-utilization.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// Imported from BOTH graphs — the staff page (client) and the App Route that\n// serves it — so it may import neither entry barrel, for the reason\n// `usage-metering.ts` states at its own head. The specific modules underneath\n// are safe in both.\nimport type { AglynOrgBilling, OrgPlan } from '../foundation'\nimport {\n AI_TIERS,\n INFRA_COGS_PER_SITE_USD,\n MARGIN_SCOPE_NOTE,\n netMarginRating,\n orgListPriceMonthlyUsd,\n orgMonthlyCogsUsd,\n resolveAiTier,\n orgMonthlyRevenueUsd,\n orgNetMonthlyRevenueUsd,\n orgSiteCount,\n pageViewsFromBandwidthGb,\n resolveOrgEntitlements,\n resolveEffectivePlan,\n type DiscountMarginRating,\n type OrgCogsResult,\n type OrgUsageRollupInput,\n} from './plan-entitlements'\nimport { planQuotaOf } from '../plugin-manager/plugin-plan-entitlements'\nimport {\n declaredMeterReading,\n pluginUsageBands,\n type ResolvedPluginUsageBand,\n} from '../plugin-manager/plugin-usage-axes'\n\nexport { MARGIN_SCOPE_NOTE }\n\n/**\n * WHAT SHARE OF THE BANDS THEY BOUGHT DO CUSTOMERS ACTUALLY SPEND?\n *\n * `tier-margin-floor.spec.ts` proves what every tier costs at 100% of every\n * band, and pins the margin at 3 / 25 / 50 / 100%. Which of those four columns\n * the business is actually operating in has never been measured — and the\n * spread between them is the whole question, because on Pro the same price\n * list yields 89.3% at the 3% column and 7.1% at the 100% one.\n *\n * Everything needed to answer it was already recorded and already priced. The\n * monthly rollup carries the meters, `ORG_COGS_UNIT_RATES_USD` carries their\n * unit costs, and `orgMonthlyCogsUsd` already turns one into the other. What\n * did not exist was the DIVISION: usage over the band the plan sold, per org\n * and across the fleet.\n *\n * This module is that division, and nothing else. It runs no query, holds no\n * rate of its own and re-derives no cost — every dollar here comes back out of\n * `orgMonthlyCogsUsd`, so a surface built on it cannot come to disagree with\n * the discount guardrail about what an org costs.\n *\n * ## The three answers a band can give, and why two of them are not numbers\n *\n * A percentage needs a denominator, and two of the bands in `PLAN_ENTITLEMENTS`\n * do not supply one:\n *\n * * `UNLIMITED` — `Number.POSITIVE_INFINITY`. Enterprise carries it on\n * contacts, API requests and dataset storage. `used / Infinity` is 0, which\n * would render the platform's largest customers as its lightest users; and\n * a model that clamps to 100% instead would render them as its heaviest.\n * Both are inventions. The band is UNCAPPED, the fraction is `null`, and no\n * aggregate may average it in either direction.\n * * `0` — Free carries it on email sends, API requests and dataset storage.\n * `0 / 0` is `NaN` and `n / 0` is `Infinity`. Neither is a utilization. The\n * band admits nothing, so there is no allowance to be a fraction of, and an\n * org that spent the meter anyway did so with no included band behind it —\n * which is a cost signal in its own right, reported as\n * `usageWithNoAllowance` rather than folded into a percentile.\n *\n * The two are kept DISTINCT from each other and from `0%`. An uncapped band\n * and an exhausted one are opposite facts, and the recurring defect in this\n * repo's cost work is exactly that an absent figure reads as a zero one.\n *\n * ## What the bands are\n *\n * `meteredIncludedAllowance` sizes three of them and is the shape followed\n * here: the two PER-HOST bands are expanded by `hostLimit`, the rest are\n * org-wide as stored. `contactsPerHost` is org-wide DESPITE ITS NAME —\n * `checkContactQuota` compares it against an org-wide headcount, and\n * `tier-margin-floor.spec.ts` costs it unexpanded. Following either of those\n * and not the name is what keeps this table agreeing with both.\n */\n\n/**\n * The platform's own bands, at the positions the plugins' declared bands\n * interleave with (`order`, a multiple of 10 here) — every meter\n * `orgMonthlyCogsUsd` prices that is the platform's, plus `hosts`.\n */\nconst CORE_BANDS: ReadonlyArray<{ id: string; label: string; order: number }> = [\n { id: 'hosts', label: 'Sites', order: 10 },\n { id: 'storageGb', label: 'Media storage', order: 20 },\n { id: 'pageViews', label: 'Bandwidth (page views)', order: 30 },\n { id: 'dataStorageMb', label: 'Dataset storage', order: 50 },\n { id: 'apiRequests', label: 'API requests', order: 60 },\n { id: 'emailSends', label: 'Email sends', order: 80 },\n]\n\n/**\n * Every band the table reads, in column order: the platform's, and each one a\n * plugin declares beside the meter it contributes (`plugin-usage-axes.ts`) —\n * the AI plugin's credits, the CRM's records, the forms plugin's\n * submissions, the workflows plugin's runs.\n *\n * A plugin band sold in a unit OF cost is measured in that unit rather than\n * in the dollars the rollup stores: the AI plugin's credits are the dollars\n * over its declared unit cost, rounded up, which is the one conversion the\n * customer's own meter uses. It is also the band most worth watching —\n * provider spend is the only line item on the platform whose unit cost is\n * real money paid to a third party, and the only one that can clear the\n * $2/site floor on its own.\n */\nconst BANDS: ReadonlyArray<{\n id: string\n label: string\n order: number\n declared?: ResolvedPluginUsageBand\n}> = [\n ...CORE_BANDS,\n ...pluginUsageBands().map((declared) => ({\n id: declared.id,\n label: declared.label,\n order: declared.order,\n declared,\n })),\n].sort((a, b) => a.order - b.order)\n\n/** Every band, keyed by what it measures, in column order. */\nexport const UTILIZATION_BANDS: readonly string[] = BANDS.map((band) => band.id)\n\nexport type UtilizationBand = string\n\n/** Column headings, so the page and the aggregate name a band identically. */\nexport const UTILIZATION_BAND_LABELS: Readonly<Record<UtilizationBand, string>> =\n Object.fromEntries(BANDS.map((band) => [band.id, band.label]))\n\n/**\n * Why a band has no percentage, or that it has one.\n *\n * `uncapped` and `noAllowance` are the two non-numbers described at the head\n * of this module. They are separate values rather than one `null` because the\n * remedies are opposite: an uncapped band is a pricing exposure, an exhausted\n * one is an upgrade conversation.\n */\nexport type BandState = 'measured' | 'uncapped' | 'noAllowance'\n\nexport interface BandUtilization {\n band: UtilizationBand\n /** The org-wide included allowance. `Infinity` when uncapped, 0 when none. */\n included: number\n /** What the rollup recorded. Always a finite, non-negative number. */\n used: number\n state: BandState\n /**\n * Share of the band consumed, as a fraction of 1.\n *\n * `null` for every state but `measured`, and callers must render it as\n * neither 0 nor 1. It is NOT clamped: an org past its included band reads\n * above 1, which is the number worth seeing.\n */\n fraction: number | null\n}\n\n/** One org's realised position: what it consumed, what it cost, what it paid. */\nexport interface OrgMarginRow {\n orgId: string\n name: string | null\n plan: OrgPlan\n /** The rollup month these figures describe, or null when none has run. */\n month: string | null\n bands: Record<UtilizationBand, BandUtilization>\n /**\n * The shared cost model's answer — the same function and the same fields\n * `/api/admin/org-discount` underwrites against.\n */\n cogs: OrgCogsResult\n /** Pre-discount sticker price. */\n listPriceUsd: number\n /** List price net of any per-org discount. */\n mrrUsd: number\n /** MRR net of Stripe's processing fee — the base the margin is taken on. */\n netRevenueUsd: number\n /**\n * The AI add-on's share of `listPriceUsd` (AGL-2930) — already INSIDE\n * `listPriceUsd`, `mrrUsd` and `netRevenueUsd`, because\n * `orgListPriceMonthlyUsd` folds every purchased add-on. Named here so the\n * surface can show that the assist band's cost has revenue against it\n * rather than reading as pure margin drag; adding it again would count it\n * twice. 0 on a custom-priced deal, whose quote already folds the add-on\n * into one negotiated figure.\n */\n aiAddonRevenueUsd: number\n /**\n * `(net revenue − COGS) / net revenue`, the same arithmetic\n * `checkDiscountMargin` rates a discount on. See `MARGIN_SCOPE_NOTE`: it is\n * a contribution margin, not a profit.\n *\n * `null` when the org bills nothing. A free or comped org has no revenue to\n * take a fraction of, and reporting it as 0% or −100% would put a customer\n * who was never meant to be profitable at the top of a list whose whole\n * purpose is finding the one who was.\n */\n marginPct: number | null\n rating: DiscountMarginRating | null\n}\n\n/**\n * A rollup as this surface reads it: every field `orgMonthlyCogsUsd` prices.\n *\n * The two run counters used to be the surface's own extension of that shape\n * — recorded and banded but unpriced. Since 2026-09-07 the cost model prices\n * them at `perRun`, so the shape is the model's and nothing here is extra.\n */\nexport type UtilizationRollup = OrgUsageRollupInput\n\nconst finite = (value: unknown): number => {\n const parsed = Number(value ?? 0)\n return Number.isFinite(parsed) && parsed > 0 ? parsed : 0\n}\n\n/**\n * The org-wide included band for every metered axis, on this org's plan and\n * with this org's overrides and add-ons applied.\n *\n * `resolveOrgEntitlements` rather than `PLAN_ENTITLEMENTS[plan]`: a staff\n * entitlement override and a purchased host add-on both change what the\n * customer actually bought, and measuring consumption against the catalogue\n * row instead of the resolved one is the same class of error as measuring it\n * against a constant.\n */\nexport function orgIncludedBands(\n org: Partial<AglynOrgBilling> | null | undefined,\n): Record<UtilizationBand, number> {\n const entitlements = resolveOrgEntitlements(org)\n const hostLimit = Math.max(1, entitlements.hostLimit)\n const included: Record<UtilizationBand, number> = {\n hosts: entitlements.hostLimit,\n // Per host, expanded by the host limit — `meteredIncludedAllowance`\n // expands the per-host bands and nothing else.\n storageGb: (hostLimit * entitlements.storagePerHostMb) / 1024,\n // Bandwidth IS the page-view band, expressed in the unit customers buy.\n pageViews: pageViewsFromBandwidthGb(entitlements.bandwidthGb),\n // Org-wide, in the unit the rollup stores. `dataStorageMbPerOrg` is\n // megabytes and so is `dataStorageMb`; the GB conversion happens inside\n // `orgMonthlyCogsUsd` and must not happen twice.\n dataStorageMb: entitlements.dataStorageMbPerOrg,\n apiRequests: entitlements.apiRequestsPerMonth,\n emailSends: entitlements.emailSendsPerMonth,\n }\n // A plugin's band reads the entitlement its declaration names — absent is\n // nothing included — expanded by the host limit where it is per site.\n for (const band of pluginUsageBands()) {\n const figure = planQuotaOf(entitlements, band.entitlement)\n included[band.id] = band.perHost ? hostLimit * figure : figure\n }\n return included\n}\n\n/** One band's reading. The whole of the `UNLIMITED` and zero-band rule. */\nexport function bandUtilization(\n band: UtilizationBand,\n used: number,\n included: number,\n): BandUtilization {\n const usedSafe = Number.isFinite(used) && used > 0 ? used : 0\n if (!Number.isFinite(included)) {\n return { band, included: Number.POSITIVE_INFINITY, used: usedSafe, state: 'uncapped', fraction: null }\n }\n if (!(included > 0)) {\n return { band, included: 0, used: usedSafe, state: 'noAllowance', fraction: null }\n }\n return { band, included, used: usedSafe, state: 'measured', fraction: usedSafe / included }\n}\n\n/**\n * What the rollup recorded, per band.\n *\n * `hosts` comes from the ORG rather than the rollup: `orgSiteCount` is what\n * the cost floor is charged on and what `checkDiscountMargin` counts, and a\n * site added since the cron last ran is a site the org has. The rollup's own\n * `hostCount` is a snapshot of the same thing at cron time.\n */\nfunction bandUsage(\n org: Partial<AglynOrgBilling> | null | undefined,\n rollup: UtilizationRollup | null | undefined,\n): Record<UtilizationBand, number> {\n const used: Record<UtilizationBand, number> = {\n hosts: orgSiteCount(org),\n storageGb: finite(rollup?.storageGb),\n pageViews: finite(rollup?.pageViews),\n dataStorageMb: finite(rollup?.dataStorageMb),\n apiRequests: finite(rollup?.apiRequests),\n emailSends: finite(rollup?.emailSends),\n }\n for (const band of pluginUsageBands()) {\n const reading = declaredMeterReading(rollup, band)\n // Dollars on the rollup, a unit of cost on the band: rounded UP, so spend\n // already made never reads as less of the band.\n used[band.id] =\n band.unitCostUsd && reading > 0\n ? Math.ceil(reading / band.unitCostUsd)\n : reading\n }\n return used\n}\n\nexport interface OrgMarginInput {\n orgId: string\n name?: string | null\n /** The merged `{...orgDoc, ...billingDoc}` every revenue helper reads. */\n org: Partial<AglynOrgBilling> | null | undefined\n rollup: UtilizationRollup | null | undefined\n month?: string | null\n}\n\n/**\n * One org's realised utilization, cost and margin.\n *\n * The cost is `orgMonthlyCogsUsd`'s, unmodified, floor included — the same\n * `max(measured, $2 × sites)` the guardrail applies. On production today that\n * floor is what every org's figure is, which is worth reading off the `basis`\n * field rather than assuming the meters are driving anything yet.\n */\nexport function orgMarginRow(input: OrgMarginInput): OrgMarginRow {\n const { org, rollup } = input\n const included = orgIncludedBands(org)\n const used = bandUsage(org, rollup)\n const bands = Object.fromEntries(\n UTILIZATION_BANDS.map((band) => [band, bandUtilization(band, used[band], included[band])]),\n ) as Record<UtilizationBand, BandUtilization>\n\n const cogs = orgMonthlyCogsUsd(rollup, orgSiteCount(org))\n const listPriceUsd = orgListPriceMonthlyUsd(org)\n const mrrUsd = orgMonthlyRevenueUsd(org)\n const netRevenueUsd = orgNetMonthlyRevenueUsd(org)\n // Only when the org bills something at all — `listPriceUsd` is 0 for a\n // comped or lapsed org whatever its `seatAddons` say, and the add-on's\n // share of nothing is nothing. `resolveAiTier` already drops a dead\n // subscription's add-on; the list-price check catches the comped one.\n const plan = resolveEffectivePlan(org)\n const customPriced =\n typeof org?.subscription?.customMonthlyUsd === 'number' &&\n org.subscription.customMonthlyUsd > 0\n // The tier's own price (AGL-3965); `resolveAiTier` drops a dead one.\n const aiTier = resolveAiTier(org)\n const aiAddonRevenueUsd =\n listPriceUsd > 0 && !customPriced && aiTier\n ? finite(AI_TIERS[aiTier].monthlyUsd)\n : 0\n // A margin needs revenue to be a fraction OF. Free and comped orgs have\n // none, and inventing one for them fills the top of a worst-first list with\n // customers who were never billed.\n const marginPct =\n netRevenueUsd > 0\n ? Math.round(((netRevenueUsd - cogs.cogsUsd) / netRevenueUsd) * 10000) / 10000\n : null\n\n return {\n orgId: input.orgId,\n name: input.name ?? null,\n plan,\n month: input.month ?? null,\n bands,\n cogs,\n listPriceUsd,\n mrrUsd,\n netRevenueUsd,\n aiAddonRevenueUsd,\n marginPct,\n rating: marginPct === null ? null : netMarginRating(marginPct),\n }\n}\n\n/**\n * Nearest-rank percentile over a sample sorted ascending.\n *\n * `null` for an empty sample. A median of nothing is not zero, and the fleet\n * view exists to answer a question that has no answer yet for most bands.\n */\nexport function percentile(sorted: readonly number[], p: number): number | null {\n if (!sorted.length) return null\n const rank = Math.ceil(p * sorted.length)\n return sorted[Math.min(sorted.length - 1, Math.max(0, rank - 1))]\n}\n\nexport interface BandDistribution {\n band: UtilizationBand\n /**\n * Orgs a percentage EXISTS for — a finite, positive band. Every percentile\n * below is over this sample and no other.\n */\n counted: number\n /** Excluded because the band is `UNLIMITED`. Not 0%, not 100%: no answer. */\n excludedUncapped: number\n /** Excluded because the plan includes none of this meter. */\n excludedNoAllowance: number\n /**\n * Orgs with no included allowance that spent the meter anyway — cost with no\n * band behind it. Excluded from the percentiles, reported on its own.\n */\n usageWithNoAllowance: number\n p50: number | null\n p25: number | null\n p75: number | null\n p90: number | null\n max: number | null\n /** Orgs at or past 100% of the band, out of `counted`. */\n overBand: number\n}\n\nexport interface FleetUtilization {\n /** Orgs folded in — every row handed to it, whatever its plan. */\n orgs: number\n /** Of those, how many had a usage rollup at all. */\n withRollup: number\n distributions: BandDistribution[]\n /** Median contribution margin across orgs that bill something. */\n medianMarginPct: number | null\n /** Orgs billing something whose margin sits under `NET_MARGIN_FLOOR_PCT`. */\n orgsUnderFloor: number\n /** Orgs billing something whose COGS exceeds their net revenue. */\n orgsUnderwater: number\n /** Total measured COGS and net revenue across the fold, in USD. */\n totalCogsUsd: number\n totalNetRevenueUsd: number\n /** Of `totalNetRevenueUsd`'s gross, how much the AI add-on brought in. */\n totalAiAddonRevenueUsd: number\n}\n\n/**\n * The fleet answer: the median and spread of utilization per band.\n *\n * This is the figure every margin projection has been assuming. It is folded\n * over whatever rows the caller read — see the route's truncation disclosure,\n * because a distribution over a partial fleet is a distribution over a partial\n * fleet and must say so.\n *\n * An org WITHOUT a rollup still contributes its `hosts` band (the org doc\n * knows its own sites) and is excluded from the other seven by having no\n * measurement, not by being scored at zero. `withRollup` is what says how much\n * of the fold is real.\n */\nexport function fleetUtilization(rows: readonly OrgMarginRow[]): FleetUtilization {\n const distributions = UTILIZATION_BANDS.map<BandDistribution>((band) => {\n const sample: number[] = []\n let excludedUncapped = 0\n let excludedNoAllowance = 0\n let usageWithNoAllowance = 0\n for (const row of rows) {\n const reading = row.bands[band]\n if (!reading) continue\n // A band the cron has never measured contributes nothing rather than a\n // zero: `hosts` is known without a rollup, the seven meters are not.\n if (band !== 'hosts' && row.month === null) continue\n if (reading.state === 'uncapped') {\n excludedUncapped += 1\n continue\n }\n if (reading.state === 'noAllowance') {\n excludedNoAllowance += 1\n if (reading.used > 0) usageWithNoAllowance += 1\n continue\n }\n sample.push(reading.fraction as number)\n }\n sample.sort((a, b) => a - b)\n return {\n band,\n counted: sample.length,\n excludedUncapped,\n excludedNoAllowance,\n usageWithNoAllowance,\n p50: percentile(sample, 0.5),\n p25: percentile(sample, 0.25),\n p75: percentile(sample, 0.75),\n p90: percentile(sample, 0.9),\n max: sample.length ? sample[sample.length - 1] : null,\n overBand: sample.filter((fraction) => fraction >= 1).length,\n }\n })\n\n const margins = rows\n .map((row) => row.marginPct)\n .filter((margin): margin is number => margin !== null)\n .sort((a, b) => a - b)\n\n return {\n orgs: rows.length,\n withRollup: rows.filter((row) => row.month !== null).length,\n distributions,\n medianMarginPct: percentile(margins, 0.5),\n orgsUnderFloor: margins.filter((margin) => netMarginRating(margin) !== 'ok').length,\n orgsUnderwater: margins.filter((margin) => margin < 0).length,\n totalCogsUsd: Math.round(rows.reduce((sum, row) => sum + row.cogs.cogsUsd, 0) * 100) / 100,\n totalNetRevenueUsd:\n Math.round(rows.reduce((sum, row) => sum + row.netRevenueUsd, 0) * 100) / 100,\n totalAiAddonRevenueUsd:\n Math.round(\n rows.reduce((sum, row) => sum + (row.aiAddonRevenueUsd ?? 0), 0) * 100,\n ) / 100,\n }\n}\n\n/**\n * Worst margin first — the ordering the surface exists for.\n *\n * An org with no revenue has no margin and sorts to the END regardless. It\n * cannot be the unprofitable customer this list is looking for, and putting\n * the free tier at the top would bury the one org that is.\n */\nexport function byWorstMargin(a: OrgMarginRow, b: OrgMarginRow): number {\n if (a.marginPct === null && b.marginPct === null) return b.cogs.cogsUsd - a.cogs.cogsUsd\n if (a.marginPct === null) return 1\n if (b.marginPct === null) return -1\n return a.marginPct - b.marginPct || b.cogs.cogsUsd - a.cogs.cogsUsd\n}\n\n/** Re-exported so the page states the floor it is colouring against. */\nexport { INFRA_COGS_PER_SITE_USD }\n"],"names":["AI_TIERS","INFRA_COGS_PER_SITE_USD","MARGIN_SCOPE_NOTE","netMarginRating","orgListPriceMonthlyUsd","orgMonthlyCogsUsd","resolveAiTier","orgMonthlyRevenueUsd","orgNetMonthlyRevenueUsd","orgSiteCount","pageViewsFromBandwidthGb","resolveOrgEntitlements","resolveEffectivePlan","planQuotaOf","declaredMeterReading","pluginUsageBands","CORE_BANDS","id","label","order","BANDS","map","declared","sort","a","b","UTILIZATION_BANDS","band","UTILIZATION_BAND_LABELS","Object","fromEntries","finite","value","parsed","Number","isFinite","orgIncludedBands","org","entitlements","hostLimit","Math","max","included","hosts","storageGb","storagePerHostMb","pageViews","bandwidthGb","dataStorageMb","dataStorageMbPerOrg","apiRequests","apiRequestsPerMonth","emailSends","emailSendsPerMonth","figure","entitlement","perHost","bandUtilization","used","usedSafe","POSITIVE_INFINITY","state","fraction","bandUsage","rollup","reading","unitCostUsd","ceil","orgMarginRow","input","bands","cogs","listPriceUsd","mrrUsd","netRevenueUsd","plan","customPriced","subscription","customMonthlyUsd","aiTier","aiAddonRevenueUsd","monthlyUsd","marginPct","round","cogsUsd","orgId","name","month","rating","percentile","sorted","p","length","rank","min","fleetUtilization","rows","distributions","sample","excludedUncapped","excludedNoAllowance","usageWithNoAllowance","row","push","counted","p50","p25","p75","p90","overBand","filter","margins","margin","orgs","withRollup","medianMarginPct","orgsUnderFloor","orgsUnderwater","totalCogsUsd","reduce","sum","totalNetRevenueUsd","totalAiAddonRevenueUsd","byWorstMargin"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,6EAA6E;AAC7E,oEAAoE;AACpE,8EAA8E;AAC9E,oBAAoB;AAEpB,SACEA,QAAQ,EACRC,uBAAuB,EACvBC,iBAAiB,EACjBC,eAAe,EACfC,sBAAsB,EACtBC,iBAAiB,EACjBC,aAAa,EACbC,oBAAoB,EACpBC,uBAAuB,EACvBC,YAAY,EACZC,wBAAwB,EACxBC,sBAAsB,EACtBC,oBAAoB,QAIf,yBAAqB;AAC5B,SAASC,WAAW,QAAQ,gDAA4C;AACxE,SACEC,oBAAoB,EACpBC,gBAAgB,QAEX,yCAAqC;AAE5C,SAASb,iBAAiB,GAAE;AAE5B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAkDC,GAED;;;;CAIC,GACD,MAAMc,aAA0E;IAC9E;QAAEC,IAAI;QAASC,OAAO;QAASC,OAAO;IAAG;IACzC;QAAEF,IAAI;QAAaC,OAAO;QAAiBC,OAAO;IAAG;IACrD;QAAEF,IAAI;QAAaC,OAAO;QAA0BC,OAAO;IAAG;IAC9D;QAAEF,IAAI;QAAiBC,OAAO;QAAmBC,OAAO;IAAG;IAC3D;QAAEF,IAAI;QAAeC,OAAO;QAAgBC,OAAO;IAAG;IACtD;QAAEF,IAAI;QAAcC,OAAO;QAAeC,OAAO;IAAG;CACrD;AAED;;;;;;;;;;;;;CAaC,GACD,MAAMC,QAKD;OACAJ;OACAD,mBAAmBM,GAAG,CAAC,CAACC,WAAc,CAAA;YACvCL,IAAIK,SAASL,EAAE;YACfC,OAAOI,SAASJ,KAAK;YACrBC,OAAOG,SAASH,KAAK;YACrBG;QACF,CAAA;CACD,CAACC,IAAI,CAAC,CAACC,GAAGC,IAAMD,EAAEL,KAAK,GAAGM,EAAEN,KAAK;AAElC,4DAA4D,GAC5D,OAAO,MAAMO,oBAAuCN,MAAMC,GAAG,CAAC,CAACM,OAASA,KAAKV,EAAE,EAAC;AAIhF,4EAA4E,GAC5E,OAAO,MAAMW,0BACXC,OAAOC,WAAW,CAACV,MAAMC,GAAG,CAAC,CAACM,OAAS;QAACA,KAAKV,EAAE;QAAEU,KAAKT,KAAK;KAAC,GAAE;AAiFhE,MAAMa,SAAS,CAACC;IACd,MAAMC,SAASC,OAAOF,gBAAAA,QAAS;IAC/B,OAAOE,OAAOC,QAAQ,CAACF,WAAWA,SAAS,IAAIA,SAAS;AAC1D;AAEA;;;;;;;;;CASC,GACD,OAAO,SAASG,iBACdC,GAAgD;IAEhD,MAAMC,eAAe3B,uBAAuB0B;IAC5C,MAAME,YAAYC,KAAKC,GAAG,CAAC,GAAGH,aAAaC,SAAS;IACpD,MAAMG,WAA4C;QAChDC,OAAOL,aAAaC,SAAS;QAC7B,oEAAoE;QACpE,+CAA+C;QAC/CK,WAAW,AAACL,YAAYD,aAAaO,gBAAgB,GAAI;QACzD,wEAAwE;QACxEC,WAAWpC,yBAAyB4B,aAAaS,WAAW;QAC5D,oEAAoE;QACpE,wEAAwE;QACxE,iDAAiD;QACjDC,eAAeV,aAAaW,mBAAmB;QAC/CC,aAAaZ,aAAaa,mBAAmB;QAC7CC,YAAYd,aAAae,kBAAkB;IAC7C;IACA,0EAA0E;IAC1E,sEAAsE;IACtE,KAAK,MAAM1B,QAAQZ,mBAAoB;QACrC,MAAMuC,SAASzC,YAAYyB,cAAcX,KAAK4B,WAAW;QACzDb,QAAQ,CAACf,KAAKV,EAAE,CAAC,GAAGU,KAAK6B,OAAO,GAAGjB,YAAYe,SAASA;IAC1D;IACA,OAAOZ;AACT;AAEA,yEAAyE,GACzE,OAAO,SAASe,gBACd9B,IAAqB,EACrB+B,IAAY,EACZhB,QAAgB;IAEhB,MAAMiB,WAAWzB,OAAOC,QAAQ,CAACuB,SAASA,OAAO,IAAIA,OAAO;IAC5D,IAAI,CAACxB,OAAOC,QAAQ,CAACO,WAAW;QAC9B,OAAO;YAAEf;YAAMe,UAAUR,OAAO0B,iBAAiB;YAAEF,MAAMC;YAAUE,OAAO;YAAYC,UAAU;QAAK;IACvG;IACA,IAAI,CAAEpB,CAAAA,WAAW,CAAA,GAAI;QACnB,OAAO;YAAEf;YAAMe,UAAU;YAAGgB,MAAMC;YAAUE,OAAO;YAAeC,UAAU;QAAK;IACnF;IACA,OAAO;QAAEnC;QAAMe;QAAUgB,MAAMC;QAAUE,OAAO;QAAYC,UAAUH,WAAWjB;IAAS;AAC5F;AAEA;;;;;;;CAOC,GACD,SAASqB,UACP1B,GAAgD,EAChD2B,MAA4C;IAE5C,MAAMN,OAAwC;QAC5Cf,OAAOlC,aAAa4B;QACpBO,WAAWb,OAAOiC,0BAAAA,OAAQpB,SAAS;QACnCE,WAAWf,OAAOiC,0BAAAA,OAAQlB,SAAS;QACnCE,eAAejB,OAAOiC,0BAAAA,OAAQhB,aAAa;QAC3CE,aAAanB,OAAOiC,0BAAAA,OAAQd,WAAW;QACvCE,YAAYrB,OAAOiC,0BAAAA,OAAQZ,UAAU;IACvC;IACA,KAAK,MAAMzB,QAAQZ,mBAAoB;QACrC,MAAMkD,UAAUnD,qBAAqBkD,QAAQrC;QAC7C,0EAA0E;QAC1E,gDAAgD;QAChD+B,IAAI,CAAC/B,KAAKV,EAAE,CAAC,GACXU,KAAKuC,WAAW,IAAID,UAAU,IAC1BzB,KAAK2B,IAAI,CAACF,UAAUtC,KAAKuC,WAAW,IACpCD;IACR;IACA,OAAOP;AACT;AAWA;;;;;;;CAOC,GACD,OAAO,SAASU,aAAaC,KAAqB;QAoCxCA,aAECA;QApBAhC;IAjBT,MAAM,EAAEA,GAAG,EAAE2B,MAAM,EAAE,GAAGK;IACxB,MAAM3B,WAAWN,iBAAiBC;IAClC,MAAMqB,OAAOK,UAAU1B,KAAK2B;IAC5B,MAAMM,QAAQzC,OAAOC,WAAW,CAC9BJ,kBAAkBL,GAAG,CAAC,CAACM,OAAS;YAACA;YAAM8B,gBAAgB9B,MAAM+B,IAAI,CAAC/B,KAAK,EAAEe,QAAQ,CAACf,KAAK;SAAE;IAG3F,MAAM4C,OAAOlE,kBAAkB2D,QAAQvD,aAAa4B;IACpD,MAAMmC,eAAepE,uBAAuBiC;IAC5C,MAAMoC,SAASlE,qBAAqB8B;IACpC,MAAMqC,gBAAgBlE,wBAAwB6B;IAC9C,uEAAuE;IACvE,uEAAuE;IACvE,oEAAoE;IACpE,sEAAsE;IACtE,MAAMsC,OAAO/D,qBAAqByB;IAClC,MAAMuC,eACJ,QAAOvC,wBAAAA,oBAAAA,IAAKwC,YAAY,qBAAjBxC,kBAAmByC,gBAAgB,MAAK,YAC/CzC,IAAIwC,YAAY,CAACC,gBAAgB,GAAG;IACtC,qEAAqE;IACrE,MAAMC,SAASzE,cAAc+B;IAC7B,MAAM2C,oBACJR,eAAe,KAAK,CAACI,gBAAgBG,SACjChD,OAAO/B,QAAQ,CAAC+E,OAAO,CAACE,UAAU,IAClC;IACN,wEAAwE;IACxE,4EAA4E;IAC5E,mCAAmC;IACnC,MAAMC,YACJR,gBAAgB,IACZlC,KAAK2C,KAAK,CAAC,AAAET,CAAAA,gBAAgBH,KAAKa,OAAO,AAAD,IAAKV,gBAAiB,SAAS,QACvE;IAEN,OAAO;QACLW,OAAOhB,MAAMgB,KAAK;QAClBC,IAAI,GAAEjB,cAAAA,MAAMiB,IAAI,YAAVjB,cAAc;QACpBM;QACAY,KAAK,GAAElB,eAAAA,MAAMkB,KAAK,YAAXlB,eAAe;QACtBC;QACAC;QACAC;QACAC;QACAC;QACAM;QACAE;QACAM,QAAQN,cAAc,OAAO,OAAO/E,gBAAgB+E;IACtD;AACF;AAEA;;;;;CAKC,GACD,OAAO,SAASO,WAAWC,MAAyB,EAAEC,CAAS;IAC7D,IAAI,CAACD,OAAOE,MAAM,EAAE,OAAO;IAC3B,MAAMC,OAAOrD,KAAK2B,IAAI,CAACwB,IAAID,OAAOE,MAAM;IACxC,OAAOF,MAAM,CAAClD,KAAKsD,GAAG,CAACJ,OAAOE,MAAM,GAAG,GAAGpD,KAAKC,GAAG,CAAC,GAAGoD,OAAO,IAAI;AACnE;AA8CA;;;;;;;;;;;;CAYC,GACD,OAAO,SAASE,iBAAiBC,IAA6B;IAC5D,MAAMC,gBAAgBvE,kBAAkBL,GAAG,CAAmB,CAACM;QAC7D,MAAMuE,SAAmB,EAAE;QAC3B,IAAIC,mBAAmB;QACvB,IAAIC,sBAAsB;QAC1B,IAAIC,uBAAuB;QAC3B,KAAK,MAAMC,OAAON,KAAM;YACtB,MAAM/B,UAAUqC,IAAIhC,KAAK,CAAC3C,KAAK;YAC/B,IAAI,CAACsC,SAAS;YACd,uEAAuE;YACvE,qEAAqE;YACrE,IAAItC,SAAS,WAAW2E,IAAIf,KAAK,KAAK,MAAM;YAC5C,IAAItB,QAAQJ,KAAK,KAAK,YAAY;gBAChCsC,oBAAoB;gBACpB;YACF;YACA,IAAIlC,QAAQJ,KAAK,KAAK,eAAe;gBACnCuC,uBAAuB;gBACvB,IAAInC,QAAQP,IAAI,GAAG,GAAG2C,wBAAwB;gBAC9C;YACF;YACAH,OAAOK,IAAI,CAACtC,QAAQH,QAAQ;QAC9B;QACAoC,OAAO3E,IAAI,CAAC,CAACC,GAAGC,IAAMD,IAAIC;QAC1B,OAAO;YACLE;YACA6E,SAASN,OAAON,MAAM;YACtBO;YACAC;YACAC;YACAI,KAAKhB,WAAWS,QAAQ;YACxBQ,KAAKjB,WAAWS,QAAQ;YACxBS,KAAKlB,WAAWS,QAAQ;YACxBU,KAAKnB,WAAWS,QAAQ;YACxBzD,KAAKyD,OAAON,MAAM,GAAGM,MAAM,CAACA,OAAON,MAAM,GAAG,EAAE,GAAG;YACjDiB,UAAUX,OAAOY,MAAM,CAAC,CAAChD,WAAaA,YAAY,GAAG8B,MAAM;QAC7D;IACF;IAEA,MAAMmB,UAAUf,KACb3E,GAAG,CAAC,CAACiF,MAAQA,IAAIpB,SAAS,EAC1B4B,MAAM,CAAC,CAACE,SAA6BA,WAAW,MAChDzF,IAAI,CAAC,CAACC,GAAGC,IAAMD,IAAIC;IAEtB,OAAO;QACLwF,MAAMjB,KAAKJ,MAAM;QACjBsB,YAAYlB,KAAKc,MAAM,CAAC,CAACR,MAAQA,IAAIf,KAAK,KAAK,MAAMK,MAAM;QAC3DK;QACAkB,iBAAiB1B,WAAWsB,SAAS;QACrCK,gBAAgBL,QAAQD,MAAM,CAAC,CAACE,SAAW7G,gBAAgB6G,YAAY,MAAMpB,MAAM;QACnFyB,gBAAgBN,QAAQD,MAAM,CAAC,CAACE,SAAWA,SAAS,GAAGpB,MAAM;QAC7D0B,cAAc9E,KAAK2C,KAAK,CAACa,KAAKuB,MAAM,CAAC,CAACC,KAAKlB,MAAQkB,MAAMlB,IAAI/B,IAAI,CAACa,OAAO,EAAE,KAAK,OAAO;QACvFqC,oBACEjF,KAAK2C,KAAK,CAACa,KAAKuB,MAAM,CAAC,CAACC,KAAKlB,MAAQkB,MAAMlB,IAAI5B,aAAa,EAAE,KAAK,OAAO;QAC5EgD,wBACElF,KAAK2C,KAAK,CACRa,KAAKuB,MAAM,CAAC,CAACC,KAAKlB;gBAAeA;mBAAPkB,QAAOlB,yBAAAA,IAAItB,iBAAiB,YAArBsB,yBAAyB;WAAI,KAAK,OACjE;IACR;AACF;AAEA;;;;;;CAMC,GACD,OAAO,SAASqB,cAAcnG,CAAe,EAAEC,CAAe;IAC5D,IAAID,EAAE0D,SAAS,KAAK,QAAQzD,EAAEyD,SAAS,KAAK,MAAM,OAAOzD,EAAE8C,IAAI,CAACa,OAAO,GAAG5D,EAAE+C,IAAI,CAACa,OAAO;IACxF,IAAI5D,EAAE0D,SAAS,KAAK,MAAM,OAAO;IACjC,IAAIzD,EAAEyD,SAAS,KAAK,MAAM,OAAO,CAAC;IAClC,OAAO1D,EAAE0D,SAAS,GAAGzD,EAAEyD,SAAS,IAAIzD,EAAE8C,IAAI,CAACa,OAAO,GAAG5D,EAAE+C,IAAI,CAACa,OAAO;AACrE;AAEA,sEAAsE,GACtE,SAASnF,uBAAuB,GAAE"}
@@ -125,6 +125,32 @@ export declare function mediaScopedSearchClause(search: readonly string[]): List
125
125
  * (`mediaScopedSearchClause`), and the library says so.
126
126
  */
127
127
  export declare function mediaQuery(input: MediaQueryInput): MediaQuery;
128
+ /** One file the search box names by its ID, rather than words of a name. */
129
+ export interface MediaIdSearch {
130
+ mediaId: string;
131
+ /**
132
+ * The CDN scope a pasted link or reference names (`{hostId}`,
133
+ * `org:{orgId}` or `org:{orgId}:{hostId}`), when it names one.
134
+ */
135
+ scope?: string;
136
+ /**
137
+ * Whether the text can only be an ID: a link, a CDN path or a `media:`
138
+ * reference. The library answers it with that one file. A bare word shaped
139
+ * like an ID may also be a word of a file name, so the name search still
140
+ * runs beside it.
141
+ */
142
+ exact: boolean;
143
+ }
144
+ /**
145
+ * The file the search box names, or null when it names words of a name.
146
+ *
147
+ * Takes what people actually have in hand: the ID the File info shows, the
148
+ * address Copy URL gives (with or without the origin, a `?w=` width or a
149
+ * content hash after the ID), the CDN path a settings field stores, and the
150
+ * `media:` reference a page node stores. The library reads that one document
151
+ * by its ID, which no word index can find.
152
+ */
153
+ export declare function mediaIdSearch(search: readonly string[]): MediaIdSearch | null;
128
154
  /**
129
155
  * Every folder at or under `folderId`, the folder itself first. Walks the
130
156
  * parent links downward and never revisits one, so a cycle a stale write
@@ -15,6 +15,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
15
15
  * See the License for the specific language governing permissions and
16
16
  * limitations under the License.
17
17
  */ import { mediaSearchToken } from "./media-metadata.js";
18
+ import { mediaRefFromCdnPath, parseMediaRef } from "./media-ref.js";
18
19
  import { nameSearchNormalizers } from "./name-search.js";
19
20
  import { planListQuery } from "@aglyn/shared-util-tools/list-query/list-query-plan";
20
21
  /*
@@ -370,6 +371,37 @@ export const MEDIA_SORT_LABELS = {
370
371
  subfoldersDropped
371
372
  };
372
373
  }
374
+ /**
375
+ * The shape of a bare media ID: one CDN path segment, long enough and mixed
376
+ * enough (a digit or a capital) that an ordinary word of a file name does not
377
+ * read as one. IDs are minted as random mixed-case strings, so a real one
378
+ * nearly always qualifies; a word that slips through costs one document read
379
+ * that finds nothing.
380
+ */ const BARE_MEDIA_ID = /^(?=.*[0-9A-Z])[A-Za-z0-9_-]{8,64}$/;
381
+ /**
382
+ * The file the search box names, or null when it names words of a name.
383
+ *
384
+ * Takes what people actually have in hand: the ID the File info shows, the
385
+ * address Copy URL gives (with or without the origin, a `?w=` width or a
386
+ * content hash after the ID), the CDN path a settings field stores, and the
387
+ * `media:` reference a page node stores. The library reads that one document
388
+ * by its ID, which no word index can find.
389
+ */ export function mediaIdSearch(search) {
390
+ var _parseMediaRef;
391
+ const words = search.map((word)=>word.trim()).filter(Boolean);
392
+ if (words.length !== 1) return null;
393
+ const typed = words[0];
394
+ const ref = (_parseMediaRef = parseMediaRef(typed)) != null ? _parseMediaRef : parseMediaRef(mediaRefFromCdnPath(typed.split(/[?#]/)[0]));
395
+ if (ref) return {
396
+ mediaId: ref.mediaId,
397
+ scope: ref.scope,
398
+ exact: true
399
+ };
400
+ return BARE_MEDIA_ID.test(typed) ? {
401
+ mediaId: typed,
402
+ exact: false
403
+ } : null;
404
+ }
373
405
  /**
374
406
  * Every folder at or under `folderId`, the folder itself first. Walks the
375
407
  * parent links downward and never revisits one, so a cycle a stale write
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/media-filter.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 { mediaSearchToken } from './media-metadata'\nimport { nameSearchNormalizers } from './name-search'\nimport type {\n ListFilterField,\n ListFilterRequest,\n} from '@aglyn/shared-util-tools/list-query/list-filter'\nimport type { ListFilterOption } from '@aglyn/shared-util-tools/list-query/list-filter-codecs'\nimport {\n type ListQueryDeclaration,\n type ListQueryFilter,\n type ListQueryNormalizers,\n type ListQueryPlan,\n type ListQuerySort,\n planListQuery,\n} from '@aglyn/shared-util-tools/list-query/list-query-plan'\n\n/*\n * What the media library filters and searches by, declared for the\n * console's list query plan (AGL-3327).\n *\n * The library composes every clause and the search word onto ONE Firestore\n * query through `planListQuery` (AGL-3321) — nothing is matched over the\n * pages already read. What one query cannot hold (a second array clause, a\n * second range, more than thirty disjunctions) the plan refuses by name and\n * the library says so; the rest still stands.\n *\n * Every field is stored on the media document by every writer\n * (`mediaFilterKeys` in `@aglyn/aglyn/app-utils/media-metadata`) and by the\n * backfill for the documents written before (`backfill-media-filter-keys`):\n *\n * type `kind`, the family (image, video, audio, pdf, document)\n * tags `tags`, lower-cased by every writer, matched whole\n * uploaded `createdAt`\n * size `sizeBytes`, asked in megabytes\n * uploadedBy `uploadedBy`, a uid, or `api:<key id>` from the REST API\n * alt `hasAlt`, so \"missing\" is a value a query can find\n * orientation `orientation`, from the stored pixel size\n * name `nameLower`, for the Name sort and \"starts with\"\n * search `nameTokens`, the word prefixes of the file name\n *\n * Not offered, because no query serves them: custom metadata (its keys are\n * the reader's own, and each would need composites of its own), the width\n * and the height as ranges (each a range field that would have to lead the\n * order, with composites for every other field — Orientation answers the\n * question people ask of them), and \"contains\" on the alt text itself.\n *\n * Firestore merges one `[field, sort]` composite per field for any\n * combination of equalities; `media-scope-indexes.spec.ts` holds the index\n * file to `listQueryIndexes` of this declaration.\n */\n\n/** The family a file belongs to, as the Type filter offers it. */\nexport const MEDIA_TYPE_OPTIONS: readonly ListFilterOption[] = [\n { value: 'image', label: 'Images' },\n { value: 'video', label: 'Video' },\n { value: 'audio', label: 'Audio' },\n { value: 'pdf', label: 'PDF' },\n { value: 'document', label: 'Other documents' },\n]\n\n/** Orientation, as the stored `orientation` names it. */\nexport const MEDIA_ORIENTATION_OPTIONS: readonly ListFilterOption[] = [\n { value: 'landscape', label: 'Landscape' },\n { value: 'portrait', label: 'Portrait' },\n { value: 'square', label: 'Square' },\n]\n\n/** Alt text, as the two answers a query can give about it. */\nexport const MEDIA_ALT_OPTIONS: readonly ListFilterOption[] = [\n { value: 'false', label: 'Missing' },\n { value: 'true', label: 'Set' },\n]\n\nconst MB = 1024 * 1024\n\n/**\n * The fields the Filters panel offers, each as the query serves it.\n *\n * Tags is an array matched whole: `exact` with its `tokensPath`, so `contains`\n * is `array-contains` and `isAnyOf` is `array-contains-any` — the panel shows\n * `contains` as the select's \"is\" (`MEDIA_TAGS_CODEC`). Size is asked in\n * megabytes and served in bytes (`mediaQueryRequest`).\n */\nexport const MEDIA_FILTER_FIELDS: readonly ListFilterField[] = [\n { column: 'type', kind: 'exact', path: 'kind', operators: ['equals', 'isAnyOf'] },\n {\n column: 'tags',\n kind: 'exact',\n path: 'tags',\n tokensPath: 'tags',\n operators: ['contains', 'isAnyOf'],\n },\n { column: 'uploaded', kind: 'date', path: 'createdAt', presence: 'always' },\n { column: 'sizeMb', kind: 'number', path: 'sizeBytes', operators: ['>', '>=', '<', '<='] },\n { column: 'uploadedBy', kind: 'exact', path: 'uploadedBy', operators: ['equals', 'isAnyOf'] },\n { column: 'alt', kind: 'boolean', path: 'hasAlt', operators: ['equals'] },\n {\n column: 'orientation',\n kind: 'exact',\n path: 'orientation',\n operators: ['equals', 'isAnyOf'],\n },\n {\n column: 'fileName',\n kind: 'text',\n path: 'fileName',\n lowerPath: 'nameLower',\n operators: ['startsWith'],\n },\n]\n\nexport const MEDIA_FILTER_HEADERS: Readonly<Record<string, string>> = {\n type: 'Type',\n tags: 'Tags',\n uploaded: 'Uploaded',\n sizeMb: 'Size (MB)',\n uploadedBy: 'Uploaded by',\n alt: 'Alt text',\n orientation: 'Orientation',\n fileName: 'Name',\n}\n\n/** The fields one library offers. */\nexport function mediaFilterFields(options: {\n /** A picker's fixed kind (AGL-2953): Type is not the reader's to change. */\n typeLocked: boolean\n /**\n * The query carries the scope clause (AGL-1042), which is its one array\n * clause, so Tags could never be applied and is not offered.\n */\n scoped: boolean\n}): ListFilterField[] {\n return MEDIA_FILTER_FIELDS.filter(\n (field) =>\n !(options.typeLocked && field.column === 'type') &&\n !(options.scoped && field.column === 'tags'),\n )\n}\n\n// ---------------------------------------------------------------------------\n// The sorts\n// ---------------------------------------------------------------------------\n\nexport type MediaSort = 'newest' | 'oldest' | 'name' | 'size'\n\nexport const MEDIA_SORTS: readonly MediaSort[] = ['newest', 'oldest', 'name', 'size']\n\n/** What each sort orders by, and the List view column that sorts it. */\nexport const MEDIA_SORT_ORDER: Readonly<Record<MediaSort, ListQuerySort>> = {\n newest: { path: 'createdAt', direction: 'desc', column: 'uploaded' },\n oldest: { path: 'createdAt', direction: 'asc', column: 'uploaded' },\n // `nameLower`, so the order ignores case, and a \"starts with\" range — the\n // name's own range — orders the list the way this sort already does.\n name: { path: 'nameLower', direction: 'asc', column: 'fileName' },\n size: { path: 'sizeBytes', direction: 'desc', column: 'size' },\n}\n\nexport const MEDIA_SORT_LABELS: Readonly<Record<MediaSort, string>> = {\n newest: 'Newest',\n oldest: 'Oldest',\n name: 'Name',\n size: 'Largest',\n}\n\n/** The library's sort for a query order, or null for one it does not offer. */\nexport function mediaSortOf(order: ListQuerySort): MediaSort | null {\n const found = MEDIA_SORTS.find(\n (sort) =>\n MEDIA_SORT_ORDER[sort].path === order.path &&\n MEDIA_SORT_ORDER[sort].direction === order.direction,\n )\n return found ?? null\n}\n\n// ---------------------------------------------------------------------------\n// The query\n// ---------------------------------------------------------------------------\n\n/**\n * The library as the list query plan reads it. Newest first is the default,\n * and every sort is one the index file holds for every field.\n */\nexport const MEDIA_LIST_QUERY: ListQueryDeclaration = {\n fields: MEDIA_FILTER_FIELDS,\n sorts: MEDIA_SORTS.map((sort) => MEDIA_SORT_ORDER[sort]),\n search: { tokensPath: 'nameTokens' },\n}\n\n/**\n * The normalizers the media writers stamped the name fields with: the\n * platform's name search, except that a file name's words are split at every\n * separator (`mediaSearchToken`), so `hero-banner.png` is found by \"banner\".\n */\nexport const MEDIA_NAME_NORMALIZERS: ListQueryNormalizers = {\n ...nameSearchNormalizers,\n token: (value) => mediaSearchToken(value),\n}\n\n/** The predicates every query of one library view carries, before any clause. */\nexport function mediaBaseFilters(input: {\n /** Every file, the files in no folder, or the files in these folders. */\n folder: 'all' | 'root' | readonly string[]\n /** The scope clause a scoped reader's query must carry (AGL-1042), or null. */\n scopeTokens: readonly string[] | null\n}): ListQueryFilter[] {\n const { folder, scopeTokens } = input\n return [\n ...(folder === 'root'\n ? [{ path: 'folderId', op: '==' as const, value: null }]\n : folder === 'all'\n ? []\n : folder.length === 1\n ? [{ path: 'folderId', op: '==' as const, value: folder[0] }]\n : [{ path: 'folderId', op: 'in' as const, value: [...folder] }]),\n ...(scopeTokens\n ? [{ path: 'visibleTo', op: 'array-contains-any' as const, value: [...scopeTokens] }]\n : []),\n ]\n}\n\n/** The scope predicates, as `listQueryIndexes` enumerates them. */\nexport const MEDIA_BASE_INDEX_FIELDS = [\n { path: 'folderId' },\n { path: 'visibleTo', array: true },\n] as const\n\n/** Firestore's cap on disjunctions: the product of every `in` and `-any`. */\nexport const MEDIA_DISJUNCTION_LIMIT = 30\n\n/** A clause as the query asks it: a size in bytes, the rest as written. */\nexport function mediaQueryRequest(clause: ListFilterRequest): ListFilterRequest {\n if (clause.field !== 'sizeMb') return clause\n const megabytes = Number(clause.value)\n return Number.isFinite(megabytes) && String(clause.value).trim() !== ''\n ? { ...clause, value: String(Math.round(megabytes * MB)) }\n : clause\n}\n\nexport interface MediaQueryInput {\n /** Every clause in force, a picker's fixed kind included. */\n clauses: readonly ListFilterRequest[]\n sort: MediaSort\n folder: 'all' | 'root' | readonly string[]\n scopeTokens: readonly string[] | null\n /** The quick search's words. */\n search: readonly string[]\n}\n\nexport interface MediaQuery {\n plan: ListQueryPlan\n /**\n * Whether Include subfolders had to give way to the open folder alone:\n * its subfolders times the scope's tokens pass Firestore's thirty\n * disjunctions.\n */\n subfoldersDropped: boolean\n}\n\n/** Said when a scoped reader's search is served as a name range. */\nexport const MEDIA_SCOPED_SEARCH_NOTICE =\n 'In a library limited to some sites, search finds files whose name ' +\n 'starts with what you type.'\n\n/**\n * A scoped reader's search, as the clause their query can serve (AGL-3327).\n *\n * The quick search is `array-contains` on `nameTokens`, and the scope clause\n * a scoped reader's query must carry (`visibleTo array-contains-any`,\n * AGL-1042) is the query's one array filter, so the two cannot share one\n * query. Folding the word into the scope clause (`search.scoped` in the list\n * query plan) cannot be used here either: the security rules prove a scoped\n * reader's list from its `visibleTo` clause, and a query that asks only for\n * `<scope>~<word>` tokens proves nothing about `visibleTo`, so Firestore\n * refuses it outright (probed against the emulator, AGL-3327). What the rules\n * and one query can both hold is a range on `nameLower` beside the scope\n * clause: the files whose name starts with what was typed, claimed first so\n * a date or size range gives way to it rather than the other way round.\n */\nexport function mediaScopedSearchClause(\n search: readonly string[],\n): ListFilterRequest | null {\n const typed = search.map((word) => word.trim()).filter(Boolean).join(' ')\n return typed ? { field: 'fileName', op: 'startsWith', value: typed } : null\n}\n\n/**\n * The one query a view of the library reads (AGL-3327).\n *\n * The folder and the scope clause are the base every query carries. A range\n * filter orders the query by its own field; where the reader's sort is on\n * that field already (Oldest under an Uploaded filter), its direction stands.\n * Under the scope clause the search is a name range\n * (`mediaScopedSearchClause`), and the library says so.\n */\nexport function mediaQuery(input: MediaQueryInput): MediaQuery {\n const { sort, scopeTokens } = input\n const scopedSearch = scopeTokens ? mediaScopedSearchClause(input.search) : null\n const clauses = scopedSearch ? [scopedSearch, ...input.clauses] : input.clauses\n const search = scopedSearch ? [] : input.search\n const tokens = scopeTokens?.length ?? 1\n const subfoldersDropped =\n Array.isArray(input.folder) &&\n input.folder.length > 1 &&\n input.folder.length * tokens > MEDIA_DISJUNCTION_LIMIT\n const folder =\n subfoldersDropped && Array.isArray(input.folder) ? [input.folder[0]] : input.folder\n const asked = MEDIA_SORT_ORDER[sort]\n const plan = planListQuery(\n MEDIA_LIST_QUERY,\n {\n base: mediaBaseFilters({ folder, scopeTokens }),\n clauses: clauses.map(mediaQueryRequest),\n search,\n sort: asked,\n },\n MEDIA_NAME_NORMALIZERS,\n )\n const orderBy =\n plan.orderBy.path === asked.path && plan.orderBy.direction !== asked.direction\n ? asked\n : plan.orderBy\n const notices = scopedSearch ? [MEDIA_SCOPED_SEARCH_NOTICE, ...plan.notices] : plan.notices\n return { plan: { ...plan, orderBy, notices }, subfoldersDropped }\n}\n\n/**\n * Every folder at or under `folderId`, the folder itself first. Walks the\n * parent links downward and never revisits one, so a cycle a stale write\n * left behind cannot loop.\n */\nexport function mediaFolderDescendants(\n folderId: string,\n folders: ReadonlyArray<{ $id: string; parentId?: string | null }>,\n): string[] {\n const children = new Map<string, string[]>()\n for (const folder of folders) {\n const parent = folder.parentId ?? null\n if (!parent) continue\n children.set(parent, [...(children.get(parent) ?? []), folder.$id])\n }\n const ids: string[] = []\n const queue = [folderId]\n const seen = new Set<string>()\n while (queue.length) {\n const next = queue.shift() as string\n if (seen.has(next)) continue\n seen.add(next)\n ids.push(next)\n queue.push(...(children.get(next) ?? []))\n }\n return ids\n}\n"],"names":["mediaSearchToken","nameSearchNormalizers","planListQuery","MEDIA_TYPE_OPTIONS","value","label","MEDIA_ORIENTATION_OPTIONS","MEDIA_ALT_OPTIONS","MB","MEDIA_FILTER_FIELDS","column","kind","path","operators","tokensPath","presence","lowerPath","MEDIA_FILTER_HEADERS","type","tags","uploaded","sizeMb","uploadedBy","alt","orientation","fileName","mediaFilterFields","options","filter","field","typeLocked","scoped","MEDIA_SORTS","MEDIA_SORT_ORDER","newest","direction","oldest","name","size","MEDIA_SORT_LABELS","mediaSortOf","order","found","find","sort","MEDIA_LIST_QUERY","fields","sorts","map","search","MEDIA_NAME_NORMALIZERS","token","mediaBaseFilters","input","folder","scopeTokens","op","length","MEDIA_BASE_INDEX_FIELDS","array","MEDIA_DISJUNCTION_LIMIT","mediaQueryRequest","clause","megabytes","Number","isFinite","String","trim","Math","round","MEDIA_SCOPED_SEARCH_NOTICE","mediaScopedSearchClause","typed","word","Boolean","join","mediaQuery","scopedSearch","clauses","tokens","subfoldersDropped","Array","isArray","asked","plan","base","orderBy","notices","mediaFolderDescendants","folderId","folders","children","Map","parent","parentId","set","get","$id","ids","queue","seen","Set","next","shift","has","add","push"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,gBAAgB,QAAQ,sBAAkB;AACnD,SAASC,qBAAqB,QAAQ,mBAAe;AAMrD,SAMEC,aAAa,QACR,sDAAqD;AAE5D;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAiCC,GAED,gEAAgE,GAChE,OAAO,MAAMC,qBAAkD;IAC7D;QAAEC,OAAO;QAASC,OAAO;IAAS;IAClC;QAAED,OAAO;QAASC,OAAO;IAAQ;IACjC;QAAED,OAAO;QAASC,OAAO;IAAQ;IACjC;QAAED,OAAO;QAAOC,OAAO;IAAM;IAC7B;QAAED,OAAO;QAAYC,OAAO;IAAkB;CAC/C,CAAA;AAED,uDAAuD,GACvD,OAAO,MAAMC,4BAAyD;IACpE;QAAEF,OAAO;QAAaC,OAAO;IAAY;IACzC;QAAED,OAAO;QAAYC,OAAO;IAAW;IACvC;QAAED,OAAO;QAAUC,OAAO;IAAS;CACpC,CAAA;AAED,4DAA4D,GAC5D,OAAO,MAAME,oBAAiD;IAC5D;QAAEH,OAAO;QAASC,OAAO;IAAU;IACnC;QAAED,OAAO;QAAQC,OAAO;IAAM;CAC/B,CAAA;AAED,MAAMG,KAAK,OAAO;AAElB;;;;;;;CAOC,GACD,OAAO,MAAMC,sBAAkD;IAC7D;QAAEC,QAAQ;QAAQC,MAAM;QAASC,MAAM;QAAQC,WAAW;YAAC;YAAU;SAAU;IAAC;IAChF;QACEH,QAAQ;QACRC,MAAM;QACNC,MAAM;QACNE,YAAY;QACZD,WAAW;YAAC;YAAY;SAAU;IACpC;IACA;QAAEH,QAAQ;QAAYC,MAAM;QAAQC,MAAM;QAAaG,UAAU;IAAS;IAC1E;QAAEL,QAAQ;QAAUC,MAAM;QAAUC,MAAM;QAAaC,WAAW;YAAC;YAAK;YAAM;YAAK;SAAK;IAAC;IACzF;QAAEH,QAAQ;QAAcC,MAAM;QAASC,MAAM;QAAcC,WAAW;YAAC;YAAU;SAAU;IAAC;IAC5F;QAAEH,QAAQ;QAAOC,MAAM;QAAWC,MAAM;QAAUC,WAAW;YAAC;SAAS;IAAC;IACxE;QACEH,QAAQ;QACRC,MAAM;QACNC,MAAM;QACNC,WAAW;YAAC;YAAU;SAAU;IAClC;IACA;QACEH,QAAQ;QACRC,MAAM;QACNC,MAAM;QACNI,WAAW;QACXH,WAAW;YAAC;SAAa;IAC3B;CACD,CAAA;AAED,OAAO,MAAMI,uBAAyD;IACpEC,MAAM;IACNC,MAAM;IACNC,UAAU;IACVC,QAAQ;IACRC,YAAY;IACZC,KAAK;IACLC,aAAa;IACbC,UAAU;AACZ,EAAC;AAED,mCAAmC,GACnC,OAAO,SAASC,kBAAkBC,OAQjC;IACC,OAAOlB,oBAAoBmB,MAAM,CAC/B,CAACC,QACC,CAAEF,CAAAA,QAAQG,UAAU,IAAID,MAAMnB,MAAM,KAAK,MAAK,KAC9C,CAAEiB,CAAAA,QAAQI,MAAM,IAAIF,MAAMnB,MAAM,KAAK,MAAK;AAEhD;AAQA,OAAO,MAAMsB,cAAoC;IAAC;IAAU;IAAU;IAAQ;CAAO,CAAA;AAErF,sEAAsE,GACtE,OAAO,MAAMC,mBAA+D;IAC1EC,QAAQ;QAAEtB,MAAM;QAAauB,WAAW;QAAQzB,QAAQ;IAAW;IACnE0B,QAAQ;QAAExB,MAAM;QAAauB,WAAW;QAAOzB,QAAQ;IAAW;IAClE,0EAA0E;IAC1E,qEAAqE;IACrE2B,MAAM;QAAEzB,MAAM;QAAauB,WAAW;QAAOzB,QAAQ;IAAW;IAChE4B,MAAM;QAAE1B,MAAM;QAAauB,WAAW;QAAQzB,QAAQ;IAAO;AAC/D,EAAC;AAED,OAAO,MAAM6B,oBAAyD;IACpEL,QAAQ;IACRE,QAAQ;IACRC,MAAM;IACNC,MAAM;AACR,EAAC;AAED,6EAA6E,GAC7E,OAAO,SAASE,YAAYC,KAAoB;IAC9C,MAAMC,QAAQV,YAAYW,IAAI,CAC5B,CAACC,OACCX,gBAAgB,CAACW,KAAK,CAAChC,IAAI,KAAK6B,MAAM7B,IAAI,IAC1CqB,gBAAgB,CAACW,KAAK,CAACT,SAAS,KAAKM,MAAMN,SAAS;IAExD,OAAOO,gBAAAA,QAAS;AAClB;AAEA,8EAA8E;AAC9E,YAAY;AACZ,8EAA8E;AAE9E;;;CAGC,GACD,OAAO,MAAMG,mBAAyC;IACpDC,QAAQrC;IACRsC,OAAOf,YAAYgB,GAAG,CAAC,CAACJ,OAASX,gBAAgB,CAACW,KAAK;IACvDK,QAAQ;QAAEnC,YAAY;IAAa;AACrC,EAAC;AAED;;;;CAIC,GACD,OAAO,MAAMoC,yBAA+C,aACvDjD;IACHkD,OAAO,CAAC/C,QAAUJ,iBAAiBI;GACpC;AAED,+EAA+E,GAC/E,OAAO,SAASgD,iBAAiBC,KAKhC;IACC,MAAM,EAAEC,MAAM,EAAEC,WAAW,EAAE,GAAGF;IAChC,OAAO;WACDC,WAAW,SACX;YAAC;gBAAE1C,MAAM;gBAAY4C,IAAI;gBAAepD,OAAO;YAAK;SAAE,GACtDkD,WAAW,QACT,EAAE,GACFA,OAAOG,MAAM,KAAK,IAChB;YAAC;gBAAE7C,MAAM;gBAAY4C,IAAI;gBAAepD,OAAOkD,MAAM,CAAC,EAAE;YAAC;SAAE,GAC3D;YAAC;gBAAE1C,MAAM;gBAAY4C,IAAI;gBAAepD,OAAO;uBAAIkD;iBAAO;YAAC;SAAE;WACjEC,cACA;YAAC;gBAAE3C,MAAM;gBAAa4C,IAAI;gBAA+BpD,OAAO;uBAAImD;iBAAY;YAAC;SAAE,GACnF,EAAE;KACP;AACH;AAEA,iEAAiE,GACjE,OAAO,MAAMG,0BAA0B;IACrC;QAAE9C,MAAM;IAAW;IACnB;QAAEA,MAAM;QAAa+C,OAAO;IAAK;CAClC,CAAS;AAEV,2EAA2E,GAC3E,OAAO,MAAMC,0BAA0B,GAAE;AAEzC,yEAAyE,GACzE,OAAO,SAASC,kBAAkBC,MAAyB;IACzD,IAAIA,OAAOjC,KAAK,KAAK,UAAU,OAAOiC;IACtC,MAAMC,YAAYC,OAAOF,OAAO1D,KAAK;IACrC,OAAO4D,OAAOC,QAAQ,CAACF,cAAcG,OAAOJ,OAAO1D,KAAK,EAAE+D,IAAI,OAAO,KACjE,aAAKL;QAAQ1D,OAAO8D,OAAOE,KAAKC,KAAK,CAACN,YAAYvD;SAClDsD;AACN;AAsBA,kEAAkE,GAClE,OAAO,MAAMQ,6BACX,uEACA,6BAA4B;AAE9B;;;;;;;;;;;;;;CAcC,GACD,OAAO,SAASC,wBACdtB,MAAyB;IAEzB,MAAMuB,QAAQvB,OAAOD,GAAG,CAAC,CAACyB,OAASA,KAAKN,IAAI,IAAIvC,MAAM,CAAC8C,SAASC,IAAI,CAAC;IACrE,OAAOH,QAAQ;QAAE3C,OAAO;QAAY2B,IAAI;QAAcpD,OAAOoE;IAAM,IAAI;AACzE;AAEA;;;;;;;;CAQC,GACD,OAAO,SAASI,WAAWvB,KAAsB;;IAC/C,MAAM,EAAET,IAAI,EAAEW,WAAW,EAAE,GAAGF;IAC9B,MAAMwB,eAAetB,cAAcgB,wBAAwBlB,MAAMJ,MAAM,IAAI;IAC3E,MAAM6B,UAAUD,eAAe;QAACA;WAAiBxB,MAAMyB,OAAO;KAAC,GAAGzB,MAAMyB,OAAO;IAC/E,MAAM7B,SAAS4B,eAAe,EAAE,GAAGxB,MAAMJ,MAAM;IAC/C,MAAM8B,iBAASxB,+BAAAA,YAAaE,MAAM,mBAAI;IACtC,MAAMuB,oBACJC,MAAMC,OAAO,CAAC7B,MAAMC,MAAM,KAC1BD,MAAMC,MAAM,CAACG,MAAM,GAAG,KACtBJ,MAAMC,MAAM,CAACG,MAAM,GAAGsB,SAASnB;IACjC,MAAMN,SACJ0B,qBAAqBC,MAAMC,OAAO,CAAC7B,MAAMC,MAAM,IAAI;QAACD,MAAMC,MAAM,CAAC,EAAE;KAAC,GAAGD,MAAMC,MAAM;IACrF,MAAM6B,QAAQlD,gBAAgB,CAACW,KAAK;IACpC,MAAMwC,OAAOlF,cACX2C,kBACA;QACEwC,MAAMjC,iBAAiB;YAAEE;YAAQC;QAAY;QAC7CuB,SAASA,QAAQ9B,GAAG,CAACa;QACrBZ;QACAL,MAAMuC;IACR,GACAjC;IAEF,MAAMoC,UACJF,KAAKE,OAAO,CAAC1E,IAAI,KAAKuE,MAAMvE,IAAI,IAAIwE,KAAKE,OAAO,CAACnD,SAAS,KAAKgD,MAAMhD,SAAS,GAC1EgD,QACAC,KAAKE,OAAO;IAClB,MAAMC,UAAUV,eAAe;QAACP;WAA+Bc,KAAKG,OAAO;KAAC,GAAGH,KAAKG,OAAO;IAC3F,OAAO;QAAEH,MAAM,aAAKA;YAAME;YAASC;;QAAWP;IAAkB;AAClE;AAEA;;;;CAIC,GACD,OAAO,SAASQ,uBACdC,QAAgB,EAChBC,OAAiE;IAEjE,MAAMC,WAAW,IAAIC;IACrB,KAAK,MAAMtC,UAAUoC,QAAS;YACbpC,kBAEWqC;QAF1B,MAAME,UAASvC,mBAAAA,OAAOwC,QAAQ,YAAfxC,mBAAmB;QAClC,IAAI,CAACuC,QAAQ;QACbF,SAASI,GAAG,CAACF,QAAQ;gBAAKF,gBAAAA,SAASK,GAAG,CAACH,mBAAbF,gBAAwB,EAAE;YAAGrC,OAAO2C,GAAG;SAAC;IACpE;IACA,MAAMC,MAAgB,EAAE;IACxB,MAAMC,QAAQ;QAACV;KAAS;IACxB,MAAMW,OAAO,IAAIC;IACjB,MAAOF,MAAM1C,MAAM,CAAE;YAKJkC;QAJf,MAAMW,OAAOH,MAAMI,KAAK;QACxB,IAAIH,KAAKI,GAAG,CAACF,OAAO;QACpBF,KAAKK,GAAG,CAACH;QACTJ,IAAIQ,IAAI,CAACJ;QACTH,MAAMO,IAAI,KAAKf,iBAAAA,SAASK,GAAG,CAACM,iBAAbX,iBAAsB,EAAE;IACzC;IACA,OAAOO;AACT"}
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/media-filter.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 { mediaSearchToken } from './media-metadata'\nimport { mediaRefFromCdnPath, parseMediaRef } from './media-ref'\nimport { nameSearchNormalizers } from './name-search'\nimport type {\n ListFilterField,\n ListFilterRequest,\n} from '@aglyn/shared-util-tools/list-query/list-filter'\nimport type { ListFilterOption } from '@aglyn/shared-util-tools/list-query/list-filter-codecs'\nimport {\n type ListQueryDeclaration,\n type ListQueryFilter,\n type ListQueryNormalizers,\n type ListQueryPlan,\n type ListQuerySort,\n planListQuery,\n} from '@aglyn/shared-util-tools/list-query/list-query-plan'\n\n/*\n * What the media library filters and searches by, declared for the\n * console's list query plan (AGL-3327).\n *\n * The library composes every clause and the search word onto ONE Firestore\n * query through `planListQuery` (AGL-3321) — nothing is matched over the\n * pages already read. What one query cannot hold (a second array clause, a\n * second range, more than thirty disjunctions) the plan refuses by name and\n * the library says so; the rest still stands.\n *\n * Every field is stored on the media document by every writer\n * (`mediaFilterKeys` in `@aglyn/aglyn/app-utils/media-metadata`) and by the\n * backfill for the documents written before (`backfill-media-filter-keys`):\n *\n * type `kind`, the family (image, video, audio, pdf, document)\n * tags `tags`, lower-cased by every writer, matched whole\n * uploaded `createdAt`\n * size `sizeBytes`, asked in megabytes\n * uploadedBy `uploadedBy`, a uid, or `api:<key id>` from the REST API\n * alt `hasAlt`, so \"missing\" is a value a query can find\n * orientation `orientation`, from the stored pixel size\n * name `nameLower`, for the Name sort and \"starts with\"\n * search `nameTokens`, the word prefixes of the file name\n *\n * Not offered, because no query serves them: custom metadata (its keys are\n * the reader's own, and each would need composites of its own), the width\n * and the height as ranges (each a range field that would have to lead the\n * order, with composites for every other field — Orientation answers the\n * question people ask of them), and \"contains\" on the alt text itself.\n *\n * Firestore merges one `[field, sort]` composite per field for any\n * combination of equalities; `media-scope-indexes.spec.ts` holds the index\n * file to `listQueryIndexes` of this declaration.\n */\n\n/** The family a file belongs to, as the Type filter offers it. */\nexport const MEDIA_TYPE_OPTIONS: readonly ListFilterOption[] = [\n { value: 'image', label: 'Images' },\n { value: 'video', label: 'Video' },\n { value: 'audio', label: 'Audio' },\n { value: 'pdf', label: 'PDF' },\n { value: 'document', label: 'Other documents' },\n]\n\n/** Orientation, as the stored `orientation` names it. */\nexport const MEDIA_ORIENTATION_OPTIONS: readonly ListFilterOption[] = [\n { value: 'landscape', label: 'Landscape' },\n { value: 'portrait', label: 'Portrait' },\n { value: 'square', label: 'Square' },\n]\n\n/** Alt text, as the two answers a query can give about it. */\nexport const MEDIA_ALT_OPTIONS: readonly ListFilterOption[] = [\n { value: 'false', label: 'Missing' },\n { value: 'true', label: 'Set' },\n]\n\nconst MB = 1024 * 1024\n\n/**\n * The fields the Filters panel offers, each as the query serves it.\n *\n * Tags is an array matched whole: `exact` with its `tokensPath`, so `contains`\n * is `array-contains` and `isAnyOf` is `array-contains-any` — the panel shows\n * `contains` as the select's \"is\" (`MEDIA_TAGS_CODEC`). Size is asked in\n * megabytes and served in bytes (`mediaQueryRequest`).\n */\nexport const MEDIA_FILTER_FIELDS: readonly ListFilterField[] = [\n { column: 'type', kind: 'exact', path: 'kind', operators: ['equals', 'isAnyOf'] },\n {\n column: 'tags',\n kind: 'exact',\n path: 'tags',\n tokensPath: 'tags',\n operators: ['contains', 'isAnyOf'],\n },\n { column: 'uploaded', kind: 'date', path: 'createdAt', presence: 'always' },\n { column: 'sizeMb', kind: 'number', path: 'sizeBytes', operators: ['>', '>=', '<', '<='] },\n { column: 'uploadedBy', kind: 'exact', path: 'uploadedBy', operators: ['equals', 'isAnyOf'] },\n { column: 'alt', kind: 'boolean', path: 'hasAlt', operators: ['equals'] },\n {\n column: 'orientation',\n kind: 'exact',\n path: 'orientation',\n operators: ['equals', 'isAnyOf'],\n },\n {\n column: 'fileName',\n kind: 'text',\n path: 'fileName',\n lowerPath: 'nameLower',\n operators: ['startsWith'],\n },\n]\n\nexport const MEDIA_FILTER_HEADERS: Readonly<Record<string, string>> = {\n type: 'Type',\n tags: 'Tags',\n uploaded: 'Uploaded',\n sizeMb: 'Size (MB)',\n uploadedBy: 'Uploaded by',\n alt: 'Alt text',\n orientation: 'Orientation',\n fileName: 'Name',\n}\n\n/** The fields one library offers. */\nexport function mediaFilterFields(options: {\n /** A picker's fixed kind (AGL-2953): Type is not the reader's to change. */\n typeLocked: boolean\n /**\n * The query carries the scope clause (AGL-1042), which is its one array\n * clause, so Tags could never be applied and is not offered.\n */\n scoped: boolean\n}): ListFilterField[] {\n return MEDIA_FILTER_FIELDS.filter(\n (field) =>\n !(options.typeLocked && field.column === 'type') &&\n !(options.scoped && field.column === 'tags'),\n )\n}\n\n// ---------------------------------------------------------------------------\n// The sorts\n// ---------------------------------------------------------------------------\n\nexport type MediaSort = 'newest' | 'oldest' | 'name' | 'size'\n\nexport const MEDIA_SORTS: readonly MediaSort[] = ['newest', 'oldest', 'name', 'size']\n\n/** What each sort orders by, and the List view column that sorts it. */\nexport const MEDIA_SORT_ORDER: Readonly<Record<MediaSort, ListQuerySort>> = {\n newest: { path: 'createdAt', direction: 'desc', column: 'uploaded' },\n oldest: { path: 'createdAt', direction: 'asc', column: 'uploaded' },\n // `nameLower`, so the order ignores case, and a \"starts with\" range — the\n // name's own range — orders the list the way this sort already does.\n name: { path: 'nameLower', direction: 'asc', column: 'fileName' },\n size: { path: 'sizeBytes', direction: 'desc', column: 'size' },\n}\n\nexport const MEDIA_SORT_LABELS: Readonly<Record<MediaSort, string>> = {\n newest: 'Newest',\n oldest: 'Oldest',\n name: 'Name',\n size: 'Largest',\n}\n\n/** The library's sort for a query order, or null for one it does not offer. */\nexport function mediaSortOf(order: ListQuerySort): MediaSort | null {\n const found = MEDIA_SORTS.find(\n (sort) =>\n MEDIA_SORT_ORDER[sort].path === order.path &&\n MEDIA_SORT_ORDER[sort].direction === order.direction,\n )\n return found ?? null\n}\n\n// ---------------------------------------------------------------------------\n// The query\n// ---------------------------------------------------------------------------\n\n/**\n * The library as the list query plan reads it. Newest first is the default,\n * and every sort is one the index file holds for every field.\n */\nexport const MEDIA_LIST_QUERY: ListQueryDeclaration = {\n fields: MEDIA_FILTER_FIELDS,\n sorts: MEDIA_SORTS.map((sort) => MEDIA_SORT_ORDER[sort]),\n search: { tokensPath: 'nameTokens' },\n}\n\n/**\n * The normalizers the media writers stamped the name fields with: the\n * platform's name search, except that a file name's words are split at every\n * separator (`mediaSearchToken`), so `hero-banner.png` is found by \"banner\".\n */\nexport const MEDIA_NAME_NORMALIZERS: ListQueryNormalizers = {\n ...nameSearchNormalizers,\n token: (value) => mediaSearchToken(value),\n}\n\n/** The predicates every query of one library view carries, before any clause. */\nexport function mediaBaseFilters(input: {\n /** Every file, the files in no folder, or the files in these folders. */\n folder: 'all' | 'root' | readonly string[]\n /** The scope clause a scoped reader's query must carry (AGL-1042), or null. */\n scopeTokens: readonly string[] | null\n}): ListQueryFilter[] {\n const { folder, scopeTokens } = input\n return [\n ...(folder === 'root'\n ? [{ path: 'folderId', op: '==' as const, value: null }]\n : folder === 'all'\n ? []\n : folder.length === 1\n ? [{ path: 'folderId', op: '==' as const, value: folder[0] }]\n : [{ path: 'folderId', op: 'in' as const, value: [...folder] }]),\n ...(scopeTokens\n ? [{ path: 'visibleTo', op: 'array-contains-any' as const, value: [...scopeTokens] }]\n : []),\n ]\n}\n\n/** The scope predicates, as `listQueryIndexes` enumerates them. */\nexport const MEDIA_BASE_INDEX_FIELDS = [\n { path: 'folderId' },\n { path: 'visibleTo', array: true },\n] as const\n\n/** Firestore's cap on disjunctions: the product of every `in` and `-any`. */\nexport const MEDIA_DISJUNCTION_LIMIT = 30\n\n/** A clause as the query asks it: a size in bytes, the rest as written. */\nexport function mediaQueryRequest(clause: ListFilterRequest): ListFilterRequest {\n if (clause.field !== 'sizeMb') return clause\n const megabytes = Number(clause.value)\n return Number.isFinite(megabytes) && String(clause.value).trim() !== ''\n ? { ...clause, value: String(Math.round(megabytes * MB)) }\n : clause\n}\n\nexport interface MediaQueryInput {\n /** Every clause in force, a picker's fixed kind included. */\n clauses: readonly ListFilterRequest[]\n sort: MediaSort\n folder: 'all' | 'root' | readonly string[]\n scopeTokens: readonly string[] | null\n /** The quick search's words. */\n search: readonly string[]\n}\n\nexport interface MediaQuery {\n plan: ListQueryPlan\n /**\n * Whether Include subfolders had to give way to the open folder alone:\n * its subfolders times the scope's tokens pass Firestore's thirty\n * disjunctions.\n */\n subfoldersDropped: boolean\n}\n\n/** Said when a scoped reader's search is served as a name range. */\nexport const MEDIA_SCOPED_SEARCH_NOTICE =\n 'In a library limited to some sites, search finds files whose name ' +\n 'starts with what you type.'\n\n/**\n * A scoped reader's search, as the clause their query can serve (AGL-3327).\n *\n * The quick search is `array-contains` on `nameTokens`, and the scope clause\n * a scoped reader's query must carry (`visibleTo array-contains-any`,\n * AGL-1042) is the query's one array filter, so the two cannot share one\n * query. Folding the word into the scope clause (`search.scoped` in the list\n * query plan) cannot be used here either: the security rules prove a scoped\n * reader's list from its `visibleTo` clause, and a query that asks only for\n * `<scope>~<word>` tokens proves nothing about `visibleTo`, so Firestore\n * refuses it outright (probed against the emulator, AGL-3327). What the rules\n * and one query can both hold is a range on `nameLower` beside the scope\n * clause: the files whose name starts with what was typed, claimed first so\n * a date or size range gives way to it rather than the other way round.\n */\nexport function mediaScopedSearchClause(\n search: readonly string[],\n): ListFilterRequest | null {\n const typed = search.map((word) => word.trim()).filter(Boolean).join(' ')\n return typed ? { field: 'fileName', op: 'startsWith', value: typed } : null\n}\n\n/**\n * The one query a view of the library reads (AGL-3327).\n *\n * The folder and the scope clause are the base every query carries. A range\n * filter orders the query by its own field; where the reader's sort is on\n * that field already (Oldest under an Uploaded filter), its direction stands.\n * Under the scope clause the search is a name range\n * (`mediaScopedSearchClause`), and the library says so.\n */\nexport function mediaQuery(input: MediaQueryInput): MediaQuery {\n const { sort, scopeTokens } = input\n const scopedSearch = scopeTokens ? mediaScopedSearchClause(input.search) : null\n const clauses = scopedSearch ? [scopedSearch, ...input.clauses] : input.clauses\n const search = scopedSearch ? [] : input.search\n const tokens = scopeTokens?.length ?? 1\n const subfoldersDropped =\n Array.isArray(input.folder) &&\n input.folder.length > 1 &&\n input.folder.length * tokens > MEDIA_DISJUNCTION_LIMIT\n const folder =\n subfoldersDropped && Array.isArray(input.folder) ? [input.folder[0]] : input.folder\n const asked = MEDIA_SORT_ORDER[sort]\n const plan = planListQuery(\n MEDIA_LIST_QUERY,\n {\n base: mediaBaseFilters({ folder, scopeTokens }),\n clauses: clauses.map(mediaQueryRequest),\n search,\n sort: asked,\n },\n MEDIA_NAME_NORMALIZERS,\n )\n const orderBy =\n plan.orderBy.path === asked.path && plan.orderBy.direction !== asked.direction\n ? asked\n : plan.orderBy\n const notices = scopedSearch ? [MEDIA_SCOPED_SEARCH_NOTICE, ...plan.notices] : plan.notices\n return { plan: { ...plan, orderBy, notices }, subfoldersDropped }\n}\n\n// ---------------------------------------------------------------------------\n// Search by ID\n// ---------------------------------------------------------------------------\n\n/** One file the search box names by its ID, rather than words of a name. */\nexport interface MediaIdSearch {\n mediaId: string\n /**\n * The CDN scope a pasted link or reference names (`{hostId}`,\n * `org:{orgId}` or `org:{orgId}:{hostId}`), when it names one.\n */\n scope?: string\n /**\n * Whether the text can only be an ID: a link, a CDN path or a `media:`\n * reference. The library answers it with that one file. A bare word shaped\n * like an ID may also be a word of a file name, so the name search still\n * runs beside it.\n */\n exact: boolean\n}\n\n/**\n * The shape of a bare media ID: one CDN path segment, long enough and mixed\n * enough (a digit or a capital) that an ordinary word of a file name does not\n * read as one. IDs are minted as random mixed-case strings, so a real one\n * nearly always qualifies; a word that slips through costs one document read\n * that finds nothing.\n */\nconst BARE_MEDIA_ID = /^(?=.*[0-9A-Z])[A-Za-z0-9_-]{8,64}$/\n\n/**\n * The file the search box names, or null when it names words of a name.\n *\n * Takes what people actually have in hand: the ID the File info shows, the\n * address Copy URL gives (with or without the origin, a `?w=` width or a\n * content hash after the ID), the CDN path a settings field stores, and the\n * `media:` reference a page node stores. The library reads that one document\n * by its ID, which no word index can find.\n */\nexport function mediaIdSearch(search: readonly string[]): MediaIdSearch | null {\n const words = search.map((word) => word.trim()).filter(Boolean)\n if (words.length !== 1) return null\n const typed = words[0]\n const ref =\n parseMediaRef(typed) ?? parseMediaRef(mediaRefFromCdnPath(typed.split(/[?#]/)[0]))\n if (ref) return { mediaId: ref.mediaId, scope: ref.scope, exact: true }\n return BARE_MEDIA_ID.test(typed) ? { mediaId: typed, exact: false } : null\n}\n\n/**\n * Every folder at or under `folderId`, the folder itself first. Walks the\n * parent links downward and never revisits one, so a cycle a stale write\n * left behind cannot loop.\n */\nexport function mediaFolderDescendants(\n folderId: string,\n folders: ReadonlyArray<{ $id: string; parentId?: string | null }>,\n): string[] {\n const children = new Map<string, string[]>()\n for (const folder of folders) {\n const parent = folder.parentId ?? null\n if (!parent) continue\n children.set(parent, [...(children.get(parent) ?? []), folder.$id])\n }\n const ids: string[] = []\n const queue = [folderId]\n const seen = new Set<string>()\n while (queue.length) {\n const next = queue.shift() as string\n if (seen.has(next)) continue\n seen.add(next)\n ids.push(next)\n queue.push(...(children.get(next) ?? []))\n }\n return ids\n}\n"],"names":["mediaSearchToken","mediaRefFromCdnPath","parseMediaRef","nameSearchNormalizers","planListQuery","MEDIA_TYPE_OPTIONS","value","label","MEDIA_ORIENTATION_OPTIONS","MEDIA_ALT_OPTIONS","MB","MEDIA_FILTER_FIELDS","column","kind","path","operators","tokensPath","presence","lowerPath","MEDIA_FILTER_HEADERS","type","tags","uploaded","sizeMb","uploadedBy","alt","orientation","fileName","mediaFilterFields","options","filter","field","typeLocked","scoped","MEDIA_SORTS","MEDIA_SORT_ORDER","newest","direction","oldest","name","size","MEDIA_SORT_LABELS","mediaSortOf","order","found","find","sort","MEDIA_LIST_QUERY","fields","sorts","map","search","MEDIA_NAME_NORMALIZERS","token","mediaBaseFilters","input","folder","scopeTokens","op","length","MEDIA_BASE_INDEX_FIELDS","array","MEDIA_DISJUNCTION_LIMIT","mediaQueryRequest","clause","megabytes","Number","isFinite","String","trim","Math","round","MEDIA_SCOPED_SEARCH_NOTICE","mediaScopedSearchClause","typed","word","Boolean","join","mediaQuery","scopedSearch","clauses","tokens","subfoldersDropped","Array","isArray","asked","plan","base","orderBy","notices","BARE_MEDIA_ID","mediaIdSearch","words","ref","split","mediaId","scope","exact","test","mediaFolderDescendants","folderId","folders","children","Map","parent","parentId","set","get","$id","ids","queue","seen","Set","next","shift","has","add","push"],"mappings":"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"}
@@ -68,12 +68,20 @@ export interface MobilePushData {
68
68
  link?: string;
69
69
  orgId?: string;
70
70
  hostId?: string;
71
+ /**
72
+ * The account the push was sent to (AGL-3943). An app signed in to more
73
+ * than one account opens a tapped push in that account, switching to it
74
+ * when it is signed in but not the one in use. A push without it (sent
75
+ * before this field) routes as before, in the account in use.
76
+ */
77
+ uid?: string;
71
78
  }
72
79
  export declare function mobilePushData(payload: {
73
80
  type: string;
74
81
  link?: string | null;
75
82
  orgId?: string | null;
76
83
  hostId?: string | null;
84
+ uid?: string | null;
77
85
  }): MobilePushData;
78
86
  /** Read a tapped push's data back, refusing anything that is not ours. */
79
87
  export declare function readMobilePushData(data: unknown): MobilePushData | null;
@@ -53,6 +53,8 @@ export function mobilePushData(payload) {
53
53
  orgId: payload.orgId
54
54
  } : {}, payload.hostId ? {
55
55
  hostId: payload.hostId
56
+ } : {}, payload.uid ? {
57
+ uid: payload.uid
56
58
  } : {});
57
59
  }
58
60
  /** Read a tapped push's data back, refusing anything that is not ours. */ export function readMobilePushData(data) {
@@ -66,7 +68,8 @@ export function mobilePushData(payload) {
66
68
  // Only a console path or an https URL; never a scheme the app would hand to the OS.
67
69
  link: link && (link.startsWith('/') || link.startsWith('https://')) ? link : undefined,
68
70
  orgId: text(record['orgId']),
69
- hostId: text(record['hostId'])
71
+ hostId: text(record['hostId']),
72
+ uid: text(record['uid'])
70
73
  });
71
74
  }
72
75
  /**
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/mobile-push.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 mobile push channel's shapes (AGL-3620, AGL-3651), for the server\n * fan-out and the native apps alike.\n *\n * NO IMPORTS, not even types, on purpose: the native apps compile this file\n * with their own compiler, and a type import would drag the core's whole\n * type graph into it. The full per-scope resolution lives in\n * `notification-push.ts`, which the server reads; the app needs only the\n * account scope its settings screen edits, `accountPushSwitch` below, which\n * a spec holds to the full resolver.\n */\n\n/** `users/{uid}/devices/{installId}`: one row per app install, written by its owner. */\nexport const MOBILE_DEVICES_COLLECTION = 'devices'\n\nexport type MobileDevicePlatform = 'ios' | 'android' | 'macos'\n\n/** The two native apps (bundle `com.aglyn.app` and `com.aglyn.pos`). */\nexport type MobileDeviceApp = 'aglyn' | 'aglyn-pos'\n\n/** Each app's bundle id: the APNs topic its pushes are addressed to. */\nexport const MOBILE_APP_BUNDLES: Readonly<Record<MobileDeviceApp, string>> = {\n aglyn: 'com.aglyn.app',\n 'aglyn-pos': 'com.aglyn.pos',\n}\n\n/** How a device is reached: Apple's APNs (iOS, macOS) or Google's FCM (Android). */\nexport type MobilePushTransport = 'apns' | 'fcm'\n\n/** The APNs environment a device token was minted in; a token only works in its own. */\nexport type ApnsEnvironment = 'sandbox' | 'production'\n\nexport interface MobileDevice {\n /** An APNs device token (hex) or an FCM registration token, per `transport`. */\n token: string\n transport: MobilePushTransport\n /** APNs only: which gateway the token belongs to. */\n apnsEnvironment?: ApnsEnvironment\n platform: MobileDevicePlatform\n app: MobileDeviceApp\n appVersion?: string\n /** Server time of the app's last launch while signed in. */\n lastSeen?: unknown\n}\n\n/** A device not seen for this long is not pushed to (and is pruned by the fan-out). */\nexport const MOBILE_DEVICE_STALE_MS = 60 * 24 * 60 * 60 * 1000\n\n/** The most devices one person's push fan-out reads. */\nexport const MOBILE_DEVICES_PER_USER = 20\n\nconst HEX = /^[0-9A-Fa-f]+$/\nconst FCM_TOKEN = /^[A-Za-z0-9_:-]+$/\n\n/** An APNs device token: hex, 64 to 200 characters (the Firestore rules hold the same). */\nexport function isApnsDeviceToken(value: unknown): value is string {\n return typeof value === 'string' && value.length >= 64 && value.length <= 200 && HEX.test(value)\n}\n\n/** An FCM registration token: `[A-Za-z0-9_:-]`, 100 to 4096 characters (the rules hold the same). */\nexport function isFcmRegistrationToken(value: unknown): value is string {\n return typeof value === 'string' && value.length >= 100 && value.length <= 4096 && FCM_TOKEN.test(value)\n}\n\n/** Whether `token` is a token of `transport`'s shape. */\nexport function isMobilePushToken(transport: unknown, token: unknown): boolean {\n if (transport === 'apns') return isApnsDeviceToken(token)\n if (transport === 'fcm') return isFcmRegistrationToken(token)\n return false\n}\n\n/**\n * What a push carries for the app to act on when it is tapped. The app\n * resolves `link` the way it resolves a feed row: natively when a plugin\n * registered a deep link for it, else in the console WebView.\n */\nexport interface MobilePushData {\n type: string\n link?: string\n orgId?: string\n hostId?: string\n}\n\nexport function mobilePushData(payload: {\n type: string\n link?: string | null\n orgId?: string | null\n hostId?: string | null\n}): MobilePushData {\n return {\n type: payload.type,\n ...(payload.link ? { link: payload.link } : {}),\n ...(payload.orgId ? { orgId: payload.orgId } : {}),\n ...(payload.hostId ? { hostId: payload.hostId } : {}),\n }\n}\n\n/** Read a tapped push's data back, refusing anything that is not ours. */\nexport function readMobilePushData(data: unknown): MobilePushData | null {\n if (!data || typeof data !== 'object') return null\n const record = data as Record<string, unknown>\n if (typeof record['type'] !== 'string' || !record['type']) return null\n const text = (value: unknown) => (typeof value === 'string' && value ? value : undefined)\n const link = text(record['link'])\n return mobilePushData({\n type: record['type'],\n // Only a console path or an https URL; never a scheme the app would hand to the OS.\n link: link && (link.startsWith('/') || link.startsWith('https://')) ? link : undefined,\n orgId: text(record['orgId']),\n hostId: text(record['hostId']),\n })\n}\n\ntype ChannelAnswers = { console?: boolean; push?: boolean } | undefined\n\n/** The account-scope slice of `NotificationSettings` the app's switch reads. */\nexport interface AccountPushSettings {\n account?: Record<string, ChannelAnswers>\n accountTypes?: Record<string, ChannelAnswers>\n}\n\n/**\n * What the app's per-type push switch shows: the account's push answer for\n * the type, then for its category, and otherwise what the console feed does\n * for it at the account scope (its console answers, the legacy category\n * mute, then `consoleDefault`, the type's console default from the catalog).\n * Equal to `notificationPushEnabled` with no scope; a spec holds the two\n * together.\n */\nexport function accountPushSwitch(\n settings: AccountPushSettings | null | undefined,\n type: string,\n category: string,\n consoleDefault: boolean,\n legacyPrefs?: Record<string, boolean> | null,\n): boolean {\n for (const answer of [settings?.accountTypes?.[type]?.push, settings?.account?.[category]?.push]) {\n if (typeof answer === 'boolean') return answer\n }\n for (const answer of [settings?.accountTypes?.[type]?.console, settings?.account?.[category]?.console]) {\n if (typeof answer === 'boolean') return answer\n }\n if (legacyPrefs?.[category] === false) return false\n return consoleDefault\n}\n"],"names":["MOBILE_DEVICES_COLLECTION","MOBILE_APP_BUNDLES","aglyn","MOBILE_DEVICE_STALE_MS","MOBILE_DEVICES_PER_USER","HEX","FCM_TOKEN","isApnsDeviceToken","value","length","test","isFcmRegistrationToken","isMobilePushToken","transport","token","mobilePushData","payload","type","link","orgId","hostId","readMobilePushData","data","record","text","undefined","startsWith","accountPushSwitch","settings","category","consoleDefault","legacyPrefs","answer","accountTypes","push","account","console"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/mobile-push.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 mobile push channel's shapes (AGL-3620, AGL-3651), for the server\n * fan-out and the native apps alike.\n *\n * NO IMPORTS, not even types, on purpose: the native apps compile this file\n * with their own compiler, and a type import would drag the core's whole\n * type graph into it. The full per-scope resolution lives in\n * `notification-push.ts`, which the server reads; the app needs only the\n * account scope its settings screen edits, `accountPushSwitch` below, which\n * a spec holds to the full resolver.\n */\n\n/** `users/{uid}/devices/{installId}`: one row per app install, written by its owner. */\nexport const MOBILE_DEVICES_COLLECTION = 'devices'\n\nexport type MobileDevicePlatform = 'ios' | 'android' | 'macos'\n\n/** The two native apps (bundle `com.aglyn.app` and `com.aglyn.pos`). */\nexport type MobileDeviceApp = 'aglyn' | 'aglyn-pos'\n\n/** Each app's bundle id: the APNs topic its pushes are addressed to. */\nexport const MOBILE_APP_BUNDLES: Readonly<Record<MobileDeviceApp, string>> = {\n aglyn: 'com.aglyn.app',\n 'aglyn-pos': 'com.aglyn.pos',\n}\n\n/** How a device is reached: Apple's APNs (iOS, macOS) or Google's FCM (Android). */\nexport type MobilePushTransport = 'apns' | 'fcm'\n\n/** The APNs environment a device token was minted in; a token only works in its own. */\nexport type ApnsEnvironment = 'sandbox' | 'production'\n\nexport interface MobileDevice {\n /** An APNs device token (hex) or an FCM registration token, per `transport`. */\n token: string\n transport: MobilePushTransport\n /** APNs only: which gateway the token belongs to. */\n apnsEnvironment?: ApnsEnvironment\n platform: MobileDevicePlatform\n app: MobileDeviceApp\n appVersion?: string\n /** Server time of the app's last launch while signed in. */\n lastSeen?: unknown\n}\n\n/** A device not seen for this long is not pushed to (and is pruned by the fan-out). */\nexport const MOBILE_DEVICE_STALE_MS = 60 * 24 * 60 * 60 * 1000\n\n/** The most devices one person's push fan-out reads. */\nexport const MOBILE_DEVICES_PER_USER = 20\n\nconst HEX = /^[0-9A-Fa-f]+$/\nconst FCM_TOKEN = /^[A-Za-z0-9_:-]+$/\n\n/** An APNs device token: hex, 64 to 200 characters (the Firestore rules hold the same). */\nexport function isApnsDeviceToken(value: unknown): value is string {\n return typeof value === 'string' && value.length >= 64 && value.length <= 200 && HEX.test(value)\n}\n\n/** An FCM registration token: `[A-Za-z0-9_:-]`, 100 to 4096 characters (the rules hold the same). */\nexport function isFcmRegistrationToken(value: unknown): value is string {\n return typeof value === 'string' && value.length >= 100 && value.length <= 4096 && FCM_TOKEN.test(value)\n}\n\n/** Whether `token` is a token of `transport`'s shape. */\nexport function isMobilePushToken(transport: unknown, token: unknown): boolean {\n if (transport === 'apns') return isApnsDeviceToken(token)\n if (transport === 'fcm') return isFcmRegistrationToken(token)\n return false\n}\n\n/**\n * What a push carries for the app to act on when it is tapped. The app\n * resolves `link` the way it resolves a feed row: natively when a plugin\n * registered a deep link for it, else in the console WebView.\n */\nexport interface MobilePushData {\n type: string\n link?: string\n orgId?: string\n hostId?: string\n /**\n * The account the push was sent to (AGL-3943). An app signed in to more\n * than one account opens a tapped push in that account, switching to it\n * when it is signed in but not the one in use. A push without it (sent\n * before this field) routes as before, in the account in use.\n */\n uid?: string\n}\n\nexport function mobilePushData(payload: {\n type: string\n link?: string | null\n orgId?: string | null\n hostId?: string | null\n uid?: string | null\n}): MobilePushData {\n return {\n type: payload.type,\n ...(payload.link ? { link: payload.link } : {}),\n ...(payload.orgId ? { orgId: payload.orgId } : {}),\n ...(payload.hostId ? { hostId: payload.hostId } : {}),\n ...(payload.uid ? { uid: payload.uid } : {}),\n }\n}\n\n/** Read a tapped push's data back, refusing anything that is not ours. */\nexport function readMobilePushData(data: unknown): MobilePushData | null {\n if (!data || typeof data !== 'object') return null\n const record = data as Record<string, unknown>\n if (typeof record['type'] !== 'string' || !record['type']) return null\n const text = (value: unknown) => (typeof value === 'string' && value ? value : undefined)\n const link = text(record['link'])\n return mobilePushData({\n type: record['type'],\n // Only a console path or an https URL; never a scheme the app would hand to the OS.\n link: link && (link.startsWith('/') || link.startsWith('https://')) ? link : undefined,\n orgId: text(record['orgId']),\n hostId: text(record['hostId']),\n uid: text(record['uid']),\n })\n}\n\ntype ChannelAnswers = { console?: boolean; push?: boolean } | undefined\n\n/** The account-scope slice of `NotificationSettings` the app's switch reads. */\nexport interface AccountPushSettings {\n account?: Record<string, ChannelAnswers>\n accountTypes?: Record<string, ChannelAnswers>\n}\n\n/**\n * What the app's per-type push switch shows: the account's push answer for\n * the type, then for its category, and otherwise what the console feed does\n * for it at the account scope (its console answers, the legacy category\n * mute, then `consoleDefault`, the type's console default from the catalog).\n * Equal to `notificationPushEnabled` with no scope; a spec holds the two\n * together.\n */\nexport function accountPushSwitch(\n settings: AccountPushSettings | null | undefined,\n type: string,\n category: string,\n consoleDefault: boolean,\n legacyPrefs?: Record<string, boolean> | null,\n): boolean {\n for (const answer of [settings?.accountTypes?.[type]?.push, settings?.account?.[category]?.push]) {\n if (typeof answer === 'boolean') return answer\n }\n for (const answer of [settings?.accountTypes?.[type]?.console, settings?.account?.[category]?.console]) {\n if (typeof answer === 'boolean') return answer\n }\n if (legacyPrefs?.[category] === false) return false\n return consoleDefault\n}\n"],"names":["MOBILE_DEVICES_COLLECTION","MOBILE_APP_BUNDLES","aglyn","MOBILE_DEVICE_STALE_MS","MOBILE_DEVICES_PER_USER","HEX","FCM_TOKEN","isApnsDeviceToken","value","length","test","isFcmRegistrationToken","isMobilePushToken","transport","token","mobilePushData","payload","type","link","orgId","hostId","uid","readMobilePushData","data","record","text","undefined","startsWith","accountPushSwitch","settings","category","consoleDefault","legacyPrefs","answer","accountTypes","push","account","console"],"mappings":"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"}
@@ -31,8 +31,9 @@ export declare function nativeAppAssociationRoute(pathname: string): string | nu
31
31
  * link to one stays in the browser: the sign-in page and SSO (the system
32
32
  * auth session opens them for the app, and a universal link there would loop
33
33
  * back into the app), the native hand-off and other `/auth` pages, the
34
- * sign-out page, the site admin bar's connect page and a plugin's public
35
- * device pages. The sections the apps finish natively are
34
+ * sign-out page, the site admin bar's connect page, a plugin's public
35
+ * device pages and a site's photo link (`/p/<linkId>`, opened on a phone
36
+ * by someone with no account). The sections the apps finish natively are
36
37
  * {@link NATIVE_AUTH_LINK_SEGMENTS}.
37
38
  *
38
39
  * The rest of the console's top-level sections (`admin`, `billing`,
@@ -51,8 +51,9 @@
51
51
  * link to one stays in the browser: the sign-in page and SSO (the system
52
52
  * auth session opens them for the app, and a universal link there would loop
53
53
  * back into the app), the native hand-off and other `/auth` pages, the
54
- * sign-out page, the site admin bar's connect page and a plugin's public
55
- * device pages. The sections the apps finish natively are
54
+ * sign-out page, the site admin bar's connect page, a plugin's public
55
+ * device pages and a site's photo link (`/p/<linkId>`, opened on a phone
56
+ * by someone with no account). The sections the apps finish natively are
56
57
  * {@link NATIVE_AUTH_LINK_SEGMENTS}.
57
58
  *
58
59
  * The rest of the console's top-level sections (`admin`, `billing`,
@@ -65,6 +66,7 @@
65
66
  'auth',
66
67
  'edit-access',
67
68
  'kiosk',
69
+ 'p',
68
70
  'signin',
69
71
  'signout',
70
72
  'sso'