@aglyn/aglyn 1.0.0-beta.270 → 1.0.0-beta.271
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +11 -11
- package/src/lib/app-utils/activity-labels.js +2 -0
- package/src/lib/app-utils/activity-labels.js.map +1 -1
- package/src/lib/app-utils/analytics-events.d.ts +13 -0
- package/src/lib/app-utils/analytics-events.js +2 -0
- package/src/lib/app-utils/analytics-events.js.map +1 -1
- package/src/lib/app-utils/append-only-history.d.ts +94 -0
- package/src/lib/app-utils/append-only-history.js +73 -0
- package/src/lib/app-utils/append-only-history.js.map +1 -0
- package/src/lib/app-utils/bundle-intent.d.ts +186 -0
- package/src/lib/app-utils/bundle-intent.js +386 -0
- package/src/lib/app-utils/bundle-intent.js.map +1 -0
- package/src/lib/app-utils/client-build.d.ts +204 -0
- package/src/lib/app-utils/client-build.js +237 -0
- package/src/lib/app-utils/client-build.js.map +1 -0
- package/src/lib/app-utils/console-routes.d.ts +4 -0
- package/src/lib/app-utils/console-routes.js +2 -0
- package/src/lib/app-utils/console-routes.js.map +1 -1
- package/src/lib/app-utils/docs-help-sections.generated.js +7 -5
- package/src/lib/app-utils/docs-help-sections.generated.js.map +1 -1
- package/src/lib/app-utils/docs-help.generated.d.ts +5 -5
- package/src/lib/app-utils/docs-help.generated.js +9 -2
- package/src/lib/app-utils/docs-help.generated.js.map +1 -1
- package/src/lib/app-utils/docs-index.generated.js +101 -31
- package/src/lib/app-utils/docs-index.generated.js.map +1 -1
- package/src/lib/app-utils/margin-utilization.js +6 -4
- package/src/lib/app-utils/margin-utilization.js.map +1 -1
- package/src/lib/app-utils/media-filter.d.ts +26 -0
- package/src/lib/app-utils/media-filter.js +32 -0
- package/src/lib/app-utils/media-filter.js.map +1 -1
- package/src/lib/app-utils/onboarding-deep-link.d.ts +36 -2
- package/src/lib/app-utils/onboarding-deep-link.js +48 -5
- package/src/lib/app-utils/onboarding-deep-link.js.map +1 -1
- package/src/lib/app-utils/operator-alerts.js +21 -0
- package/src/lib/app-utils/operator-alerts.js.map +1 -1
- package/src/lib/app-utils/owner-approval-record.d.ts +102 -0
- package/src/lib/app-utils/owner-approval-record.js +148 -0
- package/src/lib/app-utils/owner-approval-record.js.map +1 -0
- package/src/lib/app-utils/plan-entitlements.d.ts +183 -67
- package/src/lib/app-utils/plan-entitlements.js +213 -58
- package/src/lib/app-utils/plan-entitlements.js.map +1 -1
- package/src/lib/app-utils/release-flags.d.ts +1 -1
- package/src/lib/app-utils/release-flags.js +10 -0
- package/src/lib/app-utils/release-flags.js.map +1 -1
- package/src/lib/app-utils/server.d.ts +4 -0
- package/src/lib/app-utils/server.js +7 -0
- package/src/lib/app-utils/server.js.map +1 -1
- package/src/lib/app-utils/site-care-project.d.ts +227 -0
- package/src/lib/app-utils/site-care-project.js +423 -0
- package/src/lib/app-utils/site-care-project.js.map +1 -0
- package/src/lib/app-utils/site-care.d.ts +268 -0
- package/src/lib/app-utils/site-care.js +283 -0
- package/src/lib/app-utils/site-care.js.map +1 -0
- package/src/lib/app-utils/upgrade-proposal.d.ts +29 -4
- package/src/lib/app-utils/upgrade-proposal.js +60 -9
- package/src/lib/app-utils/upgrade-proposal.js.map +1 -1
- package/src/lib/foundation/definitions/org-billing.types.d.ts +40 -10
- package/src/lib/foundation/definitions/org-billing.types.js.map +1 -1
- package/src/lib/foundation/definitions/organization.types.d.ts +3 -1
- package/src/lib/foundation/definitions/organization.types.js.map +1 -1
- package/src/lib/foundation/index.d.ts +1 -1
- package/src/lib/foundation/index.js.map +1 -1
- package/src/lib/plugin-manager/feature-plugins.d.ts +48 -0
- package/src/lib/plugin-manager/feature-plugins.js.map +1 -1
- package/src/lib/plugin-manager/plugin-ai-capabilities.d.ts +19 -0
- package/src/lib/plugin-manager/plugin-ai-capabilities.js.map +1 -1
- package/src/lib/plugin-manager/plugin-domain-search.d.ts +81 -0
- package/src/lib/plugin-manager/plugin-domain-search.js +63 -0
- package/src/lib/plugin-manager/plugin-domain-search.js.map +1 -0
- package/src/lib/plugin-manager/plugin-entitlements.d.ts +23 -5
- package/src/lib/plugin-manager/plugin-entitlements.js +10 -0
- package/src/lib/plugin-manager/plugin-entitlements.js.map +1 -1
- package/src/lib/plugin-manager/purchase-gate-preclearance.d.ts +61 -0
- package/src/lib/plugin-manager/purchase-gate-preclearance.js +102 -0
- package/src/lib/plugin-manager/purchase-gate-preclearance.js.map +1 -0
|
@@ -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 {
|
|
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
|
|
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. `
|
|
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
|
-
|
|
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":"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"}
|
|
@@ -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":"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"}
|
|
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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"}
|
|
@@ -14,12 +14,16 @@
|
|
|
14
14
|
* See the License for the specific language governing permissions and
|
|
15
15
|
* limitations under the License.
|
|
16
16
|
*/
|
|
17
|
-
import type { OrgPlan } from '../foundation/definitions/org-billing.types';
|
|
17
|
+
import type { AiTierId, OrgPlan } from '../foundation/definitions/org-billing.types';
|
|
18
|
+
import { type BundleAddons } from './bundle-intent';
|
|
18
19
|
/**
|
|
19
20
|
* The marketing → console onboarding deep link (AGL-1117).
|
|
20
21
|
*
|
|
21
22
|
* `app.aglyn.com/signup?plan=pro&interval=year`, optionally `&ai=1` for the
|
|
22
|
-
* Aglyn AI add-on (AGL-2897)
|
|
23
|
+
* Aglyn AI add-on (AGL-2897), or `&ai=<tier>` (`&ai=boost`) for a named
|
|
24
|
+
* Aglyn AI tier (AGL-3965). The bundle checkout link (AGL-3955) adds
|
|
25
|
+
* `&addons=siteCare,ai:boost,extraHost:2` for any add-on the plan sells and
|
|
26
|
+
* `&coupon=<code>` for a promotion code to apply at checkout.
|
|
23
27
|
*
|
|
24
28
|
* This is a CONTRACT with a site we do not control and cannot deploy in
|
|
25
29
|
* lockstep with. So it is parsed defensively and in one place: the pricing
|
|
@@ -63,6 +67,33 @@ export interface OnboardingPlanIntent {
|
|
|
63
67
|
* the add-on is quoted with the deal, not bought through a checkout.
|
|
64
68
|
*/
|
|
65
69
|
ai?: true;
|
|
70
|
+
/**
|
|
71
|
+
* The Aglyn AI tier the link named (AGL-3965): `?ai=boost`, or an
|
|
72
|
+
* `ai:boost` token in `addons=` (AGL-3955). Present only with `ai`; absent,
|
|
73
|
+
* `ai=1` means the tier with the plan's old price and band
|
|
74
|
+
* (`defaultAiTierForPlan`), which is what checkout sells.
|
|
75
|
+
*/
|
|
76
|
+
aiTier?: AiTierId;
|
|
77
|
+
/**
|
|
78
|
+
* Every add-on the `addons=` param preselects (AGL-3955), parsed by the
|
|
79
|
+
* shared `parseBundleAddons`. Absent when it named none. `ai=1` stays the
|
|
80
|
+
* `ai` flag above; an `ai` token here sets that flag too, so a reader that
|
|
81
|
+
* wants the whole set reads both (`intentAddons`).
|
|
82
|
+
* Whether the plan sells each one is the reader's question
|
|
83
|
+
* (`fitBundleAddonsToPlan`), because the owner can pick another plan.
|
|
84
|
+
*/
|
|
85
|
+
addons?: BundleAddons;
|
|
86
|
+
/**
|
|
87
|
+
* A promotion code to apply at checkout (AGL-3955). Applied only: codes are
|
|
88
|
+
* minted on the staff Coupons page. An invalid or expired one is dropped by
|
|
89
|
+
* the Billing page with a notice, never an error page.
|
|
90
|
+
*/
|
|
91
|
+
coupon?: string;
|
|
92
|
+
/**
|
|
93
|
+
* Add-on tokens the link named that nothing understood, as written, so the
|
|
94
|
+
* page can say in one line that they were left off.
|
|
95
|
+
*/
|
|
96
|
+
droppedAddons?: string[];
|
|
66
97
|
}
|
|
67
98
|
/**
|
|
68
99
|
* Read a plan intent off the URL, or null when there isn't a usable one.
|
|
@@ -73,6 +104,8 @@ export interface OnboardingPlanIntent {
|
|
|
73
104
|
* what they already have.
|
|
74
105
|
*/
|
|
75
106
|
export declare function parseOnboardingPlanIntent(params: URLSearchParams | Record<string, string | string[] | undefined> | null | undefined): OnboardingPlanIntent | null;
|
|
107
|
+
/** The whole add-on set an intent asks for: `addons=` and the `ai` flag together. */
|
|
108
|
+
export declare function intentAddons(intent: Pick<OnboardingPlanIntent, 'ai' | 'addons'> | null | undefined): BundleAddons;
|
|
76
109
|
/**
|
|
77
110
|
* The intent as query params — the one place the wire form is written
|
|
78
111
|
* (AGL-1535).
|
|
@@ -109,6 +142,7 @@ export declare function onboardingPlanQuery(intent: OnboardingPlanIntent): strin
|
|
|
109
142
|
*/
|
|
110
143
|
export declare function onboardingSignupHref(signupUrl: string, plan: OrgPlan, interval: OnboardingInterval | null, options?: {
|
|
111
144
|
ai?: boolean;
|
|
145
|
+
aiTier?: AiTierId;
|
|
112
146
|
}): string;
|
|
113
147
|
/**
|
|
114
148
|
* Where a freshly provisioned workspace should land, given the plan the
|
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
* See the License for the specific language governing permissions and
|
|
15
15
|
* limitations under the License.
|
|
16
16
|
*/ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
17
|
-
import {
|
|
17
|
+
import { bundleAddonsParam, hasBundleAddons, normalizeBundleCoupon, parseBundleAddons } from "./bundle-intent.js";
|
|
18
|
+
import { isAiTierId, isCustomPricedPlan } from "./plan-entitlements.js";
|
|
18
19
|
const SELF_SERVE_PLANS = [
|
|
19
20
|
'starter',
|
|
20
21
|
'pro',
|
|
@@ -31,6 +32,7 @@ const SELF_SERVE_PLANS = [
|
|
|
31
32
|
* Returning an intent for it would send someone to a billing page to buy
|
|
32
33
|
* what they already have.
|
|
33
34
|
*/ export function parseOnboardingPlanIntent(params) {
|
|
35
|
+
var _parsedAddons_aiTier;
|
|
34
36
|
if (!params) return null;
|
|
35
37
|
const read = (key)=>{
|
|
36
38
|
var _ref;
|
|
@@ -57,7 +59,20 @@ const SELF_SERVE_PLANS = [
|
|
|
57
59
|
// empty, `0`, `false`, a typo — is "not asked for": a checkout that adds a
|
|
58
60
|
// paid item off a malformed link is the expensive direction to be wrong in.
|
|
59
61
|
const ai = read('ai');
|
|
60
|
-
|
|
62
|
+
// The bundle (AGL-3955). Read case-preserving: a coupon code is typed the
|
|
63
|
+
// way Stripe stores it, and `read` lower-cases.
|
|
64
|
+
const rawParam = (key)=>{
|
|
65
|
+
var _ref;
|
|
66
|
+
const raw = params instanceof URLSearchParams ? params.get(key) : params[key];
|
|
67
|
+
return String((_ref = Array.isArray(raw) ? raw[0] : raw) != null ? _ref : '').trim();
|
|
68
|
+
};
|
|
69
|
+
const parsedAddons = parseBundleAddons(rawParam('addons'));
|
|
70
|
+
const addons = _extends({}, parsedAddons.addons);
|
|
71
|
+
const coupon = normalizeBundleCoupon(rawParam('coupon'));
|
|
72
|
+
// `?ai=<tier>` names the tier outright; otherwise an `ai:<tier>` token in
|
|
73
|
+
// `addons=` does (AGL-3965).
|
|
74
|
+
const aiTier = isAiTierId(ai) ? ai : (_parsedAddons_aiTier = parsedAddons.aiTier) != null ? _parsedAddons_aiTier : null;
|
|
75
|
+
const aiStated = ai === '1' || ai === 'true' || ai === 'yes' || aiTier !== null;
|
|
61
76
|
return _extends({
|
|
62
77
|
plan: plan,
|
|
63
78
|
// Anything unrecognized falls to monthly, never to yearly: guessing the
|
|
@@ -69,8 +84,22 @@ const SELF_SERVE_PLANS = [
|
|
|
69
84
|
// org rather than be told "monthly" with a straight face.
|
|
70
85
|
intervalStated: known,
|
|
71
86
|
contactSales: false
|
|
72
|
-
}, aiStated ? {
|
|
87
|
+
}, aiStated || addons.aiAddon ? {
|
|
73
88
|
ai: true
|
|
89
|
+
} : {}, aiTier ? {
|
|
90
|
+
aiTier
|
|
91
|
+
} : {}, hasBundleAddons(addons) ? {
|
|
92
|
+
addons
|
|
93
|
+
} : {}, coupon ? {
|
|
94
|
+
coupon
|
|
95
|
+
} : {}, parsedAddons.dropped.length ? {
|
|
96
|
+
droppedAddons: parsedAddons.dropped
|
|
97
|
+
} : {});
|
|
98
|
+
}
|
|
99
|
+
/** The whole add-on set an intent asks for: `addons=` and the `ai` flag together. */ export function intentAddons(intent) {
|
|
100
|
+
var _ref;
|
|
101
|
+
return _extends({}, (_ref = intent == null ? void 0 : intent.addons) != null ? _ref : {}, (intent == null ? void 0 : intent.ai) ? {
|
|
102
|
+
aiAddon: 1
|
|
74
103
|
} : {});
|
|
75
104
|
}
|
|
76
105
|
/**
|
|
@@ -82,7 +111,19 @@ const SELF_SERVE_PLANS = [
|
|
|
82
111
|
* `&interval=` unconditionally, which turned the parser's monthly DEFAULT into
|
|
83
112
|
* a statement the next reader could not tell from the real thing.
|
|
84
113
|
*/ export function onboardingPlanQuery(intent) {
|
|
85
|
-
|
|
114
|
+
var _intent_addons, _intent_aiTier;
|
|
115
|
+
// `ai=1` stays the spelling when Aglyn AI is the only add-on, so the
|
|
116
|
+
// published pricing CTAs and every hop between them read unchanged. Any
|
|
117
|
+
// other add-on writes the whole set as `addons=` (AGL-3955), Aglyn AI with it.
|
|
118
|
+
const addons = _extends({}, (_intent_addons = intent.addons) != null ? _intent_addons : {});
|
|
119
|
+
if (intent.ai) addons.aiAddon = 1;
|
|
120
|
+
const others = Object.keys(addons).some((kind)=>{
|
|
121
|
+
var _addons_kind;
|
|
122
|
+
return kind !== 'aiAddon' && ((_addons_kind = addons[kind]) != null ? _addons_kind : 0) > 0;
|
|
123
|
+
});
|
|
124
|
+
const coupon = normalizeBundleCoupon(intent.coupon);
|
|
125
|
+
return `plan=${encodeURIComponent(intent.plan)}` + (intent.intervalStated ? `&interval=${intent.interval}` : '') + (others ? // readable when it is read aloud or typed off a QR's caption.
|
|
126
|
+
`&addons=${bundleAddonsParam(addons, intent.aiTier)}` : addons.aiAddon ? `&ai=${(_intent_aiTier = intent.aiTier) != null ? _intent_aiTier : '1'}` : '') + (coupon ? `&coupon=${encodeURIComponent(coupon)}` : '');
|
|
86
127
|
}
|
|
87
128
|
/**
|
|
88
129
|
* The signup URL a marketing CTA should carry — the WRITER half of the
|
|
@@ -121,8 +162,10 @@ const SELF_SERVE_PLANS = [
|
|
|
121
162
|
interval: stated ? interval : 'month',
|
|
122
163
|
intervalStated: stated,
|
|
123
164
|
contactSales
|
|
124
|
-
}, options.ai && !contactSales ? {
|
|
165
|
+
}, (options.ai || options.aiTier) && !contactSales ? {
|
|
125
166
|
ai: true
|
|
167
|
+
} : {}, options.aiTier && !contactSales ? {
|
|
168
|
+
aiTier: options.aiTier
|
|
126
169
|
} : {}))}`;
|
|
127
170
|
}
|
|
128
171
|
/**
|