@aglyn/aglyn 1.0.0-beta.219 → 1.0.0-beta.221
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 +12 -12
- package/src/lib/app-utils/actions.d.ts +1 -59
- package/src/lib/app-utils/actions.js +7 -72
- package/src/lib/app-utils/actions.js.map +1 -1
- package/src/lib/app-utils/consent-groups.d.ts +1 -1
- package/src/lib/app-utils/consent-groups.js.map +1 -1
- package/src/lib/app-utils/console-routes.d.ts +0 -36
- package/src/lib/app-utils/console-routes.js +2 -37
- package/src/lib/app-utils/console-routes.js.map +1 -1
- package/src/lib/app-utils/contacts.d.ts +1 -1
- package/src/lib/app-utils/contacts.js.map +1 -1
- package/src/lib/app-utils/container-membership.d.ts +93 -0
- package/src/lib/app-utils/container-membership.js +165 -0
- package/src/lib/app-utils/container-membership.js.map +1 -0
- package/src/lib/app-utils/crm-task-reminders.d.ts +23 -6
- package/src/lib/app-utils/crm-task-reminders.js +32 -11
- package/src/lib/app-utils/crm-task-reminders.js.map +1 -1
- package/src/lib/app-utils/crm.d.ts +24 -4
- package/src/lib/app-utils/crm.js +23 -0
- package/src/lib/app-utils/crm.js.map +1 -1
- package/src/lib/app-utils/csv-upload.d.ts +1 -1
- package/src/lib/app-utils/csv-upload.js +1 -1
- package/src/lib/app-utils/csv-upload.js.map +1 -1
- package/src/lib/app-utils/{dataset-csv.d.ts → csv.d.ts} +10 -43
- package/src/lib/app-utils/{dataset-csv.js → csv.js} +10 -81
- package/src/lib/app-utils/csv.js.map +1 -0
- package/src/lib/app-utils/datasets.d.ts +0 -19
- package/src/lib/app-utils/datasets.js +0 -13
- package/src/lib/app-utils/datasets.js.map +1 -1
- package/src/lib/app-utils/docs-help.generated.d.ts +4 -4
- package/src/lib/app-utils/docs-help.generated.js +5 -0
- package/src/lib/app-utils/docs-help.generated.js.map +1 -1
- package/src/lib/app-utils/docs-index.generated.js +71 -36
- package/src/lib/app-utils/docs-index.generated.js.map +1 -1
- package/src/lib/app-utils/dynamic-list-rule.js +3 -3
- package/src/lib/app-utils/dynamic-list-rule.js.map +1 -1
- package/src/lib/app-utils/email-text.d.ts +80 -0
- package/src/lib/app-utils/email-text.js +189 -0
- package/src/lib/app-utils/email-text.js.map +1 -0
- package/src/lib/app-utils/{list-assignment-policy.d.ts → enrollment-basis.d.ts} +4 -0
- package/src/lib/app-utils/{list-assignment-policy.js → enrollment-basis.js} +5 -1
- package/src/lib/app-utils/enrollment-basis.js.map +1 -0
- package/src/lib/app-utils/expand-repeatables.js +4 -4
- package/src/lib/app-utils/expand-repeatables.js.map +1 -1
- package/src/lib/app-utils/forms.js +2 -2
- package/src/lib/app-utils/forms.js.map +1 -1
- package/src/lib/app-utils/health-report.d.ts +2 -3
- package/src/lib/app-utils/health-report.js +30 -6
- package/src/lib/app-utils/health-report.js.map +1 -1
- package/src/lib/app-utils/inbound-mail-domain.d.ts +27 -0
- package/src/lib/app-utils/inbound-mail-domain.js +31 -0
- package/src/lib/app-utils/inbound-mail-domain.js.map +1 -0
- package/src/lib/app-utils/leaving-notice.d.ts +96 -0
- package/src/lib/app-utils/leaving-notice.js +157 -0
- package/src/lib/app-utils/leaving-notice.js.map +1 -0
- package/src/lib/app-utils/lockdown.d.ts +11 -0
- package/src/lib/app-utils/lockdown.js +33 -3
- package/src/lib/app-utils/lockdown.js.map +1 -1
- package/src/lib/app-utils/member-email-aliases.d.ts +25 -0
- package/src/lib/app-utils/member-email-aliases.js +29 -0
- package/src/lib/app-utils/member-email-aliases.js.map +1 -1
- package/src/lib/app-utils/notifications.d.ts +69 -0
- package/src/lib/app-utils/notifications.js +119 -0
- package/src/lib/app-utils/notifications.js.map +1 -1
- package/src/lib/app-utils/operator-alerts.d.ts +15 -1
- package/src/lib/app-utils/operator-alerts.js +64 -10
- package/src/lib/app-utils/operator-alerts.js.map +1 -1
- package/src/lib/app-utils/plan-entitlements.d.ts +164 -178
- package/src/lib/app-utils/plan-entitlements.js +200 -224
- package/src/lib/app-utils/plan-entitlements.js.map +1 -1
- package/src/lib/app-utils/platform-marketing-consent.d.ts +56 -2
- package/src/lib/app-utils/platform-marketing-consent.js +6 -5
- package/src/lib/app-utils/platform-marketing-consent.js.map +1 -1
- package/src/lib/app-utils/plugin-starter-templates.generated.d.ts +12 -0
- package/src/lib/app-utils/plugin-starter-templates.generated.js +711 -0
- package/src/lib/app-utils/plugin-starter-templates.generated.js.map +1 -0
- package/src/lib/app-utils/repeat-query.d.ts +56 -0
- package/src/lib/app-utils/{dataset-query.js → repeat-query.js} +16 -16
- package/src/lib/app-utils/repeat-query.js.map +1 -0
- package/src/lib/app-utils/screen-link-context-value.d.ts +7 -0
- package/src/lib/app-utils/screen-link-context-value.js.map +1 -1
- package/src/lib/app-utils/screen-link-context.js +14 -2
- package/src/lib/app-utils/screen-link-context.js.map +1 -1
- package/src/lib/app-utils/screen-route.d.ts +3 -0
- package/src/lib/app-utils/screen-route.js +6 -1
- package/src/lib/app-utils/screen-route.js.map +1 -1
- package/src/lib/app-utils/server.d.ts +7 -9
- package/src/lib/app-utils/server.js +18 -26
- package/src/lib/app-utils/server.js.map +1 -1
- package/src/lib/app-utils/starter-template-nodes.d.ts +137 -0
- package/src/lib/app-utils/starter-template-nodes.js +129 -0
- package/src/lib/app-utils/starter-template-nodes.js.map +1 -0
- package/src/lib/app-utils/starter-templates.d.ts +11 -34
- package/src/lib/app-utils/starter-templates.js +19 -230
- package/src/lib/app-utils/starter-templates.js.map +1 -1
- package/src/lib/app-utils/{email-topics.d.ts → subscription-topics.d.ts} +64 -48
- package/src/lib/app-utils/{email-topics.js → subscription-topics.js} +39 -109
- package/src/lib/app-utils/subscription-topics.js.map +1 -0
- package/src/lib/app-utils/usage-budget.d.ts +73 -188
- package/src/lib/app-utils/usage-budget.js +58 -194
- package/src/lib/app-utils/usage-budget.js.map +1 -1
- package/src/lib/app-utils/utm-attribution.d.ts +1 -1
- package/src/lib/app-utils/utm-attribution.js +1 -1
- package/src/lib/app-utils/utm-attribution.js.map +1 -1
- package/src/lib/app-utils/{campaign-touch.d.ts → utm-touch.d.ts} +21 -13
- package/src/lib/app-utils/{campaign-touch.js → utm-touch.js} +30 -22
- package/src/lib/app-utils/utm-touch.js.map +1 -0
- package/src/lib/app-utils/variables.d.ts +9 -9
- package/src/lib/app-utils/variables.js.map +1 -1
- package/src/lib/app-utils/visitor-record-ceiling.d.ts +32 -0
- package/src/lib/app-utils/visitor-record-ceiling.js +39 -0
- package/src/lib/app-utils/visitor-record-ceiling.js.map +1 -1
- package/src/lib/foundation/definitions/org-billing.types.d.ts +2 -2
- package/src/lib/foundation/definitions/org-billing.types.js.map +1 -1
- package/src/lib/plugin-manager/feature-plugins.d.ts +80 -79
- package/src/lib/plugin-manager/feature-plugins.js +22 -35
- package/src/lib/plugin-manager/feature-plugins.js.map +1 -1
- package/src/lib/plugin-manager/first-party-plugins.generated.d.ts +18 -1
- package/src/lib/plugin-manager/first-party-plugins.generated.js +62 -2
- package/src/lib/plugin-manager/first-party-plugins.generated.js.map +1 -1
- package/src/lib/plugin-manager/index.d.ts +0 -1
- package/src/lib/plugin-manager/index.js +0 -1
- package/src/lib/plugin-manager/index.js.map +1 -1
- package/src/lib/plugin-manager/plugin-contact-capture.d.ts +9 -4
- package/src/lib/plugin-manager/plugin-contact-capture.js.map +1 -1
- package/src/lib/plugin-manager/plugin-containers.d.ts +68 -0
- package/src/lib/plugin-manager/plugin-containers.generated.d.ts +10 -0
- package/src/lib/plugin-manager/plugin-containers.generated.js +20 -0
- package/src/lib/plugin-manager/plugin-containers.generated.js.map +1 -0
- package/src/lib/plugin-manager/plugin-containers.js +27 -0
- package/src/lib/plugin-manager/plugin-containers.js.map +1 -0
- package/src/lib/plugin-manager/plugin-entitlements.d.ts +8 -0
- package/src/lib/plugin-manager/plugin-entitlements.js.map +1 -1
- package/src/lib/plugin-manager/plugin-membership-detach.js +1 -1
- package/src/lib/plugin-manager/plugin-membership-detach.js.map +1 -1
- package/src/lib/plugin-manager/plugin-record-email-state.d.ts +69 -0
- package/src/lib/plugin-manager/plugin-record-email-state.js +32 -0
- package/src/lib/plugin-manager/plugin-record-email-state.js.map +1 -1
- package/src/lib/plugin-manager/plugin-record-lists.d.ts +115 -0
- package/src/lib/plugin-manager/plugin-record-lists.js +75 -0
- package/src/lib/plugin-manager/plugin-record-lists.js.map +1 -0
- package/src/lib/plugin-manager/plugin-record-timeline.d.ts +42 -8
- package/src/lib/plugin-manager/plugin-record-timeline.js.map +1 -1
- package/src/lib/plugin-manager/plugin-server-steps.d.ts +170 -0
- package/src/lib/plugin-manager/plugin-server-steps.js +116 -0
- package/src/lib/plugin-manager/plugin-server-steps.js.map +1 -0
- package/src/lib/plugin-manager/plugin-site-beacons.d.ts +71 -0
- package/src/lib/plugin-manager/plugin-site-beacons.js +54 -0
- package/src/lib/plugin-manager/plugin-site-beacons.js.map +1 -0
- package/src/lib/plugin-manager/plugin-subscription-topics.generated.d.ts +11 -0
- package/src/lib/plugin-manager/plugin-subscription-topics.generated.js +41 -0
- package/src/lib/plugin-manager/plugin-subscription-topics.generated.js.map +1 -0
- package/src/lib/plugin-manager/plugin-usage-axes.d.ts +212 -0
- package/src/lib/plugin-manager/plugin-usage-axes.js +82 -3
- package/src/lib/plugin-manager/plugin-usage-axes.js.map +1 -1
- package/src/lib/plugin-manager/plugin-usage-meters.d.ts +108 -0
- package/src/lib/plugin-manager/plugin-usage-meters.js +105 -0
- package/src/lib/plugin-manager/plugin-usage-meters.js.map +1 -0
- package/src/lib/plugin-manager/usage-alert-contributors.d.ts +76 -6
- package/src/lib/plugin-manager/usage-alert-contributors.js +23 -10
- package/src/lib/plugin-manager/usage-alert-contributors.js.map +1 -1
- package/src/lib/app-utils/assist-credits.d.ts +0 -482
- package/src/lib/app-utils/assist-credits.js +0 -510
- package/src/lib/app-utils/assist-credits.js.map +0 -1
- package/src/lib/app-utils/campaign-membership.d.ts +0 -121
- package/src/lib/app-utils/campaign-membership.js +0 -189
- package/src/lib/app-utils/campaign-membership.js.map +0 -1
- package/src/lib/app-utils/campaign-touch.js.map +0 -1
- package/src/lib/app-utils/console-record-links.d.ts +0 -84
- package/src/lib/app-utils/console-record-links.js +0 -76
- package/src/lib/app-utils/console-record-links.js.map +0 -1
- package/src/lib/app-utils/crm-booking.d.ts +0 -75
- package/src/lib/app-utils/crm-booking.js +0 -133
- package/src/lib/app-utils/crm-booking.js.map +0 -1
- package/src/lib/app-utils/crm-inbound.d.ts +0 -245
- package/src/lib/app-utils/crm-inbound.js +0 -407
- package/src/lib/app-utils/crm-inbound.js.map +0 -1
- package/src/lib/app-utils/crm-reports.d.ts +0 -479
- package/src/lib/app-utils/crm-reports.js +0 -680
- package/src/lib/app-utils/crm-reports.js.map +0 -1
- package/src/lib/app-utils/dataset-csv.js.map +0 -1
- package/src/lib/app-utils/dataset-query.d.ts +0 -57
- package/src/lib/app-utils/dataset-query.js.map +0 -1
- package/src/lib/app-utils/email-topics.js.map +0 -1
- package/src/lib/app-utils/list-assignment-policy.js.map +0 -1
- package/src/lib/plugin-manager/plugin-metered-lines.d.ts +0 -97
- package/src/lib/plugin-manager/plugin-metered-lines.js +0 -123
- package/src/lib/plugin-manager/plugin-metered-lines.js.map +0 -1
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/crm-reports.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * CRM reporting arithmetic (AGL-2604).\n *\n * The Reports section and the dashboard glance card read Firestore\n * aggregates and bounded windows and then have to turn them into a period, a\n * set of weekly bars, a funnel and a pipeline total. Every one of those is a\n * decision about numbers that a card could get subtly wrong in its own JSX —\n * a bucket boundary that is closed on both ends counts a midnight contact\n * twice, a funnel that divides by the step before reads 100% where nobody\n * arrived, a forecast that weights a closed deal forecasts a sale twice. So\n * they live here, pure and specified, and the cards only draw.\n *\n * Pure data module: no Firestore, no React, no clock — every function that\n * needs \"now\" is handed it.\n */\n\nimport type { ContactFacet, ContactSource } from './contacts'\nimport {\n CONTACT_LIFECYCLE_STAGES,\n CONTACT_LIFECYCLE_STAGE_LABELS,\n CRM_ACTIVITY_KINDS,\n CRM_LEAD_STATUSES,\n type ContactLifecycleStage,\n type CrmActivity,\n type CrmActivityKind,\n type CrmDeal,\n type CrmDealStage,\n type CrmLeadFields,\n type CrmLeadStatus,\n type CrmPipeline,\n type CrmTask,\n crmLeadStatus,\n dealStageById,\n isCrmActivityKind,\n isPipelineArchived,\n weightedDealAmountCents,\n} from './crm'\n\nconst DAY_MS = 24 * 60 * 60 * 1000\nconst WEEK_MS = 7 * DAY_MS\n\n/**\n * The periods a report can be read over.\n *\n * A fixed list rather than a free date range because every period here is\n * also a READ COST: the contact trend runs one count aggregate per week of\n * the period, and the closed-deal chart reads the deals closed within it. A\n * picker that offered \"all time\" would offer a read nobody has sized.\n */\nexport const CRM_REPORT_PERIODS = ['7d', '30d', '90d', 'month'] as const\n\nexport type CrmReportPeriod = (typeof CRM_REPORT_PERIODS)[number]\n\nexport const CRM_REPORT_PERIOD_LABELS: Record<CrmReportPeriod, string> = {\n '7d': 'Last 7 days',\n '30d': 'Last 30 days',\n '90d': 'Last 90 days',\n month: 'This month',\n}\n\n/**\n * A period and the period before it, as half-open millisecond ranges\n * `[from, to)`.\n *\n * The previous period is what a delta is measured against, so it is always\n * the same LENGTH as the current one and ends exactly where the current one\n * begins: \"this month\" against \"last month\", thirty days against the thirty\n * before them. A gap or an overlap between the two would make the comparison\n * a comparison of different things.\n */\nexport interface CrmReportRange {\n from: number\n to: number\n previousFrom: number\n previousTo: number\n}\n\n/**\n * The range a period names at a given moment.\n *\n * `to` is the moment itself, so a period is always \"up to now\" and a report\n * read twice in one sitting shows the same figures with the newer minutes\n * added. \"This month\" starts on the first of the LOCAL month, because that\n * is the month the reader sees on their calendar; the day periods count back\n * from the moment rather than from midnight, so \"last 7 days\" is a full\n * seven days rather than six and a fraction.\n */\nexport function crmReportRange(\n period: CrmReportPeriod,\n nowMs: number,\n): CrmReportRange {\n if (period === 'month') {\n const now = new Date(nowMs)\n const from = new Date(now.getFullYear(), now.getMonth(), 1).getTime()\n const previousFrom = new Date(\n now.getFullYear(),\n now.getMonth() - 1,\n 1,\n ).getTime()\n return { from, to: nowMs, previousFrom, previousTo: from }\n }\n const days = period === '7d' ? 7 : period === '30d' ? 30 : 90\n const from = nowMs - days * DAY_MS\n return {\n from,\n to: nowMs,\n previousFrom: from - days * DAY_MS,\n previousTo: from,\n }\n}\n\n/** One week of a period, `[start, end)`; the last week of a period may be short. */\nexport interface WeekBucketBounds {\n start: number\n end: number\n}\n\n/**\n * The weeks a range divides into: seven-day strides from `from`, the last\n * clipped at `to`.\n *\n * Strides from the START of the period rather than calendar weeks, so that\n * \"last 7 days\" is one bar and \"last 30 days\" is four full weeks and two\n * days — the period the reader picked, in the reader's units — instead of\n * whatever fragment of a Monday-to-Sunday week the period happened to start\n * inside. An empty or inverted range has no weeks.\n */\nexport function weekBuckets(from: number, to: number): WeekBucketBounds[] {\n const buckets: WeekBucketBounds[] = []\n if (!(to > from)) return buckets\n for (let start = from; start < to; start += WEEK_MS) {\n buckets.push({ start, end: Math.min(start + WEEK_MS, to) })\n }\n return buckets\n}\n\nexport interface WeekBucket<T> extends WeekBucketBounds {\n items: T[]\n}\n\n/**\n * The items of a range, grouped into its weeks.\n *\n * Half-open on every boundary, so an item at exactly midnight between two\n * weeks is in the later one and only the later one, and an item at `to` is\n * outside the period the way an item at `from` is inside it. Items with no\n * usable time are dropped rather than placed anywhere — an undated deal is\n * not a deal closed this week. Every week is returned, empty ones included,\n * because a chart wants a bar per week and a gap where a week had nothing is\n * a chart with the wrong number of bars.\n */\nexport function bucketByWeek<T>(\n items: readonly T[],\n atMs: (item: T) => number | null | undefined,\n from: number,\n to: number,\n): WeekBucket<T>[] {\n const buckets: WeekBucket<T>[] = weekBuckets(from, to).map((bounds) => ({\n ...bounds,\n items: [] as T[],\n }))\n if (!buckets.length) return buckets\n for (const item of items) {\n const at = atMs(item)\n if (typeof at !== 'number' || !Number.isFinite(at)) continue\n if (at < from || at >= to) continue\n const index = Math.min(\n buckets.length - 1,\n Math.floor((at - from) / WEEK_MS),\n )\n buckets[index].items.push(item)\n }\n return buckets\n}\n\n/** One step of the lifecycle funnel. */\nexport interface LifecycleFunnelStep {\n stage: ContactLifecycleStage\n label: string\n /** People AT this stage. */\n count: number\n /** People at this stage or any later one — everyone who got at least this far. */\n reached: number\n /**\n * `reached` over the previous step's `reached`, 0–1, or `null` on the first\n * step and wherever nobody reached the step before.\n */\n conversion: number | null\n}\n\nexport interface LifecycleFunnel {\n /** The progression, in `CONTACT_LIFECYCLE_STAGES` order, `other` left out. */\n steps: LifecycleFunnelStep[]\n /** People at the `other` stage, which is beside the funnel rather than in it. */\n other: number\n /** Every counted person, `other` included. */\n total: number\n}\n\n/**\n * The lifecycle funnel a set of per-stage counts describes.\n *\n * A funnel is read CUMULATIVELY: a customer was once a lead, so the \"lead\"\n * step is everyone who is a lead now or has moved past it, not only the\n * people currently sitting there. That is what makes the steps narrow as\n * they go down and what makes a step-to-step conversion mean something.\n * `other` is a stage a merchant chose for a person the funnel does not\n * describe, so it is reported beside the funnel and never folded into a\n * step. A conversion where the previous step was empty is `null` rather than\n * 0 or 100 — there is nothing to divide by, and either number would be read\n * as a measurement.\n */\nexport function funnelFromStages(\n counts: Partial<Record<ContactLifecycleStage, number>>,\n): LifecycleFunnel {\n const countOf = (stage: ContactLifecycleStage): number => {\n const value = Number(counts[stage] ?? 0)\n return Number.isFinite(value) && value > 0 ? Math.round(value) : 0\n }\n const progression = CONTACT_LIFECYCLE_STAGES.filter(\n (stage) => stage !== 'other',\n )\n const steps: LifecycleFunnelStep[] = []\n let reachedAfter = 0\n for (let index = progression.length - 1; index >= 0; index -= 1) {\n const stage = progression[index]\n const count = countOf(stage)\n const reached = count + reachedAfter\n steps.unshift({\n stage,\n label: CONTACT_LIFECYCLE_STAGE_LABELS[stage],\n count,\n reached,\n conversion: null,\n })\n reachedAfter = reached\n }\n for (let index = 1; index < steps.length; index += 1) {\n const previous = steps[index - 1].reached\n steps[index].conversion = previous > 0 ? steps[index].reached / previous : null\n }\n const other = countOf('other')\n return {\n steps,\n other,\n total: steps.reduce((sum, step) => sum + step.count, 0) + other,\n }\n}\n\n/** The open deals sitting in one stage of a pipeline. */\nexport interface PipelineStageTotal {\n stage: CrmDealStage\n count: number\n amountCents: number\n weightedCents: number\n}\n\nexport interface PipelineTotals {\n /** Open deals, wherever they sit. */\n count: number\n amountCents: number\n /** The forecast: each open deal at its stage's odds. */\n weightedCents: number\n /** The pipeline's open stages in order, every one present even when empty. */\n stages: PipelineStageTotal[]\n /**\n * Open deals in a stage the pipeline does not have — retired, renamed, or\n * a closed-kind stage the status disagrees with. Counted in the totals,\n * worth nothing to the forecast, and reported so a merchant can find them.\n */\n unplaced: { count: number; amountCents: number }\n}\n\ntype PipelineDeal = Pick<CrmDeal, 'status' | 'stageId' | 'amountCents'>\n\n/**\n * What a pipeline holds, by stage.\n *\n * Only OPEN deals: a won deal is revenue and a lost one is history, and a\n * pipeline report that counted either would show a stage full of deals that\n * cannot move. Only the pipeline's open-kind stages are rows, for the same\n * reason from the other side — a \"Won\" column in a pipeline chart is the\n * closed-deals report drawn in the wrong place. The weighting is\n * `weightedDealAmountCents`, applied here rather than restated.\n */\nexport function pipelineTotals(\n deals: readonly PipelineDeal[],\n pipeline: Pick<CrmPipeline, 'stages'> | null | undefined,\n): PipelineTotals {\n const stages = [...(pipeline?.stages ?? [])]\n .filter((stage) => stage.kind === 'open')\n .sort((a, b) => a.order - b.order)\n const rows = new Map<string, PipelineStageTotal>(\n stages.map((stage) => [\n stage.id,\n { stage, count: 0, amountCents: 0, weightedCents: 0 },\n ]),\n )\n const totals: PipelineTotals = {\n count: 0,\n amountCents: 0,\n weightedCents: 0,\n stages: stages.map((stage) => rows.get(stage.id) as PipelineStageTotal),\n unplaced: { count: 0, amountCents: 0 },\n }\n for (const deal of deals) {\n if (deal.status !== 'open') continue\n const amount = Math.max(0, Math.round(Number(deal.amountCents ?? 0) || 0))\n totals.count += 1\n totals.amountCents += amount\n const row = rows.get(deal.stageId)\n if (!row) {\n totals.unplaced.count += 1\n totals.unplaced.amountCents += amount\n continue\n }\n const weighted = weightedDealAmountCents(deal, row.stage)\n row.count += 1\n row.amountCents += amount\n row.weightedCents += weighted\n totals.weightedCents += weighted\n }\n return totals\n}\n\n/** How many months ahead the close-month forecast looks. */\nexport const FORECAST_MONTHS = 6\n\n/** One calendar month of the forecast, `[start, end)` in local time. */\nexport interface CloseMonthBucket {\n /** `2026-09` — stable across locales, what a row is keyed by. */\n key: string\n start: number\n end: number\n}\n\n/**\n * The next `months` calendar months from the one `nowMs` falls in, each\n * `[first of the month, first of the next)` on the reader's calendar.\n *\n * The current month is the first bucket, whole: a deal expected to close\n * on the 3rd when today is the 20th is late, not next month's, and a\n * forecast that started at \"now\" would drop it between the rows. Local\n * months rather than UTC, because `expectedCloseAtMs` is stored at local\n * noon of the day picked (`dateInputMs`) and a UTC boundary would put a\n * deal picked for the 1st into the month before in half the world.\n */\nexport function closeMonthBuckets(\n nowMs: number,\n months = FORECAST_MONTHS,\n): CloseMonthBucket[] {\n const now = new Date(nowMs)\n const buckets: CloseMonthBucket[] = []\n for (let offset = 0; offset < months; offset += 1) {\n const start = new Date(now.getFullYear(), now.getMonth() + offset, 1)\n const end = new Date(now.getFullYear(), now.getMonth() + offset + 1, 1)\n buckets.push({\n key: `${start.getFullYear()}-${String(start.getMonth() + 1).padStart(2, '0')}`,\n start: start.getTime(),\n end: end.getTime(),\n })\n }\n return buckets\n}\n\n/** One pipeline's open deals expected to close in one month. */\nexport interface ForecastCell {\n count: number\n amountCents: number\n weightedCents: number\n}\n\nexport interface PipelineForecast {\n pipelineId: string\n name: string\n /** One cell per bucket, in `buckets` order, every one present even when empty. */\n months: ForecastCell[]\n /** Open deals with no expected close — their own row, never a guess. */\n undated: ForecastCell\n /** Expected before the first bucket — overdue to close. */\n overdue: ForecastCell\n /** Expected past the last bucket. */\n later: ForecastCell\n total: ForecastCell\n}\n\nexport interface CloseMonthForecast {\n buckets: CloseMonthBucket[]\n pipelines: PipelineForecast[]\n /** Every pipeline's cells added together, in `buckets` order. */\n months: ForecastCell[]\n undated: ForecastCell\n overdue: ForecastCell\n later: ForecastCell\n total: ForecastCell\n}\n\ntype ForecastDeal = Pick<\n CrmDeal,\n 'status' | 'pipelineId' | 'stageId' | 'amountCents' | 'expectedCloseAtMs'\n>\n\nconst emptyCell = (): ForecastCell => ({ count: 0, amountCents: 0, weightedCents: 0 })\n\nfunction addToCell(cell: ForecastCell, amountCents: number, weightedCents: number) {\n cell.count += 1\n cell.amountCents += amountCents\n cell.weightedCents += weightedCents\n}\n\n/**\n * The open pipeline laid out by the month each deal is expected to close\n * (AGL-2620): per pipeline, one cell per month for the next\n * {@link FORECAST_MONTHS}, at face value and weighted by the deal's stage.\n *\n * Only open deals, for the reason `pipelineTotals` gives — a closed deal\n * has closed. A deal with no `expectedCloseAtMs` is its own row rather than\n * being dropped or dated \"now\": a forecast that quietly omitted undated\n * deals would understate the pipeline by exactly the deals nobody has\n * scheduled, which is the number a sales lead most wants to see. A deal\n * dated before the first month is overdue and a deal dated past the last\n * is \"later\"; both are reported so the rows add up to the pipeline.\n *\n * Every ACTIVE pipeline is a row even with nothing open — an empty\n * pipeline is a fact about the forecast too — while an archived one is a\n * row only if a deal still sits open in it. A deal in a pipeline the list\n * does not carry at all is grouped under its id with no name, so the\n * total still counts it; its stage cannot be resolved, so it weighs\n * nothing, as everywhere else.\n */\nexport function forecastByCloseMonth(\n deals: readonly ForecastDeal[],\n pipelines: ReadonlyArray<Pick<CrmPipeline, 'name' | 'stages' | 'archivedAt'> & { $id: string }>,\n nowMs: number,\n months = FORECAST_MONTHS,\n): CloseMonthForecast {\n const buckets = closeMonthBuckets(nowMs, months)\n const byId = new Map<string, PipelineForecast>()\n const pipelineRow = (pipelineId: string): PipelineForecast => {\n let row = byId.get(pipelineId)\n if (!row) {\n const pipeline = pipelines.find((entry) => entry.$id === pipelineId)\n row = {\n pipelineId,\n name: pipeline?.name ?? '',\n months: buckets.map(emptyCell),\n undated: emptyCell(),\n overdue: emptyCell(),\n later: emptyCell(),\n total: emptyCell(),\n }\n byId.set(pipelineId, row)\n }\n return row\n }\n for (const pipeline of pipelines) {\n if (!isPipelineArchived(pipeline)) pipelineRow(pipeline.$id)\n }\n\n const totals: CloseMonthForecast = {\n buckets,\n pipelines: [],\n months: buckets.map(emptyCell),\n undated: emptyCell(),\n overdue: emptyCell(),\n later: emptyCell(),\n total: emptyCell(),\n }\n const first = buckets[0]?.start ?? nowMs\n const last = buckets[buckets.length - 1]?.end ?? nowMs\n for (const deal of deals) {\n if (deal.status !== 'open') continue\n const pipelineId = String(deal.pipelineId ?? '')\n const row = pipelineRow(pipelineId)\n const pipeline = pipelines.find((entry) => entry.$id === pipelineId)\n const amount = Math.max(0, Math.round(Number(deal.amountCents ?? 0) || 0))\n const weighted = weightedDealAmountCents(deal, dealStageById(pipeline, deal.stageId))\n const at = deal.expectedCloseAtMs\n const cells: Array<[ForecastCell, ForecastCell]> = [[row.total, totals.total]]\n if (typeof at !== 'number' || !Number.isFinite(at)) {\n cells.push([row.undated, totals.undated])\n } else if (at < first) {\n cells.push([row.overdue, totals.overdue])\n } else if (at >= last) {\n cells.push([row.later, totals.later])\n } else {\n const index = buckets.findIndex((bucket) => at >= bucket.start && at < bucket.end)\n cells.push([row.months[index], totals.months[index]])\n }\n for (const [own, all] of cells) {\n addToCell(own, amount, weighted)\n addToCell(all, amount, weighted)\n }\n }\n totals.pipelines = [...byId.values()]\n return totals\n}\n\n/**\n * How many of a list share each key, most common first.\n *\n * Ties break on the key so two renders of the same data draw the same order.\n * An item with no key is not \"a key called empty\": a contact whose facet\n * names no source is left out of a by-source chart rather than drawn as a\n * bar labeled with nothing.\n */\nexport function tally<T>(\n items: readonly T[],\n keyOf: (item: T) => string | null | undefined,\n): Array<{ key: string; count: number }> {\n const counts = new Map<string, number>()\n for (const item of items) {\n const key = keyOf(item)\n if (!key) continue\n counts.set(key, (counts.get(key) ?? 0) + 1)\n }\n return [...counts.entries()]\n .map(([key, count]) => ({ key, count }))\n .sort((a, b) => b.count - a.count || a.key.localeCompare(b.key))\n}\n\n/**\n * Percentage change against the previous period, to one decimal, or `null`\n * when the previous period had nothing.\n *\n * `null` rather than a number because every number is wrong: \"+100%\" reads\n * as growth, \"+0%\" reads as flat, and a first contact has no growth rate.\n * The tile that renders this draws nothing for `null`, which is the honest\n * rendering of \"there is no baseline\".\n */\nexport function deltaPercent(current: number, previous: number): number | null {\n if (!(previous > 0)) return null\n return Math.round(((current - previous) / previous) * 1000) / 10\n}\n\n/**\n * The currency a set of deals is in, and whether they are actually in more\n * than one.\n *\n * A pipeline total is a sum, and a sum of dollars and euros is a number\n * with no unit. The reports still show that number — a merchant with one\n * stray euro deal wants the pipeline figure, not a refusal — but they say\n * it is mixed. The most common currency names the total; `usd` is what a\n * deal with no currency means, per the model.\n */\nexport function currencyOfDeals(\n deals: ReadonlyArray<Pick<CrmDeal, 'currency'>>,\n): { currency: string; mixed: boolean } {\n const ranked = tally(deals, (deal) =>\n String(deal.currency || 'usd').toLowerCase(),\n )\n return { currency: ranked[0]?.key ?? 'usd', mixed: ranked.length > 1 }\n}\n\n/**\n * The local calendar day a moment falls in, `[start, end)`.\n *\n * The same day `taskDueState` decides \"today\" on, so a count of tasks due\n * today and the list's \"today\" chip agree about the same task at 11 p.m.\n */\nexport function localDayBounds(nowMs: number): { start: number; end: number } {\n const now = new Date(nowMs)\n const start = new Date(now.getFullYear(), now.getMonth(), now.getDate())\n const end = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1)\n return { start: start.getTime(), end: end.getTime() }\n}\n\n/*==========================================\n * ACTIVITY BY TEAMMATE (AGL-2624)\n *=========================================*/\n\n/** One teammate's row of the activity leaderboard. */\nexport interface ActivityLeaderboardRow {\n /**\n * The account uid, or `''` for work no person did: an activity an\n * automation logged, a task ticked off with nobody assigned. One row\n * rather than a row per automation, because the leaderboard ranks the\n * team, and \"nobody\" is the honest name for the rest.\n */\n uid: string\n /**\n * The name the newest of this uid's activities was signed with, or `null`\n * when none carried one — a uid that only completed tasks, say. The\n * activity's own `byName` because a scoped reader cannot resolve a\n * colleague's uid any other way; see `CrmActivity.byName`.\n */\n name: string | null\n /** Activities logged, by kind, every kind present even at zero. */\n kinds: Record<CrmActivityKind, number>\n /** Every activity logged, whatever its kind. */\n activities: number\n /** Tasks ticked off. */\n tasksDone: number\n}\n\n/** An activity as stored — `kind` is whatever was written, not only what the model names. */\ntype LeaderboardActivity = Pick<CrmActivity, 'byUid' | 'atMs' | 'byName'> & {\n kind: string\n}\ntype LeaderboardTask = Pick<CrmTask, 'assigneeUid' | 'completedByUid'>\n\n/**\n * Who did what: the activities and the completed tasks of a period,\n * grouped by the person who did them, busiest first.\n *\n * An activity is credited to whoever logged it. A task is credited to\n * whoever COMPLETED it when the route stamped that, and to its assignee\n * otherwise: the leaderboard measures work done, and a manager closing out\n * a departed teammate's list did that work. A kind the model does not\n * name counts as `other` rather than vanishing — the activity happened.\n *\n * Busiest means activities plus tasks; ties break on activities, then on\n * the uid, so two renders of one window draw one order.\n */\nexport function activityLeaderboard(\n activities: readonly LeaderboardActivity[],\n tasksDone: readonly LeaderboardTask[],\n): ActivityLeaderboardRow[] {\n const rows = new Map<string, ActivityLeaderboardRow>()\n /** When each row's name was signed, so a newer signature replaces an older one. */\n const namedAtMs = new Map<string, number>()\n const rowFor = (uid: string) => {\n let row = rows.get(uid)\n if (!row) {\n row = {\n uid,\n name: null,\n kinds: Object.fromEntries(\n CRM_ACTIVITY_KINDS.map((kind) => [kind, 0]),\n ) as Record<CrmActivityKind, number>,\n activities: 0,\n tasksDone: 0,\n }\n rows.set(uid, row)\n }\n return row\n }\n for (const activity of activities) {\n const row = rowFor(String(activity.byUid ?? ''))\n const kind = isCrmActivityKind(activity.kind) ? activity.kind : 'other'\n row.kinds[kind] += 1\n row.activities += 1\n const name = String(activity.byName ?? '').trim()\n const atMs = Number(activity.atMs)\n if (\n name &&\n Number.isFinite(atMs) &&\n atMs > (namedAtMs.get(row.uid) ?? Number.NEGATIVE_INFINITY)\n ) {\n row.name = name\n namedAtMs.set(row.uid, atMs)\n }\n }\n for (const task of tasksDone) {\n rowFor(String(task.completedByUid || task.assigneeUid || '')).tasksDone += 1\n }\n return [...rows.values()].sort(\n (a, b) =>\n b.activities + b.tasksDone - (a.activities + a.tasksDone) ||\n b.activities - a.activities ||\n a.uid.localeCompare(b.uid),\n )\n}\n\n/*==========================================\n * LEAD FUNNEL (AGL-2624)\n *=========================================*/\n\n/** The key a lead unqualified with no reason is tallied under. */\nexport const LEAD_NO_REASON_KEY = '$none'\nexport const LEAD_NO_REASON_LABEL = 'No reason given'\n\nexport interface LeadFunnelReason {\n /** The reason, case-folded and whitespace-collapsed, or {@link LEAD_NO_REASON_KEY}. */\n key: string\n /** The reason as it was first written. */\n label: string\n count: number\n}\n\nexport interface LeadFunnel {\n /** Every lead handed in. */\n total: number\n /** Leads at each status now, every status present. An absent status is `new`. */\n byStatus: Record<CrmLeadStatus, number>\n /** `new` and `working` — the leads still needing somebody. */\n open: number\n /** Qualified over every lead, 0–1, or `null` when there were none. */\n qualifiedRate: number | null\n /** Unqualified over every lead, 0–1, or `null` when there were none. */\n unqualifiedRate: number | null\n /** Why leads were unqualified, most common first. */\n reasons: LeadFunnelReason[]\n}\n\n/**\n * Where a set of leads stands, and why the ones that were closed without\n * converting were closed.\n *\n * A status breakdown rather than a cumulative funnel, because a lead's\n * history is not on the document — only where it is now — and a lead can\n * be qualified straight from `new` without ever having been `working`. A\n * step that claimed \"reached working\" would be inventing a past. The two\n * rates are over EVERY lead handed in, so a period in which half the leads\n * are still untouched reads as half unconverted, which is what it is.\n *\n * Reasons are free text — the unqualify dialog requires one but does not\n * offer a list — so `Too small` and `too small` are one reason and the\n * label is whichever spelling came first. An unqualified lead written past\n * the dialog with no reason is counted under its own key rather than\n * dropped: the lead was closed, and \"no reason given\" is the count a\n * manager wants to see.\n */\nexport function leadFunnel(\n leads: ReadonlyArray<Pick<CrmLeadFields, 'status' | 'unqualifiedReason'>>,\n): LeadFunnel {\n const byStatus = Object.fromEntries(\n CRM_LEAD_STATUSES.map((status) => [status, 0]),\n ) as Record<CrmLeadStatus, number>\n const reasons = new Map<string, LeadFunnelReason>()\n for (const lead of leads) {\n const status = crmLeadStatus(lead)\n byStatus[status] += 1\n if (status !== 'unqualified') continue\n const label = String(lead.unqualifiedReason ?? '').replace(/\\s+/g, ' ').trim()\n const key = label ? label.toLowerCase() : LEAD_NO_REASON_KEY\n const row = reasons.get(key) ?? {\n key,\n label: label || LEAD_NO_REASON_LABEL,\n count: 0,\n }\n row.count += 1\n reasons.set(key, row)\n }\n const total = leads.length\n return {\n total,\n byStatus,\n open: byStatus.new + byStatus.working,\n qualifiedRate: total > 0 ? byStatus.qualified / total : null,\n unqualifiedRate: total > 0 ? byStatus.unqualified / total : null,\n reasons: [...reasons.values()].sort(\n (a, b) => b.count - a.count || a.label.localeCompare(b.label),\n ),\n }\n}\n\n/**\n * The leads still needing somebody, from two server counts: every lead,\n * less the ones closed one way or the other.\n *\n * Subtraction rather than a count of the open statuses because a lead\n * nobody has touched carries NO status field — see `crmLeadStatus` — and\n * Firestore cannot select a document by a field's absence. The closed\n * statuses are always written, so they can be counted; what remains is\n * open. Clamped at zero for the moment between the two counts in which a\n * lead was closed.\n */\nexport function openLeadsFromCounts(total: number, closed: number): number {\n return Math.max(0, Math.round(Number(total) || 0) - Math.round(Number(closed) || 0))\n}\n\n/*==========================================\n * CONVERSION BY SOURCE (AGL-2624)\n *=========================================*/\n\n/**\n * The stages that mean a person has bought, or better.\n *\n * `customer` and `evangelist` only. `other` sits after `customer` in the\n * stage list so that no capture can overwrite it, but it names a step the\n * business chose and the report cannot know whether that step is past a\n * purchase; counting it would credit a source with a sale nobody recorded.\n */\nconst CUSTOMER_STAGES: ReadonlySet<ContactLifecycleStage> = new Set([\n 'customer',\n 'evangelist',\n])\n\ntype ConversionFacet = Pick<ContactFacet, 'sources' | 'lifecycleStage' | 'ordersCount'>\n\n/**\n * Whether this holder's view of a person says they have bought: a stage\n * at `customer` or past it, or an order on the books whatever the stage\n * says — an order path that never advanced the stage still sold something.\n */\nexport function contactIsCustomer(\n facet: Pick<ContactFacet, 'lifecycleStage' | 'ordersCount'>,\n): boolean {\n if (facet.lifecycleStage && CUSTOMER_STAGES.has(facet.lifecycleStage)) return true\n const orders = Number(facet.ordersCount ?? 0)\n return Number.isFinite(orders) && orders > 0\n}\n\nexport interface SourceConversionRow {\n source: ContactSource\n /** People this holder captured through the source. */\n captured: number\n /** Of those, the ones who are customers now. */\n customers: number\n /** `customers / captured`, 0–1. */\n rate: number\n}\n\nexport interface SourceConversion {\n /** One row per source anybody came through, most captured first. */\n rows: SourceConversionRow[]\n /** People with no source under this holder — no door to credit. */\n unsourced: number\n /** Every counted person who is a customer, once each. */\n customers: number\n /** Every person handed in. */\n total: number\n}\n\n/**\n * Which capture surfaces turn into customers.\n *\n * Read through one holder's facet, like every by-source figure: a source\n * is which of THIS business's doors the person came through. A person\n * captured two ways counts under both — the question is \"how well does\n * each door convert\", and a person who came through two is evidence for\n * both — so the rows can add to more than `total`, and `customers` counts\n * each person once for the tile above the table. Rows rank by captured,\n * not by rate, because a door with one visitor who bought is not the best\n * door.\n */\nexport function conversionBySource(\n facets: readonly ConversionFacet[],\n): SourceConversion {\n const rows = new Map<ContactSource, SourceConversionRow>()\n let unsourced = 0\n let customers = 0\n for (const facet of facets) {\n const isCustomer = contactIsCustomer(facet)\n if (isCustomer) customers += 1\n const sources = (Object.keys(facet.sources ?? {}) as ContactSource[]).filter(\n (source) => facet.sources?.[source],\n )\n if (!sources.length) {\n unsourced += 1\n continue\n }\n for (const source of sources) {\n const row = rows.get(source) ?? { source, captured: 0, customers: 0, rate: 0 }\n row.captured += 1\n if (isCustomer) row.customers += 1\n rows.set(source, row)\n }\n }\n return {\n rows: [...rows.values()]\n .map((row) => ({ ...row, rate: row.customers / row.captured }))\n .sort(\n (a, b) =>\n b.captured - a.captured ||\n b.customers - a.customers ||\n a.source.localeCompare(b.source),\n ),\n unsourced,\n customers,\n total: facets.length,\n }\n}\n\n/*==========================================\n * WON AND LOST, BY OWNER (AGL-2662)\n *=========================================*/\n\n/** A closed deal as the by-owner tally reads it. */\ntype OwnerOutcomeDeal = Pick<CrmDeal, 'ownerUid' | 'amountCents'>\n\n/** The key an unowned deal is tallied under — see {@link OwnerOutcomeRow.uid}. */\nexport const OWNER_UNASSIGNED_KEY = ''\n\nexport interface OwnerOutcomeRow {\n /**\n * The owner's account uid, or `''` for a deal nobody owns.\n *\n * One row for the unowned rather than none: a pipeline where a third of\n * what closed had no owner is a fact about the team, and dropping those\n * deals would make the rows add to less than the period's totals without\n * saying why.\n */\n uid: string\n won: number\n lost: number\n /** Value of what this owner won, in the deals' own minor units. */\n wonAmountCents: number\n /** Value of what they lost — the size of the miss, not a negative. */\n lostAmountCents: number\n /** Everything that closed for this owner, won or lost. */\n closed: number\n /**\n * Won over closed, 0–1, or `null` when nothing of theirs closed.\n *\n * Null rather than zero because the two read differently: an owner who\n * closed nothing has no rate, and rendering that as 0% would rank a\n * person who was not in the period below one who lost every deal.\n */\n winRate: number | null\n}\n\n/**\n * Who closed what, in the period's window.\n *\n * The by-owner reading of the same two sets the Won and lost card already\n * holds — everything that closed won, and everything that closed lost — so\n * this adds no read. Credited to `ownerUid`, the field the board and the\n * bulk bar both write, and never to the creator: a deal reassigned mid-cycle\n * belongs to whoever carried it at the close, which is what \"by owner\"\n * is asked to mean when a manager reads it.\n *\n * Ranked by how much CLOSED rather than by win rate, for the reason\n * `conversionBySource` ranks by captured: an owner who closed one deal and\n * won it is not the best closer on the team. Ties break on wins, then on\n * the uid, so two renders of one window draw one order.\n */\nexport function wonLostByOwner(\n won: readonly OwnerOutcomeDeal[],\n lost: readonly OwnerOutcomeDeal[],\n): OwnerOutcomeRow[] {\n const rows = new Map<string, OwnerOutcomeRow>()\n const rowFor = (uid: string) => {\n let row = rows.get(uid)\n if (!row) {\n row = {\n uid,\n won: 0,\n lost: 0,\n wonAmountCents: 0,\n lostAmountCents: 0,\n closed: 0,\n winRate: null,\n }\n rows.set(uid, row)\n }\n return row\n }\n /** A missing or negative amount counts as nothing, never as a debit. */\n const cents = (deal: OwnerOutcomeDeal) =>\n Math.max(0, Number(deal.amountCents ?? 0) || 0)\n for (const deal of won) {\n const row = rowFor(String(deal.ownerUid ?? OWNER_UNASSIGNED_KEY))\n row.won += 1\n row.closed += 1\n row.wonAmountCents += cents(deal)\n }\n for (const deal of lost) {\n const row = rowFor(String(deal.ownerUid ?? OWNER_UNASSIGNED_KEY))\n row.lost += 1\n row.closed += 1\n row.lostAmountCents += cents(deal)\n }\n return [...rows.values()]\n .map((row) => ({\n ...row,\n winRate: row.closed ? row.won / row.closed : null,\n }))\n .sort(\n (a, b) => b.closed - a.closed || b.won - a.won || a.uid.localeCompare(b.uid),\n )\n}\n"],"names":["CONTACT_LIFECYCLE_STAGES","CONTACT_LIFECYCLE_STAGE_LABELS","CRM_ACTIVITY_KINDS","CRM_LEAD_STATUSES","crmLeadStatus","dealStageById","isCrmActivityKind","isPipelineArchived","weightedDealAmountCents","DAY_MS","WEEK_MS","CRM_REPORT_PERIODS","CRM_REPORT_PERIOD_LABELS","month","crmReportRange","period","nowMs","now","Date","from","getFullYear","getMonth","getTime","previousFrom","to","previousTo","days","weekBuckets","buckets","start","push","end","Math","min","bucketByWeek","items","atMs","map","bounds","length","item","at","Number","isFinite","index","floor","funnelFromStages","counts","countOf","stage","value","round","progression","filter","steps","reachedAfter","count","reached","unshift","label","conversion","previous","other","total","reduce","sum","step","pipelineTotals","deals","pipeline","stages","kind","sort","a","b","order","rows","Map","id","amountCents","weightedCents","totals","get","unplaced","deal","status","amount","max","row","stageId","weighted","FORECAST_MONTHS","closeMonthBuckets","months","offset","key","String","padStart","emptyCell","addToCell","cell","forecastByCloseMonth","pipelines","byId","pipelineRow","pipelineId","find","entry","$id","name","undated","overdue","later","set","first","last","expectedCloseAtMs","cells","findIndex","bucket","own","all","values","tally","keyOf","entries","localeCompare","deltaPercent","current","currencyOfDeals","ranked","currency","toLowerCase","mixed","localDayBounds","getDate","activityLeaderboard","activities","tasksDone","namedAtMs","rowFor","uid","kinds","Object","fromEntries","activity","byUid","byName","trim","NEGATIVE_INFINITY","task","completedByUid","assigneeUid","LEAD_NO_REASON_KEY","LEAD_NO_REASON_LABEL","leadFunnel","leads","byStatus","reasons","lead","unqualifiedReason","replace","open","new","working","qualifiedRate","qualified","unqualifiedRate","unqualified","openLeadsFromCounts","closed","CUSTOMER_STAGES","Set","contactIsCustomer","facet","lifecycleStage","has","orders","ordersCount","conversionBySource","facets","unsourced","customers","isCustomer","sources","keys","source","captured","rate","OWNER_UNASSIGNED_KEY","wonLostByOwner","won","lost","wonAmountCents","lostAmountCents","winRate","cents","ownerUid"],"mappings":"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"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/dataset-csv.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * Dataset CSV/JSON serialization (AGL-182). The predictable inverse of\n * `coerceDocumentValues`: ISO dates, `lat, lon` coordinates, comma-joined\n * lists, JSON maps, reference ids — so an export re-imports losslessly\n * through the same coercion.\n *\n * **Why this lives in core rather than in `@aglyn/plugins-data`** (AGL-2335).\n * AGL-413 moved dataset io out to the plugin, and the dataset-shaped half of\n * the import machinery — column mapping against a `DatasetModel` — is still\n * there, correctly: nothing outside that plugin maps a file onto a dataset.\n *\n * Writing one is different now. `/api/orgs/datasets/export` streams a\n * dataset from the server, and `scope:app` may not import a lib tagged\n * `aglyn:addons` — apps reach plugins only through the generated loader\n * manifests (AGL-417/419). The choice was a second copy of the escaper in\n * the console or one copy somewhere both may reach. There are already five\n * divergent hand-rolled CSV escapers in this repo; this is not becoming the\n * sixth. `dataset-io.ts` re-exports every name below, so the plugin's own\n * imports are unchanged.\n *\n * READING one is shared for the same reason. The email list importer parses\n * a merchant's contact file, and it lives in `@aglyn/plugins-email`, which\n * may not import `@aglyn/plugins-data` either. So {@link parseCsv} sits here\n * beside the escaper that is its inverse, and `dataset-io.ts` re-exports it\n * exactly as it re-exports the writing half.\n */\n\nimport type { DatasetFieldDefinition, DatasetModel } from './dataset-models'\n\n\n/** Storage value → portable string (CSV cell / JSON value). */\nexport function serializeDatasetValue(\n field: DatasetFieldDefinition,\n value: unknown,\n): string {\n if (value == null) return ''\n switch (field.type) {\n case 'timestamp':\n return typeof value === 'number' && Number.isFinite(value)\n ? new Date(value).toISOString()\n : String(value)\n case 'coordinates': {\n const coordinates = value as { latitude?: number; longitude?: number }\n return coordinates &&\n typeof coordinates.latitude === 'number' &&\n typeof coordinates.longitude === 'number'\n ? `${coordinates.latitude}, ${coordinates.longitude}`\n : String(value)\n }\n case 'sorted':\n case 'reference':\n return Array.isArray(value) ? value.join(', ') : String(value)\n case 'map':\n try {\n return JSON.stringify(value)\n } catch {\n return String(value)\n }\n default:\n return String(value)\n }\n}\n\nconst csvEscape = (cell: string): string =>\n /[\",\\r\\n]/.test(cell) ? `\"${cell.replace(/\"/g, '\"\"')}\"` : cell\n\n/**\n * The header line — field ids, in model order (AGL-2335).\n *\n * Split out of {@link datasetRecordsToCsv} so a server export can emit the\n * file a page at a time. The whole point of that export is that it is not\n * bounded by what one process can hold in memory, and a helper that only\n * takes a complete `rows` array cannot serve it. Everything below shares\n * one escaper: there are already five divergent hand-rolled CSV escapers in\n * this repo and this is not becoming the sixth.\n */\nexport function datasetCsvHeader(model: DatasetModel): string {\n return model.order.map(csvEscape).join(',')\n}\n\n/** One record's values (storage form) → one CSV line, no terminator. */\nexport function datasetCsvRow(\n model: DatasetModel,\n row: Record<string, unknown>,\n): string {\n return model.order\n .map((fieldId) => {\n const field = model.fields[fieldId]\n return csvEscape(field ? serializeDatasetValue(field, row[fieldId]) : '')\n })\n .join(',')\n}\n\n/** One record's values → the portable JSON object the JSON export emits. */\nexport function datasetRecordToJson(\n model: DatasetModel,\n row: Record<string, unknown>,\n): Record<string, string> {\n return Object.fromEntries(\n model.order.map((fieldId) => {\n const field = model.fields[fieldId]\n return [fieldId, field ? serializeDatasetValue(field, row[fieldId]) : '']\n }),\n )\n}\n\n/**\n * Data rows in a CSV document — the header excluded, quoted newlines not\n * counted as row breaks (AGL-2335).\n *\n * This exists so a download can be CHECKED against the row count the server\n * promised, rather than trusted. A truncated export is the defect this whole\n * area is about, and a stream that dies halfway produces a perfectly\n * well-formed shorter file: nothing about the bytes says they are short.\n *\n * Single pass, no array building — {@link parseCsv} answers the same\n * question but materializes every cell, which is the wrong trade when the\n * file may be hundreds of thousands of rows and the only thing wanted is\n * how many there are.\n */\nexport function countCsvDataRows(text: string): number {\n const source = String(text ?? '')\n if (!source) return 0\n let rows = 0\n let quoted = false\n let cellsSeen = false\n for (let index = 0; index < source.length; index += 1) {\n const char = source[index]\n if (quoted) {\n if (char === '\"') {\n if (source[index + 1] === '\"') index += 1\n else quoted = false\n }\n cellsSeen = true\n continue\n }\n if (char === '\"') {\n quoted = true\n cellsSeen = true\n continue\n }\n if (char === '\\n') {\n rows += 1\n cellsSeen = false\n continue\n }\n if (char !== '\\r') cellsSeen = true\n }\n // A file that does not end in a newline still has a final row.\n if (cellsSeen) rows += 1\n // The header is not data.\n return Math.max(0, rows - 1)\n}\n\n/**\n * Did a downloaded export arrive whole? (AGL-2335)\n *\n * The server streams and reports its `count()` aggregate in a header, so the\n * client can CHECK rather than trust. This is the check, kept here as a pure\n * function because a guard living inline in a component is a guard nobody\n * can force red — and this one exists precisely to catch a silent shortfall.\n *\n * `short` is only true when the count is both KNOWN and lower. A missing or\n * unparseable header is not evidence of truncation, and refusing a download\n * on the strength of a header that never arrived would fail the user for the\n * server's bookkeeping. More rows than promised is not short either — the\n * count is a snapshot taken before the first page, so a concurrent insert\n * can legitimately overtake it.\n */\nexport function exportShortfall(\n promisedHeader: string | null | undefined,\n body: string,\n format: 'csv' | 'json',\n): { promised: number; received: number; short: boolean } {\n const promised = Number(promisedHeader)\n let received: number\n if (format === 'csv') {\n received = countCsvDataRows(body)\n } else {\n try {\n const parsed = JSON.parse(body) as unknown\n received = Array.isArray(parsed) ? parsed.length : 0\n } catch {\n // A body that is not JSON at all did not arrive whole.\n return { promised, received: 0, short: true }\n }\n }\n return {\n promised,\n received,\n short: Number.isFinite(promised) && promisedHeader != null && received < promised,\n }\n}\n\n/** Rows (storage-form value maps) → CSV with a fieldId header row. */\nexport function datasetRecordsToCsv(\n model: DatasetModel,\n rows: Array<Record<string, unknown>>,\n): string {\n return [\n datasetCsvHeader(model),\n ...rows.map((row) => datasetCsvRow(model, row)),\n ].join('\\n')\n}\n\n/**\n * Minimal RFC-4180 CSV parser (quoted fields, escaped quotes, CRLF).\n *\n * The inverse of the escaper above, and the one parser in the repo — see the\n * module note for why the email list importer reaches this rather than\n * writing its own.\n *\n * A wholly blank row is dropped. Every spreadsheet writes a trailing newline\n * and a row of empty cells is not a record; carrying it through would give\n * every caller the same off-by-one to remember.\n */\nexport function parseCsv(text: string): string[][] {\n const rows: string[][] = []\n let row: string[] = []\n let cell = ''\n let quoted = false\n const source = String(text ?? '')\n for (let index = 0; index < source.length; index += 1) {\n const char = source[index]\n if (quoted) {\n if (char === '\"') {\n if (source[index + 1] === '\"') {\n cell += '\"'\n index += 1\n } else {\n quoted = false\n }\n } else {\n cell += char\n }\n continue\n }\n if (char === '\"') {\n quoted = true\n } else if (char === ',') {\n row.push(cell)\n cell = ''\n } else if (char === '\\n' || char === '\\r') {\n if (char === '\\r' && source[index + 1] === '\\n') index += 1\n row.push(cell)\n cell = ''\n rows.push(row)\n row = []\n } else {\n cell += char\n }\n }\n if (cell !== '' || row.length) {\n row.push(cell)\n rows.push(row)\n }\n return rows.filter((cells) => cells.some((value) => value.trim() !== ''))\n}\n\n"],"names":["serializeDatasetValue","field","value","type","Number","isFinite","Date","toISOString","String","coordinates","latitude","longitude","Array","isArray","join","JSON","stringify","csvEscape","cell","test","replace","datasetCsvHeader","model","order","map","datasetCsvRow","row","fieldId","fields","datasetRecordToJson","Object","fromEntries","countCsvDataRows","text","source","rows","quoted","cellsSeen","index","length","char","Math","max","exportShortfall","promisedHeader","body","format","promised","received","parsed","parse","short","datasetRecordsToCsv","parseCsv","push","filter","cells","some","trim"],"mappings":"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"}
|
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @license
|
|
3
|
-
* Copyright 2026 Aglyn LLC
|
|
4
|
-
*
|
|
5
|
-
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
-
* you may not use this file except in compliance with the License.
|
|
7
|
-
* You may obtain a copy of the License at
|
|
8
|
-
*
|
|
9
|
-
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
-
*
|
|
11
|
-
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
-
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
-
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
-
* See the License for the specific language governing permissions and
|
|
15
|
-
* limitations under the License.
|
|
16
|
-
*/
|
|
17
|
-
/**
|
|
18
|
-
* Dataset query layer (AGL-181): filter/sort/limit over typed documents,
|
|
19
|
-
* shared by the console editor and the tenant renderer's repeatable
|
|
20
|
-
* expansion. Deliberately evaluated IN MEMORY over the already-bounded
|
|
21
|
-
* reads (editor page / `REPEAT_MAX_RECORDS`) — the Firestore reality
|
|
22
|
-
* check from the issue: this sidesteps inequality-filter and composite-
|
|
23
|
-
* index constraints entirely, at the documented cost that documents
|
|
24
|
-
* beyond the fetch bound are never considered. The bound is explicit,
|
|
25
|
-
* never silently unbounded.
|
|
26
|
-
*/
|
|
27
|
-
import type { DatasetModel } from './dataset-models';
|
|
28
|
-
export type DatasetQueryOp = '==' | '!=' | '>' | '>=' | '<' | '<=' | 'contains';
|
|
29
|
-
export declare const DATASET_QUERY_OPS: DatasetQueryOp[];
|
|
30
|
-
export interface DatasetQueryWhere {
|
|
31
|
-
fieldId: string;
|
|
32
|
-
op: DatasetQueryOp;
|
|
33
|
-
/** Literal, compared with type-aware coercion against stored values. */
|
|
34
|
-
value: string;
|
|
35
|
-
}
|
|
36
|
-
export interface DatasetQuery {
|
|
37
|
-
where?: DatasetQueryWhere[];
|
|
38
|
-
orderBy?: {
|
|
39
|
-
fieldId: string;
|
|
40
|
-
direction?: 'asc' | 'desc';
|
|
41
|
-
};
|
|
42
|
-
limit?: number;
|
|
43
|
-
}
|
|
44
|
-
/**
|
|
45
|
-
* Parses the authoring shorthand `field op value` (e.g. `price <= 20`,
|
|
46
|
-
* `tier == plus`, `tags contains red`). Null for unparseable input so
|
|
47
|
-
* callers fail open (no filter) rather than filtering wrongly.
|
|
48
|
-
*/
|
|
49
|
-
export declare function parseDatasetFilter(input: string): DatasetQueryWhere | null;
|
|
50
|
-
/** Parses `field` or `field desc` into an orderBy config. */
|
|
51
|
-
export declare function parseDatasetSort(input: string): DatasetQuery['orderBy'] | null;
|
|
52
|
-
/**
|
|
53
|
-
* Applies where/orderBy/limit to in-memory rows. Where-clauses on fields
|
|
54
|
-
* absent from the model still run (stored-value comparison) so v1
|
|
55
|
-
* datasets work; timestamps compare numerically (epoch millis storage).
|
|
56
|
-
*/
|
|
57
|
-
export declare function applyDatasetQuery<Row extends Record<string, unknown>>(model: DatasetModel | undefined, rows: Row[], query: DatasetQuery): Row[];
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/dataset-query.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * Dataset query layer (AGL-181): filter/sort/limit over typed documents,\n * shared by the console editor and the tenant renderer's repeatable\n * expansion. Deliberately evaluated IN MEMORY over the already-bounded\n * reads (editor page / `REPEAT_MAX_RECORDS`) — the Firestore reality\n * check from the issue: this sidesteps inequality-filter and composite-\n * index constraints entirely, at the documented cost that documents\n * beyond the fetch bound are never considered. The bound is explicit,\n * never silently unbounded.\n */\n\nimport type { DatasetModel } from './dataset-models'\n\nexport type DatasetQueryOp =\n | '=='\n | '!='\n | '>'\n | '>='\n | '<'\n | '<='\n | 'contains'\n\nexport const DATASET_QUERY_OPS: DatasetQueryOp[] = [\n '==',\n '!=',\n '>',\n '>=',\n '<',\n '<=',\n 'contains',\n]\n\nexport interface DatasetQueryWhere {\n fieldId: string\n op: DatasetQueryOp\n /** Literal, compared with type-aware coercion against stored values. */\n value: string\n}\n\nexport interface DatasetQuery {\n where?: DatasetQueryWhere[]\n orderBy?: { fieldId: string; direction?: 'asc' | 'desc' }\n limit?: number\n}\n\n/**\n * Parses the authoring shorthand `field op value` (e.g. `price <= 20`,\n * `tier == plus`, `tags contains red`). Null for unparseable input so\n * callers fail open (no filter) rather than filtering wrongly.\n */\nexport function parseDatasetFilter(input: string): DatasetQueryWhere | null {\n const match = String(input ?? '')\n .trim()\n .match(/^([A-Za-z][A-Za-z0-9_]*)\\s*(==|!=|>=|<=|>|<|contains)\\s+(.+)$/)\n if (!match) return null\n return {\n fieldId: match[1],\n op: match[2] as DatasetQueryOp,\n value: match[3].trim(),\n }\n}\n\n/** Parses `field` or `field desc` into an orderBy config. */\nexport function parseDatasetSort(\n input: string,\n): DatasetQuery['orderBy'] | null {\n const match = String(input ?? '')\n .trim()\n .match(/^([A-Za-z][A-Za-z0-9_]*)(?:\\s+(asc|desc))?$/i)\n if (!match) return null\n return {\n fieldId: match[1],\n direction: match[2]?.toLowerCase() === 'desc' ? 'desc' : 'asc',\n }\n}\n\nconst comparable = (value: unknown): number | string | null => {\n if (typeof value === 'number') return value\n if (typeof value === 'boolean') return value ? 1 : 0\n if (typeof value === 'string') {\n const numeric = Number(value)\n return Number.isFinite(numeric) && value.trim() !== '' ? numeric : value\n }\n return null\n}\n\nfunction matches(stored: unknown, where: DatasetQueryWhere): boolean {\n const { op, value } = where\n if (op === 'contains') {\n if (Array.isArray(stored)) {\n return stored.some(\n (entry) => String(entry).toLowerCase() === value.toLowerCase(),\n )\n }\n return String(stored ?? '')\n .toLowerCase()\n .includes(value.toLowerCase())\n }\n if (op === '==' || op === '!=') {\n const equal =\n typeof stored === 'boolean'\n ? String(stored) === value.toLowerCase()\n : String(stored ?? '') === value\n return op === '==' ? equal : !equal\n }\n const left = comparable(stored)\n const right = comparable(value)\n if (left == null || right == null || typeof left !== typeof right) {\n return false\n }\n switch (op) {\n case '>':\n return left > right\n case '>=':\n return left >= right\n case '<':\n return left < right\n case '<=':\n return left <= right\n }\n return false\n}\n\n/**\n * Applies where/orderBy/limit to in-memory rows. Where-clauses on fields\n * absent from the model still run (stored-value comparison) so v1\n * datasets work; timestamps compare numerically (epoch millis storage).\n */\nexport function applyDatasetQuery<Row extends Record<string, unknown>>(\n model: DatasetModel | undefined,\n rows: Row[],\n query: DatasetQuery,\n): Row[] {\n let result = rows\n for (const where of query.where ?? []) {\n result = result.filter((row) => matches(row[where.fieldId], where))\n }\n if (query.orderBy) {\n const { fieldId, direction } = query.orderBy\n const sign = direction === 'desc' ? -1 : 1\n result = [...result].sort((a, b) => {\n const left = comparable(a[fieldId])\n const right = comparable(b[fieldId])\n if (left == null) return 1\n if (right == null) return -1\n if (typeof left === 'number' && typeof right === 'number') {\n return (left - right) * sign\n }\n return String(left).localeCompare(String(right)) * sign\n })\n }\n if (query.limit != null && query.limit > 0) {\n result = result.slice(0, query.limit)\n }\n return result\n}\n"],"names":["DATASET_QUERY_OPS","parseDatasetFilter","input","match","String","trim","fieldId","op","value","parseDatasetSort","direction","toLowerCase","comparable","numeric","Number","isFinite","matches","stored","where","Array","isArray","some","entry","includes","equal","left","right","applyDatasetQuery","model","rows","query","result","filter","row","orderBy","sign","sort","a","b","localeCompare","limit","slice"],"mappings":"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"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/email-topics.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * EMAIL TOPICS — the streams a recipient can leave one at a time.\n *\n * ## The gap this closes\n *\n * `docs/specs/email-competitive-gaps.md` §1f records the only row in that\n * table where every competitor has something and we have nothing: \"Preference\n * center / subscription topics — unsubscribe is all-or-nothing per site\".\n * A recipient who wanted the newsletter but not the sales mail had exactly one\n * lever, and pulling it took the newsletter too. The lever people reach for\n * when the only unsubscribe is total is \"report spam\", which is a complaint,\n * which is the sending domain's problem and therefore every tenant's.\n *\n * ## Scope: definitions are ORG-shared, opt-outs are PER-SITE\n *\n * The topic CATALOG lives at `orgs/{orgId}/emailTopics`, which is where\n * `lists` live (AGL-254) and for the same reason: a topic is authored\n * editorial content — a name and a sentence of description — that an agency\n * writes once and points several sites at. Following `lists` also means the\n * console surface that manages topics inherits a scoping question that has\n * already been answered, rather than inventing a second answer.\n *\n * A recipient's OPT-OUT is per site, at `hosts/{hostId}/topicOptOuts`. It sits\n * beside `hosts/{hostId}/suppressions`, which is per site, and it answers the\n * same shape of question one notch finer: the suppression says \"nothing from\n * this site\", the opt-out says \"not this stream from this site\". Two facts\n * about one person that live at different scopes would make \"may I mail this\n * person about product updates\" a question with two answers. It also keeps\n * the unauthenticated preference page honest — the signed link names a host\n * and nothing else, so a per-site read is exactly what the link authorizes.\n *\n * ## Why the defaults are code and not seeded documents\n *\n * Every tenant that exists today has an empty `emailTopics` collection. A\n * seeding migration would have to run against every org, would leave any org\n * created while it was mid-flight with none, and — like every backfill — is a\n * thing that gets written and then not run. So {@link DEFAULT_EMAIL_TOPICS} is\n * the floor of the catalog rather than its initial contents: it is present for\n * every org from the moment this ships, with no write anywhere, and a stored\n * document at the same id renames, re-describes or archives one. The catalog a\n * reader sees is {@link mergeEmailTopics} of the two.\n */\n\n/** One subscribable stream. */\nexport interface EmailTopic {\n /**\n * The stored id. Rides in the unsubscribe link's `tid` and is covered by\n * its signature, so it is treated as persisted — rename the {@link name},\n * never the id.\n */\n id: string\n /** What the recipient sees on the preference page. */\n name: string\n /** One sentence of \"what will I get\", shown under the name. */\n description: string\n /**\n * Retired. An archived topic stays in the catalog for the send path and the\n * preference page's history, and is hidden from the composer's picker —\n * campaigns already sent under it must go on resolving, or their unsubscribe\n * links stop naming anything.\n */\n archived?: boolean\n /**\n * Whether joining this stream needs a confirmation click.\n *\n * `undefined` — the ordinary state — means \"whatever the site says\", which\n * is what {@link topicRequiresDoubleOptIn} resolves. A stored `true` or\n * `false` is a decision made about THIS stream and overrides the site: a\n * merchant who confirms their newsletter and not their order-related\n * product updates is expressing something real, and a per-site switch alone\n * cannot hold it.\n */\n doubleOptIn?: boolean\n}\n\n/**\n * DOUBLE OPT-IN — per topic, defaulting per site.\n *\n * `docs/specs/email-competitive-gaps.md` P8. Of the ten vendors surveyed\n * **none mandates it**, so this is an option and never a default: the field\n * above is absent everywhere until a merchant turns it on, and the site\n * default is off.\n *\n * ## Why per topic, with a per-site default\n *\n * Three reasons, and the first is the one that decides it.\n *\n * The recipient-facing vocabulary is ALREADY per topic. The signed link\n * carries `tid`, the opt-out record is keyed by topic, and the preference\n * page is a list of topics. A confirmation scoped to anything else would need\n * a second vocabulary to express \"confirmed for what\", and the recipient\n * would meet both.\n *\n * It is also the axis the compared products chose — Brevo and Klaviyo offer\n * double opt-in per LIST — and a topic is our per-list.\n *\n * The site default exists because the merchant who wants this usually wants\n * it everywhere: in the jurisdictions where a DOI is the accepted proof of\n * consent, \"on for the newsletter but not for the four others\" is not the\n * answer they are after, and asking them to set it on each topic they now\n * have plus each one they later add is the shape that gets half-done.\n *\n * ## What it must have to be worth anything\n *\n * ActiveCampaign is the implementation worth copying because it has teeth:\n * an unconfirmed subscriber lands in a real quarantine and \"you cannot send\n * any emails to contacts that have this status\". Recording a pending\n * confirmation that the send path then ignores is Customer.io's DIY recipe,\n * whose own documentation warns that it \"doesn't automatically check this\n * attribute before sending messages\" — a fact stored and not enforced. So the\n * state below is read by `filterTopicSendable`, in the same pass that reads\n * the opt-outs.\n *\n * ## On the law, precisely\n *\n * No jurisdiction verified requires double opt-in by statute. They require\n * prior express consent plus a burden of proof on the sender — EU ePrivacy\n * Art. 13(1), German UWG §7(2), Canada's CASL s.13. Germany's own\n * certification body says a DOI is not required by law and then explains why\n * everyone does it anyway: the proof has so far been recognized by the courts\n * exclusively via a DOI. The law requires provable consent; a DOI is the\n * accepted proof.\n */\nexport const DEFAULT_SITE_DOUBLE_OPT_IN = false\n\n/**\n * How long a confirmation link stays good.\n *\n * 72 hours, Klaviyo's number rather than one of our own. Brevo's is 30 days,\n * which is long enough that the click stops being evidence of anything\n * contemporaneous; the point of the confirmation is to show that the person\n * holding that mailbox wanted this, and a month-old click shows it much less\n * well than a same-day one.\n *\n * Expiry stops the LINK working. It does not make an unconfirmed address\n * mailable — see {@link readTopicSubscriptionState}, where the two are\n * deliberately separate questions.\n */\nexport const DOUBLE_OPT_IN_EXPIRY_MS = 72 * 60 * 60 * 1000\n\n/**\n * Whether joining `topic` on this site needs a confirmation click.\n *\n * The topic's own setting when it has one, the site's otherwise. `false`\n * stored on a topic is a real answer and not an absence, which is what lets a\n * merchant confirm everything except their order-related stream.\n */\nexport function topicRequiresDoubleOptIn(\n topic: Pick<EmailTopic, 'doubleOptIn'> | null | undefined,\n siteDefault: boolean = DEFAULT_SITE_DOUBLE_OPT_IN,\n): boolean {\n return typeof topic?.doubleOptIn === 'boolean'\n ? topic.doubleOptIn\n : siteDefault === true\n}\n\n/** What one person's record says about one stream on one site. */\nexport type TopicSubscriptionState =\n /** They may be mailed about this. */\n | 'subscribed'\n /** Asked to join and has not confirmed. NOT mailable. */\n | 'pending'\n /** Left this stream and has not rejoined. NOT mailable. */\n | 'opted-out'\n\n/** One entry in a `topicOptOuts` document's `topics` map, as stored. */\nexport interface TopicSubscriptionEntry {\n optedOutAt?: unknown\n resubscribedAt?: unknown\n /** When a confirmation was asked for. */\n pendingAt?: unknown\n /** When they confirmed, or absent/null while they have not. */\n confirmedAt?: unknown\n}\n\n/**\n * What an entry means, in one place, for every reader.\n *\n * ## Why this is a function and not a field test at each site\n *\n * There were two fields and one test — \"an entry with no `resubscribedAt` is a\n * live opt-out\" — written out at three call sites. Adding a third and fourth\n * field breaks that test everywhere it appears at once: a CONFIRMED entry\n * carries `pendingAt` and `confirmedAt` and no `resubscribedAt`, so the old\n * shorthand reads it as somebody who left. Nobody would find that by reading\n * one call site.\n *\n * ## Precedence: a refusal outranks a pending confirmation\n *\n * Somebody who left a stream and never rejoined is opted out even if a\n * confirmation was once asked of them, because leaving is the more recent and\n * more explicit act — and because a pending confirmation that could outrank a\n * refusal would be a way to re-arm sending by asking again.\n *\n * ## An EXPIRED confirmation is still not a subscriber\n *\n * Expiry stops the link working; it does not admit anybody. The alternative —\n * treating a lapsed pending record as subscribed — would make the whole\n * mechanism a delay rather than a gate, and waiting it out would be the way\n * past it.\n */\nexport function readTopicSubscriptionState(\n entry: TopicSubscriptionEntry | null | undefined,\n): TopicSubscriptionState {\n if (!entry) return 'subscribed'\n if (entry.optedOutAt && !entry.resubscribedAt) return 'opted-out'\n if (entry.pendingAt && !entry.confirmedAt) return 'pending'\n return 'subscribed'\n}\n\n/**\n * Whether a confirmation request is too old to be acted on.\n *\n * A separate question from {@link readTopicSubscriptionState} on purpose: one\n * answers \"may we mail them\", the other answers \"does this link still work\",\n * and collapsing them is how an expiry comes to admit somebody.\n */\nexport function doubleOptInExpired(\n pendingAtMs: number | null | undefined,\n nowMs: number,\n): boolean {\n const at = Number(pendingAtMs)\n if (!Number.isFinite(at) || at <= 0) return true\n return nowMs - at > DOUBLE_OPT_IN_EXPIRY_MS\n}\n\n/** `orgs/{orgId}/emailTopics` — the catalog, org-shared like `lists`. */\nexport const EMAIL_TOPICS_COLLECTION = 'emailTopics'\n\n/** `hosts/{hostId}/topicOptOuts/{emailKey}` — one document per recipient. */\nexport const TOPIC_OPT_OUTS_SUBCOLLECTION = 'topicOptOuts'\n\n/*==========================================\n * THE BUILT-IN STREAM IDS.\n *\n * Named rather than written as literals wherever a send declares which stream\n * it belongs to, because a mistyped id does not fail loudly. It names a\n * stream nobody has opted out of, so `filterTopicSendable` finds no opt-out\n * and the send goes to everybody — the control silently doing nothing, which\n * is the failure mode a topic filter is least able to reveal.\n *\n * The value is also a Firestore path component and a colon-joined component\n * of the unsubscribe link's signed subject, so it is a wire format: changing\n * one of these strings orphans every opt-out already recorded against it.\n *=========================================*/\n\n/** Sales, discounts, seasonal campaigns — and the cart a shopper left. */\nexport const EMAIL_TOPIC_MARKETING = 'marketing'\n/** Regular news and stories, including a site's posts to its members. */\nexport const EMAIL_TOPIC_NEWSLETTER = 'newsletter'\n/** New products, restocks, and changes to what a site offers. */\nexport const EMAIL_TOPIC_PRODUCT_UPDATES = 'product-updates'\n/** One person here writing to another about working together. */\nexport const EMAIL_TOPIC_SALES = 'sales'\n\n/**\n * The catalog every org has before anyone opens the console.\n *\n * Four, not twelve. A preference page whose value is \"leave one stream without\n * leaving all of them\" stops working the moment the list is long enough that\n * reading it is a chore, and a merchant who needs a fifth can add one. The ids\n * are the generic names an unsubscribing recipient can recognize without\n * knowing anything about the sender's internal vocabulary.\n */\nexport const DEFAULT_EMAIL_TOPICS: readonly EmailTopic[] = [\n {\n id: EMAIL_TOPIC_MARKETING,\n name: 'Promotions and offers',\n description: 'Sales, discounts and seasonal campaigns.',\n },\n {\n id: EMAIL_TOPIC_NEWSLETTER,\n name: 'Newsletter',\n description: 'Regular news and stories from us.',\n },\n {\n id: EMAIL_TOPIC_PRODUCT_UPDATES,\n name: 'Product updates',\n description: 'New products, restocks and changes to what we offer.',\n },\n {\n id: EMAIL_TOPIC_SALES,\n name: 'Sales outreach',\n description: 'Messages from a person here about working together.',\n },\n]\n\n/**\n * The topic a campaign carries when its author picked none.\n *\n * Every campaign resolves to SOME topic, because a campaign with no topic\n * would mint an unsubscribe link the preference page cannot place — it could\n * offer the catalog but not say which entry this message was, which is the one\n * thing the recipient came to the page knowing.\n */\nexport const DEFAULT_CAMPAIGN_TOPIC_ID = EMAIL_TOPIC_MARKETING\n\n/**\n * WHICH STREAM AN AUTOMATED EMAIL BELONGS TO — the one answer, named once.\n *\n * An immediate reply belongs to no stream unless the step named one; anything\n * that goes out on the merchant's schedule belongs to the default stream, as a\n * campaign does. See `FlowEmailScope` for why the two differ.\n *\n * The rule is read TWICE per send — the flow gate filters on it, and the\n * executor signs it into the opt-out link so the preference page opens on the\n * stream the recipient was trying to leave rather than on a list they have to\n * search — so it lives here, in the pure leaf both sides can import, rather\n * than beside either reader. Two spellings of it is how a link comes to name\n * a stream the gate did not check.\n *\n * @returns the topic id, or `''` for a message that belongs to no stream —\n * which is not \"the default topic\", and must not be signed into a\n * link as one.\n */\nexport function flowEmailTopicId(\n topicId: string | null | undefined,\n scope?: 'scheduled' | 'immediate',\n): string {\n const named = String(topicId ?? '').trim()\n return scope === 'immediate' ? named : named || DEFAULT_CAMPAIGN_TOPIC_ID\n}\n\n/**\n * Whether `value` may be a topic id.\n *\n * Two constraints, and the second is the load-bearing one. The id becomes a\n * Firestore path component, so it carries `isDocumentId`'s rules; and it\n * becomes a COLON-JOINED component of the unsubscribe link's signed subject,\n * so a colon inside it would let one signed subject be read as two different\n * parameter tuples. See `signatureMatches` in the email plugin's\n * `unsubscribe-link.ts` for what that ambiguity would buy.\n */\nexport function isEmailTopicId(value: unknown): value is string {\n return (\n typeof value === 'string' &&\n value.length > 0 &&\n value.length <= 120 &&\n !value.includes('/') &&\n !value.includes(':') &&\n value !== '.' &&\n value !== '..' &&\n !/^__.*__$/.test(value)\n )\n}\n\n/** Coerces one stored document into a topic, or `null` if it is not one. */\nexport function normalizeEmailTopic(\n id: unknown,\n data: Record<string, unknown> | null | undefined,\n): EmailTopic | null {\n if (!isEmailTopicId(id)) return null\n const name = String(data?.['name'] ?? '').trim()\n return {\n id,\n // A nameless stored document falls back to the id rather than rendering a\n // blank checkbox on the preference page. The console refuses to create\n // one; a restore or an API write is not bound by that.\n name: name || id,\n description: String(data?.['description'] ?? '').trim(),\n ...(data?.['archived'] === true ? { archived: true as const } : {}),\n /*\n * Carried only when the document actually holds a boolean.\n *\n * A missing field and a stored `false` are different answers here —\n * `topicRequiresDoubleOptIn` reads absence as \"ask the site\" and `false`\n * as \"not this stream, whatever the site says\" — so coercing with `===\n * true` the way `archived` does would erase the second of the three\n * states this field has.\n */\n ...(typeof data?.['doubleOptIn'] === 'boolean'\n ? { doubleOptIn: data['doubleOptIn'] as boolean }\n : {}),\n }\n}\n\n/**\n * The catalog a reader sees: the built-in floor, overlaid by whatever the org\n * has stored, plus the org's own topics.\n *\n * Overlay rather than replace, so an org that adds one custom topic does not\n * thereby delete the four every one of its recipients has been unsubscribing\n * against. A built-in is removed by storing it `archived`, which is a\n * decision someone made rather than a side effect of making a different one.\n *\n * Sorted with the built-ins first in their declared order and custom topics\n * alphabetically after, so the preference page's checkbox order is stable\n * across renders and across sites.\n */\nexport function mergeEmailTopics(\n stored: readonly EmailTopic[] | null | undefined,\n): EmailTopic[] {\n const overrides = new Map<string, EmailTopic>()\n for (const topic of stored ?? []) {\n if (isEmailTopicId(topic?.id)) overrides.set(topic.id, topic)\n }\n const builtIn = DEFAULT_EMAIL_TOPICS.map(\n (topic) => overrides.get(topic.id) ?? topic,\n )\n const custom = [...overrides.values()]\n .filter((topic) => !DEFAULT_EMAIL_TOPICS.some((it) => it.id === topic.id))\n .sort((a, b) => a.name.localeCompare(b.name))\n return [...builtIn, ...custom]\n}\n\n/**\n * The catalog minus retired topics — what a composer's picker and a\n * preference page's checkboxes offer.\n *\n * The send path deliberately does NOT filter through this: a scheduled\n * campaign whose topic was archived between scheduling and sending still\n * belongs to that topic, and re-pointing it at something else would attribute\n * the unsubscribes to the wrong stream.\n */\nexport function activeEmailTopics(\n topics: readonly EmailTopic[],\n): EmailTopic[] {\n return topics.filter((topic) => !topic.archived)\n}\n\n/**\n * The topic a campaign belongs to, given what it stored and what the org\n * offers.\n *\n * Resolves an unknown or missing id to the default rather than to nothing: a\n * campaign sent before topics existed carries no `topicId`, and its\n * unsubscribe links are in inboxes right now. They must land on a preference\n * page that names A topic, not on one that says the message came from\n * nowhere.\n */\nexport function resolveCampaignTopic(\n topicId: string | null | undefined,\n topics: readonly EmailTopic[],\n): EmailTopic {\n const found = topicId\n ? topics.find((topic) => topic.id === topicId)\n : undefined\n if (found) return found\n return (\n topics.find((topic) => topic.id === DEFAULT_CAMPAIGN_TOPIC_ID) ??\n topics[0] ?? {\n id: DEFAULT_CAMPAIGN_TOPIC_ID,\n name: 'Email',\n description: '',\n }\n )\n}\n"],"names":["DEFAULT_SITE_DOUBLE_OPT_IN","DOUBLE_OPT_IN_EXPIRY_MS","topicRequiresDoubleOptIn","topic","siteDefault","doubleOptIn","readTopicSubscriptionState","entry","optedOutAt","resubscribedAt","pendingAt","confirmedAt","doubleOptInExpired","pendingAtMs","nowMs","at","Number","isFinite","EMAIL_TOPICS_COLLECTION","TOPIC_OPT_OUTS_SUBCOLLECTION","EMAIL_TOPIC_MARKETING","EMAIL_TOPIC_NEWSLETTER","EMAIL_TOPIC_PRODUCT_UPDATES","EMAIL_TOPIC_SALES","DEFAULT_EMAIL_TOPICS","id","name","description","DEFAULT_CAMPAIGN_TOPIC_ID","flowEmailTopicId","topicId","scope","named","String","trim","isEmailTopicId","value","length","includes","test","normalizeEmailTopic","data","archived","mergeEmailTopics","stored","overrides","Map","set","builtIn","map","get","custom","values","filter","some","it","sort","a","b","localeCompare","activeEmailTopics","topics","resolveCampaignTopic","found","find","undefined"],"mappings":"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"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/list-assignment-policy.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * What basis lets somebody be put on a marketing list.\n *\n * Pure policy, no Firestore and no network, so the rule that decides whether\n * somebody joins a marketing audience can be asserted without an enrollment\n * path.\n *\n * ## Why it is here and not beside one of its callers\n *\n * There are two enrollment surfaces and neither can own this: the Inbox\n * assignment route (`@aglyn/plugins-inbox`) enrolls the sender of a form\n * submission, and the Emails console's audience card enrolls an address a\n * merchant types or pastes. `plugins-inbox` already depends on\n * `plugins-email`, so the second could not have imported the first, and a\n * copy in each is the shape that ends with one surface honoring a stored\n * refusal and the other not. The basis a membership carries has to be one\n * rule with one set of words, so it lives in the framework both import.\n *\n * ## A reply is transactional; a list is marketing\n *\n * These are two acts and this module exists because they are constantly\n * mistaken for one. Answering a form submission is the transaction the person\n * started, so the Inbox's `reply-policy.ts` requires no consent record.\n * Putting them on a list is a standing invitation to mail them about things\n * they never asked about, and it requires a basis. Nothing in the reply path\n * reaches this module and nothing here sends anything.\n *\n * ## An enrollment is not a license to send\n *\n * Passing this rule writes a membership and nothing else. It meters nothing,\n * sends nothing, and is not a promise the person is still mailable when a\n * campaign eventually runs — suppression is consulted again at send time, in\n * `filterSendableForHost`, because an address can be suppressed the day after\n * it is enrolled. `apps/console/specs/an-enrollment-is-not-a-license-to-send.spec.ts`\n * holds the two halves together end to end.\n *\n * ## A basis is DECLARED, never inferred\n *\n * The act that produced the address is not a basis. Somebody who filled in a\n * contact form asked to be answered; that is the whole of what they asked for,\n * and an address a merchant has in a spreadsheet says less than that. Neither\n * is writing to the merchant, buying, booking or holding an account — the\n * shared `marketing-consent` module makes the same point for the send-time\n * join, and this is the enrollment-time half of it. So there are exactly two\n * ways in:\n *\n * 1. the person's own record already carries a stored opt-in, which is\n * carried across unchanged, timestamp and all; or\n * 2. the merchant states that they have this person's permission, and THAT\n * STATEMENT IS ITSELF THE RECORDED BASIS — stored with the account that\n * made it and the moment they did.\n *\n * There is no third way and, in particular, no unattributed one. The absence\n * of a record is reported to the merchant as an absence rather than resolved\n * quietly in either direction.\n *\n * ## A stored refusal ends it\n *\n * `declined` is checked first and answers before anything else is considered,\n * including an attestation. A merchant cannot attest their way past somebody\n * who said no; that is the one thing an attestation must not be able to buy,\n * because if it could there would be no difference between recording a\n * refusal and discarding it.\n */\n\nimport {\n OPERATOR_ATTESTED_CONSENT_BASIS,\n type MarketingConsentRecord,\n} from './marketing-consent'\n\n/**\n * What is wrong with the ADDRESS, before anybody asks what is wrong with the\n * consent.\n *\n * Four failures that are properties of the address rather than of the act, so\n * every surface that touches an address shares them: a form with no email\n * field has nobody to enroll for the same reason it has nobody to answer, and\n * a bounced address is unmailable whichever route reached it. The Inbox's\n * `ReplyRefusal` is this type — one union, aliased there — because a reply\n * and an enrollment refuse an address for the same four reasons and a second\n * copy would eventually list three.\n */\nexport type AddressRefusal =\n /** No field the resolver recognizes as an email — nothing to enroll. */\n | 'no-address'\n /** The value is not a routable address. */\n | 'unroutable-address'\n /** The address is on the platform-wide bounce/complaint list. */\n | 'suppressed-platform'\n /** The address unsubscribed from, or bounced on, this site. */\n | 'suppressed-host'\n\n/** Why an address cannot be put on a list. Each maps to one refusal message. */\nexport type AssignmentRefusal =\n | AddressRefusal\n /** A stored refusal. Never enrollable, and there is no override. */\n | 'declined'\n /** No record either way, and the merchant asserted nothing. */\n | 'no-basis'\n\n/** The basis an enrollment would carry, resolved server-side. */\nexport interface AssignmentBasisDecision {\n /*\n * The attested value is the EXPORTED CONSTANT and not a literal spelled\n * again here. `readMarketingBasis` recognizes an attestation by comparing\n * the stored field against that constant, so a second spelling on the\n * deciding side is a rename away from writing a basis the reader reports as\n * the person's own act — the one conflation this whole area exists to stop.\n */\n basis: 'contact-opt-in' | typeof OPERATOR_ATTESTED_CONSENT_BASIS\n /** When the basis was recorded — the person's own moment, or the merchant's. */\n atMs: number\n /** The account answerable for an attestation; `null` for a pass-through. */\n byUid: string | null\n}\n\n/**\n * Decides what basis, if any, an enrollment may carry.\n *\n * The client never names the basis. It says only whether the merchant is\n * asserting one, and the pass-through is derived here from the person's own\n * record — a caller that could claim `contact-opt-in` would be a caller that\n * can manufacture an opt-in for anybody by naming it.\n *\n * @param stored the person's consent facts, read off their own record.\n * @param attested whether the merchant asserted permission in this request.\n */\nexport function assignmentBasis(input: {\n stored: MarketingConsentRecord\n attested: boolean\n actingUid: string\n nowMs: number\n}): AssignmentBasisDecision | { refusal: AssignmentRefusal } {\n // First, and above the attestation on purpose. See the module note.\n if (input.stored.basis === 'declined') return { refusal: 'declined' }\n if (input.stored.basis === 'granted') {\n return {\n basis: 'contact-opt-in',\n /*\n * The person's own timestamp, and `nowMs` only when their record\n * carries none. A pass-through that restamped the moment would report\n * every historical opt-in as having happened when a merchant pressed a\n * button, which moves records across the enforcement cutoff the consent\n * policy grandfathers on and makes the audit answer the wrong question.\n */\n atMs: input.stored.basisAtMs ?? input.nowMs,\n byUid: null,\n }\n }\n if (!input.attested) return { refusal: 'no-basis' }\n return {\n basis: OPERATOR_ATTESTED_CONSENT_BASIS,\n atMs: input.nowMs,\n // Never optional for this basis: an attestation nobody is named for is\n // indistinguishable from an opt-in, which is the conflation the whole\n // module exists to prevent.\n byUid: input.actingUid,\n }\n}\n\n/** What the merchant is shown before they decide. */\nexport interface AssignmentReadout {\n /** Can this person be enrolled at all, by any answer the merchant gives? */\n enrollable: boolean\n /**\n * Must the merchant assert permission for the enrollment to proceed?\n *\n * False both when a stored opt-in already carries it and when nothing can\n * carry it, so the UI reads this alone to decide whether to show the\n * assertion control — a surface that showed it on a refusal would offer an\n * override that does not exist.\n */\n requiresAttestation: boolean\n /** One sentence of consent facts, in the merchant's terms. */\n summary: string\n}\n\n/**\n * The readout for one address, from the same inputs the decision uses.\n *\n * It lives here rather than in the component so the sentence on screen and\n * the rule that runs on the server cannot drift: a UI that computed its own\n * summary would eventually tell a merchant that somebody may be added and\n * then refuse them, or — far worse — say a check was performed that was not.\n *\n * @param suppression a suppression refusal for the address, when there is\n * one. It outranks consent in the readout because it is\n * the answer regardless of what any consent record says.\n */\nexport function assignmentReadout(input: {\n stored: MarketingConsentRecord\n suppression: AddressRefusal | null\n}): AssignmentReadout {\n if (input.suppression) {\n return {\n enrollable: false,\n requiresAttestation: false,\n summary: ASSIGNMENT_REFUSAL_MESSAGES[input.suppression],\n }\n }\n if (input.stored.basis === 'declined') {\n return {\n enrollable: false,\n requiresAttestation: false,\n summary: ASSIGNMENT_REFUSAL_MESSAGES['declined'],\n }\n }\n if (input.stored.basis === 'granted') {\n const when = input.stored.basisAtMs\n ? ` on ${new Date(input.stored.basisAtMs).toLocaleDateString()}`\n : ''\n return {\n enrollable: true,\n requiresAttestation: false,\n summary: `This person opted in to marketing email${when}. Adding them carries that opt-in across.`,\n }\n }\n return {\n enrollable: true,\n requiresAttestation: true,\n summary:\n 'There is no marketing opt-in on record for this person. Sending this ' +\n 'form does not create one. To add them you have to state that you have ' +\n 'their permission, and that statement is recorded against your account.',\n }\n}\n\n/** What a refusal says to the merchant. One line, naming the cause. */\nexport const ASSIGNMENT_REFUSAL_MESSAGES: Record<AssignmentRefusal, string> = {\n 'no-address':\n 'This submission has no email field, so there is nobody to add to a list.',\n 'unroutable-address':\n 'The email address on this submission is not a valid address.',\n 'suppressed-platform':\n 'This address bounced or reported a message as spam, so it cannot be mailed or added to a list.',\n 'suppressed-host':\n 'This address unsubscribed from this site, so it cannot be mailed or added to a list.',\n declined:\n 'This person declined marketing email. They cannot be added to a list, and there is no way to override that.',\n 'no-basis':\n 'This person has no marketing opt-in on record, so they can only be added if you state that you have their permission.',\n}\n"],"names":["OPERATOR_ATTESTED_CONSENT_BASIS","assignmentBasis","input","stored","basis","refusal","atMs","basisAtMs","nowMs","byUid","attested","actingUid","assignmentReadout","suppression","enrollable","requiresAttestation","summary","ASSIGNMENT_REFUSAL_MESSAGES","when","Date","toLocaleDateString","declined"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA+DC,GAED,SACEA,+BAA+B,QAE1B,yBAAqB;AAgD5B;;;;;;;;;;CAUC,GACD,OAAO,SAASC,gBAAgBC,KAK/B;IACC,oEAAoE;IACpE,IAAIA,MAAMC,MAAM,CAACC,KAAK,KAAK,YAAY,OAAO;QAAEC,SAAS;IAAW;IACpE,IAAIH,MAAMC,MAAM,CAACC,KAAK,KAAK,WAAW;YAU5BF;QATR,OAAO;YACLE,OAAO;YACP;;;;;;OAMC,GACDE,IAAI,GAAEJ,0BAAAA,MAAMC,MAAM,CAACI,SAAS,YAAtBL,0BAA0BA,MAAMM,KAAK;YAC3CC,OAAO;QACT;IACF;IACA,IAAI,CAACP,MAAMQ,QAAQ,EAAE,OAAO;QAAEL,SAAS;IAAW;IAClD,OAAO;QACLD,OAAOJ;QACPM,MAAMJ,MAAMM,KAAK;QACjB,uEAAuE;QACvE,sEAAsE;QACtE,4BAA4B;QAC5BC,OAAOP,MAAMS,SAAS;IACxB;AACF;AAmBA;;;;;;;;;;;CAWC,GACD,OAAO,SAASC,kBAAkBV,KAGjC;IACC,IAAIA,MAAMW,WAAW,EAAE;QACrB,OAAO;YACLC,YAAY;YACZC,qBAAqB;YACrBC,SAASC,2BAA2B,CAACf,MAAMW,WAAW,CAAC;QACzD;IACF;IACA,IAAIX,MAAMC,MAAM,CAACC,KAAK,KAAK,YAAY;QACrC,OAAO;YACLU,YAAY;YACZC,qBAAqB;YACrBC,SAASC,2BAA2B,CAAC,WAAW;QAClD;IACF;IACA,IAAIf,MAAMC,MAAM,CAACC,KAAK,KAAK,WAAW;QACpC,MAAMc,OAAOhB,MAAMC,MAAM,CAACI,SAAS,GAC/B,CAAC,IAAI,EAAE,IAAIY,KAAKjB,MAAMC,MAAM,CAACI,SAAS,EAAEa,kBAAkB,IAAI,GAC9D;QACJ,OAAO;YACLN,YAAY;YACZC,qBAAqB;YACrBC,SAAS,CAAC,uCAAuC,EAAEE,KAAK,yCAAyC,CAAC;QACpG;IACF;IACA,OAAO;QACLJ,YAAY;QACZC,qBAAqB;QACrBC,SACE,0EACA,2EACA;IACJ;AACF;AAEA,qEAAqE,GACrE,OAAO,MAAMC,8BAAiE;IAC5E,cACE;IACF,sBACE;IACF,uBACE;IACF,mBACE;IACFI,UACE;IACF,YACE;AACJ,EAAC"}
|
|
@@ -1,97 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @license
|
|
3
|
-
* Copyright 2026 Aglyn LLC
|
|
4
|
-
*
|
|
5
|
-
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
-
* you may not use this file except in compliance with the License.
|
|
7
|
-
* You may obtain a copy of the License at
|
|
8
|
-
*
|
|
9
|
-
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
-
*
|
|
11
|
-
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
-
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
-
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
-
* See the License for the specific language governing permissions and
|
|
15
|
-
* limitations under the License.
|
|
16
|
-
*/
|
|
17
|
-
/** One plugin's claim on one line. */
|
|
18
|
-
export interface PluginMeteredLineClaim {
|
|
19
|
-
/**
|
|
20
|
-
* The line the sweep knows, e.g. the assist-overage line. Core's own name
|
|
21
|
-
* for a figure it already computes — not a plugin's internal name for it.
|
|
22
|
-
*/
|
|
23
|
-
lineId: string;
|
|
24
|
-
/** The plugin that bills it. */
|
|
25
|
-
pluginId: string;
|
|
26
|
-
/**
|
|
27
|
-
* The first month (`YYYY-MM`) the plugin bills the line for, or `null`
|
|
28
|
-
* while the plugin has registered the claim but not yet been switched on.
|
|
29
|
-
*
|
|
30
|
-
* A function rather than a value because the answer comes from deployment
|
|
31
|
-
* configuration that can change without a restart, and because a claim
|
|
32
|
-
* evaluated once at boot would pin the cutover to whenever the process
|
|
33
|
-
* happened to start.
|
|
34
|
-
*/
|
|
35
|
-
billsFrom: () => string | null;
|
|
36
|
-
/**
|
|
37
|
-
* Called once per workspace for a month this sweep has CLOSED, so a plugin
|
|
38
|
-
* billing the line can settle what is left of it.
|
|
39
|
-
*
|
|
40
|
-
* A plugin that charges as usage accrues always has a remainder: the part
|
|
41
|
-
* that never reached its threshold before the month ended. Something has to
|
|
42
|
-
* bill that, and the sweep that closes the month is the one thing that
|
|
43
|
-
* already knows every workspace, its month and its Stripe customer — which
|
|
44
|
-
* is why this is a hook here rather than a second cron with a second
|
|
45
|
-
* schedule to keep in step with this one.
|
|
46
|
-
*
|
|
47
|
-
* Called whether or not the meter reported for the workspace, because the
|
|
48
|
-
* remainder is owed either way. Errors are logged and never the sweep's:
|
|
49
|
-
* this is money the plugin bills, and failing the platform's own metering
|
|
50
|
-
* over it would be the larger loss. A plugin makes it idempotent; it is
|
|
51
|
-
* called again every day the sweep runs.
|
|
52
|
-
*/
|
|
53
|
-
closeMonth?: (context: PluginMeteredLineMonthContext) => Promise<void>;
|
|
54
|
-
}
|
|
55
|
-
/** What a plugin is told about one workspace's closed month. */
|
|
56
|
-
export interface PluginMeteredLineMonthContext {
|
|
57
|
-
orgId: string;
|
|
58
|
-
/** `YYYY-MM`, the month that closed. */
|
|
59
|
-
month: string;
|
|
60
|
-
/** The workspace's own document, as the sweep already read it. */
|
|
61
|
-
org: Record<string, unknown>;
|
|
62
|
-
/** Its Stripe customer, or `null` when it has none. */
|
|
63
|
-
stripeCustomerId: string | null;
|
|
64
|
-
}
|
|
65
|
-
/**
|
|
66
|
-
* Claims a meter line. One plugin per line: a second plugin claiming a line
|
|
67
|
-
* another already holds throws, because two plugins each believing they bill
|
|
68
|
-
* a line is how it reaches no invoice at all.
|
|
69
|
-
*
|
|
70
|
-
* The same plugin re-registering replaces its own claim, so a module
|
|
71
|
-
* evaluated twice leaves one claim rather than two.
|
|
72
|
-
*/
|
|
73
|
-
export declare function registerPluginMeteredLine(claim: Omit<PluginMeteredLineClaim, 'pluginId'> & {
|
|
74
|
-
pluginId?: string;
|
|
75
|
-
}): void;
|
|
76
|
-
/**
|
|
77
|
-
* Does a plugin bill this line for this month, so the sweep must leave it
|
|
78
|
-
* out of the meter figure?
|
|
79
|
-
*
|
|
80
|
-
* A claim whose `billsFrom` throws answers `false` and logs: the sweep is
|
|
81
|
-
* the only thing that bills the line while a claim is broken, and a throw
|
|
82
|
-
* that propagated would fail the whole month's billing for every workspace.
|
|
83
|
-
*/
|
|
84
|
-
export declare function pluginBillsMeteredLine(lineId: string, month: string): boolean;
|
|
85
|
-
/**
|
|
86
|
-
* Runs the claiming plugin's close-out for one workspace's closed month.
|
|
87
|
-
*
|
|
88
|
-
* `false` when no plugin claims the line, when it does not bill this month,
|
|
89
|
-
* or when it registered no close-out — three different "nothing happened"s
|
|
90
|
-
* that the sweep does not need to tell apart, and the plugin is the only
|
|
91
|
-
* thing that could.
|
|
92
|
-
*/
|
|
93
|
-
export declare function runPluginMeteredLineClose(lineId: string, context: PluginMeteredLineMonthContext): Promise<boolean>;
|
|
94
|
-
/** The plugin billing a line, for diagnostics and staff readouts. */
|
|
95
|
-
export declare function pluginMeteredLineOwner(lineId: string): string | null;
|
|
96
|
-
/** Test seam: forget every claim. */
|
|
97
|
-
export declare function resetPluginMeteredLinesForTests(): void;
|
|
@@ -1,123 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @license
|
|
3
|
-
* Copyright 2026 Aglyn LLC
|
|
4
|
-
*
|
|
5
|
-
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
-
* you may not use this file except in compliance with the License.
|
|
7
|
-
* You may obtain a copy of the License at
|
|
8
|
-
*
|
|
9
|
-
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
-
*
|
|
11
|
-
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
-
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
-
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
-
* See the License for the specific language governing permissions and
|
|
15
|
-
* limitations under the License.
|
|
16
|
-
*/ /**
|
|
17
|
-
* A METER LINE A PLUGIN BILLS FOR ITSELF (AGL-3011).
|
|
18
|
-
*
|
|
19
|
-
* The monthly usage sweep prices several lines into one figure and posts it
|
|
20
|
-
* as a single Stripe meter event. A plugin that bills one of those lines its
|
|
21
|
-
* own way — charging it as it accrues, say — must have that line LEFT OUT of
|
|
22
|
-
* the meter figure from the month it takes over, or the customer is billed
|
|
23
|
-
* for it twice.
|
|
24
|
-
*
|
|
25
|
-
* That claim is what this registry holds: a line id, the plugin that owns
|
|
26
|
-
* the billing of it, and the first month from which the plugin bills it. The
|
|
27
|
-
* sweep asks by line id and gets a yes or a no. It never learns how the
|
|
28
|
-
* plugin bills, and the plugin never edits the sweep.
|
|
29
|
-
*
|
|
30
|
-
* ## Only the billing moves, never the measurement
|
|
31
|
-
*
|
|
32
|
-
* A claimed line is still measured, still priced and still written to the
|
|
33
|
-
* month's audit fields. What changes is whether its dollars enter the figure
|
|
34
|
-
* the meter event carries. "We stopped billing it here" and "we stopped
|
|
35
|
-
* counting it" must stay different facts, or a claim would erase a month of
|
|
36
|
-
* usage history along with its invoice line.
|
|
37
|
-
*
|
|
38
|
-
* ## Unclaimed is the safe answer
|
|
39
|
-
*
|
|
40
|
-
* A line nobody claimed, a month before the claim's start, an unparseable
|
|
41
|
-
* start month and a plugin that failed to register all answer the same way:
|
|
42
|
-
* the sweep bills the line as it always has. Every failure mode therefore
|
|
43
|
-
* bills through the channel that already works, rather than through neither.
|
|
44
|
-
*/ import { getRegisteringPluginId } from "../app-utils/registering-plugin.js";
|
|
45
|
-
const claims = new Map();
|
|
46
|
-
/**
|
|
47
|
-
* Claims a meter line. One plugin per line: a second plugin claiming a line
|
|
48
|
-
* another already holds throws, because two plugins each believing they bill
|
|
49
|
-
* a line is how it reaches no invoice at all.
|
|
50
|
-
*
|
|
51
|
-
* The same plugin re-registering replaces its own claim, so a module
|
|
52
|
-
* evaluated twice leaves one claim rather than two.
|
|
53
|
-
*/ export function registerPluginMeteredLine(claim) {
|
|
54
|
-
var _ref, _getRegisteringPluginId;
|
|
55
|
-
const pluginId = ((_ref = (_getRegisteringPluginId = getRegisteringPluginId()) != null ? _getRegisteringPluginId : claim.pluginId) != null ? _ref : '').trim();
|
|
56
|
-
if (!pluginId) {
|
|
57
|
-
throw new Error(`metered line "${claim.lineId}" claimed with no owner: pass ` + '{ pluginId } when registering outside a plugin register fn');
|
|
58
|
-
}
|
|
59
|
-
const held = claims.get(claim.lineId);
|
|
60
|
-
if (held && held.pluginId !== pluginId) {
|
|
61
|
-
throw new Error(`metered line "${claim.lineId}" is already billed by plugin ` + `"${held.pluginId}"; "${pluginId}" cannot claim it too`);
|
|
62
|
-
}
|
|
63
|
-
claims.set(claim.lineId, {
|
|
64
|
-
lineId: claim.lineId,
|
|
65
|
-
pluginId,
|
|
66
|
-
billsFrom: claim.billsFrom,
|
|
67
|
-
closeMonth: claim.closeMonth
|
|
68
|
-
});
|
|
69
|
-
}
|
|
70
|
-
/**
|
|
71
|
-
* Does a plugin bill this line for this month, so the sweep must leave it
|
|
72
|
-
* out of the meter figure?
|
|
73
|
-
*
|
|
74
|
-
* A claim whose `billsFrom` throws answers `false` and logs: the sweep is
|
|
75
|
-
* the only thing that bills the line while a claim is broken, and a throw
|
|
76
|
-
* that propagated would fail the whole month's billing for every workspace.
|
|
77
|
-
*/ export function pluginBillsMeteredLine(lineId, month) {
|
|
78
|
-
const claim = claims.get(lineId);
|
|
79
|
-
if (!claim) return false;
|
|
80
|
-
let from;
|
|
81
|
-
try {
|
|
82
|
-
from = claim.billsFrom();
|
|
83
|
-
} catch (error) {
|
|
84
|
-
console.error(`[plugins] ${claim.pluginId} failed to answer for ${lineId}`, error);
|
|
85
|
-
return false;
|
|
86
|
-
}
|
|
87
|
-
const start = String(from != null ? from : '').trim();
|
|
88
|
-
if (!/^\d{4}-\d{2}$/.test(start)) return false;
|
|
89
|
-
if (!/^\d{4}-\d{2}$/.test(month)) return false;
|
|
90
|
-
// Zero-padded fixed-width `YYYY-MM` orders identically as a string and as
|
|
91
|
-
// a date; the format checks above are what make that true rather than
|
|
92
|
-
// lucky. The same comparison `billsEmailSendOverage` makes on its own gate.
|
|
93
|
-
return month >= start;
|
|
94
|
-
}
|
|
95
|
-
/**
|
|
96
|
-
* Runs the claiming plugin's close-out for one workspace's closed month.
|
|
97
|
-
*
|
|
98
|
-
* `false` when no plugin claims the line, when it does not bill this month,
|
|
99
|
-
* or when it registered no close-out — three different "nothing happened"s
|
|
100
|
-
* that the sweep does not need to tell apart, and the plugin is the only
|
|
101
|
-
* thing that could.
|
|
102
|
-
*/ export async function runPluginMeteredLineClose(lineId, context) {
|
|
103
|
-
const claim = claims.get(lineId);
|
|
104
|
-
if (!(claim == null ? void 0 : claim.closeMonth)) return false;
|
|
105
|
-
if (!pluginBillsMeteredLine(lineId, context.month)) return false;
|
|
106
|
-
try {
|
|
107
|
-
await claim.closeMonth(context);
|
|
108
|
-
return true;
|
|
109
|
-
} catch (error) {
|
|
110
|
-
console.error(`[plugins] ${claim.pluginId} failed to close ${lineId} for ${context.orgId}`, error);
|
|
111
|
-
return false;
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
/** The plugin billing a line, for diagnostics and staff readouts. */ export function pluginMeteredLineOwner(lineId) {
|
|
115
|
-
var _ref;
|
|
116
|
-
var _claims_get;
|
|
117
|
-
return (_ref = (_claims_get = claims.get(lineId)) == null ? void 0 : _claims_get.pluginId) != null ? _ref : null;
|
|
118
|
-
}
|
|
119
|
-
/** Test seam: forget every claim. */ export function resetPluginMeteredLinesForTests() {
|
|
120
|
-
claims.clear();
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
//# sourceMappingURL=plugin-metered-lines.js.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/plugin-manager/plugin-metered-lines.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * A METER LINE A PLUGIN BILLS FOR ITSELF (AGL-3011).\n *\n * The monthly usage sweep prices several lines into one figure and posts it\n * as a single Stripe meter event. A plugin that bills one of those lines its\n * own way — charging it as it accrues, say — must have that line LEFT OUT of\n * the meter figure from the month it takes over, or the customer is billed\n * for it twice.\n *\n * That claim is what this registry holds: a line id, the plugin that owns\n * the billing of it, and the first month from which the plugin bills it. The\n * sweep asks by line id and gets a yes or a no. It never learns how the\n * plugin bills, and the plugin never edits the sweep.\n *\n * ## Only the billing moves, never the measurement\n *\n * A claimed line is still measured, still priced and still written to the\n * month's audit fields. What changes is whether its dollars enter the figure\n * the meter event carries. \"We stopped billing it here\" and \"we stopped\n * counting it\" must stay different facts, or a claim would erase a month of\n * usage history along with its invoice line.\n *\n * ## Unclaimed is the safe answer\n *\n * A line nobody claimed, a month before the claim's start, an unparseable\n * start month and a plugin that failed to register all answer the same way:\n * the sweep bills the line as it always has. Every failure mode therefore\n * bills through the channel that already works, rather than through neither.\n */\n\nimport { getRegisteringPluginId } from '../app-utils/registering-plugin'\n\n/** One plugin's claim on one line. */\nexport interface PluginMeteredLineClaim {\n /**\n * The line the sweep knows, e.g. the assist-overage line. Core's own name\n * for a figure it already computes — not a plugin's internal name for it.\n */\n lineId: string\n /** The plugin that bills it. */\n pluginId: string\n /**\n * The first month (`YYYY-MM`) the plugin bills the line for, or `null`\n * while the plugin has registered the claim but not yet been switched on.\n *\n * A function rather than a value because the answer comes from deployment\n * configuration that can change without a restart, and because a claim\n * evaluated once at boot would pin the cutover to whenever the process\n * happened to start.\n */\n billsFrom: () => string | null\n /**\n * Called once per workspace for a month this sweep has CLOSED, so a plugin\n * billing the line can settle what is left of it.\n *\n * A plugin that charges as usage accrues always has a remainder: the part\n * that never reached its threshold before the month ended. Something has to\n * bill that, and the sweep that closes the month is the one thing that\n * already knows every workspace, its month and its Stripe customer — which\n * is why this is a hook here rather than a second cron with a second\n * schedule to keep in step with this one.\n *\n * Called whether or not the meter reported for the workspace, because the\n * remainder is owed either way. Errors are logged and never the sweep's:\n * this is money the plugin bills, and failing the platform's own metering\n * over it would be the larger loss. A plugin makes it idempotent; it is\n * called again every day the sweep runs.\n */\n closeMonth?: (context: PluginMeteredLineMonthContext) => Promise<void>\n}\n\n/** What a plugin is told about one workspace's closed month. */\nexport interface PluginMeteredLineMonthContext {\n orgId: string\n /** `YYYY-MM`, the month that closed. */\n month: string\n /** The workspace's own document, as the sweep already read it. */\n org: Record<string, unknown>\n /** Its Stripe customer, or `null` when it has none. */\n stripeCustomerId: string | null\n}\n\nconst claims = new Map<string, PluginMeteredLineClaim>()\n\n/**\n * Claims a meter line. One plugin per line: a second plugin claiming a line\n * another already holds throws, because two plugins each believing they bill\n * a line is how it reaches no invoice at all.\n *\n * The same plugin re-registering replaces its own claim, so a module\n * evaluated twice leaves one claim rather than two.\n */\nexport function registerPluginMeteredLine(\n claim: Omit<PluginMeteredLineClaim, 'pluginId'> & { pluginId?: string },\n): void {\n const pluginId = (getRegisteringPluginId() ?? claim.pluginId ?? '').trim()\n if (!pluginId) {\n throw new Error(\n `metered line \"${claim.lineId}\" claimed with no owner: pass ` +\n '{ pluginId } when registering outside a plugin register fn',\n )\n }\n const held = claims.get(claim.lineId)\n if (held && held.pluginId !== pluginId) {\n throw new Error(\n `metered line \"${claim.lineId}\" is already billed by plugin ` +\n `\"${held.pluginId}\"; \"${pluginId}\" cannot claim it too`,\n )\n }\n claims.set(claim.lineId, {\n lineId: claim.lineId,\n pluginId,\n billsFrom: claim.billsFrom,\n closeMonth: claim.closeMonth,\n })\n}\n\n/**\n * Does a plugin bill this line for this month, so the sweep must leave it\n * out of the meter figure?\n *\n * A claim whose `billsFrom` throws answers `false` and logs: the sweep is\n * the only thing that bills the line while a claim is broken, and a throw\n * that propagated would fail the whole month's billing for every workspace.\n */\nexport function pluginBillsMeteredLine(lineId: string, month: string): boolean {\n const claim = claims.get(lineId)\n if (!claim) return false\n let from: string | null\n try {\n from = claim.billsFrom()\n } catch (error) {\n console.error(`[plugins] ${claim.pluginId} failed to answer for ${lineId}`, error)\n return false\n }\n const start = String(from ?? '').trim()\n if (!/^\\d{4}-\\d{2}$/.test(start)) return false\n if (!/^\\d{4}-\\d{2}$/.test(month)) return false\n // Zero-padded fixed-width `YYYY-MM` orders identically as a string and as\n // a date; the format checks above are what make that true rather than\n // lucky. The same comparison `billsEmailSendOverage` makes on its own gate.\n return month >= start\n}\n\n/**\n * Runs the claiming plugin's close-out for one workspace's closed month.\n *\n * `false` when no plugin claims the line, when it does not bill this month,\n * or when it registered no close-out — three different \"nothing happened\"s\n * that the sweep does not need to tell apart, and the plugin is the only\n * thing that could.\n */\nexport async function runPluginMeteredLineClose(\n lineId: string,\n context: PluginMeteredLineMonthContext,\n): Promise<boolean> {\n const claim = claims.get(lineId)\n if (!claim?.closeMonth) return false\n if (!pluginBillsMeteredLine(lineId, context.month)) return false\n try {\n await claim.closeMonth(context)\n return true\n } catch (error) {\n console.error(\n `[plugins] ${claim.pluginId} failed to close ${lineId} for ${context.orgId}`,\n error,\n )\n return false\n }\n}\n\n/** The plugin billing a line, for diagnostics and staff readouts. */\nexport function pluginMeteredLineOwner(lineId: string): string | null {\n return claims.get(lineId)?.pluginId ?? null\n}\n\n/** Test seam: forget every claim. */\nexport function resetPluginMeteredLinesForTests(): void {\n claims.clear()\n}\n"],"names":["getRegisteringPluginId","claims","Map","registerPluginMeteredLine","claim","pluginId","trim","Error","lineId","held","get","set","billsFrom","closeMonth","pluginBillsMeteredLine","month","from","error","console","start","String","test","runPluginMeteredLineClose","context","orgId","pluginMeteredLineOwner","resetPluginMeteredLinesForTests","clear"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA4BC,GAED,SAASA,sBAAsB,QAAQ,qCAAiC;AAoDxE,MAAMC,SAAS,IAAIC;AAEnB;;;;;;;CAOC,GACD,OAAO,SAASC,0BACdC,KAAuE;QAErDJ,MAAAA;IAAlB,MAAMK,WAAW,EAACL,QAAAA,0BAAAA,oCAAAA,0BAA4BI,MAAMC,QAAQ,YAA1CL,OAA8C,IAAIM,IAAI;IACxE,IAAI,CAACD,UAAU;QACb,MAAM,IAAIE,MACR,CAAC,cAAc,EAAEH,MAAMI,MAAM,CAAC,8BAA8B,CAAC,GAC3D;IAEN;IACA,MAAMC,OAAOR,OAAOS,GAAG,CAACN,MAAMI,MAAM;IACpC,IAAIC,QAAQA,KAAKJ,QAAQ,KAAKA,UAAU;QACtC,MAAM,IAAIE,MACR,CAAC,cAAc,EAAEH,MAAMI,MAAM,CAAC,8BAA8B,CAAC,GAC3D,CAAC,CAAC,EAAEC,KAAKJ,QAAQ,CAAC,IAAI,EAAEA,SAAS,qBAAqB,CAAC;IAE7D;IACAJ,OAAOU,GAAG,CAACP,MAAMI,MAAM,EAAE;QACvBA,QAAQJ,MAAMI,MAAM;QACpBH;QACAO,WAAWR,MAAMQ,SAAS;QAC1BC,YAAYT,MAAMS,UAAU;IAC9B;AACF;AAEA;;;;;;;CAOC,GACD,OAAO,SAASC,uBAAuBN,MAAc,EAAEO,KAAa;IAClE,MAAMX,QAAQH,OAAOS,GAAG,CAACF;IACzB,IAAI,CAACJ,OAAO,OAAO;IACnB,IAAIY;IACJ,IAAI;QACFA,OAAOZ,MAAMQ,SAAS;IACxB,EAAE,OAAOK,OAAO;QACdC,QAAQD,KAAK,CAAC,CAAC,UAAU,EAAEb,MAAMC,QAAQ,CAAC,sBAAsB,EAAEG,QAAQ,EAAES;QAC5E,OAAO;IACT;IACA,MAAME,QAAQC,OAAOJ,eAAAA,OAAQ,IAAIV,IAAI;IACrC,IAAI,CAAC,gBAAgBe,IAAI,CAACF,QAAQ,OAAO;IACzC,IAAI,CAAC,gBAAgBE,IAAI,CAACN,QAAQ,OAAO;IACzC,0EAA0E;IAC1E,sEAAsE;IACtE,4EAA4E;IAC5E,OAAOA,SAASI;AAClB;AAEA;;;;;;;CAOC,GACD,OAAO,eAAeG,0BACpBd,MAAc,EACde,OAAsC;IAEtC,MAAMnB,QAAQH,OAAOS,GAAG,CAACF;IACzB,IAAI,EAACJ,yBAAAA,MAAOS,UAAU,GAAE,OAAO;IAC/B,IAAI,CAACC,uBAAuBN,QAAQe,QAAQR,KAAK,GAAG,OAAO;IAC3D,IAAI;QACF,MAAMX,MAAMS,UAAU,CAACU;QACvB,OAAO;IACT,EAAE,OAAON,OAAO;QACdC,QAAQD,KAAK,CACX,CAAC,UAAU,EAAEb,MAAMC,QAAQ,CAAC,iBAAiB,EAAEG,OAAO,KAAK,EAAEe,QAAQC,KAAK,EAAE,EAC5EP;QAEF,OAAO;IACT;AACF;AAEA,mEAAmE,GACnE,OAAO,SAASQ,uBAAuBjB,MAAc;;QAC5CP;IAAP,gBAAOA,cAAAA,OAAOS,GAAG,CAACF,4BAAXP,YAAoBI,QAAQ,mBAAI;AACzC;AAEA,mCAAmC,GACnC,OAAO,SAASqB;IACdzB,OAAO0B,KAAK;AACd"}
|